wandres.dev
LUA · el lenguaje del editor

Módulos y require: runtimepath, caché y recarga

Cómo Lua carga un módulo y por qué Neovim sustituye el buscador estándar: el rastreo de los directorios lua de todo el runtimepath, la caché de package.loaded, el acelerador vim.loader y las condiciones reales para recargar un módulo en caliente sin engañarte.

⏱ 19 min

require("config.keymaps") parece una línea inocente, y en realidad dispara una cadena de decisiones: buscar un archivo entre decenas de directorios, compilarlo, ejecutarlo una única vez, guardar su resultado y devolver siempre esa misma referencia. Si no conoces esa cadena, tarde o temprano te encontrarás editando un archivo, recargándolo y viendo que no pasa nada.

🎯 Al terminar esta lección sabrás
  • Escribir módulos que devuelvan una tabla y razonar sobre su ámbito privado.
  • Explicar cómo el buscador de Neovim traduce un nombre de módulo a una ruta del runtimepath.
  • Describir el papel de package.loaded y del acelerador vim.loader.
  • Recargar un módulo en caliente entendiendo qué efectos secundarios no se deshacen.

Un módulo es un archivo que devuelve una tabla

No hay palabra clave module ni export. Un módulo es un archivo Lua normal cuyo valor de retorno se convierte en lo que recibe quien lo pide. El convenio universal es devolver una tabla, casi siempre llamada M, con lo público arriba y lo privado como locales del archivo.

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

local cache_rama                        -- privado: nadie fuera lo ve

local function ejecutar(cmd)            -- privado
  return vim.system(cmd, { text = true }):wait()
end

function M.rama()
  if cache_rama then return cache_rama end
  local r = ejecutar({ "git", "branch", "--show-current" })
  cache_rama = vim.trim(r.stdout or "")
  return cache_rama
end

function M.invalidar() cache_rama = nil end

return M                                -- sin esto, require devuelve true

Ese return M final no es decorativo. Si lo olvidas, el archivo se ejecuta igual pero require devuelve true —el valor por defecto de un módulo sin retorno— y la llamada siguiente falla con un error de indexación sobre un booleano, que es uno de los mensajes más desconcertantes para quien empieza.

El nombre que pasas a require usa puntos, no barras ni extensión, y se traduce a una ruta relativa dentro de un directorio lua. Además, un directorio con init.lua se puede pedir por su nombre a secas.

require("mi.git")        --> lua/mi/git.lua
require("mi")            --> lua/mi.lua o, si no existe, lua/mi/init.lua

Cómo encuentra Neovim tu módulo

Aquí viene la diferencia que casi nadie explica. Lua estándar busca módulos recorriendo el patrón package.path, una lista de plantillas de ruta. Neovim añade su propio buscador al principio de la cadena: uno que ignora package.path y recorre en cambio los directorios lua de cada entrada del runtimepath.

:lua vim.print(vim.opt.runtimepath:get())
-- ~/.config/nvim, los directorios de cada plugin, $VIMRUNTIME, ~/.local/share/nvim/site,
-- y al final las variantes after/ de cada uno

Esto explica dos cosas que confunden a diario. La primera: cualquier plugin instalado puede exponer módulos, porque su carpeta entra en el runtimepath y su subdirectorio lua pasa a ser visible. La segunda: el orden importa, y como tu configuración va primero, un archivo tuyo en lua/telescope/init.lua haría sombra al plugin real. Los nombres de tus módulos deberían llevar prefijo propio precisamente por eso.

flowchart TD
R[require con nombre punteado] --> C{Esta en package.loaded}
C -- Si --> H[Devuelve el valor cacheado]
C -- No --> P{Hay entrada en package.preload}
P -- Si --> E[Ejecuta el cargador declarado]
P -- No --> N[Buscador de Neovim sobre runtimepath]
N --> B[Busca lua barra ruta punto lua o init punto lua]
B --> Q{Encontrado}
Q -- No --> X[Error module not found con las rutas probadas]
Q -- Si --> E
E --> S[Guarda el retorno en package.loaded]
S --> H
style H fill:#a6e3a1,color:#11111b
style X fill:#f38ba8,color:#11111b

Cuando algo no aparece, la herramienta de diagnóstico es vim.api.nvim_get_runtime_file, que te dice exactamente qué archivos ve Neovim y en qué orden.

:lua vim.print(vim.api.nvim_get_runtime_file("lua/mi/git.lua", true))
:lua vim.print(vim.api.nvim_get_runtime_file("lua/**/init.lua", true))
ℹ️
`require` no es lo mismo que `source`

require busca en los directorios lua del runtimepath, cachea y devuelve un valor. dofile y luafile ejecutan una ruta concreta del disco, sin caché y sin valor de retorno útil. El comando runtime busca en el runtimepath pero fuera de lua, y ejecuta todo lo que coincida. Cada uno tiene su sitio: módulos con require, scripts sueltos con luafile.

