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
8use crate::document::{Dependency, Document, Stage, StageItem, Sub, SubTo, Tests};
9use crate::error::{Error, Result};
10use crate::item::{FuncName, Item};
11use crate::stdlib::{Funcall, FuncallName, Prim};
12use crate::tree::{leaf_text, pget, TNode};
13use std::collections::BTreeMap;
14use std::rc::Rc;
15
16/// Dependency targets declared by a parsed tree, in order
17/// (`dependency "x" [as alias]`), read before documents are loaded.
18pub fn dependency_targets(tree: &TNode) -> Vec<(String, Option<String>)> {
19    let system = tree.get("system").unwrap_or(tree);
20    let mut out = Vec::new();
21    let Some(body) = system.get("body") else { return out };
22    for block in body.ruby_array() {
23        if let Some(pair) = block.get("target") {
24            let target = unquote(pair);
25            let alias = block.get("alias").map(ident);
26            out.push((target, alias));
27        }
28    }
29    out
30}
31
32pub fn build(
33    tree: &TNode,
34    filename_stem: &str,
35    imports: &[Rc<Document>],
36    dep_docs: &BTreeMap<String, Rc<Document>>,
37) -> Result<Document> {
38    let system = tree.get("system").unwrap_or(tree);
39    let system_code = system.get("system_code").map(unquote).unwrap_or_default();
40
41    let body: Vec<&TNode> = match system.get("body") {
42        Some(b) => b.ruby_array().iter().collect(),
43        None => Vec::new(),
44    };
45
46    // Pass 1: collect raw sections in document order.
47    let mut metadata = Vec::new();
48    let mut alias_nodes: Vec<(String, TNode)> = Vec::new();
49    let mut tests = Tests::default();
50    let mut stage_nodes: Vec<(String, &TNode)> = Vec::new();
51    let mut dependencies: Vec<Dependency> = Vec::new();
52
53    for block in &body {
54        let Some(hash) = block.ruby_array_hash() else { continue };
55        if let Some(fields) = pget(hash, "metadata") {
56            extract_metadata(fields, &mut metadata);
57        } else if let Some(arr) = pget(hash, "aliases") {
58            for a in arr.ruby_array() {
59                if let Some(name_node) = a.get("name") {
60                    let name = ident(name_node);
61                    let value = a.get("value").cloned().unwrap_or(TNode::Nil);
62                    alias_nodes.push((name, value));
63                }
64            }
65        } else if let Some(arr) = pget(hash, "tests") {
66            for t in arr.ruby_array() {
67                let input = t.get("input").map(unquote).unwrap_or_default();
68                let expected = t.get("expected").map(unquote).unwrap_or_default();
69                tests.data.push((input, expected));
70            }
71        } else if let (Some(name_node), Some(stage_node)) = (pget(hash, "stage_name"), pget(hash, "stage")) {
72            stage_nodes.push((ident(name_node), stage_node));
73        } else if let Some(t) = pget(hash, "target") {
74            let target = unquote(t);
75            let alias = pget(hash, "alias").map(ident);
76            dependencies.push(Dependency { target, alias });
77        }
78    }
79
80    // Conversion context: local raws + imported alias items
81    // (NodeAdapter @local_raw / @imported).
82    let ctx = Ctx {
83        imported: {
84            let mut m = BTreeMap::new();
85            for doc in imports {
86                for (k, v) in &doc.aliases {
87                    m.entry(k.clone()).or_insert_with(|| v.clone());
88                }
89            }
90            m
91        },
92        local_raw: alias_nodes.clone(),
93        dep_docs,
94    };
95
96    // Pass 2: convert (aliases first — stages may reference them).
97    let mut aliases = BTreeMap::new();
98    for (name, value) in &alias_nodes {
99        aliases.insert(name.clone(), ctx.materialize(value)?);
100    }
101
102    let mut stages = BTreeMap::new();
103    for (name, node) in stage_nodes {
104        let stage = ctx.extract_stage(&name, node)?;
105        stages.insert(stage.name.clone(), stage);
106    }
107
108    Ok(Document {
109        name: filename_stem.to_string(),
110        system_code,
111        aliases,
112        stages,
113        dep_aliases: dep_docs
114        .iter()
115        .filter_map(|(k, v)| {
116            dependencies
117                .iter()
118                .find(|d| d.alias.as_deref() == Some(k.as_str()))
119                .map(|_| (k.clone(), v.clone()))
120        })
121        .collect(),
122        imports: imports.to_vec(),
123        tests,
124        metadata,
125    })
126}
127
128struct Ctx<'a> {
129    /// Imported library aliases (import deps' own aliases merged).
130    imported: BTreeMap<String, Item>,
131    /// Local alias raw nodes.
132    local_raw: Vec<(String, TNode)>,
133    /// Aliased dependency documents (qualified-alias expansion).
134    dep_docs: &'a BTreeMap<String, Rc<Document>>,
135}
136
137impl<'a> Ctx<'a> {
138    // ------------------------------------------------------------------
139    // Stage extraction
140    // ------------------------------------------------------------------
141
142    fn extract_stage(&self, name: &str, node: &TNode) -> Result<Stage> {
143        let mut items: Vec<StageItem> = Vec::new();
144        for entry in node.ruby_array() {
145            let Some(hash) = entry.ruby_array_hash() else { continue };
146            let keys: Vec<&str> = hash.iter().map(|(k, _)| k.as_str()).collect();
147            if keys.contains(&"sequence") {
148                let node = pget(hash, "sequence").unwrap();
149                items.push(StageItem::Sequential(self.extract_rule_list(node)?));
150            } else if keys.contains(&"parallel") {
151                let node = pget(hash, "parallel").unwrap();
152                items.push(StageItem::Parallel(self.extract_rule_list(node)?));
153            } else if keys.contains(&"separate") {
154                let separator = pget(hash, "separator")
155                    .map(|v| self.materialize(v))
156                    .transpose()?
157                    .map(|item| render_str(&item.first_string()));
158                items.push(StageItem::Funcall(Funcall {
159                    name: FuncallName::Separate,
160                    kwargs: separator
161                        .map(|s| vec![("separator".to_string(), s)])
162                        .unwrap_or_default(),
163                    reverse_run: None,
164                }));
165            } else if keys.contains(&"compose") {
166                let op = leaf_text(pget(hash, "compose").unwrap());
167                let name = if op == "decompose" {
168                    FuncallName::Decompose
169                } else {
170                    FuncallName::Compose
171                };
172                items.push(StageItem::Funcall(Funcall { name, kwargs: vec![], reverse_run: None }));
173            } else if keys.contains(&"case") {
174                let op = leaf_text(pget(hash, "case").unwrap());
175                let name = match op.as_str() {
176                    "upcase" => FuncallName::Upcase,
177                    "downcase" => FuncallName::Downcase,
178                    "title_case" => FuncallName::TitleCase,
179                    other => return Err(Error::MapLogic(format!("unknown case op {other:?}"))),
180                };
181                let kwargs = pget(hash, "case_kwargs").map(extract_kwargs).unwrap_or_default();
182                items.push(StageItem::Funcall(Funcall { name, kwargs, reverse_run: None }));
183            } else if keys.contains(&"funcall_name") {
184                let fname = leaf_text(pget(hash, "funcall_name").unwrap());
185                let kwargs = pget(hash, "funcall_kwargs").map(extract_kwargs).unwrap_or_default();
186                let fname_enum = FuncallName::from_name(&fname)
187                    .ok_or_else(|| crate::stdlib::unsupported_funcall(&fname))?;
188                items.push(StageItem::Funcall(Funcall { name: fname_enum, kwargs, reverse_run: None }));
189            } else if keys.contains(&"dep") {
190                let dep = ident(pget(hash, "dep").unwrap());
191                let stage = ident(pget(hash, "stage").unwrap());
192                items.push(StageItem::Run {
193                    dep: Some(dep),
194                    stage,
195                    reverse_run: None,
196                });
197            } else if keys.contains(&"run_stage_only") {
198                let stage = pget(hash, "run_stage_only")
199                    .and_then(|v| v.get("stage"))
200                    .map(ident)
201                    .unwrap_or_default();
202                items.push(StageItem::Run { dep: None, stage, reverse_run: None });
203            } else if keys.contains(&"bare_rule") {
204                let rule = pget(hash, "bare_rule").unwrap();
205                items.push(StageItem::Sequential(vec![self.extract_rule(rule)?]));
206            }
207            // comment / noop entries: skipped
208        }
209        Ok(Stage { name: name.to_string(), items, reverse_run: None })
210    }
211
212    fn extract_rule_list(&self, node: &TNode) -> Result<Vec<Sub>> {
213        let mut rules = Vec::new();
214        for r in node.ruby_array() {
215            // Noop filter: from none + to none (comment-only blocks).
216            if is_none_capture(r.get("from")) && is_none_capture(r.get("to")) {
217                continue;
218            }
219            rules.push(self.extract_rule(r)?);
220        }
221        Ok(rules)
222    }
223
224    fn extract_rule(&self, r: &TNode) -> Result<Sub> {
225        let from = match r.get("from") {
226            Some(n) => self.materialize(n)?,
227            None => Item::None,
228        };
229        let to = match r.get("to") {
230            Some(n) => self.materialize(n)?,
231            None => Item::None,
232        };
233
234        let mut before = None;
235        let mut not_before = None;
236        let mut after = None;
237        let mut not_after = None;
238
239        if let Some(constraints) = r.get("constraints") {
240            for c in constraints.ruby_array() {
241                let Some(hash) = c.ruby_array_hash() else { continue };
242                for (kind, item_node) in hash.iter() {
243                    let target = match kind.as_str() {
244                        "before" => &mut before,
245                        "not_before" => &mut not_before,
246                        "after" => &mut after,
247                        "not_after" => &mut not_after,
248                        _ => continue,
249                    };
250                    // Class context: charset aliases resolve to
251                    // character classes (NodeAdapter ctx=:class).
252                    *target = Some(self.materialize_class(item_node)?);
253                }
254            }
255        }
256
257        // `to`: only upcase/downcase functions become SubTo targets
258        // (Sub#initialize); other functions crash the Ruby adapter.
259        let to_target = match &to {
260            Item::Func(FuncName::Upcase) => SubTo::Upcase,
261            Item::Func(FuncName::Downcase) => SubTo::Downcase,
262            item => SubTo::Item(item.clone()),
263        };
264
265        let mut sub = Sub {
266            from,
267            to: to_target,
268            before,
269            not_before,
270            after,
271            not_after,
272            group_count: 0,
273            reverse_run: None,
274        };
275        // Alias expansion (Ruby's build_item resolves aliases inline,
276        // so capture-group numbers follow EXPANSION order): expand
277        // before/not_before/from/not_after/after in regex order, then
278        // number groups on the expanded trees.
279        sub.from = self.expand_aliases(&sub.from, 0)?;
280        for c in [&mut sub.before, &mut sub.not_before] {
281            if let Some(i) = c {
282                *i = self.expand_aliases(i, 0)?;
283            }
284        }
285        // from already expanded; do the after-side constraints.
286        for c in [&mut sub.not_after, &mut sub.after] {
287            if let Some(i) = c {
288                *i = self.expand_aliases(i, 0)?;
289            }
290        }
291        sub.group_count = assign_group_ids(&mut sub);
292        Ok(sub)
293    }
294
295    /// Deep-clone `item` with bare/qualified alias references replaced
296    /// by their resolved value trees (match-time resolve_alias
297    /// semantics: dep-map aliases first, stdlib primitives kept as
298    /// Prims, then imported/local aliases). Depth-limited against
299    /// alias cycles.
300    fn expand_aliases(&self, item: &Item, depth: usize) -> Result<Item> {
301        if depth > 32 {
302            return Err(Error::MapLogic(
303                "alias expansion too deep (alias cycle?)".into(),
304            ));
305        }
306        Ok(match item {
307            Item::Alias { map, name } => {
308                if map.is_some() {
309                    // Qualified: expand via the dep document's aliases.
310                    let alias = map.clone().unwrap();
311                    let doc = self
312                        .dep_docs
313                        .get(&alias)
314                        .ok_or_else(|| Error::MapLogic(format!("dep alias {alias:?} not found")))?;
315                    match doc.lookup_alias(name) {
316                        Some(resolved) => self.expand_aliases(&resolved, depth + 1)?,
317                        None => {
318                            return Err(Error::MapLogic(format!(
319                                "alias {name:?} of dep {alias:?} not found"
320                            )))
321                        }
322                    }
323                } else if let Some(prim) = Prim::from_name(name) {
324                    Item::Prim(prim)
325                } else if let Some(data) = self.imported.get(name) {
326                    self.expand_aliases(data, depth + 1)?
327                } else if let Some((_, raw)) = self
328                    .local_raw
329                    .iter()
330                    .find(|(n, _)| n.as_str() == name)
331                {
332                    let resolved = self.materialize(raw)?;
333                    self.expand_aliases(&resolved, depth + 1)?
334                } else {
335                    return Err(Error::MapLogic(format!(
336                        "alias {name:?} not found during expansion"
337                    )));
338                }
339            }
340            Item::CaptureGroup { id, inner } => Item::CaptureGroup {
341                id: *id,
342                inner: Box::new(self.expand_aliases(inner, depth)?),
343            },
344            Item::Maybe(inner) => Item::Maybe(Box::new(self.expand_aliases(inner, depth)?)),
345            Item::Some(inner) => Item::Some(Box::new(self.expand_aliases(inner, depth)?)),
346            Item::Concat(parts) => Item::Concat(
347                parts
348                    .iter()
349                    .map(|p| self.expand_aliases(p, depth))
350                    .collect::<Result<Vec<_>>>()?,
351            ),
352            Item::Set(entries) => Item::Set(
353                entries
354                    .iter()
355                    .map(|p| self.expand_set_entry(p, depth))
356                    .collect::<Result<Vec<_>>>()?,
357            ),
358            other => other.clone(),
359        })
360    }
361
362    /// Set entries expand too, but a set-of-characters stays compact:
363    /// entries that resolve to Sets/Strs keep their charset semantics.
364    fn expand_set_entry(&self, entry: &Item, depth: usize) -> Result<Item> {
365        match entry {
366            Item::Alias { .. } => self.expand_aliases(entry, depth),
367            Item::Concat(parts) => Ok(Item::Concat(
368                parts
369                    .iter()
370                    .map(|p| self.expand_set_entry(p, depth))
371                    .collect::<Result<Vec<_>>>()?,
372            )),
373            Item::Set(entries) => Ok(Item::Set(
374                entries
375                    .iter()
376                    .map(|p| self.expand_set_entry(p, depth))
377                    .collect::<Result<Vec<_>>>()?,
378            )),
379            Item::Maybe(inner) => Ok(Item::Maybe(Box::new(self.expand_set_entry(inner, depth)?))),
380            Item::Some(inner) => Ok(Item::Some(Box::new(self.expand_set_entry(inner, depth)?))),
381            other => Ok(other.clone()),
382        }
383    }
384
385    // ------------------------------------------------------------------
386    // Item materialization (Transform rules)
387    // ------------------------------------------------------------------
388
389    fn materialize(&self, fragment: &TNode) -> Result<Item> {
390        match fragment {
391            TNode::Hash(_) => {
392                if fragment.has_key("concatenation") {
393                    let parts = fragment.get("concatenation").unwrap();
394                    let items = parts
395                        .ruby_array()
396                        .iter()
397                        .map(|p| self.convert_fragment(p))
398                        .collect::<Result<Vec<_>>>()?;
399                    Ok(Item::concat_from_parts(items))
400                } else {
401                    let item = self.convert_fragment(fragment)?;
402                    Ok(Item::concat_from_parts(vec![item]))
403                }
404            }
405            TNode::Array(parts) => {
406                let items = parts
407                    .iter()
408                    .map(|p| self.convert_fragment(p))
409                    .collect::<Result<Vec<_>>>()?;
410                Ok(Item::concat_from_parts(items))
411            }
412            TNode::Nil => Ok(Item::None),
413            TNode::Str(_) => Ok(Item::Str(fragment.as_str().unwrap_or_default().to_string())),
414        }
415    }
416
417    /// Transform one keyed capture fragment into an Item.
418    fn convert_fragment(&self, node: &TNode) -> Result<Item> {
419        if let TNode::Hash(pairs) = node {
420            let (key, value) = (&pairs[0].0, &pairs[0].1);
421            match key.as_str() {
422                "string" => return Ok(Item::Str(decode_string_value(value))),
423                "none" => return Ok(Item::None),
424                "any_char" => {
425                    return Ok(Item::Alias {
426                        map: None,
427                        name: "any_character".to_string(),
428                    })
429                }
430                "primitive" => {
431                    let name = leaf_text(value);
432                    return Prim::from_name(&name)
433                        .map(Item::Prim)
434                        .ok_or_else(|| Error::MapLogic(format!("unknown primitive {name:?}")));
435                }
436                "function" => {
437                    let name = leaf_text(value);
438                    return match name.as_str() {
439                        "upcase" => Ok(Item::Func(FuncName::Upcase)),
440                        "downcase" => Ok(Item::Func(FuncName::Downcase)),
441                        // title_case/reverse/strip/swapcase as items are
442                        // not usable as `to` targets (Ruby adapter
443                        // raises); they can only appear inside any(...),
444                        // which the corpus does not exercise.
445                        other => Err(Error::MapLogic(format!(
446                            "function item {other:?} is not supported in this position"
447                        ))),
448                    };
449                }
450                "alias" => {
451                    // Transform parity: {identifier:, qualified_name:} is
452                    // a qualified dep alias (map.name); {identifier:}
453                    // alone is a BARE alias name (the Ruby
454                    // `rule(alias: simple(:n))` branch).
455                    return Ok(match value.get("qualified_name") {
456                        Some(q) => Item::Alias {
457                            map: value.get("identifier").map(ident),
458                            name: ident(q),
459                        },
460                        None => Item::Alias {
461                            map: None,
462                            name: match value.get("identifier") {
463                                Some(id) => ident(id),
464                                None => leaf_text(value),
465                            },
466                        },
467                    });
468                }
469                "alias_ref" => {
470                    return Ok(Item::Alias {
471                        map: None,
472                        name: match value.get("identifier") {
473                            Some(id) => ident(id),
474                            None => leaf_text(value),
475                        },
476                    });
477                }
478                "ref" => {
479                    let digit = leaf_text(value);
480                    return Ok(Item::CaptureRef(digit.parse().map_err(|_| {
481                        Error::MapLogic(format!("bad ref({digit:?})"))
482                    })?));
483                }
484                "capture_inner" => {
485                    return Ok(Item::CaptureGroup {
486                        id: 0,
487                        inner: Box::new(self.materialize(value)?),
488                    })
489                }
490                "maybe_inner" => return Ok(Item::Maybe(Box::new(self.materialize(value)?))),
491                "some_inner" => return Ok(Item::Some(Box::new(self.materialize(value)?))),
492                "any" => return self.convert_any_arg(value),
493                "concatenation" => return self.materialize(node),
494                _ => {}
495            }
496        }
497        self.convert_other(node)
498    }
499
500    /// The `any(...)` argument: range | single-string set | list | alias
501    /// (kept as a one-entry set) | nested item.
502    fn convert_any_arg(&self, value: &TNode) -> Result<Item> {
503        if let Some(hash) = value.ruby_array_hash() {
504            let keys: Vec<&str> = hash.iter().map(|(k, _)| k.as_str()).collect();
505            let get = |name: &str| pget(hash, name);
506            if keys.contains(&"lo") && keys.contains(&"hi") {
507                let lo = unquote(get("lo").unwrap());
508                let hi = unquote(get("hi").unwrap());
509                return match (chars_of(&lo).next(), chars_of(&hi).next()) {
510                    (Some(l), Some(h)) => Ok(Item::Range(l, h)),
511                    _ => Err(Error::MapLogic("empty any() range bounds".into())),
512                };
513            }
514            if keys.contains(&"single") {
515                let s = unquote(get("single").unwrap());
516                return Ok(Item::Set(
517                    chars_of(&s).map(|c| Item::Str(c.to_string())).collect(),
518                ));
519            }
520            if keys.contains(&"list") {
521                let items = get("list")
522                    .unwrap()
523                    .ruby_array()
524                    .iter()
525                    .map(|e| self.convert_set_entry(e))
526                    .collect::<Result<Vec<_>>>()?;
527                return Ok(Item::Set(items));
528            }
529            if keys.contains(&"alias_ref") || keys.contains(&"alias") {
530                // A single alias argument keeps set semantics
531                // (Transform rule(any: ...)).
532                let alias = self.convert_set_entry(value)?;
533                return Ok(Item::Set(vec![alias]));
534            }
535            if keys.contains(&"any_item") {
536                return self.convert_any_arg(get("any_item").unwrap());
537            }
538            if keys.contains(&"any_char") {
539                return Ok(Item::Set(vec![Item::Alias {
540                    map: None,
541                    name: "any_character".to_string(),
542                }]));
543            }
544            if keys.contains(&"primitive") {
545                return Ok(Item::Set(vec![self.convert_set_entry(value)?]));
546            }
547            if keys.contains(&"string") {
548                let s = decode_string_value(get("string").unwrap());
549                return Ok(Item::Set(
550                    chars_of(&s).map(|c| Item::Str(c.to_string())).collect(),
551                ));
552            }
553            if keys.contains(&"concatenation") {
554                let item = self.materialize(value)?;
555                return Ok(Item::Set(vec![item]));
556            }
557        }
558        self.convert_set_entry(value)
559    }
560
561    /// Entries of an any([...]) list (NodeAdapter#convert_set).
562    fn convert_set_entry(&self, entry: &TNode) -> Result<Item> {
563        if let TNode::Hash(pairs) = entry {
564            let (key, value) = (&pairs[0].0, &pairs[0].1);
565            match key.as_str() {
566                "string" => return Ok(Item::Str(decode_string_value(value))),
567                "primitive" => {
568                    let name = leaf_text(value);
569                    return Prim::from_name(&name)
570                        .map(Item::Prim)
571                        .ok_or_else(|| Error::MapLogic(format!("unknown primitive {name:?}")));
572                }
573                "alias_ref" | "alias" => return self.convert_class_alias(value),
574                "concatenation" => return self.materialize(entry),
575                "any" => return self.convert_any_arg(value),
576                _ => {}
577            }
578        }
579        self.materialize(entry)
580    }
581
582    /// Alias in CLASS context (NodeAdapter#resolve_charset): stdlib
583    /// -> primitive, imported charset -> Any, local charset alias ->
584    /// Any/String, unresolvable -> vacuous "".
585    fn convert_class_alias(&self, value: &TNode) -> Result<Item> {
586        let (map, name) = match value.get("qualified_name") {
587            Some(q) => (value.get("identifier").map(ident), ident(q)),
588            None => (None, match value.get("identifier") {
589                Some(id) => ident(id),
590                None => leaf_text(value),
591            }),
592        };
593        // Qualified refs resolve through the DEP document's alias (word
594        // context at runtime); NodeAdapter's class context resolves by
595        // bare name only, so keep qualified refs runtime-resolvable.
596        if map.is_some() {
597            return Ok(Item::Alias { map, name });
598        }
599        if let Some(prim) = Prim::from_name(&name) {
600            return Ok(Item::Prim(prim));
601        }
602        // Imported library charset: Any(String) charsets become sets.
603        if let Some(data) = self.imported.get(&name) {
604            return match data {
605                Item::Set(entries) => Ok(Item::Set(entries.clone())),
606                Item::Range(lo, hi) => Ok(Item::Range(*lo, *hi)),
607                Item::Str(s) => Ok(Item::Set(
608                    chars_of(s).map(|c| Item::Str(c.to_string())).collect(),
609                )),
610                _ => Ok(vacuous()),
611            };
612        }
613        // Local alias composed of charset parts (grek_latn_any = ...).
614        // Any-valued conversions qualify — Set, Range and plain Str —
615        // exactly `converted.is_a?(Any) || converted.is_a?(String)` in
616        // NodeAdapter#resolve_charset.
617        if let Some((_, raw)) = self.local_raw.iter().find(|(n, _)| *n == name) {
618            let converted = self.materialize(raw)?;
619            return Ok(match converted {
620                Item::Set(_) | Item::Range(_, _) | Item::Str(_) => converted,
621                _ => vacuous(),
622            });
623        }
624        Ok(vacuous())
625    }
626
627    /// Constraint-position items (class context).
628    fn materialize_class(&self, fragment: &TNode) -> Result<Item> {
629        match fragment {
630            TNode::Hash(_) if fragment.has_key("concatenation") => {
631                let parts = fragment.get("concatenation").unwrap();
632                let items = parts
633                    .ruby_array()
634                    .iter()
635                    .map(|p| self.convert_class_fragment(p))
636                    .collect::<Result<Vec<_>>>()?;
637                Ok(Item::concat_from_parts(items))
638            }
639            TNode::Hash(_) => {
640                let item = self.convert_class_fragment(fragment)?;
641                Ok(Item::concat_from_parts(vec![item]))
642            }
643            TNode::Array(parts) => {
644                let items = parts
645                    .iter()
646                    .map(|p| self.convert_class_fragment(p))
647                    .collect::<Result<Vec<_>>>()?;
648                Ok(Item::concat_from_parts(items))
649            }
650            TNode::Nil => Ok(Item::None),
651            TNode::Str(_) => Ok(Item::Str(fragment.as_str().unwrap_or_default().to_string())),
652        }
653    }
654
655    fn convert_class_fragment(&self, node: &TNode) -> Result<Item> {
656        // Class context only differs for alias refs (charset
657        // resolution); everything else converts identically.
658        if let TNode::Hash(pairs) = node {
659            if let Some((key, value)) = pairs.first() {
660                if key == "alias_ref" || key == "alias" {
661                    return self.convert_class_alias(value);
662                }
663            }
664        }
665        self.convert_fragment(node)
666    }
667
668    fn convert_other(&self, node: &TNode) -> Result<Item> {
669        let _ = self;
670        if let TNode::Hash(pairs) = node {
671            let keys: Vec<&str> = pairs.iter().map(|(k, _)| k.as_str()).collect();
672            let get = |name: &str| pget(pairs, name);
673            if keys.contains(&"lo") && keys.contains(&"hi") {
674                let lo = unquote(get("lo").unwrap());
675                let hi = unquote(get("hi").unwrap());
676                return match (chars_of(&lo).next(), chars_of(&hi).next()) {
677                    (Some(l), Some(h)) => Ok(Item::Range(l, h)),
678                    _ => Err(Error::MapLogic("empty range bounds".into())),
679                };
680            }
681        }
682        Ok(Item::Str(leaf_text(node)))
683    }
684}
685
686fn vacuous() -> Item {
687    Item::Str(String::new())
688}
689
690// ----------------------------------------------------------------------
691// Free helpers
692// ----------------------------------------------------------------------
693
694fn is_none_capture(node: Option<&TNode>) -> bool {
695    match node {
696        None => true,
697        Some(TNode::Nil) => true,
698        Some(n) => n.has_key("none"),
699    }
700}
701
702/// Apply the string-decode fold to a `string`-keyed capture value.
703pub fn decode_string_value(value: &TNode) -> String {
704    match value {
705        TNode::Hash(_) => decode_part(value),
706        TNode::Array(parts) => parts.iter().map(decode_part).collect(),
707        TNode::Str(s) => s.clone(),
708        TNode::Nil => String::new(),
709    }
710}
711
712fn decode_part(part: &TNode) -> String {
713    if let Some(pairs) = part.ruby_array_hash() {
714        if let Some((key, value)) = pairs.first() {
715            return match key.as_str() {
716                "run" => leaf_text(value),
717                "newline" => "\n".to_string(),
718                "carriage_return" => "\r".to_string(),
719                "tab" => "\t".to_string(),
720                "dquote" => "\"".to_string(),
721                "backslash" => "\\".to_string(),
722                "u_lone" => "U".to_string(),
723                "unicode" => {
724                    let hex = leaf_text(value);
725                    match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
726                        Some(c) => c.to_string(),
727                        // Ruby rescue fallback: the raw hex text.
728                        None => hex,
729                    }
730                }
731                _ => leaf_text(part),
732            };
733        }
734        return String::new();
735    }
736    match part {
737        TNode::Str(s) => s.clone(),
738        _ => leaf_text(part),
739    }
740}
741
742/// Normalizer.scalar for quoted strings / fragments.
743pub fn unquote(fragment: &TNode) -> String {
744    match fragment {
745        TNode::Nil => String::new(),
746        TNode::Str(s) => s.clone(),
747        TNode::Hash(pairs) => {
748            if let Some((_, v)) = pairs.iter().find(|(k2, _)| k2 == "string") {
749                decode_string_value(v)
750            } else {
751                leaf_text(fragment)
752            }
753        }
754        TNode::Array(_) => leaf_text(fragment),
755    }
756}
757
758/// Normalizer.scalar for identifiers.
759pub fn ident(fragment: &TNode) -> String {
760    match fragment {
761        TNode::Nil => String::new(),
762        TNode::Str(s) => s.clone(),
763        _ => match fragment.get("identifier") {
764            Some(id) => leaf_text(id),
765            None => leaf_text(fragment),
766        },
767    }
768}
769
770fn chars_of(s: &str) -> impl Iterator<Item = char> + '_ {
771    s.chars()
772}
773
774/// Render a collapsed item as the plain string for funcall kwargs
775/// (separator values are NodeAdapter StringValue#value).
776fn render_str(item: &Item) -> String {
777    match item {
778        Item::Str(s) => s.clone(),
779        Item::None => String::new(),
780        Item::Concat(parts) => parts.iter().map(render_str).collect(),
781        _ => String::new(),
782    }
783}
784
785/// Extract `name: "value"` kwarg pairs (DocumentBuilder#extract_kwargs).
786pub fn extract_kwargs(node: &TNode) -> Vec<(String, String)> {
787    let mut out = Vec::new();
788    let entries: Vec<&TNode> = match node.get("kwarg") {
789        Some(kw) => kw.ruby_array().iter().collect(),
790        None => vec![node],
791    };
792    for entry in entries {
793        if let (Some(name), Some(value)) = (entry.get("kwarg_name"), entry.get("kwarg_value")) {
794            out.push((ident(name), unquote(value)));
795        }
796    }
797    out
798}
799
800/// DocumentBuilder#unescape_braces.
801pub fn unescape_braces(text: &str) -> String {
802    let mut out = String::with_capacity(text.len());
803    let mut chars = text.chars();
804    while let Some(c) = chars.next() {
805        if c == '\\' {
806            match chars.next() {
807                Some(n @ ('{' | '}' | '\\')) => out.push(n),
808                Some(other) => {
809                    out.push('\\');
810                    out.push(other);
811                }
812                None => out.push('\\'),
813            }
814        } else {
815            out.push(c);
816        }
817    }
818    out
819}
820
821/// DocumentBuilder#normalize_heredoc: YAML-style dedent with
822/// paragraph preservation.
823pub fn normalize_heredoc(text: &str) -> String {
824    let lines: Vec<&str> = text.lines().collect();
825    let content: Vec<&&str> = lines
826        .iter()
827        .filter(|l| !l.trim().is_empty())
828        .collect();
829    if content.is_empty() {
830        return String::new();
831    }
832    if content.len() == 1 {
833        return content[0].trim().to_string();
834    }
835    let min_indent = content
836        .iter()
837        .skip(1)
838        .map(|l| l.len() - l.trim_start_matches([' ', '\t']).len())
839        .min()
840        .unwrap_or(0);
841    let mut result: Vec<String> = Vec::new();
842    for line in &lines {
843        if line.trim().is_empty() {
844            result.push(String::new());
845        } else {
846            let indent = line.len() - line.trim_start_matches([' ', '\t']).len();
847            if indent >= min_indent {
848                result.push(line[min_indent..].to_string());
849            } else {
850                result.push(line.trim().to_string());
851            }
852        }
853    }
854    let joined = result.join("\n");
855    let trimmed = joined.trim_end().to_string();
856    if text.ends_with('\n') && !text.ends_with("\n\n") {
857        trimmed + "\n"
858    } else {
859        trimmed
860    }
861}
862
863/// Metadata is inert for transliteration; best-effort text extraction.
864fn extract_metadata(arr: &TNode, out: &mut Vec<(String, String)>) {
865    for field in arr.ruby_array() {
866        let Some(hash) = field.ruby_array_hash() else { continue };
867        if let Some(v) = pget(hash, "field_name") {
868            let name = ident(v);
869            let val = if let Some(b) = pget(hash, "field_block") {
870                normalize_heredoc(&unescape_braces(&leaf_text(b)))
871            } else if let Some(fv) = pget(hash, "field_value") {
872                match fv {
873                    TNode::Hash(_) if fv.has_key("string") => unquote(fv),
874                    _ => fv.as_str().unwrap_or_default().trim().to_string(),
875                }
876            } else {
877                String::new()
878            };
879            out.push((name, val));
880        } else {
881            for (key, val) in hash.iter() {
882                if matches!(val, TNode::Nil) {
883                    continue;
884                }
885                let text = match val {
886                    TNode::Hash(_) if val.has_key("string") => unquote(val),
887                    _ => leaf_text(val),
888                };
889                out.push((key.clone(), text));
890            }
891        }
892    }
893}
894
895/// Assign 1-based capture group ids in regex order: before,
896/// not_before, from, not_after, after (the interpreter's lookaround
897/// assembly order). Returns the total group count.
898pub fn assign_group_ids(sub: &mut Sub) -> usize {
899    let mut counter = 0usize;
900    for item in [&mut sub.before, &mut sub.not_before] {
901        if let Some(i) = item {
902            assign_in(i, &mut counter);
903        }
904    }
905    assign_in(&mut sub.from, &mut counter);
906    for item in [&mut sub.not_after, &mut sub.after] {
907        if let Some(i) = item {
908            assign_in(i, &mut counter);
909        }
910    }
911    counter
912}
913
914fn assign_in(item: &mut Item, counter: &mut usize) {
915    match item {
916        Item::CaptureGroup { id, inner } => {
917            *counter += 1;
918            *id = *counter;
919            assign_in(inner, counter);
920        }
921        Item::Maybe(inner) | Item::Some(inner) => assign_in(inner, counter),
922        Item::Concat(parts) => parts.iter_mut().for_each(|p| assign_in(p, counter)),
923        Item::Set(entries) => entries.iter_mut().for_each(|e| assign_in(e, counter)),
924        _ => {}
925    }
926}