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

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

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

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

Теперь посмотрите на редактор, в котором вы пишете код. Он запускает анализ на каждое нажатие клавиши. Программа под курсором почти всегда сломана — вы посреди набора имени функции. Ответ нужен за десятки миллисекунд, иначе автодополнение опоздает за пальцами. И вопросы к компилятору задаются совсем другие: не «собери это», а «где определена вот эта штука», «что тут можно написать», «переименуй во всех местах, но не трогай одноимённое поле в другом модуле».

Между этими двумя мирами лежит не косметическая разница. Инструменты языка — это не надстройка над компилятором, это другая архитектура того же компилятора. В этой статье мы разберём, чем именно она отличается, и построим четыре инструмента: языковой сервер, форматтер, линтер и отладчик — для того же учебного языка Mini, который вы собирали с лексера до JIT.

Компилятор-функция против компилятора-сервиса

Соберём различия в таблицу — из неё вырастут все технические решения статьи.

Свойство Батч-компилятор Языковой сервис
Время жизни миллисекунды, один прогон часы, тысячи правок
Вход корректная программа почти всегда сломанная
Реакция на ошибку остановиться, доложить продолжить и выдать максимум информации
Единица работы «собрать всё» «ответить на один вопрос про одну точку»
Бюджет времени секунды 30–100 мс на интерактивный запрос
Память освобождается по выходе всё живёт в процессе, важна инвалидация
Порядок фаз строгий пайплайн запросы по требованию, ленивые и мемоизированные
Отменяемость нет обязательна: пользователь набрал ещё символ

Четыре свойства, которых у нашего компилятора нет и которые придётся добавить:

  1. Устойчивость к ошибкам. Парсер обязан построить дерево из сломанного текста, а не бросить исключение.
  2. Инкрементальность. После правки одного символа пересчитывается не всё, а окрестность правки.
  3. Ленивость. «Дай тип выражения под курсором» не должно тянуть за собой генерацию кода всего проекта.
  4. Отменяемость. Запрос, устаревший из-за новой правки, должен уметь умереть на полпути.

LSP: как один протокол убил задачу M×N

До 2016 года поддержка языка в редакторе писалась под редактор. Плагин для Vim, плагин для Emacs, расширение для VS Code, плагин для IntelliJ — и всё это заново для каждого языка. Комбинаторика беспощадна: M редакторов и N языков дают M × N интеграций, каждая на своём API и своём языке расширений.

Идея Language Server Protocol проста до неприличия: вынести всю языковую логику в отдельный процесс и разговаривать с ним по JSON-RPC через stdin/stdout. Редактор знает про «диагностики», «переход к определению», «подсказки» — но ничего не знает про конкретный язык. Сервер знает язык — и ничего не знает про редактор.

Кадрирование и цикл сообщений

LSP — это JSON-RPC 2.0 с HTTP-подобным заголовком. Ровно один обязательный заголовок Content-Length, разделитель \r\n\r\n, тело в UTF-8. Всё. Вот полный транспорт:

import json, sys

def read_message(stream):
    """Читает один LSP-кадр. Возвращает None, когда клиент закрыл stdin."""
    headers = {}
    while True:
        line = stream.readline()
        if not line:
            return None
        line = line.decode("ascii").rstrip("\r\n")
        if line == "":          # пустая строка — конец заголовков
            break
        key, _, value = line.partition(":")
        headers[key.strip().lower()] = value.strip()
    length = int(headers["content-length"])
    return json.loads(stream.read(length).decode("utf-8"))

def write_message(stream, payload):
    body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    stream.write(b"Content-Length: %d\r\n\r\n" % len(body))
    stream.write(body)
    stream.flush()

Две ловушки, на которых спотыкаются все, кто пишет сервер впервые. Первая: длина считается в байтах, а не в символах — отсюда ensure_ascii=False и явный encode, иначе кириллица в сообщении об ошибке развалит поток. Вторая: stdout принадлежит протоколу целиком. Любой print для отладки — это мусор посреди кадра и мгновенный разрыв сессии. Логи только в stderr или в window/logMessage.

Теперь сам разговор. Важно понимать асимметрию: клиент шлёт запросыid, ждёт ответа) и уведомления (без id, ответа не будет), сервер отвечает и сам инициирует уведомления.

Обратите внимание на две оптимизации, зашитые в протокол. didChange передаёт диапазон и новый текст, а не файл целиком — при TextDocumentSyncKind.Incremental сервер сам поддерживает копию буфера. И completionItem/resolve: сервер сначала отдаёт сотню голых имён, а документацию и детали вычисляет только для того элемента, на который пользователь навёл. Считать документацию для всех ста — верный способ не уложиться в бюджет.

Ядро сервера: диспетчер на 40 строк

