Автоматизация редактора: сниппеты, задачи, свои команды и плагины
В предыдущей статье мы научились быстро находить нужное место в коде. Теперь про обратную половину рабочего цикла: перестать руками делать то, что вы делаете каждый день по двадцать раз.
Тема кажется простой — «напиши плагин» — и почти всегда решается неправильно. Люди пишут расширение там, где хватило бы трёх строк сниппета, и годами копируют текст руками там, где нужен был один скрипт. Ошибка не в навыке программирования, а в отсутствии модели: у автоматизации есть лестница ступеней, и каждая следующая дороже предыдущей в разы — не в стоимости написания, а в стоимости сопровождения.
Лестница автоматизации
Шесть ступеней, каждая со своей ценой владения:
| Ступень | Стоимость создания | Стоимость года владения | Когда правильна |
|---|---|---|---|
| 0. Не автоматизировать | 0 | 0 | действие реже раза в неделю |
| 1. Макрос / повтор | секунды | 0 | одноразовая массовая правка |
| 2. Сниппет | минуты | почти 0 | повторяющийся шаблон кода |
| 3. Внешний фильтр | минуты | почти 0 | преобразование текста, где уже есть CLI |
| 4. Задача / build-конфигурация | десятки минут | низкая | запуск сборки, тестов, генераторов |
| 5. Своя команда в конфиге | час | средняя | связка из 3–5 шагов, специфичная для вас |
| 6. Плагин / расширение | дни | высокая | нужен UI, состояние, интеграция с API редактора |
Правило звучит так: спускайтесь по лестнице до самой нижней ступени, которая решает задачу. Плагин — последний ответ, а не первый.
Записать в список наблюдений"] B -->|чаще| C{"Это разовая
массовая правка?"} C -->|да| M["Макрос, точка-повтор,
:global, multicursor"] C -->|нет| D{"Это шаблон текста
с подстановками?"} D -->|да| S["Сниппет"] D -->|нет| E{"Есть CLI, который
уже это умеет?"} E -->|да| F{"Он преобразует
выделенный текст?"} F -->|да| G["Прогнать выделение через фильтр"] F -->|нет| T["Задача редактора
поверх Makefile / just"] E -->|нет| H{"Логика полезна
вне редактора?"} H -->|да| CLI["Написать маленький CLI,
привязать во всех редакторах"] H -->|нет| I{"Нужны UI, состояние,
API редактора?"} I -->|нет| K["Своя команда в конфиге"] I -->|да| P["Плагин или расширение"]
Сначала посчитайте
Классическая таблица из 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.
Три правила, которые отличают полезные сниппеты от коллекции мусора:
- Сниппеты живут в репозитории проекта, а не только в домашнем конфиге. Каталог
.vscode/*.code-snippets(и аналоги) делает шаблон общим для команды: все пишут тесты и логи одинаково, без ревью-замечаний «у нас принято иначе». - Сниппет должен закрывать соглашение, а не экономить нажатия.
forциклом никто не мучается; мучаются с тем, чтобы вспомнить, какие поля обязательны в структурном логе, как называется фикстура и какой у неё скоуп. - Сниппет детерминирован, ассистент — нет. Там, где важна ровно одна форма записи (шаблон миграции, заголовок лицензии, каркас обработчика), детерминированный шаблон лучше генерации. Про границы применения ассистентов — трек 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 плюс привязка клавиши.
- Разделяйте общее и личное: репозиторий владеет командами и соглашениями, человек — клавишами и темой.
- Автоматизацию нужно удалять так же осознанно, как заводить: раз в полгода ревизия расширений, проверка бюджета старта, снос мёртвого.
Источники
- VS Code Extension API — манифест, вклады, активация, публикация.
- User Defined Snippets — синтаксис табстопов и переменных, общий с LSP.
- Tasks in VS Code — задачи,
problemMatcher, входные параметры. - Extension Bisect — половинчатый поиск сломавшего расширения.
- Neovim API documentation — команды, автокоманды, плагины на Lua.
- Quickfix и errorformat в Vim — разбор вывода сборки в список ошибок.
- YASnippet и LuaSnip — движки сниппетов Emacs и Neovim.
- just — удобный командный раннер, если
Makefileжмёт. - xkcd 1205: Is It Worth the Time? — таблица окупаемости автоматизации.
Что дальше
Мы разобрали всё, что можно настроить программно: поиск, навигацию, шаблоны, задачи, плагины. Осталась часть системы, которую нельзя переустановить и обновить, — ваши руки и физический слой ввода. Именно он переживает все смены редакторов, и именно он ломается первым, если про него не думать. В заключительной статье трека — раскладки и keymap-дизайн, слои программируемых клавиатур, проблема команд в русской раскладке, честные данные про скорость набора и профилактика травм.
Клавиатура и руки: раскладка, keymap-дизайн, задержка ввода и эргономика