Skip to main content

interscript_rs/
reverse.rs

1//! `Interscript::Node::*#reverse` parity for the slice-1 subset:
2//! reversed documents, stages, sub rules (with boundary-alias
3//! transfer + capture/ref exchange), funcall inversion, and the
4//! `reverse_name` convention.
5//!
6//! Ground truth is `interscript-ruby` origin/main
7//! (`lib/interscript/node/*.rb`); every rule here mirrors the Ruby
8//! method named in its comment. The reversed document runs through
9//! the NORMAL executor — the interpreter skips rules whose
10//! `reverse_run == Some(true)`, so the inversion lives entirely in
11//! the produced document. Capture numbering is positional: refs stay
12//! untouched numbers and groups re-number in regex order over the
13//! reversed rule (`assign_group_ids`), reproducing Ruby's
14//! `build_regexp` numbering exactly.
15//!
16//! Known deviations (all documented at the site): where the Ruby
17//! reverse would crash (NoMethodError on repeat items in case-swap
18//! subs, `children=` on a compacted-away from group, an `upcase`
19//! funcall missing from `reverse_function`), this implementation
20//! raises a clean `Unsupported` carrying the same information.
21
22use crate::builder::assign_group_ids;
23use crate::document::{Document, Stage, StageItem, Sub, SubTo, Tests};
24use crate::error::{Error, Result};
25use crate::item::Item;
26use crate::stdlib::{Funcall, Prim};
27use std::collections::BTreeMap;
28use std::rc::Rc;
29
30// ----------------------------------------------------------------------
31// Document / stage / rule level
32// ----------------------------------------------------------------------
33
34/// `Interscript::Node::Document.reverse_name`: split on `-`, swap
35/// positions 2/3 (source/target code) when there are at least 4
36/// segments; otherwise strip a `-reverse` suffix, otherwise append one.
37pub fn reverse_name(name: &str) -> String {
38    let mut parts: Vec<&str> = name.split('-').collect();
39    if parts.len() >= 4 {
40        parts.swap(2, 3);
41        let swapped = parts.join("-");
42        if swapped != name {
43            return swapped;
44        }
45    }
46    let stripped = name.replace("-reverse", "");
47    if stripped != name {
48        stripped
49    } else {
50        format!("{name}-reverse")
51    }
52}
53
54/// `Interscript::Node::Document#reverse`. Aliases are NOT reversed
55/// (`rdoc.aliases = self.aliases`); tests swap their pairs; dependency
56/// documents reverse recursively.
57pub fn reverse_document(doc: &Document) -> Result<Document> {
58    let mut stages = BTreeMap::new();
59    for (name, stage) in &doc.stages {
60        stages.insert(name.clone(), reverse_stage(stage)?);
61    }
62    // ISC-WF-1 W1: rulesets reverse like stages (they only matter if a
63    // dependent map splices them after reversing the dependency).
64    let mut rulesets = BTreeMap::new();
65    for (name, stage) in &doc.rulesets {
66        rulesets.insert(name.clone(), reverse_stage(stage)?);
67    }
68    let mut dep_aliases = BTreeMap::new();
69    for (alias, dep) in &doc.dep_aliases {
70        dep_aliases.insert(alias.clone(), Rc::new(reverse_document(dep)?));
71    }
72    let mut imports = Vec::with_capacity(doc.imports.len());
73    for dep in &doc.imports {
74        imports.push(Rc::new(reverse_document(dep)?));
75    }
76    Ok(Document {
77        name: reverse_name(&doc.name),
78        system_code: doc.system_code.clone(),
79        aliases: doc.aliases.clone(),
80        stages,
81        rulesets,
82        dep_aliases,
83        imports,
84        // Tests#reverse: swap (from, to) per row (the ISC model has no
85        // per-row reverse_run flag).
86        tests: Tests {
87            data: doc
88                .tests
89                .data
90                .iter()
91                .map(|(a, b)| (b.clone(), a.clone()))
92                .collect(),
93        },
94        // MetaData#reverse swaps source_script/destination_script.
95        metadata: reverse_metadata(&doc.metadata),
96        // ISC-WF-1 W3: language declarations are surface-independent.
97        languages: doc.languages.clone(),
98    })
99}
100
101fn reverse_metadata(metadata: &[(String, String)]) -> Vec<(String, String)> {
102    let src = metadata
103        .iter()
104        .find(|(k, _)| k == "source_script")
105        .map(|(_, v)| v.clone());
106    let dst = metadata
107        .iter()
108        .find(|(k, _)| k == "destination_script")
109        .map(|(_, v)| v.clone());
110    let mut out = metadata.to_vec();
111    for (k, v) in out.iter_mut() {
112        match k.as_str() {
113            "source_script" => {
114                if let Some(d) = &dst {
115                    *v = d.clone();
116                }
117            }
118            "destination_script" => {
119                if let Some(s) = &src {
120                    *v = s.clone();
121                }
122            }
123            _ => {}
124        }
125    }
126    out
127}
128
129/// `Interscript::Node::Stage#reverse` / `Group#reverse`: children in
130/// REVERSE order, each reversed, reverse_run toggled. (The ISC grammar
131/// cannot express `dont_reverse`, so stages always reverse.)
132pub fn reverse_stage(stage: &Stage) -> Result<Stage> {
133    let mut items: Vec<StageItem> = Vec::with_capacity(stage.items.len());
134    for item in stage.items.iter().rev() {
135        items.push(reverse_stage_item(item)?);
136    }
137    Ok(Stage {
138        name: stage.name.clone(),
139        items,
140        reverse_run: toggle(stage.reverse_run),
141    })
142}
143
144fn toggle(run: Option<bool>) -> Option<bool> {
145    run.map(|b| !b)
146}
147
148fn reverse_stage_item(item: &StageItem) -> Result<StageItem> {
149    Ok(match item {
150        StageItem::Parallel(rules) => {
151            let mut out = Vec::with_capacity(rules.len());
152            for rule in rules.iter().rev() {
153                out.push(reverse_sub(rule)?);
154            }
155            StageItem::Parallel(out)
156        }
157        StageItem::Sequential(rules) => {
158            let mut out = Vec::with_capacity(rules.len());
159            for rule in rules.iter().rev() {
160                out.push(reverse_sub(rule)?);
161            }
162            StageItem::Sequential(out)
163        }
164        StageItem::Run {
165            dep,
166            stage,
167            reverse_run,
168        } => StageItem::Run {
169            dep: dep.clone(),
170            stage: stage.clone(),
171            reverse_run: toggle(*reverse_run),
172        },
173        StageItem::Funcall(funcall) => StageItem::Funcall(reverse_funcall(funcall)?),
174    })
175}
176
177/// `Interscript::Node::Rule::Funcall#reverse`: the name maps through
178/// `Stdlib.reverse_function`; kwargs carry over.
179fn reverse_funcall(funcall: &Funcall) -> Result<Funcall> {
180    let name = funcall.name.reverse_name().ok_or_else(|| {
181        Error::Unsupported(
182            "funcall upcase has no reverse (the Ruby reverse_function map omits it)".into(),
183        )
184    })?;
185    Ok(Funcall {
186        name,
187        kwargs: funcall.kwargs.clone(),
188        reverse_run: toggle(funcall.reverse_run),
189    })
190}
191
192/// `Interscript::Node::Rule::Sub#reverse`.
193pub fn reverse_sub(sub: &Sub) -> Result<Sub> {
194    let (xfrom, xto) = match &sub.to {
195        // `sub from, to upcase` reverses to `sub from.downcase, to downcase`
196        // (Item#downcase / Item#upcase parity, and vice versa).
197        SubTo::Upcase => (item_downcase(&sub.from)?, SubTo::Downcase),
198        SubTo::Downcase => (item_upcase(&sub.from)?, SubTo::Upcase),
199        SubTo::Item(to) => {
200            let (f, t) = reverse_transfer(sub.from.clone(), to.clone())?;
201            (f, SubTo::Item(t))
202        }
203    };
204
205    // reverse_run toggles; a rule whose reversed `from` is empty (and
206    // carries no assertions) would match EVERYWHERE — the Ruby
207    // Sub#reverse marks those reverse_run: true so the executor skips
208    // them.
209    let mut rrun = toggle(sub.reverse_run);
210    if rrun.is_none()
211        && sub.before.is_none()
212        && sub.not_before.is_none()
213        && sub.after.is_none()
214        && sub.not_after.is_none()
215        && is_empty_from(&xfrom)
216    {
217        rrun = Some(true);
218    }
219
220    let mut reversed = Sub {
221        // Constraints pass through UNCHANGED (Ruby keeps
222        // before/after/not_before/not_after as-is).
223        before: sub.before.clone(),
224        not_before: sub.not_before.clone(),
225        after: sub.after.clone(),
226        not_after: sub.not_after.clone(),
227        from: xfrom,
228        to: xto,
229        group_count: 0,
230        reverse_run: rrun,
231        // ISC-WF-1 W3: guards carry over unchanged — the language
232        // selection filters the same rows on either surface.
233        when: sub.when.clone(),
234    };
235    // Regexp-order group numbering over the reversed rule — the same
236    // walk `build_regexp` performs in Ruby. Refs keep their numbers.
237    reversed.group_count = assign_group_ids(&mut reversed);
238    Ok(reversed)
239}
240
241/// The Ruby empty-from test: `xfrom == "" || String("") || Alias(:none)`.
242fn is_empty_from(item: &Item) -> bool {
243    match item {
244        Item::None | Item::Prim(Prim::None_) => true,
245        Item::Str(s) => s.is_empty(),
246        _ => false,
247    }
248}
249
250// ----------------------------------------------------------------------
251// Item#downcase / Item#upcase (Sub#reverse to upcase/downcase case)
252// ----------------------------------------------------------------------
253
254/// `Item#downcase`: String data downcases; Group/CaptureGroup
255/// recurse; Alias (incl. boundary prims) and refs/ranges are
256/// identity; Any rebuilds from per-char entries.
257pub fn item_downcase(item: &Item) -> Result<Item> {
258    item_case(item, false)
259}
260
261/// `Item#upcase`.
262pub fn item_upcase(item: &Item) -> Result<Item> {
263    item_case(item, true)
264}
265
266fn item_case(item: &Item, up: bool) -> Result<Item> {
267    let apply = |s: &str| {
268        if up {
269            s.to_uppercase()
270        } else {
271            s.to_lowercase()
272        }
273    };
274    Ok(match item {
275        Item::Str(s) => Item::Str(apply(s)),
276        Item::None => Item::None,
277        // Item::Alias#downcase/#upcase = self.
278        Item::Prim(_) | Item::Alias { .. } | Item::CaptureRef(_) => item.clone(),
279        Item::CaptureGroup { id, inner } => Item::CaptureGroup {
280            id: *id,
281            inner: Box::new(item_case(inner, up)?),
282        },
283        Item::Concat(parts) => Item::Concat(
284            parts
285                .iter()
286                .map(|p| item_case(p, up))
287                .collect::<Result<Vec<_>>>()?,
288        ),
289        // Any.new(data.map(&:downcase)): a Range rebuilds as the set
290        // of its per-char entries (Any with an Array value).
291        Item::Range(lo, hi) => {
292            let mut entries = Vec::new();
293            let mut c = *lo;
294            loop {
295                entries.push(Item::Str(apply(&c.to_string())));
296                if c == *hi {
297                    break;
298                }
299                match char::from_u32(c as u32 + 1) {
300                    Some(n) => c = n,
301                    None => break,
302                }
303            }
304            Item::Set(entries)
305        }
306        Item::Set(entries) => Item::Set(
307            entries
308                .iter()
309                .map(|e| item_case(e, up))
310                .collect::<Result<Vec<_>>>()?,
311        ),
312        // Ruby Item::Repeat has no downcase/upcase: NoMethodError.
313        Item::Maybe(_) | Item::Some(_) => {
314            return Err(Error::Unsupported(
315                "repeat item in a to-upcase/downcase sub (the Ruby reverse raises NoMethodError)"
316                    .into(),
317            ))
318        }
319        // Function items are a slice-1 SubTo marker, never a from tree.
320        Item::Func(_) => {
321            return Err(Error::Unsupported(
322                "function item in a sub `from` (the Ruby adapter rejects it)".into(),
323            ))
324        }
325    })
326}
327
328// ----------------------------------------------------------------------
329// Sub#reverse_transfer: boundary hoisting + capture/ref exchange
330// ----------------------------------------------------------------------
331
332/// `Interscript::Stdlib.boundary_like_alias?` (the slice-1 subset:
333/// string_start/string_end do not exist in the ISC grammar).
334fn boundary_like(item: &Item) -> bool {
335    matches!(
336        item,
337        Item::Prim(Prim::Boundary | Prim::NonWordBoundary | Prim::LineStart | Prim::LineEnd)
338    )
339}
340
341/// Ruby `Group.new + a + b ...`: a fresh group whose children are the
342/// leading items plus `last` — flattened when `last` is itself a
343/// Group (`Group#+` merges children) — then `Group#compact` (drop
344/// none/"" children; 0 -> Alias(:none), 1 -> the child).
345fn group_plus_compact(prefix: Vec<Item>, last: Item) -> Item {
346    let mut children = prefix;
347    match last {
348        Item::Concat(parts) => children.extend(parts),
349        other => children.push(other),
350    }
351    let kept: Vec<Item> = children
352        .into_iter()
353        .filter(|c| match c {
354            Item::None | Item::Prim(Prim::None_) => false,
355            Item::Str(s) => !s.is_empty(),
356            _ => true,
357        })
358        .collect();
359    match kept.len() {
360        0 => Item::None,
361        1 => kept.into_iter().next().unwrap(),
362        _ => Item::Concat(kept),
363    }
364}
365
366/// `Interscript::Node::Rule::Sub#reverse_transfer`: boundary-like
367/// aliases at the from edges move onto the to side; then the
368/// capture/ref exchange runs. Returns (reversed_from, reversed_to) —
369/// the Ruby multi-assign `xto, xfrom = reverse_transfer(from, to)`
370/// swaps the results.
371fn reverse_transfer(from: Item, to: Item) -> Result<(Item, Item)> {
372    let mut from = from;
373    let mut to = to;
374
375    match &from {
376        Item::Concat(parts) => {
377            // Ruby reads first/last BEFORE either mutation.
378            let first = parts.first().cloned();
379            let last = parts.last().cloned();
380
381            // first child boundary-like -> prepended to `to`
382            if let Some(f) = &first {
383                if boundary_like(f) {
384                    to = group_plus_compact(vec![f.clone()], to);
385                    let mut rest = parts.clone();
386                    rest.remove(0);
387                    from = compact_children(rest);
388                }
389            }
390            // last child boundary-like -> appended to `to`. Ruby
391            // mutates `from.children` here, which requires `from` to
392            // still be a Group; a first-hoist that consumed the whole
393            // group crashes there (NoMethodError).
394            if let Some(l) = &last {
395                if boundary_like(l) {
396                    match &from {
397                        Item::Concat(prev) => {
398                            let mut shorter = prev.clone();
399                            shorter.pop();
400                            to = group_plus_compact(vec![to], l.clone());
401                            from = compact_children(shorter);
402                        }
403                        _ => {
404                            return Err(Error::Unsupported(
405                                "boundary hoist consumed the whole from group \
406                                 (the Ruby reverse raises NoMethodError)"
407                                    .into(),
408                            ))
409                        }
410                    }
411                }
412            }
413        }
414        single => {
415            // Whole-from boundary alias: `_end`-suffixed names append
416            // after `to`, everything else prepends before it; from
417            // becomes `none`.
418            if boundary_like(single) {
419                // Ruby: from.name.end_with?("_end") — among the ISC
420                // prims only line_end qualifies (string_end is not in
421                // the grammar).
422                to = if matches!(single, Item::Prim(Prim::LineEnd)) {
423                    group_plus_compact(vec![to], single.clone())
424                } else {
425                    group_plus_compact(vec![single.clone()], to)
426                };
427                from = Item::Prim(Prim::None_);
428            }
429        }
430    }
431
432    // --- capture/ref exchange ---
433    let mut state = TransferState::default();
434    let visited_from = transfer_visit(&from, Side::From, &mut state)?;
435    let visited_to = transfer_visit(&to, Side::To, &mut state)?;
436    // xto, xfrom = reverse_transfer(from, to)
437    Ok((visited_to, visited_from))
438}
439
440/// `Group#compact` on an already-collected children list.
441fn compact_children(children: Vec<Item>) -> Item {
442    let kept: Vec<Item> = children
443        .into_iter()
444        .filter(|c| match c {
445            Item::None | Item::Prim(Prim::None_) => false,
446            Item::Str(s) => !s.is_empty(),
447            _ => true,
448        })
449        .collect();
450    match kept.len() {
451        0 => Item::None,
452        1 => kept.into_iter().next().unwrap(),
453        _ => Item::Concat(kept),
454    }
455}
456
457#[derive(Clone, Copy, PartialEq)]
458enum Side {
459    From,
460    To,
461}
462
463#[derive(Default)]
464struct TransferState {
465    /// Ruby state[:left]: every CaptureGroup / kor_maybedash seen, in
466    /// visit order; minted ref ids index into it (1-based).
467    #[allow(dead_code)]
468    left: Vec<Item>,
469    /// Ruby state[:right]: ref ids already substituted on the `to`
470    /// side (a second occurrence stays a literal ref).
471    right: std::collections::BTreeSet<usize>,
472}
473
474/// `reverse_transfer_visit`. Refs stay positional numbers — the
475/// renumber pass on the reversed rule (`assign_group_ids`) produces
476/// the same numbers Ruby's `build_regexp` assigns at build time.
477fn transfer_visit(node: &Item, side: Side, state: &mut TransferState) -> Result<Item> {
478    Ok(match node {
479        // Alias :kor_maybedash -> CaptureRef(left.length). (Our builder
480        // expands the alias to its capture(maybe("-")) tree before
481        // reverse sees it, where the CaptureGroup branch handles it
482        // with identical numbering — this branch guards direct alias
483        // items and documents the Ruby special case.)
484        Item::Alias { name, .. } if name == "kor_maybedash" => {
485            state.left.push(node.clone());
486            Item::CaptureRef(state.left.len())
487        }
488        Item::Str(_) | Item::None | Item::Prim(_) | Item::Alias { .. } | Item::Range(_, _) => {
489            node.clone()
490        }
491        Item::Set(entries) => Item::Set(
492            entries
493                .iter()
494                .map(|e| transfer_visit(e, side, state))
495                .collect::<Result<Vec<_>>>()?,
496        ),
497        Item::Concat(parts) => Item::Concat(
498            parts
499                .iter()
500                .map(|p| transfer_visit(p, side, state))
501                .collect::<Result<Vec<_>>>()?,
502        ),
503        // Repeat: only the data is visited; the repeat kind survives.
504        Item::Maybe(inner) => Item::Maybe(Box::new(transfer_visit(inner, side, state)?)),
505        Item::Some(inner) => Item::Some(Box::new(transfer_visit(inner, side, state)?)),
506        Item::CaptureRef(id) => match side {
507            // refs on the from side stay refs
508            Side::From => node.clone(),
509            Side::To => {
510                if state.right.contains(id) {
511                    node.clone()
512                } else {
513                    state.right.insert(*id);
514                    match state.left.get(id.wrapping_sub(1)) {
515                        Some(group) => group.clone(),
516                        None => {
517                            return Err(Error::MapLogic(
518                                "Capture count doesn't match".into(),
519                            ))
520                        }
521                    }
522                }
523            }
524        },
525        Item::CaptureGroup { inner, .. } => {
526            state.left.push(node.clone());
527            let out = Item::CaptureRef(state.left.len());
528            // Visit but don't care (inner groups still register).
529            transfer_visit(inner, side, state)?;
530            out
531        }
532        Item::Func(_) => {
533            return Err(Error::Unsupported(
534                "Type Item::Func unhandled in reverse_transfer_visit (the Ruby raises \
535                 \"Type ... unhandled!\")"
536                    .into(),
537            ))
538        }
539    })
540}