Как стать автором
Обновить
62.32

В поисках хорошего стиля. Часть 2. Пишем свой линтер на Go для golangci-lint

Уровень сложностиСредний
Время на прочтение10 мин
Количество просмотров365

Привет! Меня зовут Артём Блохин, я Go-разработчик в команде интеграций Островка. Сегодня поговорим о линтинге кода.

Если бы «Сумерки» были про код, Эдвард — был линтером, а Белла — легаси-кодом, их диалог звучал бы так:

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

Любой, кто пытался разобраться в старом коде без статики, знает: чем глубже копаешь, тем страшнее становится. В первой части мы говорили о том, как линтеры помогают поддерживать порядок в проекте. Теперь пора перейти к практике — написать собственный линтер и встроить его в golangci-lint, чтобы он сам выслеживал проблемные места.

Disclaimer

Линтер довольно объёмный (200+ строк кода), и если бы я попытался запихнуть всё в одну статью, это было бы жестоко — и для вас, и для моей клавиатуры. Поэтому в этой части мы разберёмся только с базовой логикой, остальное можно посмотреть в репозитории. 

Пристёгивайтесь, будет интересно!

Как родился ProperOrder, или «разорвать семейные узы»

Перед тем как писать линтер, я долго ломал голову: какой кейс взять? Он должен быть полезным, не слишком сложным и при этом не повторять уже существующие решения. В итоге выбор пал на наш новый линтер — ProperOrder.

Что делает ProperOrder?

Он проверяет, что код написан в осмысленном порядке:

  • сначала объявляется структура;

  • потом конструктор (если есть);

  • затем методы этой структуры.

А теперь посмотрим на два варианта кода и выберем более читаемый. Это поможет понять, как порядок объявления влияет на восприятие и как ProperOrder помогает его соблюдать.

Пример 1 (неудачный):

type Man struct {...}

type Woman struct {...}

func (m Man) GetName() string {...}

func (w Woman) GetName() string {...}

Здесь метод GetName для Man разрывается объявлением структуры Woman. Когда код разрастается, подобные вещи снижают читаемость.

Пример 2 (удачный):

type Man struct {...}

func (m Man) GetName() string {...}

type Woman struct {...}

func (w Woman) GetName() string {...}

Теперь всё логично: объявили Man, затем сразу его метод.

Когда можно нарушать порядок?

Иногда структуры должны появляться не строго друг за другом, а в зависимости от их использования в функциях. Например:

type Man struct {...}

type Health struct {...}

func NewMan(h Health) Man {..}

type Woman struct {...}

func (m Man) FindLove(w []Woman) (Woman) {...}

В этом коде структура Health идёт до конструктора NewMan, хотя сам Man уже объявлен. Почему так? Потому что Health нужен в параметрах конструктора.

То же самое с FindLove: здесь массив []Woman передаётся в аргумент метода Man, а сам метод возвращает Woman. В таком случае линтер не будет ругаться, потому что порядок логичен: Типы, используемые в параметрах или возвращаемых значениях, могут объявляться раньше.

А существует ли читаемый шаблон для линтера?

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

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

Как итог, я пришел к такому виду:

├── analyzers
│   └── custom_linter
│       ├── analyzer.go
│       ├── analyzer_test.go
│       └── testdata
│           └── src
│               └── src.go
├── cmd
│   └── custom_linter
│       └── main.go
│── internal
│   └── tests
│       └── tests.go
└── plugin
    └── main.go

Рассмотрим директории подробнее:

analyzers/        — здесь живёт вся логика линтера
  ├── analyzer.go        — основная логика анализа кода
  ├── analyzer_test.go   — тесты для линтера
  └── testdata/          — примеры кода, на которых тестируется линтер 
                            (то, что он должен пропускать и то, 
                                              что должен ругать)
cmd/              — точка входа
  └── main.go           — запускает линтер (если хотим прогнать его 
                                          отдельно от golangci-lint)
internal/         — вспомогательные вещи для тестов и других внутренних задач
  └── tests.go          — поддерживающие функции и сценарии
plugin/           — загрузка линтера в golangci-lint
  └── main.go

Запуск линтера

package main

import (
  "golang.org/x/tools/go/analysis/singlechecker"
  "gitlab.com/common/linter/analyzers/properorder"
)

func main() {
  singlechecker.Main(properorder.New())
}

singlechecker.Main(properorder.New()) создаёт интерфейс командной строки для линтера.

Если хочется поддерживать несколько линтеров, вместо singlechecker можно использовать multichecker.Main().

Линтер, или современный Прометей