class MiniServer:
    def __init__(self):
        self.docs = {}            # uri -> текст
        self.shutdown_requested = False

    def serve(self):
        while True:
            msg = read_message(sys.stdin.buffer)
            if msg is None:
                return
            method, mid = msg.get("method"), msg.get("id")
            handler = getattr(self, "on_" + method.replace("/", "_").replace("$", "d"), None)
            if handler is None:
                if mid is not None:   # на запрос обязан прийти ответ, даже отрицательный
                    self.reply(mid, error={"code": -32601, "message": f"нет метода {method}"})
                continue              # уведомление можно молча проигнорировать
            result = handler(msg.get("params") or {})
            if mid is not None:
                self.reply(mid, result=result)

    def reply(self, mid, result=None, error=None):
        payload = {"jsonrpc": "2.0", "id": mid}
        payload["error" if error else "result"] = error if error else result
        write_message(sys.stdout.buffer, payload)

    def notify(self, method, params):
        write_message(sys.stdout.buffer,
                      {"jsonrpc": "2.0", "method": method, "params": params})

    # --- методы протокола ---
    def on_initialize(self, params):
        return {"capabilities": {
            "textDocumentSync": 1,                 # 1 = полный текст при каждом изменении
            "hoverProvider": True,
            "definitionProvider": True,
            "documentFormattingProvider": True,
            "completionProvider": {"resolveProvider": True, "triggerCharacters": ["."]},
        }}

    def on_textDocument_didOpen(self, params):
        doc = params["textDocument"]
        self.docs[doc["uri"]] = doc["text"]
        self.publish(doc["uri"])

    def on_textDocument_didChange(self, params):
        uri = params["textDocument"]["uri"]
        self.docs[uri] = params["contentChanges"][-1]["text"]   # sync=1: пришёл весь файл
        self.publish(uri)

    def publish(self, uri):
        diags = [to_lsp_diagnostic(d, self.docs[uri]) for d in analyze(self.docs[uri])]
        self.notify("textDocument/publishDiagnostics", {"uri": uri, "diagnostics": diags})

analyze — это ваш лексер и парсер из первых статей плюс разрешение имён, возвращающий список диагностик. Ничего нового писать не нужно — нужно перестать бросать исключение на первой ошибке.

Позиции: главные грабли протокола

LSP оперирует парой (line, character), и character считается в UTF-16 code units. Это наследство от JavaScript-строк, и оно живёт до сих пор. Для Python, где строка — последовательность кодовых точек, а для Rust или Go, где строка — байты UTF-8, это означает обязательную конвертацию:

def utf16_col_to_offset(line: str, col: int) -> int:
    """Колонка LSP (UTF-16 units) → индекс символа в строке Python."""
    units = 0
    for i, ch in enumerate(line):
        if units >= col:
            return i
        units += 2 if ord(ch) > 0xFFFF else 1   # суррогатная пара
    return len(line)

Проверьте на строке let s = "🔥"; foo: эмодзи занимает две UTF-16 единицы и один символ Python. Сервер, который считает колонки наивно, будет подчёркивать ошибки со сдвигом ровно на столько эмодзи, сколько встретилось левее в строке, — классический баг, который воспроизводится только у части пользователей. С версии 3.17 клиент и сервер могут договориться о positionEncoding: "utf-8", но принимать utf-16 придётся всегда: это единственная кодировка, обязательная к поддержке.

Второй нюанс — версии документов. Каждый didChange несёт version; диагностики публикуются с той же версией. Если сервер посчитал анализ для версии 7, а пользователь уже на версии 9, редактор имеет право выбросить ответ. Всегда сравнивайте версию перед публикацией, иначе подчёркивания будут «прыгать» по буферу.

Ядро: компилятор как база данных запросов

Пайплайн лексер → парсер → семантика → IR → код устроен так, что каждая фаза съедает выход предыдущей целиком. Для батч-режима это идеально. Для сервиса — катастрофа: правка одного символа означает полный прогон.

Современный ответ — перевернуть управление. Вместо «фаза вызывает фазу» пишем набор чистых функций-запросов, каждая из которых при выполнении записывает, что она читала. Тогда пересчёт превращается в задачу инвалидации графа. Так устроены salsa в rust-analyzer, система запросов в rustc, компилятор Roslyn и Kotlin Analysis API.

Ключевая деталь, ради которой всё затевалось, — раннее отсечение (early cutoff). У мемоизации две метки времени: verified_at (когда мы в последний раз убедились, что значение актуально) и changed_at (когда значение реально изменилось). Если пересчёт дал тот же результат, changed_at не двигается — и потребители не пересчитываются вообще.

from dataclasses import dataclass

@dataclass
class Memo:
    value: object
    deps: set
    verified_at: int    # ревизия, в которой значение подтверждено
    changed_at: int     # ревизия, в которой значение действительно поменялось

