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;