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/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/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..07c44f4 --- /dev/null +++ b/src/Serde.CmdLine.Analyzers/Serde.CmdLine.Analyzers.csproj @@ -0,0 +1,21 @@ + + + + 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 99% rename from src/CmdLine.cs rename to src/Serde.CmdLine/CmdLine.cs index 157ca7f..65b4f92 100644 --- a/src/CmdLine.cs +++ b/src/Serde.CmdLine/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.DeserializeType.cs b/src/Serde.CmdLine/Deserializer.DeserializeType.cs similarity index 56% rename from src/Deserializer.DeserializeType.cs rename to src/Serde.CmdLine/Deserializer.DeserializeType.cs index c102994..aac9474 100644 --- a/src/Deserializer.DeserializeType.cs +++ b/src/Serde.CmdLine/Deserializer.DeserializeType.cs @@ -10,65 +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) { - return (ITypeDeserializer.EndOfType, null); + 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) + { + throw new InvalidOperationException("Argument index exceeded argument length."); + } + + if (argIndex == args.Length) + { + _deserializer._checkingSkipped = true; + return CheckSkippedOptions(); + } + + var arg = args[argIndex]; + if (_deserializer._handleHelp && arg is "-h" or "--help") + { + argIndex++; + _deserializer._helpInfos.Add(serdeInfo); + continue; + } + + var (fieldIndex, incArgs) = CheckFields(arg); + if (fieldIndex >= 0) { - return (ITypeDeserializer.EndOfType, null); + if (incArgs) + { + argIndex++; + } + return fieldIndex; } - arg = _deserializer._args[_deserializer._argIndex]; + + // No match, so check parent options + if (arg.StartsWith('-') && IsParentOption(args, ref argIndex)) + { + continue; + } + + // Unrecognized argument + throw new ArgumentSyntaxException($"Unexpected argument: '{arg}'"); } - var (fieldIndex, errorName) = CheckFields(arg); - if (fieldIndex >= 0) + } + + /// + /// 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--) { - return (fieldIndex, errorName); + 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}'"); + 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 skipIndex = 0; skipIndex < _deserializer._skippedOptions.Count; skipIndex++) + { + var skipped = _deserializer._skippedOptions[skipIndex]; + if (CheckOptions(_command, skipped) is {} opt) + { + _deserializer._skippedOptions.RemoveAt(skipIndex); + _deserializer._skipIndex = skipIndex; + return opt.FieldIndex; + } + } + _deserializer._skippedOptions.AddRange(_skippedOptions); + _skippedOptions.Clear(); + return ITypeDeserializer.EndOfType; } int ITypeDeserializer.TryReadIndex(ISerdeInfo info) { - var (fieldIndex, _) = TryReadIndexWithName(info); - return fieldIndex; + return TryReadIndex(info); } - private (int, string?) 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) - { - _deserializer._argIndex++; - return (option.FieldIndex, null); - } + return option; } } - // No option match, return missing - return (-1, 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 @@ -76,8 +170,7 @@ int ITypeDeserializer.TryReadIndex(ISerdeInfo info) { if (arg == subCmd.Name) { - _deserializer._argIndex++; - return (subCmd.FieldIndex, null); + return (subCmd.FieldIndex, true); } } @@ -87,7 +180,7 @@ int ITypeDeserializer.TryReadIndex(ISerdeInfo info) { if (name == arg) { - return (cmdGroup.FieldIndex, null); + return (cmdGroup.FieldIndex, false); } } @@ -100,14 +193,13 @@ 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, false); } } - return (-1, 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 63% rename from test/Serde.CmdLine.Test.csproj rename to test/Serde.CmdLine.Test/Serde.CmdLine.Test.csproj index 463c53a..84d9084 100644 --- a/test/Serde.CmdLine.Test.csproj +++ b/test/Serde.CmdLine.Test/Serde.CmdLine.Test.csproj @@ -7,6 +7,7 @@ false true + true @@ -18,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 db3bd61..0000000 --- a/test/SubCommandTests.cs +++ /dev/null @@ -1,90 +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 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; } - - [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