Компиляторы и языки Проектирование языков и DSL: синтаксис, ошибки, эргономика
0%

Проектирование языков и DSL: синтаксис, ошибки, эргономика

Проектирование языков и DSL: синтаксис, ошибки, эргономика

Одиннадцать статей мы решали инженерные задачи: как разбить текст на токены, как построить дерево, как вывести типы, как разложить всё это в SSA, соптимизировать и выплюнуть машинный код, который рантайм ещё и перекомпилирует на лету. Ни разу мы не спрашивали: а почему язык выглядит именно так? Почему let x = 1;, а не x := 1 или набор x равно 1. Почему точка с запятой. Почему fn, а не def, func или функция.

Эти вопросы кажутся вкусовщиной, и в этом — главная ловушка. Компилятор виден авторам; язык виден всем. Программист за карьеру прочитает миллионы строк на языках, которые кто-то спроектировал за пару вечеров, и каждая мелочь синтаксиса умножится на эти миллионы. Язык — это пользовательский интерфейс к вычислениям, и проектируется он по законам интерфейсов, а не по законам теории формальных языков. Грамматика говорит, что можно написать. Дизайн отвечает на другой вопрос: что человек напишет по умолчанию, что он напишет по ошибке и что он увидит, когда ошибётся.

Эта статья — про три вещи, идущие в порядке важности, обратном привычному. Сначала — нужен ли язык вообще (чаще всего нет, и это самое ценное знание в статье). Затем — синтаксис: не «красивый», а однозначный, масштабируемый и предсказуемый. И наконец — ошибки, которые на практике определяют репутацию языка сильнее, чем система типов: с ними пользователь встречается каждый день, а с вашим красивым синтаксисом — один раз, при первом чтении.

Ступень нулевая: точно ли нужен свой язык

Есть соблазн, знакомый каждому, кто дочитал трек до этого места: увидев гвоздь конфигурации, достать компилятор. Прежде чем это делать, полезно честно посчитать, что вы теряете.

Любой синтаксис, отличный от синтаксиса языка-хозяина, обнуляет всю инфраструктуру. Нет подсветки — писать нужно свою. Нет автодополнения — писать свой LSP-сервер (об этом следующая статья). Нет форматтера — придётся спорить в ревью о пробелах. Нет отладчика — придётся объяснять, как ставить точку останова «внутри правила». Нет пакетного менеджера, тестового фреймворка, документации на Stack Overflow, готовых линтеров и двадцати лет накопленных идиом. Это и есть налог на язык, и платится он не разово, а ежегодно.

Лестница встраивания: от конфига до собственного языка

Между «просто структура данных» и «свой Тьюринг-полный язык» лежит лестница из пяти ступеней, и почти всегда правильный ответ находится левее, чем кажется автору.

1. Данные со схемой. JSON/YAML/TOML плюс JSON Schema. Парсер бесплатный, редакторы уже умеют валидировать по схеме, а $ref и enum дают автодополнение из коробки. Потолок: как только в конфиге появляются условия, переменные и повторы, начинается ад «языка внутри строк».

2. Fluent API. Цепочка методов на языке-хозяине: assertThat(x).isGreaterThan(3), LINQ, Gradle Kotlin DSL. Типы, автодополнение, рефакторинги, отладчик — всё чужое и всё бесплатно.

3. Внутренний DSL. То же самое, но с перегрузкой операторов, макросами и метапрограммированием (см. метапрограммирование в треке парадигм). SQLAlchemy, RSpec, macro_rules! в Rust. Синтаксис почти свой, инфраструктура почти чужая.

4. Внешний DSL. Свой лексер, парсер, семантика: SQL, HCL, GraphQL, Gherkin, jq, регулярные выражения. Полная свобода — и полная ответственность за диагностику, форматтер, тулинг и обратную совместимость.

5. Язык общего назначения. Всё вышеперечисленное плюс рантайм, GC, стандартная библиотека и обещание совместимости на десятилетия. Считайте, что это не проект, а образ жизни.

Обратите внимание на «Шаблоны в YAML» — точку в правом нижнем квадранте, где дорого и невыразительно. Так выглядит самый частый провал в отрасли: конфиг, который постепенно оброс условиями, циклами и подстановками, но так и не стал языком. Helm-чарты, Jinja поверх YAML, sendmail.cf — все они попали туда не по решению, а по инерции: каждый отдельный шаг казался маленьким. Именно от этого спасают честные конфигурационные языки — Starlark в Bazel, Dhall, CUE: кто-то уже заплатил налог за вас.

