本文档面向在 php-ast 上做日常开发的工程师:说明代码库布局、测试体系(种类 / 触发方式 / 常见问题 / 调试方法)、开发流程与注意事项。配套阅读:
README.md:项目总览、架构、快速开始doc/zen.md:设计哲学——哪些语法建节点、哪些折叠(改动 AST 前必读)doc/api.md:公开 API 手册doc/example.md:与 PHP-Parser 的用法对照示例doc/special.md:与 PHP-Parser 的结构差异清单
| 路径 | 内容 |
|---|---|
src/ |
库实现。每个源文件尾部内嵌该文件的单元测试 |
src/coverage.zig |
覆盖矩阵:编译期校验每个节点/词法种类都有用例 |
src/golden.zig |
黄金快照比对逻辑(数据在 tests/golden) |
tests/golden/ |
黄金快照:*.php 源码与同名 *.txt(AST dump)成对;parser/ 子目录由迁移而来 |
tools/ |
开发工具(不随库编译,见 §2.4) |
LICENSES/、NOTICE.md |
第三方内容(PHP-Parser)的许可全文与出处声明 |
约定:迁入的 PHP 源码(tests/golden/parser/**)是其上游测试数据的衍生内容,受 BSD
3-Clause 约束——出处与许可声明必须随分发保留(见 §5)。
| 种类 | 位置 | 触发 | 锁什么 |
|---|---|---|---|
| 单元测试 | src/*.zig 尾部 test "..." 块 |
zig build test |
单条特性行为(解析结果 / 遍历 / API) |
| 覆盖矩阵 | src/coverage.zig |
同上(编译期校验) | 每个 Node.Tag/Token.Tag 都有最小用例 |
| 黄金快照 | tests/golden/** |
zig build test(自动比对);-Dupdate-golden 重生成 |
整棵树结构(含诊断)逐字节锁定 |
| 快照源码 | tools/golden_gen.zig |
zig build golden-gen -- --php-parser <路径> |
上游用例代码段 → tests/golden/parser 源码 |
| 符合性对照 | tools/conformance.zig |
zig build conformance -- --php-parser <路径> |
接受面与诊断质量(报告 + 门禁) |
运行:zig build test --summary all。
只跑某个用例:zig build 不透传 --test-filter,用单文件方式:
zig test src/root.zig --test-filter "expr :: 赋值"--test-filter 取测试名任意片段。测试命名约定 <域> :: <特性> :: <场景>,例如
test "expr :: 变量变量 :: 间接变量与花括号名全形态"。
常见问题
All 0 tests passed:含test的新模块没有在src/root.zig末尾的登记块里_ = @import(...)。Zig 对 import 惰性分析,re-export 不会强制收集测试。- 断言失败只显示 tag 计数不符:改用结构视角排查——对同一段源码打印完整 AST (见 §3.3),对照你预期的树形。
- 解析类测试"没报错但结构不对":收集式错误模型下,无错误 ≠ 正确。语法残留常构成
另一条合法解析路径(例:限定名只吃前导
\后,残留段被误当函数调用)。断言 用 tag 计数 / 结构关系,不要用"无错误"。
新增 Node.Tag 或词法种类后,忘记补用例会得到编译期错误(中文提示指明缺失的
种类)。规则:
- 矩阵条目顺序必须与
Tag枚举声明顺序一致(编译期校验)。 - 新 tag 尽量用最小源码(一个
expr_array_dim_fetch用$a['b'];而非大段代码)。
机制:tests/golden/**/*.php 解析后与同名 *.txt 逐字节比对;诊断也会写入快照,
所以"引入了新错误"同样会被比出来。
快照来源(两类):
tests/golden/parser/**:上游(PHP-Parser)测试用例的代码段(278 段,来源与许可 见NOTICE.md)。zig build golden-gen -- --php-parser <路径>按当前用例重新导出 源码(幂等覆盖*.php,不碰*.txt;--prune清理上游已删除的旧快照);用例 增删后跑一次golden-gen+-Dupdate-golden即可对齐。tests/golden/{decl,expr,stmt}:手写样例,覆盖迁移用例未触及的组合。
何时更新:解析行为有意变更(新增语法、修 bug 改变结构)后,快照会失败。此时:
zig build test -Dupdate-golden # 重新生成
git diff tests/golden # 复核:只应含预期的结构变化纪律:不复核就更新,会让快照退化为"把错误结果固化下来"。若 diff 出现与本次改动 无关的大段变化,先查是不是解析器引入了意外行为。
新增 fixture:在 tests/golden 相应子目录放 *.php,跑一次 -Dupdate-golden
生成 *.txt,提交成对文件。
conformance 是独立于常规测试的开发工具(zig build test 不包含它):以
PHP-Parser 的测试用例为 oracle(参照),把代码段逐个喂给本解析器,度量接受面与诊断
质量是否一致,并做防回归门禁。术语与判定口径见工具头部注释;这里给使用工作流。
PHP-Parser 是开发期参照,不是构建依赖:未提供路径时工具直接成功退出,下游 clone 不会因缺少参照而失败。。
- 数据源:由命令行给出,指向 PHP-Parser 仓库根或其
test/code/parser(工具自适应, 见tools/fixtures.zig的resolveParserDir)。工具不随库固化任何参照副本。 .test格式:首行标题,之后以-----分隔的 (代码段, 期望段) 交替对。期望段以array(开头 = PHP-Parser 接受该代码;以错误说明开头 = 应报错。期望段首行的!!version=X.Y是版本 mode(该段在指定版本下解析),其余 mode 忽略。代码段中的@@{expr}@@是 PHP-Parser 测试宏(eval 注入内容),工具先展开再解析,读报告时无需 理会。切分与展开的实现集中在tools/fixtures.zig——与快照迁移共用同一份规则。
在仓库根目录运行:
zig build conformance -- --php-parser <PHP-Parser 仓库根或 test/code/parser>
[--report-dir <目录>] # 默认 zig-out/conformance
[--known-diffs <文件>] # 默认 tools/known_diffs.txt产物两份(默认落 zig-out/conformance/,属生成物、不入库):
acceptance_report.txt—— 接受面:头部统计后按两类列出差距:- 误拒(期望接受却有诊断):本库该接受却没接受,逐条列出
路径[段号]+ 首个诊断。这是要修的清单。 - 漏报(期望报错却无诊断):PHP-Parser 报错但本库没报。这是要人工核对
的清单——两种去向:属收集式错误模型的有意宽松(在
doc/special.md错误模型节 注明即可),或确属拒绝漏报(修复)。
- 误拒(期望接受却有诊断):本库该接受却没接受,逐条列出
diagnostic_report.txt—— 诊断质量:两边都报的段里,按 条数/文本/位置 逐条比对的一致度与差异明细(每条附源码行、期望、实际),供逐条校准。
门禁:报告写出后判定——差异段必须落在 known_diffs.txt 的白名单内,且全等段数
不得低于该文件的 baseline;不符即以非零码退出,zig build conformance 因此可直接
用作 CI 门禁。known_diffs.txt 是入库的受控资产:缺失(或 --known-diffs 指向不
存在的文件)直接报错,避免门禁静默失效。
- 打开
zig-out/conformance/acceptance_report.txt,先看头部统计确认没有整体漂移 (如.test数不符,多半是参照路径给错)。 - 取一条:
路径[段号]对应<参照目录>/路径里的第段号个代码段(0 基)。.test内 段号 ×2 +1 即该段的期望段(可确认 PHP-Parser 期望什么)。 - 把该代码段复制成最小用例(拆到一两句)复现根因。修复后补正式单元测试,并检查是否 需要扩快照(§2.3)。
- 清理临时文件:调试探针不属于库产物,改完即删。
- 复跑
zig build conformance -- --php-parser <路径>确认该条消失、没有新增同类别条目。 - 诊断质量的差异逐条校准到全等;确实无法对齐的登记进
tools/known_diffs.txt并写明 成因(格式与维护要求见 §5)。
提示:修复要防"贪快"。逐点打补丁会让同类差距散落多处——先看若干条是否同一 根因(如某 token 未识别、某状态机漏状态),在底层一次性修,再复扫验证。
对照上游用例时,若同一代码段的 EXPECT 结构(PHP-Parser 的 dump)与我们的树不一致,
多数不是缺口,而是有意的归一/布局差异——接受/拒绝判定一致,只是表示方式不同。
下表速查这些差异;逐模式的完整说明见 doc/special.md。
| 差异点 | PHP-Parser | php-ast |
|---|---|---|
| 运算符 / 复合赋值 / 一元子类型 | 每运算符一个子类(BinaryOp\Plus、AssignOp\Coalesce…) |
统一 expr_binary / expr_assign_op / expr_unary / `expr_post_inc |
elseif/else if、finally |
ElseIf_/Else_/Finally_ 独立节点 |
else 分支折叠为嵌套 stmt_if;finally 为普通语句块(PHP-Parser 的 ElseIf_ 节点不产生) |
die()、include 家族 |
Exit_(die/exit 区分词)、Include_(kind 记四种) |
expr_exit / expr_include,变体由 token 记录 |
exit(...)、clone(...) 的括号形态(8.5) |
单参归 Exit_/Clone_,多参/命名归 FuncCall |
统一归一为 FuncCall(名字即 exit/clone)——接受一致,结构不同 |
| 名字 | Name/FullyQualified/Relative/VarLikeIdentifier 四类 |
name_* 四 tag;关键字可作名字段(semi_reserved,PHP 同源文法) |
$$a / ${expr} |
Variable(name: expr)(无独立类) |
expr_variable_ref(name 为子节点);phpParserType 同报 Expr_Variable |
| 修饰符 / 可见性 | 独立属性(public/static/readonly…),常量与 Zend 引擎对齐 |
紧凑 flags 位字段;非对称可见性 public private(set) 存高低字节 |
| 位置 / 注释 | 节点属性(startLine/endLine/comments…) |
main_token 派生(compat 提供同名便捷函数);注释驻 token 流,getDocComment/leadingComments 取回 |
| 错误 | 抛 PhpParser\Error,遇错即停(可选 recovery) |
tree.errors 收集 + 尽力恢复——接受面报告的「漏报」多源于此 |
| 语言版本 | 无版本维度(lexer emulation 仅为在旧 PHP 上跑新语法) | 解析携带目标版本 + tagVersion 门控(8.5 管道 expr_pipe 等超前于 5.8,为独有语法) |
首类可调用 ... |
整表占位 FirstClassCallable;实参内占位 VariadicPlaceholder |
前者 expr_first_class_callable,后者 expr_variadic_placeholder(叶) |
解构空槽 [$a, , $b] |
ArrayItem.value = null |
expr_array_hole 叶(槽位计数) |
替代语法 if (x): … endif; |
If_ 的 stmts 是普通 Stmt_Block(无 {} 语义差异) |
同样 stmt_block,借 lbrace/rbrace 槽存 :/end 关键字(打印器据此还原) |
| 标量 / 魔术常量 | 各字面量类 + MagicConst* 族 |
expr_int/float/string 叶 + expr_magic_const(token 记种类) |
对照时的判断顺序:结构不同先查本表(命中 = 归一,非缺口)→ 未命中再当误拒 处理。注意本表是"表示差异",与接受/拒绝无关;拒绝面的差异(本库该报错却没报) 归入接受面报告的「漏报」节核对,不在上表。
measure 把解析包在统计型 allocator 里,报告每个输入的分配次数、累计分配字节、
峰值驻留与解析后驻留(AST 实占,取 deinit 前——此刻临时缓冲已释放):
zig build measure # 扫 tests/golden/**,按峰值列前 10 + 合计
zig build measure -- --all # 全部逐条列出
zig build measure -- <file.php>... # 只测指定文件定位:test 判对错、conformance 对齐参照,本工具只出数字——用于观察解析的内存
开销(峰值 − 驻留 ≈ 临时缓冲)并为优化留基线。不对速度作结论:那需要与参照实现
同机的稳定基准,属另一件事。
取值建议配 -Doptimize=ReleaseFast:Debug 下的内联与分配策略与发布构建不同,绝对值
只在同一模式内横向可比(跨模式比较无意义)。
解析不抛异常:错误收集在 tree.errors。定位首个错误:
var buf: [128]u8 = undefined;
for (tree.errors) |e| {
std.debug.print("@'{s}' {s}\n", .{ tree.tokenSlice(e.token), e.format(&tree, &buf) });
}e.token 指向肇事 token,tokenSlice 可看该 token 文本。错误往往发生在"期望 X 却在
Y"的 Y 上,向前看几个 token 通常能还原现场。
- 失败不消费:解析函数失败返回
null时,token 游标应回到进入时位置(必要时 显式回卷)。残留游标会让上层错误恢复基于错误状态继续。 - eof 是终点:
nextToken在 eof 哨兵处停驻,任何"越 eof"的推进都是 bug 的 症状(越界 panic 常见于此)。 - 修复同类问题时,先在底层找公共防线,而不是逐点打补丁。
src/dump.zig 提供树形文本渲染(缩进 + 主 token 文本),用于对比预期结构:
var buf: std.Io.Writer.Allocating = .init(gpa);
defer buf.deinit();
try dump.dumpTree(gpa, tree, &buf.writer);
std.debug.print("{s}", .{buf.written()});大用例一次给出多个错误时,把输入逐段减到最小单句,逐个确认哪句触发。对照参照目录下
同名 .test 的期望段(<参照目录>/路径,见 §2.4.3),能确认"该不该接受"与"接受后
长什么样";本地同名快照(tests/golden/parser/<路径>_<段号>.php)可直接取用源码。
- 弄清归属层:语义进 AST、语法降 token/字段(判据见
doc/zen.md)。新语法先查doc/special.md——能沿用既有归一(如die并入expr_exit)就不要建新形态。 - 实现 + 写单元测试(放被测文件尾部)。
zig build test --summary all全绿;golden 受影响则更新并git diff复核。- 结构变化同步
doc/zen.md(特殊点表)与doc/special.md(差异表)。
| 项 | 文件 |
|---|---|
Node.Tag 枚举 |
src/ast.zig |
有子节点则登记 forEachChild(叶子进"叶子组") |
src/ast.zig |
| 引入版本(非基础语法) | src/ast.zig tagVersion |
| php-parser 风格类型名 | src/compat.zig phpParserType |
| 覆盖矩阵用例(顺序同枚举) | src/coverage.zig |
| 单元测试(文件尾部) | 对应 src/*.zig |
golden(新语句族则扩 tests/golden 手写样例) |
tests/golden/ |
| 折叠决策 | doc/zen.md 特殊点表 |
| 与 PHP-Parser 的差异 | doc/special.md |
- 测试块固定在源文件尾部:文件末尾非空内容应是测试块的
}。 - 测试只断言必要性质;断言值若来自特殊构成("变量共 3 个:左值 + 2 处插值"), 注释写明,避免后人误改。
- 注释写"为什么"(取舍、隐含契约、踩过的坑),不写 What;仓库语言为中文。
extra_data(u32 大板)是 AST 的序列化负载区。契约靠文档 + 测试维护,不设运行期校验:
0.x 阶段布局可变,且违反契约的后果是静默解出错值——槽位都是 u32,读错片段不会报错。
约束分两侧。
写侧接口(Parser 的方法,库内部——Parser 不在 root.zig 导出面内,下游拿不到):
| 接口 | 签名 | 用法 / 适用范围 |
|---|---|---|
addExtra |
fn (p: *Parser, extra: anytype) ParseError!ExtraIndex |
写一个 Components 负载。直接子引用超过 2 个、或需可选字段随节点走时用它;字段按 std.meta.fields 顺序序列化,SubRange 占 2 槽。返回段起点,存进节点的 data 的 extra / extra_* 槽。 |
addNodeList |
fn (p: *Parser, list: []const Index) ParseError!ListRange |
写一串节点下标(子节点列表)。空列表合法,返回零长区间(start == end),读侧按 len == 0 判空。 |
addIndexList |
fn (p: *Parser, comptime T: type, list: []const T) ParseError!ListRange |
写一串下标句柄,T 限 u32 宽枚举(Index / ExtraIndex)。用于非节点序列——闭包 use 列表存的是各项的 ExtraIndex(指向其 ClosureUseComponents)。 |
emptyRange |
fn (p: *Parser) ListRange |
造零长 ListRange:不追加槽,只指向当前大板末尾。 |
emptySubRange |
fn (p: *Parser) SubRange |
同上,SubRange 版。用于「无此部分」的字段(无属性组、无 catch 等)。 |
读侧接口(Ast 的方法,在导出面内,下游可用):extraData / extraDataSlice / listSlice,用法见 doc/api.md。
范围与边界:这几个入口只负责进出大板——不做边界断言、不做类型校验。越界由 Zig 运行时兜底,类型或顺序不符则静默给出错值(契约即为此而立)。emptyRange / emptySubRange 不占槽,多个空区间可能共享同一对端点值,故不能靠区间值区分「是哪个空段」(也无需区分)。
| # | 契约 |
|---|---|
| 1 | 写读都只经统一入口:写为 addExtra(Components)、addNodeList / addIndexList(裸下标列表)、emptyRange / emptySubRange(空区间);读为 extraData / extraDataSlice / listSlice。除上述封装函数的实现内部外,不得手写 extra_data 下标。 |
| 2 | 同一段必须用同一个 Components 类型读、写:写端按 std.meta.fields 顺序序列化,读端按同序还原。 |
| 3 | 字段类型白名单是单一事实来源:ast.encodeExtraField / ast.decodeExtraField,写读共用一处。新增字段类型只改这里。 |
| 4 | 槽宽:逻辑字段 1 槽,SubRange 2 槽,bool 存 0/1。 |
| 5 | 区间端点左闭右开;空区间用 start == end;emptyRange 只指向大板末尾,不追加槽。 |
| 6 | 裸列表段(addNodeList)无类型标记,元素恒为 Index,读端须自带类型知识。 |
覆盖纪律:改动 extra 的构造或读取逻辑时,回归测试按路径判定,而非按 tag——同一 tag
若有多条构造路径(空/非空列表、有/无可选字段、失败回退),每条都要有能走到的用例(单元
测试或 golden)。覆盖矩阵(coverage.zig)只保证「每个 tag 出现一次」,不保证路径完备。
禁止:为某个具体 tag 写「它应该有 N 个槽 / N 个子节点」的假设断言——语法演进会让它误报。
取用纪律见 doc/api.md 的 extraData / extraDataSlice / listSlice 一节。
tests/golden/parser/** 的 PHP 源码衍生自 PHP-Parser 的测试用例(BSD 3-Clause,
Copyright (c) 2011, Nikita Popov)。该许可的三项义务落到本仓库:
- 保留声明:源码或二进制再分发时必须保留版权声明、许可条件与免责声明——许可
全文见
LICENSES/php-parser.txt,出处声明见NOTICE.md,两者随分发保留。 - 不背书:不得以原作者或项目名义为本库背书,文档与发布说明只做客观出处标注。
- 不推广:不得用作者名推广本库。
对应到日常动作:
- 新增或调整任何衍生自上游的内容(快照源码、整段引用的用例)时,同步更新
NOTICE.md的涉及范围。 - 不要在生成的
.php里加来源注释:那会改变解析输入、直接污染快照。出处集中在NOTICE.md与tests/golden/parser/README.md。 - 门禁白名单(
tools/known_diffs.txt)的条目须写明成因与后续方向;修好后删除条目,并把baseline上调到新的全等段数——白名单只允许变短。