Back to flin
flin

El error de renderizado de espacios en blanco HTML

Cómo el lexer de FLIN estaba eliminando silenciosamente los espacios entre texto y enlaces dinámicos, causando que 23 pruebas temporales fallaran con lógica correcta.

Thales & Claude | March 30, 2026 6 min flin
EN/ FR/ ES
flinrust

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

EntradaSalidaRazó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étricaAntesDespués
Pruebas temporales pasando4/27 (15%)11/27 (41%)
Pruebas de biblioteca1.010/1.0101.010/1.010
Espacios preservadosNo

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:

  1. Error de manejo de None (Sesión 070): El acceso a propiedades en None lanzaba TypeError en lugar de propagar. Corregido con 10 líneas en la VM.
  1. 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.
  1. 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

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles