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/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/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); + } +} 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 @@ +