How to Write Custom Roslyn Analyzers in C

This article provides an overview of .NET analyzer packages and explains how to build, test, and distribute custom Roslyn analyzers for C# development teams. You will learn the core concepts of the .NET Compiler Platform (Roslyn), the benefits of automated code governance, the step-by-step process of creating an analyzer that detects anti-patterns, and methods for distributing these rules across your organization via NuGet.

What Are Analyzer Packages?

A Roslyn analyzer is an extension for the C# compiler that inspects source code during design time and compilation. Analyzers inspect the code syntax, semantic model, and control flow, producing diagnostics such as warnings, errors, or suggestions when code violates predefined rules.

An analyzer package is a standard NuGet package containing one or more compiled analyzers (and optional code fixes). When added to a project, these analyzers run automatically inside IDEs (such as Visual Studio, Rider, or VS Code) and during command-line builds (dotnet build), ensuring uniform rule enforcement without relying entirely on manual peer reviews.

Why Teams Build Custom Analyzers

While Microsoft and the open-source community provide comprehensive analyzers (such as StyleCop.Analyzers or Microsoft.CodeAnalysis.NetAnalyzers), engineering teams often have proprietary constraints, including:

  • Enforcing architectural boundaries: Preventing direct dependencies between domain layers and infrastructure layers.
  • Company-specific conventions: Enforcing custom naming schemes, logging standards, or error-handling patterns.
  • Security and compliance: Blocking the usage of insecure internal APIs or prohibited methods (e.g., preventing raw SQL execution or restricting DateTime.Now in favor of an internal time provider).

Prerequisites and Project Setup

To develop custom analyzers, install the .NET Compiler Platform SDK via the Visual Studio Installer (under the "Visual Studio extension development" workload).

The recommended way to start is using the official analyzer template:

  1. Open Visual Studio and create a new project.
  2. Search for the template: Analyzer with Code Fix (.NET Standard).
  3. Name your project (e.g., TeamStandards.Analyzers).

This template generates a multi-project solution:

  • Analyzers: The .NET Standard 2.0 project containing the inspection logic.
  • CodeFixProvider: Logic to automatically rewrite code and fix violations.
  • Tests: Unit tests utilizing the Microsoft.CodeAnalysis.Testing framework.
  • Package: Configuration to build the .nupkg file.

Anatomy of a Custom Analyzer

Every custom analyzer inherits from DiagnosticAnalyzer and overrides two key members:

  • SupportedDiagnostics: An immutable array describing the diagnostic metadata (ID, title, severity, category).
  • Initialize(AnalysisContext context): The registration hook where you define which syntax nodes or symbols the analyzer inspects.

Example: Forbidding DateTime.Now

Consider a team rule that mandates using DateTime.UtcNow instead of DateTime.Now to prevent time-zone inconsistencies.

using System;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class AvoidDateTimeNowAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "TEAM001";
    private static readonly LocalizableString Title = "Avoid DateTime.Now";
    private static readonly LocalizableString MessageFormat = "Use 'DateTime.UtcNow' instead of 'DateTime.Now'";
    private static readonly LocalizableString Description = "DateTime.Now can cause timezone bugs. Prefer DateTime.UtcNow.";
    private const string Category = "Reliability";

    private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
        DiagnosticId,
        Title,
        MessageFormat,
        Category,
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: Description);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => 
        ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        // Configure analyzer execution behavior
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        // Register to inspect member access expressions (e.g., Target.Member)
        context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression);
    }

    private void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context)
    {
        var memberAccess = (MemberAccessExpressionSyntax)context.Node;

        // Fast syntax check: verify the property identifier is "Now"
        if (memberAccess.Name.Identifier.Text != "Now")
        {
            return;
        }

        // Semantic check: ensure the symbol belongs to System.DateTime
        var symbolInfo = context.SemanticModel.GetSymbolInfo(memberAccess).Symbol;
        if (symbolInfo == null)
        {
            return;
        }

        var containingType = symbolInfo.ContainingType;
        if (containingType?.ToDisplayString() == "System.DateTime")
        {
            var diagnostic = Diagnostic.Create(Rule, memberAccess.GetLocation());
            context.ReportDiagnostic(diagnostic);
        }
    }
}

How the Analyzer Works

  1. Configuration: ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None) prevents the analyzer from analyzing auto-generated files (such as Entity Framework migrations). EnableConcurrentExecution() allows Roslyn to run your rule concurrently across multiple threads.
  2. Syntax Filtering: RegisterSyntaxNodeAction targets SyntaxKind.SimpleMemberAccessExpression. The engine calls your method only when code matches this syntax pattern, keeping overhead minimal.
  3. Semantic Verification: Syntax only checks text structure. By calling context.SemanticModel.GetSymbolInfo(), the analyzer confirms that Now belongs specifically to System.DateTime, preventing false positives on custom types that also define a property named Now.
  4. Reporting: When a match is found, context.ReportDiagnostic(diagnostic) highlights the exact location of the error in the IDE and prints it during command-line compilation.

Distributing the Analyzer to Your Team

To enforce the analyzer across projects, package and distribute it via your team's internal NuGet feed (such as Azure Artifacts, GitHub Packages, or BaGet).

1. Configure the .csproj

In the analyzer project file, configure the output to place the compiled assembly in the analyzers/dotnet/cs directory within the NuGet package:

<PropertyGroup>
  <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
  <IncludeBuildOutput>false</IncludeBuildOutput>
  <DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>

<ItemGroup>
  <None Include="$(OutputPath)\$(AssemblyName).dll" 
        Pack="true" 
        PackagePath="analyzers/dotnet/cs" 
        Visible="false" />
</ItemGroup>

2. Centralized Adoption via Directory.Build.props

Rather than installing the analyzer package in every individual .csproj file across your repositories, place a single Directory.Build.props file in the root of your repositories:

<Project>
  <ItemGroup>
    <PackageReference Include="TeamStandards.Analyzers" Version="1.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>
</Project>

This guarantees that every project in the repository automatically runs the analyzers during compilation and continuous integration (CI) pipelines. Rules can then be elevated to build errors using standard .editorconfig files or by setting <TreatWarningsAsErrors>true</TreatWarningsAsErrors>.