wandres.dev
LUA · el lenguaje del editor

Lua en 20 minutos: tipos, locales y funciones

El núcleo del lenguaje que Neovim ejecuta: los ocho tipos de LuaJIT, semántica de valor frente a referencia, el alcance léxico de local y sus upvalues, funciones con retornos múltiples, y las sorpresas que descolocan a quien viene de C, Python o JavaScript.

⏱ 18 min

Aquí empieza la segunda mitad del camino: dejas de usar Neovim y empiezas a extenderlo. La puerta de entrada es Lua, un lenguaje diminuto —ocho tipos, una sola estructura de datos compuesta, veintiuna palabras reservadas— que puedes leer entero en una tarde. Esa pequeñez no es pobreza: es exactamente la razón por la que cabe dentro de un editor, arranca en milisegundos y no te obliga a aprender un ecosistema para cambiar un atajo.

🎯 Al terminar esta lección sabrás
  • Enumerar los ocho tipos de Lua y distinguir cuáles tienen semántica de valor y cuáles de referencia.
  • Dominar local, el alcance léxico y por qué una variable global es casi siempre un error.
  • Escribir funciones con retornos múltiples y predecir el ajuste de listas de valores.
  • Reconocer las trampas clásicas: índices desde 1, nil y una noción de verdad muy estricta.

Ocho tipos y ni uno más

Neovim no embebe el intérprete de referencia: embebe LuaJIT, un compilador JIT compatible con Lua 5.1 más algunas extensiones tomadas de 5.2. Ese detalle no es trivia: significa que no existe el operador de división entera //, que no hay un tipo entero separado del real, y que unpack es una función global en vez de vivir en table. Escribes 5.1, aunque el mundo hable de 5.4.

print(type(nil))        --> nil       ausencia de valor
print(type(true))       --> boolean
print(type(42))         --> number    doble IEEE 754, no hay int
print(type("hola"))     --> string    inmutable e internada
print(type({}))         --> table     la unica estructura compuesta
print(type(print))      --> function  valor de primera clase
print(type(coroutine.create(function() end)))  --> thread
print(type(vim.uv.new_timer()))                --> userdata

Los cuatro primeros son valores: se copian al asignarse y se comparan por contenido. Los cuatro últimos son referencias gestionadas por el recolector de basura: se copian por puntero y == compara identidad, no forma. De ahí que dos tablas con el mismo contenido nunca sean iguales.

local a, b = "opt", "opt"
print(a == b)            --> true   las cadenas se internan

local t1, t2 = { 1, 2 }, { 1, 2 }
print(t1 == t2)          --> false  dos objetos distintos
print(t1 == t1)          --> true   identidad
flowchart TD
T[Los ocho tipos] --> V[Semantica de valor]
T --> R[Semantica de referencia con GC]
V --> V1[nil boolean number string]
R --> R1[table function thread userdata]
style V fill:#a6e3a1,color:#11111b
style R fill:#89b4fa,color:#11111b

Que number sea siempre un doble tiene una consecuencia práctica: los enteros son exactos hasta 2^53, más que suficiente para números de línea, pero 10 / 3 da 3.3333, no 3. La división entera se escribe math.floor(a / b), y en LuaJIT las operaciones bit a bit viven en la biblioteca bit, no en operadores.

local no es un adorno opcional

En Lua toda variable es global por defecto. Omitir local no crea una variable de ámbito reducido: crea una entrada en la tabla _G, visible desde cualquier plugin, viva hasta que cierres Neovim.

contador = 0            -- global: entra en _G, colisiona con todo
local contador = 0      -- local: vive en el bloque, la ve el compilador

La diferencia también es de rendimiento. LuaJIT asigna cada local a un registro de la máquina virtual, mientras que cada lectura global es una búsqueda con hash en _G. En un autocmd que se dispara en cada pulsación, esa distancia se nota.

El alcance es léxico y por bloque: una local vive desde la línea siguiente a su declaración hasta el end que la contiene. Una función definida dentro de ese bloque captura las locales que ve; a esas variables capturadas se les llama upvalues, y son la base de todo lo que verás en la lección 7.3.

local M = {}

local function ayuda()          -- privada del modulo, no escapa
  return "solo visible en este archivo"
end

function M.publica()            -- expuesta por la tabla M
  return ayuda()
end

return M
⚠️
`local function` no es azúcar de `local f = function`

local function f() ... end declara la local antes de compilar el cuerpo, así que la función puede llamarse a sí misma. local f = function() ... f() ... end no: dentro del cuerpo, f todavía es la global inexistente. Para recursión, siempre la primera forma.

Las tres sorpresas que más cuestan

Uno: los índices empiezan en 1. No es un capricho; es coherente con toda la biblioteca estándar, con string.sub, con las líneas de un buffer en vim.fn. La excepción que te morderá es la API de Neovim: nvim_buf_get_lines y compañía usan índices desde 0, porque hablan el protocolo de la API, no el de Lua. Convivirás con los dos convenios en el mismo archivo.

Dos: nil es ausencia, no un valor almacenable. Asignar nil a una clave la borra de la tabla. No hay diferencia observable entre una clave con valor nil y una clave que nunca existió, y esa ambigüedad se propaga a los argumentos de una función.

local t = { a = 1 }
t.a = nil
print(next(t))          --> nil   la tabla esta vacia, no tiene una clave con nil

Tres: solo nil y false son falsos. Cualquier otra cosa es verdadera, incluidos 0 y la cadena vacía. Quien viene de C o Python tropieza aquí una vez y solo una.

if 0 then print("si") end      --> imprime: 0 es verdadero
if "" then print("si") end     --> imprime: la cadena vacia es verdadera
local n = tonumber(entrada) or 0   -- el idioma para valores por defecto

Hay una cuarta que muerde más tarde: las cadenas son secuencias de bytes inmutables, no de caracteres. Concatenar con .. crea siempre una cadena nueva, así que construir texto en un bucle es cuadrático y lo correcto es acumular en una tabla y cerrar con table.concat. Y el operador de longitud sobre una cadena cuenta bytes, no letras.

print(#"añadir")                  --> 8 bytes, no 6 caracteres
print("linea " .. 42)             --> coercion de numero a cadena
print(1 == "1")                   --> false: la igualdad NO coerciona
print("10" + 5)                   --> 15: la aritmetica SI coerciona
ℹ️
La asimetría de las coerciones

La aritmética convierte cadenas a números y la concatenación convierte números a cadenas, pero == no convierte nada: exige tipos iguales. Esa asimetría es deliberada y evita el pozo de comparaciones sorprendentes de otros lenguajes, a costa de que tonumber y tostring sean explícitos en tu código.

Ese or es el idioma más frecuente de todo el lenguaje: a or b devuelve a si es verdadero y b en caso contrario, y a and b devuelve b si a es verdadero. Combinados dan un ternario, cond and x or y, correcto siempre que x no sea falso. Es la trampa clásica de ese idioma.

Funciones, retornos múltiples y ajuste

Las funciones son valores de primera clase: se guardan en variables, se pasan como argumentos y se devuelven. Y pueden devolver varios valores, algo que casi ningún lenguaje de su generación permitía.

local function dividir(a, b)
  if b == 0 then return nil, "division por cero" end
  return math.floor(a / b), a % b
end

local q, r = dividir(17, 5)     --> q = 3, r = 2
local ok, err = dividir(1, 0)   --> ok = nil, err = "division por cero"

Ese par valor, error es el convenio de fallo de Lua y el que usa media API de Neovim. La regla que gobierna cuántos valores sobreviven se llama ajuste: una llamada en la última posición de una lista se expande a todos sus valores; en cualquier otra posición se trunca al primero. Rodearla de paréntesis también la trunca.

local t = { dividir(17, 5) }        --> { 3, 2 }   ultima posicion: expande
local u = { dividir(17, 5), 99 }    --> { 3, 99 }  posicion intermedia: trunca
local v = (dividir(17, 5))          --> 3          los parentesis truncan
La economía de un lenguaje que cabe en la cabeza

Merece la pena detenerse en lo que acabas de ver, porque explica por qué Neovim eligió Lua y no Python, Ruby o JavaScript. Todo el lenguaje son ocho tipos, una estructura compuesta, alcance léxico y funciones de primera clase; no hay clases, ni interfaces, ni módulos como constructo sintáctico, ni excepciones, ni herencia, ni decoradores. Lo que en otros lenguajes son mecanismos del núcleo, en Lua son patrones que construyes con lo poco que hay: un módulo es una tabla que devuelves, un objeto es una tabla con una metatabla, la herencia es una cadena de __index, y el manejo de errores es un valor devuelto más una función llamada pcall. Esa decisión de diseño tiene un coste —tendrás que escribir a mano lo que otros te dan hecho— y una recompensa enorme: el intérprete entero pesa unos cientos de kilobytes, arranca sin ceremonia, no impone un modelo de objetos que choque con el del anfitrión y puede detenerse y reanudarse dentro del bucle de eventos de un editor. Neovim no adoptó Lua porque fuera bonito, sino porque un lenguaje incrustable debe ser pequeño, predecible y sin opiniones. Cuando en las próximas lecciones veas que las tablas hacen de array, de diccionario, de objeto, de módulo y de espacio de nombres a la vez, no lo leas como una carencia: es el mismo principio que hace que puedas configurar tu editor sin instalar un ecosistema.

⚔️ Comprueba que el modelo mental encaja
  1. Explica por qué t1 == t2 es falso para dos tablas de contenido idéntico y qué tendrías que escribir para compararlas por valor.
  2. Predice, sin ejecutar, el resultado de print(#{ dividir(17, 5), dividir(9, 2) }) con la función de la lección, y justifica la regla de ajuste que se aplica a cada llamada.
  3. Escribe una función clamp que devuelva el valor acotado y un segundo valor booleano que indique si hubo recorte.
  4. Razona por qué cond and x or y falla cuando x es false y propón una alternativa correcta.
  5. Busca en tu configuración una asignación sin local y explica qué colisión concreta podría provocar dentro de _G.