📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do you write a Roslyn code fix provider, and what makes a code fix safe to apply across a whole solution?

CodeFixProvider authoring: FixableDiagnosticIds, RegisterCodeFixesAsync, CodeAction with equivalence keys, syntax editing with trivia, Fix All and testing.

Senior Coding Expert analysers code-fix refactoring roslyn

🎯 Key points

  • Derive from CodeFixProvider with ExportCodeFixProvider and Shared attributes
  • List handled ids in FixableDiagnosticIds and register CodeActions in RegisterCodeFixesAsync
  • Give each action an equivalence key and return BatchFixer for Fix All
  • Preserve trivia and use Simplifier and Formatter annotations
  • Check semantics before offering a fix so the result compiles
  • Consider behaviour changes for callers; use solution-level changes when needed
  • Test with CSharpCodeFixVerifier comparing before and after source

⏱ 30-second answer

A code fix is a class deriving from CodeFixProvider, exported with [ExportCodeFixProvider(LanguageNames.CSharp)] and [Shared]. It lists the diagnostic ids it can fix in FixableDiagnosticIds, and in RegisterCodeFixesAsync finds the syntax at the diagnostic's location and registers a CodeAction that returns a new Document (or Solution). Give each action an equivalenceKey and return WellKnownFixAllProviders.BatchFixer from GetFixAllProvider so users can apply it to a document, project or solution. A safe fix preserves trivia (comments and formatting), adds simplifier and formatter annotations, never changes behaviour silently, and is covered by tests that compare before and after source.

🗣 Full answer (senior)

How I would answer this in an interview

A code fix provider offers an automatic change for a diagnostic (the light bulb in the IDE). It derives from CodeFixProvider, declares which diagnostic IDs it fixes, and registers CodeActions that return a modified Document or Solution. A fix is safe to apply across a whole solution when it's semantics-preserving (or clearly intended to change semantics in the documented way), deterministic, idempotent, local to the diagnostic, preserves formatting and comments, and supports Fix All efficiently.

1. Structure of a code fix

Example: fix for SGM002 "Use SaveChangesAsync with the cancellation token in async methods" — rewrite db.SaveChanges() to await db.SaveChangesAsync(cancellationToken).

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseSaveChangesAsyncCodeFix)), Shared]
public sealed class UseSaveChangesAsyncCodeFix : CodeFixProvider
{
    public override ImmutableArray<string> FixableDiagnosticIds => ["SGM002"];
    public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
        if (root?.FindNode(context.Span) is not InvocationExpressionSyntax invocation) return;

        context.RegisterCodeFix(
            CodeAction.Create(
                title: "Use await SaveChangesAsync(cancellationToken)",
                createChangedDocument: ct => FixAsync(context.Document, invocation, ct),
                equivalenceKey: "UseSaveChangesAsync"),          // stable key -> enables Fix All
            context.Diagnostics);
    }

    private static async Task<Document> FixAsync(Document document, InvocationExpressionSyntax invocation, CancellationToken ct)
    {
        var model = await document.GetSemanticModelAsync(ct).ConfigureAwait(false);
        var method = invocation.FirstAncestorOrSelf<MethodDeclarationSyntax>();
        var tokenName = FindCancellationTokenParameter(model!, method) ?? "CancellationToken.None";

        var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression;
        var newInvocation = invocation
            .WithExpression(memberAccess.WithName(SyntaxFactory.IdentifierName("SaveChangesAsync")))
            .WithArgumentList(SyntaxFactory.ParseArgumentList($"({tokenName})"));
        var awaited = SyntaxFactory.AwaitExpression(newInvocation.WithoutTrivia())
            .WithTriviaFrom(invocation)                         // keep comments/whitespace
            .WithAdditionalAnnotations(Formatter.Annotation, Simplifier.Annotation);

        var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
        editor.ReplaceNode(invocation, awaited);
        return editor.GetChangedDocument();
    }
}

