Редакторы и IDE Автоматизация редактора: сниппеты, задачи, свои команды и плагины
0%

Автоматизация редактора: сниппеты, задачи, свои команды и плагины

Автоматизация редактора: сниппеты, задачи, свои команды и плагины

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

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

Лестница автоматизации

Лестница автоматизации редактора

Шесть ступеней, каждая со своей ценой владения:

Ступень Стоимость создания Стоимость года владения Когда правильна
0. Не автоматизировать 0 0 действие реже раза в неделю
1. Макрос / повтор секунды 0 одноразовая массовая правка
2. Сниппет минуты почти 0 повторяющийся шаблон кода
3. Внешний фильтр минуты почти 0 преобразование текста, где уже есть CLI
4. Задача / build-конфигурация десятки минут низкая запуск сборки, тестов, генераторов
5. Своя команда в конфиге час средняя связка из 3–5 шагов, специфичная для вас
6. Плагин / расширение дни высокая нужен UI, состояние, интеграция с API редактора

Правило звучит так: спускайтесь по лестнице до самой нижней ступени, которая решает задачу. Плагин — последний ответ, а не первый.

Сначала посчитайте

Классическая таблица из xkcd 1205 «Is It Worth the Time?» даёт верхнюю границу разумных вложений: если операция выполняется 5 раз в день и экономит 5 секунд, за пять лет это около 6 часов — столько и есть бюджет на автоматизацию.

Но у автоматизации редактора есть второй член, который в таблице не учтён, — стоимость сопровождения:

Выгода = (частота × экономия времени × горизонт)
       − стоимость написания
       − стоимость починки после обновлений
       − стоимость отладки, когда оно молча сломалось
       − стоимость чужого удивления, если это в общем конфиге

Третий и четвёртый члены — причина, по которой плагины проигрывают скриптам. Скрипт на 20 строк не ломается от обновления редактора. Расширение ломается: API меняется, движок меняется, зависимости устаревают. Отсюда практический вывод — чем ниже ступень, тем дольше живёт решение.

Ступень 2: сниппеты

Сниппет — это шаблон с точками остановки. Синтаксис, который стал общим, описан в LSP-спецификации и в документации VS Code: $1, $2 — табстопы по порядку, $0 — финальная позиция, ${1:default} — значение по умолчанию, ${1|a,b,c|} — выбор из списка, ${TM_FILENAME_BASE} — переменная.

// .vscode/python.code-snippets — сниппеты уровня проекта, лежат в репозитории
{
  "Тест с параметризацией": {
    "scope": "python",
    "prefix": "tparam",
    "body": [
      "@pytest.mark.parametrize(\"${1:arg}, expected\", [",
      "    (${2:value}, ${3:expected}),",
      "])",
      "def test_${4:name}($1, expected):",
      "    assert ${5:subject}($1) == expected$0"
    ],
    "description": "Параметризованный тест в стиле нашего проекта"
  },
  "Структурный лог": {
    "scope": "python",
    "prefix": "slog",
    "body": [
      "logger.${1|info,warning,error|}(",
      "    \"${2:event_name}\",",
      "    extra={\"request_id\": request_id, \"${3:key}\": ${4:value}},",
      ")$0"
    ]
  }
}
-- LuaSnip для Neovim: то же самое, но со вставкой вычисляемых значений
local ls = require('luasnip')
local s, t, i, f = ls.snippet, ls.text_node, ls.insert_node, ls.function_node

ls.add_snippets('go', {
  s('errw', {
    -- обёртка ошибки с именем текущей функции — типовой шаблон Go
    t('if err != nil {'), t({ '', '\treturn fmt.Errorf("' }), i(1, 'операция'),
    t(': %w", err)'), t({ '', '}' }), i(0),
  }),
  s('hdr', {
    t('// '), f(function() return vim.fn.expand('%:t:r') end, {}),
    t(' — '), i(1, 'назначение файла'), t({ '', '' }), i(0),
  }),
})

В Emacs ту же роль играет YASnippet, в JetBrains — Live Templates с областью применения по языку и контексту, в Sublime — файлы .sublime-snippet.