class Db:
    def __init__(self):
        self.rev, self.inputs, self.memo = 0, {}, {}
        self.queries, self.stack, self.log = {}, [], []

    def define(self, name, fn):
        self.queries[name] = fn

    def set_input(self, key, value):
        cur = self.inputs.get(key)
        if cur is not None and cur[0] == value:
            return                       # текст не изменился — ревизию не двигаем
        self.rev += 1
        self.inputs[key] = (value, self.rev)

    def get(self, key):
        if self.stack:
            self.stack[-1].add(key)      # регистрируем зависимость у вызывающего
        if key in self.inputs:
            return self.inputs[key][0]
        return self._compute(key)

    def _changed_at(self, key):
        if key in self.inputs:
            return self.inputs[key][1]
        self._compute(key)
        return self.memo[key].changed_at

    def _compute(self, key):
        m = self.memo.get(key)
        if m is not None:
            if m.verified_at == self.rev:
                return m.value                       # уже проверяли в этой ревизии
            if all(self._changed_at(d) <= m.verified_at for d in m.deps):
                m.verified_at = self.rev              # входы те же — переиспользуем
                return m.value
        name, arg = key
        self.stack.append(set())
        self.log.append(key)                          # для наглядности: что реально считали
        value = self.queries[name](self, arg)
        deps = self.stack.pop()
        if m is not None and m.value == value:
            m.deps, m.verified_at = deps, self.rev    # РАННЕЕ ОТСЕЧЕНИЕ: changed_at не трогаем
            return value
        self.memo[key] = Memo(value, deps, self.rev, self.rev)
        return value

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

def q_parse(db, path):
    src = db.get(("source", path))
    toks = []
    for line in src.splitlines():
        toks.extend(line.split("//")[0].split())      # комментарии выбрасываются
    return tuple(toks)

def q_typecheck(db, path):
    return "ok" if "let" in db.get(("parse", path)) else "empty"

db = Db(); db.define("parse", q_parse); db.define("typecheck", q_typecheck)
db.set_input(("source", "main.mini"), "let x = 40; // ответ")
db.get(("typecheck", "main.mini"))   # посчитано: typecheck, parse

db.log.clear()
db.set_input(("source", "main.mini"), "let x = 40; // ответ на всё")
db.get(("typecheck", "main.mini"))
# db.log == [('parse', 'main.mini')]  ← typecheck НЕ пересчитывался

db.log.clear()
db.set_input(("source", "main.mini"), "let y = 41;")
db.get(("typecheck", "main.mini"))
# db.log == [('parse', 'main.mini'), ('typecheck', 'main.mini')]

Ровно тот эффект, ради которого пишут query-based компиляторы: правка комментария стоит один перезапуск лексера, правка кода — полный пересчёт цепочки. В реальной системе гранулярность мельче (запрос на функцию, а не на файл), и добавляется прочность (durability): исходники зависимостей из реестра пакетов помечаются как «меняются раз в неделю», и при правке своего файла их даже не опрашивают.

Сложность проверки актуальности — O(число зависимостей) на узел, обход графа в глубину с мемоизацией по ревизии, то есть O(V + E) в худшем случае на всю базу, но на практике обход обрывается на первом же неизменившемся узле. Память — O(суммарный размер всех промежуточных результатов), и это главная плата: языковой сервер на большом проекте честно ест гигабайты. Отсюда практика LRU-вытеснения мемоизированных значений: выбросили — значит, пересчитаем, если снова спросят.

Устойчивый парсер и полное дерево

Компилятору достаточно AST — компактного дерева, где остались только значимые узлы. Инструментам этого мало: форматтер обязан сохранить комментарии, rename — переписать ровно одно слово, не тронув отступы. Нужен lossless syntax tree (он же CST): дерево, из которого посимвольно восстанавливается исходник.

Полное синтаксическое дерево и AST: зелёный и красный слои, тривии, span

Приём, придуманный в Roslyn и повторённый в rust-analyzer, — красно-зелёные деревья. Зелёный узел хранит вид и ширину в символах, но не позицию; поэтому он неизменяем и переиспользуется между версиями файла. Красный узел добавляет абсолютный офсет и ссылку на родителя и создаётся лениво при обходе сверху вниз. Правка в середине файла аллоцирует только путь от корня до места правки — O(глубина) вместо O(размер файла). Это тот же трюк, что и в персистентных структурах данных: структурное разделение вместо копирования.

Пробелы и комментарии в такой схеме называются тривиями и привязываются к токенам. Правило Roslyn, которое стоит скопировать: ведущие тривии токена — всё от конца предыдущей строки, замыкающие — всё до конца текущей строки включительно. Тогда комментарий // ok в конце строки принадлежит последнему токену этой строки, и переносить его при форматировании не нужно.

Второе требование — восстановление после ошибок. Наш рекурсивный спуск бросал исключение; в сервисе надо ловить, ставить узел-заглушку и синхронизироваться на «якорных» токенах:

SYNC = {";", "}", "let", "fn", "if", "while", "<eof>"}

