mcpField
D
dotnet-roslyn-analyzers

dotnet-roslyn-analyzers

github.com/wshaddix开发8697 次安装35 周前更新
代码仓库

关于 dotnet-roslyn-analyzers

面向 .NET 开发者的技能,指导编写自定义 Roslyn 分析器、代码修复、重构和诊断抑制器,涵盖实现、测试、打包及多版本兼容。

主要功能

  1. 01分析器项目设置与诊断规则
  2. 02分析上下文注册模式
  3. 03代码修复与重构
  4. 04诊断抑制指导
  5. 05多版本定位与打包
  6. 06使用 Microsoft.CodeAnalysis.Testing 测试

适用场景

  1. 01创建自定义 C# 分析器
  2. 02为诊断添加自动修复
  3. 03构建无需诊断的重构
  4. 04为多个 Roslyn 版本打包分析器

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-roslyn-analyzers

安装后在 Claude Code 中输入/dotnet-roslyn-analyzers确认已加载。

附带文件details.md

详细介绍

dotnet-roslyn-analyzers

关于如何编写自定义 Roslyn 分析器、代码修复提供程序、代码重构提供程序和诊断抑制器的指南。涵盖项目设置、DiagnosticDescriptor 约定、分析上下文注册、代码修复操作、代码重构操作、多 Roslyn 版本目标支持(3.8 至 4.14)、使用 Microsoft.CodeAnalysis.Testing 进行测试、NuGet 打包以及性能最佳实践。

**范围界定:**本技能介绍如何编写分析器。有关如何使用和配置现有分析器(CA 规则、EditorConfig 严重性、第三方包),请参阅 [skill:dotnet-add-analyzers]。有关如何编写源生成器(IIncrementalGenerator、语法提供程序、代码生成),请参阅 [skill:dotnet-csharp-source-generators]。分析器和源生成器采用相同的 NuGet 打包布局(analyzers/dotnet/cs/),并依赖 Microsoft.CodeAnalysis.CSharp,但用途不同:分析器报告诊断,生成器生成代码。

交叉引用:[skill:dotnet-csharp-source-generators] 介绍通用的 Roslyn 打包概念和 IIncrementalGenerator 模式;[skill:dotnet-add-analyzers] 介绍如何使用和配置分析器;[skill:dotnet-testing-strategy] 介绍通用测试组织方式和框架选择;[skill:dotnet-csharp-coding-standards] 介绍适用于分析器代码的命名约定。


项目设置

分析器项目必须以 netstandard2.0 为目标框架。编译器会将分析器加载到各种宿主进程中(运行于 .NET Framework/Mono 上的 Visual Studio、运行于 .NET Core 上的 MSBuild、dotnet build CLI)——以 net8.0+ 为目标框架会导致其与不运行在该运行时上的宿主不兼容。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
    
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
  </ItemGroup>
</Project>
  • EnforceExtendedAnalyzerRules 会启用 RS 系列元诊断,用于发现常见的分析器编写错误(见下文“元诊断”部分)。
  • IsRoslynComponent 会为项目启用 IDE 工具支持。
  • LangVersion>latest 让你可以在分析器本身中使用现代 C#,同时仍以 netstandard2.0 为目标框架。
  • 所有 Roslyn SDK 包都必须使用 PrivateAssets="all",以避免将它们作为传递依赖发布。

DiagnosticAnalyzer

每个分析器都继承自 DiagnosticAnalyzer,并且必须使用 [DiagnosticAnalyzer(LanguageNames.CSharp)] 进行修饰。

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

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class NoPublicFieldsAnalyzer : DiagnosticAnalyzer
{
    // Diagnostic ID uses project prefix + sequential number
    public const string DiagnosticId = "MYLIB001";

    private static readonly DiagnosticDescriptor Rule = new(
        id: DiagnosticId,
        title: "Public fields should be properties",
        messageFormat: "Field '{0}' is public; use a property instead",
        category: "Design",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        helpLinkUri: $"https://example.com/docs/rules/{DiagnosticId}");

    // Return an ImmutableArray -- allocating a new array per call is RS1030-adjacent waste
    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
        = ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        // Required: enable concurrent execution and generated code analysis config
        context.EnableConcurrentExecution();
        context.ConfigureGeneratedCodeAnalysis(
            GeneratedCodeAnalysisFlags.None);

        context.RegisterSymbolAction(AnalyzeField, SymbolKind.Field);
    }

    private static void AnalyzeField(SymbolAnalysisContext context)
    {
        var field = (IFieldSymbol)context.Symbol;

        if (field.DeclaredAccessibility == Accessibility.Public
            && !field.IsConst
            && !field.IsReadOnly)
        {
            var diagnostic = Diagnostic.Create(
                Rule,
                field.Locations[0],
                field.Name);

            context.ReportDiagnostic(diagnostic);
        }
    }
}
分析上下文注册

