Back to flin
flin

10 sessions : de zéro à un compilateur fonctionnel

Construire un compilateur de langage de programmation en 10 sessions : lexer, parser, vérificateur de types, codegen et VM en deux jours.

Thales & Claude | March 30, 2026 16 min flin
EN/ FR/ ES
flinsprintsessionscompilermilestonepace

Le 1er janvier, FLIN était un nom. Le 2 janvier, il avait un lexer, un parser, un vérificateur de types, un générateur de code et une machine virtuelle. Dix sessions. Deux jours.

Construire un compilateur de langage de programmation est censé prendre des mois. Les cours universitaires y consacrent un semestre entier. Le manuel « Crafting Interpreters » prend 800 pages pour passer de rien à une VM de bytecode fonctionnelle. Le premier compilateur C a pris des années. Le premier compilateur Go a nécessité une équipe de trois ingénieurs expérimentés pendant plusieurs mois.

Nous l'avons fait en dix sessions sur deux jours calendaires, chaque session durant entre 25 et 40 minutes, avec un total combiné d'environ cinq heures de temps d'implémentation. Pas un jouet. Pas une calculatrice. Un compilateur pour un langage avec 12 types de données, plus de 75 opcodes de bytecode, des déclarations d'entités, des opérateurs temporels, des vues réactives, des requêtes alimentées par l'IA et une machine virtuelle capable de tout exécuter.

Cet article est l'histoire de ces dix sessions -- ce qui a été construit dans chacune, les décisions qui ont rendu ce rythme possible et les moments où les choses auraient pu mal tourner mais ne l'ont pas fait.

La préparation : ce qui existait avant la Session 001

