Gramáticas: instalar parsers y ver qué hay dentro
Cómo se instala un parser con la rama main de nvim-treesitter y dónde vive, la cadena que va del grammar.js al objeto compartido compilado, qué contiene realmente una gramática de tree-sitter, y cómo registrar una gramática propia.
Cuando ejecutas :TSInstall rust pasa algo que casi nadie mira: se descarga una gramática escrita en JavaScript, se genera a partir de ella un fichero de C de decenas de miles de líneas que es una tabla de estados, se compila con un compilador de C real y el resultado se carga en Neovim como biblioteca dinámica. Treesitter no interpreta gramáticas en tiempo de ejecución: compila un autómata. Entender esa cadena explica los errores que verás y los límites con los que te toparás.
- Instalar parsers con la rama
maindenvim-treesittery saber dónde se buscan dentro delruntimepath. - Seguir la cadena que va de
grammar.jsal objeto compartido cargado por Neovim. - Leer las construcciones esenciales de una gramática: reglas, campos, precedencias y
extras. - Reconocer cuándo hace falta un escáner externo y por qué la gramática sola no basta.
- Registrar una gramática que no está en la lista oficial.
Instalar un parser y saber dónde vive
Un parser es, para Neovim, un archivo binario dentro de un directorio parser/ del runtimepath, con el nombre del lenguaje y la extensión de biblioteca dinámica de tu sistema: parser/lua.so, parser/rust.so. Neovim trae unos pocos empaquetados —los que necesita para su propia ayuda y configuración— y el resto los aporta nvim-treesitter.
La rama main es una reescritura incompatible: nvim-treesitter.configs y sus opciones (ensure_installed, highlight, indent, incremental_selection) ya no existen. Lo tienes explicado en la lección 2.3.
Requisitos de main: Neovim 0.12 o posterior, tar y curl en el PATH, un compilador de C y tree-sitter-cli 0.26.1 o posterior instalado con el gestor de paquetes de tu sistema, no con npm.
Solo vim.pack: la rama main no admite carga perezosa. El porque, en la leccion 2.3.
vim.pack.add({
{ src = "https://github.com/nvim-treesitter/nvim-treesitter", version = "main" },
})
require("nvim-treesitter").setup({
install_dir = vim.fn.stdpath("data") .. "/site",
})
require("nvim-treesitter").install({ "lua", "rust", "c", "markdown" })main no admite carga perezosa: con lazy.nvim va lazy = false y build = ':TSUpdate'. install es asincrono; para un arranque sincrono encadena :wait(300000).
Llamar a setup es opcional: sin él, los parsers y las queries van a stdpath('data') seguido de /site. Lo que no es opcional es entender que install no bloquea: devuelve un objeto sobre el que puedes esperar si necesitas parsers listos antes de seguir, cosa que solo hace falta en scripts de arranque.
-- Instalar y mantener parsers con nvim-treesitter (rama main)
require("nvim-treesitter").install({ "lua", "rust", "c", "markdown" })
-- Version sincrona para bootstrap: espera como maximo cinco minutos
require("nvim-treesitter").install({ "lua", "rust" }):wait(300000)
-- Que hay instalado y desde donde se cargan los binarios
vim.print(require("nvim-treesitter").get_installed("parsers"))
vim.print(vim.api.nvim_get_runtime_file("parser/*", true))
Los comandos siguen existiendo y son la vía cómoda desde el propio editor: :TSInstall, :TSUpdate, :TSUninstall y :TSLog, que muestra los mensajes de la última operación. Hay además :TSInstallFromGrammar, que regenera parser.c desde la gramática original, útil cuando el parser.c publicado usa un ABI que tu Neovim ya no soporta.
" Diagnostico del nucleo: parsers cargables, su ABI y que queries hay
:checkhealth vim.treesitter
" Diagnostico del plugin: que ha instalado el y donde
:checkhealth nvim-treesitter
Dos piezas se confunden a menudo. El lenguaje es el nombre del parser; el tipo de archivo es lo que Neovim deduce del nombre del fichero. No siempre coinciden, y por eso existe un registro explícito que asocia uno con otro:
-- Asociar tipos de archivo a un lenguaje ya instalado
vim.treesitter.language.register("bash", { "zsh", "sh" })
-- Cargar un parser desde una ruta arbitraria
vim.treesitter.language.add("mi_dsl", { path = "/opt/parsers/mi_dsl.so" })
-- Que tipo de archivo usa que lenguaje, y al reves
print(vim.treesitter.language.get_lang("zsh"))
vim.print(vim.treesitter.language.get_filetypes("bash"))
-- Activar el arbol y el resaltado en el buffer actual
vim.treesitter.start()
Cada parser compilado declara una versión de interfaz binaria. Si esa versión queda fuera del rango que soporta tu Neovim, la carga falla con un mensaje sobre ABI incompatible y el buffer se queda sin árbol, normalmente tras actualizar Neovim sin recompilar los parsers. La cura es reinstalarlos, no reconfigurarlos. :checkhealth vim.treesitter te dice la versión de cada uno.
Del grammar.js al objeto compartido
La fuente de un parser es un único archivo grammar.js que describe la gramática mediante un DSL de JavaScript. La herramienta tree-sitter generate lo ejecuta —sí, lo ejecuta: la gramática es un programa que construye una estructura de datos— y a partir de la gramática resultante calcula las tablas de un analizador LR con resolución de conflictos al estilo GLR. La salida es src/parser.c: un archivo enorme, ilegible para un humano, que no es más que esas tablas volcadas como arrays de C junto a un intérprete minúsculo que las recorre.
# 1. de la gramatica a las tablas en C
tree-sitter generate
# 2. de las tablas a la biblioteca dinamica
cc -shared -fPIC -Os -I src \
src/parser.c src/scanner.c \
-o parser.so
# 3. probar el resultado sobre un archivo real
tree-sitter parse ejemplo.rs
flowchart TD G[grammar punto js con el DSL] --> T[tree sitter generate] T --> C[src parser punto c con las tablas LR] T --> J[grammar punto json y node types punto json] S[scanner punto c opcional] --> CC[compilador de C] C --> CC CC --> SO[biblioteca dinamica del parser] SO --> RT[directorio parser dentro del runtimepath] RT --> NV[Neovim carga el parser y construye el arbol] style G fill:#cba6f7,color:#11111b style C fill:#f9e2af,color:#11111b style SO fill:#a6e3a1,color:#11111b style NV fill:#89b4fa,color:#11111b
De ahí salen dos consecuencias prácticas. La primera: instalar un parser requiere un compilador de C en la máquina, y por eso :checkhealth te lo reclama. La segunda: el coste de generación se paga una vez, en el ordenador de quien lo compila, y en tiempo de ejecución solo queda recorrer tablas, que es la razón de fondo de que parsear sea tan barato.
Dentro de una gramática
El DSL es corto de aprender. Una regla se define como una función que recibe el conjunto de reglas y devuelve una expresión construida con combinadores: seq para secuencia, choice para alternativa, repeat para cero o más, optional para lo opcional, field para etiquetar un hijo.
module.exports = grammar({
name: "mini",
// nodos permitidos en cualquier posicion: espacios y comentarios
extras: (r) => [/\s/, r.comment],
// regla que define que es una palabra, para las palabras reservadas
word: (r) => r.identifier,
// pares de reglas cuya ambiguedad se resuelve explorando en paralelo
conflicts: (r) => [[r.expresion, r.patron]],
// tokens que produce un escaner escrito a mano en C
externals: (r) => [r.cadena_cruda, r.fin_de_bloque],
rules: {
fuente: (r) => repeat(r._sentencia),
_sentencia: (r) => choice(r.asignacion, r.llamada),
asignacion: (r) =>
seq(field("izquierda", r.identifier), "=", field("derecha", r._expr)),
suma: (r) => prec.left(1, seq(r._expr, "+", r._expr)),
producto: (r) => prec.left(2, seq(r._expr, "*", r._expr)),
comment: (_) => token(seq("--", /.*/)),
identifier: (_) => /[a-zA-Z_][a-zA-Z0-9_]*/,
},
});
Cuatro construcciones merecen atención especial. Las reglas cuyo nombre empieza por guion bajo son ocultas: sirven para organizar la gramática pero no generan nodos con nombre en el árbol, y por eso el árbol es más limpio de lo que la gramática sugiere. Las precedencias —prec, prec.left, prec.right, prec.dynamic— resuelven la ambigüedad clásica de los operadores sin reescribir la gramática en cascada. La lista conflicts declara ambigüedades que el generador no puede resolver de antemano y que el analizador explorará en paralelo, descartando las ramas que mueran. Y extras es lo que permite que un comentario aparezca en cualquier hueco sin romper nada.
Junto a src/parser.c, el generador emite dos archivos que casi nadie abre y que son oro puro: grammar.json, la gramática normalizada, y node-types.json, el catálogo de todos los tipos de nodo que ese parser puede producir, con sus campos y con qué tipos admite cada uno. Es, literalmente, el esquema del árbol y la referencia exacta contra la que escribirás consultas. Cuando dudes de si un nodo tiene campo body o block, la respuesta está ahí antes que en ninguna documentación.
# Que tipos de nodo existen y con que campos
jq 'map(select(.named)) | .[0:5]' node-types.json
# Comprobar el rendimiento de la gramatica sobre un corpus
tree-sitter parse --stat "src/**/*.rs"
Registrar una gramática que nadie ha empaquetado
Antes o después te topas con un lenguaje que no está en la lista de nvim-treesitter, o con una gramática tuya. No hace falta bifurcar el plugin: basta con añadir una entrada a su tabla de parsers desde un autocomando User con patrón TSUpdate, que es el momento en que el plugin lee esa tabla.
vim.api.nvim_create_autocmd("User", {
pattern = "TSUpdate",
callback = function()
require("nvim-treesitter.parsers").zimbu = {
install_info = {
url = "https://github.com/zimbulang/tree-sitter-zimbu",
-- claves opcionales
-- revision = "<sha>", -- commit concreto; HEAD si falta
branch = "develop", -- solo si no es la rama por defecto
location = "parser", -- subdirectorio dentro de un monorepo
generate = true, -- el repo no trae src/parser.c generado
generate_from_json = false,
queries = "queries/neovim",
},
}
end,
})
-- Si el nombre del parser no coincide con el tipo de archivo, regístralo
vim.treesitter.language.register("zimbu", { "zu" })
Si lo que tienes es una copia local en disco, sustituye url por path y el plugin usará el directorio tal cual está, ignorando branch y revision. Con la entrada en su sitio, :TSInstall zimbu compila e instala como con cualquier otro lenguaje. La misma técnica sirve para modificar un parser existente: si quieres que lua se genere siempre desde la gramática, escribe require('nvim-treesitter.parsers').lua.install_info.generate = true dentro del mismo autocomando.
nvim-treesitter compila los parsers con un compilador de C. Si la gramática que quieres añadir trae su escáner externo escrito en C++, no se puede instalar por esta vía. Es la limitación que más veces convierte un lenguaje aparentemente soportado en un lenguaje que no puedes usar.
Hay construcciones que ningún conjunto de reglas libres de contexto puede reconocer: la indentación significativa de Python, los heredoc de la shell, las cadenas crudas de Rust con un número arbitrario de almohadillas. Todas exigen contar o recordar algo. Para eso existen los externals: tokens declarados en la gramática y producidos por un scanner.c escrito a mano, con estado propio que se serializa y restaura para que el parseo incremental siga funcionando. Cuando veas un parser con scanner.c, casi siempre es por uno de esos tres motivos.
Aquí está el giro que separa a quien usa parsers de quien los escribe. Uno espera que la gramática de un lenguaje sea única y canónica: el lenguaje tiene una sintaxis, luego tiene una gramática. Falso por dos motivos que se refuerzan. Primero, la misma sintaxis admite infinitas gramáticas equivalentes que aceptan exactamente las mismas cadenas pero producen árboles distintos, y en un editor el árbol es el producto: si decides que los argumentos cuelguen de un nodo arguments en vez de colgar sueltos de la llamada, acabas de decidir qué selecciona dif, qué se pliega y qué se colorea. Segundo, la gramática oficial de un lenguaje está escrita para un compilador, que solo necesita aceptar o rechazar programas válidos; la de tree-sitter está escrita para un editor, que debe producir algo útil ante código inválido, porque ese es el estado normal del texto mientras se escribe. De ahí decisiones que a un teórico le parecerían impurezas —reglas ocultas para que el árbol no se llene de ruido, campos que no aportan poder expresivo pero hacen consultables los hijos por su papel, extras que colocan los comentarios donde estorben menos, escáneres externos que rompen la pureza libre de contexto porque el mundo real no es libre de contexto—. Una gramática de tree-sitter es, en rigor, un diseño de interfaz: la forma del árbol es la API pública sobre la que después se escriben las consultas, los objetos de texto y el plegado. Cambiar un nombre de regla rompe consultas ajenas igual que cambiar la firma de una función rompe a quien la llama. Por eso las gramáticas se versionan, se discuten y se congelan: no son la verdad sobre un lenguaje, son un contrato sobre cómo mirarlo.
- Ejecuta
:checkhealth vim.treesittery:checkhealth nvim-treesittery explica qué información aporta cada uno que el otro no. - Compara la lista de
get_installed("parsers")con la denvim_get_runtime_filesobreparser/*y explica por qué pueden no coincidir. - Clona una gramática pequeña, ejecuta
tree-sitter generatey mide el tamaño desrc/parser.c. Ábrelo y busca las tablas. - Busca en esa gramática las llamadas a
fieldy comprueba en:InspectTreeque esos campos aparecen en el árbol. - Encuentra un
parserconscanner.cen tu instalación y determina qué construcción del lenguaje obligó a escribirlo. - Registra una gramática propia con el autocomando
User TSUpdate, asóciale un tipo de archivo y comprueba con:TSLogqué ocurrió al instalarla.