Практическое правило. Переходите на ступень вправо, только когда предыдущая мешает пользователю, а не вам как автору. Признаки, что пора: пользователи копипастят блоки конфига десятками; в строковых полях завелась своя микро-грамматика ("$.items[*].price > 10"); ошибку находят в рантайме, а хотелось бы при загрузке; предметные эксперты (не программисты) должны читать и править эти файлы сами.

Внутренний DSL: арендуем чужой синтаксис

Прежде чем строить свой парсер, стоит выжать максимум из хозяйского. Современные системы типов позволяют внутреннему DSL проверять не только значения, но и порядок вызовов — приём называют typestate: состояние сборки кодируется в типе, и недостроенный объект просто не имеет метода build.

type Severity = "page" | "ticket" | "log";
interface Rule { name: string; cond: string; sev: Severity; target: string }

/** Флаги HasWhen/HasThen — это фаза сборки, поднятая на уровень типов. */
class RuleDraft<HasWhen extends boolean, HasThen extends boolean> {
  private constructor(private readonly r: Partial<Rule>) {}

  static rule(name: string): RuleDraft<false, false> {
    return new RuleDraft<false, false>({ name });
  }

  when(cond: string): RuleDraft<true, HasThen> {
    return new RuleDraft<true, HasThen>({ ...this.r, cond });
  }

  then(sev: Severity, target: string): RuleDraft<HasWhen, true> {
    return new RuleDraft<HasWhen, true>({ ...this.r, sev, target });
  }

  /** Параметр this — ограничение: build() виден только у полностью заполненного правила. */
  build(this: RuleDraft<true, true>): Rule {
    return this.r as Rule;
  }
}

const ok = RuleDraft.rule("high_latency")
  .when("http.p99 > 500")
  .then("page", "oncall-backend")
  .build();                       // компилируется

const bad = RuleDraft.rule("broken")
  .then("page", "oncall-backend")
  .build();                       // ошибка компиляции: when() не вызван

Здесь всё хорошо ровно до момента, когда пользователь ошибается. Вот что скажет TypeScript про bad:

The 'this' context of type 'RuleDraft<false, true>' is not assignable to method's
'this' of type 'RuleDraft<true, true>'.
  Type 'false' is not assignable to type 'true'.

Формально верно, практически бесполезно: сообщение говорит на языке реализации, а не предметной области — про this-контексты и булевы литералы, тогда как правда звучит как «у правила нет условия». Это и есть главная цена внутреннего DSL: вы контролируете успешный путь, но не контролируете диагностику отказа. Смягчить можно (в TypeScript — брендированными типами с говорящими именами вроде RuleWithoutCondition, в Rust — #[diagnostic::on_unimplemented], в C++ — static_assert с текстом), но полностью догнать внешний DSL не выйдет: чужой компилятор не знает вашей терминологии.

Второй ограничитель — синтаксический потолок хозяина. Питоновские контекстные менеджеры, операторы | и >>, __getattr__ дают удивительно много, но when http.p99 > 500ms for 5m вы на них не напишете. Когда предметная нотация действительно ценна — а в алгебре, в матричных вычислениях, в запросах и в правилах она ценна — пора вправо.

Проектируем внешний DSL: три итерации одного примера

Дальше по статье — сквозной пример: маленький язык правил оповещения. Реалистичная задача: дежурная команда описывает, при каких метриках кого будить. Мы пройдём три версии, и каждая иллюстрирует отдельный принцип. Инфраструктуру берём готовую — лексер из статьи 01, рекурсивный спуск из статьи 02, таблицу символов из статьи 04.

Версия 0 — YAML. Так это выглядит, когда языка нет:

rules:
  - name: high_latency
    when:
      all:
        - {metric: http.p99, op: ">", value: 500, unit: ms, for: 5m}
        - {metric: http.rps, op: ">", value: 100}
    then: {notify: oncall-backend, severity: page}

Диагноз: четыре уровня вложенности ради одной мысли; op: ">" — это оператор, притворяющийся строкой (грамматика внутри грамматики); связь for с конкретным условием держится только на взаимном расположении ключей; ошибка «неизвестная метрика» прилетит из рантайма, а YAML-парсер скажет разве что «expected mapping». Читается по диагонали хуже, чем пишется.

Версия 1 — свой синтаксис:

rule high_latency {
  when http.p99 > 500ms for 5m and http.rps > 100
  then page oncall-backend
}

Одна мысль — одна строка, вложенность исчезла, оператор снова оператор. Разберём принятые решения поимённо — каждое из них общее, а не про алерты.

Решение Что выбрали Почему
Ключевые слова vs пунктуация and, or, for читают правила не программисты; && экономит символы, но требует знания традиции
Единицы измерения суффиксы литералов: 500ms, 5m, 3s «500» без единицы — источник инцидентов; единица в типе, а не в комментарии
Границы конструкции rule NAME { ... } явное начало и конец: парсер восстанавливается после ошибки по }, человек — глазами
Порядок частей всегда whenthen одна форма вместо двух; читается как предложение предметной области
Действия page / ticket / log закрытый список вместо строки: опечатка ловится на этапе разбора
Разделители перевод строки, без ; одна конструкция редко переносится; см. ниже про опасность автовставки

