docs(frontend): explain AST validation and lexical tests

This commit is contained in:
2026-11-15 18:46:57 +08:00
parent 3a75476bc5
commit ce0f507953
3 changed files with 111 additions and 0 deletions
+35
View File
@@ -1,3 +1,4 @@
//! 宿主与 compiler::compile_ast 使用的版本化 AST 文档接口;校验结构、跨度和包含关系,不执行源码。
use std::fmt;
use super::visitor::Visitor;
@@ -5,21 +6,32 @@ use super::{AST_SCHEMA_ID, AstDocument, AstRoot};
use crate::Span;
#[derive(Debug)]
/// JSON 编解码或文档验证的公开错误,供宿主、编译器包装及 AST 测试按类别判断。
pub enum AstError {
// JSON 语法、生成类型反序列化约束或序列化失败,保留 serde 原因。
Json(serde_json::Error),
// 文档 schema 身份与当前生成类型不一致,不能按此版本解释。
SchemaMismatch {
// 当前编译进前端的 schema 标识,来自生成代码的 AST_SCHEMA_ID。
expected: &'static str,
// 外部文档提供的拥有型标识,错误不依赖原输入缓冲存活。
found: String,
},
// 逆序、超出 JS 安全整数,或有源码校验时越界/切断 UTF-8 的节点范围。
InvalidSpan(Span),
// 子节点虽可能单独合法,但没有完全落在直接父节点范围内。
ChildOutsideParent {
// Visitor 祖先栈顶的直接父范围,用于诊断包含关系。
parent: Span,
// 当前试图进入、但越过父边界的子范围。
child: Span,
},
}
impl fmt::Display for AstError {
/// 宿主显示文档错误时调用,按类别保留 schema 或字节范围细节。
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// 根据具体错误分类输出信息或提供底层原因;schema/跨度错误由本模块产生。
match self {
Self::Json(error) => write!(f, "invalid AST JSON: {error}"),
Self::SchemaMismatch { expected, found } => {
@@ -38,7 +50,9 @@ impl fmt::Display for AstError {
}
impl std::error::Error for AstError {
/// 标准 Error 链查询入口;只有 serde JSON 失败有外部底层错误对象。
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
// 根据具体错误分类输出信息或提供底层原因;schema/跨度错误由本模块产生。
match self {
Self::Json(error) => Some(error),
_ => None,
@@ -47,13 +61,16 @@ impl std::error::Error for AstError {
}
impl From<serde_json::Error> for AstError {
/// from_json/to_json 使用 ? 时,把 serde 错误统一转换为 AstError::Json。
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
impl AstDocument {
/// compiler::compile_ast、宿主及测试为已有根创建文档;附当前 schema 并校验结构,尚无原文可查边界。
pub fn new(root: AstRoot) -> Result<Self, AstError> {
// 按当前 schema 包装拥有根,再校验,失败不把无效文档作为成功结果返回。
let document = Self {
schema: AST_SCHEMA_ID.into(),
root,
@@ -63,35 +80,45 @@ impl AstDocument {
}
/// Decode a versioned syntax tree. This does not compile or execute it.
/// 宿主/编辑器或测试输入 JSON 时调用;先解码类型结构,再检查 schema 身份及节点跨度。
pub fn from_json(text: &str) -> Result<Self, AstError> {
// 生成类型上的 serde 约束会在此拒绝未知字段或错误形状,再由 validate 检查文档级规则。
let document: Self = serde_json::from_str(text)?;
document.validate()?;
Ok(document)
}
/// 宿主/编辑器传输及往返测试调用;先验证当前文档,避免序列化已被手工改坏的结构。
pub fn to_json(&self) -> Result<String, AstError> {
self.validate()?;
Ok(serde_json::to_string(self)?)
}
/// Check schema identity, ranges, and parent/child containment.
/// new/from_json/to_json 共用的无原文验证:范围顺序、JS 安全整数及父子包含;不推断文本长度。
pub fn validate(&self) -> Result<(), AstError> {
self.validate_with_source(None)
}
/// Additionally check bounds and UTF-8 boundaries against original text.
/// This does not check that the text and AST have identical semantics.
/// compiler::compile_ast 和源码范围测试调用;额外用对应原文检查字节边界及长度,不证明 AST 语义与文本相同。
pub fn validate_source(&self, source: &str) -> Result<(), AstError> {
self.validate_with_source(Some(source))
}
/// validate/validate_source 共用实现,source 的有无决定是否检查真实 UTF-8 原文范围。
fn validate_with_source(&self, source: Option<&str>) -> Result<(), AstError> {
// 先核对文档版本,避免用当前访问器接受另一结构版本的数据。
if self.schema != AST_SCHEMA_ID {
return Err(AstError::SchemaMismatch {
// 当前编译进前端的 schema 标识,来自生成代码的 AST_SCHEMA_ID。
expected: AST_SCHEMA_ID,
// 外部文档提供的拥有型标识,错误不依赖原输入缓冲存活。
found: self.schema.clone(),
});
}
// 每次验证使用新祖先栈,从根递归,失败后这份临时状态随调用结束释放。
SpanValidator {
source,
parents: Vec::new(),
@@ -100,16 +127,21 @@ impl AstDocument {
}
}
/// AstDocument 校验使用的借用节点访问器;source 可选,parents 跟踪当前递归路径的节点范围。
struct SpanValidator<'a> {
// 有原文时校验 slice;无原文时只做结构与可表示范围检查。
source: Option<&'a str>,
// 成功进入才压栈,离开时弹栈,确保每个子节点与直接父节点比较。
parents: Vec<Span>,
}
impl Visitor for SpanValidator<'_> {
type Error = AstError;
/// 默认 Visitor 在进入各语法节点前调用;只有检查通过才登记本节点,失败不需要为当前节点弹栈。
fn enter_node(&mut self, span: Span) -> Result<(), AstError> {
// JSON offsets must also be exactly representable by editor JS numbers.
// end 不超过 JS Number 的最大安全整数;顺序合法同时保证 start 不会比 end 更大。
if span.start > span.end
|| span.end as u128 > 9_007_199_254_740_991
|| self
@@ -118,6 +150,7 @@ impl Visitor for SpanValidator<'_> {
{
return Err(AstError::InvalidSpan(span));
}
// 根没有父范围;其余节点不能越过直接父节点的任一端点,允许范围相等。
if let Some(parent) = self.parents.last().copied()
&& (span.start < parent.start || span.end > parent.end)
{
@@ -126,10 +159,12 @@ impl Visitor for SpanValidator<'_> {
child: span,
});
}
// 当前节点全部合法后才成为后续子节点的父范围。
self.parents.push(span);
Ok(())
}
/// 默认 Visitor 在已进入节点的遍历结束后调用,即使子遍历失败,也会为成功进入的祖先执行退出。
fn leave_node(&mut self) {
self.parents.pop();
}
+65
View File
@@ -1,3 +1,4 @@
//! Rust 测试框架验证语法根、生成 Visitor、原文定位和版本化 JSON 接口,不执行编译后代码。
use frontend::ast::visitor::{Visitor, walk_expr, walk_function};
use frontend::ast::{
AST_SCHEMA_ID, AstDocument, AstError, AstRoot, Expr, ExprKind, Function, Spanned, StmtKind,
@@ -5,15 +6,19 @@ use frontend::ast::{
use frontend::{EndorReader, ParseMode, Span, parse_ast, parse_source_ast};
#[test]
/// 由 Rust 测试框架调用:块内允许多个调用表达式语句及无 return 空体,但显式 return 必须位于块末尾。
fn function_bodies_accept_method_statements_and_optional_trailing_return() {
// 仅解析指定模式的源码,取得拥有型 AstRoot,不分配 VM 对象。
let root = parse_ast(
"function click(): Unit { title.setText(\"changed\") card.setColor(\"#FFEEDD\") }",
ParseMode::Module,
)
.unwrap();
// 模块模式必须产出 Program,不能把声明错误地包装成表达式或交互根。
let AstRoot::Module(program) = root else {
panic!("expected module")
};
// 借用首个顶层函数,用于检查块内语句分类及顺序。
let function = program.functions().next().unwrap();
assert_eq!(function.body.statements.len(), 2);
assert!(
@@ -34,18 +39,25 @@ fn function_bodies_accept_method_statements_and_optional_trailing_return() {
}
#[test]
/// 由 Rust 测试框架调用:核对模块/表达式/单条交互的根类型与语句种类,文档范围有效,来源诊断保留 Unicode 行列。
fn parse_modes_produce_distinct_roots_and_preserve_source_diagnostics() {
// 保留与 AST 跨度相对应的原始 UTF-8 文本,后续不规范化空白或换行。
let source = "function echo(value: String): String { let copy = value return copy }";
// 仅解析指定模式的源码,取得拥有型 AstRoot,不分配 VM 对象。
let root = parse_ast(source, ParseMode::Module).unwrap();
// 模块模式必须产出 Program,不能把声明错误地包装成表达式或交互根。
let AstRoot::Module(program) = &root else {
panic!("expected module")
};
// 借用首个顶层函数,用于检查块内语句分类及顺序。
let function = program.functions().next().unwrap();
assert_eq!(function.body.statements.len(), 2);
// 第一条是 let Binding,不能与后面的 Return 混成同一语句种类。
assert!(matches!(
function.body.statements[0].kind,
StmtKind::Binding(_)
));
// 第二条明确为末尾 Return,顺序应与源码一致。
assert!(matches!(
function.body.statements[1].kind,
StmtKind::Return(_)
@@ -55,10 +67,15 @@ fn parse_modes_produce_distinct_roots_and_preserve_source_diagnostics() {
.validate_source(source)
.unwrap();
// 仅解析指定模式的源码,取得拥有型 AstRoot,不分配 VM 对象。
let root = parse_ast("echo(\"日\")", ParseMode::Expression).unwrap();
// Expression 模式只能返回表达式根,不带交互语句包装。
assert!(matches!(root, AstRoot::Expression(_)));
// 一条 let、一次调用和一个函数声明分别构成交互提交,均应只含一个语句。
for text in ["let value = \"日\"", "echo(value)", source] {
// 仅解析指定模式的源码,取得拥有型 AstRoot,不分配 VM 对象。
let root = parse_ast(text, ParseMode::Interactive).unwrap();
// 解构交互根并借用其单条语句,避免只检查解析成功而忽略根形状。
let AstRoot::Interactive(input) = &root else {
panic!("expected interactive")
};
@@ -70,7 +87,9 @@ fn parse_modes_produce_distinct_roots_and_preserve_source_diagnostics() {
}
assert!(parse_ast("let x = 1 let y = 2", ParseMode::Interactive).is_err());
assert!(parse_ast("", ParseMode::Interactive).is_err());
// 给语法错误附会话来源身份,字符串中的日字使字节偏移与 Unicode 列不同。
let source = EndorReader::from_text("<session:1>", "\"日\" + )");
// 故意错误的快照解析必须返回带来源诊断,之后核对实际显示行列。
let error = parse_source_ast(&source, ParseMode::Expression).unwrap_err();
assert_eq!(error.location.line, 1);
assert_eq!(error.location.column, 7);
@@ -78,29 +97,41 @@ fn parse_modes_produce_distinct_roots_and_preserve_source_diagnostics() {
}
#[test]
/// 由 Rust 测试框架调用:自定义访问器收集函数名与引用名,并验证子遍历提前失败后,成功进入的祖先节点退出钩子仍全部配平。
fn generated_visitors_reach_names_and_unwind_node_hooks_on_error() {
#[derive(Default)]
/// 本测试的可变访问器状态;节点只借用,收集文本拥有副本,depth 跟踪默认节点钩子的配平。
struct Names {
// 自定义 visit_function 记录声明名,和表达式引用名分开。
functions: Vec<String>,
// visit_expr 仅收集 ExprKind::Name,不包含类型注解或属性名。
references: Vec<String>,
// enter/leave 计数,遍历结束或提前失败后都应回到零。
depth: usize,
// 为 true 时在第一个引用名处主动失败,用来检查错误回溯退出路径。
stop: bool,
}
impl Visitor for Names {
type Error = &'static str;
/// 生成的默认 visit 方法成功进入节点时调用,记录当前已进入祖先数量。
fn enter_node(&mut self, _span: Span) -> Result<(), Self::Error> {
self.depth += 1;
Ok(())
}
/// 默认 visit 在子遍历完成或失败后调用,撤销此前成功进入的节点计数。
fn leave_node(&mut self) {
self.depth -= 1;
}
/// 自定义函数访问入口先记录名称,再显式 walk_function 保持所有字段的递归访问。
fn visit_function(&mut self, node: &Function) -> Result<(), Self::Error> {
self.functions.push(node.name.text.clone());
walk_function(self, node)
}
/// 自定义表达式访问入口只特殊处理引用名;其余结构继续由 walk_expr 分派。
fn visit_expr(&mut self, node: &Expr) -> Result<(), Self::Error> {
// 仅变量引用表达式进入收集/主动失败分支,字面量等节点仍照常递归。
if let ExprKind::Name(name) = &node.kind {
// 在调用 walk_expr 之前失败;默认祖先 visit 必须仍执行各自的 leave_node。
if self.stop {
return Err("stopped at reference");
}
@@ -109,16 +140,19 @@ fn generated_visitors_reach_names_and_unwind_node_hooks_on_error() {
walk_expr(self, node)
}
}
// 仅解析指定模式的源码,取得拥有型 AstRoot,不分配 VM 对象。
let root = parse_ast(
"function echo(value: String): String { let copy = value return copy }",
ParseMode::Module,
)
.unwrap();
// 正常遍历从空状态开始,检查名称收集顺序及最终深度。
let mut names = Names::default();
names.visit_ast_root(&root).unwrap();
assert_eq!(names.functions, ["echo"]);
assert_eq!(names.references, ["value", "copy"]);
assert_eq!(names.depth, 0);
// 第二份访问器主动终止在引用名,不能复用正常访问器掩盖深度泄漏。
let mut stopped = Names {
stop: true,
..Names::default()
@@ -128,10 +162,14 @@ fn generated_visitors_reach_names_and_unwind_node_hooks_on_error() {
}
#[test]
/// 由 Rust 测试框架调用:括号范围对应原文,CRLF 按一行、Unicode 按一列;安全定位拒绝逆序、越界及切断字符的范围。
fn spans_share_unicode_locations_and_safe_original_source_access() {
// 保留与 AST 跨度相对应的原始 UTF-8 文本,后续不规范化空白或换行。
let source = " \r\n(値)";
// 仅解析指定模式的源码,取得拥有型 AstRoot,不分配 VM 对象。
let root = parse_ast(source, ParseMode::Expression).unwrap();
assert_eq!(root.source_text(source), Some("(値)"));
// 用同一原文把根节点的字节范围转换成一基行列,CRLF 只增加一行。
let location = root.location(source).unwrap();
assert_eq!((location.line, location.column), (2, 1));
assert_eq!(Span::new(1, 3).slice("値"), None);
@@ -140,7 +178,9 @@ fn spans_share_unicode_locations_and_safe_original_source_access() {
}
#[test]
/// 由 Rust 测试框架调用:三个语法根 JSON 往返不丢文本/数值拼写,文档 schema 与生成的 JSON Schema/TypeScript 标识一致。
fn versioned_json_roundtrips_all_roots_and_preserves_number_spelling() {
// 分别覆盖含长十进制的模块、含符号/转义字符串的表达式和交互绑定。
for (source, mode) in [
(
"function f(): Number { return 00012345678901234567890.00100 }",
@@ -149,16 +189,21 @@ fn versioned_json_roundtrips_all_roots_and_preserves_number_spelling() {
("f(.名前, \"日\\n\")", ParseMode::Expression),
("let value = 1", ParseMode::Interactive),
] {
// 用当前 schema 包装合法解析树并验证结构,为后续往返或定点破坏提供基线。
let document = AstDocument::new(parse_ast(source, mode).unwrap()).unwrap();
// 序列化前接口再次验证文档;输出应保留 schema、节点种类和原文字节范围。
let json = document.to_json().unwrap();
// 解码后重新验证文档结构,结果应与原文档完整相等。
let restored = AstDocument::from_json(&json).unwrap();
assert_eq!(restored, document);
restored.validate_source(source).unwrap();
assert!(json.contains(AST_SCHEMA_ID));
// 模块案例包含带前导零和尾零的长数值,JSON 必须保留原拼写而非转浮点数。
if mode == ParseMode::Module {
assert!(json.contains("00012345678901234567890.00100"));
}
}
// 读取仓库已生成的 JSON Schema,只检查标识与常量,不在测试中重新生成文件。
let schema: serde_json::Value =
serde_json::from_str(include_str!("../editor/ast.schema.json")).unwrap();
assert_eq!(schema["$id"], AST_SCHEMA_ID);
@@ -170,35 +215,45 @@ fn versioned_json_roundtrips_all_roots_and_preserves_number_spelling() {
}
#[test]
/// 由 Rust 测试框架调用:分别拒绝错误 schema、未知字段、子节点越界、逆序范围以及对原文切断 UTF-8 字符的范围。
fn json_interfaces_reject_unknown_fields_schema_changes_and_invalid_ranges() {
// 保留与 AST 跨度相对应的原始 UTF-8 文本,后续不规范化空白或换行。
let source = "(f(値))";
// 用当前 schema 包装合法解析树并验证结构,为后续往返或定点破坏提供基线。
let document = AstDocument::new(parse_ast(source, ParseMode::Expression).unwrap()).unwrap();
// 用可编辑 JSON 值破坏一个属性;每个错误案例之后重新从合法 document 创建。
let mut value = serde_json::to_value(&document).unwrap();
// 首先只破坏版本身份,应在 schema 比较阶段报告 SchemaMismatch。
value["schema"] = "urn:endor:ast:unknown".into();
assert!(matches!(
AstDocument::from_json(&value.to_string()),
Err(AstError::SchemaMismatch { .. })
));
value = serde_json::to_value(&document).unwrap();
// 恢复合法 JSON 后增加未知字段,生成类型的 deny_unknown_fields 应在解码阶段拒绝。
value["root"]["data"]["unexpected"] = true.into();
assert!(matches!(
AstDocument::from_json(&value.to_string()),
Err(AstError::Json(_))
));
value = serde_json::to_value(&document).unwrap();
// 再次恢复后缩小父范围,子节点仍在原位置,应报告 ChildOutsideParent。
value["root"]["data"]["span"]["end"] = 1.into();
assert!(matches!(
AstDocument::from_json(&value.to_string()),
Err(AstError::ChildOutsideParent { .. })
));
// 取出合法表达式后直接破坏其范围,验证 new 也拒绝无效手工构造树。
let AstRoot::Expression(mut expression) = document.root else {
unreachable!()
};
// 无需原文也能识别 end 小于 start 的逆序范围。
expression.span = Span::new(5, 4);
assert!(matches!(
AstDocument::new(AstRoot::Expression(expression)),
Err(AstError::InvalidSpan(_))
));
// 无原文时范围本身合法,但放回三字节字符原文时起点切断 UTF-8,应由 validate_source 拒绝。
let root = AstRoot::Expression(Expr {
kind: ExprKind::Name("値".into()),
span: Span::new(1, 3),
@@ -210,27 +265,37 @@ fn json_interfaces_reject_unknown_fields_schema_changes_and_invalid_ranges() {
}
#[test]
/// 由 Rust 测试框架调用:普通赋值使用独立 AST 节点并保留 Unicode 原文;链式赋值和非成员调用目标赋值语法失败。
fn bare_assignment_has_its_own_ast_and_unicode_source_ranges() {
// 保留与 AST 跨度相对应的原始 UTF-8 文本,后续不规范化空白或换行。
let source = "标题 = ui.text(\"你好\")";
// 仅解析指定模式的源码,取得拥有型 AstRoot,不分配 VM 对象。
let root = parse_ast(source, ParseMode::Interactive).unwrap();
// 解构交互根并借用其单条语句,避免只检查解析成功而忽略根形状。
let AstRoot::Interactive(input) = &root else {
panic!("interactive input")
};
// 普通赋值不是 let Binding,也不是成员赋值;名称节点和整句范围都要保持原文。
let StmtKind::Assignment(assignment) = &input.statements[0].kind else {
panic!("assignment")
};
assert_eq!(assignment.name.text, "标题");
assert_eq!(assignment.name.source_text(source), Some("标题"));
assert_eq!(assignment.source_text(source), Some(source));
// 用当前 schema 包装合法解析树并验证结构,为后续往返或定点破坏提供基线。
let document = AstDocument::new(root).unwrap();
document.validate_source(source).unwrap();
// 序列化前接口再次验证文档;输出应保留 schema、节点种类和原文字节范围。
let json = document.to_json().unwrap();
assert_eq!(AstDocument::from_json(&json).unwrap(), document);
assert!(parse_ast("value = 1 = 2", ParseMode::Interactive).is_err());
// 单独解析属性写入,确保普通赋值的新增路径没有抢走 MemberAssignment。
let member = parse_ast("object.field = 1", ParseMode::Interactive).unwrap();
// 必须检查节点类别,不能只根据字符串包含点就假定走了成员赋值规则。
let AstRoot::Interactive(member) = member else {
panic!("interactive")
};
// 属性写入使用 MemberAssignment,不能退化为普通目标名赋值。
assert!(matches!(
&member.statements[0].kind,
StmtKind::MemberAssignment(_)
+11
View File
@@ -1,8 +1,12 @@
//! Rust 测试框架直接验证 lex 的原文偏移与 Token 边界,不进入语法分析、编译或 VM。
use frontend::{DiagnosticKind, Span, TokenKind, lex};
#[test]
/// 验证多 Token 和 Unicode 前缀之后的错误仍定位原文绝对字节范围,并区分数字/转义/换行/EOF/未知字符失败。
fn lexical_errors_keep_original_byte_ranges_after_multiple_tokens() {
// 固定有效前缀含多字节名称,防止只在零偏移或 ASCII 输入上通过的错误位置实现。
let prefix = "let 商品 = ";
// 各尾部给出违规片段及准确错误说明,包含 Unicode 转义、原始 CR/LF/NUL 与两种未闭合结尾。
for (tail, offending, message) in [
(
"12.50名前 ;",
@@ -19,8 +23,11 @@ fn lexical_errors_keep_original_byte_ranges_after_multiple_tokens() {
("\"未完\\", "\"未完\\", "unterminated string"),
("🌸 ;", "🌸", "unexpected source character"),
] {
// 把合法前缀与错误尾部拼成真实输入,后续位置必须指向完整原文。
let source = format!("{prefix}{tail}");
// 分词必须失败,不允许回溯把非法数字/字符串拆成若干合法 Token。
let error = lex(&source).unwrap_err();
// 从完整原文查找预期片段,用字节长度构造期望半开范围。
let start = source.find(offending).unwrap();
assert_eq!(error.kind, DiagnosticKind::Lexical, "{source:?}");
assert_eq!(error.span, Span::new(start, start + offending.len()));
@@ -30,9 +37,13 @@ fn lexical_errors_keep_original_byte_ranges_after_multiple_tokens() {
}
#[test]
/// 验证注释与字符串中的双斜杠、成员点、除号和小数边界互不混淆,空白/注释输入不产生 Token。
fn token_boundaries_distinguish_members_division_comments_and_string_contents() {
// 原文混合 CRLF、Unicode、行注释中的 NUL,以及字符串内必须保留的双斜杠。
let source = " \t// 前置\r\n商品.価格 / 12.50// 行末\0\n\"//実文\" \r\n// 終端";
// lex 跳过 trivia 后只保留语法 Token,所有跨度仍指向未经修改的 source。
let tokens = lex(source).unwrap();
// 把种类与原文切片配对,既检查 Token 分类,也检查是否误吞相邻内容。
let spellings: Vec<_> = tokens
.iter()
.map(|token| (token.kind, &source[token.span.start..token.span.end]))