diff --git a/Directory.Packages.props b/Directory.Packages.props index 5ea87d5a5..21fccd0a7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -25,6 +25,7 @@ + diff --git a/src/Dapr.Actors.Analyzers/ActorAnalyzer.cs b/src/Dapr.Actors.Analyzers/ActorAnalyzer.cs new file mode 100644 index 000000000..02edc7b3a --- /dev/null +++ b/src/Dapr.Actors.Analyzers/ActorAnalyzer.cs @@ -0,0 +1,206 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapr.Actors.Analyzers; + +/// +/// Analyzes actor registration in Dapr applications. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class ActorAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor DiagnosticDescriptorActorRegistration = new( + "DAPR0001", + "Actor class not registered", + "The actor class '{0}' is not registered", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DiagnosticDescriptorJsonSerialization = new( + "DAPR0002", + "Use JsonSerialization", + "Add options.UseJsonSerialization to support interoperability with non-.NET actors", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DiagnosticDescriptorMapActorsHandlers = new( + "DAPR0003", + "Call MapActorsHandlers", + "Call app.MapActorsHandlers to map endpoints for Dapr actors", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + /// + /// Gets the supported diagnostics for this analyzer. + /// + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + DiagnosticDescriptorActorRegistration, + DiagnosticDescriptorJsonSerialization, + DiagnosticDescriptorMapActorsHandlers); + + /// + /// Initializes the analyzer. + /// + /// The analysis context. + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeActorRegistration, SyntaxKind.ClassDeclaration); + context.RegisterSyntaxNodeAction(AnalyzeSerialization, SyntaxKind.CompilationUnit); + context.RegisterSyntaxNodeAction(AnalyzeMapActorsHandlers, SyntaxKind.CompilationUnit); + } + + private void AnalyzeActorRegistration(SyntaxNodeAnalysisContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + + if (classDeclaration.BaseList != null) + { + var baseTypeSyntax = classDeclaration.BaseList.Types[0].Type; + + if (context.SemanticModel.GetSymbolInfo(baseTypeSyntax).Symbol is INamedTypeSymbol baseTypeSymbol) + { + var baseTypeName = baseTypeSymbol.ToDisplayString(); + + { + var actorTypeName = classDeclaration.Identifier.Text; + bool isRegistered = CheckIfActorIsRegistered(actorTypeName, context.SemanticModel); + if (!isRegistered) + { + var diagnostic = Diagnostic.Create(DiagnosticDescriptorActorRegistration, classDeclaration.Identifier.GetLocation(), actorTypeName); + context.ReportDiagnostic(diagnostic); + } + } + } + } + } + + private static bool CheckIfActorIsRegistered(string actorTypeName, SemanticModel semanticModel) + { + var methodInvocations = new List(); + foreach (var syntaxTree in semanticModel.Compilation.SyntaxTrees) + { + var root = syntaxTree.GetRoot(); + methodInvocations.AddRange(root.DescendantNodes().OfType()); + } + + foreach (var invocation in methodInvocations) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + continue; + } + + var methodName = memberAccess.Name.Identifier.Text; + if (methodName == "RegisterActor") + { + if (memberAccess.Name is GenericNameSyntax typeArgumentList && typeArgumentList.TypeArgumentList.Arguments.Count > 0) + { + if (typeArgumentList.TypeArgumentList.Arguments[0] is IdentifierNameSyntax typeArgument) + { + if (typeArgument.Identifier.Text == actorTypeName) + { + return true; + } + } + else if (typeArgumentList.TypeArgumentList.Arguments[0] is QualifiedNameSyntax qualifiedName) + { + if (qualifiedName.Right.Identifier.Text == actorTypeName) + { + return true; + } + } + } + } + } + + return false; + } + + private void AnalyzeSerialization(SyntaxNodeAnalysisContext context) + { + var addActorsInvocation = FindInvocation(context, "AddActors"); + + if (addActorsInvocation != null) + { + var optionsLambda = addActorsInvocation.ArgumentList.Arguments + .Select(arg => arg.Expression) + .OfType() + .FirstOrDefault(); + + if (optionsLambda != null) + { + var lambdaBody = optionsLambda.Body; + var assignments = lambdaBody.DescendantNodes().OfType(); + + var useJsonSerialization = assignments.Any(assignment => + assignment.Left is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name is IdentifierNameSyntax identifier && + identifier.Identifier.Text == "UseJsonSerialization" && + assignment.Right is LiteralExpressionSyntax literal && + literal.Token.ValueText == "true"); + + if (!useJsonSerialization) + { + var diagnostic = Diagnostic.Create(DiagnosticDescriptorJsonSerialization, addActorsInvocation.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + } + } + } + + private InvocationExpressionSyntax? FindInvocation(SyntaxNodeAnalysisContext context, string methodName) + { + foreach (var syntaxTree in context.SemanticModel.Compilation.SyntaxTrees) + { + var root = syntaxTree.GetRoot(); + var invocation = root.DescendantNodes().OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == methodName); + + if (invocation != null) + { + return invocation; + } + } + + return null; + } + + private void AnalyzeMapActorsHandlers(SyntaxNodeAnalysisContext context) + { + var addActorsInvocation = FindInvocation(context, "AddActors"); + + if (addActorsInvocation != null) + { + bool invokedByWebApplication = false; + var mapActorsHandlersInvocation = FindInvocation(context, "MapActorsHandlers"); + + if (mapActorsHandlersInvocation?.Expression is MemberAccessExpressionSyntax memberAccess) + { + var symbolInfo = context.SemanticModel.GetSymbolInfo(memberAccess.Expression); + if (symbolInfo.Symbol is ILocalSymbol localSymbol) + { + var type = localSymbol.Type; + if (type.ToDisplayString() == "Microsoft.AspNetCore.Builder.WebApplication") + { + invokedByWebApplication = true; + } + } + } + + if (mapActorsHandlersInvocation == null || !invokedByWebApplication) + { + var diagnostic = Diagnostic.Create(DiagnosticDescriptorMapActorsHandlers, addActorsInvocation.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + } + } +} diff --git a/src/Dapr.Actors.Analyzers/ActorJsonSerializationCodeFixProvider.cs b/src/Dapr.Actors.Analyzers/ActorJsonSerializationCodeFixProvider.cs new file mode 100644 index 000000000..df3e832ce --- /dev/null +++ b/src/Dapr.Actors.Analyzers/ActorJsonSerializationCodeFixProvider.cs @@ -0,0 +1,120 @@ +using System.Composition; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.CSharp; + +namespace Dapr.Actors.Analyzers; + +/// +/// Provides code fix to enable JSON serialization for actors. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ActorRegistrationCodeFixProvider))] +[Shared] +public class ActorJsonSerializationCodeFixProvider : CodeFixProvider +{ + /// + /// Gets the diagnostic IDs that this provider can fix. + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("DAPR0002"); + + /// + /// Gets the FixAllProvider for this code fix provider. + /// + /// The FixAllProvider. + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + /// + /// Registers code fixes for the specified diagnostics. + /// + /// The context to register the code fixes. + /// A task representing the asynchronous operation. + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + var title = "Use JSON serialization"; + context.RegisterCodeFix( + CodeAction.Create( + title, + createChangedDocument: c => UseJsonSerializationAsync(context.Document, context.Diagnostics.First(), c), + equivalenceKey: title), + context.Diagnostics); + return Task.CompletedTask; + } + + private async Task UseJsonSerializationAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + (_, var addActorsInvocation) = await FindAddActorsInvocationAsync(document.Project, cancellationToken); + + if (addActorsInvocation == null) + { + return document; + } + + var optionsLambda = addActorsInvocation?.ArgumentList.Arguments + .Select(arg => arg.Expression) + .OfType() + .FirstOrDefault(); + + if (optionsLambda == null || optionsLambda.Body is not BlockSyntax optionsBlock) + return document; + + // Extract the parameter name from the lambda expression + var parameterName = optionsLambda.Parameter.Identifier.Text; + + // Check if the lambda body already contains the assignment + var assignmentExists = optionsBlock.Statements + .OfType() + .Any(statement => statement.Expression is AssignmentExpressionSyntax assignment && + assignment.Left is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name is IdentifierNameSyntax identifier && + identifier.Identifier.Text == parameterName && + memberAccess.Name.Identifier.Text == "UseJsonSerialization"); + + if (!assignmentExists) + { + var assignmentStatement = SyntaxFactory.ExpressionStatement( + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName(parameterName), + SyntaxFactory.IdentifierName("UseJsonSerialization")), + SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression))); + + var newOptionsBlock = optionsBlock.AddStatements(assignmentStatement); + var root = await document.GetSyntaxRootAsync(cancellationToken); + var newRoot = root?.ReplaceNode(optionsBlock, newOptionsBlock); + return document.WithSyntaxRoot(newRoot!); + } + + return document; + } + + private async Task<(Document?, InvocationExpressionSyntax?)> FindAddActorsInvocationAsync(Project project, CancellationToken cancellationToken) + { + var compilation = await project.GetCompilationAsync(cancellationToken); + + foreach (var syntaxTree in compilation!.SyntaxTrees) + { + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken); + + var addActorsInvocation = syntaxRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddActors"); + + if (addActorsInvocation != null) + { + var document = project.GetDocument(addActorsInvocation.SyntaxTree); + return (document, addActorsInvocation); + } + } + + return (null, null); + } +} diff --git a/src/Dapr.Actors.Analyzers/ActorRegistrationCodeFixProvider.cs b/src/Dapr.Actors.Analyzers/ActorRegistrationCodeFixProvider.cs new file mode 100644 index 000000000..efb7d2994 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/ActorRegistrationCodeFixProvider.cs @@ -0,0 +1,220 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace Dapr.Actors.Analyzers; + +/// +/// Provides code fixes for actor registration issues. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ActorRegistrationCodeFixProvider))] +[Shared] +public class ActorRegistrationCodeFixProvider : CodeFixProvider +{ + /// + /// Gets the diagnostic IDs that this provider can fix. + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("DAPR0001"); + + /// + /// Registers code fixes for the specified diagnostics. + /// + /// The context to register the code fixes. + /// A task representing the asynchronous operation. + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + var title = "Register actor"; + context.RegisterCodeFix( + CodeAction.Create( + title, + createChangedDocument: c => RegisterActorAsync(context.Document, context.Diagnostics.First(), c), + equivalenceKey: title), + context.Diagnostics); + return Task.CompletedTask; + } + + private async Task RegisterActorAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + var classDeclaration = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().First(); + + if (root == null || classDeclaration == null) + return document; + + // Get the semantic model + var semanticModel = await document.GetSemanticModelAsync(cancellationToken); + + if (semanticModel == null) + return document; + + // Get the symbol for the class declaration + + if (semanticModel.GetDeclaredSymbol(classDeclaration, cancellationToken) is not INamedTypeSymbol classSymbol) + return document; + + // Get the fully qualified name + var actorType = classSymbol.ToDisplayString(new SymbolDisplayFormat( + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces)); + + if (string.IsNullOrEmpty(actorType)) + return document; + + // Get the compilation + var compilation = await document.Project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); + + if (compilation == null) + return document; + + (var targetDocument, var addActorsInvocation) = await FindAddActorsInvocationAsync(document.Project, cancellationToken); + + if (addActorsInvocation == null) + { + (targetDocument, addActorsInvocation) = await CreateAddActorsInvocation(document.Project, cancellationToken); + } + + if (addActorsInvocation == null) + return document; + + var targetRoot = await addActorsInvocation.SyntaxTree.GetRootAsync(cancellationToken); + + if (targetRoot == null || targetDocument == null) + return document; + + // Find the options lambda block + var optionsLambda = addActorsInvocation?.ArgumentList.Arguments + .Select(arg => arg.Expression) + .OfType() + .FirstOrDefault(); + + if (optionsLambda == null || optionsLambda.Body is not BlockSyntax optionsBlock) + return document; + + // Extract the parameter name from the lambda expression + var parameterName = optionsLambda.Parameter.Identifier.Text; + + // Create the new workflow registration statement + var registerWorkflowStatement = SyntaxFactory.ParseStatement($"{parameterName}.Actors.RegisterActor<{actorType}>();"); + + // Add the new registration statement to the options block + var newOptionsBlock = optionsBlock.AddStatements(registerWorkflowStatement); + + // Replace the old options block with the new one + var newRoot = targetRoot?.ReplaceNode(optionsBlock, newOptionsBlock); + + // Format the new root. + newRoot = Formatter.Format(newRoot!, document.Project.Solution.Workspace); + + return targetDocument.WithSyntaxRoot(newRoot); + } + + /// + /// Gets the FixAllProvider for this code fix provider. + /// + /// The FixAllProvider. + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + private async Task<(Document?, InvocationExpressionSyntax?)> FindAddActorsInvocationAsync(Project project, CancellationToken cancellationToken) + { + var compilation = await project.GetCompilationAsync(cancellationToken); + + foreach (var syntaxTree in compilation!.SyntaxTrees) + { + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken); + + var addActorsInvocation = syntaxRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddActors"); + + if (addActorsInvocation != null) + { + var document = project.GetDocument(addActorsInvocation.SyntaxTree); + return (document, addActorsInvocation); + } + } + + return (null, null); + } + + private async Task FindCreateBuilderInvocationAsync(Project project, CancellationToken cancellationToken) + { + var compilation = await project.GetCompilationAsync(cancellationToken); + + foreach (var syntaxTree in compilation!.SyntaxTrees) + { + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken); + + // Find the invocation expression for WebApplication.CreateBuilder() + var createBuilderInvocation = syntaxRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression is IdentifierNameSyntax identifier && + identifier.Identifier.Text == "WebApplication" && + memberAccess.Name.Identifier.Text == "CreateBuilder"); + + if (createBuilderInvocation != null) + { + return createBuilderInvocation; + } + } + + return null; + } + + private async Task<(Document?, InvocationExpressionSyntax?)> CreateAddActorsInvocation(Project project, CancellationToken cancellationToken) + { + var createBuilderInvocation = await FindCreateBuilderInvocationAsync(project, cancellationToken); + + var variableDeclarator = createBuilderInvocation?.Ancestors() + .OfType() + .FirstOrDefault(); + + var builderVariable = variableDeclarator?.Identifier.Text; + + if (createBuilderInvocation != null) + { + var targetRoot = await createBuilderInvocation.SyntaxTree.GetRootAsync(cancellationToken); + var document = project.GetDocument(createBuilderInvocation.SyntaxTree); + + if (createBuilderInvocation.Expression is MemberAccessExpressionSyntax { Expression: IdentifierNameSyntax builderIdentifier }) + { + var addActorsStatement = SyntaxFactory.ParseStatement($"{builderVariable}.Services.AddActors(options => {{ }});"); + + if (createBuilderInvocation.Ancestors().OfType().FirstOrDefault() is SyntaxNode parentBlock) + { + var firstChild = parentBlock.ChildNodes().FirstOrDefault(node => node is not UsingDirectiveSyntax); + var newParentBlock = parentBlock.InsertNodesAfter(firstChild, new[] { addActorsStatement }); + targetRoot = targetRoot.ReplaceNode(parentBlock, newParentBlock); + } + else + { + var compilationUnitSyntax = createBuilderInvocation.Ancestors().OfType().FirstOrDefault(); + var firstChild = compilationUnitSyntax.ChildNodes().FirstOrDefault(node => node is not UsingDirectiveSyntax); + var globalStatement = SyntaxFactory.GlobalStatement(addActorsStatement); + var newCompilationUnitSyntax = compilationUnitSyntax.InsertNodesAfter(firstChild, new[] { globalStatement }); + targetRoot = targetRoot.ReplaceNode(compilationUnitSyntax, newCompilationUnitSyntax); + } + + var addActorsInvocation = targetRoot?.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddActors"); + + return (document, addActorsInvocation); + } + } + + return (null, null); + } +} diff --git a/src/Dapr.Actors.Analyzers/AnalyzerReleases.Shipped.md b/src/Dapr.Actors.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 000000000..d8abdf2e2 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,9 @@ +## Release 1.16 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|-------------------- +DAPR0001| Usage | Warning | The actor class '{0}' is not registered +DAPR0002| Usage | Warning | Set options.UseJsonSerialization to true to support interoperability with non-.NET actors +DAPR0003| Usage | Warning | Call app.MapActorsHandlers to map endpoints for Dapr actors \ No newline at end of file diff --git a/src/Dapr.Actors.Analyzers/AnalyzerReleases.Unshipped.md b/src/Dapr.Actors.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 000000000..b1b99aaf2 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,3 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers.csproj b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers.csproj new file mode 100644 index 000000000..910ecb295 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers.csproj @@ -0,0 +1,43 @@ + + + + netstandard2.0 + + enable + enable + true + + + + + + + + + true + + + false + + + false + + + This package contains Roslyn analyzers for actors. + $(PackageTags) + + + + + + + + + + + + + + + + diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorRegistrationAnalyzer.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorRegistrationAnalyzer.cs new file mode 100644 index 000000000..164da75bd --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorRegistrationAnalyzer.cs @@ -0,0 +1,122 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapr.Actors.Analyzers; + +/// +/// An analyzer for Dapr actors that validates that each discovered Actor implementation is properly registered with +/// dependency injection during startup. +/// +[DiagnosticAnalyzer((LanguageNames.CSharp))] +public sealed class ActorRegistrationAnalyzer : DiagnosticAnalyzer +{ + internal static readonly DiagnosticDescriptor DiagnosticDescriptorActorRegistration = new( + id: "DAPR4002", + title: new LocalizableResourceString(nameof(Resources.DAPR4002Title), Resources.ResourceManager, + typeof(Resources)), + messageFormat: new LocalizableResourceString(nameof(Resources.DAPR4002MessageFormat), Resources.ResourceManager, + typeof(Resources)), + category: "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// Returns a set of descriptors for the diagnostics that this analyzer is capable of producing. + /// + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + DiagnosticDescriptorActorRegistration); + + /// + /// Called once at session start to register actions in the analysis context. + /// + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeActorRegistration, SyntaxKind.ClassDeclaration); + } + + private static void AnalyzeActorRegistration(SyntaxNodeAnalysisContext context) + { + var classDeclaration = (ClassDeclarationSyntax)context.Node; + + if (classDeclaration.BaseList == null) + { + return; + } + + var baseTypeSyntax = classDeclaration.BaseList.Types[0].Type; + + if (context.SemanticModel.GetSymbolInfo(baseTypeSyntax).Symbol is not INamedTypeSymbol baseTypeSymbol) + { + return; + } + + var actorTypeName = classDeclaration.Identifier.Text; + var isRegistered = CheckIfActorIsRegistered(actorTypeName, context.SemanticModel); + if (isRegistered) + { + return; + } + + var diagnostic = Diagnostic.Create(DiagnosticDescriptorActorRegistration, classDeclaration.Identifier.GetLocation(), actorTypeName); + context.ReportDiagnostic(diagnostic); + } + + private static bool CheckIfActorIsRegistered(string actorTypeName, SemanticModel semanticModel) + { + var methodInvocations = new List(); + foreach (var syntaxTree in semanticModel.Compilation.SyntaxTrees) + { + var root = syntaxTree.GetRoot(); + methodInvocations.AddRange(root.DescendantNodes().OfType()); + } + + foreach (var invocation in methodInvocations) + { + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + { + continue; + } + + var methodName = memberAccess.Name.Identifier.Text; + if (methodName != "RegisterActor") + { + continue; + } + + if (memberAccess.Name is not GenericNameSyntax typeArgumentList || + typeArgumentList.TypeArgumentList.Arguments.Count <= 0) + { + continue; + } + + switch (typeArgumentList.TypeArgumentList.Arguments[0]) + { + case IdentifierNameSyntax typeArgument when typeArgument.Identifier.Text == actorTypeName: + case QualifiedNameSyntax qualifiedName when qualifiedName.Right.Identifier.Text == actorTypeName: + return true; + } + } + + return false; + } +} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorRegistrationCodeFixProvider.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorRegistrationCodeFixProvider.cs new file mode 100644 index 000000000..828f99175 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/ActorRegistrationCodeFixProvider.cs @@ -0,0 +1,230 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Formatter = Microsoft.CodeAnalysis.Formatting.Formatter; + +namespace Dapr.Actors.Analyzers; + +/// +/// Provides code fixes for missing actor registrations during dependency injection configuration. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ActorRegistrationCodeFixProvider))] +public sealed class ActorRegistrationCodeFixProvider : CodeFixProvider +{ + /// + /// Gets the diagnostic IDs that this provider can fix. + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("DAPR4002"); + + /// + /// Registers code fixes for the specified diagnostics. + /// + /// The context to register the code fixes. + /// A task representing the asynchronous operation. + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + const string title = "Register Dapr actor"; + context.RegisterCodeFix( + CodeAction.Create( + title, + createChangedDocument: c => RegisterActorAsync(context.Document, context.Diagnostics.First(), c), + equivalenceKey: title), + context.Diagnostics); + return Task.CompletedTask; + } + + private async Task RegisterActorAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + var classDeclaration = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().First(); + + if (root == null || classDeclaration == null) + return document; + + // Get the semantic model + var semanticModel = await document.GetSemanticModelAsync(cancellationToken); + + if (semanticModel == null) + return document; + + // Get the symbol for the class declaration + + if (ModelExtensions.GetDeclaredSymbol(semanticModel, classDeclaration, cancellationToken) is not INamedTypeSymbol classSymbol) + return document; + + // Get the fully qualified name + var actorType = classSymbol.ToDisplayString(new SymbolDisplayFormat( + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces)); + + if (string.IsNullOrEmpty(actorType)) + return document; + + // Get the compilation + var compilation = await document.Project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); + + if (compilation == null) + return document; + + var (targetDocument, addActorsInvocation) = await FindAddActorsInvocationAsync(document.Project, cancellationToken); + + if (addActorsInvocation == null) + { + (targetDocument, addActorsInvocation) = await CreateAddActorsInvocation(document.Project, cancellationToken); + } + + if (addActorsInvocation == null) + return document; + + var targetRoot = await addActorsInvocation.SyntaxTree.GetRootAsync(cancellationToken); + + if (targetRoot == null || targetDocument == null) + return document; + + // Find the options lambda block + var optionsLambda = addActorsInvocation?.ArgumentList.Arguments + .Select(arg => arg.Expression) + .OfType() + .FirstOrDefault(); + + if (optionsLambda == null || optionsLambda.Body is not BlockSyntax optionsBlock) + return document; + + // Extract the parameter name from the lambda expression + var parameterName = optionsLambda.Parameter.Identifier.Text; + + // Create the new workflow registration statement + var registerWorkflowStatement = SyntaxFactory.ParseStatement($"{parameterName}.Actors.RegisterActor<{actorType}>();"); + + // Add the new registration statement to the options block + var newOptionsBlock = optionsBlock.AddStatements(registerWorkflowStatement); + + // Replace the old options block with the new one + var newRoot = targetRoot?.ReplaceNode(optionsBlock, newOptionsBlock); + + // Format the new root. + newRoot = Formatter.Format(newRoot!, document.Project.Solution.Workspace); + + return targetDocument.WithSyntaxRoot(newRoot); + } + + /// + /// Gets the FixAllProvider for this code fix provider. + /// + /// The FixAllProvider. + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + private async Task<(Document?, InvocationExpressionSyntax?)> FindAddActorsInvocationAsync(Project project, CancellationToken cancellationToken) + { + var compilation = await project.GetCompilationAsync(cancellationToken); + + foreach (var syntaxTree in compilation!.SyntaxTrees) + { + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken); + + var addActorsInvocation = syntaxRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddActors"); + + if (addActorsInvocation != null) + { + var document = project.GetDocument(addActorsInvocation.SyntaxTree); + return (document, addActorsInvocation); + } + } + + return (null, null); + } + + private async Task FindCreateBuilderInvocationAsync(Project project, CancellationToken cancellationToken) + { + var compilation = await project.GetCompilationAsync(cancellationToken); + + foreach (var syntaxTree in compilation!.SyntaxTrees) + { + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken); + + // Find the invocation expression for WebApplication.CreateBuilder() + var createBuilderInvocation = syntaxRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Expression is IdentifierNameSyntax identifier && + identifier.Identifier.Text == "WebApplication" && + memberAccess.Name.Identifier.Text == "CreateBuilder"); + + if (createBuilderInvocation != null) + { + return createBuilderInvocation; + } + } + + return null; + } + + private async Task<(Document?, InvocationExpressionSyntax?)> CreateAddActorsInvocation(Project project, CancellationToken cancellationToken) + { + var createBuilderInvocation = await FindCreateBuilderInvocationAsync(project, cancellationToken); + + var variableDeclarator = createBuilderInvocation?.Ancestors() + .OfType() + .FirstOrDefault(); + + var builderVariable = variableDeclarator?.Identifier.Text; + + if (createBuilderInvocation != null) + { + var targetRoot = await createBuilderInvocation.SyntaxTree.GetRootAsync(cancellationToken); + var document = project.GetDocument(createBuilderInvocation.SyntaxTree); + + if (createBuilderInvocation.Expression is MemberAccessExpressionSyntax { Expression: IdentifierNameSyntax builderIdentifier }) + { + var addActorsStatement = SyntaxFactory.ParseStatement($"{builderVariable}.Services.AddActors(options => {{ }});"); + + if (createBuilderInvocation.Ancestors().OfType().FirstOrDefault() is SyntaxNode parentBlock) + { + var firstChild = parentBlock.ChildNodes().FirstOrDefault(node => node is not UsingDirectiveSyntax); + var newParentBlock = parentBlock.InsertNodesAfter(firstChild, new[] { addActorsStatement }); + targetRoot = targetRoot.ReplaceNode(parentBlock, newParentBlock); + } + else + { + var compilationUnitSyntax = createBuilderInvocation.Ancestors().OfType().FirstOrDefault(); + var firstChild = compilationUnitSyntax.ChildNodes().FirstOrDefault(node => node is not UsingDirectiveSyntax); + var globalStatement = SyntaxFactory.GlobalStatement(addActorsStatement); + var newCompilationUnitSyntax = compilationUnitSyntax.InsertNodesAfter(firstChild, new[] { globalStatement }); + targetRoot = targetRoot.ReplaceNode(compilationUnitSyntax, newCompilationUnitSyntax); + } + + var addActorsInvocation = targetRoot?.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddActors"); + + return (document, addActorsInvocation); + } + } + + return (null, null); + } +} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Shipped.md b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Shipped.md index bc73dcb0b..f82775b19 100644 --- a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AnalyzerReleases.Shipped.md @@ -6,4 +6,7 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------------------------------------------------------------------------------------ -DAPR4001 | Usage | Warning | Actor timer method invocations require the named callback method to exist on type. \ No newline at end of file +DAPR4001 | Usage | Warning | Actor timer method invocations require the named callback method to exist on type. +DAPR4002 | Usage | Warning | The actor type is not registered with dependency injection +DAPR4003 | Usage | Info | Set options.UseJsonSerialization to true to support interoperability with non-.NET actors +DAPR4004 | Usage | Warning | Call app.MapActorsHandlers to map endpoints for Dapr actors. diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AssemblyInfo.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AssemblyInfo.cs index dcc08b3c1..e07f509ed 100644 --- a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AssemblyInfo.cs +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/AssemblyInfo.cs @@ -13,4 +13,4 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("Dapr.Actors.Analyzers.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2" )] +[assembly: InternalsVisibleTo("Dapr.Actors.Analyzers.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2" )] diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers.csproj b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers.csproj index a3c751c38..a75e37a3e 100644 --- a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers.csproj +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers.csproj @@ -3,15 +3,16 @@ netstandard2.0 - false enable enable This package contains Roslyn analyzers for Dapr.Actors. true + $(NoWarn);RS1038 + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/MappedActorHandlersAnalyzer.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/MappedActorHandlersAnalyzer.cs new file mode 100644 index 000000000..c85a80664 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/MappedActorHandlersAnalyzer.cs @@ -0,0 +1,90 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapr.Actors.Analyzers; + +/// +/// An analyzer for Dapr actors that validates that the handler is set up during initial setup to map +/// the actor endpoints. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class MappedActorHandlersAnalyzer : DiagnosticAnalyzer +{ + internal static readonly DiagnosticDescriptor DiagnosticDescriptorMapActorsHandlers = new( + id: "DAPR4004", + title: new LocalizableResourceString(nameof(Resources.DAPR4004Title), Resources.ResourceManager, + typeof(Resources)), + messageFormat: new LocalizableResourceString(nameof(Resources.DAPR4004MessageFormat), Resources.ResourceManager, + typeof(Resources)), + category: "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + /// + /// Returns a set of descriptors for the diagnostics that this analyzer is capable of producing. + /// + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + DiagnosticDescriptorMapActorsHandlers); + + /// + /// Called once at session start to register actions in the analysis context. + /// + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeMapActorsHandlers, SyntaxKind.CompilationUnit); + } + + private static void AnalyzeMapActorsHandlers(SyntaxNodeAnalysisContext context) + { + var addActorsInvocation = SharedUtilities.FindInvocation(context, "AddActors"); + + if (addActorsInvocation == null) + { + return; + } + + var invokedByWebApplication = false; + var mapActorsHandlersInvocation = SharedUtilities.FindInvocation(context, "MapActorsHandlers"); + + if (mapActorsHandlersInvocation?.Expression is MemberAccessExpressionSyntax memberAccess) + { + var symbolInfo = ModelExtensions.GetSymbolInfo(context.SemanticModel, memberAccess.Expression); + if (symbolInfo.Symbol is ILocalSymbol localSymbol) + { + var type = localSymbol.Type; + if (type.ToDisplayString() == "Microsoft.AspNetCore.Builder.WebApplication") + { + invokedByWebApplication = true; + } + } + } + + if (mapActorsHandlersInvocation != null && invokedByWebApplication) + { + return; + } + + var diagnostic = Diagnostic.Create(DiagnosticDescriptorMapActorsHandlers, addActorsInvocation.GetLocation()); + context.ReportDiagnostic(diagnostic); + } +} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/MappedActorHandlersCodeFixProvider.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/MappedActorHandlersCodeFixProvider.cs new file mode 100644 index 000000000..f5982ad8a --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/MappedActorHandlersCodeFixProvider.cs @@ -0,0 +1,134 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Dapr.Actors.Analyzers; + +/// +/// +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MappedActorHandlersCodeFixProvider))] +public sealed class MappedActorHandlersCodeFixProvider : CodeFixProvider +{ + /// + /// A list of diagnostic IDs that this provider can provide fixes for. + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("DAPR4004"); + + /// + /// Gets the FixAllProvider for this code fix provider. + /// + /// The FixAllProvider. + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + /// + /// Registers code fixes for the specified diagnostic. + /// + /// A context for code fix registration. + /// A task that represents the asynchronous operation. + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + const string title = "Register Dapr actor mappings"; + context.RegisterCodeFix( + CodeAction.Create( + title, + createChangedDocument: c => AddMapActorsHandlersAsync(context.Document, context.Diagnostics.First(), c), + equivalenceKey: title), + context.Diagnostics); + return Task.CompletedTask; + } + + /// + /// Adds a call to MapActorsHandlers to the specified document. + /// + /// The document to modify. + /// The diagnostic to fix. + /// A cancellation token. + /// A task that represents the asynchronous operation. The task result contains the modified document. + private static async Task AddMapActorsHandlersAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken); + var invocationExpressions = root!.DescendantNodes().OfType(); + + var createBuilderInvocation = invocationExpressions + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax { Name.Identifier.Text: "CreateBuilder", Expression: IdentifierNameSyntax + { + Identifier.Text: "WebApplication" + } + }); + + var variableDeclarator = createBuilderInvocation + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + var variableName = variableDeclarator.Identifier.Text; + + var buildInvocation = invocationExpressions + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax + { + Name.Identifier.Text: "Build", + Expression: IdentifierNameSyntax identifier + } && + identifier.Identifier.Text == variableName); + + var buildVariableDeclarator = buildInvocation + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + var buildVariableName = buildVariableDeclarator.Identifier.Text; + + var mapActorsHandlersInvocation = SyntaxFactory.ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName(buildVariableName), + SyntaxFactory.IdentifierName("MapActorsHandlers")))); + + if (buildInvocation.Ancestors().OfType().FirstOrDefault() is SyntaxNode parentBlock) + { + var localDeclaration = buildInvocation + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + var newParentBlock = parentBlock.InsertNodesAfter(localDeclaration, [mapActorsHandlersInvocation]); + root = root.ReplaceNode(parentBlock, newParentBlock); + } + else + { + var buildInvocationGlobalStatement = buildInvocation + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + var compilationUnitSyntax = createBuilderInvocation.Ancestors().OfType().FirstOrDefault(); + var newCompilationUnitSyntax = compilationUnitSyntax.InsertNodesAfter(buildInvocationGlobalStatement, + [SyntaxFactory.GlobalStatement(mapActorsHandlersInvocation)]); + root = root.ReplaceNode(compilationUnitSyntax, newCompilationUnitSyntax); + } + + return document.WithSyntaxRoot(root); + } +} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/PreferActorJsonSerializationAnalyzer.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/PreferActorJsonSerializationAnalyzer.cs new file mode 100644 index 000000000..9d36454dd --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/PreferActorJsonSerializationAnalyzer.cs @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapr.Actors.Analyzers; + +/// +/// An analyzer for Dapr Actors that suggests configuring JSON serialization during Actor DI registration for +/// better interoperability with non-.NET actors throughout a Dapr project. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PreferActorJsonSerializationAnalyzer : DiagnosticAnalyzer +{ + internal static readonly DiagnosticDescriptor DiagnosticDescriptorJsonSerialization = new( + id: "DAPR4003", + title: new LocalizableResourceString(nameof(Resources.DAPR4003Title), Resources.ResourceManager, + typeof(Resources)), + messageFormat: new LocalizableResourceString(nameof(Resources.DAPR4003MessageFormat), Resources.ResourceManager, + typeof(Resources)), + category: "Usage", + DiagnosticSeverity.Info, + isEnabledByDefault: true + ); + + /// + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create( + DiagnosticDescriptorJsonSerialization + ); + + /// + /// Called once at session start to register actions in the analysis context. + /// + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeSerialization, SyntaxKind.CompilationUnit); + } + + private static void AnalyzeSerialization(SyntaxNodeAnalysisContext context) + { + var addActorsInvocation = SharedUtilities.FindInvocation(context, "AddActors"); + + if (addActorsInvocation is null) + { + return; + } + + var optionsLambda = addActorsInvocation.ArgumentList.Arguments + .Select(arg => arg.Expression) + .OfType() + .FirstOrDefault(); + + if (optionsLambda == null) + { + return; + } + + var lambdaBody = optionsLambda.Body; + var assignments = lambdaBody.DescendantNodes().OfType(); + + var useJsonSerialization = assignments.Any(assignment => + assignment.Left is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name is IdentifierNameSyntax identifier && + identifier.Identifier.Text == "UseJsonSerialization" && + assignment.Right is LiteralExpressionSyntax literal && + literal.Token.ValueText == "true"); + + if (useJsonSerialization) + { + return; + } + + var diagnostic = Diagnostic.Create(DiagnosticDescriptorJsonSerialization, addActorsInvocation.GetLocation()); + context.ReportDiagnostic(diagnostic); + } +} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/PreferActorJsonSerializationCodeFixProvider.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/PreferActorJsonSerializationCodeFixProvider.cs new file mode 100644 index 000000000..fc3da9632 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/PreferActorJsonSerializationCodeFixProvider.cs @@ -0,0 +1,133 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Dapr.Actors.Analyzers; + +/// +/// Provides the code fix to enable JSON serialization for Dapr actors. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferActorJsonSerializationCodeFixProvider))] +public sealed class PreferActorJsonSerializationCodeFixProvider : CodeFixProvider +{ + /// + /// A list of diagnostic IDs that this provider can provide fixes for. + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("DAPR4003"); + + /// + /// Gets the FixAllProvider for this code fix provider. + /// + /// The FixAllProvider. + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + /// + /// Registers code fixes for the specified diagnostics. + /// + /// The context to register the code fixes. + /// A task representing the asynchronous operation. + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + const string title = "Use JSON serialization"; + context.RegisterCodeFix( + CodeAction.Create( + title, + createChangedDocument: c => UseJsonSerializationAsync(context.Document, context.Diagnostics.First(), c), + equivalenceKey: title), + context.Diagnostics); + return Task.CompletedTask; + } + + private async Task UseJsonSerializationAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var (_, addActorsInvocation) = await FindAddActorsInvocationAsync(document.Project, cancellationToken); + + if (addActorsInvocation == null) + { + return document; + } + + var optionsLambda = addActorsInvocation?.ArgumentList.Arguments + .Select(arg => arg.Expression) + .OfType() + .FirstOrDefault(); + + if (optionsLambda == null || optionsLambda.Body is not BlockSyntax optionsBlock) + return document; + + // Extract the parameter name from the lambda expression + var parameterName = optionsLambda.Parameter.Identifier.Text; + + // Check if the lambda body already contains the assignment + var assignmentExists = optionsBlock.Statements + .OfType() + .Any(statement => statement.Expression is AssignmentExpressionSyntax assignment && + assignment.Left is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name is IdentifierNameSyntax identifier && + identifier.Identifier.Text == parameterName && + memberAccess.Name.Identifier.Text == "UseJsonSerialization"); + + if (assignmentExists) + { + return document; + } + + var assignmentStatement = SyntaxFactory.ExpressionStatement( + SyntaxFactory.AssignmentExpression( + SyntaxKind.SimpleAssignmentExpression, + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName(parameterName), + SyntaxFactory.IdentifierName("UseJsonSerialization")), + SyntaxFactory.LiteralExpression(SyntaxKind.TrueLiteralExpression))); + + var newOptionsBlock = optionsBlock.AddStatements(assignmentStatement); + var root = await document.GetSyntaxRootAsync(cancellationToken); + var newRoot = root?.ReplaceNode(optionsBlock, newOptionsBlock); + return document.WithSyntaxRoot(newRoot!); + + } + + private async Task<(Document?, InvocationExpressionSyntax?)> FindAddActorsInvocationAsync(Project project, CancellationToken cancellationToken) + { + var compilation = await project.GetCompilationAsync(cancellationToken); + + foreach (var syntaxTree in compilation!.SyntaxTrees) + { + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken); + + var addActorsInvocation = syntaxRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddActors"); + + if (addActorsInvocation != null) + { + var document = project.GetDocument(addActorsInvocation.SyntaxTree); + return (document, addActorsInvocation); + } + } + + return (null, null); + } +} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.Designer.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.Designer.cs index af3dd797d..ac9c06d81 100644 --- a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.Designer.cs +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.Designer.cs @@ -76,5 +76,59 @@ internal static string DAPR4001Title { return ResourceManager.GetString("DAPR4001Title", resourceCulture); } } + + /// + /// Looks up a localized string similar to The actor type '{0}' is not registered with dependency injection. + /// + internal static string DAPR4002MessageFormat { + get { + return ResourceManager.GetString("DAPR4002MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The actor type is not registered with dependency injection. + /// + internal static string DAPR4002Title { + get { + return ResourceManager.GetString("DAPR4002Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set options.UseJsonSerialization to true to support interoperability with non-.NET actors. + /// + internal static string DAPR4003MessageFormat { + get { + return ResourceManager.GetString("DAPR4003MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Set options.UseJsonSerialization to true to support interoperability with non-.NET actors. + /// + internal static string DAPR4003Title { + get { + return ResourceManager.GetString("DAPR4003Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Call app.MapActorsHandlers to map endpoints for Dapr actors. + /// + internal static string DAPR4004MessageFormat { + get { + return ResourceManager.GetString("DAPR4004MessageFormat", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Call app.MapActorsHandlers to map endpoints for Dapr actors. + /// + internal static string DAPR4004Title { + get { + return ResourceManager.GetString("DAPR4004Title", resourceCulture); + } + } } } diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.resx b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.resx index 47f6c667e..93c313620 100644 --- a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.resx +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/Resources.resx @@ -24,4 +24,22 @@ Actor timer method invocations require the named callback '{0}' method to exist on type '{1}' + + The actor type is not registered with dependency injection + + + The actor type '{0}' is not registered with dependency injection + + + Set options.UseJsonSerialization to true to support interoperability with non-.NET actors + + + Set options.UseJsonSerialization to true to support interoperability with non-.NET actors + + + Call app.MapActorsHandlers to map endpoints for Dapr actors + + + Call app.MapActorsHandlers to map endpoints for Dapr actors + \ No newline at end of file diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/SharedUtilities.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/SharedUtilities.cs new file mode 100644 index 000000000..d411ec16c --- /dev/null +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/SharedUtilities.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dapr.Actors.Analyzers; + +internal class SharedUtilities +{ + internal static InvocationExpressionSyntax? FindInvocation(SyntaxNodeAnalysisContext context, string methodName) + { + foreach (var syntaxTree in context.SemanticModel.Compilation.SyntaxTrees) + { + var root = syntaxTree.GetRoot(); + var invocation = root.DescendantNodes().OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == methodName); + + if (invocation != null) + { + return invocation; + } + } + + return null; + } +} diff --git a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/TimerMethodPresentAnalyzer.cs b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/TimerCallbackMethodPresentAnalyzer.cs similarity index 94% rename from src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/TimerMethodPresentAnalyzer.cs rename to src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/TimerCallbackMethodPresentAnalyzer.cs index 59640fd58..49f03ece7 100644 --- a/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/TimerMethodPresentAnalyzer.cs +++ b/src/Dapr.Actors.Analyzers/Dapr.Actors.Analyzers/TimerCallbackMethodPresentAnalyzer.cs @@ -12,7 +12,6 @@ // ------------------------------------------------------------------------ using System.Collections.Immutable; -using System.Linq; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; @@ -25,12 +24,9 @@ namespace Dapr.Actors.Analyzers; /// as the callback should actually exist on the type. /// [DiagnosticAnalyzer(LanguageNames.CSharp)] -public sealed class TimerMethodPresentAnalyzer : DiagnosticAnalyzer +public sealed class TimerCallbackMethodPresentAnalyzer : DiagnosticAnalyzer { - /// - /// The rule validated by the analyzer. - /// - public static readonly DiagnosticDescriptor DaprTimerCallbackMethodRule = new( + internal static readonly DiagnosticDescriptor DaprTimerCallbackMethodRule = new( id: "DAPR4001", title: new LocalizableResourceString(nameof(Resources.DAPR4001Title), Resources.ResourceManager, typeof(Resources)), messageFormat: new LocalizableResourceString(nameof(Resources.DAPR4001MessageFormat), Resources.ResourceManager, typeof(Resources)), diff --git a/src/Dapr.Actors.Analyzers/MapActorsHandlersCodeFixProvider.cs b/src/Dapr.Actors.Analyzers/MapActorsHandlersCodeFixProvider.cs new file mode 100644 index 000000000..60dc7a4e8 --- /dev/null +++ b/src/Dapr.Actors.Analyzers/MapActorsHandlersCodeFixProvider.cs @@ -0,0 +1,125 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Dapr.Actors.Analyzers; + +/// +/// Provides a code fix for the diagnostic "DAPR0003" by adding a call to MapActorsHandlers. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ActorRegistrationCodeFixProvider))] +[Shared] +public class MapActorsHandlersCodeFixProvider : CodeFixProvider +{ + /// + /// Gets the diagnostic IDs that this code fix provider can fix. + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("DAPR0003"); + + /// + /// Gets the FixAllProvider for this code fix provider. + /// + /// The FixAllProvider. + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } + + /// + /// Registers code fixes for the specified diagnostic. + /// + /// A context for code fix registration. + /// A task that represents the asynchronous operation. + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + var title = "Call MapActorsHandlers"; + context.RegisterCodeFix( + CodeAction.Create( + title, + createChangedDocument: c => AddMapActorsHandlersAsync(context.Document, context.Diagnostics.First(), c), + equivalenceKey: title), + context.Diagnostics); + return Task.CompletedTask; + } + + /// + /// Adds a call to MapActorsHandlers to the specified document. + /// + /// The document to modify. + /// The diagnostic to fix. + /// A cancellation token. + /// A task that represents the asynchronous operation. The task result contains the modified document. + private async Task AddMapActorsHandlersAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken); + var invocationExpressions = root!.DescendantNodes().OfType(); + + var createBuilderInvocation = invocationExpressions + .FirstOrDefault(invocation => + { + return invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "CreateBuilder" && + memberAccess.Expression is IdentifierNameSyntax identifier && + identifier.Identifier.Text == "WebApplication"; + }); + + var variableDeclarator = createBuilderInvocation + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + var variableName = variableDeclarator.Identifier.Text; + + var buildInvocation = invocationExpressions + .FirstOrDefault(invocation => + { + return invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "Build" && + memberAccess.Expression is IdentifierNameSyntax identifier && + identifier.Identifier.Text == variableName; + }); + + var buildVariableDeclarator = buildInvocation + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + var buildVariableName = buildVariableDeclarator.Identifier.Text; + + var mapActorsHandlersInvocation = SyntaxFactory.ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName(buildVariableName), + SyntaxFactory.IdentifierName("MapActorsHandlers")))); + + if (buildInvocation.Ancestors().OfType().FirstOrDefault() is SyntaxNode parentBlock) + { + var localDeclaration = buildInvocation + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + var newParentBlock = parentBlock.InsertNodesAfter(localDeclaration, new[] { mapActorsHandlersInvocation }); + root = root.ReplaceNode(parentBlock, newParentBlock); + } + else + { + var buildInvocationGlobalStatement = buildInvocation + .AncestorsAndSelf() + .OfType() + .FirstOrDefault(); + + var compilationUnitSyntax = createBuilderInvocation.Ancestors().OfType().FirstOrDefault(); + var newCompilationUnitSyntax = compilationUnitSyntax.InsertNodesAfter(buildInvocationGlobalStatement, + new[] { SyntaxFactory.GlobalStatement(mapActorsHandlersInvocation) }); + root = root.ReplaceNode(compilationUnitSyntax, newCompilationUnitSyntax); + } + + return document.WithSyntaxRoot(root); + } +} diff --git a/test/Dapr.Actors.Analyzers.Test/ActorAnalyzerTests.cs b/test/Dapr.Actors.Analyzers.Test/ActorAnalyzerTests.cs new file mode 100644 index 000000000..8d6b9abed --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Test/ActorAnalyzerTests.cs @@ -0,0 +1,313 @@ +using Microsoft.CodeAnalysis; + +namespace Dapr.Actors.Analyzers.Test; + +public class ActorAnalyzerTests +{ + public class ActorNotRegistered + { + [Fact] + public async Task ReportDiagnostic_DAPR0001() + { + var testCode = @" + using Dapr.Actors.Runtime; + + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + "; + + var expected = VerifyAnalyzer.Diagnostic("DAPR0001", DiagnosticSeverity.Warning) + .WithSpan(4, 23, 4, 32).WithMessage("The actor class 'TestActor' is not registered"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task ReportDiagnostic_DAPR0001_FullyQualified() + { + var testCode = @" + class TestActor : Dapr.Actors.Runtime.Actor + { + public TestActor(Dapr.Actors.Runtime.ActorHost host) : base(host) + { + } + } + "; + + var expected = VerifyAnalyzer.Diagnostic("DAPR0001", DiagnosticSeverity.Warning) + .WithSpan(2, 23, 2, 32).WithMessage("The actor class 'TestActor' is not registered"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task ReportDiagnostic_DAPR0001_NamespaceAlias() + { + var testCode = @" + using alias = Dapr.Actors.Runtime; + + class TestActor : alias.Actor + { + public TestActor(alias.ActorHost host) : base(host) + { + } + } + "; + + var expected = VerifyAnalyzer.Diagnostic("DAPR0001", DiagnosticSeverity.Warning) + .WithSpan(4, 23, 4, 32).WithMessage("The actor class 'TestActor' is not registered"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + } + + public class ActorRegistered + { + [Fact] + public async Task ReportNoDiagnostic() + { + var testCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + "; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task ReportNoDiagnostic_WithNamespace() + { + var testCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + + namespace TestNamespace + { + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + } + "; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task ReportNoDiagnostic_WithNamespaceAlias () + { + var testCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + using alias = TestNamespace; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + + namespace TestNamespace + { + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + } + "; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode); + } + } + + public class JsonSerialization + { + [Fact] + public async Task ReportDiagnostic() + { + var testCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + "; + + var expected = VerifyAnalyzer.Diagnostic("DAPR0002", DiagnosticSeverity.Warning) + .WithSpan(12, 25, 14, 27).WithMessage("Add options.UseJsonSerialization to support interoperability with non-.NET actors"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task ReportNoDiagnostic() + { + var testCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + "; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode); + } + } + + public class MapActorsHandlers + { + [Fact] + public async Task ReportDiagnostic() + { + var testCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + } + } + "; + + var expected = VerifyAnalyzer.Diagnostic("DAPR0003", DiagnosticSeverity.Warning) + .WithSpan(12, 25, 15, 27).WithMessage("Call app.MapActorsHandlers to map endpoints for Dapr actors"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task ReportNoDiagnostic() + { + var testCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + "; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode); + } + } +} diff --git a/test/Dapr.Actors.Analyzers.Test/ActorJsonSerializationCodeFixProviderTests.cs b/test/Dapr.Actors.Analyzers.Test/ActorJsonSerializationCodeFixProviderTests.cs new file mode 100644 index 000000000..7d3fe6cad --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Test/ActorJsonSerializationCodeFixProviderTests.cs @@ -0,0 +1,55 @@ +namespace Dapr.Actors.Analyzers.Test; + +public class ActorJsonSerializationCodeFixProviderTests +{ + [Fact] + public async Task UseJsonSerialization() + { + var code = @" + //using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + "; + + var expectedChangedCode = @" + //using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + "; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } +} diff --git a/test/Dapr.Actors.Analyzers.Test/ActorRegistrationCodeFixProviderTests.cs b/test/Dapr.Actors.Analyzers.Test/ActorRegistrationCodeFixProviderTests.cs new file mode 100644 index 000000000..d148ed90b --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Test/ActorRegistrationCodeFixProviderTests.cs @@ -0,0 +1,175 @@ +namespace Dapr.Actors.Analyzers.Test; + +public class ActorRegistrationCodeFixProviderTests +{ + [Fact] + public async Task RegisterActor() + { + var code = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + "; + + var expectedChangedCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + options.Actors.RegisterActor(); + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + "; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } + + [Fact] + public async Task RegisterActor_WhenAddActorsIsNotFound() + { + var code = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + var app = builder.Build(); + } + } + + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + "; + + var expectedChangedCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + + var app = builder.Build(); + } + } + + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + "; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } + + [Fact] + public async Task RegisterActor_WhenAddActorsIsNotFound_TopLevelStatements() + { + var code = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + + var builder = WebApplication.CreateBuilder(); + + var app = builder.Build(); + + namespace TestNamespace + { + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + } + "; + + var expectedChangedCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + + var app = builder.Build(); + + namespace TestNamespace + { + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + } + "; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } +} diff --git a/test/Dapr.Actors.Analyzers.Test/Dapr.Actors.Analyzers.Test.csproj b/test/Dapr.Actors.Analyzers.Test/Dapr.Actors.Analyzers.Test.csproj new file mode 100644 index 000000000..934fcbfdd --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Test/Dapr.Actors.Analyzers.Test.csproj @@ -0,0 +1,35 @@ + + + + enable + enable + false + true + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/test/Dapr.Actors.Analyzers.Test/MapActorsHandlersCodeFixProviderTests.cs b/test/Dapr.Actors.Analyzers.Test/MapActorsHandlersCodeFixProviderTests.cs new file mode 100644 index 000000000..8d0f58350 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Test/MapActorsHandlersCodeFixProviderTests.cs @@ -0,0 +1,124 @@ +namespace Dapr.Actors.Analyzers.Test; + +public class MapActorsHandlersCodeFixProviderTests +{ + [Fact] + public async Task RegisterActor() + { + var code = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + } + } + + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + "; + + var expectedChangedCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + } + } + + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + "; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } + + [Fact] + public async Task RegisterActor_TopLevelStatements() + { + var code = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + "; + + var expectedChangedCode = @" + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + + var app = builder.Build(); + + app.MapActorsHandlers(); + + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + "; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } +} diff --git a/test/Dapr.Actors.Analyzers.Test/Utilities.cs b/test/Dapr.Actors.Analyzers.Test/Utilities.cs new file mode 100644 index 000000000..4b6beed0a --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Test/Utilities.cs @@ -0,0 +1,77 @@ +using System.Collections.Immutable; +using System.Reflection; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.CodeAnalysis.Testing; + +namespace Dapr.Actors.Analyzers.Test; + +internal static class Utilities +{ + public static async Task<(ImmutableArray diagnostics, Document document, Workspace workspace)> GetDiagnosticsAdvanced(string code) + { + var workspace = new AdhocWorkspace(); + +#if NET6_0 + var referenceAssemblies = ReferenceAssemblies.Net.Net60; +#elif NET7_0 + var referenceAssemblies = ReferenceAssemblies.Net.Net70; +#elif NET8_0 + var referenceAssemblies = ReferenceAssemblies.Net.Net80; +#elif NET9_0 + var referenceAssemblies = ReferenceAssemblies.Net.Net90; +#endif + + // Create a new project with necessary references + var project = workspace.AddProject("TestProject", LanguageNames.CSharp) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.ConsoleApplication) + .WithSpecificDiagnosticOptions(new Dictionary + { + { "CS1701", ReportDiagnostic.Suppress } + })) + .AddMetadataReferences(await referenceAssemblies.ResolveAsync(LanguageNames.CSharp, default)) + .AddMetadataReference(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .AddMetadataReferences(GetAllReferencesNeededForType(typeof(WebApplication))) + .AddMetadataReferences(GetAllReferencesNeededForType(typeof(IHost))) + .AddMetadataReferences(GetAllReferencesNeededForType(typeof(ActorsEndpointRouteBuilderExtensions))) + .AddMetadataReferences(GetAllReferencesNeededForType(typeof(ActorsServiceCollectionExtensions))); + + // Add the document to the project + var document = project.AddDocument("TestDocument.cs", code); + + // Get the syntax tree and create a compilation + var syntaxTree = await document.GetSyntaxTreeAsync() ?? throw new InvalidOperationException("Syntax tree is null"); + var compilation = CSharpCompilation.Create("TestCompilation") + .AddSyntaxTrees(syntaxTree) + .AddReferences(project.MetadataReferences) + .WithOptions(project.CompilationOptions!); + + var compilationWithAnalyzer = compilation.WithAnalyzers( + ImmutableArray.Create( + new ActorAnalyzer())); + + // Get diagnostics from the compilation + var diagnostics = await compilationWithAnalyzer.GetAllDiagnosticsAsync(); + return (diagnostics, document, workspace); + } + + public static MetadataReference[] GetAllReferencesNeededForType(Type type) + { + var files = GetAllAssemblyFilesNeededForType(type); + + return files.Select(x => MetadataReference.CreateFromFile(x)).Cast().ToArray(); + } + + private static ImmutableArray GetAllAssemblyFilesNeededForType(Type type) + { + return type.Assembly.GetReferencedAssemblies() + .Select(x => Assembly.Load(x.FullName)) + .Append(type.Assembly) + .Select(x => x.Location) + .ToImmutableArray(); + } +} diff --git a/test/Dapr.Actors.Analyzers.Test/VerifyAnalyzer.cs b/test/Dapr.Actors.Analyzers.Test/VerifyAnalyzer.cs new file mode 100644 index 000000000..d114caedf --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Test/VerifyAnalyzer.cs @@ -0,0 +1,68 @@ +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; + +namespace Dapr.Actors.Analyzers.Test; + +internal static class VerifyAnalyzer +{ + public static DiagnosticResult Diagnostic(string diagnosticId, DiagnosticSeverity diagnosticSeverity) + { + return new DiagnosticResult(diagnosticId, diagnosticSeverity); + } + + public static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + { + await VerifyAnalyzerAsync(source, null, expected); + } + + public static async Task VerifyAnalyzerAsync(string source, string? program, params DiagnosticResult[] expected) + { + var test = new Test { TestCode = source }; + +#if NET6_0 + test.ReferenceAssemblies = ReferenceAssemblies.Net.Net60; +#elif NET7_0 + test.ReferenceAssemblies = ReferenceAssemblies.Net.Net70; +#elif NET8_0 + test.ReferenceAssemblies = ReferenceAssemblies.Net.Net80; +#elif NET9_0 + test.ReferenceAssemblies = ReferenceAssemblies.Net.Net90; +#endif + + if (program != null) + { + test.TestState.Sources.Add(("Program.cs", program)); + } + + var metadataReferences = Utilities.GetAllReferencesNeededForType(typeof(ActorAnalyzer)).ToList(); + metadataReferences.AddRange(Utilities.GetAllReferencesNeededForType(typeof(ActorsServiceCollectionExtensions))); + metadataReferences.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); + metadataReferences.Add(MetadataReference.CreateFromFile(typeof(WebApplication).Assembly.Location)); + metadataReferences.Add(MetadataReference.CreateFromFile(typeof(IHost).Assembly.Location)); + + foreach (var reference in metadataReferences) + { + test.TestState.AdditionalReferences.Add(reference); + } + + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(CancellationToken.None); + } + + private class Test : CSharpAnalyzerTest + { + public Test() + { + SolutionTransforms.Add((solution, projectId) => + { + var compilationOptions = solution.GetProject(projectId)!.CompilationOptions!; + solution = solution.WithProjectCompilationOptions(projectId, compilationOptions); + return solution; + }); + } + } +} diff --git a/test/Dapr.Actors.Analyzers.Test/VerifyCodeFix.cs b/test/Dapr.Actors.Analyzers.Test/VerifyCodeFix.cs new file mode 100644 index 000000000..f7d0d7eb4 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Test/VerifyCodeFix.cs @@ -0,0 +1,56 @@ +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace Dapr.Actors.Analyzers.Test; + +internal class VerifyCodeFix +{ + public static async Task RunTest(string code, string expectedChangedCode) where T : CodeFixProvider, new() + { + var (diagnostics, document, workspace) = await Utilities.GetDiagnosticsAdvanced(code); + + Assert.Single(diagnostics); + + var diagnostic = diagnostics[0]; + + var codeFixProvider = new T(); + + CodeAction? registeredCodeAction = null; + + var context = new CodeFixContext(document, diagnostic, (codeAction, _) => + { + if (registeredCodeAction != null) + throw new Exception("Code action was registered more than once"); + + registeredCodeAction = codeAction; + + }, CancellationToken.None); + + await codeFixProvider.RegisterCodeFixesAsync(context); + + if (registeredCodeAction == null) + throw new Exception("Code action was not registered"); + + var operations = await registeredCodeAction.GetOperationsAsync(CancellationToken.None); + + foreach (var operation in operations) + { + operation.Apply(workspace, CancellationToken.None); + } + + var updatedDocument = workspace.CurrentSolution.GetDocument(document.Id) ?? throw new Exception("Updated document is null"); + var newCode = (await updatedDocument.GetTextAsync()).ToString(); + + // Normalize whitespace + string NormalizeWhitespace(string input) + { + var separator = new[] { ' ', '\r', '\n' }; + return string.Join(" ", input.Split(separator, StringSplitOptions.RemoveEmptyEntries)); + } + + var normalizedExpectedCode = NormalizeWhitespace(expectedChangedCode); + var normalizedNewCode = NormalizeWhitespace(newCode); + + Assert.Equal(normalizedExpectedCode, normalizedNewCode); + } +} diff --git a/test/Dapr.Actors.Analyzers.Tests/ActorRegistrationAnalyzerTests.cs b/test/Dapr.Actors.Analyzers.Tests/ActorRegistrationAnalyzerTests.cs new file mode 100644 index 000000000..63a775b15 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/ActorRegistrationAnalyzerTests.cs @@ -0,0 +1,180 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Actors.Analyzers.Tests; + +public class ActorRegistrationAnalyzerTests +{ + [Fact] + public async Task NotRegistered_ReportDiagnostic_DAPR4002() + { + const string testCode = """ + using Dapr.Actors.Runtime; + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(ActorRegistrationAnalyzer.DiagnosticDescriptorActorRegistration) + .WithSpan(2, 7, 2, 16).WithMessage("The actor type 'TestActor' is not registered with dependency injection"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task NotRegistered_ReportDiagnostic_DAPR4002_FullyQualified() + { + const string testCode = """ + class TestActor : Dapr.Actors.Runtime.Actor + { + public TestActor(Dapr.Actors.Runtime.ActorHost host) : base(host) + { + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(ActorRegistrationAnalyzer.DiagnosticDescriptorActorRegistration) + .WithSpan(1, 7, 1, 16).WithMessage("The actor type 'TestActor' is not registered with dependency injection"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + + [Fact] + public async Task NotRegistered_ReportDiagnostic_DAPR4002_NamespaceAlias() + { + const string testCode = """ + using alias = Dapr.Actors.Runtime; + class TestActor : alias.Actor + { + public TestActor(alias.ActorHost host) : base(host) + { + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(ActorRegistrationAnalyzer.DiagnosticDescriptorActorRegistration) + .WithSpan(2, 15, 2, 24).WithMessage("The actor type 'TestActor' is not registered with dependency injection"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task Registered_ReportNoDiagnostic() + { + const string testCode = """ + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task Registered_ReportNoDiagnostic_WithNamespace() + { + const string testCode = """ + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + namespace TestNamespace + { + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode); + } + + [Fact] + public async Task Registered_ReportNoDiagnostic_WithNamespaceAlias() + { + const string testCode = """ + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + using alias = TestNamespace; + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + namespace TestNamespace + { + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode); + } +} diff --git a/test/Dapr.Actors.Analyzers.Tests/ActorRegistrationCodeFixProviderTests.cs b/test/Dapr.Actors.Analyzers.Tests/ActorRegistrationCodeFixProviderTests.cs new file mode 100644 index 000000000..9ae4afc68 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/ActorRegistrationCodeFixProviderTests.cs @@ -0,0 +1,186 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Actors.Analyzers.Tests; + +public class ActorRegistrationCodeFixProviderTests +{ + [Fact] + public async Task RegisterActor() + { + const string code = """ + + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + + """; + + const string expectedChangedCode = """ + + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + options.Actors.RegisterActor(); + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + + """; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } + + [Fact] + public async Task RegisterActor_WhenAddActorsIsNotFound() + { + const string code = """ + + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + } + } + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + + """; + + const string expectedChangedCode = """ + + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + var app = builder.Build(); + } + } + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + + """; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } + + [Fact] + public async Task RegisterActor_WhenAddActorsIsNotFound_TopLevelStatements() + { + const string code = """ + + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + var builder = WebApplication.CreateBuilder(); + + var app = builder.Build(); + namespace TestNamespace + { + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + } + + """; + + const string expectedChangedCode = """ + + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + }); + var app = builder.Build(); + namespace TestNamespace + { + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + } + + """; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } +} diff --git a/test/Dapr.Actors.Analyzers.Tests/Dapr.Actors.Analyzers.Tests.csproj b/test/Dapr.Actors.Analyzers.Tests/Dapr.Actors.Analyzers.Tests.csproj index d801c2dfd..482379a80 100644 --- a/test/Dapr.Actors.Analyzers.Tests/Dapr.Actors.Analyzers.Tests.csproj +++ b/test/Dapr.Actors.Analyzers.Tests/Dapr.Actors.Analyzers.Tests.csproj @@ -9,8 +9,11 @@ + + + @@ -29,7 +32,7 @@ - + diff --git a/test/Dapr.Actors.Analyzers.Tests/MappedActorHandlersAnalyzerTests.cs b/test/Dapr.Actors.Analyzers.Tests/MappedActorHandlersAnalyzerTests.cs new file mode 100644 index 000000000..ed649f986 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/MappedActorHandlersAnalyzerTests.cs @@ -0,0 +1,71 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Actors.Analyzers.Tests; + +public class MappedActorHandlersAnalyzerTests +{ + [Fact] + public async Task ReportDiagnostic() + { + const string testCode = """ + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + } + } + """; + + var expected = VerifyAnalyzer.Diagnostic(MappedActorHandlersAnalyzer.DiagnosticDescriptorMapActorsHandlers) + .WithSpan(10, 25, 13, 27).WithMessage("Call app.MapActorsHandlers to map endpoints for Dapr actors"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task ReportNoDiagnostic() + { + const string testCode = """ + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode); + } +} diff --git a/test/Dapr.Actors.Analyzers.Tests/MappedActorHandlersCodeFixProviderTests.cs b/test/Dapr.Actors.Analyzers.Tests/MappedActorHandlersCodeFixProviderTests.cs new file mode 100644 index 000000000..7c66a2656 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/MappedActorHandlersCodeFixProviderTests.cs @@ -0,0 +1,131 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Actors.Analyzers.Tests; + +public class MappedActorHandlersCodeFixProviderTests +{ + [Fact] + public async Task RegisterActor() + { + const string code = """ + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + } + } + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + """; + + const string expectedChangedCode = """ + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + """; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } + + [Fact] + public async Task RegisterActor_TopLevelStatements() + { + const string code = """ + + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + + """; + + const string expectedChangedCode = """ + + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.Actors.RegisterActor(); + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + app.MapActorsHandlers(); + class TestActor : Actor + { + public TestActor(ActorHost host) : base(host) + { + } + } + + """; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } +} diff --git a/test/Dapr.Actors.Analyzers.Tests/PreferActorJsonSerializationAnalyzerTests.cs b/test/Dapr.Actors.Analyzers.Tests/PreferActorJsonSerializationAnalyzerTests.cs new file mode 100644 index 000000000..2fd8f5e77 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/PreferActorJsonSerializationAnalyzerTests.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Actors.Analyzers.Tests; + +public class PreferActorJsonSerializationAnalyzerTests +{ + [Fact] + public async Task ReportDiagnostic() + { + const string testCode = """ + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + """; + + var expected = VerifyAnalyzer + .Diagnostic(PreferActorJsonSerializationAnalyzer.DiagnosticDescriptorJsonSerialization) + .WithSpan(10, 25, 12, 27) + .WithMessage("Set options.UseJsonSerialization to true to support interoperability with non-.NET actors"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task ReportNoDiagnostic() + { + const string testCode = """ + using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + """; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode); + } +} diff --git a/test/Dapr.Actors.Analyzers.Tests/PreferActorJsonSerializationCodeFixProviderTests.cs b/test/Dapr.Actors.Analyzers.Tests/PreferActorJsonSerializationCodeFixProviderTests.cs new file mode 100644 index 000000000..0a7288c24 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/PreferActorJsonSerializationCodeFixProviderTests.cs @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Actors.Analyzers.Tests; + +public class PreferActorJsonSerializationCodeFixProviderTests +{ + [Fact] + public async Task UseJsonSerialization() + { + const string code = """ + //using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + + builder.Services.AddActors(options => + { + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + """; + + const string expectedChangedCode = """ + //using Dapr.Actors.Runtime; + using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.DependencyInjection; + public static class Program + { + public static void Main() + { + var builder = WebApplication.CreateBuilder(); + builder.Services.AddActors(options => + { + options.UseJsonSerialization = true; + }); + var app = builder.Build(); + app.MapActorsHandlers(); + } + } + """; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } +} diff --git a/test/Dapr.Actors.Analyzers.Tests/TestUtilities.cs b/test/Dapr.Actors.Analyzers.Tests/TestUtilities.cs new file mode 100644 index 000000000..acfec6baa --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/TestUtilities.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Collections.Immutable; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Dapr.Actors.Analyzers.Tests; + +internal static class TestUtilities +{ + public static async Task<(ImmutableArray diagnostics, Document document, Workspace workspace)> GetDiagnosticsAdvanced(string code) + { + var workspace = new AdhocWorkspace(); + +#if NET8_0 + var referenceAssemblies = ReferenceAssemblies.Net.Net80; +#elif NET9_0 + var referenceAssemblies = ReferenceAssemblies.Net.Net90; +#endif + + // Create a new project with necessary references + var project = workspace.AddProject("TestProject", LanguageNames.CSharp) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.ConsoleApplication) + .WithSpecificDiagnosticOptions(new Dictionary + { + { "CS1701", ReportDiagnostic.Suppress } + })) + .AddMetadataReferences(await referenceAssemblies.ResolveAsync(LanguageNames.CSharp, default)) + .AddMetadataReference(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .AddMetadataReferences(GetAllReferencesNeededForType(typeof(WebApplication))) + .AddMetadataReferences(GetAllReferencesNeededForType(typeof(IHost))) + .AddMetadataReferences(GetAllReferencesNeededForType(typeof(ActorsEndpointRouteBuilderExtensions))) + .AddMetadataReferences(GetAllReferencesNeededForType(typeof(ActorsServiceCollectionExtensions))); + + // Add the document to the project + var document = project.AddDocument("TestDocument.cs", code); + + // Get the syntax tree and create a compilation + var syntaxTree = await document.GetSyntaxTreeAsync() ?? throw new InvalidOperationException("Syntax tree is null"); + var compilation = CSharpCompilation.Create("TestCompilation") + .AddSyntaxTrees(syntaxTree) + .AddReferences(project.MetadataReferences) + .WithOptions(project.CompilationOptions!); + + var compilationWithAnalyzer = compilation.WithAnalyzers( + [ + new ActorRegistrationAnalyzer(), + new MappedActorHandlersAnalyzer(), + new PreferActorJsonSerializationAnalyzer(), + new TimerCallbackMethodPresentAnalyzer() + ]); + + // Get diagnostics from the compilation + var diagnostics = await compilationWithAnalyzer.GetAllDiagnosticsAsync(); + return (diagnostics, document, workspace); + } + + public static MetadataReference[] GetAllReferencesNeededForType(Type type) + { + var files = GetAllAssemblyFilesNeededForType(type); + + return files.Select(x => MetadataReference.CreateFromFile(x)).Cast().ToArray(); + } + + private static ImmutableArray GetAllAssemblyFilesNeededForType(Type type) => type.Assembly + .GetReferencedAssemblies() + .Select(x => Assembly.Load(x.FullName)) + .Append(type.Assembly) + .Select(x => x.Location) + .ToImmutableArray(); +} diff --git a/test/Dapr.Actors.Analyzers.Tests/TimerMethodPresentAnalyzerTests.cs b/test/Dapr.Actors.Analyzers.Tests/TimerCallbackMethodPresentAnalyzerTests.cs similarity index 91% rename from test/Dapr.Actors.Analyzers.Tests/TimerMethodPresentAnalyzerTests.cs rename to test/Dapr.Actors.Analyzers.Tests/TimerCallbackMethodPresentAnalyzerTests.cs index bdcf0e469..e3fd47f31 100644 --- a/test/Dapr.Actors.Analyzers.Tests/TimerMethodPresentAnalyzerTests.cs +++ b/test/Dapr.Actors.Analyzers.Tests/TimerCallbackMethodPresentAnalyzerTests.cs @@ -11,14 +11,13 @@ // limitations under the License. // ------------------------------------------------------------------------ -using System.Threading.Tasks; using Microsoft.CodeAnalysis.CSharp.Testing; using Microsoft.CodeAnalysis.Testing; #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member namespace Dapr.Actors.Analyzers.Tests; -public class TimerMethodPresentAnalyzerTests +public class TimerCallbackMethodPresentAnalyzerTests { #if NET8_0 private static readonly ReferenceAssemblies assemblies = ReferenceAssemblies.Net.Net80; @@ -29,7 +28,7 @@ public class TimerMethodPresentAnalyzerTests [Fact] public async Task TestActor_TimerRegistration_NotPresent() { - var context = new CSharpAnalyzerTest(); + var context = new CSharpAnalyzerTest(); context.ReferenceAssemblies = assemblies.AddPackages([ new ("Dapr.Actors", "1.15.3") ]); @@ -54,7 +53,7 @@ public async Task DoSomethingAsync() [Fact] public async Task TestActor_TimerRegistration_NameOfCallbackPresent() { - var context = new CSharpAnalyzerTest(); + var context = new CSharpAnalyzerTest(); context.ReferenceAssemblies = assemblies.AddPackages([ new ("Dapr.Actors", "1.15.3") ]); @@ -85,7 +84,7 @@ private static async Task TimerCallback(byte[] data) [Fact] public async Task TestActor_TimerRegistration_LiteralCallbackPresent() { - var context = new CSharpAnalyzerTest(); + var context = new CSharpAnalyzerTest(); context.ReferenceAssemblies = assemblies.AddPackages([ new ("Dapr.Actors", "1.15.3") ]); @@ -116,7 +115,7 @@ private static async Task TimerCallback(byte[] data) [Fact] public async Task TestActor_TimerRegistration_CallbackNotPresent() { - var context = new CSharpAnalyzerTest(); + var context = new CSharpAnalyzerTest(); context.ReferenceAssemblies = assemblies.AddPackages([ new ("Dapr.Actors", "1.15.3") ]); @@ -134,7 +133,7 @@ public async Task DoSomethingAsync() } """; - context.ExpectedDiagnostics.Add(new DiagnosticResult(TimerMethodPresentAnalyzer.DaprTimerCallbackMethodRule) + context.ExpectedDiagnostics.Add(new DiagnosticResult(TimerCallbackMethodPresentAnalyzer.DaprTimerCallbackMethodRule) .WithSpan(8, 45, 8, 60) .WithArguments("TimerCallback", "TestActorTimerRegistrationTimerCallbackNotPresent")); await context.RunAsync(); diff --git a/test/Dapr.Actors.Analyzers.Tests/VerifyAnalyzer.cs b/test/Dapr.Actors.Analyzers.Tests/VerifyAnalyzer.cs new file mode 100644 index 000000000..87136e411 --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/VerifyAnalyzer.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Dapr.Actors.Analyzers.Tests; + +internal static class VerifyAnalyzer +{ + public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) => new(descriptor); + + public static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected) + where TAnalyzer : DiagnosticAnalyzer, new() + { + await VerifyAnalyzerAsync(source, null, expected); + } + + public static async Task VerifyAnalyzerAsync(string source, string? program, params DiagnosticResult[] expected) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var test = new Test { TestCode = source }; + +#if NET8_0 + test.ReferenceAssemblies = ReferenceAssemblies.Net.Net80; +#elif NET9_0 + test.ReferenceAssemblies = ReferenceAssemblies.Net.Net90; +#endif + + if (program != null) + { + test.TestState.Sources.Add(("Program.cs", program)); + } + + var metadataReferences = TestUtilities.GetAllReferencesNeededForType(typeof(ActorRegistrationAnalyzer)).ToList(); + metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(MappedActorHandlersAnalyzer))); + metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(PreferActorJsonSerializationAnalyzer))); + metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(TimerCallbackMethodPresentAnalyzer))); + metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(ActorsServiceCollectionExtensions))); + metadataReferences.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); + metadataReferences.Add(MetadataReference.CreateFromFile(typeof(WebApplication).Assembly.Location)); + metadataReferences.Add(MetadataReference.CreateFromFile(typeof(IHost).Assembly.Location)); + + foreach (var reference in metadataReferences) + { + test.TestState.AdditionalReferences.Add(reference); + } + + test.ExpectedDiagnostics.AddRange(expected); + await test.RunAsync(CancellationToken.None); + } + + + + private sealed class Test : CSharpAnalyzerTest + where TAnalyzer : DiagnosticAnalyzer, new() + { + public Test() + { + SolutionTransforms.Add((solution, projectId) => + { + var compilationOptions = solution.GetProject(projectId)!.CompilationOptions!; + solution = solution.WithProjectCompilationOptions(projectId, compilationOptions); + return solution; + }); + } + } +} diff --git a/test/Dapr.Actors.Analyzers.Tests/VerifyCodeFix.cs b/test/Dapr.Actors.Analyzers.Tests/VerifyCodeFix.cs new file mode 100644 index 000000000..d09e427af --- /dev/null +++ b/test/Dapr.Actors.Analyzers.Tests/VerifyCodeFix.cs @@ -0,0 +1,70 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace Dapr.Actors.Analyzers.Tests; + +internal static class VerifyCodeFix +{ + public static async Task RunTest(string code, string expectedChangedCode) where T : CodeFixProvider, new() + { + var (diagnostics, document, workspace) = await TestUtilities.GetDiagnosticsAdvanced(code); + + Assert.Single(diagnostics); + + var diagnostic = diagnostics[0]; + + var codeFixProvider = new T(); + + CodeAction? registeredCodeAction = null; + + var context = new CodeFixContext(document, diagnostic, (codeAction, _) => + { + if (registeredCodeAction != null) + throw new Exception("Code action was registered more than once"); + + registeredCodeAction = codeAction; + + }, CancellationToken.None); + + await codeFixProvider.RegisterCodeFixesAsync(context); + + if (registeredCodeAction == null) + throw new Exception("Code action was not registered"); + + var operations = await registeredCodeAction.GetOperationsAsync(CancellationToken.None); + + foreach (var operation in operations) + { + operation.Apply(workspace, CancellationToken.None); + } + + var updatedDocument = workspace.CurrentSolution.GetDocument(document.Id) ?? throw new Exception("Updated document is null"); + var newCode = (await updatedDocument.GetTextAsync()).ToString(); + + var normalizedExpectedCode = NormalizeWhitespace(expectedChangedCode); + var normalizedNewCode = NormalizeWhitespace(newCode); + + Assert.Equal(normalizedExpectedCode, normalizedNewCode); + return; + + // Normalize whitespace + string NormalizeWhitespace(string input) + { + var separator = new[] { ' ', '\r', '\n' }; + return string.Join(" ", input.Split(separator, StringSplitOptions.RemoveEmptyEntries)); + } + } +}