第1回: なぜ構文エラーは生まれるのか
シリーズ「構文エラーのない世界へ」 — 目次に戻る
プログラムの二重生活
プログラムには二つの顔がある。
一つはテキストとしての顔。エディタに表示され、キーボードで入力し、ファイルに保存される文字の並び。
let total = prices.reduce((sum, p) => sum + p, 0)もう一つは構造としての顔。コンパイラやインタプリタが理解する、木のような形をしたデータ。
let
/ \
total .reduce
/ \
prices (callback, 0)
/
(sum, p) => sum + p
この木を AST(Abstract Syntax Tree: 抽象構文木) と呼ぶ。プログラミング言語の処理系がプログラムを実行するとき、相手にしているのはテキストではなく、この木だ。
プログラマーはテキストを書く。コンパイラは木を読む。この間を繋ぐのがパーサーだ。
テキスト → [パーサー] → AST → [コンパイラ] → 実行
パーサーは厳格な翻訳者
パーサーの仕事は、文字の並びを木構造に変換すること。しかしパーサーには厳格なルールがある。変換できるのは、文法に従った「正しい」文字列だけだ。
一文字でもルールに合わないと、パーサーは構文エラーを返す。これは「翻訳できません」という意味だ。
ここが問題の核心になる。
途中状態は「間違い」ではない
プログラムを書いている最中のことを思い出してほしい。
let x =
ここで手を止めて考えている。値に何を入れるか。この文字列は「不完全」だが、間違っているわけではない。まだ考えている途中なだけだ。
しかしパーサーにとって、この文字列は「変換できない」。結果として構文エラーが発生する。構文エラーが発生すると、型チェックも、入力補完も、リファクタリングも、すべてが止まる。
もう少し具体的に見てみよう。let x = 1 + 2 と入力する過程を追う。
l → 構文エラー
le → 構文エラー
let → 構文エラー(まだ不完全)
let x → 構文エラー
let x = → 構文エラー
let x = 1 → パース成功 ✓
let x = 1 + → 構文エラー(演算子の右辺がない)
let x = 1 + 2 → パース成功 ✓
8ステップ中、パースに成功するのは2ステップだけ。書いている時間の75%で、エディタはプログラムの構造を把握できていない。
これが「構文エラーが生まれる」構造的な理由だ。テキストという表現とASTという表現の間にパーサーがいて、パーサーは途中状態を受け付けない。
エラー回復という応急処置
現代のエディタ(VSCode、IntelliJ等)はこの問題にかなり対処している。エラー回復(error recovery) と呼ばれる技術で、パースが失敗しても「なんとかそれらしいAST」を推測して構築する。tree-sitter のようなインクリメンタルパーサーは、この技術を高度に発展させたものだ。
しかしこれは本質的に「推測」だ。推測が外れることもあるし、推測の結果としてのASTに対する型チェックの結果は信頼性が下がる。一つの構文エラーが原因で、全く関係ない場所に大量のエラーが表示される経験は、多くのプログラマーが持っているだろう。
本当の問題
ここまでの議論をまとめると、問題は三層になっている。
第一層: テキストとASTの不一致 プログラマーはテキストで考え、テキストで入力する。しかし言語処理系が必要としているのはAST。この変換は、入力の途中段階では失敗する。
第二層: パーサーの全か無か パーサーは「完全に正しい文字列」か「エラー」の二択しかない。エラー回復は応急処置であり、正確さの保証がない。
第三層: エラーの連鎖 パースが失敗すると、それに依存する型チェック、入力補完、リファクタリングといったエディタ機能がすべて劣化する。一つの不完全さが全体に波及する。
これは特定のエディタやパーサーの実装の問題ではない。「テキストを書いてパーサーに渡す」というモデルそのものに内在する問題だ。
では、どうすればいいのか?
発想を転換する。
テキストからASTに変換するのではなく、ASTそのものを直接編集する。画面に表示されるテキスト風の見た目は、ASTを人間向けに「表示」した結果にすぎない。パーサーは中心的な役割から退き、テキスト入力を補助する脇役になる。
この発想を Projectional Editing(射影編集)と呼ぶ。
次回は、この Projectional Editing がどういう仕組みで、何を解決し、何が難しいのかを見ていく。