From f4020afed89dd419f33b4e83fb1313de4e2e00de Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 15:36:55 +0200 Subject: [PATCH 01/25] docs/config --- .editorconfig | 376 ++++++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + AGENTS.md | 68 +++++++++ 3 files changed, 445 insertions(+) create mode 100644 .editorconfig create mode 100644 AGENTS.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..30024f0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,376 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] +# analyzers +dotnet_diagnostic.IDE0290.severity = none # use primary constructor +dotnet_diagnostic.IDE0028.severity = none # use collection expression +dotnet_diagnostic.IDE0056.severity = none # simplify index operator +dotnet_diagnostic.IDE0057.severity = none # use range operator +dotnet_diagnostic.IDE0301.severity = none # simplify collection initialization +dotnet_diagnostic.IDE0053.severity = none # expression body lambda +dotnet_diagnostic.IDE0046.severity = none # simplify if(s) - conditional operator +dotnet_diagnostic.IDE0305.severity = none # [, ...] instead of .ToArray() + + +# namespace declaration +csharp_style_namespace_declarations = file_scoped:warning + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = false +csharp_new_line_before_else = false +csharp_new_line_before_finally = false +csharp_new_line_before_members_in_anonymous_types = false +csharp_new_line_before_members_in_object_initializers = false +csharp_new_line_before_open_brace = none +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case \ No newline at end of file diff --git a/.gitignore b/.gitignore index 234e1b4..93b89d5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs *WARP.md +*Tasks.md # Mono auto generated files mono_crash.* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af22f9f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,68 @@ +# AGENTS.md + +This file provides guidance to agents when working with code in this repository. + +Repository: Sharpify.CommandLineInterface (C#/.NET library + tests) + +## Commands + +- Build (library only) + - dotnet build src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj +- Build (entire solution) + - dotnet build Sharpify.CommandLineInterface.slnx +- Format (respect .editorconfig) + - dotnet format +- Tests (Microsoft Testing Platform runner via dotnet run; pass arguments after `--`) + - Run all tests: dotnet run --project tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj + - List tests: dotnet run --project tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj --list-tests + - Filter by type/method (examples) + - By class: dotnet run --project tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj --filter-class "*ClassName*" + - By class: dotnet run --project tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj --filter-method "*MethodName*" + +## Agent Workflow Requirements + +- Before running any test command, explicitly ask the user for permission to escalate out of the sandbox (if required) and wait for confirmation. + +## High-level architecture and structure + +- Solution layout + - Sharpify.CommandLineInterface.slnx: solution that groups the library, shared test helpers, manual sample, and test runner projects + - src/Sharpify.CommandLineInterface: core library (targets net9.0) + - tests/Sharpify.CommandLineInterface.Tests.Common: shared fixtures and sample commands referenced by tests + - tests/manual: manual harness for experimenting with the CLI (net9.0 console app) + - tests/Sharpify.CommandLineInterface.Tests: xUnit v3 tests (net9.0), configured to run with Microsoft Testing Platform via dotnet run +- Core concepts (library) + - Commands and execution + - Command and SynchronousCommand are the extensibility points for users to implement CLI commands. They expose compile-time metadata (Name, Description, Usage) and an execution entry point (ExecuteAsync for async ValueTask workflows; Execute for sync). + - CliRunner orchestrates end-to-end invocation: parses input, routes to the correct command, handles help, and writes output. It is created via CliRunner.CreateBuilder(). + - CliBuilder provides a fluent builder to add commands, configure output writers, and modify global CLI metadata, producing a configured CliRunner instance. + - HelpCommand and VersionCommand are appended automatically during Build() to service global `--help`/`--version` requests; OnVersionRequestedInvoke allows overriding the version command behavior. + - Arguments and parsing + - Parser converts raw args (string/ReadOnlySpan/IList) to an Arguments instance by tokenizing on spaces/quotes and mapping to a case-insensitive dictionary (default StringComparer.OrdinalIgnoreCase). + - Arguments (implemented across ArgumentsCore/ArgumentsAccess/ArgumentsAccessMultiple) provides high-performance retrieval, validation, and parsing APIs: + - Positional access by index; named access by key (dashes removed); flags as keys with empty values. + - Typed getters/parsers (TryGetValue, GetValue), enum parsing helpers, multi-value retrieval (TryGetValues) with custom separators, and ForwardPositionalArguments to shift positions after command routing. + - Output abstraction + - OutputHelper centralizes returning exit codes while writing to a configurable TextWriter set on the CliRunner (no direct dependency on System.Console). This keeps the library embeddable and testable. + - Metadata and configuration + - CliMetadata captures global app identity (name, description, author, version, license) used for help text and presentation. + - CliRunnerConfiguration and ConfigurationEnums provide structured configuration for runner behavior and modes. + - Shell completions + - Completions folder defines ICompletionProvider with concrete providers for Bash, Zsh, Fish, and PowerShell. These generate shell-specific completion data from the CLI metadata/commands. + - Utilities + - Extensions contains internal helper extensions used across the library. + - Pooling defines a lightweight, concurrency-aware object pool used for frequently allocated types such as StringBuilder and HashSet. +- Tests + - Tests cover parsing (ParserArgumentsTests), argument access APIs, builder/runner behavior (CliBuilderTests), and completion providers for each shell. + - tests/Sharpify.CommandLineInterface.Tests.Common hosts reusable fixture commands (AddCommand, EchoCommand, etc.) shared across unit and manual tests. + - The test project references the library, targets net9.0, and is configured with Microsoft Testing Platform (UseMicrosoftTestingPlatformRunner=true). Execute tests with dotnet run as shown above. + - tests/manual is a net9.0 console app that references the library and common fixtures for exploratory runs; keep it compiling when making API changes. + +## CI + +- .github/workflows/Tests.yaml runs tests on Ubuntu, Windows, and macOS with .NET 9.0 using a reusable workflow and the Microsoft Testing Platform runner. + +## Notes + +- The library enables Native AOT scenarios (IsAotCompatible=true, IsTrimmable=true) and intentionally avoids reflection in core paths; when consuming the library, prefer compile-time metadata and explicit parsing over reflection to keep AOT compatibility. +- XML documentation is generated (GenerateDocumentationFile=True). Public APIs should be documented; prefer inheritdoc on members when appropriate. From f22512a1ccb7d98da92503e80363c103bbb20029 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 15:38:33 +0200 Subject: [PATCH 02/25] Arguments improvement --- .../ArgumentsAccess.cs | 78 ++++++++++++++++--- .../ArgumentsAccessMultiple.cs | 10 +-- .../ArgumentsCore.cs | 6 +- 3 files changed, 74 insertions(+), 20 deletions(-) diff --git a/src/Sharpify.CommandLineInterface/ArgumentsAccess.cs b/src/Sharpify.CommandLineInterface/ArgumentsAccess.cs index eb05cfe..9b86d76 100644 --- a/src/Sharpify.CommandLineInterface/ArgumentsAccess.cs +++ b/src/Sharpify.CommandLineInterface/ArgumentsAccess.cs @@ -1,8 +1,38 @@ -using System.Globalization; - namespace Sharpify.CommandLineInterface; public sealed partial class Arguments { + /// + /// Checks whether is the value of the first positional argument or a single flag + /// + /// + /// + /// Used mainly for single options, like "--help" or "--version" + /// + /// + public bool IsFirstOrFlag(string value) { + if (TryGetValue(0, out string? first) && first == value) { + return true; + } + return _arguments.Count is 1 && HasFlag(value); + } + + /// + /// Checks whether any of the is the value of the first positional argument or a single flag + /// + /// + /// + /// Same as but allows aliases. + /// + /// + public bool IsFirstOrFlag(ReadOnlySpan values) { + foreach (var value in values) { + if (TryGetValue(0, out string? first) && first == value) { + return true; + } + } + return _arguments.Count is 1 && HasFlag(values); + } + /// /// Checks if the specified key exists in the arguments. /// @@ -15,7 +45,15 @@ public sealed partial class Arguments { /// /// The positional argument to check. /// True if the key exists, false otherwise. - public bool Contains(int position) => Contains(position.ToString()); + public bool Contains(int position) => Contains(position.ToString(CultureInfo.CurrentCulture)); + + /// + /// Checks if any of the specified keys exists in the arguments + /// + /// + /// Can either be used for different keys, or aliases for the same parameter + /// + public bool ContainsAny(ReadOnlySpan keys) => TryGetValue(keys, out _); /// /// Checks if the specified flag is present in the arguments. @@ -27,13 +65,23 @@ public sealed partial class Arguments { /// public bool HasFlag(string flag) => _arguments.TryGetValue(flag, out string? val) && val.Length is 0; + /// + /// Checks if the any of the specified flag aliases is present in the arguments. + /// + /// The flag aliases to check. + /// True if a flag alias is present and has no value; otherwise, false. + /// + /// This is not the same as as this also checks that the value is empty, which is not the case for named arguments that can also be detected by + /// + public bool HasFlag(ReadOnlySpan flagAliases) => TryGetValue(flagAliases, out string? val) && val.Length is 0; + /// /// Tries to retrieve the value of a positional argument. /// /// The key to check. /// The value of the argument ("" if doesn't exist - NOT NULL). /// true if the key exists, false otherwise. - public bool TryGetValue(int position, out string value) => TryGetValue(position.ToString(), out value); + public bool TryGetValue(int position, out string value) => TryGetValue(position.ToString(CultureInfo.CurrentCulture), out value); /// /// Tries to retrieve the value of a specified key in the arguments. @@ -56,8 +104,16 @@ public bool TryGetValue(string key, out string value) { /// A collection of aliases for a parameter name /// The value of the argument ("" if doesn't exist - NOT NULL). /// true if the key exists, false otherwise. - public bool TryGetValue(ReadOnlySpan keys, out string value) - => _arguments.TryGetValue(keys, out value); + public bool TryGetValue(ReadOnlySpan keys, out string value) { + foreach (var key in keys) { + if (_arguments.TryGetValue(key, out var res)) { + value = res; + return true; + } + } + value = ""; + return false; + } /// /// Tries to retrieve the value of the positional argument in the arguments. @@ -74,7 +130,7 @@ public bool TryGetValue(ReadOnlySpan keys, out string value) /// /// /// true if the key exists, false otherwise. - public bool TryGetValue(int position, T defaultValue, out T value) where T : IParsable => TryGetValue(position.ToString(), defaultValue, out value); + public bool TryGetValue(int position, T defaultValue, out T value) where T : IParsable => TryGetValue(position.ToString(CultureInfo.CurrentCulture), defaultValue, out value); /// /// Tries to retrieve the value of a specified key in the arguments. @@ -179,7 +235,7 @@ public T GetValue(ReadOnlySpan keys, T defaultValue) where T : IParsa /// If the key doesn't exist or can't be parsed, the the default(TEnum) will be used in the out parameter, this overloads also implies that the enum will be parsed case-sensitive /// /// true if the key exists, false otherwise. - public bool TryGetEnum(int position, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(), default, false, out value); + public bool TryGetEnum(int position, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(CultureInfo.CurrentCulture), default, false, out value); /// /// Tries to retrieve the enum value of a specified key in the arguments. @@ -191,7 +247,7 @@ public T GetValue(ReadOnlySpan keys, T defaultValue) where T : IParsa /// If the key doesn't exist or can't be parsed, the default(TEnum) will be used in the out parameter. /// /// true if the key exists, false otherwise. - public bool TryGetEnum(int position, bool ignoreCase, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(), default, ignoreCase, out value); + public bool TryGetEnum(int position, bool ignoreCase, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(CultureInfo.CurrentCulture), default, ignoreCase, out value); /// /// Tries to retrieve the enum value of a specified key in the arguments. @@ -204,7 +260,7 @@ public T GetValue(ReadOnlySpan keys, T defaultValue) where T : IParsa /// If the key doesn't exist or can't be parsed, the default value will be used in the out parameter. /// /// true if the key exists, false otherwise. - public bool TryGetEnum(int position, TEnum defaultValue, bool ignoreCase, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(), defaultValue, ignoreCase, out value); + public bool TryGetEnum(int position, TEnum defaultValue, bool ignoreCase, out TEnum value) where TEnum : struct, Enum => TryGetEnum(position.ToString(CultureInfo.CurrentCulture), defaultValue, ignoreCase, out value); /// /// Tries to retrieve the enum value of a specified key in the arguments. @@ -380,4 +436,4 @@ public TEnum GetEnum(ReadOnlySpan keys, TEnum defaultValue, bool _ = TryGetEnum(keys, defaultValue, ignoreCase, out var value); return value; } -} +} \ No newline at end of file diff --git a/src/Sharpify.CommandLineInterface/ArgumentsAccessMultiple.cs b/src/Sharpify.CommandLineInterface/ArgumentsAccessMultiple.cs index dca7db7..88d1e1d 100644 --- a/src/Sharpify.CommandLineInterface/ArgumentsAccessMultiple.cs +++ b/src/Sharpify.CommandLineInterface/ArgumentsAccessMultiple.cs @@ -1,5 +1,3 @@ -using System.Globalization; - namespace Sharpify.CommandLineInterface; public sealed partial class Arguments { @@ -11,7 +9,7 @@ public sealed partial class Arguments { /// The values of the argument or an empty array if they don't exist. /// true if the key exists, false otherwise. public bool TryGetValues(int position, string? separator, out string[] values) - => TryGetValues(position.ToString(), separator, out values); + => TryGetValues(position.ToString(CultureInfo.CurrentCulture), separator, out values); /// /// Tries to retrieve the value of a specified key in the arguments. @@ -37,7 +35,7 @@ public bool TryGetValues(string key, string? separator, out string[] values) { /// The values of the argument or an empty array if don't exist. /// true if the key exists, false otherwise. public bool TryGetValues(ReadOnlySpan keys, string? separator, out string[] values) { - if (!_arguments.TryGetValue(keys, out var res)) { + if (!TryGetValue(keys, out var res)) { values = []; return false; } @@ -58,7 +56,7 @@ public bool TryGetValues(ReadOnlySpan keys, string? separator, out strin /// /// true if the key exists, false otherwise. public bool TryGetValues(int position, string? separator, out T[] values) where T : IParsable - => TryGetValues(position.ToString(), separator, out values); + => TryGetValues(position.ToString(CultureInfo.CurrentCulture), separator, out values); /// /// Tries to retrieve the values of a specified key in the arguments. @@ -127,4 +125,4 @@ public bool TryGetValues(ReadOnlySpan keys, string? separator, out T[ values = result; return true; } -} +} \ No newline at end of file diff --git a/src/Sharpify.CommandLineInterface/ArgumentsCore.cs b/src/Sharpify.CommandLineInterface/ArgumentsCore.cs index 2319aaa..dc7c563 100644 --- a/src/Sharpify.CommandLineInterface/ArgumentsCore.cs +++ b/src/Sharpify.CommandLineInterface/ArgumentsCore.cs @@ -12,7 +12,7 @@ public sealed partial class Arguments { /// /// Source is the list of separated arguments on top of which this instance of was built. /// - public readonly ReadOnlyCollection Source; + public ReadOnlyCollection Source { get; } private readonly Dictionary _arguments; /// @@ -71,7 +71,7 @@ public Arguments ForwardPositionalArguments() { if (numericIndex is 0) { // forwarding means the previous 0 is lost continue; } - dict.Add((numericIndex - 1).ToString(), prevValue); // Add with the index reduced by 1. + dict.Add((numericIndex - 1).ToString(CultureInfo.CurrentCulture), prevValue); // Add with the index reduced by 1. } // Because this is a new dictionary, if pos 1, isn't found, 0 still won't be present @@ -83,4 +83,4 @@ public Arguments ForwardPositionalArguments() { /// Returns the underlying dictionary /// public ReadOnlyDictionary GetInnerDictionary() => _arguments.AsReadOnly(); -} +} \ No newline at end of file From bc9904cf034450822cd0604bb87ad5f08157b87b Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 15:39:26 +0200 Subject: [PATCH 03/25] Metadata should default to empty --- .../CliMetadata.cs | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/Sharpify.CommandLineInterface/CliMetadata.cs b/src/Sharpify.CommandLineInterface/CliMetadata.cs index 7690a5d..b1a3da0 100644 --- a/src/Sharpify.CommandLineInterface/CliMetadata.cs +++ b/src/Sharpify.CommandLineInterface/CliMetadata.cs @@ -4,44 +4,44 @@ namespace Sharpify.CommandLineInterface; /// Contains metadata for a CLI application. /// public record CliMetadata { - /// - /// The name of the CLI application. - /// - public string Name { get; set; } = string.Empty; + /// + /// The name of the CLI application. + /// + public string Name { get; set; } = string.Empty; - /// - /// The description of the CLI application. - /// - public string Description { get; set; } = string.Empty; + /// + /// The description of the CLI application. + /// + public string Description { get; set; } = string.Empty; - /// - /// The version of the CLI application. - /// - public string Version { get; set; } = string.Empty; + /// + /// The version of the CLI application. + /// + public string Version { get; set; } = string.Empty; - /// - /// The author of the CLI application. - /// - public string Author { get; set; } = string.Empty; + /// + /// The author of the CLI application. + /// + public string Author { get; set; } = string.Empty; - /// - /// The license of the CLI application. - /// - public string License { get; set; } = string.Empty; + /// + /// The license of the CLI application. + /// + public string License { get; set; } = string.Empty; - /// - /// The default metadata for a CLI application. - /// - public static readonly CliMetadata Default = new() { - Name = "Interface", - Description = "Default description.", - Version = "1.0.0", - Author = "John Doe", - License = "MIT", - }; + /// + /// The default metadata for a CLI application. + /// + public static CliMetadata DefaultMetadata => new() { + Name = string.Empty, + Description = string.Empty, + Version = string.Empty, + Author = string.Empty, + License = string.Empty, + }; - /// - /// Returns the total length of the metadata. - /// - public int TotalLength => Name.Length + Description.Length + Version.Length + Author.Length + License.Length; + /// + /// Returns the total length of the metadata. + /// + public int TotalLength => Name.Length + Description.Length + Version.Length + Author.Length + License.Length; } \ No newline at end of file From cf810370da9a7d19d5e7ad8192bcde9d4ec72295 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 15:41:26 +0200 Subject: [PATCH 04/25] Parser improvement --- src/Sharpify.CommandLineInterface/Parser.cs | 142 ++++++++++++-------- 1 file changed, 84 insertions(+), 58 deletions(-) diff --git a/src/Sharpify.CommandLineInterface/Parser.cs b/src/Sharpify.CommandLineInterface/Parser.cs index 4fece04..74bd08e 100644 --- a/src/Sharpify.CommandLineInterface/Parser.cs +++ b/src/Sharpify.CommandLineInterface/Parser.cs @@ -1,5 +1,4 @@ using System.Collections.ObjectModel; -using System.Runtime.CompilerServices; namespace Sharpify.CommandLineInterface; @@ -7,11 +6,6 @@ namespace Sharpify.CommandLineInterface; /// Command line argument parser /// public static class Parser { - /// - /// The default starting capacity of argument buffers - /// - private const int DefaultBufferCapacity = 8; - /// /// Very efficiently splits an input into a List of strings, respects quotes /// @@ -21,7 +15,7 @@ public static List Split(ReadOnlySpan str) { if (str.Length is 0) { return args; } - args.EnsureCapacity(DefaultBufferCapacity); + args.EnsureCapacity(8); // 8 parameters/values is a good norm as any int i = 0; while ((uint)i < (uint)str.Length) { char c = str[i]; @@ -101,76 +95,108 @@ public static Arguments ParseArguments(TList args, StringComparer compare internal static Dictionary MapArguments(ReadOnlyCollection args, StringComparer comparer) { var length = args.Count; var results = new Dictionary(length, comparer); - Span mapped = stackalloc bool[length]; - int i = 0; - // Named arguments - while (i < length) { - var current = args[i]; - // This is positional argument, processed in the next loop - // values of named params are processed in the single iteration of the named parameter - if (!IsParameterName(current)) { - i++; + if (length is 0) { + return results; + } + + bool terminatorSeen = false; + int index = 0; + int positionalIndex = 0; + var culture = CultureInfo.InvariantCulture; + + while (index < length) { + var current = args[index]; + + if (terminatorSeen) { + // After '--', everything is positional regardless of prefix + results[positionalIndex.ToString(culture)] = current; + positionalIndex++; + index++; continue; } - // This is parameter name (starts with either - or --) - int ii = 0; - while (current[ii] is '-') { // Skip the dashes - ii++; - } - var name = current.Substring(ii); // Parameter name without dashes - - // i + 1 == args.Length => checks if the next argument is available - // if not, then this is a switch (i.e. a named boolean toggle) - // IsParameterName(args[i + 1]) => checks if the next argument is a parameter - // if it is, then again, this is a switch - if (i + 1 == length || IsParameterName(args[i + 1])) { - results[name] = string.Empty; - mapped[i] = true; - i++; + + if (current == "--") { + // POSIX terminator: stop option parsing, treat the rest as positional + terminatorSeen = true; + index++; continue; } - // If the previous condition didn't take - // then this is the value of the named parameter - var value = args[i + 1]; - results[name] = value; - mapped[i] = mapped[i + 1] = true; - i += 2; - } - int position = 0; + ReadOnlySpan span = current; + int equalsIndex = span.IndexOf('='); + ReadOnlySpan nameSpan = equalsIndex > 0 + ? span.Slice(0, equalsIndex) + : span; + + if (TryReadOptionName(nameSpan, out var optionNameSpan)) { + var optionName = new string(optionNameSpan); + if (equalsIndex > 0) { + // Inline assignment: --key=value or -k=value + var valueSpan = span.Slice(equalsIndex + 1); + var value = valueSpan.Length is 0 ? string.Empty : new string(valueSpan); + results[optionName] = value; + index++; + continue; + } - // Positional arguments (mapped as {pos: value}) - // The positional arguments are mapped in the order they appear - // And the number of the positional argument - // A positional argument may have the key 0, even if it is the last enter argument (assuming other arguments are named or switches) - for (i = 0; i < length; i++) { - if (mapped[i]) { + if (index + 1 >= length || args[index + 1] == "--" || TryReadOptionName(args[index + 1], out _)) { + // Flag or trailing option without value + results[optionName] = string.Empty; + index++; + continue; + } + + // Named option with separate value + var optionValue = args[index + 1]; + results[optionName] = optionValue; + index += 2; continue; } - results[position.ToString()] = args[i]; - position++; - mapped[i] = true; + + // Positional argument in the pre-terminator section + results[positionalIndex.ToString(culture)] = current; + positionalIndex++; + index++; } return results; } - // Checks whether a string starts with "-" - [MethodImpl(MethodImplOptions.NoInlining)] - private static bool IsParameterName(ReadOnlySpan str) { - // check length - if (str.Length is 0) { + // Determines whether a token can represent an option name (e.g., --foo or -f) and returns the span of the name without dashes + [MethodImpl(MethodImplOptions.AggressiveOptimization)] + private static bool TryReadOptionName(ReadOnlySpan token, out ReadOnlySpan name) { + name = ReadOnlySpan.Empty; + + // Must start with '-' to be considered an option + if (token.Length is 0 || token[0] != '-') { + return false; + } + + // Remove prefix dashes + int prefixLength = 0; + while (prefixLength < token.Length && token[prefixLength] == '-') { + prefixLength++; + } + + // Reject tokens that are only dashes ("-" or "--") + if (prefixLength == token.Length) { return false; } - // check numeric + negative numeric (negative numeric could look like parameter name because of the dash) - if (char.IsDigit(str[0]) || char.IsDigit(str[str.LastIndexOf('-') + 1])) { + + // Disallow negative numbers (e.g., "-5") from being parsed as options + if (char.IsDigit(token[prefixLength])) { return false; } - // not dash - not parameter - if (!str.StartsWith("-")) { + + int lastDash = token.LastIndexOf('-'); + // Reject tokens like "-foo-1" where the suffix looks numeric + if (lastDash >= 0 && lastDash + 1 < token.Length && char.IsDigit(token[lastDash + 1])) { return false; } + + // Return clean argument name slice (no dashes) + name = token.Slice(prefixLength); return true; } -} \ No newline at end of file +} From 16ef82993a7af3f98f0b80085ac464f7dfa1181c Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 15:42:26 +0200 Subject: [PATCH 05/25] Configuration simplification --- .../CliRunnerConfiguration.cs | 90 +++++++++---------- 1 file changed, 40 insertions(+), 50 deletions(-) diff --git a/src/Sharpify.CommandLineInterface/CliRunnerConfiguration.cs b/src/Sharpify.CommandLineInterface/CliRunnerConfiguration.cs index bb1e315..0f1c8b5 100644 --- a/src/Sharpify.CommandLineInterface/CliRunnerConfiguration.cs +++ b/src/Sharpify.CommandLineInterface/CliRunnerConfiguration.cs @@ -4,54 +4,44 @@ namespace Sharpify.CommandLineInterface; /// Represents the internal configuration of a CLI runner. /// internal sealed class CliRunnerConfiguration { - /// - /// The commands that the CLI runner can execute. - /// - public List Commands { get; set; } = []; - - /// - /// The metadata of the CLI runner. - /// - public CliMetadata MetaData { get; set; } = CliMetadata.Default; - - /// - /// The header to use in the help text. - /// - public string CustomHeader { get; set; } = string.Empty; - - /// - /// The source of the help text. - /// - public HelpTextSource HelpTextSource { get; set; } = HelpTextSource.Metadata; - - /// - /// Whether to sort commands alphabetically. - /// - /// - /// It is set to false by default - /// - public bool SortCommandsAlphabetically { get; set; } - - /// - /// Whether to show error codes in the help text. - /// - /// - /// It is set to false by default to improve user experience - /// - public bool ShowErrorCodes { get; set; } - - /// - /// A function that will generate completions - /// - public Func? CompletionsGenerator { get; set; } - - /// - /// Configures the case sensitivity of arguments parsing - /// - public ArgumentCaseHandling ArgumentCaseHandling { get; set; } = ArgumentCaseHandling.IgnoreCase; - - /// - /// Configures the behavior of the CLI runner when empty input is provided. - /// - public EmptyInputBehavior EmptyInputBehavior { get; set; } = EmptyInputBehavior.DisplayHelpText; + /// + /// The commands that the CLI runner can execute. + /// + public List Commands { get; } = []; + + /// + /// The metadata of the CLI runner. + /// + public CliMetadata MetaData { get; } = CliMetadata.DefaultMetadata; + + /// + /// The header to use in the help text. + /// + public string CustomHeader { get; set; } = string.Empty; + + /// + /// The source of the help text. + /// + public HelpTextSource HelpTextSource { get; set; } = HelpTextSource.Metadata; + + /// + /// Whether to sort commands alphabetically. + /// + /// + /// It is set to false by default + /// + public bool SortCommandsAlphabetically { get; set; } + + /// + /// Whether to show error codes in the help text. + /// + /// + /// It is set to false by default to improve user experience + /// + public bool ShowErrorCodes { get; set; } + + /// + /// Configures the case sensitivity of arguments parsing + /// + public ArgumentCaseHandling ArgumentCaseHandling { get; set; } = ArgumentCaseHandling.IgnoreCase; } \ No newline at end of file From 61687487d58c925f20bfc2aedac33864bdb584d7 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 15:45:06 +0200 Subject: [PATCH 06/25] Command simplification --- src/Sharpify.CommandLineInterface/Command.cs | 35 ++++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/src/Sharpify.CommandLineInterface/Command.cs b/src/Sharpify.CommandLineInterface/Command.cs index a3f0de5..52e3003 100644 --- a/src/Sharpify.CommandLineInterface/Command.cs +++ b/src/Sharpify.CommandLineInterface/Command.cs @@ -19,10 +19,10 @@ public abstract class Command { /// public abstract string Usage { get; } - /// - /// Executes the command. - /// - public abstract ValueTask ExecuteAsync(Arguments args); + /// + /// Executes the command. + /// + public abstract ValueTask ExecuteAsync(Arguments args); /// /// Gets the help for the command. @@ -42,17 +42,16 @@ public virtual string GetHelp() { return builder.ToString(); } - /// - /// Compares two commands by their name. - /// - /// The first command to compare. - /// The second command to compare. - /// - /// A value indicating the relative order of the commands. - /// The return value is less than 0 if x.Name is less than y.Name, - /// 0 if x.Name is equal to y.Name, and greater than 0 if x.Name is greater than y.Name. - /// - public static int ByNameComparer(Command x, Command y) { - return string.Compare(x.Name, y.Name, StringComparison.Ordinal); - } -} \ No newline at end of file + /// + /// Compares two commands by their name. + /// + /// The first command to compare. + /// The second command to compare. + /// + /// A value indicating the relative order of the commands. + /// The return value is less than 0 if x.Name is less than y.Name, + /// 0 if x.Name is equal to y.Name, and greater than 0 if x.Name is greater than y.Name. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static int ByNameComparer(Command x, Command y) => string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); +} From cd1a365ff226d3e9ffff42c3c82aa1e1bf7d7c1a Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 15:45:22 +0200 Subject: [PATCH 07/25] Formatting --- .../ConfigurationEnums.cs | 50 ++++++------------- 1 file changed, 16 insertions(+), 34 deletions(-) diff --git a/src/Sharpify.CommandLineInterface/ConfigurationEnums.cs b/src/Sharpify.CommandLineInterface/ConfigurationEnums.cs index 3f1153f..e1d4da5 100644 --- a/src/Sharpify.CommandLineInterface/ConfigurationEnums.cs +++ b/src/Sharpify.CommandLineInterface/ConfigurationEnums.cs @@ -1,47 +1,29 @@ namespace Sharpify.CommandLineInterface; -/// -/// Controls how the CLI runner handles empty input. -/// -public enum EmptyInputBehavior { - /// - /// Displays the help text and exits. - /// - DisplayHelpText, - /// - /// Attempts to proceed with handling the commands. - /// - /// - /// If a single command is used and command name is set to not required, this will execute the command with empty args, - /// otherwise it will display the appropriate error message. - /// - AttemptToProceed, -} - /// /// Dictates the source of the general help text /// public enum HelpTextSource { - /// - /// Use the metadata to generate HelpText - /// - Metadata, - /// - /// Use the custom header to generate HelpText - /// - CustomHeader + /// + /// Use the metadata to generate HelpText + /// + Metadata, + /// + /// Use the custom header to generate HelpText + /// + CustomHeader } /// /// Configures how to handle argument casing /// public enum ArgumentCaseHandling { - /// - /// Ignore argument case - /// - IgnoreCase, - /// - /// Sets the arguments parser to be case sensitive - /// - CaseSensitive + /// + /// Ignore argument case + /// + IgnoreCase, + /// + /// Sets the arguments parser to be case sensitive + /// + CaseSensitive } \ No newline at end of file From e11d17aefd0e3cf40ae5b2e195a848d9befbd018 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 15:45:35 +0200 Subject: [PATCH 08/25] Formatting --- .../OutputHelper.cs | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Sharpify.CommandLineInterface/OutputHelper.cs b/src/Sharpify.CommandLineInterface/OutputHelper.cs index d58619f..0c0da20 100644 --- a/src/Sharpify.CommandLineInterface/OutputHelper.cs +++ b/src/Sharpify.CommandLineInterface/OutputHelper.cs @@ -4,31 +4,31 @@ namespace Sharpify.CommandLineInterface; /// Provides helper methods for outputting using /// public static class OutputHelper { - /// - /// Writes a line to the output writer. - /// + /// + /// Writes a line to the output writer. + /// public static void WriteLine(string message) => CliRunner.OutputWriter.WriteLine(message); - /// - /// Writes a message to the output writer. - /// + /// + /// Writes a message to the output writer. + /// public static void Write(string message) => CliRunner.OutputWriter.Write(message); - /// - /// Writes a line to the output writer and returns the specified code. - /// - /// The message to write. - /// The code to return. - /// Whether to append the code to the message. - /// A containing the specified code. - /// Using will append [Code: ] to + /// + /// Writes a line to the output writer and returns the specified code. + /// + /// The message to write. + /// The code to return. + /// Whether to append the code to the message. + /// A containing the specified code. + /// Using will append [Code: ] to public static ValueTask Return(string message, int code, bool appendCode = false) { - var writer = CliRunner.OutputWriter; - writer.Write(message); - if (appendCode) { - writer.Write($" [Code: {code}]"); - } - writer.WriteLine(); - return ValueTask.FromResult(code); - } + var writer = CliRunner.OutputWriter; + writer.Write(message); + if (appendCode) { + writer.Write($" [Code: {code}]"); + } + writer.WriteLine(); + return ValueTask.FromResult(code); + } } \ No newline at end of file From 1fd91ba308e25b502ebeb51b299171305dc204d1 Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 15:52:09 +0200 Subject: [PATCH 09/25] Projects --- Sharpify.CommandLineInterface.slnx | 2 + .../Sharpify.CommandLineInterface.csproj | 54 ++++++++++++++----- ...y.CommandLineInterface.Tests.Common.csproj | 13 +++++ ...Sharpify.CommandLineInterface.Tests.csproj | 1 + tests/manual/Program.cs | 21 ++++++++ tests/manual/manual.csproj | 14 +++++ 6 files changed, 91 insertions(+), 14 deletions(-) create mode 100644 tests/Sharpify.CommandLineInterface.Tests.Common/Sharpify.CommandLineInterface.Tests.Common.csproj create mode 100644 tests/manual/Program.cs create mode 100644 tests/manual/manual.csproj diff --git a/Sharpify.CommandLineInterface.slnx b/Sharpify.CommandLineInterface.slnx index 5841ae7..0b65b37 100644 --- a/Sharpify.CommandLineInterface.slnx +++ b/Sharpify.CommandLineInterface.slnx @@ -3,6 +3,8 @@ + + diff --git a/src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj b/src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj index 4ff2aec..108cf1d 100644 --- a/src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj +++ b/src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj @@ -1,33 +1,59 @@  - net9.0;net8.0 + net9.0 latest enable - 3.0.0-rc1 enable - true + + true + true + true + true + latest-recommended + true + true + + 3.0.0-rc2 David Shnayder David Shnayder MIT - CHANGELOGLATEST.md True Sharpify.CommandLineInterface - An standalone package focused on creating minimalistic AOT compatible command line interfaces - https://github.com/dusrdev/Sharpify - https://github.com/dusrdev/Sharpify + Sharpify.CommandLineInterface + A framework for build Native AOT compatible command line interfaces + https://github.com/dusrdev/Sharpify.CommandlineInterface + https://github.com/dusrdev/Sharpify.CommandLineInterface git - Extensions;HighPerformance;Cli;Parser;Interface;CommandLine + Cli;HighPerformance;Shell;NativeAOT;Parser;Interface;CommandLine true - true - false - true + README.md + + + + + + + + + + + + + + + + + + portable + true + + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + - - - + diff --git a/tests/Sharpify.CommandLineInterface.Tests.Common/Sharpify.CommandLineInterface.Tests.Common.csproj b/tests/Sharpify.CommandLineInterface.Tests.Common/Sharpify.CommandLineInterface.Tests.Common.csproj new file mode 100644 index 0000000..0375bd3 --- /dev/null +++ b/tests/Sharpify.CommandLineInterface.Tests.Common/Sharpify.CommandLineInterface.Tests.Common.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj b/tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj index 1c472a6..05f3286 100644 --- a/tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj +++ b/tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/manual/Program.cs b/tests/manual/Program.cs new file mode 100644 index 0000000..0c3598b --- /dev/null +++ b/tests/manual/Program.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; + +using Sharpify.CommandLineInterface; +using Sharpify.CommandLineInterface.Tests.Common; + +var single = new ParameterShowcaseCommand(); + +var cliRunner = CliRunner.CreateBuilder() + .AddCommand(single) + .UseConsoleAsOutputWriter() + .WithMetadata(options => { + options.Version = "1.0.0"; + options.Author = "David"; + }) + .Build(); + +var start = Stopwatch.GetTimestamp(); +await cliRunner.RunAsync("-h"); +var elapsed = Stopwatch.GetElapsedTime(start); +Console.WriteLine(); +Console.WriteLine(elapsed.TotalMilliseconds); \ No newline at end of file diff --git a/tests/manual/manual.csproj b/tests/manual/manual.csproj new file mode 100644 index 0000000..5c8be63 --- /dev/null +++ b/tests/manual/manual.csproj @@ -0,0 +1,14 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + From 01ecfa45ca38b643fb1482121fdb091c4b34fb7a Mon Sep 17 00:00:00 2001 From: David Shnayder Date: Tue, 18 Nov 2025 15:52:38 +0200 Subject: [PATCH 10/25] Remove completions --- .../Completions/BashCompletionProvider.cs | 72 ------------------ .../Completions/FishCompletionProvider.cs | 73 ------------------- .../Completions/ICompletionProvider.cs | 14 ---- .../PowerShellCompletionProvider.cs | 65 ----------------- .../Completions/ZshCompletionProvider.cs | 71 ------------------ .../BashCompletionProviderTests.cs | 35 --------- .../FishCompletionProviderTests.cs | 34 --------- .../PowerShellCompletionProviderTests.cs | 41 ----------- .../Completions/ZshCompletionProviderTests.cs | 48 ------------ 9 files changed, 453 deletions(-) delete mode 100644 src/Sharpify.CommandLineInterface/Completions/BashCompletionProvider.cs delete mode 100644 src/Sharpify.CommandLineInterface/Completions/FishCompletionProvider.cs delete mode 100644 src/Sharpify.CommandLineInterface/Completions/ICompletionProvider.cs delete mode 100644 src/Sharpify.CommandLineInterface/Completions/PowerShellCompletionProvider.cs delete mode 100644 src/Sharpify.CommandLineInterface/Completions/ZshCompletionProvider.cs delete mode 100644 tests/Sharpify.CommandLineInterface.Tests/Completions/BashCompletionProviderTests.cs delete mode 100644 tests/Sharpify.CommandLineInterface.Tests/Completions/FishCompletionProviderTests.cs delete mode 100644 tests/Sharpify.CommandLineInterface.Tests/Completions/PowerShellCompletionProviderTests.cs delete mode 100644 tests/Sharpify.CommandLineInterface.Tests/Completions/ZshCompletionProviderTests.cs diff --git a/src/Sharpify.CommandLineInterface/Completions/BashCompletionProvider.cs b/src/Sharpify.CommandLineInterface/Completions/BashCompletionProvider.cs deleted file mode 100644 index 988cac9..0000000 --- a/src/Sharpify.CommandLineInterface/Completions/BashCompletionProvider.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Text; - -namespace Sharpify.CommandLineInterface.Completions; - -/// -/// Generates bash shell completion definitions for a CLI built with Sharpify.CommandLineInterface. -/// -public sealed class BashCompletionProvider : ICompletionProvider { - internal static string CreateScript(string rootName, List commands) { - // Minimal bash completion using compgen -W over known subcommands. - // Layout: - // __completions() { ... } - // complete -F __completions - StringBuilder builder = new(capacity: 192 + commands.Count * 32); - - string funcName = "_" + rootName + "_completions"; - - builder.Append(funcName) - .AppendLine("() {") - .AppendLine(" local cur prev words cword") - .AppendLine(" COMPREPLY=()") - .AppendLine(" cur=\"${COMP_WORDS[COMP_CWORD]}\"") - .AppendLine() - .Append(" local _sharpify_subcmds=\""); - - for (int i = 0; i < commands.Count; i++) { - if (i > 0) builder.Append(' '); - builder.Append(EscapeForBashWord(commands[i].Name)); - } - builder.AppendLine("\""); - - builder.AppendLine(" COMPREPLY=( $(compgen -W \"$_sharpify_subcmds\" -- \"$cur\") )") - .AppendLine(" return 0") - .AppendLine("}") - .Append("complete -F ") - .Append(funcName) - .Append(' ') - .AppendLine(rootName); - - return builder.ToString(); - } - - /// - public async Task GenerateAsync(string rootName, List commands) { - if (commands.Count == 0) { - return; - } - - string outputPath = GeneratePath(rootName); - string script = CreateScript(rootName, commands); - - string dir = Path.GetDirectoryName(outputPath) ?? string.Empty; - if (dir.Length > 0) { - Directory.CreateDirectory(dir); - } - - await File.WriteAllTextAsync(outputPath, script, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - } - - internal static string GeneratePath(string scriptName) - => Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".bash_completion.d", scriptName); - - private static string EscapeForBashWord(string value) { - // Used inside a double-quoted string of words. Escape backslash, double-quote, dollar and backtick. - if (string.IsNullOrEmpty(value)) return value; - return value - .Replace("\\", "\\\\") - .Replace("\"", "\\\"") - .Replace("`", "\\`") - .Replace("$", "\\$"); - } -} \ No newline at end of file diff --git a/src/Sharpify.CommandLineInterface/Completions/FishCompletionProvider.cs b/src/Sharpify.CommandLineInterface/Completions/FishCompletionProvider.cs deleted file mode 100644 index 8f4d16b..0000000 --- a/src/Sharpify.CommandLineInterface/Completions/FishCompletionProvider.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Text; - -namespace Sharpify.CommandLineInterface.Completions; - -/// -/// Generates fish shell completion definitions for a CLI built with Sharpify.CommandLineInterface. -/// -public sealed class FishCompletionProvider : ICompletionProvider { - internal static string CreateScript(string rootName, List commands) { - // Build fish completion content according to https://fishshell.com/docs/current/completions.html - // Strategy for this CLI: - // - Clear any previous completions for this command: `complete -c -e` - // - Offer subcommands when none has been seen: `complete -c -n "not __fish_seen_subcommand_from ..." -a "..."` - // There is no standardized option metadata in this framework yet, so we only provide subcommand names. - StringBuilder builder = new(capacity: 256 + commands.Count * 64); - - // Ensure we start from a clean slate - builder.Append("complete -c ") - .Append(rootName) - .AppendLine(" -e"); - - // Build the condition and argument list for subcommands - // Condition: not __fish_seen_subcommand_from ... - builder.Append("complete -c ") - .Append(rootName) - .Append(" -n \"") - .Append("not __fish_seen_subcommand_from "); - - for (int i = 0; i < commands.Count; i++) { - if (i > 0) builder.Append(' '); - builder.Append(commands[i].Name); - } - builder.Append("\" "); - - // Arguments: space-separated list of subcommand names - builder.Append("-a \""); - for (int i = 0; i < commands.Count; i++) { - if (i > 0) builder.Append(' '); - // Escape quotes or backslashes if they ever appear in names - builder.Append(EscapeForFish(commands[i].Name)); - } - builder.AppendLine("\""); - - return builder.ToString(); - } - - /// - public async Task GenerateAsync(string rootName, List commands) { - if (commands.Count == 0) { - return; - } - - string outputPath = GeneratePath(rootName); - - string script = CreateScript(rootName, commands); - - // Write to ~/.config/fish/completions/