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