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