﻿# Как сделать свой статический анализатор для Go?

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

![1329_Go_StaticAnalysis_ru/image1.png](https://import.viva64.com/docx/blog/1329_Go_StaticAnalysis_ru/image1.png)

## Введение

Опыт работы с Go\-инструментами для разбора кода был получен при разработке статического анализатора [PVS\-Studio](https://pvs-studio.ru/ru/) для Go\.

Статья направлена на начинающих, так что начнём с основ\.

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

В экосистеме Go есть встроенный анализатор `go vet` и коллекция линтеров, объединённых в `golangci-lint`\. Эти инструменты работают быстро, легко подключаются в CI и привычны каждому разработчику\. Поэтому если вы не создаёте свой собственный продукт со своей экосистемой \(как это делаем мы\), то любое правило или отдельный анализатор лучше всего интегрировать именно в этот стандартный процесс\.

Если вам не хватает возможностей существующих линтеров \(нужно проверять специфичные для проекта ошибки, следить за внутренними контрактами или вы просто уверены, что сделаете правило лучше существующего\), то в таких случаях можно написать свой собственный анализатор\. Стандартная библиотека даёт доступ ко всему, что нужно: синтаксическому дереву, информации о типах, данным о позициях в файле\. А поверх этого есть удобный фреймворк `go/analysis`, который позволяет создавать полноценные линтеры без лишней рутины\. 

В этой статье мы шаг за шагом разберём, как устроены инструменты анализа в Go, какие пакеты используются для работы с кодом и семантикой, как запускать свой анализатор и как интегрировать его в общую систему линтеров\.

## Обзор технологий и пакетов Go для анализа

Go предоставляет полный набор инструментов для разбора и анализа исходного кода\. Эти пакеты входят в стандартную библиотеку или расположены в официальном репозитории `golang.org/x/tools`\. Пройдёмся по всем, которые нам пригодятся\.

### go/ast\. Синтаксическое дерево

Пакет [`go/ast`](https://pkg.go.dev/go/ast) описывает структуру [абстрактного синтаксического дерева \(AST\)](https://pvs-studio.ru/ru/blog/terms/0004/)\. Оно отражает структуру программы: выражения, операторы, объявления\. В нём нет информации о типах переменных или о том, к какому объекту относится идентификатор, — только форма записи\. Это один из основных пакетов, с которым вам придётся работать, так как анализаторы так или иначе оперируют узлами AST\.

### go/types\. Семантический анализ

AST показывает форму записи, но не говорит, что означает идентификатор\. Это делает пакет [`go/types`](https://pkg.go.dev/go/types)\. Он позволяет:

* определить тип выражения;
* узнать, какой объект \(переменная, функция, константа\) стоит за `ast.Ident`;
* проверить корректность вызовов и приведения типов\.

Основной результат работы — это структура `types.Info`\. Она связывает узлы AST с типами и объектами\. Этот пакет нужен, когда информации из дерева недостаточно для понимания ошибки\.

### go/token\. Позиции в исходниках

Пакет [`go/token`](https://pkg.go.dev/go/token) помогает обрабатывать информацию о позициях в файле: строки, столбцы, смещения\.

### golang\.org/x/tools/go/analysis\. Фреймворк для анализаторов

[`golang.org/x/tools/go/analysis`](https://pkg.go.dev/golang.org/x/tools/go/analysis) — это официальный фреймворк для создания правил\. Он абстрагирует рутину: парсинг, разбор типов, передачу данных между анализаторами, формирование отчётов\.

Cтруктура [`analysis.Analyzer`](https://pkg.go.dev/golang.org/x/tools/go/analysis#hdr-Analyzer) является ключевым элементом\. В ней задаются:

* имя и описание правила;
* список зависимостей;
* функция `Run`, выполняющая проверку;
* флаги и дополнительные параметры\.

Фреймворк также предоставляет вспомогательные пакеты:

* `[analysis/singlechecker](https://pkg.go.dev/golang.org/x/tools/go/analysis/singlechecker)` — запуск одного анализатора;
* `[analysis/multichecker](https://pkg.go.dev/golang.org/x/tools/go/analysis/multichecker)` — объединение множества анализаторов;
* `[analysis/passes/...](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes)` — готовые проходы \(например типизация, распознавание printf\-подобных функций\)\.

### go/parser\. Построение AST \(опционально\)

Пакет [`go/parser`](https://pkg.go.dev/go/parser) умеет превращать текст Go\-файла в дерево `ast.File`\. Он нужен, когда вы строите анализатор вручную\. Если используется фреймворк `analysis`, то парсинг он берёт на себя\.

### go/packages\. Загрузка пакетов и их зависимостей \(опционально\)

Пакет `[go/packages](https://pkg.go.dev/golang.org/x/tools/go/packages)` используется для загрузки пакетов, AST и информации о типах\. Он решает, как и какие пакеты грузить, корректно учитывает модули, build tags и окружение\. Он умеет сразу загружать весь набор пакетов с зависимостями\. Фреймворк `analysis` внутри уже использует механизмы загрузки пакетов, так что обычно при написании правил `go/packages` не требуется\. Но если вы пишете самостоятельный инструмент, то `go/packages` почти всегда удобнее, чем ручной `parser` \+ `types`\.

### golang\.org/x/tools/go/ssa\. Представление в виде SSA \(опционально\)

В этой статье мы его использовать не будем, но важно упомянуть\. SSA \(Static Single Assignment\) — промежуточное представление, похожее на низкоуровневый псевдокод\. SSA позволяет решать задачи DataFlow анализа, делать межпроцедурные проверки и строить более сложные диагностики \(например, проверку возможного разыменования nil\)\.

### Как всё это сочетается

В типичном анализаторе используется следующая комбинация:

* AST \(`go/ast`\) — чтобы найти нужный фрагмент дерева;
* Типы \(`go/types`\) — чтобы точно определить, что это за переменная или функция;
* `analysis` — чтобы красиво объединить всё в одно правило и интегрировать его в линтеры\.

Далее мы разберём, как устроено выполнение анализатора и как эти пакеты взаимодействуют между собой\.

## Как устроены проходы анализа в Go

Фреймворк `go/analysis` организует выполнение правил так, чтобы автору анализатора не приходилось вручную загружать пакеты, парсить файлы, строить типы и делать много чего ещё\. Он делает это автоматически и передаёт вашему правилу уже подготовленный набор данных: AST, типы, информацию о файлах и позициях\.

Когда анализатор запускается через `singlechecker`, `multichecker` или, например, в составе `golangci-lint`, первым шагом происходит:

1. Сбор списка файлов в пакете;
1. Парсинг файлов в AST;
1. Типизация пакета через `go/types`\.

На выходе формируется структура `Pass` — это объект, содержащий всё, что нужно правилу\. Подробнее рассмотрим его в разделе про фреймворк `analysis`\.

Каждый анализатор описывает свои зависимости через поле `Requires`\. Например, он может зависеть от [`nilness`](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/nilness)\. Фреймворк вычисляет правильный порядок выполнения, запускает зависимости и передаёт их результаты вашему правилу через `pass.ResultOf`\.

Основной код проверок находится в `Run(pass *analysis.Pass) (any, error)`\.

Обычно сценарий работы правила внутри метода выглядит так:

1\. Получаем список файлов:

```cpp
for _, file := range pass.Files {
    // обходим AST, ищем нужные конструкции
}
```

2\. Используем `pass.TypesInfo` для уточнения типа переменных, функций, выражений\.

3\. При нахождении проблемы вызываем:

```cpp
pass.Reportf(expr.Pos(), "сообщение")
```

Фреймворк сам соберёт результаты, выведет их в командную строку или передаст дальше, если анализатор работает внутри других инструментов\.

## AST в деталях

Абстрактное синтаксическое дерево \(AST\) — это структурированное представление программы\. Оно показывает, из каких конструкций состоит код: объявления, операторы, выражения, типы\. Работая с AST, анализатор видит код не как текст, а как чётко организованную структуру\.

После парсинга файл превращается в дерево, где каждый узел — это конкретный элемент языка\. Например:

* `ast.FuncDecl` — объявление функции;
* `ast.AssignStmt` — оператор присваивания;
* `ast.CallExpr` — вызов функции;
* `ast.BinaryExpr` — бинарное выражение;
* `ast.Ident` — имя переменной или функции\.

AST используется всегда, он является основой всех проверок\. Если нужно найти вызовы функции, вы ищете узлы `ast.CallExpr`\. Если нужно проверить, как используется переменная, вы ищите `ast.Ident`\.

#### Как выглядит дерево на примере

Возьмём небольшой фрагмент кода:

```cpp
x := a + b
```

Для визуализации можем воспользоваться [вспомогательным сервисом](http://goast.yuroyoro.net/)\.

Вот так будет выглядеть дерево кода выше:

![1329_Go_StaticAnalysis_ru/image2.png](https://import.viva64.com/docx/blog/1329_Go_StaticAnalysis_ru/image2.png)

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

```cpp
if call, ok := n.(*ast.CallExpr); ok {
    // function call found
}
```

Точно так же обрабатываются присваивания, операции, условия и любые другие конструкции\. Например, `AssignStmt.Lhs` и `AssignStmt.Rhs` содержат левую и правую части оператора\.

Дерево не хранит информацию о типах переменных или значениях констант — это будет в следующем разделе про `go/types`\.

## Семантика \(go/types\)

Чтобы анализатор мог понять, какой тип имеет выражение, куда ссылается идентификатор и корректно ли выполнены операции, используется пакет `go/types`\. Он связывает дерево с реальными объектами: переменными, функциями, типами и методами\.

Допустим, в коде есть выражение:

```cpp
x := y + z
```

AST скажет только, что это бинарное выражение с оператором `+`, но не скажет, какие именно типы у `y` и `z`, где объявлена переменная `y`\. Ответы даёт `go/types`\. Результатом работы `go/types` является `types.Info`\.

Наиболее важные поля:

* `Types` — хранит тип каждого выражения;
* `Defs` — где определены объекты;
* `Uses` — где используются объекты\.

Таким образом, любой идентификатор можно развернуть до конкретного объекта \(переменной, функции, поля\) вместе с его типом\.

Для того, чтобы узнать тип выражения, можно использовать следующий код:

```cpp
if tv, ok := pass.TypesInfo.Types[call]; ok {
    fmt.Printf("Тип выражения: %s\n", tv.Type.String())
}
```

Идентификатор содержит только имя \(`x`\), но не знает, к чему привязан\. Семантика делает эту связь:

```cpp
obj := pass.TypesInfo.ObjectOf(ident)
```

`obj` может быть:

* переменной \(`types.Var`\);
* функцией \(`types.Func`\);
* константой \(`types.Const`\);
* типом \(`types.TypeName`\);
* пакетом \(`types.PkgName`\)\.

## Стандартный фреймворк analysis

Фреймворк `golang.org/x/tools/go/analysis` — это фундаментальная часть экосистемы статического анализа в Go\. Он избавляет от рутины и даёт удобную структуру для написания собственных правил\. Благодаря ему можно быстро создать анализатор, интегрировать его в другие инструменты и запускать на реальных проектах\.

Каждое правило оформляется как объект типа `analysis.Analyzer`\. В нём описывается всё, что нужно:

* название и описание диагностики;
* зависимости от других анализаторов;
* функция `Run`, содержащая саму проверку;
* способ вывода ошибок\.

Это делает анализаторы единицами анализа, которые можно комбинировать, объединять в один бинарник и подключать к линтерам\.

Простейший анализатор выглядит так:

```cpp
var Analyzer = &analysis.Analyzer{
    Name: "mycheck",
    Doc:  " Description of the rule",
    Run:  run,
}
```

Где `run` — это функция проверки:

```cpp
func run(pass *analysis.Pass) (any, error) {
    // логика проверки
    return nil, nil
}
```

Фреймворк передаёт вашему правилу объект `analysis.Pass`\. Он содержит всю необходимую информацию:

* `Files` — список AST\-файлов \(`ast.File`\), готовых к обходу;
* `TypesInfo` — информация о типах \(`types.Info`\) для выражений в дереве;
* `Pkg` — текущий пакет;
* `Reportf` и `Report` — методы для вывода ошибок;
* `Fset` — таблица позиций \(`token.FileSet`\);
* `ResultOf` — данные от других анализаторов, если у правила есть зависимости\.

Чтобы сообщить о проблеме, используется метод `pass.Reportf`\. Например:

```cpp
pass.Reportf(call.Pos(), "Undesirable function call %s", funObj.Name())
```

Фреймворк предоставляет два удобных пакета `singlechecker` и `multichecker`\.

`singlechecker` используется, когда у вас одно правило:

```cpp
package main

import (
    "golang.org/x/tools/go/analysis/singlechecker"
)

func main() {
    singlechecker.Main(Analyzer)
}
```

После сборки можно запускать:

```cpp
go vet -vettool=./mycheck ./...
```

`multichecker` используется, если правил много\. Их можно объединить в один инструмент:

```cpp
multichecker.Main(
    rule1.Analyzer,
    rule2.Analyzer,
    rule3.Analyzer,
)
```

Преимущества `analysis` достаточно очевидны:

* не нужно вручную парсить файлы — фреймворк делает это сам;
* типизация выполняется автоматически;
* анализатор работает на уровне пакетов и учитывает зависимости;
* легко подключать к `go vet` и `golangci-lint`;
* простая архитектура: один анализатор \= одно правило\.

## Интеграция со стандартными инструментами

Собственный анализатор ценен только тогда, когда его удобно запускать\. Экосистема Go построена так, что любые инструменты анализа можно бесшовно интегрировать в привычные рабочие процессы: запуск через `go vet`, использование в CI\. Рассмотрим, как это делается\.

### Запуск через go vet

`go vet` — встроенный в Go механизм статического анализа\. Он может загружать внешние анализаторы, если указать их как `vettool`\.

Чтобы подключить ваш анализатор, сначала его нужно собрать:

```cpp
go build -o mycheck ./cmd/mycheck
```

Теперь можно запустить:

```cpp
go vet -vettool=./mycheck ./...
```

При этом:

* `mycheck` будет работать как полноценная часть `go vet`;
* результаты диагностики появятся в стандартном формате\.

Это удобный способ распространять простой набор правил\.

Также анализатор можно встроить и в `golangci-lint`\. Подробнее про это можно почитать в [документации](https://golangci-lint.run/docs/plugins/module-plugins/) и посмотреть на наглядном [примере](https://github.com/golangci/example-plugin-module-linter)\.

### Запуск как отдельный инструмент

Если вы собрали анализатор через `singlechecker` или `multichecker`, его также можно запускать напрямую:

`./mycheck ./...`

Этот вариант используется:

* в CI;
* в автономном режиме;
* при отладке новых правил\.

## Пример простой диагностики

Давайте на самом простом примере разберём написание правила для Go анализатора\.

Диагностика должна находить пустые `if` блоки вида:

```cpp
if cond {
}
```

Правило использует пакет `go/ast` для обхода синтаксического дерева\.

Код в файле `main.go`:

```cpp
package main

import (
  "emptyif"

  "golang.org/x/tools/go/analysis/singlechecker"
)

func main() {
  singlechecker.Main(emptyif.Analyzer)
}
```

Код самого правила в файле `emptyif.go`:

```cpp
package emptyif

import (
  "go/ast"

  "golang.org/x/tools/go/analysis"
)

var Analyzer = &analysis.Analyzer{
  Name: "emptyif",
  Doc:  "reports empty if statements",
  Run:  run,
}

func run(pass *analysis.Pass) (any, error) {

  for _, file := range pass.Files {
    ast.Inspect(file, func(n ast.Node) bool {
      if stmt, ok := n.(*ast.IfStmt); ok {
        // We check if there is a body and if it is empty
        if stmt.Body != nil && len(stmt.Body.List) == 0 {
          pass.Reportf(stmt.Pos(), "empty if block")
        }
      }
      return true
    })
  }

  return nil, nil
}
```

Рассмотрим, из чего состоит это правило:

1. Обход AST\. Используется `ast.Inspect`, позволяющий рекурсивно просматривать узлы дерева\.
1. Проверка `IfStmt`\. Узел сравнивается через приведение типа: `n.(*ast.IfStmt)`\.
1. Анализ тела блока\. `stmt.Body.List` содержит список операторов\. Если он пустой, то выдаём предупреждение\.
1. Формирование отчёта\. `pass.Reportf` выводит сообщение с привязкой к точке в исходниках\.

Если вы сделали всё правильно, то после сборки сможете запустить ваш анализатор:

```cpp
go build -o emptyif.exe ./cmd/emptyif
go vet -vettool=D:\emptyif\emptyif.exe ./...
```

Вывод будет примерно таким:

```cpp
tests\emptyif_test\file1.go:12:2: empty if block
```

## Заключение

В целом, на этом всё\. Мы рассмотрели базу для написания своего статического анализатора для Go\. Если статья вам понравится, то в будущем можно будет сделать более сложную, с разбором настоящего правила статического анализа, описанием этапа тестирования и прочим\.

Кстати говоря, я думаю вы уже поняли, что мы разрабатываем свой статический анализатор для Go\. Если вы хотите поучаствовать в EAP, то следите за [нашими новостями](https://pvs-studio.ru/ru/subscribe/)\.