Gopls: Возможности преобразования кода

В данном документе описаны возможности gopls по преобразованию кода, включая диапазон изменений, сохраняющих поведение (рефакторинг, форматирование, упрощения), исправления кода (фиксы), а также поддержку редактирования (заполнение литералов структур и инструкций switch).

Преобразования кода не являются одной категорией в LSP:

  • Некоторые, такие как Formatting (форматирование) и Rename (переименование), являются основными операциями в протоколе.
  • Некоторые преобразования доступны через Code Lenses, которые возвращают команды — произвольные операции сервера, вызываемые по их побочным эффектам через запрос workspace/executeCommand; однако, на данный момент ни одна из существующих code lenses не является преобразованием синтаксиса Go.
  • Большинство преобразований определяются как code actions.

Code Actions

Code action — это действие, связанное с определённой частью файла. Каждый раз, когда изменяется выделение, типичный клиент делает запрос textDocument/codeAction для получения набора доступных действий, а затем обновляет элементы пользовательского интерфейса (меню, значки, всплывающие подсказки), чтобы отразить их. Руководство VS Code описывает code actions как “Quick fixes + Refactorings”.

Запрос codeAction предоставляет меню, так сказать, но не заказывает еду. После того как пользователь выбирает действие, происходит одно из двух. В тривиальных случаях действие само содержит правку, которую клиент может напрямую применить к файлу. Но в большинстве случаев действие содержит команду, аналогичную команде, связанной с code lens. Это позволяет вычислять патч лениво, только тогда, когда он действительно нужен. (Большинство не нуждаются.) Затем сервер может вычислить правку и отправить клиенту запрос workspace/applyEdit для внесения изменений в файлы. Не все команды code actions имеют побочный эффект applyEdit: некоторые могут изменять состояние сервера, например, переключать переменную или заставлять сервер отправлять другие запросы клиенту, например, запрос showDocument для открытия отчета в веб-браузере.

Основное различие между code lenses и code actions заключается в следующем:

  • запрос codeLens получает команды для всего файла. Каждая команда указывает свой диапазон исходного кода, и обычно отображается как аннотация в этом диапазоне.
  • запрос codeAction получает команды только для определенного диапазона: текущего выделения. Все команды представлены вместе в меню в этой позиции.

Каждое действие имеет тип, который представляет собой иерархический идентификатор, такой как refactor.inline.call. Клиенты могут фильтровать действия на основе их типа. Например, VS Code имеет: два меню, «Refactor…» и «Source action…», каждое из которых заполняется разными типами кодовых действий (refactor и source); иконку лампочки, которая вызывает меню «quick fixes» (типа quickfix); и команду «Fix All», которая выполняет все кодовые действия типа source.fixAll, которые считаются однозначно безопасными для применения.

Gopls поддерживает следующие кодовые действия:

Gopls сообщает о некоторых действиях с кодом дважды, с двумя разными типами, чтобы они отображались в нескольких элементах пользовательского интерфейса: упрощения, например, из for _ = range m в for range m, имеют типы quickfix и source.fixAll, поэтому они появляются в меню «Быстрое исправление» и активируются командой «Исправить всё».

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

Особенности:

Поддержка действий с кодом в клиентах:

Форматирование

Запрос LSP textDocument/formatting возвращает правки, которые форматируют файл. Gopls применяет канонический алгоритм форматирования Go, go fmt. Параметры форматирования LSP игнорируются.

Большинство клиентов настроены на форматирование файлов и организацию импортов при каждом сохранении файла.

Настройки:

Поддержка клиентами:

source.organizeImports: Организация импортов

Запрос codeActions в файле, где импорты не организованы, возвращает действие стандартного типа source.organizeImports. Его команда выполняет организацию импортов: удаляет существующие импорты, которые являются дубликатами или неиспользуемыми, добавляет новые для неопределённых символов, и сортирует их в обычном порядке.

Добавление новых импортов основано на эвристике, зависящей от вашего рабочего пространства и содержимого каталога GOMODCACHE; они могут иногда делать неожиданный выбор.

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

Некоторые пользователи не любят автоматическое удаление неиспользуемых импортов, потому что, например, единственная строка, ссылающаяся на импорт, временно закомментирована для отладки; см. https://go.dev/issue/54362.

Настройки:

Поддержка клиентов:

source.addTest: Добавить тест для функции или метода

