Skip to main content

interscript_rs/
builder.rs

1//! Tree → Document builder. Mirrors the interscript-ruby pipeline:
2//! `Isc::Normalizer` (scalar folding) + `Isc::Transform` (item
3//! materialization) + `Isc::DocumentBuilder` + `Isc::NodeAdapter`
4//! (document assembly). Dependencies load BEFORE conversion so
5//! class-context charset resolution sees imported aliases
6//! (NodeAdapter#build ordering).
7//!
8//! ISC-WF-1 (this crate's workflow-extension layer): W1 named
9//! rulesets and W2 `use` + `override` resolve AT LOAD TIME — the
10//! parser captures the constructs, the builder expands `use` sites
11//! inline (recursive, cycle = load error) and merges overrides per
12//! spec §4.2, so the Document stays flat (expanded StageItems only).
13//! W3 (`languages` / `when`) is runtime — see interpreter.rs.
14
15use crate::document::{Dependency, Document, Stage, StageItem, Sub, SubTo, Tests};
16use crate::error::{Error, Result};
17use crate::item::{FuncName, Item};
18use crate::stdlib::{Funcall, FuncallName, Prim};
19use crate::tree::{leaf_text, pget, TNode};
20use std::cell::RefCell;
21use std::collections::{BTreeMap, BTreeSet};
22use std::rc::Rc;
23
24/// Dependency targets declared by a parsed tree, in order
25/// (`dependency "x" [as alias]`), read before documents are loaded.
26pub fn dependency_targets(tree: &TNode) -> Vec<(String, Option<String>)> {
27    let system = tree.get("system").unwrap_or(tree);
28    let mut out = Vec::new();
29    let Some(body) = system.get("body") else { return out };
30    for block in body.ruby_array() {
31        if let Some(pair) = block.get("target") {
32            let target = unquote(pair);
33            let alias = block.get("alias").map(ident);
34            out.push((target, alias));
35        }
36    }
37    out
38}
39
40pub fn build(
41    tree: &TNode,
42    filename_stem: &str,
43    imports: &[Rc<Document>],
44    dep_docs: &BTreeMap<String, Rc<Document>>,
45) -> Result<Document> {
46    let system = tree.get("system").unwrap_or(tree);
47    let system_code = system.get("system_code").map(unquote).unwrap_or_default();
48
49    let body: Vec<&TNode> = match system.get("body") {
50        Some(b) => b.ruby_array().iter().collect(),
51        None => Vec::new(),
52    };
53
54    // Pass 1: collect raw sections in document order.
55    let mut metadata = Vec::new();
56    let mut alias_nodes: Vec<(String, TNode)> = Vec::new();
57    let mut tests = Tests::default();
58    let mut stage_nodes: Vec<(String, &TNode)> = Vec::new();
59    let mut dependencies: Vec<Dependency> = Vec::new();
60    // ISC-WF-1: named rulesets (raw bodies) + declared languages.
61    let mut ruleset_nodes: Vec<(String, &TNode)> = Vec::new();
62    let mut languages: Vec<String> = Vec::new();
63
64    for block in &body {
65        let Some(hash) = block.ruby_array_hash() else { continue };
66        if let Some(fields) = pget(hash, "metadata") {
67            extract_metadata(fields, &mut metadata);
68        } else if let Some(arr) = pget(hash, "aliases") {
69            for a in arr.ruby_array() {
70                if let Some(name_node) = a.get("name") {
71                    let name = ident(name_node);
72                    let value = a.get("value").cloned().unwrap_or(TNode::Nil);
73                    alias_nodes.push((name, value));
74                }
75            }
76        } else if let Some(arr) = pget(hash, "tests") {
77            for t in arr.ruby_array() {
78                let input = t.get("input").map(unquote).unwrap_or_default();
79                let expected = t.get("expected").map(unquote).unwrap_or_default();
80                tests.data.push((input, expected));
81            }
82        } else if let (Some(name_node), Some(stage_node)) = (pget(hash, "stage_name"), pget(hash, "stage")) {
83            stage_nodes.push((ident(name_node), stage_node));
84        } else if let (Some(name_node), Some(rs_node)) = (pget(hash, "ruleset_name"), pget(hash, "ruleset")) {
85            ruleset_nodes.push((ident(name_node), rs_node));
86        } else if let Some(lang_list) = pget(hash, "language") {
87            for l in lang_list.ruby_array() {
88                languages.push(ident(l));
89            }
90        } else if let Some(t) = pget(hash, "target") {
91            let target = unquote(t);
92            let alias = pget(hash, "alias").map(ident);
93            dependencies.push(Dependency { target, alias });
94        }
95    }
96
97    // A ruleset declared twice is a load error (the py canon): both
98    // first-wins and order-dependent merges silently pick a body.
99    {
100        let mut seen = BTreeSet::new();
101        for (name, _) in &ruleset_nodes {
102            if !seen.insert(name.clone()) {
103                return Err(Error::MapLogic(format!(
104                    "ruleset {name:?} declared more than once in {filename_stem}"
105                )));
106            }
107        }
108    }
109
110    // Conversion context: local raws + imported alias items
111    // (NodeAdapter @local_raw / @imported).
112    let ctx = Ctx {
113        imported: {
114            let mut m = BTreeMap::new();
115            for doc in imports {
116                for (k, v) in &doc.aliases {
117                    m.entry(k.clone()).or_insert_with(|| v.clone());
118                }
119            }
120            m
121        },
122        local_raw: alias_nodes.clone(),
123        dep_docs,
124        ruleset_raw: ruleset_nodes,
125        ruleset_memo: RefCell::new(BTreeMap::new()),
126        ruleset_stack: RefCell::new(Vec::new()),
127    };
128
129    // Phase A (W1): expand every declared ruleset (memoized, cycle
130    // checked) BEFORE the stages, so `use` sites hit the memo. A
131    // declared-but-never-used ruleset still expands — a broken or
132    // cyclic definition is a load error regardless of use (§6.4).
133    for (name, _) in &ctx.ruleset_raw {
134        ctx.local_ruleset(name)?;
135    }
136    let rulesets: BTreeMap<String, Stage> = ctx.ruleset_memo.borrow().clone();
137
138    // §5.1: languages are declared once per map; repeated declarations
139    // union in declaration order.
140    {
141        let mut seen = BTreeSet::new();
142        languages.retain(|l| seen.insert(l.clone()));
143    }
144
145    // Pass 2: convert (aliases first — stages may reference them).
146    let mut aliases = BTreeMap::new();
147    for (name, value) in &alias_nodes {
148        aliases.insert(name.clone(), ctx.materialize(value)?);
149    }
150
151    let mut stages = BTreeMap::new();
152    for (name, node) in stage_nodes {
153        let stage = ctx.extract_stage(&name, node)?;
154        stages.insert(stage.name.clone(), stage);
155    }
156
157    Ok(Document {
158        name: filename_stem.to_string(),
159        system_code,
160        aliases,
161        stages,
162        rulesets,
163        dep_aliases: dep_docs
164        .iter()
165        .filter_map(|(k, v)| {
166            dependencies
167                .iter()
168                .find(|d| d.alias.as_deref() == Some(k.as_str()))
169                .map(|_| (k.clone(), v.clone()))
170        })
171        .collect(),
172        imports: imports.to_vec(),
173        tests,
174        metadata,
175        languages,
176    })
177}
178
179struct Ctx<'a> {
180    /// Imported library aliases (import deps' own aliases merged).
181    imported: BTreeMap<String, Item>,
182    /// Local alias raw nodes.
183    local_raw: Vec<(String, TNode)>,
184    /// Aliased dependency documents (qualified-alias expansion).
185    dep_docs: &'a BTreeMap<String, Rc<Document>>,
186    /// ISC-WF-1 W1: raw local ruleset bodies, declaration order.
187    ruleset_raw: Vec<(String, &'a TNode)>,
188    /// W1 expansion memo: fully expanded rulesets. A ruleset currently
189    /// being expanded is NOT in the memo (the stack holds it instead).
190    ruleset_memo: RefCell<BTreeMap<String, Stage>>,
191    /// W1 expansion stack — bare `use` chain cycle detection (§3.2:
192    /// cycles are a load error).
193    ruleset_stack: RefCell<Vec<String>>,
194}
195
196impl<'a> Ctx<'a> {
197    // ------------------------------------------------------------------
198    // Stage extraction
199    // ------------------------------------------------------------------
200
201    fn extract_stage(&self, name: &str, node: &TNode) -> Result<Stage> {
202        let items = self.extract_stage_items(node)?;
203        Ok(Stage { name: name.to_string(), items, reverse_run: None })
204    }
205
206    /// The stage-body item loop. A ruleset body is stage items too
207    /// (§3.1), so `local_ruleset` runs this same loop over its body —
208    /// which is what makes nested `use` expansion uniform.
209    fn extract_stage_items(&self, node: &TNode) -> Result<Vec<StageItem>> {
210        let mut items: Vec<StageItem> = Vec::new();
211        for entry in node.ruby_array() {
212            let Some(hash) = entry.ruby_array_hash() else { continue };
213            let keys: Vec<&str> = hash.iter().map(|(k, _)| k.as_str()).collect();
214            if keys.contains(&"use_map") {
215                // ISC-WF-1 W1/W2: splice the expansion at this position.
216                items.extend(self.resolve_use_items(hash)?);
217            } else if keys.contains(&"sequence") {
218                let node = pget(hash, "sequence").unwrap();
219                items.push(StageItem::Sequential(self.extract_rule_list(node)?));
220            } else if keys.contains(&"parallel") {
221                let node = pget(hash, "parallel").unwrap();
222                items.push(StageItem::Parallel(self.extract_rule_list(node)?));
223            } else if keys.contains(&"separate") {
224                let separator = pget(hash, "separator")
225                    .map(|v| self.materialize(v))
226                    .transpose()?
227                    .map(|item| render_str(&item.first_string()));
228                items.push(StageItem::Funcall(Funcall {
229                    name: FuncallName::Separate,
230                    kwargs: separator
231                        .map(|s| vec![("separator".to_string(), s)])
232                        .unwrap_or_default(),
233                    reverse_run: None,
234                }));
235            } else if keys.contains(&"compose") {
236                let op = leaf_text(pget(hash, "compose").unwrap());
237                let name = if op == "decompose" {
238                    FuncallName::Decompose
239                } else {
240                    FuncallName::Compose
241                };
242                items.push(StageItem::Funcall(Funcall { name, kwargs: vec![], reverse_run: None }));
243            } else if keys.contains(&"case") {
244                let op = leaf_text(pget(hash, "case").unwrap());
245                let name = match op.as_str() {
246                    "upcase" => FuncallName::Upcase,
247                    "downcase" => FuncallName::Downcase,
248                    "title_case" => FuncallName::TitleCase,
249                    other => return Err(Error::MapLogic(format!("unknown case op {other:?}"))),
250                };
251                let kwargs = pget(hash, "case_kwargs").map(extract_kwargs).unwrap_or_default();
252                items.push(StageItem::Funcall(Funcall { name, kwargs, reverse_run: None }));
253            } else if keys.contains(&"funcall_name") {
254                let fname = leaf_text(pget(hash, "funcall_name").unwrap());
255                let kwargs = pget(hash, "funcall_kwargs").map(extract_kwargs).unwrap_or_default();
256                let fname_enum = FuncallName::from_name(&fname)
257                    .ok_or_else(|| crate::stdlib::unsupported_funcall(&fname))?;
258                items.push(StageItem::Funcall(Funcall { name: fname_enum, kwargs, reverse_run: None }));
259            } else if keys.contains(&"dep") {
260                let dep = ident(pget(hash, "dep").unwrap());
261                let stage = ident(pget(hash, "stage").unwrap());
262                items.push(StageItem::Run {
263                    dep: Some(dep),
264                    stage,
265                    reverse_run: None,
266                });
267            } else if keys.contains(&"run_stage_only") {
268                let stage = pget(hash, "run_stage_only")
269                    .and_then(|v| v.get("stage"))
270                    .map(ident)
271                    .unwrap_or_default();
272                items.push(StageItem::Run { dep: None, stage, reverse_run: None });
273            } else if keys.contains(&"bare_rule") {
274                let rule = pget(hash, "bare_rule").unwrap();
275                items.push(StageItem::Sequential(vec![self.extract_rule(rule)?]));
276            }
277            // comment / noop entries: skipped
278        }
279        Ok(items)
280    }
281
282    /// The expanded, CLONED target of one `use` directive. Qualified
283    /// references (`use alias.<ruleset>`, §3.3) resolve through the
284    /// alias's dependency document — whose rulesets are already fully
285    /// expanded, because it was built first (dependency cycles are
286    /// caught in Runtime::load). Bare references go through this map's
287    /// own expansion memo.
288    fn resolve_use_stage(&self, hash: &[(String, TNode)]) -> Result<Stage> {
289        let use_map = pget(hash, "use_map")
290            .map(ident)
291            .ok_or_else(|| Error::MapLogic("use directive without a target".into()))?;
292        match pget(hash, "use_ruleset") {
293            Some(rs_node) => {
294                let rs = ident(rs_node);
295                let doc = self.dep_docs.get(&use_map).ok_or_else(|| {
296                    Error::MapLogic(format!("dep alias {use_map:?} not found"))
297                })?;
298                doc.rulesets.get(&rs).cloned().ok_or_else(|| {
299                    Error::MapLogic(format!("ruleset {rs:?} of dep {use_map:?} not found"))
300                })
301            }
302            None => self.local_ruleset(&use_map),
303        }
304    }
305
306    /// The fully expanded local ruleset `name` (§3.2): memoized; bare
307    /// `use` chains walk the raw declarations under a cycle stack.
308    fn local_ruleset(&self, name: &str) -> Result<Stage> {
309        if let Some(built) = self.ruleset_memo.borrow().get(name) {
310            return Ok(built.clone());
311        }
312        {
313            let stack = self.ruleset_stack.borrow();
314            if stack.iter().any(|n| n == name) {
315                let mut chain = stack.clone();
316                chain.push(name.to_string());
317                return Err(Error::MapLogic(format!(
318                    "use cycle: {}",
319                    chain.join(" -> ")
320                )));
321            }
322        }
323        let node = self
324            .ruleset_raw
325            .iter()
326            .find(|(n, _)| n.as_str() == name)
327            .map(|(_, node)| *node)
328            .ok_or_else(|| Error::MapLogic(format!("ruleset {name:?} not found")))?;
329        self.ruleset_stack.borrow_mut().push(name.to_string());
330        let expanded = self.extract_stage_items(node);
331        self.ruleset_stack.borrow_mut().pop();
332        let stage = Stage {
333            name: name.to_string(),
334            items: expanded?,
335            reverse_run: None,
336        };
337        self.ruleset_memo
338            .borrow_mut()
339            .insert(name.to_string(), stage.clone());
340        Ok(stage)
341    }
342
343    /// `use` at a stage/ruleset-body position: the expansion splices
344    /// in place, in document order, with the override merge applied
345    /// (§4.2) when the directive carries an override block.
346    fn resolve_use_items(&self, hash: &[(String, TNode)]) -> Result<Vec<StageItem>> {
347        let mut stage = self.resolve_use_stage(hash)?;
348        if let Some(orules) = pget(hash, "override_rules") {
349            let overrides = self.extract_rule_list(orules)?;
350            stage.items = merge_override_items(stage.items, overrides)?;
351        }
352        Ok(stage.items)
353    }
354
355    /// `use` inside a parallel/sequence block (§3.2: the expansion
356    /// inherits the enclosing pass structure): the target's rule
357    /// content flattens into the enclosing collection. Non-rule items
358    /// (funcalls, stage runs) cannot join a pass — load error.
359    fn resolve_use_rules(&self, hash: &[(String, TNode)]) -> Result<Vec<Sub>> {
360        let stage = self.resolve_use_stage(hash)?;
361        let mut rules = Vec::new();
362        flatten_rules(&stage.items, &mut rules)?;
363        if let Some(orules) = pget(hash, "override_rules") {
364            let overrides = self.extract_rule_list(orules)?;
365            rules = merge_override_rules(rules, overrides);
366        }
367        Ok(rules)
368    }
369
370    fn extract_rule_list(&self, node: &TNode) -> Result<Vec<Sub>> {
371        let mut rules = Vec::new();
372        for r in node.ruby_array() {
373            // ISC-WF-1: `use` inside a rule collection joins the
374            // enclosing pass (§3.2) — expansion flattens into this
375            // list. Must precede the noop filter (a use entry has no
376            // from/to and would look like a comment).
377            if r.has_key("use_map") {
378                let Some(hash) = r.ruby_array_hash() else { continue };
379                rules.extend(self.resolve_use_rules(hash)?);
380                continue;
381            }
382            // Noop filter: from none + to none (comment-only blocks).
383            if is_none_capture(r.get("from")) && is_none_capture(r.get("to")) {
384                continue;
385            }
386            rules.push(self.extract_rule(r)?);
387        }
388        Ok(rules)
389    }
390
391    fn extract_rule(&self, r: &TNode) -> Result<Sub> {
392        let from = match r.get("from") {
393            Some(n) => self.materialize(n)?,
394            None => Item::None,
395        };
396        let to = match r.get("to") {
397            Some(n) => self.materialize(n)?,
398            None => Item::None,
399        };
400
401        let mut before = None;
402        let mut not_before = None;
403        let mut after = None;
404        let mut not_after = None;
405
406        if let Some(constraints) = r.get("constraints") {
407            for c in constraints.ruby_array() {
408                let Some(hash) = c.ruby_array_hash() else { continue };
409                for (kind, item_node) in hash.iter() {
410                    let target = match kind.as_str() {
411                        "before" => &mut before,
412                        "not_before" => &mut not_before,
413                        "after" => &mut after,
414                        "not_after" => &mut not_after,
415                        _ => continue,
416                    };
417                    // Class context: charset aliases resolve to
418                    // character classes (NodeAdapter ctx=:class).
419                    *target = Some(self.materialize_class(item_node)?);
420                }
421            }
422        }
423
424        // `to`: only upcase/downcase functions become SubTo targets
425        // (Sub#initialize); other functions crash the Ruby adapter.
426        let to_target = match &to {
427            Item::Func(FuncName::Upcase) => SubTo::Upcase,
428            Item::Func(FuncName::Downcase) => SubTo::Downcase,
429            item => SubTo::Item(item.clone()),
430        };
431
432        // ISC-WF-1 W3: the `when` language guards (§5) — the list of
433        // languages this row applies under. Empty = unguarded.
434        let mut when = Vec::new();
435        if let Some(wc) = r.get("when_clause") {
436            if let Some(langs) = wc.get("when_language") {
437                for l in langs.ruby_array() {
438                    when.push(ident(l));
439                }
440            }
441        }
442
443        let mut sub = Sub {
444            from,
445            to: to_target,
446            before,
447            not_before,
448            after,
449            not_after,
450            group_count: 0,
451            reverse_run: None,
452            when,
453        };
454        // Alias expansion (Ruby's build_item resolves aliases inline,
455        // so capture-group numbers follow EXPANSION order): expand
456        // before/not_before/from/not_after/after in regex order, then
457        // number groups on the expanded trees.
458        sub.from = self.expand_aliases(&sub.from, 0)?;
459        for c in [&mut sub.before, &mut sub.not_before] {
460            if let Some(i) = c {
461                *i = self.expand_aliases(i, 0)?;
462            }
463        }
464        // from already expanded; do the after-side constraints.
465        for c in [&mut sub.not_after, &mut sub.after] {
466            if let Some(i) = c {
467                *i = self.expand_aliases(i, 0)?;
468            }
469        }
470        sub.group_count = assign_group_ids(&mut sub);
471        Ok(sub)
472    }
473
474    /// Deep-clone `item` with bare/qualified alias references replaced
475    /// by their resolved value trees (match-time resolve_alias
476    /// semantics: dep-map aliases first, stdlib primitives kept as
477    /// Prims, then imported/local aliases). Depth-limited against
478    /// alias cycles.
479    fn expand_aliases(&self, item: &Item, depth: usize) -> Result<Item> {
480        if depth > 32 {
481            return Err(Error::MapLogic(
482                "alias expansion too deep (alias cycle?)".into(),
483            ));
484        }
485        Ok(match item {
486            Item::Alias { map, name } => {
487                if map.is_some() {
488                    // Qualified: expand via the dep document's aliases.
489                    let alias = map.clone().unwrap();
490                    let doc = self
491                        .dep_docs
492                        .get(&alias)
493                        .ok_or_else(|| Error::MapLogic(format!("dep alias {alias:?} not found")))?;
494                    match doc.lookup_alias(name) {
495                        Some(resolved) => self.expand_aliases(&resolved, depth + 1)?,
496                        None => {
497                            return Err(Error::MapLogic(format!(
498                                "alias {name:?} of dep {alias:?} not found"
499                            )))
500                        }
501                    }
502                } else if let Some(prim) = Prim::from_name(name) {
503                    Item::Prim(prim)
504                } else if let Some(data) = self.imported.get(name) {
505                    self.expand_aliases(data, depth + 1)?
506                } else if let Some((_, raw)) = self
507                    .local_raw
508                    .iter()
509                    .find(|(n, _)| n.as_str() == name)
510                {
511                    let resolved = self.materialize(raw)?;
512                    self.expand_aliases(&resolved, depth + 1)?
513                } else {
514                    return Err(Error::MapLogic(format!(
515                        "alias {name:?} not found during expansion"
516                    )));
517                }
518            }
519            Item::CaptureGroup { id, inner } => Item::CaptureGroup {
520                id: *id,
521                inner: Box::new(self.expand_aliases(inner, depth)?),
522            },
523            Item::Maybe(inner) => Item::Maybe(Box::new(self.expand_aliases(inner, depth)?)),
524            Item::Some(inner) => Item::Some(Box::new(self.expand_aliases(inner, depth)?)),
525            Item::Concat(parts) => Item::Concat(
526                parts
527                    .iter()
528                    .map(|p| self.expand_aliases(p, depth))
529                    .collect::<Result<Vec<_>>>()?,
530            ),
531            Item::Set(entries) => Item::Set(
532                entries
533                    .iter()
534                    .map(|p| self.expand_set_entry(p, depth))
535                    .collect::<Result<Vec<_>>>()?,
536            ),
537            other => other.clone(),
538        })
539    }
540
541    /// Set entries expand too, but a set-of-characters stays compact:
542    /// entries that resolve to Sets/Strs keep their charset semantics.
543    fn expand_set_entry(&self, entry: &Item, depth: usize) -> Result<Item> {
544        match entry {
545            Item::Alias { .. } => self.expand_aliases(entry, depth),
546            Item::Concat(parts) => Ok(Item::Concat(
547                parts
548                    .iter()
549                    .map(|p| self.expand_set_entry(p, depth))
550                    .collect::<Result<Vec<_>>>()?,
551            )),
552            Item::Set(entries) => Ok(Item::Set(
553                entries
554                    .iter()
555                    .map(|p| self.expand_set_entry(p, depth))
556                    .collect::<Result<Vec<_>>>()?,
557            )),
558            Item::Maybe(inner) => Ok(Item::Maybe(Box::new(self.expand_set_entry(inner, depth)?))),
559            Item::Some(inner) => Ok(Item::Some(Box::new(self.expand_set_entry(inner, depth)?))),
560            other => Ok(other.clone()),
561        }
562    }
563
564    // ------------------------------------------------------------------
565    // Item materialization (Transform rules)
566    // ------------------------------------------------------------------
567
568    fn materialize(&self, fragment: &TNode) -> Result<Item> {
569        match fragment {
570            TNode::Hash(_) => {
571                if fragment.has_key("concatenation") {
572                    let parts = fragment.get("concatenation").unwrap();
573                    let items = parts
574                        .ruby_array()
575                        .iter()
576                        .map(|p| self.convert_fragment(p))
577                        .collect::<Result<Vec<_>>>()?;
578                    Ok(Item::concat_from_parts(items))
579                } else {
580                    let item = self.convert_fragment(fragment)?;
581                    Ok(Item::concat_from_parts(vec![item]))
582                }
583            }
584            TNode::Array(parts) => {
585                let items = parts
586                    .iter()
587                    .map(|p| self.convert_fragment(p))
588                    .collect::<Result<Vec<_>>>()?;
589                Ok(Item::concat_from_parts(items))
590            }
591            TNode::Nil => Ok(Item::None),
592            TNode::Str(_) => Ok(Item::Str(fragment.as_str().unwrap_or_default().to_string())),
593        }
594    }
595
596    /// Transform one keyed capture fragment into an Item.
597    fn convert_fragment(&self, node: &TNode) -> Result<Item> {
598        if let TNode::Hash(pairs) = node {
599            let (key, value) = (&pairs[0].0, &pairs[0].1);
600            match key.as_str() {
601                "string" => return Ok(Item::Str(decode_string_value(value))),
602                "none" => return Ok(Item::None),
603                "any_char" => {
604                    return Ok(Item::Alias {
605                        map: None,
606                        name: "any_character".to_string(),
607                    })
608                }
609                "primitive" => {
610                    let name = leaf_text(value);
611                    return Prim::from_name(&name)
612                        .map(Item::Prim)
613                        .ok_or_else(|| Error::MapLogic(format!("unknown primitive {name:?}")));
614                }
615                "function" => {
616                    let name = leaf_text(value);
617                    return match name.as_str() {
618                        "upcase" => Ok(Item::Func(FuncName::Upcase)),
619                        "downcase" => Ok(Item::Func(FuncName::Downcase)),
620                        // title_case/reverse/strip/swapcase as items are
621                        // not usable as `to` targets (Ruby adapter
622                        // raises); they can only appear inside any(...),
623                        // which the corpus does not exercise.
624                        other => Err(Error::MapLogic(format!(
625                            "function item {other:?} is not supported in this position"
626                        ))),
627                    };
628                }
629                "alias" => {
630                    // Transform parity: {identifier:, qualified_name:} is
631                    // a qualified dep alias (map.name); {identifier:}
632                    // alone is a BARE alias name (the Ruby
633                    // `rule(alias: simple(:n))` branch).
634                    return Ok(match value.get("qualified_name") {
635                        Some(q) => Item::Alias {
636                            map: value.get("identifier").map(ident),
637                            name: ident(q),
638                        },
639                        None => Item::Alias {
640                            map: None,
641                            name: match value.get("identifier") {
642                                Some(id) => ident(id),
643                                None => leaf_text(value),
644                            },
645                        },
646                    });
647                }
648                "alias_ref" => {
649                    return Ok(Item::Alias {
650                        map: None,
651                        name: match value.get("identifier") {
652                            Some(id) => ident(id),
653                            None => leaf_text(value),
654                        },
655                    });
656                }
657                "ref" => {
658                    let digit = leaf_text(value);
659                    return Ok(Item::CaptureRef(digit.parse().map_err(|_| {
660                        Error::MapLogic(format!("bad ref({digit:?})"))
661                    })?));
662                }
663                "capture_inner" => {
664                    return Ok(Item::CaptureGroup {
665                        id: 0,
666                        inner: Box::new(self.materialize(value)?),
667                    })
668                }
669                "maybe_inner" => return Ok(Item::Maybe(Box::new(self.materialize(value)?))),
670                "some_inner" => return Ok(Item::Some(Box::new(self.materialize(value)?))),
671                "any" => return self.convert_any_arg(value),
672                "concatenation" => return self.materialize(node),
673                _ => {}
674            }
675        }
676        self.convert_other(node)
677    }
678
679    /// The `any(...)` argument: range | single-string set | list | alias
680    /// (kept as a one-entry set) | nested item.
681    fn convert_any_arg(&self, value: &TNode) -> Result<Item> {
682        if let Some(hash) = value.ruby_array_hash() {
683            let keys: Vec<&str> = hash.iter().map(|(k, _)| k.as_str()).collect();
684            let get = |name: &str| pget(hash, name);
685            if keys.contains(&"lo") && keys.contains(&"hi") {
686                let lo = unquote(get("lo").unwrap());
687                let hi = unquote(get("hi").unwrap());
688                return match (chars_of(&lo).next(), chars_of(&hi).next()) {
689                    (Some(l), Some(h)) => Ok(Item::Range(l, h)),
690                    _ => Err(Error::MapLogic("empty any() range bounds".into())),
691                };
692            }
693            if keys.contains(&"single") {
694                let s = unquote(get("single").unwrap());
695                return Ok(Item::Set(
696                    chars_of(&s).map(|c| Item::Str(c.to_string())).collect(),
697                ));
698            }
699            if keys.contains(&"list") {
700                let items = get("list")
701                    .unwrap()
702                    .ruby_array()
703                    .iter()
704                    .map(|e| self.convert_set_entry(e))
705                    .collect::<Result<Vec<_>>>()?;
706                return Ok(Item::Set(items));
707            }
708            if keys.contains(&"alias_ref") || keys.contains(&"alias") {
709                // A single alias argument keeps set semantics
710                // (Transform rule(any: ...)).
711                let alias = self.convert_set_entry(value)?;
712                return Ok(Item::Set(vec![alias]));
713            }
714            if keys.contains(&"any_item") {
715                return self.convert_any_arg(get("any_item").unwrap());
716            }
717            if keys.contains(&"any_char") {
718                return Ok(Item::Set(vec![Item::Alias {
719                    map: None,
720                    name: "any_character".to_string(),
721                }]));
722            }
723            if keys.contains(&"primitive") {
724                return Ok(Item::Set(vec![self.convert_set_entry(value)?]));
725            }
726            if keys.contains(&"string") {
727                let s = decode_string_value(get("string").unwrap());
728                return Ok(Item::Set(
729                    chars_of(&s).map(|c| Item::Str(c.to_string())).collect(),
730                ));
731            }
732            if keys.contains(&"concatenation") {
733                let item = self.materialize(value)?;
734                return Ok(Item::Set(vec![item]));
735            }
736        }
737        self.convert_set_entry(value)
738    }
739
740    /// Entries of an any([...]) list (NodeAdapter#convert_set).
741    fn convert_set_entry(&self, entry: &TNode) -> Result<Item> {
742        if let TNode::Hash(pairs) = entry {
743            let (key, value) = (&pairs[0].0, &pairs[0].1);
744            match key.as_str() {
745                "string" => return Ok(Item::Str(decode_string_value(value))),
746                "primitive" => {
747                    let name = leaf_text(value);
748                    return Prim::from_name(&name)
749                        .map(Item::Prim)
750                        .ok_or_else(|| Error::MapLogic(format!("unknown primitive {name:?}")));
751                }
752                "alias_ref" | "alias" => return self.convert_class_alias(value),
753                "concatenation" => return self.materialize(entry),
754                "any" => return self.convert_any_arg(value),
755                _ => {}
756            }
757        }
758        self.materialize(entry)
759    }
760
761    /// Alias in CLASS context (NodeAdapter#resolve_charset): stdlib
762    /// -> primitive, imported charset -> Any, local charset alias ->
763    /// Any/String, unresolvable -> vacuous "".
764    fn convert_class_alias(&self, value: &TNode) -> Result<Item> {
765        let (map, name) = match value.get("qualified_name") {
766            Some(q) => (value.get("identifier").map(ident), ident(q)),
767            None => (None, match value.get("identifier") {
768                Some(id) => ident(id),
769                None => leaf_text(value),
770            }),
771        };
772        // Qualified refs resolve through the DEP document's alias (word
773        // context at runtime); NodeAdapter's class context resolves by
774        // bare name only, so keep qualified refs runtime-resolvable.
775        if map.is_some() {
776            return Ok(Item::Alias { map, name });
777        }
778        if let Some(prim) = Prim::from_name(&name) {
779            return Ok(Item::Prim(prim));
780        }
781        // Imported library charset: Any(String) charsets become sets.
782        if let Some(data) = self.imported.get(&name) {
783            return match data {
784                Item::Set(entries) => Ok(Item::Set(entries.clone())),
785                Item::Range(lo, hi) => Ok(Item::Range(*lo, *hi)),
786                Item::Str(s) => Ok(Item::Set(
787                    chars_of(s).map(|c| Item::Str(c.to_string())).collect(),
788                )),
789                _ => Ok(vacuous()),
790            };
791        }
792        // Local alias composed of charset parts (grek_latn_any = ...).
793        // Any-valued conversions qualify — Set, Range and plain Str —
794        // exactly `converted.is_a?(Any) || converted.is_a?(String)` in
795        // NodeAdapter#resolve_charset.
796        if let Some((_, raw)) = self.local_raw.iter().find(|(n, _)| *n == name) {
797            let converted = self.materialize(raw)?;
798            return Ok(match converted {
799                Item::Set(_) | Item::Range(_, _) | Item::Str(_) => converted,
800                _ => vacuous(),
801            });
802        }
803        Ok(vacuous())
804    }
805
806    /// Constraint-position items (class context).
807    fn materialize_class(&self, fragment: &TNode) -> Result<Item> {
808        match fragment {
809            TNode::Hash(_) if fragment.has_key("concatenation") => {
810                let parts = fragment.get("concatenation").unwrap();
811                let items = parts
812                    .ruby_array()
813                    .iter()
814                    .map(|p| self.convert_class_fragment(p))
815                    .collect::<Result<Vec<_>>>()?;
816                Ok(Item::concat_from_parts(items))
817            }
818            TNode::Hash(_) => {
819                let item = self.convert_class_fragment(fragment)?;
820                Ok(Item::concat_from_parts(vec![item]))
821            }
822            TNode::Array(parts) => {
823                let items = parts
824                    .iter()
825                    .map(|p| self.convert_class_fragment(p))
826                    .collect::<Result<Vec<_>>>()?;
827                Ok(Item::concat_from_parts(items))
828            }
829            TNode::Nil => Ok(Item::None),
830            TNode::Str(_) => Ok(Item::Str(fragment.as_str().unwrap_or_default().to_string())),
831        }
832    }
833
834    fn convert_class_fragment(&self, node: &TNode) -> Result<Item> {
835        // Class context only differs for alias refs (charset
836        // resolution); everything else converts identically.
837        if let TNode::Hash(pairs) = node {
838            if let Some((key, value)) = pairs.first() {
839                if key == "alias_ref" || key == "alias" {
840                    return self.convert_class_alias(value);
841                }
842            }
843        }
844        self.convert_fragment(node)
845    }
846
847    fn convert_other(&self, node: &TNode) -> Result<Item> {
848        let _ = self;
849        if let TNode::Hash(pairs) = node {
850            let keys: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
851            let get = |name: &str| pget(pairs, name);
852            if keys.contains(&"lo") && keys.contains(&"hi") {
853                let lo = unquote(get("lo").unwrap());
854                let hi = unquote(get("hi").unwrap());
855                return match (chars_of(&lo).next(), chars_of(&hi).next()) {
856                    (Some(l), Some(h)) => Ok(Item::Range(l, h)),
857                    _ => Err(Error::MapLogic("empty range bounds".into())),
858                };
859            }
860        }
861        Ok(Item::Str(leaf_text(node)))
862    }
863}
864
865// ----------------------------------------------------------------------
866// ISC-WF-1 §4.2 — use/override merge (W2)
867// ----------------------------------------------------------------------
868
869/// Merge on a flat rule list (the collection-context expansion):
870/// partition R by exact from-equality against O; effective expansion =
871/// R_kept (in R's order) followed by O (in O's order). Multiple R
872/// rules sharing an overridden from ALL drop (an override is strictly
873/// stronger, §4.2 rule 1); override rows matching nothing are simply
874/// added (§4.2 rule 4). An override row is complete — it carries its
875/// own to/constraints/when (§4.2 rule 3), so no field merging.
876fn merge_override_rules(rules: Vec<Sub>, overrides: Vec<Sub>) -> Vec<Sub> {
877    let replaced: BTreeSet<String> = overrides.iter().map(|o| o.from_key()).collect();
878    let mut kept: Vec<Sub> = rules
879        .into_iter()
880        .filter(|r| !replaced.contains(&r.from_key()))
881        .collect();
882    kept.extend(overrides);
883    kept
884}
885
886/// Merge over a STRUCTURAL expansion (the stage-position use, §8.1):
887/// matching rules drop from their collections while kept rules stay in
888/// order (§4.2 rules 1-2). The override rows land at the expansion
889/// tail per the amended §4.2.6 (ruby reference PR #834): appended INTO
890/// the final parallel block when the expansion ends with one — so the
891/// child rows share that block's single longest-match pass (§4.2.5) —
892/// otherwise appended as trailing stage-level rows. An expansion with
893/// no rules at all takes O as one trailing sequential item.
894fn merge_override_items(
895    mut items: Vec<StageItem>,
896    overrides: Vec<Sub>,
897) -> Result<Vec<StageItem>> {
898    let replaced: BTreeSet<String> = overrides.iter().map(|o| o.from_key()).collect();
899    for item in items.iter_mut() {
900        if let StageItem::Parallel(list) | StageItem::Sequential(list) = item {
901            list.retain(|r| !replaced.contains(&r.from_key()));
902        }
903    }
904    match items.last_mut() {
905        Some(StageItem::Parallel(list)) => list.extend(overrides),
906        _ => {
907            if !overrides.is_empty() {
908                items.push(StageItem::Sequential(overrides));
909            }
910        }
911    }
912    Ok(items)
913}
914
915/// Flatten a structural expansion into the ordered rule list of an
916/// enclosing pass (`use` inside a parallel/sequence block).
917fn flatten_rules(items: &[StageItem], out: &mut Vec<Sub>) -> Result<()> {
918    for item in items {
919        match item {
920            StageItem::Parallel(list) | StageItem::Sequential(list) => {
921                out.extend(list.iter().cloned());
922            }
923            _ => {
924                return Err(Error::MapLogic(
925                    "use inside a parallel/sequence block: the referenced \
926                     ruleset carries non-rule items (funcalls/stage runs) \
927                     that cannot join the pass"
928                        .into(),
929                ))
930            }
931        }
932    }
933    Ok(())
934}
935
936fn vacuous() -> Item {
937    Item::Str(String::new())
938}
939
940// ----------------------------------------------------------------------
941// Free helpers
942// ----------------------------------------------------------------------
943
944fn is_none_capture(node: Option<&TNode>) -> bool {
945    match node {
946        None => true,
947        Some(TNode::Nil) => true,
948        Some(n) => n.has_key("none"),
949    }
950}
951
952/// Apply the string-decode fold to a `string`-keyed capture value.
953pub fn decode_string_value(value: &TNode) -> String {
954    match value {
955        TNode::Hash(_) => decode_part(value),
956        TNode::Array(parts) => parts.iter().map(decode_part).collect(),
957        TNode::Str(s) => s.clone(),
958        TNode::Nil => String::new(),
959    }
960}
961
962fn decode_part(part: &TNode) -> String {
963    if let Some(pairs) = part.ruby_array_hash() {
964        if let Some((key, value)) = pairs.first() {
965            return match key.as_str() {
966                "run" => leaf_text(value),
967                "newline" => "\n".to_string(),
968                "carriage_return" => "\r".to_string(),
969                "tab" => "\t".to_string(),
970                "dquote" => "\"".to_string(),
971                "backslash" => "\\".to_string(),
972                "u_lone" => "U".to_string(),
973                "unicode" => {
974                    let hex = leaf_text(value);
975                    match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
976                        Some(c) => c.to_string(),
977                        // Ruby rescue fallback: the raw hex text.
978                        None => hex,
979                    }
980                }
981                _ => leaf_text(part),
982            };
983        }
984        return String::new();
985    }
986    match part {
987        TNode::Str(s) => s.clone(),
988        _ => leaf_text(part),
989    }
990}
991
992/// Normalizer.scalar for quoted strings / fragments.
993pub fn unquote(fragment: &TNode) -> String {
994    match fragment {
995        TNode::Nil => String::new(),
996        TNode::Str(s) => s.clone(),
997        TNode::Hash(pairs) => {
998            if let Some((_, v)) = pairs.iter().find(|(k2, _)| k2 == "string") {
999                decode_string_value(v)
1000            } else {
1001                leaf_text(fragment)
1002            }
1003        }
1004        TNode::Array(_) => leaf_text(fragment),
1005    }
1006}
1007
1008/// Normalizer.scalar for identifiers.
1009pub fn ident(fragment: &TNode) -> String {
1010    match fragment {
1011        TNode::Nil => String::new(),
1012        TNode::Str(s) => s.clone(),
1013        _ => match fragment.get("identifier") {
1014            Some(id) => leaf_text(id),
1015            None => leaf_text(fragment),
1016        },
1017    }
1018}
1019
1020fn chars_of(s: &str) -> impl Iterator<Item = char> + '_ {
1021    s.chars()
1022}
1023
1024/// Render a collapsed item as the plain string for funcall kwargs
1025/// (separator values are NodeAdapter StringValue#value).
1026fn render_str(item: &Item) -> String {
1027    match item {
1028        Item::Str(s) => s.clone(),
1029        Item::None => String::new(),
1030        Item::Concat(parts) => parts.iter().map(render_str).collect(),
1031        _ => String::new(),
1032    }
1033}
1034
1035/// Extract `name: "value"` kwarg pairs (DocumentBuilder#extract_kwargs).
1036pub fn extract_kwargs(node: &TNode) -> Vec<(String, String)> {
1037    let mut out = Vec::new();
1038    let entries: Vec<&TNode> = match node.get("kwarg") {
1039        Some(kw) => kw.ruby_array().iter().collect(),
1040        None => vec![node],
1041    };
1042    for entry in entries {
1043        if let (Some(name), Some(value)) = (entry.get("kwarg_name"), entry.get("kwarg_value")) {
1044            out.push((ident(name), unquote(value)));
1045        }
1046    }
1047    out
1048}
1049
1050/// DocumentBuilder#unescape_braces.
1051pub fn unescape_braces(text: &str) -> String {
1052    let mut out = String::with_capacity(text.len());
1053    let mut chars = text.chars();
1054    while let Some(c) = chars.next() {
1055        if c == '\\' {
1056            match chars.next() {
1057                Some(n @ ('{' | '}' | '\\')) => out.push(n),
1058                Some(other) => {
1059                    out.push('\\');
1060                    out.push(other);
1061                }
1062                None => out.push('\\'),
1063            }
1064        } else {
1065            out.push(c);
1066        }
1067    }
1068    out
1069}
1070
1071/// DocumentBuilder#normalize_heredoc: YAML-style dedent with
1072/// paragraph preservation.
1073pub fn normalize_heredoc(text: &str) -> String {
1074    let lines: Vec<&str> = text.lines().collect();
1075    let content: Vec<&&str> = lines
1076        .iter()
1077        .filter(|l| !l.trim().is_empty())
1078        .collect();
1079    if content.is_empty() {
1080        return String::new();
1081    }
1082    if content.len() == 1 {
1083        return content[0].trim().to_string();
1084    }
1085    let min_indent = content
1086        .iter()
1087        .skip(1)
1088        .map(|l| l.len() - l.trim_start_matches([' ', '\t']).len())
1089        .min()
1090        .unwrap_or(0);
1091    let mut result: Vec<String> = Vec::new();
1092    for line in &lines {
1093        if line.trim().is_empty() {
1094            result.push(String::new());
1095        } else {
1096            let indent = line.len() - line.trim_start_matches([' ', '\t']).len();
1097            if indent >= min_indent {
1098                result.push(line[min_indent..].to_string());
1099            } else {
1100                result.push(line.trim().to_string());
1101            }
1102        }
1103    }
1104    let joined = result.join("\n");
1105    let trimmed = joined.trim_end().to_string();
1106    if text.ends_with('\n') && !text.ends_with("\n\n") {
1107        trimmed + "\n"
1108    } else {
1109        trimmed
1110    }
1111}
1112
1113/// Metadata is inert for transliteration; best-effort text extraction.
1114fn extract_metadata(arr: &TNode, out: &mut Vec<(String, String)>) {
1115    for field in arr.ruby_array() {
1116        let Some(hash) = field.ruby_array_hash() else { continue };
1117        if let Some(v) = pget(hash, "field_name") {
1118            let name = ident(v);
1119            let val = if let Some(b) = pget(hash, "field_block") {
1120                normalize_heredoc(&unescape_braces(&leaf_text(b)))
1121            } else if let Some(fv) = pget(hash, "field_value") {
1122                match fv {
1123                    TNode::Hash(_) if fv.has_key("string") => unquote(fv),
1124                    _ => fv.as_str().unwrap_or_default().trim().to_string(),
1125                }
1126            } else {
1127                String::new()
1128            };
1129            out.push((name, val));
1130        } else {
1131            for (key, val) in hash.iter() {
1132                if matches!(val, TNode::Nil) {
1133                    continue;
1134                }
1135                let text = match val {
1136                    TNode::Hash(_) if val.has_key("string") => unquote(val),
1137                    _ => leaf_text(val),
1138                };
1139                out.push((key.clone(), text));
1140            }
1141        }
1142    }
1143}
1144
1145/// Assign 1-based capture group ids in regex order: before,
1146/// not_before, from, not_after, after (the interpreter's lookaround
1147/// assembly order). Returns the total group count.
1148pub fn assign_group_ids(sub: &mut Sub) -> usize {
1149    let mut counter = 0usize;
1150    for item in [&mut sub.before, &mut sub.not_before] {
1151        if let Some(i) = item {
1152            assign_in(i, &mut counter);
1153        }
1154    }
1155    assign_in(&mut sub.from, &mut counter);
1156    for item in [&mut sub.not_after, &mut sub.after] {
1157        if let Some(i) = item {
1158            assign_in(i, &mut counter);
1159        }
1160    }
1161    counter
1162}
1163
1164fn assign_in(item: &mut Item, counter: &mut usize) {
1165    match item {
1166        Item::CaptureGroup { id, inner } => {
1167            *counter += 1;
1168            *id = *counter;
1169            assign_in(inner, counter);
1170        }
1171        Item::Maybe(inner) | Item::Some(inner) => assign_in(inner, counter),
1172        Item::Concat(parts) => parts.iter_mut().for_each(|p| assign_in(p, counter)),
1173        Item::Set(entries) => entries.iter_mut().for_each(|e| assign_in(e, counter)),
1174        _ => {}
1175    }
1176}