diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..848c3d2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # MinVer reads the version from git tags + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore + run: dotnet restore holonsoft.CmdLineParser.slnx + + - name: Build + run: dotnet build holonsoft.CmdLineParser.slnx -c Release --no-restore + + - name: Test + run: dotnet test holonsoft.CmdLineParser.Tests/holonsoft.CmdLineParser.Tests.csproj -c Release --no-build + + - name: Pack + run: dotnet pack holonsoft.CmdLineParser.slnx -c Release --no-build -o artifacts + + - name: Upload packages + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v7 + with: + name: packages + path: artifacts/*.nupkg diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f240cd8 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,59 @@ +name: Publish to NuGet + +# Triggered by version tags such as v5.0.0 or v5.1.0-preview.1. +# Authenticates against nuget.org with trusted publishing (OIDC), no API key is stored anywhere. +# The nuget.org policy matches this file name (publish.yml) and the environment name (release). + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write # required for the OIDC token that NuGet/login exchanges + contents: read + + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # MinVer reads the version from git tags + + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Build + run: dotnet build holonsoft.CmdLineParser.slnx -c Release + + - name: Test + run: dotnet test holonsoft.CmdLineParser.Tests/holonsoft.CmdLineParser.Tests.csproj -c Release --no-build + + - name: Pack + run: dotnet pack holonsoft.CmdLineParser.slnx -c Release --no-build -o artifacts + + - name: Verify package version matches the tag + run: | + expected="${GITHUB_REF_NAME#v}" + ls -l artifacts + test -f "artifacts/holonsoft.CmdLineParser.${expected}.nupkg" + test -f "artifacts/holonsoft.CmdLineParser.Abstractions.${expected}.nupkg" + + - name: NuGet login (OIDC) + id: login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} # nuget.org profile name, not the email + + - name: Push to nuget.org + run: dotnet nuget push "artifacts/*.nupkg" --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate diff --git a/.gitignore b/.gitignore index b21e17b..55b9ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -344,3 +344,6 @@ healthchecksdb # Backup folder for Package Reference Convert tool in Visual Studio 2017 MigrationBackup/ /Acs.BackendHost/app.config + +# Editor-local settings +.vscode/ diff --git a/Directory.Build.props b/Directory.Build.props index 7f72ceb..cabc943 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,7 +10,6 @@ - 5.0.0 holonsoft Christian Vogt Christian Vogt, holonsoft @@ -36,4 +35,15 @@ + + + v + 5.0 + preview.0 + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index f6b722a..fce6adb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,10 @@ false + + + + diff --git a/README.md b/README.md index 55d5be3..90f644d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # CmdLineParser +[![CI](https://github.com/holonsoft/CmdLineParser/actions/workflows/ci.yml/badge.svg)](https://github.com/holonsoft/CmdLineParser/actions/workflows/ci.yml) +[![NuGet](https://img.shields.io/nuget/v/holonsoft.CmdLineParser.svg)](https://www.nuget.org/packages/holonsoft.CmdLineParser/) + Reflection based fast command line parser (`args[]` -> POCO). Define a class, put attributes on its public fields or properties, call `Parse`. You get a filled object, a list of structured errors and a help text. No builder DSL, no handlers, no ceremony. @@ -150,7 +153,7 @@ public class Options : IValidatableArguments { ## Parsing rules -* `-name`, `--name` and `/name` are equivalent. `/` can be disabled in the options for Unix style tools. +* `-name` and `--name` are equivalent. `/name` is accepted too when `AllowSlashPrefix` is on. That is the default on Windows only, because absolute paths start with `/` on Unix-like systems. * A value can follow as separate token or inline: `-name value`, `-name:value`, `-name=value`. Only the first separator counts, so `-url:http://x` works. * Values wrapped in double quotes are unquoted. * Negative numbers do not need quotes: `-Value -5`, `-Points -1.5 -.5 2`. @@ -231,7 +234,7 @@ switch (result.Value) { ```csharp var options = new CommandLineParserOptions { IgnoreCase = true, // default false - AllowSlashPrefix = false, // default true + AllowSlashPrefix = true, // default: true on Windows, false elsewhere ValueSeparators = [':', '='], // default RecognizeEndOfOptionsMarker = true, // default AutoHelp = true, // default @@ -275,6 +278,7 @@ Breaking changes in 5.0: * Empty `args` applies defaults and checks required arguments. * Built-in help names `help`, `h` and `?` are recognized unless your class defines them. * `--name=value`, unquoted negative numbers and `@file` response files are recognized. A bare `--` ends the options. +* `/name` is recognized by default on Windows only. Set `AllowSlashPrefix = true` to keep it on Linux and macOS. * `Uri`, `Version`, `FileInfo`, `DirectoryInfo` and many more types are supported. Byte overflow is an error instead of silent truncation. * The `Argument` class was removed from the Abstractions package. `ArgumentAttribute` string properties are nullable. Missing required arguments are reported once, not once per alias. * `GetHelpTexts()` returns empty strings instead of null for missing parts and sorts case-insensitively. diff --git a/holonsoft.CmdLineParser.Tests/ArgumentLexerTests.cs b/holonsoft.CmdLineParser.Tests/ArgumentLexerTests.cs index 30126ba..9bbd451 100644 --- a/holonsoft.CmdLineParser.Tests/ArgumentLexerTests.cs +++ b/holonsoft.CmdLineParser.Tests/ArgumentLexerTests.cs @@ -13,9 +13,7 @@ private static ArgumentLexer Create(Action? configure [Theory] [InlineData("-name", "name")] [InlineData("--name", "name")] - [InlineData("/name", "name")] [InlineData("-n", "n")] - [InlineData("/?", "?")] [InlineData("- name ", "name")] public void OptionPrefixes(string raw, string expectedName) { var token = Create().Classify(raw, afterEndOfOptions: false); @@ -28,7 +26,6 @@ public void OptionPrefixes(string raw, string expectedName) { [Theory] [InlineData("-name:value", "name", "value")] [InlineData("--name=value", "name", "value")] - [InlineData("/name:\"quoted value\"", "name", "quoted value")] [InlineData("-url:http://host:8080/x", "url", "http://host:8080/x")] [InlineData("-path=C:\\temp", "path", "C:\\temp")] [InlineData("-x:a=b", "x", "a=b")] @@ -82,11 +79,32 @@ public void EndOfOptionsMarkerCanBeDisabled() { } [Fact] - public void SlashPrefixCanBeDisabled() { - var token = Create(o => o.AllowSlashPrefix = false).Classify("/etc/passwd", afterEndOfOptions: false); + public void SlashPrefixDefaultFollowsTheOperatingSystem() { + Assert.Equal(OperatingSystem.IsWindows(), new CommandLineParserOptions().AllowSlashPrefix); + } + + [Theory] + [InlineData("/name", "name", null)] + [InlineData("/?", "?", null)] + [InlineData("/name:\"quoted value\"", "name", "quoted value")] + [InlineData("/etc/passwd", "etc/passwd", null)] + public void SlashPrefixWhenEnabled(string raw, string expectedName, string? expectedValue) { + var token = Create(o => o.AllowSlashPrefix = true).Classify(raw, afterEndOfOptions: false); + + Assert.Equal(LexedTokenKind.Option, token.Kind); + Assert.Equal(expectedName, token.Name); + Assert.Equal(expectedValue, token.Value); + } + + [Theory] + [InlineData("/name")] + [InlineData("/?")] + [InlineData("/etc/passwd")] + public void SlashPrefixWhenDisabled(string raw) { + var token = Create(o => o.AllowSlashPrefix = false).Classify(raw, afterEndOfOptions: false); Assert.Equal(LexedTokenKind.Value, token.Kind); - Assert.Equal("/etc/passwd", token.Value); + Assert.Equal(raw, token.Value); } [Fact] diff --git a/holonsoft.CmdLineParser.Tests/CommandLineParserTests.cs b/holonsoft.CmdLineParser.Tests/CommandLineParserTests.cs index 158547c..f675a00 100644 --- a/holonsoft.CmdLineParser.Tests/CommandLineParserTests.cs +++ b/holonsoft.CmdLineParser.Tests/CommandLineParserTests.cs @@ -11,6 +11,12 @@ namespace holonsoft.CmdLineParser.Tests; /// The scenarios of the original test suite, kept as regression guard for the 5.0 rewrite. /// public class CommandLineParserTests { + /// + /// The original suite mixes '-' and '/' prefixes, so the slash prefix is enabled explicitly. It is off by default on + /// Unix-like systems. + /// + private static CommandLineParserOptions WithSlashPrefix => new() { AllowSlashPrefix = true }; + public static readonly string[] Args1 = ["-s", "--help", "-ka", "\"was das soll\""]; public static readonly string[] Args2 = ["-c", "huhu", "-d", "-ef", "--h", "/?", "-f", "dummy", "-g", "\"Long text with spaces\"", "/t:test", "dd"]; public static readonly string[] Args3 = ["-StartConnections", "3", "-MaxConnections:5"]; @@ -56,7 +62,7 @@ public class CommandLineParserTests { [Fact] public void UnknownArgumentsAndHelpDoNotThrow() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.ParseArguments(Args1); @@ -67,7 +73,7 @@ public void UnknownArgumentsAndHelpDoNotThrow() { [Fact] public void GarbageInputProducesOnlyUnknownArgumentErrors() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.ParseArguments(Args2); @@ -78,7 +84,7 @@ public void GarbageInputProducesOnlyUnknownArgumentErrors() { [Fact] public void EmptyArgumentsReturnInstanceWithDefaults() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.Parse([]); @@ -89,7 +95,7 @@ public void EmptyArgumentsReturnInstanceWithDefaults() { [Fact] public void SimpleValuesAndDefaults() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var reported = new List<(ParserErrorKinds Kind, string Hint)>(); var result = parser.Parse(Args3, (kind, hint) => reported.Add((kind, hint))); @@ -107,7 +113,7 @@ public void SimpleValuesAndDefaults() { [Fact] public void MultiValueCollections() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.Parse(Args4); @@ -119,7 +125,7 @@ public void MultiValueCollections() { [Fact] public void MultipleUniqueCollectionRejectsDuplicates() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var kindOfError = ParserErrorKinds.None; var result = parser.Parse(Args7, (kind, _) => kindOfError = kind); @@ -131,7 +137,7 @@ public void MultipleUniqueCollectionRejectsDuplicates() { [Fact] public void UnknownOptionIsReported() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var kindOfError = ParserErrorKinds.None; parser.Parse(Args8, (kind, _) => kindOfError = kind); @@ -142,7 +148,7 @@ public void UnknownOptionIsReported() { [Fact] public void FlagWithoutValueIsTrue() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.Parse(Args9); @@ -152,7 +158,7 @@ public void FlagWithoutValueIsTrue() { [Fact] public void GuidWithInlineQuotedValue() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.Parse(Args5); @@ -162,7 +168,7 @@ public void GuidWithInlineQuotedValue() { [Fact] public void EnumByName() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.Parse(Args6); @@ -172,7 +178,7 @@ public void EnumByName() { [Fact] public void MissingRequiredFieldsAreReported() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); parser.Parse(Args6); @@ -183,7 +189,7 @@ public void MissingRequiredFieldsAreReported() { [Fact] public void AllSupportedTypes() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.ParseArguments(Args99); @@ -214,7 +220,7 @@ public void AllSupportedTypes() { [Fact] public void HelpTextsListEveryArgument() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var helpTexts = parser.GetHelpTexts().ToList(); @@ -223,14 +229,14 @@ public void HelpTextsListEveryArgument() { [Fact] public void UnsupportedMemberTypeThrowsOnFirstUse() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); Assert.Throws(() => parser.Parse(["/Anything", "x"])); } [Fact] public void ShortNameWithEveryPrefix() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); Assert.True(parser.Parse(Args10).FlagWhenFound); Assert.True(parser.Parse(Args11).FlagWhenFound); @@ -239,7 +245,7 @@ public void ShortNameWithEveryPrefix() { [Fact] public void MemberNameLongNameAndShortNameAddressTheSameField() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); Assert.Equal("Special_Out1.txt", parser.Parse(Args13).OutFileName); Assert.Equal("Special_Out1.txt", parser.Parse(Args14).OutFileName); @@ -248,7 +254,7 @@ public void MemberNameLongNameAndShortNameAddressTheSameField() { [Fact] public void DefaultValueAppliesWhenFieldNotGiven() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.Parse(Args16); @@ -258,7 +264,7 @@ public void DefaultValueAppliesWhenFieldNotGiven() { [Fact] public void RepeatedCollectionOption() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.Parse(Args17); @@ -270,7 +276,7 @@ public void RepeatedCollectionOption() { [Fact] public void CollectionWithSingleOptionAndSeveralValues() { - var parser = new CommandLineParser(); + var parser = new CommandLineParser(WithSlashPrefix); var result = parser.Parse(Args18); diff --git a/holonsoft.CmdLineParser.Tests/EdgeCaseTests.cs b/holonsoft.CmdLineParser.Tests/EdgeCaseTests.cs index 6731a91..766f2a1 100644 --- a/holonsoft.CmdLineParser.Tests/EdgeCaseTests.cs +++ b/holonsoft.CmdLineParser.Tests/EdgeCaseTests.cs @@ -114,7 +114,7 @@ public class FormattedDefaults { [Theory] [InlineData("-Verbose:false", false)] [InlineData("-Verbose=yes", true)] - [InlineData("/v:0", false)] + [InlineData("-v:0", false)] public void InlineValueOnBool(string argument, bool expected) { var result = Parse(argument); diff --git a/holonsoft.CmdLineParser.Tests/FuzzTests.cs b/holonsoft.CmdLineParser.Tests/FuzzTests.cs index 6a57b16..c21f264 100644 --- a/holonsoft.CmdLineParser.Tests/FuzzTests.cs +++ b/holonsoft.CmdLineParser.Tests/FuzzTests.cs @@ -35,6 +35,9 @@ private static IEnumerable> Parsers() { var relaxed = new CommandLineParser(new CommandLineParserOptions { IgnoreCase = true, AllowSlashPrefix = false, ValueSeparators = ['='] }); yield return args => relaxed.ParseArguments(args); + var classic = new CommandLineParser(new CommandLineParserOptions { AllowSlashPrefix = true }); + yield return args => classic.ParseArguments(args); + var verbs = new VerbParser().Add().Add().Add("run", isDefault: true); yield return args => verbs.Parse(args); } diff --git a/holonsoft.CmdLineParser.Tests/HelpTests.cs b/holonsoft.CmdLineParser.Tests/HelpTests.cs index ca90213..358ad8b 100644 --- a/holonsoft.CmdLineParser.Tests/HelpTests.cs +++ b/holonsoft.CmdLineParser.Tests/HelpTests.cs @@ -121,7 +121,7 @@ public void WrapSplitsAtWordBoundaries() { [InlineData("/?")] [InlineData("-help")] public void BuiltInHelpSuppressesMissingRequiredErrors(string helpArgument) { - var result = new CommandLineParser().ParseArguments([helpArgument]); + var result = new CommandLineParser(new CommandLineParserOptions { AllowSlashPrefix = true }).ParseArguments([helpArgument]); Assert.True(result.HelpRequested); Assert.Empty(result.Errors); diff --git a/holonsoft.CmdLineParser.Tests/VerbParserTests.cs b/holonsoft.CmdLineParser.Tests/VerbParserTests.cs index e0dbb2e..019aadd 100644 --- a/holonsoft.CmdLineParser.Tests/VerbParserTests.cs +++ b/holonsoft.CmdLineParser.Tests/VerbParserTests.cs @@ -93,7 +93,7 @@ public void DefaultVerbHandlesCommandLinesWithoutVerb() { [InlineData("-h")] [InlineData("/?")] public void HelpWithoutVerbAsksForTheOverview(string help) { - var result = Create().Parse([help]); + var result = new VerbParser(new CommandLineParserOptions { AllowSlashPrefix = true }).Add().Add().Parse([help]); Assert.True(result.HelpRequested); Assert.Null(result.Verb); diff --git a/holonsoft.CmdLineParser/CommandLineParserOptions.cs b/holonsoft.CmdLineParser/CommandLineParserOptions.cs index 42b8dfb..7530e18 100644 --- a/holonsoft.CmdLineParser/CommandLineParserOptions.cs +++ b/holonsoft.CmdLineParser/CommandLineParserOptions.cs @@ -15,10 +15,11 @@ public sealed class CommandLineParserOptions { public bool IgnoreCase { get; set; } /// - /// Accept '/' as argument prefix in addition to '-' and '--'. Default: true. - /// Consider disabling this on Unix-like systems when values may be absolute paths. + /// Accept '/' as argument prefix in addition to '-' and '--', as in /name or /?. + /// Default: true on Windows, false elsewhere, because absolute paths start with '/' on Unix-like + /// systems and would otherwise be read as options. Set it explicitly when the behavior must not depend on the OS. /// - public bool AllowSlashPrefix { get; set; } = true; + public bool AllowSlashPrefix { get; set; } = OperatingSystem.IsWindows(); /// /// Characters that separate an argument name from an inline value, as in -name:value or --name=value.