Git, formateo, linting y debugging
Cierra el IDE: gitsigns y lazygit para Git, conform.nvim para formatear al guardar, nvim-lint para linters, y nvim-dap para depurar con breakpoints.
Un IDE no es solo LSP. Es también ver los cambios de Git en el margen, formatear al guardar, avisar de problemas de estilo y poner breakpoints. Vamos a montar esas cuatro piezas.
- Git integrado: gitsigns (hunks en el margen) y lazygit (TUI completa).
- Formateo automático al guardar con conform.nvim.
- Linting con nvim-lint.
- Debugging real con nvim-dap y su UI.
Git dentro de Neovim
gitsigns: el margen que lo cuenta todo
Muestra líneas añadidas, modificadas y borradas en la columna de signos, y permite hacer stage, reset y ver el blame sin salir del buffer.
return {
"lewis6991/gitsigns.nvim",
event = { "BufReadPre", "BufNewFile" },
opts = {
-- Los seis signos. Si dejas alguno vacío, ese cambio es INVISIBLE:
-- una línea borrada sin signo no existe para ti.
signs = {
add = { text = "▎" },
change = { text = "▎" },
delete = { text = "_" },
topdelete = { text = "‾" }, -- borrado en la primera línea
changedelete = { text = "~" }, -- cambiada Y con líneas borradas
untracked = { text = "┆" },
},
on_attach = function(bufnr)
local gs = require("gitsigns")
local map = function(modo, k, fn, desc)
vim.keymap.set(modo, k, fn, { buffer = bufnr, desc = desc })
end
-- Saltos repetibles con ; y , (lección 2.3). Si no tienes el plugin
-- de textobjects, el pcall deja los saltos normales y no rompe nada.
local ok, rep = pcall(require, "nvim-treesitter-textobjects.repeatable_move")
local ir = ok
and rep.make_repeatable_move(function(o) gs.nav_hunk(o.forward and "next" or "prev") end)
or function(o) gs.nav_hunk(o.forward and "next" or "prev") end
map("n", "]h", function() ir({ forward = true }) end, "Siguiente hunk")
map("n", "[h", function() ir({ forward = false }) end, "Hunk anterior")
map("n", "]H", function() gs.nav_hunk("last") end, "Último hunk")
map("n", "[H", function() gs.nav_hunk("first") end, "Primer hunk")
-- OJO con el rango en Visual: sin él, seleccionas tres líneas y
-- gitsigns hace stage del hunk ENTERO. Falla en silencio.
map("n", "<leader>hs", gs.stage_hunk, "Stage hunk")
map("n", "<leader>hr", gs.reset_hunk, "Reset hunk")
map("x", "<leader>hs", function()
gs.stage_hunk({ vim.fn.line("."), vim.fn.line("v") })
end, "Stage de la selección")
map("x", "<leader>hr", function()
gs.reset_hunk({ vim.fn.line("."), vim.fn.line("v") })
end, "Reset de la selección")
map("n", "<leader>hp", gs.preview_hunk, "Previsualizar hunk")
map("n", "<leader>hi", gs.preview_hunk_inline, "Previsualizar en línea")
map("n", "<leader>hb", function() gs.blame_line({ full = true }) end, "Blame de la línea")
map("n", "<leader>hB", function() gs.blame() end, "Blame del archivo")
end,
},
}vim.pack.add({ { src = "https://github.com/lewis6991/gitsigns.nvim" } })
require("gitsigns").setup({
signs = {
add = { text = "▎" },
change = { text = "▎" },
delete = { text = "_" },
topdelete = { text = "‾" },
changedelete = { text = "~" },
untracked = { text = "┆" },
},
on_attach = function(bufnr)
local gs = require("gitsigns")
local map = function(modo, k, fn, desc)
vim.keymap.set(modo, k, fn, { buffer = bufnr, desc = desc })
end
local ok, rep = pcall(require, "nvim-treesitter-textobjects.repeatable_move")
local ir = ok
and rep.make_repeatable_move(function(o) gs.nav_hunk(o.forward and "next" or "prev") end)
or function(o) gs.nav_hunk(o.forward and "next" or "prev") end
map("n", "]h", function() ir({ forward = true }) end, "Siguiente hunk")
map("n", "[h", function() ir({ forward = false }) end, "Hunk anterior")
map("n", "<leader>hs", gs.stage_hunk, "Stage hunk")
map("n", "<leader>hr", gs.reset_hunk, "Reset hunk")
map("x", "<leader>hs", function()
gs.stage_hunk({ vim.fn.line("."), vim.fn.line("v") })
end, "Stage de la selección")
map("x", "<leader>hr", function()
gs.reset_hunk({ vim.fn.line("."), vim.fn.line("v") })
end, "Reset de la selección")
map("n", "<leader>hp", gs.preview_hunk, "Previsualizar hunk")
map("n", "<leader>hb", function() gs.blame_line({ full = true }) end, "Blame de la línea")
end,
})El callback de on_attach recibe el número de buffer, así que los atajos quedan locales al buffer versionado.
Casi todo lo escrito antes de 2025 usa gs.next_hunk() y gs.prev_hunk(). La documentación oficial de gitsigns los marca como deprecados a favor de gs.nav_hunk("next") y gs.nav_hunk("prev"), que además aceptan "first" y "last". En la misma lista de deprecados está undo_stage_hunk: hoy se deshace llamando a stage_hunk sobre un signo ya en stage. Y ojo con preview_hunk: no recibe argumentos; la variante flotante en línea es preview_hunk_inline.
El objeto de texto “un hunk”, y por qué no lo mapeamos a ih
Falta una pieza obvia: poder decir “borra este hunk” con dih. gitsigns trae la función (select_hunk) pero no la mapea, así que la tecla la eliges tú — y aquí hay una trampa que se explica sola en cuanto la ves.
Si mapeas ih y además usas mini.ai (lo instalaste en la lección 2.3), tienes dos dueños del prefijo i. mini.ai mapea la i a secas en modo Visual y operador, y luego lee el siguiente carácter para saber qué objeto quieres. gitsigns mapearía ih, dos caracteres. Vim resuelve esa ambigüedad esperando: al pulsar i no sabe si vas a completar ih o si i va solo, así que se queda quieto hasta timeoutlen. Si escribes rápido gana gitsigns; si dudas medio segundo gana mini.ai, que buscará texto entre dos caracteres h. El mismo tecleo hace dos cosas distintas según lo rápido que vayas, y eso es peor que no tener el objeto.
La salida limpia es que el prefijo i tenga un solo dueño: registra el hunk dentro de mini.ai, que ya lo gobierna.
require("mini.ai").setup({
custom_textobjects = {
-- Ahora "ih" y "ah" pasan por mini.ai, que es quien manda en la i.
h = function()
local ok, gs = pcall(require, "gitsigns")
if not ok then return nil end
local hunks = gs.get_hunks()
if not hunks then return nil end
local fila = vim.fn.line(".")
for _, h in ipairs(hunks) do
local ini = h.added.start
local fin = ini + math.max(h.added.count, 1) - 1
if fila >= ini and fila <= fin then
return {
from = { line = ini, col = 1 },
to = { line = fin, col = math.max(#vim.fn.getline(fin), 1) },
}
end
end
end,
},
})
Si no usas mini.ai, nada de esto aplica: mapea ih directamente a gs.select_hunk en los modos o y x y listo, porque entonces el prefijo i no tiene competencia.
Merece la pena mirar juntos los cuatro detalles de esta sección, porque son la misma familia de bug y la vas a reencontrar en cada plugin que instales. Un signo de borrado vacío no te avisa de que está vacío: simplemente no ves nunca las líneas eliminadas y acabas creyendo que gitsigns no las detecta. stage_hunk sin rango en Visual no da error: hace stage del hunk entero cuando tú seleccionaste tres líneas, y solo lo descubres al revisar el commit. Un salto que no pasa por make_repeatable_move no falla: es que ; no lo repite, y lo achacas a que “los repeats no funcionan con gitsigns”. Y el objeto ih compitiendo con mini.ai es el peor de los cuatro, porque el resultado depende de tu velocidad de tecleo. Ninguno produce un mensaje en :messages, ninguno aparece en :checkhealth, y los cuatro se manifiestan como “este plugin va raro”. La lección transferible: cuando copies una configuración de internet, la pregunta útil no es si funciona —casi siempre funciona en el caso feliz— sino qué caso límite no está cubierto: el modo Visual, el rango vacío, el prefijo compartido, el signo que no se ve. Ahí es donde vive el noventa por ciento del tiempo que la gente pierde con su editor.
lazygit: la TUI que lo cambia todo
Para commits, ramas, rebase interactivo y resolución de conflictos, nada supera a lazygit. Lo abrimos flotante desde Neovim (requiere tener lazygit instalado, lo hiciste en la lección 0.2):
return {
"kdheepak/lazygit.nvim",
cmd = { "LazyGit", "LazyGitCurrentFile" },
dependencies = { "nvim-lua/plenary.nvim" },
keys = {
{ "<leader>gg", "<cmd>LazyGit<cr>", desc = "LazyGit" },
{ "<leader>gf", "<cmd>LazyGitCurrentFile<cr>", desc = "LazyGit (archivo actual)" },
},
}vim.pack.add({
{ src = "https://github.com/nvim-lua/plenary.nvim" },
{ src = "https://github.com/kdheepak/lazygit.nvim" },
})
vim.keymap.set("n", "<leader>gg", "<cmd>LazyGit<cr>", { desc = "LazyGit" })
vim.keymap.set("n", "<leader>gf", "<cmd>LazyGitCurrentFile<cr>", { desc = "LazyGit (archivo actual)" })plenary es opcional: solo dibuja el borde de la ventana flotante.
Usa gitsigns para el trabajo fino (stage por hunk, blame, saltar entre cambios) y lazygit (<leader>gg) para todo lo demás: commits, ramas, rebase. Es la combinación que usan la mayoría de devs de Neovim en 2026.
Formateo al guardar: conform.nvim
conform.nvim ejecuta el formateador correcto para cada lenguaje. Necesita los binarios instalados (stylua, clang-format, prettierd…): con :Mason o con el gestor de paquetes de tu sistema, como prefieras.
return {
"stevearc/conform.nvim",
event = { "BufWritePre" },
cmd = { "ConformInfo" },
opts = {
formatters_by_ft = {
lua = { "stylua" },
c = { "clang_format" },
rust = { "rustfmt" },
swift = { "swift_format" },
javascript = { "prettierd", "prettier", stop_after_first = true },
typescript = { "prettierd", "prettier", stop_after_first = true },
typescriptreact = { "prettierd", "prettier", stop_after_first = true },
css = { "prettierd", "prettier", stop_after_first = true },
html = { "prettierd", "prettier", stop_after_first = true },
json = { "prettierd", "prettier", stop_after_first = true },
},
format_on_save = { timeout_ms = 1000, lsp_format = "fallback" },
},
}vim.pack.add({ { src = "https://github.com/stevearc/conform.nvim" } })
require("conform").setup({
formatters_by_ft = {
lua = { "stylua" },
c = { "clang_format" },
rust = { "rustfmt" },
swift = { "swift_format" },
javascript = { "prettierd", "prettier", stop_after_first = true },
typescript = { "prettierd", "prettier", stop_after_first = true },
typescriptreact = { "prettierd", "prettier", stop_after_first = true },
css = { "prettierd", "prettier", stop_after_first = true },
html = { "prettierd", "prettier", stop_after_first = true },
json = { "prettierd", "prettier", stop_after_first = true },
},
format_on_save = { timeout_ms = 1000, lsp_format = "fallback" },
})stop_after_first hace que se use el PRIMER formateador disponible de la lista, no todos en cadena.
Ejecuta :ConformInfo: te dice qué formateadores hay configurados para el buffer actual, cuáles encontró en el PATH y dónde está el log. Si un binario falta, conform lo omite en silencio para ese lenguaje; lsp_format = "fallback" hace que entonces formatee el propio servidor LSP, que en Neovim 0.12 basta para muchos lenguajes.
Linting con nvim-lint
El LSP ya da muchos diagnósticos, pero para linters de estilo (eslint_d, shellcheck, clippy) nvim-lint los ejecuta y los muestra como diagnósticos:
return {
"mfussenegger/nvim-lint",
event = { "BufReadPre", "BufNewFile" },
config = function()
local lint = require("lint")
lint.linters_by_ft = {
javascript = { "eslint_d" },
typescript = { "eslint_d" },
typescriptreact = { "eslint_d" },
sh = { "shellcheck" },
}
vim.api.nvim_create_autocmd({ "BufWritePost", "InsertLeave" }, {
callback = function() lint.try_lint() end,
})
end,
}vim.pack.add({ { src = "https://github.com/mfussenegger/nvim-lint" } })
local lint = require("lint")
lint.linters_by_ft = {
javascript = { "eslint_d" },
typescript = { "eslint_d" },
typescriptreact = { "eslint_d" },
sh = { "shellcheck" },
}
vim.api.nvim_create_autocmd({ "BufWritePost", "InsertLeave" }, {
callback = function() lint.try_lint() end,
})No trae autocomando: el disparo lo montas tú, y así decides cuándo cuesta rendimiento.
Debugging con nvim-dap
nvim-dap implementa el Debug Adapter Protocol: breakpoints, step, inspección de variables. Le añadimos una UI y virtual text.
return {
"mfussenegger/nvim-dap",
dependencies = {
{ "rcarriga/nvim-dap-ui", dependencies = { "nvim-neotest/nvim-nio" } },
"theHamsta/nvim-dap-virtual-text",
},
keys = {
{ "<leader>db", function() require("dap").toggle_breakpoint() end, desc = "Breakpoint" },
{ "<leader>dc", function() require("dap").continue() end, desc = "Continuar" },
{ "<leader>di", function() require("dap").step_into() end, desc = "Step into" },
{ "<leader>do", function() require("dap").step_over() end, desc = "Step over" },
{ "<leader>du", function() require("dapui").toggle() end, desc = "UI de debug" },
},
config = function()
local dap, dapui = require("dap"), require("dapui")
require("nvim-dap-virtual-text").setup()
dapui.setup()
-- Abre/cierra la UI automáticamente con la sesión
dap.listeners.before.attach.dapui_config = function() dapui.open() end
dap.listeners.before.launch.dapui_config = function() dapui.open() end
dap.listeners.before.event_terminated.dapui_config = function() dapui.close() end
dap.listeners.before.event_exited.dapui_config = function() dapui.close() end
end,
}vim.pack.add({
{ src = "https://github.com/mfussenegger/nvim-dap" },
{ src = "https://github.com/nvim-neotest/nvim-nio" },
{ src = "https://github.com/rcarriga/nvim-dap-ui" },
{ src = "https://github.com/theHamsta/nvim-dap-virtual-text" },
})
local dap, dapui = require("dap"), require("dapui")
require("nvim-dap-virtual-text").setup()
dapui.setup()
dap.listeners.before.attach.dapui_config = function() dapui.open() end
dap.listeners.before.launch.dapui_config = function() dapui.open() end
dap.listeners.before.event_terminated.dapui_config = function() dapui.close() end
dap.listeners.before.event_exited.dapui_config = function() dapui.close() end
local map = vim.keymap.set
map("n", "<leader>db", function() dap.toggle_breakpoint() end, { desc = "Breakpoint" })
map("n", "<leader>dc", function() dap.continue() end, { desc = "Continuar" })
map("n", "<leader>di", function() dap.step_into() end, { desc = "Step into" })
map("n", "<leader>do", function() dap.step_over() end, { desc = "Step over" })
map("n", "<leader>du", function() dapui.toggle() end, { desc = "UI de debug" })nvim-dap-ui necesita nvim-nio: es dependencia obligatoria, no adorno.
Falta el adaptador. codelldb es el de los lenguajes nativos —C, C++, Rust, Swift— y lo instalas con :Mason o con tu gestor de paquetes; con jay-babu/mason-nvim-dap.nvim puedes automatizarlo (ensure_installed = { "codelldb" }). En cada lección de lenguaje configuraremos su dap.configurations específico.
Fíjate en que todos los atajos de esta lección viven en <leader> o en los prefijos ] y [. Es deliberado. En este track, <C-Space> está reservado para el autocompletado —el nativo se enciende con vim.o.autocomplete— y <Tab> para moverse por su menú;Y s y S son de flash.nvim, con mini.surround en el prefijo gs (lección 2.3). Antes de inventarte una tecla, pregúntale a Neovim: :map <leader>d, :map s, :map <C-Space>. Si la respuesta no está vacía, elige otra.
Con <leader>db pones un breakpoint, <leader>dc arranca, y la UI (<leader>du) te muestra variables, call stack y scopes en vivo. Aprender a depurar de verdad dentro de Neovim, en lugar de sembrar print, es uno de los saltos de productividad más grandes que darás.
- Añade los cinco archivos (
git,lazygit,format,lint,dap) y reinicia. - Modifica un archivo versionado: mira los signos de gitsigns y salta con
]h. - Abre
<leader>gg(lazygit), haz un commit de prueba. - Desordena la indentación y guarda: conform debería arreglarla. Confirma con
:ConformInfoquién lo hizo. - Pon un breakpoint con
<leader>db(lo usarás de verdad en las lecciones de lenguaje). - Prueba el objeto de texto
ih:vihselecciona el hunk bajo el cursor,dihlo revierte a lo bruto.
Con esto, Neovim ya es un IDE. Ahora lo afinamos para cada lenguaje.