🦀 bions-rust vague 1 : 6 briques build-your-own-x — on ne les rebuild plus jamais
Principe RS-7 : « dès qu'on build un truc, plus personne n'a à le rebuild —
la seule chose à faire est l'optimisation. » (nexus/RepoVerse)
- bion-vc : horloges vectorielles + MvReg fork-visible (LA spec
xion-relativiste-v0 enfin codée — CRDT testé par permutations)
- bion-triplet : l'Adressage Génératif (gen_hash BLAKE3, coords, résidu ;
résidu vide quand déjà-su ; align décidable au bit)
- bion-tsoinlog: journal append-only rejouable (CRC32 maison, crash-recovery)
- bion-kv : magasin clé-valeur bitcask (compaction atomique, tombstones)
- bion-regex : moteur Thompson NFA linéaire (jamais exponentiel — Russ Cox)
- bion-git : mini-git content-addressed (SHA-1 maison + vecteurs officiels,
branches divergentes = le fork visible)
129 tests verts, clippy 0 warning, doc française = chaque bion est un cours.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
8
bion-regex/Cargo.toml
Normal file
8
bion-regex/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "bion-regex"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Moteur d'expressions régulières from scratch, NFA de Thompson + simulation par ensembles d'états : temps linéaire garanti, jamais de backtracking exponentiel. std-only, build-your-own-regex."
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
126
bion-regex/README.md
Normal file
126
bion-regex/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# bion-regex 🔤
|
||||
|
||||
Moteur d'expressions régulières **from scratch, std-only, zéro dépendance,
|
||||
zéro unsafe** — LE classique *build-your-own-regex*, version **NFA de
|
||||
Thompson** simulé par ensembles d'états. La brique regex du xerboxion :
|
||||
construite une fois, plus jamais rebâtie, seulement optimisée (principe RS-7).
|
||||
|
||||
Référence fondatrice : Russ Cox, **« Regular Expression Matching Can Be
|
||||
Simple And Fast »** — <https://swtch.com/~rsc/regexp/regexp1.html>. À lire
|
||||
avec le code sous les yeux : ce crate en est une implémentation Rust fidèle
|
||||
et commentée. Voir aussi la section *build-your-own-x* correspondante :
|
||||
<https://github.com/codecrafters-io/build-your-own-x#build-your-own-regex-engine>.
|
||||
|
||||
## Quoi
|
||||
|
||||
Pipeline en trois étapes, un fichier par étape :
|
||||
|
||||
```
|
||||
motif ──parse──▶ AST ──compile──▶ NFA ──simulation──▶ oui/non + position
|
||||
src/parser.rs src/nfa.rs src/nfa.rs (ensembles d'états)
|
||||
```
|
||||
|
||||
- `Regex::new(pattern) -> Result<Regex, Error>` — compile une fois pour toutes
|
||||
- `is_match(text) -> bool` — le motif apparaît-il quelque part ?
|
||||
- `find(text) -> Option<(usize, usize)>` — première occurrence,
|
||||
*leftmost-longest*, offsets en **octets** (`&text[start..end]` = le match)
|
||||
- Erreurs de syntaxe **localisées** (position en caractères) et en français
|
||||
|
||||
Langage supporté : littéraux Unicode · `.` (tout sauf `\n`) · `*` `+` `?` ·
|
||||
`|` · groupes `(…)` (priorité seulement, pas de capture — choix assumé pour
|
||||
garder l'API minuscule) · classes `[a-z]`, `[^…]`, `]`/`-` littéraux aux
|
||||
positions classiques · `\d \D \w \W \s \S` · `\n \t \r` · métacaractères
|
||||
échappés (`\.` `\(` …) · ancres `^` `$`.
|
||||
|
||||
## Pourquoi Thompson (et pas du backtracking)
|
||||
|
||||
C'est **le** point pédagogique du crate. Un moteur à backtracking (Perl,
|
||||
PCRE, `re` de Python…) essaie les alternatives une par une et revient en
|
||||
arrière : sur `a*a*a*…a*b` face à `aaaa…a` (sans `b`), il doit explorer
|
||||
~2ⁿ découpages avant d'avouer l'échec — 30 `a` suffisent à le figer des
|
||||
secondes, 40 des heures. C'est la racine des CVE « ReDoS ».
|
||||
|
||||
L'approche Thompson (1968), ressuscitée par l'article de Russ Cox :
|
||||
|
||||
1. **Compilation** : chaque nœud de l'AST devient un fragment de NFA d'au
|
||||
plus UN état (`Char`, `Split`, assertions). Le NFA fait O(m) états pour
|
||||
un motif de m caractères — jamais plus.
|
||||
2. **Simulation par ensembles d'états** : on lit le texte UNE fois ; à chaque
|
||||
caractère on maintient *l'ensemble de tous les états où le NFA pourrait
|
||||
être* (≤ n états, dédupliqués). C'est la déterminisation « à la volée »,
|
||||
sans matérialiser le DFA.
|
||||
|
||||
Résultat : **O(texte × motif) garanti, pour tout motif, tout texte**. Le
|
||||
motif pathologique ci-dessus répond en microsecondes — c'est testé,
|
||||
chronomètre à l'appui (`pathologique_a_star_reste_instantane`).
|
||||
|
||||
Les ancres `^`/`$` sont des états-assertions évalués pendant la fermeture
|
||||
epsilon (elles ne consomment rien), donc elles marchent aussi au milieu
|
||||
d'une alternance (`^début|fin$`).
|
||||
|
||||
## Exemple
|
||||
|
||||
```rust
|
||||
use bion_regex::Regex;
|
||||
|
||||
let re = Regex::new(r"^\w+@\w+\.[a-z]+$").unwrap();
|
||||
assert!(re.is_match("rs7@xerion.ch"));
|
||||
|
||||
let re = Regex::new(r"\d+").unwrap();
|
||||
let texte = "il y a 285 bions";
|
||||
let (s, e) = re.find(texte).unwrap();
|
||||
assert_eq!(&texte[s..e], "285");
|
||||
|
||||
// Le piège qui tue un backtracker — instantané ici :
|
||||
let patho = Regex::new(&format!("{}b", "a*".repeat(30))).unwrap();
|
||||
assert!(!patho.is_match(&"a".repeat(30)));
|
||||
```
|
||||
|
||||
## Le bug classique (vécu, puis testé)
|
||||
|
||||
Première version : la fermeture epsilon marquait les états `Split` comme
|
||||
« vus » mais ne les démarquait pas entre deux caractères → les boucles
|
||||
(`a+`, `(ab)*`) n'étaient traversables qu'**une seule fois** (`ab+c`
|
||||
matchait `abc` mais pas `abbc`). Le fix : l'ensemble d'états garde la liste
|
||||
de TOUS les états marqués (pas seulement les stables) pour tout démarquer au
|
||||
`clear`. Si tu réimplémentes ce moteur, tu feras ce bug — le test
|
||||
`plus_exige_au_moins_un` t'attend.
|
||||
|
||||
## Complément, pas doublon
|
||||
|
||||
Le core (`~/xerboxion-rt`) n'a aucun moteur de motifs ; les ploxions font du
|
||||
`match exact only` (cf. tsoin engine / `config/ploxions.php`). `bion-regex`
|
||||
est la brique qui manque : filtrage de tsoins, routes, validation d'entrées —
|
||||
embarquable partout (std-only, compile en WASM sans rien changer).
|
||||
|
||||
## Comment l'optimiser (l'invitation au fork)
|
||||
|
||||
Le moteur est volontairement la version *simple et juste*. Pistes, par ordre
|
||||
de rendement (toutes dans les articles suivants de Russ Cox,
|
||||
[regexp2](https://swtch.com/~rsc/regexp/regexp2.html) et
|
||||
[regexp3](https://swtch.com/~rsc/regexp/regexp3.html)) :
|
||||
|
||||
1. **`find` en un seul passage** — aujourd'hui `find` relance une simulation
|
||||
ancrée par position de départ (O(n²·m) au pire). La VM de Pike attache la
|
||||
position de départ à chaque « thread » : leftmost-longest en O(n·m).
|
||||
2. **Captures** — même VM de Pike : chaque thread porte ses positions de
|
||||
sous-groupes. C'est l'étape qui transforme `(…)` en vraies captures.
|
||||
3. **Cache DFA à la RE2** — mémoïser les ensembles d'états rencontrés :
|
||||
chaque caractère devient UN lookup de table (c'est ce que fait `grep`).
|
||||
4. **Octets plutôt que chars** — compiler les classes Unicode en automate
|
||||
sur les octets UTF-8 : plus de décodage à l'exécution.
|
||||
5. **Littéraux préfixes** — `memchr` sur le premier octet obligatoire avant
|
||||
de lancer le NFA (l'optimisation qui rend `ripgrep` rapide).
|
||||
|
||||
L'API (`new` / `is_match` / `find` / `pattern`) ne bouge pas : optimiser =
|
||||
remplacer l'intérieur, jamais casser l'extérieur.
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
cargo test -p bion-regex # 34 tests : 12 unitaires + 20 intégration + 2 doc
|
||||
```
|
||||
|
||||
Couvre : les pièges pathologiques (chronométrés), boucles vides imbriquées
|
||||
`((a*)*)*`, classes négatives, `]`/`-` littéraux, ancres en alternance,
|
||||
matchs vides, offsets Unicode, et huit erreurs de syntaxe localisées.
|
||||
162
bion-regex/src/lib.rs
Normal file
162
bion-regex/src/lib.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
//! # bion-regex — un moteur d'expressions régulières from scratch
|
||||
//!
|
||||
//! Le classique *build-your-own-regex*, version **NFA de Thompson** : jamais
|
||||
//! de backtracking, donc jamais l'explosion exponentielle des moteurs à la
|
||||
//! Perl/PCRE. Référence pédagogique : Russ Cox, *Regular Expression Matching
|
||||
//! Can Be Simple And Fast* (<https://swtch.com/~rsc/regexp/regexp1.html>).
|
||||
//!
|
||||
//! ## Le pipeline en trois étapes
|
||||
//!
|
||||
//! ```text
|
||||
//! motif ──parse──▶ AST ──compile──▶ NFA ──simulation──▶ oui/non + position
|
||||
//! parser.rs nfa.rs nfa.rs (ensembles d'états)
|
||||
//! ```
|
||||
//!
|
||||
//! 1. **Parse** (`parser.rs`) : descente récursive → arbre de syntaxe.
|
||||
//! 2. **Compile** (`nfa.rs`) : construction de Thompson — chaque nœud de
|
||||
//! l'AST devient un fragment de NFA d'au plus un état ; le NFA fait O(m)
|
||||
//! états pour un motif de m caractères.
|
||||
//! 3. **Simule** : on parcourt le texte UNE fois en maintenant *l'ensemble*
|
||||
//! des états possibles. Coût **O(texte × motif), garanti** — le motif
|
||||
//! pathologique `a*a*a*…` qui fige un backtracker reste instantané ici
|
||||
//! (c'est testé, chronomètre à l'appui).
|
||||
//!
|
||||
//! ## Langage supporté
|
||||
//!
|
||||
//! littéraux Unicode · `.` (tout sauf `\n`) · `*` `+` `?` · `|` · groupes
|
||||
//! `(…)` (priorité seulement, pas de capture) · classes `[a-z]`, `[^…]` ·
|
||||
//! `\d \D \w \W \s \S`, `\n \t \r`, métacaractères échappés (`\.` `\(`…) ·
|
||||
//! ancres `^` `$` (début/fin de texte).
|
||||
//!
|
||||
//! ## Exemple
|
||||
//!
|
||||
//! ```
|
||||
//! use bion_regex::Regex;
|
||||
//!
|
||||
//! let re = Regex::new(r"^\w+@\w+\.[a-z]+$").unwrap();
|
||||
//! assert!(re.is_match("rs7@xerion.ch"));
|
||||
//! assert!(!re.is_match("pas un mail"));
|
||||
//!
|
||||
//! let re = Regex::new(r"\d+").unwrap();
|
||||
//! assert_eq!(re.find("abc 42 def"), Some((4, 6))); // offsets en octets
|
||||
//! assert_eq!(&"abc 42 def"[4..6], "42");
|
||||
//! ```
|
||||
|
||||
mod nfa;
|
||||
mod parser;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Une expression régulière **compilée** : le motif a été parsé et traduit
|
||||
/// en NFA une fois pour toutes — les recherches ne re-parsent jamais.
|
||||
///
|
||||
/// Construire avec [`Regex::new`], interroger avec [`Regex::is_match`] et
|
||||
/// [`Regex::find`]. La compilation est O(m) en temps et en mémoire, la
|
||||
/// recherche est linéaire dans le texte : aucun motif ne peut rendre ce
|
||||
/// moteur exponentiel.
|
||||
#[derive(Debug)]
|
||||
pub struct Regex {
|
||||
pattern: String,
|
||||
nfa: nfa::Nfa,
|
||||
}
|
||||
|
||||
impl Regex {
|
||||
/// Compile `pattern`. Erreur descriptive (avec position **en caractères**
|
||||
/// dans le motif, 0-basé) si la syntaxe est invalide.
|
||||
///
|
||||
/// ```
|
||||
/// use bion_regex::{Regex, Error};
|
||||
/// assert!(Regex::new("a(b|c)*d").is_ok());
|
||||
/// assert_eq!(Regex::new("(ab").unwrap_err(), Error::UnbalancedParen { pos: 0 });
|
||||
/// ```
|
||||
pub fn new(pattern: &str) -> Result<Regex, Error> {
|
||||
let ast = parser::parse(pattern)?;
|
||||
let nfa = nfa::compile(&ast);
|
||||
Ok(Regex {
|
||||
pattern: pattern.to_string(),
|
||||
nfa,
|
||||
})
|
||||
}
|
||||
|
||||
/// Le motif apparaît-il **quelque part** dans `text` ? (recherche non
|
||||
/// ancrée ; utiliser `^`/`$` dans le motif pour ancrer). Un seul passage
|
||||
/// sur le texte, O(texte × motif).
|
||||
pub fn is_match(&self, text: &str) -> bool {
|
||||
self.nfa.is_match(text)
|
||||
}
|
||||
|
||||
/// Première occurrence du motif : `Some((start, end))` en **offsets
|
||||
/// d'octets**, `end` exclu — `&text[start..end]` est le texte reconnu.
|
||||
/// Sémantique *leftmost-longest* (POSIX) : le match qui commence le plus
|
||||
/// tôt, et à cette position, le plus long possible.
|
||||
///
|
||||
/// Un motif pouvant reconnaître la chaîne vide (ex. `a*`) trouve un
|
||||
/// match vide : `find` peut renvoyer `Some((i, i))`.
|
||||
pub fn find(&self, text: &str) -> Option<(usize, usize)> {
|
||||
self.nfa.find(text)
|
||||
}
|
||||
|
||||
/// Le motif d'origine, tel que passé à [`Regex::new`].
|
||||
pub fn pattern(&self) -> &str {
|
||||
&self.pattern
|
||||
}
|
||||
}
|
||||
|
||||
/// Erreur de syntaxe dans un motif. Chaque variante localise le problème :
|
||||
/// `pos` compte en **caractères** (pas en octets) depuis le début du motif,
|
||||
/// 0-basé.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
/// `*`, `+` ou `?` sans rien à répéter devant (ex. `*a`, `(|*)`).
|
||||
DanglingQuantifier { pos: usize },
|
||||
/// Quantificateur appliqué à une ancre (ex. `^*`) : ça n'a pas de sens.
|
||||
QuantifierOnAnchor { pos: usize },
|
||||
/// `(` jamais fermée (pos = la parenthèse ouvrante) ou `)` orpheline
|
||||
/// (pos = la parenthèse fermante).
|
||||
UnbalancedParen { pos: usize },
|
||||
/// `[` jamais fermé (pos = le crochet ouvrant).
|
||||
UnclosedClass { pos: usize },
|
||||
/// Intervalle de classe invalide, ex. `[z-a]` (pos = la borne basse).
|
||||
InvalidClassRange { pos: usize },
|
||||
/// Échappement inconnu, ex. `\q` (pos = le caractère après le `\`).
|
||||
UnknownEscape { c: char, pos: usize },
|
||||
/// Le motif se termine sur un `\` seul.
|
||||
TrailingBackslash,
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::DanglingQuantifier { pos } => {
|
||||
write!(f, "quantificateur sans opérande au caractère {pos}")
|
||||
}
|
||||
Error::QuantifierOnAnchor { pos } => {
|
||||
write!(f, "quantificateur sur une ancre au caractère {pos}")
|
||||
}
|
||||
Error::UnbalancedParen { pos } => {
|
||||
write!(f, "parenthèse non appariée au caractère {pos}")
|
||||
}
|
||||
Error::UnclosedClass { pos } => {
|
||||
write!(
|
||||
f,
|
||||
"classe de caractères jamais fermée (ouverte au caractère {pos})"
|
||||
)
|
||||
}
|
||||
Error::InvalidClassRange { pos } => {
|
||||
write!(
|
||||
f,
|
||||
"intervalle de classe invalide au caractère {pos} (borne basse > haute)"
|
||||
)
|
||||
}
|
||||
Error::UnknownEscape { c, pos } => {
|
||||
write!(f, "échappement inconnu \\{c} au caractère {pos}")
|
||||
}
|
||||
Error::TrailingBackslash => {
|
||||
write!(f, "le motif se termine sur un '\\' seul")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
445
bion-regex/src/nfa.rs
Normal file
445
bion-regex/src/nfa.rs
Normal file
@@ -0,0 +1,445 @@
|
||||
//! Étapes 2 et 3 du pipeline : **AST → NFA de Thompson**, puis **simulation
|
||||
//! par ensembles d'états** — le cœur de l'article de Russ Cox, *Regular
|
||||
//! Expression Matching Can Be Simple And Fast*
|
||||
//! (<https://swtch.com/~rsc/regexp/regexp1.html>).
|
||||
//!
|
||||
//! # La construction de Thompson (1968)
|
||||
//!
|
||||
//! Chaque nœud de l'AST devient un petit **fragment** de NFA : un état
|
||||
//! d'entrée + une liste de flèches de sortie *pendantes* (pas encore
|
||||
//! branchées). On assemble les fragments par récursion structurelle, puis on
|
||||
//! « soude » (`patch`) les flèches pendantes du fragment final sur l'état
|
||||
//! `Match`. Chaque construction ajoute au plus un état :
|
||||
//!
|
||||
//! ```text
|
||||
//! a : ─▶[a]─▶ … (1 état Char)
|
||||
//! e1 e2 : frag(e1) ─▶ frag(e2) (0 état, on soude)
|
||||
//! e1|e2 : ─▶[Split]─▶ frag(e1) (1 état Split)
|
||||
//! └──▶ frag(e2)
|
||||
//! e* : ─▶[Split]─▶ frag(e) ──┐ (1 état, boucle sur le Split)
|
||||
//! └──▶ … ◀────┘
|
||||
//! e+ : ─▶ frag(e) ─▶[Split]─▶ … (comme e* mais on entre par e)
|
||||
//! ▲────────┘
|
||||
//! e? : ─▶[Split]─▶ frag(e) ─▶ … (les deux sorties pendantes)
|
||||
//! └──▶ …
|
||||
//! ```
|
||||
//!
|
||||
//! Le NFA a donc **O(m)** états pour un motif de m caractères. Aucune
|
||||
//! epsilon-transition explicite : les `Split` en tiennent lieu.
|
||||
//!
|
||||
//! # La simulation par ensembles d'états
|
||||
//!
|
||||
//! Au lieu d'explorer le NFA par backtracking (exponentiel dans le pire cas,
|
||||
//! cf. `a*a*a*…` — c'est LE piège de Perl/PCRE que ce crate évite par
|
||||
//! construction), on avance dans le texte **caractère par caractère** en
|
||||
//! maintenant *l'ensemble de tous les états où le NFA pourrait être*. C'est
|
||||
//! exactement la déterminisation « à la volée », sans jamais matérialiser le
|
||||
//! DFA. L'ensemble contient au plus n états (n = taille du NFA), donc chaque
|
||||
//! caractère coûte O(n) : **temps total O(m·n), garanti, quel que soit le
|
||||
//! motif**.
|
||||
//!
|
||||
//! Les ancres `^`/`$` sont des états-assertions traversés pendant la
|
||||
//! fermeture epsilon : elles ne consomment rien, elles vérifient seulement
|
||||
//! la position courante.
|
||||
|
||||
use crate::parser::{Ast, Matcher};
|
||||
|
||||
/// Sentinelle « flèche pas encore soudée ». Après compilation, plus aucune
|
||||
/// flèche ne pointe dessus (vérifié par les tests).
|
||||
const DANGLING: usize = usize::MAX;
|
||||
|
||||
/// Un état du NFA. Les flèches sont des indices dans `Nfa::states` — pas de
|
||||
/// pointeurs, pas d'unsafe : l'arène `Vec` possède tout.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum State {
|
||||
/// Consomme un caractère si `m` l'accepte, puis va en `out`.
|
||||
Char { m: Matcher, out: usize },
|
||||
/// Ne consomme rien : le NFA est dans les DEUX branches à la fois.
|
||||
Split { out1: usize, out2: usize },
|
||||
/// Assertion `^` : franchissable seulement en début de texte.
|
||||
AssertStart { out: usize },
|
||||
/// Assertion `$` : franchissable seulement en fin de texte.
|
||||
AssertEnd { out: usize },
|
||||
/// L'état acceptant : y être = le motif a reconnu.
|
||||
Match,
|
||||
}
|
||||
|
||||
/// Le NFA compilé — la forme exécutable d'un motif.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Nfa {
|
||||
states: Vec<State>,
|
||||
start: usize,
|
||||
/// Indice de l'unique état `Match` (test d'acceptation en O(1)).
|
||||
match_id: usize,
|
||||
}
|
||||
|
||||
/// Un fragment en cours d'assemblage : son état d'entrée et ses flèches de
|
||||
/// sortie pendantes `(état, n° de slot)` — slot 0 = `out`/`out1`, slot 1 =
|
||||
/// `out2` d'un `Split`.
|
||||
struct Frag {
|
||||
start: usize,
|
||||
outs: Vec<(usize, u8)>,
|
||||
}
|
||||
|
||||
/// Compile l'AST en NFA. Infaillible : toute la validation a eu lieu au
|
||||
/// parsing — un AST bien formé donne toujours un NFA bien formé.
|
||||
pub(crate) fn compile(ast: &Ast) -> Nfa {
|
||||
let mut states: Vec<State> = Vec::new();
|
||||
let frag = build(ast, &mut states);
|
||||
let match_id = states.len();
|
||||
states.push(State::Match);
|
||||
patch(&mut states, &frag.outs, match_id);
|
||||
debug_assert!(no_dangling(&states), "flèche pendante après compilation");
|
||||
Nfa {
|
||||
states,
|
||||
start: frag.start,
|
||||
match_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Soude chaque flèche pendante de `outs` vers `target`.
|
||||
fn patch(states: &mut [State], outs: &[(usize, u8)], target: usize) {
|
||||
for &(id, slot) in outs {
|
||||
match (&mut states[id], slot) {
|
||||
(State::Char { out, .. }, _)
|
||||
| (State::AssertStart { out }, _)
|
||||
| (State::AssertEnd { out }, _) => *out = target,
|
||||
(State::Split { out1, .. }, 0) => *out1 = target,
|
||||
(State::Split { out2, .. }, _) => *out2 = target,
|
||||
(State::Match, _) => unreachable!("Match n'a pas de sortie"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn no_dangling(states: &[State]) -> bool {
|
||||
states.iter().all(|s| match s {
|
||||
State::Char { out, .. } | State::AssertStart { out } | State::AssertEnd { out } => {
|
||||
*out != DANGLING
|
||||
}
|
||||
State::Split { out1, out2 } => *out1 != DANGLING && *out2 != DANGLING,
|
||||
State::Match => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// La traduction structurelle AST → fragments (schémas en tête de module).
|
||||
fn build(ast: &Ast, states: &mut Vec<State>) -> Frag {
|
||||
match ast {
|
||||
Ast::Empty => {
|
||||
// Un Split dont les deux sorties pendantes seront soudées au même
|
||||
// endroit : une pure epsilon-transition.
|
||||
let i = states.len();
|
||||
states.push(State::Split {
|
||||
out1: DANGLING,
|
||||
out2: DANGLING,
|
||||
});
|
||||
Frag {
|
||||
start: i,
|
||||
outs: vec![(i, 0), (i, 1)],
|
||||
}
|
||||
}
|
||||
Ast::Char(m) => {
|
||||
let i = states.len();
|
||||
states.push(State::Char {
|
||||
m: m.clone(),
|
||||
out: DANGLING,
|
||||
});
|
||||
Frag {
|
||||
start: i,
|
||||
outs: vec![(i, 0)],
|
||||
}
|
||||
}
|
||||
Ast::AnchorStart => {
|
||||
let i = states.len();
|
||||
states.push(State::AssertStart { out: DANGLING });
|
||||
Frag {
|
||||
start: i,
|
||||
outs: vec![(i, 0)],
|
||||
}
|
||||
}
|
||||
Ast::AnchorEnd => {
|
||||
let i = states.len();
|
||||
states.push(State::AssertEnd { out: DANGLING });
|
||||
Frag {
|
||||
start: i,
|
||||
outs: vec![(i, 0)],
|
||||
}
|
||||
}
|
||||
Ast::Concat(a, b) => {
|
||||
let fa = build(a, states);
|
||||
let fb = build(b, states);
|
||||
patch(states, &fa.outs, fb.start);
|
||||
Frag {
|
||||
start: fa.start,
|
||||
outs: fb.outs,
|
||||
}
|
||||
}
|
||||
Ast::Alt(a, b) => {
|
||||
let fa = build(a, states);
|
||||
let fb = build(b, states);
|
||||
let i = states.len();
|
||||
states.push(State::Split {
|
||||
out1: fa.start,
|
||||
out2: fb.start,
|
||||
});
|
||||
let mut outs = fa.outs;
|
||||
outs.extend(fb.outs);
|
||||
Frag { start: i, outs }
|
||||
}
|
||||
Ast::Star(a) => {
|
||||
let fa = build(a, states);
|
||||
let i = states.len();
|
||||
states.push(State::Split {
|
||||
out1: fa.start,
|
||||
out2: DANGLING,
|
||||
});
|
||||
patch(states, &fa.outs, i); // la boucle
|
||||
Frag {
|
||||
start: i,
|
||||
outs: vec![(i, 1)],
|
||||
}
|
||||
}
|
||||
Ast::Plus(a) => {
|
||||
let fa = build(a, states);
|
||||
let i = states.len();
|
||||
states.push(State::Split {
|
||||
out1: fa.start,
|
||||
out2: DANGLING,
|
||||
});
|
||||
patch(states, &fa.outs, i); // la boucle — mais on ENTRE par fa
|
||||
Frag {
|
||||
start: fa.start,
|
||||
outs: vec![(i, 1)],
|
||||
}
|
||||
}
|
||||
Ast::Quest(a) => {
|
||||
let fa = build(a, states);
|
||||
let i = states.len();
|
||||
states.push(State::Split {
|
||||
out1: fa.start,
|
||||
out2: DANGLING,
|
||||
});
|
||||
let mut outs = fa.outs;
|
||||
outs.push((i, 1));
|
||||
Frag { start: i, outs }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simulation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// L'ensemble d'états courant : une liste dense pour itérer + un tableau de
|
||||
/// marques pour dédupliquer en O(1). Les marques garantissent AUSSI la
|
||||
/// terminaison de la fermeture epsilon face aux boucles vides (`(a*)*`).
|
||||
struct StateSet {
|
||||
/// Les états « stables » (Char/Match) — ceux que `step` fait avancer.
|
||||
list: Vec<usize>,
|
||||
/// TOUS les états marqués (y compris les Split/assertions traversés) :
|
||||
/// indispensable pour les démarquer au `clear` suivant. Premier bug
|
||||
/// classique de ce moteur : ne démarquer que `list`, et les boucles ne
|
||||
/// sont alors traversables qu'une seule fois.
|
||||
marked: Vec<usize>,
|
||||
seen: Vec<bool>,
|
||||
}
|
||||
|
||||
impl StateSet {
|
||||
fn new(n: usize) -> Self {
|
||||
StateSet {
|
||||
list: Vec::with_capacity(n),
|
||||
marked: Vec::with_capacity(n),
|
||||
seen: vec![false; n],
|
||||
}
|
||||
}
|
||||
fn clear(&mut self) {
|
||||
for &id in &self.marked {
|
||||
self.seen[id] = false;
|
||||
}
|
||||
self.marked.clear();
|
||||
self.list.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Nfa {
|
||||
/// Ajoute `id` **et toute sa fermeture epsilon** à `set`. `at_start` /
|
||||
/// `at_end` décrivent la position courante dans le texte : c'est là que
|
||||
/// les assertions `^`/`$` sont tranchées. Itératif (pile explicite) pour
|
||||
/// être insensible à la profondeur du motif.
|
||||
fn add_closure(&self, id: usize, at_start: bool, at_end: bool, set: &mut StateSet) {
|
||||
let mut stack = vec![id];
|
||||
while let Some(id) = stack.pop() {
|
||||
if set.seen[id] {
|
||||
continue;
|
||||
}
|
||||
set.seen[id] = true;
|
||||
set.marked.push(id);
|
||||
match &self.states[id] {
|
||||
State::Split { out1, out2 } => {
|
||||
stack.push(*out1);
|
||||
stack.push(*out2);
|
||||
}
|
||||
State::AssertStart { out } => {
|
||||
if at_start {
|
||||
stack.push(*out);
|
||||
}
|
||||
}
|
||||
State::AssertEnd { out } => {
|
||||
if at_end {
|
||||
stack.push(*out);
|
||||
}
|
||||
}
|
||||
// Char et Match sont les seuls états "stables" de l'ensemble.
|
||||
State::Char { .. } | State::Match => set.list.push(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Un pas de simulation : consomme `ch` (situé à [pos, next_pos) en
|
||||
/// octets) et remplit `next` avec les états atteignables.
|
||||
fn step(&self, cur: &StateSet, ch: char, next_pos: usize, len: usize, next: &mut StateSet) {
|
||||
next.clear();
|
||||
for &id in &cur.list {
|
||||
if let State::Char { m, out } = &self.states[id] {
|
||||
if m.matches(ch) {
|
||||
self.add_closure(*out, next_pos == 0, next_pos == len, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_match(&self, set: &StateSet) -> bool {
|
||||
set.seen[self.match_id]
|
||||
}
|
||||
|
||||
/// Recherche **non ancrée** : « le motif apparaît-il quelque part ? ».
|
||||
///
|
||||
/// L'astuce : on réinjecte l'état de départ à CHAQUE position — c'est
|
||||
/// l'équivalent d'un `.*` implicite devant le motif, sans le payer en
|
||||
/// états. Un seul passage sur le texte : O(texte × états).
|
||||
pub(crate) fn is_match(&self, text: &str) -> bool {
|
||||
let len = text.len();
|
||||
let mut cur = StateSet::new(self.states.len());
|
||||
let mut next = StateSet::new(self.states.len());
|
||||
|
||||
self.add_closure(self.start, true, len == 0, &mut cur);
|
||||
if self.has_match(&cur) {
|
||||
return true;
|
||||
}
|
||||
for (i, ch) in text.char_indices() {
|
||||
let next_pos = i + ch.len_utf8();
|
||||
self.step(&cur, ch, next_pos, len, &mut next);
|
||||
std::mem::swap(&mut cur, &mut next);
|
||||
// Nouveau départ possible à next_pos (recherche non ancrée).
|
||||
self.add_closure(self.start, false, next_pos == len, &mut cur);
|
||||
if self.has_match(&cur) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Recherche du match **le plus à gauche, le plus long** : pour chaque
|
||||
/// position de départ candidate (dans l'ordre), simulation ancrée qui
|
||||
/// note la dernière position d'acceptation. Premier départ gagnant =
|
||||
/// résultat. Offsets en **octets**, `end` exclu — directement
|
||||
/// utilisables pour trancher `&text[start..end]`.
|
||||
pub(crate) fn find(&self, text: &str) -> Option<(usize, usize)> {
|
||||
let len = text.len();
|
||||
let starts = text
|
||||
.char_indices()
|
||||
.map(|(i, _)| i)
|
||||
.chain(std::iter::once(len));
|
||||
for s in starts {
|
||||
if let Some(end) = self.match_at(text, s) {
|
||||
return Some((s, end));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Simulation ancrée à `from` : renvoie la fin (exclue) du match le plus
|
||||
/// long commençant exactement à `from`, s'il existe.
|
||||
fn match_at(&self, text: &str, from: usize) -> Option<usize> {
|
||||
let len = text.len();
|
||||
let mut cur = StateSet::new(self.states.len());
|
||||
let mut next = StateSet::new(self.states.len());
|
||||
|
||||
self.add_closure(self.start, from == 0, from == len, &mut cur);
|
||||
let mut last = if self.has_match(&cur) {
|
||||
Some(from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for (i, ch) in text[from..].char_indices() {
|
||||
if cur.list.is_empty() {
|
||||
break; // plus aucun futur possible : inutile de continuer
|
||||
}
|
||||
let next_pos = from + i + ch.len_utf8();
|
||||
self.step(&cur, ch, next_pos, len, &mut next);
|
||||
std::mem::swap(&mut cur, &mut next);
|
||||
if self.has_match(&cur) {
|
||||
last = Some(next_pos);
|
||||
}
|
||||
}
|
||||
last
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests unitaires du compilateur/simulateur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::parser::parse;
|
||||
|
||||
fn nfa(pat: &str) -> Nfa {
|
||||
compile(&parse(pat).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taille_lineaire_du_nfa() {
|
||||
// Thompson : ~1 état par construction. Un motif de 30 `a*` doit
|
||||
// rester à ~2 états par `a*` (+1 Match), pas exploser.
|
||||
let pat = "a*".repeat(30);
|
||||
let n = nfa(&pat);
|
||||
assert!(
|
||||
n.states.len() <= 2 * 30 + 1,
|
||||
"NFA trop gros : {}",
|
||||
n.states.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boucle_vide_termine() {
|
||||
// (a*)* : une étoile d'expression pouvant matcher vide crée un cycle
|
||||
// d'epsilon — la fermeture doit terminer grâce aux marques `seen`.
|
||||
let n = nfa("(a*)*b");
|
||||
assert!(n.is_match("aaab"));
|
||||
assert!(!n.is_match("aaa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_exige_au_moins_un() {
|
||||
let n = nfa("ab+c");
|
||||
assert!(!n.is_match("ac"));
|
||||
assert!(n.is_match("abc"));
|
||||
assert!(n.is_match("abbbbc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_at_prend_le_plus_long() {
|
||||
let n = nfa("a+");
|
||||
assert_eq!(n.match_at("aaab", 0), Some(3));
|
||||
assert_eq!(n.match_at("aaab", 3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assertion_fin_dans_la_fermeture() {
|
||||
let n = nfa("a$");
|
||||
assert!(n.is_match("bca"));
|
||||
assert!(!n.is_match("ab"));
|
||||
}
|
||||
}
|
||||
449
bion-regex/src/parser.rs
Normal file
449
bion-regex/src/parser.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
//! Étape 1 du pipeline : **motif texte → AST**.
|
||||
//!
|
||||
//! Analyseur syntaxique par **descente récursive**, une fonction par niveau
|
||||
//! de priorité de la grammaire (du plus faible au plus fort) :
|
||||
//!
|
||||
//! ```text
|
||||
//! alternance := concat ( '|' concat )* — priorité la plus faible
|
||||
//! concat := répétition*
|
||||
//! répétition := atome ( '*' | '+' | '?' )*
|
||||
//! atome := '(' alternance ')' — groupe (priorité max)
|
||||
//! | '[' classe ']' — classe de caractères
|
||||
//! | '.' | '^' | '$'
|
||||
//! | '\' échappement
|
||||
//! | caractère littéral
|
||||
//! ```
|
||||
//!
|
||||
//! La descente récursive rend la priorité des opérateurs *structurelle* :
|
||||
//! `ab|cd*` se parse naturellement en `(ab)|(c(d*))` parce que `parse_alt`
|
||||
//! appelle `parse_concat` qui appelle `parse_repeat` — chaque niveau « voit »
|
||||
//! d'abord ce qui le lie le plus fort. Les groupes `(…)` ne servent ici qu'à
|
||||
//! forcer la priorité : sans capture dans l'API, ils sont transparents dans
|
||||
//! l'AST (c'est un choix assumé, voir le README).
|
||||
|
||||
use crate::Error;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reconnaisseur de caractère
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Ce qu'une transition du NFA *consomme* : la question « ce caractère du
|
||||
/// texte convient-il ? ». Littéral, joker `.` ou classe — c'est TOUT ce que
|
||||
/// le moteur sait tester, et c'est suffisant pour tout le langage supporté
|
||||
/// (`\d`, `\w`, `\s` ne sont que des classes pré-remplies).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum Matcher {
|
||||
/// Un caractère précis (Unicode, pas seulement ASCII).
|
||||
Char(char),
|
||||
/// Le joker `.` — tout caractère **sauf** `\n` (convention classique :
|
||||
/// `.` ne traverse pas les lignes).
|
||||
Any,
|
||||
/// Une classe `[…]` : liste d'éléments, éventuellement niée (`[^…]`).
|
||||
Class {
|
||||
negated: bool,
|
||||
items: Vec<ClassItem>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Un élément d'une classe : un caractère seul ou un intervalle `a-z`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ClassItem {
|
||||
Ch(char),
|
||||
Range(char, char),
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
/// Le test central du moteur : `c` est-il accepté ?
|
||||
/// Pour une classe niée, on inverse simplement le verdict (XOR).
|
||||
pub(crate) fn matches(&self, c: char) -> bool {
|
||||
match self {
|
||||
Matcher::Char(x) => *x == c,
|
||||
Matcher::Any => c != '\n',
|
||||
Matcher::Class { negated, items } => {
|
||||
let hit = items.iter().any(|it| match it {
|
||||
ClassItem::Ch(x) => *x == c,
|
||||
ClassItem::Range(lo, hi) => *lo <= c && c <= *hi,
|
||||
});
|
||||
hit != *negated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `\d` = `[0-9]`.
|
||||
fn class_digit() -> Vec<ClassItem> {
|
||||
vec![ClassItem::Range('0', '9')]
|
||||
}
|
||||
/// `\w` = `[a-zA-Z0-9_]`.
|
||||
fn class_word() -> Vec<ClassItem> {
|
||||
vec![
|
||||
ClassItem::Range('a', 'z'),
|
||||
ClassItem::Range('A', 'Z'),
|
||||
ClassItem::Range('0', '9'),
|
||||
ClassItem::Ch('_'),
|
||||
]
|
||||
}
|
||||
/// `\s` = les blancs usuels (espace, tab, sauts de ligne, etc.).
|
||||
fn class_space() -> Vec<ClassItem> {
|
||||
vec![
|
||||
ClassItem::Ch(' '),
|
||||
ClassItem::Ch('\t'),
|
||||
ClassItem::Ch('\n'),
|
||||
ClassItem::Ch('\r'),
|
||||
ClassItem::Ch('\u{000B}'), // tab vertical
|
||||
ClassItem::Ch('\u{000C}'), // form feed
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AST
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// L'arbre de syntaxe abstraite du motif. Volontairement minuscule : sept
|
||||
/// constructions suffisent à couvrir tout le langage supporté, et chacune
|
||||
/// se traduit en un fragment de NFA d'une poignée d'états (voir `nfa.rs`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum Ast {
|
||||
/// Le motif vide (`""`, ou une branche vide de `a|`) : accepte la chaîne vide.
|
||||
Empty,
|
||||
/// Un reconnaisseur de caractère (littéral, `.`, classe, `\d`…).
|
||||
Char(Matcher),
|
||||
/// Concaténation `ab` : d'abord le gauche, puis le droit.
|
||||
Concat(Box<Ast>, Box<Ast>),
|
||||
/// Alternance `a|b` : le gauche OU le droit.
|
||||
Alt(Box<Ast>, Box<Ast>),
|
||||
/// `a*` : zéro ou plusieurs fois.
|
||||
Star(Box<Ast>),
|
||||
/// `a+` : une ou plusieurs fois.
|
||||
Plus(Box<Ast>),
|
||||
/// `a?` : zéro ou une fois.
|
||||
Quest(Box<Ast>),
|
||||
/// `^` : assertion « on est au début du texte » (ne consomme rien).
|
||||
AnchorStart,
|
||||
/// `$` : assertion « on est à la fin du texte » (ne consomme rien).
|
||||
AnchorEnd,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parseur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Point d'entrée : parse `pattern` en entier ou explique pourquoi c'est
|
||||
/// impossible. Les positions des erreurs comptent en **caractères** (pas en
|
||||
/// octets), 0-basé — plus lisible pour l'humain qui a tapé le motif.
|
||||
pub(crate) fn parse(pattern: &str) -> Result<Ast, Error> {
|
||||
let mut p = Parser {
|
||||
chars: pattern.chars().collect(),
|
||||
pos: 0,
|
||||
};
|
||||
let ast = p.parse_alt()?;
|
||||
// S'il reste quelque chose, c'est forcément une ')' orpheline :
|
||||
// tout le reste aurait été consommé par parse_concat.
|
||||
if let Some(c) = p.peek() {
|
||||
debug_assert_eq!(c, ')');
|
||||
return Err(Error::UnbalancedParen { pos: p.pos });
|
||||
}
|
||||
Ok(ast)
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
chars: Vec<char>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.chars.get(self.pos).copied()
|
||||
}
|
||||
fn peek2(&self) -> Option<char> {
|
||||
self.chars.get(self.pos + 1).copied()
|
||||
}
|
||||
fn bump(&mut self) -> Option<char> {
|
||||
let c = self.peek();
|
||||
if c.is_some() {
|
||||
self.pos += 1;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// alternance := concat ('|' concat)*
|
||||
fn parse_alt(&mut self) -> Result<Ast, Error> {
|
||||
let mut node = self.parse_concat()?;
|
||||
while self.peek() == Some('|') {
|
||||
self.bump();
|
||||
let rhs = self.parse_concat()?;
|
||||
node = Ast::Alt(Box::new(node), Box::new(rhs));
|
||||
}
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// concat := répétition* — s'arrête sur `|`, `)` ou la fin.
|
||||
fn parse_concat(&mut self) -> Result<Ast, Error> {
|
||||
let mut node: Option<Ast> = None;
|
||||
loop {
|
||||
match self.peek() {
|
||||
None | Some('|') | Some(')') => break,
|
||||
_ => {}
|
||||
}
|
||||
let (atom, quantifiable) = self.parse_atom()?;
|
||||
let atom = self.parse_postfix(atom, quantifiable)?;
|
||||
node = Some(match node {
|
||||
None => atom,
|
||||
Some(n) => Ast::Concat(Box::new(n), Box::new(atom)),
|
||||
});
|
||||
}
|
||||
// Aucune brique ? C'est le motif vide (permis : `a|` ou `()`).
|
||||
Ok(node.unwrap_or(Ast::Empty))
|
||||
}
|
||||
|
||||
/// répétition := atome ('*'|'+'|'?')* — on autorise l'empilement
|
||||
/// (`a*?`, `a**`) : avec un NFA c'est bien défini et inoffensif.
|
||||
fn parse_postfix(&mut self, mut node: Ast, quantifiable: bool) -> Result<Ast, Error> {
|
||||
while let Some(q @ ('*' | '+' | '?')) = self.peek() {
|
||||
if !quantifiable {
|
||||
return Err(Error::QuantifierOnAnchor { pos: self.pos });
|
||||
}
|
||||
self.bump();
|
||||
node = match q {
|
||||
'*' => Ast::Star(Box::new(node)),
|
||||
'+' => Ast::Plus(Box::new(node)),
|
||||
_ => Ast::Quest(Box::new(node)),
|
||||
};
|
||||
}
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// atome — retourne aussi « peut-on le quantifier ? » (non pour `^`/`$` :
|
||||
/// `^*` n'a pas de sens, on préfère une erreur nette à un comportement
|
||||
/// surprenant).
|
||||
fn parse_atom(&mut self) -> Result<(Ast, bool), Error> {
|
||||
let start = self.pos;
|
||||
let c = self.bump().expect("parse_concat garantit un caractère");
|
||||
match c {
|
||||
'(' => {
|
||||
let inner = self.parse_alt()?;
|
||||
if self.bump() != Some(')') {
|
||||
return Err(Error::UnbalancedParen { pos: start });
|
||||
}
|
||||
// Groupe transparent : il n'a servi qu'à la priorité.
|
||||
Ok((inner, true))
|
||||
}
|
||||
'[' => Ok((Ast::Char(self.parse_class(start)?), true)),
|
||||
'.' => Ok((Ast::Char(Matcher::Any), true)),
|
||||
'^' => Ok((Ast::AnchorStart, false)),
|
||||
'$' => Ok((Ast::AnchorEnd, false)),
|
||||
'*' | '+' | '?' => Err(Error::DanglingQuantifier { pos: start }),
|
||||
'\\' => Ok((Ast::Char(self.parse_escape()?), true)),
|
||||
lit => Ok((Ast::Char(Matcher::Char(lit)), true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Échappement hors classe : `\d \D \w \W \s \S`, contrôles `\n \t \r`,
|
||||
/// et tout métacaractère rendu littéral (`\.` `\*` `\(`…).
|
||||
fn parse_escape(&mut self) -> Result<Matcher, Error> {
|
||||
let at = self.pos;
|
||||
let c = self.bump().ok_or(Error::TrailingBackslash)?;
|
||||
let m = match c {
|
||||
'd' => Matcher::Class {
|
||||
negated: false,
|
||||
items: class_digit(),
|
||||
},
|
||||
'D' => Matcher::Class {
|
||||
negated: true,
|
||||
items: class_digit(),
|
||||
},
|
||||
'w' => Matcher::Class {
|
||||
negated: false,
|
||||
items: class_word(),
|
||||
},
|
||||
'W' => Matcher::Class {
|
||||
negated: true,
|
||||
items: class_word(),
|
||||
},
|
||||
's' => Matcher::Class {
|
||||
negated: false,
|
||||
items: class_space(),
|
||||
},
|
||||
'S' => Matcher::Class {
|
||||
negated: true,
|
||||
items: class_space(),
|
||||
},
|
||||
'n' => Matcher::Char('\n'),
|
||||
't' => Matcher::Char('\t'),
|
||||
'r' => Matcher::Char('\r'),
|
||||
'.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
|
||||
| '-' | '/' => Matcher::Char(c),
|
||||
other => return Err(Error::UnknownEscape { c: other, pos: at }),
|
||||
};
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// classe := '[' '^'? élément+ ']'
|
||||
///
|
||||
/// Subtilités classiques gérées :
|
||||
/// - `]` en **première** position est littéral : `[]a]` = « `]` ou `a` » ;
|
||||
/// - `-` en début ou fin de classe est littéral : `[a-]` = « `a` ou `-` » ;
|
||||
/// - `\d \w \s` se développent en leurs éléments ; `\n \t \r \] \\ \- \^`
|
||||
/// sont des littéraux ;
|
||||
/// - `[z-a]` (borne basse > haute) est une erreur, pas un piège silencieux.
|
||||
fn parse_class(&mut self, open_pos: usize) -> Result<Matcher, Error> {
|
||||
let negated = if self.peek() == Some('^') {
|
||||
self.bump();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let mut items: Vec<ClassItem> = Vec::new();
|
||||
let mut first = true;
|
||||
loop {
|
||||
let at = self.pos;
|
||||
let c = self.bump().ok_or(Error::UnclosedClass { pos: open_pos })?;
|
||||
if c == ']' && !first {
|
||||
break;
|
||||
}
|
||||
first = false;
|
||||
// Départ d'élément : simple caractère, ou échappement.
|
||||
let lo: ClassEsc = if c == '\\' {
|
||||
self.parse_class_escape()?
|
||||
} else {
|
||||
ClassEsc::One(c)
|
||||
};
|
||||
match lo {
|
||||
ClassEsc::Many(mut set) => items.append(&mut set),
|
||||
ClassEsc::One(lo_c) => {
|
||||
// Intervalle ? Seulement si `-` n'est pas collé à `]`.
|
||||
if self.peek() == Some('-')
|
||||
&& self.peek2().is_some()
|
||||
&& self.peek2() != Some(']')
|
||||
{
|
||||
self.bump(); // le '-'
|
||||
let hc = self.bump().ok_or(Error::UnclosedClass { pos: open_pos })?;
|
||||
let hi_c = if hc == '\\' {
|
||||
match self.parse_class_escape()? {
|
||||
ClassEsc::One(x) => x,
|
||||
// `[a-\d]` : un ensemble comme borne haute
|
||||
// n'a pas de sens.
|
||||
ClassEsc::Many(_) => {
|
||||
return Err(Error::InvalidClassRange { pos: at })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hc
|
||||
};
|
||||
if lo_c > hi_c {
|
||||
return Err(Error::InvalidClassRange { pos: at });
|
||||
}
|
||||
items.push(ClassItem::Range(lo_c, hi_c));
|
||||
} else {
|
||||
items.push(ClassItem::Ch(lo_c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Matcher::Class { negated, items })
|
||||
}
|
||||
|
||||
/// Échappement **dans** une classe. Les versions niées (`\D`…) y sont
|
||||
/// refusées : une négation dans une classe déjà (peut-être) niée est un
|
||||
/// nid à confusion — on préfère l'interdire proprement.
|
||||
fn parse_class_escape(&mut self) -> Result<ClassEsc, Error> {
|
||||
let at = self.pos;
|
||||
let c = self.bump().ok_or(Error::TrailingBackslash)?;
|
||||
let e = match c {
|
||||
'd' => ClassEsc::Many(class_digit()),
|
||||
'w' => ClassEsc::Many(class_word()),
|
||||
's' => ClassEsc::Many(class_space()),
|
||||
'n' => ClassEsc::One('\n'),
|
||||
't' => ClassEsc::One('\t'),
|
||||
'r' => ClassEsc::One('\r'),
|
||||
']' | '\\' | '-' | '^' | '[' => ClassEsc::One(c),
|
||||
other => return Err(Error::UnknownEscape { c: other, pos: at }),
|
||||
};
|
||||
Ok(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Résultat d'un échappement en classe : un caractère, ou un paquet
|
||||
/// d'éléments (`\d` → l'intervalle `0-9`).
|
||||
enum ClassEsc {
|
||||
One(char),
|
||||
Many(Vec<ClassItem>),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests unitaires du parseur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn priorite_alternance_concat_etoile() {
|
||||
// ab|cd* doit se lire (ab)|(c(d*))
|
||||
let ast = parse("ab|cd*").unwrap();
|
||||
match ast {
|
||||
Ast::Alt(l, r) => {
|
||||
assert!(matches!(*l, Ast::Concat(_, _)));
|
||||
match *r {
|
||||
Ast::Concat(_, d) => assert!(matches!(*d, Ast::Star(_))),
|
||||
other => panic!("attendu Concat, obtenu {other:?}"),
|
||||
}
|
||||
}
|
||||
other => panic!("attendu Alt, obtenu {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groupe_transparent() {
|
||||
// (a) et a produisent le même AST
|
||||
assert_eq!(parse("(a)").unwrap(), parse("a").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motif_vide_et_branche_vide() {
|
||||
assert_eq!(parse("").unwrap(), Ast::Empty);
|
||||
assert!(matches!(parse("a|").unwrap(), Ast::Alt(_, b) if *b == Ast::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erreurs_de_syntaxe() {
|
||||
assert_eq!(parse("*a"), Err(Error::DanglingQuantifier { pos: 0 }));
|
||||
assert_eq!(parse("(ab"), Err(Error::UnbalancedParen { pos: 0 }));
|
||||
assert_eq!(parse("ab)"), Err(Error::UnbalancedParen { pos: 2 }));
|
||||
assert_eq!(parse("[a-"), Err(Error::UnclosedClass { pos: 0 }));
|
||||
assert_eq!(parse("[z-a]"), Err(Error::InvalidClassRange { pos: 1 }));
|
||||
assert_eq!(parse("\\q"), Err(Error::UnknownEscape { c: 'q', pos: 1 }));
|
||||
assert_eq!(parse("a\\"), Err(Error::TrailingBackslash));
|
||||
assert_eq!(parse("^*"), Err(Error::QuantifierOnAnchor { pos: 1 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classe_crochet_litteral_en_tete() {
|
||||
// []a] = la classe { ']', 'a' }
|
||||
let m = match parse("[]a]").unwrap() {
|
||||
Ast::Char(m) => m,
|
||||
other => panic!("attendu Char, obtenu {other:?}"),
|
||||
};
|
||||
assert!(m.matches(']'));
|
||||
assert!(m.matches('a'));
|
||||
assert!(!m.matches('b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classe_tiret_litteral_en_fin() {
|
||||
let m = match parse("[a-]").unwrap() {
|
||||
Ast::Char(m) => m,
|
||||
other => panic!("attendu Char, obtenu {other:?}"),
|
||||
};
|
||||
assert!(m.matches('a'));
|
||||
assert!(m.matches('-'));
|
||||
assert!(!m.matches('b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_point_exclut_newline() {
|
||||
assert!(Matcher::Any.matches('x'));
|
||||
assert!(!Matcher::Any.matches('\n'));
|
||||
}
|
||||
}
|
||||
283
bion-regex/tests/regex.rs
Normal file
283
bion-regex/tests/regex.rs
Normal file
@@ -0,0 +1,283 @@
|
||||
//! Tests d'intégration de bion-regex : l'API publique, les pièges classiques
|
||||
//! et LA garantie du moteur — le motif pathologique reste instantané.
|
||||
//!
|
||||
//! NOTE : `clippy::invalid_regex` est un faux positif ici — le lint croit
|
||||
//! reconnaître le crate `regex` externe à cause du nom `Regex::new`, alors
|
||||
//! qu'on teste NOTRE moteur, y compris ses motifs volontairement invalides.
|
||||
#![allow(clippy::invalid_regex)]
|
||||
|
||||
use bion_regex::{Error, Regex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bases : littéraux, ., quantificateurs, alternance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn litteraux() {
|
||||
let re = Regex::new("chat").unwrap();
|
||||
assert!(re.is_match("le chat dort"));
|
||||
assert!(!re.is_match("le chien dort"));
|
||||
assert_eq!(re.pattern(), "chat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_joker_sauf_newline() {
|
||||
let re = Regex::new("a.c").unwrap();
|
||||
assert!(re.is_match("abc"));
|
||||
assert!(re.is_match("aéc")); // Unicode : é = 1 caractère (2 octets)
|
||||
assert!(!re.is_match("a\nc")); // `.` ne traverse pas les lignes
|
||||
assert!(!re.is_match("ac"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantificateurs_star_plus_quest() {
|
||||
let star = Regex::new("ab*c").unwrap();
|
||||
assert!(star.is_match("ac"));
|
||||
assert!(star.is_match("abbbc"));
|
||||
|
||||
let plus = Regex::new("ab+c").unwrap();
|
||||
assert!(!plus.is_match("ac"));
|
||||
assert!(plus.is_match("abc"));
|
||||
|
||||
let quest = Regex::new("ab?c").unwrap();
|
||||
assert!(quest.is_match("ac"));
|
||||
assert!(quest.is_match("abc"));
|
||||
assert!(!quest.is_match("abbc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternance() {
|
||||
let re = Regex::new("chat|chien|oiseau").unwrap();
|
||||
assert!(re.is_match("un chien"));
|
||||
assert!(re.is_match("un oiseau"));
|
||||
assert!(!re.is_match("un poisson"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Groupes (y compris imbriqués)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn groupes_imbriques() {
|
||||
// ((ab)+c)|d — groupes dans groupes, quantifiés
|
||||
let re = Regex::new("((ab)+c)|d").unwrap();
|
||||
assert!(re.is_match("abc"));
|
||||
assert!(re.is_match("abababc"));
|
||||
assert!(re.is_match("d"));
|
||||
assert!(!re.is_match("ac"));
|
||||
assert!(!re.is_match("ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groupe_quantifie_et_alternance_interne() {
|
||||
let re = Regex::new("^(ab|cd)*$").unwrap();
|
||||
assert!(re.is_match(""));
|
||||
assert!(re.is_match("abcdab"));
|
||||
assert!(!re.is_match("abc"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Classes de caractères
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn classes_simples_et_intervalles() {
|
||||
let re = Regex::new("[a-f0-9]+").unwrap();
|
||||
assert!(re.is_match("deadbeef42"));
|
||||
assert_eq!(re.find("xyz a3f xyz"), Some((4, 7)));
|
||||
assert!(!re.is_match("xyz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classe_negative() {
|
||||
let re = Regex::new("[^0-9 ]+").unwrap();
|
||||
assert_eq!(re.find("12 abc 34"), Some((3, 6))); // « abc »
|
||||
let strict = Regex::new("^[^abc]+$").unwrap();
|
||||
assert!(strict.is_match("xyz"));
|
||||
assert!(!strict.is_match("xbz")); // contient un 'b'
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echappements_d_w_s() {
|
||||
let re = Regex::new(r"\d+\s\w+").unwrap();
|
||||
assert!(re.is_match("il y a 42 bions"));
|
||||
assert!(!re.is_match("quarante-deux bions"));
|
||||
|
||||
let nd = Regex::new(r"^\D+$").unwrap();
|
||||
assert!(nd.is_match("abc!"));
|
||||
assert!(!nd.is_match("ab3c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metacaracteres_echappes() {
|
||||
let re = Regex::new(r"^\(\d+\.\d+\)$").unwrap();
|
||||
assert!(re.is_match("(3.14)"));
|
||||
assert!(!re.is_match("(3x14)")); // le \. est bien littéral
|
||||
assert!(!re.is_match("3.14"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ancres
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ancres_debut_fin() {
|
||||
let deb = Regex::new("^abc").unwrap();
|
||||
assert!(deb.is_match("abcdef"));
|
||||
assert!(!deb.is_match("xabc"));
|
||||
|
||||
let fin = Regex::new("abc$").unwrap();
|
||||
assert!(fin.is_match("xxabc"));
|
||||
assert!(!fin.is_match("abcx"));
|
||||
|
||||
let exact = Regex::new("^abc$").unwrap();
|
||||
assert!(exact.is_match("abc"));
|
||||
assert!(!exact.is_match("aabc"));
|
||||
assert!(!exact.is_match("abcc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancres_dans_les_branches() {
|
||||
// L'ancre est une assertion d'état, pas un simple préfixe :
|
||||
// elle marche aussi au milieu d'une alternance.
|
||||
let re = Regex::new("^début|fin$").unwrap();
|
||||
assert!(re.is_match("début de texte"));
|
||||
assert!(re.is_match("texte fin"));
|
||||
assert!(!re.is_match("la fin arrive")); // « fin » pas en fin de texte
|
||||
assert!(!re.is_match("le début")); // « début » pas en début de texte
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// find : positions, leftmost-longest, matchs vides, Unicode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn find_leftmost_longest() {
|
||||
let re = Regex::new("a+").unwrap();
|
||||
assert_eq!(re.find("baaa"), Some((1, 4))); // le plus long à la 1re position
|
||||
assert_eq!(re.find("bbb"), None);
|
||||
|
||||
// Leftmost prime sur longest : le match à 0 gagne même s'il est court.
|
||||
let re = Regex::new("ab?").unwrap();
|
||||
assert_eq!(re.find("acabb"), Some((0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_match_vide() {
|
||||
// b* reconnaît la chaîne vide : match vide à la position 0.
|
||||
let re = Regex::new("b*").unwrap();
|
||||
assert_eq!(re.find("aaab"), Some((0, 0)));
|
||||
assert_eq!(re.find(""), Some((0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_offsets_octets_unicode() {
|
||||
// Les offsets sont en OCTETS : « é » en occupe deux.
|
||||
let re = Regex::new("chè+vre").unwrap();
|
||||
let texte = "la chèèèvre";
|
||||
let (s, e) = re.find(texte).unwrap();
|
||||
assert_eq!(&texte[s..e], "chèèèvre");
|
||||
assert_eq!(s, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texte_vide_et_motif_vide() {
|
||||
let vide = Regex::new("").unwrap();
|
||||
assert!(vide.is_match(""));
|
||||
assert!(vide.is_match("abc"));
|
||||
assert_eq!(vide.find("abc"), Some((0, 0)));
|
||||
|
||||
let re = Regex::new("a").unwrap();
|
||||
assert!(!re.is_match(""));
|
||||
assert_eq!(re.find(""), None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Erreurs de syntaxe propres
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn erreurs_de_syntaxe_propres() {
|
||||
assert_eq!(
|
||||
Regex::new("*a").unwrap_err(),
|
||||
Error::DanglingQuantifier { pos: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
Regex::new("(ab").unwrap_err(),
|
||||
Error::UnbalancedParen { pos: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
Regex::new("ab)").unwrap_err(),
|
||||
Error::UnbalancedParen { pos: 2 }
|
||||
);
|
||||
assert_eq!(
|
||||
Regex::new("[abc").unwrap_err(),
|
||||
Error::UnclosedClass { pos: 0 }
|
||||
);
|
||||
assert_eq!(
|
||||
Regex::new("[z-a]").unwrap_err(),
|
||||
Error::InvalidClassRange { pos: 1 }
|
||||
);
|
||||
assert_eq!(
|
||||
Regex::new(r"\q").unwrap_err(),
|
||||
Error::UnknownEscape { c: 'q', pos: 1 }
|
||||
);
|
||||
assert_eq!(Regex::new("ab\\").unwrap_err(), Error::TrailingBackslash);
|
||||
assert_eq!(
|
||||
Regex::new("^*").unwrap_err(),
|
||||
Error::QuantifierOnAnchor { pos: 1 }
|
||||
);
|
||||
// Les messages Display sont en français et localisés :
|
||||
let msg = Regex::new("(ab").unwrap_err().to_string();
|
||||
assert!(msg.contains("parenthèse"), "message inattendu : {msg}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LE test du crate : pas d'explosion exponentielle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pathologique_a_star_reste_instantane() {
|
||||
// « a*a*a*…a*b » sur 30 'a' sans 'b' : un backtracker doit essayer
|
||||
// ~2^30 découpages avant d'échouer. Le NFA de Thompson simulé par
|
||||
// ensembles d'états répond en O(texte × motif) — microsecondes.
|
||||
let pattern = format!("{}b", "a*".repeat(30));
|
||||
let text = "a".repeat(30);
|
||||
|
||||
let re = Regex::new(&pattern).unwrap();
|
||||
let debut = Instant::now();
|
||||
assert!(!re.is_match(&text));
|
||||
assert_eq!(re.find(&text), None);
|
||||
let duree = debut.elapsed();
|
||||
assert!(
|
||||
duree < Duration::from_secs(1),
|
||||
"pathologique trop lent : {duree:?} (devrait être ~µs)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathologique_a_quest_n_a_n() {
|
||||
// L'autre classique de l'article de Russ Cox : a?ⁿaⁿ sur « aⁿ ».
|
||||
// Ici le match EXISTE (tous les a? prennent vide) — un backtracker
|
||||
// le trouve vite dans ce sens, mais a?ⁿaⁿ sur aⁿ⁻¹ (échec) le tue.
|
||||
let n = 25;
|
||||
let pattern = format!("^{}{}$", "a?".repeat(n), "a".repeat(n));
|
||||
let re = Regex::new(&pattern).unwrap();
|
||||
|
||||
let debut = Instant::now();
|
||||
assert!(re.is_match(&"a".repeat(n))); // succès
|
||||
assert!(!re.is_match(&"a".repeat(n - 1))); // échec = le cas qui explose ailleurs
|
||||
let duree = debut.elapsed();
|
||||
assert!(duree < Duration::from_secs(1), "trop lent : {duree:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boucles_vides_imbriquees() {
|
||||
// (a*)* et ((a*)*)* : cycles d'epsilon-transitions — la simulation doit
|
||||
// terminer (déduplication des états) et donner le bon résultat.
|
||||
let re = Regex::new("^((a*)*)*b$").unwrap();
|
||||
assert!(re.is_match("b"));
|
||||
assert!(re.is_match("aaaab"));
|
||||
assert!(!re.is_match("aaaa"));
|
||||
}
|
||||
Reference in New Issue
Block a user