若要將巢狀括號文字轉換為陣列樹,請由左至右掃描;每遇到一個左括號就建立一個陣列,每遇到一個右括號就返回其父陣列。本指南提供完整的 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 Inspector 提供此桌面轉換所需的 JavaScript 提示字元。你不需要安裝套件,也不必另建專案。

  1. 在 Safari 中,選擇 Safari → 設定⋯ → 進階。
  2. 啟用 顯示網頁開發者功能。
  3. 開啟分頁,並在網址欄輸入 about:blank。
  4. 選擇 開發 → 顯示網頁檢閱器,或按下 Option–Command–I。
  5. 選取 主控台 分頁,並使用其輸入提示字元。

WebKit 在啟用 Web Inspector中說明了開發者設定和檢閱器快捷鍵。Web Inspector 介面參考文件則說明,主控台提示字元會執行 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 字串索引方式一致。它們不是行號,也不是可見字元的數量。若引號詞元驗證發生錯誤,回報的位置是該詞元的起始處,因此請檢查整個引號標籤。

格式錯誤的輸入會擲回例外,函式也不會回傳結果。雖然陣列會隨掃描過程在內部逐步建立,呼叫端不會收到部分樹。此剖析器使用明確的堆疊,而不是遞迴函式呼叫,但範例並未設定文件大小或巢狀深度的支援上限。

對於小型桌面大綱,實際流程是先確認文法、執行未修改的範例和檢查項目,再換成你撰寫的文字。重用結果前,請確認引號標籤、空群組和文件根節點;這三項細節會決定陣列樹是否保留了你預期的結構。

來源