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

от автора

Привет! Меня зовут Артём Блохин, я 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-канале.


ссылка на оригинал статьи https://habr.com/ru/articles/908768/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *