Back to flin
flin

Le pipeline de compilation complet, de bout en bout

Le pipeline de compilation complet de FLIN : six phases du code source à l'application en cours d'exécution, expliquées de bout en bout.

Thales & Claude | March 30, 2026 15 min flin
EN/ FR/ ES
flinpipelinecompilerarchitectureoverviewphases

Le code source entre. Une application en cours d'exécution sort. Entre les deux, six phases transforment le texte en exécution.

Les articles précédents de cette série ont examiné des phases individuelles : le générateur de code, le format de bytecode, le système de diagnostics d'erreurs. Cet article prend du recul et suit un programme FLIN unique à travers l'ensemble du pipeline de compilation, du premier caractère lu par le lexer à la dernière instruction exécutée par la machine virtuelle. L'objectif est de montrer comment les phases se connectent -- comment la sortie de chaque phase devient l'entrée de la suivante, quelles informations sont préservées à travers les frontières, lesquelles sont jetées et pourquoi.

Le programme que nous allons suivre est une application FLIN simplifiée :

flinentity Todo {
    title: text
    done: bool = false
}

todos = Todo.all

<div>
    {for todo in todos}
        <p class={if todo.done "completed" else "pending"}>
            {todo.title}
        </p>
    {/for}
</div>

Ce programme déclare une entité, interroge toutes les instances de la base de données et les rend dans une boucle avec un style conditionnel. Il exerce cinq des fonctionnalités distinctives de FLIN : les entités, les requêtes, les vues, les boucles et les conditionnels. Suivons-le à travers le pipeline.

Phase 1 : analyse lexicale

Le lexer lit le source comme un flux de caractères et produit un flux de tokens. Chaque token enregistre ce qu'il est (son TokenKind), où il est (son Span) et à quoi le texte original ressemblait (son lexeme).

Le lexer opère en trois modes. Il commence en mode Code. Quand il rencontre <div>, le < suivi d'un caractère alphabétique déclenche un basculement en mode View. À l'intérieur du mode vue, { bascule en mode ViewExpression, et } revient en mode View.

Pour notre programme, le flux de tokens commence :

Keyword(Entity)   "entity"     1:1
Identifier("Todo") "Todo"      1:8
LeftBrace          "{"         1:13
Newline                        1:14
Identifier("title") "title"   2:5
Colon              ":"         2:10
Keyword(Text)      "text"      2:12
Newline                        2:16
Identifier("done")  "done"    3:5
Colon              ":"         3:9
Keyword(Bool)      "bool"      3:11
Equal              "="         3:16
Keyword(False)     "false"     3:18
Newline                        3:23
RightBrace         "}"         4:1
Newline                        4:2
Identifier("todos") "todos"   6:1
Equal              "="         6:8
Identifier("Todo")  "Todo"    6:10
Dot                "."         6:14
Keyword(All)       "all"       6:15
Newline                        6:18
...

Deux observations. Premièrement, chaque token porte une position précise -- ligne et colonne pour les messages d'erreur lisibles par l'humain, offset en octets pour le traitement machine. Cette information de position se propage à travers l'ensemble du pipeline et apparaît finalement dans les diagnostics d'erreurs et les informations de débogage. Deuxièmement, le lexer résout l'ambiguïté entre < comme opérateur de comparaison et < comme ouverture de balise selon le contexte. En mode code, < suivi d'une lettre produit TagOpen. En mode expression, < suivi d'un chiffre ou d'un espace produit Less.

La sortie du lexer est Vec<Token> -- une séquence plate et ordonnée sans hiérarchie. Toute structure a disparu. Le lexer n'a aucune idée de ce à quoi ressemble une déclaration d'entité ; il sait juste que entity est un mot-clé, Todo est un identifiant et { est une accolade ouvrante. La structure est le travail du parser.

Phase 2 : analyse syntaxique

Le parser consomme le flux de tokens et construit un arbre syntaxique abstrait. Là où le lexer voit une séquence plate, le parser voit une structure hiérarchique : des instructions contenant des expressions, des éléments de vue contenant des enfants, des blocs contenant des instructions.

Le parser utilise la descente récursive pour les instructions et un parser de Pratt pour les expressions. L'analyse des instructions distribue sur le token de tête :

  • Keyword(Entity) -- analyser la déclaration d'entité
  • TagOpen -- analyser l'élément de vue
  • Keyword(Save) -- analyser l'instruction save
  • Identifier suivi de Equal -- analyser la déclaration ou l'affectation de variable
  • Identifier suivi de Dot -- analyser l'expression (requête d'entité, accès au champ)

