Привет, я Павел Таланов из команды Yandex Infrastructure. Вместе с командой мы создаём SourceCraft — платформу для полного цикла разработки IT‑продуктов. Хочу рассказать о прикольной задаче на стыке бэкенда и IDE, которую мы решали, чтобы сделать ещё более удобную навигацию по коду в SourceCraft — когда индексация кода проходит с нужной скоростью, а подсказки и другие фичи навигации всегда готовы к открытию пул‑реквеста.

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

Навигация по коду для любого коммита: кратко о задаче

Одна из базовых функций платформы для совместной разработки — пул‑реквесты (PR) и их ревью. На уровне Git коммиты — всего лишь текстовые изменения. Но для разработчика это уже код с семантикой: он видит не текст, а вызовы функций, объявления классов и так далее. В сложных PR недостаточно просто видеть, какой текст поменялся — нужен контекст: какие типы используются и какие функции теперь вызываются.

Цель SourceCraft — сделать работу на платформе максимально комфортной: уменьшить суету при переключении между разными инструментами разработки. Среди фич платформы — умнаянавигация по коду (Code Navigation): подсказки о семантике и структуре программы — прямо при просмотре кода и в код‑ревью.

Например, вот так:

«IDE это уже умеет. Зачем что‑то ещё?» 

IDE реально спасает, когда проект открыт локально и среда настроена. Но в живой работе часто нужно быстро разобраться в коде, не клонируя репозиторий, не поднимая окружение и не дожидаясь сборки. Особенно когда речь про чужой репозиторий, новую подсистему или PR в большом репозитории. Хочется просто открыть страницу в браузере и сразу получить ответы: где определён символ, какие у него типы, кто его вызывает.

Code Navigation в SourceCraft даёт возможность разбираться в коде и проводить ревью быстрее: меньше механических действий, больше фокуса на сути изменений.

Индексы, эвристики, инкременты: с чего мы начинали

Год назад моя коллега Ольга Лукьянова уже рассказывала, как возникла задача навигации по коду и как мы выработали общий подход к её решению. Напомню основные вводные, с которых мы стартовали.

Требования и ограничения

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

  • Всплывающие подсказки (Quick Info).

  • Переход к определению (Go to Definition).

  • Поиск использований (Find References).

Это работает для всех основных популярных языков.

Где это должно работать? В тех местах, где код смотрят чаще всего: в PR и активных ветках. Навигация по коду должна быть доступна и в старой, и в новой версии файла при просмотре PR.

Когда появляться? Идеально — сразу после того, как код попал в репозиторий. Практическая цель — функциональность должна быть доступной к моменту открытия PR на платформе.

Данные и ограничения: доступны только файлы из репозитория. Зависимости не скачиваются, сборка не требуется — это долго и ломает поток работы.

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

Базовое решение и формулировка задачи

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

1. Текстовые эвристики. Самый простой вариант — обычный текстовый поиск с фильтрами по языку, типу сущности и пути. В SourceCraft он тоже есть — но используется как fallback, например, для символов из внешних библиотек, к определениям которых нет доступа.

Почему этого мало:

  • Низкая точность. Одинаковые имена встречаются в разных модулях и пакетах; перегрузки и алиасы путают картину.

  • Ложные срабатывания. Текст совпал — семантика нет; и наоборот.

  • Плохая масштабируемость. Поиск по тексту растёт с размером репозитория и даёт шум в больших кодовых базах.

Чтобы наводить курсор и попадать в «то самое» определение, нужна не текстовая эвристика, а семантика: пространства имён (scope), символы и связи между ними. 

Здесь кратко проясним терминологию. 

Символ — именованная сущность (модуль, класс, тип, функция, метод, поле, параметр). 

Разрешение ссылок (resolve) — установление связи между ссылками и символами по правилам языка.

package foo

import bar.*

class Baz extends <Type> {
	void method(...) {
		<identifier>
		this.<member>
	}
}

Все программисты интуитивно понимают, что такое пространства имён. Пример разрешения ссылок в Java:

  • Type — объявление типа в пакете foo или пакете bar.

  • member — метод Baz или его суперклассов (обращение через this).

  • identifier — параметр или локальная переменная method; если нет — поле Baz; если и это не так — член Type.

2. Использование компилятора. Конечно, есть инструменты, которые уже решают задачу семантической навигации.

Компиляторы вычисляют связи точно, но делают гораздо больше, чем нужно для навигации (генерация, диагностика и тому подобное), и требуют всех зависимостей. Инкрементальность там сложна; часть экосистем сохраняет служебные метаданные (Rust:.rmeta; TypeScript:.tsbuildinfo), но это про сборку, а не про быстрый интерактивный поиск.

