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.
588 lines
22 KiB
C#
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(_ => "}"))}}
|
|
""";
|
|
}
|