Skip to content

Грамматика

JavaScript — один из самых капризных языков для разбора по грамматике; этот материал — про все мои трудности по пути её изучения.

Грамматика LL(1)

Согласно Wikipedia,

an LL grammar is a context-free grammar that can be parsed by an LL parser, which parses the input from Left to right

Первое L — сканирование исходника слева направо, второе L — построение левого выводного дерева.

Контекстно-свободная грамматика и цифра (1) в LL(1) означают, что дерево строится, заглядывая только в один следующий токен и не дальше.

LL-грамматики особенно интересны в академии: мы ленивые и хотим программы, которые генерируют парсеры сами, чтобы не писать их вручную.

К сожалению, у большинства промышленных языков нет «хорошей» LL(1)-грамматики — с JavaScript то же самое.

INFO

Несколько лет назад Mozilla начала проект jsparagus и написала генератор LALR-парсера на Python. За последние пару лет его мало обновляли; в конце 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.

Практический путь разобрать JavaScript — рукописный рекурсивный спуск: так устроена её грамматика; разберём все эти особенности до того, как себе же подложим свинью.

Список ниже от простого к сложному — захватите кофе и не торопитесь.

Идентификаторы

В #sec-identifiers определены три вида идентификаторов:

IdentifierReference[Yield, Await] :
BindingIdentifier[Yield, Await] :
LabelIdentifier[Yield, Await] :

estree и часть AST их не различают, а в спецификации это не разжёвано обычным текстом.

BindingIdentifier — объявления, IdentifierReference — ссылки на привязку. Например в var foo = bar у foo в грамматике роль BindingIdentifier, у barIdentifierReference:

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 сильно упрощают инструменты дальше по цепочке, особенно семантический анализ.

rust
pub struct BindingIdentifier {
    pub node: Node,
    pub name: Atom,
}

pub struct IdentifierReference {
    pub node: Node,
    pub name: Atom,
}

Классы и строгий режим

Класс ECMAScript появился после строгого режима; для простоты решили: всё тело класса всегда в strict mode. В #sec-class-definitions сказано буквально: Note: A class definition is always strict mode code. (в оригинале спецификации.)

Привязать strict mode к областям функций просто, но у объявления class нет своей «области» — нужно отдельное состояние только для разбора классов.

rust
// 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");

Устаревший восьмеричный escape и Use Strict

#sec-string-literals-early-errors запрещает устаревший восьмеричный escape внутри строк "\01":

EscapeSequence ::
    LegacyOctalEscapeSequence
    NonOctalDecimalEscapeSequence

It is a Syntax Error if the source text matched by this production is strict mode code.

Логичнее всего ловить это в лексере: он может запросить у парсера флаг строгости и выдавать ошибки.

Но всё ломается в смеси с директивами:

javascript
https://github.com/tc39/test262/blob/747bed2e8aaafe8fdf2c65e8a10dd7ae64f66c47/test/language/literals/string/legacy-octal-escape-sequence-prologue-strict.js#L16-L19

use strict объявлен после устаревшего escape, тем не менее нужно выбросить синтаксическую ошибку. К счастью, в живом коде директивы с такими octal почти не встречаются … разве что ради случая из test262 выше.


Несписок параметров и строгий режим

В нестрогом режиме можно писать function foo(a, a) { }, а use strict это запретит: function foo(a, a) { "use strict" }. В ES6 параметрам добавили другие формы: function foo({ a }, b = c) {}.

Что происходит, если написать так, причём "01" — ошибка в strict mode?

javaScript
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

Забавно: при таргете es5 в TypeScript правило выше не применяется, код превращается в

javaScript
function foo(a, b) {
  "use strict";
  if (b === void 0) b = "\01";
}

Выражение в скобках

Скобки вроде бы не несут семантики? Для ((x)) AST может быть одним IdentifierReference, а не цепочкой ParenthesizedExpression → … → IdentifierReference. В JS-грамматике так и есть.

Но оказывается, на рантайм это влияет. В этом issue estree видно:

javascript
> 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] :
    ... declarations

узел Statement в вашем AST, скорее всего, не содержит Declaration,

но в Annex B #sec-functiondeclarations-in-ifstatement-statement-clauses в нестрогом режиме допускается объявление в позиции оператора у if:

javascript
if (x) {
  function foo() {}
} else function bar() {}

Метки — это норма

Вы, возможно, ни разу не писали метку, но в современном JavaScript они законны и strict mode их не запрещает.

Ниже корректный синтаксис: это именно LabelledStatement, не объектный литерал.

javascript
<Foo
  bar={() => {
    baz: "quaz";
  }}
/>
//   ^^^^^^^^^^^ `LabelledStatement`

let — не ключевое слово

let не ключевое слово: может встретиться почти где угодно, пока грамматика явно не запретит позицию. Парсеру нужно заглянуть за токен let и решить, что разбирать, напр.:

javascript
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, нужно проверить lookahead:

  • не in, чтобы запретить for (let in)
  • это {, [ или идентификатор — чтобы разрешить for (let {} = foo), for (let [] = foo), for (let bar = foo)

Достигнув ключевых слов of или in, правую часть выражения нужно разбирать с правильным контекстом [+In], чтобы отсечь две продукции in из #prod-RelationalExpression:

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. Суть сводится к

javascript
https://github.com/acornjs/acorn/blob/11735729c4ebe590e406f952059813f250a4cbd1/acorn/src/scope.js#L30-L35

Имя FunctionDeclaration нужно считать так же, как объявление var, если оно внутри объявления функции. Этот фрагмент даёт ошибку повторного объявления, потому что bar в блочной области:

javascript
function foo() {
  if (true) {
    var bar;
    function bar() {} // redeclaration error
  }
}

а здесь ошибки нет — область функции; bar трактуется как var:

javascript
function foo() {
  var bar;
  function bar() {}
}

Контекст грамматики

У синтаксической грамматики пять параметров контекста, разрешающих или запрещающих конструкции: [In], [Return], [Yield], [Await] и [Default].

Удобнее хранить контекст в парсере, как в Biome:

rust
// 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:

javascript
// AssignmentExpression:
{ foo } = bar;
  ^^^ IdentifierReference
[ foo ] = bar;
  ^^^ IdentifierReference

// VariableDeclarator
var { foo } = bar;
      ^^^ BindingIdentifier
var [ foo ] = bar;
      ^^^ BindingIdentifier

Становится неясно — внутри Pattern один Identifier это BindingIdentifier или IdentifierReference:

rust
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 под ваш парсер:

rust
enum BindingPattern {
    BindingIdentifier,
    ObjectBindingPattern,
    ArrayBindingPattern,
}

enum AssignmentPattern {
    IdentifierReference,
    ObjectAssignmentPattern,
    ArrayAssignmentPattern,
}

Целую неделю я был в тумане, пока не «просветлел»: нужны отдельные узлы AssignmentPattern и BindingPattern, а не один общий Pattern.

  • estree ведь давно принят — значит, он не может быть неправильным?
  • как чисто отличить Identifier внутри паттернов без двух разных узлов? Где же грамматика?
  • после целого дня в спецификации… грамматика AssignmentPattern оказалась в 5-м подразделе раздела «13.15 Assignment Operators» с подзаголовком «Supplemental Syntax» 🤯 — совсем не на своём месте: обычно грамматика в основном тексте, а не после «Runtime Semantics», как здесь.

TIP

Дальше особенно крутые случаи. Осторожно, драконы.

Двусмысленная грамматика

Подойдём как парсер: перед нами токен / — деление или начало литерала регулярного выражения?

javascript
a / b;
a / / regex /;
a /= / regex /;
/ regex / / b;
/=/ / /=/;

Почти нереально в лоб? Разберём по грамматике.

Сперва важно понять: синтаксическая грамматика управляет лексической, см. #sec-ecmascript-language-lexical-grammar

There are several situations where the identification of lexical input elements is sensitive to the syntactic grammar context that is consuming the input elements.

То есть парсер говорит лексеру, какой токен вернуть следующим. В примерах нужен либо токен /, либо литерал RegExp. Чтобы выбрать между / и 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:

ExpressionStatementExpressionAssignmentExpression → … → MultiplicativeExpression → … → MemberExpressionPrimaryExpressionIdentifierReference.

Мы остановились на IdentifierReference, не на RegularExpressionLiteral, поэтому действует формулировка «во всех остальных контекстах используется InputElementDiv». Первый слэш — DivPunctuator.

Раз это DivPunctuator, подходит продукция MultiplicativeExpression: MultiplicativeExpression MultiplicativeOperator ExponentiationExpression; справа ожидается ExponentiationExpression.

На втором слэше в a / / по цепочке ExponentiationExpression приходим к PrimaryExpression: RegularExpressionLiteral, потому что подходящая грамматика с / только эта:

RegularExpressionLiteral ::
    / RegularExpressionBody / RegularExpressionFlags

Второй / становится литералом RegExp, ибо действует правило про InputElementRegExp там, где разрешён RegularExpressionLiteral.

INFO

Для упражнения распишите разбор строки /=/ / /=/.


Cover grammar

Сначала прочитайте пост блога V8 по этой теме.

Кратко: в спецификации три «cover»-грамматики:

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]

Эти определения дают пример:

javascript
let foo = (a, b, c); // SequenceExpression
let bar = (a, b, c) => {}; // ArrowExpression
          ^^^^^^^^^ CoverParenthesizedExpressionAndArrowParameterList

Простой, но тяжёлый способ — сначала распарсить как Vec<Expression>, потом конвертер превратит это в ArrowParameters: каждый Expression — в соответствующий BindingPattern.

Если дерево областей строится внутри парсера — область для стрелки создавать во время разбора, а для последовательности выражений — нет — задача неочевидна. esbuild решал это временной областью и отбросом, если узел не стрелка.

Из architecture document 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]

Эти определения иллюстрируют случай:

javascript
async (a, b, c); // CallExpression
async (a, b, c) => {} // AsyncArrowFunction
^^^^^^^^^^^^^^^ CoverCallExpressionAndAsyncArrowHead

Странно звучит: async не ключевое слово. Первое 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.

Так определяются случаи:

javascript
({ prop = value } = {}); // ObjectAssignmentPattern
({ prop: value }); // ObjectLiteral with SyntaxError

Парсеру нужно разобрать ObjectLiteral с CoverInitializedName и выдать синтаксическую ошибку, если это не доходит до = в роли ObjectAssignmentPattern.

Упражнение: какой из = ниже должен дать синтаксическую ошибку?

javascript
let { x = 1 } = ({ x = 1 } = { x: 1 });