要将嵌套括号文本转换为数组树,请从左向右扫描,每遇到一个左括号就创建一个数组,每遇到一个右括号就返回其父级数组。本指南提供了一个完整的 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 提示符。你无需安装软件包或创建单独的项目。

  1. 在 Safari 中,选择 Safari → Settings… → Advanced。
  2. 启用 Show features for web developers。
  3. 打开一个标签页,在地址栏中输入 about:blank。
  4. 选择 Develop → Show Web Inspector,或按下 Option–Command–I。
  5. 选择 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 字符串索引一致。它们不是行号,也不等于可见字符的数量。对于带引号标记的验证错误,报告的位置是标记的起始位置,因此请检查整个带引号标签。

格式错误的输入会抛出异常,函数不会返回任何结果。虽然扫描过程中会在内部构建数组,但调用者不会收到部分树。解析器使用显式栈,而不是递归函数调用;但示例没有确定所支持文档大小或嵌套深度的上限。

对于小型桌面大纲,实际操作顺序是先商定语法,再运行未更改的示例和检查项,最后替换为你编写的文本。重新使用结果前,请确认带引号的标签、空组和文档根节点;这三个细节决定了数组树是否保留了你预期的结构。

来源