Writing your first diagnostic

Blazor Analyzer Feature Image1

In Build a Roslyn Analyzer from scratch we scaffolded PSC.Analyzers.Blazor. Now we write the rule: PSC001 — a Blazor component’s [Parameter] properties must be PascalCase. [Parameter] properties are the component’s public API, so PolicyId is right and policyId is not.

The full source code of the analyzer is available on GitHub.

The anatomy of an analyzer

Every analyzer is a class that:

  1. is decorated with [DiagnosticAnalyzer(LanguageNames.CSharp)],
  2. derives from DiagnosticAnalyzer,
  3. exposes the diagnostics it can produce via SupportedDiagnostics,
  4. registers callbacks in Initialize.

Let’s build each piece.

Describe the diagnostic

A DiagnosticDescriptor is the definition of a rule — its id, its message, its default severity. You create one static, reusable instance:

private static readonly DiagnosticDescriptor Rule = new(
    id: "PSC001",
    title: "Component parameter should be PascalCase",
    messageFormat: "Component parameter '{0}' should be PascalCase",
    category: "PSC.Blazor",
    defaultSeverity: DiagnosticSeverity.Warning,
    isEnabledByDefault: true,
    description: "[Parameter] properties are part of a component's public API and must use PascalCase.");

The {0} in messageFormat is filled in when you report the diagnostic, so each message names the offending property. defaultSeverity is Warning — a good default; consumers can dial it up to error or down to suggestion in their .editorconfig.

Choosing what to analyze

Roslyn lets you register callbacks against different granularities:

  • Syntax nodes — raw source shapes (an if with no braces). Fast, but no type information.
  • Symbols — semantic declarations (a method, a property, a type). This is where most rules live.
  • Operations — the semantic meaning of code inside a method (a call, a property access). Used for things like “don’t call .Result”.

PSC001 is about a property declaration and whether it carries the [Parameter] attribute, so we register a symbol action on properties.

Resolving Blazor types

Here’s the catch: our analyzer targets netstandard2.0 and does not reference Microsoft.AspNetCore.Components. So how do we recognise [Parameter]? We ask the compilation being analyzed for the type by its full metadata name:

var parameterAttribute = compilation
    .GetTypeByMetadataName("Microsoft.AspNetCore.Components.ParameterAttribute");

If it returns null, the project doesn’t reference Blazor — so there’s nothing for us to do, and the analyzer becomes a no-op. This is the idiomatic way to write framework-specific rules without taking a dependency on that framework.

We do this lookup once per compilation using RegisterCompilationStartAction, then register the per-symbol work inside it. That avoids repeating the lookup for every property in the project.

The full analyzer

using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;

namespace PSC.Analyzers.Blazor;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ParameterNamingAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "PSC001";

    private static readonly DiagnosticDescriptor Rule = new(
        id: DiagnosticId,
        title: "Component parameter should be PascalCase",
        messageFormat: "Component parameter '{0}' should be PascalCase",
        category: "PSC.Blazor",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "[Parameter] properties are part of a component's public API and must use PascalCase.");

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

    public override void Initialize(AnalysisContext context)
    {
        // Blazor compiles @code blocks into generated C#; analyse it too so
        // parameters declared in .razor files are covered.
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze);
        context.EnableConcurrentExecution();
        context.RegisterCompilationStartAction(OnCompilationStart);
    }

    private static void OnCompilationStart(CompilationStartAnalysisContext context)
    {
        var parameterAttribute = context.Compilation
            .GetTypeByMetadataName("Microsoft.AspNetCore.Components.ParameterAttribute");

        // Not a Blazor project — nothing to do.
        if (parameterAttribute is null)
        {
            return;
        }

        context.RegisterSymbolAction(
            symbolContext => Analyze(symbolContext, parameterAttribute),
            SymbolKind.Property);
    }

    private static void Analyze(SymbolAnalysisContext context, INamedTypeSymbol parameterAttribute)
    {
        var property = (IPropertySymbol)context.Symbol;

        // The name is fixed by a base type — we can't fix it here.
        if (property.IsOverride)
        {
            return;
        }

        var isParameter = property.GetAttributes().Any(attribute =>
            SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, parameterAttribute));
        if (!isParameter)
        {
            return;
        }

        var name = property.Name;
        if (name.Length == 0 || char.IsUpper(name[0]))
        {
            return;
        }

        context.ReportDiagnostic(Diagnostic.Create(Rule, property.Locations[0], name));
    }
}

Reading the logic

The Analyze method is a series of cheap early-exits — a good habit, since it runs for every property in the project:

  1. Skip override properties — their name is inherited and can’t be changed here.
  2. Skip properties without [Parameter] — we compare each attribute’s class to the ParameterAttribute symbol using SymbolEqualityComparer, never by string.
  3. If the name already starts with an uppercase letter, it’s fine.
  4. Otherwise, report the diagnostic at the property’s location, passing name to fill the {0} in the message.

Diagnostic.Create(Rule, property.Locations[0], name) places the squiggle on the property identifier — exactly where the developer needs to see it.

A note on Initialize

Three lines you’ll write in almost every analyzer:

  • ConfigureGeneratedCodeAnalysis(...) — for Blazor we choose Analyze, because a component’s @code block is compiled into generated C#, and we want to catch parameters declared there too. Many analyzers use None to skip generated code.
  • EnableConcurrentExecution() — lets the host run your analyzer in parallel. Safe because our callbacks are static and stateless.
  • RegisterCompilationStartAction(...) — our per-compilation setup.

Try it out

Reference the analyzer project from a Blazor app as an analyzer:

<ProjectReference Include="..\PSC.Analyzers.Blazor\src\PSC.Analyzers.Blazor\PSC.Analyzers.Blazor.csproj"
                  OutputItemType="Analyzer"
                  ReferenceOutputAssembly="false" />

Add a component with a bad parameter and build:

[Parameter] public int policyId { get; set; }   // PSC001

You’ll see warning PSC001: Component parameter 'policyId' should be PascalCase.

The rule works — but “it worked when I tried it” isn’t a test suite. In Part 3 we write automated tests that prove the analyzer fires on the right code and, just as importantly, stays silent on the right code.

Related posts

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.