diff --git a/README.md b/README.md index 214a522..35bcc24 100644 --- a/README.md +++ b/README.md @@ -248,6 +248,65 @@ Similar commands: - See also: [CoconaSample.GettingStarted.SubCommandApp](samples/GettingStarted.SubCommandApp) +##### Nested sub-commands + +Cocona also supports nested sub-commands. Specify the class that has nested sub-commands using `HasSubCommands` attribute. + +```csharp +[HasSubCommands(typeof(Server), Description = "Server commands")] +[HasSubCommands(typeof(Client), Description = "Client commands")] +class Program +{ + static void Main(string[] args) => CoconaApp.Run(args); + + // ./myapp info + public void Info() => Console.WriteLine("Show information"); +} + +// ./myapp server [command] +class Server +{ + public void Start() => Console.WriteLine("Start"); + public void Stop() => Console.WriteLine("Stop"); +} + +// ./myapp client [command] +class Client +{ + public void Connect() => Console.WriteLine("Connect"); + public void Disconnect() => Console.WriteLine("Disconnect"); +} +``` +```bash +$ ./SubCommandApp +Usage: SubCommandApp [command] +Usage: SubCommandApp [--help] [--version] + +SubCommandApp + +Commands: + info + server Server commands + client Client commands + +Options: + -h, --help Show help message + --version Show version + +$ ./SubCommandApp +Usage: SubCommandApp server [command] +Usage: SubCommandApp server [--help] + +SubCommandApp + +Commands: + start + stop + +Options: + -h, --help Show help message +``` + #### PrimaryCommand ```csharp [PrimaryCommand] diff --git a/samples/GettingStarted.SubCommandApp/Program.cs b/samples/GettingStarted.SubCommandApp/Program.cs index ac7f33e..8b9a063 100644 --- a/samples/GettingStarted.SubCommandApp/Program.cs +++ b/samples/GettingStarted.SubCommandApp/Program.cs @@ -3,6 +3,7 @@ namespace CoconaSample.GettingStarted.SubCommandApp { + [HasSubCommands(typeof(SubCommands), Description = "Nested sub-commands")] class Program { static void Main(string[] args) @@ -22,4 +23,34 @@ public void Bye([Option('l', Description = "Print a name converted to lower-case Console.WriteLine($"Goodbye {(toLowerCase ? name.ToLower() : name)}!"); } } + + // ./myapp sub-commands [command] + [HasSubCommands(typeof(SubSubCommands))] + class SubCommands + { + public void Konnichiwa() + { + Console.WriteLine("Konnichiwa!"); + } + + public void Hello() + { + Console.WriteLine("Hello!"); + } + } + + // ./myapp sub-commands sub-sub-commands [command] + class SubSubCommands + { + public void Foobar() + { + Console.WriteLine("Foobar!"); + } + + [PrimaryCommand] + public void Primary(string value) + { + Console.WriteLine($"value={value}"); + } + } } diff --git a/src/Cocona.Core/CoconaAppContext.cs b/src/Cocona.Core/CoconaAppContext.cs index 0c1c246..dc96d37 100644 --- a/src/Cocona.Core/CoconaAppContext.cs +++ b/src/Cocona.Core/CoconaAppContext.cs @@ -20,8 +20,14 @@ public class CoconaAppContext /// public CoconaAppFeatureCollection Features { get; } - public CoconaAppContext(CancellationToken cancellationToken) + /// + /// Gets a executing command. + /// + public CommandDescriptor ExecutingCommand { get; } + + public CoconaAppContext(CommandDescriptor command, CancellationToken cancellationToken) { + ExecutingCommand = command; CancellationToken = cancellationToken; Features = new CoconaAppFeatureCollection(); } diff --git a/src/Cocona.Core/Command/BuiltIn/BuiltInCommandMiddleware.cs b/src/Cocona.Core/Command/BuiltIn/BuiltInCommandMiddleware.cs index ae2a475..d3ab71a 100644 --- a/src/Cocona.Core/Command/BuiltIn/BuiltInCommandMiddleware.cs +++ b/src/Cocona.Core/Command/BuiltIn/BuiltInCommandMiddleware.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Cocona.Command.Features; namespace Cocona.Command.BuiltIn { @@ -16,14 +17,16 @@ public class BuiltInCommandMiddleware : CommandDispatcherMiddleware private readonly ICoconaCommandHelpProvider _commandHelpProvider; private readonly ICoconaCommandProvider _commandProvider; private readonly ICoconaConsoleProvider _console; + private readonly ICoconaAppContextAccessor _appContext; - public BuiltInCommandMiddleware(CommandDispatchDelegate next, ICoconaHelpRenderer helpRenderer, ICoconaCommandHelpProvider commandHelpProvider, ICoconaCommandProvider commandProvider, ICoconaConsoleProvider console) + public BuiltInCommandMiddleware(CommandDispatchDelegate next, ICoconaHelpRenderer helpRenderer, ICoconaCommandHelpProvider commandHelpProvider, ICoconaCommandProvider commandProvider, ICoconaConsoleProvider console, ICoconaAppContextAccessor appContext) : base(next) { _helpRenderer = helpRenderer; _commandHelpProvider = commandHelpProvider; _commandProvider = commandProvider; _console = console; + _appContext = appContext; } public override ValueTask DispatchAsync(CommandDispatchContext ctx) @@ -31,9 +34,12 @@ public override ValueTask DispatchAsync(CommandDispatchContext ctx) var hasHelpOption = ctx.ParsedCommandLine.Options.Any(x => x.Option == BuiltInCommandOption.Help); if (hasHelpOption) { + var feature = _appContext.Current!.Features.Get()!; + var commandCollection = feature.CommandCollection ?? _commandProvider.GetCommandCollection(); + var help = (ctx.Command.IsPrimaryCommand) - ? _commandHelpProvider.CreateCommandsIndexHelp(_commandProvider.GetCommandCollection()) - : _commandHelpProvider.CreateCommandHelp(ctx.Command); + ? _commandHelpProvider.CreateCommandsIndexHelp(commandCollection, feature.CommandStack) + : _commandHelpProvider.CreateCommandHelp(ctx.Command, feature.CommandStack); _console.Output.Write(_helpRenderer.Render(help)); return new ValueTask(129); diff --git a/src/Cocona.Core/Command/BuiltIn/BuiltInPrimaryCommand.cs b/src/Cocona.Core/Command/BuiltIn/BuiltInPrimaryCommand.cs index 057b051..7d8e274 100644 --- a/src/Cocona.Core/Command/BuiltIn/BuiltInPrimaryCommand.cs +++ b/src/Cocona.Core/Command/BuiltIn/BuiltInPrimaryCommand.cs @@ -2,21 +2,25 @@ using Cocona.Help; using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Text; +using Cocona.Command.Features; namespace Cocona.Command.BuiltIn { public class BuiltInPrimaryCommand { + private readonly ICoconaAppContextAccessor _appContext; private readonly ICoconaConsoleProvider _console; private readonly ICoconaCommandHelpProvider _commandHelpProvider; private readonly ICoconaHelpRenderer _helpRenderer; private readonly ICoconaCommandProvider _commandProvider; private static readonly MethodInfo _methodShowDefaultMessage = typeof(BuiltInPrimaryCommand).GetMethod(nameof(ShowDefaultMessage), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - public BuiltInPrimaryCommand(ICoconaConsoleProvider console, ICoconaCommandHelpProvider commandHelpProvider, ICoconaHelpRenderer helpRenderer, ICoconaCommandProvider commandProvider) + public BuiltInPrimaryCommand(ICoconaAppContextAccessor appContext, ICoconaConsoleProvider console, ICoconaCommandHelpProvider commandHelpProvider, ICoconaHelpRenderer helpRenderer, ICoconaCommandProvider commandProvider) { + _appContext = appContext; _console = console; _commandHelpProvider = commandHelpProvider; _helpRenderer = helpRenderer; @@ -34,13 +38,16 @@ public static CommandDescriptor GetCommand(string description) Array.Empty(), Array.Empty(), Array.Empty(), - CommandFlags.Primary + CommandFlags.Primary, + null ); } private void ShowDefaultMessage() { - _console.Output.Write(_helpRenderer.Render(_commandHelpProvider.CreateCommandsIndexHelp(_commandProvider.GetCommandCollection()))); + var commandStack = _appContext.Current!.Features.Get().CommandStack!; + var commandCollection = commandStack.LastOrDefault()?.SubCommands ?? _commandProvider.GetCommandCollection(); + _console.Output.Write(_helpRenderer.Render(_commandHelpProvider.CreateCommandsIndexHelp(commandCollection, commandStack))); } public static bool IsBuiltInCommand(CommandDescriptor command) diff --git a/src/Cocona.Core/Command/BuiltIn/CoconaBuiltInCommandProvider.cs b/src/Cocona.Core/Command/BuiltIn/CoconaBuiltInCommandProvider.cs index 63df092..4832d5d 100644 --- a/src/Cocona.Core/Command/BuiltIn/CoconaBuiltInCommandProvider.cs +++ b/src/Cocona.Core/Command/BuiltIn/CoconaBuiltInCommandProvider.cs @@ -8,24 +8,24 @@ namespace Cocona.Command.BuiltIn public class CoconaBuiltInCommandProvider : ICoconaCommandProvider { private readonly ICoconaCommandProvider _underlyingCommandProvider; - private readonly Lazy _commandCollection; + private CommandCollection? _cachedCommandCollection; public CoconaBuiltInCommandProvider(ICoconaCommandProvider underlyingCommandProvider) { _underlyingCommandProvider = underlyingCommandProvider; - _commandCollection = new Lazy(GetCommandCollectionCore); } public CommandCollection GetCommandCollection() - => _commandCollection.Value; + { + return _cachedCommandCollection ??= GetWrappedCommandCollection(_underlyingCommandProvider.GetCommandCollection()); + } - private CommandCollection GetCommandCollectionCore() + private CommandCollection GetWrappedCommandCollection(CommandCollection commandCollection, int depth = 0) { - var commandCollection = _underlyingCommandProvider.GetCommandCollection(); var commands = commandCollection.All; // If the collection has multiple-commands without primary command, use built-in primary command. - if (commandCollection.All.Count() > 1 && commandCollection.Primary == null) + if (commandCollection.All.Count > 1 && commandCollection.Primary == null) { commands = commands.Concat(new[] { BuiltInPrimaryCommand.GetCommand(string.Empty) }).ToArray(); } @@ -41,17 +41,18 @@ private CommandCollection GetCommandCollectionCore() command.Aliases, command.Description, command.Parameters, - GetParametersWithBuiltInOptions(command.Options, command.IsPrimaryCommand), + GetParametersWithBuiltInOptions(command.Options, command.IsPrimaryCommand, depth != 0), command.Arguments, command.Overloads, - command.Flags + command.Flags, + (command.SubCommands != null && command.SubCommands != commandCollection) ? GetWrappedCommandCollection(command.SubCommands, depth + 1) : command.SubCommands ); } return new CommandCollection(newCommands); } - private IReadOnlyList GetParametersWithBuiltInOptions(IReadOnlyList options, bool isPrimaryCommand) + private IReadOnlyList GetParametersWithBuiltInOptions(IReadOnlyList options, bool isPrimaryCommand, bool isNestedSubCommand) { var hasHelp = options.Any(x => string.Equals(x.Name, "help", StringComparison.OrdinalIgnoreCase) || x.ShortName.Any(x => x == 'h')); var hasVersion = options.Any(x => string.Equals(x.Name, "version", StringComparison.OrdinalIgnoreCase)); @@ -62,7 +63,7 @@ private IReadOnlyList GetParametersWithBuiltInOptions(I { newOptions = newOptions.Concat(new[] { BuiltInCommandOption.Help }); } - if (!hasVersion && isPrimaryCommand) + if (!hasVersion && isPrimaryCommand && !isNestedSubCommand) { newOptions = newOptions.Concat(new[] { BuiltInCommandOption.Version }); } diff --git a/src/Cocona.Core/Command/CoconaCommandProvider.cs b/src/Cocona.Core/Command/CoconaCommandProvider.cs index 496c2db..100ce9b 100644 --- a/src/Cocona.Core/Command/CoconaCommandProvider.cs +++ b/src/Cocona.Core/Command/CoconaCommandProvider.cs @@ -14,7 +14,6 @@ public class CoconaCommandProvider : ICoconaCommandProvider { private readonly Type[] _targetTypes; private static readonly Dictionary> _emptyOverloads = new Dictionary>(); - private readonly Lazy _commandCollection; private readonly bool _treatPublicMethodsAsCommands; private readonly bool _enableConvertOptionNameToLowerCase; private readonly bool _enableConvertCommandNameToLowerCase; @@ -22,23 +21,23 @@ public class CoconaCommandProvider : ICoconaCommandProvider public CoconaCommandProvider(Type[] targetTypes, bool treatPublicMethodsAsCommands = true, bool enableConvertOptionNameToLowerCase = false, bool enableConvertCommandNameToLowerCase = false) { _targetTypes = targetTypes ?? throw new ArgumentNullException(nameof(targetTypes)); - _commandCollection = new Lazy(GetCommandCollectionCore, LazyThreadSafetyMode.None); _treatPublicMethodsAsCommands = treatPublicMethodsAsCommands; _enableConvertOptionNameToLowerCase = enableConvertOptionNameToLowerCase; _enableConvertCommandNameToLowerCase = enableConvertCommandNameToLowerCase; } public CommandCollection GetCommandCollection() - => _commandCollection.Value; + => GetCommandCollectionCore(_targetTypes); [MethodImpl(MethodImplOptions.NoOptimization)] - private CommandCollection GetCommandCollectionCore() + private CommandCollection GetCommandCollectionCore(IReadOnlyList targetTypes) { var commandMethods = new List(10); var overloadCommandMethods = new Dictionary>(10); + var subCommandEntryPoints = new List(); // Command types - foreach (var type in _targetTypes) + foreach (var type in targetTypes) { if (type.IsAbstract || (type.IsGenericType && type.IsConstructedGenericType)) continue; @@ -73,9 +72,40 @@ private CommandCollection GetCommandCollectionCore() } } } + + // Nested sub-commands + var subCommandsAttrs = type.GetCustomAttributes(); + foreach (var subCommandsAttr in subCommandsAttrs) + { + if (subCommandsAttr.Type == type) throw new InvalidOperationException("Sub-commands type must not be same as command type."); + + var subCommands = GetCommandCollectionCore(new[] { subCommandsAttr.Type }); + var commandName = subCommandsAttr.Type.Name; + if (!string.IsNullOrWhiteSpace(subCommandsAttr.CommandName)) + { + commandName = subCommandsAttr.CommandName!; + } + + if (_enableConvertCommandNameToLowerCase) commandName = ToCommandCase(commandName); + + var dummyMethod = ((Action)(() => { })).Method; + var command = new CommandDescriptor( + dummyMethod, + commandName, + Array.Empty(), + subCommandsAttr.Description ?? subCommands.Description, + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + CommandFlags.SubCommandsEntryPoint, + subCommands + ); + subCommandEntryPoints.Add(command); + } } - var hasMultipleCommand = commandMethods.Count > 1; + var hasMultipleCommand = commandMethods.Count > 1 || subCommandEntryPoints.Count != 0; var commandNames = new HashSet(StringComparer.OrdinalIgnoreCase); var commands = new List(commandMethods.Count); foreach (var commandMethod in commandMethods) @@ -102,6 +132,8 @@ private CommandCollection GetCommandCollectionCore() commands.Add(command); } + commands.AddRange(subCommandEntryPoints); + return new CommandCollection(commands); } @@ -269,7 +301,8 @@ public CommandDescriptor CreateCommand(MethodInfo methodInfo, bool isSingleComma options, arguments, overloadDescriptors, - flags + flags, + null ); } diff --git a/src/Cocona.Core/Command/CommandDescriptor.cs b/src/Cocona.Core/Command/CommandDescriptor.cs index dd00930..1fa3116 100644 --- a/src/Cocona.Core/Command/CommandDescriptor.cs +++ b/src/Cocona.Core/Command/CommandDescriptor.cs @@ -26,6 +26,8 @@ public class CommandDescriptor public IReadOnlyList Arguments { get; } public IReadOnlyList Overloads { get; } + public CommandCollection? SubCommands { get; } + public CommandDescriptor( MethodInfo methodInfo, string name, @@ -35,7 +37,8 @@ public CommandDescriptor( IReadOnlyList options, IReadOnlyList arguments, IReadOnlyList overloads, - CommandFlags flags + CommandFlags flags, + CommandCollection? subCommands ) { Method = methodInfo ?? throw new ArgumentNullException(nameof(methodInfo)); @@ -47,6 +50,7 @@ CommandFlags flags Arguments = arguments ?? throw new ArgumentNullException(nameof(arguments)); Overloads = overloads ?? throw new ArgumentNullException(nameof(overloads)); Flags = flags; + SubCommands = subCommands; } } @@ -56,5 +60,6 @@ public enum CommandFlags None = 0, Primary = 1 << 0, Hidden = 1 << 1, + SubCommandsEntryPoint = 1 << 2, } } diff --git a/src/Cocona.Core/Command/Dispatcher/CoconaCommandDispatcher.cs b/src/Cocona.Core/Command/Dispatcher/CoconaCommandDispatcher.cs index 908763b..e865a4a 100644 --- a/src/Cocona.Core/Command/Dispatcher/CoconaCommandDispatcher.cs +++ b/src/Cocona.Core/Command/Dispatcher/CoconaCommandDispatcher.cs @@ -1,10 +1,12 @@ using Cocona.Application; using Cocona.CommandLine; using System; +using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using Cocona.Command.Features; namespace Cocona.Command.Dispatcher { @@ -44,9 +46,16 @@ public async ValueTask DispatchAsync(CancellationToken cancellationToken) { var commandCollection = _commandProvider.GetCommandCollection(); var args = _commandLineArgumentProvider.GetArguments(); + var subCommandStack = new List(); + Retry: var matchedCommand = default(CommandDescriptor); - if (commandCollection.All.Count > 1) + if (commandCollection.All.Count == 1 && !commandCollection.All[0].Flags.HasFlag(CommandFlags.SubCommandsEntryPoint)) + { + // single-command style + matchedCommand = commandCollection.All[0]; + } + else if (commandCollection.All.Count > 0) { // multi-commands hosted style if (_commandLineParser.TryGetCommandName(args, out var commandName)) @@ -62,6 +71,14 @@ public async ValueTask DispatchAsync(CancellationToken cancellationToken) // NOTE: Skip a first argument that is command name. args = args.Skip(1).ToArray(); + + // If the command have nested sub-commands, try to restart parse command. + if (matchedCommand.SubCommands != null) + { + commandCollection = matchedCommand.SubCommands; + subCommandStack.Add(matchedCommand); + goto Retry; + } } else { @@ -69,14 +86,6 @@ public async ValueTask DispatchAsync(CancellationToken cancellationToken) matchedCommand = commandCollection.Primary ?? throw new CommandNotFoundException("", commandCollection, "A primary command was not found."); } } - else - { - // single-command style - if (commandCollection.All.Any()) - { - matchedCommand = commandCollection.All[0]; - } - } // Found a command and dispatch. if (matchedCommand != null) @@ -92,14 +101,18 @@ public async ValueTask DispatchAsync(CancellationToken cancellationToken) var parsedCommandLine = _commandLineParser.ParseCommand(args, matchedCommand.Options, matchedCommand.Arguments); var dispatchAsync = _dispatcherPipelineBuilder.Build(); + // Activate a command type. + var commandInstance = _activator.GetServiceOrCreateInstance(_serviceProvider, matchedCommand.CommandType); + if (commandInstance == null) throw new InvalidOperationException($"Unable to activate command type '{matchedCommand.CommandType.FullName}'"); + // Set CoconaAppContext - _appContext.Current = new CoconaAppContext(cancellationToken); + _appContext.Current = new CoconaAppContext(matchedCommand, cancellationToken); + _appContext.Current.Features.Set(new CoconaCommandFeature(commandCollection, matchedCommand, subCommandStack, commandInstance)); - // Dispatch command. - var commandInstance = _activator.GetServiceOrCreateInstance(_serviceProvider, matchedCommand.CommandType); + // Dispatch the command try { - var ctx = new CommandDispatchContext(matchedCommand, parsedCommandLine, commandInstance!, cancellationToken); + var ctx = new CommandDispatchContext(matchedCommand, parsedCommandLine, commandInstance, cancellationToken); return await dispatchAsync(ctx); } finally diff --git a/src/Cocona.Core/Command/Dispatcher/CoconaCommandDispatcherPipelineBuilder.cs b/src/Cocona.Core/Command/Dispatcher/CoconaCommandDispatcherPipelineBuilder.cs index ef0a57c..e128bc5 100644 --- a/src/Cocona.Core/Command/Dispatcher/CoconaCommandDispatcherPipelineBuilder.cs +++ b/src/Cocona.Core/Command/Dispatcher/CoconaCommandDispatcherPipelineBuilder.cs @@ -108,7 +108,8 @@ public CommandDispatchDelegate Build() GetRequiredService(_serviceProvider), GetRequiredService(_serviceProvider), GetRequiredService(_serviceProvider), - GetRequiredService(_serviceProvider)); + GetRequiredService(_serviceProvider), + GetRequiredService(_serviceProvider)); next = m.DispatchAsync; continue; } diff --git a/src/Cocona.Core/Command/Features/CoconaCommandFeature.cs b/src/Cocona.Core/Command/Features/CoconaCommandFeature.cs new file mode 100644 index 0000000..b17b396 --- /dev/null +++ b/src/Cocona.Core/Command/Features/CoconaCommandFeature.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Cocona.Command.Features +{ + public interface ICoconaCommandFeature + { + object CommandInstance { get; } + CommandDescriptor Command { get; } + CommandCollection CommandCollection { get; } + IReadOnlyList CommandStack { get; } + } + + public class CoconaCommandFeature : ICoconaCommandFeature + { + public object CommandInstance { get; } + public CommandDescriptor Command { get; } + public CommandCollection CommandCollection { get; } + public IReadOnlyList CommandStack { get; } + + public CoconaCommandFeature(CommandCollection commandCollection, CommandDescriptor commandDescriptor, IReadOnlyList commandStack, object commandInstance) + { + CommandCollection = commandCollection ?? throw new ArgumentNullException(nameof(commandCollection)); + Command = commandDescriptor ?? throw new ArgumentNullException(nameof(commandDescriptor)); + CommandInstance = commandInstance ?? throw new ArgumentNullException(nameof(commandInstance)); + CommandStack = commandStack ?? throw new ArgumentNullException(nameof(commandStack)); + } + } +} diff --git a/src/Cocona.Core/Command/ICoconaCommandProvider.cs b/src/Cocona.Core/Command/ICoconaCommandProvider.cs index 11c36ba..4c57c30 100644 --- a/src/Cocona.Core/Command/ICoconaCommandProvider.cs +++ b/src/Cocona.Core/Command/ICoconaCommandProvider.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; namespace Cocona.Command { diff --git a/src/Cocona.Core/HasSubCommandsAttribute.cs b/src/Cocona.Core/HasSubCommandsAttribute.cs new file mode 100644 index 0000000..745737d --- /dev/null +++ b/src/Cocona.Core/HasSubCommandsAttribute.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Cocona +{ + /// + /// Specifies a class has a nested sub-commands. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] + public class HasSubCommandsAttribute : Attribute + { + /// + /// Gets the sub-commands collection type. + /// + public Type Type { get; } + + /// + /// Gets the sub-commands name. + /// + public string? CommandName { get; } + + /// + /// Gets or sets the sub-commands description. + /// + public string? Description { get; set; } + + public HasSubCommandsAttribute(Type commandsType, string? commandName = null) + { + Type = commandsType ?? throw new ArgumentNullException(nameof(commandsType)); + CommandName = commandName; + } + } + +} diff --git a/src/Cocona.Core/Help/CoconaCommandHelpProvider.cs b/src/Cocona.Core/Help/CoconaCommandHelpProvider.cs index 076e277..1057d96 100644 --- a/src/Cocona.Core/Help/CoconaCommandHelpProvider.cs +++ b/src/Cocona.Core/Help/CoconaCommandHelpProvider.cs @@ -22,11 +22,17 @@ public CoconaCommandHelpProvider(ICoconaApplicationMetadataProvider applicationM _serviceProvider = serviceProvider; } - private string CreateUsageCommandOptionsAndArgs(CommandDescriptor command) + private string CreateUsageCommandOptionsAndArgs(CommandDescriptor command, IReadOnlyList subCommandStack) { var sb = new StringBuilder(); sb.Append(_applicationMetadataProvider.GetExecutableName()); + if (subCommandStack.Count > 0) + { + sb.Append(" "); + sb.Append(string.Join(" ", subCommandStack.Select(x => x.Name))); + } + if (!command.IsPrimaryCommand) { sb.Append(" "); @@ -79,12 +85,12 @@ private string CreateUsageCommandOptionsAndArgs(CommandDescriptor command) return sb.ToString(); } - public HelpMessage CreateCommandHelp(CommandDescriptor command) + public HelpMessage CreateCommandHelp(CommandDescriptor command, IReadOnlyList subCommandStack) { var help = new HelpMessage(); // Usage - help.Children.Add(new HelpSection(HelpSectionId.Usage, new HelpUsage($"Usage: {CreateUsageCommandOptionsAndArgs(command)}"))); + help.Children.Add(new HelpSection(HelpSectionId.Usage, new HelpUsage($"Usage: {CreateUsageCommandOptionsAndArgs(command, subCommandStack)}"))); // Description if (!string.IsNullOrWhiteSpace(command.Description)) @@ -108,19 +114,20 @@ public HelpMessage CreateCommandHelp(CommandDescriptor command) return help; } - public HelpMessage CreateCommandsIndexHelp(CommandCollection commandCollection) + public HelpMessage CreateCommandsIndexHelp(CommandCollection commandCollection, IReadOnlyList subCommandStack) { var help = new HelpMessage(); // Usage var usageSection = new HelpSection(HelpSectionId.Usage); + var subCommandParams = (subCommandStack.Count > 0) ? string.Join(" ", subCommandStack.Select(x => x.Name)) + " " : ""; if (commandCollection.All.Count != 1) { - usageSection.Children.Add(new HelpUsage($"Usage: {_applicationMetadataProvider.GetExecutableName()} [command]")); + usageSection.Children.Add(new HelpUsage($"Usage: {_applicationMetadataProvider.GetExecutableName()} {subCommandParams}[command]")); } if (commandCollection.Primary != null && (commandCollection.All.Count == 1 || commandCollection.Primary.Options.Any() || commandCollection.Primary.Arguments.Any())) { - usageSection.Children.Add(new HelpUsage($"Usage: {CreateUsageCommandOptionsAndArgs(commandCollection.Primary)}")); + usageSection.Children.Add(new HelpUsage($"Usage: {CreateUsageCommandOptionsAndArgs(commandCollection.Primary, subCommandStack)}")); } help.Children.Add(usageSection); diff --git a/src/Cocona.Core/Help/ICoconaCommandHelpProvider.cs b/src/Cocona.Core/Help/ICoconaCommandHelpProvider.cs index 2a3c7b6..e3b2194 100644 --- a/src/Cocona.Core/Help/ICoconaCommandHelpProvider.cs +++ b/src/Cocona.Core/Help/ICoconaCommandHelpProvider.cs @@ -1,12 +1,13 @@ -using Cocona.Command; +using System.Collections.Generic; +using Cocona.Command; using Cocona.Help.DocumentModel; namespace Cocona.Help { public interface ICoconaCommandHelpProvider { - HelpMessage CreateCommandHelp(CommandDescriptor command); - HelpMessage CreateCommandsIndexHelp(CommandCollection commandCollection); + HelpMessage CreateCommandHelp(CommandDescriptor command, IReadOnlyList subCommandStack); + HelpMessage CreateCommandsIndexHelp(CommandCollection commandCollection, IReadOnlyList subCommandStack); HelpMessage CreateVersionHelp(); } } diff --git a/test/Cocona.Lite.Test/Cocona.Lite.Test.csproj b/test/Cocona.Lite.Test/Cocona.Lite.Test.csproj index 2cffc7b..95bd96c 100644 --- a/test/Cocona.Lite.Test/Cocona.Lite.Test.csproj +++ b/test/Cocona.Lite.Test/Cocona.Lite.Test.csproj @@ -8,6 +8,18 @@ 1701;1702;CS1998 + + TRACE;COCONA_LITE + + + + TRACE;COCONA_LITE + + + + + + @@ -20,4 +32,8 @@ + + + + diff --git a/test/Cocona.Lite.Test/Integration/EndToEndTest.cs b/test/Cocona.Lite.Test/Integration/EndToEndTest.cs deleted file mode 100644 index 375a152..0000000 --- a/test/Cocona.Lite.Test/Integration/EndToEndTest.cs +++ /dev/null @@ -1,241 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Cocona; -using Cocona.Application; -using FluentAssertions; -using Xunit; - -namespace Cocona.Lite.Test.Integration -{ - [Collection("End to End")] // NOTE: Test cases use `Console` and does not run in parallel. - public class EndToEndTest - { - private (string StandardOut, string StandardError, int ExitCode) Run(string[] args) - { - var stdOutWriter = new StringWriter(); - var stdErrWriter = new StringWriter(); - - Console.SetOut(stdOutWriter); - Console.SetError(stdErrWriter); - - CoconaLiteApp.Create() - .Run(args); - - return (stdOutWriter.ToString(), stdErrWriter.ToString(), Environment.ExitCode); - } - - private async Task<(string StandardOut, string StandardError, int ExitCode)> RunAsync(string[] args, CancellationToken cancellationToken) - { - var stdOutWriter = new StringWriter(); - var stdErrWriter = new StringWriter(); - - Console.SetOut(stdOutWriter); - Console.SetError(stdErrWriter); - - await CoconaLiteApp.Create() - .RunAsync(args, cancellationToken: cancellationToken); - - return (stdOutWriter.ToString(), stdErrWriter.ToString(), Environment.ExitCode); - } - - private (string StandardOut, string StandardError, int ExitCode) Run(string[] args, Type[] types) - { - var stdOutWriter = new StringWriter(); - var stdErrWriter = new StringWriter(); - - Console.SetOut(stdOutWriter); - Console.SetError(stdErrWriter); - - CoconaLiteApp.Create() - .Run(args, types); - - return (stdOutWriter.ToString(), stdErrWriter.ToString(), Environment.ExitCode); - } - - private async Task<(string StandardOut, string StandardError, int ExitCode)> RunAsync(string[] args, Type[] types, CancellationToken cancellationToken) - { - var stdOutWriter = new StringWriter(); - var stdErrWriter = new StringWriter(); - - Console.SetOut(stdOutWriter); - Console.SetError(stdErrWriter); - - await CoconaLiteApp.Create() - .RunAsync(args, types, cancellationToken: cancellationToken); - - return (stdOutWriter.ToString(), stdErrWriter.ToString(), Environment.ExitCode); - } - - - [Fact] - public void CoconaApp_Run_Single() - { - var (stdOut, stdErr, exitCode) = Run(new string[] { }); - - stdOut.Should().Be("Hello Konnichiwa!" + Environment.NewLine); - exitCode.Should().Be(0); - } - - class TestCommand_Single - { - public void Hello() - { - Console.WriteLine("Hello Konnichiwa!"); - } - } - - [Fact] - public void CoconaApp_Run_Multiple_Command() - { - var (stdOut, stdErr, exitCode) = Run(new string[] { "konnichiwa" }); - - stdOut.Should().Be("Konnichiwa!" + Environment.NewLine); - exitCode.Should().Be(0); - } - - [Fact] - public void CoconaApp_Run_Multiple_Index() - { - var (stdOut, stdErr, exitCode) = Run(new string[] { }); - - stdOut.Should().Contain("Usage:"); - stdOut.Should().Contain("Commands:"); - stdOut.Should().Contain(" konnichiwa"); - stdOut.Should().Contain(" hello"); - exitCode.Should().Be(0); - } - - [Fact] - public void CoconaApp_Run_Multiple_ExitCode() - { - var (stdOut, stdErr, exitCode) = Run(new string[] { "exit-code" }); - - stdOut.Should().Contain("ExitCode=128"); - exitCode.Should().Be(128); - } - - [Fact] - public void CoconaApp_Run_Multiple_CommandMissing() - { - var (stdOut, stdErr, exitCode) = Run(new string[] {"axit-mode"}); - - stdOut.Should().BeEmpty(); - stdErr.Should().Contain("Similar"); - stdErr.Should().Contain("exit-code"); - exitCode.Should().Be(1); - } - - [Fact] - public async Task CoconaApp_Run_Multiple_Task() - { - var (stdOut, stdErr, exitCode) = (await RunAsync(new string[] { "long-running" }, new CancellationTokenSource(1000).Token)); - - stdOut.Should().Contain("Begin"); - stdOut.Should().Contain("Canceled"); - stdErr.Should().BeEmpty(); - exitCode.Should().Be(127); - } - - [Fact] - public void CoconaApp_Run_MultipleClass() - { - var (stdOut, stdErr, exitCode) = Run(new string[] { }, new [] { typeof(TestCommand_Multiple), typeof(TestCommand2) }); - - stdOut.Should().Contain("exit-code"); - stdOut.Should().Contain("foo-bar"); - stdErr.Should().BeEmpty(); - exitCode.Should().Be(0); - } - - [Fact] - public void CoconaApp_Run_ArgTest_1() - { - var (stdOut, stdErr, exitCode) = Run(new string[] { "arg-test", "Alice" }); - - stdOut.Should().Contain("Hello Alice (17)!"); - stdErr.Should().BeEmpty(); - exitCode.Should().Be(0); - } - - [Fact] - public void CoconaApp_Run_ArgTest_2() - { - var (stdOut, stdErr, exitCode) = Run(new string[] { "arg-test", "Karen", "18" }); - - stdOut.Should().Contain("Hello Karen (18)!"); - stdErr.Should().BeEmpty(); - exitCode.Should().Be(0); - } - - [Fact] - public void CoconaApp_Run_OptionTest_1() - { - var (stdOut, stdErr, exitCode) = Run(new string[] { "option-test", "--name", "Alice" }); - - stdOut.Should().Contain("Hello Alice (17)!"); - stdErr.Should().BeEmpty(); - exitCode.Should().Be(0); - } - - [Fact] - public void CoconaApp_Run_OptionTest_2() - { - var (stdOut, stdErr, exitCode) = Run(new string[] { "option-test", "--name", "Karen", "-a", "18" }); - - stdOut.Should().Contain("Hello Karen (18)!"); - stdErr.Should().BeEmpty(); - exitCode.Should().Be(0); - } - - class TestCommand_Multiple - { - public void Hello() - { - Console.WriteLine("Hello!"); - } - - public void Konnichiwa() - { - Console.WriteLine("Konnichiwa!"); - } - - public int ExitCode() - { - Console.WriteLine("ExitCode=128"); - return 128; - } - - public async Task LongRunning([FromService]ICoconaAppContextAccessor context) - { - Console.WriteLine("Begin"); - while (!context.Current.CancellationToken.IsCancellationRequested) - { - await Task.Delay(1); - } - Console.WriteLine("Canceled"); - - return 127; - } - - public void ArgTest([Argument] string name, [Argument]int age = 17) - { - Console.WriteLine($"Hello {name} ({age})!"); - } - - public void OptionTest([Option] string name, [Option('a')]int age = 17) - { - Console.WriteLine($"Hello {name} ({age})!"); - } - } - - class TestCommand2 - { - public void FooBar() { } - } - } -} diff --git a/test/Cocona.Test/Command/CommandDispatcher/CommandMatcherTest.cs b/test/Cocona.Test/Command/CommandDispatcher/CommandMatcherTest.cs index ca7723b..3fe56f9 100644 --- a/test/Cocona.Test/Command/CommandDispatcher/CommandMatcherTest.cs +++ b/test/Cocona.Test/Command/CommandDispatcher/CommandMatcherTest.cs @@ -28,7 +28,8 @@ private CommandDescriptor CreateCommand(string name, ICommandParameterDescriptor parameterDescriptors.OfType().ToArray(), parameterDescriptors.OfType().ToArray(), overloads ?? Array.Empty(), - isPrimaryCommand ? CommandFlags.Primary : CommandFlags.None + isPrimaryCommand ? CommandFlags.Primary : CommandFlags.None, + null ); } diff --git a/test/Cocona.Test/Command/CommandDispatcher/DispatcherTest.cs b/test/Cocona.Test/Command/CommandDispatcher/DispatcherTest.cs index 0152e71..5123291 100644 --- a/test/Cocona.Test/Command/CommandDispatcher/DispatcherTest.cs +++ b/test/Cocona.Test/Command/CommandDispatcher/DispatcherTest.cs @@ -38,6 +38,14 @@ private ServiceCollection CreateDefaultServices(string[] args) services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; } @@ -110,6 +118,68 @@ public async Task CommandNotFound_Multiple_Primary() ex.ImplementedCommands.Primary.Should().BeNull(); } + [Fact] + public async Task NestedCommand_TopLevel() + { + var services = CreateDefaultServices(new string[] { "A" }); + var serviceProvider = services.BuildServiceProvider(); + + var nestedCommand = serviceProvider.GetService(); + var nestedCommandNested = serviceProvider.GetService(); + var dispatcher = serviceProvider.GetService(); + var result = await dispatcher.DispatchAsync(); + + nestedCommand.Log.Should().Contain("A"); + nestedCommandNested.Log.Should().BeEmpty(); + } + + [Fact] + public async Task NestedCommand_Nested() + { + var services = CreateDefaultServices(new string[] { "TestNestedCommand_Nested", "B" }); + var serviceProvider = services.BuildServiceProvider(); + + var nestedCommand = serviceProvider.GetService(); + var nestedCommandNested = serviceProvider.GetService(); + var dispatcher = serviceProvider.GetService(); + var result = await dispatcher.DispatchAsync(); + + nestedCommand.Log.Should().BeEmpty(); + nestedCommandNested.Log.Should().Contain("B"); + } + + [Fact] + public async Task NestedCommand_Primary() + { + var services = CreateDefaultServices(new string[] { "TestNestedCommand_Primary_Nested" }); + var serviceProvider = services.BuildServiceProvider(); + + var nestedCommand = serviceProvider.GetService(); + var nestedCommandNested = serviceProvider.GetService(); + var dispatcher = serviceProvider.GetService(); + var result = await dispatcher.DispatchAsync(); + + nestedCommand.Log.Should().BeEmpty(); + nestedCommandNested.Log.Should().Contain("B"); + } + + [Fact] + public async Task DeepNestedCommand() + { + var services = CreateDefaultServices(new string[] { "TestDeepNestedCommand_Nested", "TestDeepNestedCommand_Nested_2", "C" }); + var serviceProvider = services.BuildServiceProvider(); + + var nestedCommand = serviceProvider.GetService(); + var nestedCommandNested = serviceProvider.GetService(); + var nestedCommandNested2 = serviceProvider.GetService(); + var dispatcher = serviceProvider.GetService(); + var result = await dispatcher.DispatchAsync(); + + nestedCommand.Log.Should().BeEmpty(); + nestedCommandNested.Log.Should().BeEmpty(); + nestedCommandNested2.Log.Should().Contain("C"); + } + public class NoCommand { } @@ -152,5 +222,62 @@ public void B(bool option0, [Argument]string arg0) Log.Add($"{nameof(TestMultipleCommand.B)}:{nameof(option0)} -> {option0}"); } } + + [HasSubCommands(typeof(TestNestedCommand_Nested))] + public class TestNestedCommand + { + public List Log { get; } = new List(); + + public void A() => Log.Add($"{nameof(TestNestedCommand.A)}"); + public void Dummy() { } + + public class TestNestedCommand_Nested + { + public List Log { get; } = new List(); + + public void B() => Log.Add($"{nameof(TestNestedCommand_Nested.B)}"); + public void C() => Log.Add($"{nameof(TestNestedCommand_Nested.C)}"); + } + } + + + [HasSubCommands(typeof(TestNestedCommand_Primary_Nested))] + public class TestNestedCommand_Primary + { + public List Log { get; } = new List(); + + public class TestNestedCommand_Primary_Nested + { + public List Log { get; } = new List(); + + public void B() => Log.Add($"{nameof(TestNestedCommand_Primary_Nested.B)}"); + } + } + + [HasSubCommands(typeof(TestDeepNestedCommand_Nested))] + public class TestDeepNestedCommand + { + public List Log { get; } = new List(); + + public void A() => Log.Add($"{nameof(TestDeepNestedCommand.A)}"); + public void Dummy() { } + + [HasSubCommands(typeof(TestDeepNestedCommand_Nested_2))] + public class TestDeepNestedCommand_Nested + { + public List Log { get; } = new List(); + + public void B() => Log.Add($"{nameof(TestDeepNestedCommand_Nested.B)}"); + public void Dummy() { } + } + + public class TestDeepNestedCommand_Nested_2 + { + public List Log { get; } = new List(); + + public void C() => Log.Add($"{nameof(TestDeepNestedCommand_Nested_2.C)}"); + public void Dummy() { } + } + } } } diff --git a/test/Cocona.Test/Command/CommandProvider/CommandOverloadTest.cs b/test/Cocona.Test/Command/CommandProvider/CommandOverloadTest.cs index cbd3a31..a58b903 100644 --- a/test/Cocona.Test/Command/CommandProvider/CommandOverloadTest.cs +++ b/test/Cocona.Test/Command/CommandProvider/CommandOverloadTest.cs @@ -1,4 +1,4 @@ -using Cocona.Command; +using Cocona.Command; using FluentAssertions; using System; using System.Collections.Generic; diff --git a/test/Cocona.Test/Command/CommandProvider/NestedSubCommandTest.cs b/test/Cocona.Test/Command/CommandProvider/NestedSubCommandTest.cs new file mode 100644 index 0000000..d76273a --- /dev/null +++ b/test/Cocona.Test/Command/CommandProvider/NestedSubCommandTest.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Cocona.Command; +using FluentAssertions; +using Xunit; + +namespace Cocona.Test.Command.CommandProvider +{ + public class NestedSubCommandTest + { + [Fact] + public void GetCommandCollection() + { + var provider = new CoconaCommandProvider(new[] { typeof(TestCommand_TopLevel) }); + var commands = provider.GetCommandCollection(); + commands.All.Should().HaveCount(3); + commands.All[2].SubCommands.Should().NotBeNull(); // Hello, SubCommand, NestedSubCommand + commands.All[2].Name.Should().Be("NestedSubCommand"); + commands.All[2].Flags.Should().Be(CommandFlags.SubCommandsEntryPoint); + + commands.All[2].SubCommands.Primary.Should().BeNull(); + commands.All[2].SubCommands.All.Should().HaveCount(2); + commands.All[2].SubCommands.All[0].Name.Should().Be("SubSubCommand1"); + } + + [HasSubCommands(typeof(NestedSubCommand))] + class TestCommand_TopLevel + { + public void Hello() + { } + + public void SubCommand() + { } + + class NestedSubCommand + { + public void SubSubCommand1() + { } + public void SubSubCommand2() + { } + } + } + + [Fact] + public void GetCommandCollection_Single() + { + var provider = new CoconaCommandProvider(new[] { typeof(TestCommand_Single_TopLevel) }); + var commands = provider.GetCommandCollection(); + commands.All.Should().HaveCount(3); + commands.All[2].SubCommands.Should().NotBeNull(); // Hello, SubCommand, NestedSubCommand + commands.All[2].Name.Should().Be("NestedSubCommand"); + commands.All[2].Flags.Should().Be(CommandFlags.SubCommandsEntryPoint); + + commands.All[2].SubCommands.Primary.Should().NotBeNull(); + commands.All[2].SubCommands.All.Should().HaveCount(1); + commands.All[2].SubCommands.All[0].Name.Should().Be("SubSubCommand1"); + } + + [HasSubCommands(typeof(NestedSubCommand))] + class TestCommand_Single_TopLevel + { + public void Hello() + { } + + public void SubCommand() + { } + + class NestedSubCommand + { + public void SubSubCommand1() + { } + } + } + + [Fact] + public void GetCommandCollection_Deep() + { + var provider = new CoconaCommandProvider(new[] { typeof(TestCommand_Deep) }); + var commands = provider.GetCommandCollection(); + commands.All.Should().HaveCount(3); + commands.All[2].SubCommands.Should().NotBeNull(); // Hello, SubCommand, NestedSubCommand + commands.All[2].Name.Should().Be("NestedSubCommand"); + commands.All[2].Flags.Should().Be(CommandFlags.SubCommandsEntryPoint); + + commands.All[2].SubCommands.Primary.Should().BeNull(); + commands.All[2].SubCommands.All.Should().HaveCount(3); // SubSubCommand1, SubSubCommand2, NestedSubCommand2 + commands.All[2].SubCommands.All[0].Name.Should().Be("SubSubCommand1"); + commands.All[2].SubCommands.All[1].Name.Should().Be("SubSubCommand2"); + commands.All[2].SubCommands.All[2].Name.Should().Be("NestedSubCommand2"); + commands.All[2].SubCommands.All[2].SubCommands.Should().NotBeNull(); + commands.All[2].SubCommands.All[2].SubCommands.All.Should().HaveCount(2); // SubSubSubCommand1, SubSubSubCommand2 + commands.All[2].SubCommands.All[2].SubCommands.All[0].Name.Should().Be("SubSubSubCommand1"); + commands.All[2].SubCommands.All[2].SubCommands.All[1].Name.Should().Be("SubSubSubCommand2"); + } + + [HasSubCommands(typeof(NestedSubCommand))] + class TestCommand_Deep + { + public void Hello() + { } + + public void SubCommand() + { } + + [HasSubCommands(typeof(NestedSubCommand2))] + class NestedSubCommand + { + public void SubSubCommand1() + { } + public void SubSubCommand2() + { } + + class NestedSubCommand2 + { + public void SubSubSubCommand1() + { } + public void SubSubSubCommand2() + { } + } + } + } + + [Fact] + public void CommandName() + { + var provider = new CoconaCommandProvider(new[] { typeof(TestCommand_CommandName) }); + var commands = provider.GetCommandCollection(); + commands.All.Should().HaveCount(3); + commands.All[2].SubCommands.Should().NotBeNull(); // Hello, SubCommand, NestedSubCommand + commands.All[2].Name.Should().Be("NESTED"); + commands.All[2].Flags.Should().Be(CommandFlags.SubCommandsEntryPoint); + + commands.All[2].SubCommands.Primary.Should().BeNull(); + commands.All[2].SubCommands.All.Should().HaveCount(2); + } + + [HasSubCommands(typeof(NestedSubCommand), "NESTED")] + class TestCommand_CommandName + { + public void Hello() + { } + + public void SubCommand() + { } + + class NestedSubCommand + { + public void SubSubCommand1() + { } + public void SubSubCommand2() + { } + } + } + + [Fact] + public void SingleMethod_NotPrimary() + { + var provider = new CoconaCommandProvider(new[] { typeof(TestCommand_SingleMethod_NotPrimary) }); + var commands = provider.GetCommandCollection(); + commands.Primary.Should().BeNull(); + commands.All[0].IsPrimaryCommand.Should().BeFalse(); + commands.All.Should().HaveCount(2); + commands.All[1].SubCommands.Should().NotBeNull(); // Hello, SubCommand, NestedSubCommand + commands.All[1].Name.Should().Be("NestedSubCommand"); + commands.All[1].Flags.Should().Be(CommandFlags.SubCommandsEntryPoint); + } + + [HasSubCommands(typeof(NestedSubCommand))] + class TestCommand_SingleMethod_NotPrimary + { + public void Hello() + { } + + class NestedSubCommand + { + public void SubSubCommand1() + { } + public void SubSubCommand2() + { } + } + } + + [Fact] + public void HasManyNestedCommands() + { + var provider = new CoconaCommandProvider(new[] { typeof(TestCommand_HasManyNestedCommands) }); + var commands = provider.GetCommandCollection(); + commands.All.Should().HaveCount(4); + commands.All[2].SubCommands.Should().NotBeNull(); // Hello, SubCommand, NestedSubCommand + commands.All[2].Name.Should().Be("nested1"); + commands.All[2].Flags.Should().Be(CommandFlags.SubCommandsEntryPoint); + commands.All[3].SubCommands.Should().NotBeNull(); // Hello, SubCommand, NestedSubCommand + commands.All[3].Name.Should().Be("nested2"); + commands.All[3].Flags.Should().Be(CommandFlags.SubCommandsEntryPoint); + } + + [HasSubCommands(typeof(NestedSubCommand), "nested1")] + [HasSubCommands(typeof(NestedSubCommand), "nested2")] + class TestCommand_HasManyNestedCommands + { + public void Hello() + { } + + public void SubCommand() + { } + + class NestedSubCommand + { + public void SubSubCommand1() + { } + public void SubSubCommand2() + { } + } + } + } +} diff --git a/test/Cocona.Test/Command/ParameterBinder/BindParameterTest.cs b/test/Cocona.Test/Command/ParameterBinder/BindParameterTest.cs index b99c364..7a9d753 100644 --- a/test/Cocona.Test/Command/ParameterBinder/BindParameterTest.cs +++ b/test/Cocona.Test/Command/ParameterBinder/BindParameterTest.cs @@ -41,7 +41,8 @@ private CommandDescriptor CreateCommand(ICommandParameterDescriptor[] parameterD parameterDescriptors.OfType().ToArray(), parameterDescriptors.OfType().ToArray(), Array.Empty(), - CommandFlags.None + CommandFlags.None, + null ); } diff --git a/test/Cocona.Test/Command/ParameterBinder/ParameterValidationTest.cs b/test/Cocona.Test/Command/ParameterBinder/ParameterValidationTest.cs index b9ee260..ae273a8 100644 --- a/test/Cocona.Test/Command/ParameterBinder/ParameterValidationTest.cs +++ b/test/Cocona.Test/Command/ParameterBinder/ParameterValidationTest.cs @@ -26,7 +26,8 @@ private CommandDescriptor CreateCommand(ICommandParameterDescriptor[] parameterD parameterDescriptors.OfType().ToArray(), parameterDescriptors.OfType().ToArray(), Array.Empty(), - CommandFlags.None + CommandFlags.None, + null ); } private static CoconaParameterBinder CreateCoconaParameterBinder() diff --git a/test/Cocona.Test/Help/CoconaCommandHelpProviderTest.cs b/test/Cocona.Test/Help/CoconaCommandHelpProviderTest.cs index 43eee4c..8ca1d55 100644 --- a/test/Cocona.Test/Help/CoconaCommandHelpProviderTest.cs +++ b/test/Cocona.Test/Help/CoconaCommandHelpProviderTest.cs @@ -41,7 +41,8 @@ private CommandDescriptor CreateCommand(string name, string description, IComman parameterDescriptors.OfType().ToArray(), parameterDescriptors.OfType().ToArray(), Array.Empty(), - flags + flags, + null ); } @@ -73,7 +74,7 @@ public void CommandHelp1() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); } @@ -92,7 +93,7 @@ public void CommandIndexHelp_Single_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName [--foo ] [--looooooong-option] @@ -120,7 +121,7 @@ public void CommandIndexHelp_Single_NoDescription_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName [--foo ] [--looooooong-option] @@ -146,7 +147,7 @@ public void CommandIndexHelp_Single_DescriptionFromMetadata_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider() { Description = "via metadata" }, new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName [--foo ] [--looooooong-option] @@ -170,7 +171,7 @@ public void CommandIndexHelp_Single_NoParams_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider() { Description = "via metadata" }, new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName @@ -193,13 +194,41 @@ public void CommandHelp_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName Test [--foo ] [--looooooong-option] command description +Options: + -f, --foo Foo option (Required) + -l, --looooooong-option Long name option +".TrimStart()); + } + + [Fact] + public void CommandHelp_Nested_Rendered() + { + var commandDescriptor = CreateCommand( + "Test", + "command description", + new ICommandParameterDescriptor[] + { + CreateCommandOption(typeof(string), "foo", new [] { 'f' }, "Foo option", CoconaDefaultValue.None), + CreateCommandOption(typeof(bool), "looooooong-option", new [] { 'l' }, "Long name option", new CoconaDefaultValue(false)), + } + ); + + var subCommandStack = new[] { CreateCommand("Nested", "", Array.Empty()) }; + var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); + var help = provider.CreateCommandHelp(commandDescriptor, subCommandStack); + var text = new CoconaHelpRenderer().Render(help); + text.Should().Be(@" +Usage: ExeName Nested Test [--foo ] [--looooooong-option] + +command description + Options: -f, --foo Foo option (Required) -l, --looooooong-option Long name option @@ -222,7 +251,7 @@ public void CommandHelp_Arguments_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName Test [--foo ] [--looooooong-option] src0 ... srcN dest @@ -254,13 +283,42 @@ public void CreateCommandsIndexHelp_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName [--foo ] [--looooooong-option] command description +Options: + -f, --foo Foo option (Required) + -l, --looooooong-option Long name option +".TrimStart()); + } + + [Fact] + public void CreateCommandsIndexHelp_Nested_Rendered() + { + var commandDescriptor = CreateCommand( + "Test", + "command description", + new ICommandParameterDescriptor[] + { + CreateCommandOption(typeof(string), "foo", new [] { 'f' }, "Foo option", CoconaDefaultValue.None), + CreateCommandOption(typeof(bool), "looooooong-option", new [] { 'l' }, "Long name option", new CoconaDefaultValue(false)), + }, + CommandFlags.Primary + ); + + var subCommandStack = new[] {CreateCommand("Nested", "", Array.Empty())}; + var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor }), subCommandStack); + var text = new CoconaHelpRenderer().Render(help); + text.Should().Be(@" +Usage: ExeName Nested [--foo ] [--looooooong-option] + +command description + Options: -f, --foo Foo option (Required) -l, --looooooong-option Long name option @@ -283,7 +341,7 @@ public void CreateCommandsIndexHelp_Arguments_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName [--foo ] [--looooooong-option] arg0 @@ -321,7 +379,7 @@ public void CreateCommandsIndexHelp_Commands_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor2 })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor2 }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName [command] @@ -329,6 +387,47 @@ public void CreateCommandsIndexHelp_Commands_Rendered() command description +Commands: + Test2 command2 description + +Options: + -f, --foo Foo option (Required) + -l, --looooooong-option Long name option + -b, --bar has default value (Default: 123) +".TrimStart()); + } + + [Fact] + public void CreateCommandsIndexHelp_Nested_Commands_Rendered() + { + var commandDescriptor = CreateCommand( + "Test", + "command description", + new ICommandParameterDescriptor[] + { + CreateCommandOption(typeof(string), "foo", new [] { 'f' }, "Foo option", CoconaDefaultValue.None), + CreateCommandOption(typeof(bool), "looooooong-option", new [] { 'l' }, "Long name option", new CoconaDefaultValue(false)), + CreateCommandOption(typeof(int), "bar", new [] { 'b' }, "has default value", new CoconaDefaultValue(123)), + }, + CommandFlags.Primary + ); + var commandDescriptor2 = CreateCommand( + "Test2", + "command2 description", + new ICommandParameterDescriptor[0], + CommandFlags.None + ); + + var subCommandStack = new[] { CreateCommand("Nested", "", Array.Empty()) }; + var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor2 }), subCommandStack); + var text = new CoconaHelpRenderer().Render(help); + text.Should().Be(@" +Usage: ExeName Nested [command] +Usage: ExeName Nested [--foo ] [--looooooong-option] [--bar ] + +command description + Commands: Test2 command2 description @@ -356,7 +455,7 @@ public void CreateCommandsIndexHelp_Commands_NoOptionInPrimary_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor2 })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor2 }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName [command] @@ -385,7 +484,7 @@ public void CreateCommandsIndexHelp_Commands_Hidden_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor2 })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor2 }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName [command] @@ -408,7 +507,7 @@ public void CommandHelp_Options_Enum_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName Test [--enum ] @@ -433,7 +532,7 @@ public void CommandHelp_Options_Boolean_DefaultFalse_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName Test [--flag] @@ -458,7 +557,7 @@ public void CommandHelp_Options_Boolean_DefaultTrue_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName Test [--flag=] @@ -484,7 +583,7 @@ public void CommandHelp_Options_Hidden_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName Test @@ -506,7 +605,7 @@ public void CommandHelp_Options_Array_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName Test [--option0 ...] @@ -531,7 +630,7 @@ public void CommandHelp_Options_Generics_Rendered() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), new ServiceCollection().BuildServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName Test [--option0 ...] diff --git a/test/Cocona.Test/Help/CoconaCommandHelpProviderTransformTest.cs b/test/Cocona.Test/Help/CoconaCommandHelpProviderTransformTest.cs index f960037..d672961 100644 --- a/test/Cocona.Test/Help/CoconaCommandHelpProviderTransformTest.cs +++ b/test/Cocona.Test/Help/CoconaCommandHelpProviderTransformTest.cs @@ -52,7 +52,8 @@ private CommandDescriptor CreateCommand(string methodName, ICommandParameterD parameterDescriptors.OfType().ToArray(), parameterDescriptors.OfType().ToArray(), Array.Empty(), - isPrimaryCommand ? CommandFlags.Primary : CommandFlags.None + isPrimaryCommand ? CommandFlags.Primary : CommandFlags.None, + null ); } @@ -73,7 +74,7 @@ public void Transform_CreateCommandHelp() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), CreateServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName A @@ -94,7 +95,7 @@ public void Transform_CreateCommandHelp_InheritedAttribute() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), CreateServiceProvider()); - var help = provider.CreateCommandHelp(commandDescriptor); + var help = provider.CreateCommandHelp(commandDescriptor, Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName A @@ -115,7 +116,7 @@ public void Transform_CreateCommandsIndexHelp() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), CreateServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName @@ -146,7 +147,7 @@ public void Transform_CreateCommandsIndexHelp_Primary_Class() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), CreateServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor1, commandDescriptor2 })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor1, commandDescriptor2 }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName [command] @@ -181,7 +182,7 @@ public void Transform_CreateCommandsIndexHelp_Primary_Class_InheritedAttribute() ); var provider = new CoconaCommandHelpProvider(new FakeApplicationMetadataProvider(), CreateServiceProvider()); - var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor1, commandDescriptor2 })); + var help = provider.CreateCommandsIndexHelp(new CommandCollection(new[] { commandDescriptor, commandDescriptor1, commandDescriptor2 }), Array.Empty()); var text = new CoconaHelpRenderer().Render(help); text.Should().Be(@" Usage: ExeName [command] diff --git a/test/Cocona.Test/Integration/EndToEndTest.cs b/test/Cocona.Test/Integration/EndToEndTest.cs index 6110bf1..9232818 100644 --- a/test/Cocona.Test/Integration/EndToEndTest.cs +++ b/test/Cocona.Test/Integration/EndToEndTest.cs @@ -7,9 +7,12 @@ using System.Threading.Tasks; using Cocona.Application; using FluentAssertions; -using Microsoft.Extensions.DependencyInjection; using Xunit; +#if COCONA_LITE +using CoconaApp = Cocona.CoconaLiteApp; +#endif + namespace Cocona.Test.Integration { [Collection("End to End")] // NOTE: Test cases use `Console` and does not run in parallel. @@ -237,5 +240,68 @@ class TestCommand2 { public void FooBar() { } } + + [Fact] + public void CoconaApp_Run_Nested() + { + var (stdOut, stdErr, exitCode) = Run(new string[] { "nested", "hello", "Karen" }); + + stdOut.Should().Contain("Hello Karen"); + stdErr.Should().BeEmpty(); + exitCode.Should().Be(0); + } + + [Fact] + public void CoconaApp_Run_Nested_CommandHelp() + { + var (stdOut, stdErr, exitCode) = Run(new string[] { "nested", "hello", "--help" }); + + stdOut.Should().Contain("Usage:"); + stdOut.Should().Contain(" nested hello [--help] arg0"); + stdOut.Should().Contain("Arguments:"); + } + + [Fact] + public void CoconaApp_Run_Nested_Index_0() + { + var (stdOut, stdErr, exitCode) = Run(new string[] { }); + + stdOut.Should().Contain("Usage:"); + stdOut.Should().Contain("Commands:"); + stdOut.Should().Contain(" konnichiwa"); + stdOut.Should().Contain(" nested"); + } + + [Fact] + public void CoconaApp_Run_Nested_Index_1() + { + var (stdOut, stdErr, exitCode) = Run(new string[] { "nested" }); + + stdOut.Should().Contain("Usage:"); + stdOut.Should().Contain("Commands:"); + stdOut.Should().Contain(" hello"); + stdOut.Should().Contain(" bye"); + } + + [HasSubCommands(typeof(Nested))] + class TestCommand_Nested + { + public void Konnichiwa() + { + Console.WriteLine("Konnichiwa"); + } + + class Nested + { + public void Hello([Argument] string arg0) + { + Console.WriteLine($"Hello {arg0}"); + } + public void Bye([Argument] string arg0) + { + Console.WriteLine($"Bye {arg0}"); + } + } + } } }