Pour notre programme, l'AST ressemble à ceci :

Program
  Stmt::EntityDecl
    name: "Todo"
    fields:
      FieldDecl { name: "title", type: Text, default: None }
      FieldDecl { name: "done", type: Bool, default: Some(Bool(false)) }

  Stmt::VarDecl
    name: "todos"
    type_ann: None
    value: Expr::EntityQuery { entity: "Todo", operation: All }

  Stmt::View
    ViewElement
      tag: "div"
      attributes: []
      children:
        ViewChild::For
          variable: "todo"
          iterable: Expr::Identifier("todos")
          body:
            ViewChild::Element
              tag: "p"
              attributes:
                ViewAttribute
                  name: "class"
                  value: Dynamic(
                    Expr::If {
                      condition: Expr::FieldAccess {
                        object: Identifier("todo"),
                        field: "done"
                      },
                      then: Expr::String("completed"),
                      else: Expr::String("pending")
                    }
                  )
              children:
                ViewChild::Expression(
                  Expr::FieldAccess {
                    object: Identifier("todo"),
                    field: "title"
                  }
                )

Le parser a résolu chaque ambiguïté. Todo.all n'est pas un accès au champ suivi d'un identifiant -- c'est une EntityQuery avec l'opération All. Le bloc {for todo in todos} n'est pas une série d'identifiants -- c'est un ViewFor avec une variable liée, une expression itérable et un corps d'enfants de vue. Le {if todo.done "completed" else "pending"} à l'intérieur de l'attribut class est une expression conditionnelle en ligne.

L'AST préserve les spans source sur chaque noeud. Quand le vérificateur de types rencontre plus tard une erreur dans l'expression conditionnelle, il peut pointer vers la position exacte dans le fichier source parce que le noeud Expr::If porte le span du mot-clé if à la chaîne "pending" fermante.

Phase 3 : analyse sémantique

Le vérificateur de types parcourt l'AST et vérifie que le programme est sémantiquement valide. Il maintient une table de symboles qui associe des noms à des types et des portées.

