На днях я запустил wasm-приложение, которое позволяет запускать gogrep шаблоны на относительно крупном корпусе Go кода (~11 миллионов строк кода).


В этой заметке я напишу как этим пользоваться и зачем оно вообще может быть нужно.


Звёздочки нести сюда Исходный код можно найти здесь.



Зачем?


Допустим, вы хотите проверить утверждение, что в среднестатистическом Go коде так никто не пишет (или наоборот, что все так пишут). Для этого вам придётся выполнить эти шаги:


  1. Собрать коллекцию Go кода. Несколько репозиториев, желательно разнообразных.
  2. Придумать, как исполнять поиск. Регулярные выражения могут быть не самым подходящим инструментом.
  3. Сделать результаты воспроизводимыми для других людей, чтобы им не пришлось верить вам на слово.

Проект gocorpus решает все эти шаги за вас.


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

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


О существующих решениях


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


codesearch работает на регулярных выражениях. Этого не всегда достаточно, как я уже упоминал выше.


Был ещё старенький корпус от Russ Cox, но он уже в архиве и его содержимое никогда не актуализировалось. Тем более это просто коллекция кода, а не готовое решение всё-в-одном.


Daniel Marti (оригинальный автор gogrep) собрал что-то вроде индекса популярного кода: github.com/mvdan/corpus. В теории, этот индекс можно использовать для формирования набора репозиториев, доступных в моём приложении.


И некоторые другие:



Моё решение выгодно отличается как минимум тем, что оно заточено под Go: правильно распознаются autogenerated файлы, файлы с тестами, а к выражениям можно применять фильтры. Например, можно требовать от них того, чтобы они не имели побочных эффектов ($x.IsPure).


Корпус кода


Для выбора репозиториев я делал поиск по GitHub с фильтрами и сортировкой по количеству звёздочек. Не самый научный подход, но он достаточно хорош для первой итерации.


После этого выбранные репозитории клонируются, анализируются, минифицируются и кладутся в tar архивы со сжатием.


По мере анализа файлов, мы записываем в метаданные некоторые метрики и факты о файле. Например, импортирует ли файл "unsafe" или "C" (cgo). Эту информацию затем можно использовать в фильтрах.


На клиенте мы качаем tar.gz файлы и на месте их разжимаем.


Подробнее о том, как собирается корпус, можно посмотреть в makecorpus.



Фильтры


На текущий момент реализованы следующие фильтры:


  • $var.IsConst() выражение, захваченное $var является константой
  • $var.IsPure() выражение, захваченное $var не имеет побочных эффектов
  • $var.Is<Kind>Lit() выражение, захваченное $var является литералом данного типа*
  • file.IsTest() true для файлов с суффиксом _test.go в названии или _test в имени пакета
  • file.IsMain() true для файлов с именем пакета main
  • file.IsAutogen() true для файлов, которые размечены как автоматически сгенерированные
  • file.MaxDepth() int значение, которое можно сравнивать с константой**

(*) Kind может быть String, Int, Float, Rune (token.CHAR), Complex.

(**) Некоторые файлы имеют аномальную максимальную глубину AST, из-за чего в некоторых браузерах могут происходить stack overflow внутри wasm кода. С помощью подбора правильного MaxFileDepth можно обойти эту проблему, игнорируя эти страшные файлы.

Фильтры — это обычные Go выражения. Их можно сочетать через && и ||. Также можно использовать ( и ) для группирования, а ! для инвертирования эффекта.


file — это предопределённая переменная, которая привязана к текущему обрабатываемому файлу. $<var> — это переменная из шаблона поиска.


  • $x.IsConst() && !file.IsTest() — не искать в тестах, $x должен быть константным.
  • $x.IsPure() && !$y.IsPure()$x должен быть чистым выражением, а $y — нет.
  • !file.IsAutogen() && !file.IsTest() — не искать в тестах и автоматически сгенерированных файлах.
  • file.MaxDepth() <= 100 — пропускать файлы, в которых максимальная глубина AST выше 100

Если вы пользовались ruleguard, то вам это может напомнить фильтры в Where().


Примеры запросов


Приведу несколько примеров поисковых шаблонов.


Шаблон Описание
copy($_, []byte($_)) Находим избыточные конвертации к []byte.
reflect.DeepEqual($x, $x) Сомнительное использование DeepEqual.
if err != nil { return nil } Потенциальные опечатки в обработке ошибок.
ioutil.ReadAll($_) Находим использования deprecated функции.
var() Пустой gen decl блок для переменных.
len($_) >= 0 Ошибочная проверка длины (always true).

Одинаковые имена переменных будут требовать идентичных матчей. То есть $x = $x находит только самоприсваивания, а $x = $y может найти любые присваивания (в том числе и самоприсваивания). Исключением из правил является $_ — пустая переменная не требует соответствий даже если она используется несколько раз.


Вот паттерн посложнее: map[$_]$_{$*_, $k: $_, $*_, $k: $_, $*_}. Он находит map-литералы, которые содержат ключи-дубликаты. Модификатор * работает прямо как в регулярных выражениях: будет 0 или более матчей. Чтобы найти любые вызовы fmt.Printf, мы можем сделать такой шаблон: fmt.Printf($*_).


Переменная с модификатором необязательно должна быть пустой. Например, вот это тоже валидный шаблон: fmt.Printf($format, $*args).


Модификатора + нет, но его часто можно эмулировать вот так: f($_, $*_) — вызовы f с одним или более аргументами.


Больше примеров шаблонов можно подсмотреть в правилах go-critic.



Frequency score


Если сделать несколько запросов, то можно сравнить количество матчей между ними. Если паттерн X даёт в 2 раза больше матчей, то считаем, что он встречается в коде в такое же количество раз чаще.


Но как анализировать частоту паттерна относительно среднестатистического кода? Мифические 100 матчей могут значить разное в зависимости от размера данных, на которых мы запускали запрос.


Чтобы посчитать некоторую частотность, я взял за основу частоту err != nil. На 11 миллионах строк кода нашлось ~150 тысяч этих проверок на ошибку. Будем считать, что коэффициент 1.0 — это 1 матч на 70 строк кода. Следовательно, если тестируемый шаблон встречается раз в 140 строк, то его frequency score будет равен 1.0. Чтобы результаты было более легко интерпретировать, я домножаю значение на 100, чтобы получились более красивые глазу числа.


Значение frequency score выдаётся на уровне с другими результатами после полного исполнения запроса.


Заключение


Если вы считаете, что нужно добавить какой-то репозиторий в корпус, сообщите мне об этом. Аналогично, если не хватает какой-то фичи или вы нашли баг, тоже открываем issue и я постараюсь это исправить.


Буду рад обратной связи.


P.S. — у меня мало опыта работы с фронтендом, поэтому код на TypeScript и вёртска оставляют желать лучшего. Если кто-то поможет с этой частью, я буду очень признателен.

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


  1. byko3y
    05.12.2021 11:04

    Допустим, вы хотите проверить утверждение, что в среднестатистическом Go коде так никто не пишет (или наоборот, что все так пишут).

    Зачем? Я могу придумать, зачем это разработчику компилятора или стандартной библиотеки, но зачем остальным людям - не могу придумать.


    1. quasilyte Автор
      05.12.2021 17:10
      +1

      Кроме "разработчиков компилятора", это так же полезно в следующих случаях:

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

      • Разработка статических анализаторов.

      • Попытка выбора между вариантами X и Y, когда вы составляете гайдлайны стиля для своей команды (выбираете более идиоматичное).

      • Банальное удовлетворение любопытства.

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


  1. JPEG
    05.12.2021 14:24
    +1

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


  1. quasilyte Автор
    06.12.2021 19:08

    Вот реальный пример где это было полезно:

    cmd/compile: detect and optimize slice insertion idiom append(sa, append(sb, sc...)...) · Issue #31592 · golang/go (github.com)

    Я сначала даже удивился, что на корпусе было 0 срабатываний, но тесты в gogrep показывают, что матчить такие паттерны он может. Так что делаем выводы, что быстрый insert в слайс если и добавлять, то через внешний generic пакет, а не через распознавание паттерна компилятором.

    Хотя это так же может означать, что корпус недостаточно разнородный. Если есть предложения, что ещё добавить - открывайте issue, будем расширять. :)