Bac à sable interractif pour BNF en Rust (Leptos & Trunk)

Création d'un bac à sable interractif pour tester vos grammaires visuellement et rapidement.

2025-08-13

Rust offre de très bons parseurs BNF, mais ils sont généralement pensés pour le backend. Avec la compilation en WebAssembly, ce n'est plus réellement un problème. Dans cet article, nous construisons les base d'un bac à sable interactif avec Leptos et Trunk pour debugger des BNF entièrement dans le navigateur.

Des regex...

Récemment, j’ai dû analyser des références bibliques dans le contenu de publications quotidiennes couvrant plus de 15 ans et des dizaines d’auteurs. Par exemple, « Jn 12.3-4 » désigne les versets 3 et 4 du 12ᵉ chapitre de l’évangile de Jean — mais « Joh 12.3,4 » ou « J12.3 et 4 » signifient la même chose, selon l’auteur.

Comme tout le monde, j’ai commencé par des regex. Beaucoup de regex. Comme celle-ci :

let main_regex = Regex::new(
    r"(?:(?P<book>\d?[\wÉéèêîïâäàçôöûü]+)[ ]+)?(?P<verses>(?:(?:(?:\d+)[: ]+)?(?:(?:(?:\d+)-(?:\d+))|(?:\d+))(?:,|,[ ]|[ ]et[ ])?)+)"
)?;

let chapters_regex = Regex::new(
    r"(?:(?P<chapter>\d+)[: ])(?P<verses>(?:(?:(?:(?:\d+)-(?:\d+))|(?:\d+))(?:,|,[ ]|[ ]et[ ]))*)(?P<last>(?:(?:(?:\d+)-(?:\d+))|(?:\d+))$)?", 
)?;

let verse_range_regex = Regex::new(
    r"(?:(?:(?:(?P<start>\d+)-(?P<end>\d+))|(?P<verse>\d+))(?:,|,[ ]|[ ]et[ ])?)"
)?;

... aux formes Backus–Naur

Fatigué de bricoler ce genre de monstres et ayant une patience très limitée pour déboguer ce genre de choses — même avec un bac à sable en ligne — j’ai exploré d’autres techniques.

Les formes Backus–Naur (BNF) en sont une. C’est une représentation formelle d’une grammaire (un ensemble de règles décrivant ce qui est un entrée valide ou non). Par exemple, un nombre pourrait être défini ainsi :

<number> ::= <digit> | <number> <digit>
<digit> ::= '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0'

C’est très formel, j’en conviens. On n’a pas les jolis raccourcis et plages de caractères des regex. En fait, au sens le plus strict, BNF décrit un ensemble plus vaste : les grammaires hors-contexte. (Mais en pratique, la plupart des implémentations de regex modernes disposent de fonctionnalités supplémentaires qui leur permettent de dépasser leur définition théorique).

L’avantage, c’est qu’on obtient une description expressive de ce à quoi une phrase doit ressembler pour respecter une grammaire. L’exemple précédent se lit ainsi :

Un chiffre est l’un des caractères 1, 2, 3, 4, 5, 6, 7, 8, 9 ou 0 Un nombre est soit un chiffre seul, soit un chiffre suivi de quelque chose qui est un nombre

Cette expressivité rend la BNF idéale pour décrire des spécifications formelles.

Analyseurs syntaxiques

Comme on peut s’en douter, il existe des analyseurs pour les BNF. J’ai utilisé par exemple la bibliothèque Rust bnf crate. Rust est particulièrement adapté à ce type de parsing.

Elle fonctionne bien, en construisant un arbre représentant le résultat de l’analyse. Dans l’exemple précédent, on obtiendrait :

<number> ::= <number> <digit>
├── <number> ::= <digit>
│   └── <digit> ::= "1"
│       └── "1"
└── <digit> ::= "2"
   └── "2" 

Côté frontend

Parfait, nous avons un moyen de décrire des grammaires, et un analyseur. Si seulement on pouvait avoir un petit bac à sable pour les tester ! On pourrait créer un petit serveur pour ça, et un frontend React…

Attendez ! Pourquoi ? Autant l’exécuter directement dans le navigateur !

Est-ce que ça marche ? Parfaitement !

Leptos et Trunk

Leptos est un framework web Rust, qui supporte le rendu côté serveur (SSR) et le rendu côté client (CSR, via Trunk). Pour commencer :

cargo install trunk
cargo init epigrammars 

cd epigrammars
cargo add leptos --features=csr
cargo add bnf

rustup target add wasm32-unknown-unknown

Ensuite, on peut créer notre application : ajoutons simplement deux zones de texte. La réactivité est assurée avec des signals. On les relie à prop:value et on:input:target sur les champs texte.

Dans notre vue, on affiche la saisie et la grammaire en dessous.

use leptos::prelude::*;

fn main() {
    leptos::mount::mount_to_body(App)
}

const EXAMPLE_GRAMMAR: &str = r#"<number> ::= <digit> | <number> <digit>
<digit> ::= '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '0'
"#;

#[component]
pub fn App() -> impl IntoView {
    let (grammar, set_grammar) = signal(EXAMPLE_GRAMMAR.to_string());
    let (input, set_input) = signal("12".to_string());

    view! {
        <div>
            <h2>Grammaire</h2>
            <textarea
                on:input:target=move |ev| { set_grammar.set(ev.target().value()); }
                prop:value=grammar />
        </div>

        <div>
            <h2>Entrée</h2>
            <textarea
                on:input:target=move |ev| { set_input.set(ev.target().value()); }
                prop:value=input />
        </div>

        <p>Entrée : {input}</p>
        <p>Grammaire : {grammar}</p>
    }
}
Champs interactifs
Champs interactifs

Pour l’instant, grammar est de type ReadSignal<String>. On voudrait l’analyser en une structure de grammaire BNF, et la mettre à jour si l’entrée change. Pour dériver de nouveaux signals à partir d’existants, et ne recalculer que si les valeurs changent, on peut utiliser Memo :

let parsed_grammar: Memo<Result<Grammar, String>> = Memo::new(
    move |_| match Grammar::from_str(&grammar.get()) {
        Ok(grammar) => Ok(grammar),
        Err(bnf::Error::ParseError(e)) => Err(format!("Grammaire invalide : {:}", e)),
        Err(e) => Err(format!("Échec de l’analyse, erreur inconnue : {:#?}", e)),
    }
);

Dans la fermeture de Memo, il faut récupérer la valeur courante du signal dépendant (grammar.get()).

On peut alors dériver un autre signal avec l’analyse de l’entrée :

let output = Memo::new(move |_| match parsed_grammar.get() {
    Ok(grammar) => {
        let binding = input.get();
        grammar
            .parse_input(&binding)
            .map(|output| format!("{}", output))
            .collect::<Vec<_>>()
            .join("\n")
    }
    Err(message) => message,
});

Et enfin, l’afficher :

<pre> {output} </pre>
Affichage du résultat de l’analyse
Affichage du résultat de l’analyse

Aller plus loin

Cet exemple très simple peut être amélioré, par exemple en ajoutant un composant récursif pour afficher les nœuds imbriqués, et des boutons pour choisir la règle de production avec laquelle analyser.

Version améliorée avec styles et composants imbriqués
Version améliorée avec styles et composants imbriqués

Bac à sable en ligne