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 vueKeyword(Save)-- analyser l'instruction saveIdentifiersuivi deEqual-- analyser la déclaration ou l'affectation de variableIdentifiersuivi deDot-- 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 :
- Enregistrer le schéma d'entité.
entity Todo { title: text, done: bool = false }ajouteTodoau registre d'entités avec deux champs.
- Typer la requête.
Todo.all-- chercherTododans le registre d'entités, vérifier qu'il existe, déterminer que.allretourne[Todo](une liste d'entités Todo).
- Typer la variable.
todos = Todo.all-- inférer quetodosa le type[Todo].
- Entrer dans la boucle for.
for todo in todos-- vérifier quetodosest itérable (c'est une liste), liertodoavec le typeTododans la portée de la boucle.
- Typer le conditionnel.
if todo.done-- vérifier quetodoa un champdone, vérifier quedoneestBool(convenable pour une condition). La branche then ("completed") estText, la branche else ("pending") estText, donc l'expression conditionnelle a le typeText.
- Typer l'attribut.
class={...}-- vérifier que la valeur d'attribut dynamique estText. Elle l'est, parce que l'expression conditionnelle se résout enText.
- Typer la liaison de texte.
{todo.title}-- vérifier quetodoa un champtitle, vérifier que le résultat est affichable.Textest 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éactifFermeture des éléments et boucle. Les balises fermantes émettent :
CloseElement ; Fermer <p>
EndFor ; Retourner à NextFor
CloseElement ; Fermer <div>
Halt ; Fin du programmeLe 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 :
- Lit l'en-tête et valide le nombre magique et la version.
- Charge le pool de constantes en mémoire.
- Lit les schémas d'entités et les enregistre avec FlinDB.
- Initialise la pile d'opérandes, la pile d'appels et la table de variables globales.
- 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 :
QueryAll-- FlinDB récupère toutes les entités Todo, la VM alloue une liste sur le tas.StoreGlobal-- la liste est stockée comme variable globaletodos.CreateElement-- un élément<div>est créé sur la pile d'éléments.LoadGlobal--todosest poussé sur la pile d'opérandes.StartFor-- la liste est dépilée, un itérateur est créé. Si la liste est vide, l'exécution saute àEndFor.NextFor-- l'entité Todo courante est liée à la variable localetodo.CreateElement-- un élément<p>est créé.LoadLocal,GetField--todo.doneest évalué.JumpIfFalse-- sidoneest faux, sauter à la branche « pending ».LoadConst-- pousser la chaîne de classe (« completed » ou « pending »).BindAttr-- lier l'attribut class à l'élément<p>.LoadLocal,GetField--todo.titleest évalué.BindText-- lier le titre comme contenu texte réactif.CloseElement-- fermer l'élément<p>.EndFor-- avancer l'itérateur et revenir àNextFor, ou continuer si épuisé.CloseElement-- fermer l'élément<div>.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 tokensflin emit-ast app.flin-- affiche l'AST comme texte indentéflin check app.flin-- vérifie les types sans générer de bytecodeflin emit-bytecode app.flin-- affiche le désassemblage du bytecodeflin build app.flin-- produit le binaire.flincflin 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.