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. diff --git a/README.md b/README.md index d3165ee..0ef6a9f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,20 @@ Most other command line frameworks in c# use `reflection` to provide their "magic" such as generating help text, and providing input validation, `Sharpify.CommandLineInterface` instead uses compile time implemented metadata and user guided validation. each command must implement the `Command` or `SynchronousCommand` abstract class, part of which will be to set the command metadata, the main entry `CliRunner` also has an application level metadata object that can be customized in the `CliBuilder` process, using those, `Sharpify.CommandLineInterface` can resolve and format that metadata to generate an output similar to the other frameworks. Each command's entry point is either `ExecuteAsync` or `Execute` which receive an input of type `Arguments` that can be used to retrieve, validate and parse arguments. +## State as of .NET 10 + +While `Sharpify.CommandLineInterface` remains one of the best performing libraries for creating CLIs, at the current state [ConsoleAppFramework](https://github.com/Cysharp/ConsoleAppFramework) is a better library and the one I would recommend using. It uses source generators so it is less manual and verbose while providing even better performance and compatibility with other commonly used features such as DI and other abstractions. + +For this point onward I plan on using `ConsoleAppFramework` in my own apps, and I am also contributing to it to help make it even better. + +Nevertheless, `Sharpify.CommandLineInterface` version 3.0.0 was released to provide performance and stability enhancements to users that already use it, and this repo will stay for the foreseeable future to support with fixes if any issues should arise. + +## Installation + +```bash +dotnet add package Sharpify.CommandLineInterface +``` + ## Usage ### Implementing Commands @@ -71,16 +85,16 @@ public static class Program { var runner = CliRunner.CreateBuilder() .AddCommands(Commands) .UseConsoleAsOutputWriter() - .ModifyMetadata(metadata => { - metadata.Name = "MyCli"; - metadata.Descriptions = "MyCli Description"; - metadata.Author = "John Doe"; - metadata.Version = "1.0.0"; - metadata.License = "MIT" + .WithMetadata(metadata => { + metadata.Name = "MyCli"; + metadata.Description = "MyCli Description"; + metadata.Author = "John Doe"; + metadata.Version = "1.0.0"; + metadata.License = "MIT"; }) .Build(); - return runner.RunAsync(args).AsTask(); + return runner.RunAsync(args).AsTask(); } } ``` 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/VERSIONS.md b/VERSIONS.md index 7cd7adf..4ee196e 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -1,5 +1,23 @@ # CHANGELOG +## Version 3.0.0 + +- `Arguments` improvements + - `IsFirstOrFlag` will now allow simpler checks to verify if a command was entered as named parameter or vise-versa. + - `HasFlag` now has an overload that accepts aliases. + - All overloads and options will be be `CurrentCulture` aware. +- `CliMetadata` now defaults to keeping every property as empty string. + - This is so that the global help text will only output the overridden properties. +- `CliBuilder.ConfigureEmptyInputBehavior` was removed. + - If there is a single command - it will be executed. + - If there are multiple commands - global help text will be shown. + - You can override this behavior simply by using `if (args.Length == 0) args = ["--help"];` for example. +- Both `Help` and `Version` are no first-class internal commands that the builder injects automatically. + - `Help` was improved and more accurately displays information. + - `Version` will now just display `CliMetadata.Version` so make sure to override it for it to function well. +- `Parser` had many improvements that should result in more consistency, correctness, and better performance. +- General performance improvements. + ## Version 2.0.0 **WARNING:** This release may contain breaking changes. 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 diff --git a/src/Sharpify.CommandLineInterface/CliBuilder.cs b/src/Sharpify.CommandLineInterface/CliBuilder.cs index 1f2c68d..984c3a7 100644 --- a/src/Sharpify.CommandLineInterface/CliBuilder.cs +++ b/src/Sharpify.CommandLineInterface/CliBuilder.cs @@ -1,156 +1,128 @@ -using Sharpify.CommandLineInterface.Completions; - namespace Sharpify.CommandLineInterface; /// /// Represents a builder for a CliRunner. /// public sealed class CliBuilder { - private readonly CliRunnerConfiguration _config; - - internal CliBuilder() { - _config = new CliRunnerConfiguration(); - } - - /// - /// Adds a command to the CLI runner. - /// - /// - /// The same instance of - public CliBuilder AddCommand(Command command) { - _config.Commands.Add(command); - return this; - } - - /// - /// Adds commands to the CLI runner. - /// - /// - /// The same instance of - public CliBuilder AddCommands(ReadOnlySpan commands) { - _config.Commands.AddRange(commands); - return this; - } - - /// - /// Sets the output writer for the CLI runner. - /// - /// - /// The same instance of - public CliBuilder SetOutputWriter(TextWriter writer) { - CliRunner.SetOutputWriter(writer); - return this; - } - - /// - /// Sets the output writer for the CLI runner to be . - /// - /// The same instance of - public CliBuilder UseConsoleAsOutputWriter() { - CliRunner.SetOutputWriter(Console.Out); - return this; - } - - /// - /// Sorts the commands alphabetically. - /// - /// - /// This change only affects the functionality of the help text. - /// - /// The same instance of - public CliBuilder SortCommandsAlphabetically() { - _config.SortCommandsAlphabetically = true; - return this; - } - - /// - /// Add metadata - can be used to generate the general help text (Is the default source) - /// - /// - /// Configure the help text source with - /// - /// The same instance of - public CliBuilder WithMetadata(Action options) { - options(_config.MetaData); - return this; - } - - /// - /// Add a custom header - can be used instead of Metadata in the header of the help text - /// - /// - /// Configure the help text source with - /// - /// The same instance of - public CliBuilder WithCustomHeader(string header) { - _config.CustomHeader = header; - return this; - } - - /// - /// Sets the source of the general help text. - /// - /// Requested source of the help text. - /// The same instance of - public CliBuilder SetHelpTextSource(HelpTextSource source) { - _config.HelpTextSource = source; - return this; - } - - /// - /// Configures how the parser handles argument casing. - /// - /// - /// By default it is set to to improve user experience - /// - /// The same instance of - public CliBuilder ConfigureArgumentCaseHandling(ArgumentCaseHandling caseHandling) { - _config.ArgumentCaseHandling = caseHandling; - return this; - } - - /// - /// Show error codes next to the error messages. - /// - /// The same instance of - public CliBuilder ShowErrorCodes() { - _config.ShowErrorCodes = true; - return this; - } - - /// - /// Configures how the CLI runner handles empty input. - /// - /// The same instance of - public CliBuilder ConfigureEmptyInputBehavior(EmptyInputBehavior behavior) { - _config.EmptyInputBehavior = behavior; - return this; - } - - /// - /// Configures the builder to generate shell completions - /// - /// - /// The rootname of the completion will be generated from , make to override it with - /// - /// - public CliBuilder GenerateShellCompletions(T[] providers) where T : ICompletionProvider { - if (providers.Length > 0) { - _config.CompletionsGenerator = async () => { - var tasks = providers.Select(p => p.GenerateAsync(_config.MetaData.Name, _config.Commands)); - await Task.WhenAll(tasks); - }; - } - return this; - } - - /// - /// Builds the CLI runner. - /// - public CliRunner Build() { - if (_config.Commands.Count is 0) { - throw new InvalidOperationException("No commands were added."); - } - return new CliRunner(_config); - } + private readonly CliRunnerConfiguration _config; + + internal CliBuilder() { + _config = new CliRunnerConfiguration(); + } + + /// + /// Adds a command to the CLI runner. + /// + /// + /// The same instance of + public CliBuilder AddCommand(Command command) { + _config.Commands.Add(command); + return this; + } + + /// + /// Adds commands to the CLI runner. + /// + /// + /// The same instance of + public CliBuilder AddCommands(ReadOnlySpan commands) { + _config.Commands.AddRange(commands); + return this; + } + + /// + /// Sets the output writer for the CLI runner. + /// + /// + /// The same instance of + public CliBuilder SetOutputWriter(TextWriter writer) { + CliRunner.SetOutputWriter(writer); + return this; + } + + /// + /// Sets the output writer for the CLI runner to be . + /// + /// The same instance of + public CliBuilder UseConsoleAsOutputWriter() { + CliRunner.SetOutputWriter(Console.Out); + return this; + } + + /// + /// Sorts the commands alphabetically. + /// + /// + /// This change only affects the functionality of the help text. + /// + /// The same instance of + public CliBuilder SortCommandsAlphabetically() { + _config.SortCommandsAlphabetically = true; + return this; + } + + /// + /// Add metadata - can be used to generate the general help text (Is the default source) + /// + /// + /// Configure the help text source with + /// + /// The same instance of + public CliBuilder WithMetadata(Action options) { + options(_config.MetaData); + return this; + } + + /// + /// Add a custom header - can be used instead of Metadata in the header of the help text + /// + /// + /// Configure the help text source with + /// + /// The same instance of + public CliBuilder WithCustomHeader(string header) { + _config.CustomHeader = header; + return this; + } + + /// + /// Sets the source of the general help text. + /// + /// Requested source of the help text. + /// The same instance of + public CliBuilder SetHelpTextSource(HelpTextSource source) { + _config.HelpTextSource = source; + return this; + } + + /// + /// Configures how the parser handles argument casing. + /// + /// + /// By default it is set to to improve user experience + /// + /// The same instance of + public CliBuilder ConfigureArgumentCaseHandling(ArgumentCaseHandling caseHandling) { + _config.ArgumentCaseHandling = caseHandling; + return this; + } + + /// + /// Show error codes next to the error messages. + /// + /// The same instance of + public CliBuilder ShowErrorCodes() { + _config.ShowErrorCodes = true; + return this; + } + + /// + /// Builds the CLI runner. + /// + public CliRunner Build() { + InternalException.ThrowIf(_config.Commands.Count is 0, "No commands were added to the builder."); + _config.Commands.Add(new HelpCommand(_config)); + _config.Commands.Add(new VersionCommand(_config.MetaData)); + return new CliRunner(_config); + } } \ No newline at end of file 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 diff --git a/src/Sharpify.CommandLineInterface/CliRunner.cs b/src/Sharpify.CommandLineInterface/CliRunner.cs index b920576..84b97bc 100644 --- a/src/Sharpify.CommandLineInterface/CliRunner.cs +++ b/src/Sharpify.CommandLineInterface/CliRunner.cs @@ -1,5 +1,5 @@ using System.Collections.ObjectModel; -using System.Text; +using System.Runtime.InteropServices; namespace Sharpify.CommandLineInterface; @@ -7,186 +7,114 @@ namespace Sharpify.CommandLineInterface; /// Provides the means of running a CLI and configuring package wide settings. /// public sealed class CliRunner { - /// - /// Creates a new instance of the class. - /// - public static CliBuilder CreateBuilder() => new(); - - /// - /// Gets the commands registered with the CLI runner. - /// - public ReadOnlyCollection Commands => _config.Commands.AsReadOnly(); - - /// - /// Gets the output writer for the CLI runner. - /// - /// Defaults to - public static TextWriter OutputWriter { get; private set; } = TextWriter.Null; - - /// - /// Sets the output writer for the CLI runner. - /// - public static void SetOutputWriter(TextWriter writer) { - OutputWriter = writer; - } - - private readonly CliRunnerConfiguration _config; - - /// - /// Creates a new instance of the class. - /// - /// To be used with the - internal CliRunner(CliRunnerConfiguration config) { - _config = config; - // If there is only one command, sorting is not necessary - if (_config.SortCommandsAlphabetically && _config.Commands.Count is not 1) { - _config.Commands.Sort(Command.ByNameComparer); - } - } - - /// - /// Runs the CLI application with the specified arguments. - /// - public ValueTask RunAsync(ReadOnlySpan args, bool commandNameRequired = true) { - // Handle no input - if (args.Length is 0) { - // If display help text is used, always display the help text - if (_config.EmptyInputBehavior is EmptyInputBehavior.DisplayHelpText) { - return OutputHelper.Return(GenerateHelpText(commandNameRequired), 0); - } - // We assume the need to attempt to proceed - if (commandNameRequired || _config.Commands.Count is not 1) { - // In this case, input is required - return OutputHelper.Return("No command specified", 404, _config.ShowErrorCodes); - } - return _config.Commands[0].ExecuteAsync(Arguments.Empty); - } - var arguments = Parser.ParseArguments(args, _config.GetComparer()); - return RunAsync(arguments, commandNameRequired); - } - - /// - /// Runs the CLI application with the specified arguments. - /// - public ValueTask RunAsync(IList args, bool commandNameRequired = true) { - // Handle no input - if (args.Count is 0) { - // If display help text is used, always display the help text - if (_config.EmptyInputBehavior is EmptyInputBehavior.DisplayHelpText) { - return OutputHelper.Return(GenerateHelpText(commandNameRequired), 0); - } - // We assume the need to attempt to proceed - if (commandNameRequired || _config.Commands.Count is not 1) { - // In this case, input is required - return OutputHelper.Return("No command specified", 404, _config.ShowErrorCodes); - } - return _config.Commands[0].ExecuteAsync(Arguments.Empty); + /// + /// Creates a new instance of the class. + /// + public static CliBuilder CreateBuilder() => new(); + + /// + /// Gets the commands registered with the CLI runner. + /// + public ReadOnlyCollection Commands => _config.Commands.AsReadOnly(); + + /// + /// Gets the output writer for the CLI runner. + /// + /// Defaults to + public static TextWriter OutputWriter { get; private set; } = TextWriter.Null; + + /// + /// Sets the output writer for the CLI runner. + /// + public static void SetOutputWriter(TextWriter writer) { + OutputWriter = writer; + } + + private readonly CliRunnerConfiguration _config; + + /// + /// Creates a new instance of the class. + /// + /// To be used with the + internal CliRunner(CliRunnerConfiguration config) { + _config = config; + // If there is only one command, sorting is not necessary + if (_config.SortCommandsAlphabetically) { + _config.Commands.Sort(Command.ByNameComparer); + } + } + + /// + /// Runs the CLI application with the specified arguments. + /// + public ValueTask RunAsync(ReadOnlySpan args) { + var arguments = Parser.ParseArguments(args, _config.GetComparer()); + return RunAsync(arguments); + } + + /// + /// Runs the CLI application with the specified arguments. + /// + public ValueTask RunAsync(IList args) { + var arguments = Parser.ParseArguments(args, _config.GetComparer()); + return RunAsync(arguments); + } + + /// + /// Runs the CLI application with the specified arguments. + /// + /// + /// should never be , if required, use + /// + public ValueTask RunAsync(Arguments arguments) { + try { + InternalException.ThrowIf(arguments is null, "arguments cannot be null."); + + // Global help/version handling: supports both first-token form ("help") and flag form ("--help") + if (arguments!.IsFirstOrFlag(["h", "help"])) { + return InvokeByNameAsync(_config.Commands, "Help", _config.GetComparer(), Arguments.Empty); + } + if (arguments.IsFirstOrFlag(["v", "version"])) { + return InvokeByNameAsync(_config.Commands, "Version", _config.GetComparer(), Arguments.Empty); + } + + // Try resolve the first positional token as a command name + if (arguments.TryGetValue(0, out string commandName)) { + var command = FindByName(_config.Commands, commandName, _config.GetComparer()); + if (command is not null) { + // Support per-command help: "cmd --help" + if (arguments.HasFlag(["h", "help"])) { + return OutputHelper.Return(command.GetHelp(), 0); + } + return command.ExecuteAsync(arguments.ForwardPositionalArguments()); + } + } + + // Fallback: default-route if there is exactly one user command + var sole = _config.Commands.SoleUserCommand(); + if (sole is not null) { + return sole.ExecuteAsync(arguments); + } + + return InvokeByNameAsync(_config.Commands, "Help", _config.GetComparer(), Arguments.Empty); + } catch (InternalException e) { + return ValueTask.FromResult(e.WriteToOutput()); } - var arguments = Parser.ParseArguments(args, _config.GetComparer()); - return RunAsync(arguments, commandNameRequired); - } - - /// - /// Runs the CLI application with the specified arguments. - /// - public ValueTask RunAsync(Arguments? arguments, bool commandNameRequired = true) { - if (arguments is null or { Count: 0 }) { - return OutputHelper.Return("Input could not be parsed", 400, _config.ShowErrorCodes); - } - - string version = $"Version: {_config.MetaData.Version}"; // cache version - - // general help text - if (arguments.IsFirstOrFlag("help")) { - return OutputHelper.Return(GenerateHelpText(commandNameRequired), 0); - } - if (arguments.IsFirstOrFlag("version")) { - return OutputHelper.Return(version, 0); - } - - // Only for single command CLIs - if (!commandNameRequired) { - // If there is more than one command, the command name is required - if (_config.Commands.Count is not 1) { - return OutputHelper.Return("Command name is required when using more than one command", 405, _config.ShowErrorCodes); - } - // Execute the command - return _config.Commands[0].ExecuteAsync(arguments); - } - - if (!arguments.TryGetValue(0, out string commandName)) { - return OutputHelper.Return("Command name is required", 405, _config.ShowErrorCodes); - } - - Command? command = _config.Commands.FirstOrDefault(c => _config.GetComparer().Equals(c.Name, commandName)); - if (command == default) { - return OutputHelper.Return($"Command \"{commandName}\" not found.", 404, _config.ShowErrorCodes); - } - - if (arguments.Contains("help") || arguments.HasFlag("help")) { - return OutputHelper.Return(command.GetHelp(), 0); - } - return command.ExecuteAsync(arguments.ForwardPositionalArguments()); - } - - // Generates the help for the application - happens once, at initialization of CliRunner - private string GenerateHelpText(bool commandNameRequired) { - //TODO: find better placement? - // generate completions - _config.CompletionsGenerator?.Invoke().Wait(); - - // generate help text - StringBuilder builder = new(GetRequiredBufferLength()); - builder.AppendLine(); - if (_config.HelpTextSource is HelpTextSource.Metadata) { - var metaData = _config.MetaData; - builder.AppendLine(metaData.Name) - .AppendLine() - .AppendLine(metaData.Description) - .Append("Author: ") - .AppendLine(metaData.Author) - .Append("Version: ") - .AppendLine(metaData.Version) - .Append("License: ") - .AppendLine(metaData.License) - .AppendLine(); - } else if (_config.HelpTextSource is HelpTextSource.CustomHeader) { - builder.AppendLine(_config.CustomHeader) - .AppendLine(); - } - if (commandNameRequired) { - builder.AppendLine("Commands:"); - var maxCommandLength = GetMaximumCommandLength() + 2; - foreach (Command command in _config.Commands) { - builder.Append(command.Name.PadRight(maxCommandLength)) - .Append(" - ") - .AppendLine(command.Description); - } - builder.Append( - """ - - To get help for a command, use: " --help" - To get help for the application, use: "--help" - - """ - ); - } else { - var command = _config.Commands[0]; - builder.Append("Usage: ") - .AppendLine(command.Usage); - } - - return builder.ToString(); - } - - private int GetMaximumCommandLength() => _config.Commands.Max(c => c.Name.Length); - - private int GetRequiredBufferLength() { - int length = (_config.Commands.Count + 5) * 256; // default buffer for commands and possible extra text - return _config.HelpTextSource switch { - HelpTextSource.Metadata => length + _config.MetaData.TotalLength, - HelpTextSource.CustomHeader => length + _config.CustomHeader.Length, - _ => length - }; + } + + private static ValueTask InvokeByNameAsync(List commands, string name, StringComparer comparer, Arguments args) { + var command = FindByName(commands, name, comparer); + InternalException.ThrowIf(command is null, $"Command {name} could not be found."); + return command!.ExecuteAsync(args); + } + + private static Command? FindByName(List commands, string name, StringComparer comparer) { + ReadOnlySpan span = CollectionsMarshal.AsSpan(commands); + for (int i = 0; i < span.Length; i++) { + if (comparer.Equals(span[i].Name, name)) { + return span[i]; + } + } + return null; } } \ No newline at end of file diff --git a/src/Sharpify.CommandLineInterface/CliRunnerConfiguration.cs b/src/Sharpify.CommandLineInterface/CliRunnerConfiguration.cs index bb1e315..6349c28 100644 --- a/src/Sharpify.CommandLineInterface/CliRunnerConfiguration.cs +++ b/src/Sharpify.CommandLineInterface/CliRunnerConfiguration.cs @@ -4,54 +4,53 @@ 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; + + /// + /// Returns the associated with the . + /// + public StringComparer GetComparer() + => ArgumentCaseHandling switch { + ArgumentCaseHandling.CaseSensitive => StringComparer.Ordinal, + _ => StringComparer.OrdinalIgnoreCase + }; } \ No newline at end of file diff --git a/src/Sharpify.CommandLineInterface/Command.cs b/src/Sharpify.CommandLineInterface/Command.cs index a3f0de5..299e74b 100644 --- a/src/Sharpify.CommandLineInterface/Command.cs +++ b/src/Sharpify.CommandLineInterface/Command.cs @@ -19,10 +19,12 @@ public abstract class Command { /// public abstract string Usage { get; } - /// - /// Executes the command. - /// - public abstract ValueTask ExecuteAsync(Arguments args); + internal virtual bool IsSystemCommand { get; } + + /// + /// Executes the command. + /// + public abstract ValueTask ExecuteAsync(Arguments args); /// /// Gets the help for the command. @@ -42,17 +44,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); +} diff --git a/src/Sharpify.CommandLineInterface/CommandLineInterfaceException.cs b/src/Sharpify.CommandLineInterface/CommandLineInterfaceException.cs new file mode 100644 index 0000000..bc25558 --- /dev/null +++ b/src/Sharpify.CommandLineInterface/CommandLineInterfaceException.cs @@ -0,0 +1,19 @@ +global using InternalException = Sharpify.CommandLineInterface.CommandLineInterfaceException; + +namespace Sharpify.CommandLineInterface; + +internal sealed class CommandLineInterfaceException : Exception { + public CommandLineInterfaceException(string? message) : base(message) { + } + + public int WriteToOutput() { + OutputHelper.WriteLine(Message); + return 1; + } + + public static void ThrowIf(bool condition, string message) { + if (condition) { + throw new InternalException(message); + } + } +} \ No newline at end of file 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/