Инструменты языка: LSP, форматтеры, линтеры, отладчики
Двенадцать статей подряд мы строили компилятор как функцию: на входе текст, на выходе машинный код или байткод. У этой функции есть свойства, которые казались само собой разумеющимися. Она запускается один раз и умирает. Она получает синтаксически корректный вход или отказывается работать. Она вольна потратить секунду на оптимизацию — пользователь всё равно ждёт сборку. Она ничего не помнит между запусками.
Теперь посмотрите на редактор, в котором вы пишете код. Он запускает анализ на каждое нажатие клавиши. Программа под курсором почти всегда сломана — вы посреди набора имени функции. Ответ нужен за десятки миллисекунд, иначе автодополнение опоздает за пальцами. И вопросы к компилятору задаются совсем другие: не «собери это», а «где определена вот эта штука», «что тут можно написать», «переименуй во всех местах, но не трогай одноимённое поле в другом модуле».
Между этими двумя мирами лежит не косметическая разница. Инструменты языка — это не надстройка над компилятором, это другая архитектура того же компилятора. В этой статье мы разберём, чем именно она отличается, и построим четыре инструмента: языковой сервер, форматтер, линтер и отладчик — для того же учебного языка Mini, который вы собирали с лексера до JIT.
Компилятор-функция против компилятора-сервиса
Соберём различия в таблицу — из неё вырастут все технические решения статьи.
| Свойство | Батч-компилятор | Языковой сервис |
|---|---|---|
| Время жизни | миллисекунды, один прогон | часы, тысячи правок |
| Вход | корректная программа | почти всегда сломанная |
| Реакция на ошибку | остановиться, доложить | продолжить и выдать максимум информации |
| Единица работы | «собрать всё» | «ответить на один вопрос про одну точку» |
| Бюджет времени | секунды | 30–100 мс на интерактивный запрос |
| Память | освобождается по выходе | всё живёт в процессе, важна инвалидация |
| Порядок фаз | строгий пайплайн | запросы по требованию, ленивые и мемоизированные |
| Отменяемость | нет | обязательна: пользователь набрал ещё символ |
Четыре свойства, которых у нашего компилятора нет и которые придётся добавить:
- Устойчивость к ошибкам. Парсер обязан построить дерево из сломанного текста, а не бросить исключение.
- Инкрементальность. После правки одного символа пересчитывается не всё, а окрестность правки.
- Ленивость. «Дай тип выражения под курсором» не должно тянуть за собой генерацию кода всего проекта.
- Отменяемость. Запрос, устаревший из-за новой правки, должен уметь умереть на полпути.
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, ответа не будет), сервер отвечает и сам инициирует уведомления.
hover, definition, completion,
синхронизацию документа E->>S: initialized (уведомление) E->>S: textDocument/didOpen (весь текст файла) S->>S: лексер + парсер + разрешение имён S--)E: textDocument/publishDiagnostics E->>S: textDocument/didChange (только дельта!) S->>S: инвалидация запросов, пересчёт S--)E: textDocument/publishDiagnostics E->>S: textDocument/completion (позиция курсора) S-->>E: список меток без документации E->>S: completionItem/resolve (выбранный элемент) S-->>E: та же метка + сигнатура и docstring E-)S: $/cancelRequest (пользователь набрал ещё символ) E->>S: shutdown S-->>E: null E->>S: exit (уведомление)
Обратите внимание на две оптимизации, зашитые в протокол. 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): дерево, из которого посимвольно восстанавливается исходник.
Приём, придуманный в 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)
Три механизма, без которых линтер не выживает в живом репозитории:
- Подавление в коде —
// mini:ignore unused-local — держим для отладки, обязательно с требованием причины. Подавление без причины через год никто не решится удалить. - Baseline. Включаете новое правило на миллионе строк — получаете 4000 предупреждений. Фиксируете их снимком (файл со списком известных мест) и требуете, чтобы новых не появлялось. Долг гасится постепенно, а не блокирует релиз.
- Единый формат вывода. SARIF — OASIS-стандарт, который понимают GitHub Code Scanning, GitLab и все крупные CI. Один экспорт — и находки приезжают аннотациями прямо в pull request, а не строчками в логе на 40 тысяч строк.
Отладчик: как остановить работающую программу
Отладчик кажется магией ровно до того момента, когда узнаёшь механику. Магии нет: отладчик переписывает код отлаживаемого процесса и ловит сигнал, который сам же и подстроил.
На 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 перед перевзводом — иначе точка останова сработает один раз.
Перевзвод — иначе она не сработает во второй итерации цикла.
Есть и второй механизм — аппаратные точки останова: четыре отладочных регистра DR0–DR3
позволяют ловить не только исполнение, но и чтение/запись по адресу. Это единственный способ сделать
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-кадрах и деоптимизации.
Источники
- Language Server Protocol Specification 3.17 — первоисточник; читается за вечер и отвечает на 90% вопросов.
- Debug Adapter Protocol — спецификация и примеры адаптеров.
- rust-analyzer: Architecture и salsa book — лучшее открытое описание query-based компилятора.
- Roslyn overview: Syntax Trees — откуда пошли красно-зелёные деревья и тривии.
- Philip Wadler, «A prettier printer» — 40 страниц, полностью объясняющих любой современный форматтер.
- Derek Oppen, «Prettyprinting», TOPLAS 1980 — линейный алгоритм с ограниченным буфером.
- Prettier: Rationale — как принимаются решения в форматтере без настроек.
- Sadowski и др., «Lessons from Building Static Analysis Tools at Google», CACM 2018 — про порог ложных срабатываний и внедрение анализа в процесс.
- Bessey и др., «A Few Billion Lines of Code Later», CACM 2010 — честный отчёт о том, как статический анализ встречается с реальностью.
- DWARF Debugging Information Format v5 — секции
.debug_line,.debug_info, выражения местоположения. - Eli Bendersky, «How debuggers work»
и Sy Brand, «Writing a Linux Debugger» —
два цикла статей с рабочим кодом на
ptrace. - tree-sitter documentation — инкрементальный парсинг и запросы к дереву для подсветки.
- SARIF 2.1.0 specification — формат обмена находками статического анализа.
Что дальше
Трек закончился. Мы прошли путь от символа во входном файле до языкового сервера, который отвечает
редактору за 30 миллисекунд, — и по дороге собрали лексер, парсер, тайпчекер, IR, оптимизатор,
кодогенератор, виртуальную машину, сборщик мусора, JIT и инструментальную обвязку. Компилятор
перестал быть чёрным ящиком: теперь понятно, почему сообщение об ошибке выглядит именно так, почему
сборка тормозит, почему в отладчике написано <optimized out> и почему автодополнение иногда думает.
Куда идти дальше — зависит от того, что зацепило сильнее.
Если тянет к теории. Типы, вывод и унификация из статьи про тайпчекинг растут прямо из лямбда-исчисления — там же живут стратегии вычисления и типизированные системы. Грамматики и разрешимость — в математике и теории вычислимости.
Если тянет к языкам. Компилятор объясняет, почему парадигмы устроены именно так: посмотрите на обзор парадигм и функциональное программирование — теперь за каждым «языковым удобством» вы увидите конкретную работу компилятора.
Если тянет вниз, к железу. Как работает процессор и память, управление памятью в ОС и системные вызовы — продолжение разговора, начатого в главах про кодогенерацию и сборку мусора.
Если тянет к инструментам. Трек про редакторы показывает другую сторону того же LSP — со стороны пользователя, а не сервера. Про то, как всё это собирается и катится в прод, — DevOps и тестирование.
А общая карта портала с маршрутами под конкретные цели — в роадмапе. Отдельного трека по производительности пока нет, но близкие темы разобраны в статьях про оптимизацию и JIT здесь и в главах про профилирование в треке операционных систем.
И последнее. Лучший способ закрепить всё, что вы прочитали, — довести свой Mini до состояния, в котором им можно пользоваться: язык, который сам себя форматирует, показывает ошибки в редакторе и останавливается на точке останова, воспринимается совсем иначе, чем упражнение из учебника.