🦀 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:
cloudion-labo
2026-08-16 01:07:31 +00:00
commit 2556698dd3
27 changed files with 6518 additions and 0 deletions

8
bion-regex/Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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"));
}