diff --git a/ArgumentsToolkit.Core/ArgumentsToolkit.Core.csproj b/ArgumentsToolkit.Core/ArgumentsToolkit.Core.csproj new file mode 100644 index 0000000..4722a42 --- /dev/null +++ b/ArgumentsToolkit.Core/ArgumentsToolkit.Core.csproj @@ -0,0 +1,30 @@ + + + + + net8.0 + + + ArgumentsToolkit.Core + 0.1.0 + FrigaT + FrigaT + Ядро библиотеки ArgumentsToolkit: парсинг аргументов, генерация строки, обязательные параметры, базовые конвертеры. + cli arguments parser toolkit core + https://git.frigat.duckdns.org/FrigaT/ArgumentsToolkit + https://git.frigat.duckdns.org/FrigaT/ArgumentsToolkit + MIT + + + true + true + snupkg + + + true + enable + enable + latest + + + diff --git a/ArgumentsToolkit.Core/Attributes/OptionAttribute.cs b/ArgumentsToolkit.Core/Attributes/OptionAttribute.cs new file mode 100644 index 0000000..88a0931 --- /dev/null +++ b/ArgumentsToolkit.Core/Attributes/OptionAttribute.cs @@ -0,0 +1,49 @@ +namespace ArgumentsToolkit; + +/// +/// Атрибут для описания параметра командной строки. +/// Позволяет задать имя, короткое имя, описание, обязательность и значение по умолчанию. +/// +[AttributeUsage(AttributeTargets.Property)] +public class OptionAttribute : Attribute +{ + /// + /// Поолное название для --Name + /// + public string Name { get; } + + /// + /// Краткое название для -ShortName + /// + public string? ShortName { get; } + + /// + /// Описание параметра + /// + public string? Description { get; } + + /// + /// Обязательный параметр + /// + public bool Required { get; } + + /// + /// Значение по умолчанию + /// + 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; + } +} diff --git a/ArgumentsToolkit.Core/Attributes/OptionConverterAttribute.cs b/ArgumentsToolkit.Core/Attributes/OptionConverterAttribute.cs new file mode 100644 index 0000000..a6e9e24 --- /dev/null +++ b/ArgumentsToolkit.Core/Attributes/OptionConverterAttribute.cs @@ -0,0 +1,15 @@ +namespace ArgumentsToolkit; + +/// +/// Атрибут для указания кастомного конвертера для свойства. +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class OptionConverterAttribute : Attribute +{ + public Type ConverterType { get; } + + public OptionConverterAttribute(Type converterType) + { + ConverterType = converterType; + } +} diff --git a/ArgumentsToolkit.Core/Converters/Converters.cs b/ArgumentsToolkit.Core/Converters/Converters.cs new file mode 100644 index 0000000..0b63eab --- /dev/null +++ b/ArgumentsToolkit.Core/Converters/Converters.cs @@ -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); + } +} \ No newline at end of file diff --git a/ArgumentsToolkit.Core/Converters/IOptionConverter.cs b/ArgumentsToolkit.Core/Converters/IOptionConverter.cs new file mode 100644 index 0000000..9410e10 --- /dev/null +++ b/ArgumentsToolkit.Core/Converters/IOptionConverter.cs @@ -0,0 +1,17 @@ +namespace ArgumentsToolkit; + +/// +/// Интерфейс для конвертера значений аргументов. +/// +public interface IOptionConverter +{ + /// + /// Проверяет, может ли данный конвертер обработать указанный тип. + /// + bool CanConvert(Type targetType); + + /// + /// Преобразует строковое значение в указанный тип. + /// + object Convert(Type targetType, string value); +} diff --git a/ArgumentsToolkit.Core/Converters/TypeConverters.cs b/ArgumentsToolkit.Core/Converters/TypeConverters.cs new file mode 100644 index 0000000..1af1f9d --- /dev/null +++ b/ArgumentsToolkit.Core/Converters/TypeConverters.cs @@ -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 + 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; } + } +} diff --git a/ArgumentsToolkit.Core/Errors/ArgumentError.cs b/ArgumentsToolkit.Core/Errors/ArgumentError.cs new file mode 100644 index 0000000..9860275 --- /dev/null +++ b/ArgumentsToolkit.Core/Errors/ArgumentError.cs @@ -0,0 +1,3 @@ +namespace ArgumentsToolkit; + +public record ArgumentError(ErrorCode Code, string Message, string? Option = null); \ No newline at end of file diff --git a/ArgumentsToolkit.Core/Errors/ErrorCode.cs b/ArgumentsToolkit.Core/Errors/ErrorCode.cs new file mode 100644 index 0000000..466cde0 --- /dev/null +++ b/ArgumentsToolkit.Core/Errors/ErrorCode.cs @@ -0,0 +1,14 @@ +namespace ArgumentsToolkit; + +/// +/// Ошибки парсера +/// +public enum ErrorCode +{ + UnknownOption, + MissingValue, + InvalidValue, + MissingRequired, + DuplicateOption, + ConversionFailed, +} diff --git a/ArgumentsToolkit.Core/Exceptions.cs b/ArgumentsToolkit.Core/Exceptions.cs new file mode 100644 index 0000000..9f052b8 --- /dev/null +++ b/ArgumentsToolkit.Core/Exceptions.cs @@ -0,0 +1,10 @@ +namespace ArgumentsToolkit; + +/// +/// Исключение для отсутствующих обязательных параметров. +/// +public class MissingRequiredOptionException : Exception +{ + public MissingRequiredOptionException(string optionName) + : base($"Обязательный параметр '--{optionName}' не указан.") { } +} diff --git a/ArgumentsToolkit.Core/Parsers/ArgumentsParser.cs b/ArgumentsToolkit.Core/Parsers/ArgumentsParser.cs new file mode 100644 index 0000000..622bf13 --- /dev/null +++ b/ArgumentsToolkit.Core/Parsers/ArgumentsParser.cs @@ -0,0 +1,308 @@ +using System.Reflection; + +namespace ArgumentsToolkit; + +/// +/// Основной парсер аргументов командной строки. +/// Преобразует массив строковых аргументов в объект указанного типа. +/// +public static class ArgumentsParser +{ + private static readonly List _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() + where TConverter : IOptionConverter, new() + => _converters.Add(new TConverter()); + + /// + /// Парсит массив аргументов в объект типа . + /// + /// Тип модели опций, содержащий свойства с атрибутами . + /// Массив аргументов командной строки. + /// Результат парсинга, содержащий объект и список ошибок. + + public static ParseResult Parse(string[] args) + where T : class, new() + { + var result = new ParseResult { Success = true, Value = new T() }; + + var props = typeof(T).GetProperties(); + + var optionMap = BuildOptionMap(typeof(T)); + var valuesByProperty = new Dictionary(); + + + 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(); + 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()!; + 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 options) + { + var props = typeof(T).GetProperties(); + var parts = new List(); + + foreach (var prop in props) + { + var opt = prop.GetCustomAttribute(); + 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())}"); + } + else if (prop.PropertyType.IsGenericType && + prop.PropertyType.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + { + var dict = (System.Collections.IDictionary)value; + var items = new List(); + 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 + 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; + } + } + + + /// + /// Проверяет, является ли токен аргументом (начинается с '-' или '--'). + /// + private static bool IsOptionToken(string token) => token.StartsWith("-"); + + /// + /// Строит карту имён аргументов к свойствам модели. + /// + private static Dictionary BuildOptionMap(Type optionsType) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var prop in optionsType.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + var opt = prop.GetCustomAttribute(); + 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; + } + +} + diff --git a/ArgumentsToolkit.Core/Parsers/ParseResult.cs b/ArgumentsToolkit.Core/Parsers/ParseResult.cs new file mode 100644 index 0000000..1611fe1 --- /dev/null +++ b/ArgumentsToolkit.Core/Parsers/ParseResult.cs @@ -0,0 +1,9 @@ +namespace ArgumentsToolkit; + +public class ParseResult where T : class, new() +{ + public T? Value { get; internal set; } + public bool Success { get; internal set; } + public List Errors { get; } = new(); +} + diff --git a/ArgumentsToolkit.Help/ArgumentsToolkit.Help.csproj b/ArgumentsToolkit.Help/ArgumentsToolkit.Help.csproj new file mode 100644 index 0000000..0ead996 --- /dev/null +++ b/ArgumentsToolkit.Help/ArgumentsToolkit.Help.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + ArgumentsToolkit.Help + 1.0.0 + FrigaT + FrigaT + Расширение для ArgumentsToolkit.Core: генерация справки (--help), вывод в консоль, Markdown/HTML. + cli arguments parser help toolkit + https://git.frigat.duckdns.org/FrigaT/ArgumentsToolkit + MIT + + true + true + enable + enable + latest + + + + + + + diff --git a/ArgumentsToolkit.Help/Collectors/HelpCollector.cs b/ArgumentsToolkit.Help/Collectors/HelpCollector.cs new file mode 100644 index 0000000..ca25521 --- /dev/null +++ b/ArgumentsToolkit.Help/Collectors/HelpCollector.cs @@ -0,0 +1,36 @@ +using System.Reflection; + +namespace ArgumentsToolkit.Help; + +/// +/// Сборщик информации о параметрах из модели Options. +/// +public static class HelpCollector +{ + /// + /// Собирает метаданные о параметрах из указанной модели Options. + /// + public static HelpModel Collect() + { + var model = new HelpModel(); + var props = typeof(T).GetProperties(); + + foreach (var prop in props) + { + var opt = prop.GetCustomAttribute(); + 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; + } +} diff --git a/ArgumentsToolkit.Help/Exceptions/HelpGenerationException.cs b/ArgumentsToolkit.Help/Exceptions/HelpGenerationException.cs new file mode 100644 index 0000000..3389854 --- /dev/null +++ b/ArgumentsToolkit.Help/Exceptions/HelpGenerationException.cs @@ -0,0 +1,13 @@ +namespace ArgumentsToolkit.Help; + +/// +/// Исключение, выбрасываемое при ошибках генерации справки. +/// +public class HelpGenerationException : Exception +{ + /// + /// Создаёт новое исключение генерации справки. + /// + /// Сообщение об ошибке. + public HelpGenerationException(string message) : base(message) { } +} diff --git a/ArgumentsToolkit.Help/Extensions/HelpExtensions.cs b/ArgumentsToolkit.Help/Extensions/HelpExtensions.cs new file mode 100644 index 0000000..87191a0 --- /dev/null +++ b/ArgumentsToolkit.Help/Extensions/HelpExtensions.cs @@ -0,0 +1,29 @@ +namespace ArgumentsToolkit.Help; + +/// +/// Расширенные методы для удобного форматирования справки. +/// +public static class HelpExtensions +{ + /// + /// Форматирует справку в Markdown. + /// + /// Модель справки. + /// Строка в формате Markdown. + public static string AsMarkdown(this HelpModel model) + { + IHelpFormatter formatter = new MarkdownHelpFormatter(); + return formatter.Format(model); + } + + /// + /// Форматирует справку в HTML. + /// + /// Модель справки. + /// Строка в формате HTML. + public static string AsHtml(this HelpModel model) + { + IHelpFormatter formatter = new HtmlHelpFormatter(); + return formatter.Format(model); + } +} diff --git a/ArgumentsToolkit.Help/Formatters/HtmlHelpFormatter.cs b/ArgumentsToolkit.Help/Formatters/HtmlHelpFormatter.cs new file mode 100644 index 0000000..ad1b677 --- /dev/null +++ b/ArgumentsToolkit.Help/Formatters/HtmlHelpFormatter.cs @@ -0,0 +1,21 @@ +namespace ArgumentsToolkit.Help; + +/// +/// Форматтер для генерации справки в формате HTML. +/// +public class HtmlHelpFormatter : IHelpFormatter +{ + public string Format(HelpModel model) + { + var lines = new List { $"