根据分析需求选择最合适的注册方法:

方法粒度适用场景
RegisterSyntaxNodeAction单个语法节点对特定语法进行模式匹配(例如,if 语句、方法声明)
RegisterSymbolAction已声明的符号检查符号级属性(可访问性、类型、特性)
RegisterOperationActionIL 级操作分析与语法无关的语义操作(赋值、调用)
RegisterSyntaxTreeAction整棵语法树文件级检查(例如,缺少文件头、编码)
RegisterCompilationStartAction编译开始时需要在整个编译过程中累积状态,然后在结束时报告
RegisterCompilationAction完整编译所有文件编译完成后的单次分析
// RegisterSyntaxNodeAction -- analyze specific syntax nodes
context.RegisterSyntaxNodeAction(
    AnalyzeInvocation,
    SyntaxKind.InvocationExpression);

// RegisterOperationAction -- analyze semantic operations
context.RegisterOperationAction(
    AnalyzeAssignment,
    OperationKind.SimpleAssignment);

// RegisterCompilationStartAction -- accumulate state, then report at compilation end
context.RegisterCompilationStartAction(compilationContext =>
{
    // Resolve types once at compilation start
    var disposableType = compilationContext.Compilation
        .GetTypeByMetadataName("System.IDisposable");

    if (disposableType is null)
        return;

    compilationContext.RegisterSymbolAction(
        ctx => AnalyzeTypeDisposal(ctx, disposableType),
        SymbolKind.NamedType);
});

DiagnosticDescriptor 约定

所有自定义分析器都应遵循以下约定:

ID 前缀模式

使用源自项目或库名称的简短且唯一的前缀,后接连续编号:

模式示例适用场景
PROJ###MYLIB001单项目分析器
AREA####PERF0001按类别划分的分析器(性能、安全)
XX####MA0042短前缀约定(例如,Meziantou.Analyzer)

避免使用 .NET 平台保留的前缀:CA(代码分析)、CS(编译器)、RS(Roslyn SDK)、IDE(代码样式)、IL(链接器)、SYSLIB(运行时)。在 ID 常量中包含命名空间,以避免安装多个分析器包时发生冲突。

类别命名

在适用时使用标准 .NET 分析类别:

Design、Globalization、Interoperability、Maintainability、Naming、Performance、Reliability、Security、Style、Usage

对于特定领域的类别,使用清晰的标题式名称(例如,EntityFramework、AspNetCore)。

严重性选择
严重性使用场景
Error代码在运行时无法正常工作(空引用解引用、SQL 注入、资源泄漏)
Warning代码可以运行,但违反最佳实践或存在性能问题
Info改进建议,并非缺陷
Hidden仅在 IDE 中显示的重构建议,不会出现在构建输出中

大多数规则默认使用 Warning。谨慎使用 Error——用户无法通过 EditorConfig 禁止显示错误,除非完全禁用该规则。

helpLinkUri

始终提供非空的 helpLinkUri(RS1015 会强制执行此要求)。指向稳定的文档:

helpLinkUri: $"https://github.com/myorg/mylib/blob/main/docs/rules/{DiagnosticId}.md"

CodeFixProvider

代码修复提供程序为诊断提供自动修复。它们继承自 CodeFixProvider,并为特定的诊断 ID 注册修复。

using System.Collections.Immutable;
using System.Composition;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(NoPublicFieldsCodeFixProvider))]
[Shared]
public sealed class NoPublicFieldsCodeFixProvider : CodeFixProvider
{
    public override ImmutableArray<string> FixableDiagnosticIds { get; }
        = ImmutableArray.Create(NoPublicFieldsAnalyzer.DiagnosticId);

