Files
Tyrrrz-CliFx/CliFx.Generators/Emitters/CommandDescriptor.cs
T
dualfroz 5e1eff9f24 feat: add command usage examples
Add an Examples string array property to CommandAttribute so command authors can document example invocations. Each example contains only the arguments that follow the command name; the executable and command name are prepended automatically when the example is rendered.

Examples are rendered as extra lines in the USAGE section of the help text. The source generator resolves the values from the attribute and emits them into the command descriptor consumed at runtime.
2026-08-28 10:12:58 +02:00

588 lines
22 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Generators.Binding;
using CliFx.Generators.Utils;
using CliFx.Generators.Utils.Extensions;
using Microsoft.CodeAnalysis;
using PowerKit.Extensions;
namespace CliFx.Generators.Emitters;
internal static class CommandDescriptor
{
private static string EmitValidators(
IReadOnlyList<INamedTypeSymbol> validatorTypes,
string propertyTypeName
)
{
if (validatorTypes.Count == 0)
return $"global::System.Array.Empty<global::CliFx.Activation.IInputValidator<{propertyTypeName}>>()";
// lang=csharp
return $$"""
new global::CliFx.Activation.IInputValidator<{{propertyTypeName}}>[]
{
{{string.Join(
", ",
validatorTypes.Select(v => $"new {v.GetGloballyQualifiedName()}()")
)}}
}
""";
}
private static string EmitExamples(IReadOnlyList<string> examples)
{
if (examples.Count == 0)
return "global::System.Array.Empty<string>()";
// lang=csharp
return $$"""
new[] { {{string.Join(", ", examples.Select(CSharp.Encode))}} }
""";
}
private static string? TryEmitDefaultScalarConverter(ITypeSymbol type)
{
// Object
if (type.SpecialType == SpecialType.System_Object)
return "new global::CliFx.Activation.StringScalarInputConverter()";
// String
if (type.SpecialType == SpecialType.System_String)
return "new global::CliFx.Activation.StringScalarInputConverter()";
// Bool
if (type.SpecialType == SpecialType.System_Boolean)
return "new global::CliFx.Activation.BoolScalarInputConverter()";
// Enum
if (type.TypeKind == TypeKind.Enum)
return $"new global::CliFx.Activation.EnumScalarInputConverter<{type.GetGloballyQualifiedName()}>()";
// Nullable<T>
if (type.TryGetNullableUnderlyingType() is { } underlyingType)
{
var innerConverterCode = TryEmitDefaultScalarConverter(underlyingType);
if (innerConverterCode is null)
return null;
return $"global::CliFx.Activation.NullableScalarInputConverter.Create({innerConverterCode})";
}
// Has static Parse(string, IFormatProvider)
var parseMethodWithFormatProvider = type.GetMethods()
.FirstOrDefault(m =>
string.Equals(m.Name, "Parse", StringComparison.Ordinal)
&& m.IsStatic
&& m.DeclaredAccessibility == Accessibility.Public
&& m.Parameters.Length == 2
&& m.Parameters[0].Type.SpecialType == SpecialType.System_String
&& m.Parameters[1].Type.IsMatchedBy("System.IFormatProvider")
&& SymbolEqualityComparer.Default.Equals(m.ReturnType, type)
);
if (parseMethodWithFormatProvider is not null)
{
return $"""
global::CliFx.Activation.DelegateScalarInputConverter.Create(
v => {parseMethodWithFormatProvider.GetGloballyQualifiedName()}(
v!,
global::System.Globalization.CultureInfo.InvariantCulture
)
)
""";
}
// Has static Parse(string)
var parseMethod = type.GetMethods()
.FirstOrDefault(m =>
string.Equals(m.Name, "Parse", StringComparison.Ordinal)
&& m.IsStatic
&& m.DeclaredAccessibility == Accessibility.Public
&& m.Parameters.Length == 1
&& m.Parameters[0].Type.SpecialType == SpecialType.System_String
&& SymbolEqualityComparer.Default.Equals(m.ReturnType, type)
);
if (parseMethod is not null)
{
// lang=csharp
return $"""
global::CliFx.Activation.DelegateScalarInputConverter.Create(
v => {parseMethod.GetGloballyQualifiedName()}(v!)
)
""";
}
// Has ctor(string)
if (
type is INamedTypeSymbol namedConstructable
&& namedConstructable.Constructors.Any(c =>
c.DeclaredAccessibility == Accessibility.Public
&& c.Parameters.Length == 1
&& c.Parameters[0].Type.SpecialType == SpecialType.System_String
)
)
{
// lang=csharp
return $"""
global::CliFx.Activation.DelegateScalarInputConverter.Create(
v => new {type.GetGloballyQualifiedName()}(v!)
)
""";
}
return null;
}
private static string? TryEmitDefaultSequenceConverter(
ITypeSymbol collectionType,
ITypeSymbol elementType
)
{
var elementConverterFragment = TryEmitDefaultScalarConverter(elementType);
if (elementConverterFragment is null)
return null;
// Assignable from T[]
if (
// T[]
collectionType is IArrayTypeSymbol
|| (
collectionType
is INamedTypeSymbol
{
TypeKind: TypeKind.Interface,
TypeArguments: [var interfaceElementType],
} collectionInterfaceType
&& collectionInterfaceType.ConstructedFrom.SpecialType
// IEnumerable<T>
is SpecialType.System_Collections_Generic_IEnumerable_T
// ICollection<T>
or SpecialType.System_Collections_Generic_ICollection_T
// IList<T>
or SpecialType.System_Collections_Generic_IList_T
// IReadOnlyCollection<T>
or SpecialType.System_Collections_Generic_IReadOnlyCollection_T
// IReadOnlyList<T>
or SpecialType.System_Collections_Generic_IReadOnlyList_T
&& SymbolEqualityComparer.Default.Equals(interfaceElementType, elementType)
)
)
{
return $"global::CliFx.Activation.ArraySequenceInputConverter.Create({elementConverterFragment})";
}
// Has ctor(T[])
if (
collectionType is INamedTypeSymbol collectionNamedType
&& collectionNamedType.Constructors.Any(c =>
c.DeclaredAccessibility == Accessibility.Public
&& c.Parameters.Length == 1
&& (
// T[]
(
c.Parameters[0].Type is IArrayTypeSymbol arrayParameterType
&& SymbolEqualityComparer.Default.Equals(
arrayParameterType.ElementType,
elementType
)
)
// Interfaces implemented by T[]
|| (
c.Parameters[0].Type is INamedTypeSymbol namedParameterType
&& namedParameterType.ConstructedFrom.SpecialType
// IEnumerable<T>
is SpecialType.System_Collections_Generic_IEnumerable_T
// ICollection<T>
or SpecialType.System_Collections_Generic_ICollection_T
// IList<T>
or SpecialType.System_Collections_Generic_IList_T
// IReadOnlyCollection<T>
or SpecialType.System_Collections_Generic_IReadOnlyCollection_T
// IReadOnlyList<T>
or SpecialType.System_Collections_Generic_IReadOnlyList_T
&& SymbolEqualityComparer.Default.Equals(
namedParameterType.TypeArguments[0],
elementType
)
)
)
)
)
{
// lang=csharp
return $"""
global::CliFx.Activation.DelegateSequenceInputConverter.Create(
global::CliFx.Activation.ArraySequenceInputConverter.Create({elementConverterFragment}),
vs => new {collectionType.GetGloballyQualifiedName()}(vs)
)
""";
}
return null;
}
private static string? TryEmitConverter(
INamedTypeSymbol? userConverterType,
IPropertySymbol property
)
{
if (userConverterType is not null)
return $"new {userConverterType.GetGloballyQualifiedName()}()";
// Sequence-based property
if (
property.Type.SpecialType != SpecialType.System_String
&& property.Type.TryGetEnumerableUnderlyingType() is { } elementType
)
{
return TryEmitDefaultSequenceConverter(property.Type, elementType);
}
// Scalar property
return TryEmitDefaultScalarConverter(property.Type);
}
private static string? TryEmitCommandParameterDescriptor(
CommandSymbol command,
CommandParameterSymbol parameter,
DiagnosticReporter diagnostics
)
{
var converterFragment = TryEmitConverter(parameter.ConverterType, parameter.Property);
if (converterFragment is null)
{
diagnostics.Report(
DiagnosticDescriptors.CommandInputConverterNotInferrable,
parameter.Property.Locations.FirstOrDefault(),
parameter.Property.Name,
parameter.Property.Type.GetGloballyQualifiedName()
);
return null;
}
// lang=csharp
return $$"""
new global::CliFx.Binding.CommandParameterDescriptor<{{command.Type.GetGloballyQualifiedName()}}, {{parameter.Property.Type.GetGloballyQualifiedName()}}>(
new global::CliFx.Binding.PropertyDescriptor<{{command.Type.GetGloballyQualifiedName()}}, {{parameter.Property.Type.GetGloballyQualifiedName()}}>(
{{CSharp.Encode(parameter.Property.Name)}},
c => c.{{parameter.Property.Name}},
(c, v) => c.{{parameter.Property.Name}} = v
),
{{parameter.Order}},
{{CSharp.Encode(parameter.Name)}},
{{parameter.IsRequired.ToString().ToLowerInvariant()}},
{{CSharp.Encode(parameter.Description)}},
{{converterFragment}},
{{EmitValidators(
parameter.ValidatorTypes,
parameter.Property.Type.GetGloballyQualifiedName()
)}}
)
""";
}
private static string? TryEmitCommandOptionDescriptor(
CommandSymbol command,
CommandOptionSymbol option,
DiagnosticReporter diagnostics
)
{
var converterFragment = TryEmitConverter(option.ConverterType, option.Property);
if (converterFragment is null)
{
diagnostics.Report(
DiagnosticDescriptors.CommandInputConverterNotInferrable,
option.Property.Locations.FirstOrDefault(),
option.Property.Name,
option.Property.Type.GetGloballyQualifiedName()
);
return null;
}
// lang=csharp
return $$"""
new global::CliFx.Binding.CommandOptionDescriptor<{{command.Type.GetGloballyQualifiedName()}}, {{option.Property.Type.GetGloballyQualifiedName()}}>(
new global::CliFx.Binding.PropertyDescriptor<{{command.Type.GetGloballyQualifiedName()}}, {{option.Property.Type.GetGloballyQualifiedName()}}>(
{{CSharp.Encode(option.Property.Name)}},
c => c.{{option.Property.Name}},
(c, v) => c.{{option.Property.Name}} = v
),
{{CSharp.Encode(option.Name)}},
{{CSharp.Encode(option.ShortName)}},
{{CSharp.Encode(option.EnvironmentVariable)}},
{{option.IsRequired.ToString().ToLowerInvariant()}},
{{CSharp.Encode(option.Description)}},
{{converterFragment}},
{{EmitValidators(
option.ValidatorTypes,
option.Property.Type.GetGloballyQualifiedName()
)}}
)
""";
}
private static string TryEmitCommandHelpOptionDescriptor(
CommandSymbol command,
DiagnosticReporter diagnostics
)
{
// Check if the user already implemented a custom help option
if (command.Type.Implements("CliFx.ICommandWithHelpOption"))
{
// Ensure that the user bound their IsHelpRequested property to an option
var isHelpRequestedProperty = command
.Type.GetProperties()
.FirstOrDefault(p =>
string.Equals(p.Name, "IsHelpRequested", StringComparison.Ordinal)
);
var hasOptionBinding =
isHelpRequestedProperty?.TryGetAttribute("CliFx.Binding.CommandOptionAttribute")
is not null;
if (!hasOptionBinding)
{
diagnostics.Report(
DiagnosticDescriptors.CommandHelpOptionPropertyMustBeBound,
isHelpRequestedProperty?.Locations.FirstOrDefault()
?? command.Type.Locations.FirstOrDefault(),
"IsHelpRequested"
);
}
return null;
}
// Check if the help option is being shadowed by an existing option
var shadowingOptionByName = command.Options.FirstOrDefault(o =>
string.Equals(o.Name, "help", StringComparison.OrdinalIgnoreCase)
);
var shadowingOptionByShortName = command.Options.FirstOrDefault(o => o.ShortName == 'h');
var shadowingOption = shadowingOptionByShortName ?? shadowingOptionByName;
if (shadowingOption is not null)
{
diagnostics.Report(
DiagnosticDescriptors.CommandOptionShadowsConventionalHelpOption,
shadowingOption.Property.Locations.FirstOrDefault(),
shadowingOption.Property.Name,
shadowingOptionByName is not null ? "name" : "short name"
);
return null;
}
// lang=csharp
return $$"""
new global::CliFx.Binding.CommandOptionDescriptor<{{command.Type.GetGloballyQualifiedName()}}, bool>(
new global::CliFx.Binding.PropertyDescriptor<{{command.Type.GetGloballyQualifiedName()}}, bool>(
{{CSharp.Encode("IsHelpRequested")}},
c => c.IsHelpRequested,
(c, v) => c.IsHelpRequested = v
),
{{CSharp.Encode("help")}},
{{CSharp.Encode('h')}},
null,
false,
{{CSharp.Encode("Shows help text.")}},
new global::CliFx.Activation.BoolScalarInputConverter(),
global::System.Array.Empty<global::CliFx.Activation.IInputValidator<bool>>()
)
""";
}
private static string TryEmitCommandVersionOptionDescriptor(
CommandSymbol command,
DiagnosticReporter diagnostics
)
{
// Check if the user already implemented a custom version option
if (command.Type.Implements("CliFx.ICommandWithVersionOption"))
{
var isVersionRequestedProperty = command
.Type.GetProperties()
.FirstOrDefault(p =>
string.Equals(p.Name, "IsVersionRequested", StringComparison.Ordinal)
);
var hasOptionBinding =
isVersionRequestedProperty?.TryGetAttribute("CliFx.Binding.CommandOptionAttribute")
is not null;
if (!hasOptionBinding)
{
diagnostics.Report(
DiagnosticDescriptors.CommandVersionOptionPropertyMustBeBound,
isVersionRequestedProperty?.Locations.FirstOrDefault()
?? command.Type.Locations.FirstOrDefault(),
"IsVersionRequested"
);
}
return null;
}
// Only default commands get the conventional version option
if (!command.IsDefault)
return null;
// Check if the version option is being shadowed by an existing option
var shadowingOptionByName = command.Options.FirstOrDefault(o =>
string.Equals(o.Name, "version", StringComparison.OrdinalIgnoreCase)
);
if (shadowingOptionByName is not null)
{
diagnostics.Report(
DiagnosticDescriptors.CommandOptionShadowsConventionalVersionOption,
shadowingOptionByName.Property.Locations.FirstOrDefault(),
shadowingOptionByName.Property.Name,
"name"
);
return null;
}
// lang=csharp
return $$"""
new global::CliFx.Binding.CommandOptionDescriptor<{{command.Type.GetGloballyQualifiedName()}}, bool>(
new global::CliFx.Binding.PropertyDescriptor<{{command.Type.GetGloballyQualifiedName()}}, bool>(
{{CSharp.Encode("IsVersionRequested")}},
c => c.IsVersionRequested,
(c, v) => c.IsVersionRequested = v
),
{{CSharp.Encode("version")}},
null,
null,
false,
{{CSharp.Encode("Shows version information.")}},
new global::CliFx.Activation.BoolScalarInputConverter(),
global::System.Array.Empty<global::CliFx.Activation.IInputValidator<bool>>()
)
""";
}
private static string? TryEmitCommandInputDescriptor(
CommandSymbol command,
CommandInputSymbol input,
DiagnosticReporter diagnostics
) =>
input switch
{
CommandParameterSymbol parameter => TryEmitCommandParameterDescriptor(
command,
parameter,
diagnostics
),
CommandOptionSymbol option => TryEmitCommandOptionDescriptor(
command,
option,
diagnostics
),
_ => throw new InvalidOperationException(
$"Unknown input type '{input.GetType().Name}'."
),
};
internal static string Emit(CommandSymbol command, DiagnosticReporter diagnostics) =>
// lang=csharp
$$"""
// <auto-generated />
{{(command.Type.TryGetNamespaceName() is { } namespaceName
? $"namespace {namespaceName};"
: ""
)}}
{{string.Concat(command.Type.GetContainingTypes().Reverse().Select(t =>
// lang=csharp
$$"""
partial class {{t.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)}}
{
"""
))}}
partial class {{command.Type.Name}}
{
/// <summary>
/// Generated command descriptor for <see cref="{{Xml.Escape(command.Type.GetGloballyQualifiedName())}}" /> ({{Xml.Escape(command.ToString())}}).
/// </summary>
/// <remarks>
/// <list type="bullet">
{{string.Join(
Environment.NewLine,
command.Inputs.Select(i =>
// lang=xml
$"""
/// <item>
/// <see cref="{Xml.Escape(i.Property.GetGloballyQualifiedName())}" /> ({Xml.Escape(i.ToString())})
/// </item>
"""
)
).NullIfWhiteSpace() ?? "///"}}
/// </list>
/// </remarks>
public static global::CliFx.Binding.CommandDescriptor Descriptor { get; } =
new global::CliFx.Binding.CommandDescriptor<{{command.Type.GetGloballyQualifiedName()}}>(
{{CSharp.Encode(command.Name)}},
{{CSharp.Encode(command.Description)}},
{{EmitExamples(command.Examples)}},
new global::CliFx.Binding.CommandInputDescriptor[]
{
{{string.Join(",", command.Inputs
.Select(i => TryEmitCommandInputDescriptor(
command,
i,
diagnostics
))
.Append(
TryEmitCommandHelpOptionDescriptor(command, diagnostics) is {} helpOptionDescriptorFragment
? helpOptionDescriptorFragment
: null
)
.Append(
TryEmitCommandVersionOptionDescriptor(command, diagnostics) is {} versionOptionDescriptorFragment
? versionOptionDescriptorFragment
: null
)
.WhereNotNull()
)}}
}
);
}
{{(!command.Type.Implements("CliFx.ICommandWithHelpOption")
? // lang=csharp
$$"""
partial class {{command.Type.Name}} : global::CliFx.ICommandWithHelpOption
{
/// <inheritdoc />
public bool IsHelpRequested { get; set; }
}
"""
: ""
)}}
{{(command.IsDefault && !command.Type.Implements("CliFx.ICommandWithVersionOption")
? // lang=csharp
$$"""
partial class {{command.Type.Name}} : global::CliFx.ICommandWithVersionOption
{
/// <inheritdoc />
public bool IsVersionRequested { get; set; }
}
"""
: ""
)}}
{{string.Concat(command.Type.GetContainingTypes().Reverse().Select(_ => "}"))}}
""";
}