Avant la première session, FLIN avait une spécification. Pas une idée vague ou une liste de souhaits -- une spécification. Six documents PRD :

  • PRD 01 : Vue d'ensemble du langage (philosophie de conception, public cible, cas d'utilisation)
  • PRD 02 : Spécification de syntaxe (chaque construction, grammaire formelle en EBNF)
  • PRD 03 : Système de types (12 types, règles de coercition, comportement d'inférence)
  • PRD 04 : Modèle temporel (sémantique de versionnement, opérateur @, intégration FlinDB)
  • PRD 06 : Architecture du compilateur (pipeline de compilation, limites de phases, exemples de code)
  • PRD 08 : Spécification du bytecode (tables d'opcodes, format de fichier, encodage du pool de constantes)

Ces documents ont été écrits en collaboration -- Thales décrivait ce que le langage devait faire, Claude affinait les détails techniques et écrivait les spécifications. Au moment où la Session 001 a commencé, chaque décision avait été prise. Les types de tokens étaient définis. Les noeuds de l'AST étaient spécifiés. Les valeurs d'opcodes étaient assignées. Le format de fichier était conçu.

C'est le facteur le plus important dans le développement rapide du compilateur. Pas la vitesse de codage de l'IA. Pas la simplicité du langage. Les spécifications. Quand vous vous asseyez pour implémenter un lexer et que vous connaissez déjà chaque type de token, chaque mot-clé et chaque cas limite (comment distinguer < l'opérateur de comparaison de < l'ouverture de balise), l'implémentation est purement mécanique. Il n'y a pas de réunions de conception. Il n'y a pas de pauses « laissez-moi réfléchir à comment cela devrait fonctionner ». Vous lisez la spécification, vous écrivez le code.

Session 001 : configuration du projet et définitions des tokens

Date : 1er janvier 2026. Durée : ~30 minutes.

La première session a créé le projet Rust, défini tous les types de tokens et implémenté les types de base Span et Position pour le suivi des emplacements dans le source.

rustpub struct Token {
    pub kind: TokenKind,
    pub span: Span,
    pub lexeme: String,
}

pub struct Span {
    pub start: Position,
    pub end: Position,
}

pub struct Position {
    pub line: u32,
    pub column: u32,
    pub offset: u32,
}

L'enum TokenKind avait plus de 60 variantes, couvrant les littéraux (entier, flottant, chaîne, booléen), plus de 30 mots-clés (entity, save, delete, where, find, all, if, for, ask, search, now, yesterday...), les opérateurs (arithmétiques, de comparaison, logiques, temporel @, incrément/décrément), les délimiteurs et les tokens spéciaux HTML/vues (TagOpen, TagClose, TagSelfClose, TagEnd).

La session a aussi configuré Cargo.toml, la structure des modules et le framework de test. À la fin, le projet compilait et les définitions de tokens étaient complètes. Pas encore de scanner -- juste le vocabulaire du langage.

Sessions 002-003 : implémentation du scanner

Date : 2 janvier 2026. Durée : ~55 minutes combinées.

La Session 002 a construit le scanner de base : lecture de caractères, correspondance de tokens à un et plusieurs caractères, scan de chaînes, scan de nombres et reconnaissance des mots-clés par rapport aux identifiants.

La Session 003 a ajouté l'innovation critique -- le lexer tri-modal :

rustenum LexerMode {
    Code,            // Code normal : count = 0
    View,            // À l'intérieur des balises HTML : <button click=...>
    ViewExpression,  // À l'intérieur de {expr} dans une vue
}

Le code source FLIN bascule entre le mode code et le mode vue dans un seul fichier. L'expression <button click={count++}>{count}</button> contient une syntaxe de type HTML, des expressions embarquées et des gestionnaires d'événements. Le lexer doit produire différents tokens selon le contexte -- < est Less en mode code mais TagOpen en mode vue.

Les transitions de mode sont déterministes :

  • Voir < suivi d'un caractère alphabétique bascule de Code à View
  • Voir { en mode View bascule vers ViewExpression
  • Voir } en ViewExpression revient à View
  • Voir > ou /> en mode View retourne au contexte approprié

À la fin de la Session 003, le lexer pouvait tokeniser n'importe quel programme FLIN valide. 87 tests réussis.

Session 004 : définition de l'AST

Date : 2 janvier. Durée : ~25 minutes.

La Session 004 a défini l'arbre syntaxique abstrait complet : la struct Program, l'enum Stmt (11 variantes : EntityDecl, VarDecl, Assignment, Save, Delete, If, For, Route, View, Style, Expr), l'enum Expr (15 variantes incluant EntityCreate, EntityQuery, Ask, Search, Temporal) et les types AST de vues (ViewElement, ViewAttribute, ViewChild, ViewIf, ViewFor).

Cette session n'a écrit aucune logique -- c'était de la pure définition de types. Mais ces types sont la colonne vertébrale du compilateur. Chaque phase après celle-ci produit ou consomme des noeuds de l'AST. Obtenir les types corrects signifiait que les sessions suivantes pouvaient s'appuyer sur le compilateur Rust pour imposer la correction : si le parser produit un Stmt::If avec une condition, une then_branch et une else_branch, le vérificateur de types doit gérer les trois champs, et le générateur de code doit gérer les trois champs. En oublier un, et le code ne compile pas.

Sessions 005-006 : parser

Date : 2 janvier. Durée : ~60 minutes combinées.

La Session 005 a construit le parser par descente récursive pour les instructions et les expressions de base. La Session 006 a remplacé le parser d'expressions par un parser de Pratt (montée de précédence) et ajouté l'analyse du flux de contrôle.

Le parser de Pratt était le choix architectural clé. La descente récursive fonctionne bien pour les instructions, où chaque type d'instruction a un token de tête distinct (entity, save, if, for, <). Mais pour les expressions, où la précédence et l'associativité des opérateurs créent une imbrication complexe, un parser de Pratt est à la fois plus simple et plus correct :

rustfn parse_expression(&mut self, precedence: u8) -> Result<Expr, ParseError> {
    // Parser le préfixe (littéral, identifiant, opérateur unaire, expr parenthésée)
    let mut left = self.parse_prefix()?;

    // Parser les opérateurs infixes tant que la précédence le permet
    while !self.is_at_end() && precedence < self.current_precedence() {
        left = self.parse_infix(left)?;
    }

    Ok(left)
}

Cette fonction de 10 lignes gère l'ensemble de la grammaire d'expressions : arithmétique avec précédence correcte (a + b <em> c est analysé comme a + (b </em> c)), chaînes de comparaison, opérateurs logiques avec sémantique de court-circuit, opérateurs postfixes, accès aux champs, accès par index, appels de fonctions et l'opérateur temporel @.

Le parser gérait aussi la syntaxe de vues de FLIN, qui nécessite une attention particulière parce que les éléments de type HTML sont des instructions qui contiennent des expressions :

flin<div class="counter">
    <h1>{title}</h1>
    <button click={count++}>Increment</button>
    <p>{count}</p>
    {if count > 10}
        <span>High count</span>
    {/if}
</div>

À la fin de la Session 006, 158 tests réussis. Le parser pouvait gérer chaque construction de la spécification FLIN.

Sessions 007-008 : vérificateur de types

Date : 2 janvier. Durée : ~50 minutes combinées.

La Session 007 a construit les fondations du vérificateur de types : l'enum Type (12 types incluant SemanticText, Money, Entity et Optional), la gestion des portées et l'inférence de types de base.

La Session 008 l'a étendu avec une inférence de style Hindley-Milner pour les expressions. L'idée clé était que le système de types de FLIN est intentionnellement simple -- pas de génériques, pas de paramètres de type, pas de types de plus haut genre. Le vérificateur de types doit :

  1. Suivre les schémas d'entités déclarés (entity User { name: text, email: text })
  2. Inférer les types à partir des littéraux (42 est Int, "hello" est Text)
  3. Vérifier les opérations binaires (Int + Int est valide, Int + Text ne l'est pas)
  4. Inférer les types de retour des requêtes (User.all retourne [User], User.count retourne Int)
  5. Vérifier l'accès aux champs d'entité (user.name est valide si User a un champ name)
  6. Valider les expressions temporelles (l'opérateur @ préserve le type de base)
rustpub fn infer_type(&mut self, expr: &Expr) -> Result<Type, TypeError> {
    match expr {
        Expr::Integer(_) => Ok(Type::Int),
        Expr::String(_) => Ok(Type::Text),
        Expr::Bool(_) => Ok(Type::Bool),
        Expr::EntityQuery { entity, operation } => {
            let entity_type = Type::Entity(entity.clone());
            match operation {
                QueryOp::All => Ok(Type::List(Box::new(entity_type))),
                QueryOp::Count => Ok(Type::Int),
                QueryOp::First | QueryOp::Find(_) =>
                    Ok(Type::Optional(Box::new(entity_type))),
                QueryOp::Where(_) | QueryOp::Order(_) =>
                    Ok(Type::List(Box::new(entity_type))),
            }
        }
        Expr::Temporal { expr, .. } => self.infer_type(expr),
        // ...
    }
}

À la fin de la Session 008, 193 tests réussis. Le vérificateur de types validait chaque type d'expression et d'instruction du langage.

Session 009 : générateur de code

Date : 2 janvier. Durée : ~30 minutes.

C'est la session décrite dans l'article précédent. Le générateur de code parcourt l'AST typé et émet du bytecode : plus de 75 opcodes, gestion du pool de constantes avec déduplication, patching des sauts pour le flux de contrôle, émission d'instructions de vues, détection des méthodes d'entité, gestion optimisée des littéraux et évaluation en court-circuit des booléens.

1 700 lignes de Rust. 26 nouveaux tests. 219 au total. L'exemple du compteur compilé en bytecode valide.

Session 010 : machine virtuelle

Date : 2 janvier. Durée : ~35 minutes.

La session finale du sprint. La Session 010 a construit les fondations de la VM : représentation des valeurs, pile d'opérandes, pile d'appels, stockage des variables globales, allocation sur le tas et distribution d'instructions pour les plus de 75 opcodes.

rustpub struct VM {
    stack: Vec<Value>,
    frames: Vec<CallFrame>,
    ip: usize,
    globals: HashMap<String, Value>,
    heap: Vec<HeapObject>,
    free_list: Vec<usize>,
    bytes_allocated: usize,
    gc_threshold: usize,
    output: Vec<String>,
    debug: bool,
}

L'enum Value utilise une représentation compacte. Les valeurs primitives (None, Bool, Int, Float) sont stockées en ligne. Les valeurs complexes (String, List, Map, Entity, Function, Closure) sont stockées sur le tas, avec le Value contenant un index ObjectId dans le tableau du tas.

rustpub enum Value {
    None,
    Bool(bool),
    Int(i64),
    Float(f64),
    Object(ObjectId),
}

La boucle de distribution d'instructions est un match sur l'opcode courant :

Récupérer l'opcode à IP -> Décoder les opérandes -> Exécuter -> Avancer IP -> Répéter

Pour l'arithmétique : dépiler deux valeurs, effectuer l'opération, empiler le résultat. Pour les sauts : lire l'adresse cible de l'opérande, définir IP. Pour LoadGlobal : lire l'index du pool de constantes, chercher la chaîne identifiant, trouver la globale par nom, empiler la valeur. Pour CreateElement : chercher le nom de balise dans le pool de constantes, créer un nouvel élément sur la pile d'éléments de la VM.

Les opérations d'entités (Save, Delete, QueryAll) et les opérations temporelles (AtVersion, AtTime) ont été implémentées comme des stubs qui enregistrent l'intention mais ne se connectent pas encore à FlinDB. Les opérations de vues étaient similairement stubées -- elles émettent des événements dans le journal de sortie de la VM mais ne rendent pas encore dans un vrai DOM. C'était délibéré. L'objectif de la Session 010 était de prouver que la boucle de distribution d'instructions fonctionne, pas de construire l'ensemble du runtime.

Le test critique : l'exemple du compteur. Compiler count = 0; count++ à travers le pipeline complet (lexer, parser, vérificateur de types, générateur de code), donner le bytecode à la VM, l'exécuter et vérifier que la variable globale count contient la valeur 1 après l'exécution.

Il a réussi. 251 tests au total. 32 nouveaux dans cette seule session.

Les chiffres

SessionDuréeFocusTests ajoutésTotal tests
001~30 minConfiguration du projet, définitions de tokens1212
002~30 minScanner de base3850
003~25 minMode vue, lexer tri-modal3787
004~25 minDéfinitions de l'AST895
005~30 minParser par descente récursive30125
006~30 minParser de Pratt, flux de contrôle33158
007~25 minFondations du vérificateur de types19177
008~25 minInférence Hindley-Milner16193
009~30 minGénérateur de code26219
010~35 minMachine virtuelle32251

Cinq heures et vingt-cinq minutes de temps d'implémentation. 251 tests. Un pipeline de compilation complet du texte source au bytecode en cours d'exécution.

Pourquoi cela a fonctionné

Les spécifications ont éliminé les décisions de conception pendant l'implémentation. Chaque session commençait avec un objectif clair : « implémenter cette phase comme spécifié dans le PRD 06 ». Il n'y avait aucune ambiguïté sur quels types de tokens existaient, à quoi l'AST ressemblait, quels opcodes émettre ou comment la VM devait les distribuer. Les spécifications étaient le plan ; les sessions étaient la construction.

Chaque phase était un transfert propre. Le lexer produit des tokens. Le parser consomme des tokens et produit un AST. Le vérificateur de types consomme un AST et produit un AST typé. Le générateur de code consomme un AST typé et produit du bytecode. La VM consomme du bytecode et produit une exécution. Aucune phase ne remonte en arrière. Aucune phase ne connaît les phases à deux étapes de distance. Cette séparation signifiait que chaque session pouvait être implémentée et testée isolément.

La suite de tests était le suivi de progression. Chaque session ajoutait des tests avant ou en parallèle de l'implémentation. Quand un test échouait, le bogue était dans le code écrit dans la session courante -- pas dans le code d'il y a trois sessions. C'est trivialement vrai avec le recul, mais c'est l'opposé de la façon dont beaucoup de projets fonctionnent, où les tests sont écrits après coup et les bogues sont découverts loin de leur origine.

Le système de types de Rust a empêché les bogues d'intégration. Quand la Session 009 (générateur de code) a consommé l'AST produit par la Session 006 (parser), le compilateur Rust a garanti que chaque variante de l'AST était gérée. Oublier d'émettre du bytecode pour Expr::Temporal ? Erreur de compilation. Passer un Stmt là où un Expr est attendu ? Erreur de compilation. Ce sont les bogues qui affligent les implémentations dynamiquement typées de compilateurs, où un case manquant dans un switch tombe silencieusement et produit une sortie incorrecte. En Rust, ils ne peuvent pas arriver.

Le flux de travail CEO-CTO IA a éliminé les surcharges. Thales ne revoyait pas de pull requests. Claude n'attendait pas d'approbation. Chaque session était un flux d'implémentation continu : spécification vers code vers tests vers commit. Les surcharges traditionnelles d'un projet logiciel -- standups, revues de code, configuration d'environnement, changements de contexte -- étaient à zéro. Pas réduites. Zéro.

Ce qui restait pour plus tard

Dix sessions ont construit le pipeline de compilation. Elles n'ont pas construit le runtime complet. Ce qui restait après la Session 010 :

  • Gestion de la mémoire. Le tas avait l'allocation mais pas le ramasse-miettes.
  • Intégration FlinDB. Les opérations d'entités étaient des stubs.
  • Rendu des vues. Les instructions de vues journalisaient la sortie mais ne produisaient pas de DOM.
  • Serveur HTTP. Les gestionnaires de routes existaient dans l'AST mais pas dans le runtime.
  • Rechargement à chaud. Le compilateur pouvait compiler une fois ; il ne pouvait pas surveiller les changements et recompiler.
  • Diagnostics d'erreurs. Le compilateur rapportait les erreurs, mais sans contexte source, couleurs ou suggestions.

Ceux-ci seraient construits dans les Sessions 011 à 018, chaque session étendant les fondations que les dix premières sessions avaient établies. Mais le jalon critique était la Session 010. À la fin de la Session 010, FLIN n'était plus une spécification. C'était un compilateur fonctionnel.

La leçon

La leçon n'est pas « l'IA rend les compilateurs faciles ». La leçon est que le développement piloté par la spécification rend l'ingénierie complexe réalisable dans des délais compressés, et que le modèle CEO-CTO IA élimine les surcharges de coordination qui normalement étirent ces délais d'un ordre de grandeur.

Les spécifications ont pris du temps à écrire. Les PRD n'étaient pas des documents de week-end -- c'étaient des artefacts techniques précis qui anticipaient les cas limites, définissaient le comportement exact et fournissaient des conseils d'implémentation sous forme d'exemples de code Rust. Cet investissement s'est rentabilisé de nombreuses fois. Cinq heures d'implémentation ont produit un compilateur fonctionnel parce que cinquante heures de travail de spécification avaient déjà résolu chaque question de conception.

La plupart des projets logiciels fonctionnent dans la direction opposée. Ils commencent à coder, découvrent des questions de conception pendant l'implémentation, s'arrêtent pour en discuter, reprennent le codage, découvrent plus de questions et itèrent jusqu'à l'arrivée de la date limite. Le temps total d'implémentation peut être le même, mais il est étalé sur des mois et ponctué de changements de contexte coûteux.

Nous avons compressé ces mois en deux jours en faisant la réflexion d'abord et la saisie ensuite.


Ceci est la partie 18 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 système de diagnostics d'erreurs -- comment FLIN produit des messages d'erreur écrits pour les humains, pas pour les ingénieurs de compilateurs.

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles