withのAST


withはテキストエディターですが、ファイルに記録された文字の並びをエディターに表示しているわけではありません。フォルダーの構造とそれぞれのテキストファイルにある見出し、そして読み仮名ルビや圏点(﹅)などの行中タグを抽象構文木――AST(Abstract Syntax Tree)にして、そこから抽出したテキストをエディターに表示して、編集しています。

たとえば以下の文です(withのサンプル小説の書き出しです)。

# ガラスの下の母
[#地付き]藤井太洋

## プロローグ
 母はスーツケースの|傍《かたわら》に立っていた。

1行目はMarkdownの見出しレベル1。文書のタイトルです。次の段落はインラインタグの[#地付き]があり、その後ろが横書きなら右寄せ、縦書きなら下付きになります。見出しレベル2がプロローグ。そして「 母はスーツケースの|傍《かたわら》に立っていた。」はプロローグ下の文書ということになり、傍にルビのタグがマークアップされています。

最終的なプレゼンテーションこうなります。

まだ見出しレベの表現に差をつけていないのでちょっと雰囲気が出ませんが、テキストエディターの画面としては上出来でしょう(ルビを肩付きにしているのも、これが本番の組版ではなく簡易表示であることを表明するためです)。

エディターはmacOS標準のNSTextViewを用いています。Apple Intelligenceの校正ツールなんか出てくる標準のテキストエディットの画面です。その画面に「1行目はゴシックを使ってください。2行目の0文字目から後ろは右寄せ、4行目はゴシック。5行目の10、12、17番目のテキスト(タグの記号)は出力しないで11文字目文字の脇に、小さな13〜16番目の文字を並べます――」みたいな指示をするわけですが、何番目が見出しかどうかを知るには、テキストを検索するしかありません。

行頭に#がある行は大見出し、みたいな処理です。検索は確実ですし、これが一回で済めばいいのですが、何千行、何万文字にもなるテキストを全てスタイリングしたままにしておくのは無駄が多いので、画面に見えそうな範囲を評価して、スタイリングすることになります。文字が変わらなければファイルを開いたときの検索結果をキャッシュしておけばいいのですが、エディターなのでそうもいきません。修正した文字の後ろの「N文字目」は全部ずれてしまいますから、タイピングのたびに「N文字目」を再評価しなければなりません。さらに、タグの表現を一つ増やすたびに、検索回数は増えてしまいます。入れ子になった時の処理も複雑になりますし、評価範囲外で始まるスタイリング(回想や手紙で使う字下げは五画面分にまたがることもありますね)があると、タグの末尾を拾い損ねたりするなど、バグの温床になります。

そこでwithでは、プロジェクト全体を通した構文木を作ることにしました。それがASTです。

上記のテキストは、withの中では以下のような形式でメモリに載っています。これはエディター用なので、ちょっと長い。

      children:
        - block 1
          id: d79274de7c6e42cb
          proseKind: narrative
          sourceFile: Untitled.txt
          range: {10, 10}
          lines: 2...2
          text: ""
          semanticHash: e36cf2af4be81ba9
          children:
            - paragraph
              id: e5ddfce12abd9327
              proseKind: narrative
              sourceFile: Untitled.txt
              range: {10, 10}
              lines: 2...2
              text: "[#地付き]藤井太洋"
              semanticHash: cadf1740633e72b7
              inlineMarkups:
                - aozoraAnnotation
                  id: cadab84632425619
                  fullText: "[#地付き]"
                  contentText: "地付き"
                  fullRange: {10, 6}
                  contentRange: {12, 3}
                  metadata: aozoraCommand("地付き")
                  markers:
                    - opening "[#" range: {10, 2}
                    - annotation "地付き" range: {12, 3}
                    - closing "]" range: {15, 1}
        - paragraph
          id: 4f9343474567af45
          proseKind: narrative
          sourceFile: Untitled.txt
          range: {21, 0}
          lines: 3...3
          text: ""
          semanticHash: 7ae39404b7b92d83
        - chapter "プロローグ"
          id: 480708c7b439de41
          proseKind: narrative
          sourceFile: Untitled.txt
          range: {22, 8}
          lines: 4...4
          text: "プロローグ"
          semanticHash: 52575a141860cfa8
          children:
            - block 1
              id: 04db94014bd6528f
              proseKind: narrative
              sourceFile: Untitled.txt
              range: {31, 24}
              lines: 5...5
              text: ""
              semanticHash: 25d95d8de13545f0
              children:
                - paragraph
                  id: 464884ad669ed167
                  proseKind: narrative
                  sourceFile: Untitled.txt
                  range: {31, 24}
                  lines: 5...5
                  text: "母はスーツケースの|傍《かたわら》に立っていた。"
                  semanticHash: c8fdecbdaf6722c2
                  inlineMarkups:
                    - ruby
                      id: d52c5ebbcd0f94a9
                      fullText: "|傍《かたわら》"
                      contentText: ""
                      fullRange: {40, 8}
                      contentRange: {41, 1}
                      metadata: rubyText("かたわら")
                      markers:
                        - prefix "|" range: {40, 1}
                        - opening "《" range: {42, 1}
                        - annotation "かたわら" range: {43, 4}
                        - closing "》" range: {47, 1}
            - paragraph
              id: 990e861a8ae9c087
              proseKind: narrative
              sourceFile: Untitled.txt
              range: {56, 0}
              lines: 6...6
              text: ""
              semanticHash: 7ae39404b7b92d83

ファイルを開いたときにこれを作ると、そのあとは全文検索を何度も何度もやる必要がありません。どこにルビがあるか見出しがあるかわかっているからです。エディターで編集すると、段落単位でこの構造を作って差し替えていく。アウトラインで文章のブロックをごそっと入れ替える機能があるんですが、ASTを使うとあっという間に実装できました。レスポンスを保つために、データを入れ替えてエディターに反映して、それからファイルに書き込んでいくようなことも可能です。

検索も、ちょっと面白いことになりました。まだまだ作りかけなんですが、プロジェクトの一括検索で、ファイルを舐めなくても、どこに検索対象があるかわかる。そして「ファイルにある」だけではなくて、見出しレベルでどこの下にあるかも表示できます。

履歴やスナップショットから復元する時など、差分が必要になることもあります。そこでもASTは大活躍です。これはスナップショットから復元する時の変更部分の表示なのですが、並び順の変更、テキストの変更部分がどの見出し下にあるかを表示することができています。追加と削除だけでなく、ちょっとだけ変わった文がどこからどこに移動したのかを知ることだって可能です。

下の画面は見てわかるように選択的に上書き部分を受け入れる統合用のインターフェイスですが、それほど悩まずに作れました。何せハッシュとidがあるAST同を比較するだけですから、それほど負荷は高くありません。ハッシュを比べるだけで、ファイルを読むことなく判定できるからです。

文章そのものを構文木にすることも考えたんですが、メリットが見えないので流石に手はつけていません。

つらつらと書きましたが、withの中身の話でした。

コメントを残す