入れ子の括弧付きテキストを配列ツリーに変換するには、左から右へ読み進め、開き括弧ごとに配列を作り、閉じ括弧ごとに親の配列へ戻ります。このガイドでは、Mac上のSafariでローカルに実行できる完全なJavaScriptパーサーを紹介します。

ここでの作業は、作成した小さなラベルのアウトラインを、順序、引用符で囲まれたテキスト、空のグループを維持したまま、入れ子の配列に変換することです。すべての例では合成文字列を使用します。このパーサーには明示的な文法規則と検査が含まれているため、生成された構造が意図したとおりかどうかを確認できます。

括弧と引用符付きラベルの意味を決める

変換する前に、構造を表す括弧と、ラベル内に含まれる括弧を区別します。この例では、外側の括弧はグループを作り、二重引用符で囲まれたラベル内の括弧はテキストとして扱われます。

(desk ("blue (round)" "say \"hello\"") () cards)

意図される配列構造は次のとおりです。

[
  [
    "desk",
    ["blue (round)", "say \"hello\""],
    [],
    "cards"
  ]
]

外側に余分な配列があるのは意図的です。これは文書全体を表します。括弧で囲まれた各グループは、その中の1つの配列になります。これにより、入力にグループが1つ含まれる場合も、複数含まれる場合も、括弧で囲まれていないラベルが含まれる場合も、戻り値の型が一貫します。

次の規則で、この記事の入力形式を定義します。これはこのパーサーの設計上の選択であり、JavaScriptに組み込みの括弧付きテキスト形式があるわけではありません。

入力の要素 結果のツリーでの意味
二重引用符の外にある ( と ) 入れ子の配列の開始と終了
二重引用符の外にある空白 ラベルを区切る。区切り自体は保持しない
cards のような引用符で囲まれていないトークン 文字列を生成する
"blue tile" のような二重引用符付き文字列 内部の空白を含む1つの文字列を生成する
() 空の配列を生成する
"" 空の文字列を生成する
引用符の外にある 12、true、または null 表記を文字列として保持する
カンマと一重引用符 引用符で囲まれていないトークン内の通常の文字として扱う

括弧もトークンを区切るため、a(b)c は ["a", ["b"], "c"] になります。隣り合う2つのラベルの間には空白が必要です。"a"b は拒否されます。ラベルに空白、二重引用符、または文字としての括弧が含まれる場合は、二重引用符で囲んでください。

二重引用符付きトークンはJSON文字列の構文に従います。パーサーは、完成した引用符付きトークンを JSON.parse() に渡します。この関数はJSONテキストを受け取り、構文が無効なら SyntaxError をスローします。これにより、エスケープの検証を、仕様で定められたパーサーに委ねられます。ECMAScript仕様: JSON.parse

SafariでローカルのJavaScript作業環境を開く

SafariのWebインスペクタには、このデスクトップでの変換に必要なJavaScript入力欄があります。パッケージのインストールや別のプロジェクトは必要ありません。

  1. Safariで Safari → 設定… → 詳細 を選択します。
  2. Web開発者向けの機能を表示 を有効にします。
  3. タブを開き、アドレス欄に about:blank と入力します。
  4. 開発 → Webインスペクタを表示 を選択するか、Option–Command–I を押します。
  5. コンソール タブを選択し、入力欄を使います。

WebKitは、開発者向け設定とインスペクタのショートカットをWebインスペクタを有効にするで説明しています。Webインスペクタのインターフェイスのリファレンスでは、コンソールの入力欄がJavaScriptを評価し、結果を表示することが説明されています。

以下のプログラム全体を入力欄に貼り付けて実行します。パーサー、作成したサンプル、有効な入力の検査6件、形式不正な入力の検査7件が含まれています。インポートは不要です。このプログラムは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() はその値を表示可能なテキストに変換し、3番目の引数でインデントを指定します。ECMAScript仕様: JSON.stringify

別の合成アウトラインを変換するには、input に代入されているサンプルだけを変更して、プログラムを再実行します。パーサーが意図した動作を引き続き検証できるよう、検査は変更しないでください。テキスト形式が必要な場合は、出力された配列テキストを作業中の文書にコピーします。

引用符とエスケープの意味を保つ

考慮すべき層は2つあります。サンプルを格納するJavaScriptソースと、解析対象の括弧付きテキストです。サンプルでは String.raw タグ付きテンプレートを使うため、通常の文字列エスケープ処理によってJavaScriptソース側でデコードされず、バックスラッシュがパーサーに渡されます。ここで説明する処理では、テンプレートのrawセグメントを読み取ります。ECMAScript仕様: String.raw

用意されたサンプルを囲むバッククォートはそのまま残してください。この簡単な編集手順では、JavaScriptのテンプレート構文で意味を持つバッククォートや補間開始記号 ${ を含まないサンプルテキストを作成します。

二重引用符付きの入力トークン内では、\" は文字としての二重引用符、\\ は1つのバックスラッシュ、\u0061 は a を表します。これらはデコードされた文字列値です。ツリーではラベルの意味が保持されますが、元のエスケープ表記と周囲の引用符は破棄されます。二重引用符の外では、この独自パーサーはバックスラッシュを通常のトークン文字として扱います。

たとえば、有効な入力を使った最後の検査では、入力トークン "a\\b"、"\u0061"、"$&" が、それぞれバックスラッシュを1つ含むラベル、ラベル a、文字どおりのラベル $& になることを確認します。元の空白やエスケープ表記を正確に再現する必要がある場合、デコード済み文字列の配列だけでは不十分です。表現にソーステキストまたはトークン位置も保持する必要があります。

一律の置換で結果が変わる理由

すべての開き括弧を角括弧に、すべての閉じ括弧を別の角括弧に置き換えても、この文法には対応できません。"blue (round)" 内の括弧まで変わってしまい、引用符で囲まれていないラベルや、欠けている項目区切りも解決されません。この結論は、作成したサンプルから直接導かれます。

置換にはもう1つ関係する点があります。文字列を置換引数に渡すと、$& のような特殊な並びが解釈され、マッチしたテキストが挿入されます。置換関数を使うと、返したテキストはその置換処理を経ずに使われます。ECMAScript仕様: replaceAllと置換文字列の展開

次の独立した例で違いを確認できます。

const label = "tile";
console.log(label.replaceAll("tile", "$&"));       // tile
console.log(label.replaceAll("tile", () => "$&")); // $&

準備中に両方の結果を検証しました。ツリーパーサーでは、文字列を配列に直接追加するため、置換の意味論を完全に避けられます。後からラベル名を変更する処理を加える場合は、解析済みのラベルを置換し、置換テキストを解釈させるのか、文字どおりに挿入するのかを明示的に選んでください。

ツリーを再利用する前に予期しない出力を診断する

含まれている検査は、簡潔な判断材料になります。空の入力、空のグループ、隣接するグループとラベル、空白を含む引用符付き文字列、文字列のみの値、エスケープ、形式不正な構文を対象にしています。次の症状を参考に、意図したアウトラインと定義済みの文法との不一致を特定してください。

症状 説明と次の対応
結果に外側の配列が余分にある ルートは文書を表します。次の処理で最上位のグループが1つだけ必要と明示されていない限り、残してください。
1つのフレーズが複数の文字列になる 空白は引用符で囲まれていないラベルを区切ります。フレーズを二重引用符で囲んでください。
カンマがラベルに残る ここではカンマは通常の文字です。ラベルを分けるには空白を使ってください。
括弧が予想外に入れ子の配列になる 文字としての括弧をラベルに含める場合は、二重引用符で囲んでください。
Unexpected closing parenthesis 対応する開きグループのない閉じ括弧があります。報告された位置を確認してください。
Unclosed opening parenthesis グループが閉じられないまま終わっています。報告位置は、閉じられていない開き括弧のうち最も内側のものを示します。
Invalid JSON string 引用符付きトークンに無効なエスケープやエスケープされていない制御文字がないか確認してください。
引用符に関する区切りエラー 隣接するラベルを空白で区切るか、意図したラベル全体を1つの引用符付き文字列で囲んでください。

この実装のオフセットは0から始まり、JavaScriptの文字列インデックスと同様にUTF-16コード単位で数えます。行番号や、画面上で見える文字数ではありません。引用符付きトークンの検証エラーで報告される位置はトークンの開始位置です。引用符付きラベル全体を確認してください。

形式不正な入力では例外がスローされ、関数は値を返しません。読み取り中に配列は内部で組み立てられますが、呼び出し元が部分的なツリーを受け取ることはありません。このパーサーは再帰関数呼び出しの代わりに明示的なスタックを使いますが、この例では文書サイズや入れ子の深さの上限を定めていません。

小規模なデスクトップ用アウトラインなら、文法を決め、サンプルと検査を変更せずに実行してから、作成したテキストに差し替えるのが実用的です。結果を再利用する前に、引用符付きラベル、空のグループ、文書のルートを確認してください。この3点によって、配列ツリーに意図した構造が保持されるかどうかが決まります。

出典