diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f6cb753 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,52 @@ +name: Build Projects + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + test: + runs-on: windows-latest + name: Unit Test + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@master + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 10.x + - name: Run unit tests + run: | + dotnet test + build: + runs-on: windows-latest + name: Build Project + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@master + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 10.x + - name: Build (debug) + run: | + dotnet build -c Debug + - name: Clean + run: | + dotnet clean + - name: Build (release) + run: | + dotnet build -c Release \ No newline at end of file diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 467bc8a..9dcc313 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -7,6 +7,23 @@ on: workflow_dispatch: jobs: + test: + runs-on: windows-latest + name: Unit Test + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@master + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 10.x + - name: Run unit tests + run: | + dotnet test build: runs-on: windows-latest strategy: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6a7d601..3af1121 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,23 @@ on: workflow_dispatch: jobs: + test: + runs-on: windows-latest + name: Unit Test + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@master + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 10.x + - name: Run unit tests + run: | + dotnet test build: runs-on: windows-latest strategy: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c6cca80 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Run Unit Tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + test: + runs-on: windows-latest + name: Unit Test + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@master + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.x + 10.x + - name: Run unit tests + run: | + dotnet test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5803b4a..c15a190 100644 --- a/.gitignore +++ b/.gitignore @@ -349,3 +349,6 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ publish.ps1 + +# IntelliJ +.idea diff --git a/kate.shared.sln b/kate.shared.sln index 68129e8..edfcbfa 100644 --- a/kate.shared.sln +++ b/kate.shared.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "kate.shared.CommandLine", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "kate.shared.EtoForms", "src\kate.shared.EtoForms\kate.shared.EtoForms.csproj", "{E6403316-5C2D-4C9B-BC92-380A8C3CD4C4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "kate.shared.CommandLine.Test", "src\kate.shared.CommandLine.Test\kate.shared.CommandLine.Test.csproj", "{8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,18 @@ Global {E6403316-5C2D-4C9B-BC92-380A8C3CD4C4}.Release|x64.Build.0 = Release|Any CPU {E6403316-5C2D-4C9B-BC92-380A8C3CD4C4}.Release|x86.ActiveCfg = Release|Any CPU {E6403316-5C2D-4C9B-BC92-380A8C3CD4C4}.Release|x86.Build.0 = Release|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Debug|x64.Build.0 = Debug|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Debug|x86.Build.0 = Debug|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Release|Any CPU.Build.0 = Release|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Release|x64.ActiveCfg = Release|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Release|x64.Build.0 = Release|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Release|x86.ActiveCfg = Release|Any CPU + {8A46A5DC-8A11-4728-9A8E-ABD2C9BA0540}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/kate.shared.CommandLine.Test/SimpleCommandTests.cs b/src/kate.shared.CommandLine.Test/SimpleCommandTests.cs new file mode 100644 index 0000000..f9c60c9 --- /dev/null +++ b/src/kate.shared.CommandLine.Test/SimpleCommandTests.cs @@ -0,0 +1,127 @@ +/* + Copyright 2022-2026 Kate Ward + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ +using System.CommandLine; +using JetBrains.Annotations; + +namespace kate.shared.CommandLine.Test; + +public class Tests +{ + [Test] + public void SimpleCommandImplicit() + { + var c = CommandLineHelper.GenerateCommand(); + var r = new RootCommand() + { + c + }; + var e = r.Parse(["test"]).Invoke(); + Assert.That(e, Is.EqualTo(0)); + } + + [Test] + public void SimpleCommandExplicit() + { + var c = CommandLineHelper.GenerateCommandExplicit(); + + var r = new RootCommand() + { + c + }; + var e = r.Parse(["test"]).Invoke(); + Assert.That(e, Is.EqualTo(0)); + } + + [Test] + public void SimpleCommandExplicitMultiToken() + { + var c = CommandLineHelper.GenerateCommandExplicit(); + + var r = new RootCommand() + { + c + }; + var e = r.Parse([ + "test", + "--array", "bweh1", + "--array", "test2", + "--array", "ur mom", + "--array", "HAHAHA X3!!!"]).Invoke(); + Assert.That(e, Is.EqualTo(0)); + } + + [UsedImplicitly] + [CommandAction("test", typeof(Options))] + public class TestAction1 : IAction + { + [UsedImplicitly] + public Task RunAsync(object options) + { + if (options is not Options) + throw new InvalidOperationException("Test failure - invalid type: " + options?.GetType()); + Console.WriteLine("Log from inside TestAction1"); + return Task.CompletedTask; + } + + [UsedImplicitly] + public class Options + { + } + } + + [UsedImplicitly] + [CommandAction("test", typeof(Options))] + public class TestAction2 : IAction + { + [UsedImplicitly] + public Task RunAsync(Options options) + { + Console.WriteLine("Log from inside TestAction2"); + return Task.CompletedTask; + } + + [UsedImplicitly] + public class Options + { + } + } + + [UsedImplicitly] + [CommandAction("test", typeof(Options))] + public class TestActionMulti3 : IAction + { + [UsedImplicitly] + public Task RunAsync(Options options) + { + Console.WriteLine("Log from inside TestActionMulti3"); + Assert.That(options, Is.Not.Null); + Assert.That(options.ArrayValue, Is.Not.Null); + Assert.That(options.ArrayValue, Has.Length.EqualTo(4)); + Assert.That(options.ArrayValue[0], Is.EqualTo("bweh1")); + Assert.That(options.ArrayValue[1], Is.EqualTo("test2")); + Assert.That(options.ArrayValue[2], Is.EqualTo("ur mom")); + Assert.That(options.ArrayValue[3], Is.EqualTo("HAHAHA X3!!!")); + return Task.CompletedTask; + } + + [UsedImplicitly] + public class Options + { + [ActionParameter("array", "Array", AllowMultipleArgumentsPerToken = true)] + public string[] ArrayValue { get; set; } = Array.Empty(); + } + } +} \ No newline at end of file diff --git a/src/kate.shared.CommandLine.Test/kate.shared.CommandLine.Test.csproj b/src/kate.shared.CommandLine.Test/kate.shared.CommandLine.Test.csproj new file mode 100644 index 0000000..64e4cd5 --- /dev/null +++ b/src/kate.shared.CommandLine.Test/kate.shared.CommandLine.Test.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/src/kate.shared.CommandLine/ActionParameterAliasAttribute.cs b/src/kate.shared.CommandLine/ActionParameterAliasAttribute.cs index 60508fd..f57a81f 100644 --- a/src/kate.shared.CommandLine/ActionParameterAliasAttribute.cs +++ b/src/kate.shared.CommandLine/ActionParameterAliasAttribute.cs @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Kate Ward + Copyright 2022-2026 Kate Ward Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,17 +15,22 @@ limitations under the License. */ using System; +using System.ComponentModel; +using JetBrains.Annotations; namespace kate.shared.CommandLine { /// /// Define multiple aliases for . /// + [PublicAPI] [AttributeUsage(AttributeTargets.Property, AllowMultiple = true, Inherited = false)] public class ActionParameterAliasAttribute : Attribute { public ActionParameterAliasAttribute(string alias, ActionParameterAliasKind kind) { + if (string.IsNullOrWhiteSpace(alias)) + throw new ArgumentException("Value cannot be null or whitespace", nameof(alias)); Alias = alias; CustomPrefix = null; Kind = kind; @@ -36,8 +41,13 @@ public ActionParameterAliasAttribute(string alias) { } + [NotNull] public string Alias { get; set; } + + [CanBeNull] public string CustomPrefix { get; set; } + + [DefaultValue(ActionParameterAliasKind.DoubleDash)] public ActionParameterAliasKind Kind { get; set; } } } \ No newline at end of file diff --git a/src/kate.shared.CommandLine/ActionParameterAliasKind.cs b/src/kate.shared.CommandLine/ActionParameterAliasKind.cs index 7d0f8e8..1c2cd4f 100644 --- a/src/kate.shared.CommandLine/ActionParameterAliasKind.cs +++ b/src/kate.shared.CommandLine/ActionParameterAliasKind.cs @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Kate Ward + Copyright 2022-2026 Kate Ward Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,11 +14,14 @@ You may obtain a copy of the License at limitations under the License. */ +using JetBrains.Annotations; + namespace kate.shared.CommandLine { /// /// Kind of alias for /// + [PublicAPI] public enum ActionParameterAliasKind { /// diff --git a/src/kate.shared.CommandLine/ActionParameterAttribute.cs b/src/kate.shared.CommandLine/ActionParameterAttribute.cs index 62e84a7..26cdd6a 100644 --- a/src/kate.shared.CommandLine/ActionParameterAttribute.cs +++ b/src/kate.shared.CommandLine/ActionParameterAttribute.cs @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Kate Ward + Copyright 2022-2026 Kate Ward Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,31 +17,36 @@ limitations under the License. using System; using System.Collections.Generic; using System.Text; +using JetBrains.Annotations; namespace kate.shared.CommandLine { + [PublicAPI] [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] public class ActionParameterAttribute : Attribute { /// /// Short-hand argument name, like h for help /// + [NotNull] public string ShortNameAlias { get; private set; } /// /// Parameter name, must not start with -- /// + [NotNull] public string Name { get; private set; } /// /// Help text to display for this parameter. /// + [NotNull] public string HelpText { get; private set; } /// /// Is this parameter required? (Default: ) /// - public bool IsRequired { get; set; } = true; + public bool IsRequired { get; set; } /// public bool AllowMultipleArgumentsPerToken { get; set; } diff --git a/src/kate.shared.CommandLine/CHANGELOG.md b/src/kate.shared.CommandLine/CHANGELOG.md index 3b233d3..96cfb7f 100644 --- a/src/kate.shared.CommandLine/CHANGELOG.md +++ b/src/kate.shared.CommandLine/CHANGELOG.md @@ -1,5 +1,12 @@ ## Next Release +- Add support for generic instances of `IAction` + - `IAction` now extends `IAction` to support this change +- Start working on unit tests for `kate.shared.CommandLine` +- Add dependency `JetBrains.Annotations` with version `2026.*` + +## v1.7.1 + - Reference `2.0.*` for package `System.CommandLine` ## v1.7.0 (12th Nov, 2025) diff --git a/src/kate.shared.CommandLine/CommandActionAttribute.cs b/src/kate.shared.CommandLine/CommandActionAttribute.cs index c980790..6033722 100644 --- a/src/kate.shared.CommandLine/CommandActionAttribute.cs +++ b/src/kate.shared.CommandLine/CommandActionAttribute.cs @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Kate Ward + Copyright 2022-2026 Kate Ward Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,12 +15,14 @@ limitations under the License. */ using System; +using JetBrains.Annotations; namespace kate.shared.CommandLine { /// /// Required attribute about details for /// + [PublicAPI] [AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] public class CommandActionAttribute : Attribute { @@ -28,6 +30,7 @@ public class CommandActionAttribute : Attribute /// Name of the action to use when invoking via command-line. /// public string ActionName { get; set; } + /// /// Type of the class where all the action-specific options are. /// diff --git a/src/kate.shared.CommandLine/CommandLineHelper.cs b/src/kate.shared.CommandLine/CommandLineHelper.cs index 657f6de..f319048 100644 --- a/src/kate.shared.CommandLine/CommandLineHelper.cs +++ b/src/kate.shared.CommandLine/CommandLineHelper.cs @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Kate Ward + Copyright 2022-2026 Kate Ward Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ limitations under the License. using System.Linq; using System.Reflection; using System.Threading.Tasks; +using JetBrains.Annotations; using ParseResult = System.CommandLine.ParseResult; namespace kate.shared.CommandLine @@ -41,8 +42,9 @@ public static class CommandLineHelper /// /// Help text for the command /// - /// Handler to be called when the generated command was ran. + /// Handler to be called when the generated command was run /// Instance of + [PublicAPI] public static Command GenerateCommand(string commandName, string commandHelpText, Func handler) where TOptions : class, new() { @@ -70,9 +72,13 @@ public static Command GenerateCommand(string commandName, string comma /// /// Help text for the command /// - /// Handler to be called when the generated command was ran. + /// Handler to be called when the generated command was run /// Instance of - public static Command GenerateCommand(string commandName, string commandHelpText, Func handler) + [PublicAPI] + public static Command GenerateCommand( + string commandName, + string commandHelpText, + Func handler) where TOptions : class, new() { return GenerateCommand(typeof(TOptions), commandName, commandHelpText, (obj, ctx) => @@ -81,14 +87,14 @@ public static Command GenerateCommand(string commandName, string comma { return handler(null, ctx); } - else if (obj is TOptions opts) + if (obj is TOptions opts) { return handler(opts, ctx); } throw new ArgumentException($"Not an instance of {typeof(TOptions)}", nameof(obj)); }); } - + /// /// Generate an instance of /// @@ -99,8 +105,9 @@ public static Command GenerateCommand(string commandName, string comma /// /// Help text for the command /// - /// Handler to be called when the generated command was ran. + /// Handler to be called when the generated command was run /// Instance of + [PublicAPI] public static Command GenerateCommand( Type optionsType, string commandName, @@ -109,48 +116,13 @@ public static Command GenerateCommand( { var argumentPropertyMap = new Dictionary(); var optionsProps = optionsType.GetProperties(BindingFlags.Instance | BindingFlags.Public); - var registeredParameterAliases = new List<(PropertyInfo Property, string Name, int? AliasAttributeIndex)>(); - string BuildAlias(ActionParameterAliasAttribute a) - { - if (a.Kind == ActionParameterAliasKind.DoubleDash) - { - return "--" + a.Alias.Trim(); - } - else if (a.Kind == ActionParameterAliasKind.SingleDash) - { - return "-" + a.Alias.Trim(); - } - else if (a.Kind == ActionParameterAliasKind.Custom && a.CustomPrefix != null) - { - return a.CustomPrefix + a.Alias.Trim(); - } - else - { - return a.Alias.Trim(); - } - } - string BuildName(ActionParameterAttribute a) - { - var name = a.Name.Trim().Replace(' ', '-'); - if (name.StartsWith("--") == false) - name = $"--{name}"; - return name; - } - string BuildShortName(ActionParameterAttribute a) - { - var shortName = a.ShortNameAlias.Trim(); - if (!shortName.StartsWith("-")) - { - shortName = "-" + shortName; - } - return shortName; - } + var registeredParameterAliases = new List(); foreach (var prop in optionsProps) { var actionParamAttr = prop.GetCustomAttribute(); if (actionParamAttr == null) continue; - var actionParamName = BuildName(actionParamAttr); - registeredParameterAliases.Add((prop, actionParamName, null)); + var actionParamName = BuildCommandName(actionParamAttr); + registeredParameterAliases.Add(new(prop, actionParamName, null)); if (registeredParameterAliases.Any(e => e.Property != prop && e.Name == actionParamName)) { @@ -159,8 +131,8 @@ string BuildShortName(ActionParameterAttribute a) if (!string.IsNullOrEmpty(actionParamAttr.ShortNameAlias)) { - var shortNameAlias = BuildShortName(actionParamAttr); - registeredParameterAliases.Add((prop, shortNameAlias, null)); + var shortNameAlias = BuildCommandShortName(actionParamAttr); + registeredParameterAliases.Add(new(prop, shortNameAlias, null)); if (registeredParameterAliases.Any(e => e.Property != prop && e.Name == shortNameAlias)) { throw new InvalidOperationException($"An argument alias called \"{shortNameAlias}\" in property {prop.Name} already exists on {optionsType.Namespace}.{optionsType.Name}"); @@ -175,8 +147,8 @@ string BuildShortName(ActionParameterAttribute a) { throw new InvalidOperationException($"Alias cannot be empty for {nameof(ActionParameterAliasAttribute)}[{i}] on property {prop.Name} in class {optionsType.Namespace}.{optionsType.Name}"); } - var a = BuildAlias(aliasAttr); - registeredParameterAliases.Add((prop, a, i)); + var a = BuildCommandAlias(aliasAttr); + registeredParameterAliases.Add(new(prop, a, i)); if (registeredParameterAliases.Any(e => e.Property != prop && e.Name == a)) { throw new InvalidOperationException($"An argument alias called \"{a}\" in property {prop.Name} already exists on {optionsType.Namespace}.{optionsType.Name}"); @@ -247,7 +219,7 @@ string[] GenerateAliases( { if (!string.IsNullOrEmpty(aliasAttr.Alias)) { - var a = BuildAlias(aliasAttr); + var a = BuildCommandAlias(aliasAttr); if (!argumentAliases.Contains(a)) { argumentAliases.Add(a); @@ -255,7 +227,7 @@ string[] GenerateAliases( } } - return argumentAliases.ToArray(); + return [.. argumentAliases]; } foreach (var prop in optionsProps) @@ -269,15 +241,9 @@ string[] GenerateAliases( argumentAliasesM.RemoveAt(0); var argumentInstance = Activator.CreateInstance( genericArgumentType, - argumentAliases.Length == 1 ? new object[] - { - argumentAliases[0], Array.Empty() - } - : new object[] - { - argumentAliases[0], - argumentAliasesM.ToArray() - }); + argumentAliases.Length == 1 + ? [ argumentAliases[0], Array.Empty() ] + : [ argumentAliases[0], argumentAliasesM.ToArray() ]); if (argumentInstance == null) { throw new InvalidOperationException($"Failed to create instance of {genericArgumentType}"); @@ -322,10 +288,7 @@ string[] GenerateAliases( }); if (addArgumentMethod != null) { - _ = addArgumentMethod.Invoke(cmd, new object[] - { - argValue - }); + _ = addArgumentMethod.Invoke(cmd, [ argValue ]); } } @@ -368,7 +331,7 @@ Task SetActionCallback(ParseResult ctx) FormatTypeName(typeof(ParseResult))); throw new ApplicationException(msg); } - var argumentValue = getValueMethodGeneric.Invoke(ctx, new object[] { argValue }); + var argumentValue = getValueMethodGeneric.Invoke(ctx, [ argValue ]); prop.SetValue(options, argumentValue); } } @@ -390,9 +353,14 @@ Task SetActionCallback(ParseResult ctx) /// /// Help text for the command /// - /// Handler to be called when the generated command was ran. + /// Handler to be called when the generated command was run /// Instance of - public static Command GenerateCommand(Type optionsType, string commandName, string commandHelpText, Func handler) + [PublicAPI] + public static Command GenerateCommand( + Type optionsType, + string commandName, + string commandHelpText, + Func handler) { return GenerateCommand( optionsType, @@ -417,9 +385,16 @@ public static Command GenerateCommand(Type optionsType, string commandName, stri /// /// Thrown when doesn't implement /// + [PublicAPI] public static Command GenerateCommand(Type optionsType, Type actionType) { - if (!typeof(IAction).IsAssignableFrom(actionType)) + var actionInts = actionType + .GetInterfaces(); + var genericActionType = actionInts + .Any(i => i.IsGenericType && i.GenericTypeArguments[0] == optionsType); + if (!typeof(IAction).IsAssignableFrom(actionType) && + !typeof(IAction<>).IsAssignableFrom(actionType) && + !genericActionType) { throw new ArgumentException($"Class must implement {nameof(IAction)}", nameof(actionType)); } @@ -428,7 +403,7 @@ public static Command GenerateCommand(Type optionsType, Type actionType) { var msg = string.Format("Attribute {0} does not exist on type {1}", FormatTypeName(typeof(CommandActionAttribute)), - actionType.ToString()); + actionType); throw new ArgumentException(msg, nameof(actionType)); } @@ -438,8 +413,30 @@ public static Command GenerateCommand(Type optionsType, Type actionType) cmdActionAttr.DisplayName, async (opts, ctx) => { - var actionInstance = (IAction)Activator.CreateInstance(actionType); - await actionInstance.RunAsync(opts); + if (typeof(IAction).IsAssignableFrom(actionType)) + { + var it = (IAction)Activator.CreateInstance(actionType); + await it!.RunAsync(opts); + return; + } + var instance = Activator.CreateInstance(actionType); + var method = actionType.GetMethods() + .Where((e) => + { + var prm = e.GetParameters(); + return e.Name == nameof(IAction.RunAsync) + && prm.Length == 1 && prm[0].ParameterType == optionsType + && (e.ReturnType == typeof(Task) || typeof(Task).IsAssignableFrom(e.ReturnType)); + }) + .FirstOrDefault(); + if (method == null) + { + throw new InvalidOperationException( + $"Could not find RunAsync method on type {actionType} with single parameter of type {optionsType}"); + } + + var task = (Task)method.Invoke(instance, [ opts ]); + await task!; }); } @@ -447,12 +444,62 @@ public static Command GenerateCommand(Type optionsType, Type actionType) /// /// /// + [PublicAPI] + public static Command GenerateCommandExplicit() + where TOptions : notnull, new() + where TAction : class, IAction + { + return GenerateCommand(typeof(TOptions), typeof(TAction)); + } + + [PublicAPI] public static Command GenerateCommand() - where TOptions : class - where TAction : class, IAction + where TOptions : notnull, new() + where TAction : class, IAction { return GenerateCommand(typeof(TOptions), typeof(TAction)); } + + private sealed record RegisteredParameterAliasRecord( + PropertyInfo Property, + string Name, + int? AliasAttributeIndex); + + private static string BuildCommandAlias(ActionParameterAliasAttribute a) + { + var alias = a.Alias.Trim(); + return a.Kind switch + { + ActionParameterAliasKind.NoPrefix => alias, + ActionParameterAliasKind.DoubleDash => "--" + alias, + ActionParameterAliasKind.SingleDash => "-" + alias, + ActionParameterAliasKind.Custom when a.CustomPrefix != null => a.CustomPrefix + alias, + _ => alias + }; + } + + private static string BuildCommandShortName(ActionParameterAttribute a) + { + var shortName = a.ShortNameAlias.Trim(); + if (!shortName.StartsWith('-')) + { + shortName = '-' + shortName; + } + return shortName; + } + + private static string BuildCommandName(ActionParameterAttribute a) + { + var name = a.Name.Trim(); + while (name.Contains(" ", StringComparison.Ordinal)) + { + name = name.Replace(" ", " "); + } + name = name.Replace(' ', '-'); + if (!name.StartsWith("--", StringComparison.Ordinal)) + name = "--" + name; + return name; + } private static string FormatTypeName(Type type) { diff --git a/src/kate.shared.CommandLine/IAction.cs b/src/kate.shared.CommandLine/IAction.cs index d6b224f..b3d0d8b 100644 --- a/src/kate.shared.CommandLine/IAction.cs +++ b/src/kate.shared.CommandLine/IAction.cs @@ -1,5 +1,5 @@ /* - Copyright 2022-2025 Kate Ward + Copyright 2022-2026 Kate Ward Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ limitations under the License. */ using System.Threading.Tasks; +using JetBrains.Annotations; namespace kate.shared.CommandLine { @@ -23,7 +24,19 @@ namespace kate.shared.CommandLine /// /// Inherited class must have /// - public interface IAction + [PublicAPI] + public interface IAction : IAction + { + } + + /// + /// Inherited by a class that can be used for a CLI action. + /// + /// Inherited class must have + /// + [PublicAPI] + public interface IAction + where T : notnull, new() { /// /// Run this action. @@ -31,6 +44,7 @@ public interface IAction /// /// Parsed command-line options. Will be deserialized into the type provided in /// - Task RunAsync(object options); + [UsedImplicitly] + Task RunAsync(T options); } } \ No newline at end of file diff --git a/src/kate.shared.CommandLine/kate.shared.CommandLine.csproj b/src/kate.shared.CommandLine/kate.shared.CommandLine.csproj index 81bce19..4d8e804 100644 --- a/src/kate.shared.CommandLine/kate.shared.CommandLine.csproj +++ b/src/kate.shared.CommandLine/kate.shared.CommandLine.csproj @@ -4,7 +4,7 @@ net8.0;net10.0 disable True - 1.7.1 + 1.7.1.1 Kate's Shared Library - System.CommandLine Utilities Copyright 2022-2026 Kate Ward <kate@dariox.club> @@ -36,6 +36,7 @@ +