Skip to content

Commit

Permalink
Merge pull request #13 from mayuki/feature/NestedSubCommands
Browse files Browse the repository at this point in the history
Add nested sub-commands support
  • Loading branch information
mayuki authored Feb 10, 2020
2 parents 0cc2504 + 608e344 commit f2da4fe
Show file tree
Hide file tree
Showing 26 changed files with 844 additions and 320 deletions.
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Program>(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]
Expand Down
31 changes: 31 additions & 0 deletions samples/GettingStarted.SubCommandApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

namespace CoconaSample.GettingStarted.SubCommandApp
{
[HasSubCommands(typeof(SubCommands), Description = "Nested sub-commands")]
class Program
{
static void Main(string[] args)
Expand All @@ -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}");
}
}
}
8 changes: 7 additions & 1 deletion src/Cocona.Core/CoconaAppContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ public class CoconaAppContext
/// </summary>
public CoconaAppFeatureCollection Features { get; }

public CoconaAppContext(CancellationToken cancellationToken)
/// <summary>
/// Gets a executing command.
/// </summary>
public CommandDescriptor ExecutingCommand { get; }

public CoconaAppContext(CommandDescriptor command, CancellationToken cancellationToken)
{
ExecutingCommand = command;
CancellationToken = cancellationToken;
Features = new CoconaAppFeatureCollection();
}
Expand Down
12 changes: 9 additions & 3 deletions src/Cocona.Core/Command/BuiltIn/BuiltInCommandMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Cocona.Command.Features;

namespace Cocona.Command.BuiltIn
{
Expand All @@ -16,24 +17,29 @@ 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<int> DispatchAsync(CommandDispatchContext ctx)
{
var hasHelpOption = ctx.ParsedCommandLine.Options.Any(x => x.Option == BuiltInCommandOption.Help);
if (hasHelpOption)
{
var feature = _appContext.Current!.Features.Get<ICoconaCommandFeature>()!;
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<int>(129);
Expand Down
13 changes: 10 additions & 3 deletions src/Cocona.Core/Command/BuiltIn/BuiltInPrimaryCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,13 +38,16 @@ public static CommandDescriptor GetCommand(string description)
Array.Empty<CommandOptionDescriptor>(),
Array.Empty<CommandArgumentDescriptor>(),
Array.Empty<CommandOverloadDescriptor>(),
CommandFlags.Primary
CommandFlags.Primary,
null
);
}

private void ShowDefaultMessage()
{
_console.Output.Write(_helpRenderer.Render(_commandHelpProvider.CreateCommandsIndexHelp(_commandProvider.GetCommandCollection())));
var commandStack = _appContext.Current!.Features.Get<ICoconaCommandFeature>().CommandStack!;
var commandCollection = commandStack.LastOrDefault()?.SubCommands ?? _commandProvider.GetCommandCollection();
_console.Output.Write(_helpRenderer.Render(_commandHelpProvider.CreateCommandsIndexHelp(commandCollection, commandStack)));
}

public static bool IsBuiltInCommand(CommandDescriptor command)
Expand Down
21 changes: 11 additions & 10 deletions src/Cocona.Core/Command/BuiltIn/CoconaBuiltInCommandProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,24 @@ namespace Cocona.Command.BuiltIn
public class CoconaBuiltInCommandProvider : ICoconaCommandProvider
{
private readonly ICoconaCommandProvider _underlyingCommandProvider;
private readonly Lazy<CommandCollection> _commandCollection;
private CommandCollection? _cachedCommandCollection;

public CoconaBuiltInCommandProvider(ICoconaCommandProvider underlyingCommandProvider)
{
_underlyingCommandProvider = underlyingCommandProvider;
_commandCollection = new Lazy<CommandCollection>(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();
}
Expand All @@ -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<CommandOptionDescriptor> GetParametersWithBuiltInOptions(IReadOnlyList<CommandOptionDescriptor> options, bool isPrimaryCommand)
private IReadOnlyList<CommandOptionDescriptor> GetParametersWithBuiltInOptions(IReadOnlyList<CommandOptionDescriptor> 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));
Expand All @@ -62,7 +63,7 @@ private IReadOnlyList<CommandOptionDescriptor> GetParametersWithBuiltInOptions(I
{
newOptions = newOptions.Concat(new[] { BuiltInCommandOption.Help });
}
if (!hasVersion && isPrimaryCommand)
if (!hasVersion && isPrimaryCommand && !isNestedSubCommand)
{
newOptions = newOptions.Concat(new[] { BuiltInCommandOption.Version });
}
Expand Down
47 changes: 40 additions & 7 deletions src/Cocona.Core/Command/CoconaCommandProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,30 @@ public class CoconaCommandProvider : ICoconaCommandProvider
{
private readonly Type[] _targetTypes;
private static readonly Dictionary<string, List<(MethodInfo Method, CommandOverloadAttribute Attribute)>> _emptyOverloads = new Dictionary<string, List<(MethodInfo Method, CommandOverloadAttribute Attribute)>>();
private readonly Lazy<CommandCollection> _commandCollection;
private readonly bool _treatPublicMethodsAsCommands;
private readonly bool _enableConvertOptionNameToLowerCase;
private readonly bool _enableConvertCommandNameToLowerCase;

public CoconaCommandProvider(Type[] targetTypes, bool treatPublicMethodsAsCommands = true, bool enableConvertOptionNameToLowerCase = false, bool enableConvertCommandNameToLowerCase = false)
{
_targetTypes = targetTypes ?? throw new ArgumentNullException(nameof(targetTypes));
_commandCollection = new Lazy<CommandCollection>(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<Type> targetTypes)
{
var commandMethods = new List<MethodInfo>(10);
var overloadCommandMethods = new Dictionary<string, List<(MethodInfo Method, CommandOverloadAttribute Attribute)>>(10);
var subCommandEntryPoints = new List<CommandDescriptor>();

// Command types
foreach (var type in _targetTypes)
foreach (var type in targetTypes)
{
if (type.IsAbstract || (type.IsGenericType && type.IsConstructedGenericType)) continue;

Expand Down Expand Up @@ -73,9 +72,40 @@ private CommandCollection GetCommandCollectionCore()
}
}
}

// Nested sub-commands
var subCommandsAttrs = type.GetCustomAttributes<HasSubCommandsAttribute>();
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<string>(),
subCommandsAttr.Description ?? subCommands.Description,
Array.Empty<ICommandParameterDescriptor>(),
Array.Empty<CommandOptionDescriptor>(),
Array.Empty<CommandArgumentDescriptor>(),
Array.Empty<CommandOverloadDescriptor>(),
CommandFlags.SubCommandsEntryPoint,
subCommands
);
subCommandEntryPoints.Add(command);
}
}

var hasMultipleCommand = commandMethods.Count > 1;
var hasMultipleCommand = commandMethods.Count > 1 || subCommandEntryPoints.Count != 0;
var commandNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var commands = new List<CommandDescriptor>(commandMethods.Count);
foreach (var commandMethod in commandMethods)
Expand All @@ -102,6 +132,8 @@ private CommandCollection GetCommandCollectionCore()
commands.Add(command);
}

commands.AddRange(subCommandEntryPoints);

return new CommandCollection(commands);
}

Expand Down Expand Up @@ -269,7 +301,8 @@ public CommandDescriptor CreateCommand(MethodInfo methodInfo, bool isSingleComma
options,
arguments,
overloadDescriptors,
flags
flags,
null
);
}

Expand Down
Loading

0 comments on commit f2da4fe

Please sign in to comment.