La caché: package.loaded y vim.loader

require ejecuta el archivo una sola vez por sesión. El valor devuelto se guarda en package.loaded bajo el nombre del módulo, y todas las llamadas posteriores devuelven esa misma referencia sin volver a tocar el disco.

:lua vim.print(vim.tbl_count(package.loaded))       -- cuantos modulos vivos
:lua vim.print(package.loaded["mi.git"] ~= nil)     -- esta cargado

Esa identidad compartida es la propiedad más importante y la más olvidada: si dos archivos hacen require("mi.git"), ambos manipulan la misma tabla. Un módulo es, de hecho, un singleton, y su estado interno —el cache_rama del ejemplo— es global de facto.

Encima de esa caché hay una segunda, en disco. vim.loader reemplaza el buscador por uno que memoriza la resolución de nombres a rutas y guarda el bytecode compilado de cada archivo, de modo que en los arranques siguientes no se vuelve a parsear el fuente. En configuraciones con cientos de módulos recorta decenas de milisegundos.

vim.loader.enable()                     -- al principio del init.lua
vim.loader.reset(vim.fn.stdpath("config"))  -- invalida lo cacheado de una ruta

Recargar en caliente sin engañarte

Recargar es, en teoría, una línea: borra la entrada de la caché y vuelve a pedir el módulo.

local function recargar(nombre)
  package.loaded[nombre] = nil
  if vim.loader then vim.loader.reset() end
  return require(nombre)
end

vim.api.nvim_create_user_command("R", function(o) recargar(o.args) end,
  { nargs = 1, desc = "Recargar un modulo Lua" })

En la práctica, esa línea solo devuelve una tabla nueva. Todo lo que el módulo hizo al ejecutarse la primera vez sigue ahí, y el editor no tiene forma de saberlo:

🔁

Referencias colgadas

Quien hiciera local git = require("mi.git") conserva la tabla vieja. Solo se actualiza quien vuelva a pedir el módulo.

📌

Efectos que persisten

Autocomandos, mapeos, comandos de usuario y temporizadores creados al cargar se duplican en cada recarga si no usas un augroup con clear.

🌙

Identidad rota

Una metatabla recreada ya no es la misma tabla, así que los objetos construidos antes de recargar dejan de reconocer sus métodos nuevos.

De ahí la disciplina que hace la recarga fiable: que cargar un módulo sea idempotente. Declara los autocomandos dentro de un grupo creado con clear = true, define los mapeos en una función setup en vez de en el cuerpo del archivo, guarda el estado que deba sobrevivir en un sitio estable, y prefiere require("mi.git").rama() a capturar el módulo en una local de larga vida cuando estés iterando sobre él.

El singleton silencioso que gobierna tu configuración

La idea que hay que llevarse de esta lección es que require no es una directiva de importación: es una memoización con efectos secundarios. Cada módulo se ejecuta una sola vez, y ese momento único es cuando registra autocomandos, crea comandos, arranca temporizadores y toca opciones globales. La tabla que devuelve no es una copia, es la única instancia, compartida por todo el proceso, sin frontera entre tu configuración y el código de cualquier plugin: en el espacio Lua de Neovim todos habitáis el mismo package.loaded, el mismo _G y el mismo runtimepath, y el orden de ese runtimepath decide quién gana ante un nombre repetido. Comprender esto cambia cómo escribes. Deja de importar por costumbre y empieza a preguntarte qué ocurre en el instante exacto de la primera carga, porque ese instante es tu única oportunidad de ser ordenado. Separa definir de activar: que el cuerpo del archivo solo construya la tabla y que todo efecto secundario viva en una función setup que puedas volver a invocar sin acumular basura. Ese es el motivo real por el que la comunidad entera adoptó el patrón setup, y por el que la carga perezosa que ofrecen los gestores de plugins es posible: si el efecto está separado de la definición, puedes retrasarlo, repetirlo o cancelarlo. Un módulo bien escrito no es el que hace muchas cosas al cargarse, sino el que se puede volver a cargar sin que el editor se entere.

⚔️ Domina el ciclo de carga
  1. Crea lua/mi/probe.lua con una local privada y una función pública, y demuestra desde :lua que dos require devuelven la misma tabla.
  2. Usa nvim_get_runtime_file para listar todas las rutas donde Neovim buscaría un módulo tuyo, y explica el orden que ves.
  3. Provoca a propósito el error de olvidar el return M y describe el mensaje exacto que aparece y por qué.
  4. Implementa un comando de recarga que, además de limpiar package.loaded, borre el augroup del módulo, y justifica por qué hace falta.
  5. Activa vim.loader, mide el arranque con nvim --startuptime antes y después, y explica qué parte del coste desaparece.