Key pieces:

  • FixableDiagnosticIds — which analyser diagnostics it handles.
  • RegisterCodeFixesAsync — find the node at context.Span, register one or more CodeActions.
  • CodeAction.Create with createChangedDocument (single document) or createChangedSolution (multi-file, e.g. renames) and an equivalenceKey.
  • GetFixAllProvider — enables "Fix all in document/project/solution".
  • DocumentEditor/SyntaxEditor for multiple edits; Formatter/Simplifier annotations to format and simplify names (System.Threading.CancellationToken → CancellationToken if imported).

2. What makes a fix safe across a solution

  1. Correctness / semantics: the rewritten code must compile and behave as intended. Here, if the method isn't async, adding await is wrong — the analyser should only report (or the fix should only be offered) in async methods, or the fix must also convert the method (a larger, riskier change — better as a separate action).
  2. Precondition checks in the fix: re-verify with the semantic model (types, async context, available token) and don't offer the fix when unsure.
  3. Locality: change only what's needed near the diagnostic; avoid reformatting whole files.
  4. Trivia preservation: keep comments, whitespace and preprocessor directives (WithTriviaFrom, annotations).
  5. Determinism and idempotency: same input → same output; applying twice makes no further change (no new diagnostic).
  6. Fix All behaviour: with BatchFixer, each fix is computed independently on the original document and merged — fixes must not overlap or depend on each other's results. If they do (e.g. adding a parameter to a method touched by multiple diagnostics), implement a custom FixAllProvider that processes a document's diagnostics together.
  7. No silent behaviour changes: e.g. replacing == string comparison with string.Equals(..., OrdinalIgnoreCase) changes semantics — offer multiple clearly titled options.
  8. Performance: avoid per-fix compilation of whole solutions; use document-level changes when possible.
  9. Tests for each scenario and for Fix All.

3. Testing

await new CSharpCodeFixTest<SaveChangesAnalyzer, UseSaveChangesAsyncCodeFix, DefaultVerifier>
{
    TestCode = """... async Task SaveAsync(CancellationToken ct) { {|SGM002:db.SaveChanges()|}; } ...""",
    FixedCode = """... async Task SaveAsync(CancellationToken ct) { await db.SaveChangesAsync(ct); } ...""",
    NumberOfFixAllIterations = 1,
}.RunAsync();

The testing library runs the fix, compares to FixedCode, verifies no remaining diagnostics, and can test Fix All in document/project/solution scopes. Add tests for comments/trivia, nested expressions, missing tokens, and code where the fix must not be offered.

4. Rolling out solution-wide fixes

  • Use dotnet format analyzers --diagnostics SGM002 --severity warn in CI or locally to apply fixes across repositories.
  • Review diffs in a dedicated PR; run the full test suite.
  • Apply in batches per project for large codebases.

5. A production example

We shipped SGM002 with a code fix. The first version used BatchFixer but also added a CancellationToken parameter to the containing method when none existed; in methods with two diagnostics, Fix All produced two duplicate parameters and broke compilation. We split it into two code actions ("use CancellationToken.None" safe default, and "add parameter" offered only individually), added Fix All tests, and then used dotnet format to fix ~600 occurrences across 20 services in one reviewed PR per service.

🔍 What the interviewer is checking

The interviewer checks advanced tooling skills and care for developer experience.

Green flags: FixableDiagnosticIds, finding the node, CodeAction.Create with equivalence key, BatchFixer, trivia and annotations, semantic checks, behaviour-change awareness, before/after tests.

Red flags: text replacement instead of syntax; no Fix All support; ignores behaviour changes for callers.

📚 Learn in depth

1. Code actions and equivalence keys

The IDE groups code actions with the same equivalenceKey for Fix All. Without a key, Fix All isn't offered. Nested code actions (CodeAction.Create(title, nestedActions, isInlinable)) group variants in the light bulb.

