Read OSS

从源代码到 AST:Scanner、Parser 与节点系统

高级

前置知识

  • 第 1 篇:架构与代码库全景图
  • 了解词法分析器/tokenizer 的基本概念,以及递归下降解析原理
  • 熟悉 TypeScript 语法(泛型、JSX、装饰器、模板字面量类型)

从源代码到 AST:Scanner、Parser 与节点系统

TypeScript 的每一次编译都从一段源代码字符串开始,至少在前端阶段,最终会产出一个 SourceFile——一棵完整的抽象语法树。这个过程由两个相互协作的模块共同完成:Scanner 负责将文本切分为 token,Parser 则将这些 token 组装成语法树。两者合计约 15,000 行代码,处理的是编程语言领域最复杂的语法之一——TypeScript 是 JavaScript 的超集,在此之上叠加了泛型、JSX、装饰器、模板字面量类型和 satisfies 表达式等特性。

本文将深入剖析这两个模块,梳理为每个节点打标签的 SyntaxKind 分类体系,并描绘组织 AST 的 Node 类型层次结构。

SyntaxKind:统一的节点分类机制

TypeScript AST 中的所有内容都通过一个 const enum 来分类:SyntaxKind。其定义从 src/compiler/types.ts#L40 开始,从 trivia 和 token 起步,经过关键字,最终覆盖所有可能的表达式、语句、声明和 JSDoc 节点。

flowchart TD
    SK["SyntaxKind (const enum)"]
    SK --> Trivia["Trivia (0-8)<br/>Comments, Whitespace, Shebang"]
    SK --> Literals["Literals (9-15)<br/>NumericLiteral, StringLiteral, RegExp"]
    SK --> Punctuation["Punctuation (16-80)<br/>Braces, Operators, Arrows"]
    SK --> Keywords["Keywords (81-165)<br/>if, class, const, type, ..."]
    SK --> TypeNodes["Type Nodes<br/>TypeReference, UnionType, ConditionalType"]
    SK --> Expressions["Expressions<br/>CallExpression, BinaryExpression, ..."]
    SK --> Statements["Statements<br/>IfStatement, ForStatement, ReturnStatement"]
    SK --> Declarations["Declarations<br/>ClassDeclaration, FunctionDeclaration, ..."]
    SK --> JSDoc["JSDoc Nodes<br/>JSDocComment, JSDocTag, JSDocTypeExpression"]

有一条关键的设计规则:token > SyntaxKind.Identifier 意味着该 token 是一个关键字。这个简单的比较在 parser 和 scanner 中被广泛用于快速检测关键字。

SyntaxKind 枚举还配有一组联合类型别名,用于将相关的 kind 归组。TypeNodeSyntaxKind 收录了所有出现在类型位置的语法 kind;TokenSyntaxKind 涵盖 scanner 所能产出的全部内容;JsxTokenSyntaxKindJSDocSyntaxKind 则对应各自的专用扫描模式。这些类型起到约束作用,限定了 scanner 在不同上下文中的返回值范围。

SyntaxKind 并列的还有 NodeFlags 枚举,用于存储每个节点的元数据:变量是 let/const/using、节点是否在转换过程中被合成、是否在 awaityield 上下文中解析,以及追踪错误和 import 存在情况的标志位。

Scanner:有状态的 tokenization

Scanner 定义于 src/compiler/scanner.ts,约 4,100 行代码,全部封装在一个闭包工厂函数中。

Scanner 接口

Scanner 接口直观地揭示了其设计思路:它是一个在文本上移动的有状态游标。调用 scan() 向前推进到下一个 token,然后通过 getToken()getTokenStart()getTokenEnd()getTokenValue() 等方法查询当前位置的状态。这里没有 token 数组——parser 每次只拉取一个 token。

接口还提供了一系列 reScan* 方法:reScanGreaterToken()reScanSlashToken()reScanTemplateToken()reScanJsxToken() 等。这些方法的存在是因为 TypeScript 的语法是上下文相关的——> 可能是大于运算符,也可能是类型参数列表的结束;/ 可能是除法,也可能是正则表达式的开始;} 可能关闭一个代码块,也可能开启一段模板字面量。