Код рождается из идеи. Какая у нас идея? Порядок.
Главная проблема, связанная с ProperOrder, — как запомнить, что за чем следует?

Как мы можем быть уверены, что когда мы видим метод GetName для структуры Man, где-то выше уже объявлена эта структура? Или что перед конструктором NewPerson сначала идёт type Person struct {}?

Ответ прост: будем использовать стек. Но самое главное – стек нужно вычищать полностью, когда мы заходим в новый файл.

Стек — ключ к порядку

Когда линтер анализирует файл, он складывает все важные узлы в стек и проверяет, не нарушен ли порядок.

Что мы будем хранить в стеке?

  • ast.FuncDecl — функции и методы (func Foo() или func (r *Receiver) Foo());

  • ast.TypeSpec — объявления типов (type Man struct{}).

А теперь вернемся к примеру 1 и пройдёмся по коду сверху-вниз, попутно записывая в стек значения:

Что же мы видим? Красный элемент стека (метод Man) не соответствует нижележащему (тип Woman).

Как ProperOrder анализирует код?

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

Но перед тем, как перейдем к функции run(), нужно инициализировать наш линтер:

package properoder

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

func New() *analysis.Analyzer {
  return &analysis.Analyzer{
    Name:     "properorder",
    Doc:      "short documentation about linter",
    Run:      run,
    URL:      "https://documentation.com",
    Requires: []*analysis.Analyzer{inspect.Analyzer},
  }
}

Узнаем, что скрывается под ним:

  • Name – имя линтера.

  • Doc – документация линтера (что делает, для чего и т.д.). Длинным быть не предполагается.

  • Run – функция, в которой содержится логика линтера.

  • URL – опциональное поле, которое содержит ссылку на полную документацию.

  • Requires – некая оптимизация: golangci-lint запустит inspect.Analyzer только один раз для всех анализаторов (наших линтеров). То есть, мы не будем для каждого линтера заново строить AST-дерево.

Как работает run()?