2. FixAll providers

  • WellKnownFixAllProviders.BatchFixer: computes each fix independently and merges text changes; conflicts are dropped.
  • Custom FixAllProvider (override GetFixAsync(FixAllContext)): process all diagnostics in a document together with a single SyntaxEditor, enabling dependent edits.
  • .NET's newer DocumentBasedFixAllProvider simplifies per-document batch fixes.

3. Syntax generation

  • SyntaxFactory creates nodes; SyntaxGenerator (language-agnostic) can be easier.
  • ParseExpression/ParseStatement for quick snippets (then add annotations).
  • Formatter.Annotation, Simplifier.Annotation, Simplifier.AddImportsAnnotation let the workspace format and add using directives.

4. Solution-level fixes

For renames or signature changes, use Renamer.RenameSymbolAsync or produce a new Solution with changes in multiple documents. These are more expensive and riskier; keep them explicit.

5. Packaging

Code fixes depend on Microsoft.CodeAnalysis.CSharp.Workspaces; they're packed alongside analysers (often in a separate assembly to keep the analyser assembly free of Workspaces dependencies, since the compiler loads analysers without Workspaces).

Try it yourself

  1. Implement a code fix for an existing analyser and test single and Fix All scenarios.
  2. Write a test with comments around the target expression and verify trivia is preserved.
  3. Create overlapping fixes and observe BatchFixer conflicts; implement a custom provider.
  4. Apply fixes with dotnet format analyzers.

💻 Code

// Analyzers.CodeFixes.csproj: netstandard2.0, Microsoft.CodeAnalysis.CSharp.Workspaces (PrivateAssets=all)
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Simplification;

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AsyncVoidCodeFix)), Shared]
public sealed class AsyncVoidCodeFix : CodeFixProvider
{
    private const string EquivalenceKey = "ReturnTask";

    public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(AsyncVoidAnalyzer.Id);

    public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        SyntaxNode? root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
        MethodDeclarationSyntax? method = root?.FindToken(context.Span.Start).Parent?
            .AncestorsAndSelf().OfType<MethodDeclarationSyntax>().FirstOrDefault();
        if (method is null) return;

        context.RegisterCodeFix(
            CodeAction.Create(
                title: "Return Task instead of void",
                createChangedDocument: ct => ReturnTaskAsync(context.Document, method, ct),
                equivalenceKey: EquivalenceKey),
            context.Diagnostics);
    }

    private static async Task<Document> ReturnTaskAsync(Document document, MethodDeclarationSyntax method, CancellationToken ct)
    {
        TypeSyntax taskType = SyntaxFactory.ParseTypeName("System.Threading.Tasks.Task")
            .WithTriviaFrom(method.ReturnType)                 // keep spacing and comments
            .WithAdditionalAnnotations(Simplifier.Annotation); // shorten to Task when possible

        SyntaxNode root = (await document.GetSyntaxRootAsync(ct).ConfigureAwait(false))!;
        return document.WithSyntaxRoot(root.ReplaceNode(method.ReturnType, taskType));
    }
}
using VerifyFix = Microsoft.CodeAnalysis.CSharp.Testing.CSharpCodeFixVerifier<
    AsyncVoidAnalyzer, AsyncVoidCodeFix, Microsoft.CodeAnalysis.Testing.DefaultVerifier>;

public class AsyncVoidCodeFixTests
{
    [Fact]
    public Task Changes_void_to_Task() => VerifyFix.VerifyCodeFixAsync(
        """
        using System.Threading.Tasks;
        class Worker { /* entry */ async void {|DEMO001:Run|}() => await Task.Yield(); }
        """,
        """
        using System.Threading.Tasks;
        class Worker { /* entry */ async Task Run() => await Task.Yield(); }
        """);
}

⚠ Common mistakes

1. Fixes that change semantics silently

What happens: "fix" alters behaviour (culture, equality, async). Why it is wrong: introduces bugs at scale. Do instead: preserve semantics or clearly title semantic changes.

2. Not re-checking preconditions