Если выделенный фрагмент кода является частью объявления функции или метода F, gopls предложит код-действие «Add test for F», которое добавляет новый тест для выбранной функции в соответствующий файл _test.go. Сгенерированный тест учитывает её сигнатуру, включая входные параметры и результаты.

Тестовый файл: если файл _test.go не существует, gopls создаёт его, используя имя текущего файла (a.goa_test.go), копируя любые комментарии с copyright и ограничениями сборки из оригинального файла.

Тестовый пакет: для новых файлов, которые тестируют код в пакете p, тестовый файл использует имя пакета p_test, если это возможно, чтобы поощрить тестирование только экспортируемых функций. (Если тестовый файл уже существует, новый тест добавляется в этот файл.)

Параметры: каждый из непустых параметров функции становится элементом структуры, используемой для таблицы тестов. (Для каждого пустого параметра _ значение не имеет эффекта, поэтому тест предоставляет аргумент с нулевым значением.)

Контексты: если первый параметр — context.Context, тест передаёт context.Background().

Результаты: результаты функции присваиваются переменным (got, got2, и так далее) и сравниваются с ожидаемыми значениями (want, want2, и т.д.) определёнными в структуре тестового случая. Пользователь должен отредактировать логику для выполнения соответствующего сравнения. Если финальный результат — error, тестовый случай определяет булеву переменную wantErr.

Получатели методов: при тестировании метода T.F или (*T).F, тест должен создать экземпляр T для передачи в качестве получателя. Gopls ищет в пакете подходящую функцию, которая создаёт значение типа T или *T, возможно с ошибкой, предпочитая функцию с именем NewT.

Импорты: Gopls добавляет недостающие импорты в тестовый файл, используя последний соответствующий спецификатор импорта из оригинального файла. Он избегает дублирования импортов, сохраняя все существующие импорты в тестовом файле.

Переименование

Запрос LSP textDocument/rename переименовывает символ.

