Добавьте файлы проекта.

This commit is contained in:
2025-11-27 09:10:58 +03:00
parent 730fd30d87
commit c1f50fcca0
32 changed files with 1154 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Целевая платформа -->
<TargetFramework>net8.0</TargetFramework>
<!-- Информация о пакете -->
<PackageId>ArgumentsToolkit.Core</PackageId>
<Version>0.1.0</Version>
<Authors>FrigaT</Authors>
<Company>FrigaT</Company>
<Description>Ядро библиотеки ArgumentsToolkit: парсинг аргументов, генерация строки, обязательные параметры, базовые конвертеры.</Description>
<PackageTags>cli arguments parser toolkit core</PackageTags>
<RepositoryUrl>https://git.frigat.duckdns.org/FrigaT/ArgumentsToolkit</RepositoryUrl>
<PackageProjectUrl>https://git.frigat.duckdns.org/FrigaT/ArgumentsToolkit</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<!-- Сборка и публикация -->
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<!-- Документация и качество кода -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,49 @@
namespace ArgumentsToolkit;
/// <summary>
/// Атрибут для описания параметра командной строки.
/// Позволяет задать имя, короткое имя, описание, обязательность и значение по умолчанию.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class OptionAttribute : Attribute
{
/// <summary>
/// Поолное название для --Name
/// </summary>
public string Name { get; }
/// <summary>
/// Краткое название для -ShortName
/// </summary>
public string? ShortName { get; }
/// <summary>
/// Описание параметра
/// </summary>
public string? Description { get; }
/// <summary>
/// Обязательный параметр
/// </summary>
public bool Required { get; }
/// <summary>
/// Значение по умолчанию
/// </summary>
public object? DefaultValue { get; }
public OptionAttribute(
string name,
string? shortName = null,
string? description = null,
bool required = false,
object? defaultValue = null
)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
ShortName = shortName;
Description = description;
Required = required;
DefaultValue = defaultValue;
}
}

View File

@@ -0,0 +1,15 @@
namespace ArgumentsToolkit;
/// <summary>
/// Атрибут для указания кастомного конвертера для свойства.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class OptionConverterAttribute : Attribute
{
public Type ConverterType { get; }
public OptionConverterAttribute(Type converterType)
{
ConverterType = converterType;
}
}

View File

@@ -0,0 +1,93 @@
using System.Globalization;
namespace ArgumentsToolkit;
public class DateTimeOptionConverter : IOptionConverter
{
public bool CanConvert(Type targetType) => targetType == typeof(DateTime);
public object Convert(Type targetType, string value) => DateTime.Parse(value);
}
public class TimeSpanOptionConverter : IOptionConverter
{
public bool CanConvert(Type targetType) => targetType == typeof(TimeSpan);
public object Convert(Type targetType, string value) => TimeSpan.Parse(value);
}
public class JsonOptionConverter : IOptionConverter
{
public bool CanConvert(Type targetType) => true;
public object Convert(Type targetType, string value)
{
return System.Text.Json.JsonSerializer.Deserialize(value, targetType)!;
}
}
public class BoolOptionConverter : IOptionConverter
{
public bool CanConvert(Type targetType) => targetType == typeof(bool);
public object Convert(Type targetType, string value)
{
// когда указали просто флаг без значения
if (string.IsNullOrEmpty(value)) { return true; }
if (bool.TryParse(value, out var b)) { return b; }
if (value == "0" || value.Equals("false", StringComparison.OrdinalIgnoreCase)) { return true; }
if (value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase)) { return true; }
return false;
}
}
public class EnumOptionConverter : IOptionConverter
{
public bool CanConvert(Type targetType) => targetType.IsEnum;
public object Convert(Type targetType, string value)
{
return Enum.Parse(targetType, value, ignoreCase: true);
}
}
public class IntOptionConverter : IOptionConverter
{
public bool CanConvert(Type targetType) => targetType == typeof(int);
public object Convert(Type targetType, string value)
{
return int.Parse(value, CultureInfo.InvariantCulture);
}
}
public class LongOptionConverter : IOptionConverter
{
public bool CanConvert(Type targetType) => targetType == typeof(long);
public object Convert(Type targetType, string value)
{
return long.Parse(value, CultureInfo.InvariantCulture);
}
}
public class DoubleOptionConverter : IOptionConverter
{
public bool CanConvert(Type targetType) => targetType == typeof(double);
public object Convert(Type targetType, string value)
{
return double.Parse(value, CultureInfo.InvariantCulture);
}
}
public class DecimalOptionConverter : IOptionConverter
{
public bool CanConvert(Type targetType) => targetType == typeof(decimal);
public object Convert(Type targetType, string value)
{
return decimal.Parse(value, CultureInfo.InvariantCulture);
}
}