Заметьте общий мотив: синтаксис заимствован из языка, на котором предметные эксперты уже говорят. Это ровно единый язык из DDD, доведённый до грамматики. Если в вашей команде говорят «поднять инцидент», конструкция должна называться так, а не severity: 1.

Версия 2 — грамматика. Формализуем в EBNF; ниже приведён именно тот вариант, который разбирается рекурсивным спуском без забегания вперёд:

program     := rule*
rule        := "rule" IDENT "{" "when" condition "then" action "}"
condition   := conjunction ("or" conjunction)*
conjunction := comparison ("and" comparison)*
comparison  := metric CMP literal [ "for" DURATION ]
metric      := IDENT ("." IDENT)*
literal     := NUMBER UNIT?
action      := ("page" | "ticket" | "log") IDENT
CMP         := ">" | ">=" | "<" | "<=" | "==" | "!="

Приоритет and над or закодирован вложенностью правил — тем же приёмом, что и приоритет умножения над сложением в Mini. Это не только удобно парсеру: читатель воспринимает приоритеты как естественные ровно тогда, когда они совпадают с математической и логической традицией. Изобретать здесь — верный способ породить класс багов, который никто не заметит на ревью.

Однозначность — сразу для двоих

Грамматика должна быть однозначной для парсера. Синтаксис должен быть однозначным для человека. Это разные требования, и хороший дизайн закрывает оба сразу — потому что почти каждая формальная неоднозначность отражает реальную читательскую растерянность.

Классика жанра — висячий else:

if a then if b then x() else y()

К какому if относится else? Грамматика неоднозначна; парсеры разрешают конфликт правилом «к ближайшему», но человек-то читает по отступам, а отступы парсер игнорирует. Языки с обязательными блоками ({ } в Rust и Go, отступы в Python) этой проблемы просто не имеют — не потому, что скобки красивее, а потому, что вопрос перестаёт возникать.

Вторая классика — lexer hack в C. Строка A * B; — это умножение или объявление указателя? Ответ зависит от того, является ли A именем типа, а это знает только таблица символов. Лексер вынужден спрашивать у семантического анализа, фазы склеиваются, и чистый конвейер из обзорной статьи ломается. Мораль: если для разбора конструкции нужны знания более поздних фаз — конструкция спроектирована неудачно.

Третья — автоматическая вставка точек с запятой в JavaScript. Правило «вставлять ;, если строка иначе не разбирается» звучит милосердно, но порождает знаменитую ловушку:

function f() {
  return
    { ok: true };   // вернётся undefined: ASI вставила ; сразу после return
}

