From a6ca163ec44f8a6e5b6dbc9c9ddf8eaa45d43328 Mon Sep 17 00:00:00 2001 From: Christos Maragkos Date: Wed, 3 Dec 2025 16:59:03 +0200 Subject: [PATCH 1/7] Implement deprecation for command options --- .../Annotations/CommandOptionAttribute.cs | 9 ++++++++- .../Internal/Modelling/CommandOption.cs | 6 +++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs b/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs index c43b927..c6dfab4 100644 --- a/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs +++ b/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs @@ -40,12 +40,18 @@ public sealed class CommandOptionAttribute : Attribute /// public bool IsHidden { get; set; } + /// + /// Gets the warning message displayed when this (deprecated) option is used. + /// + public string? DeprecationMessage { get; } + /// /// Initializes a new instance of the class. /// /// The option template. /// Indicates whether the option is required or not. - public CommandOptionAttribute(string template, bool isRequired = false) + /// The warning message that is displayed when this deprecated option is used. + public CommandOptionAttribute(string template, bool isRequired = false, string? deprecationMessage = null) { if (template == null) { @@ -61,6 +67,7 @@ public CommandOptionAttribute(string template, bool isRequired = false) ValueName = result.Value; ValueIsOptional = result.ValueIsOptional; IsRequired = isRequired; + DeprecationMessage = deprecationMessage; } internal bool IsMatch(string name) diff --git a/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs b/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs index f2d6a9c..4729ce6 100644 --- a/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs +++ b/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs @@ -7,13 +7,16 @@ internal sealed class CommandOption : CommandParameter, ICommandOption public string? ValueName { get; } public bool ValueIsOptional { get; } public bool IsShadowed { get; set; } + public bool IsDeprecated => DeprecationMessage != null; + public string? DeprecationMessage { get; } public CommandOption( Type parameterType, ParameterKind parameterKind, PropertyInfo property, string? description, TypeConverterAttribute? converter, PairDeconstructorAttribute? deconstructor, CommandOptionAttribute optionAttribute, ParameterValueProviderAttribute? valueProvider, IEnumerable validators, - DefaultValueAttribute? defaultValue, bool valueIsOptional) + DefaultValueAttribute? defaultValue, bool valueIsOptional, + string? deprecationMessage) : base(parameterType, parameterKind, property, description, converter, defaultValue, deconstructor, valueProvider, validators, optionAttribute.IsRequired, optionAttribute.IsHidden) @@ -22,6 +25,7 @@ public CommandOption( ShortNames = optionAttribute.ShortNames; ValueName = optionAttribute.ValueName; ValueIsOptional = valueIsOptional; + DeprecationMessage = deprecationMessage; } public string GetOptionName() From 8ac491985bff4e3412becbec5c75507e5cf2a155 Mon Sep 17 00:00:00 2001 From: Christos Maragkos Date: Wed, 3 Dec 2025 16:59:51 +0200 Subject: [PATCH 2/7] Implement proper handling of deprecated options --- .../Internal/Binding/CommandValueResolver.cs | 26 +++++++++++++++++++ .../Internal/Modelling/CommandModelBuilder.cs | 3 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs b/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs index 2cc9af6..9d0fd84 100644 --- a/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs +++ b/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs @@ -7,6 +7,9 @@ public static CommandValueLookup GetParameterValues(CommandTree? tree, ITypeReso var lookup = new CommandValueLookup(); var binder = new CommandValueBinder(lookup); + // Track which deprecated options we've warned about to avoid spamming the console. + var warnedDeprecatedOptions = new HashSet(StringComparer.Ordinal); + CommandValidator.ValidateRequiredParameters(tree); while (tree != null) @@ -56,6 +59,29 @@ public static CommandValueLookup GetParameterValues(CommandTree? tree, ITypeReso // Process mapped parameters. foreach (var mapped in tree.Mapped) { + if (mapped.Parameter is CommandOption commandOption && commandOption.IsDeprecated) + { + var optionName = commandOption.LongNames.Count > 0 ? commandOption.LongNames[0] + : commandOption.ShortNames.Count > 0 ? commandOption.ShortNames[0] + : commandOption.GetOptionName(); + if (!warnedDeprecatedOptions.Contains(optionName)) + { + warnedDeprecatedOptions.Add(optionName); + try + { + if (resolver.Resolve(typeof(IAnsiConsole)) is IAnsiConsole console) + { + var msg = commandOption.DeprecationMessage ?? $"Option '{optionName}' is deprecated."; + console.MarkupLine($"[yellow]Warning: {msg.EscapeMarkup()}[/]"); + } + } + catch + { + // Deprecation is best-effort + } + } + } + if (mapped.Parameter.WantRawValue) { // Just try to assign the raw value. diff --git a/src/Spectre.Console.Cli/Internal/Modelling/CommandModelBuilder.cs b/src/Spectre.Console.Cli/Internal/Modelling/CommandModelBuilder.cs index 4845ab0..c7df01c 100644 --- a/src/Spectre.Console.Cli/Internal/Modelling/CommandModelBuilder.cs +++ b/src/Spectre.Console.Cli/Internal/Modelling/CommandModelBuilder.cs @@ -191,7 +191,8 @@ private static CommandOption BuildOptionParameter(PropertyInfo property, Command return new CommandOption(property.PropertyType, kind, property, description?.Description, converter, deconstructor, attribute, valueProvider, validators, defaultValue, - attribute.ValueIsOptional); + attribute.ValueIsOptional, + attribute.DeprecationMessage); } private static CommandArgument BuildArgumentParameter(PropertyInfo property, CommandArgumentAttribute attribute) From 6e9f6827292515fe2b405d0043ce5b53566ccf41 Mon Sep 17 00:00:00 2001 From: Christos Maragkos Date: Wed, 3 Dec 2025 17:00:03 +0200 Subject: [PATCH 3/7] Implement deprecation test --- ...CommandOptionAttributeTests.Deprecation.cs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/Spectre.Console.Cli.Tests/CommandOptionAttributeTests.Deprecation.cs diff --git a/src/Spectre.Console.Cli.Tests/CommandOptionAttributeTests.Deprecation.cs b/src/Spectre.Console.Cli.Tests/CommandOptionAttributeTests.Deprecation.cs new file mode 100644 index 0000000..454539a --- /dev/null +++ b/src/Spectre.Console.Cli.Tests/CommandOptionAttributeTests.Deprecation.cs @@ -0,0 +1,32 @@ + +namespace Spectre.Console.Tests.Unit.Cli.Annotations; + +public sealed partial class CommandOptionAttributeTests +{ + [Fact] + public void Should_Write_Deprecation_Warning() + { + //Given, When + var fixture = new CommandAppTester(); + fixture.Configure(configurator => configurator.AddCommand("cmd")); + var result = fixture.Run("cmd", "-d", "yes"); + + // Then + result.Output.ShouldContain("Warning"); + result.Output.ShouldContain("This option is deprecated and subject to removal."); + } + + private sealed class DeprecatedOptionSettings : CommandSettings + { + [CommandOption("-d|--deprecated ", true, "This option is deprecated and subject to removal.")] + public string? Deprecated { get; set; } + } + + private sealed class DeprecatedOptionCommand : Command + { + protected override int Execute(CommandContext context, DeprecatedOptionSettings settings, CancellationToken cancellationToken) + { + return 0; + } + } +} \ No newline at end of file From 99601e0c363c804cf33a7f4808af63ce5eeebff5 Mon Sep 17 00:00:00 2001 From: Christos Maragkos Date: Wed, 3 Dec 2025 19:34:12 +0200 Subject: [PATCH 4/7] Refactor deprecation handling to utilize the built-in ObsoleteAttribute --- .../Internal/Binding/CommandValueResolver.cs | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs b/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs index 9d0fd84..366d382 100644 --- a/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs +++ b/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs @@ -59,26 +59,39 @@ public static CommandValueLookup GetParameterValues(CommandTree? tree, ITypeReso // Process mapped parameters. foreach (var mapped in tree.Mapped) { - if (mapped.Parameter is CommandOption commandOption && commandOption.IsDeprecated) + if (mapped.Parameter is CommandOption commandOption) { + string? deprecationMessage = null; + try + { + var prop = mapped.Parameter.Property; + var propAttrs = prop.GetCustomAttributes(typeof(ObsoleteAttribute), false); + if (propAttrs.Length > 0 && propAttrs[0] is ObsoleteAttribute obsoleteAttr && + !string.IsNullOrWhiteSpace(obsoleteAttr.Message)) + { + deprecationMessage = obsoleteAttr.Message; + } + } + catch + { + // Just consume so we do not block binding + } + + var isDeprecated = deprecationMessage != null; var optionName = commandOption.LongNames.Count > 0 ? commandOption.LongNames[0] : commandOption.ShortNames.Count > 0 ? commandOption.ShortNames[0] : commandOption.GetOptionName(); - if (!warnedDeprecatedOptions.Contains(optionName)) + if (isDeprecated && warnedDeprecatedOptions.Add(optionName)) { - warnedDeprecatedOptions.Add(optionName); try { if (resolver.Resolve(typeof(IAnsiConsole)) is IAnsiConsole console) { - var msg = commandOption.DeprecationMessage ?? $"Option '{optionName}' is deprecated."; + var msg = deprecationMessage ?? $"Option '{optionName}' is deprecated."; console.MarkupLine($"[yellow]Warning: {msg.EscapeMarkup()}[/]"); } } - catch - { - // Deprecation is best-effort - } + catch { } } } From a5716fd75407aded85e291fc9fb887225545f34e Mon Sep 17 00:00:00 2001 From: Christos Maragkos Date: Wed, 3 Dec 2025 19:35:48 +0200 Subject: [PATCH 5/7] Remove redundant public API --- .../CommandOptionAttributeTests.Deprecation.cs | 3 ++- .../Annotations/CommandOptionAttribute.cs | 9 +-------- .../Internal/Modelling/CommandModelBuilder.cs | 3 +-- .../Internal/Modelling/CommandOption.cs | 6 +----- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/Spectre.Console.Cli.Tests/CommandOptionAttributeTests.Deprecation.cs b/src/Spectre.Console.Cli.Tests/CommandOptionAttributeTests.Deprecation.cs index 454539a..4e9bb52 100644 --- a/src/Spectre.Console.Cli.Tests/CommandOptionAttributeTests.Deprecation.cs +++ b/src/Spectre.Console.Cli.Tests/CommandOptionAttributeTests.Deprecation.cs @@ -18,7 +18,8 @@ public void Should_Write_Deprecation_Warning() private sealed class DeprecatedOptionSettings : CommandSettings { - [CommandOption("-d|--deprecated ", true, "This option is deprecated and subject to removal.")] + [CommandOption("-d|--deprecated ")] + [Obsolete("This option is deprecated and subject to removal.")] public string? Deprecated { get; set; } } diff --git a/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs b/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs index c6dfab4..c43b927 100644 --- a/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs +++ b/src/Spectre.Console.Cli/Annotations/CommandOptionAttribute.cs @@ -40,18 +40,12 @@ public sealed class CommandOptionAttribute : Attribute /// public bool IsHidden { get; set; } - /// - /// Gets the warning message displayed when this (deprecated) option is used. - /// - public string? DeprecationMessage { get; } - /// /// Initializes a new instance of the class. /// /// The option template. /// Indicates whether the option is required or not. - /// The warning message that is displayed when this deprecated option is used. - public CommandOptionAttribute(string template, bool isRequired = false, string? deprecationMessage = null) + public CommandOptionAttribute(string template, bool isRequired = false) { if (template == null) { @@ -67,7 +61,6 @@ public CommandOptionAttribute(string template, bool isRequired = false, string? ValueName = result.Value; ValueIsOptional = result.ValueIsOptional; IsRequired = isRequired; - DeprecationMessage = deprecationMessage; } internal bool IsMatch(string name) diff --git a/src/Spectre.Console.Cli/Internal/Modelling/CommandModelBuilder.cs b/src/Spectre.Console.Cli/Internal/Modelling/CommandModelBuilder.cs index c7df01c..4845ab0 100644 --- a/src/Spectre.Console.Cli/Internal/Modelling/CommandModelBuilder.cs +++ b/src/Spectre.Console.Cli/Internal/Modelling/CommandModelBuilder.cs @@ -191,8 +191,7 @@ private static CommandOption BuildOptionParameter(PropertyInfo property, Command return new CommandOption(property.PropertyType, kind, property, description?.Description, converter, deconstructor, attribute, valueProvider, validators, defaultValue, - attribute.ValueIsOptional, - attribute.DeprecationMessage); + attribute.ValueIsOptional); } private static CommandArgument BuildArgumentParameter(PropertyInfo property, CommandArgumentAttribute attribute) diff --git a/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs b/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs index 4729ce6..f2d6a9c 100644 --- a/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs +++ b/src/Spectre.Console.Cli/Internal/Modelling/CommandOption.cs @@ -7,16 +7,13 @@ internal sealed class CommandOption : CommandParameter, ICommandOption public string? ValueName { get; } public bool ValueIsOptional { get; } public bool IsShadowed { get; set; } - public bool IsDeprecated => DeprecationMessage != null; - public string? DeprecationMessage { get; } public CommandOption( Type parameterType, ParameterKind parameterKind, PropertyInfo property, string? description, TypeConverterAttribute? converter, PairDeconstructorAttribute? deconstructor, CommandOptionAttribute optionAttribute, ParameterValueProviderAttribute? valueProvider, IEnumerable validators, - DefaultValueAttribute? defaultValue, bool valueIsOptional, - string? deprecationMessage) + DefaultValueAttribute? defaultValue, bool valueIsOptional) : base(parameterType, parameterKind, property, description, converter, defaultValue, deconstructor, valueProvider, validators, optionAttribute.IsRequired, optionAttribute.IsHidden) @@ -25,7 +22,6 @@ public CommandOption( ShortNames = optionAttribute.ShortNames; ValueName = optionAttribute.ValueName; ValueIsOptional = valueIsOptional; - DeprecationMessage = deprecationMessage; } public string GetOptionName() From 7fc77847a67d4c1f37ae5358d72b40aed7382664 Mon Sep 17 00:00:00 2001 From: Christos Maragkos Date: Wed, 3 Dec 2025 19:42:38 +0200 Subject: [PATCH 6/7] Use custom attribute helper --- .../Internal/Binding/CommandValueResolver.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs b/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs index 366d382..b305bed 100644 --- a/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs +++ b/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs @@ -65,9 +65,8 @@ public static CommandValueLookup GetParameterValues(CommandTree? tree, ITypeReso try { var prop = mapped.Parameter.Property; - var propAttrs = prop.GetCustomAttributes(typeof(ObsoleteAttribute), false); - if (propAttrs.Length > 0 && propAttrs[0] is ObsoleteAttribute obsoleteAttr && - !string.IsNullOrWhiteSpace(obsoleteAttr.Message)) + var obsoleteAttr = prop.GetCustomAttribute(false); + if (obsoleteAttr is { Message: not null }) { deprecationMessage = obsoleteAttr.Message; } From 8cfee74693c4e7e0a9c5d674b70ca4b639a24db5 Mon Sep 17 00:00:00 2001 From: Christos Maragkos Date: Wed, 3 Dec 2025 19:42:38 +0200 Subject: [PATCH 7/7] Use custom attribute helper --- .../Internal/Binding/CommandValueResolver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs b/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs index b305bed..348088c 100644 --- a/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs +++ b/src/Spectre.Console.Cli/Internal/Binding/CommandValueResolver.cs @@ -66,7 +66,7 @@ public static CommandValueLookup GetParameterValues(CommandTree? tree, ITypeReso { var prop = mapped.Parameter.Property; var obsoleteAttr = prop.GetCustomAttribute(false); - if (obsoleteAttr is { Message: not null }) + if (obsoleteAttr is not null && !string.IsNullOrWhiteSpace(obsoleteAttr.Message)) { deprecationMessage = obsoleteAttr.Message; }