diff --git a/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Shipped.md b/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Shipped.md index a69e0089..8a4a7a2b 100644 --- a/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/Dapr.Workflow.Analyzers/AnalyzerReleases.Shipped.md @@ -4,4 +4,5 @@ Rule ID | Category | Severity | Notes --------|----------|----------|-------------------- -DAPR1001| Usage | Warning | The class '{0}' is not registered \ No newline at end of file +DAPR1001| Usage | Warning | The workflow class '{0}' is not registered +DAPR1002| Usage | Warning | The workflow activity class '{0}' is not registered \ No newline at end of file diff --git a/src/Dapr.Workflow.Analyzers/WorkflowActivityAnalyzer.cs b/src/Dapr.Workflow.Analyzers/WorkflowActivityAnalyzer.cs deleted file mode 100644 index 1ee6b556..00000000 --- a/src/Dapr.Workflow.Analyzers/WorkflowActivityAnalyzer.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis; -using System.Collections.Immutable; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace Dapr.Workflow.Analyzers; - -/// -/// Analyzes whether or not workflow activities are registered. -/// -[DiagnosticAnalyzer(LanguageNames.CSharp)] -public class WorkflowActivityAnalyzer : DiagnosticAnalyzer -{ - private static readonly DiagnosticDescriptor DiagnosticDescriptor = new( - "DAPR1001", - "Class not registered in DI", - "The class '{0}' is not registered in the DI container", - "Usage", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - - - /// - /// Gets the supported diagnostics for this analyzer. - /// - public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptor); - - /// - /// Initializes the analyzer. - /// - /// The analysis context. - public override void Initialize(AnalysisContext context) - { - context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); - context.EnableConcurrentExecution(); - context.RegisterSyntaxNodeAction(AnalyzeSyntaxNode, SyntaxKind.InvocationExpression); - } - - private void AnalyzeSyntaxNode(SyntaxNodeAnalysisContext context) - { - var invocationExpr = (InvocationExpressionSyntax)context.Node; - - if (invocationExpr.Expression is not MemberAccessExpressionSyntax memberAccessExpr) - return; - - if (memberAccessExpr.Name.Identifier.Text != "CallActivityAsync") - return; - - var argumentList = invocationExpr.ArgumentList.Arguments; - if (argumentList.Count == 0) - return; - - var firstArgument = argumentList[0].Expression; - if (firstArgument is InvocationExpressionSyntax nameofInvocation) - { - var activityName = nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString().Trim('"'); - if (activityName != null) - { - bool isRegistered = CheckIfActivityIsRegistered(activityName, context.SemanticModel); - if (!isRegistered) - { - var diagnostic = Diagnostic.Create(DiagnosticDescriptor, firstArgument.GetLocation(), activityName); - context.ReportDiagnostic(diagnostic); - } - } - } - } - - private static bool CheckIfActivityIsRegistered(string activityName, 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 == "RegisterActivity") - { - if (memberAccess.Name is GenericNameSyntax typeArgumentList && typeArgumentList.TypeArgumentList.Arguments.Count > 0) - { - if (typeArgumentList.TypeArgumentList.Arguments[0] is IdentifierNameSyntax typeArgument) - { - if (string.Equals(typeArgument.Identifier.Text, activityName)) - { - return true; - } - } - } - } - } - - return false; - } -} diff --git a/src/Dapr.Workflow.Analyzers/WorkflowActivityRegistrationCodeFixProvider.cs b/src/Dapr.Workflow.Analyzers/WorkflowActivityRegistrationCodeFixProvider.cs new file mode 100644 index 00000000..91158bb6 --- /dev/null +++ b/src/Dapr.Workflow.Analyzers/WorkflowActivityRegistrationCodeFixProvider.cs @@ -0,0 +1,124 @@ +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; +using Microsoft.CodeAnalysis.Formatting; + +namespace Dapr.Workflow.Analyzers; + +/// +/// Provides code fixes for DAPR1002 diagnostic. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WorkflowActivityRegistrationCodeFixProvider))] +[Shared] +public class WorkflowActivityRegistrationCodeFixProvider : CodeFixProvider +{ + /// + /// Gets the diagnostic IDs that this provider can fix. + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("DAPR1002"); + + /// + /// Registers the code fix for the diagnostic. + /// + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + var title = "Register workflow activity"; + context.RegisterCodeFix( + CodeAction.Create( + title, + createChangedDocument: c => RegisterWorkflowActivityAsync(context.Document, context.Diagnostics.First(), c), + equivalenceKey: title), + context.Diagnostics); + return Task.CompletedTask; + } + + private async Task RegisterWorkflowActivityAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + var oldInvocation = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().First(); + + if (oldInvocation is null) + return document; + + if (root == null || oldInvocation == null) + return document; + + // Extract the workflow activity type name + var workflowActivityType = oldInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString(); + + if (string.IsNullOrEmpty(workflowActivityType)) + return document; + + // Get the compilation + var compilation = await document.Project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); + + if (compilation == null) + return document; + + InvocationExpressionSyntax? addDaprWorkflowInvocation = null; + SyntaxNode? targetRoot = null; + Document? targetDocument = null; + + // Iterate through all syntax trees in the compilation + foreach (var syntaxTree in compilation.SyntaxTrees) + { + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + addDaprWorkflowInvocation = syntaxRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddDaprWorkflow"); + + if (addDaprWorkflowInvocation != null) + { + targetRoot = syntaxRoot; + targetDocument = document.Project.GetDocument(syntaxTree); + break; + } + } + + if (addDaprWorkflowInvocation == null || targetRoot == null || targetDocument == null) + return document; + + // Find the options lambda block + var optionsLambda = addDaprWorkflowInvocation.ArgumentList.Arguments + .Select(arg => arg.Expression) + .OfType() + .FirstOrDefault(); + + // 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}.RegisterActivity<{workflowActivityType}>();"); + + if (optionsLambda == null || optionsLambda.Body is not BlockSyntax optionsBlock) + return document; + + // 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 instance. + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } +} diff --git a/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs new file mode 100644 index 00000000..f335d717 --- /dev/null +++ b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs @@ -0,0 +1,178 @@ +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Dapr.Workflow.Analyzers; + +/// +/// Analyzes whether or not workflow activities are registered. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class WorkflowRegistrationAnalyzer : DiagnosticAnalyzer +{ + private static readonly DiagnosticDescriptor WorkflowDiagnosticDescriptor = new( + "DAPR1001", + "Workflow not registered", + "The workflow class '{0}' is not registered", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor WorkflowActivityDiagnosticDescriptor = new( + "DAPR1002", + "Workflow activity not registered", + "The workflow activity class '{0}' is not registered", + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + + /// + /// Gets the supported diagnostics for this analyzer. + /// + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(WorkflowDiagnosticDescriptor, WorkflowActivityDiagnosticDescriptor); + + /// + /// Initializes the analyzer. + /// + /// The analysis context. + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeWorkflowRegistration, SyntaxKind.InvocationExpression); + context.RegisterSyntaxNodeAction(AnalyzeWorkflowActivityRegistration, SyntaxKind.InvocationExpression); + } + + private void AnalyzeWorkflowRegistration(SyntaxNodeAnalysisContext context) + { + var invocationExpr = (InvocationExpressionSyntax)context.Node; + + if (invocationExpr.Expression is not MemberAccessExpressionSyntax memberAccessExpr) + return; + + if (memberAccessExpr.Name.Identifier.Text != "ScheduleNewWorkflowAsync") + return; + + var argumentList = invocationExpr.ArgumentList.Arguments; + if (argumentList.Count == 0) + return; + + var firstArgument = argumentList[0].Expression; + if (firstArgument is InvocationExpressionSyntax nameofInvocation) + { + var workflowName = nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString().Trim('"'); + if (workflowName != null) + { + bool isRegistered = CheckIfWorkflowIsRegistered(workflowName, context.SemanticModel); + if (!isRegistered) + { + var diagnostic = Diagnostic.Create(WorkflowDiagnosticDescriptor, firstArgument.GetLocation(), workflowName); + context.ReportDiagnostic(diagnostic); + } + } + } + } + + private void AnalyzeWorkflowActivityRegistration(SyntaxNodeAnalysisContext context) + { + var invocationExpr = (InvocationExpressionSyntax)context.Node; + + if (invocationExpr.Expression is not MemberAccessExpressionSyntax memberAccessExpr) + return; + + if (memberAccessExpr.Name.Identifier.Text != "CallActivityAsync") + return; + + var argumentList = invocationExpr.ArgumentList.Arguments; + if (argumentList.Count == 0) + return; + + var firstArgument = argumentList[0].Expression; + if (firstArgument is InvocationExpressionSyntax nameofInvocation) + { + var activityName = nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString().Trim('"'); + if (activityName != null) + { + bool isRegistered = CheckIfActivityIsRegistered(activityName, context.SemanticModel); + if (!isRegistered) + { + var diagnostic = Diagnostic.Create(WorkflowActivityDiagnosticDescriptor, firstArgument.GetLocation(), activityName); + context.ReportDiagnostic(diagnostic); + } + } + } + } + + private static bool CheckIfWorkflowIsRegistered(string workflowName, 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 == "RegisterWorkflow") + { + if (memberAccess.Name is GenericNameSyntax typeArgumentList && typeArgumentList.TypeArgumentList.Arguments.Count > 0) + { + if (typeArgumentList.TypeArgumentList.Arguments[0] is IdentifierNameSyntax typeArgument) + { + if (typeArgument.Identifier.Text == workflowName) + { + return true; + } + } + } + } + } + + return false; + } + + private static bool CheckIfActivityIsRegistered(string activityName, 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 == "RegisterActivity") + { + if (memberAccess.Name is GenericNameSyntax typeArgumentList && typeArgumentList.TypeArgumentList.Arguments.Count > 0) + { + if (typeArgumentList.TypeArgumentList.Arguments[0] is IdentifierNameSyntax typeArgument) + { + if (typeArgument.Identifier.Text == activityName) + { + return true; + } + } + } + } + } + + return false; + } +} diff --git a/src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs new file mode 100644 index 00000000..06e34e36 --- /dev/null +++ b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs @@ -0,0 +1,124 @@ +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; +using Microsoft.CodeAnalysis.Formatting; + +namespace Dapr.Workflow.Analyzers; + +/// +/// Provides code fixes for DAPR1001 diagnostic. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(WorkflowRegistrationCodeFixProvider))] +[Shared] +public class WorkflowRegistrationCodeFixProvider : CodeFixProvider +{ + /// + /// Gets the diagnostic IDs that this provider can fix. + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create("DAPR1001"); + + /// + /// Registers the code fix for the diagnostic. + /// + public override Task RegisterCodeFixesAsync(CodeFixContext context) + { + var title = "Register workflow"; + context.RegisterCodeFix( + CodeAction.Create( + title, + createChangedDocument: c => RegisterWorkflowAsync(context.Document, context.Diagnostics.First(), c), + equivalenceKey: title), + context.Diagnostics); + return Task.CompletedTask; + } + + private async Task RegisterWorkflowAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + var diagnosticSpan = diagnostic.Location.SourceSpan; + + var oldInvocation = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().First(); + + if (oldInvocation is null) + return document; + + if (root == null || oldInvocation == null) + return document; + + // Extract the workflow type name + var workflowType = oldInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString(); + + if (string.IsNullOrEmpty(workflowType)) + return document; + + // Get the compilation + var compilation = await document.Project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); + + if (compilation == null) + return document; + + InvocationExpressionSyntax? addDaprWorkflowInvocation = null; + SyntaxNode? targetRoot = null; + Document? targetDocument = null; + + // Iterate through all syntax trees in the compilation + foreach (var syntaxTree in compilation.SyntaxTrees) + { + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + addDaprWorkflowInvocation = syntaxRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddDaprWorkflow"); + + if (addDaprWorkflowInvocation != null) + { + targetRoot = syntaxRoot; + targetDocument = document.Project.GetDocument(syntaxTree); + break; + } + } + + if (addDaprWorkflowInvocation == null || targetRoot == null || targetDocument == null) + return document; + + // Find the options lambda block + var optionsLambda = addDaprWorkflowInvocation.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}.RegisterWorkflow<{workflowType}>();"); + + // 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 instance. + public override FixAllProvider? GetFixAllProvider() + { + return WellKnownFixAllProviders.BatchFixer; + } +} diff --git a/test/Dapr.Workflow.Analyzers.Test/Utilities.cs b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs new file mode 100644 index 00000000..51ebfef4 --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs @@ -0,0 +1,54 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using System.Reflection; + +namespace Dapr.Workflow.Analyzers.Test; + +internal static class Utilities +{ + public static async Task<(ImmutableArray diagnostics, Document document, Workspace workspace)> GetDiagnosticsAdvanced(string code) + { + var workspace = new AdhocWorkspace(); + + // Create a new project with necessary references + var project = workspace.AddProject("TestProject", LanguageNames.CSharp) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddMetadataReference(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .AddMetadataReferences(GetAllReferencesNeededForType(typeof(Workflow<,>))); + + // 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); + + var compilationWithAnalyzer = compilation.WithAnalyzers( + ImmutableArray.Create( + new WorkflowRegistrationAnalyzer())); + + // 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.Workflow.Analyzers.Test/Verify.cs b/test/Dapr.Workflow.Analyzers.Test/VerifyAnalyzer.cs similarity index 58% rename from test/Dapr.Workflow.Analyzers.Test/Verify.cs rename to test/Dapr.Workflow.Analyzers.Test/VerifyAnalyzer.cs index 6a892ef2..2c0204b0 100644 --- a/test/Dapr.Workflow.Analyzers.Test/Verify.cs +++ b/test/Dapr.Workflow.Analyzers.Test/VerifyAnalyzer.cs @@ -1,12 +1,10 @@ using Microsoft.CodeAnalysis; -using System.Collections.Immutable; -using System.Reflection; using Microsoft.CodeAnalysis.CSharp.Testing; using Microsoft.CodeAnalysis.Testing; namespace Dapr.Workflow.Analyzers.Test; -internal static class Verify +internal static class VerifyAnalyzer { public static DiagnosticResult Diagnostic(string diagnosticId, DiagnosticSeverity diagnosticSeverity) { @@ -20,15 +18,25 @@ public static async Task VerifyAnalyzerAsync(string source, params DiagnosticRes public static async Task VerifyAnalyzerAsync(string source, string? program, params DiagnosticResult[] expected) { - var test = new Test { TestCode = source, ReferenceAssemblies = ReferenceAssemblies.Net.Net60 }; + 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 = GetAllReferencesNeededForType(typeof(WorkflowActivityAnalyzer)).ToList(); - metadataReferences.AddRange(GetAllReferencesNeededForType(typeof(Workflow<,>))); + var metadataReferences = Utilities.GetAllReferencesNeededForType(typeof(WorkflowRegistrationAnalyzer)).ToList(); + metadataReferences.AddRange(Utilities.GetAllReferencesNeededForType(typeof(Workflow<,>))); metadataReferences.Add(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)); foreach (var reference in metadataReferences) @@ -40,7 +48,7 @@ public static async Task VerifyAnalyzerAsync(string source, string? program, par await test.RunAsync(CancellationToken.None); } - private class Test : CSharpAnalyzerTest + private class Test : CSharpAnalyzerTest { public Test() { @@ -52,20 +60,4 @@ public Test() }); } } - - private 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.Workflow.Analyzers.Test/VerifyCodeFix.cs b/test/Dapr.Workflow.Analyzers.Test/VerifyCodeFix.cs new file mode 100644 index 00000000..ba7c89b5 --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/VerifyCodeFix.cs @@ -0,0 +1,56 @@ +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace Dapr.Workflow.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.Workflow.Analyzers.Test/WorkflowActivityAnalyzerTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityAnalyzerTests.cs deleted file mode 100644 index 07a38e51..00000000 --- a/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityAnalyzerTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace Dapr.Workflow.Analyzers.Test; - -public class WorkflowActivityAnalyzerTests -{ - [Fact] - public async Task VerifyNotifyActivityNotRegistered() - { - var testCode = @" -using Dapr.Workflow; -using System.Threading.Tasks; - -class OrderProcessingWorkflow : Workflow -{ - public override async Task RunAsync(WorkflowContext context, OrderPayload order) - { - await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); - return new OrderResult(""Order processed""); - } -} - -class OrderPayload { } -class OrderResult(string message) { } -class Notification { public Notification(string message) { } } -class NotifyActivity { } -"; - - var expected = Verify.Diagnostic("DAPR1001", DiagnosticSeverity.Warning) - .WithSpan(9, 41, 9, 63).WithMessage("The class 'NotifyActivity' is not registered in the DI container"); - - await Verify.VerifyAnalyzerAsync(testCode, expected); - } - - [Fact] - public async Task VerifyNotifyActivityRegistered() - { - var testCode = @" - using Dapr.Workflow; - using Microsoft.Extensions.DependencyInjection; - using System.Threading.Tasks; - - class OrderProcessingWorkflow : Workflow - { - public override async Task RunAsync(WorkflowContext context, OrderPayload order) - { - await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); - return new OrderResult(""Order processed""); - } - } - - record OrderPayload { } - record OrderResult(string message) { } - record Notification(string Message); - - class NotifyActivity : WorkflowActivity - { - - public override Task RunAsync(WorkflowActivityContext context, Notification notification) - { - return Task.FromResult(null); - } - } - "; - - var startupCode = @" - using Dapr.Workflow; - using Microsoft.Extensions.DependencyInjection; - - internal static class Extensions - { - public static void AddApplicationServices(this IServiceCollection services) - { - services.AddDaprWorkflow(options => - { - options.RegisterActivity(); - }); - } - } - "; - - await Verify.VerifyAnalyzerAsync(testCode, startupCode); - } -} diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs new file mode 100644 index 00000000..57cda2f2 --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowActivityRegistrationCodeFixProviderTests.cs @@ -0,0 +1,75 @@ +namespace Dapr.Workflow.Analyzers.Test; + +public class WorkflowActivityRegistrationCodeFixProviderTests +{ + [Fact] + public async Task VerifyWorkflowActivityRegistrationCodeFix() + { + var code = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + public static class Program + { + public static void Main() + { + var services = new ServiceCollection(); + + services.AddDaprWorkflow(options => + { + }); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); + return new OrderResult(""Order processed""); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + record Notification { public Notification(string message) { } } + class NotifyActivity { } + "; + + var expectedChangedCode = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + public static class Program + { + public static void Main() + { + var services = new ServiceCollection(); + + services.AddDaprWorkflow(options => + { + options.RegisterActivity(); + }); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); + return new OrderResult(""Order processed""); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + record Notification { public Notification(string message) { } } + class NotifyActivity { } + "; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } +} diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationAnalyzerTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationAnalyzerTests.cs new file mode 100644 index 00000000..9ce8d1d8 --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationAnalyzerTests.cs @@ -0,0 +1,161 @@ +using Microsoft.CodeAnalysis; + +namespace Dapr.Workflow.Analyzers.Test; + +public class WorkflowRegistrationAnalyzerTests +{ + public class Workflow + { + [Fact] + public async Task VerifyWorkflowNotRegistered() + { + var testCode = @" + using Dapr.Workflow; + using System.Threading.Tasks; + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + return new OrderResult(""Order processed""); + } + } + + class UseWorkflow() + { + public async Task RunWorkflow(DaprWorkflowClient client, OrderPayload order) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, order); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + "; + + var expected = VerifyAnalyzer.Diagnostic("DAPR1001", DiagnosticSeverity.Warning) + .WithSpan(17, 63, 17, 94).WithMessage("The workflow class 'OrderProcessingWorkflow' is not registered"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task VerifyWorkflowRegistered() + { + var testCode = @" + using Dapr.Workflow; + using System.Threading.Tasks; + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + return new OrderResult(""Order processed""); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + "; + + var startupCode = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + + internal static class Extensions + { + public static void AddApplicationServices(this IServiceCollection services) + { + services.AddDaprWorkflow(options => + { + options.RegisterWorkflow(); + }); + } + } + "; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, startupCode); + } + } + + public class WorkflowActivity + { + [Fact] + public async Task VerifyActivityNotRegistered() + { + var testCode = @" + using Dapr.Workflow; + using System.Threading.Tasks; + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); + return new OrderResult(""Order processed""); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + record Notification { public Notification(string message) { } } + class NotifyActivity { } + "; + + var expected = VerifyAnalyzer.Diagnostic("DAPR1002", DiagnosticSeverity.Warning) + .WithSpan(9, 57, 9, 79).WithMessage("The workflow activity class 'NotifyActivity' is not registered"); + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, expected); + } + + [Fact] + public async Task VerifyActivityRegistered() + { + var testCode = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + class OrderProcessingWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + await context.CallActivityAsync(nameof(NotifyActivity), new Notification(""Order received"")); + return new OrderResult(""Order processed""); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + record Notification(string Message); + + class NotifyActivity : WorkflowActivity + { + + public override Task RunAsync(WorkflowActivityContext context, Notification notification) + { + return Task.FromResult(null); + } + } + "; + + var startupCode = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + + internal static class Extensions + { + public static void AddApplicationServices(this IServiceCollection services) + { + services.AddDaprWorkflow(options => + { + options.RegisterActivity(); + }); + } + } + "; + + await VerifyAnalyzer.VerifyAnalyzerAsync(testCode, startupCode); + } + } +} diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs new file mode 100644 index 00000000..0ec1941f --- /dev/null +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs @@ -0,0 +1,79 @@ +namespace Dapr.Workflow.Analyzers.Test; + +public class WorkflowRegistrationCodeFixProviderTests +{ + [Fact] + public async Task VerifyWorkflowRegistrationCodeFix() + { + var code = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + public static class Program + { + public static void Main() + { + var services = new ServiceCollection(); + + services.AddDaprWorkflow(options => + { + }); + } + + private static async Task ScheduleWorkflow(DaprWorkflowClient client) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, new OrderPayload()); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, OrderPayload order) + { + return Task.FromResult(new OrderResult(""Order processed"")); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + "; + + var expectedChangedCode = @" + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + public static class Program + { + public static void Main() + { + var services = new ServiceCollection(); + + services.AddDaprWorkflow(options => + { + options.RegisterWorkflow(); + }); + } + + private static async Task ScheduleWorkflow(DaprWorkflowClient client) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, new OrderPayload()); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, OrderPayload order) + { + return Task.FromResult(new OrderResult(""Order processed"")); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + "; + + await VerifyCodeFix.RunTest(code, expectedChangedCode); + } +}