diff --git a/ReadMe.md b/ReadMe.md index 0b40fb4..588cad4 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -697,6 +697,13 @@ The field secondArg must be between 0 and 2. By default, the ExitCode is set to 1 in this case. +Hide command/parameter help +--- +`ConsoleAppFramework` supports `HiddenAttribute` which is used to hide specific help for a command/parameter. + +- When`HiddenAttribute` is set to command, it hides command from command list. +- When`HiddenAttribute` is set to parameter, it hides parameter from command help. + Filter(Middleware) Pipeline / ConsoleAppContext --- Filters are provided as a mechanism to hook into the execution before and after. To use filters, define an `internal class` that implements `ConsoleAppFilter`. diff --git a/src/ConsoleAppFramework/Command.cs b/src/ConsoleAppFramework/Command.cs index 7d763ae..25869b8 100644 --- a/src/ConsoleAppFramework/Command.cs +++ b/src/ConsoleAppFramework/Command.cs @@ -18,6 +18,7 @@ public record class Command { public required bool IsAsync { get; init; } // Task or Task public required bool IsVoid { get; init; } // void or int + public required bool IsHidden { get; init; } // Hide help from command list public bool IsRootCommand => Name == ""; public required string Name { get; init; } @@ -153,6 +154,7 @@ public record class CommandParameter public required IgnoreEquality WellKnownTypes { get; init; } public required bool IsNullableReference { get; init; } public required bool IsParams { get; init; } + public required bool IsHidden { get; init; } // Hide command parameter help public required string Name { get; init; } public required string OriginalParameterName { get; init; } public required bool HasDefaultValue { get; init; } diff --git a/src/ConsoleAppFramework/CommandHelpBuilder.cs b/src/ConsoleAppFramework/CommandHelpBuilder.cs index a8b6fb8..d3bd982 100644 --- a/src/ConsoleAppFramework/CommandHelpBuilder.cs +++ b/src/ConsoleAppFramework/CommandHelpBuilder.cs @@ -64,7 +64,7 @@ static string BuildHelpMessageCore(Command command, bool showCommandName, bool s if (definition.Options.Any()) { var hasArgument = definition.Options.Any(x => x.Index.HasValue); - var hasOptions = definition.Options.Any(x => !x.Index.HasValue); + var hasNoHiddenOptions = definition.Options.Any(x => !x.Index.HasValue && !x.IsHidden); if (hasArgument) { @@ -72,7 +72,7 @@ static string BuildHelpMessageCore(Command command, bool showCommandName, bool s sb.AppendLine(BuildArgumentsMessage(definition)); } - if (hasOptions) + if (hasNoHiddenOptions) { sb.AppendLine(); sb.AppendLine(BuildOptionsMessage(definition)); @@ -102,7 +102,7 @@ static string BuildUsageMessage(CommandHelpDefinition definition, bool showComma sb.Append(" [arguments...]"); } - if (definition.Options.Any(x => !x.Index.HasValue)) + if (definition.Options.Any(x => !x.Index.HasValue && !x.IsHidden)) { sb.Append(" [options...]"); } @@ -160,6 +160,7 @@ static string BuildOptionsMessage(CommandHelpDefinition definition) { var optionsFormatted = definition.Options .Where(x => !x.Index.HasValue) + .Where(x => !x.IsHidden) .Select(x => (Options: string.Join("|", x.Options) + (x.IsFlag ? string.Empty : $" {x.FormattedValueTypeName}{(x.IsParams ? "..." : "")}"), x.Description, x.IsRequired, x.IsFlag, x.DefaultValue)) .ToArray(); @@ -215,6 +216,7 @@ static string BuildOptionsMessage(CommandHelpDefinition definition) static string BuildMethodListMessage(IEnumerable commands, out int maxWidth) { var formatted = commands + .Where(x => !x.IsHidden) .Select(x => { return (Command: x.Name, x.Description); @@ -279,6 +281,7 @@ static CommandHelpDefinition CreateCommandHelpDefinition(Command descriptor) var description = item.Description; var isFlag = item.Type.SpecialType == Microsoft.CodeAnalysis.SpecialType.System_Boolean; var isParams = item.IsParams; + var isHidden = item.IsHidden; var defaultValue = default(string); if (item.HasDefaultValue) @@ -300,7 +303,7 @@ static CommandHelpDefinition CreateCommandHelpDefinition(Command descriptor) } var paramTypeName = item.ToTypeShortString(); - parameterDefinitions.Add(new CommandOptionHelpDefinition(options.Distinct().ToArray(), description, paramTypeName, defaultValue, index, isFlag, isParams)); + parameterDefinitions.Add(new CommandOptionHelpDefinition(options.Distinct().ToArray(), description, paramTypeName, defaultValue, index, isFlag, isParams, isHidden)); } var commandName = descriptor.Name; @@ -336,9 +339,10 @@ class CommandOptionHelpDefinition public bool IsRequired => DefaultValue == null && !IsParams; public bool IsFlag { get; } public bool IsParams { get; } + public bool IsHidden { get; } public string FormattedValueTypeName => "<" + ValueTypeName + ">"; - public CommandOptionHelpDefinition(string[] options, string description, string valueTypeName, string? defaultValue, int? index, bool isFlag, bool isParams) + public CommandOptionHelpDefinition(string[] options, string description, string valueTypeName, string? defaultValue, int? index, bool isFlag, bool isParams, bool isHidden) { Options = options; Description = description; @@ -347,6 +351,7 @@ public CommandOptionHelpDefinition(string[] options, string description, string Index = index; IsFlag = isFlag; IsParams = isParams; + IsHidden = isHidden; } } } diff --git a/src/ConsoleAppFramework/ConsoleAppBaseCode.cs b/src/ConsoleAppFramework/ConsoleAppBaseCode.cs index f6b16cb..a352040 100644 --- a/src/ConsoleAppFramework/ConsoleAppBaseCode.cs +++ b/src/ConsoleAppFramework/ConsoleAppBaseCode.cs @@ -122,6 +122,11 @@ public CommandAttribute(string command) } } +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)] +internal sealed class HiddenAttribute : Attribute +{ +} + [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] internal sealed class RegisterCommandsAttribute : Attribute { diff --git a/src/ConsoleAppFramework/Parser.cs b/src/ConsoleAppFramework/Parser.cs index 006ae69..932d599 100644 --- a/src/ConsoleAppFramework/Parser.cs +++ b/src/ConsoleAppFramework/Parser.cs @@ -283,7 +283,11 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag } } - var hasParams = x.Modifiers.Any(x => x.IsKind(SyntaxKind.ParamsKeyword)); + var hasParams = x.Modifiers.Any(x => x.IsKind(SyntaxKind.ParamsKeyword)); + + var isHidden = x.AttributeLists + .SelectMany(x => x.Attributes) + .Any(x => model.GetTypeInfo(x).Type?.Name == "HiddenAttribute"); var customParserType = x.AttributeLists.SelectMany(x => x.Attributes) .Select(x => @@ -360,6 +364,7 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag IsNullableReference = isNullableReference, IsConsoleAppContext = isConsoleAppContext, IsParams = hasParams, + IsHidden = isHidden, Type = new EquatableTypeSymbol(type.Type!), Location = x.GetLocation(), HasDefaultValue = hasDefault, @@ -381,6 +386,7 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag Name = commandName, IsAsync = isAsync, IsVoid = isVoid, + IsHidden = false, // Anonymous lambda don't support attribute. Parameters = parameters, MethodKind = MethodKind.Lambda, Description = "", @@ -472,6 +478,8 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag return null; } + var isHiddenCommand = methodSymbol.GetAttributes().Any(x => x.AttributeClass?.Name == "HiddenAttribute"); + var methodFilters = methodSymbol.GetAttributes() .Where(x => x.AttributeClass?.Name == "ConsoleAppFilterAttribute") .Select(x => @@ -516,6 +524,7 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag var hasValidation = x.GetAttributes().Any(x => x.AttributeClass?.GetBaseTypes().Any(y => y.Name == "ValidationAttribute") ?? false); var isCancellationToken = SymbolEqualityComparer.Default.Equals(x.Type, wellKnownTypes.CancellationToken); var isConsoleAppContext = x.Type!.Name == "ConsoleAppContext"; + var isHiddenParameter = x.GetAttributes().Any(x => x.AttributeClass?.Name == "HiddenAttribute"); string description = ""; string[] aliases = []; @@ -547,6 +556,7 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag IsNullableReference = isNullableReference, IsConsoleAppContext = isConsoleAppContext, IsParams = x.IsParams, + IsHidden = isHiddenParameter, Location = x.DeclaringSyntaxReferences[0].GetSyntax().GetLocation(), Type = new EquatableTypeSymbol(x.Type), HasDefaultValue = x.HasExplicitDefaultValue, @@ -567,6 +577,7 @@ internal class Parser(ConsoleAppFrameworkGeneratorOptions generatorOptions, Diag Name = commandName, IsAsync = isAsync, IsVoid = isVoid, + IsHidden = isHiddenCommand, Parameters = parameters, MethodKind = addressOf ? MethodKind.FunctionPointer : MethodKind.Method, Description = summary, diff --git a/tests/ConsoleAppFramework.GeneratorTests/HiddenAttributeTest.cs b/tests/ConsoleAppFramework.GeneratorTests/HiddenAttributeTest.cs new file mode 100644 index 0000000..e309048 --- /dev/null +++ b/tests/ConsoleAppFramework.GeneratorTests/HiddenAttributeTest.cs @@ -0,0 +1,121 @@ +namespace ConsoleAppFramework.GeneratorTests; + +public class HiddenAtttributeTest(ITestOutputHelper output) +{ + VerifyHelper verifier = new(output, "CAF"); + + [Fact] + public void VerifyHiddenOptions_Lambda() + { + var code = + """ + ConsoleApp.Run(args, (int x, [Hidden]int y) => { }); + """; + + // Verify Hidden options is not shown on command help. + verifier.Execute(code, args: "--help", expected: + """ + Usage: [options...] [-h|--help] [--version] + + Options: + --x (Required) + + """); + } + + [Fact] + public void VerifyHiddenCommands_Class() + { + var code = + """ + var builder = ConsoleApp.Create(); + builder.Add(); + await builder.RunAsync(args); + + public class Commands + { + [Hidden] + public void Command1() { Console.Write("command1"); } + + public void Command2() { Console.Write("command2"); } + + [Hidden] + public void Command3(int x, [Hidden]int y) { Console.Write($"command3: x={x} y={y}"); } + } + """; + + // Verify hidden command is not shown on root help commands. + verifier.Execute(code, args: "--help", expected: + """ + Usage: [command] [-h|--help] [--version] + + Commands: + command2 + + """); + + // Verify Hidden command help is shown when explicitly specify command name. + verifier.Execute(code, args: "command1 --help", expected: + """ + Usage: command1 [-h|--help] [--version] + + """); + + verifier.Execute(code, args: "command2 --help", expected: + """ + Usage: command2 [-h|--help] [--version] + + """); + + verifier.Execute(code, args: "command3 --help", expected: + """ + Usage: command3 [options...] [-h|--help] [--version] + + Options: + --x (Required) + + """); + + // Verify commands involations + verifier.Execute(code, args: "command1", "command1"); + verifier.Execute(code, args: "command2", "command2"); + verifier.Execute(code, args: "command3 --x 1 --y 2", expected: "command3: x=1 y=2"); + } + + [Fact] + public void VerifyHiddenCommands_LocalFunctions() + { + var code = + """ + var builder = ConsoleApp.Create(); + + builder.Add("", () => { Console.Write("root"); }); + builder.Add("command1", Command1); + builder.Add("command2", Command2); + builder.Add("command3", Command3); + builder.Run(args); + + [Hidden] + static void Command1() { Console.Write("command1"); } + + static void Command2() { Console.Write("command2"); } + + [Hidden] + static void Command3(int x, [Hidden]int y) { Console.Write($"command3: x={x} y={y}"); } + """; + + verifier.Execute(code, args: "--help", expected: + """ + Usage: [command] [-h|--help] [--version] + + Commands: + command2 + + """); + + // Verify commands can be invoked. + verifier.Execute(code, args: "command1", expected: "command1"); + verifier.Execute(code, args: "command2", expected: "command2"); + verifier.Execute(code, args: "command3 --x 1 --y 2", expected: "command3: x=1 y=2"); + } +}