Переименование происходит в два этапа. Первый этап — это prepareRename запрос, который возвращает текущее имя идентификатора под курсором (если он действительно существует). Клиент затем отображает диалог, предлагающий пользователю выбрать новое имя, отредактировав старое. Второй этап — это сам процесс rename, который применяет изменения. (Поддержка простого диалога уникальна среди операций рефакторинга в LSP; см. microsoft/language-server-protocol#1164.)

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

В качестве другого примера рассмотрим переименование метода конкретного типа. Переименование может привести к тому, что тип больше не будет удовлетворять тем же интерфейсам, что и раньше, что может вызвать ошибку компиляции программы. Чтобы избежать этого, gopls проверяет каждое преобразование (явное или неявное) от затронутого типа к типу интерфейса и проверяет, останется ли оно корректным после переименования. Если нет, переименование прерывается с ошибкой.

Если вы намерены переименовать исходный метод и соответствующие методы любых совпадающих типов интерфейсов (а также методы типов, совпадающих с ними), вы можете указать это, вызвав операцию переименования на методе интерфейса.

Аналогично, gopls сообщит об ошибке, если вы переименуете поле структуры, которое является «анонимным» полем, встраиваемым в тип, поскольку это потребовало бы более широкого переименования, включающего и сам тип. Если это именно то, что вы хотите, вы можете снова указать это, вызвав операцию переименования на типе.

Переименование не должно вводить ошибку компиляции, но может привести к динамическим ошибкам. Например, при переименовании метода, если нет прямого преобразования затронутого типа в тип интерфейса, но есть промежуточное преобразование в более широкий тип (например, any), за которым следует утверждение типа к типу интерфейса, то gopls может продолжить переименование метода, что приведёт к сбою утверждения типа во время выполнения. Подобные проблемы могут возникнуть в пакетах, использующих рефлексию, таких как encoding/json или text/template. Нет замены хорошему здравому смыслу и тестированию.

Особые случаи:

Некоторые советы для достижения наилучших результатов:

Для получения подробностей о работе алгоритма переименования gopls, вы можете обратиться ко второй части выступления на конференции GothamGo 2015: Using go/types for Code Comprehension and Refactoring Tools.

Поддержка клиентов:

refactor.extract: Извлечение функции/метода/переменной

Семейство действий с кодом refactor.extract возвращает команды, которые заменяют выбранные выражения или инструкции ссылкой на новое объявление, содержащее выбранный код:

Извлечение — это сложная задача, требующая учета области видимости искажений (shadowing), управления потоком выполнения, например break/continue в цикле или return в функции, количества переменных и даже тонких аспектов стиля. В каждом случае инструмент будет пытаться обновить извлеченные инструкции, чтобы избежать нарушения сборки или изменения поведения. К сожалению, алгоритмы Extract в gopls значительно менее строгие, чем операции Переименование и Встраивание, и мы знаем о нескольких случаях, где они не справляются, включая:

Следующие функции Extract запланированы на 2024 год, но пока не поддерживаются:

refactor.extract.toNewFile: Извлечение объявлений в новый файл

(Доступно начиная с gopls/v0.17.0)

Если вы выберете одно или несколько объявлений на верхнем уровне, gopls предложит действие «Извлечь объявления в новый файл», которое переместит выбранные объявления в новый файл, имя которого основано на первом объявленном символе. Объявления импортов создаются по мере необходимости. Gopls также предлагает это действие, когда выделение состоит только из первого токена объявления, например func или type.

До: выберите объявления для перемещения После: новый файл основан на имени первого символа

refactor.inline.call: Встраивание вызова функции

Для запроса codeActions, где выделение (или находится внутри) вызова функции или метода, gopls вернёт команду типа refactor.inline.call, действие которой — встроить вызов функции.

Скриншоты ниже показывают вызов sum до и после встраивания:

До: выберите Refactor… Inline call to sum После: вызов был заменён на логику суммирования

Встраивание заменяет выражение вызова копией тела функции, где параметры заменяются аргументами. Встраивание полезно по ряду причин. Возможно, вы хотите устранить вызов устаревшей функции, такой как ioutil.ReadFile, заменив её вызовом более новой os.ReadFile; встраивание сделает это за вас. Или, возможно, вы хотите скопировать и изменить существующую функцию каким-либо образом; встраивание может стать отправной точкой. Логика встраивания также предоставляет строительный блок для других рефакторингов, таких как «изменение сигнатуры».

Не каждая функция может быть встроена (inlined). Конечно, инструменту нужно знать, какая функция вызывается, поэтому нельзя встроить динамический вызов через значение функции или метод интерфейса; однако статические вызовы методов допустимы. Также нельзя встроить вызов, если вызываемая функция объявлена в другом пакете и ссылается на неэкспортируемые части этого пакета или на внутренние пакеты, недоступные для вызывающего. Вызовы дженерик-функций пока не поддерживаются (https://go.dev/issue/63352), хотя планируется это исправить.

Когда встраивание возможно, очень важно, чтобы инструмент сохранял исходное поведение программы. Мы не хотим, чтобы рефакторинг ломал сборку, или ещё хуже — вводил скрытые ошибки. Это особенно важно, когда инструменты встраивания используются для автоматической очистки больших кодовых баз; мы должны иметь возможность доверять этим инструментам. Наш инлайнер очень осторожен и не делает предположений или небезопасных допущений о поведении кода. Однако это означает, что он иногда производит изменения, которые отличаются от того, что кто-то с экспертными знаниями того же кода мог бы написать вручную.

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

<code class="language-go">func f(s string) {
  defer fmt.Println("goodbye")
  fmt.Println(s)
}
</code>

то вызов f("hello") будет встроен в:

<code class="language-go">    func() {
  defer fmt.Println("goodbye")
  fmt.Println("hello")
}()
</code>

Хотя параметр был устранён, вызов функции остаётся.

Инлайнер похож на оптимизирующий компилятор. Компилятор считается «корректным», если он не изменяет смысл программы при переводе из исходного языка в целевой. Оптимизирующий компилятор использует особенности входных данных для генерации более эффективного кода, где «более эффективный» обычно означает более производительный. По мере того как пользователи сообщают о случаях, при которых компилятор генерирует неоптимальный код, компилятор улучшается, чтобы распознавать больше случаев, больше правил и больше исключений из правил — но этот процесс не имеет конца. Встраивание похоже на это, но «лучший» код означает более чистый и лаконичный. Наиболее консервативное преобразование обеспечивает простую, но (надеемся) корректную основу, на которой можно накладывать бесконечное количество правил и исключений, чтобы улучшить качество вывода.

Вот некоторые технические сложности, связанные с безопасным встраиванием:

Это всего лишь небольшой взгляд на проблемную область. Если интересно, документация для golang.org/x/tools/internal/refactor/inline содержит больше подробностей. Всё это говорит о том, что это сложная задача, и мы стремимся к правильности в первую очередь. Мы уже реализовали целый ряд важных «оптимизаций упорядоченности» и ожидаем, что их будет ещё больше.

refactor.inline.variable: Встроить локальную переменную

Для запроса codeActions, где выделение (или находится внутри) идентификатора, который является использованием локальной переменной, объявление которой имеет выражение инициализации, gopls вернёт код-действие типа refactor.inline.variable, действие которого заключается во встраивании переменной: то есть замене ссылки на выражение инициализации переменной.

Например, если вызвать это действие на идентификаторе s в вызове println(s):

<code class="language-go">func f(x int) {
  s := fmt.Sprintf("+%d", x)
  println(s)
}
</code>

код-действие преобразует код следующим образом:

<code class="language-go">func f(x int) {
  s := fmt.Sprintf("+%d", x)
  println(fmt.Sprintf("+%d", x))
}
</code>

(В этом случае s становится неиспользуемой переменной, которую вам нужно будет удалить.)

Код-действие всегда заменяет ссылку выражением инициализации, даже если позже происходит присваивание переменной (например, s = "").

Код-действие сообщает об ошибке, если невозможно выполнить преобразование, потому что один из идентификаторов внутри выражения инициализации (например, x в приведённом выше примере) скрыт объявлениями между ними, как в следующем примере:

<code class="language-go">func f(x int) {
  s := fmt.Sprintf("+%d", x)
  {
    x := 123
    println(s, x) // error: cannot replace s with fmt.Sprintf(...) since x is shadowed
  }
}
</code>

refactor.rewrite: Различные перезаписи

В этом разделе рассматриваются различные преобразования, доступные как код-действия, типы которых являются дочерними по отношению к refactor.rewrite.

refactor.rewrite.removeUnusedParam: Удалить неиспользуемый параметр

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

<code class="language-go">func f(x, y int) { // "unused parameter: x"
fmt.Println(y)
}
</code>

Он не сообщает диагностику для функций, адрес которых был взят, поскольку они могут требовать всех своих параметров, даже неиспользуемых, чтобы соответствовать конкретной сигнатуре функции. Также он не сообщает диагностику для экспортированных функций, которые могут быть использованы из другого пакета. (Функция считается имеющей взятый адрес, если она используется не в позиции вызова, f(...).)

Помимо диагностики, предлагается два возможных исправления:

  1. переименовать параметр в _, чтобы подчеркнуть, что он не используется (непосредственное редактирование); или
  2. полностью удалить параметр с помощью команды ChangeSignature, обновив все вызывающие функции.

Исправление №2 использует ту же самую инфраструктуру, что и «Inline function call» (см. выше), чтобы гарантировать сохранение поведения всех существующих вызовов, даже если выражение аргумента для удалённого параметра имеет побочные эффекты, как показано в примере ниже.

Параметр x не используется Параметр x был удалён

Обратите внимание, что в первом вызове аргумент chargeCreditCard() не был удалён из-за потенциальных побочных эффектов, тогда как во втором вызове аргумент 2, являющийся константой, был безопасно удалён.

refactor.rewrite.moveParam{Left,Right}: Перемещение параметров функции

Когда выделен параметр в сигнатуре функции или метода, gopls предлагает код-действие для перемещения параметра влево или вправо (если это возможно), при этом автоматически обновляются все вызывающие функции.

Например:

<code class="language-go">func Foo(x, y int) int {
  return x + y
}
func _() {
  _ = Foo(0, 1)
}
</code>

превращается в

<code class="language-go">func Foo(y, x int) int {
  return x + y
}
func _() {
  _ = Foo(1, 0)
}
</code>

после запроса на перемещение x вправо или y влево.

Это простой строительный блок более обобщённых операций «Change signature» (изменение сигнатуры). Мы планируем расширить его до произвольной перезаписи сигнатуры, но протокол сервера языка в настоящее время не предоставляет хорошей поддержки для ввода данных пользователем в рамках операций рефакторинга (см. microsoft/language-server-protocol#1164). Поэтому любая такая операция рефакторинга потребует пользовательской клиентской логики. (Как очень хакерское решение, можно выразить произвольное перемещение параметров, вызвав Rename на ключевом слове func объявления функции, но этот интерфейс — лишь временное решение.)

refactor.rewrite.changeQuote: Преобразование строкового литерала между необработанным и интерпретированным форматами

Когда выделен строковой литерал, gopls предлагает код-действие для преобразования строки между необработанной формой (`abc`) и интерпретированной формой ("abc"), когда это возможно:

Преобразовать в интерпретированную Преобразовать в необработанную

Применение действия с кодом во второй раз возвращает исходную форму.

refactor.rewrite.invertIf: Инвертировать условие 'if'

Если выделение находится внутри инструкции if/else, которая не сопровождается else if, то gopls предлагает действие с кодом для инвертирования инструкции, отрицая условие и меняя местами блоки if и else.

До изменения “Invert if condition” После изменения “Invert if condition”

refactor.rewrite.{split,join}Lines: Разбить элементы по отдельным строкам

Если выделение находится внутри списка элементов в скобках, такого как:

то gopls предлагает действие с кодом “Разбить [элементы] по отдельным строкам”, которое преобразует приведённые выше формы в следующие:

<code class="language-go">[]T{
  a,
  b,
  c,
}
f(
  a,
  b,
  c,
)
func(
  a, b, c int,
  d, e bool,
)
func() (
  x, y string,
  z rune,
)
</code>

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

Противоположное действие с кодом, “Объединить [элементы] в одну строку”, отменяет операцию. Ни одно из действий не предлагается, если список уже полностью разбит или объединён, либо является тривиальным (менее двух элементов).

Эти действия с кодом не предлагаются для списков, содержащих комментарии в стиле //, которые простираются до конца строки.

refactor.rewrite.fillStruct: Заполнить литерал структуры

Если курсор находится внутри литерала структуры S{}, то gopls предлагает действие с кодом “Заполнить S”, которое заполняет каждый отсутствующий поле литерала, доступный для записи.

Оно использует следующий эвристический подход для выбора значения, назначенного каждому полю: находит кандидатов в переменные, константы и функции, которым можно назначить поле, и выбирает тот, чьё имя наиболее близко к имени поля. Если таких нет, то используется нулевое значение (например, 0, "" или nil) типа поля.

В приведённом ниже примере литерал структуры slog.HandlerOptions заполняется с использованием двух локальных переменных (level и add) и функции (replace):

Before “Fill slog.HandlerOptions” After “Fill slog.HandlerOptions”

Особенности:

refactor.rewrite.fillSwitch: Fill switch

Когда курсор находится внутри инструкции switch, тип операнда которой является перечислением (конечным набором именованных констант), или внутри инструкции типа switch, gopls предлагает действие «Add cases for T» (Добавить случаи для T), которое заполняет инструкцию switch путём добавления случая для каждой доступной именованной константы типа перечисления, или, для инструкции типа switch, добавления случая для каждого доступного именованного не-интерфейсного типа, реализующего интерфейс. Добавляются только отсутствующие случаи.

Снимки экрана ниже показывают инструкцию типа switch, операнд которой имеет тип интерфейса net.Addr. Действие добавляет один случай для каждого конкретного типа сетевого адреса, а также стандартный случай, вызывающий panic с информативным сообщением, если встречается неожиданный операнд.

Before “Add cases for Addr” After “Add cases for Addr”

А эти снимки экрана иллюстрируют действие, которое добавляет случаи для каждого значения типа перечисления html.TokenType, представляющего различные типы токенов, из которых состоят HTML-документы:

Before “Add cases for Addr” After “Add cases for Addr”

refactor.rewrite.eliminateDotImport: Eliminate dot import

Когда курсор находится на точечном импорте, gopls может предложить действие «Eliminate dot import» (Удалить точечный импорт), которое удаляет точку из импорта и добавляет квалификацию использования пакета во всем файле. Это действие предлагается только в том случае, если каждое использование пакета может быть квалифицировано без конфликтов с существующими именами.

refactor.rewrite.addTags: Добавить теги структуры

Когда курсор находится внутри структуры, это действие рефакторинга добавляет каждому полю json тег структуры, который указывает имя поля в JSON, используя строчные буквы с подчеркиваниями (например, LinkTarget превращается в link_target). Для выделенного фрагмента кода, действие добавляет теги только на выделенные поля.

refactor.rewrite.removeTags: Удалить теги структуры

Когда курсор находится внутри структуры, это действие рефакторинга очищает теги структуры у всех полей структуры. Для выделенного фрагмента кода, действие удаляет теги только с выделенных полей.


Исходные файлы для этой документации можно найти в golang.org/x/tools/gopls/doc.

GoRu.dev Golang на русском

На сайте представлена адаптированная под русский язык документация языка программирования Golang