El pipeline de rustc: del texto fuente al HIR
Compilar es una sucesión de traducciones que pierden azúcar y ganan estructura. Recorremos la primera mitad del viaje de rustc: el lexer parte el texto en tokens, el parser los organiza en un AST, la expansión de macros y la resolución de nombres lo completan, y el descenso al HIR desazucara los `for`, los `?` y los `async` a un puñado de construcciones primitivas. Por el medio, el chequeo de tipos e inferencia rellena cada hueco, y todo se orquesta no como fases rígidas sino como un sistema de consultas perezosas.
Has escrito cargo build miles de veces y has visto aparecer errores con una precisión casi inquietante. Detrás de ese comando vive rustc, y rustc no es una caja mágica: es una sucesión de traducciones. Cada etapa toma una representación de tu programa y la desciende a otra que pierde azúcar sintáctico y gana estructura explícita, hasta que lo que queda es tan regular que se puede analizar y convertir en código máquina. Este es el nivel dios: dejar de usar el compilador para empezar a mirar dentro. Abrimos la primera mitad del viaje —del texto crudo al HIR— con el chequeo de tipos entretejido por el medio.
- Trazar el recorrido fuente, tokens,
AST,HIRy qué gana y pierde cada etapa. - Entender el desazucarado: cómo
for,if let,?yasyncse reducen a primitivas. - Situar dónde ocurren la inferencia y el chequeo de tipos, y qué es una variable de inferencia.
- Comprender por qué
rustces un sistema de consultas perezosas y no una tubería rígida.
Del texto al árbol: lexing y parsing
Tu fichero .rs no es más que una tira de bytes. La primera tarea de rustc es el lexing: rustc_lexer recorre el texto y lo parte en tokens —fn, un identificador, (, un literal 5—, cada uno anotado con su span, el rango de bytes de donde salió. Ese span es lo que luego permite que un error apunte con el dedo a la columna exacta. El lexer no entiende gramática; solo reconoce las piezas atómicas del lenguaje.
Con los tokens en mano, el parser (rustc_parse) los organiza en un árbol de sintaxis abstracta, el AST (rustc_ast). El AST es un espejo fiel de lo que escribiste: hay un nodo por cada expresión, cada declaración, cada bloque, y su forma sigue la de tu código carácter a carácter. Un if es un nodo if; un for es un nodo for. Todavía no se ha simplificado nada.
Sobre el AST ocurren dos procesos entrelazados que a menudo se pasan por alto: la expansión de macros (rustc_expand) reescribe cada invocación —println!, tus macro_rules!, tus derives— hasta que no queda ninguna, y la resolución de nombres (rustc_resolve) ata cada identificador a la cosa que designa: esta v es aquella variable, este HashMap es aquel tipo del std. Solo cuando ambos terminan, el árbol está completo y listo para descender.
Un detalle que revela la elegancia del diseño: entre el lexer y el parser, rustc agrupa los tokens en árboles de tokens (token trees), emparejando cada delimitador de apertura con su cierre. Esa estructura intermedia es la que sostiene la higiene de las macros que estudiaste: una macro recibe y produce árboles de tokens equilibrados, no texto plano, y por eso no puede romper el emparejamiento de paréntesis ni filtrar variables como hacía el preprocesador de C. La forma de los tokens ya carga con una garantía antes de que el parser los toque.
El AST sabe cómo se escribió el programa, no qué significa. No conoce tipos, no sabe si x + y es válido, no ha resuelto qué método invoca .push. Es puro reflejo de la gramática. Toda la inteligencia semántica —tipos, préstamos, capacidades— se añade en las etapas siguientes, y por eso el compilador necesita descender a representaciones cada vez menos parecidas a tu texto.
El descenso al HIR: desazucarar
El AST es cómodo para las herramientas que trabajan sobre la superficie —rustfmt, clippy en parte—, pero es un mal sustrato para razonar. Tiene demasiadas formas distintas de decir lo mismo. Así que rustc lo desciende (lowering) a la High-level IR, el HIR (rustc_hir): un árbol todavía reconocible, pero desazucarado y con cada ruta ya resuelta y cada nodo identificado por un HirId.
Desazucarar significa reducir el azúcar sintáctico a un núcleo pequeño de construcciones primitivas. El caso canónico es el for, que no existe en el HIR: se reescribe como un loop sobre IntoIterator.
// Lo que escribes
for x in coleccion {
consumir(x);
}
// A lo que desciende en el HIR (conceptual)
{
match IntoIterator::into_iter(coleccion) {
mut iter => loop {
match iter.next() {
Some(x) => { consumir(x); }
None => break,
}
},
}
}
El operador ? recibe el mismo trato: es puro azúcar sobre un match que ramifica entre continuar y retornar. Bajo la edición 2024 se apoya en el trait Try y en FromResidual.
// Lo que escribes
let valor = puede_fallar()?;
// A lo que desciende (conceptual)
let valor = match Try::branch(puede_fallar()) {
ControlFlow::Continue(v) => v,
ControlFlow::Break(residual) => return FromResidual::from_residual(residual),
};
La lista es larga y reveladora: if let y while let se reducen a match; los rangos a..b a un Range { start, end }; los literales de closure a una estructura anónima que implementa los traits Fn; y una async fn se reescribe como una función que devuelve impl Future, cuyo cuerpo se transforma en una máquina de estados. Todo el lenguaje expresivo que usas a diario se apoya, en el HIR, sobre un puñado de primitivas: match, loop, llamadas y asignaciones.
AST · el espejo de tu texto
Un nodo por construcción sintáctica. Conserva for, ?, if let. Es la superficie: sabe cómo escribiste, no qué significa.
HIR · el núcleo desazucarado
Mismo árbol, sin azúcar. for y ? reducidos a match y loop, rutas resueltas, cada nodo con su HirId. Es donde empieza el análisis semántico.
Chequeo de tipos e inferencia: rellenar los huecos
Sobre el HIR actúa el corazón semántico del compilador: el análisis y chequeo de tipos (rustc_hir_analysis y rustc_hir_typeck). Aquí se resuelven los traits, se comprueba que cada operación es legal para sus operandos, y se ejecuta la inferencia de tipos que te ahorra anotar casi todo.
La inferencia funciona por unificación. Cuando escribes let n = 5, rustc no fija de inmediato el tipo: crea una variable de inferencia, un hueco anotado internamente como ?0, y luego lo va estrechando según cómo se use n en el resto del cuerpo. Cada uso impone una restricción; el motor unifica todas las restricciones hasta obtener un tipo concreto. Si al final quedan enteros sin determinar, aplica el defecto: i32.
fn main() {
let n = 5; // ?0: variable de inferencia
let v = Vec::new(); // ?1: Vec<?2>, el elemento aun es desconocido
procesar(&v, n); // los usos imponen restricciones sobre ?0, ?1 y ?2
// sin mas pistas, un entero pendiente por defecto es i32
}
Entrelazado con la inferencia corre el solucionador de traits (trait solver), el subsistema que responde a la pregunta constante del chequeo: ¿implementa este tipo el trait que aquí se exige? Cada impl del programa es una regla, y resolver T: Display es en esencia una búsqueda entre esas reglas, a menudo recursiva —para satisfacer un bound hay que satisfacer los suyos—. Es una de las partes más sutiles del compilador, y una en plena renovación: el motor clásico convive en 2026 con un next-gen trait solver que lo reemplaza pieza a pieza. Cuando lees un error de la forma “the trait Foo is not implemented”, es esta maquinaria la que ha agotado su búsqueda sin hallar una regla que encaje.
Una sutileza que define el carácter de Rust: la inferencia es local a cada cuerpo de función. Dentro de un cuerpo, rustc deduce casi todo; pero las firmas —parámetros y retorno— deben anotarse siempre. No es una limitación técnica, es una decisión de diseño: hace que el tipo de cada función sea legible sin analizar su cuerpo, que los errores queden acotados a una función, y que el compilador no tenga que resolver un sistema global gigante. Aquí, en el chequeo de tipos, es donde nacen la mayoría de los errores que lees a diario. Superada esta etapa, el HIR se convierte en THIR (Typed HIR), un árbol ya completamente tipado que sirve de trampolín hacia el MIR de la próxima lección.
La regla no es arbitraria. La inferencia local mantiene el problema pequeño y los mensajes de error cerca de su causa. Si Rust infiriese también las firmas, un cambio en el cuerpo de una función podría alterar en silencio su tipo público y romper a sus llamadores a distancia. Anotar la frontera y deducir el interior es el equilibrio exacto entre comodidad y previsibilidad.
rustc no es una tubería: es un sistema de consultas
Es tentador imaginar estas etapas como una cinta transportadora rígida: primero todo el lexing, luego todo el parsing, luego todo el typeck. Pero rustc moderno no funciona así. Su arquitectura es un sistema de consultas (queries) perezosas, centrado en una estructura llamada TyCtxt (el typing context).
En vez de ejecutar fases en orden, el compilador pregunta: “¿cuál es el tipo del item X?”, “¿cuál es el MIR de la función Y?”. Cada consulta desencadena exactamente el trabajo necesario para responderla —que a su vez lanza otras consultas— y su resultado se memoiza: si dos partes del programa piden lo mismo, se calcula una sola vez. Es programación dirigida por demanda, no por planificación.
flowchart TB src[Codigo fuente rs] --> lex[Lexer produce tokens] lex --> par[Parser produce el AST] par --> exp[Expansion de macros y resolucion de nombres] exp --> hir[Descenso al HIR desazucarado] hir --> tyck[Chequeo de tipos e inferencia] tyck --> thir[THIR el HIR ya tipado] thir --> mir[Descenso al MIR] mir --> sig[Siguiente leccion borrow check y codegen] style src fill:#89b4fa,color:#11111b style hir fill:#cba6f7,color:#11111b style tyck fill:#fab387,color:#11111b style mir fill:#a6e3a1,color:#11111b
Esta arquitectura no es un capricho de ingeniería: es lo que hace posible la compilación incremental. Como cada consulta conoce sus dependencias, cuando cambias una función rustc puede identificar con precisión qué resultados quedaron obsoletos y recalcular solo esos, reutilizando el resto de la caché de la compilación anterior. La cinta transportadora rígida tendría que rehacerlo todo; el sistema de consultas rehace lo mínimo.
Contempla la forma del viaje completo. No hay una única representación de tu programa dentro del compilador: hay una sucesión de ellas, y cada descenso es un acto deliberado de olvido y ganancia. El AST recuerda tu sintaxis exacta pero ignora todo significado. El HIR olvida el azúcar —tus for, tus ?, tus async— y a cambio reduce el lenguaje entero a un núcleo tan pequeño que se puede razonar sobre él. El chequeo de tipos olvida la ambigüedad y gana certeza: cada hueco relleno, cada trait resuelto. Y esto revela una verdad profunda sobre los lenguajes: el Rust expresivo y humano que escribes es, en el fondo, una fachada construida sobre un puñado diminuto de primitivas. La riqueza sintáctica no está en el motor; está en el azúcar que el compilador retira antes de pensar. Entender esto reordena tu forma de leer errores: un mensaje sobre into_iter en un for que nunca escribiste no es un fallo del compilador filtrándose, es el HIR mostrándote la maquinaria bajo el azúcar. Y reordena tu forma de leer el lenguaje entero: cuando alguien propone una sintaxis nueva, la pregunta del compilador no es cómo se ejecuta, sino a qué primitiva del núcleo desciende. Quien entiende el pipeline deja de ver Rust como una lista de características y empieza a verlo como un núcleo pequeño y elegante vestido de muchas formas. Esa es la mirada del que construye lenguajes, no solo del que los usa.
rustc traduce tu programa por etapas: el lexer produce tokens, el parser un AST fiel a tu sintaxis, la expansión de macros y la resolución de nombres lo completan, y el descenso al HIR lo desazucara —for, ?, if let y async se reducen a match, loop y llamadas—. Sobre el HIR corre el chequeo de tipos con inferencia local por unificación, que produce el THIR tipado camino del MIR. Y todo se orquesta como un sistema de consultas perezosas y memoizadas sobre TyCtxt, lo que habilita la compilación incremental.
- Escribe un
for x in vec![1, 2, 3] { }y describe, sin ejecutar nada, a quéloopconmatchsobreIntoIteratordesciende en elHIR. - Toma una función con
let dato = leer()?;y reescribe a mano elmatchsobreTry::branchyFromResidualal que se desazucara. - Explica por qué el
ASTno puede detectar un error de tipos y en qué etapa exacta aparece ese error. - Razona qué es una variable de inferencia y por qué
let n = 5;acaba siendoi32si nada más la restringe. - Argumenta cómo el sistema de consultas hace posible la compilación incremental frente a una tubería de fases rígidas.