Pour notre programme, le vérificateur de types effectue ces étapes :

  1. Enregistrer le schéma d'entité. entity Todo { title: text, done: bool = false } ajoute Todo au registre d'entités avec deux champs.
  1. Typer la requête. Todo.all -- chercher Todo dans le registre d'entités, vérifier qu'il existe, déterminer que .all retourne [Todo] (une liste d'entités Todo).
  1. Typer la variable. todos = Todo.all -- inférer que todos a le type [Todo].
  1. Entrer dans la boucle for. for todo in todos -- vérifier que todos est itérable (c'est une liste), lier todo avec le type Todo dans la portée de la boucle.
  1. Typer le conditionnel. if todo.done -- vérifier que todo a un champ done, vérifier que done est Bool (convenable pour une condition). La branche then ("completed") est Text, la branche else ("pending") est Text, donc l'expression conditionnelle a le type Text.
  1. Typer l'attribut. class={...} -- vérifier que la valeur d'attribut dynamique est Text. Elle l'est, parce que l'expression conditionnelle se résout en Text.
  1. Typer la liaison de texte. {todo.title} -- vérifier que todo a un champ title, vérifier que le résultat est affichable. Text est affichable.

Si le programmeur avait écrit {todo.titl} (une faute de frappe), le vérificateur de types rapporterait :

error[T0005]: entity 'Todo' has no field 'titl'
  --> app.flin:11:14
   |
11 |             {todo.titl}
   |                   ^^^^ unknown field
   |
   = help: Did you mean 'title'?

Le vérificateur de types ne transforme pas l'AST -- il le valide. La sortie de la Phase 3 est le même AST avec des annotations de type attachées aux noeuds d'expression. Le générateur de code peut s'appuyer sur ces annotations pour émettre du bytecode correct sans réanalyser les types.

Phase 4 : génération de code

Le générateur de code parcourt l'AST typé et émet du bytecode. Chaque noeud de l'AST se traduit en une séquence d'opcodes. La sortie est un Chunk : un pool de constantes, un tableau d'octets d'instructions et une table de lignes associant les offsets d'instructions aux positions source.

Pour notre programme, la génération de code procède de haut en bas :

Déclaration d'entité. La déclaration entity Todo n'émet pas d'instructions d'exécution. Au lieu de cela, elle enregistre le schéma dans la table d'entités du générateur de code, qui est ensuite sérialisée dans la section de schéma d'entité du fichier .flinc. Au démarrage de la VM, le runtime lit cette section et initialise le registre de schémas de FlinDB.

Déclaration de variable avec requête. todos = Todo.all émet :

QueryAll  [Todo_idx]      ; Pousser la liste de toutes les entités Todo
StoreGlobal [todos_idx]   ; Stocker dans la globale 'todos'

Élément de vue. <div> émet :

CreateElement [div_idx]   ; Créer l'élément <div>

Boucle for dans la vue. {for todo in todos} émet :

LoadGlobal [todos_idx]    ; Pousser la liste todos
StartFor [end_addr]       ; Commencer l'itération, sauter à end_addr si vide
NextFor [todo_slot]       ; Lier l'élément courant à la locale 'todo'

Élément de vue imbriqué avec attribut conditionnel. Le <p> avec sa classe conditionnelle émet :

CreateElement [p_idx]     ; Créer l'élément <p>
LoadLocal [todo_slot]     ; Charger 'todo'
GetField [done_idx]       ; Obtenir le champ .done
JumpIfFalse [else_addr]   ; Si faux, sauter au else
LoadConst [completed_idx] ; Pousser "completed"
Jump [end_attr]           ; Sauter au-delà du else
; else_addr:
LoadConst [pending_idx]   ; Pousser "pending"
; end_attr:
BindAttr [class_idx]      ; Lier à l'attribut 'class'

Liaison de texte. {todo.title} émet :

LoadLocal [todo_slot]     ; Charger 'todo'
GetField [title_idx]      ; Obtenir le champ .title
BindText                  ; Lier comme texte réactif

Fermeture des éléments et boucle. Les balises fermantes émettent :

CloseElement              ; Fermer <p>
EndFor                    ; Retourner à NextFor
CloseElement              ; Fermer <div>
Halt                      ; Fin du programme

Le bytecode complet pour ce programme fait environ 40-50 octets d'instructions, référençant 8-10 constantes dans le pool. Une application complète qui affiche, crée, édite et supprime des éléments Todo pourrait faire 200-300 octets.

Phase 5 : sérialisation du bytecode

Le générateur de code produit un Chunk en mémoire. Pour le persister comme fichier .flinc, le sérialiseur écrit l'en-tête de 64 octets, le pool de constantes, la section de code, les informations de débogage (en mode développement) et la section de schéma d'entité.

rustpub fn serialize(chunk: &Chunk, entities: &[EntitySchema]) -> Vec<u8> {
    let mut output = Vec::new();

    // Magique
    output.extend_from_slice(b"FLIN");

    // Version
    output.push(0); // majeure
    output.push(1); // mineure
    output.push(0); // patch

    // Drapeaux
    let flags = Flags::DEBUG_INFO | Flags::HAS_VIEWS | Flags::HAS_ENTITIES;
    output.push(flags);

    // Offsets de sections (calculés après écriture des sections)
    // ...

    // Pool de constantes
    for constant in &chunk.constants {
        serialize_constant(&mut output, constant);
    }

    // Section de code
    output.extend_from_slice(&chunk.code);

    // Informations de débogage
    for (offset, line) in chunk.lines.iter().enumerate() {
        serialize_line_entry(&mut output, offset as u32, *line);
    }

    // Schémas d'entités
    for schema in entities {
        serialize_entity_schema(&mut output, schema);
    }

    output
}

Le fichier .flinc sérialisé est portable. Il ne contient pas de chemins absolus, pas de code spécifique à une plateforme et pas de références à l'environnement de build. Le même fichier .flinc peut être exécuté par une VM FLIN sur n'importe quelle plateforme.

Phase 6 : exécution par la machine virtuelle

La VM charge le fichier .flinc, initialise ses sous-systèmes et commence l'exécution à l'offset d'instruction 0.

Au démarrage, la VM :

  1. Lit l'en-tête et valide le nombre magique et la version.
  2. Charge le pool de constantes en mémoire.
  3. Lit les schémas d'entités et les enregistre avec FlinDB.
  4. Initialise la pile d'opérandes, la pile d'appels et la table de variables globales.
  5. Place le pointeur d'instruction à l'offset de la section de code.

Puis la boucle d'exécution commence :

loop {
    let opcode = read_byte(ip);
    ip += 1;

    match opcode {
        OpCode::QueryAll => {
            let type_idx = read_u16(ip);
            ip += 2;
            let entity_name = constants[type_idx].as_identifier();
            let results = flindb.query_all(entity_name);
            let list_id = heap.alloc_list(results);
            stack.push(Value::Object(list_id));
        }

        OpCode::StoreGlobal => {
            let name_idx = read_u16(ip);
            ip += 2;
            let name = constants[name_idx].as_identifier();
            let value = stack.pop();
            globals.insert(name.to_string(), value);
        }

        OpCode::CreateElement => {
            let tag_idx = read_u16(ip);
            ip += 2;
            let tag = constants[tag_idx].as_identifier();
            element_stack.push(Element::new(tag));
        }

        OpCode::StartFor => {
            let end_addr = read_u16(ip);
            ip += 2;
            let list = stack.pop();
            let items = heap.get_list(list);
            if items.is_empty() {
                ip = end_addr as usize;  // Sauter le corps de la boucle
            } else {
                // Pousser l'état de l'itérateur
                iterators.push(Iterator::new(items, 0));
            }
        }

        OpCode::BindText => {
            let value = stack.pop();
            let text = value.to_display_string(&heap);
            let element = element_stack.current();
            element.add_text_binding(text, /* dependency info */);
        }

        OpCode::Halt => break,

        // ... 70+ opcodes supplémentaires
    }
}

Pour notre programme Todo, la séquence d'exécution est :

  1. QueryAll -- FlinDB récupère toutes les entités Todo, la VM alloue une liste sur le tas.
  2. StoreGlobal -- la liste est stockée comme variable globale todos.
  3. CreateElement -- un élément <div> est créé sur la pile d'éléments.
  4. LoadGlobal -- todos est poussé sur la pile d'opérandes.
  5. StartFor -- la liste est dépilée, un itérateur est créé. Si la liste est vide, l'exécution saute à EndFor.
  6. NextFor -- l'entité Todo courante est liée à la variable locale todo.
  7. CreateElement -- un élément <p> est créé.
  8. LoadLocal, GetField -- todo.done est évalué.
  9. JumpIfFalse -- si done est faux, sauter à la branche « pending ».
  10. LoadConst -- pousser la chaîne de classe (« completed » ou « pending »).
  11. BindAttr -- lier l'attribut class à l'élément <p>.
  12. LoadLocal, GetField -- todo.title est évalué.
  13. BindText -- lier le titre comme contenu texte réactif.
  14. CloseElement -- fermer l'élément <p>.
  15. EndFor -- avancer l'itérateur et revenir à NextFor, ou continuer si épuisé.
  16. CloseElement -- fermer l'élément <div>.
  17. Halt -- l'exécution se termine.

Le résultat est un arbre d'éléments avec des liaisons réactives. Si le champ done d'un Todo change, le système de réactivité sait quel élément <p> mettre à jour et quel attribut class relier -- parce que les instructions BindAttr et BindText ont enregistré les dépendances au moment de l'exécution.

Ce qui traverse les frontières de phases

Comprendre le pipeline signifie comprendre quelles informations survivent à chaque transition de phase et lesquelles sont jetées.

Lexer vers Parser. Le flux de tokens préserve : le type de chaque token, sa position exacte dans le source et son texte original. Il jette : les espaces (sauf les retours à la ligne, qui sont significatifs pour la terminaison des instructions), les commentaires et les détails au niveau des caractères (le lexer ne dit pas au parser que >= a été formé de deux caractères).

Parser vers vérificateur de types. L'AST préserve : la structure hiérarchique complète, les spans source sur chaque noeud, les noms d'identifiants, les valeurs littérales et les types d'opérateurs. Il jette : les détails au niveau des tokens (le parser ne dit pas au vérificateur de types que entity était le token mot-clé), la ponctuation (accolades, virgules, deux-points qui ont été consommés pendant l'analyse) et les retours à la ligne.

Vérificateur de types vers générateur de code. L'AST typé préserve : tout de l'AST plus les annotations de type sur les noeuds d'expression et les schémas d'entités résolus. Il jette : rien. Le vérificateur de types est un passage de validation, pas un passage de transformation.

Générateur de code vers VM. Le bytecode préserve : la sémantique d'exécution du programme (opérations, flux de contrôle, données), les valeurs constantes, les schémas d'entités et (en mode débogage) les correspondances d'emplacements source. Il jette : la structure arborescente (le bytecode est plat), les noms de variables (remplacés par des emplacements de pile et des indices de pool de constantes), les informations de type (la VM est dynamiquement typée à l'exécution) et les détails syntaxiques (la VM ne sait pas si count++ était préfixe ou postfixe -- elle voit juste LoadGlobal, Dup, Incr, StoreGlobal).

