Jazor.Analyzer 实战:从零写一个 Roslyn 诊断分析器
Jazor.Analyzer 实战:从零写一个 Roslyn 诊断分析器
不是玩具,是真的在生产中拦截非法输入的分析器——JAZOR001 到 JAZOR004,每一个诊断规则背后都是对"什么能进编译域"的边界定义。
一、DiagnosticAnalyzer 是什么
Roslyn 的诊断分析器(DiagnosticAnalyzer)是编译器插件——它在编译期间运行,分析你的代码,产生诊断(Diagnostic)。这些诊断可以是错误、警告、信息,在 IDE 里表现为红色/绿色波浪线。
写一个分析器的步骤是固定的:
- 继承
DiagnosticAnalyzer - 加
[DiagnosticAnalyzer(LanguageNames.CSharp)]特性 - 声明
SupportedDiagnostics(你产出的诊断 ID 列表) - 重写
Initialize,注册对特定语法/操作节点的回调 - 在回调里检查代码,需要时报
Diagnostic
Jazor 的 Analyzer 在 src/Jazor.Analyzer/Analyzer.cs 里,它遵守了上面每一条。
二、声明诊断规则:四个 ID,四种场景
Jazor 的 Analyzer 定义了四条诊断规则:
private const string DiagnosticId = "JAZOR001";
private const string AmbiguousRuntimeTypeFilterDiagnosticId = "JAZOR002";
private const string InvalidSpreadUsageDiagnosticId = "JAZOR003";
private const string ConflictingSpreadPropertyNameDiagnosticId = "JAZOR004";
每条规则通过 DiagnosticDescriptor 注册:
private static readonly DiagnosticDescriptor Rule = new(
DiagnosticId, // "JAZOR001"
"Jazor", // 标题
"[{0}] is not support in ECMAScript", // 消息模板
"Security", // 分类
DiagnosticSeverity.Error, // 严重级别
isEnabledByDefault: true); // 默认启用
关键字段:
- DiagnosticId:唯一标识符,格式通常是
<前缀><数字>。NuGet 包里所有分析器的 ID 不能冲突。 - Severity:
Error、Warning、Info、Hidden。Jazor 的全是Error——不符合白名单规则就是编译错误。 - isEnabledByDefault:用户可以在
.editorconfig里禁用特定诊断。
然后声明你支持哪些诊断:
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
=> [Rule, AmbiguousRuntimeTypeFilterRule, InvalidSpreadUsageRule, ConflictingSpreadPropertyNameRule];
这一步不能跳过。 如果分析器产生的诊断不在 SupportedDiagnostics 里,Roslyn 会忽略它。
三、Initialize:往编译管道里注册回调
分析器的入口方法:
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
// 在 IOperation 层面注册回调
context.RegisterOperationAction(AnalyzeOperation, AnalysisOperationKinds);
// 在 Symbol 层面注册回调(用于属性级别的检查)
context.RegisterSymbolAction(AnalyzeSpreadPropertyUsage, SymbolKind.Property);
}
逐行解释:
ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None):跳过自动生成的代码(Source Generator 输出等)。如果你的分析器也要检查生成代码,改用Analyze。EnableConcurrentExecution():允许 Roslyn 在多线程环境下并行调用你的分析器。如果你用了共享可变状态就不要开——Jazor 的分析器是无状态的,所以可以开。RegisterOperationAction:在 IOperation 层面注册——每次编译遇到指定的OperationKind时回调你的方法。RegisterSymbolAction:在符号层面注册——每次编译遇到指定类型的符号(类、方法、属性等)时回调。
四、RegisterOperationAction:在什么节点上触发
Jazor 注册了这些 OperationKind:
internal static readonly ImmutableArray<OperationKind> AnalysisOperationKinds =
[
OperationKind.FieldInitializer,
OperationKind.PropertyInitializer,
OperationKind.ParameterInitializer,
OperationKind.VariableDeclarationGroup,
OperationKind.ObjectCreation,
OperationKind.ArrayCreation,
OperationKind.CollectionExpression,
OperationKind.Invocation,
OperationKind.BinaryOperator,
OperationKind.FieldReference,
OperationKind.PropertyReference,
OperationKind.MethodReference,
OperationKind.IsType,
OperationKind.Conversion,
OperationKind.DelegateCreation,
OperationKind.EventReference,
OperationKind.AnonymousObjectCreation,
OperationKind.Tuple,
// ... 还有更多
];
这个列表不是随便列的。每一个 OperationKind 代表了一种"可能出现不支持的类型的场景"。比如:
ObjectCreation—new UnsupportedType()需要拦截FieldReference— 访问不支持的类型的字段需要拦截Conversion— 隐式转换到不支持的类型需要拦截CollectionExpression—List<Unsupported>的集合表达式需要拦截
五、AnalyzeOperation 回调:核心检查逻辑
这是 JAZOR001 的核心逻辑——检查一个类型是否被 Jazor 支持:
private static void AnalyzeOperation(OperationAnalysisContext context)
{
var operation = context.Operation;
// 只检查被 [ECMAScript] / [ECMAScriptModule] 标记的类
if (!IsInsideEcmascriptContext(operation))
return;
// 遍历操作中涉及的所有类型
// 如果某个闭合的具体类型不在白名单里 → 报告 JAZOR001
var unsupportedType = FindFirstUnsupportedExternalType(operation);
if (unsupportedType is not null)
{
context.ReportDiagnostic(Diagnostic.Create(
Rule,
operation.Syntax.GetLocation(),
unsupportedType.ToDisplayString()));
}
}
几个关键设计决策:
1. 只检查被 [ECMAScript] 标记的类。
普通 C# 项目不会被分析器影响——它只在你写的 Jazor 模块上生效。这个隔离通过 IsInsideEcmascriptContext(operation) 实现:沿着 IOperation 的 Parent 链往上走,找到包含类型,检查这个类型是否有 ECMAScript 特性。
2. 在擦除位置也检查。
List<Unsupported>、Unsupported[]、Task<Unsupported>——这些是"擦除位置"(erased positions),类型参数在编译后不存在。Jazor 的编译器允许它们存在(因为 T 不被 lowering),但 Analyzer 比 Compiler 更严格——它在擦除位置也做诊断。这是故意的设计不对称:Analyzer 可以更严格,因为它的目标是早期拦截。
3. 不追踪泛型参数来源。
T 本身总是允许通过——分析器不追踪 T 的真实来源。这是为了避免在泛型代码里产生大量误报。如果一个 T 最终在运行时被具化为不支持的类型,编译器的 SemanticWalker 会在 lowering 时捕获。
六、RegisterSymbolAction:属性级别的检查
除了 IOperation 层面的检查,Jazor 的分析器还注册了一个符号级别的回调来检查 record 属性的 [Spread] 用法:
context.RegisterSymbolAction(AnalyzeSpreadPropertyUsage, SymbolKind.Property);
AnalyzeSpreadPropertyUsage 检查两个条件:
JAZOR003:[Spread]只能用在实例 record 属性上。如果用在静态属性、索引器或非 record 类型上 → 报错。JAZOR004:[Spread]不能和显式的 JavaScript 属性名注解([Description("@#xxx")])同时使用——因为 spread 的语义是把属性"展开"到父对象,不能同时又指定一个固定的 JS 属性名。
这种符号级别的检查在 IOperation 层面是做不了的——[Spread] 是一个 Attribute,它在编译早期就附着在符号上,不需要等到操作树生成。
七、ReportDiagnostic:如何产生一条诊断
产生诊断的 API 很简单:
context.ReportDiagnostic(Diagnostic.Create(
Rule, // 使用哪个规则
operation.Syntax.GetLocation(), // 波浪线标在哪里
unsupportedType.ToDisplayString() // 消息模板的参数(替换 {0})
));
GetLocation() 是关键——它决定了 IDE 里红色波浪线出现在哪里。你可以精确到 token、节点或整个语句。Jazor 通常选择整个操作节点(如整个 new UnsupportedType())作为位置,因为错误信息需要对用户足够明显。
八、分析器 vs 编译器的分工
Jazor 有两条检查链:Analyzer 做前置收口,SemanticWalker 做最终裁决。二者的区别:
| Analyzer | SemanticWalker | |
|---|---|---|
| 运行时机 | 编译早期,IDE 实时 | lowering 阶段 |
| 检查粒度 | 对闭合具体外部类型可在擦除位置提前诊断 | 使用点裁决 |
| 严格度 | 可以更严格 | 精确——只在需要 lowering 时拒绝 |
| 对泛型 T 的态度 | 放过 T,诊断具体类型 | 同样放过 T |
| 能报什么 | JAZOR001-JAZOR004 | 编译错误 |
这种分工有明确的设计意图:Analyzer 宁严勿松,Compiler 精确裁决。 Analyzer 在写代码阶段就拦截问题——用户在 IDE 里还没编译就能看到错误。Compiler 在 lowering 阶段做最终判断——只在真正需要生成代码时拒绝不支持的用法。
九、小结
写一个 Roslyn DiagnosticAnalyzer 的核心步骤:
- 继承
DiagnosticAnalyzer+ 声明SupportedDiagnostics - 在
Initialize里注册RegisterOperationAction/RegisterSymbolAction - 在回调里检查代码,用
context.ReportDiagnostic报错
Jazor 的分析器在这套基础框架之上加了几个生产级别的细节:
- 通过
IsInsideEcmascriptContext隔离检查范围 - 在擦除位置做诊断(比编译器更严格)
- 使用点裁决和符号级别检查互补
- 四条规则覆盖类型支持、模糊运行时类型过滤,以及 spread 属性的合法性和命名冲突
如果你要给自己的库加一个分析器,Jazor 的 Analyzer.cs 是一个可以直接参考的模板。

浙公网安备 33010602011771号