Back to flin
flin

Des diagnostics d'erreurs qui aident vraiment

Comment les diagnostics d'erreurs de FLIN aident les développeurs : emplacements source, sortie colorée et messages lisibles par l'humain.

Thales & Claude | March 30, 2026 14 min flin
EN/ FR/ ES
flinerrorsdiagnosticsdeveloper-experiencecompilerux

La plupart des erreurs de compilateur sont écrites pour les ingénieurs de compilateurs. Celles de FLIN sont écrites pour les humains.

Voici un message d'erreur typique d'un compilateur C :

error: use of undeclared identifier 'count'

Voici la même erreur dans FLIN :

error[E0001]: undefined variable 'count'
  --> app.flin:12:15
   |
12 |     total = count + tax
   |             ^^^^^ variable not declared in this scope
   |
   = help: Did you mean 'cost'? (declared on line 3)
   = note: Variables must be declared before use in FLIN

La différence n'est pas cosmétique. L'erreur C vous dit ce qui s'est passé. L'erreur FLIN vous dit ce qui s'est passé, où cela s'est passé, ce que vous vouliez probablement dire et comment le langage fonctionne. L'une vous arrête. L'autre vous apprend.

Cet article couvre la Session 018, où nous avons construit le système de diagnostics d'erreurs de FLIN : la struct Diagnostic, le formatage du contexte source, la sortie colorée dans le terminal, le moteur de suggestions et le DiagnosticBag pour collecter plusieurs erreurs.

L'architecture des diagnostics

Le système de diagnostics comporte quatre composants : la struct Diagnostic (les données d'erreur), le DiagnosticReporter (le formateur), le DiagnosticBag (le collecteur) et les points d'intégration avec le lexer, le parser et le vérificateur de types.

La struct Diagnostic capture tout ce qui concerne une seule erreur :

rustpub struct Diagnostic {
    pub level: DiagnosticLevel,
    pub code: Option<String>,
    pub message: String,
    pub span: Option<Span>,
    pub details: Vec<String>,
    pub suggestions: Vec<String>,
    pub notes: Vec<Diagnostic>,
}

pub enum DiagnosticLevel {
    Error,
    Warning,
    Note,
    Help,
}

La conception est récursive : un Diagnostic peut contenir des sous-diagnostics dans son champ notes. Cela permet des chaînes d'erreurs riches. Une erreur d'incompatibilité de type peut inclure un diagnostic Note pointant vers la déclaration originale (« déclaré ici »), et un diagnostic Help suggérant la correction.

La construction utilise un pattern builder :

rustlet diagnostic = Diagnostic::error("undefined variable 'foo'")
    .with_code("E0001")
    .at_span(span)
    .with_detail("Variables must be declared before use")
    .with_suggestion("Did you mean 'bar'?")
    .with_note(Diagnostic::note("'bar' declared here").at_span(bar_span));

Chaque méthode retourne self, permettant une construction chaînée. C'est délibérément verbeux -- chaque information dans le diagnostic est explicitement spécifiée, et le code du compilateur rend clair quelles informations l'utilisateur verra. Il n'y a pas de formatage magique, pas de génération implicite de messages. Si une suggestion apparaît dans la sortie, un chemin de code a appelé .with_suggestion().

Formatage du contexte source

La partie la plus utile d'un message d'erreur est le contexte source -- la ligne de code où l'erreur s'est produite, avec la zone problématique soulignée. Produire cette sortie nécessite trois informations : le texte source, le span (ligne/colonne de début et de fin) et le nom du fichier.

Le DiagnosticReporter gère le formatage :

rustpub struct DiagnosticReporter<'a> {
    source: &'a str,
    file_name: String,
    lines: Vec<&'a str>,
}

impl<'a> DiagnosticReporter<'a> {
    pub fn new(source: &'a str) -> Self {
        let lines: Vec<&str> = source.lines().collect();
        DiagnosticReporter {
            source,
            file_name: String::from("<input>"),
            lines,
        }
    }

    pub fn with_file_name(mut self, name: &str) -> Self {
        self.file_name = name.to_string();
        self
    }

    pub fn report(&self, diagnostic: &Diagnostic) -> String {
        let mut output = String::new();

        // Niveau et message
        output.push_str(&format!(
            "{}{}: {}\n",
            self.format_level(diagnostic.level),
            diagnostic.code.as_ref()
                .map(|c| format!("[{}]", c))
                .unwrap_or_default(),
            diagnostic.message
        ));

        // Emplacement source et contexte
        if let Some(span) = &diagnostic.span {
            self.format_source_context(&mut output, span);
        }

        // Détails, suggestions, notes
        for detail in &diagnostic.details {
            output.push_str(&format!("   = note: {}\n", detail));
        }
        for suggestion in &diagnostic.suggestions {
            output.push_str(&format!("   = help: {}\n", suggestion));
        }
        for note in &diagnostic.notes {
            output.push_str(&self.report(note));
        }

        output
    }
}