Сравните с решением Go: точка с запятой вставляется, если строка заканчивается токеном, которым может кончаться выражение (идентификатор, литерал, ), ], }, ++, return…) — правило формулируется через лексер, полностью локально и не зависит от того, разберётся ли что-то дальше. Отсюда, кстати, обязательная { на той же строке в Go: язык не милосерден, он предсказуем, и это лучше.

Ортогональность, локальность, масштабируемость

Три свойства, отличающие синтаксис, который приятно читать на пятитысячной строке, от синтаксиса, приятного в туториале.

Ортогональность. Конструкции должны свободно комбинироваться. Если for работает с and, но внезапно не работает внутри or — вы завели спецслучай, и пользователь обязан помнить таблицу исключений. Каждый спецслучай стоит строки в документации, ветки в парсере, ветки в форматтере, ветки в подсветке и пункта в списке «почему у нас так странно».

Локальность рассуждения. Смысл фрагмента должен восстанавливаться из самого фрагмента. Именно поэтому Rust требует mut, Go — явного err, а TypeScript просит аннотации на границах модулей: не из любви к многословию, а чтобы читателю не приходилось прыгать по файлам. Полный вывод типов приятен автору и враждебен читателю — это тот случай, когда мощь Хиндли — Милнера стоит намеренно ограничить.

Масштабируемость. Проверяйте синтаксис не на трёх строках, а на трёхстах. Отступы Python прекрасны на экране и мучительны в шаблонизаторах и однострочниках. Значимые пробелы, невидимые символы, зависимость смысла от переноса строки — всё это ломается ровно тогда, когда файл становится большим или проходит через чужой инструмент.

И отдельно — краткость не равна читаемости. APL и Perl доказали, что плотность нотации имеет предел полезности. При этом эмпирика говорит, что и многословие само по себе не спасает: в исследовании Стефика и Зиберта «An Empirical Investigation into Programming Language Syntax» (ACM TOCE, 2013) новички справлялись с задачами на Perl не лучше, чем на языке со случайно сгенерированным синтаксисом, а привычные слова (repeat, if) давали измеримый выигрыш перед символами. Синтаксис — это не вкусовщина, его влияние измеримо.

Ошибки: главная часть UX языка

Теперь главное. Пользователь видит успешный путь один раз — когда всё скомпилировалось. Ошибки он видит десятки раз в день. Качество диагностики определяет ощущение от языка сильнее, чем любая фича системы типов. Elm в своё время получил репутацию «дружелюбного языка» не за архитектуру, а за сообщения компилятора; Rust вкладывает в диагностику отдельную главу dev-guide и содержит целый индекс ошибок с командой rustc --explain E0308.

Анатомия диагностики: шесть слоёв одного сообщения об ошибке

Разберём слои по функции. Код и уровень дают стабильный ключ: по нему ищут в интернете, подавляют в конфиге линтера, группируют в CI. Локация в формате файл:строка:колонка — машиночитаемый контракт с редактором. Первичная метка показывает, где сломалось. Вторичная метка отвечает на вопрос, который человек задаёт следующим: почему компилятор так решил — и указывает на объявление, а не на место падения. Note объясняет правило языка (это обучающий слой: язык учит на ошибках пользователя). Help с исправлением даёт готовый текст — и именно из него редактор делает quick fix.

Работающий движок диагностик

Ключевое архитектурное решение: диагностика — это данные, а не строка. Форматирование отделено от содержания, иначе второй канал вывода (LSP) и третий (SARIF для CI) придётся писать заново.

from bisect import bisect_right
from dataclasses import dataclass, field

@dataclass(frozen=True)
class Span:
    """Полуинтервал смещений в исходнике. Спаны рождаются в лексере
    и живут до конца конвейера — см. статью 01."""
    start: int
    end: int

@dataclass(frozen=True)
class Label:
    span: Span
    message: str
    primary: bool = True     # первичная метка подчёркивается ^^^, вторичная ---

@dataclass(frozen=True)
class Fix:
    """Машинно-применимое исправление: заменить текст в span на replacement."""
    span: Span
    replacement: str
    title: str

@dataclass
class Diagnostic:
    code: str                                    # стабильный ключ: E0308
    severity: str                                # error | warning | note
    message: str                                 # одна фраза, без «unexpected»
    labels: list = field(default_factory=list)
    notes: list = field(default_factory=list)    # правило языка
    fixes: list = field(default_factory=list)


class Source:
    """Исходник + индекс начал строк: перевод смещения в (строка, колонка)."""

    def __init__(self, name: str, text: str):
        self.name, self.text = name, text
        self.line_starts = [0] + [i + 1 for i, ch in enumerate(text) if ch == "\n"]

    def locate(self, offset: int) -> tuple[int, int]:
        """Двоичный поиск по началам строк: O(log L). Строки и колонки 1-based."""
        idx = bisect_right(self.line_starts, offset) - 1
        return idx + 1, offset - self.line_starts[idx] + 1

    def line(self, lineno: int) -> str:
        start = self.line_starts[lineno - 1]
        end = self.text.find("\n", start)
        return self.text[start:] if end < 0 else self.text[start:end]

Рендер в терминал — чистая функция от Diagnostic и Source:

def render(diag: Diagnostic, src: Source) -> str:
    primary = next(l for l in diag.labels if l.primary)
    head_line, head_col = src.locate(primary.span.start)
    width = len(str(max(src.locate(l.span.start)[0] for l in diag.labels)))
    pad = " " * width

    out = [f"{diag.severity}[{diag.code}]: {diag.message}",
           f"{pad}--> {src.name}:{head_line}:{head_col}",
           f"{pad} |"]

    # метки печатаем в порядке появления в файле — читатель идёт сверху вниз
    for lab in sorted(diag.labels, key=lambda l: l.span.start):
        ln, col = src.locate(lab.span.start)
        caret = "^" if lab.primary else "-"
        under = caret * max(1, lab.span.end - lab.span.start)
        out.append(f"{ln:>{width}} | {src.line(ln)}")
        out.append(f"{pad} | {' ' * (col - 1)}{under} {lab.message}")

    out.append(f"{pad} |")
    out += [f"{pad} = note: {n}" for n in diag.notes]

    for fix in diag.fixes:
        ln, _ = src.locate(fix.span.start)
        base = src.line(ln)
        rel = fix.span.start - src.line_starts[ln - 1]
        patched = base[:rel] + fix.replacement + base[rel + (fix.span.end - fix.span.start):]
        out.append(f"{pad} = help: {fix.title}")
        out.append(f"{ln:>{width}} | {patched}")

    return "\n".join(out)

Проверим на нашем DSL. Пусть пользователь написал pge вместо page:

text = ('rule high_latency {\n'
        '  when http.p99 > 500ms for 5m\n'
        '  then pge oncall-backend\n'
        '}\n')
src = Source("alerts.rules", text)
start = text.index("pge")

print(render(Diagnostic(
    code="E0102", severity="error",
    message="неизвестное действие 'pge'",
    labels=[Label(Span(start, start + 3), "ожидалось page, ticket или log")],
    notes=["список действий закрыт: расширить его может только новая версия языка"],
    fixes=[Fix(Span(start, start + 3), "page", "возможно, имелось в виду 'page'")],
), src))
error[E0102]: неизвестное действие 'pge'
 --> alerts.rules:3:8
  |
3 |   then pge oncall-backend
  |        ^^^ ожидалось page, ticket или log
  |
  = note: список действий закрыт: расширить его может только новая версия языка
  = help: возможно, имелось в виду 'page'
3 |   then page oncall-backend

Сто строк кода — и диагностика уровня взрослого компилятора. Сложность рендера — $O(k \cdot L)$, где $k$ — число меток, $L$ — длина строки; locate работает за $O(\log n)$ по числу строк, память под индекс — $O(n)$ по числу строк файла. Ничего из этого никогда не станет узким местом: диагностики печатаются единицами, а не миллионами.

Три реализации Renderer — терминал с каретками, JSON для редактора, SARIF для аннотаций в pull request — читают один и тот же объект и ничего в нём не меняют. Стоило бы render печатать строку прямо из парсера, и каждый новый канал вывода пришлось бы писать с нуля, вылавливая формат регулярками.

Ровно тот же Diagnostic без изменений отдаётся редактору: Spanrange, FixCodeAction, codediagnostic.code. Именно поэтому следующая статья про LSP окажется на удивление короткой — вся работа сделана здесь.

«Возможно, вы имели в виду»: подсказки по опечаткам

Подсказка по опечатке — самая дешёвая функция с самым высоким отношением пользы к затратам. Алгоритм — расстояние Дамерау — Левенштейна (транспозиция важна: люди переставляют буквы постоянно), классическая динамика на двух строках таблицы.

ФУНКЦИЯ расстояние(a, b, порог):
    если |длина(a) - длина(b)| > порог: вернуть порог + 1   # отсечение без работы
    prev2 = пусто; prev = [0..длина(b)]
    ДЛЯ i ОТ 1 ДО длина(a):
        cur[0] = i; best = i
        ДЛЯ j ОТ 1 ДО длина(b):
            cost = 0 если a[i] == b[j] иначе 1
            cur[j] = МИН(prev[j] + 1, cur[j-1] + 1, prev[j-1] + cost)
            ЕСЛИ i>1 И j>1 И a[i]==b[j-1] И a[i-1]==b[j]:      # транспозиция
                cur[j] = МИН(cur[j], prev2[j-2] + 1)
            best = МИН(best, cur[j])
        ЕСЛИ best > порог: вернуть порог + 1   # строка целиком хуже порога
        prev2, prev = prev, cur
    вернуть prev[длина(b)]
def edit_distance(a: str, b: str, limit: int) -> int:
    """Дамерау — Левенштейн с отсечением. O(|a|*|b|) время, O(|b|) память."""
    if abs(len(a) - len(b)) > limit:
        return limit + 1
    prev2, prev = None, list(range(len(b) + 1))
    for i, ca in enumerate(a, start=1):
        cur = [i] + [0] * len(b)
        best = i
        for j, cb in enumerate(b, start=1):
            cost = 0 if ca == cb else 1
            cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
            if i > 1 and j > 1 and ca == b[j - 2] and a[i - 2] == cb:
                cur[j] = min(cur[j], prev2[j - 2] + 1)   # переставленные буквы
            best = min(best, cur[j])
        if best > limit:            # ни один путь уже не уложится в порог
            return limit + 1
        prev2, prev = prev, cur
    return prev[len(b)]


def suggest(unknown: str, candidates: list[str]) -> str | None:
    """Порог зависит от длины: для коротких имён почти любая подсказка — мимо."""
    limit = 0 if len(unknown) <= 3 else (1 if len(unknown) <= 5 else 2)
    if limit == 0:
        return None
    best = min(((edit_distance(unknown.lower(), c.lower(), limit), c) for c in candidates),
               default=(limit + 1, None))
    return best[1] if best[0] <= limit else None


assert suggest("pge", ["page", "ticket", "log"]) is None      # слишком коротко — молчим
assert suggest("tickt", ["page", "ticket", "log"]) == "ticket"
assert suggest("htp.p99", ["http.p99", "http.rps"]) == "http.p99"

Сложность: $O(k \cdot n \cdot m)$ на подсказку, где $k$ — число кандидатов в области видимости, $n$ и $m$ — длины имён. При тысяче видимых имён и длине 10 это порядка $10^5$ операций — доли миллисекунды, и платим мы их только на пути ошибки, когда компиляция всё равно провалилась. Это общее правило: на пути ошибки можно позволить себе дорогие вычисления, которые немыслимы на успешном пути.

Два нюанса из практики. Первый: порог должен зависеть от длины — иначе log начнёт предлагаться вместо lag, dog и lot, и подсказки перестанут вызывать доверие. Второй: кандидатов надо брать из правильной области видимости — предлагать имя переменной вместо имени функции хуже, чем не предлагать ничего. Здесь пригодятся области видимости из статьи 04.

Восстановление после ошибки и правило «одной причины»

Компилятор, падающий на первой ошибке, заставляет пользователя ходить кругами. Компилятор, выдающий сорок ошибок из-за одной забытой скобки, — ещё хуже: настоящая причина тонет в шуме. Практический компромисс, принятый почти всеми:

  • Panic mode с синхронизацией (см. статью 02): встретив непонятное, пропускаем токены до ближайшей «якорной» точки — в нашем DSL это } или ключевое слово rule. Границы конструкций, которые мы заложили в синтаксис, окупаются именно здесь.
  • Подавление каскадов. Узел, помеченный как ошибочный, получает тип-заглушку Error, который совместим со всем. Проверка типов не ругается на него повторно — один разрыв даёт одну диагностику, а не сорок.
  • Лимит и сортировка. Показываем первые 5–10 ошибок, отсортированных по позиции, и пишем «и ещё 34». Первая ошибка почти всегда настоящая — остальные часто её тень.

Текст сообщения: правила, которые экономят часы поддержки

Формат — половина дела, слова — вторая. Проверенный набор требований к тексту:

  1. Называйте предметную сущность, а не внутренность парсера. «неизвестное действие ‘pge’» лучше, чем «unexpected token IDENT at 3:8». Пользователь не обязан знать имён ваших токенов.
  2. Не обвиняйте. «illegal», «invalid», «you must» — плохо; «ожидалось X, найдено Y» — хорошо. Это не вежливость ради вежливости: обвиняющий тон заставляет искать вину вместо причины.
  3. Ошибка — это утверждение о коде, а не о человеке. «пропущена закрывающая скобка» вместо «вы забыли скобку».
  4. Одна ошибка — одна причина. Если печатаете три сообщения об одном пропущенном } — чините восстановление, а не текст.
  5. Пишите, что делать дальше. Диагностика без help — это половина работы. Даже общее «объявите метрику в блоке metrics» лучше, чем ничего.
  6. Никогда не печатайте внутренние идентификаторы (_tmp42, Node<0x7f…>) — это утечка абстракции реализации в интерфейс.
  7. Стабильные коды. E0102 не меняет смысла между версиями: на него ссылаются в чатах, подавляют в конфигах, ищут в поиске. Смена смысла кода — ломающее изменение.