    // Enable FixAll support -- RS1016 flags missing FixAllProvider
    public override FixAllProvider GetFixAllProvider()
        => WellKnownFixAllProviders.BatchFixer;

    public override async Task RegisterCodeFixesAsync(CodeFixContext context)
    {
        var root = await context.Document
            .GetSyntaxRootAsync(context.CancellationToken)
            .ConfigureAwait(false);

        var diagnostic = context.Diagnostics[0];
        var diagnosticSpan = diagnostic.Location.SourceSpan;

        var fieldDeclaration = root?.FindToken(diagnosticSpan.Start)
            .Parent?
            .AncestorsAndSelf()
            .OfType<FieldDeclarationSyntax>()
            .FirstOrDefault();

        if (fieldDeclaration is null)
            return;

        // EquivalenceKey enables FixAll grouping -- RS1010/RS1011 require unique keys
        context.RegisterCodeFix(
            CodeAction.Create(
                title: "Convert to property",
                createChangedDocument: ct =>
                    ConvertToPropertyAsync(context.Document, fieldDeclaration, ct),
                equivalenceKey: "ConvertToProperty"),
            diagnostic);
    }

    private static async Task<Document> ConvertToPropertyAsync(
        Document document,
        FieldDeclarationSyntax fieldDeclaration,
        CancellationToken cancellationToken)
    {
        var root = await document
            .GetSyntaxRootAsync(cancellationToken)
            .ConfigureAwait(false);

        var variable = fieldDeclaration.Declaration.Variables[0];
        var propertyName = variable.Identifier.Text;

        // Build auto-property with same type and accessibility
        var property = SyntaxFactory.PropertyDeclaration(
                fieldDeclaration.Declaration.Type,
                propertyName)
            .WithModifiers(fieldDeclaration.Modifiers)
            .WithAccessorList(
                SyntaxFactory.AccessorList(
                    SyntaxFactory.List(new[]
                    {
                        SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
                            .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),
                        SyntaxFactory.AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)
                            .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))
                    })))
            .WithLeadingTrivia(fieldDeclaration.GetLeadingTrivia())
            .WithTrailingTrivia(fieldDeclaration.GetTrailingTrivia());

        var newRoot = root!.ReplaceNode(fieldDeclaration, property);
        return document.WithSyntaxRoot(newRoot);
    }
}
CodeFixProvider 的关键模式
  • EquivalenceKey: 每个 CodeAction 都必须具有唯一的 equivalenceKey,以支持 FixAll(RS1010、RS1011)。使用修复说明或诊断 ID 作为键。
  • 修改 Document 还是 Solution: 修复只修改单个文件时,使用 createChangedDocument。修复必须跨文件重命名符号或添加新文件时,使用 createChangedSolution。
  • 保留 Trivia: 始终将被替换节点的前导和尾随 Trivia 转移过去,以保留格式和注释。
  • FixAllProvider: 对支持批量应用的修复,返回 WellKnownFixAllProviders.BatchFixer。仅在修复需要用户交互或存在跨修复依赖时省略。

DiagnosticSuppressor

DiagnosticSuppressor 有条件地禁止其他分析器报告的诊断。当代码库中存在某种模式,被第三方分析器误报,且 EditorConfig 无法表达该禁止条件时,使用此方式。

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

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class CustomNullCheckSuppressor : DiagnosticSuppressor
{
    private static readonly SuppressionDescriptor SuppressCA1062 = new(
        id: "MYLIB_SUP001",
        suppressedDiagnosticId: "CA1062",
        justification: "Parameter is validated by custom Guard.NotNull helper");

    public override ImmutableArray<SuppressionDescriptor> SupportedSuppressions { get; }
        = ImmutableArray.Create(SuppressCA1062);

    public override void ReportSuppressions(SuppressionAnalysisContext context)
    {
        foreach (var diagnostic in context.ReportedDiagnostics)
        {
            var tree = diagnostic.Location.SourceTree;
            if (tree is null)
                continue;

            var root = tree.GetRoot(context.CancellationToken);
            var node = root.FindNode(diagnostic.Location.SourceSpan);

            // Walk up to the containing method and check for Guard.NotNull call
            var method = node.FirstAncestorOrSelf<Microsoft.CodeAnalysis.CSharp.Syntax.MethodDeclarationSyntax>();
            if (method is null)
                continue;

            var methodText = method.ToFullString();

            // Simplified check -- production code should use semantic analysis
            if (methodText.Contains("Guard.NotNull"))
            {
                context.ReportSuppression(
                    Suppression.Create(SuppressCA1062, diagnostic));
            }
        }
    }
}
何时使用 DiagnosticSuppressor,而非 EditorConfig
方式使用场景
EditorConfig 严重性覆盖对某条规则的所有实例无条件禁止显示
[SuppressMessage] 属性对特定代码位置禁止显示,并附上理由
DiagnosticSuppressor禁止显示取决于代码结构或模式(例如,自定义验证、代码生成标记)