View File

@@ -0,0 +1,17 @@
namespace ArgumentsToolkit;
/// <summary>
/// Интерфейс для конвертера значений аргументов.
/// </summary>
public interface IOptionConverter
{
/// <summary>
/// Проверяет, может ли данный конвертер обработать указанный тип.
/// </summary>
bool CanConvert(Type targetType);
/// <summary>
/// Преобразует строковое значение в указанный тип.
/// </summary>
object Convert(Type targetType, string value);
}

View File

@@ -0,0 +1,43 @@
using System.Globalization;
namespace ArgumentsToolkit;
internal static class TypeConverters
{
public static bool TryConvert(string input, Type targetType, out object? value)
{
value = null;
try
{
if (targetType == typeof(string)) { value = input; return true; }
if (targetType == typeof(bool))
{
if (string.IsNullOrEmpty(input)) { value = true; return true; }
if (bool.TryParse(input, out var b)) { value = b; return true; }
if (input == "0" || input.Equals("false", StringComparison.OrdinalIgnoreCase)) { value = false; return true; }
if (input == "1" || input.Equals("true", StringComparison.OrdinalIgnoreCase)) { value = true; return true; }
return false;
}
if (targetType.IsEnum) { value = Enum.Parse(targetType, input, ignoreCase: true); return true; }
if (targetType == typeof(int)) { value = int.Parse(input, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(long)) { value = long.Parse(input, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(double)) { value = double.Parse(input, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(decimal)) { value = decimal.Parse(input, CultureInfo.InvariantCulture); return true; }
if (targetType == typeof(Uri)) { value = new Uri(input, UriKind.RelativeOrAbsolute); return true; }
// Nullable<T>
if (Nullable.GetUnderlyingType(targetType) is Type underlying)
{
if (string.IsNullOrEmpty(input)) { value = null; return true; }
return TryConvert(input, underlying, out value);
}
// Custom types with string ctor
var ctor = targetType.GetConstructor(new[] { typeof(string) });
if (ctor != null) { value = ctor.Invoke(new object[] { input }); return true; }
return false;
}
catch { return false; }
}
}

View File

@@ -0,0 +1,3 @@
namespace ArgumentsToolkit;
public record ArgumentError(ErrorCode Code, string Message, string? Option = null);

View File

@@ -0,0 +1,14 @@
namespace ArgumentsToolkit;
/// <summary>
/// Ошибки парсера
/// </summary>
public enum ErrorCode
{
UnknownOption,
MissingValue,
InvalidValue,
MissingRequired,
DuplicateOption,
ConversionFailed,
}

View File

@@ -0,0 +1,10 @@
namespace ArgumentsToolkit;
/// <summary>
/// Исключение для отсутствующих обязательных параметров.
/// </summary>
public class MissingRequiredOptionException : Exception
{
public MissingRequiredOptionException(string optionName)
: base($"Обязательный параметр '--{optionName}' не указан.") { }
}

View File

@@ -0,0 +1,308 @@
using System.Reflection;
namespace ArgumentsToolkit;
/// <summary>
/// Основной парсер аргументов командной строки.
/// Преобразует массив строковых аргументов в объект указанного типа.
/// </summary>
public static class ArgumentsParser
{
private static readonly List<IOptionConverter> _converters = new()
{
new DateTimeOptionConverter(),
new TimeSpanOptionConverter(),
new BoolOptionConverter(),
new IntOptionConverter(),
new LongOptionConverter(),
new DoubleOptionConverter(),
new DecimalOptionConverter(),
new EnumOptionConverter(),
};
public static void RegisterConverter(IOptionConverter converter) => _converters.Add(converter);
public static void RegisterConverter<TConverter>()
where TConverter : IOptionConverter, new()
=> _converters.Add(new TConverter());
/// <summary>
/// Парсит массив аргументов <paramref name="args"/> в объект типа <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">Тип модели опций, содержащий свойства с атрибутами <see cref="OptionAttribute"/>.</typeparam>
/// <param name="args">Массив аргументов командной строки.</param>
/// <returns>Результат парсинга, содержащий объект и список ошибок.</returns>
public static ParseResult<T> Parse<T>(string[] args)
where T : class, new()
{
var result = new ParseResult<T> { Success = true, Value = new T() };
var props = typeof(T).GetProperties();
var optionMap = BuildOptionMap(typeof(T));
var valuesByProperty = new Dictionary<PropertyInfo, object?>();
for (int i = 0; i < args.Length; i++)
{
var token = args[i];
if (!IsOptionToken(token))
{
result.Errors.Add(new ArgumentError(ErrorCode.UnknownOption, $"Не является токеном '{token}'"));
result.Success = false;
continue;
}
if (!optionMap.TryGetValue(token, out var prop))
{
result.Errors.Add(new ArgumentError(ErrorCode.UnknownOption, $"Неизвестный токен '{token}'", token));
result.Success = false;
continue;
}
var targetType = prop.PropertyType;
// Значение аргумента
string? raw = null;
if (targetType == typeof(bool) || targetType == typeof(bool?))
{
// Флаг: --flag или --flag=true
if (i + 1 < args.Length && !IsOptionToken(args[i + 1]))
raw = args[++i];
else
raw = string.Empty; // true
}
else
{
if (i + 1 >= args.Length || IsOptionToken(args[i + 1]))
{
result.Errors.Add(new ArgumentError(ErrorCode.MissingValue, $"Токен '{token}' ожидает значение"));
result.Success = false;
continue;
}
raw = args[++i];
}
// Выбор конвертера
var converterAttr = prop.GetCustomAttribute<OptionConverterAttribute>();
var converter = converterAttr != null ? (IOptionConverter)Activator.CreateInstance(converterAttr.ConverterType)! : null;
if (TryParse(targetType, raw, converter, out var converted))
{
//TODO: Generic List / Dictionary
if (valuesByProperty.ContainsKey(prop))
{
result.Errors.Add(new ArgumentError(ErrorCode.DuplicateOption, $"Токен '{token}' указан несколько раз"));
result.Success = false;
continue;
}
valuesByProperty[prop] = converted;
}
else
{
result.Errors.Add(new ArgumentError(ErrorCode.ConversionFailed, $"Не найден конвертер для {targetType.Name}", token));
result.Success = false;
continue;
}
}
// Применение значений по умолчанию и проверка обязательных
foreach (var prop in optionMap.Values.Distinct())
{
var opt = prop.GetCustomAttribute<OptionAttribute>()!;
if (!valuesByProperty.TryGetValue(prop, out var value))
{
if (opt.Required)
{
result.Errors.Add(new ArgumentError(ErrorCode.MissingRequired, $"Отсутствует обязательный токен '--{opt.Name}'", opt.Name));
result.Success = false;
}
else if (opt.DefaultValue is not null)
{
valuesByProperty[prop] = opt.DefaultValue;
}
}
}
// Заполнение объекта
foreach (var kv in valuesByProperty)
{
kv.Key.SetValue(result.Value, kv.Value);
}
return result;
}
public static string ToArguments<T>(T options)
{
var props = typeof(T).GetProperties();
var parts = new List<string>();
foreach (var prop in props)
{
var opt = prop.GetCustomAttribute<OptionAttribute>();
if (opt == null) continue;
var value = prop.GetValue(options);
if (value == null) continue;
string prefix = "--" + opt.Name;
if (prop.PropertyType == typeof(bool))
{
if ((bool)value)
parts.Add(prefix);
}
else if (prop.PropertyType.IsGenericType &&
prop.PropertyType.GetGenericTypeDefinition() == typeof(List<>))
{
var list = (System.Collections.IList)value;
if (list.Count > 0)
parts.Add($"{prefix} {string.Join(",", list.Cast<object>())}");
}
else if (prop.PropertyType.IsGenericType &&
prop.PropertyType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
{
var dict = (System.Collections.IDictionary)value;
var items = new List<string>();
foreach (var k in dict.Keys)
items.Add($"{k}={dict[k]}");
if (items.Count > 0)
parts.Add($"{prefix} {string.Join(",", items)}");
}
else
{
parts.Add($"{prefix} {value}");
}
}
return string.Join(" ", parts);
}
private static bool TryParse(Type targetType, string input, IOptionConverter? converterAttr, out object? value)
{
if (converterAttr != null && converterAttr.CanConvert(targetType))
{
try
{
value = converterAttr.Convert(targetType, input);
return true;
}
catch
{
value = null;
return false;
}
}
// Nullable<T>
if (Nullable.GetUnderlyingType(targetType) is Type underlying)
{
if (string.IsNullOrEmpty(input)) { value = input; return true; }
return TryParse(underlying, input, converterAttr, out value);
}
var converter = _converters.LastOrDefault(c => c.CanConvert(targetType));
if (converter != null)
{
try
{
value = converter.Convert(targetType, input);
return true;
}
catch
{
value = null;
return false;
}
}
// Пользовательские типы с string ctor
var ctor = targetType.GetConstructor([typeof(string)]);
if (ctor != null) { value = ctor.Invoke([input]); return true; }
//TODO: Generic types
/*
if (targetType.IsGenericType)
{
var genType = targetType.GetGenericTypeDefinition();
if (genType == typeof(List<>))
{
var elementType = targetType.GetGenericArguments()[0];
var list = (System.Collections.IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(elementType))!;
foreach (var item in value.Split(','))
list.Add(Convert.ChangeType(item.Trim(), elementType));
return list;
}
if (genType == typeof(Dictionary<,>))
{
var keyType = targetType.GetGenericArguments()[0];
var valueType = targetType.GetGenericArguments()[1];
var dict = (System.Collections.IDictionary)Activator.CreateInstance(targetType)!;
foreach (var pair in value.Split(','))
{
var kv = pair.Split('=');
if (kv.Length == 2)
{
var k = Convert.ChangeType(kv[0].Trim(), keyType);
var v = Convert.ChangeType(kv[1].Trim(), valueType);
dict.Add(k, v);
}
}
return dict;
}
}
*/
try
{
value = Convert.ChangeType(input, targetType);
return true;
}
catch
{
value = null;
return false;
}
}
/// <summary>
/// Проверяет, является ли токен аргументом (начинается с '-' или '--').
/// </summary>
private static bool IsOptionToken(string token) => token.StartsWith("-");
/// <summary>
/// Строит карту имён аргументов к свойствам модели.
/// </summary>
private static Dictionary<string, PropertyInfo> BuildOptionMap(Type optionsType)
{
var map = new Dictionary<string, PropertyInfo>(StringComparer.OrdinalIgnoreCase);
foreach (var prop in optionsType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
{
var opt = prop.GetCustomAttribute<OptionAttribute>();
if (opt == null) continue;
void add(string key)
{
if (!string.IsNullOrWhiteSpace(key))
map[key] = prop;
}
add("--" + opt.Name);
add(("-" + opt.ShortName) ?? string.Empty);
}
return map;
}
}

View File

@@ -0,0 +1,9 @@
namespace ArgumentsToolkit;
public class ParseResult<T> where T : class, new()
{
public T? Value { get; internal set; }
public bool Success { get; internal set; }
public List<ArgumentError> Errors { get; } = new();
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PackageId>ArgumentsToolkit.Help</PackageId>
<Version>1.0.0</Version>
<Authors>FrigaT</Authors>
<Company>FrigaT</Company>
<Description>Расширение для ArgumentsToolkit.Core: генерация справки (--help), вывод в консоль, Markdown/HTML.</Description>
<PackageTags>cli arguments parser help toolkit</PackageTags>
<RepositoryUrl>https://git.frigat.duckdns.org/FrigaT/ArgumentsToolkit</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ArgumentsToolkit.Core\ArgumentsToolkit.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,36 @@
using System.Reflection;
namespace ArgumentsToolkit.Help;
/// <summary>
/// Сборщик информации о параметрах из модели Options.
/// </summary>
public static class HelpCollector
{
/// <summary>
/// Собирает метаданные о параметрах из указанной модели Options.
/// </summary>
public static HelpModel Collect<T>()
{
var model = new HelpModel();
var props = typeof(T).GetProperties();
foreach (var prop in props)
{
var opt = prop.GetCustomAttribute<OptionAttribute>();
if (opt == null) continue;
model.Entries.Add(new HelpEntry
{
Name = opt.Name,
ShortName = opt.ShortName,
Description = opt.Description,
TypeName = prop.PropertyType.Name,
Required = opt.Required,
DefaultValue = opt.DefaultValue
});
}
return model;
}
}

View File

@@ -0,0 +1,13 @@
namespace ArgumentsToolkit.Help;
/// <summary>
/// Исключение, выбрасываемое при ошибках генерации справки.
/// </summary>
public class HelpGenerationException : Exception
{
/// <summary>
/// Создаёт новое исключение генерации справки.
/// </summary>
/// <param name="message">Сообщение об ошибке.</param>
public HelpGenerationException(string message) : base(message) { }
}

View File

@@ -0,0 +1,29 @@
namespace ArgumentsToolkit.Help;
/// <summary>
/// Расширенные методы для удобного форматирования справки.
/// </summary>
public static class HelpExtensions
{
/// <summary>
/// Форматирует справку в Markdown.
/// </summary>
/// <param name="model">Модель справки.</param>
/// <returns>Строка в формате Markdown.</returns>
public static string AsMarkdown(this HelpModel model)
{
IHelpFormatter formatter = new MarkdownHelpFormatter();
return formatter.Format(model);
}
/// <summary>
/// Форматирует справку в HTML.
/// </summary>
/// <param name="model">Модель справки.</param>
/// <returns>Строка в формате HTML.</returns>
public static string AsHtml(this HelpModel model)
{
IHelpFormatter formatter = new HtmlHelpFormatter();
return formatter.Format(model);
}
}

View File

@@ -0,0 +1,21 @@
namespace ArgumentsToolkit.Help;
/// <summary>
/// Форматтер для генерации справки в формате HTML.
/// </summary>
public class HtmlHelpFormatter : IHelpFormatter
{
public string Format(HelpModel model)
{
var lines = new List<string> { $"<h2>{model.Title}</h2>", "<ul>" };
foreach (var entry in model.Entries)
{
string required = entry.Required ? " (обязательный)" : "";
lines.Add($"<li><b>--{entry.Name} / -{entry.ShortName}</b>: {entry.Description} ({entry.TypeName}{required})</li>");
}
lines.Add("</ul>");
return string.Join(Environment.NewLine, lines);
}
}

View File

@@ -0,0 +1,9 @@
namespace ArgumentsToolkit.Help;
/// <summary>
/// Интерфейс для форматирования справки в разные форматы.
/// </summary>
public interface IHelpFormatter
{
string Format(HelpModel model);
}

View File

@@ -0,0 +1,20 @@
namespace ArgumentsToolkit.Help;
/// <summary>
/// Форматтер для генерации справки в формате Markdown.
/// </summary>
public class MarkdownHelpFormatter : IHelpFormatter
{
public string Format(HelpModel model)
{
var lines = new List<string> { $"## {model.Title}" };
foreach (var entry in model.Entries)
{
string required = entry.Required ? " (обязательный)" : "";
lines.Add($"- **--{entry.Name} / -{entry.ShortName}**: {entry.Description} ({entry.TypeName}{required})");
}
return string.Join(Environment.NewLine, lines);
}
}

View File

@@ -0,0 +1,14 @@
namespace ArgumentsToolkit.Help;
/// <summary>
/// Описание одного аргумента для справки.
/// </summary>
public class HelpEntry
{
public string Name { get; set; }
public string? ShortName { get; set; }
public string? Description { get; set; }
public string TypeName { get; set; }
public bool Required { get; set; }
public object? DefaultValue { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace ArgumentsToolkit.Help;
/// <summary>
/// Справка по всей модели Options.
/// </summary>
public class HelpModel
{
public string Title { get; set; } = "Доступные аргументы";
public List<HelpEntry> Entries { get; set; } = new();
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PackageId>ArgumentsToolkit.Validation</PackageId>
<Version>1.0.0</Version>
<Authors>FrigaT</Authors>
<Company>FrigaT</Company>
<Description>Расширение для ArgumentsToolkit.Core: атрибуты и правила валидации аргументов.</Description>
<PackageTags>cli arguments parser validation toolkit</PackageTags>
<RepositoryUrl>https://git.frigat.duckdns.org/FrigaT/ArgumentsToolkit</RepositoryUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ArgumentsToolkit.Core\ArgumentsToolkit.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
namespace ArgumentsToolkit;
/// <summary>
/// Исключение, выбрасываемое при нарушении правил валидации аргументов.
/// </summary>
public class ValidationException : Exception
{
/// <summary>
/// Создаёт новое исключение валидации.
/// </summary>
/// <param name="message">Сообщение об ошибке.</param>
public ValidationException(string message) : base(message) { }
}

View File

@@ -0,0 +1,53 @@
namespace ArgumentsToolkit;
/// <summary>
/// Атрибут для проверки строкового значения на соответствие списку допустимых значений.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class AllowedValuesAttribute : ValidationAttribute
{
/// <summary>
/// Список допустимых значений.
/// </summary>
public string[] Values { get; }
/// <summary>
/// Создаёт новый атрибут допустимых значений.
/// </summary>
/// <param name="values">Массив допустимых строковых значений.</param>
public AllowedValuesAttribute(params string[] values)
{
Values = values;
}
/// <summary>
/// Создаёт новый атрибут допустимых значений.
/// </summary>
/// <param name="en">Enum допустимых значений.</param>
public AllowedValuesAttribute(Enum en)
{
var enums = Enum.GetValues(en.GetType());
List<string> values = new();
foreach (var e in enums)
{
values.Add(e.ToString());
}
Values = values.ToArray();
}
public override string ErrorTemplate { get; set; } = "--{0}: значение {1} не входит в диапазон [{2}]";
public override bool Validate(object? value)
{
if (value is string s)
return Values.Contains(s);
return true;
}
public override string GetErrorMessage(string optionName, object? value)
{
return string.Format(ErrorTemplate, optionName, value, string.Join(", ", Values));
}
}

View File

@@ -0,0 +1,48 @@
namespace ArgumentsToolkit;
/// <summary>
/// Атрибут для проверки числового значения на соответствие диапазону.
/// Применяется к свойствам модели Options.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public partial class RangeAttribute : ValidationAttribute
{
/// <summary>
/// Минимально допустимое значение.
/// </summary>
public double Min { get; }
/// <summary>
/// Максимально допустимое значение.
/// </summary>
public double Max { get; }
/// <summary>
/// Создаёт новый атрибут диапазона.
/// </summary>
/// <param name="min">Минимальное значение.</param>
/// <param name="max">Максимальное значение.</param>
public RangeAttribute(double min, double max)
{
Min = min;
Max = max;
}
public override string ErrorTemplate { get; set; } = "--{0}: значение {1} выходит за диапазон {2}..{3}";
public override bool Validate(object? value)
{
if (value is IConvertible c)
{
var d = c.ToDouble(System.Globalization.CultureInfo.InvariantCulture);
if (d < Min || d > Max)
return false;
}
return true;
}
public override string GetErrorMessage(string optionName, object? value)
{
return string.Format(ErrorTemplate, optionName, value, Min, Max);
}
}

View File

@@ -0,0 +1,43 @@
using System.Text.RegularExpressions;
namespace ArgumentsToolkit;
public partial class RangeAttribute
{
/// <summary>
/// Атрибут для проверки строкового значения по регулярному выражению.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class RegexAttribute : ValidationAttribute
{
/// <summary>
/// Шаблон регулярного выражения.
/// </summary>
public string Pattern { get; }
/// <summary>
/// Создаёт новый атрибут регулярного выражения.
/// </summary>
/// <param name="pattern">Регулярное выражение для проверки.</param>
public RegexAttribute(string pattern)
{
Pattern = pattern;
}
public override string ErrorTemplate { get; set; } = "--{0}: значение {1} не соответствует регулярному выражению '{2}'";
public override bool Validate(object? value)
{
if (value is string s)
return Regex.IsMatch(s, Pattern);
return true;
}
public override string GetErrorMessage(string optionName, object? value)
{
return string.Format(ErrorTemplate, optionName, value, Pattern);
}
}
}

View File

@@ -0,0 +1,23 @@
namespace ArgumentsToolkit;
/// <summary>
/// Базовый атрибут для всех правил валидации.
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public abstract class ValidationAttribute : Attribute
{
/// <summary>Кастомное сообщение об ошибке.</summary>
public abstract string ErrorTemplate { get; set; }
/// <summary>
/// Проверяет значение свойства.
/// </summary>
/// <param name="value">Значение свойства.</param>
public abstract bool Validate(object? value);
/// <summary>
/// Возвращает сообщение об ошибке для указанного значения.
/// </summary>
public abstract string GetErrorMessage(string optionName, object? value);
}

View File

@@ -0,0 +1,45 @@
using System.Reflection;
namespace ArgumentsToolkit;
/// <summary>
/// Класс для выполнения валидации модели Options на основе атрибутов.
/// </summary>
public static class Validator
{
/// <summary>
/// Проверяет объект <paramref name="options"/> на соответствие правилам валидации.
/// </summary>
/// <typeparam name="T">Тип модели опций.</typeparam>
/// <param name="options">Экземпляр модели опций.</param>
/// <param name="errors">Список ошибок валидации.</param>
/// <returns>true, если ошибок нет; иначе false.</returns>
public static bool Validate<T>(T options, out string[] errors)
{
var list = new List<string>();
var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
foreach (var p in props)
{
// Проверяем только свойства с [Option]
var optionAttr = p.GetCustomAttribute<OptionAttribute>();
if (optionAttr == null) continue;
var val = p.GetValue(options);
// Берём все атрибуты, которые реализуют IValidationAttribute
foreach (var attr in p.GetCustomAttributes().OfType<ValidationAttribute>())
{
if (!attr.Validate(val))
{
var error = attr.GetErrorMessage(optionAttr.Name, val);
list.Add(error);
}
}
}
errors = list.ToArray();
return list.Count == 0;
}
}

7
ArgumentsToolkit.slnx Normal file
View File

@@ -0,0 +1,7 @@
<Solution>
<Project Path="ArgumentsToolkit.Core/ArgumentsToolkit.Core.csproj" />
<Project Path="ArgumentsToolkit.Help/ArgumentsToolkit.Help.csproj" />
<Project Path="ArgumentsToolkit.Validation/ArgumentsToolkit.Validation.csproj" />
<Project Path="ArgumentsToolkit/ArgumentsToolkit.csproj" />
<Project Path="Demo/Demo.csproj" />
</Solution>

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ArgumentsToolkit.Core\ArgumentsToolkit.Core.csproj" />
<ProjectReference Include="..\ArgumentsToolkit.Help\ArgumentsToolkit.Help.csproj" />
<ProjectReference Include="..\ArgumentsToolkit.Validation\ArgumentsToolkit.Validation.csproj" />
</ItemGroup>
</Project>

14
Demo/Demo.csproj Normal file
View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ArgumentsToolkit\ArgumentsToolkit.csproj" />
</ItemGroup>
</Project>

88
Demo/Program.cs Normal file
View File

@@ -0,0 +1,88 @@
using ArgumentsToolkit;
using ArgumentsToolkit.Help;
namespace Demo;
internal class Program
{
static int Main(string[] args)
{ // Пример запуска:
// dotnet run --server myhost --port 8080 --env staging --mode Incremental --config "{\"Author\":\"FrigaT\",\"Timeout\":60}"
var result = ArgumentsParser.Parse<DeployOptions>(args);
if (!result.Success)
{
Console.ForegroundColor = ConsoleColor.Red;
foreach (var e in result.Errors)
Console.WriteLine($"{e.Code}: {e.Message}");
Console.ResetColor();
var help = HelpCollector.Collect<DeployOptions>();
Console.WriteLine(help.AsMarkdown());
return 1;
}
if (!Validator.Validate(result.Value!, out var vErrors))
{
Console.ForegroundColor = ConsoleColor.Yellow;
foreach (var e in vErrors)
Console.WriteLine($"Validation: {e}");
Console.ResetColor();
return 2;
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Аргументы успешно разобраны и прошли валидацию:");
Console.WriteLine($"Server: {result.Value!.Server}");
Console.WriteLine($"Port: {result.Value.Port}");
Console.WriteLine($"Environment: {result.Value.Environment}");
Console.WriteLine($"DryRun: {result.Value.DryRun}");
Console.WriteLine($"Mode: {result.Value.Mode}");
Console.WriteLine($"Config.Author: {result.Value.Config.Author}");
Console.WriteLine($"Config.Timeout: {result.Value.Config.Timeout}");
Console.ResetColor();
return 0;
}
}
public class DeployOptions
{
[Option("server", "s", "Адрес сервера", required: true)]
public string Server { get; set; } = default!;
[Option("port", "p", "Порт подключения", defaultValue: 22)]
[Range(1, 65535)]
public int Port { get; set; }
[Option("env", "e", "Среда деплоя")]
[AllowedValues("dev", "staging", "prod")]
public string Environment { get; set; } = "dev";
[Option("dry-run", "d", "Пробный запуск без изменений")]
public bool DryRun { get; set; }
[Option("config", "c", "JSONконфиг для деплоя")]
[OptionConverter(typeof(JsonOptionConverter))]
public MyConfig Config { get; set; } = new();
[Option("mode", "m", "Режим деплоя")]
public DeployMode Mode { get; set; } = DeployMode.Full;
}
public class MyConfig
{
public string Author { get; set; } = "unknown";
public int Timeout { get; set; } = 30;
}
public enum DeployMode
{
Full,
Incremental,
DryRun
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"Demo": {
"commandName": "Project",
"commandLineArgs": "--server myhost --port 8080 --env staging --mode Incremental --config \"{\\\"Author\\\":\\\"FrigaT\\\",\\\"Timeout\\\":60}"
},
"Demo (Error)": {
"commandName": "Project",
"commandLineArgs": "--server myhost --port \"128080\" --env staging --mode Incremental --config \"{\\\"Author\\\":\\\"FrigaT\\\",\\\"Timeout\\\":60}"
}
}
}