Полезный приём проектирования — error-first design: прежде чем реализовывать конструкцию, напишите все сообщения об ошибках, которые она породит. Если внятного текста не выходит («ну, тут пользователь неправильно скомбинировал модификаторы, и мы не можем понять, что он имел в виду») — конструкция спроектирована плохо, и лучше узнать это до кода, а не после.

Эргономика: что делает язык приятным

Эргономика — это про то, куда пользователь скатывается по умолчанию. Формулировка Рико Мариани, прижившаяся в .NET: pit of success — язык должен быть устроен так, чтобы правильное решение получалось само собой, а неправильное требовало усилий.

Умолчания решают больше, чем возможности. Изменяемость по умолчанию в C++ и неизменяемость по умолчанию в Rust — одна и та же выразительная сила, разная статистика ошибок в реальных программах. В нашем DSL: если for 5m не указан, правило срабатывает мгновенно — верное ли это умолчание? Скорее нет: мгновенные алерты дают шум, поэтому разумнее потребовать for явно или подставить безопасное значение. Умолчание — это редакционное решение о том, как большинство будет писать.

Прогрессивное раскрытие. Простой случай должен писаться просто, сложный — быть возможным, и второй не должен усложнять первый. Опасность — «взрослые» фичи, протекающие в базовый уровень: в C++ новичок спотыкается о move-семантику, которая нужна библиотечным авторам. В DSL это выглядит как обязательные версии, неймспейсы и импорты в файле из трёх строк.