Suppressor 无法报告新诊断——只能禁止显示现有诊断。它们参与相同的分析器管道,并遵循相同的 netstandard2.0 目标框架要求。

版本限制: DiagnosticSuppressor 需要 Roslyn 3.8+。如果分析器包通过多版本打包支持较旧的 Roslyn 版本,请在 #if ROSLYN_3_8_OR_GREATER 后保护 suppressor 注册代码(参见下文的多 Roslyn 版本目标框架设置)。


CodeRefactoringProvider

CodeRefactoringProvider 提供由用户触发的代码转换(灯泡菜单),无需诊断。对于非缺陷的结构改进、模式应用或代码生成,请使用此方式。

using System.Composition;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeRefactorings;
using Microsoft.CodeAnalysis.CSharp.Syntax;

[ExportCodeRefactoringProvider(LanguageNames.CSharp, Name = nameof(ExtractInterfaceRefactoring))]
[Shared]
public sealed class ExtractInterfaceRefactoring : CodeRefactoringProvider
{
    public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context)
    {
        var root = await context.Document
            .GetSyntaxRootAsync(context.CancellationToken)
            .ConfigureAwait(false);

        var node = root?.FindNode(context.Span);
        var classDecl = node?.FirstAncestorOrSelf<ClassDeclarationSyntax>();

        if (classDecl is null)
            return;

        // Only offer when cursor is on the class identifier
        if (!classDecl.Identifier.Span.IntersectsWith(context.Span))
            return;

        context.RegisterRefactoring(
            CodeAction.Create(
                title: $"Extract interface I{classDecl.Identifier.Text}",
                createChangedSolution: ct =>
                    ExtractInterfaceAsync(context.Document, classDecl, ct),
                equivalenceKey: "ExtractInterface"));
    }

    private static async Task<Solution> ExtractInterfaceAsync(
        Document document,
        ClassDeclarationSyntax classDecl,
        CancellationToken cancellationToken)
    {
        // ... build interface from public method signatures, add base type
        // See details.md for complete implementation
        throw new NotImplementedException();
    }
}

请参阅 details.md,了解完整的 ExtractInterfaceAsync 实现(该实现为提取的方法列表添加了 methods 参数)以及 CSharpCodeRefactoringVerifier<T> 测试示例。

CodeRefactoringProvider 的关键模式
  • Span 检查: 仅当光标/选区与相关节点相交时才提供重构。过于宽泛的 Span 匹配会让灯泡菜单杂乱不堪。
  • 无需诊断: 与 CodeFixProvider 不同,重构提供程序不会修复诊断,而是提供可选的转换。
  • 解决方案级更改: 当重构添加文件、重命名符号或修改多个文档时,请使用 createChangedSolution。
  • 属性要求: 使用 [ExportCodeRefactoringProvider(LanguageNames.CSharp)] 和 [Shared](MEF)进行装饰。该提供程序不会通过 SupportedDiagnostics 注册。
  • 测试: 使用 Microsoft.CodeAnalysis.CSharp.CodeRefactoring.Testing 中的 CSharpCodeRefactoringVerifier<T>(参见 details.md)。

面向多个 Roslyn 版本

Analyzer NuGet 包可以随包提供针对不同 Roslyn 版本的多个 DLL,使 Analyzer 能在可用时使用较新的 API,同时保持与旧版编译器的兼容性。

版本边界

Roslyn SDK 在多目标定位时使用以下版本边界:

