Hay una frustración especial reservada para los errores donde la lógica es correcta pero la salida es incorrecta. Has verificado cada paso intermedio. Los datos fluyen por el sistema con precisión. La computación produce los valores correctos. Y sin embargo el resultado final, lo que el usuario realmente ve, está sutilmente roto -- no por un error de lógica sino por un defecto de renderizado.
El 7 de enero de 2026, acabábamos de corregir el error de manejo de None que desbloqueó el modelo temporal de FLIN. Cuatro de nuestras 27 pruebas de integración temporal ahora pasaban. Las 23 restantes fallaban, y a primera vista, los fallos parecían errores de lógica temporal. Pero no lo eran. Los valores temporales eran todos correctos. El error estaba en cómo se manejaban los espacios en blanco entre texto estático y enlaces dinámicos en la salida HTML.
El síntoma
Considera esta plantilla FLIN:
flin<div>
Current: {user.name}<br>
Previous: {previous.name}
</div>Un desarrollador que escribe esto espera que la salida diga "Current: Alice Smith" con un espacio entre los dos puntos y el nombre. Las aserciones de las pruebas reflejaban esta expectativa:
rustassert_output_contains(&output, "Current: Alice Smith");Pero la salida HTML real era:
html<div>Current:<span data-flin-bind="user.name">Alice Smith</span><br>
Previous:<span data-flin-bind="previous.name">Alice</span></div>Sin espacio antes de la etiqueta <span>. "Current:" se pegaba directamente al enlace dinámico. Los valores temporales "Alice Smith" y "Alice" eran perfectamente correctos -- el error no tenía nada que ver con el modelo temporal. Era un defecto de renderizado que hacía que valores correctos parecieran incorrectos en las aserciones de las pruebas.
La causa raíz
El problema residía en el lexer de FLIN, específicamente en cómo manejaba los nodos de texto durante el escaneo en modo vista. Cuando el lexer encontraba contenido de texto en una plantilla, llamaba a .trim() sobre todo el nodo de texto:
rust// src/lexer/scanner.rs:1413 -- ANTES
let trimmed = text.trim();
if !trimmed.is_empty() {
self.add_token(TokenKind::Text(trimmed.to_string()));
}El método .trim() elimina todos los espacios en blanco iniciales y finales de una cadena. Para el nodo de texto "Current: ", esto eliminaba el espacio final, produciendo "Current:". El espacio que debería haber aparecido entre el texto estático y el enlace dinámico era consumido silenciosamente por el lexer.
Espacios en blanco en línea vs. multilínea
La idea clave fue que los espacios en blanco en las plantillas tienen dos significados diferentes dependiendo del contexto:
Espacios de indentación son formateo. Existen porque el desarrollador indentó su plantilla para legibilidad. Deben eliminarse.
Espacios en línea son contenido. Existen porque el desarrollador quiere un espacio entre elementos. Deben preservarse.
La diferencia es si el texto contiene saltos de línea. Los nodos de texto multilínea están formateados con indentación y deben recortarse. Los nodos de texto en línea son contenido y deben preservarse exactamente.
El algoritmo de recorte inteligente
Reemplazamos el .trim() general con una función smart_trim_text() que maneja ambos casos:
rustfn smart_trim_text(&self, text: &str) -> String {
if !text.contains('\n') {
// Texto en línea -- preservar todos los espacios
return text.to_string();
}
// Texto multilínea -- recortar indentación
let mut result = text.to_string();
// Eliminar saltos de línea iniciales y su indentación siguiente
loop {
let before = result.len();
result = result.trim_start_matches('\n')
.trim_start_matches('\r')
.to_string();
if result.starts_with(' ') || result.starts_with('\t') {
result = result.trim_start().to_string();
}
if result.len() == before { break; }
}
// Eliminar saltos de línea finales y su indentación precedente
loop {
let before = result.len();
result = result.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string();
if result.ends_with(' ') || result.ends_with('\t') {
result = result.trim_end().to_string();
}
if result.len() == before { break; }
}
result
}El algoritmo es simple: si el texto no contiene saltos de línea, devolverlo sin cambios. Si contiene saltos de línea, eliminar iterativamente las secuencias de salto de línea más indentación iniciales y finales. Esto preserva los espacios en línea mientras elimina la indentación de formateo.
Ejemplos
| Entrada | Salida | Razón |
|---|---|---|
"Current: " | "Current: " | Sin saltos de línea, preservar todo |
"\n Hello\n " | "Hello" | Tiene saltos de línea, recortar indentación |
"Hello " | "Hello " | Sin saltos de línea, preservar espacio final |
" inline " | " inline " | Sin saltos de línea, preservar todo |
Los resultados
El impacto fue inmediato y dramático:
| Métrica | Antes | Después |
|---|---|---|
| Pruebas temporales pasando | 4/27 (15%) | 11/27 (41%) |
| Pruebas de biblioteca | 1.010/1.010 | 1.010/1.010 |
| Espacios preservados | No | Sí |
Siete pruebas temporales adicionales comenzaron a pasar, no porque cambiamos alguna lógica temporal, sino porque el renderizado ahora mostraba correctamente los valores temporales que habían sido correctos todo el tiempo.
Las 16 pruebas restantes que fallaban eran problemas de lógica genuinos -- funcionalidades no implementadas como el acceso a la propiedad .history, el renderizado condicional con valores temporales, y casos límite para acceso temporal fuera de rango. Estos eran elementos de trabajo reales, no artefactos de renderizado.
La cadena de errores
Este fue el tercer error en una cadena que bloqueaba el modelo temporal:
- Error de manejo de None (Sesión 070): El acceso a propiedades en
Nonelanzaba TypeError en lugar de propagar. Corregido con 10 líneas en la VM.
- Error de seguimiento de versiones (Sesión 073): Las versiones de las entidades no se sincronizaban desde la base de datos a la VM después de guardar, causando que el acceso temporal calculara versiones objetivo incorrectas. Corregido obteniendo los datos de la entidad después de guardar.
- Error de renderizado de espacios (Sesión 074): El lexer recortaba espacios en línea entre texto estático y enlaces dinámicos. Corregido con
smart_trim_text().
Cada error era independiente. Cada uno tenía una causa raíz diferente en un componente diferente (VM, sincronización de base de datos, lexer). Sin embargo, juntos formaban una cadena que hacía que el modelo temporal pareciera completamente roto cuando, de hecho, la lógica temporal había estado funcionando correctamente desde la Sesión 070. Las correcciones fueron acumulativas -- cada una llevó más pruebas de fallar a pasar, revelando gradualmente que los cimientos eran sólidos y solo la superficie necesitaba reparación.
Esta es la Parte 159 de la serie "Cómo construimos FLIN", que documenta cómo un CEO en Abidjan y un CTO de IA diseñaron y construyeron un lenguaje de programación desde cero.
Navegación de la serie: - [158] El error de manejo de None - [159] El error de renderizado de espacios en blanco HTML (estás aquí) - [160] Cuando la VM se bloqueó en la creación de entidades