Никакого действия на расстоянии. Конструкция, меняющая смысл далёкого кода — глобальные флаги, using namespace, монки-патчинг, неявные конверсии, — экономит символы и разрушает локальность. Каждый раз, когда вы думаете «пусть эта настройка влияет на весь файл», умножьте выгоду на число людей, которые будут читать середину файла, не видя начала.

Предсказуемая стоимость. Пользователь должен грубо понимать, во что превращается конструкция. Индексация a[i] за $O(1)$ в массиве и за $O(n)$ в списке под одним синтаксисом — ловушка; поэтому в Go нет перегрузки операторов, а в Rust clone() пишется явно. Для DSL то же: если for 30d тихо разворачивается в хранение месяца истории метрик — это должно быть видно в синтаксисе или хотя бы в предупреждении.

Отладка на уровне DSL. Как только вы транслируете DSL во что-то ещё (SQL, Python, байткод из статьи 09), нужны source maps: сохраняйте Span исходника в сгенерированном коде, чтобы стектрейс указывал на строку правила, а не на сгенерированную функцию __rule_17. Именно этот шаг чаще всего забывают — и получают DSL, в котором невозможно понять, что происходит.

Эволюция: язык живёт дольше, чем кажется

Первая версия DSL пишется за неделю. Дальше начинается настоящая работа: любой синтаксис, попавший к пользователям, становится обязательством. Работает закон Хайрама: при достаточном числе пользователей всякое наблюдаемое поведение системы — включая случайные особенности разбора — становится чьей-то зависимостью.

Три работающих стратегии эволюции — и одна поучительная катастрофа.

Обещание совместимости. Go 1 compatibility promise: код, собиравшийся под Go 1.0, соберётся и сегодня. Цена — язык носит все ранние решения на себе. Выгода — доверие: обновление рантайма никогда не ломает сборку.

Editions. Rust editions — версия синтаксиса объявляется в манифесте пакета; крейты разных редакций линкуются друг с другом, потому что редакция меняет только фронтенд, а не ABI и не библиотеку. Так async и dyn смогли стать ключевыми словами без раскола экосистемы. Для DSL это переводится в поле version: 2 в первой строке файла и в компилятор, умеющий обе грамматики.