class Parser:
    def synchronize(self):
        """Проматываем мусор до токена, с которого можно продолжить осмысленно."""
        while self.peek() not in SYNC:
            self.advance()
        if self.peek() == ";":
            self.advance()

    def parse_program(self):
        out = []
        while self.peek() != "<eof>":
            before = self.i
            try:
                out.append(self.parse_stmt())
            except ParseError as e:
                self.errors.append(e)
                self.synchronize()
                out.append(("error", before, self.i))   # узел-заглушка со span
            if self.i == before:        # НИ ОДИН токен не съеден — иначе вечный цикл
                self.advance()
        return out

Строка if self.i == before: self.advance() — не паранойя, а обязательный инвариант. Любой парсер с восстановлением рано или поздно попадает в состояние, где обработчик ошибки не продвигает позицию, и сервер зависает на 100% CPU. Проверка «прогресс обязателен» ловит это структурно.

На входе let a = 1; let b 1; let c = 3; такой парсер выдаёт одну ошибку («ожидался =»), узел error вместо второго объявления и корректно разобранное третье. Именно это и требуется от IDE: одна ошибка не должна отравлять весь файл каскадом.

Для подсветки синтаксиса всё это часто заменяют на tree-sitter — инкрементальный GLR-парсер с встроенным восстановлением, который переразбирает только изменённое поддерево. Разумное разделение труда: tree-sitter отвечает за мгновенную подсветку и структурную навигацию, языковой сервер — за семантику, которая приходит на 200 мс позже.

Форматтер: не регулярки, а алгебра документов

Форматирование выглядит простой задачей ровно до первого длинного вызова. Настоящий вопрос форматтера: где ставить переносы. Наивные правила («перенос после каждой запятой» или «переносить, если строка длиннее 100») дают либо разреженный, либо рваный код.

Правильный ответ — алгоритм Вадлера из статьи «A prettier printer» (1998), на котором построены Prettier, black и десятки других. Идея: строим не текст, а документ — дерево в маленькой алгебре, где перенос строки не решён, а помечен как возможный. Группа печатается «плоско», если целиком влезает в остаток строки; иначе все переносы внутри неё раскрываются.

from dataclasses import dataclass
from functools import reduce

@dataclass(frozen=True)
class Nil: pass
@dataclass(frozen=True)
class Text:   s: str
@dataclass(frozen=True)
class Line:   flat: str = " "        # чем становится перенос в плоском режиме: " " или ""
@dataclass(frozen=True)
class Concat: a: object; b: object
@dataclass(frozen=True)
class Nest:   indent: int; d: object
@dataclass(frozen=True)
class Group:  d: object              # либо всё плоско, либо все Line внутри — переносы

FLAT, BREAK = 0, 1
soft = Line("")                      # «мягкий» перенос: в плоском виде исчезает

def cat(*ds):
    return reduce(Concat, ds, Nil())

def fits(width, stack):
    """Влезает ли остаток в width колонок? Ограничено шириной строки — дальше не смотрим."""
    stack = list(stack)
    while width >= 0:
        if not stack:
            return True
        i, mode, d = stack.pop()
        if isinstance(d, Text):     width -= len(d.s)
        elif isinstance(d, Concat): stack += [(i, mode, d.b), (i, mode, d.a)]
        elif isinstance(d, Nest):   stack.append((i + d.indent, mode, d.d))
        elif isinstance(d, Group):  stack.append((i, FLAT, d.d))
        elif isinstance(d, Line):
            if mode == FLAT: width -= len(d.flat)
            else:            return True        # дошли до переноса — остаток не важен
    return False

def render(doc, width=80):
    out, pos, stack = [], 0, [(0, BREAK, doc)]
    while stack:
        i, mode, d = stack.pop()
        if isinstance(d, Nil):      continue
        if isinstance(d, Text):     out.append(d.s); pos += len(d.s)
        elif isinstance(d, Concat): stack += [(i, mode, d.b), (i, mode, d.a)]
        elif isinstance(d, Nest):   stack.append((i + d.indent, mode, d.d))
        elif isinstance(d, Line):
            if mode == FLAT:
                out.append(d.flat); pos += len(d.flat)
            else:
                out.append("\n" + " " * i); pos = i
        elif isinstance(d, Group):
            # ключевое решение: смотрим не только на группу, но и на хвост строки за ней
            flat_ok = fits(width - pos, stack + [(i, FLAT, d.d)])
            stack.append((i, FLAT if flat_ok else BREAK, d.d))
    return "".join(out)

Строим документ для вызова функции Mini и печатаем на разной ширине:

def call(name, args):
    sep = cat(Text(","), Line())
    body = reduce(lambda a, b: cat(a, sep, b), args)
    return Group(cat(Text(name), Text("("), Nest(4, cat(soft, body)), soft, Text(")")))

doc = call("send", [Text("channel"), Text("payload"),
                    call("retry", [Text("3"), Text("backoff")])])
print(render(doc, 80))
# send(channel, payload, retry(3, backoff))

