diff --git a/.editorconfig b/.editorconfig index 02015f8..30024f0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -77,13 +77,17 @@ dotnet_remove_unnecessary_suppression_exclusions = none #### C# Coding Conventions #### [*.cs] # analyzers -dotnet_diagnostic.IDE0290.severity = none # use primary constuctor +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 decleration + +# namespace declaration csharp_style_namespace_declarations = file_scoped:warning # var preferences @@ -369,4 +373,4 @@ 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_casel \ No newline at end of file +dotnet_naming_style.s_camelcase.capitalization = camel_case \ No newline at end of file diff --git a/.github/workflows/Tests.yaml b/.github/workflows/Tests.yaml index bf1b6f9..dc174c8 100644 --- a/.github/workflows/Tests.yaml +++ b/.github/workflows/Tests.yaml @@ -1,136 +1,18 @@ name: Tests on: - push: pull_request: workflow_dispatch: jobs: - test-sharpify: - runs-on: ${{ matrix.os }} + unit-tests: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - configuration: [Debug, Release] - - env: - # Define the path to project and test project - PROJECT: src/Sharpify/Sharpify.csproj - TEST_PROJECT: tests/Sharpify.Tests/Sharpify.Tests.csproj - - steps: - # 1. Checkout the repository code - - name: Checkout Repository - uses: actions/checkout@v4 - - # 2. Cache NuGet packages - - name: Cache NuGet Packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-nuget- - - # 3. Setup .NET - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - # 4. Clean - - name: Clean - run: | - dotnet clean ${{ env.PROJECT }} -c ${{ matrix.configuration }} - dotnet clean ${{ env.TEST_PROJECT }} -c ${{ matrix.configuration }} - - # 5. Run Unit Tests - - name: Run Unit Tests - run: dotnet test ${{ env.TEST_PROJECT }} -c ${{ matrix.configuration }} - - test-sharpify-data: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - configuration: [Debug, Release] - - env: - # Define the path to project and test project - PROJECT: src/Sharpify.Data/Sharpify.Data.csproj - TEST_PROJECT: tests/Sharpify.Data.Tests/Sharpify.Data.Tests.csproj - - steps: - # 1. Checkout the repository code - - name: Checkout Repository - uses: actions/checkout@v4 - - # 2. Cache NuGet packages - - name: Cache NuGet Packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-nuget- - - # 3. Setup .NET - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - # 4. Clean - - name: Clean - run: | - dotnet clean ${{ env.PROJECT }} -c ${{ matrix.configuration }} - dotnet clean ${{ env.TEST_PROJECT }} -c ${{ matrix.configuration }} - - # 5. Run Unit Tests - - name: Run Unit Tests - run: dotnet test ${{ env.TEST_PROJECT }} -c ${{ matrix.configuration }} - - test-sharpify-cli: - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - configuration: [Debug, Release] - - env: - # Define the path to project and test project - PROJECT: src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj - TEST_PROJECT: tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj - - steps: - # 1. Checkout the repository code - - name: Checkout Repository - uses: actions/checkout@v4 - - # 2. Cache NuGet packages - - name: Cache NuGet Packages - uses: actions/cache@v4 - with: - path: ~/.nuget/packages - key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} - restore-keys: | - ${{ runner.os }}-nuget- - - # 3. Setup .NET - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - # 4. Clean - - name: Clean - run: | - dotnet clean ${{ env.PROJECT }} -c ${{ matrix.configuration }} - dotnet clean ${{ env.TEST_PROJECT }} -c ${{ matrix.configuration }} - - # 5. Run Unit Tests - - name: Run Unit Tests - run: dotnet test ${{ env.TEST_PROJECT }} -c ${{ matrix.configuration }} \ No newline at end of file + platform: [ubuntu-latest, windows-latest, macos-latest] + project: [tests/Sharpify.Tests/Sharpify.Tests.csproj, tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj] + uses: dusrdev/actions/.github/workflows/reusable-dotnet-test-mtp.yaml@main + with: + platform: ${{ matrix.platform }} + dotnet-version: 9.0.x + test-project-path: ${{ matrix.project }} \ No newline at end of file diff --git a/README.md b/README.md index 1fe30df..1ad8dfa 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ A collection of high performance language extensions for C#, fully compatible wi [![Nuget](https://img.shields.io/nuget/dt/Sharpify.Data?label=Sharpify.Data%20Nuget%20Downloads)](https://www.nuget.org/packages/Sharpify.Data/) > dotnet add package Sharpify.Data +* `Sharpify.Data` is deprecated and will no longer be maintained. Refer to [ArrowDb](https://github.com/dusrdev/ArrowDb) for a superior alternative. + [![Nuget](https://img.shields.io/nuget/dt/Sharpify.CommandLineInterface?label=Sharpify.CommandLineInterface%20Nuget%20Downloads)](https://www.nuget.org/packages/Sharpify.CommandLineInterface/) > dotnet add package Sharpify.CommandLineInterface @@ -57,12 +59,12 @@ For more information check [inner directory](src/Sharpify.Data/README.md). ## Sharpify.CommandLineInterface -`Sharpify.CommandLineInterface` is another extension package that adds a high performance, reflection free and `AOT-ready` framework for creating command line and embedded interfaces +`Sharpify.CommandLineInterface` is a standalone package that adds a high performance, reflection free and `AOT-ready` framework for creating command line and embedded interfaces -* Maintenance friendly model that depends on class that implement `Command` or `SynchronousCommand` +* Maintenance friendly model that depends on classes that implement `Command` or `SynchronousCommand` * `Arguments` is an abstraction layer over the inputs that validate during runtime according to user needs via convenient APIs. * Configuration using a fluent builder pattern. -* Configurable output and input pipes, enable usage outside of `Console` apps, enabling the option for embedded use in any application. +* Configurable output and input pipes, enable usage outside of `Console` apps, supporting embedded use in any application. * Automatic and structured general and command-specific help text. * Configurable error handling with defaults. * Super lightweight diff --git a/Sharpify.sln b/Sharpify.sln deleted file mode 100644 index 29ae411..0000000 --- a/Sharpify.sln +++ /dev/null @@ -1,59 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.33516.290 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sharpify", "src\Sharpify\Sharpify.csproj", "{7E6960CB-A0F2-4AE4-B383-98763AB03E75}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sharpify.Data", "src\Sharpify.Data\Sharpify.Data.csproj", "{13E844B7-D575-489D-B1E4-97F30B948227}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sharpify.CommandLineInterface", "src\Sharpify.CommandLineInterface\Sharpify.CommandLineInterface.csproj", "{85B4CC1F-D9F3-4EE7-BB8D-3A2FBA3CB52A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sharpify.Tests", "tests\Sharpify.Tests\Sharpify.Tests.csproj", "{39884502-0357-4A6D-A03E-02D5BE7B0BFF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sharpify.Data.Tests", "tests\Sharpify.Data.Tests\Sharpify.Data.Tests.csproj", "{D7749972-F24B-426D-B46C-D5949C4DCFD5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sharpify.CommandLineInterface.Tests", "tests\Sharpify.CommandLineInterface.Tests\Sharpify.CommandLineInterface.Tests.csproj", "{C653A5E1-39AA-4C0C-A5A7-CB735C1DCDB4}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {37D56CBF-AD80-4361-8F43-568A93FB1D42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {37D56CBF-AD80-4361-8F43-568A93FB1D42}.Debug|Any CPU.Build.0 = Debug|Any CPU - {37D56CBF-AD80-4361-8F43-568A93FB1D42}.Release|Any CPU.ActiveCfg = Release|Any CPU - {37D56CBF-AD80-4361-8F43-568A93FB1D42}.Release|Any CPU.Build.0 = Release|Any CPU - {7E6960CB-A0F2-4AE4-B383-98763AB03E75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7E6960CB-A0F2-4AE4-B383-98763AB03E75}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7E6960CB-A0F2-4AE4-B383-98763AB03E75}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7E6960CB-A0F2-4AE4-B383-98763AB03E75}.Release|Any CPU.Build.0 = Release|Any CPU - {13E844B7-D575-489D-B1E4-97F30B948227}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {13E844B7-D575-489D-B1E4-97F30B948227}.Debug|Any CPU.Build.0 = Debug|Any CPU - {13E844B7-D575-489D-B1E4-97F30B948227}.Release|Any CPU.ActiveCfg = Release|Any CPU - {13E844B7-D575-489D-B1E4-97F30B948227}.Release|Any CPU.Build.0 = Release|Any CPU - {85B4CC1F-D9F3-4EE7-BB8D-3A2FBA3CB52A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {85B4CC1F-D9F3-4EE7-BB8D-3A2FBA3CB52A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {85B4CC1F-D9F3-4EE7-BB8D-3A2FBA3CB52A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {85B4CC1F-D9F3-4EE7-BB8D-3A2FBA3CB52A}.Release|Any CPU.Build.0 = Release|Any CPU - {39884502-0357-4A6D-A03E-02D5BE7B0BFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {39884502-0357-4A6D-A03E-02D5BE7B0BFF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {39884502-0357-4A6D-A03E-02D5BE7B0BFF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {39884502-0357-4A6D-A03E-02D5BE7B0BFF}.Release|Any CPU.Build.0 = Release|Any CPU - {D7749972-F24B-426D-B46C-D5949C4DCFD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D7749972-F24B-426D-B46C-D5949C4DCFD5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D7749972-F24B-426D-B46C-D5949C4DCFD5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D7749972-F24B-426D-B46C-D5949C4DCFD5}.Release|Any CPU.Build.0 = Release|Any CPU - {C653A5E1-39AA-4C0C-A5A7-CB735C1DCDB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C653A5E1-39AA-4C0C-A5A7-CB735C1DCDB4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C653A5E1-39AA-4C0C-A5A7-CB735C1DCDB4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C653A5E1-39AA-4C0C-A5A7-CB735C1DCDB4}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {E9922EF9-146A-40B4-B612-F9C6BB5D9F64} - EndGlobalSection -EndGlobal diff --git a/Sharpify.slnx b/Sharpify.slnx new file mode 100644 index 0000000..808ae74 --- /dev/null +++ b/Sharpify.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/build.sh b/build.sh deleted file mode 100644 index 5aeaeda..0000000 --- a/build.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -if [ $# != 2 ]; then - echo "Usage: $0 " - exit 1 -fi - -project_name=$1 -snk_path=$2 - -currentDir=$(pwd) - -navigateAndBuild() { - cd $1 # Navigate to the project directory - dotnet clean -c Release - dotnet build -c Release -p:SignAssembly="$snk_path" - cd $currentDir # Navigate back to the root directory -} - -if [ $project_name == "main" ]; then - navigateAndBuild src/Sharpify/ -elif [ $project_name == "data" ]; then - navigateAndBuild src/Sharpify.Data/ -elif [ $project_name == "cli" ]; then - navigateAndBuild src/Sharpify.CommandLineInterface/ -fi \ No newline at end of file diff --git a/src/Sharpify.CommandLineInterface/ArgumentsCore.cs b/src/Sharpify.CommandLineInterface/ArgumentsCore.cs index d1be83c..2319aaa 100644 --- a/src/Sharpify.CommandLineInterface/ArgumentsCore.cs +++ b/src/Sharpify.CommandLineInterface/ArgumentsCore.cs @@ -9,7 +9,10 @@ namespace Sharpify.CommandLineInterface; /// Arguments instances are created via /// public sealed partial class Arguments { - private readonly string[] _args; + /// + /// Source is the list of separated arguments on top of which this instance of was built. + /// + public readonly ReadOnlyCollection Source; private readonly Dictionary _arguments; /// @@ -17,8 +20,8 @@ public sealed partial class Arguments { /// /// Copy or reference of the arguments before processing /// Ensure not null or empty - internal Arguments(string[] args, Dictionary arguments) { - _args = args; + internal Arguments(ReadOnlyCollection args, Dictionary arguments) { + Source = args; _arguments = arguments; } @@ -35,33 +38,12 @@ internal Arguments(string[] args, Dictionary arguments) { /// /// Returns an empty arguments object. /// - public static readonly Arguments Empty = new([], []); + public static readonly Arguments Empty = new(Array.Empty().AsReadOnly(), []); /// - /// Returns a of the arguments as they were before processing, but after splitting (if it was required) + /// Returns an array copy of /// - /// - /// - /// If you passed a collection of strings to be used for it will contain a copy of that array, if a was passed, it will contain a copy of the result of - /// - /// - /// In normal use case you shouldn't need this, but in case you want to manufacture some sort of a nested command structure, you can use this to filter once more for after selectively parsing some of the arguments, in which case it is very powerful. - /// - /// - public ReadOnlyMemory ArgsAsMemory() => _args; - - /// - /// Returns a of the arguments as they were before processing, but after splitting (if it was required) - /// - /// - /// - /// If you passed a collection of strings to be used for it will contain a copy of that array, if a was passed, it will contain a copy of the result of - /// - /// - /// In normal use case you shouldn't need this, but in case you want to manufacture some sort of a nested command structure, you can use this to filter once more for after selectively parsing some of the arguments, in which case it is very powerful. - /// - /// - public ReadOnlySpan ArgsAsSpan() => _args; + public string[] SourceCopy => Source.ToArray(); /// /// Returns new Arguments with positional arguments forwarded by 1, so that argument that was 1 is now 0, 2 is now 1 and so on. This is non-destructive, the original arguments are not modified. @@ -94,7 +76,7 @@ public Arguments ForwardPositionalArguments() { // Because this is a new dictionary, if pos 1, isn't found, 0 still won't be present // So essentially 0 was forwarded to no longer exist - return new Arguments(_args, dict); + return new Arguments(Source, dict); } /// diff --git a/src/Sharpify.CommandLineInterface/CHANGELOG.md b/src/Sharpify.CommandLineInterface/CHANGELOG.md index 0c74ac7..7cd7adf 100644 --- a/src/Sharpify.CommandLineInterface/CHANGELOG.md +++ b/src/Sharpify.CommandLineInterface/CHANGELOG.md @@ -1,40 +1,58 @@ # CHANGELOG +## Version 2.0.0 + +**WARNING:** This release may contain breaking changes. + +- The `Arguments` source collection has been rewritten as a `ReadOnlyCollection`, which cascaded into numerous changes: + - `Parser.ParseArguments` (collection-based overloads) now takes a generic `IList`. This is converted internally to a `ReadOnlyCollection`, which is used as the source for `Arguments`. + - As a result, `Parser.Split` now returns a `List`. + - The `Parser.SplitToList` method was removed, as it is no longer needed. + - `Arguments.ArgsAsMemory` and `Arguments.ArgsAsSpan` were also removed. To inspect the source, use `Arguments.Source` or obtain a copy as a `string[]` with `Arguments.SourceCopy`. + - The `CliRunner.RunAsync` overload that previously accepted a `ReadOnlySpan` now accepts an `IList` instead. This allows implicit casting from both `string[]` and `List`, which are the most common CLI inputs. +- `HelpText` generators now use a `StringBuilder` internally, replacing the previous custom buffer. Since help text generation typically occurs only once during a CLI's lifetime, any potential performance impact is minimal. This also removes some logical size restraints. +- Removed `Microsoft.SourceLink.Github` as it is now used implicitly. + +**These changes enable several improvements:** + +- `Sharpify` is no longer a required dependency of this package and has been removed. This package can now be installed as a standalone. +- Creating an `Arguments` object with `Parser.ParseArguments` is now much simpler. You can use it directly without commands to create minimal CLIs in `Program.cs`. This will be particularly useful with the upcoming `.NET 10` feature allowing direct execution of `.cs` files. (A demo video with examples and best practices will be released when this feature is available.) + ## Version 1.5.0 -* Updated to support NET9 with `Sharpify` 2.5.0 -* Optimized path of `Arguments` forwarding when no positional arguments are present. +- Updated to support NET9 with `Sharpify` 2.5.0 +- Optimized path of `Arguments` forwarding when no positional arguments are present. ## Version 1.4.0 -* Optimized `Parser`: - * `Split` now rents a buffer the array pool by itself and returns a `RentedBufferWriter`, this enables greater flexibility in usage, and simplifies the code. - * Changed lower level array allocation code to use generalized api to optimize on more platforms. -* `Arguments.TryGetValue` and `Arguments.TryGetValues` now have overloads that accept a `ReadOnlySpan keys`, this overload enables much simpler retrieval of parameters that have aliases, for example you might want something like `--name` and `-n` to map to the same value. - * If you specify the aliases using the collections expression (i.e `["one", "two"]`), since .NET 8, the compiler will generate an inline array for that, which is very efficient, you don't need to create an array yourself. but if you wanted to to you could for example create a `static readonly ReadOnlySpan aliases => new[] { "one", "two" };` and pass that instead, the compiler optimizes such case by writing the values directly in the assembly. -* `CliBuilder` now has an option to configure arguments case sensitivity using `ConfigureArgumentsCaseHandling`, by default arguments are case insensitive to prioritize user experience. however, if you want to have parameters that are case sensitive, for instances where you need more short flags like `grep` you can opt in for this feature by setting it to be case sensitive. -* `CliBuilder` can now configure how to handle empty inputs with `ConfigureEmptyInputBehavior`, by default it will display the help text and exit, but you can configure it to attempt 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. - * This is a change in behavior, as previously by default an error showing that no command was found was displayed, but seems that showing the help text in those situations is the more common approach in modern CLIs. -* Updated parsing to detect cases where arguments start with `-` and are not names of arguments, for example if you required a positional argument of type `int` and the input was a negative number (also starts with `-`), it would've been interpreted as a named argument, now it will be correctly interpreted as a positional argument. - * The rule now also checks if the first character following a `-` is a digit, if it is, it will not be marked as named argument. Which means - don't use argument names that start with digits (this is a bad practice in general). -* Help text no contains a special case for "version" and "--version" that will just display the version from metadata. - * Help text (from main) now has specialized structure for cases where you only have one command, instead of printing commands and descriptions, it will print the single command usage - the rest will of the whole cli (metadata) -* To support `--version` and add more customization options, now `Metadata` and `CustomHeader` are independent, and you can configure which is used for help text with `SetHelpTextSource(HelpTextSource)`. `Metadata` will be used by default. -* The help text portion that used to display instruction to get help text is now shorter and more concise. -* `Arguments` now has overload to directly get the values that correspond to `TryGetValue` overloads with default values, since defaults values can be returned if no key was found or failed to be parsed, In some case the actual reason is not important and only the value is needed so we now have `GetValue` for this exact reason. +- Optimized `Parser`: + - `Split` now rents a buffer the array pool by itself and returns a `RentedBufferWriter`, this enables greater flexibility in usage, and simplifies the code. + - Changed lower level array allocation code to use generalized api to optimize on more platforms. +- `Arguments.TryGetValue` and `Arguments.TryGetValues` now have overloads that accept a `ReadOnlySpan keys`, this overload enables much simpler retrieval of parameters that have aliases, for example you might want something like `--name` and `-n` to map to the same value. + - If you specify the aliases using the collections expression (i.e `["one", "two"]`), since .NET 8, the compiler will generate an inline array for that, which is very efficient, you don't need to create an array yourself. but if you wanted to to you could for example create a `static readonly ReadOnlySpan aliases => new[] { "one", "two" };` and pass that instead, the compiler optimizes such case by writing the values directly in the assembly. +- `CliBuilder` now has an option to configure arguments case sensitivity using `ConfigureArgumentsCaseHandling`, by default arguments are case insensitive to prioritize user experience. however, if you want to have parameters that are case sensitive, for instances where you need more short flags like `grep` you can opt in for this feature by setting it to be case sensitive. +- `CliBuilder` can now configure how to handle empty inputs with `ConfigureEmptyInputBehavior`, by default it will display the help text and exit, but you can configure it to attempt 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. + - This is a change in behavior, as previously by default an error showing that no command was found was displayed, but seems that showing the help text in those situations is the more common approach in modern CLIs. +- Updated parsing to detect cases where arguments start with `-` and are not names of arguments, for example if you required a positional argument of type `int` and the input was a negative number (also starts with `-`), it would've been interpreted as a named argument, now it will be correctly interpreted as a positional argument. + - The rule now also checks if the first character following a `-` is a digit, if it is, it will not be marked as named argument. Which means - don't use argument names that start with digits (this is a bad practice in general). +- Help text no contains a special case for "version" and "--version" that will just display the version from metadata. + - Help text (from main) now has specialized structure for cases where you only have one command, instead of printing commands and descriptions, it will print the single command usage - the rest will of the whole cli (metadata) +- To support `--version` and add more customization options, now `Metadata` and `CustomHeader` are independent, and you can configure which is used for help text with `SetHelpTextSource(HelpTextSource)`. `Metadata` will be used by default. +- The help text portion that used to display instruction to get help text is now shorter and more concise. +- `Arguments` now has overload to directly get the values that correspond to `TryGetValue` overloads with default values, since defaults values can be returned if no key was found or failed to be parsed, In some case the actual reason is not important and only the value is needed so we now have `GetValue` for this exact reason. ## Version 1.3.0 -* `Arguments` now contains new methods `TryGetValues` and `TryGetValues{T}` to get arrays from values, there are overloads for regular and positional arguments, each overload requires a `string? separator` that is used to split the value, as with te regular values, `T` needs to implement `IParsable{T}`. -* `CliBuilder` now has a method `ShowErrorCodes` that will enable the error codes next to `CliRunner` error outputs, that was previously enabled by default, now it will hide them by default to provide a cleaner experience for users, but the builder now can easily configure this for testing, or if you still want the user to see them. +- `Arguments` now contains new methods `TryGetValues` and `TryGetValues{T}` to get arrays from values, there are overloads for regular and positional arguments, each overload requires a `string? separator` that is used to split the value, as with te regular values, `T` needs to implement `IParsable{T}`. +- `CliBuilder` now has a method `ShowErrorCodes` that will enable the error codes next to `CliRunner` error outputs, that was previously enabled by default, now it will hide them by default to provide a cleaner experience for users, but the builder now can easily configure this for testing, or if you still want the user to see them. ## Version 1.2.2 -* Rewritten core function of argument forwarding to fix issue that caused non-positional arguments to be removed, now named arguments and flags should not be affected by positional forwarding at all. - * Important note: the `Args` array that is stored within the `Arguments` object, is never modified and no matter how many positional forwarding iterations have been executed, it maintains the original arguments. -* Added `Arguments.HasFlag(string)` method that could be used to specifically checks for flags. - * Previously `Arguments.Contains(string)` could be used for this purpose, but it could also return `true` for a named argument, effectively allowing a false-positive. `HasFlag` prevents this by checking that if it exists, the value is empty, which could only be the case for flags. -* Increased buffer size for help-text generation to prevents issues with complex clis. +- Rewritten core function of argument forwarding to fix issue that caused non-positional arguments to be removed, now named arguments and flags should not be affected by positional forwarding at all. + - Important note: the `Args` array that is stored within the `Arguments` object, is never modified and no matter how many positional forwarding iterations have been executed, it maintains the original arguments. +- Added `Arguments.HasFlag(string)` method that could be used to specifically checks for flags. + - Previously `Arguments.Contains(string)` could be used for this purpose, but it could also return `true` for a named argument, effectively allowing a false-positive. `HasFlag` prevents this by checking that if it exists, the value is empty, which could only be the case for flags. +- Increased buffer size for help-text generation to prevents issues with complex clis. ### Usage Note @@ -44,53 +62,53 @@ This means that you can create objects for the nested commands, inside the top l ## Version 1.2.1 -* Updated core to use `Sharpify` 2.0.0 -* small optimizations +- Updated core to use `Sharpify` 2.0.0 +- small optimizations ## Version 1.2.0 -* `Arguments`'s internal copy of the parsed args is now an array, this change was necessary to avoid special cases where the backing array was garbage collected leaving a phantom view. To get a read only copy you can use `.ArgsAsSpan` or `.ArgsAsMemory` according to your preference or use case. -* Improved `Parser`'s mapping function's stability, and also further reworked it to allow positional arguments after named ones, now positional arguments can be anywhere. - * A special case that needs consideration before usage is switches, i.e boolean toggle parameters, as they look like named parameters without values. If such "switch" is followed by a regular value, it will be regarded as a named parameter and its value, as opposed to a switch and a positional argument. Keep this in mind when you decide the arrangement of input arguments, to ensure your input works as intended. - * Switches work well, either when they are followed by other named arguments, or other switches. For simplicity, it is best to leave them as the last arguments. -* Added a new `SynchronousCommand` as an alternative to `Command`, it is basically syntactic sugar that makes it so you can implement an `Execute` method instead, in which you can return an `int`, when `async` is not needed, this can save multiple lines of code that just wrap `int`s in `ValueTask.FromResult` which can be quite verbose. +- `Arguments`'s internal copy of the parsed args is now an array, this change was necessary to avoid special cases where the backing array was garbage collected leaving a phantom view. To get a read only copy you can use `.ArgsAsSpan` or `.ArgsAsMemory` according to your preference or use case. +- Improved `Parser`'s mapping function's stability, and also further reworked it to allow positional arguments after named ones, now positional arguments can be anywhere. + - A special case that needs consideration before usage is switches, i.e boolean toggle parameters, as they look like named parameters without values. If such "switch" is followed by a regular value, it will be regarded as a named parameter and its value, as opposed to a switch and a positional argument. Keep this in mind when you decide the arrangement of input arguments, to ensure your input works as intended. + - Switches work well, either when they are followed by other named arguments, or other switches. For simplicity, it is best to leave them as the last arguments. +- Added a new `SynchronousCommand` as an alternative to `Command`, it is basically syntactic sugar that makes it so you can implement an `Execute` method instead, in which you can return an `int`, when `async` is not needed, this can save multiple lines of code that just wrap `int`s in `ValueTask.FromResult` which can be quite verbose. ## Version 1.1.0 ### Changes to `CliBuilder` -* `DoNotIncludeMetadataInHelpText` was removed, instead it will not be included by default. `ModifyMetadata` was renamed to `WithMetadata` and if used, will modify the default `CliMetadata` and include it in the help text. -* Added `WithCustomHeader(string)` as an alternative to using `CliRunnerMetadata`, there will be no exception when both are used, but in that case, `CliRunnerMetadata` has priority and will be the only one displayed. -* Added `SortCommandsAlphabetically`, which if specified will sort the commands alphabetically by name in the general help text, other than the help text, it has virtually no affect. Not specifying this, gives you control over the order, it will be exactly in the order that you added the commands and order of existing collection (if you added any commands via a collection). +- `DoNotIncludeMetadataInHelpText` was removed, instead it will not be included by default. `ModifyMetadata` was renamed to `WithMetadata` and if used, will modify the default `CliMetadata` and include it in the help text. +- Added `WithCustomHeader(string)` as an alternative to using `CliRunnerMetadata`, there will be no exception when both are used, but in that case, `CliRunnerMetadata` has priority and will be the only one displayed. +- Added `SortCommandsAlphabetically`, which if specified will sort the commands alphabetically by name in the general help text, other than the help text, it has virtually no affect. Not specifying this, gives you control over the order, it will be exactly in the order that you added the commands and order of existing collection (if you added any commands via a collection). ### Changes to `Arguments` -* Overloads of `TryGetValue` were modified to add an option to `ignoreCase`, to make it more user friendly and still adhere to parameter placement guidelines, more overloads were added. +- Overloads of `TryGetValue` were modified to add an option to `ignoreCase`, to make it more user friendly and still adhere to parameter placement guidelines, more overloads were added. ## Version 1.0.5 -* Added a `ReadOnlyMemory{string}` which is a copy of the arguments split up before being parsed to `Arguments`, it can be retrieved by the `Arguments.PureArguments`, in special cases in which you might create a nested command structure, which requires a partial parsing, then secondary parsing within a command, this can be very powerful as you can create a secondary `CliRunner` and pass any subsequence of those arguments to recreate an input. -* Overloads of `Arguments.GetValue` which take an `int` as `positional argument`, now that parameter renamed to be `position` to better signify what the overloads mean, it is a rather cosmetic change, but nevertheless. -* Add a `Arguments.Contains(int)` overload to match with the rest of the methods and suit `positional arguments`. +- Added a `ReadOnlyMemory{string}` which is a copy of the arguments split up before being parsed to `Arguments`, it can be retrieved by the `Arguments.PureArguments`, in special cases in which you might create a nested command structure, which requires a partial parsing, then secondary parsing within a command, this can be very powerful as you can create a secondary `CliRunner` and pass any subsequence of those arguments to recreate an input. +- Overloads of `Arguments.GetValue` which take an `int` as `positional argument`, now that parameter renamed to be `position` to better signify what the overloads mean, it is a rather cosmetic change, but nevertheless. +- Add a `Arguments.Contains(int)` overload to match with the rest of the methods and suit `positional arguments`. ## Version 1.0.4 -* Added missing line break in global help text -* If the single word help is entered, it will now be recognized in place of command name to return the global help text, instead of trying to be parsed as a command. +- Added missing line break in global help text +- If the single word help is entered, it will now be recognized in place of command name to return the global help text, instead of trying to be parsed as a command. ## Version 1.0.3 -* Updated `Sharpify` dependency and implemented usage of new APIs to aid in maintainability. -* Add `DoNotIncludeMetadataInHelpText()` in `CliBuilder` which removes the metadata inclusion in the general help text. +- Updated `Sharpify` dependency and implemented usage of new APIs to aid in maintainability. +- Add `DoNotIncludeMetadataInHelpText()` in `CliBuilder` which removes the metadata inclusion in the general help text. ## Version 1.0.2 -* Removed thread-local `StringBuilder` from `CliRunner`, replaced all usages with `StringBuffer` from `Sharpify` +- Removed thread-local `StringBuilder` from `CliRunner`, replaced all usages with `StringBuffer` from `Sharpify` ## Version 1.0.1 -* Updated `Sharpify` dependency -* Slightly improved performance of general help text generator +- Updated `Sharpify` dependency +- Slightly improved performance of general help text generator ## Version 1.0.0 diff --git a/src/Sharpify.CommandLineInterface/CHANGELOGLATEST.md b/src/Sharpify.CommandLineInterface/CHANGELOGLATEST.md index 2298b89..c99433f 100644 --- a/src/Sharpify.CommandLineInterface/CHANGELOGLATEST.md +++ b/src/Sharpify.CommandLineInterface/CHANGELOGLATEST.md @@ -1,6 +1,19 @@ # CHANGELOG -## Version 1.5.0 +## Version 2.0.0 -* Updated to support NET9 with `Sharpify` 2.5.0 -* Optimized path of `Arguments` forwarding when no positional arguments are present. +**WARNING:** This release may contain breaking changes. + +- The `Arguments` source collection has been rewritten as a `ReadOnlyCollection`, which cascaded into numerous changes: + - `Parser.ParseArguments` (collection-based overloads) now takes a generic `IList`. This is converted internally to a `ReadOnlyCollection`, which is used as the source for `Arguments`. + - As a result, `Parser.Split` now returns a `List`. + - The `Parser.SplitToList` method was removed, as it is no longer needed. + - `Arguments.ArgsAsMemory` and `Arguments.ArgsAsSpan` were also removed. To inspect the source, use `Arguments.Source` or obtain a copy as a `string[]` with `Arguments.SourceCopy`. + - The `CliRunner.RunAsync` overload that previously accepted a `ReadOnlySpan` now accepts an `IList` instead. This allows implicit casting from both `string[]` and `List`, which are the most common CLI inputs. +- `HelpText` generators now use a `StringBuilder` internally, replacing the previous custom buffer. Since help text generation typically occurs only once during a CLI's lifetime, any potential performance impact is minimal. This also removes some logical size restraints. +- Removed `Microsoft.SourceLink.Github` as it is now used implicitly. + +**These changes enable several improvements:** + +- `Sharpify` is no longer a required dependency of this package and has been removed. This package can now be installed as a standalone. +- Creating an `Arguments` object with `Parser.ParseArguments` is now much simpler. You can use it directly without commands to create minimal CLIs in `Program.cs`. This will be particularly useful with the upcoming `.NET 10` feature allowing direct execution of `.cs` files. (A demo video with examples and best practices will be released when this feature is available.) diff --git a/src/Sharpify.CommandLineInterface/CliRunner.cs b/src/Sharpify.CommandLineInterface/CliRunner.cs index d2889f7..dd4b52a 100644 --- a/src/Sharpify.CommandLineInterface/CliRunner.cs +++ b/src/Sharpify.CommandLineInterface/CliRunner.cs @@ -1,7 +1,5 @@ -using System.Buffers; -using System.Collections.ObjectModel; - -using Sharpify.Collections; +using System.Collections.ObjectModel; +using System.Text; namespace Sharpify.CommandLineInterface; @@ -70,9 +68,9 @@ public ValueTask RunAsync(ReadOnlySpan args, bool commandNameRequired /// /// Runs the CLI application with the specified arguments. /// - public ValueTask RunAsync(ReadOnlySpan args, bool commandNameRequired = true) { + public ValueTask RunAsync(IList args, bool commandNameRequired = true) { // Handle no input - if (args.Length is 0) { + 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); @@ -92,7 +90,7 @@ public ValueTask RunAsync(ReadOnlySpan args, bool commandNameRequir /// Runs the CLI application with the specified arguments. /// public ValueTask RunAsync(Arguments? arguments, bool commandNameRequired = true) { - if (arguments is null) { + if (arguments is null or { Count: 0 }) { return OutputHelper.Return("Input could not be parsed", 400, _config.ShowErrorCodes); } @@ -133,36 +131,33 @@ public ValueTask RunAsync(Arguments? arguments, bool commandNameRequired = // Generates the help for the application - happens once, at initialization of CliRunner private string GenerateHelpText(bool commandNameRequired) { - // here the likely help text is larger than per command, so we use a rented buffer - using var owner = MemoryPool.Shared.Rent(GetRequiredBufferLength()); - var buffer = StringBuffer.Create(owner.Memory.Span); - buffer.AppendLine(); + StringBuilder builder = new(GetRequiredBufferLength()); + builder.AppendLine(); if (_config.HelpTextSource is HelpTextSource.Metadata) { var metaData = _config.MetaData; - buffer.AppendLine(metaData.Name); - buffer.AppendLine(); - buffer.AppendLine(metaData.Description); - buffer.AppendLine(); - buffer.Append("Author: "); - buffer.AppendLine(metaData.Author); - buffer.Append("Version: "); - buffer.AppendLine(metaData.Version); - buffer.Append("License: "); - buffer.AppendLine(metaData.License); - buffer.AppendLine(); + 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) { - buffer.AppendLine(_config.CustomHeader); - buffer.AppendLine(); + builder.AppendLine(_config.CustomHeader) + .AppendLine(); } if (commandNameRequired) { - buffer.AppendLine("Commands:"); + builder.AppendLine("Commands:"); var maxCommandLength = GetMaximumCommandLength() + 2; foreach (Command command in _config.Commands) { - buffer.Append(command.Name.PadRight(maxCommandLength)); - buffer.Append(" - "); - buffer.AppendLine(command.Description); + builder.Append(command.Name.PadRight(maxCommandLength)) + .Append(" - ") + .AppendLine(command.Description); } - buffer.Append( + builder.Append( """ To get help for a command, use: " --help" @@ -172,11 +167,11 @@ private string GenerateHelpText(bool commandNameRequired) { ); } else { var command = _config.Commands[0]; - buffer.Append("Usage: "); - buffer.AppendLine(command.Usage); + builder.Append("Usage: ") + .AppendLine(command.Usage); } - return buffer.Allocate(); + return builder.ToString(); } private int GetMaximumCommandLength() => _config.Commands.Max(c => c.Name.Length); diff --git a/src/Sharpify.CommandLineInterface/Command.cs b/src/Sharpify.CommandLineInterface/Command.cs index d58fb84..a3f0de5 100644 --- a/src/Sharpify.CommandLineInterface/Command.cs +++ b/src/Sharpify.CommandLineInterface/Command.cs @@ -1,6 +1,4 @@ -using System.Buffers; - -using Sharpify.Collections; +using System.Text; namespace Sharpify.CommandLineInterface; @@ -31,18 +29,17 @@ public abstract class Command { /// public virtual string GetHelp() { var length = (Name.Length + Description.Length + Usage.Length) * 2; - using var owner = MemoryPool.Shared.Rent(length); - var buffer = StringBuffer.Create(owner.Memory.Span); - buffer.AppendLine(); - buffer.Append("Command: "); - buffer.AppendLine(Name); - buffer.AppendLine(); - buffer.Append("Description: "); - buffer.AppendLine(Description); - buffer.AppendLine(); - buffer.Append("Usage: "); - buffer.AppendLine(Usage); - return buffer.Allocate(); + StringBuilder builder = new(length); + builder.AppendLine() + .Append("Command: ") + .AppendLine(Name) + .AppendLine() + .Append("Description: ") + .AppendLine(Description) + .AppendLine() + .Append("Usage: ") + .AppendLine(Usage); + return builder.ToString(); } /// diff --git a/src/Sharpify.CommandLineInterface/Parser.cs b/src/Sharpify.CommandLineInterface/Parser.cs index 677ed6b..4fece04 100644 --- a/src/Sharpify.CommandLineInterface/Parser.cs +++ b/src/Sharpify.CommandLineInterface/Parser.cs @@ -1,22 +1,27 @@ +using System.Collections.ObjectModel; using System.Runtime.CompilerServices; -using Sharpify.Collections; - 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 /// /// - public static RentedBufferWriter Split(ReadOnlySpan str) { + public static List Split(ReadOnlySpan str) { + List args = new(0); // Force usage of empty array if (str.Length is 0) { - return new RentedBufferWriter(0); + return args; } - var buffer = new RentedBufferWriter(str.Length); + args.EnsureCapacity(DefaultBufferCapacity); int i = 0; while ((uint)i < (uint)str.Length) { char c = str[i]; @@ -30,7 +35,7 @@ public static RentedBufferWriter Split(ReadOnlySpan str) { if (nextQuote is -1) { break; } - buffer.WriteAndAdvance(new string(str.Slice(0, nextQuote))); + args.Add(new string(str.Slice(0, nextQuote))); i = nextQuote + 1; continue; } @@ -38,78 +43,69 @@ public static RentedBufferWriter Split(ReadOnlySpan str) { str = str.Slice(i); int nextSpace = str.IndexOf(' '); if (nextSpace <= 0) { // the last word, no spaces after - buffer.WriteAndAdvance(new string(str)); + args.Add(new string(str)); i = str.Length; continue; } - buffer.WriteAndAdvance(new string(str.Slice(0, nextSpace))); + args.Add(new string(str.Slice(0, nextSpace))); i = nextSpace + 1; } - return buffer; - } - - /// - /// Splits a of characters into a list of strings. - /// - /// The input of characters to split. - /// A of strings containing the split parts. - public static List SplitToList(ReadOnlySpan str) { - using var splitBuffer = Split(str); - var span = splitBuffer.WrittenSpan; - var list = new List(span.Length); - list.AddRange(span); - return list; + return args; } /// /// Parses a string into an object /// /// - public static Arguments? ParseArguments(ReadOnlySpan str) => ParseArguments(str, StringComparer.OrdinalIgnoreCase); + /// + /// This overload uses + /// + public static Arguments ParseArguments(ReadOnlySpan str) => ParseArguments(str, StringComparer.OrdinalIgnoreCase); /// /// Parses a string into an object /// /// /// - public static Arguments? ParseArguments(ReadOnlySpan str, StringComparer comparer) { - using var splitBuffer = Split(str); - return ParseArguments(splitBuffer.WrittenSpan, comparer); + public static Arguments ParseArguments(ReadOnlySpan str, StringComparer comparer) { + var args = Split(str); + return ParseArguments(args, comparer); } /// - /// Parses an List of strings into an object + /// Parses a collection of strings into an object /// /// - /// - public static Arguments? ParseArguments(List args, StringComparer comparer) => ParseArgumentsInternal(args.AsSpan(), comparer); + /// + /// This overload uses + /// + public static Arguments ParseArguments(TList args) where TList : IList + => ParseArguments(args, StringComparer.OrdinalIgnoreCase); /// - /// Parses a ReadOnlySpan of strings into arguments. + /// Parses a collections of strings into arguments. /// /// /// - public static Arguments? ParseArguments(ReadOnlySpan args, StringComparer comparer) => ParseArgumentsInternal(args, comparer); - - // Parses a List into a dictionary of arguments - internal static Arguments? ParseArgumentsInternal(ReadOnlySpan args, StringComparer comparer) { - if (args.Length is 0) { - return null; + [MethodImpl(MethodImplOptions.NoInlining)] + public static Arguments ParseArguments(TList args, StringComparer comparer) where TList : IList { + if (args.Count is 0) { + return Arguments.Empty; } - - var argsCopy = args.ToArray(); - var results = MapArguments(argsCopy, comparer); - return results.Count is 0 ? null : new Arguments(argsCopy, results); + var roc = new ReadOnlyCollection(args); + var results = MapArguments(roc, comparer); + return results.Count is 0 ? Arguments.Empty : new Arguments(roc, results); } - // Maps a List of strings into a dictionary of arguments - internal static Dictionary MapArguments(ReadOnlySpan args, StringComparer comparer) { - var results = new Dictionary(args.Length, comparer); - Span mapped = stackalloc bool[args.Length]; + // Maps a ReadOnlyCollection of strings into a dictionary of arguments + 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 < args.Length) { + 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 @@ -128,7 +124,7 @@ internal static Dictionary MapArguments(ReadOnlySpan arg // 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 == args.Length || IsParameterName(args[i + 1])) { + if (i + 1 == length || IsParameterName(args[i + 1])) { results[name] = string.Empty; mapped[i] = true; i++; @@ -148,7 +144,7 @@ internal static Dictionary MapArguments(ReadOnlySpan arg // 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 < args.Length; i++) { + for (i = 0; i < length; i++) { if (mapped[i]) { continue; } diff --git a/src/Sharpify.CommandLineInterface/README.md b/src/Sharpify.CommandLineInterface/README.md index fd5b3a5..d3165ee 100644 --- a/src/Sharpify.CommandLineInterface/README.md +++ b/src/Sharpify.CommandLineInterface/README.md @@ -2,7 +2,7 @@ `Sharpify.CommandLineInterface` is a high performance, reflection free and AOT-ready framework for creating command line interfaces, with a configurable output writer and no direct dependency to `System.Console` enabling it to be embedded, used with inputs from any source and output to any source. -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` 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. +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. ## Usage @@ -54,9 +54,9 @@ public sealed class EchoCommand : SynchronousCommand { As you can see the properties set the metadata for the command at compile time, and when it comes time to resolve it, no `reflection` is needed. -`ExecuteAsync` is returning a `ValueTask` allowing both synchronous and asynchronous code, we use the high performance `Arguments` which is an object that manages arguments parsed from the input, to retrieving and validating data. `Execute` is a sync alternative that just reduces the need of wrapping `ValueTask.FromResult(int)` verbosity when `async` is not needed. +`ExecuteAsync` is returning a `ValueTask` allowing both synchronous and asynchronous code, we use the high performance `Arguments` which is an object that manages arguments parsed from the input for retrieving and validating parameters. `Execute` is a synchronous alternative that just reduces the need of verbosity from `ValueTask.FromResult(int)` when `async` is not needed. -`OutputHelper.Return` is a helper method which outputs the message to customizable `TextWriter` in `CliRunner`, and returns the code that is specified. +`OutputHelper.Return` is a helper method which outputs the message to customizable `TextWriter` in `CliRunner`, and returns the code (`int`) that is specified. ### Program.cs (Or other entry point) @@ -85,12 +85,11 @@ public static class Program { } ``` -We can see that we can use high performances compiler optimized `ReadOnlySpan` to consolidate the commands, -We can also add command one by one, using `params []` or `ReadOnlySpan`, if you want, you can also dynamically create an array of `Command`s from the executing assembly or any other using `reflection` and pass it as an argument, however this won't be AOT-compatible. +We can add commands one by one, or use `params []` and `ReadOnlySpan`, if you want, you can also dynamically create an array of `Command`s from the executing assembly or any other using `reflection` and pass it as an argument, however this will be subject to trimming and can affect AOT compatibility. -Then we use the fluent api to add the commands, set the output to the console (we can also set it to any `TextWriter`), then we modify the global metadata and build. +Fluent API (builder pattern) is used to add the commands, set the output to the console (we can also set it to any `TextWriter`), and modify the global metadata that will be used for HelpText generation, and finally, build. -Running the app with `RunAsync` parses the `args`, and handles `help` requests, both global and per command, delegates and forwards the arguments to the requested command by name, and executes. +Running the app with `RunAsync` parses the `args`, and handles `help` requests, both global and per command, it delegates the execution to the appropriate command and injects arguments. After parsing the command name (first argument), `RunAsync` will also trigger `Arguments.ForwardPositionalArguments`, which will remove the command name and shift the arguments, so you don't need to account for it inside the logic of the command. ### Validation @@ -111,26 +110,54 @@ public override int Execute(Arguments args) { } ``` -Because you provide the actual type (no inference is needed), reflection is also not needed which maintains the Native Aot compatibility and removes the possibility of trimming. With the consolidated APIs of `Arguments` you can parse of validate concisely without verbose code filled with your own parsing logic. +Because you provide the actual type (no inference is needed), reflection is also not needed, thus, Native AOT compatibility is maintained without the possibility of trimming. With the consolidated APIs of `Arguments` you can validate and parse concisely with minimal verbosity. + +### Minimalistic Structure Without Command Classes + +As validation and parsing (the main pain points of CLI development) are manged through the `Arguments` object. You can use it directly if you don't need the global orchestration of `CliRunner`. + +Example: Imagine you wanted a one `.cs` file that will take 2 numbers and add them, here's how to do that: + +```csharp +using Sharpify.CommandLineInterface; +// using top level statements (>= .NET 5) Program.cs implicitly gets string[] args +Arguments arguments = Parser.ParseArguments(args); +// For the example we will decide that we expect named parameters x and y +if (!arguments.TryGetValue("x", 0, out int x)) { + Console.WriteLine("Parameter \"x\" is required."); + return 1; // 1 is a common code for error +} +if (!arguments.TryGetValue("y", 0, out int y)) { + Console.WriteLine("Parameter \"y\" is required."); + return 1; +} +// If we reached here, we validated and parsed x and y successfully +Console.WriteLine($"{x} + {y} = {x + y}"); +return 0; // 0 is a common success code +``` + +In this example, we created a functional CLI that validates the existence and parses 2 named parameters, and used them, all in 10 lines of code. ### Arguments Key Logic -`Arguments` is a key-value-pair wrapper around `Dictionary` and before validation maintains these types. To ensure a wide variety of applications it parses arguments in the following way: +`Arguments` is a key-value-pair wrapper around `Dictionary` which stores mapped arguments. To ensure a wide variety of applications, it parses arguments in the following way: -* Positional arguments are parsed as such, if `int x` is their position, the key is essentially `x.ToString()`. Positions start with 0. +* Positional arguments are retrieved and parsed by using the position as key, for example: for the first argument (not named or flag), it could be retrieved by the key "0" or simply the number 0. * Named arguments are parsed as regular key and value, dashes are removed from the key. So "--n" or "-n", key is "n". (But without dashes "n" will be registered as value of positional argument) * If a number is following a dash, it will be considered a numeric value, so don't use numbers as keys. -* Flags are like named arguments but whose value is empty +* Flags are like named arguments but whose value is empty, in order to avoid them being interpreted as named arguments, it is best practice to keep them *after* all the other parameters. To handle the above there are the following overload resolutions in `Arguments`: * `TryGetValue(int position, out string value)` - Will `.ToString()` the position and check the arguments. * `TryGetValue(string key, out string value)` - Will check the arguments for the key. * `HasFlag(string flag)` - Will check the arguments for the flag, so it will check both named key and that value is empty. -* `TryGetValue(ReadOnlySpan keys, out string value)` - Will check the arguments for the keys, so the first matching key will be returned. +* `TryGetValue(ReadOnlySpan keys, out string value)` - Will check the arguments for the keys, so the first matching key will be returned. This can be used to work with aliases (such as "-f" or "--file", it will find whichever the user enters and parse the value into the same variable) * `ContainsKey(string key)` - Will check the arguments for the key. The argument in this case can be a named argument or flag, this overload doesn't distinguish between them. * `ContainsKey(int position)` - Will `.ToString()` the position and check if a positional argument exists. +Arguments also support parameters which their value is a group of inputs, think of how the tool `rm` support any number of files, this is the same here. To further help working with these scenarios `TryGetValues` overloads accept a `separator` and return either a `string[]` or `T[]`. See all the options below. + ### Arguments - All methods ```csharp @@ -188,13 +215,13 @@ bool TryGetValues(ReadOnlySpan keys, string? separator, out T[] value ### Custom Parsing -`Parser` is a static class that provides the functionality of parsing inputs to `Arguments`, it also has a function of parsing an input such as string (or `ReadOnlySpan`) to a `List`, it is efficient and different than `string.Split()` since it splits both on space and quotes, giving quotes priority, so that whatever is within quotes, will remain a single string, regardless of how many spaces there are inside. This can be especially important if you need perhaps file names that could contain spaces, or any other text. +`Parser` is a static class that provides the functionality of mapping inputs to an `Arguments` object, it also has a function of parsing an input such as string (or `ReadOnlySpan`) to a `List`, it is efficient and different than `string.Split()` since it splits both on space and quotes, giving quotes priority, so that whatever is within quotes, will remain a single string, regardless of how many spaces there are inside. This can be especially important if you need file names that could contain spaces, or any other text. -`Parser` also has overloads for parsing arguments that configure a `StringComparer`, by default a `CurrentCultureIgnoreCase` is used, but whatever you prefer can be used instead. +`Parser` also has overloads for mapping arguments that configure a `StringComparer`, by default a `StringComparer.OrdinalIgnoreCase` is used, but whatever you prefer can be used instead. ### Overloads of `CliRunner.RunAsync` -`CliRunner.RunAsync` has overloads for `ReadOnlySpan` (string), `ReadOnlySpan` (array), and `Arguments` giving you full control over your input, and even custom parsing. +`CliRunner.RunAsync` has overloads for `ReadOnlySpan` (string), `IList` (direct cast from `string[]` or `List`), and `Arguments`, giving you full control over your input, and even custom parsing. ## Contact diff --git a/src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj b/src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj index cc64d81..8239efa 100644 --- a/src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj +++ b/src/Sharpify.CommandLineInterface/Sharpify.CommandLineInterface.csproj @@ -4,7 +4,7 @@ net9.0;net8.0 latest enable - 1.5.0 + 2.0.0 enable true David Shnayder @@ -13,7 +13,7 @@ CHANGELOGLATEST.md True Sharpify.CommandLineInterface - An extension of Sharpify, focused on creating command line interfaces + An standalone package focused on creating minimalistic AOT compatible command line interfaces https://github.com/dusrdev/Sharpify https://github.com/dusrdev/Sharpify git @@ -24,11 +24,6 @@ true - - - - - diff --git a/tests/Sharpify.CommandLineInterface.Tests/AssemblyInfo.cs b/tests/Sharpify.CommandLineInterface.Tests/AssemblyInfo.cs deleted file mode 100644 index b0b47aa..0000000 --- a/tests/Sharpify.CommandLineInterface.Tests/AssemblyInfo.cs +++ /dev/null @@ -1 +0,0 @@ -[assembly: CollectionBehavior(DisableTestParallelization = true)] \ No newline at end of file diff --git a/tests/Sharpify.CommandLineInterface.Tests/GlobalUsings.cs b/tests/Sharpify.CommandLineInterface.Tests/GlobalUsings.cs index f55b219..8c927eb 100644 --- a/tests/Sharpify.CommandLineInterface.Tests/GlobalUsings.cs +++ b/tests/Sharpify.CommandLineInterface.Tests/GlobalUsings.cs @@ -1,2 +1 @@ -global using Xunit; -global using Sharpify.CommandLineInterface; \ No newline at end of file +global using Xunit; \ No newline at end of file diff --git a/tests/Sharpify.CommandLineInterface.Tests/ParserArgumentsTests.cs b/tests/Sharpify.CommandLineInterface.Tests/ParserArgumentsTests.cs index cb3e39f..ba5749e 100644 --- a/tests/Sharpify.CommandLineInterface.Tests/ParserArgumentsTests.cs +++ b/tests/Sharpify.CommandLineInterface.Tests/ParserArgumentsTests.cs @@ -1,11 +1,11 @@ -using ConsoleDump; +using System.Collections.ObjectModel; namespace Sharpify.CommandLineInterface.Tests; public class ParserArgumentsTests { [Fact] public void Split_WhenEmpty_ReturnsEmptyList() { - Assert.True(Parser.Split("").IsDisabled); + Assert.Empty(Parser.Split("")); } [Theory] @@ -14,7 +14,7 @@ public void Split_WhenEmpty_ReturnsEmptyList() { [InlineData("\"hello world\"", new[] { "hello world" })] [InlineData("\"hello world\" \"hello world\"", new[] { "hello world", "hello world" })] public void Split_WhenValid_ReturnsValid(string input, string[] expected) { - Assert.Equal(expected, Parser.Split(input).WrittenSpan); + Assert.Equal(expected, Parser.Split(input)); } [Fact] @@ -38,15 +38,51 @@ public void MapArguments_Valid() { Helper.GetMapped(("0", "test"), ("1", "one"), ("param", "value"), ("2", "two")), }; for (var i = 0; i < args.Length; i++) { - var localArgs = args[i]; + var localArgs = args[i].AsReadOnly(); var localArguments = Parser.MapArguments(localArgs, StringComparer.CurrentCultureIgnoreCase); Assert.Equal(expected[i], localArguments); } } [Fact] - public void Parse_WhenEmpty_ReturnsNull() { - Assert.Null(Parser.ParseArguments("")); + public void Parse_WhenEmpty_ReturnsValidButEmptyArguments() { + Assert.Equal(0, Parser.ParseArguments("").Count); + } + + [Fact] + public void ParseArguments_ForCollection_List() { + List args = ["command", "--message", "hello world", "--code", "404", "--force"]; + var arguments = Parser.ParseArguments(args, StringComparer.OrdinalIgnoreCase); + Assert.NotNull(arguments); + Assert.Equal(4, arguments.Count); + Assert.Equal("command", arguments.GetValue(0, "")); + Assert.Equal("hello world", arguments.GetValue("message", "")); + Assert.Equal(404, arguments.GetValue("code", 0)); + Assert.True(arguments.HasFlag("force")); + } + + [Fact] + public void ParseArguments_ForCollection_Array() { + string[] args = ["command", "--message", "hello world", "--code", "404", "--force"]; + var arguments = Parser.ParseArguments(args, StringComparer.OrdinalIgnoreCase); + Assert.NotNull(arguments); + Assert.Equal(4, arguments.Count); + Assert.Equal("command", arguments.GetValue(0, "")); + Assert.Equal("hello world", arguments.GetValue("message", "")); + Assert.Equal(404, arguments.GetValue("code", 0)); + Assert.True(arguments.HasFlag("force")); + } + + [Fact] + public void ParseArguments_ForCollection_ReadOnlyCollection() { + ReadOnlyCollection roc = new(["command", "--message", "hello world", "--code", "404", "--force"]); + var arguments = Parser.ParseArguments(roc, StringComparer.OrdinalIgnoreCase); + Assert.NotNull(arguments); + Assert.Equal(4, arguments.Count); + Assert.Equal("command", arguments.GetValue(0, "")); + Assert.Equal("hello world", arguments.GetValue("message", "")); + Assert.Equal(404, arguments.GetValue("code", 0)); + Assert.True(arguments.HasFlag("force")); } [Fact] diff --git a/tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj b/tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj index 0bebe05..1c472a6 100644 --- a/tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj +++ b/tests/Sharpify.CommandLineInterface.Tests/Sharpify.CommandLineInterface.Tests.csproj @@ -1,34 +1,26 @@ - - net9.0;net8.0 - enable - enable - false - true - + + net9.0 + true + true + enable + enable + Exe + - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - - - + + + - + \ No newline at end of file diff --git a/tests/Sharpify.CommandLineInterface.Tests/xunit.runner.json b/tests/Sharpify.CommandLineInterface.Tests/xunit.runner.json new file mode 100644 index 0000000..7d6ce78 --- /dev/null +++ b/tests/Sharpify.CommandLineInterface.Tests/xunit.runner.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "diagnosticMessages": true, + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "showLiveOutput": true +} diff --git a/tests/Sharpify.Tests/AssemblyInfo.cs b/tests/Sharpify.Tests/AssemblyInfo.cs deleted file mode 100644 index b0b47aa..0000000 --- a/tests/Sharpify.Tests/AssemblyInfo.cs +++ /dev/null @@ -1 +0,0 @@ -[assembly: CollectionBehavior(DisableTestParallelization = true)] \ No newline at end of file diff --git a/tests/Sharpify.Tests/Collections/LocalPersistentDictionaryTests.cs b/tests/Sharpify.Tests/Collections/LocalPersistentDictionaryTests.cs index 89fb2a6..bd8e327 100644 --- a/tests/Sharpify.Tests/Collections/LocalPersistentDictionaryTests.cs +++ b/tests/Sharpify.Tests/Collections/LocalPersistentDictionaryTests.cs @@ -1,7 +1,5 @@ using Sharpify.Collections; -using Xunit.Abstractions; - namespace Sharpify.Tests.Collections; public class LocalPersistentDictionaryTests { @@ -73,11 +71,11 @@ public async Task LocalPersistentDictionary_Upsert_Concurrent() { // Act Task[] upsertTasks = [ - Task.Run(async () => await dict.UpsertAsync("one", "1")), - Task.Run(async () => await dict.UpsertAsync("two", "2")), - Task.Run(async () => await dict.UpsertAsync("three", "3")), - Task.Run(async () => await dict.UpsertAsync("four", "4")), - Task.Run(async () => await dict.UpsertAsync("five", "5")), + Task.Run(async () => await dict.UpsertAsync("one", "1"), TestContext.Current.CancellationToken), + Task.Run(async () => await dict.UpsertAsync("two", "2"), TestContext.Current.CancellationToken), + Task.Run(async () => await dict.UpsertAsync("three", "3"), TestContext.Current.CancellationToken), + Task.Run(async () => await dict.UpsertAsync("four", "4"), TestContext.Current.CancellationToken), + Task.Run(async () => await dict.UpsertAsync("five", "5"), TestContext.Current.CancellationToken), ]; await Task.WhenAll(upsertTasks); diff --git a/tests/Sharpify.Tests/ParallelExtensionsTests.cs b/tests/Sharpify.Tests/ParallelExtensionsTests.cs index 43d87fc..13ac449 100644 --- a/tests/Sharpify.Tests/ParallelExtensionsTests.cs +++ b/tests/Sharpify.Tests/ParallelExtensionsTests.cs @@ -11,7 +11,7 @@ public async Task ForAll_WithAsyncAction() { var action = new MultiplyActionDict(results); // Act - await dict.ForAll(action); + await dict.ForAll(action, TestContext.Current.CancellationToken); var expected = dict.ToDictionary(x => x.Key, x => x.Value * 2); // Assert @@ -30,7 +30,7 @@ await dict.ForAll((x, token) => { results[x.Key] = x.Value * 2; threads.Push(Environment.CurrentManagedThreadId); return Task.CompletedTask; - }); + }, TestContext.Current.CancellationToken); var expected = dict.ToDictionary(x => x.Key, x => x.Value * 2); // Assert @@ -45,7 +45,7 @@ public async Task ForAllAsync_WithAsyncAction() { var action = new MultiplyActionDictAsync(results); // Act - await dict.ForAllAsync(action); + await dict.ForAllAsync(action, TestContext.Current.CancellationToken); var expected = dict.ToDictionary(x => x.Key, x => x.Value * 2); // Assert @@ -64,7 +64,7 @@ await dict.ForAllAsync(async (x, token) => { results[x.Key] = x.Value * 2; threads.Push(Environment.CurrentManagedThreadId); await Task.Delay(50, token); - }); + }, TestContext.Current.CancellationToken); var expected = dict.ToDictionary(x => x.Key, x => x.Value * 2); // Assert @@ -83,7 +83,7 @@ await dict.ForAllAsync(async (x, token) => { results[x.Key] = x.Value * 2; threads.Push(Environment.CurrentManagedThreadId); await Task.Delay(50, token); - }); + }, TestContext.Current.CancellationToken); var expected = dict.ToDictionary(x => x.Key, x => x.Value * 2); // Assert diff --git a/tests/Sharpify.Tests/SerializableObjectTests.cs b/tests/Sharpify.Tests/SerializableObjectTests.cs index 46b254d..f79845d 100644 --- a/tests/Sharpify.Tests/SerializableObjectTests.cs +++ b/tests/Sharpify.Tests/SerializableObjectTests.cs @@ -104,7 +104,7 @@ public async Task OnFileChanged_DoesntChangeWhenFileIsEmpty() { using var obj = new MonitoredSerializableObject(file.Path, config, JsonContext.Default.Configuration); // Act - await File.WriteAllTextAsync(file, ""); + await File.WriteAllTextAsync(file, "", TestContext.Current.CancellationToken); // Assert Assert.Equal("John Doe", obj.Value.Name); @@ -125,7 +125,7 @@ public async Task OnFileChanged_DoesntChangeWhenFileIsInvalid() { }; // Act - await File.WriteAllTextAsync(file, "invalid json"); + await File.WriteAllTextAsync(file, "invalid json", TestContext.Current.CancellationToken); // Assert Assert.Equal(0, count); @@ -143,7 +143,7 @@ public async Task OnFileChanged_ChangesWhenFileIsValid() { obj.OnChanged += (sender, e) => Assert.Equal("Jane", e.Value.Name); // Act - await File.WriteAllTextAsync(file, JsonSerializer.Serialize(config with { Name = "Jane" }, JsonContext.Default.Configuration)); + await File.WriteAllTextAsync(file, JsonSerializer.Serialize(config with { Name = "Jane" }, JsonContext.Default.Configuration), TestContext.Current.CancellationToken); // Cleanup await file.DeleteAsync(); diff --git a/tests/Sharpify.Tests/Sharpify.Tests.csproj b/tests/Sharpify.Tests/Sharpify.Tests.csproj index 498fb4b..e4e0fb8 100644 --- a/tests/Sharpify.Tests/Sharpify.Tests.csproj +++ b/tests/Sharpify.Tests/Sharpify.Tests.csproj @@ -1,27 +1,23 @@ - net9.0;net8.0 - latest + net9.0 + true + true enable enable - - false - true + Exe - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/tests/Sharpify.Tests/UnsafeSpanIteratorTests.cs b/tests/Sharpify.Tests/UnsafeSpanIteratorTests.cs index 9661934..9c0cd6e 100644 --- a/tests/Sharpify.Tests/UnsafeSpanIteratorTests.cs +++ b/tests/Sharpify.Tests/UnsafeSpanIteratorTests.cs @@ -15,25 +15,6 @@ public void UnsafeSpanIterator_UseLinq() { Assert.Equal(5050, sum); } - [Fact] - public async Task UnsafeSpanIterator_UseConcurrentlyInAsync() { - // Arrange - var arr = Enumerable.Range(1, 100).ToArray(); - int sum = 0; - - // Act - async Task Increment(int item) { - await Task.Delay(20); - Interlocked.Add(ref sum, item); - } - var iterator = new UnsafeSpanIterator(arr.AsSpan()); - var tasks = iterator.AsParallel().Select(Increment); - await Task.WhenAll(tasks); - - // Assert - Assert.Equal(5050, sum); - } - [Fact] public void UnsafeSpanIterator_Slice() { // Arrange diff --git a/tests/Sharpify.Tests/xunit.runner.json b/tests/Sharpify.Tests/xunit.runner.json new file mode 100644 index 0000000..7d6ce78 --- /dev/null +++ b/tests/Sharpify.Tests/xunit.runner.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "diagnosticMessages": true, + "parallelizeAssembly": false, + "parallelizeTestCollections": false, + "showLiveOutput": true +}