Roslyn 版本随附于新增的关键 API
3.8VS 16.8 / .NET 5 SDKDiagnosticSuppressor、IOperation 改进
4.2VS 17.2 / .NET 6 SDKRegisterHostObjectAction、增量分析改进
4.4VS 17.4 / .NET 7 SDKSyntaxValueProvider.ForAttributeWithMetadataName(生成器)
4.6VS 17.6 / .NET 8 SDK拦截器预览版、增强的 IOperation 节点
4.8VS 17.8 / .NET 8 U1CollectionExpression 语法支持
4.14VS 17.14 / .NET 10 SDK最新 API 表面
项目配置(Meziantou.Analyzer 模式)

定义 $(RoslynVersion) MSBuild 属性,并使用 Version="$(RoslynVersion).0" 引用 Microsoft.CodeAnalysis.CSharp。对于多版本生成,请使用 Directory.Build.targets 在不同生成配置中参数化 Roslyn 版本(完整项目结构请参见 details.md)。

条件编译常量

在 Directory.Build.targets 中按照 ROSLYN_X_Y 和 ROSLYN_X_Y_OR_GREATER 模式定义常量:

<PropertyGroup Condition="'$(RoslynVersion)' >= '3.8'">
  <DefineConstants>$(DefineConstants);ROSLYN_3_8;ROSLYN_3_8_OR_GREATER</DefineConstants>
</PropertyGroup>

使用这些常量保护特定版本的代码:

#if ROSLYN_4_8_OR_GREATER
    // CollectionExpression operation kind available in Roslyn 4.8+
    context.RegisterOperationAction(AnalyzeCollectionExpression,
        OperationKind.CollectionExpression);
#endif
NuGet 打包路径

多版本 Analyzer 使用特定版本的 NuGet 路径:每个版本使用 analyzers/dotnet/roslyn{version}/cs/,此外还使用 analyzers/dotnet/cs/ 作为低于 3.8 的宿主的回退路径。宿主会从最高匹配的 roslyn{version} 目录中选择 DLL。使用 <None Include="..." Pack="true" PackagePath="analyzers/dotnet/roslyn{version}/cs" /> 项目项,将每个生成的文件放入正确的路径。完整的打包 .csproj 和 pack 验证命令请参见 details.md。

多版本测试矩阵

通过在测试项目中参数化 $(RoslynVersion),分别测试每个 Roslyn 版本的生成结果。测试运行器使用 xUnit v3 和 Microsoft.Testing.Platform v2(MTP2):

# Build and test each Roslyn version (xUnit v3 + MTP2)
for version in 3.8 4.2 4.4; do
  dotnet test -p:RoslynVersion=$version
done

完整的多版本项目结构请参见 details.md(Directory.Build.props、Directory.Build.targets、打包 .csproj,以及使用 xUnit v3 的 GitHub Actions CI 矩阵)。


测试 Analyzer

使用 Microsoft.CodeAnalysis.Testing 基础设施进行简洁、高级别的 Analyzer 测试。相比直接使用 CSharpCompilation 测试,这是首选方案。

必需的 NuGet 包
<PropertyGroup>
  
  <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
</PropertyGroup>

<ItemGroup>
  
  <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" Version="1.1.3" />
  
  <PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.3" />
  
  <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" />
  
  <PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>

迁移说明: 特定于测试框架的包(例如 Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.XUnit)已弃用。请改用通用包和 DefaultVerifier。这样可以让 Roslyn 测试基础设施与测试框架解耦,并兼容 xUnit v3 和 Microsoft.Testing.Platform v2(MTP2)。

仅测试 Analyzer

使用 CSharpAnalyzerVerifier<T> 测试诊断是否被正确报告(或未被报告):

using Microsoft.CodeAnalysis.Testing;
using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpAnalyzerVerifier<
    NoPublicFieldsAnalyzer,
    Microsoft.CodeAnalysis.Testing.DefaultVerifier>;

public class NoPublicFieldsAnalyzerTests
{
    [Fact]
    public async Task PublicField_ReportsDiagnostic()
    {
        var test = """
            public class MyClass
            {
                public int {|MYLIB001:Value|};
            }
            """;

        await Verify.VerifyAnalyzerAsync(test);
    }

    [Fact]
    public async Task PrivateField_NoDiagnostic()
    {
        var test = """
            public class MyClass
            {
                private int _value;
            }
            """;

        await Verify.VerifyAnalyzerAsync(test);
    }