{model.Title}

", "
    " }; + + foreach (var entry in model.Entries) + { + string required = entry.Required ? " (обязательный)" : ""; + lines.Add($"
  • --{entry.Name} / -{entry.ShortName}: {entry.Description} ({entry.TypeName}{required})
  • "); + } + + lines.Add("
"); + return string.Join(Environment.NewLine, lines); + } +} diff --git a/ArgumentsToolkit.Help/Formatters/IHelpFormatter.cs b/ArgumentsToolkit.Help/Formatters/IHelpFormatter.cs new file mode 100644 index 0000000..f090309 --- /dev/null +++ b/ArgumentsToolkit.Help/Formatters/IHelpFormatter.cs @@ -0,0 +1,9 @@ +namespace ArgumentsToolkit.Help; + +/// +/// Интерфейс для форматирования справки в разные форматы. +/// +public interface IHelpFormatter +{ + string Format(HelpModel model); +} diff --git a/ArgumentsToolkit.Help/Formatters/MarkdownHelpFormatter.cs b/ArgumentsToolkit.Help/Formatters/MarkdownHelpFormatter.cs new file mode 100644 index 0000000..cb8effe --- /dev/null +++ b/ArgumentsToolkit.Help/Formatters/MarkdownHelpFormatter.cs @@ -0,0 +1,20 @@ +namespace ArgumentsToolkit.Help; + +/// +/// Форматтер для генерации справки в формате Markdown. +/// +public class MarkdownHelpFormatter : IHelpFormatter +{ + public string Format(HelpModel model) + { + var lines = new List { $"## {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); + } +} diff --git a/ArgumentsToolkit.Help/Models/HelpEntry.cs b/ArgumentsToolkit.Help/Models/HelpEntry.cs new file mode 100644 index 0000000..8250af2 --- /dev/null +++ b/ArgumentsToolkit.Help/Models/HelpEntry.cs @@ -0,0 +1,14 @@ +namespace ArgumentsToolkit.Help; + +/// +/// Описание одного аргумента для справки. +/// +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; } +} diff --git a/ArgumentsToolkit.Help/Models/HelpModel.cs b/ArgumentsToolkit.Help/Models/HelpModel.cs new file mode 100644 index 0000000..9c0bc87 --- /dev/null +++ b/ArgumentsToolkit.Help/Models/HelpModel.cs @@ -0,0 +1,10 @@ +namespace ArgumentsToolkit.Help; + +/// +/// Справка по всей модели Options. +/// +public class HelpModel +{ + public string Title { get; set; } = "Доступные аргументы"; + public List Entries { get; set; } = new(); +} diff --git a/ArgumentsToolkit.Validation/ArgumentsToolkit.Validation.csproj b/ArgumentsToolkit.Validation/ArgumentsToolkit.Validation.csproj new file mode 100644 index 0000000..261a0f4 --- /dev/null +++ b/ArgumentsToolkit.Validation/ArgumentsToolkit.Validation.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + ArgumentsToolkit.Validation + 1.0.0 + FrigaT + FrigaT + Расширение для ArgumentsToolkit.Core: атрибуты и правила валидации аргументов. + cli arguments parser validation toolkit + https://git.frigat.duckdns.org/FrigaT/ArgumentsToolkit + MIT + + true + true + enable + enable + latest + + + + + + + \ No newline at end of file diff --git a/ArgumentsToolkit.Validation/ValidationException.cs b/ArgumentsToolkit.Validation/ValidationException.cs new file mode 100644 index 0000000..cd32670 --- /dev/null +++ b/ArgumentsToolkit.Validation/ValidationException.cs @@ -0,0 +1,13 @@ +namespace ArgumentsToolkit; + +/// +/// Исключение, выбрасываемое при нарушении правил валидации аргументов. +/// +public class ValidationException : Exception +{ + /// + /// Создаёт новое исключение валидации. + /// + /// Сообщение об ошибке. + public ValidationException(string message) : base(message) { } +} diff --git a/ArgumentsToolkit.Validation/Validations/AllowedValuesAttribute.cs b/ArgumentsToolkit.Validation/Validations/AllowedValuesAttribute.cs new file mode 100644 index 0000000..4d24da6 --- /dev/null +++ b/ArgumentsToolkit.Validation/Validations/AllowedValuesAttribute.cs @@ -0,0 +1,53 @@ +namespace ArgumentsToolkit; + +/// +/// Атрибут для проверки строкового значения на соответствие списку допустимых значений. +/// +[AttributeUsage(AttributeTargets.Property)] +public class AllowedValuesAttribute : ValidationAttribute +{ + /// + /// Список допустимых значений. + /// + public string[] Values { get; } + + /// + /// Создаёт новый атрибут допустимых значений. + /// + /// Массив допустимых строковых значений. + public AllowedValuesAttribute(params string[] values) + { + Values = values; + } + + /// + /// Создаёт новый атрибут допустимых значений. + /// + /// Enum допустимых значений. + public AllowedValuesAttribute(Enum en) + { + var enums = Enum.GetValues(en.GetType()); + List 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)); + } +} diff --git a/ArgumentsToolkit.Validation/Validations/RangeAttribute.cs b/ArgumentsToolkit.Validation/Validations/RangeAttribute.cs new file mode 100644 index 0000000..2e60c18 --- /dev/null +++ b/ArgumentsToolkit.Validation/Validations/RangeAttribute.cs @@ -0,0 +1,48 @@ +namespace ArgumentsToolkit; + +/// +/// Атрибут для проверки числового значения на соответствие диапазону. +/// Применяется к свойствам модели Options. +/// +[AttributeUsage(AttributeTargets.Property)] +public partial class RangeAttribute : ValidationAttribute +{ + /// + /// Минимально допустимое значение. + /// + public double Min { get; } + + /// + /// Максимально допустимое значение. + /// + public double Max { get; } + + /// + /// Создаёт новый атрибут диапазона. + /// + /// Минимальное значение. + /// Максимальное значение. + 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); + } +} diff --git a/ArgumentsToolkit.Validation/Validations/RegexAttribute.cs b/ArgumentsToolkit.Validation/Validations/RegexAttribute.cs new file mode 100644 index 0000000..df4c705 --- /dev/null +++ b/ArgumentsToolkit.Validation/Validations/RegexAttribute.cs @@ -0,0 +1,43 @@ +using System.Text.RegularExpressions; + +namespace ArgumentsToolkit; + +public partial class RangeAttribute +{ + /// + /// Атрибут для проверки строкового значения по регулярному выражению. + /// + [AttributeUsage(AttributeTargets.Property)] + public class RegexAttribute : ValidationAttribute + { + /// + /// Шаблон регулярного выражения. + /// + public string Pattern { get; } + + /// + /// Создаёт новый атрибут регулярного выражения. + /// + /// Регулярное выражение для проверки. + 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); + } + } + +} diff --git a/ArgumentsToolkit.Validation/Validations/ValidationAttribute.cs b/ArgumentsToolkit.Validation/Validations/ValidationAttribute.cs new file mode 100644 index 0000000..2c1aa5a --- /dev/null +++ b/ArgumentsToolkit.Validation/Validations/ValidationAttribute.cs @@ -0,0 +1,23 @@ +namespace ArgumentsToolkit; + +/// +/// Базовый атрибут для всех правил валидации. +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)] +public abstract class ValidationAttribute : Attribute +{ + /// Кастомное сообщение об ошибке. + public abstract string ErrorTemplate { get; set; } + + /// + /// Проверяет значение свойства. + /// + /// Значение свойства. + public abstract bool Validate(object? value); + + /// + /// Возвращает сообщение об ошибке для указанного значения. + /// + public abstract string GetErrorMessage(string optionName, object? value); + +} diff --git a/ArgumentsToolkit.Validation/Validator.cs b/ArgumentsToolkit.Validation/Validator.cs new file mode 100644 index 0000000..7876d2a --- /dev/null +++ b/ArgumentsToolkit.Validation/Validator.cs @@ -0,0 +1,45 @@ +using System.Reflection; + +namespace ArgumentsToolkit; + +/// +/// Класс для выполнения валидации модели Options на основе атрибутов. +/// +public static class Validator +{ + /// + /// Проверяет объект на соответствие правилам валидации. + /// + /// Тип модели опций. + /// Экземпляр модели опций. + /// Список ошибок валидации. + /// true, если ошибок нет; иначе false. + public static bool Validate(T options, out string[] errors) + { + var list = new List(); + var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var p in props) + { + // Проверяем только свойства с [Option] + var optionAttr = p.GetCustomAttribute(); + if (optionAttr == null) continue; + + var val = p.GetValue(options); + + // Берём все атрибуты, которые реализуют IValidationAttribute + foreach (var attr in p.GetCustomAttributes().OfType()) + { + if (!attr.Validate(val)) + { + var error = attr.GetErrorMessage(optionAttr.Name, val); + list.Add(error); + } + } + } + + errors = list.ToArray(); + return list.Count == 0; + } + +} diff --git a/ArgumentsToolkit.slnx b/ArgumentsToolkit.slnx new file mode 100644 index 0000000..6d9fd0f --- /dev/null +++ b/ArgumentsToolkit.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ArgumentsToolkit/ArgumentsToolkit.csproj b/ArgumentsToolkit/ArgumentsToolkit.csproj new file mode 100644 index 0000000..46744c6 --- /dev/null +++ b/ArgumentsToolkit/ArgumentsToolkit.csproj @@ -0,0 +1,15 @@ + + + + net8.0 + enable + enable + + + + + + + + + diff --git a/Demo/Demo.csproj b/Demo/Demo.csproj new file mode 100644 index 0000000..d7c4221 --- /dev/null +++ b/Demo/Demo.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/Demo/Program.cs b/Demo/Program.cs new file mode 100644 index 0000000..9705eac --- /dev/null +++ b/Demo/Program.cs @@ -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(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(); + 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 +} diff --git a/Demo/Properties/launchSettings.json b/Demo/Properties/launchSettings.json new file mode 100644 index 0000000..b173926 --- /dev/null +++ b/Demo/Properties/launchSettings.json @@ -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}" + } + } +} \ No newline at end of file