Le formateur de contexte source extrait la ligne pertinente, l'affiche avec son numéro de ligne et dessine un caret ou soulignement sous le span de l'erreur :

  --> app.flin:12:15
   |
12 |     total = count + tax
   |             ^^^^^ variable not declared in this scope

L'alignement est précis. La flèche --> pointe vers le fichier, la ligne et la colonne. Les caractères pipe créent une gouttière visuelle. Le soulignement utilise des caractères ^ correspondant à la largeur exacte du span problématique. Si le span fait un seul caractère, il y a un ^. S'il couvre un identifiant entier, le soulignement couvre tout le nom.

Pour les spans multi-lignes, le formateur affiche les lignes de début et de fin avec une barre verticale les connectant. Cela gère les cas comme les chaînes non terminées ou les crochets non appariés qui s'étendent sur plusieurs lignes.

Sortie colorée dans le terminal

La couleur transforme un mur de texte en un rapport scannable. Le système de diagnostics utilise le crate colored pour la sortie terminal :

  • Les labels Error sont rouges et en gras
  • Les labels Warning sont jaunes et en gras
  • Les labels Note sont bleus
  • Les labels Help sont verts
  • Le code source est affiché dans la couleur par défaut
  • Les soulignements utilisent la même couleur que leur niveau de diagnostic

Les couleurs sont appliquées à l'étape de formatage, pas stockées dans la struct Diagnostic. Cette séparation est importante parce que le même diagnostic pourrait être rendu dans un terminal (avec couleurs), dans un fichier journal (sans couleurs) ou dans une réponse d'API JSON (avec un balisage sémantique au lieu de codes ANSI).

Quand le terminal ne supporte pas la couleur (détecté via isatty()), le crate colored revient automatiquement au texte brut. La sortie des diagnostics reste lisible sans couleur -- le formatage structurel (flèches, pipes, soulignements) transmet la hiérarchie même en monochrome.

Le moteur de suggestions

Les suggestions sont la différence entre un message d'erreur qui vous arrête et un qui vous aide. Le système de diagnostics de FLIN supporte trois types de suggestions :

Suggestions textuelles sont des indices en texte brut ajoutés au diagnostic :

   = help: Did you mean 'cost'? (declared on line 3)

Celles-ci sont générées par la phase du compilateur qui a détecté l'erreur. Le vérificateur de types, par exemple, maintient un ensemble de tous les noms de variables déclarés. Quand il rencontre une variable indéfinie, il calcule la distance de Levenshtein entre le nom indéfini et tous les noms connus, et suggère la correspondance la plus proche si la distance est en dessous d'un seuil.

Suggestions contextuelles fournissent des informations sur la règle du langage qui a été violée :

   = note: Variables must be declared before use in FLIN
   = help: Declare 'count' with: count = 0

Celles-ci sont particulièrement précieuses pour les nouveaux développeurs FLIN qui apprennent encore le langage. L'erreur ne dit pas juste « c'est faux » -- elle dit « voici comment FLIN fonctionne » et « voici ce que vous devriez écrire à la place ».

Suggestions d'emplacements liés pointent vers d'autres endroits dans le code qui sont pertinents pour comprendre l'erreur :

error[E0003]: type mismatch
  --> app.flin:12:15
   |
12 |     count = "hello"
   |             ^^^^^^^ expected int, found text
   |
note: 'count' was previously declared as int
  --> app.flin:5:1
   |
5  |     count = 0
   |     ^^^^^^^^^ first assignment (inferred type: int)

Le diagnostic note imbriqué a son propre span pointant vers la ligne 5, où count a été assigné pour la première fois. L'utilisateur voit les deux emplacements en même temps et comprend immédiatement pourquoi la réaffectation est invalide.

Patterns d'erreur courants

Le système de diagnostics gère les erreurs de chaque phase du compilateur. Voici les patterns pour chacune :

Erreurs du lexer attrapent les entrées malformées avant l'analyse :

error[L0001]: unterminated string literal
  --> app.flin:7:12
   |
7  |     name = "hello
   |            ^ string starts here but never ends
   |
   = help: Add a closing " at the end of the string
error[L0002]: unexpected character '#'
  --> app.flin:3:1
   |
3  |     #comment
   |     ^ unexpected character
   |
   = help: FLIN uses // for comments, not #

Erreurs du parser attrapent les problèmes syntaxiques :

error[P0001]: expected '}' to close entity declaration
  --> app.flin:4:1
   |
