Upstream Tyrrrz/CliFx state of the touched files before 9ef5dcb635

This commit is contained in:
upstream committed 2026-08-28 10:12:58 +02:00
commit e3e64a40e4
6 files changed
+2165

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.Linq;
using CliFx.Generators.Utils;
using CliFx.Generators.Utils.Extensions;
using Microsoft.CodeAnalysis;
namespace CliFx.Generators.Binding;
internal partial record CommandSymbol(
INamedTypeSymbol Type,
string? Name,
string? Description,
IReadOnlyList<CommandInputSymbol> Inputs
)
{
public bool IsDefault => string.IsNullOrWhiteSpace(Name);
public IReadOnlyList<CommandParameterSymbol> Parameters { get; } =
Inputs.OfType<CommandParameterSymbol>().ToArray();
public IReadOnlyList<CommandOptionSymbol> Options { get; } =
Inputs.OfType<CommandOptionSymbol>().ToArray();
public override string ToString() => !string.IsNullOrWhiteSpace(Name) ? Name : "<default>";
}
internal partial record CommandSymbol
{
internal static CommandSymbol? TryResolve(INamedTypeSymbol type, DiagnosticReporter diagnostics)
{
// Must have the [Command] attribute
var attribute = type.TryGetAttribute("CliFx.Binding.CommandAttribute");
if (attribute is null)
{
// Shouldn't happen when called by the generator since it filters types by attribute,
// so not worth the effort to produce a diagnostic.
return null;
}
// Must be partial by itself, along with all containing types
foreach (var declaration in type.GetDeclarations().ToArray())
{
if (!declaration.IsFullyPartial())
{
diagnostics.Report(
DiagnosticDescriptors.CommandTypeMustBePartial,
declaration.Identifier.GetLocation(),
type.Name
);
}
}
// Must implement ICommand
if (!type.Implements("CliFx.ICommand"))
{
diagnostics.Report(
DiagnosticDescriptors.CommandTypeMustImplementICommand,
type.Locations.FirstOrDefault(),
type.Name
);
}
var name =
attribute?.NamedArguments.FirstOrDefault(a => a.Key == "Name").Value.Value as string
?? attribute
?.ConstructorArguments.Where(a => a.Type?.SpecialType == SpecialType.System_String)
.Select(a => a.Value as string)
.FirstOrDefault();
var description =
attribute?.NamedArguments.FirstOrDefault(a => a.Key == "Description").Value.Value
as string;
var properties = type.GetProperties().ToArray();
var parameters = CommandParameterSymbol.Resolve(properties, diagnostics);
var options = CommandOptionSymbol.Resolve(properties, diagnostics);
return new CommandSymbol(type, name, description, [.. parameters, .. options]);
}
}
@@ -0,0 +1,575 @@
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? 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)}},
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(_ => "}"))}}
""";
}