I'm working on a somewhat 'Hello World' type analyzer+code fix with the C# Roslyn API, and running into a test failure which I don't understand and haven't had much luck in tracking down any additional information on thus far.
I have a suite of tests covering the typical basic use cases for the analyzer and its code fix:
- Various cases which do not trigger the diagnostic
- These tests all pass just fine
- Various cases which should trigger the diagnostic, and the available code fix, and the result of the code fix
- These tests all fail only because of performing 1 too many iterations - the actual fix does generate the expected/correct code
Here's one of the failing tests:
[Test]
public async Task ClassGenericTaskReturnTypeMethodWithNoCancellationTokenShouldTriggerDiagnosticWithCorrectCodeFix()
{
var test = @"
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
namespace ConsoleApplication1
{
class Foo
{
public Task<int> Bar(int baz)
{
throw new NotImplementedException(""Not real code"");
}
}
}
";
var fixtest = @"
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Threading;
namespace ConsoleApplication1
{
class Foo
{
public Task<int> Bar(int baz, CancellationToken cancellationToken)
{
throw new NotImplementedException(""Not real code"");
}
}
}
";
var expected = VerifyCS.Diagnostic(AsyncMissingCancellationTokenAnalyzer.DiagnosticId)
.WithLocation(13, 3)
.WithArguments("Bar", "Foo");
await VerifyCS.VerifyCodeFixAsync(test, expected, fixtest);
}
This test fails with these messages (stack traces omitted):
Multiple failures or warnings in test:
1) Context: Iterative code fix application
Expected '1' iterations but found '2' iterations.
Expected: 1
But was: 2
2) Context: Fix all in document
Expected '1' iterations but found '2' iterations.
Expected: 1
But was: 2
I have debugged the tests, and I can see that it is hitting my code fix code twice, but I don't know why that would be the case. Analyzer + code fix:
Analyzer:
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class AsyncMissingCancellationTokenAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "AsyncCancellation1000";
// You can change these strings in the Resources.resx file. If you do not want your analyzer to be localize-able, you can use regular strings for Title and MessageFormat.
// See https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Localizing%20Analyzers.md for more on localization
private static readonly LocalizableString Title = new LocalizableResourceString(nameof(Resources.AnalyzerTitle), Resources.ResourceManager, typeof(Resources));
private static readonly LocalizableString MessageFormat = new LocalizableResourceString(nameof(Resources.AnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources));
private static readonly LocalizableString Description = new LocalizableResourceString(nameof(Resources.AnalyzerDescription), Resources.ResourceManager, typeof(Resources));
private const string Category = "Usage";
private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
// TODO: Consider registering other actions that act on syntax instead of or in addition to symbols
// See https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Actions%20Semantics.md for more information
context.RegisterSyntaxNodeAction(AnalyzeSymbol, SyntaxKind.MethodDeclaration);
}
private const string CancellationTokenFullName = "System.Threading.CancellationToken";
private const string TaskFullName = "System.Threading.Tasks.Task";
private const string GenericTaskFullName = "System.Threading.Tasks.Task`1";
private const string ValueTaskFullName = "System.Threading.Tasks.ValueTask";
private const string GenericValueTaskFullName = "System.Threading.Tasks.ValueTask`1";
private static void AnalyzeSymbol(SyntaxNodeAnalysisContext context)
{
var compilation = context.Compilation;
var syntax = (MethodDeclarationSyntax)context.Node;
// identify async (returning) methods
var semanticReturnType = context.SemanticModel.GetTypeInfo(syntax.ReturnType);
if (semanticReturnType.Type is INamedTypeSymbol namedReturnType)
{
if (namedReturnType.IsGenericType)
{
if (!SymbolEqualityComparer.Default.Equals(
namedReturnType.OriginalDefinition,
compilation.GetTypeByMetadataName(GenericTaskFullName)
)
&& !SymbolEqualityComparer.Default.Equals(
namedReturnType.OriginalDefinition,
compilation.GetTypeByMetadataName(
GenericValueTaskFullName
)
))
{
// Return type is some generic but not Task<T> or ValueTask<T>
// this is not an async method - code is compliant
return;
}
}
else if (!SymbolEqualityComparer.Default.Equals(
namedReturnType,
compilation.GetTypeByMetadataName(TaskFullName)
)
&& !SymbolEqualityComparer.Default.Equals(
namedReturnType,
compilation.GetTypeByMetadataName(ValueTaskFullName)
))
{
// Return type is something non-generic but not Task or ValueTask
// this is not an async method - code is compliant
return;
}
}
var cancellationTokenTypeSymbol = compilation.GetTypeByMetadataName(CancellationTokenFullName);
foreach (var parameterSyntax in syntax.ParameterList.Parameters)
{
var parameterType = context.SemanticModel.GetTypeInfo(parameterSyntax.Type);
if (SymbolEqualityComparer.Default.Equals(parameterType.Type, cancellationTokenTypeSymbol))
{
// found a CancellationToken parameter - code is compliant
return;
}
}
// For all such symbols, produce a diagnostic.
var containingType = (TypeDeclarationSyntax)syntax.Parent;
var diagnostic = Diagnostic.Create(
Rule,
syntax.GetLocation(),
syntax.Identifier.ValueText,
containingType.Identifier.ValueText);
context.ReportDiagnostic(diagnostic);
}
}
Code Fix:
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AsyncMissingCancellationTokenCodeFixProvider)), Shared]
public class AsyncMissingCancellationTokenCodeFixProvider : CodeFixProvider
{
public sealed override ImmutableArray<string> FixableDiagnosticIds
{
get { return ImmutableArray.Create(AsyncMissingCancellationTokenAnalyzer.DiagnosticId); }
}
public sealed override FixAllProvider GetFixAllProvider()
{
// See https://github.com/dotnet/roslyn/blob/main/docs/analyzers/FixAllProvider.md for more information on Fix All Providers
return WellKnownFixAllProviders.BatchFixer;
}
public sealed override Task RegisterCodeFixesAsync(CodeFixContext context)
{
// TODO: Replace the following code with your own analysis, generating a CodeAction for each fix to suggest
var diagnostic = context.Diagnostics.First();
// Register a code action that will invoke the fix.
context.RegisterCodeFix(
CodeAction.Create(
title: CodeFixResources.CodeFixTitle,
createChangedDocument: d => AddCancellationTokenParameterAsync(
context, diagnostic, d),
equivalenceKey: nameof(CodeFixResources.CodeFixTitle)),
diagnostic);
return Task.CompletedTask;
}
private async Task<Document> AddCancellationTokenParameterAsync(
CodeFixContext context, Diagnostic diagnostic, CancellationToken cancellationToken)
{
var syntaxRoot = await context.Document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
var root = (CompilationUnitSyntax)syntaxRoot;
// Find the type declaration identified by the diagnostic.
var diagnosticSpan = diagnostic.Location.SourceSpan;
var methodDecl = (MethodDeclarationSyntax)root.FindNode(diagnosticSpan);
// required using directive
var namespaceQualifiedName = SyntaxFactory.QualifiedName(
SyntaxFactory.IdentifierName("System"),
SyntaxFactory.IdentifierName("Threading"));
var namespaceUsingDirective = SyntaxFactory.UsingDirective(namespaceQualifiedName)
.NormalizeWhitespace()
.WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed);
var tokenParameter = SyntaxFactory.Parameter(SyntaxFactory.Identifier("cancellationToken"))
// The using directive will be present, so do not need to use FQN
.WithType(SyntaxFactory.ParseTypeName(nameof(CancellationToken)));
var newMethodDecl = methodDecl.AddParameterListParameters(tokenParameter);
var document = context.Document;
var newRoot = root;
if (root.Usings.All(u => u.Name.ToString() != "System.Threading"))
{
newRoot = root.AddUsings(namespaceUsingDirective);
}
newRoot = newRoot.ReplaceNode(methodDecl, newMethodDecl);
return document.WithSyntaxRoot(newRoot);
}
}
This code ends up being very similar to another question - same idea for the analyzer + fix, but it has no mention of the unit testing side of this.
Relevant project info:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="3.1.2" />
<PackageReference Include="FakeItEasy" Version="7.3.1" />
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="nunit" Version="3.13.3" />
<PackageReference Include="NUnit.Analyzers" Version="3.3.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.0" />
<PackageReference Include="Microsoft.CodeAnalysis" Version="4.2.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.NUnit" Version="1.1.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.NUnit" Version="1.1.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeRefactoring.Testing.NUnit" Version="1.1.1" />
<PackageReference Include="Microsoft.CodeAnalysis.VisualBasic.Analyzer.Testing.NUnit" Version="1.1.1" />
<PackageReference Include="Microsoft.CodeAnalysis.VisualBasic.CodeFix.Testing.NUnit" Version="1.1.1" />
<PackageReference Include="Microsoft.CodeAnalysis.VisualBasic.CodeRefactoring.Testing.NUnit" Version="1.1.1" />
</ItemGroup>
</Project>