문법
JavaScript는 파싱하기 가장 까다로운 문법 중 하나를 갖습니다. 이 튜토리얼에서는 제가 배우면서 겪었던 시행착오를 빠짐없이 정리합니다.
LL(1) 문법
Wikipedia에 따르면,
LL 문법은 LL 파서가 파싱할 수 있는 맥락자유 문법으로, 입력을 왼쪽에서 오른쪽으로 읽으며 파스 트리를 만든다
첫 번째 L은 소스를 Left to right로 스캔한다는 뜻이고, 두 번째 L은 가장 왼쪽 유도 트리를 만든다는 뜻입니다.
맥락자유이고 LL(1)의 (1)은 다음 토큰 하나만 보면 트리를 만들 수 있다는 의미입니다.
LL 문법은 학계에서 특히 관심이 큰데, 손으로 파서를 안 짜고 프로그램이 파서를 자동 생성하게 하려는 인간 탓입니다.
안타깝게도 많은 산업용 언어는 예쁜 LL(1) 문법을 갖지 않고, JavaScript도 마찬가지입니다.
INFO
Mozilla는 몇 년 전 jsparagus 프로젝트를 시작해 파이썬으로 LALR 파서 생성기를 만들었습니다. 최근 2년간 업데이트는 거의 없고, js-quirks.md 마지막에 강한 메시지를 남겼습니다.
What have we learned today?
- Do not write a JS parser.
- JavaScript has some syntactic horrors in it. But hey, you don't make the world's most widely used programming language by avoiding all mistakes. You do it by shipping a serviceable tool, in the right circumstances, for the right users.
문법의 성격상 실용적인 방법은 재귀 하강 파서를 손으로 짜는 것뿐이므로, 발을 찍기 전에 문법의 온갖 껄끄러움을 익혀 둡시다.
아래 목록은 처음엔 단순하다가 점점 이해하기 어려워지니, 커피 한 잔 하고 여유 있게 읽어 주세요.
식별자(Identifiers)
#sec-identifiers에는 세 종류 식별자가 정의되어 있습니다.
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]follow AssignmentExpression into PrimaryExpression we get
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,
}클래스와 Strict Mode
ECMAScript 클래스는 strict mode 이후에 생겼기 때문에 클래스 안은 단순화를 위해 항상 strict mode로 두기로 했습니다. #sec-class-definitions에는 Node: A class definition is always strict mode code. 한 줄로만 적혀 있습니다.
strict mode를 함수 스코프에 매기기는 쉬운데, 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
It is a Syntax Error if the source text matched by this production is strict mode code.렉서 안에서 감지하는 것이 가장 좋고, 파서의 strict mode 상태를 물어 오류를 낼 수 있습니다.
하지만 지시문(directive)과 섞이면 이렇게 할 수 없습니다:
https://github.com/tc39/test262/blob/747bed2e8aaafe8fdf2c65e8a10dd7ae64f66c47/test/language/literals/string/legacy-octal-escape-sequence-prologue-strict.js#L16-L19use strict는 이스케이프된 레거지 8진보다 뒤에 선언되지만, 여전히 구문 오류를 내야 합니다. 다행히 실제 코드에서 지시문과 레거지 8진을 같이 쓰는 경우는 거의 없습니다… 위 test262 케이스를 통과하고 싶을 때만 해당됩니다.
비단순 매개변수(non-simple parameter)와 Strict Mode
비 strict 모드에서는 같은 이름의 함수 매개변수가 허용됩니다(function foo(a, a) { }). use strict를 넣으면 금지됩니다: function foo(a, a) { "use strict" }. ES6 이후 매개변수에는 다른 문법도 들어왔습니다. 예: function foo({ a }, b = c) {}.
그렇다면 아래처럼 "01"이 strict 모드 오류인 경우는 어떻게 될까요?
function foo(
value = (function() {
return "\01";
}()),
) {
"use strict";
return value;
}구체적으로, 파서 입장에서 매개변수 안에 strict mode 구문 오류가 있을 때 어떻게 할까요? #sec-function-definitions-static-semantics-early-errors에서는 이를 아래처럼 금지합니다.
FunctionDeclaration :
FunctionExpression :
It is a Syntax Error if FunctionBodyContainsUseStrict of FunctionBody is true and IsSimpleParameterList of FormalParameters is false.Chrome은 "Uncaught SyntaxError: Illegal 'use strict' directive in function with non-simple parameter list" 같은 다소 난해한 메시지로 이 오류를 냅니다.
ESLint 저자의 블로그 글에 더 자세한 설명이 있습니다.
INFO
재미있게도 TypeScript에서 es5를 타깃으로 하면 위 규칙이 적용되지 않고, 다음처럼 트랜스파일됩니다.
function foo(a, b) {
"use strict";
if (b === void 0) b = "\01";
}괄호로 둘러싼 표현식
괄호 표현식은 의미가 없어야 하지 않을까요? 예를 들어 ((x))의 AST는 ParenthesizedExpression을 거치지 않고 단일 IdentifierReference면 됩니다. JavaScript 문법도 그렇습니다.
그런데 … 런타임 의미가 생길 줄이야. 이 estree 이슈에 나온 대로:
> fn = function () {};
> fn.name
< "fn"
> (fn) = function () {};
> fn.name
< ''그래서 acorn과 babel은 호환을 위해 preserveParens 옵션을 추가했습니다.
if 문 안의 함수 선언
#sec-ecmascript-language-statements-and-declarations의 문법을 정확히 따르면:
Statement[Yield, Await, Return] :
... lots of statements
Declaration[Yield, Await] :
... declarationsAST의 Statement 노드에는 당연히 Declaration이 들어가지 않습니다.
그러나 Annex B #sec-functiondeclarations-in-ifstatement-statement-clauses에서 비 strict 모드일 때 if의 문 위치에 선언을 허용합니다:
if (x) {
function foo() {}
} else function bar() {}레이블 문은 유효하다
레이블 문을 써 본 적이 없어도 현대 JavaScript에서는 유효하며 strict mode가 금지하지도 않습니다.
아래 구문은 올바르며 객체 리터럴이 아니라 레이블 문을 반환합니다.
<Foo
bar={() => {
baz: "quaz";
}}
/>
// ^^^^^^^^^^^ `LabelledStatement`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 문법을 보면 어떻게 파싱할지 헷갈립니다.
이해를 가로막는 두 덩어리는 [lookahead ≠ let]과 [+In]입니다.
for (let까지 파싱했다면 들여볼 토큰은:
in이 아니어야for (let in)을 막을 수 있습니다{,[, 식별자여야for (let {} = foo),for (let [] = foo),for (let bar = foo)등을 허용합니다
of 또는 in까지 도달하면 오른쪽 식에는 올바른 [+In] 문맥을 넘겨야 #prod-RelationalExpression의 두 in 표현식을 막습니다:
RelationalExpression[In, Yield, Await] :
[+In] RelationalExpression[+In, ?Yield, ?Await] in ShiftExpression[?Yield, ?Await]
[+In] PrivateIdentifier in ShiftExpression[?Yield, ?Await]
Note 2: The [In] grammar parameter is needed to avoid confusing the in operator in a relational expression with the in operator in a for statement.명세 전체에서 [In] 문맥은 여기만 씁니다.
또 문법 [lookahead ∉ { let, async of }]는 for (async of ...)를 금지하므로 명시적으로 막아야 합니다.
블록 수준 함수 선언
Annex B.3.2 #sec-block-level-function-declarations-web-legacy-compatibility-semantics에는 한 페이지가 FunctionDeclaration이 Block 안에서 어떻게 행동해야 하는지 설명합니다. 요지는 다음과 같습니다:
https://github.com/acornjs/acorn/blob/11735729c4ebe590e406f952059813f250a4cbd1/acorn/src/scope.js#L30-L35함수 선언의 이름은 함수 선언 안 블록에 있을 때는 var 선언과 같이 취급해야 합니다. 아래는 bar가 블록 스코프 안에 있어 재선언 오류가 납니다:
function foo() {
if (true) {
var bar;
function bar() {} // redeclaration error
}
}반면 아래는 함수 스코프 안이므로 오류가 없습니다. 함수 bar는 var 선언처럼 취급됩니다.
function foo() {
var bar;
function bar() {}
}문법 문맥(Grammar Context)
문법 문맥 매개변수는 [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 {
/// Whether the parser is in a generator function like `function* a() {}`
/// Matches the `Yield` parameter in the ECMA spec
const IN_GENERATOR = 1 << 0;
/// Whether the parser is inside a function
const IN_FUNCTION = 1 << 1;
/// Whatever the parser is inside a constructor
const IN_CONSTRUCTOR = 1 << 2;
/// Is async allowed in this context. Either because it's an async function or top level await is supported.
/// Equivalent to the `Async` generator in the ECMA spec
const IN_ASYNC = 1 << 3;
/// Whether the parser is parsing a top-level statement (not inside a class, function, parameter) or not
const TOP_LEVEL = 1 << 4;
/// Whether the parser is in an iteration or switch statement and
/// `break` is allowed.
const BREAK_ALLOWED = 1 << 5;
/// Whether the parser is in an iteration statement and `continue` is allowed.
const CONTINUE_ALLOWED = 1 << 6;문법을 따라 이 플래그를 켜고 끄며 검사합니다.
AssignmentPattern 대 BindingPattern
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 ];
}명세 관점의 JavaScript는 이렇습니다:
// AssignmentExpression:
{ foo } = bar;
^^^ IdentifierReference
[ foo ] = bar;
^^^ IdentifierReference
// VariableDeclarator
var { foo } = bar;
^^^ BindingIdentifier
var [ foo ] = bar;
^^^ BindingIdentifierPattern 안의 Identifier가 BindingIdentifier인지 IdentifierReference인지 바로 구분할 수 없게 됩니다:
enum Pattern {
Identifier, // Is this a `BindingIdentifier` or a `IdentifierReference`?
ArrayPattern,
ObjectPattern,
}파서 파이프라인 아래쪽에서 불필요하게 복잡해집니다. 예를 들어 의미 분석에서 스코프를 만들 때 이 Identifier의 부모를 뒤져 바인딩할지 말지를 결정해야 합니다.
더 나은 방법은 명세를 끝까지 이해하고 판단하는 것입니다.
AssignmentExpression과 VariableDeclaration의 문법은:
13.15 Assignment Operators
AssignmentExpression[In, Yield, Await] :
LeftHandSideExpression[?Yield, ?Await] = AssignmentExpression[?In, ?Yield, ?Await]
13.15.5 Destructuring Assignment
In certain circumstances when processing an instance of the production
AssignmentExpression : LeftHandSideExpression = AssignmentExpression
the interpretation of LeftHandSideExpression is refined using the following grammar:
AssignmentPattern[Yield, Await] :
ObjectAssignmentPattern[?Yield, ?Await]
ArrayAssignmentPattern[?Yield, ?Await]14.3.2 Variable Statement
VariableDeclaration[In, Yield, Await] :
BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await]opt
BindingPattern[?Yield, ?Await] Initializer[?In, ?Yield, ?Await]명세는 AssignmentPattern과 BindingPattern을 별도 생산 규칙으로 구분합니다.
이럴 때는 estree에서 벗어나 추가 AST 노드를 두는 것을 두려워하지 마세요:
enum BindingPattern {
BindingIdentifier,
ObjectBindingPattern,
ArrayBindingPattern,
}
enum AssignmentPattern {
IdentifierReference,
ObjectAssignmentPattern,
ArrayAssignmentPattern,
}한 주 내내 엄청 헷갈리다 깨달았습니다: Pattern 노드 하나가 아니라 AssignmentPattern과 BindingPattern을 나눠야 한다는 것입니다.
estree는 오래 썼으니 무조건 맞다?- 두 노드 없이 패턴 속
Identifier를 깔끔히 구별하려면? 문법이 어디 있는지 찾을 수가 없네? - 명세를 하루 종일 돌아다니다 보니…
AssignmentPattern문법은 "13.15 Assignment Operators" 본문 5번째 소절 "Supplemental Syntax" 부제 아래에 있었다 🤯 — 본문에 정의되는 다른 문법과 달리 "Runtime Semantics" 뒤에 붙어 있어 위치가 정말 엉성하다.
TIP
아래 사례는 정말 이해하기 어렵습니다. 이 이후는 드래곤 영역입니다.
모호한 문법
파서처럼 생각해 / 토큰이 나눗셈인지 정규식 시작인지 풀어 봅시다.
a / b;
a / / regex /;
a /= / regex /;
/ regex / / b;
/=/ / /=/;거의 불가능해 보이죠? 하나씩 문법을 따라가 봅시다.
먼저 #sec-ecmascript-language-lexical-grammar에 나온 대로 문법 문법이 렉시컬 문법을 이끈다는 점을 이해해야 합니다.
렉시컬 입력 요소 식별이 입력 요소를 소비하는 문법 문법 문맥에 민감한 여러 상황이 있다.
즉 파서가 렉서에 다음에 어떤 토큰을 돌려줄지 알려 줘야 합니다. 위 예는 렉서가 / 토큰과 RegExp 토큰 중 무엇을 내야 하는지 보여 줍니다. 올바른 / vs RegExp를 위해 명세는 말합니다:
The InputElementRegExp goal symbol is used in all syntactic grammar contexts where a RegularExpressionLiteral is permitted ... In all other contexts, InputElementDiv is used as the lexical goal symbol.
InputElementDiv와 InputElementRegExp 문법은
InputElementDiv ::
WhiteSpace
LineTerminator
Comment
CommonToken
DivPunctuator <---------- the `/` and `/=` token
RightBracePunctuator
InputElementRegExp ::
WhiteSpace
LineTerminator
Comment
CommonToken
RightBracePunctuator
RegularExpressionLiteral <-------- the `RegExp` token문법이 RegularExpressionLiteral에 도달할 때마다 /를 RegExp 토큰으로 렉시컬해야 하고(닫는 /가 없으면 오류), 그 외에는 /를 슬래시 토큰으로 토큰화합니다.
예를 하나 따라가 보겠습니다:
a / / regex /
^ ------------ PrimaryExpression:: IdentifierReference
^ ---------- MultiplicativeExpression: MultiplicativeExpression MultiplicativeOperator ExponentiationExpression
^^^^^^^^ - PrimaryExpression: RegularExpressionLiteral이 문은 다른 Statement 시작과 맞지 않아 ExpressionStatement 경로로 갑니다:
ExpressionStatement --> Expression --> AssignmentExpression --> ... --> MultiplicativeExpression --> ... --> MemberExpression --> PrimaryExpression --> IdentifierReference.
IdentifierReference에서 멈추었지 RegularExpressionLiteral에서는 멈추지 않았으므로, "In all other contexts, InputElementDiv is used..."가 적용됩니다. 첫 슬래시는 DivPunctuator 토큰입니다.
그게 DivPunctuator이므로 문법 MultiplicativeExpression: MultiplicativeExpression MultiplicativeOperator ExponentiationExpression가 맞고, 오른쪽은 ExponentiationExpression이어야 합니다.
이제 a / /에서 두 번째 슬래시입니다. ExponentiationExpression을 따라 가면 /가 있는 유일한 맞는 문법은 PrimaryExpression: RegularExpressionLiteral이기 때문에 도달합니다:
RegularExpressionLiteral ::
/ RegularExpressionBody / RegularExpressionFlags이 두 번째 /는 명세대로 "RegularExpressionLiteral이 허용되는 모든 문법 문맥에서는 InputElementRegExp 목표 심벌을 쓴다"이므로 RegExp로 렉시컬됩니다.
INFO
연습으로 /=/ / /=/ 문법을 직접 따라가 보세요.
커버 문법(Cover Grammar)
먼저 이 주제의 V8 블로그 글을 읽으세요.
요약하면 명세에는 세 가지 커버 문법이 있습니다:
CoverParenthesizedExpressionAndArrowParameterList
PrimaryExpression[Yield, Await] :
CoverParenthesizedExpressionAndArrowParameterList[?Yield, ?Await]
When processing an instance of the production
PrimaryExpression[Yield, Await] : CoverParenthesizedExpressionAndArrowParameterList[?Yield, ?Await]
the interpretation of CoverParenthesizedExpressionAndArrowParameterList is refined using the following grammar:
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는 임시 스코프를 만들었다가 화살표가 아니면 버리는 식으로 풀었습니다.
아키텍처 문서에 다음처럼 적혀 있습니다:
This is mostly pretty straightforward except for a few places where the parser has pushed a scope and is in the middle of parsing a declaration only to discover that it's not a declaration after all. This happens in TypeScript when a function is forward-declared without a body, and in JavaScript when it's ambiguous whether a parenthesized expression is an arrow function or not until we reach the => token afterwards. This would be solved by doing three passes instead of two so we finish parsing before starting to set up scopes and declare symbols, but we're trying to do this in just two passes. So instead we call popAndDiscardScope() or popAndFlattenScope() instead of popScope() to modify the scope tree later if our assumptions turn out to be incorrect.
CoverCallExpressionAndAsyncArrowHead
CallExpression :
CoverCallExpressionAndAsyncArrowHead
When processing an instance of the production
CallExpression : CoverCallExpressionAndAsyncArrowHead
the interpretation of CoverCallExpressionAndAsyncArrowHead is refined using the following grammar:
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]
When processing an instance of the production
AsyncArrowFunction : CoverCallExpressionAndAsyncArrowHead => AsyncConciseBody
the interpretation of CoverCallExpressionAndAsyncArrowHead is refined using the following grammar:
AsyncArrowHead :
async [no LineTerminator here] ArrowFormalParameters[~Yield, +Await]이 정의는 다음을 의미합니다:
async (a, b, c); // CallExpression
async (a, b, c) => {} // AsyncArrowFunction
^^^^^^^^^^^^^^^ CoverCallExpressionAndAsyncArrowHeadasync는 키워드가 아니라 첫 async는 함수 이름이라 이상하게 보입니다.
CoverInitializedName
13.2.5 Object Initializer
ObjectLiteral[Yield, Await] :
...
PropertyDefinition[Yield, Await] :
CoverInitializedName[?Yield, ?Await]
Note 3: In certain contexts, ObjectLiteral is used as a cover grammar for a more restricted secondary grammar.
The CoverInitializedName production is necessary to fully cover these secondary grammars. However, use of this production results in an early Syntax Error in normal contexts where an actual ObjectLiteral is expected.
13.2.5.1 Static Semantics: Early Errors
In addition to describing an actual object initializer the ObjectLiteral productions are also used as a cover grammar for ObjectAssignmentPattern and may be recognized as part of a CoverParenthesizedExpressionAndArrowParameterList. When ObjectLiteral appears in a context where ObjectAssignmentPattern is required the following Early Error rules are not applied. In addition, they are not applied when initially parsing a CoverParenthesizedExpressionAndArrowParameterList or CoverCallExpressionAndAsyncArrowHead.
PropertyDefinition : CoverInitializedName
I* t is a Syntax Error if any source text is matched by this production.13.15.1 Static Semantics: Early Errors
AssignmentExpression : LeftHandSideExpression = AssignmentExpression
If LeftHandSideExpression is an ObjectLiteral or an ArrayLiteral, the following Early Error rules are applied:
* LeftHandSideExpression must cover an AssignmentPattern.이 정의는 다음을 의미합니다:
({ prop = value } = {}); // ObjectAssignmentPattern
({ prop: value }); // ObjectLiteral with SyntaxError파서는 ObjectLiteral를 CoverInitializedName까지 파싱한 뒤, ObjectAssignmentPattern까지 =에 도달하지 못하면 구문 오류를 던져야 합니다.
연습: 아래 = 중 어느 것이 구문 오류를 내야 할까요?
let { x = 1 } = ({ x = 1 } = { x: 1 });