createScanner:闭包模式

flowchart LR
    CS["createScanner()"] --> Closure["Closure with var locals"]
    Closure --> pos["var pos: number"]
    Closure --> endVar["var end: number"]
    Closure --> token["var token: SyntaxKind"]
    Closure --> tokenValue["var tokenValue: string"]
    Closure --> scan["scan() → SyntaxKind"]
    Closure --> reScan["reScan*() methods"]

createScanner() 通过闭包捕获 var 局部变量来维护位置、token 状态和错误处理信息。函数开头有一段我们已经熟悉的 TDZ 规避注释:

// Why var? It avoids TDZ checks in the runtime which can be costly.
// See: https://github.com/microsoft/TypeScript/issues/52924

在闭包内使用 var 而非 let,是为了规避运行时的暂时性死区(TDZ)检查开销。对于每次编译都会被调用数百万次的热函数来说,这一改动能带来可量化的性能提升。整个编译器都沿用了这一模式。

核心的 scan() 函数是一个对当前字符编码进行分支的大型 switch 语句,处理范围涵盖简单的单字符 token({}())、多字符运算符(===>>>=)、带转义序列的字符串字面量、模板字面量片段、数字字面量(包括 0x0o0b 和 BigInt 的 n 后缀),以及正则表达式。

扫描模式

Scanner 支持多种模式以适应不同的语法上下文:

  • 普通模式:标准的 TypeScript/JavaScript tokenization
  • JSX 模式scanJsxToken()reScanJsxToken() 处理 JSX 文本内容与元素边界
  • 模板字面量模式reScanTemplateToken() 在模板片段与嵌入表达式之间切换
  • 正则表达式模式reScanSlashToken()/ 重新解释为正则表达式分隔符,并对正则语法(包括命名分组和 Unicode 属性转义)进行完整验证
  • JSDoc 模式scanJsDocToken() 处理 JSDoc 注释所需的简化 tokenization

提示: 当 parser 遇到有歧义的 token 时,它不会回退 scanner,而是调用对应的 reScan* 方法在当前上下文中重新解释该 token。这比维护 scanner 快照的开销要低得多。

Parser:递归下降构建 AST

Parser 位于 src/compiler/parser.ts,约 10,800 行递归下降解析代码,主要的公开入口为 createSourceFile()

createSourceFile:入口函数

createSourceFile() 接收文件名、源代码文本、语言版本和可选的 script kind,然后委托给 Parser.parseSourceFile() 处理。该函数负责区分 JSON 文件(以 ScriptKind.JSON 解析)和普通 TypeScript/JavaScript 文件,并为 Node.js 模块解析(ESM vs CJS)设置 impliedNodeFormat

整个解析过程被性能打点(beforeParse/afterParse)和可选的 tracing 所包裹——这与编译器中用于性能分析的统一插桩模式一致。

递归下降结构

Parser 遵循标准的递归下降模式:每条语法规则对应一个函数。parseStatement() 根据当前 token 分发到 parseIfStatement()parseReturnStatement()parseVariableStatement() 等。表达式解析通过 parseBinaryExpressionOrHigher() 实现优先级爬升。类型解析也有自己的并行层次:parseType()parseUnionTypeOrHigher()parseIntersectionTypeOrHigher() 等。

sequenceDiagram
    participant E as External Caller
    participant P as Parser
    participant S as Scanner
    participant F as NodeFactory

    E->>P: createSourceFile(fileName, text)
    P->>S: createScanner(...)
    P->>S: scan() → first token
    loop For each statement
        P->>S: getToken()
        P->>P: parseStatement()
        P->>F: factory.createIfStatement(...)
        P->>S: scan() → next token
    end
    P->>F: factory.createSourceFile(statements)
    P-->>E: SourceFile

错误恢复与推测解析

Parser 必须具备足够的健壮性——即使面对不完整的代码,也要能产出可用的 AST,因为语言服务依赖它来提供编辑器功能。为此,Parser 采用了几种策略:

缺失 token 处理:当期望的 token 未出现时,Parser 会在当前位置创建一个"缺失"节点——一个宽度为零的合成节点——并上报错误,然后继续解析。

前瞻与推测解析:对于有歧义的语法(<T> 是类型参数列表还是 JSX 元素?),Parser 使用 lookAhead()tryParse()lookAhead() 尝试向前解析,若推测失败则回滚 scanner 状态;tryParse() 逻辑相同,但成功时会提交结果。

列表解析中的错误恢复:Parser 的列表解析函数(用于语句列表、参数列表等)能够跳过不属于当前上下文的 token,通过一组预期的 token kind 来判断何时停止或重新同步。

Node 层次结构与 SourceFile

SyntaxKind 为每个节点分类,Parser 负责创建它们,剩下的问题是类型系统如何组织这些 AST 节点。

横切关注点的子接口

并非每个 Node 都具备相同的能力。TypeScript AST 没有使用一个带大量可选属性的单一接口,而是采用了子接口的方式:

classDiagram
    class Node {
        +kind: SyntaxKind
        +flags: NodeFlags
        +parent: Node
        +pos: number
        +end: number
    }
    class JSDocContainer {
        +jsDoc: JSDoc[]
    }
    class LocalsContainer {
        +locals: SymbolTable
        +nextContainer: HasLocals
    }
    class FlowContainer {
        +flowNode: FlowNode
    }
    class Declaration {
        +symbol: Symbol
        +localSymbol: Symbol
    }
    Node <|-- JSDocContainer
    Node <|-- LocalsContainer
    Node <|-- FlowContainer
    Node <|-- Declaration

LocalsContainer 节点拥有 locals 符号表——这类节点会引入新的作用域(函数、代码块、模块)。FlowContainer 节点参与控制流分析。Declaration 节点关联一个 symbol

这一拆分源于一个专项 PR,目的是将 symbollocalsflowNode 从基础 Node 接口移到真正使用它们的子接口上,从而减少那些永远不需要这些字段的节点类型的内存浪费。

SourceFile 接口

SourceFile 同时继承了 DeclarationLocalsContainer,既是 AST 的根节点,也是文件级符号的容器。它持有以下信息:

  • statements——顶层语句列表
  • fileNamepath——文件标识
  • text——原始源代码文本(用于错误报告和语言服务)
  • languageVersionlanguageVariant——影响解析行为
  • isDeclarationFile——.d.ts 文件会受到特殊处理
  • impliedNodeFormat——Node.js 模块解析的 ESM vs CJS
  • 模块标识符:externalModuleIndicatorcommonJsModuleIndicator

SourceFile 是整个编译器的基本工作单元。Program 管理一组 SourceFile 对象;类型检查器逐个处理 SourceFile;代码生成器也独立地对每个 SourceFile 进行转换和输出。

NodeFactory:类型安全的 AST 构建

Parser 不通过 new 直接创建节点,而是使用 NodeFactory——一套完整的类型化工厂函数。factory.createIfStatement(expression, thenStatement, elseStatement) 创建一个 IfStatement 节点,自动设置正确的 kind、类型化的子节点,并预先计算好 TransformFlags

NodeFactory 不仅服务于 Parser,也是转换器 pipeline(第 5 篇)的核心,后者在降级过程中会创建新的合成节点。统一使用工厂函数确保了节点的形状和标志位始终一致,无论来自解析还是转换。

提示: 在阅读转换代码时,留意 factory.create*factory.update* 的调用。update 系列函数只在子节点确实发生变化时才创建新节点,从而实现高效的结构共享。

下一步

至此,源代码已被转换为一棵完整的 AST——由 SyntaxKind 分类的 Node 对象所构成的树形结构。但 AST 本身并不能告诉你名称的含义或作用域的嵌套关系。在第 3 篇中,我们将跟随 Binder 遍历这棵 AST,看它如何创建 Symbol、填充符号表,并构建让类型收窄成为可能的控制流图。