print(render(doc, 40))
# send(
#     channel,
#     payload,
#     retry(3, backoff)
# )

Заметьте: внутренний retry остался в одну строку, потому что на своём отступе он помещается. Это и есть то, чего не даёт ни одно наивное правило, — решение принимается локально, но с учётом контекста.

Сложность: каждый Group может вызвать fits, который смотрит максимум на w колонок вперёд, — итого O(n · w) по времени, O(глубина) по памяти. Алгоритм Оппена («Prettyprinting», TOPLAS 1980) даёт честный O(n) за счёт кольцевого буфера ограниченного размера — но он заметно сложнее, а w — константа порядка сотни, так что на практике почти все берут Вадлера.

Что отличает форматтер-игрушку от рабочего

  • Комментарии. В алгебре документов их нет — они приезжают из тривий CST. Каждый комментарий надо привязать к узлу и решить, печатается он до, после или «висит» на строке. Это половина сложности любого настоящего форматтера, и именно здесь возникают баги вида «комментарий уехал внутрь скобки».
  • Идемпотентность. format(format(x)) == format(x) — обязательное свойство, проверяемое property-based тестом. Нарушение означает бесконечные диффы в репозитории.
  • Сохранение семантики. parse(format(x)) должно давать то же AST, что parse(x). Это второе автоматическое свойство, и оно ловит настоящие ошибки: потерянные скобки вокруг выражений с разными приоритетами.
  • Уважение к автору в одном месте. Prettier сохраняет решение пользователя переносить объектный литерал, если он уже был перенесён. Один такой «escape hatch» обычно нужен; больше — уже настройки.
  • Отсутствие настроек. gofmt победил не качеством вывода, а тем, что спорить не о чем. Каждая опция форматтера — это дифф между двумя репозиториями и вечный тред в code review. Опыт Go подробно описан в «Go Proverbs» и статье «Gofmt’s style is no one’s favorite, yet gofmt is everyone’s favorite».

Отдавать результат форматтера в LSP надо не как «весь новый файл», а как минимальный diff: textDocument/formatting возвращает список TextEdit, и редактор применяет их, сохраняя позицию курсора и складки. Замена файла целиком технически допустима, но у пользователя прыгнет вьюпорт.

Линтер: программа корректна, но подозрительна

Компилятор отвечает на вопрос «это законная программа?». Линтер — на вопрос «это программа, которую хотел написать человек?». Отсюда его главное отличие: линтер имеет право ошибаться, и вся его инженерия крутится вокруг цены ошибки.

Правила делятся по глубине требуемого анализа:

Класс Что нужно Пример правила Стоимость
Синтаксические только CST if (x == x), пустой блок catch O(n), доли миллисекунды
Со скоплениями имён таблица символов неиспользуемая локальная, теневое имя O(n) поверх семантики
Потоковые CFG и решётки недостижимый код, чтение до присваивания тот же анализ, что в оптимизациях
Типозависимые результат вывода типов «сравнение всегда ложно», await без промиса требует полного тайпчека
Межпроцедурные граф вызовов проекта утечка ресурса, taint-анализ для инъекций минуты, только в CI

Никакой новой теории здесь нет: потоковые правила — это те же решётки и итерация до неподвижной точки, которые мы разбирали в статье про оптимизации, только результат идёт не в кодогенератор, а в сообщение человеку.

Нижняя половина квадранта — правила, которые технически работают, но воспитывают привычку игнорировать линтер. Инженеры Google описали это в «Lessons from Building Static Analysis Tools at Google» (CACM, 2018) с конкретным порогом: при доле ложных срабатываний выше ~10% разработчики перестают читать вывод инструмента вообще — включая настоящие находки. Похожий вывод, только со стороны коммерческого анализатора, — в «A Few Billion Lines of Code Later» (Coverity, CACM 2010): пользователь, увидевший непонятное предупреждение, не разбирается — он отключает проверку.

Правило и автофикс

Диагностика — это данные, а не строка. Один объект обслуживает три канала вывода: терминал, LSP и SARIF для CI. Как проектировать текст сообщения, мы разбирали в статье про эргономику языка; здесь — как его доставить.

from dataclasses import dataclass, field

@dataclass(frozen=True)
class TextEdit:
    start: int; end: int; new_text: str

@dataclass
class Fix:
    title: str            # то, что покажут в меню «быстрое исправление»
    edits: list

@dataclass
class Diagnostic:
    code: str             # стабильный ключ: для подавления и для статистики
    severity: str         # "error" | "warning" | "hint"
    span: tuple
    message: str
    notes: list = field(default_factory=list)
    fixes: list = field(default_factory=list)

class UnusedLocal:
    """Правило поверх Visitor из статьи про AST: объявлено, но ни разу не прочитано."""
    code = "mini/unused-local"

    def check(self, fn, scope):
        for name, decl in scope.locals_of(fn).items():
            if scope.read_count(decl) == 0 and not name.startswith("_"):
                yield Diagnostic(
                    code=self.code, severity="warning", span=decl.name_span,
                    message=f"переменная `{name}` объявлена, но нигде не читается",
                    notes=["если нужен только побочный эффект, оставьте сам вызов"],
                    fixes=[Fix(f"переименовать в `_{name}`",
                               [TextEdit(decl.name_span[0], decl.name_span[0], "_")])],
                )

Применение правок требует аккуратности: они приходят в офсетах исходного текста, поэтому применять надо справа налево (или строить результат за один проход), а пересечения — обнаруживать и отклонять, а не молча склеивать.

def apply_edits(src, edits):
    edits = sorted(edits, key=lambda e: e.start)
    for a, b in zip(edits, edits[1:]):
        if a.end > b.start:
            raise ValueError(f"пересекающиеся правки: {a} и {b}")
    out, pos = [], 0
    for e in edits:
        out.append(src[pos:e.start]); out.append(e.new_text); pos = e.end
    out.append(src[pos:])
    return "".join(out)

Три механизма, без которых линтер не выживает в живом репозитории:

  1. Подавление в коде// mini:ignore unused-local — держим для отладки, обязательно с требованием причины. Подавление без причины через год никто не решится удалить.
  2. Baseline. Включаете новое правило на миллионе строк — получаете 4000 предупреждений. Фиксируете их снимком (файл со списком известных мест) и требуете, чтобы новых не появлялось. Долг гасится постепенно, а не блокирует релиз.
  3. Единый формат вывода. SARIF — OASIS-стандарт, который понимают GitHub Code Scanning, GitLab и все крупные CI. Один экспорт — и находки приезжают аннотациями прямо в pull request, а не строчками в логе на 40 тысяч строк.

Отладчик: как остановить работающую программу

Отладчик кажется магией ровно до того момента, когда узнаёшь механику. Магии нет: отладчик переписывает код отлаживаемого процесса и ловит сигнал, который сам же и подстроил.

Механика точки останова: подмена байта на 0xCC, коррекция RIP и таблица строк DWARF

На x86 есть однобайтовая инструкция int3 с кодом 0xCC, вызывающая отладочное прерывание. Ядро превращает его в SIGTRAP для трассируемого процесса и будит отладчика в waitpid. Весь цикл:

/* установка: сохранить оригинальный байт и подменить его на 0xCC */
long orig = ptrace(PTRACE_PEEKDATA, pid, addr, 0);
ptrace(PTRACE_POKEDATA, pid, addr, (orig & ~0xFFL) | 0xCC);

/* ... PTRACE_CONT, ждём срабатывания ... */
waitpid(pid, &status, 0);                    /* пришёл SIGTRAP */

struct user_regs_struct r;
ptrace(PTRACE_GETREGS, pid, 0, &r);
r.rip -= 1;                                  /* int3 выполнена, RIP смотрит ЗА неё */
ptrace(PTRACE_SETREGS, pid, 0, &r);

ptrace(PTRACE_POKEDATA, pid, addr, orig);    /* вернуть настоящую инструкцию */
ptrace(PTRACE_SINGLESTEP, pid, 0, 0);        /* выполнить её ровно один раз */
waitpid(pid, &status, 0);
ptrace(PTRACE_POKEDATA, pid, addr, (orig & ~0xFFL) | 0xCC);  /* перевзвести ловушку */

Четыре детали, каждая из которых при пропуске даёт незабываемый баг. RIP -= 1 — иначе выполнение продолжится с середины настоящей инструкции. Сохранение оригинального байта — иначе программа безвозвратно испорчена. Single-step перед перевзводом — иначе точка останова сработает один раз. Перевзвод — иначе она не сработает во второй итерации цикла.

Есть и второй механизм — аппаратные точки останова: четыре отладочных регистра DR0DR3 позволяют ловить не только исполнение, но и чтение/запись по адресу. Это единственный способ сделать watchpoint («останови, когда эта переменная изменится») без чудовищного замедления. Их всего четыре на ядро — отсюда сообщения отладчиков «слишком много watchpoint’ов».

Соответствие «адрес ↔ строка исходника» даёт DWARF: секция .debug_line хранится не таблицей, а программой для машины состояний — байткодом, который эту таблицу разворачивает (специальные опкоды упаковывают «продвинуть адрес и строку» в один байт). Секция .debug_info описывает переменные деревом DIE, включая выражение местоположения (DW_OP_fbreg -24 — «на 24 байта ниже базы кадра»). Секция .eh_frame с CFI объясняет, как размотать стек кадров и добраться до вызывающих функций.

Состояния сессии

Состояние Stopped — не украшение диаграммы, а контракт: адаптер обязан отклонять variables, пока процесс бежит, потому что читать память бегущего процесса — гонка.

Отладчик для нашей ВМ

Для байткодной виртуальной машины отладчик пишется без всякого ptrace: точка останова — это просто множество PC, а таблицу строк генерирует кодогенератор.

from bisect import bisect_right

class LineTable:
    """Отсортированные пары (pc, line), которые кодогенератор пишет параллельно байткоду."""
    def __init__(self, entries):
        self.pcs   = [pc for pc, _ in entries]
        self.lines = [ln for _, ln in entries]

    def line_for(self, pc):                     # O(log n)
        i = bisect_right(self.pcs, pc) - 1
        return self.lines[i] if i >= 0 else None

    def pc_for(self, line):                     # первый адрес строки — туда ставим точку
        cands = [pc for pc, ln in zip(self.pcs, self.lines) if ln == line]
        return min(cands) if cands else None

class Debugger:
    def __init__(self, table):
        self.table, self.breakpoints = table, set()
        self.mode = "run"                       # run | step-in | step-over | step-out
        self.anchor_depth, self.anchor_line = 0, None

    def set_breakpoint(self, line):
        pc = self.table.pc_for(line)
        if pc is not None:
            self.breakpoints.add(pc)
        return pc                               # None → редактор покажет точку «непривязанной»

    def should_stop(self, pc, depth):
        """Вызывается ВМ перед каждой инструкцией (или только на границах строк)."""
        if pc in self.breakpoints:
            return True
        line = self.table.line_for(pc)
        if self.mode == "step-in":
            return line != self.anchor_line or depth != self.anchor_depth
        if self.mode == "step-over":
            return depth < self.anchor_depth or (depth == self.anchor_depth
                                                 and line != self.anchor_line)
        if self.mode == "step-out":
            return depth < self.anchor_depth
        return False

Вся разница между «шагнуть внутрь», «шагнуть через» и «выйти» — это три условия на глубину стека относительно кадра, в котором пользователь нажал кнопку. step-over не останавливается в чужих кадрах (depth > anchor_depth), но обязан остановиться, если вызванная функция сама бросила исключение и мы вылетели наверх (depth < anchor_depth). Забывают именно второе условие.

Если точка останова поставлена на строку, для которой нет ни одного PC (пустая строка, комментарий, удалённый оптимизатором код), pc_for вернёт None. Правильное поведение — не молчать, а вернуть verified: false в DAP: редактор покажет полую точку, и пользователь поймёт, что здесь не остановится.

Debug Adapter Protocol — тот же приём, что и LSP, только для отладчиков: JSON поверх stdio, редактор говорит setBreakpoints / stackTrace / variables / evaluate, адаптер переводит это в команды конкретного отладчика. Один адаптер — и ваш язык отлаживается в VS Code, Neovim и всём остальном. Спецификация — на microsoft.github.io/debug-adapter-protocol.

Отладка оптимизированного кода

Здесь трек замыкается сам на себя. Всё, что мы делали в оптимизациях, разрушает отладку: инлайнинг стирает кадр, регистровое распределение выбрасывает переменную из памяти, планировщик инструкций перемешивает строки. Отсюда <optimized out> в gdb и «шагаешь по функции, а курсор прыгает вверх-вниз».

Индустрия отвечает тремя способами. Первый — уровень -Og: оптимизации, не ломающие соответствие строкам. Второй — честная отладочная информация об инлайне: DWARF умеет описывать inline-кадры (DW_TAG_inlined_subroutine), и отладчик показывает «виртуальный» стек, которого в железе нет. Третий, самый радикальный, — деоптимизация: JIT-рантайм, как мы видели в предыдущей главе, умеет по требованию выкинуть оптимизированный код и вернуться в интерпретатор, где видно всё. Отладчик JavaScript в браузере работает именно так.

Бюджеты задержки

Инструменты живут в интерактивном цикле, и числа здесь такие же жёсткие, как в UI:

Операция Бюджет Что делать, если не укладываемся
Подсветка синтаксиса 16 мс tree-sitter, инкрементальный переразбор
Автодополнение 100 мс отдать имена без документации, детали в resolve
Hover 50 мс кешировать тип узла под курсором
Диагностики файла 300 мс debounce 150–300 мс после последнего нажатия
Диагностики проекта секунды фон, $/progress, pull-модель textDocument/diagnostic
Rename по проекту 1–2 с индекс ссылок, построенный заранее

Три практики, которые вытекают из таблицы. Debounce: не запускать анализ на каждый keystroke — подождать паузу в наборе. Отмена: каждый долгий запрос получает токен отмены и проверяет его в цикле; $/cancelRequest от клиента должен реально прерывать работу, а не просто выбрасывать результат. Частичные ответы: лучше отдать сорок процентов автодополнений за 50 мс, чем сто процентов за 400 — пользователь всё равно печатает дальше и запрос устареет.

Как это тестировать

Инструменты языка тестируются иначе, чем компилятор, и это отдельное ремесло:

  • Snapshot-тесты. Вход — файл с курсором, помеченным маркером вроде foo.ba<|> ; выход — сериализованный результат, зафиксированный в файле. Диффы читаются глазами при code review. Так устроены тесты rust-analyzer и gopls.
  • Property-тесты форматтера. Идемпотентность (format∘format == format) и сохранение AST (parse(format(x)) ≡ parse(x)) — два свойства, ловящие почти все реальные баги. Подход разобран в треке про тестирование.
  • Фаззинг парсера. Случайные и мутированные входы; проверяемое свойство — «не падает и всегда завершается». Парсер с восстановлением обязан терминировать на любом мусоре.
  • Дифференциальное тестирование. Тот же файл через батч-компилятор и через языковой сервер: списки диагностик должны совпадать. Расхождение — признак, что сервис и компилятор разошлись в логике, а это худший класс багов в такой архитектуре.
  • Golden-трассы протокола. Записанная сессия LSP или DAP, проигрываемая в тесте: ловит регрессии вроде «перестали слать publishDiagnostics после didSave».

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

  • Печатать в stdout в LSP-сервере. Мгновенный разрыв протокола, диагностируется тяжело.
  • Считать колонки в символах, а не в UTF-16 единицах. Работает у всех, кроме тех, кто пишет комментарии с эмодзи.
  • Парсер без гарантии прогресса в восстановлении — зависание на 100% CPU.
  • Публиковать диагностики без сверки версии документа — подчёркивания «плавают» по буферу.
  • Держать глобальную блокировку на весь анализ: один долгий запрос замораживает автодополнение.
  • Форматтер, работающий по AST, а не по CST: комментарии исчезают при первом же автофиксе.
  • Правило линтера с 30% ложных — убивает доверие не к правилу, а ко всему инструменту.
  • Отсутствие baseline при внедрении статического анализа: 4000 предупреждений в первый день означают, что их не исправит никто.
  • Никогда не вытеснять кеш запросов — сервер, съедающий 8 ГБ, выключат вместе с языком.
  • Своя реализация анализа для IDE, отдельная от компилятора. Через год это два языка с расходящимися правилами. Единственный устойчивый вариант — общее ядро и два фронтенда над ним.

Мини-итог

  • Языковой сервис — это тот же компилятор, вывернутый наизнанку: не пайплайн, а граф мемоизированных запросов с инвалидацией по ревизиям и ранним отсечением.
  • LSP и DAP решают одну и ту же комбинаторную задачу M × N → M + N, вынося языковую логику в отдельный процесс за JSON-RPC. Транспорт тривиален; сложность — в позициях, версиях и отмене.
  • Инструментам нужно полное синтаксическое дерево с тривиями и узлами-ошибками. Красно-зелёная схема даёт неизменяемость и переиспользование поддеревьев между версиями файла.
  • Форматтер — это алгебра документов Вадлера, а не набор регулярных выражений; проверяется идемпотентностью и сохранением AST.
  • Линтер измеряется не количеством правил, а долей ложных срабатываний; baseline, подавления с причиной и SARIF — обязательная обвязка.
  • Отладчик — это 0xCC, коррекция RIP, сохранённый байт и таблица строк DWARF; для байткодной ВМ всё то же самое, только вместо памяти процесса — массив инструкций.
  • Оптимизации и отладка — прямой конфликт; индустрия живёт на компромиссах -Og, inline-кадрах и деоптимизации.

Источники

Что дальше

Трек закончился. Мы прошли путь от символа во входном файле до языкового сервера, который отвечает редактору за 30 миллисекунд, — и по дороге собрали лексер, парсер, тайпчекер, IR, оптимизатор, кодогенератор, виртуальную машину, сборщик мусора, JIT и инструментальную обвязку. Компилятор перестал быть чёрным ящиком: теперь понятно, почему сообщение об ошибке выглядит именно так, почему сборка тормозит, почему в отладчике написано <optimized out> и почему автодополнение иногда думает.

Куда идти дальше — зависит от того, что зацепило сильнее.

Если тянет к теории. Типы, вывод и унификация из статьи про тайпчекинг растут прямо из лямбда-исчисления — там же живут стратегии вычисления и типизированные системы. Грамматики и разрешимость — в математике и теории вычислимости.

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

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

Если тянет к инструментам. Трек про редакторы показывает другую сторону того же LSP — со стороны пользователя, а не сервера. Про то, как всё это собирается и катится в прод, — DevOps и тестирование.

А общая карта портала с маршрутами под конкретные цели — в роадмапе. Отдельного трека по производительности пока нет, но близкие темы разобраны в статьях про оптимизацию и JIT здесь и в главах про профилирование в треке операционных систем.

И последнее. Лучший способ закрепить всё, что вы прочитали, — довести свой Mini до состояния, в котором им можно пользоваться: язык, который сам себя форматирует, показывает ошибки в редакторе и останавливается на точке останова, воспринимается совсем иначе, чем упражнение из учебника.

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

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

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

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