Как это делает IDE: с помощью индекса. Индекс в IDE — это локальная база для навигации по коду, построенная парсером и облегчённым анализатором. Обычно он включает:

  • объявления (символы) с полностью квалифицированными именами и позициями в исходниках;

  • связи «ссылка → объявление» и обратный индекс «символ → использования»;

  • границы областей видимости, импорты/экспорты, граф модулей/пакетов;

  • типовую информацию и сигнатуры.

Индекс строится один раз при открытии проекта (IDE подтягивает зависимости/SDK), а дальше обновляется инкрементально по мере набора текста или переключения веток. Благодаря этому IDE отвечает на Quick Info, Go to Definition и Find References быстро — за счёт обращений к локальному индексу, без полной сборки.

Такой подход напрямую, без доработок, нам не подходит. Индекс IDE привязан к локальной рабочей копии и, как правило, к одной ветке. Он требует установленных зависимостей и настроенного окружения. Нам нужно обслуживать десятки параллельных веток и PR в браузере, работать только по содержимому репозитория (без зависимостей), масштабироваться горизонтально и инкрементально обновлять серверный индекс под разные коммиты. Локальная модель IDE под эти требования не масштабируется. 

Баланс «время запроса vs время индексирования», инкрементальные обновления

Было важно найти баланс между вычислениями во время индексации и вычислениями при ответе на запрос.

Наивная идея — предвычислить все связи между символами на этапе индексации и при ответе только читать их. 

Почему это не работает? 

  1. Даже небольшие правки задевают большие подграфы связей, и обновление становится дорогим.

  2. Значительная часть этих связей никогда не запрашивается — лишняя работа и раздувание индекса. 

  3. Ответ зависит от ветки Git: одно и то же имя в разных ветках может вести к разным объявлениям, поэтому «глобально подсчитанные» связи быстро устаревают.

Из этих наблюдений родились принципы будущего решения: 

  • переносить часть вычислений на момент запроса, а в индексе хранить компактное представление кода, достаточное для восстановления нужных связей; учитывать состояние конкретной ветки при ответе; 

  • обновлять индекс пропорционально размеру коммита — только для новых и измененных файлов. 

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

Десятки параллельных веток и работа в браузере: расширяем идею индексов IDE

Хватит о задаче, давайте поговорим о решениях.

Архитектура одним взглядом

Вот что у нас получилось.

Пользователь отправляет код в систему хранения репозиториев, которая уведомляет сервис Code Navigation о новом событии. Формируется задача на обновление индекса для конкретного репозитория или на построение индекса с нуля для новых репозиториев. Один из экземпляров сервиса Indexer забирает задачу и записывает индекс в хранилище. Сервис Navigator читает индекс из хранилища и отвечает на запросы.

Фронтенд шлёт запросы в формате Language Server Protocol (LSP).

Все сервисы сами по себе без состояния (stateless). Indexer и Navigator можно горизонтально масштабировать в зависимости от нагрузки.

Построение индекса

А теперь подробнее рассмотрим самое интересное — устройство индекса.

Построение индекса для нового коммита состоит из двух шагов.

Шаг 1. Мы парсим каждый новый файл и строим промежуточное представление (Intermediate Representation, IR). Наш IR содержит:

  • объявления;

  • ссылки;

  • сигнатуры функций;

  • локальные пространства имен;

  • атрибуты, специфичные для языка программирования.

Шаг 2. По всем IR данного коммита собираем поисковый индекс — в нём хранится, в каком конкретном IR лежит нужное объявление по ключу. 

В качестве ключа, как правило, берётся полное квалифицированное имя (fully qualified name) объявления. Эти ключи отражают структуру конкретного языка. 

Например, в Java java.lang.String — полное имя класса String.

Мы следим, чтобы в поисковый индекс не попадала лишняя информация о сущностях, на которые не бывает ссылок из других файлов.

Инкрементальное индексирование

Мы индексируем только новые файлы коммита — это укладывается в требование по времени обновления.

Поисковый индекс даёт простое API:

  • поиск объявлений по ключу;

  • поиск по префиксу ключа (например, все методы у java.lang.String).

Индекс, который построен по одному коммиту, — это дельта индекса, она содержит информацию только о файлах, которые добавились в этом конкретном коммите. С точки зрения структур данных весь индекс целиком — это персистентная ассоциативная структура (persistent map): каждому коммиту соответствует своя версия отображения «ключ → местоположение объявления».

Тут возможна проблема: даже если поиск внутри одной дельты занимает O(log размера дельты), в худшем случае приходится проходить всю цепочку дельт до начала истории. Наше решение — компактификация: дельты можно сливать в слепок (снапшот), не трогая IR. Так получаем один крупный индекс с быстрым поиском и без пересчёта IR для старых файлов. Быстро достраиваем дельты для новых коммитов.

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

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

Баланс между тем, насколько мы часто строим снапшот, и длиной цепочки дельт влияет на время ответа на запрос, а также на суммарный размер индекса для репозитория. 

Реализация фич навигации

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

Всплывающие подсказки Quick Info на входе в запросе получают ветку/коммит, путь к файлу, позицию. Что происходит дальше:

  • Загрузка поискового индекса. Загружается снапшот репозитория для нужной ветки; при необходимости добавляется «хвост» дельт.

  • Доступ к данным. Из кеша или хранилища подтягивается IR конкретной версии файла (ветка/коммит + путь). Проверяем, находится ли курсор на идентификаторе; если нет — возвращаем пустой результат.

  • Разрешение ссылки (resolve):

    • Сначала ищем внутри файла: параметры, локальные переменные, замыкания. 

    • Затем — перебираем члены текущего типа/класса (this/self) с учётом наследования/встраивания по правилам языка. Далее — импорты и экспорты в пределах модуля/пакета/пространства имён. 

    • Строим список кандидатов с учётом видимости, параметров и аргументов в месте вызова. Проверяем кандидаты через обращение к поисковому индексу по FQN или по префиксу. Эта часть целиком специфична для конкретного языка программирования. 

    • Если точного результата нет — fallback: текстовый поиск с последующей фильтрацией.

  • Формирование ответа. Собирается сигнатура/тип, извлекается док‑комментарий (если есть), подготавливается короткий сниппет исходника. Ответ возвращается в формате LSP (Quick Info).

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

Вот пример того, как это выглядит:

Краевые случаи и ограничения

Расскажу и про ограничения системы.

  • Слишком большие файлы (включая сгенерированные) не поддерживаются, поэтому связи внутри них недоступны.

  • Внутрифайловая инкрементальность не поддерживается: IR пересчитывается целиком для каждой новой версии файла в истории.

  • Сложные языковые конструкции без однозначной семантики на уровне исходников (например, макросы в C/C++) поддерживаются ограниченно.

  • Гигантские монорепозитории: поисковый индекс получается слишком большим, а текстовый поиск возвращает слишком много кандидатов.

  • Гарантии быстрой индексации не распространяются на только что созданные/импортированные репозитории с большим объёмом кода.

  • Для крупных репозиториев (порядка 1 000 000 строк) подгрузка полного поискового индекса занимает заметное время. Navigator держит такие индексы в RAM и инкрементально подтягивает новые дельты по необходимости; при промахе по кешу первый запрос может иметь повышенную задержку.

Как мы измеряли качество

Помимо очевидных технических метрик — задержка индексации, latency запросов, размер индекса — важна ещё одна: точность ответов. 

Тут нам пригодится уже упомянутый Language Server Protocol (LSP) — стандарт взаимодействия редактора с «языковым сервером» для функций вроде Quick Info, Go to Definition и Find References. Именно он работает «под капотом» в VS Code и многих редакторах и IDE. 

Поскольку сервис Code Navigation тоже поддерживает LSP, можно сравнивать его ответы с референсными LSP‑серверами по тем же запросам и на тех же проектах — ровно в том формате, в котором их вызывает IDE. Это удобный способ и измерять качество, и быстро находить баги.

Есть и подводные камни: LSP‑сервера обычно требуют реальные зависимости. Приходится либо фильтровать результаты, указывающие на код библиотек/SDK, либо запускать их в «неполном режиме», когда доступен только код текущего проекта.

Приведу результаты, которых мы добились:

  • Задержка индексации — менее 5 секунд для типичного пуша.

  • Quick Info: p95 latency — < 100 мс. На фронтенде добавляем небольшую задержку отображения для удобства пользователей.

  • Размер индекса — сопоставим с размером репозитория (.git).

  • Для Golang — более 90% абсолютно точных ответов и менее 3% ошибок.

Результаты

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

Например, одним из удачных решений оказался отказ от единого «сложного» IR для всех языков. Подход с языко‑специфичными атрибутами и алгоритмами для связывания символов позволил быстрее прототипировать поддержку новых языков и итеративно улучшать качество.

Что дальше? Идей много. Одно из направлений — навигация между несколькими репозиториями. Например, уже умеем показывать подсказки для символов из JDK (Java) и стандартной библиотеки Go.

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

Комментарии (0)