Writing a Custom Roslyn Analyser from Scratch

Roslyn analysers run inside the compiler and IDE, providing real-time diagnostics without requiring developers to run a separate tool. In this article, we will build a custom analyser that flags methods exceeding a configurable line count.

Project Setup

Create a new class library targeting netstandard2.0 — analysers must target this framework for broad compatibility:

terminal
dotnet new classlib -n MethodLengthAnalyser -f netstandard2.0
cd MethodLengthAnalyser
dotnet add package Microsoft.CodeAnalysis.Analyzers
dotnet add package Microsoft.CodeAnalysis.CSharp

You also need to mark the project as an analyser in the .csproj:

MethodLengthAnalyser.csproj
<PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>

Defining the Diagnostic

Every analyser reports one or more diagnostics. Each diagnostic needs an ID, a title, a message format, a category, and a default severity.

Example.cs
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class MethodLengthAnalyser : DiagnosticAnalyzer
{
    public const string DiagnosticId = "ML001";

    private static readonly DiagnosticDescriptor Rule = new(
        id: DiagnosticId,
        title: "Method is too long",
        messageFormat: "Method '{0}' is {1} lines long (maximum: {2})",
        category: "Maintainability",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Methods that exceed the configured line limit " +
                     "are harder to understand and maintain.");

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

The DiagnosticDescriptor is what the IDE uses to display the squiggly line, populate the Error List, and show the rule in EditorConfig settings.

Registering the Analysis Action

Analysers work by registering callbacks for specific syntax or symbol events. For our case, we want to inspect every method declaration:

Example.cs
    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(
            GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        context.RegisterSyntaxNodeAction(
            AnalyseMethod,
            SyntaxKind.MethodDeclaration);
    }

Two important calls here. ConfigureGeneratedCodeAnalysis(None) tells Roslyn to skip generated files — you do not want to flag auto-generated code. EnableConcurrentExecution() allows the analyser to run on multiple threads, which is essential for performance.

Implementing the Analysis Logic

Example.cs
    private void AnalyseMethod(SyntaxNodeAnalysisContext context)
    {
        var method = (MethodDeclarationSyntax)context.Node;

        // Read the threshold from an .editorconfig option, defaulting to 30
        var config = context.Options.AnalyzerConfigOptionsProvider
            .GetOptions(context.Node.SyntaxTree);

        int maxLines = 30;
        if (config.TryGetValue(
                "dotnet_diagnostic.ML001.max_lines", out var value)
            && int.TryParse(value, out var parsed))
        {
            maxLines = parsed;
        }

        if (method.Body is null && method.ExpressionBody is null)
            return;

        var span = method.GetLocation().GetLineSpan();
        int lineCount = span.EndLinePosition.Line
                      - span.StartLinePosition.Line + 1;

        if (lineCount > maxLines)
        {
            var diagnostic = Diagnostic.Create(
                Rule,
                method.Identifier.GetLocation(),
                method.Identifier.Text,
                lineCount,
                maxLines);

            context.ReportDiagnostic(diagnostic);
        }
    }
}

We read from EditorConfig so teams can configure their preferred threshold. A consumer can add this to their .editorconfig:

ini
[*.cs]
dotnet_diagnostic.ML001.max_lines = 50

Packaging and Consuming

To distribute your analyser as a NuGet package, add the following to your .csproj:

config.xml
<PropertyGroup>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
    <IncludeBuildOutput>false</IncludeBuildOutput>
    <DevelopmentDependency>true</DevelopmentDependency>
    <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>

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

The key detail is the PackagePath — analysers must be placed in the analyzers/dotnet/cs folder inside the NuGet package for the compiler to discover them.

Consumer projects then simply reference the package:

config.xml
<PackageReference Include="MethodLengthAnalyser" Version="1.0.0" />

No additional configuration is needed. The compiler picks up the analyser automatically, and the IDE shows warnings in real time as the developer types.

Key Takeaways

With this foundation, you can build analysers for any team convention — from naming rules to architectural constraints.