From e732304cb937535a8d31a8788033e322df9e940b Mon Sep 17 00:00:00 2001 From: Andy Gocke Date: Wed, 24 Dec 2025 00:51:47 -0800 Subject: [PATCH 1/4] WIP --- src/Deserializer.DeserializeType.cs | 54 ++++++++++++++++++++++------- src/Deserializer.cs | 9 ++++- test/Serde.CmdLine.Test.csproj | 1 + test/SubCommandTests.cs | 8 +++++ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/Deserializer.DeserializeType.cs b/src/Deserializer.DeserializeType.cs index c102994..53b16c0 100644 --- a/src/Deserializer.DeserializeType.cs +++ b/src/Deserializer.DeserializeType.cs @@ -14,6 +14,7 @@ ISerdeInfo serdeInfo ) : ITypeDeserializer { private readonly Command _command = ParseCommand(serdeInfo); + private readonly List _skippedOptions = new(); (int, string?) ITypeDeserializer.TryReadIndexWithName(ISerdeInfo serdeInfo) => TryReadIndexWithName(serdeInfo); @@ -21,7 +22,7 @@ ISerdeInfo serdeInfo { if (_deserializer._argIndex == _deserializer._args.Length) { - return (ITypeDeserializer.EndOfType, null); + goto endOfType; } var arg = _deserializer._args[_deserializer._argIndex]; @@ -31,17 +32,47 @@ ISerdeInfo serdeInfo _deserializer._helpInfos.Add(serdeInfo); if (_deserializer._argIndex == _deserializer._args.Length) { - return (ITypeDeserializer.EndOfType, null); + goto endOfType; } arg = _deserializer._args[_deserializer._argIndex]; } - var (fieldIndex, errorName) = CheckFields(arg); + + var (fieldIndex, incArgs,errorName) = CheckFields(arg); if (fieldIndex >= 0) { + if (incArgs) + { + _deserializer._argIndex++; + } return (fieldIndex, errorName); } + if (arg.StartsWith('-')) + { + // It's an option we don't recognize, so skip it for checking later + _skippedOptions.Add(arg); + // Don't skip the arg, since the SkipValue call will do that + return (ITypeDeserializer.IndexNotFound, null); + } + throw new ArgumentSyntaxException($"Unexpected argument: '{arg}'"); + + endOfType: + // Before we leave we need to check all skipped options, then add any skipped options + // we've recorded to the parent deserializer. + for (int i = 0; i < _deserializer._skippedOptions.Count; i++) + { + var skipped = _deserializer._skippedOptions[i]; + var (skippedIndex, _, _) = CheckFields(skipped); + if (skippedIndex >= 0) + { + _deserializer._skippedOptions.RemoveAt(i); + return (skippedIndex, null); + } + } + _deserializer._skippedOptions.AddRange(_skippedOptions); + _skippedOptions.Clear(); + return (ITypeDeserializer.EndOfType, null); } int ITypeDeserializer.TryReadIndex(ISerdeInfo info) @@ -50,7 +81,7 @@ int ITypeDeserializer.TryReadIndex(ISerdeInfo info) return fieldIndex; } - private (int, string?) CheckFields(string arg) + private (int fieldIndex, bool incArgs, string? errorName) CheckFields(string arg) { var cmd = _command; if (arg.StartsWith('-')) @@ -62,13 +93,12 @@ int ITypeDeserializer.TryReadIndex(ISerdeInfo info) { if (name == arg) { - _deserializer._argIndex++; - return (option.FieldIndex, null); + return (option.FieldIndex, true, null); } } } // No option match, return missing - return (-1, null); + return (-1, false, null); } // Check for command group matches @@ -76,8 +106,7 @@ int ITypeDeserializer.TryReadIndex(ISerdeInfo info) { if (arg == subCmd.Name) { - _deserializer._argIndex++; - return (subCmd.FieldIndex, null); + return (subCmd.FieldIndex, true, null); } } @@ -87,7 +116,7 @@ int ITypeDeserializer.TryReadIndex(ISerdeInfo info) { if (name == arg) { - return (cmdGroup.FieldIndex, null); + return (cmdGroup.FieldIndex, false, null); } } @@ -100,11 +129,10 @@ int ITypeDeserializer.TryReadIndex(ISerdeInfo info) // Parameters are positional, so we check the current param index if (_deserializer._paramIndex == param.Ordinal) { - _deserializer._paramIndex++; - return (param.FieldIndex, null); + return (param.FieldIndex, true, null); } } - return (-1, null); + return (-1, false, null); } private static Command ParseCommand(ISerdeInfo serdeInfo) diff --git a/src/Deserializer.cs b/src/Deserializer.cs index 8d0b42a..b410721 100644 --- a/src/Deserializer.cs +++ b/src/Deserializer.cs @@ -1,6 +1,7 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.Collections.Immutable; namespace Serde.CmdLine; @@ -11,10 +12,16 @@ internal sealed partial class Deserializer(string[] args, bool handleHelp) : IDe private int _argIndex = 0; private int _paramIndex = 0; private readonly List _helpInfos = new(); + // We keep a list of skipped options because options from parent commands are inherited by + // subcommands. + private readonly List _skippedOptions = new(); public IReadOnlyList HelpInfos => _helpInfos; - public ITypeDeserializer ReadType(ISerdeInfo typeInfo) => new DeserializeType(this, typeInfo); + public ITypeDeserializer ReadType(ISerdeInfo typeInfo) + { + return new DeserializeType(this, typeInfo); + } public bool ReadBool() { diff --git a/test/Serde.CmdLine.Test.csproj b/test/Serde.CmdLine.Test.csproj index 463c53a..436c3d0 100644 --- a/test/Serde.CmdLine.Test.csproj +++ b/test/Serde.CmdLine.Test.csproj @@ -7,6 +7,7 @@ false true + true diff --git a/test/SubCommandTests.cs b/test/SubCommandTests.cs index db3bd61..e789ce0 100644 --- a/test/SubCommandTests.cs +++ b/test/SubCommandTests.cs @@ -24,6 +24,14 @@ public void FirstCommand() Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() }, cmd); } + [Fact] + public void FirstCommandOutOfOrder() + { + string[] testArgs = [ "first", "-v" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() }, cmd); + } + [Fact] public void FirstCommandWithShortOption() { From 4b8d988f0dd64f7b1b5d3a4eb461349fedcace70 Mon Sep 17 00:00:00 2001 From: Andy Gocke Date: Thu, 25 Dec 2025 01:01:33 -0800 Subject: [PATCH 2/4] WIP --- src/CmdLine.cs | 4 ++-- src/Deserializer.cs | 8 +++++++- test/SubCommandTests.cs | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/CmdLine.cs b/src/CmdLine.cs index 157ca7f..65b4f92 100644 --- a/src/CmdLine.cs +++ b/src/CmdLine.cs @@ -23,7 +23,7 @@ public sealed record Help(IReadOnlyList HelpInfos) : ParsedArgsOrHel public static ParsedArgsOrHelpInfos ParseRawWithHelp(string[] args) where T : IDeserializeProvider { - var deserializer = new Deserializer(args, handleHelp: true); + using var deserializer = new Deserializer(args, handleHelp: true); try { var cmd = T.Instance.Deserialize(deserializer); @@ -54,9 +54,9 @@ public static ParsedArgsOrHelpInfos ParseRawWithHelp(string[] args) /// public static T ParseRaw(string[] args) where T : IDeserializeProvider { - var deserializer = new Deserializer(args, handleHelp: false); try { + using var deserializer = new Deserializer(args, handleHelp: false); return T.Instance.Deserialize(deserializer); } catch (DeserializeException e) diff --git a/src/Deserializer.cs b/src/Deserializer.cs index b410721..a222f83 100644 --- a/src/Deserializer.cs +++ b/src/Deserializer.cs @@ -72,5 +72,11 @@ public T ReadNullableRef(IDeserialize d) public DateTime ReadDateTime() => throw new NotImplementedException(); public void ReadBytes(IBufferWriter writer) => throw new NotImplementedException(); - public void Dispose() { } + public void Dispose() + { + if (_skippedOptions.Count > 0) + { + throw new ArgumentSyntaxException($"Unexpected argument: '{_skippedOptions[0]}'"); + } + } } \ No newline at end of file diff --git a/test/SubCommandTests.cs b/test/SubCommandTests.cs index e789ce0..c81ea10 100644 --- a/test/SubCommandTests.cs +++ b/test/SubCommandTests.cs @@ -32,6 +32,21 @@ public void FirstCommandOutOfOrder() Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() }, cmd); } + [Fact] + public void FirstCommandOutOfOrderStringOption() + { + string[] testArgs = [ "first", "-s", "value" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + Assert.Equal(new TopCommand { StringOption = "value", SubCommand = new SubCommand.FirstCommand() }, cmd); + } + + [Fact] + public void FirstCommandUnknownOption() + { + string[] testArgs = [ "first", "-t" ]; + Assert.Throws(() => CmdLine.ParseRawWithHelp(testArgs)); + } + [Fact] public void FirstCommandWithShortOption() { @@ -76,6 +91,9 @@ private partial record TopCommand [CommandOption("-h|--help")] public bool? Help { get; init; } + [CommandOption("-s|--string-option")] + public string? StringOption { get; init; } + [CommandGroup("command")] public SubCommand? SubCommand { get; init; } } From 4394fa85e1602a80998711a1faa7881685aec0ea Mon Sep 17 00:00:00 2001 From: Andy Gocke Date: Wed, 31 Dec 2025 15:23:05 -0800 Subject: [PATCH 3/4] Add support for out-of-order options and refactor * Added support for out-of-order parent options: parent command options can now appear after subcommand names (e.g., mycli subcommand --parent-option works the same as mycli --parent-option subcommand) * Moved projects into proper src and test subdirectories * Added Serde.CmdLine.Analyzers with SERDECMD001: error when [CommandParameter] and [CommandGroup] are combined on the same type --- Directory.Packages.props | 3 + bench/bench.csproj | 2 +- cmdline.sln | 88 +++++--- .../CommandParameterAndGroupAnalyzer.cs | 80 ++++++++ .../Serde.CmdLine.Analyzers.csproj | 16 ++ .../ArgumentSyntaxException.cs | 0 src/{ => Serde.CmdLine}/Attributes.cs | 0 src/{ => Serde.CmdLine}/CmdLine.cs | 0 .../Deserializer.DeserializeType.cs | 188 ++++++++++++----- src/{ => Serde.CmdLine}/Deserializer.cs | 32 ++- src/{ => Serde.CmdLine}/OptionTypes.cs | 6 +- src/{ => Serde.CmdLine}/Serde.CmdLine.csproj | 5 + src/{ => Serde.CmdLine}/Utils.cs | 0 .../CommandParameterAndGroupAnalyzerTests.cs | 116 +++++++++++ .../Serde.CmdLine.Analyzers.Test.csproj | 24 +++ test/{ => Serde.CmdLine.Test}/CliTests.cs | 0 .../ParsedArgsOrHelpInfosExt.cs | 0 .../Serde.CmdLine.Test.csproj | 5 +- test/Serde.CmdLine.Test/SubCommandTests.cs | 194 ++++++++++++++++++ test/SubCommandTests.cs | 116 ----------- 20 files changed, 661 insertions(+), 214 deletions(-) create mode 100644 src/Serde.CmdLine.Analyzers/CommandParameterAndGroupAnalyzer.cs create mode 100644 src/Serde.CmdLine.Analyzers/Serde.CmdLine.Analyzers.csproj rename src/{ => Serde.CmdLine}/ArgumentSyntaxException.cs (100%) rename src/{ => Serde.CmdLine}/Attributes.cs (100%) rename src/{ => Serde.CmdLine}/CmdLine.cs (100%) rename src/{ => Serde.CmdLine}/Deserializer.DeserializeType.cs (59%) rename src/{ => Serde.CmdLine}/Deserializer.cs (75%) rename src/{ => Serde.CmdLine}/OptionTypes.cs (82%) rename src/{ => Serde.CmdLine}/Serde.CmdLine.csproj (69%) rename src/{ => Serde.CmdLine}/Utils.cs (100%) create mode 100644 test/Serde.CmdLine.Analyzers.Test/CommandParameterAndGroupAnalyzerTests.cs create mode 100644 test/Serde.CmdLine.Analyzers.Test/Serde.CmdLine.Analyzers.Test.csproj rename test/{ => Serde.CmdLine.Test}/CliTests.cs (100%) rename test/{ => Serde.CmdLine.Test}/ParsedArgsOrHelpInfosExt.cs (100%) rename test/{ => Serde.CmdLine.Test}/Serde.CmdLine.Test.csproj (70%) create mode 100644 test/Serde.CmdLine.Test/SubCommandTests.cs delete mode 100644 test/SubCommandTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 5cf571a..fa3a283 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,9 @@ + + + diff --git a/bench/bench.csproj b/bench/bench.csproj index ec764fc..6738444 100644 --- a/bench/bench.csproj +++ b/bench/bench.csproj @@ -9,7 +9,7 @@ - + diff --git a/cmdline.sln b/cmdline.sln index bad63d2..a81b001 100644 --- a/cmdline.sln +++ b/cmdline.sln @@ -3,15 +3,19 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "bench", "bench\bench.csproj", "{B16C946A-F5BA-47B7-A751-79EAEEE0296D}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serde.CmdLine", "src\Serde.CmdLine.csproj", "{36E277D7-EA89-4A06-8AC2-A931C90347B5}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serde.CmdLine", "src\Serde.CmdLine\Serde.CmdLine.csproj", "{DB38D678-1389-4C18-9BE2-D745C708FCCF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serde.CmdLine.Analyzers", "src\Serde.CmdLine.Analyzers\Serde.CmdLine.Analyzers.csproj", "{D8BA4FB3-519B-4D42-9803-CD775BEF29FD}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serde.CmdLine.Test", "test\Serde.CmdLine.Test.csproj", "{40480398-EDF1-4893-8491-3C47792F1608}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serde.CmdLine.Test", "test\Serde.CmdLine.Test\Serde.CmdLine.Test.csproj", "{EC640983-252D-404F-9D36-52DDC421DBEF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "bench", "bench\bench.csproj", "{B16C946A-F5BA-47B7-A751-79EAEEE0296D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Serde.CmdLine.Analyzers.Test", "test\Serde.CmdLine.Analyzers.Test\Serde.CmdLine.Analyzers.Test.csproj", "{828B11A8-D042-4D5F-B9BE-208AFF11C437}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -23,30 +27,6 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Debug|x64.ActiveCfg = Debug|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Debug|x64.Build.0 = Debug|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Debug|x86.ActiveCfg = Debug|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Debug|x86.Build.0 = Debug|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Release|Any CPU.Build.0 = Release|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Release|x64.ActiveCfg = Release|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Release|x64.Build.0 = Release|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Release|x86.ActiveCfg = Release|Any CPU - {36E277D7-EA89-4A06-8AC2-A931C90347B5}.Release|x86.Build.0 = Release|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Debug|Any CPU.Build.0 = Debug|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Debug|x64.ActiveCfg = Debug|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Debug|x64.Build.0 = Debug|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Debug|x86.ActiveCfg = Debug|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Debug|x86.Build.0 = Debug|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Release|Any CPU.ActiveCfg = Release|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Release|Any CPU.Build.0 = Release|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Release|x64.ActiveCfg = Release|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Release|x64.Build.0 = Release|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Release|x86.ActiveCfg = Release|Any CPU - {40480398-EDF1-4893-8491-3C47792F1608}.Release|x86.Build.0 = Release|Any CPU {B16C946A-F5BA-47B7-A751-79EAEEE0296D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B16C946A-F5BA-47B7-A751-79EAEEE0296D}.Debug|Any CPU.Build.0 = Debug|Any CPU {B16C946A-F5BA-47B7-A751-79EAEEE0296D}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -59,12 +39,62 @@ Global {B16C946A-F5BA-47B7-A751-79EAEEE0296D}.Release|x64.Build.0 = Release|Any CPU {B16C946A-F5BA-47B7-A751-79EAEEE0296D}.Release|x86.ActiveCfg = Release|Any CPU {B16C946A-F5BA-47B7-A751-79EAEEE0296D}.Release|x86.Build.0 = Release|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Debug|x64.Build.0 = Debug|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Debug|x86.Build.0 = Debug|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Release|Any CPU.Build.0 = Release|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Release|x64.ActiveCfg = Release|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Release|x64.Build.0 = Release|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Release|x86.ActiveCfg = Release|Any CPU + {DB38D678-1389-4C18-9BE2-D745C708FCCF}.Release|x86.Build.0 = Release|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Debug|x64.ActiveCfg = Debug|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Debug|x64.Build.0 = Debug|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Debug|x86.ActiveCfg = Debug|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Debug|x86.Build.0 = Debug|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Release|Any CPU.Build.0 = Release|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Release|x64.ActiveCfg = Release|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Release|x64.Build.0 = Release|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Release|x86.ActiveCfg = Release|Any CPU + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD}.Release|x86.Build.0 = Release|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Debug|x64.Build.0 = Debug|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Debug|x86.Build.0 = Debug|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Release|Any CPU.Build.0 = Release|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Release|x64.ActiveCfg = Release|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Release|x64.Build.0 = Release|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Release|x86.ActiveCfg = Release|Any CPU + {EC640983-252D-404F-9D36-52DDC421DBEF}.Release|x86.Build.0 = Release|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Debug|Any CPU.Build.0 = Debug|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Debug|x64.ActiveCfg = Debug|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Debug|x64.Build.0 = Debug|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Debug|x86.ActiveCfg = Debug|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Debug|x86.Build.0 = Debug|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Release|Any CPU.ActiveCfg = Release|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Release|Any CPU.Build.0 = Release|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Release|x64.ActiveCfg = Release|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Release|x64.Build.0 = Release|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Release|x86.ActiveCfg = Release|Any CPU + {828B11A8-D042-4D5F-B9BE-208AFF11C437}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {36E277D7-EA89-4A06-8AC2-A931C90347B5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {40480398-EDF1-4893-8491-3C47792F1608} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {DB38D678-1389-4C18-9BE2-D745C708FCCF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {D8BA4FB3-519B-4D42-9803-CD775BEF29FD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {EC640983-252D-404F-9D36-52DDC421DBEF} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {828B11A8-D042-4D5F-B9BE-208AFF11C437} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection EndGlobal diff --git a/src/Serde.CmdLine.Analyzers/CommandParameterAndGroupAnalyzer.cs b/src/Serde.CmdLine.Analyzers/CommandParameterAndGroupAnalyzer.cs new file mode 100644 index 0000000..32d50f9 --- /dev/null +++ b/src/Serde.CmdLine.Analyzers/CommandParameterAndGroupAnalyzer.cs @@ -0,0 +1,80 @@ +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Serde.CmdLine.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CommandParameterAndGroupAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "SERDECMD001"; + + private static readonly LocalizableString Title = + "CommandParameter and CommandGroup cannot be used together"; + + private static readonly LocalizableString MessageFormat = + "Type '{0}' has both [CommandParameter] and [CommandGroup] attributes. These cannot be combined on the same type."; + + private static readonly LocalizableString Description = + "A command type cannot have both positional parameters and subcommands. Move parameters to the subcommand types instead."; + + private const string Category = "Design"; + + private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( + DiagnosticId, + Title, + MessageFormat, + Category, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: Description); + + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType); + } + + private static void AnalyzeNamedType(SymbolAnalysisContext context) + { + var namedType = (INamedTypeSymbol)context.Symbol; + + bool hasCommandParameter = false; + bool hasCommandGroup = false; + + foreach (var member in namedType.GetMembers()) + { + if (!(member is IPropertySymbol || member is IFieldSymbol)) + continue; + + foreach (var attribute in member.GetAttributes()) + { + var attrName = attribute.AttributeClass?.Name; + + if (attrName == "CommandParameterAttribute" || attrName == "CommandParameter") + { + hasCommandParameter = true; + } + else if (attrName == "CommandGroupAttribute" || attrName == "CommandGroup") + { + hasCommandGroup = true; + } + } + } + + if (hasCommandParameter && hasCommandGroup) + { + // Report on the type itself + var location = namedType.Locations.Length > 0 ? namedType.Locations[0] : Location.None; + var diagnostic = Diagnostic.Create(Rule, location, namedType.Name); + + context.ReportDiagnostic(diagnostic); + } + } +} diff --git a/src/Serde.CmdLine.Analyzers/Serde.CmdLine.Analyzers.csproj b/src/Serde.CmdLine.Analyzers/Serde.CmdLine.Analyzers.csproj new file mode 100644 index 0000000..c33ad7d --- /dev/null +++ b/src/Serde.CmdLine.Analyzers/Serde.CmdLine.Analyzers.csproj @@ -0,0 +1,16 @@ + + + + netstandard2.0 + Serde.CmdLine.Analyzers + enable + latest + true + true + + + + + + + diff --git a/src/ArgumentSyntaxException.cs b/src/Serde.CmdLine/ArgumentSyntaxException.cs similarity index 100% rename from src/ArgumentSyntaxException.cs rename to src/Serde.CmdLine/ArgumentSyntaxException.cs diff --git a/src/Attributes.cs b/src/Serde.CmdLine/Attributes.cs similarity index 100% rename from src/Attributes.cs rename to src/Serde.CmdLine/Attributes.cs diff --git a/src/CmdLine.cs b/src/Serde.CmdLine/CmdLine.cs similarity index 100% rename from src/CmdLine.cs rename to src/Serde.CmdLine/CmdLine.cs diff --git a/src/Deserializer.DeserializeType.cs b/src/Serde.CmdLine/Deserializer.DeserializeType.cs similarity index 59% rename from src/Deserializer.DeserializeType.cs rename to src/Serde.CmdLine/Deserializer.DeserializeType.cs index 53b16c0..aac9474 100644 --- a/src/Deserializer.DeserializeType.cs +++ b/src/Serde.CmdLine/Deserializer.DeserializeType.cs @@ -10,95 +10,159 @@ internal sealed partial class Deserializer { private sealed class DeserializeType( Deserializer _deserializer, - ISerdeInfo serdeInfo + Command _command ) : ITypeDeserializer { - private readonly Command _command = ParseCommand(serdeInfo); private readonly List _skippedOptions = new(); - (int, string?) ITypeDeserializer.TryReadIndexWithName(ISerdeInfo serdeInfo) => TryReadIndexWithName(serdeInfo); + void IDisposable.Dispose() + { + // Pop the command stack + _deserializer._commandStack.RemoveAt(_deserializer._commandStack.Count - 1); + _deserializer._checkingSkipped = false; + } - private (int, string?) TryReadIndexWithName(ISerdeInfo serdeInfo) + (int, string?) ITypeDeserializer.TryReadIndexWithName(ISerdeInfo serdeInfo) => (TryReadIndex(serdeInfo), null); + + private int TryReadIndex(ISerdeInfo serdeInfo) { - if (_deserializer._argIndex == _deserializer._args.Length) + if (_deserializer._checkingSkipped) { - goto endOfType; + return CheckSkippedOptions(); } - var arg = _deserializer._args[_deserializer._argIndex]; - while (_deserializer._handleHelp && arg is "-h" or "--help") + // Loop until we find a matching field, or run out of args + ref int argIndex = ref _deserializer._argIndex; + string[] args = _deserializer._args; + while (true) { - _deserializer._argIndex++; - _deserializer._helpInfos.Add(serdeInfo); - if (_deserializer._argIndex == _deserializer._args.Length) + if (argIndex > args.Length) { - goto endOfType; + throw new InvalidOperationException("Argument index exceeded argument length."); } - arg = _deserializer._args[_deserializer._argIndex]; - } - var (fieldIndex, incArgs,errorName) = CheckFields(arg); - if (fieldIndex >= 0) - { - if (incArgs) + if (argIndex == args.Length) + { + _deserializer._checkingSkipped = true; + return CheckSkippedOptions(); + } + + var arg = args[argIndex]; + if (_deserializer._handleHelp && arg is "-h" or "--help") { - _deserializer._argIndex++; + argIndex++; + _deserializer._helpInfos.Add(serdeInfo); + continue; } - return (fieldIndex, errorName); + + var (fieldIndex, incArgs) = CheckFields(arg); + if (fieldIndex >= 0) + { + if (incArgs) + { + argIndex++; + } + return fieldIndex; + } + + // No match, so check parent options + if (arg.StartsWith('-') && IsParentOption(args, ref argIndex)) + { + continue; + } + + // Unrecognized argument + throw new ArgumentSyntaxException($"Unexpected argument: '{arg}'"); } + } - if (arg.StartsWith('-')) + /// + /// Given a list of args and an arg index, check to see if the current arg matches any + /// options from parent commands. If a match is found, advance the arg index appropriately + /// and record the skipped option. + /// + private bool IsParentOption(ReadOnlySpan args, ref int argIndex) + { + var arg = args[argIndex]; + // It's an option we don't recognize, so we will check the parent deserializer. + // We need to immediately check if it's valid because we need to know how many + // args to skip. However, the actual value parsing needs to be done later because + // the parent field is part of the parent type. + // N.B. The top of the stack is the current command + for (int ci = _deserializer._commandStack.Count - 2; ci >= 0; ci--) { - // It's an option we don't recognize, so skip it for checking later - _skippedOptions.Add(arg); - // Don't skip the arg, since the SkipValue call will do that - return (ITypeDeserializer.IndexNotFound, null); + var parentCmd = _deserializer._commandStack[ci]; + foreach (var option in parentCmd.Options) + { + foreach (var name in option.FlagNames) + { + if (name == arg) + { + _skippedOptions.Add(arg); + argIndex++; + // If this is not a bool flag, we need to skip the next arg as well + if (option.HasArg) + { + _skippedOptions.Add(args[argIndex]); + argIndex++; + } + return true; + } + } + } } + return false; + } - throw new ArgumentSyntaxException($"Unexpected argument: '{arg}'"); - - endOfType: + private int CheckSkippedOptions() + { // Before we leave we need to check all skipped options, then add any skipped options // we've recorded to the parent deserializer. - for (int i = 0; i < _deserializer._skippedOptions.Count; i++) + for (int skipIndex = 0; skipIndex < _deserializer._skippedOptions.Count; skipIndex++) { - var skipped = _deserializer._skippedOptions[i]; - var (skippedIndex, _, _) = CheckFields(skipped); - if (skippedIndex >= 0) + var skipped = _deserializer._skippedOptions[skipIndex]; + if (CheckOptions(_command, skipped) is {} opt) { - _deserializer._skippedOptions.RemoveAt(i); - return (skippedIndex, null); + _deserializer._skippedOptions.RemoveAt(skipIndex); + _deserializer._skipIndex = skipIndex; + return opt.FieldIndex; } } _deserializer._skippedOptions.AddRange(_skippedOptions); _skippedOptions.Clear(); - return (ITypeDeserializer.EndOfType, null); + return ITypeDeserializer.EndOfType; } int ITypeDeserializer.TryReadIndex(ISerdeInfo info) { - var (fieldIndex, _) = TryReadIndexWithName(info); - return fieldIndex; + return TryReadIndex(info); } - private (int fieldIndex, bool incArgs, string? errorName) CheckFields(string arg) + /// + /// Check if the given argument matches any options in the current command. + /// + private static Option? CheckOptions(Command cmd, string arg) { - var cmd = _command; - if (arg.StartsWith('-')) + foreach (var option in cmd.Options) { - // It's an option, so check options - foreach (var option in cmd.Options) + foreach (var name in option.FlagNames) { - foreach (var name in option.FlagNames) + if (name == arg) { - if (name == arg) - { - return (option.FieldIndex, true, null); - } + return option; } } - // No option match, return missing - return (-1, false, null); + } + return null; + } + + private (int fieldIndex, bool incArgs) CheckFields(string arg) + { + var cmd = _command; + if (arg.StartsWith('-')) + { + var fieldIndex = CheckOptions(cmd, arg)?.FieldIndex ?? -1; + return (fieldIndex, fieldIndex >= 0); } // Check for command group matches @@ -106,7 +170,7 @@ int ITypeDeserializer.TryReadIndex(ISerdeInfo info) { if (arg == subCmd.Name) { - return (subCmd.FieldIndex, true, null); + return (subCmd.FieldIndex, true); } } @@ -116,7 +180,7 @@ int ITypeDeserializer.TryReadIndex(ISerdeInfo info) { if (name == arg) { - return (cmdGroup.FieldIndex, false, null); + return (cmdGroup.FieldIndex, false); } } @@ -129,13 +193,13 @@ int ITypeDeserializer.TryReadIndex(ISerdeInfo info) // Parameters are positional, so we check the current param index if (_deserializer._paramIndex == param.Ordinal) { - return (param.FieldIndex, true, null); + return (param.FieldIndex, false); } } - return (-1, false, null); + return (-1, false); } - private static Command ParseCommand(ISerdeInfo serdeInfo) + public static Command ParseCommand(ISerdeInfo serdeInfo) { var options = ImmutableArray.CreateBuilder + + diff --git a/src/Utils.cs b/src/Serde.CmdLine/Utils.cs similarity index 100% rename from src/Utils.cs rename to src/Serde.CmdLine/Utils.cs diff --git a/test/Serde.CmdLine.Analyzers.Test/CommandParameterAndGroupAnalyzerTests.cs b/test/Serde.CmdLine.Analyzers.Test/CommandParameterAndGroupAnalyzerTests.cs new file mode 100644 index 0000000..0434473 --- /dev/null +++ b/test/Serde.CmdLine.Analyzers.Test/CommandParameterAndGroupAnalyzerTests.cs @@ -0,0 +1,116 @@ +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Microsoft.CodeAnalysis.CSharp.Testing.XUnit.AnalyzerVerifier< + Serde.CmdLine.Analyzers.CommandParameterAndGroupAnalyzer>; + +namespace Serde.CmdLine.Analyzers.Test; + +public class CommandParameterAndGroupAnalyzerTests +{ + [Fact] + public async Task NoError_WhenOnlyCommandParameter() + { + var source = """ + public class MyCommand + { + [CommandParameter(0, "file")] + public string FilePath { get; set; } + } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class CommandParameterAttribute : System.Attribute + { + public CommandParameterAttribute(int ordinal, string name) { } + } + """; + + await Verify.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task NoError_WhenOnlyCommandGroup() + { + var source = """ + public class MyCommand + { + [CommandGroup("command")] + public object SubCommand { get; set; } + } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class CommandGroupAttribute : System.Attribute + { + public CommandGroupAttribute(string name) { } + } + """; + + await Verify.VerifyAnalyzerAsync(source); + } + + [Fact] + public async Task Error_WhenBothCommandParameterAndCommandGroup() + { + var source = """ + public class {|#0:MyCommand|} + { + [CommandParameter(0, "file")] + public string FilePath { get; set; } + + [CommandGroup("command")] + public object SubCommand { get; set; } + } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class CommandParameterAttribute : System.Attribute + { + public CommandParameterAttribute(int ordinal, string name) { } + } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class CommandGroupAttribute : System.Attribute + { + public CommandGroupAttribute(string name) { } + } + """; + + var expected = Verify.Diagnostic(CommandParameterAndGroupAnalyzer.DiagnosticId) + .WithLocation(0) + .WithArguments("MyCommand"); + + await Verify.VerifyAnalyzerAsync(source, expected); + } + + [Fact] + public async Task NoError_WhenAttributesOnDifferentTypes() + { + var source = """ + public class ParentCommand + { + [CommandGroup("command")] + public ChildCommand SubCommand { get; set; } + } + + public class ChildCommand + { + [CommandParameter(0, "file")] + public string FilePath { get; set; } + } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class CommandParameterAttribute : System.Attribute + { + public CommandParameterAttribute(int ordinal, string name) { } + } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class CommandGroupAttribute : System.Attribute + { + public CommandGroupAttribute(string name) { } + } + """; + + await Verify.VerifyAnalyzerAsync(source); + } +} diff --git a/test/Serde.CmdLine.Analyzers.Test/Serde.CmdLine.Analyzers.Test.csproj b/test/Serde.CmdLine.Analyzers.Test/Serde.CmdLine.Analyzers.Test.csproj new file mode 100644 index 0000000..791a967 --- /dev/null +++ b/test/Serde.CmdLine.Analyzers.Test/Serde.CmdLine.Analyzers.Test.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + false + true + + + + + + + + + + + + + + + + + diff --git a/test/CliTests.cs b/test/Serde.CmdLine.Test/CliTests.cs similarity index 100% rename from test/CliTests.cs rename to test/Serde.CmdLine.Test/CliTests.cs diff --git a/test/ParsedArgsOrHelpInfosExt.cs b/test/Serde.CmdLine.Test/ParsedArgsOrHelpInfosExt.cs similarity index 100% rename from test/ParsedArgsOrHelpInfosExt.cs rename to test/Serde.CmdLine.Test/ParsedArgsOrHelpInfosExt.cs diff --git a/test/Serde.CmdLine.Test.csproj b/test/Serde.CmdLine.Test/Serde.CmdLine.Test.csproj similarity index 70% rename from test/Serde.CmdLine.Test.csproj rename to test/Serde.CmdLine.Test/Serde.CmdLine.Test.csproj index 436c3d0..84d9084 100644 --- a/test/Serde.CmdLine.Test.csproj +++ b/test/Serde.CmdLine.Test/Serde.CmdLine.Test.csproj @@ -19,7 +19,10 @@ - + + diff --git a/test/Serde.CmdLine.Test/SubCommandTests.cs b/test/Serde.CmdLine.Test/SubCommandTests.cs new file mode 100644 index 0000000..dae790b --- /dev/null +++ b/test/Serde.CmdLine.Test/SubCommandTests.cs @@ -0,0 +1,194 @@ + +using System.IO; +using System.Linq; +using Spectre.Console.Testing; +using Xunit; + +namespace Serde.CmdLine.Test; + +public sealed partial class SubCommandTests +{ + [Fact] + public void NoSubCommand() + { + string[] testArgs = [ "-v" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + Assert.Equal(new TopCommand { Verbose = true, SubCommand = null }, cmd); + } + + [Fact] + public void FirstCommand() + { + string[] testArgs = [ "-v", "first" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() }, cmd); + } + + [Fact] + public void FirstCommandOutOfOrder() + { + string[] testArgs = [ "first", "-v" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() }, cmd); + } + + [Fact] + public void FirstCommandOutOfOrderStringOption() + { + string[] testArgs = [ "first", "-t", "value" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + Assert.Equal(new TopCommand { StringOption = "value", SubCommand = new SubCommand.FirstCommand() }, cmd); + } + + [Fact] + public void FirstCommandUnknownOption() + { + // -x is unknown to both FirstCommand and TopCommand + string[] testArgs = [ "first", "-x" ]; + Assert.Throws(() => CmdLine.ParseRawWithHelp(testArgs)); + } + + [Fact] + public void FirstCommandWithShortOption() + { + string[] testArgs = [ "-v", "first", "-s" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() { SomeOption = true } }, cmd); + } + + [Fact] + public void FirstCommandWithLongOption() + { + string[] testArgs = [ "-v", "first", "--some-option" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() { SomeOption = true } }, cmd); + } + + /// + /// When the argument following an unknown option looks like a value, it gets consumed. + /// Even if that value happens to be a sibling command name, from the subcommand's perspective + /// it doesn't know about sibling commands. + /// + [Fact] + public void SkippedOptionValueLooksLikeCommand() + { + // -t is unknown to SecondCommand, "first" looks like a value from SecondCommand's perspective + // (SecondCommand doesn't know about its sibling "first" command) + // So "first" gets consumed as the value for -t + string[] testArgs = [ "second", "-t", "first" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + Assert.Equal(new TopCommand { StringOption = "first", SubCommand = new SubCommand.SecondCommand() }, cmd); + } + + /// + /// When a skipped boolean option is followed by "true" or "false", + /// Boolean flags should not accept explicit "true"/"false" values. + /// The literal "true" should be treated as an unrecognized argument. + /// + [Fact] + public void SkippedBoolOptionWithExplicitTrue() + { + string[] testArgs = [ "first", "-v", "true" ]; + Assert.Throws(() => CmdLine.ParseRawWithHelp(testArgs)); + } + + /// + /// Boolean flags should not accept explicit "true"/"false" values. + /// The literal "false" should be treated as an unrecognized argument. + /// + [Fact] + public void SkippedBoolOptionWithExplicitFalse() + { + string[] testArgs = [ "first", "-v", "false" ]; + Assert.Throws(() => CmdLine.ParseRawWithHelp(testArgs)); + } + + /// + /// When multiple options are skipped, they should all be passed to the parent. + /// + [Fact] + public void MultipleSkippedOptions() + { + string[] testArgs = [ "first", "-v", "-t", "myvalue" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + Assert.Equal(new TopCommand { Verbose = true, StringOption = "myvalue", SubCommand = new SubCommand.FirstCommand() }, cmd); + } + + /// + /// A skipped string option followed by another option consumes the next token as its value, + /// following GNU getopt behavior. + /// + [Fact] + public void SkippedStringOptionFollowedByAnotherOption() + { + // -t expects a value, and GNU behavior consumes the next token even if it starts with '-' + string[] testArgs = [ "first", "-t", "-v" ]; + var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); + // -t gets the literal value "-v", Verbose remains false + Assert.Equal(new TopCommand { StringOption = "-v", SubCommand = new SubCommand.FirstCommand() }, cmd); + } + + /// + /// When a skipped boolean flag is followed by an unknown argument, + /// it should be treated as an unrecognized argument error. + /// + [Fact] + public void SkippedBoolFlagFollowedByUnknownArg() + { + // -v is a bool flag (recognized by TopCommand), "myfile.txt" is unknown to both + string[] testArgs = [ "first", "-v", "myfile.txt" ]; + Assert.Throws(() => CmdLine.ParseRawWithHelp(testArgs)); + } + + [Fact] + public void TopLevelHelp() + { + var help = CmdLine.GetHelpText(SerdeInfoProvider.GetDeserializeInfo()); + var text = """ +usage: TopCommand [-v | --verbose] [-h | --help] [-t | --string-option ] + +Options: + -v, --verbose + -h, --help + -t, --string-option + +Commands: + first + second + +"""; + Assert.Equal(text.NormalizeLineEndings(), help.NormalizeLineEndings()); + } + + [GenerateDeserialize] + private partial record TopCommand + { + [CommandOption("-v|--verbose")] + public bool? Verbose { get; init; } + + [CommandOption("-h|--help")] + public bool? Help { get; init; } + + [CommandOption("-t|--string-option")] + public string? StringOption { get; init; } + + [CommandGroup("command")] + public SubCommand? SubCommand { get; init; } + } + + [GenerateDeserialize] + private abstract partial record SubCommand + { + private SubCommand() { } + + [Command("first")] + public sealed partial record FirstCommand : SubCommand + { + [CommandOption("-s|--some-option")] + public bool? SomeOption { get; init; } + } + + [Command("second")] + public sealed partial record SecondCommand : SubCommand; + } +} \ No newline at end of file diff --git a/test/SubCommandTests.cs b/test/SubCommandTests.cs deleted file mode 100644 index c81ea10..0000000 --- a/test/SubCommandTests.cs +++ /dev/null @@ -1,116 +0,0 @@ - -using System.IO; -using System.Linq; -using Spectre.Console.Testing; -using Xunit; - -namespace Serde.CmdLine.Test; - -public sealed partial class SubCommandTests -{ - [Fact] - public void NoSubCommand() - { - string[] testArgs = [ "-v" ]; - var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); - Assert.Equal(new TopCommand { Verbose = true, SubCommand = null }, cmd); - } - - [Fact] - public void FirstCommand() - { - string[] testArgs = [ "-v", "first" ]; - var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); - Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() }, cmd); - } - - [Fact] - public void FirstCommandOutOfOrder() - { - string[] testArgs = [ "first", "-v" ]; - var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); - Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() }, cmd); - } - - [Fact] - public void FirstCommandOutOfOrderStringOption() - { - string[] testArgs = [ "first", "-s", "value" ]; - var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); - Assert.Equal(new TopCommand { StringOption = "value", SubCommand = new SubCommand.FirstCommand() }, cmd); - } - - [Fact] - public void FirstCommandUnknownOption() - { - string[] testArgs = [ "first", "-t" ]; - Assert.Throws(() => CmdLine.ParseRawWithHelp(testArgs)); - } - - [Fact] - public void FirstCommandWithShortOption() - { - string[] testArgs = [ "-v", "first", "-s" ]; - var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); - Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() { SomeOption = true } }, cmd); - } - - [Fact] - public void FirstCommandWithLongOption() - { - string[] testArgs = [ "-v", "first", "--some-option" ]; - var cmd = CmdLine.ParseRawWithHelp(testArgs).Unwrap(); - Assert.Equal(new TopCommand { Verbose = true, SubCommand = new SubCommand.FirstCommand() { SomeOption = true } }, cmd); - } - - [Fact] - public void TopLevelHelp() - { - var help = CmdLine.GetHelpText(SerdeInfoProvider.GetDeserializeInfo()); - var text = """ -usage: TopCommand [-v | --verbose] [-h | --help] - -Options: - -v, --verbose - -h, --help - -Commands: - first - second - -"""; - Assert.Equal(text.NormalizeLineEndings(), help.NormalizeLineEndings()); - } - - [GenerateDeserialize] - private partial record TopCommand - { - [CommandOption("-v|--verbose")] - public bool? Verbose { get; init; } - - [CommandOption("-h|--help")] - public bool? Help { get; init; } - - [CommandOption("-s|--string-option")] - public string? StringOption { get; init; } - - [CommandGroup("command")] - public SubCommand? SubCommand { get; init; } - } - - [GenerateDeserialize] - private abstract partial record SubCommand - { - private SubCommand() { } - - [Command("first")] - public sealed partial record FirstCommand : SubCommand - { - [CommandOption("-s|--some-option")] - public bool? SomeOption { get; init; } - } - - [Command("second")] - public sealed partial record SecondCommand : SubCommand; - } -} \ No newline at end of file From c1c38b5b9f50fdae6dd8aaf8ad17d274e3aa1e4d Mon Sep 17 00:00:00 2001 From: Andy Gocke Date: Thu, 1 Jan 2026 11:04:33 -0800 Subject: [PATCH 4/4] Add shipped/unshipped analyzer files --- src/Serde.CmdLine.Analyzers/AnalyzerReleases.Shipped.md | 3 +++ src/Serde.CmdLine.Analyzers/AnalyzerReleases.Unshipped.md | 8 ++++++++ .../Serde.CmdLine.Analyzers.csproj | 5 +++++ 3 files changed, 16 insertions(+) create mode 100644 src/Serde.CmdLine.Analyzers/AnalyzerReleases.Shipped.md create mode 100644 src/Serde.CmdLine.Analyzers/AnalyzerReleases.Unshipped.md diff --git a/src/Serde.CmdLine.Analyzers/AnalyzerReleases.Shipped.md b/src/Serde.CmdLine.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..60c1edf --- /dev/null +++ b/src/Serde.CmdLine.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,3 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/src/Serde.CmdLine.Analyzers/AnalyzerReleases.Unshipped.md b/src/Serde.CmdLine.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..31acf40 --- /dev/null +++ b/src/Serde.CmdLine.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; Unshipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +SERDECMD001 | Design | Error | CommandParameterAndGroupAnalyzer, [Documentation](https://github.com/serdedotnet/cmdline) diff --git a/src/Serde.CmdLine.Analyzers/Serde.CmdLine.Analyzers.csproj b/src/Serde.CmdLine.Analyzers/Serde.CmdLine.Analyzers.csproj index c33ad7d..07c44f4 100644 --- a/src/Serde.CmdLine.Analyzers/Serde.CmdLine.Analyzers.csproj +++ b/src/Serde.CmdLine.Analyzers/Serde.CmdLine.Analyzers.csproj @@ -13,4 +13,9 @@ + + + + +