关于 dotnet-roslyn-analyzers
面向 .NET 开发者的技能,指导编写自定义 Roslyn 分析器、代码修复、重构和诊断抑制器,涵盖实现、测试、打包及多版本兼容。
主要功能
- 01分析器项目设置与诊断规则
- 02分析上下文注册模式
- 03代码修复与重构
- 04诊断抑制指导
- 05多版本定位与打包
- 06使用 Microsoft.CodeAnalysis.Testing 测试
适用场景
- 01创建自定义 C# 分析器
- 02为诊断添加自动修复
- 03构建无需诊断的重构
- 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 | 已声明的符号 | 检查符号级属性(可访问性、类型、特性) |
RegisterOperationAction | IL 级操作 | 分析与语法无关的语义操作(赋值、调用) |
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.8 | VS 16.8 / .NET 5 SDK | DiagnosticSuppressor、IOperation 改进 |
| 4.2 | VS 17.2 / .NET 6 SDK | RegisterHostObjectAction、增量分析改进 |
| 4.4 | VS 17.4 / .NET 7 SDK | SyntaxValueProvider.ForAttributeWithMetadataName(生成器) |
| 4.6 | VS 17.6 / .NET 8 SDK | 拦截器预览版、增强的 IOperation 节点 |
| 4.8 | VS 17.8 / .NET 8 U1 | CollectionExpression 语法支持 |
| 4.14 | VS 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 | 提供非空的 helpLinkUri | DiagnosticDescriptor 的帮助链接为空或未设置 |
| RS1016 | 代码修复提供程序应支持 FixAll | 缺少 GetFixAllProvider() 重写 |
| RS1017 | DiagnosticId 必须是非空常量 | 诊断 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。