El patrón setup: opciones por defecto, mezcla y estado del módulo
Por qué el ecosistema convergió en una única función de configuración: valores por defecto, mezcla profunda con la tabla del usuario y la trampa de las listas, validación temprana con mensajes accionables, idempotencia frente a llamadas repetidas y el estado que vive en el módulo.
Ningún documento normativo obliga a que un plugin exponga una función llamada setup. Sin embargo, si abres cien plugins de Neovim escritos en la última década, noventa y ocho la tienen, con la misma firma y casi la misma implementación. Esa convergencia no es imitación: es la respuesta a una restricción real del entorno. La configuración del usuario y el código del plugin se ejecutan en el mismo intérprete, en un orden que ninguno de los dos controla del todo, y el plugin necesita un instante bien definido en el que el usuario ya ha hablado y él todavía no ha actuado. setup es ese instante. Todo lo demás —la mezcla de tablas, la validación, la guarda de idempotencia— son detalles de implementación de una idea sola: separar el momento en que se declara la intención del momento en que se ejerce el efecto.
- Justificar la existencia de
setupcomo punto de sincronización entre configuración y efectos. - Combinar valores por defecto con la tabla del usuario entendiendo la semántica exacta de la mezcla profunda.
- Validar la entrada temprano y producir errores que señalen la línea del usuario, no la tuya.
- Gobernar el estado del módulo: idempotencia, reconfiguración y qué hacer si nadie llama a
setup.
Por qué existe y qué problema resuelve
Un plugin necesita hacer cosas con efecto observable: crear atajos, registrar autocomandos, abrir conexiones, arrancar temporizadores. Si esas acciones ocurren en el cuerpo del módulo, se ejecutan en el instante impredecible en que alguien hace el primer require, con los valores por defecto y sin posibilidad de que el usuario intervenga antes. Colocarlas dentro de una función que el usuario invoca explícitamente traslada el control al único que sabe qué quiere.
-- lua/pico/init.lua
local M = {}
M.defaults = {
ruta = vim.fn.stdpath("data") .. "/pico",
ventana = { ancho = 0.6, alto = 0.6, borde = "rounded" },
filetype = "markdown",
atajos = { abrir = "<leader>pp", cerrar = "q" },
autoguardar = true,
}
M.options = nil -- nil significa: todavia nadie ha configurado esto
function M.setup(opts)
M.options = vim.tbl_deep_extend("force", M.defaults, opts or {})
return M
end
return M
Repara en el detalle de opts or {}: setup debe poder invocarse sin argumentos, porque esa es la forma en que un usuario dice acepto todo por defecto pero quiero los efectos. Y repara también en que la tabla de valores por defecto es un campo público. Exponerla cuesta una línea y permite a un usuario avanzado leerla, derivar de ella o componer configuraciones sin duplicar tus decisiones en su archivo.
La mezcla profunda y su trampa
La función de mezcla recorre recursivamente las tablas y, ante una clave presente en varias, la estrategia indicada decide quién gana. Con la estrategia de fuerza, el último argumento prevalece; con la de error, una colisión aborta; con la de conservación, gana el primero. Para el caso de configuración se usa siempre la primera, con los defectos a la izquierda y el usuario a la derecha, de modo que el usuario sobrescribe hoja a hoja sin tener que repetir el resto del árbol.
local defaults = { ventana = { ancho = 0.6, alto = 0.6 }, lenguajes = { "lua", "c" } }
local usuario = { ventana = { ancho = 0.9 }, lenguajes = { "rust" } }
vim.tbl_deep_extend("force", defaults, usuario)
-- ventana = { ancho = 0.9, alto = 0.6 } <- mezcla hoja a hoja, como se espera
-- lenguajes = { "rust" } <- SUSTITUCION TOTAL, casi nunca lo esperado
Ahí está la trampa que todo autor descubre tarde: en Lua no hay distinción de tipo entre un diccionario y una lista, ambos son tablas, y la mezcla profunda trata la lista como un diccionario de claves numéricas. El resultado es que las claves uno a uno se sobrescriben y la lista corta reemplaza a la larga en lugar de añadirse. No existe una respuesta universal correcta, solo una decisión de diseño que debes tomar conscientemente y documentar. Las tres salidas habituales son: aceptar la sustitución y decirlo en la ayuda; ofrecer campos separados para añadir y para reemplazar; o admitir una función en esa clave, invocarla con la lista por defecto y usar lo que devuelva. La tercera es la más flexible y la que emplean los gestores de plugins maduros para las listas de dependencias.
La mezcla devuelve una tabla nueva, pero si tu tabla de defectos contiene una lista y el usuario no la toca, la referencia interior es compartida. Si más tarde mutas esa lista en tiempo de ejecución, estarás mutando también los valores por defecto del módulo y contaminando futuras llamadas. Ante cualquier duda, vim.deepcopy sobre los defectos antes de mezclar cuesta microsegundos y elimina toda una familia de errores irreproducibles.
Validar temprano, culpar correctamente
Un error de configuración detectado en el momento del uso aparece minutos después, en una traza que apunta a las entrañas de tu plugin, y el usuario concluye razonablemente que tu plugin está roto. Detectado en setup, aparece en el arranque y señala la línea de su propio archivo. La diferencia entre ambas experiencias es literalmente una llamada de validación.
local function validar(o)
vim.validate("ruta", o.ruta, "string")
vim.validate("filetype", o.filetype, "string")
vim.validate("autoguardar", o.autoguardar, "boolean")
vim.validate("ventana", o.ventana, "table")
vim.validate("ventana.ancho", o.ventana.ancho, function(v)
return type(v) == "number" and v > 0 and v <= 1
end, "numero entre 0 y 1 como fraccion de la pantalla")
end
function M.setup(opts)
opts = opts or {}
local desconocidas = vim.tbl_filter(function(k)
return M.defaults[k] == nil
end, vim.tbl_keys(opts))
if #desconocidas > 0 then
vim.notify("pico: opciones desconocidas: " .. table.concat(desconocidas, ", "),
vim.log.levels.WARN)
end
M.options = vim.tbl_deep_extend("force", vim.deepcopy(M.defaults), opts)
validar(M.options)
return M
end
Avisar de las claves desconocidas parece un lujo y es, en la práctica, lo que más soporte ahorra: cubre las erratas y las opciones renombradas entre versiones, que de otro modo se manifiestan como configuré esto y no pasa nada. Nota además la asimetría de severidad, deliberada: una clave desconocida es una advertencia porque el plugin puede continuar; un tipo incorrecto es un error porque continuar produciría un fallo peor y más lejano. Y una precaución final: setup se ejecuta durante el arranque, así que ni valides con operaciones de disco lentas ni emitas mensajes que interrumpan la carga. Validar barato, avisar en diferido con vim.schedule si hace falta.
flowchart LR a[Usuario llama a setup con su tabla] --> b[Copia de los valores por defecto] b --> c[Mezcla profunda con estrategia de fuerza] c --> d[Aviso de claves desconocidas] d --> e[Validacion de tipos y rangos] e --> f[Estado del modulo listo] f --> g[Efectos diferidos comandos autocomandos atajos] style e fill:#f9e2af,color:#11111b style f fill:#a6e3a1,color:#11111b
El estado del módulo y la idempotencia
Como require memoriza el resultado, el módulo es un singleton para todo el proceso: su tabla es un espacio de estado global compartido por cualquiera que lo pida. Eso obliga a responder tres preguntas que la mayoría de los plugins jóvenes ignoran y acaban pagando. ¿Qué ocurre si alguien llama a setup dos veces? ¿Qué ocurre si nadie lo llama y un usuario invoca el comando directamente? ¿Qué parte del estado es configuración y cuál es datos vivos?
M._configurado = false
function M.setup(opts)
M.options = vim.tbl_deep_extend("force", vim.deepcopy(M.defaults), opts or {})
validar(M.options)
-- Efectos idempotentes: crear el grupo con clear vacia el anterior
local grupo = vim.api.nvim_create_augroup("Pico", { clear = true })
vim.api.nvim_create_autocmd("VimLeavePre", { group = grupo, callback = M.guardar })
for accion, tecla in pairs(M.options.atajos) do
vim.keymap.set("n", tecla, function() M[accion]() end, { desc = "pico " .. accion })
end
M._configurado = true
return M
end
-- Cualquier punto de entrada publico tolera que setup no se haya llamado
function M.get()
if not M._configurado then M.setup({}) end
return M.options
end
La segunda llamada debe dejar el sistema exactamente como la primera, no acumular duplicados: por eso los autocomandos van a un grupo que se vacía al recrearse, y por eso conviene guardar las teclas asignadas para poder retirarlas si la reconfiguración las cambia. La distinción entre configuración y datos vivos merece disciplina explícita: M.options es inmutable tras la validación y nunca se muta en tiempo de ejecución; el estado mutable —ventanas abiertas, temporizadores, cachés— vive en campos separados con prefijo de privacidad. Mezclar ambos produce el peor error de todos, el que aparece solo tras varias horas de uso porque una función escribió sobre la configuración de otra.
Conviene mirar setup desde arriba, porque su forma tiene más que enseñar que su código. Lo que esa función hace, en términos abstractos, es convertir un objeto de datos inerte —una tabla que el usuario escribió sin ejecutar nada— en una configuración validada y en un conjunto de efectos comprometidos. Es una frontera, y todas las fronteras de un sistema comparten las mismas obligaciones: aceptar entrada arbitraria del exterior, no confiar en ella, normalizarla, rechazarla con un diagnóstico útil si es inválida y, a partir de ahí, garantizar al interior que puede confiar ciegamente en lo que recibe. Es exactamente el trabajo de un manejador de peticiones en un servidor, del analizador de argumentos de una herramienta de línea de comandos o de la capa de deserialización de un protocolo, y la calidad de un plugin se predice mejor observando cómo trata esa frontera que leyendo su lógica principal. De ahí se sigue la consecuencia práctica que más cuesta interiorizar: la tabla de opciones es la parte de tu plugin que no puedes cambiar. El código interno se refactoriza cuando quieras, la implementación se reescribe entera, la estructura de módulos se reorganiza sin que nadie se entere; pero el día que renombras una clave o cambias su tipo, rompes en silencio la configuración de todos tus usuarios, y el fallo se manifestará en su arranque, no en tus pruebas. Por eso los autores experimentados diseñan el árbol de opciones con más cuidado que el resto del plugin y son avaros al añadir claves: cada opción nueva es una promesa perpetua, multiplica los estados que debes probar y suele delatar una decisión que no quisiste tomar. Antes de exponer una opción, pregunta si existe un valor por defecto que sirva al noventa por ciento y si el diez restante puede resolverse con una función. Los plugins que envejecen bien casi siempre tienen menos opciones de las que sus usuarios pidieron.
- Define la tabla de valores por defecto de tu plugin e impleméntala como campo público, con mezcla profunda sobre una copia y llamada sin argumentos admitida.
- Añade una lista a la configuración y comprueba empíricamente qué ocurre al mezclarla; después implementa la variante que admite una función y recibe la lista por defecto.
- Valida tipos y rangos con mensajes que expliquen qué se esperaba, y avisa de las claves desconocidas sin abortar la carga.
- Llama a
setupdos veces con configuraciones distintas y verifica que no quedan autocomandos duplicados ni atajos huérfanos. - Invoca un comando público sin haber llamado nunca a
setupy asegúrate de que funciona con los valores por defecto en lugar de fallar con un acceso a un valor nulo.