Контекстные ключевые слова. Слово, являющееся ключевым только в определённой позиции (await в C#, record, yield), позволяет расширять язык, не отнимая имён у существующего кода. Плата — усложнение лексера и парсера; выигрыш — отсутствие массовых переименований.

Катастрофа для сравнения — Python 2 → 3: ломающие изменения без пути миграции, растянувшиеся на двенадцать лет. Урок не «никогда не ломайте», а «ломайте вместе с автоматическим преобразователем»: 2to3 появился, но не покрывал главного изменения — семантики строк, которую статически преобразовать нельзя. Если для миграции нельзя написать инструмент, изменение слишком дорогое.

Практический минимум для маленького DSL: версия в первой строке файла; коды ошибок, не меняющие смысла; закрытые списки (как наши page/ticket/log), расширяемые только в новой версии; CI-корпус реальных файлов пользователей, на котором прогоняется каждый парсер-коммит.

Процесс: как это делают на практике

Порядок, который экономит месяцы, и он ровно обратный интуитивному.

  1. Соберите корпус. Двадцать реальных примеров того, что люди хотят выразить, — до всякой грамматики. Не гипотетических, а вытащенных из текущих конфигов и тикетов.
  2. Напишите желаемый вид. Как вы хотели бы, чтобы эти двадцать примеров выглядели. Это документ дизайна; грамматика выводится из него, а не наоборот.
  3. Проверьте на людях. Дайте пять примеров коллеге, не участвовавшему в разработке, и попросите прочитать вслух и пересказать смысл. Всё, что вызвало паузу, — дефект дизайна, а не читателя. Это обычное юзабилити-тестирование, просто применённое к тексту программы.
  4. Напишите сообщения об ошибках для десяти способов написать эти примеры неправильно.
  5. Только теперь — грамматика, и проверка её на неоднозначность (см. автоматы и языки).
  6. Форматтер — в первом же релизе. Он убивает споры о стиле навсегда и заодно служит безжалостным тестом грамматики: если формат нельзя восстановить из AST, синтаксис хранит смысл в пробелах.
  7. Тесты на снимках (snapshot). Каждый пример корпуса → зафиксированный вывод (AST, ошибки, форматирование). Любое изменение парсера показывает дифф по всему корпусу — это самая полезная тестовая стратегия для языков, см. тестирование.

Типичные ошибки

Антипаттерн Как выглядит Чем чинить
Язык вместо конфига свой парсер для трёх ключей JSON Schema
Конфиг вместо языка циклы и условия в YAML-шаблонах внешний DSL или Starlark
Грамматика внутри строк filter: "amount > 100 and status == 'new'" сделать это выражением языка
Синтаксис от парсера форма правил подогнана под удобство разработчика сначала примеры, потом грамматика
Диагностика-строка raise Error(f"bad token at {pos}") структурный Diagnostic
Каскад ошибок одна скобка → сорок сообщений узел Error + подавление повторов
Тьюринг-полнота «на всякий случай» в DSL завелись рекурсия и eval оставить закрытый набор операций
Тихая семантика 500 без единицы, таймзона по месту запуска обязательные суффиксы и явные единицы
Молчаливое расширение новое ключевое слово ломает чужие идентификаторы контекстные ключевые слова или версия файла
DSL без отладки стектрейс указывает в сгенерированный код source maps со Span исходника

Мини-итог

  • Сначала не проектируйте язык. Лестница «данные → fluent API → внутренний DSL → внешний DSL → свой язык» — это лестница расходов; двигайтесь вправо только когда левая ступень мешает пользователю.
  • Внутренний DSL берёт чужую инфраструктуру даром, но не может дать хорошую диагностику отказа — сообщения будут на языке хозяина.
  • Синтаксис обязан быть однозначным дважды: для парсера (иначе lexer hack и висячий else) и для читателя (иначе ASI-ловушки). Приоритеты кодируйте вложенностью правил и не изобретайте своих.
  • Ортогональность, локальность и масштаб на трёхстах строках важнее краткости; влияние синтаксиса на понимание измеримо экспериментально.
  • Диагностика — это данные, а не строка. Код, локация, первичная и вторичная метки, note, help с машинно-применимым исправлением; один объект — три канала вывода (терминал, LSP, CI).
  • Подсказки по опечаткам дёшевы: Дамерау — Левенштейн с порогом, зависящим от длины, и кандидатами из правильной области видимости.
  • Восстановление после ошибки — часть дизайна синтаксиса: явные границы конструкций дают точки синхронизации; узел Error гасит каскады.
  • Эволюция дороже создания. Версия файла, стабильные коды ошибок, контекстные ключевые слова, editions — и правило: изменение, для которого нельзя написать мигратор, слишком дорогое.

Источники

Что дальше

Язык спроектирован: синтаксис однозначен, ошибки объясняют себя, эволюция продумана. Осталось то, без чего сегодня язык не считается живым, — среда вокруг него. Хорошая новость: половину работы мы уже сделали. Span, Diagnostic и Fix из этой статьи — это ровно range, Diagnostic и CodeAction из протокола LSP; парсер с восстановлением после ошибок — то, что нужно редактору, работающему по недописанному файлу. В следующей статье соберём вокруг языка настоящий тулинг: сервер автодополнения, форматтер, восстанавливающий текст из AST, линтер поверх обхода дерева и отладчик, умеющий останавливаться на строке исходника.

Инструменты языка: LSP, форматтеры, линтеры, отладчики

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов