rustyfi_lang/regexp.rs
1//! A backtracking regular-expression engine for OCaml's `Str` dialect.
2//!
3//! Upstream SATySFi's `regexp` values are `Str.regexp`s and its regexp
4//! primitives (`string-scan`, `string-match`, `split-on-regexp`) are thin
5//! wrappers over `Str.string_match` / `Str.search_forward`. This port models a
6//! `regexp` as its own pattern *string* (`regexp-of-string` is the identity),
7//! so the dialect has to be interpreted here.
8//!
9//! # Why `Str` and not a stock regex crate
10//!
11//! `Str`'s surface syntax is deliberately unlike PCRE, and the difference is
12//! not cosmetic — it inverts which characters are special:
13//!
14//! * grouping is `\(` … `\)`, and a bare `(` / `)` is a LITERAL parenthesis;
15//! * alternation is `\|`, and a bare `|` is a LITERAL bar;
16//! * `{` and `}` are always literal — `Str` has no counted repetition at all;
17//! * inside a `[…]` set, backslash is NOT an escape: `[\t]` is the two-element
18//! set `{'\\', 't'}`.
19//!
20//! Feeding a `Str` pattern to a PCRE-syntax engine therefore does not merely
21//! fail, it silently means something else: `satysfi-code-printer`'s SATySFi
22//! identifier rule `\(\\\|\+\)?[a-zA-Z][a-zA-Z0-9-]*\|[0-9]+` would parse as a
23//! literal-parenthesis soup. Translating `Str` to PCRE is the same work as
24//! parsing `Str`, minus the control over match semantics, so the pattern is
25//! parsed directly here.
26//!
27//! # Semantics
28//!
29//! `Str` is a backtracking matcher (`strstubs.c`'s `re_match`), so quantifiers
30//! are Perl-style — greedy by default, leftmost-biased alternation, first
31//! match in backtracking order rather than leftmost-longest. This engine
32//! reproduces that, which is what makes `Str.matched_string` predictable:
33//! `a\|ab` matched against `"ab"` yields `"a"` in both.
34//!
35//! Supported: `.` (any but newline), `*`, `+`, `?` and their lazy `*?`, `+?`,
36//! `??` forms, `[…]` sets with ranges and a leading `^` complement, `^` / `$`
37//! (line anchors), `\|`, `\(`…`\)`, `\b`, `\1`–`\9` backreferences, and `\`
38//! quoting of any special character.
39
40use std::cell::{Cell, RefCell};
41use std::collections::HashMap;
42use std::rc::Rc;
43
44/// One element of a `[…]` character set.
45#[derive(Debug, Clone, PartialEq, Eq)]
46enum ClassItem {
47 Char(char),
48 Range(char, char),
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52enum Node {
53 /// Matches nothing and consumes nothing — the empty branch of `a\|`.
54 Empty,
55 Char(char),
56 /// `.` — any character except a newline, per `Str`.
57 Any,
58 Class {
59 negated: bool,
60 items: Vec<ClassItem>,
61 },
62 /// `^` — start of the text or just after a `\n`.
63 Bol,
64 /// `$` — end of the text or just before a `\n`.
65 Eol,
66 /// `\b` — a word-constituent/non-constituent boundary.
67 WordBoundary,
68 /// `\(` … `\)`; the index is 1-based, matching `Str.matched_group`.
69 Group(usize, Box<Node>),
70 /// `\1` … `\9`.
71 Backref(usize),
72 Concat(Vec<Node>),
73 /// `\|`, in source order — the matcher tries branches left to right.
74 Alt(Vec<Node>),
75 Repeat {
76 node: Box<Node>,
77 min: u32,
78 /// `None` for an unbounded `*` / `+`.
79 max: Option<u32>,
80 greedy: bool,
81 },
82}
83
84/// A parsed pattern, plus the group count needed to size the capture vector.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Regexp {
87 root: Node,
88 groups: usize,
89 /// The parser hit [`Budget::MAX_NESTING`] and read a `\(` as a literal
90 /// `(`. The tree below is therefore not this pattern, so matching it
91 /// reports [`GaveUp`] rather than answering from the degraded reading —
92 /// the parser's other degradations turn a malformed pattern into a
93 /// literal one, but this one would turn a WELL-FORMED pattern into a
94 /// different well-formed pattern, and answer confidently.
95 truncated: bool,
96}
97
98struct Parser<'p> {
99 src: &'p [char],
100 pos: usize,
101 groups: usize,
102 /// `\(` nesting still available. The grammar below is recursive descent —
103 /// `parse_atom` → `parse_alt` → `parse_concat` → `parse_repeat` →
104 /// `parse_atom` — so a pattern is free to drive the PARSER off the stack
105 /// even though nothing has matched yet. See [`Budget::MAX_NESTING`].
106 nesting: usize,
107 /// Set once the cap above has fired; see [`Regexp::truncated`].
108 truncated: bool,
109}
110
111impl<'p> Parser<'p> {
112 fn peek(&self) -> Option<char> {
113 self.src.get(self.pos).copied()
114 }
115
116 fn peek_at(&self, off: usize) -> Option<char> {
117 self.src.get(self.pos + off).copied()
118 }
119
120 fn bump(&mut self) -> Option<char> {
121 let c = self.peek();
122 if c.is_some() {
123 self.pos += 1;
124 }
125 c
126 }
127
128 /// alternation := concat (`\|` concat)*
129 fn parse_alt(&mut self) -> Node {
130 let mut branches = vec![self.parse_concat()];
131 while self.peek() == Some('\\') && self.peek_at(1) == Some('|') {
132 self.pos += 2;
133 branches.push(self.parse_concat());
134 }
135 if branches.len() == 1 {
136 branches.pop().unwrap()
137 } else {
138 Node::Alt(branches)
139 }
140 }
141
142 /// concat := repeat*, stopping at `\|` or `\)` (or end of input).
143 fn parse_concat(&mut self) -> Node {
144 let mut items: Vec<Node> = Vec::new();
145 loop {
146 match self.peek() {
147 None => break,
148 Some('\\') => match self.peek_at(1) {
149 // Both terminate a concatenation; leave them for the caller.
150 Some('|') | Some(')') => break,
151 _ => {}
152 },
153 _ => {}
154 }
155 match self.parse_repeat() {
156 Some(node) => items.push(node),
157 None => break,
158 }
159 }
160 match items.len() {
161 0 => Node::Empty,
162 1 => items.pop().unwrap(),
163 _ => Node::Concat(items),
164 }
165 }
166
167 /// repeat := atom postfix*, where postfix is `*`, `+` or `?` with an
168 /// optional trailing `?` making it lazy.
169 fn parse_repeat(&mut self) -> Option<Node> {
170 let mut node = self.parse_atom()?;
171 loop {
172 let (min, max) = match self.peek() {
173 Some('*') => (0, None),
174 Some('+') => (1, None),
175 Some('?') => (0, Some(1)),
176 _ => break,
177 };
178 self.pos += 1;
179 // `Str` supports the lazy forms `*?`, `+?`, `??`. A `?` here is
180 // therefore a modifier, not another quantifier.
181 let greedy = if self.peek() == Some('?') {
182 self.pos += 1;
183 false
184 } else {
185 true
186 };
187 node = Node::Repeat {
188 node: Box::new(node),
189 min,
190 max,
191 greedy,
192 };
193 }
194 Some(node)
195 }
196
197 fn parse_atom(&mut self) -> Option<Node> {
198 let c = self.peek()?;
199 match c {
200 '.' => {
201 self.pos += 1;
202 Some(Node::Any)
203 }
204 '^' => {
205 self.pos += 1;
206 Some(Node::Bol)
207 }
208 '$' => {
209 self.pos += 1;
210 Some(Node::Eol)
211 }
212 '[' => {
213 self.pos += 1;
214 Some(self.parse_class())
215 }
216 // A quantifier with nothing to quantify is a literal in `Str`.
217 '*' | '+' | '?' => {
218 self.pos += 1;
219 Some(Node::Char(c))
220 }
221 '\\' => {
222 self.pos += 1;
223 let e = match self.bump() {
224 Some(e) => e,
225 // A trailing lone backslash is itself.
226 None => return Some(Node::Char('\\')),
227 };
228 match e {
229 // Past the nesting cap a `\(` reads as the literal `(` it
230 // would be without the backslash — recursing further is
231 // how the PARSER goes off the stack, before anything has
232 // matched. `truncated` then makes every match report
233 // `GaveUp`, so the degraded tree is never answered from.
234 '(' if self.nesting == 0 => {
235 self.truncated = true;
236 Some(Node::Char('('))
237 }
238 '(' => {
239 self.groups += 1;
240 let idx = self.groups;
241 self.nesting -= 1;
242 let inner = self.parse_alt();
243 self.nesting += 1;
244 // Tolerate an unclosed `\(` rather than failing the
245 // whole pattern: `Str` would raise, but a raised
246 // exception here would abort a whole document over
247 // one malformed rule.
248 if self.peek() == Some('\\') && self.peek_at(1) == Some(')') {
249 self.pos += 2;
250 }
251 Some(Node::Group(idx, Box::new(inner)))
252 }
253 'b' => Some(Node::WordBoundary),
254 d if d.is_ascii_digit() && d != '0' => {
255 Some(Node::Backref(d as usize - '0' as usize))
256 }
257 other => Some(Node::Char(other)),
258 }
259 }
260 other => {
261 self.pos += 1;
262 Some(Node::Char(other))
263 }
264 }
265 }
266
267 /// `[…]`. Per `Str`, a leading `^` complements, a `]` or `-` in first
268 /// position is literal, and backslash is NOT an escape inside the set.
269 fn parse_class(&mut self) -> Node {
270 let negated = if self.peek() == Some('^') {
271 self.pos += 1;
272 true
273 } else {
274 false
275 };
276 let mut items: Vec<ClassItem> = Vec::new();
277 let mut first = true;
278 loop {
279 let c = match self.peek() {
280 None => break,
281 Some(c) => c,
282 };
283 if c == ']' && !first {
284 self.pos += 1;
285 break;
286 }
287 self.pos += 1;
288 first = false;
289 // `a-z`, but a `-` in last position is a literal `-`.
290 if self.peek() == Some('-')
291 && self.peek_at(1).is_some()
292 && self.peek_at(1) != Some(']')
293 {
294 self.pos += 1;
295 let hi = self.bump().unwrap();
296 items.push(ClassItem::Range(c, hi));
297 } else {
298 items.push(ClassItem::Char(c));
299 }
300 }
301 Node::Class { negated, items }
302 }
303}
304
305impl Regexp {
306 /// Parse a `Str`-dialect pattern. Never fails: malformed input degrades to
307 /// the most literal reading, because a raised error here would take down a
308 /// whole document over a single bad highlighting rule.
309 pub fn parse(pattern: &str) -> Regexp {
310 let chars: Vec<char> = pattern.chars().collect();
311 let mut p = Parser {
312 src: &chars,
313 pos: 0,
314 groups: 0,
315 nesting: Budget::MAX_NESTING,
316 truncated: false,
317 };
318 let root = p.parse_alt();
319 Regexp {
320 root,
321 groups: p.groups,
322 truncated: p.truncated,
323 }
324 }
325
326 /// Anchored match at `start`, returning the end offset (in `char`s) of the
327 /// match — `Str.string_match` plus `Str.match_end`.
328 ///
329 /// A pattern that exhausts its step budget reports `Err(GaveUp)` rather
330 /// than "no match": see [`Budget`]. Silently answering `None` would turn a
331 /// hang into wrong output, which is worse.
332 pub fn match_at(&self, input: &[char], start: usize) -> Result<Option<usize>, GaveUp> {
333 if self.truncated {
334 return Err(GaveUp);
335 }
336 let m = Matcher::with_budget(input);
337 let mut caps = self.fresh_caps();
338 let out = self.attempt(&m, start, &mut caps);
339 if m.gave_up.get() {
340 Err(GaveUp)
341 } else {
342 Ok(out)
343 }
344 }
345
346 /// Leftmost match at or after `start` — `Str.search_forward`.
347 pub fn search_from(
348 &self,
349 input: &[char],
350 start: usize,
351 ) -> Result<Option<(usize, usize)>, GaveUp> {
352 // ONE budget for the whole search, not one per start position. A fresh
353 // `Matcher` per position — which is what calling `match_at` in the
354 // loop would build — makes the total `input.len() × budget`, i.e. the
355 // budget stops bounding anything as soon as the input is long, which
356 // is precisely when it needs to.
357 if self.truncated {
358 return Err(GaveUp);
359 }
360 let m = Matcher::with_budget(input);
361 let mut caps = self.fresh_caps();
362 for i in start..=input.len() {
363 caps.iter_mut().for_each(|c| *c = None);
364 let hit = self.attempt(&m, i, &mut caps);
365 if m.gave_up.get() {
366 return Err(GaveUp);
367 }
368 if let Some(end) = hit {
369 return Ok(Some((i, end)));
370 }
371 }
372 Ok(None)
373 }
374
375 fn fresh_caps(&self) -> Vec<Option<(usize, usize)>> {
376 vec![None; self.groups + 1]
377 }
378
379 /// One anchored attempt against an already-seeded budget.
380 fn attempt(
381 &self,
382 m: &Matcher<'_>,
383 start: usize,
384 caps: &mut Vec<Option<(usize, usize)>>,
385 ) -> Option<usize> {
386 m.node(&self.root, start, caps, &|end, _| Some(end))
387 }
388}
389
390/// The matcher ran out of steps or nesting depth before deciding.
391///
392/// This is the regexp twin of `rustyfi-syntax`'s `ParseFailureKind::GaveUp`,
393/// and it exists for the same reason: the matcher is an ordered-choice
394/// backtracker, so a pattern with a quantified group inside a quantifier
395/// (`\(a*\)*b`) costs a factor per nesting level. Unbounded, thirty
396/// characters of input take two minutes and thirty-two never finish — in a
397/// browser tab that is a freeze with no cancel, because the playground
398/// compiles on the main thread.
399#[derive(Clone, Copy, Debug, PartialEq, Eq)]
400pub struct GaveUp;
401
402struct Matcher<'i> {
403 input: &'i [char],
404 /// Work remaining. One unit per [`Matcher::node`] entry — every
405 /// alternative the backtracker tries — plus one per CHARACTER for the two
406 /// places a single entry buys a whole scan: `repeat`'s single-character
407 /// fast path and a backreference's slice compare. Charging those keeps
408 /// the budget a bound on work rather than merely on step count; without
409 /// it a step is O(input) and the wall clock is quadratic while the
410 /// counter looks linear.
411 fuel: Cell<u64>,
412 /// [`Matcher::node`] frames still available. Every recursion in the
413 /// matcher — `repeat`'s general branch, a nested `Group`, the `seq` chain
414 /// down a `Concat` — bottoms out in a `node` call whose frame stays live
415 /// while its continuation runs, so counting live `node` frames counts the
416 /// stack. Separate from `fuel` because the failure it prevents is
417 /// different: not slowness but a stack overflow, which aborts the process
418 /// natively and traps unrecoverably in wasm, where the shadow stack is
419 /// fixed at link time.
420 frames: Cell<u32>,
421 gave_up: Cell<bool>,
422}
423
424/// How much work a single [`Regexp::match_at`] may do.
425///
426/// Modelled on `rustyfi-syntax`'s `Budget`, and seeded the same way — a floor
427/// so a short input still gets a real answer, plus an allowance per input
428/// character so a long one is not cut off for being long. Only SUPERLINEAR
429/// backtracking can outrun it.
430struct Budget;
431
432impl Budget {
433 /// Work units per input character.
434 ///
435 /// Measured, not guessed. Across all 80 patterns `satysfi-code-printer`'s
436 /// `code-syntax.satyg` can produce — every `syntax-rule-line`, both halves
437 /// of every `syntax-rule-block`, and every keyword list joined the way
438 /// `strlst-to-syntax-rule` joins it — the worst honest cost against a
439 /// deliberately hostile 200,000-character input (an unterminated string
440 /// body, `"[^"]*"`, which scans to the end and then retries the closing
441 /// quote at every offset) is **2 units per character**. Everything else in
442 /// the corpus is O(1) in the input, because the single-character
443 /// quantifier takes the iterative fast path. 64 is a 32× margin on that.
444 ///
445 /// The old value here was 4,096, and the cost of that slack is paid on
446 /// the pathological side, because the give-up point is `PER_CHAR × len`
447 /// units away: `\(a*\)*b` against 200,000 characters took **21.7 s** to
448 /// give up. It now takes 0.17 s. Erring high is not free — a bound this
449 /// loose is a bound in name only, since nothing in a document is
450 /// interactive at twenty seconds.
451 const PER_CHAR: u64 = 64;
452
453 /// Floor, so a pattern against a short input is not capped below what a
454 /// long one gets. Also measured: the most work any corpus pattern does in
455 /// one anchored match is 835 units (the ~470-branch COBOL keyword
456 /// alternation), so this is a 1,197× margin, and about 25 ms of trying.
457 const FLOOR: u64 = 1_000_000;
458
459 fn for_input(len: usize) -> u64 {
460 ((len as u64).saturating_mul(Self::PER_CHAR)).max(Self::FLOOR)
461 }
462
463 /// Live [`Matcher::node`] frames allowed.
464 ///
465 /// Also measured, by bisecting the thread stack a match actually needs.
466 /// At 4,096 the worst shape found needs 1.3 MB in a release build and
467 /// 5.9 MB in a debug one — inside the wasm shadow stack 24× over, and
468 /// inside a default 8 MB thread stack even unoptimised.
469 ///
470 /// This replaced a cap on `repeat`'s recursion depth alone, which did NOT
471 /// bound the stack: the bytes a repeat LEVEL costs are a function of the
472 /// pattern nested inside it, so while `\(a\)*` needed 2.75 MB at the old
473 /// 5,000-level cap, `\(` × 60 around the same body needed **59 MB**
474 /// (227 MB in a debug build) — past the wasm shadow stack, and heading
475 /// for the CLI worker's 256 MB. Nor did it cover the other two recursions:
476 /// a 200,000-atom `Concat` took 37.6 MB and 100,000 nested `\(` took the
477 /// same, both without touching `repeat` at all. The four shapes now come
478 /// in at 1.3 MB / 0.9 MB / 0.2 MB / 0.4 MB.
479 const MAX_FRAMES: u32 = 4_096;
480
481 /// `\(` nesting the PARSER will descend into; see [`Parser::nesting`].
482 /// Four frames a level (`parse_atom` → `parse_alt` → `parse_concat` →
483 /// `parse_repeat`) at about 850 bytes, so under a megabyte at the cap.
484 /// Unbounded, `\(` × 300,000 overflowed a 256 MB stack before a single
485 /// character had been matched. The deepest pattern in the corpus nests
486 /// twice.
487 const MAX_NESTING: usize = 1_024;
488}
489
490/// Returns a [`Matcher::frames`] slot on scope exit, so a backtracked branch
491/// does not permanently consume nesting the way a bare decrement would.
492struct Frame<'a, 'i>(&'a Matcher<'i>);
493
494impl Drop for Frame<'_, '_> {
495 fn drop(&mut self) {
496 self.0.frames.set(self.0.frames.get() + 1);
497 }
498}
499
500/// The continuation a node hands its remainder to. Returning `Some(end)`
501/// means the whole match succeeded and `end` is its final offset; `None`
502/// asks the node to backtrack and try its next alternative.
503type Cont<'k> = &'k dyn Fn(usize, &mut Vec<Option<(usize, usize)>>) -> Option<usize>;
504
505fn is_word_char(c: char) -> bool {
506 c.is_alphanumeric() || c == '_'
507}
508
509impl<'i> Matcher<'i> {
510 fn with_budget(input: &'i [char]) -> Self {
511 Matcher {
512 input,
513 fuel: Cell::new(Budget::for_input(input.len())),
514 frames: Cell::new(Budget::MAX_FRAMES),
515 gave_up: Cell::new(false),
516 }
517 }
518
519 /// Spend `n` units of work. `false` means the budget is gone and the
520 /// caller must unwind — every `node` alternative checks this, so
521 /// exhaustion stops the whole match rather than being mistaken for a
522 /// failed alternative.
523 fn spend(&self, n: u64) -> bool {
524 if self.gave_up.get() {
525 return false;
526 }
527 match self.fuel.get().checked_sub(n) {
528 Some(left) => {
529 self.fuel.set(left);
530 true
531 }
532 None => {
533 self.fuel.set(0);
534 self.gave_up.set(true);
535 false
536 }
537 }
538 }
539
540 /// Take one step and one stack frame, returning the frame on scope exit.
541 /// `None` means one of the two budgets is gone.
542 fn enter(&self) -> Option<Frame<'_, 'i>> {
543 if !self.spend(1) {
544 return None;
545 }
546 match self.frames.get() {
547 0 => {
548 self.gave_up.set(true);
549 None
550 }
551 n => {
552 self.frames.set(n - 1);
553 Some(Frame(self))
554 }
555 }
556 }
557
558 fn single(&self, node: &Node, pos: usize) -> Option<usize> {
559 let c = *self.input.get(pos)?;
560 let ok = match node {
561 Node::Char(want) => c == *want,
562 Node::Any => c != '\n',
563 Node::Class { negated, items } => {
564 let hit = items.iter().any(|it| match it {
565 ClassItem::Char(x) => c == *x,
566 ClassItem::Range(lo, hi) => *lo <= c && c <= *hi,
567 });
568 hit != *negated
569 }
570 _ => return None,
571 };
572 if ok {
573 Some(pos + 1)
574 } else {
575 None
576 }
577 }
578
579 fn node(
580 &self,
581 node: &Node,
582 pos: usize,
583 caps: &mut Vec<Option<(usize, usize)>>,
584 k: Cont<'_>,
585 ) -> Option<usize> {
586 let _frame = self.enter()?;
587 match node {
588 Node::Empty => k(pos, caps),
589 Node::Char(_) | Node::Any | Node::Class { .. } => match self.single(node, pos) {
590 Some(next) => k(next, caps),
591 None => None,
592 },
593 Node::Bol => {
594 if pos == 0 || self.input.get(pos - 1) == Some(&'\n') {
595 k(pos, caps)
596 } else {
597 None
598 }
599 }
600 Node::Eol => {
601 if pos == self.input.len() || self.input.get(pos) == Some(&'\n') {
602 k(pos, caps)
603 } else {
604 None
605 }
606 }
607 Node::WordBoundary => {
608 let before = pos > 0 && self.input.get(pos - 1).copied().is_some_and(is_word_char);
609 let after = pos < self.input.len() && is_word_char(self.input[pos]);
610 if before != after {
611 k(pos, caps)
612 } else {
613 None
614 }
615 }
616 Node::Backref(n) => {
617 let (s, e) = match caps.get(*n).copied().flatten() {
618 Some(span) => span,
619 // An unset group matches the empty string, as in `Str`.
620 None => return k(pos, caps),
621 };
622 let len = e - s;
623 if pos + len > self.input.len() {
624 return None;
625 }
626 // The compare below is O(len) for what the entry step already
627 // paid one unit for, and a group can capture the whole input:
628 // `\(a*\)\1\1x` did 5e9 character compares against 200,000
629 // characters while spending 200,001 units. Charge the compare.
630 if !self.spend(len as u64) {
631 return None;
632 }
633 if self.input[pos..pos + len] == self.input[s..e] {
634 k(pos + len, caps)
635 } else {
636 None
637 }
638 }
639 Node::Group(idx, inner) => {
640 let idx = *idx;
641 let saved = caps.get(idx).copied().flatten();
642 let start = pos;
643 let out = self.node(inner, pos, caps, &|end, caps| {
644 let prev = caps[idx];
645 caps[idx] = Some((start, end));
646 match k(end, caps) {
647 Some(v) => Some(v),
648 None => {
649 caps[idx] = prev;
650 None
651 }
652 }
653 });
654 if out.is_none() {
655 caps[idx] = saved;
656 }
657 out
658 }
659 Node::Concat(items) => self.seq(items, pos, caps, k),
660 Node::Alt(branches) => {
661 for b in branches {
662 if let Some(v) = self.node(b, pos, caps, k) {
663 return Some(v);
664 }
665 }
666 None
667 }
668 Node::Repeat {
669 node,
670 min,
671 max,
672 greedy,
673 } => self.repeat(node, *min, *max, *greedy, pos, caps, k),
674 }
675 }
676
677 fn seq(
678 &self,
679 items: &[Node],
680 pos: usize,
681 caps: &mut Vec<Option<(usize, usize)>>,
682 k: Cont<'_>,
683 ) -> Option<usize> {
684 // A run of single-character nodes is DETERMINISTIC — each matches one
685 // character or fails, with nothing to backtrack into — so walk it
686 // iteratively. Going through `node` per item would cost one live
687 // frame per PATTERN character, and a literal run is the commonest
688 // long thing a pattern has: `strlst-to-syntax-rule` builds a keyword
689 // alternation whose every branch is one. The step accounting is
690 // unchanged, one unit per item either way.
691 let mut pos = pos;
692 let mut items = items;
693 while let Some((head, rest)) = items.split_first() {
694 if !matches!(head, Node::Char(_) | Node::Any | Node::Class { .. }) {
695 break;
696 }
697 if !self.spend(1) {
698 return None;
699 }
700 pos = self.single(head, pos)?;
701 items = rest;
702 }
703 match items.split_first() {
704 None => k(pos, caps),
705 Some((head, rest)) => self.node(head, pos, caps, &|p, caps| self.seq(rest, p, caps, k)),
706 }
707 }
708
709 #[allow(clippy::too_many_arguments)]
710 fn repeat(
711 &self,
712 node: &Node,
713 min: u32,
714 max: Option<u32>,
715 greedy: bool,
716 pos: usize,
717 caps: &mut Vec<Option<(usize, usize)>>,
718 k: Cont<'_>,
719 ) -> Option<usize> {
720 // Fast path for the overwhelmingly common `X*` / `X+` where `X` is a
721 // single-character matcher (`[^"]*`, `[a-z]+`, `.*`). Recursing once
722 // per repetition would make stack depth proportional to input length;
723 // a code block a few thousand characters long would then risk
724 // overflowing on a pattern as ordinary as a string literal's body.
725 if matches!(node, Node::Char(_) | Node::Any | Node::Class { .. }) {
726 // One `single` call per repetition is real work that the single
727 // step this `node` entry already paid for would otherwise buy
728 // without limit, so clamp the scan to what is left of the budget
729 // and charge what it consumes.
730 let hard = max.map_or(u64::MAX, u64::from);
731 let afford = self.fuel.get();
732 let cap = hard.min(afford);
733 let mut cur = pos;
734 let mut count = 0u64;
735 while count < cap {
736 match self.single(node, cur) {
737 Some(next) => {
738 cur = next;
739 count += 1;
740 }
741 None => break,
742 }
743 }
744 self.fuel.set(afford - count);
745 if count == cap && cap < hard {
746 // Stopped because the budget ran out, not because the input
747 // did: a truncated repetition would be a WRONG answer.
748 self.gave_up.set(true);
749 return None;
750 }
751 // A single-character node consumes exactly one character, so the
752 // end offset for `i` repetitions is `pos + i` — the admissible
753 // ones run from `min` to `count`. (This used to be materialised as
754 // a `Vec` of offsets, which is an O(input) allocation per scan for
755 // a sequence that is just addition.)
756 let lo = u64::from(min);
757 if count < lo {
758 return None;
759 }
760 let mut try_at = |i: u64| k(pos + i as usize, caps);
761 if greedy {
762 for i in (lo..=count).rev() {
763 if let Some(v) = try_at(i) {
764 return Some(v);
765 }
766 }
767 } else {
768 for i in lo..=count {
769 if let Some(v) = try_at(i) {
770 return Some(v);
771 }
772 }
773 }
774 return None;
775 }
776
777 // General case: `node` can consume a variable amount, so recurse —
778 // one `node` frame per repetition, which is what `Budget::MAX_FRAMES`
779 // bounds, along with every other recursion in the matcher.
780 let more = |m: &Matcher<'i>, p: usize, caps: &mut Vec<Option<(usize, usize)>>| {
781 if max == Some(0) {
782 return None;
783 }
784 let next_min = min.saturating_sub(1);
785 let next_max = max.map(|m| m - 1);
786 m.node(node, p, caps, &|q, caps| {
787 // A zero-width body would otherwise loop forever.
788 if q == p && next_min == 0 {
789 return None;
790 }
791 m.repeat(node, next_min, next_max, greedy, q, caps, k)
792 })
793 };
794
795 if min > 0 {
796 return more(self, pos, caps);
797 }
798 if greedy {
799 match more(self, pos, caps) {
800 Some(v) => Some(v),
801 None => k(pos, caps),
802 }
803 } else {
804 match k(pos, caps) {
805 Some(v) => Some(v),
806 None => more(self, pos, caps),
807 }
808 }
809 }
810}
811
812thread_local! {
813 /// `code-printer` calls `regexp-of-string` inside its lexer loop, and this
814 /// port's `regexp` is the pattern string itself, so the same handful of
815 /// patterns are re-parsed once per scanned character. Memoizing keeps that
816 /// linear in the input rather than in input × pattern length.
817 static CACHE: RefCell<HashMap<String, Rc<Regexp>>> = RefCell::new(HashMap::new());
818
819 /// Total pattern TEXT cached, in chars. The cap is on text size rather
820 /// than on entry count because the entries are not uniform: a parsed
821 /// pattern is
822 /// roughly thirty times its own text, so 4,096 twenty-thousand-character
823 /// patterns — cheap to generate from a document, `arabic i ^ body` will do
824 /// — reach 2.6 GB while never approaching an entry-count limit.
825 static CACHE_CHARS: Cell<usize> = const { Cell::new(0) };
826}
827
828/// Cached pattern text allowed before the table is dropped, in chars. The
829/// real corpus caches a few hundred characters in total.
830const CACHE_CHAR_LIMIT: usize = 4 * 1024 * 1024;
831
832/// Parse `pattern`, reusing a previously parsed copy when possible.
833pub fn compile(pattern: &str) -> Rc<Regexp> {
834 CACHE.with(|c| {
835 let mut c = c.borrow_mut();
836 if let Some(re) = c.get(pattern) {
837 return Rc::clone(re);
838 }
839 // The pattern set a document uses is small and fixed (one per
840 // language rule), but cap the table anyway so a program generating
841 // patterns cannot grow it without bound.
842 CACHE_CHARS.with(|n| {
843 if n.get() + pattern.chars().count() > CACHE_CHAR_LIMIT {
844 c.clear();
845 n.set(0);
846 }
847 n.set(n.get() + pattern.chars().count());
848 });
849 let re = Rc::new(Regexp::parse(pattern));
850 c.insert(pattern.to_string(), Rc::clone(&re));
851 re
852 })
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858
859 fn scan(pat: &str, input: &str) -> Option<String> {
860 let re = Regexp::parse(pat);
861 let chars: Vec<char> = input.chars().collect();
862 re.match_at(&chars, 0)
863 .expect("this pattern must not exhaust the budget")
864 .map(|end| chars[..end].iter().collect())
865 }
866
867 /// `scan`, for a pattern that is EXPECTED to run out of budget.
868 fn scan_gives_up(pat: &str, input: &str) -> bool {
869 let re = Regexp::parse(pat);
870 let chars: Vec<char> = input.chars().collect();
871 re.match_at(&chars, 0).is_err()
872 }
873
874 #[test]
875 fn literals_and_escapes() {
876 assert_eq!(scan("abc", "abcdef").as_deref(), Some("abc"));
877 assert_eq!(scan("abc", "abdef"), None);
878 // `(` and `|` are LITERAL in `Str`, unlike PCRE.
879 assert_eq!(scan("(a)", "(a)b").as_deref(), Some("(a)"));
880 assert_eq!(scan("a|b", "a|b").as_deref(), Some("a|b"));
881 assert_eq!(scan("{2}", "{2}x").as_deref(), Some("{2}"));
882 assert_eq!(scan(r"\*", "*x").as_deref(), Some("*"));
883 }
884
885 #[test]
886 fn alternation_is_leftmost_biased_not_longest() {
887 // `Str`, like Perl and unlike POSIX, returns the FIRST branch that
888 // matches rather than the longest.
889 assert_eq!(scan(r"a\|ab", "ab").as_deref(), Some("a"));
890 assert_eq!(scan(r"ab\|a", "ab").as_deref(), Some("ab"));
891 }
892
893 #[test]
894 fn quantifiers_greedy_and_lazy() {
895 assert_eq!(scan("a*", "aaab").as_deref(), Some("aaa"));
896 assert_eq!(scan("a*?", "aaab").as_deref(), Some(""));
897 assert_eq!(scan("a+", "aaab").as_deref(), Some("aaa"));
898 assert_eq!(scan("a+?", "aaab").as_deref(), Some("a"));
899 assert_eq!(scan("ab?", "ab").as_deref(), Some("ab"));
900 assert_eq!(scan("ab?", "ac").as_deref(), Some("a"));
901 assert_eq!(scan("a+", "b"), None);
902 }
903
904 #[test]
905 fn character_classes() {
906 assert_eq!(scan("[a-z]+", "abcD").as_deref(), Some("abc"));
907 assert_eq!(scan("[^\"]*", "ab\"c").as_deref(), Some("ab"));
908 assert_eq!(scan("[]a]+", "]a]b").as_deref(), Some("]a]"));
909 assert_eq!(scan("[a-]+", "a-a!").as_deref(), Some("a-a"));
910 // Backslash is not an escape inside a set: `[\t]` is `{'\\','t'}`.
911 assert_eq!(scan(r"[\t]+", r"t\t;").as_deref(), Some(r"t\t"));
912 }
913
914 #[test]
915 fn anchors() {
916 assert_eq!(scan("$", "").as_deref(), Some(""));
917 assert_eq!(scan("$", "\nx").as_deref(), Some(""));
918 assert_eq!(scan("$", "x\n"), None);
919 assert_eq!(scan("^a", "a"). as_deref(), Some("a"));
920 }
921
922 #[test]
923 fn groups_and_backrefs() {
924 assert_eq!(scan(r"\(ab\)+", "ababc").as_deref(), Some("abab"));
925 assert_eq!(scan(r"\(a\|b\)c", "bc").as_deref(), Some("bc"));
926 assert_eq!(scan(r"\(a\)\1", "aa").as_deref(), Some("aa"));
927 assert_eq!(scan(r"\(a\)\1", "ab"), None);
928 }
929
930 #[test]
931 fn code_printer_real_patterns() {
932 // The SATySFi identifier rule from `code-syntax.satyg`.
933 let ident = r"\(\\\|\+\)?[a-zA-Z][a-zA-Z0-9-]*\|[0-9]+\|0x[0-9a-fA-F]+";
934 assert_eq!(scan(ident, "let-rec x").as_deref(), Some("let-rec"));
935 assert_eq!(scan(ident, r"\emph{a}").as_deref(), Some(r"\emph"));
936 assert_eq!(scan(ident, "+section{}").as_deref(), Some("+section"));
937 assert_eq!(scan(ident, "123abc").as_deref(), Some("123"));
938 // The rule's own third branch, `0x[0-9a-fA-F]+`, is unreachable: the
939 // second branch `[0-9]+` is tried first and always matches the
940 // leading `0`. That is what `Str` does too — the port must reproduce
941 // the quirk, not "fix" it, or highlighting would drift from upstream.
942 assert_eq!(scan(ident, "0x1F;").as_deref(), Some("0"));
943
944 // Rust identifiers, and the block-comment delimiters.
945 assert_eq!(scan("[a-zA-Z][a-zA-Z0-9_]*!?", "println!(").as_deref(), Some("println!"));
946 assert_eq!(scan(r"/\*", "/* c */").as_deref(), Some("/*"));
947 assert_eq!(scan(r"\*/", "*/ rest").as_deref(), Some("*/"));
948 // A double-quoted string body.
949 assert_eq!(scan("\"[^\"]*\"", "\"hi\" x").as_deref(), Some("\"hi\""));
950 }
951
952 #[test]
953 fn long_repeat_does_not_overflow_the_stack() {
954 // The single-character fast path must keep this iterative.
955 let long: String = std::iter::repeat('a').take(200_000).collect();
956 assert_eq!(scan("[a-z]*", &long).map(|s| s.len()), Some(200_000));
957 }
958
959 /// The case the test above does NOT cover, and which used to abort the
960 /// process: a repeated GROUP takes `repeat`'s recursive branch, one frame
961 /// per repetition. `[a-z]*` above is the single-character fast path — it
962 /// was never at risk, so it could not have caught this.
963 #[test]
964 fn a_repeated_group_over_a_long_input_gives_up_instead_of_overflowing() {
965 let long: String = std::iter::repeat('a').take(200_000).collect();
966 // Either it matches or it gives up; what it must not do is abort.
967 let re = Regexp::parse("\\(a\\)*");
968 let chars: Vec<char> = long.chars().collect();
969 let _ = re.match_at(&chars, 0);
970 }
971
972 /// Catastrophic backtracking is bounded. Unbounded, this took two minutes
973 /// at thirty characters and did not finish at thirty-two.
974 #[test]
975 fn a_quantified_group_inside_a_quantifier_gives_up_rather_than_hanging() {
976 let input: String = std::iter::repeat('a').take(64).collect::<String>() + "!";
977 assert!(
978 scan_gives_up("\\(a*\\)*b", &input),
979 "the exponential pattern should have exhausted its budget"
980 );
981 // And the budget must not fire on ordinary work: the same shape that
982 // CAN match still does, promptly.
983 assert_eq!(scan("\\(a*\\)*b", "aaab").as_deref(), Some("aaab"));
984 }
985
986 /// `match_at` is `pub` and takes an arbitrary `start`; `^` and `\b` used
987 /// to index `input[pos - 1]` unguarded and panic past the end.
988 ///
989 /// The three patterns below the fold are the ones that actually REACH the
990 /// anchor: `"a$"` does not, because the `a` fails on `input.get(pos)`
991 /// first and the `$` is never evaluated — so it was `$`, indexing
992 /// `input[pos]` forward, that was still panicking after `^` and `\b` were
993 /// fixed. A guard test has to be written against the node under test, not
994 /// against a pattern that merely contains it.
995 #[test]
996 fn match_at_past_the_end_does_not_panic() {
997 let chars: Vec<char> = "abc".chars().collect();
998 for pat in ["^a", "\\ba", "a$", "$", "a*$", "^", "\\b", "\\(a\\)*$"] {
999 let _ = Regexp::parse(pat).match_at(&chars, 7);
1000 }
1001 }
1002
1003 #[test]
1004 fn search_forward_finds_leftmost() {
1005 let re = Regexp::parse("b+");
1006 let chars: Vec<char> = "aabbbc".chars().collect();
1007 assert_eq!(re.search_from(&chars, 0), Ok(Some((2, 5))));
1008 assert_eq!(re.search_from(&chars, 5), Ok(None));
1009 }
1010
1011 /// `search_from` tries every start position, so a per-position budget
1012 /// would let the total reach `len × budget` — the bound would stop
1013 /// bounding exactly when the input got long. One shared budget means a
1014 /// pattern that is pathological at ONE position is refused for the whole
1015 /// search rather than being paid for at every one of them.
1016 #[test]
1017 fn a_leftmost_search_shares_one_budget_across_start_positions() {
1018 let input: String = "a".repeat(4_000);
1019 let chars: Vec<char> = input.chars().collect();
1020 let re = Regexp::parse("\\(a*\\)*b");
1021 let t0 = std::time::Instant::now();
1022 assert_eq!(re.search_from(&chars, 0), Err(GaveUp));
1023 // Per-position budgets would multiply this by 4,000.
1024 assert!(
1025 t0.elapsed().as_secs() < 5,
1026 "the search re-seeded its budget per start position"
1027 );
1028 }
1029
1030 /// The work a step buys must be bounded, or the counter is linear while
1031 /// the clock is quadratic: the group here captures a prefix of the input
1032 /// and the backreference compares it character by character, so one step
1033 /// used to buy an O(input) `memcmp`. 200,000 characters cost 5e9 compares
1034 /// for 200,001 steps.
1035 #[test]
1036 fn a_backreference_is_charged_for_the_characters_it_compares() {
1037 let input: String = "a".repeat(20_000);
1038 let chars: Vec<char> = input.chars().collect();
1039 let t0 = std::time::Instant::now();
1040 assert!(Regexp::parse("\\(a*\\)\\1\\1x").match_at(&chars, 0).is_err());
1041 assert!(t0.elapsed().as_secs() < 5, "the compare was not charged");
1042 // A backreference to a SHORT group — the shape real patterns use, a
1043 // matching quote or bracket — is unaffected.
1044 assert_eq!(scan(r"\(['`]\)[a-z]*\1", "'abc'!").as_deref(), Some("'abc'"));
1045 }
1046
1047 /// Same point for `repeat`'s single-character fast path, which scans the
1048 /// whole input for the one step its `node` entry paid for. Charging it is
1049 /// what keeps a give-up fast: `\(a*\)*b` against 200,000 characters took
1050 /// 21.7 s to give up before, and 0.17 s after.
1051 #[test]
1052 fn a_quantifier_scan_is_charged_for_the_characters_it_consumes() {
1053 let input: String = "a".repeat(200_000);
1054 let chars: Vec<char> = input.chars().collect();
1055 let t0 = std::time::Instant::now();
1056 assert!(Regexp::parse("\\(a*\\)*b").match_at(&chars, 0).is_err());
1057 assert!(
1058 t0.elapsed().as_secs() < 10,
1059 "giving up took longer than doing the work would have"
1060 );
1061 // The charge must not make an honest scan of the same input fail.
1062 assert_eq!(scan("[a-z]*", &input).map(|s| s.len()), Some(200_000));
1063 }
1064
1065 /// A pattern nested deeper than [`Budget::MAX_NESTING`] drives the PARSER
1066 /// off the stack, before a single character has been matched — `\(` ×
1067 /// 300,000 overflowed a 256 MB stack. The parser stops descending, and
1068 /// because the tree it then has is a DIFFERENT well-formed pattern rather
1069 /// than a malformed one read literally, matching it reports the give-up
1070 /// instead of answering.
1071 #[test]
1072 fn a_pattern_nested_past_the_parser_cap_is_refused_not_reinterpreted() {
1073 let deep = format!("{}a{}", r"\(".repeat(100_000), r"\)".repeat(100_000));
1074 let chars: Vec<char> = "a".chars().collect();
1075 assert_eq!(Regexp::parse(&deep).match_at(&chars, 0), Err(GaveUp));
1076 // Just inside the cap still works.
1077 let ok = format!("{}a{}", r"\(".repeat(1_000), r"\)".repeat(1_000));
1078 assert_eq!(Regexp::parse(&ok).match_at(&chars, 0), Ok(Some(1)));
1079 }
1080
1081 /// A long run of single-character atoms is deterministic, so `seq` walks
1082 /// it iteratively. Recursing per atom made the STACK proportional to the
1083 /// pattern's length — 200,000 literal characters needed 37.6 MB — which
1084 /// no cap on `repeat`'s depth could see.
1085 #[test]
1086 fn a_long_literal_run_does_not_recurse_per_character() {
1087 let long: String = "a".repeat(200_000);
1088 assert_eq!(scan(&long, &long).map(|s| s.len()), Some(200_000));
1089 }
1090}