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