From 8546c54c23bc3a0bdfdeac33fd1ae22c847e8f6e Mon Sep 17 00:00:00 2001 From: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> Date: Fri, 13 Sep 2024 02:00:39 +0300 Subject: [PATCH] wip as fuck --- .../CliFx.SourceGeneration.csproj | 6 +- .../CommandSchemaGenerator.cs | 299 ++++++++++++++++++ .../DiagnosticDescriptors.cs | 26 ++ CliFx.SourceGeneration/KnownSymbolNames.cs | 10 + .../Utils/Extensions/CollectionExtensions.cs | 16 + .../Utils/Extensions/RoslynExtensions.cs | 20 ++ .../Utils/Extensions/StringExtensions.cs | 9 + CliFx/Formatting/HelpConsoleFormatter.cs | 1 - 8 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 CliFx.SourceGeneration/CommandSchemaGenerator.cs create mode 100644 CliFx.SourceGeneration/DiagnosticDescriptors.cs create mode 100644 CliFx.SourceGeneration/KnownSymbolNames.cs create mode 100644 CliFx.SourceGeneration/Utils/Extensions/CollectionExtensions.cs create mode 100644 CliFx.SourceGeneration/Utils/Extensions/RoslynExtensions.cs create mode 100644 CliFx.SourceGeneration/Utils/Extensions/StringExtensions.cs diff --git a/CliFx.SourceGeneration/CliFx.SourceGeneration.csproj b/CliFx.SourceGeneration/CliFx.SourceGeneration.csproj index 10b7043..a305c42 100644 --- a/CliFx.SourceGeneration/CliFx.SourceGeneration.csproj +++ b/CliFx.SourceGeneration/CliFx.SourceGeneration.csproj @@ -5,7 +5,7 @@ true true true - $(NoWarn);RS1025;RS1026 + $(NoWarn);RS1035 @@ -19,8 +19,8 @@ - - + + diff --git a/CliFx.SourceGeneration/CommandSchemaGenerator.cs b/CliFx.SourceGeneration/CommandSchemaGenerator.cs new file mode 100644 index 0000000..ddb28be --- /dev/null +++ b/CliFx.SourceGeneration/CommandSchemaGenerator.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using CliFx.SourceGeneration.Utils.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CliFx.SourceGeneration; + +[Generator] +public partial class CommandSchemaGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var values = context.SyntaxProvider.ForAttributeWithMetadataName<( + CommandSymbol?, + Diagnostic? + )>( + KnownSymbolNames.CliFxCommandAttribute, + (n, _) => n is TypeDeclarationSyntax, + (x, _) => + { + // Predicate ensures that these casts are safe + var typeDeclarationSyntax = (TypeDeclarationSyntax)x.TargetNode; + var namedTypeSymbol = (INamedTypeSymbol)x.TargetSymbol; + + // Check if the target type and all its containing types are partial + if ( + typeDeclarationSyntax + .AncestorsAndSelf() + .Any(a => + a is TypeDeclarationSyntax t + && !t.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)) + ) + ) + { + return ( + null, + Diagnostic.Create( + DiagnosticDescriptors.CommandMustBePartial, + typeDeclarationSyntax.Identifier.GetLocation() + ) + ); + } + + // Check if the target type implements ICommand + var hasCommandInterface = namedTypeSymbol.AllInterfaces.Any(i => + i.DisplayNameMatches("CliFx.ICommand") + ); + + if (!hasCommandInterface) + { + return ( + null, + Diagnostic.Create( + DiagnosticDescriptors.CommandMustImplementInterface, + namedTypeSymbol.Locations.First() + ) + ); + } + + // Get the command name + var commandAttribute = x.Attributes.First(a => + a.AttributeClass?.DisplayNameMatches(KnownSymbolNames.CliFxCommandAttribute) + == true + ); + + var commandName = + commandAttribute.ConstructorArguments.FirstOrDefault().Value as string; + + // Get all parameter inputs + var parameterSymbols = namedTypeSymbol + .GetMembers() + .OfType() + .Select(p => + { + var parameterAttribute = p.GetAttributes() + .FirstOrDefault(a => + a.AttributeClass?.DisplayNameMatches( + KnownSymbolNames.CliFxCommandParameterAttribute + ) == true + ); + + if (parameterAttribute is null) + return null; + + var order = + parameterAttribute.ConstructorArguments.FirstOrDefault().Value as int?; + + var isRequired = + parameterAttribute + .NamedArguments.FirstOrDefault(a => + string.Equals(a.Key, "IsRequired", StringComparison.Ordinal) + ) + .Value.Value as bool? + ?? p.IsRequired; + + var name = + parameterAttribute + .NamedArguments.FirstOrDefault(a => + string.Equals(a.Key, "Name", StringComparison.Ordinal) + ) + .Value.Value as string; + + var description = + parameterAttribute + .NamedArguments.FirstOrDefault(a => + string.Equals(a.Key, "Description", StringComparison.Ordinal) + ) + .Value.Value as string; + + var converter = + parameterAttribute + .NamedArguments.FirstOrDefault(a => + string.Equals(a.Key, "Converter", StringComparison.Ordinal) + ) + .Value.Value as ITypeSymbol; + + var validators = parameterAttribute + .NamedArguments.FirstOrDefault(a => + string.Equals(a.Key, "Validators", StringComparison.Ordinal) + ) + .Value.Values.CastArray(); + + return new CommandParameterSymbol( + new PropertyInfo( + p.Name, + p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ), + order, + isRequired, + name, + description, + converter?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + validators + .Select(v => + v.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ) + .ToArray() + ); + }) + .WhereNotNull() + .ToArray(); + + // Get all option inputs + var optionSymbols = namedTypeSymbol + .GetMembers() + .OfType() + .Select(p => + { + var optionAttribute = p.GetAttributes() + .FirstOrDefault(a => + a.AttributeClass?.DisplayNameMatches( + KnownSymbolNames.CliFxCommandOptionAttribute + ) == true + ); + + if (optionAttribute is null) + return null; + + var names = + optionAttribute.ConstructorArguments.FirstOrDefault().Value as string[]; + + var description = + optionAttribute + .NamedArguments.FirstOrDefault(a => + string.Equals(a.Key, "Description", StringComparison.Ordinal) + ) + .Value.Value as string; + + var converter = + optionAttribute + .NamedArguments.FirstOrDefault(a => + string.Equals(a.Key, "Converter", StringComparison.Ordinal) + ) + .Value.Value as ITypeSymbol; + + var validators = optionAttribute + .NamedArguments.FirstOrDefault(a => + string.Equals(a.Key, "Validators", StringComparison.Ordinal) + ) + .Value.Values.CastArray(); + + return new CommandOptionSymbol( + new PropertyInfo( + p.Name, + p.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ), + names, + description, + converter?.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + validators + .Select(v => + v.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + ) + .ToArray() + ); + }) + .WhereNotNull() + .ToArray(); + + return ( + new CommandSymbol( + namedTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + commandName, + parameterSymbols, + optionSymbols + ), + null + ); + } + ); + + // Report diagnostics + var diagnostics = values.Select((v, _) => v.Item2).WhereNotNull(); + context.RegisterSourceOutput(diagnostics, (x, d) => x.ReportDiagnostic(d)); + + // Generate source + var symbols = values.Select((v, _) => v.Item1).WhereNotNull(); + context.RegisterSourceOutput( + symbols, + (x, c) => + { + var source = + // lang=csharp + $$""" + using System.Linq; + using CliFx.Schema; + using CliFx.Extensibility; + + partial class {{ c.TypeName }} + { + public static CommandSchema<{{ c.TypeName }}> Schema { get; } = new( + {{ c.Name }}, + [ + {{ c.Parameters.Select(p => + // lang=csharp + $$""" + new CommandParameterSchema<{{ c.TypeName }}, {{ p.Property.TypeName }}>( + new PropertyBinding<{{ c.TypeName }}, {{ p.Property.TypeName }}>( + obj => obj.{{ p.Property.Name }}, + (obj, value) => obj.{{ p.Property.Name }} = value + ), + p.Order, + p.IsRequired, + p.Name, + p.Description, + new {{ p.ConverterTypeName }}(), + [ + {{ p.ValidatorTypeNames.Select(v => + // lang=csharp + $"new {v}()").JoinToString(",\n") + }} + ] + ) + """ + ).JoinToString(",\n") + }} + ] + } + """; + + x.AddSource($"{c.TypeName}.CommandSchema.Generated.cs", source); + } + ); + } +} + +public partial class CommandSchemaGenerator +{ + // TODO make all types structurally equatable + private record PropertyInfo(string Name, string TypeName); + + private record CommandParameterSymbol( + PropertyInfo Property, + int? Order, + bool IsRequired, + string? Name, + string? Description, + string? ConverterTypeName, + IReadOnlyList ValidatorTypeNames + ); + + private record CommandOptionSymbol( + PropertyInfo Property, + string[] Names, + string? Description, + string? ConverterTypeName, + IReadOnlyList ValidatorTypeNames + ); + + private record CommandSymbol( + string TypeName, + string? Name, + IReadOnlyList Parameters, + IReadOnlyList Options + ); +} diff --git a/CliFx.SourceGeneration/DiagnosticDescriptors.cs b/CliFx.SourceGeneration/DiagnosticDescriptors.cs new file mode 100644 index 0000000..023be11 --- /dev/null +++ b/CliFx.SourceGeneration/DiagnosticDescriptors.cs @@ -0,0 +1,26 @@ +using Microsoft.CodeAnalysis; + +namespace CliFx.SourceGeneration; + +internal static class DiagnosticDescriptors +{ + public static DiagnosticDescriptor CommandMustBePartial { get; } = + new( + $"{nameof(CliFx)}_{nameof(CommandMustBePartial)}", + "Command types must be declared as `partial`", + "This type (and all its containing types, if present) must be declared as `partial` in order to be a valid command.", + "CliFx", + DiagnosticSeverity.Error, + true + ); + + public static DiagnosticDescriptor CommandMustImplementInterface { get; } = + new( + $"{nameof(CliFx)}_{nameof(CommandMustImplementInterface)}", + $"Commands must implement the `{KnownSymbolNames.CliFxCommandInterface}` interface", + $"This type must implement the `{KnownSymbolNames.CliFxCommandInterface}` interface in order to be a valid command.", + "CliFx", + DiagnosticSeverity.Error, + true + ); +} diff --git a/CliFx.SourceGeneration/KnownSymbolNames.cs b/CliFx.SourceGeneration/KnownSymbolNames.cs new file mode 100644 index 0000000..e08fda9 --- /dev/null +++ b/CliFx.SourceGeneration/KnownSymbolNames.cs @@ -0,0 +1,10 @@ +namespace CliFx.SourceGeneration; + +internal static class KnownSymbolNames +{ + public const string CliFxCommandInterface = "CliFx.ICommand"; + public const string CliFxCommandAttribute = "CliFx.Attributes.CommandAttribute"; + public const string CliFxCommandParameterAttribute = + "CliFx.Attributes.CommandParameterAttribute"; + public const string CliFxCommandOptionAttribute = "CliFx.Attributes.CommandOptionAttribute"; +} diff --git a/CliFx.SourceGeneration/Utils/Extensions/CollectionExtensions.cs b/CliFx.SourceGeneration/Utils/Extensions/CollectionExtensions.cs new file mode 100644 index 0000000..a9f3314 --- /dev/null +++ b/CliFx.SourceGeneration/Utils/Extensions/CollectionExtensions.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace CliFx.SourceGeneration.Utils.Extensions; + +internal static class CollectionExtensions +{ + public static IEnumerable WhereNotNull(this IEnumerable source) + where T : class + { + foreach (var i in source) + { + if (i is not null) + yield return i; + } + } +} diff --git a/CliFx.SourceGeneration/Utils/Extensions/RoslynExtensions.cs b/CliFx.SourceGeneration/Utils/Extensions/RoslynExtensions.cs new file mode 100644 index 0000000..985dc03 --- /dev/null +++ b/CliFx.SourceGeneration/Utils/Extensions/RoslynExtensions.cs @@ -0,0 +1,20 @@ +using System; +using Microsoft.CodeAnalysis; + +namespace CliFx.SourceGeneration.Utils.Extensions; + +internal static class RoslynExtensions +{ + public static bool DisplayNameMatches(this ISymbol symbol, string name) => + string.Equals( + // Fully qualified name, without `global::` + symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), + name, + StringComparison.Ordinal + ); + + public static IncrementalValuesProvider WhereNotNull( + this IncrementalValuesProvider values + ) + where T : class => values.Where(i => i is not null); +} diff --git a/CliFx.SourceGeneration/Utils/Extensions/StringExtensions.cs b/CliFx.SourceGeneration/Utils/Extensions/StringExtensions.cs new file mode 100644 index 0000000..33dc7a0 --- /dev/null +++ b/CliFx.SourceGeneration/Utils/Extensions/StringExtensions.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace CliFx.SourceGeneration.Utils.Extensions; + +internal static class StringExtensions +{ + public static string JoinToString(this IEnumerable source, string separator) => + string.Join(separator, source); +} diff --git a/CliFx/Formatting/HelpConsoleFormatter.cs b/CliFx/Formatting/HelpConsoleFormatter.cs index 21a1be2..a7c0e74 100644 --- a/CliFx/Formatting/HelpConsoleFormatter.cs +++ b/CliFx/Formatting/HelpConsoleFormatter.cs @@ -4,7 +4,6 @@ using System.Globalization; using System.Linq; using CliFx.Infrastructure; using CliFx.Schema; -using CliFx.Utils.Extensions; namespace CliFx.Formatting;