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
+[](https://github.com/holonsoft/CmdLineParser/actions/workflows/ci.yml)
+[](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.