wandres.dev
LA API · vim.api, vim.fn, vim.opt

Variables y estado: vim.g, vim.b, vim.w y vim.env

Los diccionarios de variables de Neovim parecen tablas de Lua y no lo son: son proxies sobre estructuras del núcleo, así que cada lectura devuelve una copia y mutar una tabla dentro de vim.g no tiene ningún efecto.

⏱ 20 min

Una configuración madura de Neovim acaba teniendo estado propio: si el formateo automático está activo, cuál es la raíz del proyecto actual, qué perfil de tema se aplicó, si ya se inicializó tal integración. La pregunta de dónde poner ese estado parece trivial y no lo es, porque Neovim ofrece cinco sitios distintos y ninguno es un simple almacén. Cada uno tiene un tiempo de vida propio, una política de visibilidad propia y un coste de traducción propio. Elegir bien es lo que hace que el estado se limpie solo cuando debe limpiarse; elegir mal produce esa clase de fallo que solo aparece tras dos horas de sesión y que nadie consigue reproducir.

🎯 Al terminar esta lección sabrás
  • Describir los diccionarios vim.g, vim.b, vim.w, vim.t y vim.v y su tiempo de vida.
  • Explicar por qué son proxies con copia y qué operaciones dejan de funcionar por ello.
  • Distinguir vim.env del entorno que heredan los procesos lanzados desde el editor.
  • Decidir con criterio si un dato concreto pertenece a un diccionario del editor o a un módulo de Lua.

Cinco diccionarios y un entorno

vim.g expone las variables globales del editor, las que en Vimscript se escriben con el prefijo de ámbito global. Es el bus de interoperación por excelencia: ahí viven mapleader, los indicadores que desactivan plugins incorporados como loaded_netrw y prácticamente toda la configuración de los plugins escritos en Vimscript.

vim.b guarda variables por buffer, vim.w por ventana y vim.t por pestaña. Los tres admiten forma indexada, de modo que vim.b[bufnr].clave alcanza un buffer que no es el actual. vim.v expone las variables predefinidas del editor, casi todas de solo lectura, entre ellas vim.v.count con el contador que precedió a un mapeo, vim.v.shell_error con el código de salida del último comando externo y vim.v.event con los datos del autocomando en curso.

vim.env es distinto de los anteriores: no es un diccionario del editor sino el entorno del proceso. Escribir en él modifica el entorno real de Neovim, y por tanto el que heredarán los procesos que el editor lance después: terminales integrados, servidores de lenguaje, trabajos asíncronos.

vim.g.mapleader = " "
vim.g.loaded_netrwPlugin = 1              -- desactiva un plugin incorporado

vim.b.disable_autoformat = true           -- solo este buffer
vim.b[otro_buf].raiz_proyecto = "/srv/app"

vim.env.PATH = vim.fn.stdpath("data") .. "/mason/bin:" .. vim.env.PATH
⚠️
vim.env afecta a todo lo que lances despues

Modificar vim.env.PATH es la forma correcta de que los servidores de lenguaje y los terminales integrados encuentren binarios instalados por el gestor de herramientas. También significa que un error ahí se propaga a todos los procesos hijos y produce fallos que parecen del servidor y son tuyos.

Los proxies copian, y eso lo cambia todo

Ninguno de esos nombres es una tabla de Lua. Son tablas vacías con metatabla: leer una clave dispara una llamada al núcleo que serializa el valor interno y construye una tabla nueva; escribir dispara la conversión inversa. La consecuencia es inmediata y desconcierta a todo el mundo la primera vez.

vim.g.config = { tema = "oscuro", nivel = 3 }

vim.g.config.tema = "claro"      -- NO tiene efecto
print(vim.g.config.tema)          -- sigue siendo "oscuro"

-- La lectura devolvio una copia, se muto la copia y se descarto
local c = vim.g.config             -- copia
c.tema = "claro"                   -- muta la copia
vim.g.config = c                   -- lectura, modificacion y reescritura

El patrón correcto es siempre el mismo: leer, modificar en Lua, reescribir entero. No hay mutación en el sitio porque no hay sitio compartido.