Три правила, которые отличают полезные сниппеты от коллекции мусора:

  1. Сниппеты живут в репозитории проекта, а не только в домашнем конфиге. Каталог .vscode/*.code-snippets (и аналоги) делает шаблон общим для команды: все пишут тесты и логи одинаково, без ревью-замечаний «у нас принято иначе».
  2. Сниппет должен закрывать соглашение, а не экономить нажатия. for циклом никто не мучается; мучаются с тем, чтобы вспомнить, какие поля обязательны в структурном логе, как называется фикстура и какой у неё скоуп.
  3. Сниппет детерминирован, ассистент — нет. Там, где важна ровно одна форма записи (шаблон миграции, заголовок лицензии, каркас обработчика), детерминированный шаблон лучше генерации. Про границы применения ассистентов — трек AI-агентов.

Ступень 3: внешние фильтры — самая недооценённая

Любой редактор из этого трека умеет прогнать выделенный текст через внешнюю программу и заменить его выводом. Это превращает всю экосистему Unix в набор команд редактирования и почти всегда дешевле плагина.

" Vim/Neovim: ! — фильтр через команду
:'<,'>!jq .                      " отформатировать выделенный JSON
:'<,'>!sort -u                   " отсортировать и убрать дубли
:'<,'>!python3 -c 'import sys,base64;print(base64.b64decode(sys.stdin.read()).decode())'
:'<,'>!sqlformat -r -k upper -   " привести SQL к общему виду
:%!xmllint --format -            " весь буфер через форматтер XML
:'<,'>!column -t -s,             " выровнять CSV в колонки
;; Emacs: то же самое — shell-command-on-region, C-u M-| заменяет регион выводом
;; Обёртка, чтобы не набирать команду каждый раз
(defun my/jq-region (beg end)
  "Прогнать регион через jq и заменить результатом."
  (interactive "r")
  (shell-command-on-region beg end "jq ." nil t "*jq error*" t))
(global-set-key (kbd "C-c j") #'my/jq-region)

В VS Code штатного фильтра нет — это одно из немногих мест, где он проигрывает и Vim, и Emacs, и Sublime («Filter Through Command» есть в Sublime из коробки). Обходной путь — задача с ${selectedText} или расширение-фильтр; но чаще правильный ответ — не тащить данные в редактор вообще, а обработать их в терминале рядом.

Ступень 4: задачи и сборка

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

// .vscode/tasks.json — задача с разбором вывода в проблемы
{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "test: быстрый прогон",
      "type": "shell",
      "command": "make test-fast",
      "group": { "kind": "test", "isDefault": true },
      "presentation": { "reveal": "silent", "panel": "dedicated" },
      "problemMatcher": {
        "owner": "python",
        "fileLocation": ["relative", "${workspaceFolder}"],
        "pattern": {
          // pytest: "path/to/test_x.py:42: AssertionError: текст"
          "regexp": "^(.*):(\\d+):\\s+(\\w+):\\s+(.*)$",
          "file": 1, "line": 2, "severity": 3, "message": 4
        }
      }
    },
    {
      "label": "миграция: создать",
      "type": "shell",
      "command": "alembic revision -m \"${input:migrationName}\"",
      "problemMatcher": []
    }
  ],
  "inputs": [
    { "id": "migrationName", "type": "promptString", "description": "Название миграции" }
  ]
}
" Vim/Neovim: то же самое через makeprg + errorformat, результат — в quickfix
setlocal makeprg=make\ test-fast
setlocal errorformat=%f:%l:\ %t%*[^:]:\ %m
" :make запускает и наполняет quickfix; :copen открывает список
" Механика quickfix подробно разобрана в статье про продвинутый Vim
;; Emacs: compile + свой шаблон разбора ошибок
(setq compile-command "make test-fast")
(add-to-list 'compilation-error-regexp-alist-alist
             '(pytest "^\\(.*\\):\\([0-9]+\\): \\(\\w+\\)" 1 2))
(add-to-list 'compilation-error-regexp-alist 'pytest)

Правило, которое экономит недели: команда живёт в Makefile, justfile или скрипте репозитория; задача редактора её только вызывает. Тогда одно и то же работает у человека с Vim, у человека с IDEA и в CI, а не расползается тремя несовместимыми копиями. Это тот же принцип «золотого пути», что и в платформенной инженерии, и проверяется он тем же способом: команда из README запускается на чистой машине без IDE.

# Makefile — единственный источник истины для команд проекта
.PHONY: test-fast lint fmt migrate

test-fast:            ## быстрые тесты без интеграционных
	pytest -q -m "not integration" --maxfail=1

lint:                 ## то же, что в CI, ни строкой меньше
	ruff check . && mypy src

fmt:
	ruff format .

Ступень 5: своя команда

Следующая ступень — связка из нескольких шагов, оформленная как команда редактора. Здесь уже нужен код, но ещё не нужен плагин.

-- Neovim: команда «прогнать тест под курсором» — три шага в одной клавише
local function nearest_test()
  local file = vim.fn.expand('%:p')
  local line = vim.fn.line('.')
  -- ищем вверх ближайшее определение теста
  for l = line, 1, -1 do
    local text = vim.fn.getline(l)
    local name = text:match('^%s*def (test_[%w_]+)')
    if name then return file .. '::' .. name end
  end
  return file
end

vim.api.nvim_create_user_command('TestNearest', function()
  local target = nearest_test()
  vim.cmd('botright split | terminal pytest -q ' .. vim.fn.shellescape(target))
end, { desc = 'Прогнать ближайший тест' })

vim.keymap.set('n', '<leader>tt', '<cmd>TestNearest<cr>', { desc = 'Тест под курсором' })
;; Emacs: та же идея — интерактивная функция плюс привязка
(defun my/test-nearest ()
  "Запустить ближайший тест сверху от курсора."
  (interactive)
  (save-excursion
    (let ((name (when (re-search-backward "^\\s-*def \\(test_[a-zA-Z0-9_]+\\)" nil t)
                  (match-string 1))))
      (compile (format "pytest -q %s%s"
                       (shell-quote-argument (buffer-file-name))
                       (if name (concat "::" name) ""))))))
(global-set-key (kbd "C-c t") #'my/test-nearest)

Признак, что вы всё ещё на пятой ступени, а не на шестой: код помещается в конфиг, не требует сборки, не хранит состояние между вызовами и не рисует собственный интерфейс.

Ступень 6: свой плагин

Плагин оправдан, когда нужны хотя бы две вещи из списка: собственный UI, состояние между вызовами, реакция на события редактора, интеграция с его API (диагностики, decorations, tree view, статус-бар), распространение на команду через маркетплейс.

Анатомия расширения VS Code

Ключевое, что нужно понять про манифест: всё, что видно пользователю, объявляется декларативно в package.json, а не создаётся кодом. Команда сначала попадает в палитру, и только при её вызове грузится ваш JavaScript. Так работает ленивая активация, о которой шла речь в статье про VS Code.

{
  "name": "team-line-link",
  "displayName": "Ссылка на строку в GitLab",
  "publisher": "acme",
  "version": "0.1.0",
  "engines": { "vscode": "^1.90.0" },
  "main": "./out/extension.js",
  "activationEvents": [],
  "contributes": {
    "commands": [
      { "command": "teamLineLink.copy", "title": "Скопировать ссылку на эту строку" }
    ],
    "keybindings": [
      { "command": "teamLineLink.copy", "key": "ctrl+alt+l", "when": "editorTextFocus" }
    ],
    "configuration": {
      "title": "Team Line Link",
      "properties": {
        "teamLineLink.baseUrl": {
          "type": "string",
          "default": "https://gitlab.acme.internal",
          "description": "Базовый URL самоуправляемого GitLab"
        }
      }
    }
  }
}
// src/extension.ts — минимальное, но полноценное расширение
import * as vscode from 'vscode';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const run = promisify(execFile);

export function activate(context: vscode.ExtensionContext): void {
  // Всё, что регистрируем, кладём в subscriptions — иначе утечёт при перезагрузке окна
  const disposable = vscode.commands.registerCommand('teamLineLink.copy', async () => {
    const editor = vscode.window.activeTextEditor;
    if (!editor) {
      vscode.window.showWarningMessage('Нет активного редактора');
      return;
    }

    const file = editor.document.uri.fsPath;
    const folder = vscode.workspace.getWorkspaceFolder(editor.document.uri);
    if (!folder) { return; }

    const line = editor.selection.active.line + 1;
    const cwd = folder.uri.fsPath;

    try {
      // Логику намеренно держим короткой: тяжёлое место — вызов git, а не наш код
      const { stdout: sha } = await run('git', ['rev-parse', 'HEAD'], { cwd });
      const rel = vscode.workspace.asRelativePath(file, false);
      const base = vscode.workspace
        .getConfiguration('teamLineLink')
        .get<string>('baseUrl', '');
      const project = folder.name;

      const url = `${base}/${project}/-/blob/${sha.trim()}/${rel}#L${line}`;
      await vscode.env.clipboard.writeText(url);
      vscode.window.setStatusBarMessage('Ссылка скопирована', 2000);
    } catch (err) {
      vscode.window.showErrorMessage(`Не удалось построить ссылку: ${String(err)}`);
    }
  });

  context.subscriptions.push(disposable);
}

export function deactivate(): void {
  // Ресурсы освобождает сам context.subscriptions; сюда — только то, что вне его
}
# Сборка и приватная раздача без публикации в маркетплейс
npm install --save-dev @vscode/vsce typescript @types/vscode
npx tsc -p .
npx vsce package                       # получаем team-line-link-0.1.0.vsix
code --install-extension team-line-link-0.1.0.vsix

# Проверка на чистом профиле — обязательный шаг перед раздачей команде
code --extensions-dir /tmp/ext-test --user-data-dir /tmp/usr-test \
     --install-extension team-line-link-0.1.0.vsix

Жизненный цикл вызова

Neovim и Emacs: тот же плагин дешевле

-- lua/team-line-link/init.lua — плагин Neovim: модуль, команда, привязка
local M = {}

function M.copy_link(opts)
  opts = opts or {}
  local base = opts.base_url or vim.g.team_line_link_base or 'https://gitlab.acme.internal'
  local sha = vim.trim(vim.fn.system({ 'git', 'rev-parse', 'HEAD' }))
  if vim.v.shell_error ~= 0 then
    vim.notify('Не репозиторий git', vim.log.levels.WARN)
    return
  end
  local root = vim.trim(vim.fn.system({ 'git', 'rev-parse', '--show-toplevel' }))
  local rel = vim.fn.expand('%:p'):sub(#root + 2)
  local url = ('%s/%s/-/blob/%s/%s#L%d'):format(base, vim.fn.fnamemodify(root, ':t'),
                                                sha, rel, vim.fn.line('.'))
  vim.fn.setreg('+', url)
  vim.notify('Ссылка скопирована')
end

function M.setup(opts)
  vim.api.nvim_create_user_command('CopyLineLink', function() M.copy_link(opts) end, {})
  vim.keymap.set('n', '<leader>gl', M.copy_link, { desc = 'Ссылка на строку' })
end

return M

Разница в стоимости показательна: в Neovim и Emacs плагин — это файл в конфиге, который работает сразу, без сборки, публикации и версии API. В VS Code — проект на TypeScript, сборка, упаковка и совместимость с engines.vscode. Это не аргумент за Vim; это аргумент за то, чтобы не писать расширение VS Code там, где хватает задачи или CLI.

Правило «логика в CLI, редактор — обёртка»

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

#!/usr/bin/env bash
# bin/line-link — печатает ссылку на строку; работает везде, где есть bash и git
set -euo pipefail

file="${1:?нужен путь к файлу}"
line="${2:-1}"
base="${LINE_LINK_BASE:-https://gitlab.acme.internal}"

root=$(git -C "$(dirname "$file")" rev-parse --show-toplevel)
sha=$(git -C "$root" rev-parse HEAD)
rel=${file#"$root"/}
printf '%s/%s/-/blob/%s/%s#L%s\n' "$base" "$(basename "$root")" "$sha" "$rel" "$line"
" Vim: одна строка вместо плагина
nnoremap <leader>gl :let @+ = system('bin/line-link ' . expand('%:p') . ' ' . line('.'))<CR>
// VS Code: задача вместо расширения
{ "label": "Ссылка на строку", "type": "shell",
  "command": "bin/line-link ${file} ${lineNumber} | pbcopy", "problemMatcher": [] }

Что вы получаете, отдав логику в CLI:

  • работает у коллеги с другим редактором — без переписывания;
  • работает в CI и в скриптах — без запуска редактора;
  • тестируется обычными тестами, а не «руками в интерфейсе»;
  • переживает смену редактора, которая рано или поздно случится;
  • ревьюится в общем PR, а не живёт в чужих личных dotfiles.

Это тот же принцип, что и с .editorconfig и форматтерами из статьи про сравнение: договорённость живёт в репозитории, а редактор остаётся сменной деталью.

Раздать команде, не сломав людям день

// .vscode/extensions.json — рекомендации, а не принуждение
{
  "recommendations": [
    "acme.team-line-link",
    "charliermarsh.ruff",
    "ms-python.python"
  ],
  "unwantedRecommendations": ["ms-python.autopep8"]
}
// .vscode/settings.json — только то, что действительно общее для проекта
{
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "charliermarsh.ruff",
  "python.testing.pytestEnabled": true,
  "files.trimTrailingWhitespace": true
  // Тем и шрифтов здесь быть не должно: это личное, и навязывание вызывает саботаж
}

Разделение, которое стоит держать в голове:

Уровень Что сюда кладут Кто владеет
Репозиторий проекта Makefile, .editorconfig, сниппеты проекта, рекомендации расширений, задачи команда, через ревью
Профиль/dotfiles человека клавиши, тема, шрифт, личные команды, менеджер плагинов сам человек
Организация внутренний реестр расширений, devcontainer-образы, политики безопасности платформенная команда

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

Обслуживание, деградация и риски

Раз в полгода полезно проходить по установленным расширениям и удалять то, что не использовалось. Инструменты для этого есть штатные:

# VS Code: что вообще стоит и сколько весит
code --list-extensions --show-versions

# Кто тормозит запуск: команда "Developer: Startup Performance" в палитре
# Кто ломает поведение: Extension Bisect — половинчатый поиск виновника
#   палитра → "Help: Start Extension Bisect"

# Запуск без расширений — первый шаг любой диагностики
code --disable-extensions
# Neovim: бюджет старта по плагинам
nvim --startuptime /tmp/start.log +q && sort -k2 -rn /tmp/start.log | head -20

# Emacs: профиль загрузки пакетов
emacs -Q --eval '(progn (require (quote benchmark-init)) (benchmark-init/show-durations-tree))'

Безопасность. Расширение выполняется с вашими правами: читает исходники, переменные окружения, SSH-ключи, ходит в сеть. Маркетплейсы модерируются слабо, имена издателей подделываются, а популярные расширения перекупаются. Минимальная гигиена: ставить только то, что реально нужно; смотреть на издателя и репозиторий; не ставить расширения с доступом к секретам «на попробовать»; в организациях — держать внутренний реестр разрешённых. Модель угроз для зависимостей одинакова для npm-пакетов и расширений редактора и разобрана в статье про безопасность цепочки поставок.

Типичные ошибки

  • Плагин вместо сниппета. Три дня работы и вечная поддержка ради того, что решалось десятью строками JSON.
  • Автоматизация редкого. Скрипт для операции раз в квартал: к следующему разу вы забудете, как он называется, и он всё равно сломается.
  • Логика внутри редактора. Как только это понадобится в CI или коллеге на другом редакторе, придётся переписывать с нуля.
  • Задача без problemMatcher. Вывод есть, кликабельных ошибок нет — и человек снова ищет строку глазами.
  • Общий конфиг с личными вкусами. Тема и клавиши в .vscode/settings.json — самый быстрый способ поссориться с командой.
  • Молчаливые сбои. Автоматизация, которая при ошибке ничего не показывает, хуже её отсутствия: вы уверены, что действие выполнено.
  • Форк вместо конфигурации. Копия чужого плагина «с одной правкой» превращается в вечный технический долг; сначала ищите точку расширения или отправляйте PR.
  • Ноль расширений как принцип. Обратная крайность: отказ от форматтера и линтера ради «чистоты» перекладывает работу на ревью, где она стоит дороже в разы.

Мини-итог

  • У автоматизации есть лестница: не автоматизировать → макрос → сниппет → фильтр → задача → своя команда → плагин. Правильный ответ — самая нижняя ступень, которая решает задачу.
  • Считайте не только время написания, но и стоимость сопровождения: она и определяет, почему скрипты живут годами, а расширения ломаются на каждом мажорном обновлении.
  • Сниппеты закрепляют соглашения команды и потому должны лежать в репозитории проекта, а не только в личном конфиге.
  • Прогон текста через внешнюю программу — самый недооценённый механизм: вся экосистема CLI становится набором команд редактирования.
  • Задачи редактора должны быть тонкой обёрткой над Makefile, иначе одно и то же расползается тремя несовместимыми копиями и не работает в CI.
  • Расширение оправдано только при потребности в UI, состоянии или событиях редактора; во всех остальных случаях выигрывает маленький CLI плюс привязка клавиши.
  • Разделяйте общее и личное: репозиторий владеет командами и соглашениями, человек — клавишами и темой.
  • Автоматизацию нужно удалять так же осознанно, как заводить: раз в полгода ревизия расширений, проверка бюджета старта, снос мёртвого.

Источники

Что дальше

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

Клавиатура и руки: раскладка, keymap-дизайн, задержка ввода и эргономика

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов
Дальше