- 语言运行时
- 编译器
- 移动开发
【免费下载链接】hermes
A JavaScript engine optimized for running React Native.
导读
hermes_semantic_analysis是 Hermes 项目中用 Rust 实现的 JavaScript 语义分析 crate,负责在hermes_estree生成的 AST 之上完成名称解析(name resolution)与作用域信息(scope information)的构建,为后续的 Flow/TypeScript 类型检查等高级语义功能铺路。本文将以 crate 的 README 为主线,结合其 analyzer.rs、scope_manager.rs、scope_view.rs 与 analysis_test.rs 源码,完整还原其架构设计、核心数据结构、两阶段分析算法、TDZ(暂时性死区)检测原理与测试方式,帮助读者理解"如何用 Rust 给 JavaScript 做静态语义分析"这一经典问题在 Hermes 中的落地实现。
模块定位:在 hermes_estree AST 之上做语义分析
crate 的 README 用三句话划定了它的边界:
Implements semantic analysis of JavaScript name resolution and scope information over the
hermes_estreeAST. Eventually this analysis will be extended to cover Flow and TypeScript in addition to the core JS and JSX language.NOTE: this analysisassumes strict modeand does not support legacy non-strict semantics.
从中可以提炼出三个关键定位:
- 输入是
hermes_estree的 AST。该 crate 不自己做语法解析,而是消费同工作区内hermes_estreecrate 定义的 ESTree 节点类型(Program、Statement、Expression、Pattern等),通过hermes_estree::Visitortrait 完成遍历。 - 输出是"名称解析 + 作用域信息",即把每一个标识符引用(Reference)绑定到它对应的声明(Declaration),并把所有声明组织进正确的词法作用域(Scope)层级中。
- 当前阶段只覆盖核心 JS 与 JSX,且假定严格模式。非严格模式下的历史遗留语义(如
with语句、隐式全局变量等)不在支持范围内,未来才计划扩展到 Flow 与 TypeScript。
从仓库结构看,该 crate 位于 unsupported/hermes/crates/ 下的 Rust workspace 中,与hermes_parser、hermes_estree、hermes_diagnostics、hermes_utils等并列,属于 Hermes "Rust bindings"(见 unsupported/hermes/Cargo.toml 的 workspace 声明)中的语义分析环节,其位置也呼应了 README 中 "unsupported" 前缀所代表的试验性/演进中属性。
四个核心语义实体:Scope、Declaration、Reference、Label
scope_manager.rs 定义了语义分析的全部核心数据结构。整个分析结果由四种实体组成,每种实体都有对应的强类型 ID(newtype,内部是usize):
| 实体 | ID 类型 | 语义 | 关键字段 |
|---|---|---|---|
| Scope(作用域) | ScopeId | 词法作用域,形成树状层级 | kind、parent、declarations、references、children |
| Declaration(声明) | DeclarationId | 一个标识符的声明(let/const/var/function/class/import…) | kind、name、scope |
| Reference(引用) | ReferenceId | 一次对标识符的读取/写入 | kind(Read/Write/ReadWrite)、declaration(绑定目标)、scope |
| Label(标签) | LabelId | break/continue 的跳转目标 | kind(Loop/Other)、scope、name |
其中Scope的定义(scope_manager.rs#L472-L480)直接体现了"作用域树"的建模方式:每个作用域持有自己的声明表(IndexMap<String, DeclarationId>,按名字快速查找)、引用列表和子作用域列表,并通过parent指回父作用域:
pub struct Scope { pub id: ScopeId, pub kind: ScopeKind, pub parent: Option<ScopeId>, pub declarations: IndexMap<String, DeclarationId>, pub references: Vec<ReferenceId>, pub children: Vec<ScopeId>, }ScopeManager(scope_manager.rs#L24-L41)作为总容器,用若干Vec顺序存储全部实体,同时维护四张"AST 节点 → 语义信息"的映射表:
pub struct ScopeManager { root: ScopeId, globals: IndexMap<String, DeclarationId>, scopes: Vec<Scope>, labels: Vec<Label>, declarations: Vec<Declaration>, references: Vec<Reference>, node_scopes: IndexMap<AstNode, ScopeId>, node_labels: IndexMap<AstNode, LabelId>, node_declarations: IndexMap<AstNode, DeclarationId>, node_references: IndexMap<AstNode, ReferenceId>, diagnostics: Vec<Diagnostic>, }注意node_scopes等映射的键是AstNode,它本质上是 AST 节点的指针地址(见 scope_manager.rs#L541-L557 的AstNode(PointerAddress)),这样可以在不修改 AST 的情况下,把语义信息"挂"回任意 AST 节点——例如node_declaration(node)可以查询某个标识符节点对应哪个声明,break_label(node)可以查询某个break语句跳向哪个标签。
分析流程:两阶段算法(收集 → 解析)
入口函数位于 analyzer.rs#L42-L46:
pub fn analyze(ast: &Program, options: AnalyzeOptions) -> ScopeManager { let mut analyzer = Analyzer::new(ast, options); analyzer.visit_program(ast); analyzer.complete() }整体采用两阶段(two-pass)算法,这也是静态语义分析工具(如 ESLint 的eslint-scope、Babel 的 scope 分析)常见的做法:
阶段一:visitor 遍历,收集"未决引用"
Analyzer实现了hermes_estree::Visitortrait,以深度优先方式遍历整个 AST。遍历期间:
- 遇到声明节点,立即调用
manager.add_declaration(...)在当前(或提升后的)作用域登记声明; - 遇到引用节点,不立即解析,而是构造一个
UnresolvedReference压入self.unresolved列表(analyzer.rs#L225-L240)。
UnresolvedReference结构体(analyzer.rs#L60-L72)记录了这个引用所在的作用域、AST 节点、名字、引用类型、源码范围和"创建该引用时下一条声明的 ID"(next_declaration,后面 TDZ 检测会用到):
pub struct UnresolvedReference { pub scope: ScopeId, pub ast: AstNode, pub name: String, pub kind: ReferenceKind, pub range: SourceRange, pub next_declaration: DeclarationId, }阶段二:complete() 统一解析
遍历结束后调用complete()(analyzer.rs#L87-L106),此时所有声明都已登记完毕,因此可以逐个回溯解析引用:
fn complete(mut self) -> ScopeManager { for reference in self.unresolved { if let Some(declaration) = self.manager.lookup_reference( reference.scope, &reference.name, reference.next_declaration, ) { let id = self.manager.add_reference(reference.scope, reference.kind, declaration.id); self.manager.node_references.insert(reference.ast, id); } else { self.manager.diagnostics.push(Diagnostic::invalid_syntax( "Undefined variable", reference.range, )); } } self.manager }解析成功则将引用登记到对应作用域,并建立node_references映射;解析失败则产生"Undefined variable"语法诊断,这正是静态分析报错(如"未定义变量")的来源。诊断类型统一使用hermes_diagnostics::Diagnostic。
作用域栈的管理
遍历过程中,Analyzer用current: ScopeId维护"当前作用域",通过enter_scope/close_scope成对进出(analyzer.rs#L166-L177),并提供了enter(kind, f)便捷封装:进入新作用域 → 执行闭包 → 关闭作用域(analyzer.rs#L156-L164)。close_scope中还有assert_eq!(self.current, id)断言,用于在调试期捕捉作用域进出不匹配的编码错误。
作用域体系:九种 ScopeKind
scope_manager.rs#L459-L470 定义了完整的词法作用域种类:
| ScopeKind | 触发节点 | 说明 |
|---|---|---|
Global | 脚本(Script)根 | 非模块脚本的根作用域 |
Module | 模块(Module)根 | 模块顶层作用域,import仅允许在此声明 |
Function | 函数声明/表达式/箭头函数 | 形参 + 函数体 |
Class | 类声明/表达式 | 类体作用域 |
Block | 块语句{} | 普通词法块 |
CatchClause | catch子句(带形参时) | 捕获参数专属作用域 |
For | for/for-in/for-of | 仅当循环初始化使用let/const时创建 |
Switch | switch语句 | 存放所有 case |
StaticBlock | 类的静态初始化块 | 类静态块作用域 |
根作用域的kind由源码类型决定(scope_manager.rs#L50-L55):
let root_kind = match source_type { SourceType::Module => ScopeKind::Module, SourceType::Script => ScopeKind::Global, };实现中有几个值得注意的作用域细节:
- 函数体不额外建 Block 作用域:
visit_function(analyzer.rs#L179-L210)对函数体块直接遍历其语句列表,注释明确说明"Skip calling visit_block_statement to avoid creating an extra block scope",避免函数作用域与函数体块作用域重复。 - 形参中的
this不产生声明:函数形参遍历时跳过名为this的参数(analyzer.rs#L182-L190),因为它不声明变量也不可能有默认值。 - for 循环的
let/const初始化单独建作用域:visit_for_statement(analyzer.rs#L687-L715)与visit_for_in_of(analyzer.rs#L304-L335)都会在初始化语句是VariableDeclarationKind::Var时跳过建作用域,否则创建ScopeKind::For,这精确模拟了for (let i ...)中i的词法隔离语义。 catch形参单独建作用域:visit_catch_clause(analyzer.rs#L618-L633)只有在带形参时才enter(ScopeKind::CatchClause, ...),不带形参则直接访问 body 块,避免多余的块级作用域。
声明语义:八种 DeclarationKind 与变量提升
scope_manager.rs#L496-L506 定义了声明种类:
pub enum DeclarationKind { Global, Class, Const, Var, Let, Function, CatchClause, Import, }其中Var/Let/Const由 ESTree 的VariableDeclarationKind直接转换而来(scope_manager.rs#L508-L516)。
提升规则(hoisting)
add_declaration的核心逻辑是先确定声明被挂到哪个作用域,即get_scope_for_declaration(scope_manager.rs#L378-L411):
Let/Const/Import/CatchClause/Class:就地声明在当前位置的作用域;Var/Function:向上提升(hoist)到最近的Function/Global/Module/StaticBlock作用域,这准确实现了"var提升到函数顶部"的 JS 语义。
重复声明检查
add_declaration(scope_manager.rs#L290-L376)实现了严格的重复声明规则,注释中给出了两个典型冲突示例:
function() { {let a; var a;} }:在声明所在作用域就冲突;function() { let a; { var a; } }:var提升到含冲突let的作用域时冲突。
具体规则:
var可以被重复声明任意多次,后续声明等价于对原声明的重新赋值(var a = 1; var a = 2;等价于var a; a = 1; a = 2;),因此重复的var直接复用原声明的 ID;var不能与同一作用域(或提升目标作用域)中的let/const/class/import/function/catch形参等块级声明冲突(通过is_block_scoped_declaration判定,scope_manager.rs#L435-L445),否则产生"Duplicate declaration"诊断;- 其余声明种类在同一作用域内重复声明即报错,但解析时仍绑定到第一个声明——注释说明:一旦出现错误,语义结果本身已无效,关键是不要再因此产生二次的"找不到声明"误报。
另外,visit_import_declaration_specifier(analyzer.rs#L339-L374)会在非 Module 作用域遇到import声明时立即报告"import declarations are only allowed at the top-level of a module"诊断,三种 import specifier(默认、具名、命名空间)统一只登记local标识符(具名导入的imported被忽略,因为它引用的是被导入模块中的名字)。
引用语义:Read / Write / ReadWrite 与赋值处理
scope_manager.rs#L526-L531 定义了引用的三种种类:
pub enum ReferenceKind { Read, Write, ReadWrite, }analyzer.rs中对"哪些标识符是引用、哪些只是名字"做了非常细致的区分,这是 ESTree 的一个经典坑:同一个Identifier节点既可能是变量引用,也可能只是属性名/字符串名(如x.y中的x是引用,而y不是)。visit_identifier(analyzer.rs#L717-L732)的注释明确说明:所有非变量引用的Identifier都被上游访问器刻意跳过,因此走到这里的一定是变量读取:
fn visit_identifier(&mut self, ast: &hermes_estree::Identifier) { Analyzer::visit_reference_identifier( self, &ast.name, AstNode::from(ast), ReferenceKind::Read, ast.range, ); }visit_assignment_expression(analyzer.rs#L497-L587)对赋值运算符做了分流:
=简单赋值:左侧若是Pattern(解构赋值),调用visit_declaration_pattern(..., None)——None表示"这不是声明而是重新赋值",因此内部会将其视为ReferenceKind::Write的引用(见 analyzer.rs#L242-L265);左侧若是MemberExpression链(如a.b.c = x),则展开到最内层.object,把真正的根标识符记为Read,计算属性则正常访问;- 复合赋值/更新运算符(
+=、-=等):左侧必须是标识符,记为ReferenceKind::ReadWrite(先读后写);若左侧不合法则报诊断,但仍继续访问右侧以尽量发现其中的错误。
解构模式的处理集中在visit_declaration_pattern(analyzer.rs#L267-L302),递归处理Identifier、ArrayPattern、ObjectPattern(含计算属性键、Rest 元素)、RestElement、AssignmentPattern(带默认值,默认值表达式按普通表达式访问)。函数形参的声明也走这条路径,因此形参解构、默认值都能被正确登记。
标签解析:break / continue / labeled statement
break/continue的跳转目标解析是语义分析的另一块内容,相关实体为Label与LabelKind(scope_manager.rs#L482-L494):
pub enum LabelKind { Loop, Other }visit_labeled_statement(analyzer.rs#L734-L751)根据被标注的语句是否为for/for-in/for-of/while/do-while,把标签标记为LabelKind::Loop或LabelKind::Other,并通过enter_label把标签压入self.labels栈;- 循环与
switch语句会创建匿名标签(add_anonymous_label),使无标签的break/continue也能找到最近的跳转目标; lookup_break/lookup_continue(analyzer.rs#L118-L154)从标签栈顶向内查找:带名字的break label;要求精确匹配,无名字的则取最近的标签;visit_break_statement/visit_continue_statement(analyzer.rs#L600-L663)解析成功后把标签 ID 关联到break/continue节点及其标签节点;continue还额外校验目标标签必须是LabelKind::Loop,否则报"Invalid continue statement, the named label must be for a loop";找不到目标则分别报 "Non-syntactic break/continue" 诊断。
TDZ(暂时性死区)检测:利用声明顺序做静态近似
crate 内置了一个轻量的 TDZ 违规检测,原理非常巧妙,利用的是"引用创建时点与声明时点的顺序关系"。
回忆UnresolvedReference中的next_declaration字段(analyzer.rs#L67-L71):它是"该引用被创建时,下一条将要产生的声明的 ID"。由于ScopeManager用Vec顺序存储声明,声明 ID 的递增顺序就反映了声明在代码中的出现顺序。
解析阶段lookup_reference(scope_manager.rs#L204-L248)在沿作用域链向上查找声明时,维护一个tdz_limit:
if let Some(tdz_limit) = tdz_limit { if (declaration.kind == DeclarationKind::Let || declaration.kind == DeclarationKind::Const) && id.0 >= tdz_limit.0 { return None; } }也就是说:如果在引用之后才出现的let/const声明被该引用命中(id >= next_declaration),就认为这是 TDZ 违规(引用发生在其初始化之前),解析返回None,最终产生未定义变量诊断。
一个关键的修正细节是:跨函数作用域时清空tdz_limit(scope_manager.rs#L230-L238)。因为函数内的引用即使文本上写在外部let声明之前,实际执行时(函数被调用时)该声明可能早已初始化,不能一律判为 TDZ。这是对function f() { return x; } let x = 1;这类合法代码的准确处理。
全局变量注入:AnalyzeOptions
分析结果的质量依赖于"哪些名字是预定义的全局变量"。入口参数AnalyzeOptions(analyzer.rs#L48-L51)只有一个字段:
#[derive(Debug, Default)] pub struct AnalyzeOptions { pub globals: Vec<String>, }ScopeManager::new(scope_manager.rs#L50-L88)会把globals中的每个名字注册为挂在根作用域下的DeclarationKind::Global声明,并维护globals: IndexMap<String, DeclarationId>索引。lookup_reference沿作用域链找不到任何声明时,最后会查这张全局表(scope_manager.rs#L240-L245):
if let Some(id) = self.globals.get(name) { let declaration = self.declaration(*id); return Some(declaration); } return None;测试用例 analysis_test.rs#L25-L40 展示了典型的注入集合:
let mut analysis = analyze( &result.ast, AnalyzeOptions { globals: vec![ "Array".to_string(), "Boolean".to_string(), "console".to_string(), "global".to_string(), "Math".to_string(), "Number".to_string(), "setInterval".to_string(), "setTimeout".to_string(), "String".to_string(), ], }, );凡是未声明又不在 globals 中的标识符,都会被判定为未定义变量——这也解释了为什么测试集合中必须显式列出宿主环境提供的全局 API。实际使用时,应根据目标运行环境(浏览器、Node、Hermes 运行时等)扩充这份清单。
只读调试视图:ScopeManagerView 与 Debug 输出
为了便于调试与快照测试,crate 在 scope_view.rs 中提供了只读视图层,全部以引用方式借用ScopeManager,不持有所有权,也不可变:
ScopeManagerView(scope_view.rs#L22-L47):顶层视图,提供root()与globals();ScopeView(scope_view.rs#L48-L166):作用域视图,提供id()、kind()、parent()、declarations()、references()、children()、is_descendant_of();DeclarationView(scope_view.rs#L185-L221)与ReferenceView(scope_view.rs#L223-L266):分别暴露声明的 id/名字/种类/所在作用域,与引用的 id/种类/作用域/绑定的声明。
ScopeManager::debug()(scope_manager.rs#L90-L92)返回ScopeManagerView,且ScopeManager的Debug实现委托给它(scope_manager.rs#L43-L47)。各 View 均实现了详尽的Debug——例如ScopeView会递归打印声明、引用(含绑定的声明名)与子作用域,使得println!("{:#?}", analysis.debug())就能输出整棵作用域树的结构化文本。这既是日常调试手段,也是下方快照测试的核心输出物。
测试体系:fixture 快照测试
crate 的测试集中在 analysis_test.rs,采用insta 快照测试配合 fixture 目录扫描:
#[test] fn fixtures() { glob!("fixtures/**.js", |path| { let input = std::fs::read_to_string(path).unwrap(); let result = parse(&input, path.to_str().unwrap(), ParserFlags::default()).unwrap(); let mut analysis = analyze(&result.ast, AnalyzeOptions { globals: vec![...] }); let mut output = String::new(); writeln!(&mut output, "{:#?}", analysis.debug()).unwrap(); let diagnostics = analysis.diagnostics(); for diagnostic in diagnostics { writeln!(&mut output, "{:#?}", diagnostic).unwrap(); println!("{:?}", Report::new(diagnostic) .with_source_code(NamedSource::new(path.to_string_lossy(), input.clone()))); } assert_snapshot!(format!("Input:\n{input}\n\nAnalysis:\n{output}")); }); }测试流程清晰地演示了 crate 的端到端集成方式:
- 用
hermes_parser::parse(ParserFlags::default())把 JS 源码解析为hermes_estree的ProgramAST; - 调用
hermes_semantic_analysis::analyze获得ScopeManager; - 通过
analysis.debug()的Debug输出得到完整作用域树文本,同时收集analysis.diagnostics()打印诊断(用miette::Report渲染为带源码上下文的错误报告); - 与 insta 快照比对,任何分析行为的变化(作用域结构、绑定关系、诊断文本)都会导致快照 diff 失败。
assert_snapshot!把"输入源码 + 分析输出"整体存档,这意味着作用域树的每个细节都被纳入了回归保护。依赖方面,Cargo.toml 显示该 crate 的运行时依赖仅四个 workspace crate:hermes_diagnostics(诊断)、hermes_estree(AST 类型)、hermes_utils(指针地址等工具)与indexmap(保序 Map);hermes_parser、insta、miette、serde_json为 dev-dependencies,仅用于测试。
构建与运行
该 crate 是 unsupported/hermes Rust workspace(members = ["crates/*"])的一员,需要在 workspace 根目录下执行 cargo 命令:
# 在仓库的 unsupported/hermes 目录下 cargo build -p hermes_semantic_analysis # 编译 cargo test -p hermes_semantic_analysis # 运行快照测试注意 workspace 的[profile.release](unsupported/hermes/Cargo.toml#L40-L48)沿用了较高的发布优化配置(opt-level = 3、lto = "fat"、codegen-units = 1、panic = "abort"),并特意为 insta 及其 diff 库(similar)在 dev profile 下开启opt-level = 3以加速快照测试——这是 Rust 工具链中针对快照测试性能的常见调优手法。
作为依赖方接入时,只需在业务 crate 的Cargo.toml中声明hermes_semantic_analysis = { workspace = true }(或path = "crates/hermes_semantic_analysis"),即可使用其公开 API:analyze、AnalyzeOptions,以及ScopeManager上的各类查询方法(scope、declaration、reference、label、node_scope、node_declaration、node_reference、break_label、continue_label、is_descendant_of、diagnostics等)。
当前限制与后续规划
结合 README 与源码中的 TODO 标记,可以清晰看到该模块的边界与演进方向:
- 严格模式假设:README 明确声明不兼容 legacy 非严格语义,因此
with语句、非严格模式下的隐式全局赋值等都不会被正确建模,这是当前最重要的使用前提; - Flow / TypeScript 扩展:目前仅覆盖核心 JS 与 JSX,README 说明未来将扩展到 Flow 与 TypeScript——考虑到 Hermes 官方文档中 TS 语法的剥离与类型检查是活跃方向,该 crate 很可能是其 Rust 侧语义基础设施;
- 尚未记录的 JSX pragma:源码中
visit_jsxfragment留有TODO: record the pragmas,visit_jsxopening_element也有TODO: record jsx pragma if root_name is not an FBT name(analyzer.rs#L837-L856),说明 JSX pragma 相关的语义信息收集尚未完成; - 成员表达式展开逻辑待简化:
visit_assignment_expression中对成员表达式链的展开逻辑标有TODO: revisit and maybe revert this to just visit ast.left normally(analyzer.rs#L509-L512),属于已知的实现细节待优化项。
从源码结构看,可以推断该 crate 采用"AST 不可变、语义信息旁挂"的架构(AstNode基于指针地址),未来扩展 Flow/TS 时只需增加新的 visitor 分支,而无需改动ScopeManager的核心数据结构——这是其面向演进的架构设计使然。
结语
hermes_semantic_analysis用约 900 行 Rust 代码,完整实现了 JavaScript 静态语义分析中最硬核的部分:九类词法作用域的精确构建、八类声明的登记与var/function提升、三类引用的收集与回溯解析、break/continue标签绑定,以及基于声明序号的静态 TDZ 检测,并配以快照测试保证行为可回归。对于想了解"如何用 Rust 为 JavaScript 构建 scope 分析"的读者,analyzer.rs、scope_manager.rs 与 analysis_test.rs 是顺序阅读的最佳路径;而它"两阶段解析 + 指针地址旁挂语义信息 + 只读视图调试"的整体架构,也为任何 AST 驱动的静态分析工具提供了可复用的设计范式。
- 语言运行时
- 编译器
- 移动开发
【免费下载链接】hermes
A JavaScript engine optimized for running React Native.
相关推荐
Carbon 语言名称查找设计解析:作用域、未限定名称解析、名称污染与遮蔽规则
Carbon 语言名称查找设计解析:作用域、未限定名称解析、名称污染与遮蔽规则 导读 本文聚焦 Carbon Language(实验性编程语言)的名称查找(na
编程语言编译器标准库ENScan_GO深度解析:区块链域名分析的革命性工具
ENScan_GO深度解析:区块链域名分析的革命性工具 你还在为区块链域名分析效率低下而烦恼?还在手动查询Ethereum域名(ENS)持有者信息?ENScan
网络安全渗透测试网页爬虫MCP 服务Hutch源码解析:深入理解Ruby消息处理框架的设计原理和实现机制
Hutch源码解析:深入理解Ruby消息处理框架的设计原理和实现机制 Hutch是一个基于Ruby的 RabbitMQ消息处理框架 ,它为开发者提供了简洁而强大
消息队列后端微服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考