1

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>
Chris Thompson
  • 490
  • 5
  • 17

1 Answers1

0

It might be better to fix everything in one iteration , but if you want please use:

testhost.NumberOfIncrementalIterations = 2

Joost
  • 131
  • 1
  • 4