Skip to main content

interscript_rs/
interpreter.rs

1//! The interpreter: `Interscript::Interpreter::Stage` parity. A
2//! backtracking item matcher (the Ruby runtime's regex semantics
3//! without compiling to regex — Rust's regex crate has no lookaround,
4//! which the constraint semantics require) plus the stage executor
5//! over parallel groups, sequential sub rules, run rules and funcalls.
6//!
7//! Semantics contract (from interpreter.rb / stdlib.rb):
8//! * `before`     -> lookbehind  (?<=…)
9//! * `not_before` -> (?<!…)
10//! * `after`      -> lookahead   (?=…)
11//! * `not_after`  -> (?!…)
12//! * an EMPTY constraint item behaves like the empty regex in that
13//!   position: positive constraints become no-ops, negative ones
14//!   disable the rule (Ruby `(?<=)` always true / `(?<!)` never true).
15//! * parallel groups: single left-to-right pass. Ruby's two paths are
16//!   both mirrored (they disagree on duplicate `from` keys): the
17//!   tree path (`parallel_replace_tree`, constraint-free groups) is a
18//!   last-wins char trie with longest-match scanning; the megaregexp
19//!   fallback tries rules in `deterministic_sort_by_max_length`
20//!   order (-max_length*100000+idx), first match wins.
21//! * replacement `to` items collapse through `nth_string`
22//!   (Any -> first string) before building, exactly like build_item.
23
24use crate::document::{Document, StageItem, Sub, SubTo};
25use crate::error::{Error, Result};
26use crate::item::Item;
27use crate::stdlib::Prim;
28use std::cell::RefCell;
29use std::collections::{BTreeMap, BTreeSet};
30use std::path::PathBuf;
31use std::rc::Rc;
32
33/// A loaded-map cache bound to a load path. `Interscript.transliterate`
34/// parity: `transliterate(map_id, string)` runs stage `main`.
35pub struct Runtime {
36    load_path: Vec<PathBuf>,
37    cache: RefCell<BTreeMap<String, Rc<Document>>>,
38    /// Dependency-cycle guard.
39    loading: RefCell<Vec<String>>,
40}
41
42impl Runtime {
43    pub fn new(load_path: Vec<PathBuf>) -> Runtime {
44        Runtime {
45            load_path,
46            cache: RefCell::new(BTreeMap::new()),
47            loading: RefCell::new(Vec::new()),
48        }
49    }
50
51    /// Load (and dependency-close) a map document.
52    pub fn load(&self, map_id: &str) -> Result<Rc<Document>> {
53        if let Some(doc) = self.cache.borrow().get(map_id) {
54            return Ok(doc.clone());
55        }
56        {
57            let loading = self.loading.borrow();
58            if loading.iter().any(|m| m == map_id) {
59                let mut chain = loading.clone();
60                chain.push(map_id.to_string());
61                return Err(Error::DependencyCycle(chain));
62            }
63        }
64        self.loading.borrow_mut().push(map_id.to_string());
65        let result = self.load_uncached(map_id);
66        self.loading.borrow_mut().pop();
67        result
68    }
69
70    fn load_uncached(&self, map_id: &str) -> Result<Rc<Document>> {
71        let path = crate::locate::locate(&self.load_path, map_id)?;
72        let source = crate::locate::read_map(&path)?;
73        let stem = path
74            .file_stem()
75            .and_then(|s| s.to_str())
76            .unwrap_or(map_id)
77            .to_string();
78        let tree = crate::grammar::parse_isc(&source).map_err(|(offset, expected)| Error::Parse {
79            map: stem.clone(),
80            offset,
81            expected,
82        })?;
83
84        // Dependency documents load FIRST (NodeAdapter ordering).
85        let targets = crate::builder::dependency_targets(&tree);
86        let mut imports: Vec<Rc<Document>> = Vec::new();
87        let mut dep_docs: BTreeMap<String, Rc<Document>> = BTreeMap::new();
88        for (target, alias) in targets {
89            let dep = self.load(&target)?;
90            match alias {
91                Some(name) => {
92                    dep_docs.insert(name, dep);
93                }
94                None => imports.push(dep),
95            }
96        }
97
98        let doc = Rc::new(crate::builder::build(&tree, &stem, &imports, &dep_docs)?);
99        self.cache
100            .borrow_mut()
101            .insert(map_id.to_string(), doc.clone());
102        Ok(doc)
103    }
104
105    /// `Interscript.transliterate(map_id, string)`.
106    pub fn transliterate(&self, map_id: &str, input: &str) -> Result<String> {
107        self.transliterate_stage(map_id, "main", input, None)
108    }
109
110    /// ISC-WF-1 W3: transliterate with a language selection (§5.1).
111    /// Selecting a language the map does not declare behaves like no
112    /// selection (every `when` guard inert).
113    pub fn transliterate_lang(
114        &self,
115        map_id: &str,
116        input: &str,
117        language: Option<&str>,
118    ) -> Result<String> {
119        self.transliterate_stage(map_id, "main", input, language)
120    }
121
122    pub fn transliterate_stage(
123        &self,
124        map_id: &str,
125        stage: &str,
126        input: &str,
127        language: Option<&str>,
128    ) -> Result<String> {
129        let doc = self.load(map_id)?;
130        execute_stage(&doc, stage, input, language)
131    }
132
133    /// Run a document's embedded tests; returns (passed, failed, failures).
134    /// Embedded tests run WITHOUT a language selection (§5.1 default).
135    pub fn run_tests(&self, map_id: &str) -> Result<(usize, usize, Vec<String>)> {
136        let doc = self.load(map_id)?;
137        let mut passed = 0usize;
138        let mut failed = 0usize;
139        let mut failures = Vec::new();
140        for (input, expected) in &doc.tests.data {
141            match self.transliterate(map_id, input) {
142                Ok(actual) if &actual == expected => passed += 1,
143                Ok(actual) => {
144                    failed += 1;
145                    failures.push(format!("{input:?} -> {actual:?} (expected {expected:?})"));
146                }
147                Err(e) => {
148                    failed += 1;
149                    failures.push(format!("{input:?} -> ERROR {e}"));
150                }
151            }
152        }
153        Ok((passed, failed, failures))
154    }
155}
156
157// ----------------------------------------------------------------------
158// Stage execution
159// ----------------------------------------------------------------------
160
161/// Public alias for diagnostics and tests.
162pub fn execute_stage_pub(
163    doc: &Document,
164    stage_name: &str,
165    input: &str,
166    language: Option<&str>,
167) -> Result<String> {
168    execute_stage(doc, stage_name, input, language)
169}
170
171/// W3 (§5.2) pass filtering: the applicable rule set = unguarded rules
172/// ∪ guarded rules whose `when` matches the selected language — and a
173/// guard-applicable rule BEATS an unguarded rule with the same from
174/// (a language-specific value overrides the generic value without
175/// restating the generic row). With no selection every guard is inert,
176/// so this reduces to the unguarded set (§5.1).
177fn guard_filter<'a>(rules: &[&'a Sub], language: Option<&str>) -> Vec<&'a Sub> {
178    let applicable: Vec<&Sub> = rules
179        .iter()
180        .copied()
181        .filter(|r| r.guard_applies(language))
182        .collect();
183    let guarded_froms: BTreeSet<String> = applicable
184        .iter()
185        .filter(|r| !r.when.is_empty())
186        .map(|r| r.from_key())
187        .collect();
188    if guarded_froms.is_empty() {
189        return applicable;
190    }
191    // Guarded rows always stay; an UNGUARDED row drops when an
192    // applicable guarded row shares its from (§5.2 precedence).
193    applicable
194        .into_iter()
195        .filter(|r| !r.when.is_empty() || !guarded_froms.contains(&r.from_key()))
196        .collect()
197}
198
199fn execute_stage(
200    doc: &Document,
201    stage_name: &str,
202    input: &str,
203    language: Option<&str>,
204) -> Result<String> {
205    // W3 §5.1: the selection is honored only if this document declares
206    // it — an undeclared selection is like no selection, and every
207    // guard stays inert. Re-checked per document (§5.3: each doc
208    // gates on its own declaration).
209    let language = language.filter(|l| doc.languages.iter().any(|d| d == l));
210    let stage = doc
211        .imported_stages()
212        .remove(stage_name)
213        .ok_or_else(|| Error::MapLogic(format!("stage {stage_name:?} not found in {}", doc.name)))?;
214    // Ruby execute_rule: `return if r.reverse_run == true` — a rule or
215    // stage carrying reverse_run == true is skipped entirely.
216    if stage.reverse_run == Some(true) {
217        return Ok(input.to_string());
218    }
219    let mut s = input.to_string();
220    for item in &stage.items {
221        // Ruby execute_rule top: reverse_run == true skips the rule.
222        let skipped = match item {
223            StageItem::Funcall(f) => f.reverse_run == Some(true),
224            StageItem::Run { reverse_run, .. } => *reverse_run == Some(true),
225            _ => false,
226        };
227        if skipped {
228            continue;
229        }
230        s = match item {
231            StageItem::Parallel(rules) => apply_parallel(doc, rules, &s, language)?,
232            StageItem::Sequential(rules) => {
233                let mut out = s;
234                for rule in rules {
235                    if rule.reverse_run == Some(true) {
236                        continue;
237                    }
238                    // W3 §5.2: a guarded rule whose guard is inert is
239                    // skipped entirely (behaves as if absent).
240                    if !rule.guard_applies(language) {
241                        continue;
242                    }
243                    out = apply_sub(doc, rule, &out)?;
244                }
245                out
246            }
247            StageItem::Run { dep, stage, .. } => match dep {
248                Some(alias) => {
249                    let dep_doc = doc.dep_aliases.get(alias).ok_or_else(|| {
250                        Error::MapLogic(format!("dep alias {alias:?} not found in {}", doc.name))
251                    })?;
252                    // W3 §5.3: the selection threads into dependency
253                    // stage runs (each document's own declaration
254                    // gates its guards).
255                    execute_stage(dep_doc, stage, &s, language)?
256                }
257                None => execute_stage(doc, stage, &s, language)?,
258            },
259            StageItem::Funcall(funcall) => funcall.name.apply(s, &funcall.kwargs)?,
260        };
261    }
262    Ok(s)
263}
264
265/// Single left-to-right simultaneous pass. Ruby has TWO paths and
266/// they disagree on duplicate `from` keys, so both are mirrored:
267///
268/// * tree path (`Stdlib.parallel_replace_tree`), used when every rule
269///   is constraint-free and builds to plain from-strings: a
270///   last-wins char-trie, longest match at each position;
271/// * megaregexp path (the rescue fallback): rules tried in
272///   `deterministic_sort_by_max_length` order (-max_length*100000+idx),
273///   first match wins.
274fn apply_parallel(doc: &Document, rules: &[Sub], s: &str, language: Option<&str>) -> Result<String> {
275    // Ruby: `next if i.reverse_run == true` in both the tree and the
276    // megaregexp branch — skipped rules join neither array.
277    let live: Vec<&Sub> = rules
278        .iter()
279        .filter(|r| r.reverse_run != Some(true))
280        .collect();
281    // W3 §5.2: the pass runs over the language-applicable set, with
282    // guard-applicable beating unguarded same-from.
283    let applicable = guard_filter(&live, language);
284    if let Some(tree) = build_tree(&applicable, doc)? {
285        return tree_replace(&tree, s);
286    }
287
288    let chars: Vec<char> = s.chars().collect();
289    // deterministic_sort_by_max_length: -max_length * 100000 + idx.
290    let mut order: Vec<usize> = (0..applicable.len()).collect();
291    order.sort_by_key(|&i| (std::cmp::Reverse(applicable[i].max_length()), i));
292
293    let mut out = String::with_capacity(s.len());
294    let mut copied = 0usize; // chars[..copied] are already emitted
295    let mut pos = 0usize;
296    while pos < chars.len() {
297        let mut matched: Option<(usize, String)> = None;
298        for &i in &order {
299            let rule = applicable[i];
300            let m = Matcher::new(&chars, doc);
301            if let Some((end, replacement)) = m.match_rule(rule, pos)? {
302                matched = Some((end, replacement));
303                break;
304            }
305        }
306        match matched {
307            Some((end, replacement)) => {
308                out.extend(&chars[copied..pos]);
309                out.push_str(&replacement);
310                copied = end;
311                // Ruby gsub: a zero-width match cannot loop — advance
312                // at least one char.
313                pos = std::cmp::max(end, pos + 1);
314            }
315            None => pos += 1,
316        }
317    }
318    out.extend(&chars[copied..]);
319    Ok(out)
320}
321
322/// A char-trie node with an optional terminal replacement.
323type Trie = std::collections::BTreeMap<char, TrieNode>;
324#[derive(Default)]
325struct TrieNode {
326    children: Trie,
327    replacement: Option<String>,
328}
329
330/// `Interscript::Stdlib.parallel_replace_compile_tree` parity: build
331/// the trie when the group is tree-eligible (no constraints; every
332/// from builds via build_item(:par) without raising; every to builds
333/// via build_item(:parstr)). Duplicate from-strings: later rules
334/// overwrite earlier terminals (hash assignment semantics).
335fn build_tree(rules: &[&Sub], doc: &Document) -> Result<Option<TrieNode>> {
336    let m = Matcher::new(&[], doc);
337    let mut map: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
338    for rule in rules {
339        if rule.before.is_some()
340            || rule.not_before.is_some()
341            || rule.after.is_some()
342            || rule.not_after.is_some()
343        {
344            return Ok(None);
345        }
346        let to = match &rule.to {
347            SubTo::Item(item) => match m.build_par_str(item) {
348                Ok(s) => s,
349                Err(_) => return Ok(None),
350            },
351            // to upcase/downcase inside a parallel group has no
352            // build_item case (nil replacement) — Ruby silently
353            // skips nothing here; the tree hash gets nil, matching
354            // `Array(nil)` → no keys. Treat as skip.
355            SubTo::Upcase | SubTo::Downcase => String::new(),
356        };
357        let _ = to;
358        match m.build_par_from(&rule.from) {
359            Ok(keys) => {
360                for key in keys {
361                    map.insert(key, to.clone());
362                }
363            }
364            Err(_) => return Ok(None),
365        }
366    }
367    let mut root = TrieNode::default();
368    for (from, to) in map {
369        let chars: Vec<char> = from.chars().collect();
370        if chars.is_empty() {
371            // The Ruby trie cannot represent an empty key (the scan
372            // only follows char edges), so it is unreachable there.
373            continue;
374        }
375        let mut node = &mut root;
376        for c in chars {
377            node = node.children.entry(c).or_default();
378        }
379        node.replacement = Some(to);
380    }
381    Ok(Some(root))
382}
383
384/// `parallel_replace_tree` parity: greedy longest-match scan.
385fn tree_replace(root: &TrieNode, s: &str) -> Result<String> {
386    let chars: Vec<char> = s.chars().collect();
387    let len = chars.len();
388    let mut out = String::with_capacity(s.len());
389    let mut i = 0usize;
390    while i < len {
391        let mut branch = &root.children;
392        let mut matched: Option<(usize, &String)> = None;
393        let mut j = 0usize;
394        while j < len - i {
395            let cc = chars[i + j];
396            match branch.get(&cc) {
397                Some(node) => {
398                    branch = &node.children;
399                    if let Some(repl) = &node.replacement {
400                        matched = Some((j + 1, repl));
401                    }
402                    j += 1;
403                }
404                None => break,
405            }
406        }
407        match matched {
408            Some((mlen, repl)) => {
409                out.push_str(repl);
410                i += mlen;
411            }
412            None => {
413                out.push(chars[i]);
414                i += 1;
415            }
416        }
417    }
418    Ok(out)
419}
420
421/// Sequential gsub pass for one sub rule.
422fn apply_sub(doc: &Document, rule: &Sub, s: &str) -> Result<String> {
423    let chars: Vec<char> = s.chars().collect();
424    let mut out = String::with_capacity(s.len());
425    let mut copied = 0usize; // chars[..copied] are already emitted
426    let mut pos = 0usize;
427    // A gsub pass attempts the pattern at every position INCLUDING the
428    // end (a zero-width pattern produces one final empty match).
429    while pos <= chars.len() {
430        let m = Matcher::new(&chars, doc);
431        match m.match_rule(rule, pos)? {
432            Some((end, replacement)) => {
433                out.extend(&chars[copied..pos]);
434                out.push_str(&replacement);
435                copied = end;
436                pos = std::cmp::max(end, pos + 1);
437            }
438            None => pos += 1,
439        }
440    }
441    out.extend(&chars[copied..]);
442    Ok(out)
443}
444
445// ----------------------------------------------------------------------
446// The matcher
447// ----------------------------------------------------------------------
448
449/// Per-rule match environment. Group slots are char-index ranges,
450/// 1-based (slot 0 unused), mirroring regex group numbering.
451struct Matcher<'a> {
452    chars: &'a [char],
453    doc: &'a Document,
454    groups: RefCell<Vec<Option<(usize, usize)>>>,
455    error: RefCell<Option<Error>>,
456}
457
458impl<'a> Matcher<'a> {
459    fn new(chars: &'a [char], doc: &'a Document) -> Matcher<'a> {
460        Matcher {
461            chars,
462            doc,
463            groups: RefCell::new(Vec::new()),
464            error: RefCell::new(None),
465        }
466    }
467
468    /// Match one sub rule at `pos`: constraints + from, then build the
469    /// replacement. Returns (end, replacement).
470    fn match_rule(&self, rule: &Sub, pos: usize) -> Result<Option<(usize, String)>> {
471        let slots = rule.group_count + 1;
472        self.groups.borrow_mut().resize_with(slots, || None);
473
474        // before -> (?<=…): some k <= pos where before ends at pos.
475        if let Some(before) = &rule.before {
476            if !self.lookbehind(before, pos)? {
477                return Ok(None);
478            }
479        }
480        // not_before -> (?<!…): the item must NOT match ending at pos.
481        // An empty item matches the empty string at every position, so
482        // `not_before ""` disables the rule — the Ruby (?<!) behavior.
483        if let Some(nb) = &rule.not_before {
484            if self.lookbehind(nb, pos)? {
485                return Ok(None);
486            }
487        }
488
489        let mut end_slot: Option<usize> = None;
490        let matched = {
491            let slot_ref = &mut end_slot;
492            self.match_item(&rule.from, pos, &mut |m, end| {
493                // not_after -> (?!…)
494                if let Some(na) = &rule.not_after {
495                    if m.lookahead(na, end)? {
496                        return Ok(false);
497                    }
498                }
499                // after -> (?=…)
500                if let Some(a) = &rule.after {
501                    if !m.lookahead(a, end)? {
502                        return Ok(false);
503                    }
504                }
505                *slot_ref = Some(end);
506                Ok(true)
507            })
508        };
509
510        if let Some(e) = self.error.borrow_mut().take() {
511            return Err(e);
512        }
513        if !matched {
514            return Ok(None);
515        }
516        let end = end_slot.unwrap_or(pos);
517
518        let replacement = match &rule.to {
519            SubTo::Upcase => self.chars[pos..end].iter().collect::<String>().to_uppercase(),
520            SubTo::Downcase => self.chars[pos..end].iter().collect::<String>().to_lowercase(),
521            SubTo::Item(to) => {
522                let collapsed = to.first_string();
523                let built = self.build_replacement(&collapsed)?;
524                self.decode_replacement(&built, pos, end)
525            }
526        };
527        Ok(Some((end, replacement)))
528    }
529
530    /// Positive lookbehind: does `item` match SOME span ending at pos?
531    fn lookbehind(&self, item: &Item, pos: usize) -> Result<bool> {
532        for start in (0..=pos).rev() {
533            let ok = self.match_item(item, start, &mut |_m, end| Ok(end == pos));
534            if let Some(e) = self.error.borrow_mut().take() {
535                return Err(e);
536            }
537            if ok {
538                return Ok(true);
539            }
540        }
541        Ok(false)
542    }
543
544    /// Lookahead: does `item` match starting at pos (any length)?
545    fn lookahead(&self, item: &Item, pos: usize) -> Result<bool> {
546        let ok = self.match_item(item, pos, &mut |_m, _end| Ok(true));
547        if let Some(e) = self.error.borrow_mut().take() {
548            return Err(e);
549        }
550        Ok(ok)
551    }
552
553    /// CPS backtracking matcher: match `item` at `pos`, calling `k`
554    /// with each candidate end; success when any continuation accepts.
555    fn match_item(
556        &self,
557        item: &Item,
558        pos: usize,
559        k: &mut dyn FnMut(&Matcher, usize) -> Result<bool>,
560    ) -> bool {
561        match item {
562            Item::None => self.call(k, pos),
563            Item::Str(s) => {
564                let pat: Vec<char> = s.chars().collect();
565                if pos + pat.len() <= self.chars.len()
566                    && self.chars[pos..pos + pat.len()] == pat[..]
567                {
568                    self.call(k, pos + pat.len())
569                } else {
570                    false
571                }
572            }
573            Item::Prim(prim) => match prim.matches_zero_width(self.chars, pos) {
574                Some(ok) => ok && self.call(k, pos),
575                None => {
576                    if pos < self.chars.len() && prim.matches_char(self.chars[pos]).unwrap_or(false)
577                    {
578                        self.call(k, pos + 1)
579                    } else {
580                        false
581                    }
582                }
583            },
584            Item::Concat(parts) => self.match_seq(parts, pos, k),
585            Item::Set(entries) => {
586                for entry in entries {
587                    if self.match_item(entry, pos, k) {
588                        return true;
589                    }
590                }
591                false
592            }
593            Item::Range(lo, hi) => {
594                if pos < self.chars.len() && *lo <= self.chars[pos] && self.chars[pos] <= *hi {
595                    self.call(k, pos + 1)
596                } else {
597                    false
598                }
599            }
600            Item::Maybe(inner) => {
601                let extended = self.match_item(inner, pos, k);
602                if extended {
603                    return true;
604                }
605                self.call(k, pos)
606            }
607            Item::Some(inner) => self.match_some(inner, pos, 1, k),
608            Item::CaptureGroup { id, inner } => {
609                let snapshot = self.groups.borrow().clone();
610                let id = *id;
611                let ok = self.match_item(inner, pos, &mut |m, end| {
612                    m.groups.borrow_mut()[id] = Some((pos, end));
613                    match k(m, end) {
614                        Ok(true) => Ok(true),
615                        failed => {
616                            *m.groups.borrow_mut() = snapshot.clone();
617                            failed
618                        }
619                    }
620                });
621                if !ok {
622                    *self.groups.borrow_mut() = snapshot;
623                }
624                ok
625            }
626            Item::CaptureRef(n) => {
627                let text = self
628                    .groups
629                    .borrow()
630                    .get(*n)
631                    .and_then(|g| g.as_ref())
632                    .map(|(s, e)| self.chars[*s..*e].iter().collect::<String>());
633                match text {
634                    Some(t) => {
635                        let pat: Vec<char> = t.chars().collect();
636                        if pos + pat.len() <= self.chars.len()
637                            && self.chars[pos..pos + pat.len()] == pat[..]
638                        {
639                            self.call(k, pos + pat.len())
640                        } else {
641                            false
642                        }
643                    }
644                    // Ruby \N with an unparticipated group fails to match.
645                    None => false,
646                }
647            }
648            Item::Alias { map, name } => match self.resolve_alias(map.as_deref(), name) {
649                Ok(Some(resolved)) => self.match_item(&resolved, pos, k),
650                Ok(None) => false,
651                Err(e) => {
652                    self.record_error(e);
653                    false
654                }
655            },
656            Item::Func(_) => {
657                self.record_error(Error::MapLogic("function item in match position".into()));
658                false
659            }
660        }
661    }
662
663    fn match_seq(
664        &self,
665        parts: &[Item],
666        pos: usize,
667        k: &mut dyn FnMut(&Matcher, usize) -> Result<bool>,
668    ) -> bool {
669        match parts.split_first() {
670            None => self.call(k, pos),
671            Some((first, rest)) => {
672                let rest: Vec<Item> = rest.to_vec();
673                self.match_item(first, pos, &mut |m, end| Ok(m.match_seq(&rest, end, k)))
674            }
675        }
676    }
677
678    fn match_some(
679        &self,
680        inner: &Item,
681        pos: usize,
682        count: usize,
683        k: &mut dyn FnMut(&Matcher, usize) -> Result<bool>,
684    ) -> bool {
685        // Greedy: extend first (one more inner match), then fall back
686        // to the continuation. Zero-width inner matches stop the loop
687        // (regex empty-loop guard).
688        let extended = self.match_item(inner, pos, &mut |m, end| {
689            if end > pos {
690                Ok(m.match_some(inner, end, count + 1, k))
691            } else {
692                Ok(false)
693            }
694        });
695        if extended {
696            return true;
697        }
698        if count >= 1 {
699            self.call(k, pos)
700        } else {
701            false
702        }
703    }
704
705    fn call(&self, k: &mut dyn FnMut(&Matcher, usize) -> Result<bool>, pos: usize) -> bool {
706        match k(self, pos) {
707            Ok(true) => true,
708            Ok(false) => false,
709            Err(e) => {
710                self.record_error(e);
711                false
712            }
713        }
714    }
715
716    fn record_error(&self, e: Error) {
717        let mut slot = self.error.borrow_mut();
718        if slot.is_none() {
719            *slot = Some(e);
720        }
721    }
722
723    fn resolve_alias(&self, map: Option<&str>, name: &str) -> Result<Option<Item>> {
724        // Stdlib primitives (checked after dep maps, like Ruby: the
725        // `i.map` branch comes first, stdlib second, local last).
726        if map.is_none() {
727            if let Some(prim) = Prim::from_name(name) {
728                return Ok(Some(Item::Prim(prim)));
729            }
730        }
731        match map {
732            Some(alias) => {
733                let doc = self
734                    .doc
735                    .dep_aliases
736                    .get(alias)
737                    .ok_or_else(|| Error::MapLogic(format!("dep alias {alias:?} not found")))?;
738                doc.lookup_alias(name).map(Some).ok_or_else(|| {
739                    Error::MapLogic(format!("alias {name:?} of dep {alias:?} not found"))
740                })
741            }
742            None => self.doc.lookup_alias(name).map(Some).ok_or_else(|| {
743                Error::MapLogic(format!("alias {name:?} not found in {}", self.doc.name))
744            }),
745        }
746    }
747
748    /// build_item(to, :str) semantics for the collapsed replacement.
749    fn build_replacement(&self, item: &Item) -> Result<String> {
750        match item {
751            Item::Str(s) => Ok(s.clone()),
752            Item::None => Ok(String::new()),
753            Item::Concat(parts) => {
754                let mut out = String::new();
755                for p in parts {
756                    out.push_str(&self.build_replacement(p)?);
757                }
758                Ok(out)
759            }
760            Item::CaptureRef(n) => Ok(format!("\\{n}")),
761            // Maybe/Some/CaptureGroup in :str build their data plus a
762            // literal suffix — the Ruby replacement-string quirk.
763            Item::Maybe(inner) => Ok(format!("{}?", self.build_replacement(inner)?)),
764            Item::Some(inner) => Ok(format!("{}+", self.build_replacement(inner)?)),
765            Item::CaptureGroup { inner, .. } => {
766                Ok(format!("({})", self.build_replacement(inner)?))
767            }
768            Item::Prim(prim) => match prim {
769                Prim::None_ => Ok(String::new()),
770                Prim::Space => Ok(" ".to_string()),
771                other => Err(Error::MapLogic(format!(
772                    "can't use {} in a string context",
773                    other.name()
774                ))),
775            },
776            Item::Alias { map, name } => {
777                if map.is_none() {
778                    if let Some(prim) = Prim::from_name(name) {
779                        return self.build_replacement(&Item::Prim(prim));
780                    }
781                }
782                match self.resolve_alias(map.as_deref(), name)? {
783                    Some(resolved) => self.build_replacement(&resolved),
784                    None => Err(Error::MapLogic(format!("alias {name:?} not found"))),
785                }
786            }
787            // nth_string collapse already handled Any; a residual
788            // Set/Range in replacement position is the Ruby
789            // SystemConversionError ("Can't use Any in a string context").
790            Item::Set(_) | Item::Range(_, _) => {
791                Err(Error::MapLogic("can't use Any in a string context".into()))
792            }
793            Item::Func(_) => Err(Error::MapLogic(
794                "function item in replacement position".into(),
795            )),
796        }
797    }
798
799    /// Ruby gsub replacement-string decoding over the built string:
800    /// \1..\9 group refs, \0 whole match, \\ literal backslash,
801    /// \& whole match; unknown escapes drop the backslash.
802    fn decode_replacement(&self, built: &str, start: usize, end: usize) -> String {        let chars: Vec<char> = built.chars().collect();
803        let mut out = String::with_capacity(built.len());
804        let mut i = 0usize;
805        while i < chars.len() {
806            if chars[i] == '\\' && i + 1 < chars.len() {
807                let next = chars[i + 1];
808                match next {
809                    '0' | '1'..='9' => {
810                        let n = next.to_digit(10).unwrap() as usize;
811                        if n == 0 {
812                            out.extend(&self.chars[start..end]);
813                        } else if let Some(Some((gs, ge))) = self.groups.borrow().get(n) {
814                            out.extend(&self.chars[*gs..*ge]);
815                        }
816                        i += 2;
817                        continue;
818                    }
819                    '\\' => {
820                        out.push('\\');
821                        i += 2;
822                        continue;
823                    }
824                    '&' => {
825                        out.extend(&self.chars[start..end]);
826                        i += 2;
827                        continue;
828                    }
829                    _ => {
830                        // Ruby drops the backslash on unknown escapes.
831                        out.push(next);
832                        i += 2;
833                        continue;
834                    }
835                }
836            }
837            out.push(chars[i]);
838            i += 1;
839        }
840        out
841    }
842}
843
844impl<'a> Matcher<'a> {
845    // ------------------------------------------------------------------
846    // build_item(:par) / build_item(:parstr) parity — the tree-path
847    // eligibility check for parallel groups. Errors mirror the Ruby
848    // SystemConversionError raises that force the megaregexp fallback.
849    // ------------------------------------------------------------------
850
851    /// build_item(item, :par): the key strings this `from` contributes
852    /// to the parallel tree. `Ok(vec![])` mirrors Ruby's silent nil
853    /// (function items); `Err` forces the megaregexp path.
854    fn build_par_from(&self, item: &Item) -> Result<Vec<String>> {
855        self.build_par_from_depth(item, 0)
856    }
857
858    fn build_par_from_depth(&self, item: &Item, depth: usize) -> Result<Vec<String>> {
859        if depth > 64 {
860            return Err(Error::MapLogic("alias expansion too deep".into()));
861        }
862        match item {
863            Item::Str(s) => Ok(vec![s.clone()]),
864            Item::None => Ok(vec![String::new()]),
865            Item::Prim(prim) => match prim {
866                Prim::None_ => Ok(vec![String::new()]),
867                Prim::Space => Ok(vec![" ".to_string()]),
868                other => Err(Error::MapLogic(format!(
869                    "can't use {} in a par context",
870                    other.name()
871                ))),
872            },
873            // Any(String/Range) -> one key per char; Any(Array) ->
874            // one key per entry. Nested Any entries produce nested
875            // arrays in Ruby (NoMethodError on `.chars`) -> fallback.
876            Item::Range(lo, hi) => {
877                let mut keys = Vec::new();
878                let mut c = *lo;
879                loop {
880                    keys.push(c.to_string());
881                    if c == *hi {
882                        break;
883                    }
884                    match char::from_u32(c as u32 + 1) {
885                        Some(n) => c = n,
886                        None => break,
887                    }
888                }
889                Ok(keys)
890            }
891            Item::Set(entries) => {
892                let mut keys = Vec::new();
893                for entry in entries {
894                    match entry {
895                        Item::Str(s) => keys.push(s.clone()),
896                        // Nested Any / Range / Alias entries become
897                        // arrays in the Ruby build -> raise -> fallback.
898                        _ => {
899                            return Err(Error::MapLogic(
900                                "can't parallelize nested any() entries".into(),
901                            ))
902                        }
903                    }
904                }
905                Ok(keys)
906            }
907            // Group in :par is the cartesian product of its children.
908            Item::Concat(parts) => {
909                let mut acc: Vec<String> = vec![String::new()];
910                for part in parts {
911                    let next = self.build_par_from_depth(part, depth)?;
912                    let mut joined = Vec::new();
913                    for a in &acc {
914                        for b in &next {
915                            joined.push(format!("{a}{b}"));
916                        }
917                    }
918                    acc = joined;
919                }
920                Ok(acc)
921            }
922            Item::Alias { map, name } => {
923                if map.is_none() {
924                    if let Some(prim) = Prim::from_name(name) {
925                        return self.build_par_from_depth(&Item::Prim(prim), depth);
926                    }
927                }
928                match self.resolve_alias(map.as_deref(), name)? {
929                    Some(resolved) => self.build_par_from_depth(&resolved, depth + 1),
930                    None => Ok(vec![]),
931                }
932            }
933            // Maybe/Some/CaptureGroup/CaptureRef raise in :par.
934            Item::Maybe(_) | Item::Some(_) => Err(Error::MapLogic(
935                "can't use a MaybeSome in a par context".into(),
936            )),
937            Item::CaptureGroup { .. } => Err(Error::MapLogic(
938                "can't use a CaptureGroup in a par context".into(),
939            )),
940            Item::CaptureRef(_) => Err(Error::MapLogic(
941                "Can't use CaptureRef in parallel mode".into(),
942            )),
943            // Function items have no build_item case -> nil -> no keys.
944            Item::Func(_) => Ok(vec![]),
945        }
946    }
947
948    /// build_item(item, :parstr): nth_string collapse, then the :par
949    /// string build. `to` in a parallel group must yield ONE string.
950    fn build_par_str(&self, item: &Item) -> Result<String> {
951        let collapsed = item.first_string();
952        self.build_par_str_inner(&collapsed, 0)
953    }
954
955    fn build_par_str_inner(&self, item: &Item, depth: usize) -> Result<String> {
956        if depth > 64 {
957            return Err(Error::MapLogic("alias expansion too deep".into()));
958        }
959        match item {
960            Item::Str(s) => Ok(s.clone()),
961            Item::None => Ok(String::new()),
962            Item::Prim(prim) => match prim {
963                Prim::None_ => Ok(String::new()),
964                Prim::Space => Ok(" ".to_string()),
965                other => Err(Error::MapLogic(format!(
966                    "can't use {} in a par context",
967                    other.name()
968                ))),
969            },
970            Item::Concat(parts) => {
971                let mut out = String::new();
972                for p in parts {
973                    out.push_str(&self.build_par_str_inner(p, depth)?);
974                }
975                Ok(out)
976            }
977            Item::Alias { map, name } => {
978                if map.is_none() {
979                    if let Some(prim) = Prim::from_name(name) {
980                        return self.build_par_str_inner(&Item::Prim(prim), depth);
981                    }
982                }
983                match self.resolve_alias(map.as_deref(), name)? {
984                    Some(resolved) => self.build_par_str_inner(&resolved, depth + 1),
985                    None => Err(Error::MapLogic(format!("alias {name:?} not found"))),
986                }
987            }
988            // A residual Any after the nth_string collapse yields an
989            // array in Ruby (not a replacement string) -> broken map.
990            Item::Set(_) | Item::Range(_, _) => Err(Error::MapLogic(
991                "parallel replacement is not a single string".into(),
992            )),
993            Item::Maybe(_) | Item::Some(_) => Err(Error::MapLogic(
994                "can't use a MaybeSome in a par context".into(),
995            )),
996            Item::CaptureGroup { .. } => Err(Error::MapLogic(
997                "can't use a CaptureGroup in a par context".into(),
998            )),
999            Item::CaptureRef(_) => Err(Error::MapLogic(
1000                "Can't use CaptureRef in parallel mode".into(),
1001            )),
1002            Item::Func(_) => Err(Error::MapLogic(
1003                "function item in a par context".into(),
1004            )),
1005        }
1006    }
1007}