    // Also test: public const fields (no diagnostic), public readonly fields (no diagnostic)
}
诊断标记语法

测试框架使用标记来指示预期的诊断位置:

标记含义
`[text
`{DIAG_ID:text
`{DIAG_ID:text{
分析器 + 代码修复测试

使用 CSharpCodeFixVerifier<TAnalyzer, TCodeFix> 测试完整的分析器到修复流程:

using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpCodeFixVerifier<
    NoPublicFieldsAnalyzer,
    NoPublicFieldsCodeFixProvider,
    Microsoft.CodeAnalysis.Testing.DefaultVerifier>;

public class NoPublicFieldsCodeFixTests
{
    [Fact]
    public async Task PublicField_FixConvertsToProperty()
    {
        var test = """
            public class MyClass
            {
                public int {|MYLIB001:Value|};
            }
            """;

        var fixedCode = """
            public class MyClass
            {
                public int Value { get; set; }
            }
            """;

        await Verify.VerifyCodeFixAsync(test, fixedCode);
    }
}
多文件测试场景

对于多文件测试,使用 Verify.Test 类和 TestState.Sources 添加多个带名称的源文件。在每个文件中使用标准标记语法设置预期诊断。使用 test.RunAsync(),而不是静态的 VerifyAnalyzerAsync 简写。


NuGet 打包

分析器以 NuGet 包的形式分发,并使用特定的目录布局。程序集应放在 analyzers/dotnet/cs/ 下,而不是 lib/ 下。

包布局
MyAnalyzers.nupkg
  analyzers/
    dotnet/
      cs/
        MyAnalyzers.dll           # Analyzer assembly
        MyAnalyzers.CodeFixes.dll # Code fix assembly (optional, separate)
  lib/
    netstandard2.0/
      _._                        # Empty placeholder (no runtime dependency)
项目配置

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

<ItemGroup>
  <None Include="$(OutputPath)\$(AssemblyName).dll"
        Pack="true"
        PackagePath="analyzers/dotnet/cs" />
</ItemGroup>
分离分析器和代码修复程序集

对于包含多个分析器的 NuGet 包,将分析器与代码修复分离可以缩短 IDE 加载时间。IDE 会延迟加载代码修复程序集(仅在用户请求修复时加载),而分析器程序集则会立即加载。创建两个项目(MyAnalyzers 和 MyAnalyzers.CodeFixes),然后使用 <None Include="..." Pack="true" PackagePath="analyzers/dotnet/cs" /> 项将两个 DLL 都打包到 analyzers/dotnet/cs/ 中。

打包验证

打包后,使用 unzip -l ./bin/Release/MyAnalyzers.1.0.0.nupkg | grep analyzers/ 验证布局(nupkg 文件是 zip 归档文件)。有关其他通用打包概念,请参阅 [skill:dotnet-csharp-source-generators](analyzers/dotnet/cs/ 布局对于分析器和源生成器相同)。


性能最佳实践

分析器会在编辑时实时运行。性能不佳会影响所有使用该分析器的用户的 IDE 体验。

无分配回调

避免在热点回调中进行分配。每个 RegisterSyntaxNodeAction 回调都会针对每个节点运行,每次击键都可能触发数千次:

// BAD: resolves type on every symbol callback (per-symbol overhead)
context.RegisterSymbolAction(symbolCtx =>
{
    // GetTypeByMetadataName is called for EVERY symbol in the compilation
    var disposableType = symbolCtx.Compilation
        .GetTypeByMetadataName("System.IDisposable");
    if (disposableType is null) return;

    Analyze(symbolCtx, disposableType);
}, SymbolKind.NamedType);

// GOOD: resolve state once per compilation, closure allocated once
context.RegisterCompilationStartAction(compilationCtx =>
{
    var importantType = compilationCtx.Compilation
        .GetTypeByMetadataName("System.IDisposable");
    if (importantType is null) return;

    // One closure per compilation -- acceptable cost
    compilationCtx.RegisterSymbolAction(
        symbolCtx => Analyze(symbolCtx, importantType),
        SymbolKind.NamedType);
});

注意: 在 RegisterCompilationStartAction 中每次编译只分配一次闭包是可以接受的。反模式是在每节点或每符号回调中解析类型或分配闭包,因为这类操作的成本会随着数千次调用而累积。

基于符号进行筛选

当两种方式都可行时,优先使用基于符号的分析,而不是基于语法的分析。符号分析基于编译器解析后的模型运行,并可避免重新解析:

// Prefer: RegisterSymbolAction for declared-symbol checks
context.RegisterSymbolAction(ctx =>
{
    var method = (IMethodSymbol)ctx.Symbol;
    if (method.ReturnsVoid && method.Parameters.Length == 0) { /* ... */ }
}, SymbolKind.Method);

// Avoid: RegisterSyntaxNodeAction when symbol analysis suffices
// Syntax analysis requires manually resolving types and handling aliases
为 SupportedDiagnostics 使用 ImmutableArray

将 SupportedDiagnostics 缓存为 ImmutableArray<DiagnosticDescriptor> 字段。运行时会频繁调用此属性:

// GOOD: single allocation, cached
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
    = ImmutableArray.Create(Rule1, Rule2, Rule3);

// BAD: allocates a new array on every access
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
    => ImmutableArray.Create(Rule1, Rule2, Rule3);
其他性能指南
  • 启用并发执行: 除非分析器具有共享可变状态(而它本不应具有),否则始终调用 context.EnableConcurrentExecution()。
  • 避免在字段中存储每次编译的状态: 不要将每次编译的数据存储在分析器实例字段中(RS1008)。使用 RegisterCompilationStartAction 将状态限定在单次编译范围内。
  • 尽早在语法谓词中筛选: 使用 RegisterSyntaxNodeAction 时,注册尽可能具体的 SyntaxKind,以尽量减少回调频率。
  • 避免使用 Compilation.GetSemanticModel(): 改用分析上下文提供的 SemanticModel(RS1030)。

常见元诊断(RS 系列)

Microsoft.CodeAnalysis.Analyzers 包会针对分析器代码本身报告 RS 系列诊断。这些诊断对于在编译时发现编写错误非常有用。通过项目文件中的 EnforceExtendedAnalyzerRules 启用它们。

常见的 RS 诊断
ID标题检测内容
RS1001缺少 DiagnosticAnalyzerAttribute分析器类缺少 [DiagnosticAnalyzer(LanguageNames.CSharp)]
RS1004建议添加语言支持分析器支持 C#,但不支持 VB(提示信息)
RS1007提供可本地化的参数DiagnosticDescriptor 字符串应使用 LocalizableResourceString
RS1008避免存储每次编译专属的数据保存编译专属数据的实例字段会破坏并发执行
RS1010使用唯一的 EquivalenceKey 创建代码操作CodeAction 缺少等效键,无法支持 FixAll
RS1015提供非空的 helpLinkUriDiagnosticDescriptor 的帮助链接为空或未设置
RS1016代码修复提供程序应支持 FixAll缺少 GetFixAllProvider() 重写
RS1017DiagnosticId 必须是非空常量诊断 ID 不是编译时常量
RS1022不要使用 Workspaces 程序集中的类型分析器引用了 Microsoft.CodeAnalysis.Workspaces(并非所有宿主都提供该程序集)
RS1024应使用相等比较器比较符号比较符号时使用了 ==,而非 SymbolEqualityComparer
RS1026启用并发执行缺少 context.EnableConcurrentExecution() 调用
RS1029不要使用保留的诊断 ID使用了 CA、CS、IDE 或其他平台保留的前缀
RS1030不要调用 Compilation.GetSemanticModel()应改用分析上下文中的 SemanticModel
RS1035不要使用分析器禁用的 API使用了并非所有分析器宿主环境都支持的 API
RS1041编译器扩展应以 netstandard2.0 为目标框架项目目标框架不是 netstandard2.0
发布跟踪(RS2000 系列)

对于成熟的分析器,启用发布跟踪来管理诊断 ID 的生命周期:

ID标题检测内容
RS2000将分析器诊断 ID 添加到分析器发布记录中新诊断 ID 未在发布文件中跟踪
RS2001确保分析器诊断 ID 的条目为最新发布跟踪文件已过期
RS2008启用分析器发布跟踪项目应选择启用发布跟踪

发布跟踪使用 AnalyzerReleases.Shipped.md 和 AnalyzerReleases.Unshipped.md 文件来跟踪已发布的诊断 ID。


参考资料

评论

支持 Markdown