Написание собственного парсера

Парсер в ESLint отвечает за преобразование исходного JavaScript-кода в абстрактное синтаксическое дерево (AST), которое используется всеми правилами анализа. Вся система линтинга опирается на корректность и полноту AST, включая информацию о позициях, комментариях, типах узлов и дополнительных метаданных.

ESLint не привязывает разработчика к единственному парсеру. Вместо этого используется контракт, позволяющий подключать собственные реализации, если они удовлетворяют требованиям структуры AST и интерфейса взаимодействия.

Основные задачи парсера:

  • построение AST в формате ESTree
  • сохранение информации о диапазонах символов (range)
  • сохранение координат (loc: line/column)
  • обработка комментариев
  • формирование дополнительных сервисов (scope, type information)
  • предоставление visitor keys для обхода дерева

Формат AST и требования ESTree

ESLint ориентируется на спецификацию ESTree. Это де-факто стандарт представления JavaScript-кода в виде дерева.

Каждый узел AST должен содержать:

  • type — тип узла (например, BinaryExpression, Identifier)
  • loc — позиции в исходном коде
  • range — числовые индексы начала и конца
  • специфичные поля узла (например, left, right, operator)

Пример узла:

{
  "type": "BinaryExpression",
  "operator": "+",
  "left": {
    "type": "Identifier",
    "name": "a",
    "range": [0, 1],
    "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 1, "column": 1 } }
  },
  "right": {
    "type": "Identifier",
    "name": "b",
    "range": [4, 5],
    "loc": { "start": { "line": 1, "column": 4 }, "end": { "line": 1, "column": 5 } }
  },
  "range": [0, 5]
}

Любое отклонение от ESTree приводит к некорректной работе правил ESLint.

Интерфейс парсера ESLint

ESLint ожидает от парсера реализацию одного из двух интерфейсов:

  • parse(code, options)
  • parseForESLint(code, options)

Современный формат предполагает использование parseForESLint, так как он позволяет возвращать дополнительные сервисы.

Базовая сигнатура

parseForESLint(code, options) {
  return {
    ast,
    services,
    visitorKeys,
    scopeManager
  };
}

Обязательным элементом является только ast, остальные поля используются для расширенной функциональности.

Минимальная реализация собственного парсера

Простейший парсер может быть построен поверх существующих решений (например, Acorn или Espree), с последующим преобразованием результата.

import { parse } from "acorn";

export function parseForESLint(code) {
  const ast = parse(code, {
    ecmaVersion: "latest",
    locations: true,
    ranges: true,
    sourceType: "module"
  });

  return {
    ast
  };
}

Такая реализация уже совместима с ESLint, но лишена расширенных возможностей.

Построение AST вручную

Полностью собственный парсер требует реализации следующих этапов:

Лексический анализ

На этом этапе исходный код разбивается на токены:

  • идентификаторы
  • ключевые слова
  • литералы
  • операторы
  • разделители

Пример токенов:

let a = 10;
→
LET, IDENTIFIER(a), EQUALS, NUMBER(10), SEMICOLON

Синтаксический анализ

Токены преобразуются в дерево согласно грамматике языка.

Пример:

let a = 10;

AST:

VariableDeclaration
 └── VariableDeclarator
     ├── Identifier (a)
     └── Literal (10)

Рекурсивный разбор выражений

Ключевая техника — рекурсивный спуск (recursive descent parser).

Пример структуры:

function parseEx * pression() {
  return parseAdditive();
}

function parseAdditive() {
  let node = parsePrimary();

  while (match("+")) {
    node = {
      type: "BinaryExpression",
      operator: "+",
      left: node,
      right: parsePrimary()
    };
  }

  return node;
}

Поддержка диапазонов и координат

ESLint требует точной привязки узлов к исходному коду.

Необходимо хранить:

  • range: [start, end]
  • loc.start.line, loc.start.column
  • loc.end.line, loc.end.column

Пример добавления метаданных:

function attachLocation(node, start, end, source) {
  node.range = [start, end];
  node.loc = computeLoc(source, start, end);
  return node;
}

Функция computeLoc должна учитывать переносы строк и табуляции.

Обработка комментариев

Комментарии не входят в AST как узлы, но ESLint использует их для:

  • отключения правил
  • документации (eslint-disable, eslint-enable)
  • анализа JSDoc

Парсер должен собирать комментарии отдельно:

comments.push({
  type: "Line",
  value: "eslint-disable",
  range: [start, end],
  loc: { start, end }
});

И возвращать их через ast.comments или через дополнительные сервисы.

visitorKeys и обход дерева

visitorKeys описывает, какие поля узла содержат дочерние узлы.

Пример:

const visitorKeys = {
  BinaryExpression: ["left", "right"],
  VariableDeclaration: ["declarations"],
  VariableDeclarator: ["id", "init"]
};

Без этого ESLint не сможет корректно обходить AST.

Scope Manager и анализ областей видимости

Для полноценной работы некоторых правил требуется информация о scope.

Scope включает:

  • глобальную область
  • функции
  • блоки let/const
  • модули

Парсер может делегировать построение scope-дерева сторонним библиотекам (например, eslint-scope), но обязан вернуть результат.

import { analyze } from "eslint-scope";

const scopeManager = analyze(ast, {
  ecmaVersion: 2022,
  sourceType: "module"
});

Parser Services

services — механизм расширения возможностей AST.

Чаще всего используется для TypeScript и JSX парсеров.

Пример:

services: {
  getType(node) {
    return typeChecker.getTypeAtLocation(node);
  }
}

В @typescript-eslint/parser services предоставляют доступ к TypeScript Program.

Совместимость с ESLint правилами

Ключевой критерий качества парсера — возможность работы стандартных правил ESLint:

  • no-undef
  • no-unused-vars
  • eqeqeq
  • consistent-return

Ошибки в AST приводят к:

  • пропуску нарушений
  • ложным срабатываниям
  • падению анализа

Работа с расширенными синтаксисами

Современные парсеры должны поддерживать:

  • JSX
  • TypeScript
  • Optional Chaining
  • Nullish Coalescing
  • Decorators
  • Class Fields

Каждое расширение требует дополнительных типов узлов:

OptionalMemberExpression
TSInterfaceDeclaration
JSXElement
ClassProperty

Интеграция с ESLint

Подключение кастомного парсера осуществляется через конфигурацию:

export default {
  parser: "./my-parser.js",
  rules: {
    "no-unused-vars": "error"
  }
};

Парсер становится первым этапом конвейера обработки кода:

Source Code
   ↓
Parser
   ↓
AST
   ↓
Rules (AST traversal)
   ↓
Lint Results

Ошибки и диагностика в парсере

Парсер может сообщать об ошибках синтаксиса через исключения или специальные структуры:

throw {
  message: "Unexpected token",
  index: 10,
  lineNumber: 2,
  column: 5
};

ESLint преобразует их в диагностические сообщения.

Производительность парсера

Критические факторы:

  • сложность грамматики
  • количество проходов по коду
  • создание промежуточных структур
  • работа с большими файлами

Оптимизации:

  • однопроходный парсинг
  • кеширование токенов
  • минимизация аллокаций объектов
  • ленивое построение узлов

Совместимость с экосистемой

Хороший парсер должен учитывать взаимодействие с:

  • ESLint core
  • eslint-plugin-* пакетами
  • Prettier (в конфликтных сценариях)
  • TypeScript tooling
  • Babel pipeline

Особенно важна совместимость с форматами AST, используемыми другими инструментами, чтобы избежать расхождений в анализе одного и того же кода.

Структура полноценного парсера

Типичная организация модуля:

parser/
 ├── tokenizer.js
 ├── parser.js
 ├── ast-builder.js
 ├── location.js
 ├── comments.js
 ├── visitor-keys.js
 └── index.js

index.js экспортирует parseForESLint.

Контракт устойчивости AST

AST должен быть:

  • детерминированным
  • неизменяемым после создания
  • полностью воспроизводимым
  • строго соответствующим входному коду

Любое расхождение делает невозможным корректный линтинг и нарушает работу правил.