What happens: assuming the analyser's context is enough. Why it is wrong: code may have changed or edge cases exist. Do instead: verify with the semantic model before offering.

3. Losing comments and formatting

What happens: replacing nodes without trivia. Why it is wrong: noisy diffs, lost comments. Do instead: WithTriviaFrom and formatter annotations.

4. Missing equivalenceKey

What happens: Fix All not available. Why it is wrong: developers fix hundreds of cases manually. Do instead: stable keys.

5. Overlapping Fix All edits

What happens: BatchFixer drops or corrupts dependent changes. Why it is wrong: broken code after Fix All. Do instead: custom FixAll providers.

6. Non-idempotent fixes

What happens: applying twice adds duplicate code. Why it is wrong: unstable results. Do instead: ensure the fix removes the diagnostic.

7. Analysers depending on Workspaces

What happens: analyser and code fix in one assembly referencing Workspaces. Why it is wrong: compiler loading issues. Do instead: separate analyser and code fix assemblies.

8. No Fix All tests

What happens: only single-instance tests. Why it is wrong: solution-wide application breaks. Do instead: test Fix All scopes.

🏛 Senior insight: trade-offs

A good code fix is what gets an analyser adopted.

Analyser without fix Analyser with safe fix
Teams suppress warnings Teams apply Fix All and move on
Slow migrations Repository-wide fixes in one commit
Debates in code review Consistent automated change

Seniors scope fixes to safe, mechanical changes and leave judgement calls to humans, documenting follow-up steps in the diagnostic's help link.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate refers to the diagnostic the fix handles Why: Checks analyser authoring
    How to answer

    An analyser is a class deriving from DiagnosticAnalyzer with [DiagnosticAnalyzer(LanguageNames.CSharp)]. It declares DiagnosticDescriptors (id, title, message, category, default severity) and in Initialize calls EnableConcurrentExecution(), ConfigureGeneratedCodeAnalysis(...) and registers callbacks such as RegisterSymbolAction, RegisterSyntaxNodeAction or RegisterOperationAction, reporting with context.ReportDiagnostic. It targets netstandard2.0 like a generator. Test it with Microsoft.CodeAnalysis.CSharp.Analyzer.Testing using markup such as {|DEMO001:Run|}. Ship it as a project reference with OutputItemType="Analyzer" or a NuGet package, and let teams tune severity in .editorconfig.

  2. When: Candidate mentions trivia and immutable trees Why: Checks Roslyn syntax model knowledge
    How to answer

    Roslyn is the C# and VB compiler exposed as libraries. It parses source into immutable syntax trees (with full fidelity: every space and comment is kept as trivia), builds declarations and symbols, binds expressions to symbols and types, runs flow analysis, then lowers and emits IL, metadata and PDBs. The same objects are public: SyntaxTree, CSharpCompilation, SemanticModel, ISymbol and IOperation. IDEs, analysers, source generators and refactoring tools are all built on these APIs. You can use them to compile code at run time, write code-mod scripts that rewrite thousands of files, or build analysers that run in every build.

🛠 Real-world problems: how you handle them

TimeProvider code fix broke builds until it checked semantics
Situation
A domain-model analyser flagged `DateTime.Now` and offered a fix to use an injected `TimeProvider`.
Signals
Fix All produced [dozens of] compile errors in classes with no `TimeProvider` field.
Investigation
The fix assumed a `_timeProvider` field existed and did not consult the semantic model.
Resolution
The fix now checks for an accessible `TimeProvider` member; if none exists it offers only `DateTime.UtcNow`, with a separate key.
Prevention
Added Fix All tests across several documents and classes with and without the field.

How to tell it (STAR)

Our [DateTime analyser] had a fix that broke builds when applied solution-wide. I changed it to check the semantic model first and offer a safe alternative when no TimeProvider existed. After that, teams applied Fix All across [N] projects with zero compile errors.

↩ Usually asked after

More in Compilation

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me