構文
JavaScript は解析が最も難しい構文の一つであり、 このチュートリアルでは、学習中に経験したすべての苦労と涙を詳細に紹介します。
LL(1) 構文
ウィキペディアによると、
LL 構文とは、左から右に入力を解析できる LL パーサーで解析可能な文脈自由構文である。
最初の L はソースを 左 から右にスキャンすることを意味し、 2番目の L は 左最深 の導出木の構築を意味する。
文脈自由であり、LL(1) の (1) は、次のトークンを一瞥するだけで木を構築できることを意味する。
LL 構文は、我々は怠け者な人間であり、手動でパーサーを書く必要なく、自動的にパーサーを生成するプログラムを書きたいため、学術界で特に注目されている。
残念なことに、多くの産業用プログラミング言語にはきれいな LL(1) 構文がなく、これは JavaScript も同様である。
INFO
Mozilla は数年前に jsparagus プロジェクトを開始し、Python による LALR パーサー生成器を書いた。 過去2年間はほとんど更新されておらず、js-quirks.md の最後に強いメッセージを送っている。
今日何を学んだか?
- なぜなら、JavaScript のパーサーを書くべきではない。
- JavaScript にはいくつかの構文的な恐怖がある。しかし、すべての間違いを避けて世界で最も広く使われるプログラミング言語を作ったわけではない。適切な状況で、適切なユーザーのために機能するツールを提供することで、そうした言語になったのだ。
現実的で唯一の方法は、その構文の性質上、手動で再帰下位降下パーサーを書くことである。 そのため、自らの足を撃つ前に、構文のすべての奇妙さを学びましょう。
以下のリストは簡単なものから始まり、徐々に理解しづらくなるため、コーヒーを一杯取り、時間をかけてください。
識別子
#sec-identifiers で定義された識別子は3種類ある。
IdentifierReference[Yield, Await] :
BindingIdentifier[Yield, Await] :
LabelIdentifier[Yield, Await] :estree と一部の AST は上記の識別子を区別しない。 仕様書もこれらを平易な文章で説明していない。
BindingIdentifier は宣言であり、IdentifierReference はバインディング識別子への参照である。 たとえば var foo = bar において、foo は BindingIdentifier、bar は IdentifierReference である:
VariableDeclaration[In, Yield, Await] :
BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await] opt
Initializer[In, Yield, Await] :
= AssignmentExpression[?In, ?Yield, ?Await]AssignmentExpression から PrimaryExpression へ進むと、以下のようになる:
PrimaryExpression[Yield, Await] :
IdentifierReference[?Yield, ?Await]AST 内でこれらの識別子を異なるものとして宣言すると、後続のツール(特に意味解析)が大幅にシンプルになる。
pub struct BindingIdentifier {
pub node: Node,
pub name: Atom,
}
pub struct IdentifierReference {
pub node: Node,
pub name: Atom,
}クラスと厳格モード
ECMAScript クラスは厳格モードの後に誕生したため、クラス内のすべてが厳格モードであるようにした。 これは #sec-class-definitions で「ノード:クラス定義は常に厳格モードコードである。」と述べられている。
関数スコープに関連付けることで厳格モードを簡単に宣言できるが、class 宣言にはスコープがない。 したがって、クラスの解析だけのために追加の状態を保持する必要がある。
// https://github.com/swc-project/swc/blob/f9c4eff94a133fa497778328fa0734aa22d5697c/crates/swc_ecma_parser/src/parser/class_and_fn.rs#L85
fn parse_class_inner(
&mut self,
_start: BytePos,
class_start: BytePos,
decorators: Vec<Decorator>,
is_ident_required: bool,
) -> PResult<(Option<Ident>, Class)> {
self.strict_mode().parse_with(|p| {
expect!(p, "class");古典的な8進数と "use strict"
#sec-string-literals-early-errors は、文字列内にエスケープされた古典的な8進数 "\01" を禁止している:
EscapeSequence ::
LegacyOctalEscapeSequence
NonOctalDecimalEscapeSequence
この生産によって一致するソーステキストが厳格モードコードである場合、構文エラーとなる。これを検出する最適な場所はレキサ内部である。 レキサはパーサーに厳格モード状態を問い合わせ、それに応じてエラーを発生させることができる。
しかし、指示子と混在している場合、これが不可能になる。
https://github.com/tc39/test262/blob/747bed2e8aaafe8fdf2c65e8a10dd7ae64f66c47/test/language/literals/string/legacy-octal-escape-sequence-prologue-strict.js#L16-L19use strict はエスケープされた古典的な8進数の後に宣言されているが、構文エラーが必要となる。 幸運にも、実際に使用されるコードでは古典的な8進数と指示子を併用することはほとんどない……ただし、上記の test262 ケースをパスしたい場合は例外である。
非単純パラメータと厳格モード
非厳格モードでは同一の関数パラメータが許可される function foo(a, a) { }、 これを use strict を追加することで禁止できる:function foo(a, a) { "use strict" }。 その後の es6 では、関数パラメータに他の構文が追加された例としては function foo({ a }, b = c) {} がある。
では、以下のように書いてみた場合、"01" が厳格モードエラーであるとき、どうなるだろうか?
function foo(
value = (function() {
return "\01";
}()),
) {
"use strict";
return value;
}より具体的に言えば、パラメータ内で厳格モードの構文エラーがある場合、パーサーの観点から何をすべきか? #sec-function-definitions-static-semantics-early-errors では、単純にこのケースを禁止し、次のように述べている:
FunctionDeclaration :
FunctionExpression :
FunctionBodyContainsUseStrict of FunctionBody が真であり、IsSimpleParameterList of FormalParameters が偽である場合、構文エラーとなる。Chrome は「未処理の構文エラー:非単純パラメータリストを持つ関数での不正な 'use strict' 指示子」という謎のメッセージでこのエラーを投げる。
より詳しくは、ESLint の作者によるこのブログ記事に詳述されている。
INFO
面白い事実:TypeScript で es5 にターゲットしている場合、上記のルールは適用されない。 これはトランスパイルして以下のようになる:
function foo(a, b) {
"use strict";
if (b === void 0) b = "\01";
}括弧式
括弧式は意味を持たないはずだという認識がある。 たとえば ((x)) に対する AST は、ParenthesizedExpression → ParenthesizedExpression → IdentifierReference ではなく、単一の IdentifierReference でよい。 これこそが JavaScript 構文の本来の姿である。
しかし……予想もしなかったことに、これは実行時にも意味を持つことがある。 この estree 問題で見つかったが、以下の通り:
> fn = function () {};
> fn.name
< "fn"
> (fn) = function () {};
> fn.name
< ''最終的に、acorn と babel は互換性のために preserveParens オプションを追加した。
条件文内の関数宣言
#sec-ecmascript-language-statements-and-declarations の構文に正確に従うと:
Statement[Yield, Await, Return] :
... 多くのステートメント
Declaration[Yield, Await] :
... 宣言私たちが作る AST 用の Statement ノードは明らかに Declaration を含まない。
しかし、付録 B #sec-functiondeclarations-in-ifstatement-statement-clauses では、非厳格モードで if ステートメントのステートメント位置に宣言を許可している:
if (x) {
function foo() {}
} else function bar() {}ラベルステートメントは正当
おそらく誰もがラベルステートメントを一度も書いたことはないだろうが、現代の JavaScript ではそれが正当であり、厳格モードでも禁止されていない。
以下の構文は正しい。これはオブジェクトリテラルではなくラベル付きステートメントを返す。
<Foo
bar={() => {
baz: "quaz";
}}
/>
// ^^^^^^^^^^^ `LabelledStatement`let はキーワードではない
let はキーワードではないため、構文が明確に let がその位置では許可されていないと規定しない限り、どこにでも出現可能である。 パーサーは let トークンの後のトークンを先読みし、どのようにパースすべきか決定する必要がある。例えば:
let a;
let = foo;
let instanceof x;
let + 1;
while (true) let;
a = let[0];for-in / for-of と [In] コンテキスト
#prod-ForInOfStatement における for-in および for-of の構文を見てみると、これらの解析方法がすぐには理解できない。
我々が理解する上で大きな障害となるのは2点:[lookahead ≠ let] の部分と [+In] の部分である。
for (let までパース済みの場合、先読みトークンが:
inではない(for (let inを禁止){、[、または識別子である(for (let {} = foo)、for (let [] = foo)、for (let bar = foo)を許可)
of または in キーワードに到達した時点で、右辺式は正しい [+In] コンテキストで渡される必要があり、#prod-RelationalExpression の2つの in 式を禁止する。
RelationalExpression[In, Yield, Await] :
[+In] RelationalExpression[+In, ?Yield, ?Await] in ShiftExpression[?Yield, ?Await]
[+In] PrivateIdentifier in ShiftExpression[?Yield, ?Await]
注釈2:[In] グラマー・パラメータは、関係式内の `in` 演算子と、`for` ステートメント内の `in` 演算子を誤解するのを避けるために必要である。そして、この [In] コンテキストは仕様書全体で唯一の用途である。
また、[lookahead ∉ { let, async of }] により for (async of ...) が禁止される。 これは明示的に防ぐ必要がある。
ブロックレベルの関数宣言
付録 B.3.2 #sec-block-level-function-declarations-web-legacy-compatibility-semantics では、Block ステートメント内での FunctionDeclaration の振る舞いについて、1ページが割かれている。 要するに:
https://github.com/acornjs/acorn/blob/11735729c4ebe590e406f952059813f250a4cbd1/acorn/src/scope.js#L30-L35関数宣言内の FunctionDeclaration の名前は、関数宣言内にある場合は var 宣言と同じ扱いにする必要がある。 このコードスニペットは、bar がブロックスコープ内にあるため再宣言エラーで終了する:
function foo() {
if (true) {
var bar;
function bar() {} // 再宣言エラー
}
}一方、以下はエラーにならない。なぜなら関数スコープ内にあるため、関数 bar は var 宣言として扱われるからである:
function foo() {
var bar;
function bar() {}
}構文コンテキスト
文法には、特定の構造を許可または禁止するために使用される5つのコンテキストパラメータがある。 すなわち [In]、[Return]、[Yield]、[Await]、[Default] である。
パース中はコンテキストを保持するのが最善である。 たとえば、Biome では:
// https://github.com/rome/tools/blob/5a059c0413baf1d54436ac0c149a829f0dfd1f4d/crates/rome_js_parser/src/state.rs#L404-L425
pub(crate) struct ParsingContextFlags: u8 {
/// パーサーがジェネレーター関数(例:`function* a() {}`)内にあるかどうか
/// `Yield` パラメータに一致
const IN_GENERATOR = 1 << 0;
/// パーサーが関数内にあるかどうか
const IN_FUNCTION = 1 << 1;
/// パーサーがコンストラクター内にあるかどうか
const IN_CONSTRUCTOR = 1 << 2;
/// そのコンテキストで `async` が許可されているか。関数が `async` であるか、トップレベルの `await` がサポートされているため。
/// `Async` ジェネレーターに相当
const IN_ASYNC = 1 << 3;
/// パーサーがトップレベルのステートメント(クラス、関数、パラメータの外)内にあるかどうか
const TOP_LEVEL = 1 << 4;
/// パーサーが反復または `switch` ステートメント内にあり、`break` が許可されているかどうか
const BREAK_ALLOWED = 1 << 5;
/// パーサーが反復ステートメント内にあり、`continue` が許可されているかどうか
const CONTINUE_ALLOWED = 1 << 6;そして、文法に従ってこれらのフラグを切り替え、確認する。
代入パターンとバインディングパターン
estree では、AssignmentExpression の左側は Pattern である:
extend interface AssignmentExpression {
left: Pattern;
}VariableDeclarator の左側も Pattern である:
interface VariableDeclarator <: Node {
type: "VariableDeclarator";
id: Pattern;
init: Expression | null;
}Pattern は Identifier、ObjectPattern、ArrayPattern である:
interface Identifier <: Expression, Pattern {
type: "Identifier";
name: string;
}
interface ObjectPattern <: Pattern {
type: "ObjectPattern";
properties: [ AssignmentProperty ];
}
interface ArrayPattern <: Pattern {
type: "ArrayPattern";
elements: [ Pattern | null ];
}しかし、仕様書の観点からは、以下のようになる:
// AssignmentExpression:
{ foo } = bar;
^^^ IdentifierReference
[ foo ] = bar;
^^^ IdentifierReference
// VariableDeclarator
var { foo } = bar;
^^^ BindingIdentifier
var [ foo ] = bar;
^^^ BindingIdentifierここから混乱が始まる。 なぜなら、Pattern 内部の Identifier が BindingIdentifier か IdentifierReference かを直接区別できなくなるからである:
enum Pattern {
Identifier, // これは `BindingIdentifier` か `IdentifierReference` か?
ArrayPattern,
ObjectPattern,
}これにより、パーサーパイプラインの後段で不要なコードが大量に発生する。 たとえば、意味解析のスコープ設定を行う際、この Identifier の親を調査して、スコープにバインドするかどうかを判断する必要がある。
より良い解決策は、仕様書を完全に理解し、どうすべきかを決めるものである。
AssignmentExpression と VariableDeclaration は以下の通り定義されている:
13.15 代入演算子
AssignmentExpression[In, Yield, Await] :
LeftHandSideExpression[?Yield, ?Await] = AssignmentExpression[?In, ?Yield, ?Await]
13.15.5 解体代入
特定の状況下で、Production「AssignmentExpression : LeftHandSideExpression = AssignmentExpression」を処理する際に、LeftHandSideExpression の解釈は以下の文法を使用して細分化される:
AssignmentPattern[Yield, Await] :
ObjectAssignmentPattern[?Yield, ?Await]
ArrayAssignmentPattern[?Yield, ?Await]14.3.2 変数ステートメント
VariableDeclaration[In, Yield, Await] :
BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await]opt
BindingPattern[?Yield, ?Await] Initializer[?In, ?Yield, ?Await]仕様書は、これらの2つの文法を区別し、AssignmentPattern と BindingPattern として別々に定義している。
したがって、このような状況では estree から逸脱することを恐れず、パーサー用に追加の AST ノードを定義すべきである:
enum BindingPattern {
BindingIdentifier,
ObjectBindingPattern,
ArrayBindingPattern,
}
enum AssignmentPattern {
IdentifierReference,
ObjectAssignmentPattern,
ArrayAssignmentPattern,
}私は、1週間ほど非常に混乱した状態にあったが、やっと開眼した: Pattern 1つのノードではなく、AssignmentPattern ノードと BindingPattern ノードを定義する必要がある。
estreeは正しいはずだ。人々が長年使っており、間違っているはずがない?- 2つの独立したノードを定義せずに、パターン内の
Identifierをクリアに区別する方法はあるのか? 私は文法を見つけることができない。 - 1日かけて仕様書を調べ続け……
AssignmentPatternの文法は、「13.15 代入演算子」のメインセクションの第5節「補足構文」に記載されている 🤯 —— これは本当に不自然である。すべての文法はメインセクションに定義されているが、このように「実行時意味」セクションの後に定義されている。
TIP
以下のケースは非常に理解しづらい。ここにはドラゴンがいる。
不明確な構文
まずパーサーの立場から考え、問題を解決しよう —— / トークンは除算演算子か、正規表現の開始か?
a / b;
a / / regex /;
a /= / regex /;
/ regex / / b;
/=/ / /=/;ほぼ不可能ではないか? 分けて考えて、文法に従ってみよう。
最初に理解すべきことは、#sec-ecmascript-language-lexical-grammar で述べられているように、文法がレキサの文法を駆動しているということである。
複数の状況において、レキサ入力要素の識別は、その入力要素を消費している文法コンテキストに敏感である。
つまり、パーサーがレキサに次にどのトークンを返すかを通知する責任がある。 上記の例では、レキサは / トークンまたは RegExp トークンを返す必要がある。
正しい / または RegExp トークンを得るために、仕様書は次のように述べている:
InputElementRegExp目標シンボルは、正規表現リテラルが許可されているすべての文法コンテキストで使用される… その他のすべてのコンテキストでは、InputElementDivがレキサの目標シンボルとして使用される。
InputElementDiv と InputElementRegExp の構文は以下の通り:
InputElementDiv ::
WhiteSpace
LineTerminator
Comment
CommonToken
DivPunctuator <---------- `/` と `/=` トークン
RightBracePunctuator
InputElementRegExp ::
WhiteSpace
LineTerminator
Comment
CommonToken
RightBracePunctuator
RegularExpressionLiteral <-------- `RegExp` トークンこれは、文法が RegularExpressionLiteral に到達した場合、/ は RegExp トークンとしてトークナイズされなければならない(マッチする / がなければエラーを投げる)ことを意味する。 それ以外のすべての場合、/ はスラッシュトークンとしてトークナイズされる。
例を一つ見てみよう:
a / / regex /
^ ------------ PrimaryExpression:: IdentifierReference
^ ---------- MultiplicativeExpression: MultiplicativeExpression MultiplicativeOperator ExponentiationExpression
^^^^^^^^ - PrimaryExpression: RegularExpressionLiteralこのステートメントは他の Statement の開始と一致しないので、ExpressionStatement のルートに行く:
ExpressionStatement --> Expression --> AssignmentExpression --> ... --> MultiplicativeExpression --> ... --> MemberExpression --> PrimaryExpression --> IdentifierReference。
ここで IdentifierReference で停止しており、RegularExpressionLiteral ではない。 「その他のすべてのコンテキストでは、InputElementDiv がレキサの目標シンボルとして使用される」という文が適用される。 最初のスラッシュは DivPunctuator トークンである。
この DivPunctuator トークンであるため、MultiplicativeExpression: MultiplicativeExpression MultiplicativeOperator ExponentiationExpression の文法が一致する。 右辺には ExponentiationExpression が必要である。
今、a / / の2番目のスラッシュに到達している。 ExponentiationExpression に従って、PrimaryExpression: RegularExpressionLiteral に到達する。 なぜなら RegularExpressionLiteral が / と一致する唯一の文法だからである:
RegularExpressionLiteral ::
/ RegularExpressionBody / RegularExpressionFlagsこの2番目の / は、仕様書が「InputElementRegExp 目標シンボルは、正規表現リテラルが許可されているすべての文法コンテキストで使用される。」と述べているため、RegExp としてトークナイズされる。
INFO
練習として、/=/ / /=/ の文法を追ってみよう。
カバー構文
このトピックについて、まずV8のブログ記事を読むこと。
要約すると、仕様書は以下の3つのカバー構文を定義している。
CoverParenthesizedExpressionAndArrowParameterList
PrimaryExpression[Yield, Await] :
CoverParenthesizedExpressionAndArrowParameterList[?Yield, ?Await]
この生産のインスタンスを処理する際、
`CoverParenthesizedExpressionAndArrowParameterList` の解釈は以下の文法によって細分化される:
ParenthesizedExpression[Yield, Await] :
( Expression[+In, ?Yield, ?Await] )ArrowFunction[In, Yield, Await] :
ArrowParameters[?Yield, ?Await] [no LineTerminator here] => ConciseBody[?In]
ArrowParameters[Yield, Await] :
BindingIdentifier[?Yield, ?Await]
CoverParenthesizedExpressionAndArrowParameterList[?Yield, ?Await]これらの定義は以下のものを定義する:
let foo = (a, b, c); // SequenceExpression
let bar = (a, b, c) => {}; // ArrowExpression
^^^^^^^^^ CoverParenthesizedExpressionAndArrowParameterListこの問題を解決する単純だが面倒なアプローチは、まず Vec<Expression> としてパースし、その後変換関数を記述して ArrowParameters ノードに変換すること。 つまり、個々の Expression を BindingPattern に変換する必要がある。
注意すべき点は、パーサー内でスコープツリーを構築している場合、すなわちアロー式のスコープをパース中に作成しているが、シーケンス式のスコープは作成しない場合、どうやってこの処理をするのかが明らかではない。 esbuild は、まず一時的なスコープを作成し、アロー式でない場合にそれを破棄することでこの問題を解決した。
これはそのアーキテクチャドキュメントに記載されている:
ほとんどはかなり直感的だが、いくつかの場所で、パーサーがスコープをプッシュして宣言の解析途中に至り、それが実際には宣言でないことに気づくということが起こる。これは TypeScript で関数が本体なしで前方宣言された場合や、JavaScript で括弧式がアロー関数か否かが
=>トークンに達するまで曖昧な場合に起きる。これは2回のパスではなく3回のパスを行うことで解決できるが、2回のパスで行うことを目指している。そのため、仮に仮定が誤っていた場合に後でスコープツリーを修正するために、popScope()の代わりにpopAndDiscardScope()またはpopAndFlattenScope()を呼び出す。
CoverCallExpressionAndAsyncArrowHead
CallExpression :
CoverCallExpressionAndAsyncArrowHead
この生産のインスタンスを処理する際、
`CoverCallExpressionAndAsyncArrowHead` の解釈は以下の文法によって細分化される:
CallMemberExpression[Yield, Await] :
MemberExpression[?Yield, ?Await] Arguments[?Yield, ?Await]AsyncArrowFunction[In, Yield, Await] :
CoverCallExpressionAndAsyncArrowHead[?Yield, ?Await] [no LineTerminator here] => AsyncConciseBody[?In]
CoverCallExpressionAndAsyncArrowHead[Yield, Await] :
MemberExpression[?Yield, ?Await] Arguments[?Yield, ?Await]
この生産のインスタンスを処理する際、
`AsyncArrowFunction : CoverCallExpressionAndAsyncArrowHead => AsyncConciseBody` では、
`CoverCallExpressionAndAsyncArrowHead` の解釈は以下の文法によって細分化される:
AsyncArrowHead :
async [no LineTerminator here] ArrowFormalParameters[~Yield, +Await]これらの定義は以下のものを定義する:
async (a, b, c); // CallExpression
async (a, b, c) => {} // AsyncArrowFunction
^^^^^^^^^^^^^^^ CoverCallExpressionAndAsyncArrowHeadasync がキーワードではないため、奇妙に見える。 最初の async は関数名である。
CoverInitializedName
13.2.5 オブジェクト初期化子
ObjectLiteral[Yield, Await] :
...
PropertyDefinition[Yield, Await] :
CoverInitializedName[?Yield, ?Await]
注釈3:特定のコンテキストでは、`ObjectLiteral` はより制限された補助文法のカバー構文として使用される。
`CoverInitializedName` 生産は、これらの補助文法を完全にカバーするために必要である。ただし、この生産を使用すると、実際の `ObjectLiteral` が期待される通常のコンテキストで早期構文エラーが発生する。
13.2.5.1 静的意味:早期エラー
実際のオブジェクト初期化子を記述するだけでなく、`ObjectLiteral` の生産は `ObjectAssignmentPattern` に対してカバー構文として使用され、`CoverParenthesizedExpressionAndArrowParameterList` の一部として認識されることもある。`ObjectLiteral` が `ObjectAssignmentPattern` が必要なコンテキストに現れた場合、以下の早期エラー規則は適用されない。また、`CoverParenthesizedExpressionAndArrowParameterList` や `CoverCallExpressionAndAsyncArrowHead` を初期解析する場合にも適用されない。
PropertyDefinition : CoverInitializedName
任意のソーステキストがこの生産に一致する場合、構文エラーとなる。13.15.1 静的意味:早期エラー
AssignmentExpression : LeftHandSideExpression = AssignmentExpression
LeftHandSideExpression が `ObjectLiteral` または `ArrayLiteral` である場合、以下の早期エラー規則が適用される:
* LeftHandSideExpression は `AssignmentPattern` でなければならず。これらの定義は以下のものを定義する:
({ prop = value } = {}); // ObjectAssignmentPattern
({ prop: value }); // SyntaxError を伴う ObjectLiteralパーサーは CoverInitializedName を持つ ObjectLiteral をパースし、ObjectAssignmentPattern で = に到達しない場合に構文エラーを投げなければならない。
練習として、以下の = のうち、どれが構文エラーを投げるべきか?
let { x = 1 } = ({ x = 1 } = { x: 1 });