Ce raffinement progressif -- chaque phase extrayant ce dont elle a besoin et jetant ce dont elle n'a pas besoin -- est ce qui rend le pipeline efficace. La VM ne porte pas le poids de l'AST. Le vérificateur de types ne porte pas le poids des tokens bruts. Chaque phase opère sur exactement la représentation dont elle a besoin.

Le pipeline comme produit

Le pipeline de compilation n'est pas juste un détail d'implémentation -- c'est une fonctionnalité produit. Chaque phase produit une sortie inspectable :

  • flin emit-tokens app.flin -- affiche le flux de tokens
  • flin emit-ast app.flin -- affiche l'AST comme texte indenté
  • flin check app.flin -- vérifie les types sans générer de bytecode
  • flin emit-bytecode app.flin -- affiche le désassemblage du bytecode
  • flin build app.flin -- produit le binaire .flinc
  • flin run app.flin -- compile et exécute en une étape

Ces modes existent parce que la débogabilité était un principe de conception dès le départ. Quand un programme ne se comporte pas comme prévu, le développeur peut inspecter la sortie à n'importe quelle phase pour trouver où est le problème. Le lexer produit-il de mauvais tokens ? Le parser construit-il le mauvais arbre ? Le vérificateur de types accepte-t-il quelque chose qu'il devrait rejeter ? Le générateur de code émet-il les mauvais opcodes ? La sortie de chaque phase est lisible par l'humain, et chaque phase peut être testée isolément.

C'est le pipeline complet. Six phases. Du texte source à l'application en cours d'exécution. Chaque phase fait une chose bien, passe sa sortie à la suivante et peut être inspectée indépendamment. Il a été construit en dix sessions, testé avec 590 tests et exécute des programmes avec des entités, des vues, des requêtes temporelles et une recherche alimentée par l'IA -- le tout compilé en un format de bytecode qui tient en quelques centaines d'octets.


Ceci est la partie 20 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 : Les entrailles de la machine virtuelle -- cadres de pile, allocation sur le tas, ramasse-miettes et comment la VM exécute le bytecode FLIN à l'exécution.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles