Journal [Letlang] Écrire un compilateur en Rust (partie 2)

Posté par  (site web personnel) . Licence CC By‑SA.
20
11
mar.
2022

Sommaire

Bonjour Nal :)

Je suis inspiré en ce moment, du coup je continue ma série sur l'écriture d'un compilateur en Rust. Si tu es intéressé par les précédents articles, les voici :

Dans cette partie, je vais te présenter mes dernières découvertes concernant le parser.

Introduction

Tout d'abord, c'est quoi un parser ?

Google Translate traduit le terme par analyseur. Voilà bonne journée, à demain.

Non, en vrai, c'est pas suffisant comme explication. Dans le domaine des langages de programmation, le parser est la partie du compilateur ou interpréteur qui va traduire le texte en AST.

Cela est fait en plusieurs étapes :

1. On transforme le flux de caractères en flux de token :

const a := 42;

devient

KEYWORD_CONST IDENTIFIER(a) OPERATOR_ASSIGN INTEGER(42) STATEMENT_END

Différentes méthodes sont possibles, une souvent utilisée est basée sur les expressions régulières (les regex sont nos amies, stop regex hate).

Si on rencontre une chaîne de caractère qui n'est reconnue par aucun token, on a alors une erreur de syntaxe.

Chaque token est accompagné d'information indiquant OÙ dans la chaine de caractère initiale il se trouve.

2. On analyse le flux de token à l'aide d'une grammaire:

Les règles de grammaires définissent quels sont les pattern de token que l'on doit rencontrer. Par exemple :

  • Déclaration de constante : KEYWORD_CONST IDENTIFIER OPERATOR_ASSIGN EXPRESSION STATEMENT_END
  • Importation de module : KEYWORD_IMPORT STRING_LITERAL STATEMENT_END
  • Exportation de symbole : KEYWORD_EXPORT IDENTIFIER+ STATEMENT_END (le + signifiant 1 ou plus)

Si on rencontre un pattern qui n'est défini par aucune règle de grammaire, alors il y a une erreur grammaticale (unexpected token blabla).

Le résultat de cette étape est très souvent un AST annoté des informations suivantes : nom de fichier, ligne, colonne.

3. On parcours l'AST pour vérifier qu'il est bien formé :

Un petit exemple en Rust :

const good: i64 = 21 * 2;
const bad: i64 = 42 + some_function();

Le compilateur ne va pas apprécier la seconde ligne. En effet, bien que valide au niveau de la syntaxe et de la grammaire, ce n'est pas valide d'un point de vue sémantique : on ne peut pas appeler de fonction non constante lorsque l'on déclare une constante ou une variable statique.

C'est donc à cette étape (qui elle même peut être découpée en plusieurs phases) que l'on va pouvoir vérifier les règles sémantiques, soit :

  • inférence de type (on ajoute des métadonnées aux noeuds de l'AST)
  • vérification des types
  • rust a le bound-checking / borrow-checker / … pour assurer la viabilité de l'accès à la mémoire
  • et tout ce que tu peux imaginer (exemple : si on est samedi, les if sont interdit)

Pour ma part, je prévois d'interdire les récursions qui ne sont pas Tail Call. L'idée étant de traduire la récursion en itération lors de la compilation, pour permettre d'avoir de la récursion infinie (pas d'erreur du type Maximum Depth Exceeded).

Les différents types de parser

Il existe différents types de parser, sujet très largement couvert en Computer Science et dans de nombreux ouvrages littéraires que je n'ai pas lu.

On retrouve par exemple :

  • les Parser Combinators :
    • un parser est une fonction qui accepte une chaîne de caractères et retourne une structure
    • un combinator est une fonction qui accepte des parsers et retourne un nouveau parser
  • les Parsing Expression Grammar (PEG pour les intimes) :
    • On commence par les règles de haut niveau, et on descend jusqu'à rencontrer une erreur
  • les Left to right, Rightmost derivation (LR pour les intimes) :
    • On commence par les règles de plus bas niveau, et on remonte jusqu'à obtenir la structure finale

NB : Merci de me corriger si j'ai dit des bêtises.

En programmation fonctionnelle, les Parser Combinator sont très appréciés. Moi je les aime pas, car la gestion des espaces ( [ \t\n\f] ) est particulièrement pénible a implémenter. Si cela t'intéresse, en Rust je ne peux que te recommander nom.

Parmi les PEG parser, on retrouvera beaucoup des Packrat Parser, très souvent basé sur une grammaire inspirée de BNF. Je trouve ces grammaires assez simple à implémenter, et en Rust je peux te recommander pest.

Le soucis que j'ai avec cette dernière, c'est encore une fois la gestion des espaces. Tout les espaces ne sont pas insignifiants dans un langage de programmation, exemple :

  • GOOD : const a = 42;
  • GOOD : const a=42;
  • BAD : consta=42;

Et donc, en utilisant pest, je dois désactiver le "skip" des espaces, et ajouter moi même la règle partout dans la grammaire. Bref, même soucis qu'avec les Parser Combinator.

Au final, les LR(1) parser sont très utilisés dans l'implémentation de langage de programmation. Tu as sûrement entendu parler des vénérables lex et yacc, c'est parce qu'ils permettent de produire des grammaires LR(k).

Côté Rust, j'ai ainsi découvert LALRPOP et Logos. On va regarder ça ensemble de suite :)

Au travail

Tout d'abord, préparons notre projet en ajoutant à Cargo.toml :

[package]
# ...
build = "build.rs"

[build-dependencies]
lalrpop = "0.19.7"

[dependencies]
lalrpop-util = "0.19.7"
regex = "1"
logos = "0.12.0"

Et dans build.rs :

extern crate lalrpop;

fn main() {
  lalrpop::process_root().unwrap();
}

LALRPOP s'inspire de lex et yacc. On a un Lexer (pour transformer le flux de caractères en flux de token), et une grammaire qui pour chaque règle va y associer un bout de code Rust (qu'on utilisera pour créer l'AST).

Le lexer par défaut ignore tout les espaces, mais il est possible de fournir un lexer fait maison. Ils fournissent d'ailleurs un exemple de parser pour le langage whitespace, l'opposé de ce que fait donc le lexer par défault :P

C'est là que Logos intervient. Il permet de définir le pattern de nos tokens et il va lui même s'occupe de transformer notre chaine de caractères en chaîne de token. Le tout en gérant correctement les espaces et autres séparateurs. Petit exemple:

use logos::Logos;

#[derive(Logos, Clone, Debug, PartialEq)]
pub enum Token {
  #[regex(r"[_a-zA-Z][_0-9a-zA-Z]*", |lex| lex.slice().parse())]
  Identifier(String),

  #[token("const")]
  Const,

  #[error]
  Error,  // requis pour tout les tokens invalides
}

Ici je définis 2 tokens, const et un identifier. Tu remarqueras que la regex de Identifier est capable de parser const. Logos résous cela de 2 manières :

  • les #[token()] ont priorité sur les #[regex()]
  • les tokens les plus longs ont priorité sur les tokens les plus court

Ce deuxième point est très important :

  • const a donne [Const, Identifier("a")]
  • consta donne [Identifier("consta")]

Je complète donc mon enumeration avec l'ensemble des tokens de mon langage, et je teste :

let tokens: Vec<_> = Token::lexer("const a := 42;").spanned().collect();
assert_eq!(
  tokens,
  &[
    (Token::StatementConst, 0..5),
    (Token::Identifier(String::from("a")), 6..7),
    (Token::OperatorAssign, 8..10),
    (Token::IntegerBase10(42), 11..13),
    (Token::StatementSeparator, 13..14),
  ]
);

Bingo! Je peux passer à la suite :)

Implémenter le lexer

LALRPOP demande à ce que le lexer implémente le trait Iterator, et lui retourne à chaque itération un Result avec le tuple (start_location, token, end_location). C'est donc ce que l'on va faire pour intégrer le tokenizer Logos à LALRPOP.

Commençons par importer tout ce qui va nous être utile :

use logos::{Logos, SpannedIter};  // on importe le trait Logos

use crate::core::errors::{ParseResult, ParseError}; // ma propre structure Result/Error
use crate::parser::tokens::Token; // l'enum créée précédemment

Comme dit précédemment, le Lexer va implémenter le trait Iterator, voici donc les items de notre itérateur :

pub type Spanned<Tok, Loc> = ParseResult<(Loc, Tok, Loc)>;

Notre Lexer va utiliser notre tokenizer Logos en entrée pour générer la liste des tokens :

pub struct Lexer<'input> {
  filename: String,
  token_stream: SpannedIter<'input, Token>
}

impl<'input> Lexer<'input> {
  pub fn new(filename: &String, input: &'input str) -> Self {
    Self {
      filename: filename.clone(),
      token_stream: Token::lexer(input).spanned(),
    }
  }
}

Ce qui va grandement simplifier l'écriture de l'itérateur :

impl<'input> Iterator for Lexer<'input> {
  type Item = Spanned<Token, usize>;

  fn next(&mut self) -> Option<Self::Item> {
    match self.token_stream.next() {
      None => {
        None // on est a la fin du stream
      },
      Some((token, span)) => {
        match token {
          Token::Error => {
            Some(Err(ParseError::new(
              format!(
                "[{}][{}:{}]: unexpected token",
                self.filename, span.start, span.end
              )
            )))
          },
          _ => Some(Ok((span.start, token, span.end)))
        }
      }
    }
  }
}

Et juste comme ça, l'étape 1 de notre parseur, à savoir l'analyse lexicale, est faite.

Implémenter la grammaire

Cette étape va te rappeler votre expérience avec lex et yacc, ou pas si tu ne les as jamais utilisés.

On crée dans nos sources un fichier grammar.larlpop (c'est la que le build.rs entre en jeu, il va générer le module Rust qui correspond). Il va avoir une syntaxe très similaire à Rust. Voici son en-tête :

use lalrpop_util::{ParseError as GrammarError};

use crate::{
  ast,
  core::errors::ParseError,
  parser::tokens::Token,
};

grammar(unit_identifier: &String);

La dernière ligne indique les données en entrée que notre grammaire va utiliser. À l'avenir, il s'agira d'une structure contenant un peu plus d'information. En Letlang, chaque fichier de source va produire une unité de compilation, le unit_identifier sera un hash unique pour identifier cette unité de compilation. C'est tout ce dont j'ai besoin pour l'instant.

A la fin du fichier je vais ajouter les informations concernant le lexer que j'utilise :

extern {
  type Location = usize;
  type Error = ParseError;

  enum Token {
    "identifier" => Token::Identifier(_),
    // ...
    "int10" => Token::IntegerBase10(_),
    // ...
    "const" => Token::StatementConst,
    // ...
    ";" => Token::StatementSeparator,
    // ...
    ":=" => Token::OperatorAssign,
    // ...
  }
}

Maintenant, dès que je vais spécifier "identifier" dans ma grammaire, cela fera référence à mon token.

Ma règle de plus haut niveau sera Unit, correspondant à un fichier source :

pub Unit: Box<ast::Unit> = {
  <stmts:Statement*> => ast::Unit::new(unit_identifier.clone(), stmts)
}

Elle comprend un 0 ou plus Statement, et le code associé est la création du noeud de l'AST.

Je peux maintenant définir ma règle Statement :

Statement: Box<ast::Statement> = {
  "const" <i:"identifier"> ":=" <e:Expr> ";" =>? {
    match i {
      Token::Identifier(name) => Ok(ast::Statement::constant(name, e)),
      _ => Err(GrammarError::User {
        error: ParseError::new(format!("unexpected token: {:?}", i))
      })
    }
  },
  // ...
}

On a donc le pattern de token suivi à nouveau du code qui va produire le noeud de l'AST. Plusieurs choses :

  • j'utilise =>? et non =>, pour produire un Result<_, _> au lieu de juste une valeur
  • bien qu'il ne soit pas possible d'avoir autre chose que Token::Identifier, le compilateur ne sait pas le garantir, et si je refactor mon code, au moins j'aurais une erreur levée

Pour compléter mon exemple, voici la règle Expr :

Expr: Box<ast::expression::Expression> = {
  <n:"int10"> =>? {
    match n {
      Token::IntegerBase10(val) => {
        Ok(ast::expression::Expression::literal(
          ast::expression::Literal::int(val)
        ))
      },
      _ => Err(GrammarError::User {
        error: ParseError::new(format!("unexpected token: {:?}", n))
      })
    }
  },
  // ...
}

Oui, c'est un peu verbeux, je factoriserai le code plus tard, je préfère avoir quelque chose qui fonctionne d'abord.

Pour finaliser l'intégration de notre grammaire, dans le mod.rs du même dossier ou j'ai mis ma grammaire (src/parser/grammar.lalrpop) :

pub mod tokens; // contient le tokenizer Logos
pub mod lexer;  // contient le Lexer pour LALRPOP

lalrpop_mod!(pub grammar, "/parser/grammar.rs"); // pointe vers le module généré à partir de la grammaire

Une fois fait, on peut tester cela dans le main.rs :

use letlang::parser::{
  lexer::Lexer,
  grammar::UnitParser,
};

fn main() {
  let filename = String::from("main");
  let src = "const foo:=42;";

  let lexer = Lexer::new(&filename, src);
  let parser = UnitParser::new();
  let ast = parser.parse(&filename, lexer).unwrap();

  println!("{:?}", ast);
}

Et après exécution (j'ai fait le pretty print à la mano, car je suis sympa, enfin je crois) :

Unit {
  identifier: "main",
  statements: [
    ConstDecl(ConstDeclStatement {
      symbol_name: "foo",
      value: Literal(Integer(42))
    })
  ]
}

BOOM! L'AST a été généré! Je peux déjà le passer à mon compilateur qui va me générer le code Rust qui va bien. Bon en vrai je vais d'abord vérifier qu'il est correct sémantiquement, mais t'as compris le principe :)

Conclusion

LALRPOP et Logos sont une joyeuse découverte, qui va accélérer grandement l'écriture de la phase la plus pénible du compilateur : le parser.

C'est un couple de librairie à avoir dans son outillage pour peu que vous ayez à faire à des données textuelles (sisi, ça existe encore, tout n'est pas que JSON ou XML).

Je te remercie de m'avoir lu jusqu'au bout, voici une petite récompense :

tartiflette

Si tu as la recette…

  • # super article !

    Posté par  (site web personnel) . Évalué à 8.

    Est-ce que tu va parler de la forme de l'ast ?

    J'ai déja écrit des ast sous la forme de simple type somme. A priori, la forme SSA simplifie la manipulation du code ( https://en.m.wikipedia.org/wiki/Static_single_assignment_form )

    Qu'en penses-tu ?

    "La première sécurité est la liberté"

    • [^] # Re: super article !

      Posté par  (site web personnel) . Évalué à 4.

      Merci pour ton retour :)

      Est-ce que tu va parler de la forme de l'ast ?

      Je l'ai déjà évoqué rapidement dans la première partie, c'est un simple arbre ou chaque noeud contient les informations nécessaire pour passer à la phase suivante.

      Ces informations sont complétées au fur et à mesure des différents parcours de l'arbre, produisant à la fin le code source Rust à compiler.

      A priori, la forme SSA simplifie la manipulation du code

      Je ne connais pas la théorie derrière, c'est la première fois que j'entends le nom de ce concept, la page wikipedia parle du concept de CPS (Continuation Passing Style) qui semble plus utilisé par les langages de programmation fonctionnelle. C'est un concept que j'utilise pas mal pour l'implémentation de protocole TCP (le dernier en date, un subset du protocole de Redis), je trouve cela assez naturel.

      Qu'en penses-tu ?

      A ce stade, l'AST dans sa forme actuelle me semble suffisant, mais je garde l'article wikipedia sous la main,, on sait jamais je pourrais en avoir besoin :) Merci pour l'info.

      https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

      • [^] # Re: super article !

        Posté par  (site web personnel) . Évalué à 6.

        Autant la SSA semble simple avec des expression types "a := f(b,c)", autant je n'ai rien compris à l'usage du CPS.

        En SSA, on comprend que l'on nomme le retour de chaque expression ce qui permet facilement de réutiliser un résultat, ou de simplifier un nœud derrière.

        CPS utilise une fonction supplémentaire en argument, pour matérialiser le "return", j'imagine. Mais je ne comprends pas l'utilité (et les exemple en lisp ou haskell n'aide pas à comprendre :)

        "La première sécurité est la liberté"

        • [^] # Re: super article !

          Posté par  (site web personnel) . Évalué à 6.

          En fait la continuation va indiquer ce qu'on fait ensuite.

          Donc pour parser la ligne const a := 42;, la fonction qui parse const va retourner la fonction qui parse le reste ou une fonction qui retourne une erreur.

          Et ainsi de suite, on va parser a et retourner la fonction qui parse le reste.

          L'utilisateur va donc pouvoir contrôler quand et à quel rythme l'analyse se déroule. Il peut faire une simple boucle (ou fonction récursive) :

          def get_ast({:ok, ast}), do: {:ok, ast}
          def get_ast({:error, reason}). do: {:error, reason}
          def get_ast({:fun, fun}), do: get_ast(fun.())

          https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

  • # Erreurs

    Posté par  . Évalué à 6.

    Alors, j'aurais plutôt dit qu'un token non reconnu est une erreur lexicale, que que l'erreur de syntaxe est l'erreur grammaticale.

    • [^] # Re: Erreurs

      Posté par  (site web personnel) . Évalué à 5.

      Ça se tient.

      Tu remarqueras cependant qu'aucun compilo/interpréteur ne fait la différence, ils appellent les deux une erreur de syntaxe. D'où ma confusion.

      Pour ma part, j'utilise le type "ParseError" et "CompilationError", donc je fais même pas la différence avec l'erreur sémantique.

      https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

      • [^] # Re: Erreurs

        Posté par  . Évalué à 5. Dernière modification le 12 mars 2022 à 12:33.

        En effet, je pense qu'en pratique, il est assez difficile de distinguer les erreurs lexicales/grammaticales/sémantiques. Typiquement, quasiment tous les tokens non reconnus seront classés comme identifiants, et on aura donc une erreur de syntaxe car il n'existe pas de règle avec un identifiant à cette place. Ou alors on pourrait considérer que c'est une erreur sémantique car l'identifiant n'a jamais été déclaré.

  • # Commentaire supprimé

    Posté par  . Évalué à 2.

    Ce commentaire a été supprimé par l’équipe de modération.

    • [^] # Re: Première partie

      Posté par  (site web personnel) . Évalué à 3.

      Merci :) j'oublierai pas de lier la partie 3 ici ^

      https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

      • [^] # Commentaire supprimé

        Posté par  . Évalué à 1. Dernière modification le 14 mars 2022 à 12:31.

        Ce commentaire a été supprimé par l’équipe de modération.

        • [^] # Re: Première partie

          Posté par  (site web personnel) . Évalué à 2.

          Ah je pensais que tu parlais du lien dans la partie 1 vers la partie 2.

          Le lien dans la partie 2 vers la partie 1 et cité tout en haut de l'article :)

          https://link-society.com - https://kubirds.com - https://github.com/link-society/flowg

        • [^] # Re: Première partie

          Posté par  (site web personnel, Mastodon) . Évalué à 2.

          Comme c'est bien étiqueté, faut suivre le tag ; LinuxFr n'est pas conçu/prévu pour faire des livres (plusieurs parties et chapitres quoi.)

          “It is seldom that liberty of any kind is lost all at once.” ― David Hume

  • # ouf

    Posté par  (site web personnel, Mastodon) . Évalué à 4.

    Après avoir lu les deux premières parties, j'étais un peu perdu que ne soit pas mentionné : Lex+Yacc et son pendant GNU Flex+Bison, Alex+Ayacc (Ada), Ocamllex+Ocamlyacc (OCaml), Alex+Happy (Haskell), JFlex+CUP (Java), GPLEX+GPPG (C#), etc. En fait il fallait attendre de tout lire… Et merci ; je découvre l'approche Rust.

    “It is seldom that liberty of any kind is lost all at once.” ― David Hume

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.