func run(pass *analysis.Pass) (any, error) {
  var lastFile *token.File
  v := validator{
    Pass: pass,
    Stack: stack.New(),
  }

pass *analysis.Pass — объект, содержащий всю информацию о коде (файлы, позиции узлов и т. д.).

validator — структура, которая отвечает за проверки в линтере. В ней есть:

  • Pass — переданный объект analysis.Pass;

  • Stackстек узлов AST, где мы храним, какие структуры и функции уже встретились.

Какие узлы нас интересуют?

nodeTypes := []ast.Node{
  (*ast.FuncDecl)(nil), // func Foo() or func (r *Receiver) Foo()
  (*ast.TypeSpec)(nil), // type smt (e.g. struct, int, array etc)
}

Здесь мы фильтруем AST и говорим, что нас интересуют только объявления типов и функций.

Как run() анализирует код?

При встрече нового узла мы вызываем enterNode(), которая решает, что с ним делать:

enterNode := func(n ast.Node) bool {
  currentFile := pass.Fset.File(n.Pos())

  if lastFile == nil || currentFile != lastFile {
    v.flushStack() // Очистка стека при смене файла
  }
  lastFile = currentFile

Если мы зашли в новый файлочищаем стек. Это важно, чтобы линтер не сравнивал элементы из разных файлов.

Как enterNode() обрабатывает узлы?

switch node := n.(type) {
case *ast.TypeSpec:
  v.CheckTypeSpecPosition(node) // Проверяем, где объявлен тип
  v.Stack.Push(node)             // Добавляем его в стек

case *ast.FuncDecl:
  v.TraverseStack(node)         // Проверяем порядок методов
  v.Stack.Push(node)             // Добавляем метод в стек
}

Если это структура (TypeSpec)проверяем её порядок и кладём в стек.

Если это функция или метод (FuncDecl)проверяем, стоит ли она на своём месте, затем добавляем в стек.

Как линтер проходит по AST?

inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
inspect.WithStack(nodeTypes, func(n ast.Node, push bool, stack []ast.Node) (proceed bool) {
  if push {
    return enterNode(n) // Анализируем узел
  }
  return true
})

WithStack проходит по AST, анализирует только нужные узлы (TypeSpec, FuncDecl) и вызывает enterNode() для их обработки.

push bool означает:

  • true – мы вошли в узел, анализируем его (enterNode(n));

  • false – мы вышли из узла, просто продолжаем обход.

Proceed = true, мы спускаемся глубже в ast, = false, прекращаем обход узла.

В итоге получаем такой код:

func run(pass *analysis.Pass) (any, error) {
  var lastFile *token.File
  v := validator{
    Pass: pass,
    Stack: stack.New(),
  }

  nodeTypes := []ast.Node{
    (*ast.FuncDecl)(nil), // func Foo() or func (r *Receiver) Foo()
    (*ast.TypeSpec)(nil), // type smt (e.g. struct, int, array etc)
  }
  enterNode := func(n ast.Node) bool {
    currentFile := pass.Fset.File(n.Pos())
    if lastFile == nil || currentFile != lastFile {
      v.flushStack()
    }
    lastFile = currentFile

    switch node := n.(type) {
    case *ast.TypeSpec:
      v.CheckTypeSpecPosition(node)
      v.Stack.Push(node)
    case *ast.FuncDecl:
      v.TraverseStack(node)
      v.Stack.Push(node)
    }
    return false
  }

  inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
  inspect.WithStack(nodeTypes, func(n ast.Node, push bool, stack []ast.Node) (proceed bool) {
    if push {
      return enterNode(n)
    }
    return true
  })

  return nil, nil
}

На самом деле именно здесь и заканчивается вся сложность. Почему? Потому что дальше будут лишь проверки корректности. Приступим.

Раз мне не дано вселять любовь, я буду вызывать страх

Кажется, именно так можно описать две ключевые структуры в AST, с которыми работает наш линтер:

  • ast.FuncDecl — узлы, представляющие функции и методы;

  • ast.TypeSpec — узлы, представляющие объявления типов.

Разбираем ast.FuncDecl (функции и методы)

Рассмотрим метод:

func (m Man) MakeItGreatAgain(a int, b float64) (enemy int, err error) {..}

Структура FuncDecl из пакета go/ast выглядит так:

FuncDecl struct {
  Doc  *CommentGroup // associated documentation; or nil
  Recv *FieldList    // receiver (methods); or nil (functions)
  Name *Ident        // function/method name
  Type *FuncType     // function signature: type and value parameters, results, and position of "func" keyword
  Body *BlockStmt    // function body; or nil for external (non-Go) function
}

Визуализация:

На основе этой структуры делаем выводы:

  • если Recv = nil, значит, это обычная функция;

  • если Recv != nil, значит, это метод.

Разбираем ast.TypeSpec (объявления типов)

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

type Person[T any] = struct { 
  Name string
  Age  int  
}

Она в AST представлена как TypeSpec:

TypeSpec struct {
  Doc        *CommentGroup  // associated documentation; or nil
  Name       *Ident         // type name
  TypeParams *FieldList     // type parameters; or nil
  Assign     token.Pos      // position of '=', if any
  Type       Expr           // *Ident, *ParenExpr, *SelectorExpr, *StarExpr, or any of the *XxxTypes
  Comment    *CommentGroup  // line comments; or nil
}

Визуализация:

Что важно для нас:

  • поле Type указывает, к какому типу относится объявление — например, это может быть структура, интерфейс или другой тип;

  • линтер может анализировать Name и Type, чтобы проверять порядок.

Путешествие на запад. Король порядка

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

Как линтер следит за порядком объявления типов? (CheckTypeSpecPosition)

Представим ситуацию

func NewPerson(name string) Person {
  return Person{Name: name}
}

type Person struct {
  Name string
}

Ошибка! Конструктор объявлен раньше типа. Линтер должен это отловить и сообщить об ошибке.

Логика проверки

Посмотреть, что на вершине стека (Stack) находится функция.
Убедиться, что это конструктор (NewPerson).
Проверить, совпадает ли возвращаемый тип с объявленным Person.
Если да — вывести ошибку.

Реализация в коде

func (v *validator) CheckTypeSpecPosition(typeSpec *ast.TypeSpec) {
  if interfaceValue := v.Stack.Peek(); interfaceValue != nil {
    if funcDecl, ok := interfaceValue.(*ast.FuncDecl); ok {
      if isFunc(funcDecl) && isFuncHasResults(funcDecl) {
        result := funcDecl.Type.Results.List[0]
        if isTypeNamedAsExpected(
          unstar(v.Pass.TypesInfo.TypeOf(result.Type)), 
          typeSpec.Name.Name,
        ) {
          v.reportInvalidOrder(result.Pos(), result.Pos(),  
          "the constructor must be positioned after the type is defined.")
          _ = v.Stack.Pop() // Удаляем обработанный элемент из стека
        }
      }
    }
  }
}

Как это работает?

  • Stack.Peek() берёт последний добавленный элемент (конструктор);

  • isFunc(funcDecl) проверяет, что это функция, а не метод;

  • isFuncHasResults(funcDecl) проверяет, что функция что-то возвращает;

  • isTypeNamedAsExpected() сравнивает имя возвращаемого типа с объявленным.

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

Тестируем через комментарии

Мы уже написали большую часть кода и готовы проверить, как работает наш линтер. Но как убедиться, что он действительно находит ошибки?

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

  • мы сразу видим, где должна быть ошибка;

  • можно проверять разные сценарии, просто добавляя новые файлы;

  • тесты не требуют внешних зависимостей — всё хранится прямо в репозитории.

Настройка тестов

Тесты запускаются в файле: analyzers/properorder/analyzer_test.go

package properorder_test

import (
  "testing"

  "gitlab.com/common/linter/analyzers/properorder"
  "gitlab.com/common/linter/internal/tests"
)

func TestAll(t *testing.T) {
  tests.AnalyzeCodeInTestdata(t, properorder.New())
}
  • tests.AnalyzeCodeInTestdata(t, properorder.New()) запускает линтер на тестовых файлах;

  • Если линтер не найдёт ошибку там, где должен, или выдаст ошибку не там — тест провалится.

Добавляем тест с ошибкой

Теперь создадим тестовый файл, который будет содержать ошибки. Файлы с тестами хранятся в analyzers/properorder/testdata/src.go

type Car struct {
  Model string
}

func (c Car) Drive() { // want "the method must be located below the constructor function."
  println("Driving")
}

func NewCar(model string) Car {
  return Car{Model: model}
}
  • комментарий // want "..." указывает, что линтер ДОЛЖЕН здесь выдать ошибку;

  • если линтер не выдаст ошибку, тест упадёт;

  • если ошибка будет в другом месте, тест тоже упадёт.

Результаты запуска тестов

Если тест написан без тега // want

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

=== RUN   TestAll
linter/analyzers/properorder/testdata/src/properorder.go:7:7 
diagnostic the method must be located below the constructor function.
--- FAIL: TestAll (0.02s)

Если тест написан с тегом // want

Линтер нашёл ошибку в нужном месте, тест успешно прошёл:

=== RUN   TestAll
--- PASS: TestAll (0.02s)
PASS

Встраивание ошибок в виде комментариев

Функция AnalyzeCodeInTestdata(t testing.T, analyzer analysis.Analyzer) запускает линтер на тестовых файлах в testdata/src и проверяет, правильно ли он находит ошибки.

Разбор кода

func AnalyzeCodeInTestdata(t *testing.T, analyzer *analysis.Analyzer) {
  wd, err := os.Getwd()
  if err != nil {
    panic(err)
  }

  var printer errsPrinter
  analysistest.Run(&printer, filepath.Join(wd, "testdata/src"), analyzer, "./...")
  if printer.PrintedLines > 0 {
    t.Fail()
  }
}
  • os.Getwd() получает текущую директорию, где выполняется тест;

  • analysistest.Run() запускает линтер на файлах в testdata/src;

  • Если printer.PrintedLines > 0 (есть ошибки), тест проваливается (t.Fail()).

Как ошибки перехватываются?

Функция errsPrinter.Errorf() отвечает за перехват ошибок от линтера и их вывод в консоль.

type errsPrinter struct {
  PrintedLines int
}

func (p *errsPrinter) Errorf(format string, args ...interface{}) {
  fmt.Println(args...) // Вывод ошибки в консоль
  p.PrintedLines++     // Увеличиваем счётчик ошибок
}
  • если линтер нашёл ошибку, он вызывает Errorf();

  • ошибка выводится в консоль (fmt.Println()).

Заключение

В этой части мы разобрали и реализовали основу линтера ProperOrder. Мы шаг за шагом прошли через ключевые аспекты, включая:

  • создание структуры проекта для удобной организации кода линтера;

  • разбор AST и использование стека для отслеживания порядка объявлений;

  • проверку правильного расположения типов, конструкторов и методов.

Что дальше?

Хотя на данном этапе наш линтер уже работает, в следующей части статьи мы:

  • добавим поддержку Golangci-lint без необходимости merge’а в общий репозиторий;

  • разберём, как правильно интегрировать кастомный линтер в CI/CD.

Если вы хотите поэкспериментировать с кодом или доработать линтер под свои нужды — весь код можно найти на GitHub: ProperOrder.

Больше технических новостей из Островка в нашем Telegram-канале.

Теги:
Хабы:
+7
Комментарии3

Публикации

Информация

Сайт
ostrovok.ru
Дата регистрации
Дата основания
2010
Численность
501–1 000 человек
Местоположение
Россия
Представитель
Николай Свиридов