要将嵌套括号文本转换为数组树,请从左向右扫描,每遇到一个左括号就创建一个数组,每遇到一个右括号就返回其父级数组。本指南提供了一个完整的 JavaScript 解析器,可在 Mac 上的 Safari 中本地运行。
这里的任务是将一份由作者编写的小型标签大纲转换为嵌套数组,同时保留标签顺序、引号内文本和空组。所有示例都使用合成字符串。解析器包含明确的语法规则和检查项,便于你确认结果结构是否符合预期。
确定括号和带引号标签的含义
转换前,先区分表示结构的括号和属于标签内容的括号。在此示例中,外层括号创建一个组,而双引号标签内的括号仍是文本:
(desk ("blue (round)" "say \"hello\"") () cards)
预期的数组结构如下:
[
[
"desk",
["blue (round)", "say \"hello\""],
[],
"cards"
]
]
额外的外层数组是有意设计的:它代表整个文档。每个带括号的组都会成为其中的一个数组。这样,无论输入包含一个组、多个组,还是没有外围括号的标签,返回类型都保持一致。
以下规则定义了本文的输入格式。这些是此解析器的设计选择,并非 JavaScript 内置的括号文本格式。
| 输入特征 | 在结果树中的含义 |
|---|---|
双引号外的 ( 和 ) |
开始和结束一个嵌套数组 |
| 双引号外的空白 | 分隔标签;不保留分隔符 |
未加引号的标记(例如 cards) |
生成一个字符串 |
双引号字符串(例如 \"blue tile\") |
生成一个字符串,包含其中的空格 |
() |
生成一个空数组 |
\"\" |
生成一个空字符串 |
引号外的 12、true 或 null |
将其拼写保留为字符串 |
| 逗号和单引号 | 将其视为未加引号标记中的普通字符 |
括号也会分隔标记,因此 a(b)c 会变成 ["a", ["b"], "c"]。相邻的两个标签之间需要空白:\"a\"b 会被拒绝。如果标签包含空格、双引号或字面括号,请使用双引号形式。
双引号标记遵循 JSON 字符串语法。解析器会将每个完整的带引号标记传递给 JSON.parse();它接受 JSON 文本,并会对无效语法抛出 SyntaxError。这样就可以使用文档说明的解析器验证转义序列。ECMAScript 规范:JSON.parse
在 Safari 中打开本地 JavaScript 工作区
Safari 的 Web 检查器提供了此桌面转换所需的 JavaScript 提示符。你无需安装软件包或创建单独的项目。
- 在 Safari 中,选择
Safari→Settings…→Advanced。 - 启用
Show features for web developers。 - 打开一个标签页,在地址栏中输入
about:blank。 - 选择
Develop→Show Web Inspector,或按下Option–Command–I。 - 选择
Console标签页,并使用其中的输入提示符。
WebKit 在启用 Web 检查器中介绍了开发者设置和检查器快捷键。Web 检查器界面参考文档说明,控制台提示符会执行 JavaScript 并显示结果。
将下面的完整程序粘贴到提示符中并执行。程序包括解析器、一份作者编写的示例、六项有效输入检查和七项格式错误输入检查。无需导入任何内容。此程序已在 Node.js 20.19.2 中成功运行;Safari 界面操作步骤有文档说明,但本文未独立进行验证。
运行完整的括号文本解析器
解析器会维护一个由未闭合数组组成的栈。遇到左括号时,它会创建一个子数组并将其压入栈中;遇到右括号时,则将当前数组从栈中移除。标签会追加到当前打开的数组中。
function parenthesizedToTree(text) {
if (typeof text !== "string") {
throw new TypeError("Expected a string");
}
const root = [];
const stack = [{ items: root, start: -1 }];
const separator = c => /\s|[()]/u.test(c);
let i = 0;
function fail(message, at = i) {
throw new SyntaxError(message + " at UTF-16 offset " + at);
}
while (i < text.length) {
const c = text[i];
const current = stack[stack.length - 1].items;
if (/\s/u.test(c)) {
i++;
} else if (c === "(") {
const child = [];
current.push(child);
stack.push({ items: child, start: i });
i++;
} else if (c === ")") {
if (stack.length === 1) fail("Unexpected closing parenthesis");
stack.pop();
i++;
} else if (c === '"') {
const start = i++;
let closed = false;
while (i < text.length) {
if (text[i] === "\\") {
i += 2;
} else if (text[i] === '"') {
i++;
closed = true;
break;
} else {
i++;
}
}
if (!closed) fail("Unclosed quoted string", start);
let value;
try {
value = JSON.parse(text.slice(start, i));
} catch {
fail("Invalid JSON string", start);
}
if (i < text.length && !separator(text[i])) {
fail("Expected whitespace or a parenthesis");
}
current.push(value);
} else {
const start = i;
while (i < text.length && !separator(text[i])) {
if (text[i] === '"') fail("Quote inside an unquoted token");
i++;
}
current.push(text.slice(start, i));
}
}
if (stack.length !== 1) {
fail("Unclosed opening parenthesis", stack[stack.length - 1].start);
}
return root;
}
const input = String.raw`(desk ("blue (round)" "say \"hello\"") () cards)`;
console.log(JSON.stringify(parenthesizedToTree(input), null, 2));
const validCases = [
["", []],
["()", [[]]],
["a(b)c", ["a", ["b"], "c"]],
['("a b" "")', [["a b", ""]]],
["(12 true)", [["12", "true"]]],
[String.raw`("a\\b" "\u0061" "$&")`, [["a\\b", "a", "$&"]]]
];
for (const [source, expected] of validCases) {
const actual = parenthesizedToTree(source);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Unexpected tree for " + source);
}
}
const invalidCases = [
"(a", "a)", '("a)', String.raw`("\q")`,
'"a"b', 'a"b"', '("line\nbreak")'
];
for (const source of invalidCases) {
let rejected = false;
try {
parenthesizedToTree(source);
} catch (error) {
if (!(error instanceof SyntaxError)) throw error;
rejected = true;
}
if (!rejected) throw new Error("Expected rejection for " + source);
}
console.log("All 13 checks passed.");
第一条打印结果就是前面展示的嵌套结构,以多行格式呈现。最终消息应为 All 13 checks passed.。该函数会返回一个真正的 JavaScript 数组;JSON.stringify() 会将其转换为可打印文本,其第三个参数用于指定缩进。ECMAScript 规范:JSON.stringify
要转换另一份合成大纲,只需更改赋给 input 的示例,然后重新运行程序。保留检查代码不变,使其继续验证解析器的预期行为。需要文本表示时,将打印出的数组文本复制到你的工作文档中。
保留引号和转义语义
需要考虑两个层面:保存示例的 JavaScript 源代码,以及要解析的括号文本。示例使用 String.raw 标签模板,因此反斜杠会传递给解析器,而不会在 JavaScript 源代码层面经过普通字符串转义解码。该操作读取模板的原始片段。ECMAScript 规范:String.raw
保留所提供示例两侧的反引号。对于这种简单的编辑流程,请编写不含反引号或模板插值起始符 ${ 的示例文本,因为这些符号在 JavaScript 模板语法中有特殊含义。
在双引号输入标记中,\" 表示一个字面双引号,\\ 表示一个反斜杠,而 \u0061 表示 a。这些是解码后的字符串值:树会保留标签的含义,但会丢弃原始转义写法和外围引号。在双引号之外,自定义解析器会将反斜杠视为普通的标记字符。
例如,最后一项有效输入检查确认,输入标记 "a\\b"、"\u0061" 和 \"$&\" 会分别变成包含一个反斜杠的标签、标签 a,以及字面标签 $&。如果你需要精确保留原始空格和转义写法,仅有解码后的字符串数组是不够的;这种表示还需要保留源文本或标记位置。
为什么直接全面替换会改变结果
将每个左括号替换为左方括号、每个右括号替换为右方括号,并不能实现此语法。这样还会改动 "blue (round)" 中的括号,也无法解决未加引号的标签和缺少项分隔符的问题。此结论可直接从作者编写的示例中得出。
替换还有一个相关细节:字符串替换参数会解释 $& 等特殊序列,$& 会插入匹配到的文本。替换函数则会直接提供其返回文本,不会执行该替换步骤。ECMAScript 规范:replaceAll 和替换文本替换
此独立示例展示了两者的区别:
const label = "tile";
console.log(label.replaceAll("tile", "$&")); // tile
console.log(label.replaceAll("tile", () => "$&")); // $&
这两个结果在准备本文时都经过了验证。树解析器完全避开了替换语义:它会将解码后的字符串直接追加到数组中。如果你之后添加标签重命名步骤,请对解析后的标签执行替换,并明确选择是否要解释替换文本,还是按字面内容插入。
重新使用数组树之前,先诊断意外结果
内置检查可以作为简洁的排查工具。它们涵盖空输入、空组、相邻的组和标签、带空格的引号文本、仅字符串值、转义序列以及格式错误的语法。遇到以下情况时,可据此找出预期大纲与已定义语法之间的不一致。
| 现象 | 说明和下一步操作 |
|---|---|
| 结果多出一个外层数组 | 根数组代表整个文档。除非后续步骤明确要求只有一个顶层组,否则请保留它。 |
| 一个短语变成多个字符串 | 空白会分隔未加引号的标签。请将整个短语放在双引号内。 |
| 逗号保留在标签中 | 逗号在此处是普通字符。请使用空白分隔不同标签。 |
| 括号意外变成嵌套数组 | 请将字面括号放在双引号标签内。 |
Unexpected closing parenthesis |
某个右括号没有对应的左括号。请检查报告的位置。 |
Unclosed opening parenthesis |
输入结束时仍有一个组未闭合。报告的位置指出最内层未闭合左括号的位置。 |
Invalid JSON string |
检查带引号标记中是否包含无效转义序列或未转义的控制字符。 |
| 与引号相关的分隔符错误 | 用空白分隔相邻标签,或将整个预期标签括在一对引号中。 |
此实现中的偏移量从零开始,按 UTF-16 代码单元计数,与 JavaScript 字符串索引一致。它们不是行号,也不等于可见字符的数量。对于带引号标记的验证错误,报告的位置是标记的起始位置,因此请检查整个带引号标签。
格式错误的输入会抛出异常,函数不会返回任何结果。虽然扫描过程中会在内部构建数组,但调用者不会收到部分树。解析器使用显式栈,而不是递归函数调用;但示例没有确定所支持文档大小或嵌套深度的上限。
对于小型桌面大纲,实际操作顺序是先商定语法,再运行未更改的示例和检查项,最后替换为你编写的文本。重新使用结果前,请确认带引号的标签、空组和文档根节点;这三个细节决定了数组树是否保留了你预期的结构。
来源
- 启用 Web 检查器 — WebKit。
- Web 检查器界面 — WebKit。
- ECMAScript® 2027 语言规范:结构化数据 — Ecma International / TC39,持续更新的草案。
- ECMAScript® 2027 语言规范:文本处理 — Ecma International / TC39,持续更新的草案。