De la misma frontera se deduce la segunda restricción: solo cruzan los valores que el sistema de tipos del editor sabe representar. Números, cadenas, booleanos, listas y diccionarios pasan sin problema. Funciones de Lua, metatablas, valores de usuario y referencias circulares no. Intentar guardar una función en vim.g no produce un fallo elegante en el punto de uso, sino un error en el punto de asignación, que al menos es honesto.

La tercera consecuencia es de rendimiento y rara vez importa, pero conviene conocerla: leer una tabla grande de vim.g dentro de un bucle la reconstruye en cada vuelta. Si lo haces en una función que se ejecuta en cada pulsación, el coste deja de ser teórico.

Borrar es asignar el valor nulo de Lua, que elimina la clave en lugar de guardar un nulo.

El tiempo de vida como criterio de diseno

La pregunta de dónde guardar algo se responde casi siempre mirando cuándo debe dejar de existir. Ese es el criterio dominante, muy por encima de la comodidad sintáctica.

flowchart LR
A[Dato que quieres guardar] --> B{Cuando debe morir}
B -->|con el editor| G[vim.g o modulo Lua]
B -->|con el buffer| BB[vim.b]
B -->|con la ventana| W[vim.w]
B -->|con la pestana| T[vim.t]
B -->|nunca muere| D[Disco en stdpath state]
G --> Q{Lo lee Vimscript}
Q -->|si| GG[vim.g obligatorio]
Q -->|no| M[Modulo Lua mejor]
style BB fill:#a6e3a1,color:#11111b
style M fill:#89b4fa,color:#11111b

Un dato que describe un buffer concreto —si el formateo al guardar está desactivado ahí, cuál es la raíz del proyecto de ese archivo, qué cliente de lenguaje lo atiende— pertenece a vim.b. La razón no es estética: cuando el buffer se elimina, la variable desaparece con él. Has obtenido invalidación de caché gratuita y sin escribir una línea de limpieza.

Con vim.w hay un matiz que sorprende: al dividir una ventana, las opciones de ventana se heredan en la nueva, pero conviene no dar por hecho que las variables de ventana viajen igual. Trata siempre vim.w como una caché reconstruible y nunca como la única copia de algo que no puedas recalcular.

vim.v no es un sitio donde guardar: es un sitio donde leer. Su utilidad está en escribir mapeos y autocomandos que se comportan según el contexto, como usar vim.v.count1 para que un atajo respete el número que el usuario tecleó antes.

-- Un atajo que respeta el contador tecleado por el usuario.
-- Ojo con la tecla: s y S son de flash.nvim en esta guia (leccion 2.3).
vim.keymap.set("n", "<leader>zj", function()
  vim.cmd("normal! " .. vim.v.count1 .. "j")
end, { desc = "Bajar respetando el contador" })

Una nota sobre vim.g que conviene interiorizar pronto: es un espacio de nombres plano y compartido con todos los plugins instalados. No hay ámbitos, no hay prefijos obligatorios y no hay aviso de colisión. Si guardas ahí una clave con un nombre genérico, estás apostando a que ningún plugin presente ni futuro elija el mismo nombre. Cuando no tengas más remedio que usarlo, prefija siempre con algo inequívocamente tuyo.

Donde vive el estado de tu configuracion

Llegamos a la recomendación que ordena todo lo anterior: el estado propio de tu configuración pertenece a un módulo de Lua, no a vim.g.

Un módulo es una tabla devuelta por un archivo bajo el directorio lua/, que el sistema de carga guarda en caché tras el primer require. Todo lo que ese archivo declare como local es privado; lo que exponga en la tabla es público. No hay serialización, no hay conversión de tipos, no hay colisión de nombres con otros plugins, y puedes guardar funciones, metatablas y cierres sin restricción alguna.

-- lua/mi/estado.lua
local M = {}

local cache = {}                         -- privado de verdad

function M.raiz(bufnr)
  bufnr = bufnr or vim.api.nvim_get_current_buf()
  if cache[bufnr] then return cache[bufnr] end
  local encontrada = vim.fs.root(bufnr, { ".git", "Makefile" })
  cache[bufnr] = encontrada
  return encontrada
end

function M.olvidar(bufnr) cache[bufnr] = nil end

return M

Ese módulo tiene un problema que vim.b no tendría: la caché no se limpia sola. La solución no es abandonar el módulo, sino conectarlo al ciclo de vida con un autocomando que llame a la función de olvido cuando el buffer desaparece. Y ese es justamente el punto: el módulo te da control explícito sobre la política de invalidación, mientras que los diccionarios del editor te dan una política implícita y fija. Elige según cuál de las dos necesites.

Reserva vim.g para lo que tiene que ser visible desde fuera de Lua: la tecla líder, los indicadores que leen los plugins de Vimscript, las opciones que un plugin documenta como variables globales. Es un bus de interoperación, no un armario.

-- Conectar el modulo al ciclo de vida del editor
vim.api.nvim_create_autocmd({ "BufDelete", "BufWipeout" }, {
  group = vim.api.nvim_create_augroup("invalidar_raiz", { clear = true }),
  callback = function(args) require("mi.estado").olvidar(args.buf) end,
})

Queda un cuarto tiempo de vida que no aparece en ningún diccionario: el de los datos que deben sobrevivir al cierre del editor. Ni vim.g ni un módulo sirven para eso, porque ambos mueren con el proceso. Lo que corresponde ahí es el disco, y Neovim ofrece rutas normalizadas para cada propósito mediante la función de rutas estándar: una para configuración, otra para datos, otra para estado volátil y otra para caché desechable. Escribir un archivo de sesión propio en la ruta de estado y leerlo al arrancar es un patrón perfectamente respetable; guardarlo junto a tu configuración versionada no lo es, porque mezcla lo que escribes tú con lo que escribe el programa.

Una advertencia final sobre los módulos: el sistema de carga guarda en caché la tabla devuelta, de modo que volver a pedir el módulo no vuelve a ejecutar el archivo y el estado sobrevive. Eso es deseable en funcionamiento normal y molesto mientras desarrollas, porque tus cambios en el archivo no se ven hasta reiniciar. La solución durante el desarrollo es eliminar la entrada correspondiente de la tabla de módulos cargados antes de volver a pedirlo; la solución en producción es no hacerlo nunca, porque perderías precisamente el estado que querías conservar.

El estado sin politica de muerte es una fuga

Toda variable que se crea plantea, aunque nadie la formule, la pregunta de cuándo dejará de ser cierta. Las configuraciones que envejecen mal son casi siempre las que guardaron estado sin responderla: un indicador global que se puso a cierto en el primer buffer de un tipo y que sigue a cierto tres horas después en un contexto donde ya no significa nada, una caché de raíz de proyecto que se calculó en un monorepo y contamina el siguiente archivo, un contador global que dos autocomandos incrementan sin saber el uno del otro. Y la observación que ordena todo el diseño es esta: los diccionarios de Neovim no son cinco almacenes intercambiables sino cinco políticas de invalidación ya escritas. vim.b no es un lugar donde guardar cosas de un buffer, es la afirmación de que ese dato deja de ser verdad cuando el buffer deja de existir. vim.w afirma lo mismo respecto de una ventana, y vim.t respecto de una pestaña. Elegir el diccionario es, literalmente, escribir la condición de caducidad del dato sin escribir código de limpieza, que es la única forma de código de limpieza que no se olvida nunca de ejecutarse. Por eso la pregunta correcta antes de guardar algo no es dónde cabe ni qué resulta más cómodo de escribir, sino cuándo deja de ser cierto. Si la respuesta coincide con la vida de un buffer, de una ventana o de una pestaña, el editor ya tiene el sitio hecho. Si no coincide con ninguna, entonces necesitas un módulo de Lua y necesitas escribir tú la política, explícitamente, porque nadie va a hacerlo por ti.

⚔️ Da a cada dato su tiempo de vida
  1. Inventaria el estado que guarda tu configuración hoy. Para cada dato escribe en una frase cuándo deja de ser cierto, y solo entonces decide dónde debería vivir.
  2. Guarda una tabla en vim.g, muta un campo directamente y comprueba que no cambia nada. Reescribe la operación con el patrón de lectura, modificación y reescritura.
  3. Sustituye un indicador global que solo tenga sentido por buffer por su equivalente en vim.b. Elimina el código de limpieza que ya no hace falta.
  4. Escribe un módulo con caché por número de buffer y conéctalo a un autocomando que invalide la entrada al eliminarse el buffer. Comprueba que la tabla no crece indefinidamente.
  5. Añade un directorio al PATH con vim.env, abre un terminal integrado y verifica que el proceso hijo lo heredó.