From 69fcee867a99726906467c9d2a7fa9231870d3ae Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Sat, 12 Sep 2026 12:17:55 -0400 Subject: [PATCH 1/2] Move method cloning plumbing into a Cloning layer Separates the parts of the generator which have nothing to do with async from the async to sync rules, so that the next transformation does not have to start by untangling them: - CloneTarget finds the methods an attribute marks, directly or through their containing type - MethodLocation collects the namespaces, using directives and containing types a copy of a method needs to compile - MethodSignature and ClonedMethodOutput detect colliding signatures, name the generated files and register the output - ClonedMethodSource writes the file around the rewritten method SyncMethodSourceGenerator keeps the attributes, the filtering and the rewriter. Namespaces and using directives are now collected for every method rather than only for those without errors, so a method with errors is no longer compared for collisions without its namespace. Generated with Claude Code --- .../Cloning/CloneTarget.cs | 61 +++ .../Cloning/ClonedMethod.cs | 53 +++ .../Cloning/ClonedMethodOutput.cs | 138 +++++++ .../Cloning/ClonedMethodSource.cs | 146 +++++++ .../Cloning/MethodLocation.cs | 183 +++++++++ .../{ => Cloning}/MethodParent.cs | 2 +- .../{ => Cloning}/MethodParentDeclaration.cs | 12 +- .../Cloning/MethodSignature.cs | 89 ++++ .../MethodToGenerate.cs | 32 -- .../Models/SynchronizedSignature.cs | 33 -- .../SourceGenerationHelper.cs | 133 +----- .../SyncMethodSourceGenerator.cs | 380 +----------------- .../Zomp.SyncMethodGenerator.csproj | 1 + 13 files changed, 691 insertions(+), 572 deletions(-) create mode 100644 src/Zomp.SyncMethodGenerator/Cloning/CloneTarget.cs create mode 100644 src/Zomp.SyncMethodGenerator/Cloning/ClonedMethod.cs create mode 100644 src/Zomp.SyncMethodGenerator/Cloning/ClonedMethodOutput.cs create mode 100644 src/Zomp.SyncMethodGenerator/Cloning/ClonedMethodSource.cs create mode 100644 src/Zomp.SyncMethodGenerator/Cloning/MethodLocation.cs rename src/Zomp.SyncMethodGenerator/{ => Cloning}/MethodParent.cs (59%) rename src/Zomp.SyncMethodGenerator/{ => Cloning}/MethodParentDeclaration.cs (70%) create mode 100644 src/Zomp.SyncMethodGenerator/Cloning/MethodSignature.cs delete mode 100644 src/Zomp.SyncMethodGenerator/MethodToGenerate.cs delete mode 100644 src/Zomp.SyncMethodGenerator/Models/SynchronizedSignature.cs diff --git a/src/Zomp.SyncMethodGenerator/Cloning/CloneTarget.cs b/src/Zomp.SyncMethodGenerator/Cloning/CloneTarget.cs new file mode 100644 index 0000000..c57662e --- /dev/null +++ b/src/Zomp.SyncMethodGenerator/Cloning/CloneTarget.cs @@ -0,0 +1,61 @@ +namespace Zomp.SyncMethodGenerator.Cloning; + +/// +/// A method marked for cloning, either by an attribute of its own or by one on its containing type. +/// +/// Context of the attribute which marked the method. +/// Declaration of the method. +internal sealed record CloneTarget(GeneratorAttributeSyntaxContext Context, MethodDeclarationSyntax Syntax) +{ + /// + /// Finds the methods an attribute marks. One on a type marks every method the type declares. + /// + /// Syntax provider of the generator. + /// Name of the attribute. + /// A method for each method marked. + public static IncrementalValuesProvider ForAttribute(SyntaxValueProvider syntaxProvider, string fullyQualifiedMetadataName) + => syntaxProvider + .ForAttributeWithMetadataName( + fullyQualifiedMetadataName, + predicate: static (s, _) => IsCandidate(s), + transform: static (ctx, ct) => Expand(ctx, ct)) + .SelectMany((list, ct) => list); + + private static bool IsCandidate(SyntaxNode node) => node switch + { + MethodDeclarationSyntax { AttributeLists.Count: > 0 } => true, + ClassDeclarationSyntax { AttributeLists.Count: > 0 } => true, + StructDeclarationSyntax { AttributeLists.Count: > 0 } => true, + InterfaceDeclarationSyntax { AttributeLists.Count: > 0 } => true, + RecordDeclarationSyntax { AttributeLists.Count: > 0 } => true, + _ => false, + }; + + private static ImmutableArray Expand(GeneratorAttributeSyntaxContext ctx, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + if (ctx.TargetNode is TypeDeclarationSyntax typeDecl) + { +#if ROSLYN_4_12_OR_GREATER + return [.. typeDecl.Members.OfType().Select(s => new CloneTarget(ctx, s))]; +#else + return ImmutableArray.CreateRange(typeDecl.Members.OfType().Select(s => new CloneTarget(ctx, s))); +#endif + } + else if (ctx.TargetNode is MethodDeclarationSyntax methodDecl) + { +#if ROSLYN_4_12_OR_GREATER + return [new CloneTarget(ctx, methodDecl)]; +#else + return ImmutableArray.Create(new CloneTarget(ctx, methodDecl)); +#endif + } + +#if ROSLYN_4_12_OR_GREATER + return []; +#else + return ImmutableArray.Empty; +#endif + } +} diff --git a/src/Zomp.SyncMethodGenerator/Cloning/ClonedMethod.cs b/src/Zomp.SyncMethodGenerator/Cloning/ClonedMethod.cs new file mode 100644 index 0000000..ccf899f --- /dev/null +++ b/src/Zomp.SyncMethodGenerator/Cloning/ClonedMethod.cs @@ -0,0 +1,53 @@ +namespace Zomp.SyncMethodGenerator.Cloning; + +/// +/// A method rewritten by a transformation, ready to be emitted into a file of its own. +/// +/// Where the original method sits. +/// Name of the original method. +/// The rewritten method. +/// True to leave the #nullable enable directive out of the file. +/// Diagnostics the transformation reported. +/// True if there are errors in , in which case nothing is emitted. +/// Signature the rewritten method will be emitted with, used to detect colliding overloads. +internal sealed record ClonedMethod( + MethodLocation Location, + string MethodName, + string Implementation, + bool DisableNullable, + EquatableArray Diagnostics, + bool HasErrors, + MethodSignature? Signature) +{ + /// + /// Creates a cloned method from the result of rewriting it. + /// + /// Where the original method sits. + /// Declaration of the original method. + /// Result of rewriting the method, or the extension block which holds it. + /// True to leave the #nullable enable directive out of the file. + /// Diagnostics the transformation reported. + /// A new . + public static ClonedMethod Create( + MethodLocation location, + MethodDeclarationSyntax original, + SyntaxNode rewritten, + bool disableNullable, + ImmutableArray diagnostics) + { + var hasErrors = false; + foreach (var diagnostic in diagnostics) + { + hasErrors |= diagnostic.Descriptor.DefaultSeverity == DiagnosticSeverity.Error; + } + + return new( + location, + original.Identifier.ValueText, + rewritten.ToFullString(), + disableNullable, + diagnostics, + hasErrors, + MethodSignature.Create(rewritten, location, original)); + } +} diff --git a/src/Zomp.SyncMethodGenerator/Cloning/ClonedMethodOutput.cs b/src/Zomp.SyncMethodGenerator/Cloning/ClonedMethodOutput.cs new file mode 100644 index 0000000..5486ce0 --- /dev/null +++ b/src/Zomp.SyncMethodGenerator/Cloning/ClonedMethodOutput.cs @@ -0,0 +1,138 @@ +namespace Zomp.SyncMethodGenerator.Cloning; + +/// +/// Emits cloned methods, each into a file of its own, and reports those which cannot be emitted. +/// +internal static class ClonedMethodOutput +{ + /// + /// Longest generated file name before the scope is shortened. Emitted files sit under + /// {project}\obj\{configuration}\{framework}\generated\{generator assembly}\{generator}, + /// which alone is around a hundred characters before the project path is counted. + /// + private const int MaxFileNameLength = 100; + + /// + /// Adds the output of the generator: a file for each method, and the diagnostics of each. + /// + /// Context of the generator. + /// Methods to emit. + /// Reported against each method whose signature another method also produces. + public static void Register( + IncrementalGeneratorInitializationContext context, + IncrementalValuesProvider methods, + DiagnosticDescriptor collision) + { + var sourceTexts = methods + .Select(static (m, _) => GenerateSource(m)) + .WithTrackingName("GenerateSource"); + + // Signatures produced more than once. Emitting them all would declare the same member + // twice, so the methods behind them are reported instead of generated. + var collidingSignatures = methods + .Select(static (m, _) => m.Signature?.Key) + .Collect() + .Select(static (keys, _) => MethodSignature.FindCollisions(keys)); + + context.RegisterSourceOutput( + sourceTexts.Combine(collidingSignatures), + (spc, pair) => + { + var (source, colliding) = pair; + + foreach (var diagnostic in source.Method.Diagnostics) + { + spc.ReportDiagnostic(diagnostic); + } + + if (source.Method.Signature is { } signature + && colliding.AsImmutableArray().Contains(signature.Key, StringComparer.Ordinal)) + { + spc.ReportDiagnostic(signature.ToDiagnostic(collision)); + return; + } + + if (!source.Method.HasErrors) + { + spc.AddSource(source.Path, SourceText.From(source.Content, Encoding.UTF8)); + } + }); + } + + private static (ClonedMethod Method, string Path, string Content) GenerateSource(ClonedMethod m) + { + var location = m.Location; + + var scope = $"{string.Join(".", location.Namespaces)}" + + $".{string.Join(".", location.Parents.Select(static p => p.ScopeName))}" + + (location.IsCSharp14Extension ? ".ext" : string.Empty); + + var method = m.MethodName + (location.Index == 1 ? string.Empty : "_" + location.Index); + + var sourcePath = BuildFileName(scope, method); + + var source = ClonedMethodSource.Generate(m); + + return (m, sourcePath, source); + } + + /// + /// Builds the file name a generated method is emitted under, shortening it when the + /// namespace and containing type chain make it long enough to be a problem. Emitted files + /// live several directories deep inside the intermediate output path, so a long name here + /// can carry the full path past the limit the file system accepts. + /// + /// + /// The scope is what gets shortened, since the method name is what someone reads the file + /// name for. A hash of the untruncated name keeps distinct methods in distinct files. + /// + private static string BuildFileName(string scope, string method) + { + const string extension = ".g.cs"; + + var fileName = $"{scope}.{method}{extension}"; + + if (fileName.Length <= MaxFileNameLength) + { + return fileName; + } + + var hash = Hash(fileName); + + // separator, hash, dot, extension + var fixedLength = 1 + hash.Length + 1 + extension.Length; + + var forScope = MaxFileNameLength - fixedLength - method.Length; + + if (forScope > 0) + { + return $"{scope[..Math.Min(scope.Length, forScope)]}_{hash}.{method}{extension}"; + } + + // The method name alone fills the budget, so it has to be shortened as well. + return $"_{hash}.{method[..(MaxFileNameLength - fixedLength)]}{extension}"; + } + + /// + /// FNV-1a. Short, dependency free, and stable across processes and runtimes, which + /// is not. + /// + private static string Hash(string value) + { + const uint offsetBasis = 2166136261; + const uint prime = 16777619; + + var hash = offsetBasis; + + unchecked + { + foreach (var c in value) + { + hash ^= c; + hash *= prime; + } + } + + return hash.ToString("x8", System.Globalization.CultureInfo.InvariantCulture); + } +} diff --git a/src/Zomp.SyncMethodGenerator/Cloning/ClonedMethodSource.cs b/src/Zomp.SyncMethodGenerator/Cloning/ClonedMethodSource.cs new file mode 100644 index 0000000..870b0a8 --- /dev/null +++ b/src/Zomp.SyncMethodGenerator/Cloning/ClonedMethodSource.cs @@ -0,0 +1,146 @@ +namespace Zomp.SyncMethodGenerator.Cloning; + +/// +/// Builds the source file a cloned method is emitted in, declaring again, as partial, each +/// namespace and type the original method was nested in. +/// +internal static class ClonedMethodSource +{ + /// + /// Builds the source file for a method. + /// + /// The method. + /// Text of the file. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases", Justification = "Only deal with selected SyntaxKinds")] + public static string Generate(ClonedMethod method) + { + static string GetKeyword(SyntaxKind sk) => sk switch + { + SyntaxKind.PublicKeyword => "public", + SyntaxKind.InternalKeyword => "internal", + SyntaxKind.PrivateKeyword => "private", + SyntaxKind.SealedKeyword => "sealed", + SyntaxKind.ProtectedKeyword => "protected", + SyntaxKind.StaticKeyword => "static", + SyntaxKind.AbstractKeyword => "abstract", + _ => throw new InvalidOperationException($"{sk} is not supported"), + }; + + var location = method.Location; + + // Handle namespaces + var sbBegin = new StringBuilder(); + var sbEnd = new StringBuilder(); + var i = 0; + + if (!location.IsNamespaceFileScoped) + { + foreach (var @namespace in location.Namespaces) + { + var indent = new string(' ', 4 * i); + _ = sbBegin.Append($$""" +{{indent}}namespace {{@namespace}} +{{indent}}{ + +"""); + _ = sbEnd.Insert(0, $$""" +{{indent}}} + +"""); + ++i; + } + } + + // Directives which were written inside the namespace are emitted inside it, at the + // innermost level. One which names a namespace relatively still resolves from there. + foreach (var @using in location.InnerUsings) + { + _ = sbBegin.Append($$""" +{{new string(' ', 4 * i)}}{{@using}} + +"""); + } + + if (!location.InnerUsings.IsEmpty) + { + _ = sbBegin.Append(""" + + +"""); + } + + // Handle classes + foreach (var parent in location.Parents) + { + var indent = new string(' ', 4 * i); + + var modifiers = string.Join(string.Empty, parent.Modifiers.Select(z => GetKeyword((SyntaxKind)z) + " ")); + var parentType = parent.MethodParent switch + { + MethodParent.Class => "class", + MethodParent.Interface => "interface", + MethodParent.Struct => "struct", + MethodParent.Record => "record" + + parent.ClassOrStructKeyword.Kind() switch + { + SyntaxKind.StructKeyword => " struct", + SyntaxKind.ClassKeyword => " class", + _ => string.Empty, + }, + _ => throw new NotImplementedException("Cannot handle the parent of the method"), + }; + + var classDeclarationLine = $"{modifiers}partial {parentType} {parent.ParentName}{(parent.TypeParameterListSyntax.IsEmpty ? string.Empty + : "<" + string.Join(", ", parent.TypeParameterListSyntax) + ">")}"; + + _ = sbBegin.Append($$""" +{{indent}}{{classDeclarationLine}} +{{indent}}{ + +"""); + _ = sbEnd.Insert(0, $$""" +{{indent}}} + +"""); + ++i; + } + + var sbOuterUsings = new StringBuilder(); + foreach (var @using in location.OuterUsings) + { + if (sbOuterUsings.Length == 0) + { + // Separates the directives from the header above them. + _ = sbOuterUsings.Append(""" + + + +"""); + } + + _ = sbOuterUsings.Append($$""" +{{@using}} + +"""); + } + + var beforeNamespace = $""" +// {(method.DisableNullable ? string.Empty : """ + +#nullable enable +""")}{sbOuterUsings} +"""; + + return location.IsNamespaceFileScoped ? $$""" +{{beforeNamespace}} +namespace {{location.Namespaces.First()}}; +{{sbBegin}}{{new string(' ', 4 * i)}}{{method.Implementation.Trim()}} +{{sbEnd}} +""" + : $$""" +{{beforeNamespace}} +{{sbBegin}}{{new string(' ', 4 * i)}}{{method.Implementation.Trim()}} +{{sbEnd}} +"""; + } +} diff --git a/src/Zomp.SyncMethodGenerator/Cloning/MethodLocation.cs b/src/Zomp.SyncMethodGenerator/Cloning/MethodLocation.cs new file mode 100644 index 0000000..158cc23 --- /dev/null +++ b/src/Zomp.SyncMethodGenerator/Cloning/MethodLocation.cs @@ -0,0 +1,183 @@ +namespace Zomp.SyncMethodGenerator.Cloning; + +/// +/// Everything around a method which a copy of it needs in order to compile in a file of its own: +/// the namespaces and types it is nested in, and the using directives in scope where it was written. +/// +/// Position of the method among the members of its type which share its name, starting at 1. +/// Namespaces the method is under, outermost first. +/// Using directives the source file declares outside its namespace. +/// Using directives the source file declares inside its namespace. +/// True if the namespace is file scoped. +/// True if the method is a member of a C# 14 extension block. +/// Types the method is nested in, outermost first. +internal sealed record MethodLocation( + int Index, + EquatableArray Namespaces, + EquatableArray OuterUsings, + EquatableArray InnerUsings, + bool IsNamespaceFileScoped, + bool IsCSharp14Extension, + EquatableArray Parents) +{ + /// + /// Gets the namespaces and types the method is nested in, joined with dots. + /// + public string Scope => string.Join(".", Namespaces.Concat(Parents.Select(static p => p.ScopeName))); + + /// + /// Locates a method. + /// + /// Declaration of the method. + /// Symbol of the method. + /// Where the method sits. + /// Node to rewrite: the method, or the extension block which holds it. + /// False when the method is not nested in a type which can be declared again as partial. + public static bool TryCreate( + MethodDeclarationSyntax method, + IMethodSymbol symbol, + [NotNullWhen(true)] out MethodLocation? location, + [NotNullWhen(true)] out SyntaxNode? root) + { + location = null; + root = null; + + var parents = ImmutableArray.CreateBuilder(); + SyntaxNode? node = method; +#if ROSLYN_5_0_OR_GREATER + ExtensionBlockDeclarationSyntax? extensionParent = null; +#endif + while (node.Parent is not null) + { + node = node.Parent; +#if ROSLYN_5_0_OR_GREATER + if (node is ExtensionBlockDeclarationSyntax eds) + { + extensionParent = eds; + continue; + } +#endif + + MethodParentDeclaration? mpd = node switch + { + ClassDeclarationSyntax o => new(MethodParent.Class, o.Identifier, o.Modifiers, o.TypeParameterList), + StructDeclarationSyntax o => new(MethodParent.Struct, o.Identifier, o.Modifiers, o.TypeParameterList), + RecordDeclarationSyntax o => new(MethodParent.Record, o.Identifier, o.Modifiers, o.TypeParameterList, o.ClassOrStructKeyword), + InterfaceDeclarationSyntax o => new(MethodParent.Interface, o.Identifier, o.Modifiers, o.TypeParameterList), + _ => null, + }; + + if (mpd is null) + { + break; + } + + parents.Insert(0, mpd); + } + + if (parents.Count == 0) + { + return false; + } + + var isNamespaceFileScoped = false; + var namespaces = ImmutableArray.CreateBuilder(); + + // Documentation comments are copied across verbatim, and a cref in one is resolved + // against the file it lands in rather than the file it was written in. Without the + // using directives which were in scope where it was written, every cref which relied on + // one goes unresolved, which a project building with warnings as errors reads as a + // build failure. The directives are kept on the side of the namespace they were + // declared on, since one declared inside a namespace may name it only relatively. + var outerUsings = ImmutableArray.CreateBuilder(); + var innerUsings = ImmutableArray.CreateBuilder(); + + while (node is not null and not CompilationUnitSyntax) + { + switch (node) + { + case NamespaceDeclarationSyntax nds: + namespaces.Insert(0, nds.Name.ToString()); + InsertUsings(innerUsings, nds.Usings); + break; + case FileScopedNamespaceDeclarationSyntax file: + namespaces.Add(file.Name.ToString()); + InsertUsings(innerUsings, file.Usings); + isNamespaceFileScoped = true; + break; + default: + throw new InvalidOperationException($"Cannot handle {node}"); + } + + node = node.Parent; + } + + if (node is CompilationUnitSyntax compilationUnit) + { + InsertUsings(outerUsings, compilationUnit.Usings); + } + +#if ROSLYN_5_0_OR_GREATER + var isCSharp14Extension = extensionParent is not null; + root = extensionParent ?? (SyntaxNode)method; +#else + var isCSharp14Extension = false; + root = method; +#endif + + location = new( + GetIndex(symbol), + namespaces.ToImmutable(), + outerUsings.ToImmutable(), + innerUsings.ToImmutable(), + isNamespaceFileScoped, + isCSharp14Extension, + parents.ToImmutable()); + + return true; + } + + /// + /// Counts the members of the containing type which share the method's name and come before + /// it, so that overloads are told apart in the names of the files they are emitted to. + /// + /// Symbol of the method. + /// Position of the method among its namesakes, starting at 1. + private static int GetIndex(IMethodSymbol symbol) + { + var index = 1; + + if (symbol.ContainingType is { } containingType) + { + foreach (var member in containingType.GetMembers()) + { + if (member.Equals(symbol, SymbolEqualityComparer.Default)) + { + break; + } + + if (member.Name.Equals(symbol.Name, StringComparison.Ordinal)) + { + ++index; + } + } + } + + return index; + } + + /// + /// Records the directives, innermost first, so that walking outwards from the method builds + /// them up in the order they were written. + /// + /// Collected directives. + /// Directives declared at one level. + private static void InsertUsings(ImmutableArray.Builder destination, SyntaxList usings) + { + var index = 0; + foreach (var @using in usings) + { + destination.Insert(index++, @using.ToString()); + } + } +} diff --git a/src/Zomp.SyncMethodGenerator/MethodParent.cs b/src/Zomp.SyncMethodGenerator/Cloning/MethodParent.cs similarity index 59% rename from src/Zomp.SyncMethodGenerator/MethodParent.cs rename to src/Zomp.SyncMethodGenerator/Cloning/MethodParent.cs index 22badb9..f7e78ce 100644 --- a/src/Zomp.SyncMethodGenerator/MethodParent.cs +++ b/src/Zomp.SyncMethodGenerator/Cloning/MethodParent.cs @@ -1,4 +1,4 @@ -namespace Zomp.SyncMethodGenerator; +namespace Zomp.SyncMethodGenerator.Cloning; internal enum MethodParent { diff --git a/src/Zomp.SyncMethodGenerator/MethodParentDeclaration.cs b/src/Zomp.SyncMethodGenerator/Cloning/MethodParentDeclaration.cs similarity index 70% rename from src/Zomp.SyncMethodGenerator/MethodParentDeclaration.cs rename to src/Zomp.SyncMethodGenerator/Cloning/MethodParentDeclaration.cs index 8946eda..6b5d538 100644 --- a/src/Zomp.SyncMethodGenerator/MethodParentDeclaration.cs +++ b/src/Zomp.SyncMethodGenerator/Cloning/MethodParentDeclaration.cs @@ -1,7 +1,7 @@ -namespace Zomp.SyncMethodGenerator; +namespace Zomp.SyncMethodGenerator.Cloning; /// -/// Represents a class a belongs to. +/// Represents a type a is nested in. /// /// Type of container. /// Class name. @@ -19,4 +19,12 @@ public MethodParentDeclaration(MethodParent methodParent, SyntaxToken parentName classOrStructKeyword) { } + + /// + /// Gets the name of the type with its type parameters, so that Class, Class{T} + /// and Class{T,T2} stay distinct. + /// + public string ScopeName => TypeParameterListSyntax.IsEmpty + ? ParentName + : ParentName + "{" + string.Join(",", TypeParameterListSyntax) + "}"; } diff --git a/src/Zomp.SyncMethodGenerator/Cloning/MethodSignature.cs b/src/Zomp.SyncMethodGenerator/Cloning/MethodSignature.cs new file mode 100644 index 0000000..881b916 --- /dev/null +++ b/src/Zomp.SyncMethodGenerator/Cloning/MethodSignature.cs @@ -0,0 +1,89 @@ +namespace Zomp.SyncMethodGenerator.Cloning; + +/// +/// The signature a cloned method will be emitted with, and where the method it came from was +/// written. Two methods sharing a cannot both be emitted: the compiler would +/// see the same member declared twice. +/// +/// Fully qualified signature of the method which will be emitted. +/// File path of the method being cloned. +/// Text span of the method being cloned. +/// Line span of the method being cloned. +internal sealed record MethodSignature(string Key, string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan) +{ + /// + /// Compares by alone. The spans move whenever anything above the method is + /// edited, and letting that reach the incremental pipeline would defeat caching for a method + /// whose generated output has not changed at all. + /// + /// Signature to compare against. + /// True if both describe the same emitted member. + public bool Equals(MethodSignature? other) + => other is not null && string.Equals(Key, other.Key, StringComparison.Ordinal); + + /// + public override int GetHashCode() => Key.GetHashCode(); + + /// + /// Describes the method which is about to be emitted, precisely enough to tell whether two + /// of them would declare the same member. The rewritten declaration is used rather than the + /// original symbol, so the comparison sees exactly what the compiler will see - parameters + /// already dropped, types already substituted. + /// + /// Result of rewriting the method. + /// Where the original method sits. + /// Method being cloned, for the location to report against. + /// The signature, or null when the rewritten method could not be located. + public static MethodSignature? Create(SyntaxNode rewritten, MethodLocation location, MethodDeclarationSyntax original) + { + var method = rewritten as MethodDeclarationSyntax + ?? rewritten.DescendantNodes().OfType().FirstOrDefault(); + + if (method is null) + { + return null; + } + + // Arity by count rather than by name, since overloads which differ only in what they + // call their type parameters still declare the same member. + var arity = method.TypeParameterList?.Parameters.Count ?? 0; + var arityMarker = arity > 0 ? "`" + arity.ToString(System.Globalization.CultureInfo.InvariantCulture) : string.Empty; + + var parameters = string.Join(",", method.ParameterList.Parameters.Select(static p => p.Type?.ToString() ?? string.Empty)); + + var key = $"{location.Scope}.{method.Identifier.ValueText}{arityMarker}({parameters})"; + + var sourceLocation = original.GetLocation(); + + return new(key, sourceLocation.SourceTree?.FilePath ?? string.Empty, sourceLocation.SourceSpan, sourceLocation.GetLineSpan().Span); + } + + /// + /// Picks out the signatures produced by more than one method. Emitting all of them would + /// declare the same member twice. + /// + /// Signature of every method being generated. + /// The signatures which appear more than once. + public static EquatableArray FindCollisions(ImmutableArray keys) + { + var colliding = keys + .Where(static key => key is not null) + .GroupBy(static key => key!, StringComparer.Ordinal) + .Where(static group => group.Count() > 1) + .Select(static group => group.Key); + +#if ROSLYN_4_12_OR_GREATER + return new([.. colliding]); +#else + return new(ImmutableArray.CreateRange(colliding)); +#endif + } + + /// + /// Builds the diagnostic reported when this signature is produced more than once. + /// + /// Descriptor of the diagnostic. + /// A new . + public ReportedDiagnostic ToDiagnostic(DiagnosticDescriptor descriptor) + => new(descriptor, FilePath, TextSpan, LineSpan, Key); +} diff --git a/src/Zomp.SyncMethodGenerator/MethodToGenerate.cs b/src/Zomp.SyncMethodGenerator/MethodToGenerate.cs deleted file mode 100644 index 6094d2b..0000000 --- a/src/Zomp.SyncMethodGenerator/MethodToGenerate.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Zomp.SyncMethodGenerator; - -/// -/// Represents a sync method to generate from its async version. -/// -/// Index of the method in the source file. -/// List of namespaces this method is under. -/// Using directives the source file declares outside its namespace. -/// Using directives the source file declares inside its namespace. -/// True if namespace is file scoped. -/// True if this is C# 14 extension. -/// List of classes/structs/records this method belongs to starting from the outer-most class. -/// Name of the method. -/// Implementation. -/// Disables nullable for the method. -/// Diagnostics. -/// True if there are errors in . -/// Signature the synchronized method will be emitted with, used to detect colliding overloads. -internal sealed record MethodToGenerate( - int Index, - EquatableArray Namespaces, - EquatableArray OuterUsings, - EquatableArray InnerUsings, - bool IsNamespaceFileScoped, - bool IsCSharp14Extension, - EquatableArray Parents, - string MethodName, - string Implementation, - bool DisableNullable, - EquatableArray Diagnostics, - bool HasErrors, - SynchronizedSignature? Signature); diff --git a/src/Zomp.SyncMethodGenerator/Models/SynchronizedSignature.cs b/src/Zomp.SyncMethodGenerator/Models/SynchronizedSignature.cs deleted file mode 100644 index 9d51666..0000000 --- a/src/Zomp.SyncMethodGenerator/Models/SynchronizedSignature.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Zomp.SyncMethodGenerator.Models; - -/// -/// The signature a synchronized method will be emitted with, and where the method it came from -/// was written. Two methods sharing a cannot both be emitted: the compiler -/// would see the same member declared twice. -/// -/// Fully qualified signature of the method which will be emitted. -/// File path of the method being synchronized. -/// Text span of the method being synchronized. -/// Line span of the method being synchronized. -internal sealed record SynchronizedSignature(string Key, string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan) -{ - /// - /// Compares by alone. The spans move whenever anything above the method is - /// edited, and letting that reach the incremental pipeline would defeat caching for a method - /// whose generated output has not changed at all. - /// - /// Signature to compare against. - /// True if both describe the same emitted member. - public bool Equals(SynchronizedSignature? other) - => other is not null && string.Equals(Key, other.Key, StringComparison.Ordinal); - - /// - public override int GetHashCode() => Key.GetHashCode(); - - /// - /// Builds the diagnostic reported when this signature is produced more than once. - /// - /// A new . - public ReportedDiagnostic ToCollisionDiagnostic() - => new(DiagnosticMessages.CollidingOverloads, FilePath, TextSpan, LineSpan, Key); -} diff --git a/src/Zomp.SyncMethodGenerator/SourceGenerationHelper.cs b/src/Zomp.SyncMethodGenerator/SourceGenerationHelper.cs index bd736fb..6f69b3e 100644 --- a/src/Zomp.SyncMethodGenerator/SourceGenerationHelper.cs +++ b/src/Zomp.SyncMethodGenerator/SourceGenerationHelper.cs @@ -1,7 +1,7 @@ namespace Zomp.SyncMethodGenerator; /// -/// Contains routines to construct a generated source file with a synchronized method. +/// Contains the source of the attributes the generator adds to every compilation. /// public static class SourceGenerationHelper { @@ -61,135 +61,4 @@ internal class {{SyncMethodSourceGenerator.SkipSyncVersionAttribute}} : System.A #endif } """; - - [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases", Justification = "Only deal with selected SyntaxKinds")] - internal static string GenerateExtensionClass(MethodToGenerate methodToGenerate) - { - static string GetKeyword(SyntaxKind sk) => sk switch - { - SyntaxKind.PublicKeyword => "public", - SyntaxKind.InternalKeyword => "internal", - SyntaxKind.PrivateKeyword => "private", - SyntaxKind.SealedKeyword => "sealed", - SyntaxKind.ProtectedKeyword => "protected", - SyntaxKind.StaticKeyword => "static", - SyntaxKind.AbstractKeyword => "abstract", - _ => throw new InvalidOperationException($"{sk} is not supported"), - }; - - // Handle namespaces - var sbBegin = new StringBuilder(); - var sbEnd = new StringBuilder(); - var i = 0; - - if (!methodToGenerate.IsNamespaceFileScoped) - { - foreach (var @namespace in methodToGenerate.Namespaces) - { - var indent = new string(' ', 4 * i); - _ = sbBegin.Append($$""" -{{indent}}namespace {{@namespace}} -{{indent}}{ - -"""); - _ = sbEnd.Insert(0, $$""" -{{indent}}} - -"""); - ++i; - } - } - - // Directives which were written inside the namespace are emitted inside it, at the - // innermost level. One which names a namespace relatively still resolves from there. - foreach (var @using in methodToGenerate.InnerUsings) - { - _ = sbBegin.Append($$""" -{{new string(' ', 4 * i)}}{{@using}} - -"""); - } - - if (!methodToGenerate.InnerUsings.IsEmpty) - { - _ = sbBegin.Append(""" - - -"""); - } - - // Handle classes - foreach (var parent in methodToGenerate.Parents) - { - var indent = new string(' ', 4 * i); - - var modifiers = string.Join(string.Empty, parent.Modifiers.Select(z => GetKeyword((SyntaxKind)z) + " ")); - var parentType = parent.MethodParent switch - { - MethodParent.Class => "class", - MethodParent.Interface => "interface", - MethodParent.Struct => "struct", - MethodParent.Record => "record" - + parent.ClassOrStructKeyword.Kind() switch - { - SyntaxKind.StructKeyword => " struct", - SyntaxKind.ClassKeyword => " class", - _ => string.Empty, - }, - _ => throw new NotImplementedException("Cannot handle the parent of the method"), - }; - - var classDeclarationLine = $"{modifiers}partial {parentType} {parent.ParentName}{(parent.TypeParameterListSyntax.IsEmpty ? string.Empty - : "<" + string.Join(", ", parent.TypeParameterListSyntax) + ">")}"; - - _ = sbBegin.Append($$""" -{{indent}}{{classDeclarationLine}} -{{indent}}{ - -"""); - _ = sbEnd.Insert(0, $$""" -{{indent}}} - -"""); - ++i; - } - - var sbOuterUsings = new StringBuilder(); - foreach (var @using in methodToGenerate.OuterUsings) - { - if (sbOuterUsings.Length == 0) - { - // Separates the directives from the header above them. - _ = sbOuterUsings.Append(""" - - - -"""); - } - - _ = sbOuterUsings.Append($$""" -{{@using}} - -"""); - } - - var beforeNamespace = $""" -// {(methodToGenerate.DisableNullable ? string.Empty : """ - -#nullable enable -""")}{sbOuterUsings} -"""; - - return methodToGenerate.IsNamespaceFileScoped ? $$""" -{{beforeNamespace}} -namespace {{methodToGenerate.Namespaces.First()}}; -{{sbBegin}}{{new string(' ', 4 * i)}}{{methodToGenerate.Implementation.Trim()}} -{{sbEnd}} -""" - : $$""" -{{beforeNamespace}} -{{sbBegin}}{{new string(' ', 4 * i)}}{{methodToGenerate.Implementation.Trim()}} -{{sbEnd}} -"""; - } } diff --git a/src/Zomp.SyncMethodGenerator/SyncMethodSourceGenerator.cs b/src/Zomp.SyncMethodGenerator/SyncMethodSourceGenerator.cs index 3b76bfc..da7cb0e 100644 --- a/src/Zomp.SyncMethodGenerator/SyncMethodSourceGenerator.cs +++ b/src/Zomp.SyncMethodGenerator/SyncMethodSourceGenerator.cs @@ -19,17 +19,10 @@ public class SyncMethodSourceGenerator : IIncrementalGenerator internal const string QualifiedCreateSyncVersionAttribute = $"{ThisAssembly.RootNamespace}.{CreateSyncVersionAttribute}"; internal const string QualifiedSkipSyncVersionAttribute = $"{ThisAssembly.RootNamespace}.{SkipSyncVersionAttribute}"; - /// - /// Longest generated file name before the scope is shortened. Emitted files sit under - /// {project}\obj\{configuration}\{framework}\generated\{generator assembly}\{generator}, - /// which alone is around a hundred characters before the project path is counted. - /// internal const string OmitNullableDirective = "OmitNullableDirective"; internal const string PreserveProgress = "PreserveProgress"; internal const string PreserveCancellationToken = "PreserveCancellationToken"; - private const int MaxFileNameLength = 100; - /// public void Initialize(IncrementalGeneratorInitializationContext context) { @@ -54,199 +47,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return isNullableDisabledInProject || isLanguageVersionBelowCSharp8; }); - var methodDeclarations = context.SyntaxProvider - .ForAttributeWithMetadataName( - QualifiedCreateSyncVersionAttribute, - predicate: static (s, _) => IsSyntaxTargetForGeneration(s), - transform: static (ctx, ct) => TransformForGeneration(ctx, ct)) - .SelectMany((list, ct) => list) + var methodDeclarations = CloneTarget.ForAttribute(context.SyntaxProvider, QualifiedCreateSyncVersionAttribute) .Combine(disableNullable) .Select((data, ct) => GetMethodToGenerate(data.Left.Context, data.Left.Syntax, data.Right, ct)!) .WithTrackingName("GetMethodToGenerate") .Where(static s => s is not null); - var sourceTexts = methodDeclarations - .Select(static (m, _) => GenerateSource(m)) - .WithTrackingName("GenerateSource"); - - // Signatures produced more than once. Emitting them all would declare the same member - // twice, so the methods behind them are reported instead of generated. - var collidingSignatures = methodDeclarations - .Select(static (m, _) => m.Signature?.Key) - .Collect() - .Select(static (keys, _) => ToCollidingSignatures(keys)); - - context.RegisterSourceOutput( - sourceTexts.Combine(collidingSignatures), - static (spc, pair) => - { - var (source, colliding) = pair; - - foreach (var diagnostic in source.MethodToGenerate.Diagnostics) - { - spc.ReportDiagnostic(diagnostic); - } - - if (source.MethodToGenerate.Signature is { } signature - && colliding.AsImmutableArray().Contains(signature.Key, StringComparer.Ordinal)) - { - spc.ReportDiagnostic(signature.ToCollisionDiagnostic()); - return; - } - - if (!source.MethodToGenerate.HasErrors) - { - spc.AddSource(source.Path, SourceText.From(source.Content, Encoding.UTF8)); - } - }); - } - - private static bool IsSyntaxTargetForGeneration(SyntaxNode node) => node switch - { - MethodDeclarationSyntax { AttributeLists.Count: > 0 } => true, - ClassDeclarationSyntax { AttributeLists.Count: > 0 } => true, - StructDeclarationSyntax { AttributeLists.Count: > 0 } => true, - InterfaceDeclarationSyntax { AttributeLists.Count: > 0 } => true, - RecordDeclarationSyntax { AttributeLists.Count: > 0 } => true, - _ => false, - }; - - private static ImmutableArray TransformForGeneration(GeneratorAttributeSyntaxContext ctx, CancellationToken ct) - { - ct.ThrowIfCancellationRequested(); - - if (ctx.TargetNode is TypeDeclarationSyntax typeDecl) - { -#if ROSLYN_4_12_OR_GREATER - return [.. typeDecl.Members.OfType().Select(s => new TransformResult(ctx, s))]; -#else - return ImmutableArray.CreateRange(typeDecl.Members.OfType().Select(s => new TransformResult(ctx, s))); -#endif - } - else if (ctx.TargetNode is MethodDeclarationSyntax methodDecl) - { -#if ROSLYN_4_12_OR_GREATER - return [new TransformResult(ctx, methodDecl)]; -#else - return ImmutableArray.Create(new TransformResult(ctx, methodDecl)); -#endif - } - -#if ROSLYN_4_12_OR_GREATER - return []; -#else - return ImmutableArray.Empty; -#endif - } - - /// - /// Picks out the signatures produced by more than one method. Emitting all of them would - /// declare the same member twice. - /// - /// Signature of every method being generated. - /// The signatures which appear more than once. - private static EquatableArray ToCollidingSignatures(ImmutableArray keys) - { - var colliding = keys - .Where(static key => key is not null) - .GroupBy(static key => key!, StringComparer.Ordinal) - .Where(static group => group.Count() > 1) - .Select(static group => group.Key); - -#if ROSLYN_4_12_OR_GREATER - return new([.. colliding]); -#else - return new(ImmutableArray.CreateRange(colliding)); -#endif - } - - /// - /// Names a containing type, keeping its type parameters so that Class, Class{T} - /// and Class{T,T2} stay distinct. - /// - /// The containing type. - /// The name. - private static string BuildClassName(MethodParentDeclaration c) - => c.TypeParameterListSyntax.IsEmpty - ? c.ParentName - : c.ParentName + "{" + string.Join(",", c.TypeParameterListSyntax) + "}"; - - private static (MethodToGenerate MethodToGenerate, string Path, string Content) GenerateSource(MethodToGenerate m) - { - var scope = $"{string.Join(".", m.Namespaces)}" + - $".{string.Join(".", m.Parents.Select(BuildClassName))}" + - (m.IsCSharp14Extension ? ".ext" : string.Empty); - - var method = m.MethodName + (m.Index == 1 ? string.Empty : "_" + m.Index); - - var sourcePath = BuildFileName(scope, method); - - var source = SourceGenerationHelper.GenerateExtensionClass(m); - - return (m, sourcePath, source); - } - - /// - /// Builds the file name a generated method is emitted under, shortening it when the - /// namespace and containing type chain make it long enough to be a problem. Emitted files - /// live several directories deep inside the intermediate output path, so a long name here - /// can carry the full path past the limit the file system accepts. - /// - /// - /// The scope is what gets shortened, since the method name is what someone reads the file - /// name for. A hash of the untruncated name keeps distinct methods in distinct files. - /// - private static string BuildFileName(string scope, string method) - { - const string extension = ".g.cs"; - - var fileName = $"{scope}.{method}{extension}"; - - if (fileName.Length <= MaxFileNameLength) - { - return fileName; - } - - var hash = Hash(fileName); - - // separator, hash, dot, extension - var fixedLength = 1 + hash.Length + 1 + extension.Length; - - var forScope = MaxFileNameLength - fixedLength - method.Length; - - if (forScope > 0) - { - return $"{scope[..Math.Min(scope.Length, forScope)]}_{hash}.{method}{extension}"; - } - - // The method name alone fills the budget, so it has to be shortened as well. - return $"_{hash}.{method[..(MaxFileNameLength - fixedLength)]}{extension}"; - } - - /// - /// FNV-1a. Short, dependency free, and stable across processes and runtimes, which - /// is not. - /// - private static string Hash(string value) - { - const uint offsetBasis = 2166136261; - const uint prime = 16777619; - - var hash = offsetBasis; - - unchecked - { - foreach (var c in value) - { - hash ^= c; - hash *= prime; - } - } - - return hash.ToString("x8", System.Globalization.CultureInfo.InvariantCulture); + ClonedMethodOutput.Register(context, methodDeclarations, CollidingOverloads); } - private static MethodToGenerate? GetMethodToGenerate(GeneratorAttributeSyntaxContext context, MethodDeclarationSyntax methodDeclarationSyntax, bool disableNullable, CancellationToken ct) + private static ClonedMethod? GetMethodToGenerate(GeneratorAttributeSyntaxContext context, MethodDeclarationSyntax methodDeclarationSyntax, bool disableNullable, CancellationToken ct) { // stop if we're asked to ct.ThrowIfCancellationRequested(); @@ -269,25 +79,6 @@ private static string Hash(string value) return null; } - // find the index of the method in the containing type - var index = 1; - - if (methodSymbol.ContainingType is { } containingType) - { - foreach (var member in containingType.GetMembers()) - { - if (member.Equals(methodSymbol, SymbolEqualityComparer.Default)) - { - break; - } - - if (member.Name.Equals(methodSymbol.Name, StringComparison.Ordinal)) - { - ++index; - } - } - } - foreach (var attributeData in methodSymbol.GetAttributes()) { var attributeClassName = attributeData.AttributeClass?.ToDisplayString(); @@ -305,177 +96,22 @@ private static string Hash(string value) } } - var syncMethodGeneratorAttributeData = context.Attributes[0]; - - var classes = ImmutableArray.CreateBuilder(); - SyntaxNode? node = methodDeclarationSyntax; -#if ROSLYN_5_0_OR_GREATER - ExtensionBlockDeclarationSyntax? extensionParent = null; -#endif - while (node.Parent is not null) - { - node = node.Parent; -#if ROSLYN_5_0_OR_GREATER - if (node is ExtensionBlockDeclarationSyntax eds) - { - extensionParent = eds; - continue; - } -#endif - - MethodParentDeclaration? mpd = node switch - { - ClassDeclarationSyntax o => new(MethodParent.Class, o.Identifier, o.Modifiers, o.TypeParameterList), - StructDeclarationSyntax o => new(MethodParent.Struct, o.Identifier, o.Modifiers, o.TypeParameterList), - RecordDeclarationSyntax o => new(MethodParent.Record, o.Identifier, o.Modifiers, o.TypeParameterList, o.ClassOrStructKeyword), - InterfaceDeclarationSyntax o => new(MethodParent.Interface, o.Identifier, o.Modifiers, o.TypeParameterList), - ////ExtensionBlockDeclarationSyntax o => new(MethodParent.ExtensionDeclaration, o.Identifier, o.Modifiers, o.TypeParameterList), - _ => null, - }; - - if (mpd is null) - { - break; - } - - classes.Insert(0, mpd); - } - - if (classes.Count == 0) + if (!MethodLocation.TryCreate(methodDeclarationSyntax, methodSymbol, out var location, out var root)) { return null; } + var syncMethodGeneratorAttributeData = context.Attributes[0]; + var explicitDisableNullable = syncMethodGeneratorAttributeData.NamedArguments.FirstOrDefault(c => c.Key == OmitNullableDirective) is { Value.Value: true }; disableNullable |= explicitDisableNullable; var preserveProgress = syncMethodGeneratorAttributeData.NamedArguments.FirstOrDefault(c => c.Key == PreserveProgress) is { Value.Value: true }; var preserveCancellationToken = syncMethodGeneratorAttributeData.NamedArguments.FirstOrDefault(c => c.Key == PreserveCancellationToken) is { Value.Value: true }; -#if ROSLYN_5_0_OR_GREATER - var toVisit = extensionParent ?? (SyntaxNode)methodDeclarationSyntax; -#else - var toVisit = (SyntaxNode)methodDeclarationSyntax; -#endif var rewriter = new AsyncToSyncRewriter(context.SemanticModel, disableNullable, preserveProgress, preserveCancellationToken, methodDeclarationSyntax); - var sn = rewriter.Visit(toVisit); - var content = sn.ToFullString(); - - var diagnostics = rewriter.Diagnostics; - - var hasErrors = false; - foreach (var diagnostic in diagnostics) - { - hasErrors |= diagnostic.Descriptor.DefaultSeverity == DiagnosticSeverity.Error; - } - - var isNamespaceFileScoped = false; - var namespaces = ImmutableArray.CreateBuilder(); - - // Documentation comments are copied across verbatim, and a cref in one is resolved - // against the file it lands in rather than the file it was written in. Without the - // using directives which were in scope where it was written, every cref which relied on - // one goes unresolved, which a project building with warnings as errors reads as a - // build failure. The directives are kept on the side of the namespace they were - // declared on, since one declared inside a namespace may name it only relatively. - var outerUsings = ImmutableArray.CreateBuilder(); - var innerUsings = ImmutableArray.CreateBuilder(); - - if (!hasErrors) - { - while (node is not null and not CompilationUnitSyntax) - { - switch (node) - { - case NamespaceDeclarationSyntax nds: - namespaces.Insert(0, nds.Name.ToString()); - InsertUsings(innerUsings, nds.Usings); - break; - case FileScopedNamespaceDeclarationSyntax file: - namespaces.Add(file.Name.ToString()); - InsertUsings(innerUsings, file.Usings); - isNamespaceFileScoped = true; - break; - default: - throw new InvalidOperationException($"Cannot handle {node}"); - } - - node = node.Parent; - } - - if (node is CompilationUnitSyntax compilationUnit) - { - InsertUsings(outerUsings, compilationUnit.Usings); - } - } - -#if ROSLYN_5_0_OR_GREATER - var isCSharp14Extension = extensionParent is not null; -#else - var isCSharp14Extension = false; -#endif - var signature = BuildSignature(sn, namespaces, classes, methodDeclarationSyntax); - - var result = new MethodToGenerate(index, namespaces.ToImmutable(), outerUsings.ToImmutable(), innerUsings.ToImmutable(), isNamespaceFileScoped, isCSharp14Extension, classes.ToImmutable(), methodDeclarationSyntax.Identifier.ValueText, content, disableNullable, rewriter.Diagnostics, hasErrors, signature); - - return result; - } - - /// - /// Records the directives, innermost first, so that walking outwards from the method builds - /// them up in the order they were written. - /// - /// Collected directives. - /// Directives declared at one level. - private static void InsertUsings(ImmutableArray.Builder destination, SyntaxList usings) - { - var index = 0; - foreach (var @using in usings) - { - destination.Insert(index++, @using.ToString()); - } - } + var rewritten = rewriter.Visit(root); - /// - /// Describes the method which is about to be emitted, precisely enough to tell whether two - /// of them would declare the same member. The rewritten declaration is used rather than the - /// original symbol, so the comparison sees exactly what the compiler will see - parameters - /// already dropped, types already substituted. - /// - /// Result of rewriting the method. - /// Namespaces the method lives in. - /// Types the method is nested in. - /// Method being synchronized, for the location to report against. - /// The signature, or null when the rewritten method could not be located. - private static SynchronizedSignature? BuildSignature( - SyntaxNode rewritten, - ImmutableArray.Builder namespaces, - ImmutableArray.Builder parents, - MethodDeclarationSyntax original) - { - var method = rewritten as MethodDeclarationSyntax - ?? rewritten.DescendantNodes().OfType().FirstOrDefault(); - - if (method is null) - { - return null; - } - - var scope = string.Join(".", namespaces.Concat(parents.Select(BuildClassName))); - - // Arity by count rather than by name, since overloads which differ only in what they - // call their type parameters still declare the same member. - var arity = method.TypeParameterList?.Parameters.Count ?? 0; - var arityMarker = arity > 0 ? "`" + arity.ToString(System.Globalization.CultureInfo.InvariantCulture) : string.Empty; - - var parameters = string.Join(",", method.ParameterList.Parameters.Select(static p => p.Type?.ToString() ?? string.Empty)); - - var key = $"{scope}.{method.Identifier.ValueText}{arityMarker}({parameters})"; - - var location = original.GetLocation(); - - return new(key, location.SourceTree?.FilePath ?? string.Empty, location.SourceSpan, location.GetLineSpan().Span); + return ClonedMethod.Create(location, methodDeclarationSyntax, rewritten, disableNullable, rewriter.Diagnostics); } - - internal sealed record TransformResult(GeneratorAttributeSyntaxContext Context, MethodDeclarationSyntax Syntax); } diff --git a/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj b/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj index 1124328..38aa6d0 100644 --- a/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj +++ b/src/Zomp.SyncMethodGenerator/Zomp.SyncMethodGenerator.csproj @@ -45,6 +45,7 @@ + From 354fd2cad638517db6ee309d2da1ca6f460191b1 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Sat, 12 Sep 2026 12:28:58 -0400 Subject: [PATCH 2/2] Extract a CloningRewriter base from AsyncToSyncRewriter The half of the rewriter which fully qualifies the types and static members a method names, so that its copy compiles in a file of its own, moves into Cloning/CloningRewriter. AsyncToSyncRewriter derives from it and keeps the async to sync rules. Visitors which did both now call the base for the qualification and apply the async rules to its result. Where the async rules change how a type is named, they go through two hooks of the base: MapSymbol for Task to void or T and the async enumerator to IEnumerator, and MapTypeName for AsyncEnumerable to Enumerable. A parameter whose type has no named type symbol, such as a type parameter, now goes through the base's qualification as well, which writes the same text for it. AGENTS.md describes the Cloning layer and where the boundary is. Generated with Claude Code --- AGENTS.md | 30 +- .../AsyncToSyncRewriter.cs | 434 ++++-------------- .../Cloning/CloningRewriter.cs | 432 +++++++++++++++++ 3 files changed, 535 insertions(+), 361 deletions(-) create mode 100644 src/Zomp.SyncMethodGenerator/Cloning/CloningRewriter.cs diff --git a/AGENTS.md b/AGENTS.md index b0f984b..83743a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,10 +108,11 @@ Use `#if ROSLYN_X_Y_OR_GREATER` guards for APIs that only exist in newer Roslyn ```text src/Zomp.SyncMethodGenerator/ Generator (netstandard2.0) SyncMethodSourceGenerator.cs Entry point — IIncrementalGenerator - AsyncToSyncRewriter.cs Core transformation engine (CSharpSyntaxRewriter) - SourceGenerationHelper.cs Output file structure and attribute definitions + AsyncToSyncRewriter.cs Async to sync rules, on top of CloningRewriter + SourceGenerationHelper.cs Attribute definitions Extensions.cs Type-checking extensions on INamedTypeSymbol - DiagnosticMessages.cs ZSMGEN001-004 diagnostic descriptors + DiagnosticMessages.cs ZSMGEN001-006 diagnostic descriptors + Cloning/ Copying a method into a file of its own Models/ Data records for the pipeline Helpers/ EquatableArray, DirectiveStack, etc. Properties/ Assembly attributes @@ -121,11 +122,26 @@ tests/Generator.Tests/ Unit tests (xUnit + Verify snapshot testi tests/GenerationSandbox.Tests/ Integration tests (real-world patterns) ``` +## Cloning layer + +`Cloning/` holds everything about emitting a copy of a method that has nothing +to do with async: finding the marked methods (`CloneTarget`), collecting the +namespaces, usings and containing types around them (`MethodLocation`), fully +qualifying the names a method uses (`CloningRewriter`), detecting colliding +signatures and naming the files (`ClonedMethodOutput`), and writing the file +(`ClonedMethodSource`). + +Keep async knowledge out of it. A transformation plugs in by deriving from +`CloningRewriter`, overriding the visitors it needs, and using the `MapSymbol` +and `MapTypeName` hooks to substitute types. Nothing under `Cloning/` should +know about tasks or `SYNC_ONLY`; a grep for `Task|Async|SYNC_ONLY` there should +come back empty. + ## Transformation Pipeline -1. **Find candidates** — `ForAttributeWithMetadataName` locates `[CreateSyncVersion]` on methods or types -2. **Extract metadata** — parent class hierarchy, namespaces, configuration flags -3. **Rewrite** — `AsyncToSyncRewriter` (a `CSharpSyntaxRewriter`) traverses the syntax tree: +1. **Find candidates** — `CloneTarget` uses `ForAttributeWithMetadataName` to locate `[CreateSyncVersion]` on methods or types +2. **Extract metadata** — `MethodLocation` collects the parent class hierarchy, namespaces and usings; the generator reads the configuration flags +3. **Rewrite** — `AsyncToSyncRewriter` (a `CloningRewriter`, which fully qualifies names) traverses the syntax tree: - Strips `async` modifier and `await` expressions - Transforms return types: `Task`/`ValueTask` to `void`, `Task`/`ValueTask` to `T` - Transforms collection types: `IAsyncEnumerable` to `IEnumerable` @@ -134,7 +150,7 @@ tests/GenerationSandbox.Tests/ Integration tests (real-world patterns) - Renames method calls: strips `Async` suffix - Handles special methods: `Task.FromResult(x)` to `x`, `Task.Delay()` to `Thread.Sleep()` - Processes `#if SYNC_ONLY` / `#if !SYNC_ONLY` directives -4. **Emit** — `SourceGenerationHelper` wraps the rewritten method in namespace/class structure +4. **Emit** — `ClonedMethodSource` wraps the rewritten method in namespace/class structure, and `ClonedMethodOutput` names the file and reports colliding signatures ## Testing Conventions diff --git a/src/Zomp.SyncMethodGenerator/AsyncToSyncRewriter.cs b/src/Zomp.SyncMethodGenerator/AsyncToSyncRewriter.cs index ce714ca..a5ecb93 100644 --- a/src/Zomp.SyncMethodGenerator/AsyncToSyncRewriter.cs +++ b/src/Zomp.SyncMethodGenerator/AsyncToSyncRewriter.cs @@ -1,4 +1,4 @@ -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; namespace Zomp.SyncMethodGenerator; @@ -13,7 +13,7 @@ namespace Zomp.SyncMethodGenerator; /// Instructs the source generator to preserve parameters. /// Instructs the source generator to preserve parameters. /// The method declaration to rewrite; other method declarations are ignored. -internal sealed class AsyncToSyncRewriter(SemanticModel semanticModel, bool disableNullable, bool preserveProgress, bool preserveCancellationToken, MethodDeclarationSyntax targetMethod) : CSharpSyntaxRewriter +internal sealed class AsyncToSyncRewriter(SemanticModel semanticModel, bool disableNullable, bool preserveProgress, bool preserveCancellationToken, MethodDeclarationSyntax targetMethod) : CloningRewriter(semanticModel, targetMethod) { public const string SyncOnly = "SYNC_ONLY"; @@ -50,22 +50,6 @@ internal sealed class AsyncToSyncRewriter(SemanticModel semanticModel, bool disa private const string TaskFullyQualified = $"{System}.{Threading}.{Tasks}.{TaskName}"; private const string TimeProviderTaskExtensionsFullyQualified = $"{System}.{Threading}.{Tasks}.{TimeProviderTaskExtensions}"; - private static readonly SymbolDisplayFormat GlobalDisplayFormat = new( - globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, - genericsOptions: SymbolDisplayGenericsOptions.None, - miscellaneousOptions: - SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | - SymbolDisplayMiscellaneousOptions.UseSpecialTypes); - - private static readonly SymbolDisplayFormat GlobalDisplayFormatWithTypeParameters = new( - globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, - typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, - genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, - miscellaneousOptions: - SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | - SymbolDisplayMiscellaneousOptions.UseSpecialTypes); - /// /// Marks the brace-less block which produces for /// return TaskReturningAsync(); (TaskReturning(); return;) so that @@ -74,10 +58,8 @@ internal sealed class AsyncToSyncRewriter(SemanticModel semanticModel, bool disa /// private static readonly SyntaxAnnotation ExpandedReturnAnnotation = new(nameof(ExpandedReturnAnnotation)); - private readonly SemanticModel semanticModel = semanticModel; private readonly bool disableNullable = disableNullable; private readonly bool preserveProgress = preserveProgress; - private readonly MethodDeclarationSyntax targetMethod = targetMethod; private readonly HashSet removedParameters = []; /// @@ -266,24 +248,16 @@ BinaryExpressionSyntax CheckNull(ExpressionSyntax expr) => BinaryExpression( return InvocationExpression(ParenthesizedExpression(CastExpression(funcExpr, ParenthesizedExpression(lambda))), ArgumentList(arguments)); } - /// - public override SyntaxNode? VisitNullableType(NullableTypeSyntax node) - { - var @base = (NullableTypeSyntax)base.VisitNullableType(node)!; - - return TypeAlreadyQualified(node.ElementType) ? @base : @base.WithElementType(ProcessType(@base.ElementType)).WithTriviaFrom(@base); - } - /// public override SyntaxNode? VisitGenericName(GenericNameSyntax node) { + var @base = (GenericNameSyntax)base.VisitGenericName(node)!; + if (GetSymbol(node) is not INamedTypeSymbol symbol) { - return (GenericNameSyntax)base.VisitGenericName(node)!; + return @base; } - var @base = (GenericNameSyntax)base.VisitGenericName(node)!; - string? GetReplacement(INamedTypeSymbol symbol) => symbol switch { { IsMemory: true } @@ -298,67 +272,27 @@ BinaryExpressionSyntax CheckNull(ExpressionSyntax expr) => BinaryExpression( _ => null, }; - string GetIdentifier(INamedTypeSymbol symbol) - => GetReplacement(symbol) is { } replacement - ? Global(replacement) - : symbol switch - { - { ContainingSymbol: INamedTypeSymbol { IsGenericType: true } parentSymbol } - => parentSymbol.ToDisplayString(GlobalDisplayFormatWithTypeParameters) + "." + symbol.Name, - _ => symbol.ToDisplayString(GlobalDisplayFormat), - }; - var replacement = symbol switch { { IsTaskOrValueTask: true, IsGenericType: true, } => @base.TypeArgumentList.Arguments[0], - _ => @base.WithIdentifier(Identifier(GetIdentifier(symbol))), + _ when GetReplacement(symbol) is { } name => @base.WithIdentifier(Identifier(Global(name))), + _ => null, }; - return replacement.WithTriviaFrom(@base); + return replacement is null ? @base : replacement.WithTriviaFrom(@base); } /// public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) - { - var @base = (IdentifierNameSyntax)base.VisitIdentifierName(node)!; - if (renamedLocalFunctions.TryGetValue(@base.Identifier.ValueText, out var newName)) - { - return @base.WithIdentifier(Identifier(newName)); - } - - if (node.Parent is not MemberAccessExpressionSyntax) - { - var symbol = GetSymbol(node); - if (symbol is { IsStatic: true, ContainingType: { } containingType } memberSymbol) - { - if (symbol is IFieldSymbol or IMethodSymbol { MethodKind: not MethodKind.LocalFunction }) - { - var typeString = containingType.ToDisplayString(GlobalDisplayFormatWithTypeParameters); - return @base.WithIdentifier(Identifier($"{typeString}.{memberSymbol.Name}")).WithTriviaFrom(node); - } - } - } - - if (node.Parent is TypeArgumentListSyntax) - { - return ProcessType(node); - } - - return @base; - } - - public override SyntaxNode? VisitQualifiedName(QualifiedNameSyntax node) - { - var @base = (QualifiedNameSyntax)base.VisitQualifiedName(node)!; - - return @base.Right is GenericNameSyntax ? @base.Right : (SyntaxNode)ProcessType(node); - } + => renamedLocalFunctions.TryGetValue(node.Identifier.ValueText, out var newName) + ? node.WithIdentifier(Identifier(newName)) + : base.VisitIdentifierName(node); public override SyntaxNode? VisitLocalFunctionStatement(LocalFunctionStatementSyntax node) { var @base = (LocalFunctionStatementSyntax)base.VisitLocalFunctionStatement(node)!; - if (semanticModel.GetTypeInfo(node.ReturnType).Type is not INamedTypeSymbol symbol) + if (SemanticModel.GetTypeInfo(node.ReturnType).Type is not INamedTypeSymbol symbol) { return @base; } @@ -398,7 +332,7 @@ bool IsValidParameter(ParameterSyntax ps, int i) modifications.Add(i + 1, true); } - if (semanticModel.GetDeclaredSymbol(ps) is not { } symbol) + if (SemanticModel.GetDeclaredSymbol(ps) is not { } symbol) { return true; } @@ -458,7 +392,7 @@ var entries public override SyntaxNode? VisitParameter(ParameterSyntax node) { if (node.Type is null || GetSymbol(node.Type) is not INamedTypeSymbol namedTypeSymbol - || semanticModel.GetDeclaredSymbol(node) is not IParameterSymbol ps) + || SemanticModel.GetDeclaredSymbol(node) is not IParameterSymbol ps) { return (ParameterSyntax)base.VisitParameter(node)!; } @@ -496,8 +430,8 @@ var entries } } - return node.Type is null || TypeAlreadyQualified(node.Type) ? @base - : @base.WithType(ProcessType(node.Type)).WithAttributeLists(attributeLists).WithTriviaFrom(@base); + return TypeAlreadyQualified(node.Type) ? @base + : @base.WithAttributeLists(attributeLists).WithTriviaFrom(@base); } /// @@ -515,22 +449,13 @@ var entries callingSpanProperty = prevCallingSpanProperty; - if (exprSymbol is ITypeSymbol && node.Expression is TypeSyntax type) + // Calling EntityFrameworkQueryableExtensions.AnyAsync(query) directly rather than as an extension + if (exprSymbol is INamedTypeSymbol containingType + && node.Expression is TypeSyntax + && GetSymbol(node) is IMethodSymbol { Name: var name } && name.EndsWithAsync() + && SyncQueryableContainer(containingType, RemoveAsync(name)) is { } container) { - // Rewrite static invocation (eg. File.ReadAllTextAsync) - var newType = ProcessType(type); - if (newType != type) - { - @base = @base.WithExpression(newType); - } - - // Calling EntityFrameworkQueryableExtensions.AnyAsync(query) directly rather than as an extension - if (exprSymbol is INamedTypeSymbol containingType - && GetSymbol(node) is IMethodSymbol { Name: var name } && name.EndsWithAsync() - && SyncQueryableContainer(containingType, RemoveAsync(name)) is { } container) - { - @base = @base.WithExpression(IdentifierName(container).WithTriviaFrom(@base.Expression)); - } + @base = @base.WithExpression(IdentifierName(container).WithTriviaFrom(@base.Expression)); } if (isSpan && changedMemoryToSpan.Contains(exprSymbol)) @@ -545,7 +470,7 @@ var entries [MethodImpl(MethodImplOptions.AggressiveInlining)] bool InitializedToMemory(SyntaxNode node) => node.Parent is EqualsValueClauseSyntax { Parent: VariableDeclaratorSyntax { } z } - && semanticModel.GetDeclaredSymbol(z) is ILocalSymbol { Type: INamedTypeSymbol { IsMemory: true } }; + && SemanticModel.GetDeclaredSymbol(z) is ILocalSymbol { Type: INamedTypeSymbol { IsMemory: true } }; return GetSymbol(node) is IPropertySymbol property && property.Type is INamedTypeSymbol { IsMemory: true } @@ -745,16 +670,6 @@ bool InitializedToMemory(SyntaxNode node) return dropParentheses ? @base.Expression.WithTriviaFrom(@base) : @base; } - /// - public override SyntaxNode? VisitArrayType(ArrayTypeSyntax node) - { - var @base = (ArrayTypeSyntax)base.VisitArrayType(node)!; - var elementType = TypeAlreadyQualified(node.ElementType) - ? @base.ElementType - : ProcessType(@base.ElementType); - return @base.WithElementType(elementType).WithTriviaFrom(@base); - } - /// public override SyntaxNode? VisitAnonymousMethodExpression(AnonymousMethodExpressionSyntax node) { @@ -762,19 +677,6 @@ bool InitializedToMemory(SyntaxNode node) return @base.WithModifiers(StripAsyncModifier(@base.Modifiers)); } - /// - public override SyntaxNode? VisitInterpolation(InterpolationSyntax node) - { - var @base = (InterpolationSyntax)base.VisitInterpolation(node)!; - if (@base.Expression is not ParenthesizedExpressionSyntax) - { - var newExpression = ParenthesizedExpression(@base.Expression); - @base = @base.WithExpression(newExpression); - } - - return @base; - } - /// public override SyntaxNode? VisitImplicitObjectCreationExpression(ImplicitObjectCreationExpressionSyntax node) { @@ -788,7 +690,7 @@ bool InitializedToMemory(SyntaxNode node) { // Replace expressions that return the task directly. if (node is { Expression: { } returnExpression } && - semanticModel.GetTypeInfo(returnExpression).Type is INamedTypeSymbol named && named.IsNonGenericTaskOrValueTask) + SemanticModel.GetTypeInfo(returnExpression).Type is INamedTypeSymbol named && named.IsNonGenericTaskOrValueTask) { var result = ExpressionToStatement(returnExpression); @@ -829,22 +731,10 @@ bool InitializedToMemory(SyntaxNode node) /// public override SyntaxNode? VisitObjectCreationExpression(ObjectCreationExpressionSyntax node) { - var @base = (ObjectCreationExpressionSyntax)base.VisitObjectCreationExpression(node)!; + var @base = base.VisitObjectCreationExpression(node); var symbol = GetSymbol(node); - if (TryReplaceObjectCreation(node, symbol, out var replacement)) - { - return replacement; - } - - if (semanticModel.GetTypeInfo(node).Type is not { } t - || t is INamedTypeSymbol { IsGenericType: true }) - { - return @base; - } - - var newType = ProcessSymbol(t); - return newType == node.Type ? @base : @base.WithType(newType); + return TryReplaceObjectCreation(node, symbol, out var replacement) ? replacement : @base; } public override SyntaxNode? VisitIfStatement(IfStatementSyntax node) @@ -903,7 +793,7 @@ bool InitializedToMemory(SyntaxNode node) { // Ensure that Func> is not removed var isFunc = node.Parent is { } parent - && semanticModel.GetTypeInfo(parent).Type is INamedTypeSymbol { IsGenericType: true, IsSystemFunc: true } n; + && SemanticModel.GetTypeInfo(parent).Type is INamedTypeSymbol { IsGenericType: true, IsSystemFunc: true } n; // Generic method type arguments like Bar> are unwrapped to Bar, not removed var isMethodTypeArgumentList = node.Parent is GenericNameSyntax gn && GetSymbol(gn) is IMethodSymbol; @@ -911,7 +801,7 @@ bool InitializedToMemory(SyntaxNode node) // Do not remove Task, but remove Task inside a Func<> bool RemoveType(TypeSyntax z, int index) => !(isFunc && index == node.Arguments.Count - 1 && z is GenericNameSyntax) - && semanticModel.GetTypeInfo(z).Type is { } type + && SemanticModel.GetTypeInfo(z).Type is { } type && !(isMethodTypeArgumentList && type is INamedTypeSymbol { IsTaskOrValueTask: true, IsGenericType: true }) && ShouldRemoveType(type); @@ -926,33 +816,17 @@ bool RemoveType(TypeSyntax z, int index) => return @base.WithArguments(newArguments); } - /// - public override SyntaxNode? VisitTypeConstraint(TypeConstraintSyntax node) - { - var @base = (TypeConstraintSyntax)base.VisitTypeConstraint(node)!; - var newType = ProcessType(@base.Type); - return newType == @base.Type ? @base : @base.WithType(newType).WithTriviaFrom(@base); - } - - /// - public override SyntaxNode? VisitCatchDeclaration(CatchDeclarationSyntax node) - { - var @base = (CatchDeclarationSyntax)base.VisitCatchDeclaration(node)!; - return @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); - } - /// public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) { - if (targetMethod != node) + if (base.VisitMethodDeclaration(node) is not MethodDeclarationSyntax @base) { return default; } - var @base = base.VisitMethodDeclaration(node) as MethodDeclarationSyntax ?? throw new InvalidOperationException("Can't cast"); var returnType = node.ReturnType; - if (semanticModel.GetTypeInfo(returnType).Type is not INamedTypeSymbol symbol) + if (SemanticModel.GetTypeInfo(returnType).Type is not INamedTypeSymbol symbol) { return @base; } @@ -1021,20 +895,7 @@ bool RemoveType(TypeSyntax z, int index) => newTriviaList = RemoveParameterDocumentationLines(trivia, removedParameters); } - static bool Preprocessors(SyntaxTrivia st) - => st.IsKind(SyntaxKind.IfDirectiveTrivia) - || st.IsKind(SyntaxKind.ElifDirectiveTrivia) - || st.IsKind(SyntaxKind.ElseDirectiveTrivia) - || st.IsKind(SyntaxKind.EndIfDirectiveTrivia) - || st.IsKind(SyntaxKind.RegionDirectiveTrivia) - || st.IsKind(SyntaxKind.EndRegionDirectiveTrivia) - || st.IsKind(SyntaxKind.DisabledTextTrivia); - - while (newTriviaList.FirstOrDefault(Preprocessors) is { } preprocessor - && preprocessor != default) - { - newTriviaList = newTriviaList.Remove(preprocessor); - } + newTriviaList = RemovePreprocessorDirectives(newTriviaList); if (ShouldRemoveArrowExpression(node.ExpressionBody)) { @@ -1096,8 +957,6 @@ static bool Preprocessors(SyntaxTrivia st) var @base = (ArgumentSyntax)base.VisitArgument(node)!; return GetSymbol(node.Expression) switch { - // Handles nameof(Type) - ITypeSymbol { } typeSymbol when !TypeAlreadyQualified(typeSymbol) => @base.WithExpression(ProcessSymbol(typeSymbol)).WithTriviaFrom(@base), ILocalSymbol { Type: INamedTypeSymbol { IsMemory: true } } ls when changeMemoryToSpan.Contains(ls) && droppingAsync => Argument(AppendSpan(node.Expression)), IFieldSymbol { Type: INamedTypeSymbol { IsMemory: true } } when droppingAsync => Argument(AppendSpan(node.Expression)), IPropertySymbol { Type: INamedTypeSymbol { IsMemory: true } } when droppingAsync => Argument(AppendSpan(node.Expression)), @@ -1195,7 +1054,7 @@ bool ShouldRemoveArgumentLocal(ArgumentSyntax arg, int index) if (GetIdentifier(node.Expression) is { } identifier && GetSymbol(identifier) is { } - && semanticModel.GetDeclaredSymbol(node) is { } s) + && SemanticModel.GetDeclaredSymbol(node) is { } s) { if (s.Type is INamedTypeSymbol { IsMemory: true }) { @@ -1207,7 +1066,7 @@ bool ShouldRemoveArgumentLocal(ArgumentSyntax arg, int index) return TypeAlreadyQualified(node.Type) ? @base.WithAwaitKeyword(default) - : @base.WithAwaitKeyword(default).WithType(ProcessType(node.Type)).WithTriviaFrom(@base); + : @base.WithAwaitKeyword(default).WithTriviaFrom(@base); } /// @@ -1248,7 +1107,7 @@ bool ShouldRemoveArgumentLocal(ArgumentSyntax arg, int index) var variableTypeName = node.Declaration.Type; - var variableType = semanticModel + var variableType = SemanticModel .GetSymbolInfo(variableTypeName) .Symbol; @@ -1325,7 +1184,7 @@ bool ShouldRemoveArgumentLocal(ArgumentSyntax arg, int index) public override SyntaxNode? VisitVariableDeclarator(VariableDeclaratorSyntax node) { // Cannot initialize Span to null, so preserving memory. - if (semanticModel.GetDeclaredSymbol(node) is ILocalSymbol { Type: INamedTypeSymbol { IsMemoryOrNullableMemory: true } } symbol + if (SemanticModel.GetDeclaredSymbol(node) is ILocalSymbol { Type: INamedTypeSymbol { IsMemoryOrNullableMemory: true } } symbol && !node.InitializedToNull() && !IsInIterator(node)) { @@ -1336,31 +1195,6 @@ bool ShouldRemoveArgumentLocal(ArgumentSyntax arg, int index) return @base; } - /// - public override SyntaxNode? VisitVariableDeclaration(VariableDeclarationSyntax node) - { - var @base = (VariableDeclarationSyntax)base.VisitVariableDeclaration(node)!; - - var type = node.Type; - var newType = @base.Type; - - if (newType == type || - (newType is IdentifierNameSyntax { Identifier.ValueText: { } newTypeString } - && type is IdentifierNameSyntax { Identifier.ValueText: { } typeString } - && newTypeString == typeString)) - { - // not replaced - newType = ProcessType(type); - - if (newType == type) - { - return @base; - } - } - - return @base.WithType(newType).WithTriviaFrom(@base); - } - public override SyntaxNode? VisitAttributeList(AttributeListSyntax node) { bool ShouldRemoveAttribute(AttributeSyntax attributeSyntax) @@ -1383,69 +1217,6 @@ bool ShouldRemoveAttribute(AttributeSyntax attributeSyntax) return @base.WithAttributes(newList); } - public override SyntaxNode? VisitAttribute(AttributeSyntax node) - { - var @base = (AttributeSyntax)base.VisitAttribute(node)!; - - if (GetSymbol(node.Name) is not IMethodSymbol ms) - { - return @base; - } - - var retval = @base.WithName(ProcessSymbol(ms.ContainingType)); - return retval; - } - - public override SyntaxNode? VisitConstantPattern(ConstantPatternSyntax node) - { - var @base = (ConstantPatternSyntax)base.VisitConstantPattern(node)!; - return node.Expression switch - { - LiteralExpressionSyntax or MemberAccessExpressionSyntax => @base, - _ => semanticModel.GetTypeInfo(node.Expression).Type is { } type - ? @base.WithExpression(ProcessSymbol(type).WithTriviaFrom(@base)) - : @base, - }; - } - - public override SyntaxNode? VisitDeclarationExpression(DeclarationExpressionSyntax node) - { - var @base = (DeclarationExpressionSyntax)base.VisitDeclarationExpression(node)!; - return TypeAlreadyQualified(node.Type) ? @base : @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); - } - - public override SyntaxNode? VisitCastExpression(CastExpressionSyntax node) - { - var @base = (CastExpressionSyntax)base.VisitCastExpression(node)!; - return TypeAlreadyQualified(node.Type) ? @base : @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); - } - - public override SyntaxNode? VisitTupleType(TupleTypeSyntax node) - { - var @base = (TupleTypeSyntax)base.VisitTupleType(node)!; - - var newTuples = new List(); - foreach (var t in node.Elements.Zip(@base.Elements, (original, visited) => (original, visited))) - { - var newType = TypeAlreadyQualified(t.original.Type) ? t.visited.Type : ProcessType(t.original.Type); - newTuples.Add(TupleElement(newType, t.original.Identifier)); - } - - return @base.WithElements(SeparatedList(newTuples, node.Elements.GetSeparators())); - } - - public override SyntaxNode? VisitDeclarationPattern(DeclarationPatternSyntax node) - { - var @base = (DeclarationPatternSyntax)base.VisitDeclarationPattern(node)!; - return TypeAlreadyQualified(node.Type) ? @base : @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); - } - - public override SyntaxNode? VisitTypeOfExpression(TypeOfExpressionSyntax node) - { - var @base = (TypeOfExpressionSyntax)base.VisitTypeOfExpression(node)!; - return TypeAlreadyQualified(node.Type) ? @base : @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); - } - public override SyntaxNode? VisitBinaryExpression(BinaryExpressionSyntax node) { var @base = (BinaryExpressionSyntax)base.VisitBinaryExpression(node)!; @@ -1453,7 +1224,7 @@ bool ShouldRemoveAttribute(AttributeSyntax attributeSyntax) // For task-valued null coalescing (eg. await (t?.DoAsync() ?? Task.CompletedTask)), // reduce to the side which has a synchronized version when the other side has none. if (node.IsKind(SyntaxKind.CoalesceExpression) - && semanticModel.GetTypeInfo(node).Type is INamedTypeSymbol { IsNonGenericTaskOrValueTask: true }) + && SemanticModel.GetTypeInfo(node).Type is INamedTypeSymbol { IsNonGenericTaskOrValueTask: true }) { var removeLeft = ShouldRemoveArgument(node.Left); var removeRight = ShouldRemoveArgument(node.Right); @@ -1469,21 +1240,6 @@ bool ShouldRemoveAttribute(AttributeSyntax attributeSyntax) } } - if (@base.OperatorToken.IsKind(SyntaxKind.IsKeyword) || @base.OperatorToken.IsKind(SyntaxKind.AsKeyword)) - { - if (GetSymbol(node.Left) is IFieldSymbol leftSymbol) - { - @base = @base.WithLeft(ProcessSymbol(leftSymbol).WithTriviaFrom(node.Left)); - } - - if (GetSymbol(node.Right) is ISymbol symbol) - { - @base = @base.WithRight(ProcessSymbol(symbol).WithTriviaFrom(node.Right)); - } - - return @base.WithTriviaFrom(@base); - } - return @base; } @@ -1496,6 +1252,19 @@ bool ShouldRemoveAttribute(AttributeSyntax attributeSyntax) _ => false, }; + /// + protected override SimpleNameSyntax? MapSymbol(ISymbol symbol) => symbol switch + { + INamedTypeSymbol { IsEnumerator: true } s + => GenericName(IEnumerator).WithTypeArgumentList(TypeArgumentList(SeparatedList([ProcessSymbol(s.ContainingType.TypeArguments[0])], []))), + INamedTypeSymbol { IsTaskOrValueTask: true } s => s.IsGenericType ? ProcessSymbol(s.TypeArguments[0]) : IdentifierName("void"), + _ => null, + }; + + /// + protected override string? MapTypeName(INamedTypeSymbol symbol) + => symbol is { Name: "AsyncEnumerable" } ? Global("System.Linq.Enumerable") : null; + private static string ReplaceWithSpan(ISymbol symbol) => Regex.Replace(symbol.Name, Memory, Span); @@ -1523,25 +1292,6 @@ private static ExpressionSyntax KeepExpressionBeforeDot(MemberAccessExpressionSy : memberAccess.Expression.WithTrailingTrivia(surrounding); } - private static string MakeType(ISymbol symbol) - => symbol switch - { - INamedTypeSymbol { Name: "AsyncEnumerable" } => Global("System.Linq.Enumerable"), - INamedTypeSymbol => symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), - _ => symbol.Name, - }; - - private static SimpleNameSyntax ProcessSymbol(ISymbol typeSymbol) => typeSymbol switch - { - INamedTypeSymbol { IsEnumerator: true } s - => GenericName(IEnumerator).WithTypeArgumentList(TypeArgumentList(SeparatedList([ProcessSymbol(s.ContainingType.TypeArguments[0])], []))), - INamedTypeSymbol { IsTaskOrValueTask: true } s => s.IsGenericType ? ProcessSymbol(s.TypeArguments[0]) : IdentifierName("void"), - INamedTypeSymbol nts => IdentifierName(MakeType(nts)), - IArrayTypeSymbol ats => IdentifierName(MakeType(ats.ElementType) + $"[{new string(',', ats.Rank - 1)}]"), - IFieldSymbol fs => IdentifierName(MakeType(fs.Type) + '.' + fs.Name), - _ => IdentifierName(typeSymbol.Name), - }; - private static void ProcessSyncOnlyEntries(KeyValuePair[] entries, ref SeparatedSyntaxList separatedItems, ref bool removeTrailingEndIf, Func createNewListItem) where TNode : SyntaxNode { @@ -1883,21 +1633,6 @@ private static bool CanDropEmptyStatement(StatementSyntax statement) _ => false, }; - private static TypeSyntax GetReturnType(TypeSyntax returnType, INamedTypeSymbol symbol) => (returnType switch - { - IdentifierNameSyntax => ProcessSymbol(symbol), - _ => returnType, - }).WithTriviaFrom(returnType); - - private static string Global(string type) => $"global::{type}"; - - private static bool TypeAlreadyQualified(TypeSyntax type) - => type is NullableTypeSyntax or GenericNameSyntax or TupleTypeSyntax or ArrayTypeSyntax or QualifiedNameSyntax; - - private static bool TypeAlreadyQualified(ITypeSymbol type) - => type is INamedTypeSymbol namedType - && namedType is { IsGenericType: true }; - private static bool TryReplaceObjectCreation(BaseObjectCreationExpressionSyntax node, ISymbol? symbol, out SyntaxNode? replacement) { if (symbol is IMethodSymbol { ReceiverType: INamedTypeSymbol { IsValueTask: true, IsGenericType: true } } @@ -1923,7 +1658,33 @@ private static bool TryStripAsync(string str, [NotNullWhen(true)] out string? st return false; } - private static TypeSyntax? ConvertFuncToAction(TypeSyntax originalType, INamedTypeSymbol namedTypeSymbol) + private static List RemoveFirstEndIf(SyntaxTriviaList list) + { + var newLeadingTrivia = new List(); + + var removed = false; + foreach (var st in list) + { + if (!removed && st.IsKind(SyntaxKind.EndIfDirectiveTrivia)) + { + removed = true; + newLeadingTrivia.Add(ElasticCarriageReturnLineFeed); + continue; + } + + newLeadingTrivia.Add(st); + } + + return newLeadingTrivia; + } + + private TypeSyntax GetReturnType(TypeSyntax returnType, INamedTypeSymbol symbol) => (returnType switch + { + IdentifierNameSyntax => ProcessSymbol(symbol), + _ => returnType, + }).WithTriviaFrom(returnType); + + private TypeSyntax? ConvertFuncToAction(TypeSyntax originalType, INamedTypeSymbol namedTypeSymbol) { var typeArgs = namedTypeSymbol.TypeArguments; @@ -1983,26 +1744,6 @@ private static bool TryStripAsync(string str, [NotNullWhen(true)] out string? st return newTypeSyntax; } - private static List RemoveFirstEndIf(SyntaxTriviaList list) - { - var newLeadingTrivia = new List(); - - var removed = false; - foreach (var st in list) - { - if (!removed && st.IsKind(SyntaxKind.EndIfDirectiveTrivia)) - { - removed = true; - newLeadingTrivia.Add(ElasticCarriageReturnLineFeed); - continue; - } - - newLeadingTrivia.Add(st); - } - - return newLeadingTrivia; - } - private bool EndsWithAsync(ExpressionSyntax expression) => expression switch { IdentifierNameSyntax id => ReplaceAsync(id) is not null, @@ -2025,7 +1766,7 @@ GenericNameSyntax gn when TryStripAsync(gn.Identifier.Text, out var newName) => { if (id.Identifier.ValueText is WaitAsync) { - var symbol = semanticModel.GetSymbolInfo(id).Symbol as IMethodSymbol; + var symbol = SemanticModel.GetSymbolInfo(id).Symbol as IMethodSymbol; if (symbol?.ContainingType?.ToDisplayString() is { } containingType) { if (containingType.StartsWith(TaskFullyQualified, StringComparison.Ordinal) || @@ -2053,7 +1794,7 @@ GenericNameSyntax gn when TryStripAsync(gn.Identifier.Text, out var newName) => return null; } - var (enumerableMembers, queryableMembers) = linqMembers ??= semanticModel.Compilation.GetLinqMembers(); + var (enumerableMembers, queryableMembers) = linqMembers ??= SemanticModel.Compilation.GetLinqMembers(); return queryableMembers.Contains(newName) ? Global("System.Linq.Queryable") : enumerableMembers.Contains(newName) ? Global("System.Linq.Enumerable") @@ -2348,8 +2089,6 @@ private SyncOnlyAttributeContext ProcessSyncOnlyAttributes(SyntaxTriviaList synt return new(List(attributes), triviaList); } - private ISymbol? GetSymbol(SyntaxNode node) => semanticModel.GetSymbolInfo(node).Symbol; - private bool IsEnumeratorCancellationAttribute(AttributeSyntax attributeSyntax) { var type = GetSymbol(attributeSyntax)?.ContainingType; @@ -2474,24 +2213,11 @@ private HashSet MethodsWhichNeverReturn(SyntaxNode node) /// private bool DropsReturnKeyword(StatementSyntax statement) => statement is ReturnStatementSyntax { Expression: InvocationExpressionSyntax, Parent: BlockSyntax { Parent: MethodDeclarationSyntax method } } - && semanticModel.GetTypeInfo(method.ReturnType).Type is INamedTypeSymbol { IsNonGenericTaskOrValueTask: true }; + && SemanticModel.GetTypeInfo(method.ReturnType).Type is INamedTypeSymbol { IsNonGenericTaskOrValueTask: true }; private bool RemoveDeclarator(VariableDeclaratorSyntax variable) => variable.Initializer is { Value: { } value } && ShouldRemoveArgument(value); - private TypeSyntax ProcessSyntaxUsingSymbol(TypeSyntax typeSyntax) - { - var typeSymbol = semanticModel.GetTypeInfo(typeSyntax).Type; - return typeSymbol is null ? typeSyntax : ProcessSymbol(typeSymbol).WithTriviaFrom(typeSyntax); - } - - private TypeSyntax ProcessType(TypeSyntax typeSyntax) => typeSyntax switch - { - IdentifierNameSyntax { Identifier.ValueText: "var" } => typeSyntax, - IdentifierNameSyntax or QualifiedNameSyntax => ProcessSyntaxUsingSymbol(typeSyntax), - _ => typeSyntax, - }; - private bool CanDropStatement(StatementSyntax statement) => statement switch { IfStatementSyntax @if => ShouldRemoveArgument(@if.Condition), @@ -2628,7 +2354,7 @@ private bool ProcessUnaryExpression(PrefixUnaryExpressionSyntax pue, RemoveArgum private bool ShouldRemoveLiteral(LiteralExpressionSyntax literalExpression) => literalExpression.Token.IsKind(SyntaxKind.DefaultKeyword) - && semanticModel.GetTypeInfo(literalExpression).Type is INamedTypeSymbol { IsNonGenericValueTask: true }; + && SemanticModel.GetTypeInfo(literalExpression).Type is INamedTypeSymbol { IsNonGenericValueTask: true }; private bool ShouldRemoveObjectCreation(BaseObjectCreationExpressionSyntax oe) => GetSymbol(oe) is IMethodSymbol { ReceiverType: INamedTypeSymbol { IsNonGenericValueTask: true } }; diff --git a/src/Zomp.SyncMethodGenerator/Cloning/CloningRewriter.cs b/src/Zomp.SyncMethodGenerator/Cloning/CloningRewriter.cs new file mode 100644 index 0000000..05e74cf --- /dev/null +++ b/src/Zomp.SyncMethodGenerator/Cloning/CloningRewriter.cs @@ -0,0 +1,432 @@ +using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; + +namespace Zomp.SyncMethodGenerator.Cloning; + +/// +/// Rewrites a method so that a copy of it compiles in a file of its own. Every type and static +/// member the method names is fully qualified, so that it resolves to the same symbol in the file +/// the copy is emitted to as in the file the method was written in. A transformation derives from +/// this class to change what the copy does. +/// +/// The semantic model. +/// The method declaration to rewrite; other method declarations are dropped. +internal abstract class CloningRewriter(SemanticModel semanticModel, MethodDeclarationSyntax targetMethod) : CSharpSyntaxRewriter +{ + private static readonly SymbolDisplayFormat GlobalDisplayFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.None, + miscellaneousOptions: + SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | + SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly SymbolDisplayFormat GlobalDisplayFormatWithTypeParameters = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: + SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers | + SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + /// + /// Gets the semantic model of the tree the method is in. + /// + protected SemanticModel SemanticModel { get; } = semanticModel; + + /// + public override SyntaxNode? VisitArgument(ArgumentSyntax node) + { + var @base = (ArgumentSyntax)base.VisitArgument(node)!; + + // Handles nameof(Type) + return GetSymbol(node.Expression) is ITypeSymbol typeSymbol && !TypeAlreadyQualified(typeSymbol) + ? @base.WithExpression(ProcessSymbol(typeSymbol)).WithTriviaFrom(@base) + : @base; + } + + /// + public override SyntaxNode? VisitArrayType(ArrayTypeSyntax node) + { + var @base = (ArrayTypeSyntax)base.VisitArrayType(node)!; + var elementType = TypeAlreadyQualified(node.ElementType) + ? @base.ElementType + : ProcessType(@base.ElementType); + return @base.WithElementType(elementType).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitAttribute(AttributeSyntax node) + { + var @base = (AttributeSyntax)base.VisitAttribute(node)!; + + if (GetSymbol(node.Name) is not IMethodSymbol ms) + { + return @base; + } + + var retval = @base.WithName(ProcessSymbol(ms.ContainingType)); + return retval; + } + + /// + public override SyntaxNode? VisitBinaryExpression(BinaryExpressionSyntax node) + { + var @base = (BinaryExpressionSyntax)base.VisitBinaryExpression(node)!; + + if (@base.OperatorToken.IsKind(SyntaxKind.IsKeyword) || @base.OperatorToken.IsKind(SyntaxKind.AsKeyword)) + { + if (GetSymbol(node.Left) is IFieldSymbol leftSymbol) + { + @base = @base.WithLeft(ProcessSymbol(leftSymbol).WithTriviaFrom(node.Left)); + } + + if (GetSymbol(node.Right) is ISymbol symbol) + { + @base = @base.WithRight(ProcessSymbol(symbol).WithTriviaFrom(node.Right)); + } + + return @base.WithTriviaFrom(@base); + } + + return @base; + } + + /// + public override SyntaxNode? VisitCastExpression(CastExpressionSyntax node) + { + var @base = (CastExpressionSyntax)base.VisitCastExpression(node)!; + return TypeAlreadyQualified(node.Type) ? @base : @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitCatchDeclaration(CatchDeclarationSyntax node) + { + var @base = (CatchDeclarationSyntax)base.VisitCatchDeclaration(node)!; + return @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitConstantPattern(ConstantPatternSyntax node) + { + var @base = (ConstantPatternSyntax)base.VisitConstantPattern(node)!; + return node.Expression switch + { + LiteralExpressionSyntax or MemberAccessExpressionSyntax => @base, + _ => SemanticModel.GetTypeInfo(node.Expression).Type is { } type + ? @base.WithExpression(ProcessSymbol(type).WithTriviaFrom(@base)) + : @base, + }; + } + + /// + public override SyntaxNode? VisitDeclarationExpression(DeclarationExpressionSyntax node) + { + var @base = (DeclarationExpressionSyntax)base.VisitDeclarationExpression(node)!; + return TypeAlreadyQualified(node.Type) ? @base : @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitDeclarationPattern(DeclarationPatternSyntax node) + { + var @base = (DeclarationPatternSyntax)base.VisitDeclarationPattern(node)!; + return TypeAlreadyQualified(node.Type) ? @base : @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitForEachStatement(ForEachStatementSyntax node) + { + var @base = (ForEachStatementSyntax)base.VisitForEachStatement(node)!; + return TypeAlreadyQualified(node.Type) ? @base : @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitGenericName(GenericNameSyntax node) + { + var @base = (GenericNameSyntax)base.VisitGenericName(node)!; + + if (GetSymbol(node) is not INamedTypeSymbol symbol) + { + return @base; + } + + var identifier = symbol switch + { + { ContainingSymbol: INamedTypeSymbol { IsGenericType: true } parentSymbol } + => parentSymbol.ToDisplayString(GlobalDisplayFormatWithTypeParameters) + "." + symbol.Name, + _ => symbol.ToDisplayString(GlobalDisplayFormat), + }; + + return @base.WithIdentifier(Identifier(identifier)).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitIdentifierName(IdentifierNameSyntax node) + { + var @base = (IdentifierNameSyntax)base.VisitIdentifierName(node)!; + + if (node.Parent is not MemberAccessExpressionSyntax) + { + var symbol = GetSymbol(node); + if (symbol is { IsStatic: true, ContainingType: { } containingType } memberSymbol) + { + if (symbol is IFieldSymbol or IMethodSymbol { MethodKind: not MethodKind.LocalFunction }) + { + var typeString = containingType.ToDisplayString(GlobalDisplayFormatWithTypeParameters); + return @base.WithIdentifier(Identifier($"{typeString}.{memberSymbol.Name}")).WithTriviaFrom(node); + } + } + } + + if (node.Parent is TypeArgumentListSyntax) + { + return ProcessType(node); + } + + return @base; + } + + /// + /// Parenthesizes every interpolated expression. A fully qualified name contains ::, + /// whose first colon would otherwise start a format string. + /// + /// The interpolation. + /// The interpolation with its expression parenthesized. + public override SyntaxNode? VisitInterpolation(InterpolationSyntax node) + { + var @base = (InterpolationSyntax)base.VisitInterpolation(node)!; + if (@base.Expression is not ParenthesizedExpressionSyntax) + { + var newExpression = ParenthesizedExpression(@base.Expression); + @base = @base.WithExpression(newExpression); + } + + return @base; + } + + /// + public override SyntaxNode? VisitMemberAccessExpression(MemberAccessExpressionSyntax node) + { + var @base = (MemberAccessExpressionSyntax)base.VisitMemberAccessExpression(node)!; + + // Rewrite static invocation (eg. File.ReadAllText) + if (GetSymbol(node.Expression) is ITypeSymbol && node.Expression is TypeSyntax type) + { + var newType = ProcessType(type); + if (newType != type) + { + @base = @base.WithExpression(newType); + } + } + + return @base; + } + + /// + public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) + => node == targetMethod ? base.VisitMethodDeclaration(node) : null; + + /// + public override SyntaxNode? VisitNullableType(NullableTypeSyntax node) + { + var @base = (NullableTypeSyntax)base.VisitNullableType(node)!; + + return TypeAlreadyQualified(node.ElementType) ? @base : @base.WithElementType(ProcessType(@base.ElementType)).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitObjectCreationExpression(ObjectCreationExpressionSyntax node) + { + var @base = (ObjectCreationExpressionSyntax)base.VisitObjectCreationExpression(node)!; + + if (SemanticModel.GetTypeInfo(node).Type is not { } t + || t is INamedTypeSymbol { IsGenericType: true }) + { + return @base; + } + + var newType = ProcessSymbol(t); + return newType == node.Type ? @base : @base.WithType(newType); + } + + /// + public override SyntaxNode? VisitParameter(ParameterSyntax node) + { + var @base = (ParameterSyntax)base.VisitParameter(node)!; + + return node.Type is null || TypeAlreadyQualified(node.Type) ? @base + : @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitQualifiedName(QualifiedNameSyntax node) + { + var @base = (QualifiedNameSyntax)base.VisitQualifiedName(node)!; + + return @base.Right is GenericNameSyntax ? @base.Right : (SyntaxNode)ProcessType(node); + } + + /// + public override SyntaxNode? VisitTupleType(TupleTypeSyntax node) + { + var @base = (TupleTypeSyntax)base.VisitTupleType(node)!; + + var newTuples = new List(); + foreach (var t in node.Elements.Zip(@base.Elements, (original, visited) => (original, visited))) + { + var newType = TypeAlreadyQualified(t.original.Type) ? t.visited.Type : ProcessType(t.original.Type); + newTuples.Add(TupleElement(newType, t.original.Identifier)); + } + + return @base.WithElements(SeparatedList(newTuples, node.Elements.GetSeparators())); + } + + /// + public override SyntaxNode? VisitTypeConstraint(TypeConstraintSyntax node) + { + var @base = (TypeConstraintSyntax)base.VisitTypeConstraint(node)!; + var newType = ProcessType(@base.Type); + return newType == @base.Type ? @base : @base.WithType(newType).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitTypeOfExpression(TypeOfExpressionSyntax node) + { + var @base = (TypeOfExpressionSyntax)base.VisitTypeOfExpression(node)!; + return TypeAlreadyQualified(node.Type) ? @base : @base.WithType(ProcessType(node.Type)).WithTriviaFrom(@base); + } + + /// + public override SyntaxNode? VisitVariableDeclaration(VariableDeclarationSyntax node) + { + var @base = (VariableDeclarationSyntax)base.VisitVariableDeclaration(node)!; + + var type = node.Type; + var newType = @base.Type; + + if (newType == type || + (newType is IdentifierNameSyntax { Identifier.ValueText: { } newTypeString } + && type is IdentifierNameSyntax { Identifier.ValueText: { } typeString } + && newTypeString == typeString)) + { + // not replaced + newType = ProcessType(type); + + if (newType == type) + { + return @base; + } + } + + return @base.WithType(newType).WithTriviaFrom(@base); + } + + /// + /// Prefixes a fully qualified name with the global alias. + /// + /// Fully qualified name. + /// The name, prefixed with global::. + protected static string Global(string type) => $"global::{type}"; + + /// + /// Removes the preprocessor directives, and the text they disable, from the trivia which + /// leads a method. The copy is emitted without whatever surrounded the original. + /// + /// Leading trivia of the method. + /// The trivia without preprocessor directives. + protected static SyntaxTriviaList RemovePreprocessorDirectives(SyntaxTriviaList trivia) + { + static bool Preprocessors(SyntaxTrivia st) + => st.IsKind(SyntaxKind.IfDirectiveTrivia) + || st.IsKind(SyntaxKind.ElifDirectiveTrivia) + || st.IsKind(SyntaxKind.ElseDirectiveTrivia) + || st.IsKind(SyntaxKind.EndIfDirectiveTrivia) + || st.IsKind(SyntaxKind.RegionDirectiveTrivia) + || st.IsKind(SyntaxKind.EndRegionDirectiveTrivia) + || st.IsKind(SyntaxKind.DisabledTextTrivia); + + while (trivia.FirstOrDefault(Preprocessors) is { } preprocessor + && preprocessor != default) + { + trivia = trivia.Remove(preprocessor); + } + + return trivia; + } + + /// + /// Checks whether a type is written in a form which its own visitor qualifies. + /// + /// The type. + /// True if the type needs nothing more. + protected static bool TypeAlreadyQualified(TypeSyntax type) + => type is NullableTypeSyntax or GenericNameSyntax or TupleTypeSyntax or ArrayTypeSyntax or QualifiedNameSyntax; + + /// + /// Gets the symbol a node refers to. + /// + /// The node. + /// The symbol, or null if the node does not bind to one. + protected ISymbol? GetSymbol(SyntaxNode node) => SemanticModel.GetSymbolInfo(node).Symbol; + + /// + /// Gets the fully qualified name of a type. + /// + /// The type, or any other symbol, which is named as it is. + /// The name. + protected string MakeType(ISymbol symbol) + => symbol switch + { + INamedTypeSymbol nts => MapTypeName(nts) ?? symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + _ => symbol.Name, + }; + + /// + /// Names a symbol so that it resolves wherever the copy is emitted. + /// + /// The symbol. + /// The name. + protected SimpleNameSyntax ProcessSymbol(ISymbol typeSymbol) => MapSymbol(typeSymbol) ?? typeSymbol switch + { + INamedTypeSymbol nts => IdentifierName(MakeType(nts)), + IArrayTypeSymbol ats => IdentifierName(MakeType(ats.ElementType) + $"[{new string(',', ats.Rank - 1)}]"), + IFieldSymbol fs => IdentifierName(MakeType(fs.Type) + '.' + fs.Name), + _ => IdentifierName(typeSymbol.Name), + }; + + /// + /// Qualifies a type written as a simple or qualified name. + /// + /// The type. + /// The qualified type, or the type itself if it needs nothing. + protected TypeSyntax ProcessType(TypeSyntax typeSyntax) => typeSyntax switch + { + IdentifierNameSyntax { Identifier.ValueText: "var" } => typeSyntax, + IdentifierNameSyntax or QualifiedNameSyntax => ProcessSyntaxUsingSymbol(typeSyntax), + _ => typeSyntax, + }; + + /// + /// Gives a transformation the chance to name a symbol differently in the copy. + /// + /// The symbol. + /// The name to use, or null to use the fully qualified name. + protected virtual SimpleNameSyntax? MapSymbol(ISymbol symbol) => null; + + /// + /// Gives a transformation the chance to substitute one type for another wherever a type is + /// named by its fully qualified name. + /// + /// The type. + /// The name to use, or null to use the fully qualified name. + protected virtual string? MapTypeName(INamedTypeSymbol symbol) => null; + + private static bool TypeAlreadyQualified(ITypeSymbol type) + => type is INamedTypeSymbol namedType + && namedType is { IsGenericType: true }; + + private TypeSyntax ProcessSyntaxUsingSymbol(TypeSyntax typeSyntax) + { + var typeSymbol = SemanticModel.GetTypeInfo(typeSyntax).Type; + return typeSymbol is null ? typeSyntax : ProcessSymbol(typeSymbol).WithTriviaFrom(typeSyntax); + } +}