Jazor.Analyzer 实战:从零写一个 Roslyn 诊断分析器

Jazor.Analyzer 实战:从零写一个 Roslyn 诊断分析器

不是玩具,是真的在生产中拦截非法输入的分析器——JAZOR001 到 JAZOR004,每一个诊断规则背后都是对"什么能进编译域"的边界定义。

一、DiagnosticAnalyzer 是什么

Roslyn 的诊断分析器(DiagnosticAnalyzer)是编译器插件——它在编译期间运行,分析你的代码,产生诊断(Diagnostic)。这些诊断可以是错误、警告、信息,在 IDE 里表现为红色/绿色波浪线。

写一个分析器的步骤是固定的:

  1. 继承 DiagnosticAnalyzer
  2. [DiagnosticAnalyzer(LanguageNames.CSharp)] 特性
  3. 声明 SupportedDiagnostics(你产出的诊断 ID 列表)
  4. 重写 Initialize,注册对特定语法/操作节点的回调
  5. 在回调里检查代码,需要时报 Diagnostic

Jazor 的 Analyzersrc/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 不能冲突。
  • SeverityErrorWarningInfoHidden。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 代表了一种"可能出现不支持的类型的场景"。比如:

  • ObjectCreationnew UnsupportedType() 需要拦截
  • FieldReference — 访问不支持的类型的字段需要拦截
  • Conversion — 隐式转换到不支持的类型需要拦截
  • CollectionExpressionList<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 的核心步骤:

  1. 继承 DiagnosticAnalyzer + 声明 SupportedDiagnostics
  2. Initialize 里注册 RegisterOperationAction / RegisterSymbolAction
  3. 在回调里检查代码,用 context.ReportDiagnostic 报错

Jazor 的分析器在这套基础框架之上加了几个生产级别的细节:

  • 通过 IsInsideEcmascriptContext 隔离检查范围
  • 在擦除位置做诊断(比编译器更严格)
  • 使用点裁决和符号级别检查互补
  • 四条规则覆盖类型支持、模糊运行时类型过滤,以及 spread 属性的合法性和命名冲突

如果你要给自己的库加一个分析器,Jazor 的 Analyzer.cs 是一个可以直接参考的模板。

项目地址:https://github.com/devhxj/Jazor

posted @ 2026-05-11 15:30  形而下晓寒  阅读(15)  评论(0)    收藏  举报