1  |     entity User {
   |                 - opening brace here
...
4  |     save user
   |     ^^^^ expected '}', found keyword 'save'
   |
   = help: Add '}' after the last field declaration
error[P0003]: mismatched closing tag
  --> app.flin:8:3
   |
5  |     <div>
   |      --- opening tag
...
8  |     </span>
   |       ^^^^ expected </div>, found </span>

Erreurs du vérificateur de types attrapent les problèmes sémantiques :

error[T0001]: cannot add text and int
  --> app.flin:6:15
   |
6  |     result = name + count
   |              ^^^^^^^^^^^^ operator '+' cannot be applied to text and int
   |
   = help: Convert count to text first: name + to_text(count)
error[T0004]: unknown entity 'Usr'
  --> app.flin:10:1
   |
10 |     save Usr { name: "Juste" }
   |          ^^^ entity 'Usr' not declared
   |
   = help: Did you mean 'User'? (declared on line 1)

Chaque code d'erreur (L0001, P0001, T0001) est stable et documenté. Les utilisateurs peuvent rechercher le code pour trouver des explications détaillées, et les outils peuvent catégoriser les erreurs programmatiquement.

Le DiagnosticBag : plusieurs erreurs à la fois

Un compilateur qui s'arrête à la première erreur force l'utilisateur dans un cycle frustrant : corriger une erreur, recompiler, voir l'erreur suivante, la corriger, recompiler, voir une autre erreur. Les compilateurs modernes rapportent autant d'erreurs qu'ils peuvent trouver en un seul passage.

FLIN utilise un DiagnosticBag pour collecter les diagnostics pendant la compilation :

rustpub struct DiagnosticBag {
    diagnostics: Vec<Diagnostic>,
}

impl DiagnosticBag {
    pub fn new() -> Self {
        DiagnosticBag { diagnostics: Vec::new() }
    }

    pub fn add(&mut self, diagnostic: Diagnostic) {
        self.diagnostics.push(diagnostic);
    }

    pub fn has_errors(&self) -> bool {
        self.diagnostics.iter().any(|d| d.level == DiagnosticLevel::Error)
    }

    pub fn error_count(&self) -> usize {
        self.diagnostics.iter()
            .filter(|d| d.level == DiagnosticLevel::Error)
            .count()
    }

    pub fn report_all(&self, reporter: &DiagnosticReporter) -> String {
        let mut output = String::new();
        for diagnostic in &self.diagnostics {
            output.push_str(&reporter.report(diagnostic));
            output.push('\n');
        }

        let errors = self.error_count();
        let warnings = self.diagnostics.iter()
            .filter(|d| d.level == DiagnosticLevel::Warning)
            .count();

        if errors > 0 {
            output.push_str(&format!(
                "error: compilation failed with {} error{} and {} warning{}\n",
                errors,
                if errors == 1 { "" } else { "s" },
                warnings,
                if warnings == 1 { "" } else { "s" },
            ));
        }

        output
    }
}

Le sac collecte les erreurs, avertissements, notes et messages d'aide. À la fin de la compilation, report_all les formate dans l'ordre d'occurrence et ajoute une ligne de résumé (« compilation failed with 3 errors and 1 warning »).

L'interaction clé est avec la récupération d'erreurs. Quand le parser rencontre un token inattendu, il ajoute un diagnostic au sac puis appelle synchronize() pour sauter des tokens jusqu'à atteindre une frontière d'instruction (un retour à la ligne suivi d'un mot-clé, ou un < qui commence un élément de vue). Il reprend ensuite l'analyse à partir de la position récupérée. Cela signifie qu'un programme FLIN avec cinq erreurs de syntaxe rapportera typiquement les cinq en une seule tentative de compilation, plutôt que de forcer cinq cycles compiler-corriger-compiler.

Intégration avec le pipeline de compilation

Chaque phase du compilateur possède ses types d'erreurs et les convertit en diagnostics à la frontière :

rustpub fn compile(source: &str) -> Result<Chunk, DiagnosticBag> {
    let mut bag = DiagnosticBag::new();

    // Phase 1 : Lexer
    let tokens = match Lexer::new(source).tokenize() {
        Ok(tokens) => tokens,
        Err(lex_errors) => {
            for err in lex_errors {
                bag.add(err.into_diagnostic());
            }
            return Err(bag);
        }
    };

    // Phase 2 : Parser (avec récupération)
    let (ast, parse_errors) = Parser::new(tokens).parse_with_recovery();
    for err in parse_errors {
        bag.add(err.into_diagnostic());
    }
    if bag.has_errors() {
        return Err(bag);
    }

    // Phase 3 : Vérification de types
    let typed_ast = match TypeChecker::new().check(ast) {
        Ok(typed) => typed,
        Err(type_errors) => {
            for err in type_errors {
                bag.add(err.into_diagnostic());
            }
            return Err(bag);
        }
    };

    // Phase 4 : Génération
    let chunk = CodeGenerator::new().generate(&typed_ast)
        .map_err(|e| {
            bag.add(e.into_diagnostic());
            bag
        })?;

    Ok(chunk)
}

Chaque type d'erreur (LexError, ParseError, TypeError, CodeGenError) implémente une méthode into_diagnostic() qui convertit la représentation d'erreur interne en un Diagnostic avec le niveau, le code, le message, le span et les suggestions appropriés. Cela garde la logique de conversion proche du code qui génère l'erreur -- le parser sait le mieux quelle suggestion donner pour une erreur d'analyse.

La philosophie de conception

Trois principes ont guidé le système de diagnostics.

Montrer, ne pas dire. Chaque erreur inclut la ligne source où le problème s'est produit. L'utilisateur n'a jamais à ouvrir un fichier et compter les numéros de ligne -- le message d'erreur lui montre le code exact et pointe vers le caractère exact. Cela semble évident, mais de nombreux compilateurs produisent encore des erreurs comme « line 42: type error » sans le contexte source.

Suggérer, ne pas juste rejeter. Quand le compilateur peut inférer ce que l'utilisateur voulait probablement dire, il le dit. Une variable indéfinie avec une correspondance proche reçoit une suggestion « did you mean? ». Une accolade fermante manquante reçoit une suggestion « add } after line N ». Une incompatibilité de type reçoit une suggestion « convert with to_text() ». Ces suggestions ne sont pas toujours correctes, mais même quand elles sont fausses, elles donnent à l'utilisateur un point de départ pour comprendre l'erreur.

Respecter le temps de l'utilisateur. Rapporter toutes les erreurs en une fois. Utiliser la couleur pour rendre la sortie scannable. Mettre les informations les plus importantes (le message d'erreur et la ligne source) en haut, et les informations supplémentaires (notes, suggestions) en dessous. Un développeur devrait pouvoir jeter un coup d'oeil à la sortie et savoir quoi corriger, sans lire chaque mot.

Test du système de diagnostics

La Session 018 a ajouté 68 nouveaux tests, portant le total de 522 à 590. Les tests du système de diagnostics couvraient :

  • Formatage du contexte source (extraction correcte de ligne, largeur de soulignement, spans multi-lignes)
  • Sortie colorée (codes d'échappement ANSI pour chaque niveau de diagnostic)
  • Formatage des suggestions (suggestions simples, suggestions multiples, notes imbriquées)
  • Comportement du DiagnosticBag (comptage d'erreurs, vérification has_errors, ordonnancement)
  • Intégration avec le pipeline de compilation (les erreurs de lexer produisent des diagnostics, les erreurs de parser produisent des diagnostics avec récupération, les erreurs de type incluent le contexte source)

Les tests utilisent des assertions de type snapshot : la sortie attendue est une chaîne multi-lignes qui correspond exactement à la sortie du formateur, y compris l'alignement des espaces. Cela garantit que les changements de formatage sont détectés par la suite de tests -- un soulignement décalé ou un caractère pipe manquant causera un échec de test.

Ce que coûtent de bons diagnostics

Le système de diagnostics a ajouté environ 570 lignes de Rust à la base de code. Il n'a nécessité aucun changement aux implémentations du lexer, du parser ou du vérificateur de types -- seulement l'ajout de méthodes into_diagnostic() sur les types d'erreur existants et le nouveau module diagnostic.rs.

Le coût à l'exécution est négligeable. Les diagnostics ne sont construits que sur le chemin d'erreur -- une compilation réussie n'alloue jamais un Diagnostic. Sur le chemin d'erreur, le coût de formatage de quelques messages d'erreur est invisible par rapport au travail de compilation qui a précédé l'erreur.

Le coût de développement a été d'une session -- environ 30 minutes. Mais la valeur continue se mesure dans chaque message d'erreur que chaque développeur FLIN verra jamais. Un message d'erreur inutile est une taxe sur chaque futur utilisateur. Un message d'erreur utile est un investissement qui se rentabilise des milliers de fois.

L'équipe du compilateur Rust l'a compris. Elm l'a compris. Maintenant FLIN le comprend. Les messages d'erreur ne sont pas une réflexion après coup. Ce sont une fonctionnalité produit.


Ceci est la partie 19 de la série « Comment nous avons construit FLIN », documentant comment un CEO à Abidjan et un CTO IA ont construit un compilateur de langage de programmation en sessions mesurées en minutes, pas en mois.

Prochain dans la série : Le pipeline de compilation complet de bout en bout -- comment le code source entre, comment six phases le transforment et comment une application en cours d'exécution en sort.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles