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 validatorTypes, string propertyTypeName ) { if (validatorTypes.Count == 0) return $"global::System.Array.Empty>()"; // lang=csharp return $$""" new global::CliFx.Activation.IInputValidator<{{propertyTypeName}}>[] { {{string.Join( ", ", validatorTypes.Select(v => $"new {v.GetGloballyQualifiedName()}()") )}} } """; } private static string EmitExamples(IReadOnlyList examples) { if (examples.Count == 0) return "global::System.Array.Empty()"; // 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 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 is SpecialType.System_Collections_Generic_IEnumerable_T // ICollection or SpecialType.System_Collections_Generic_ICollection_T // IList or SpecialType.System_Collections_Generic_IList_T // IReadOnlyCollection or SpecialType.System_Collections_Generic_IReadOnlyCollection_T // IReadOnlyList 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 is SpecialType.System_Collections_Generic_IEnumerable_T // ICollection or SpecialType.System_Collections_Generic_ICollection_T // IList or SpecialType.System_Collections_Generic_IList_T // IReadOnlyCollection or SpecialType.System_Collections_Generic_IReadOnlyCollection_T // IReadOnlyList 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>() ) """; } 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>() ) """; } 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 $$""" // {{(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}} { /// /// Generated command descriptor for ({{Xml.Escape(command.ToString())}}). /// /// /// {{string.Join( Environment.NewLine, command.Inputs.Select(i => // lang=xml $""" /// /// ({Xml.Escape(i.ToString())}) /// """ ) ).NullIfWhiteSpace() ?? "///"}} /// /// 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 { /// public bool IsHelpRequested { get; set; } } """ : "" )}} {{(command.IsDefault && !command.Type.Implements("CliFx.ICommandWithVersionOption") ? // lang=csharp $$""" partial class {{command.Type.Name}} : global::CliFx.ICommandWithVersionOption { /// public bool IsVersionRequested { get; set; } } """ : "" )}} {{string.Concat(command.Type.GetContainingTypes().Reverse().Select(_ => "}"))}} """; }