Skip to main content

gdscript_scene/
parse.rs

1//! The `.tscn`/`.tres` text parser (Phase-4 M0) — a wasm-clean, never-panic, byte-offset-tracking
2//! scanner that produces a [`SceneModel`].
3//!
4//! Strategy (per `PHASE-4-M0-PLAYBOOK.md` §4): detect a binary resource and degrade; then a
5//! **two-pass** scan — pass 1 sectionizes with a Variant-aware header lexer + a lossless multiline
6//! value-skipper, pass 2 builds the node tree. The parser **never errors**: every malformed/unknown
7//! form becomes a [`SceneProblem`] and the model degrades to the engine's `Node`-everywhere floor.
8//!
9//! Only ASCII bytes are structurally significant (`[ ] { } ( ) " = ; # & @ /` + newlines); UTF-8
10//! multibyte sequences in names/values are all `>= 0x80` and never collide with a delimiter, so the
11//! byte scan is safe and every slice boundary lands on a char boundary (it's an ASCII delimiter).
12
13use gdscript_base::TextRange;
14use rustc_hash::{FxHashMap, FxHashSet};
15use smol_str::SmolStr;
16
17use crate::model::{
18    ExtId, ExtResource, NodeIdx, NodeProp, SceneConnection, SceneKind, SceneModel, SceneNode,
19    SceneProblem, SubResource,
20};
21
22/// Parse `.tscn`/`.tres` text into a [`SceneModel`]. Pure, never panics, never returns `Err`.
23#[must_use]
24pub fn parse_scene(text: &str) -> SceneModel {
25    if binary_magic(text) {
26        let mut m = SceneModel::empty(SceneKind::Scene);
27        m.problems.push(SceneProblem::BinaryResource);
28        return m;
29    }
30    let mut p = Parser::new(text);
31    p.run();
32    p.build_tree();
33    p.model
34}
35
36/// Whether the (whitespace-skipped) head is a binary resource magic (`RSRC`/`RSCC`).
37fn binary_magic(text: &str) -> bool {
38    let b = text.as_bytes();
39    let mut i = 0;
40    while i < b.len() && matches!(b[i], b' ' | b'\t' | b'\r' | b'\n') {
41        i += 1;
42    }
43    let rest = &b[i..];
44    rest.starts_with(b"RSRC") || rest.starts_with(b"RSCC")
45}
46
47/// A byte range `[start, end)` into the source.
48type Span = (usize, usize);
49
50/// The header attributes we recognize (raw value byte-ranges; interpreted at dispatch).
51#[derive(Default)]
52struct HeaderAttrs {
53    name: Option<Span>,
54    typ: Option<Span>,
55    parent: Option<Span>,
56    instance: Option<Span>,
57    instance_placeholder: Option<Span>,
58    format: Option<Span>,
59    uid: Option<Span>,
60    script_class: Option<Span>,
61    id: Option<Span>,
62    path: Option<Span>,
63    signal: Option<Span>,
64    from: Option<Span>,
65    to: Option<Span>,
66    method: Option<Span>,
67}
68
69impl HeaderAttrs {
70    fn set(&mut self, key: &str, value: Span) {
71        let slot = match key {
72            "name" => &mut self.name,
73            "type" => &mut self.typ,
74            "parent" => &mut self.parent,
75            "instance" => &mut self.instance,
76            "instance_placeholder" => &mut self.instance_placeholder,
77            "format" => &mut self.format,
78            "uid" => &mut self.uid,
79            "script_class" => &mut self.script_class,
80            "id" => &mut self.id,
81            "path" => &mut self.path,
82            "signal" => &mut self.signal,
83            "from" => &mut self.from,
84            "to" => &mut self.to,
85            "method" => &mut self.method,
86            _ => return, // unknown attribute — ignored, never an error
87        };
88        *slot = Some(value);
89    }
90}
91
92struct Parser<'a> {
93    src: &'a str,
94    bytes: &'a [u8],
95    pos: usize,
96    model: SceneModel,
97}
98
99impl<'a> Parser<'a> {
100    fn new(src: &'a str) -> Self {
101        Self {
102            src,
103            bytes: src.as_bytes(),
104            pos: 0,
105            model: SceneModel::empty(SceneKind::Scene),
106        }
107    }
108
109    // ---- low-level cursor ----
110
111    fn peek(&self) -> Option<u8> {
112        self.bytes.get(self.pos).copied()
113    }
114
115    fn bump(&mut self) {
116        self.pos += 1;
117    }
118
119    fn at_eof(&self) -> bool {
120        self.pos >= self.bytes.len()
121    }
122
123    fn skip_inline_ws(&mut self) {
124        while matches!(self.peek(), Some(b' ' | b'\t')) {
125            self.bump();
126        }
127    }
128
129    /// Skip whitespace, newlines, and `;`-comment lines (the trivia between/around sections).
130    fn skip_trivia(&mut self) {
131        loop {
132            match self.peek() {
133                Some(b' ' | b'\t' | b'\r' | b'\n') => self.bump(),
134                Some(b';') => self.skip_to_eol(),
135                _ => break,
136            }
137        }
138    }
139
140    fn skip_to_eol(&mut self) {
141        while !matches!(self.peek(), None | Some(b'\n')) {
142            self.bump();
143        }
144        if self.peek() == Some(b'\n') {
145            self.bump();
146        }
147    }
148
149    /// Read an identifier `[A-Za-z0-9_/]+` (header tag, or a key — keys may contain `/`).
150    fn read_ident(&mut self) -> Option<SmolStr> {
151        let start = self.pos;
152        while matches!(self.peek(), Some(b) if b.is_ascii_alphanumeric() || b == b'_' || b == b'/')
153        {
154            self.bump();
155        }
156        if self.pos == start {
157            None
158        } else {
159            self.src.get(start..self.pos).map(SmolStr::new)
160        }
161    }
162
163    // ---- value lexing (the lossless skipper) ----
164
165    /// Consume one complete value expression (string / array / dict / constructor / bare / color /
166    /// `&"…"`), returning its byte span. Never panics; stops at EOF.
167    fn consume_value(&mut self) -> Span {
168        self.skip_inline_ws();
169        let start = self.pos;
170        match self.peek() {
171            Some(b'"') => self.consume_quoted(),
172            Some(b'&' | b'@') => {
173                self.bump();
174                if self.peek() == Some(b'"') {
175                    self.consume_quoted();
176                } else {
177                    self.consume_bare();
178                }
179            }
180            Some(b'[' | b'{' | b'(') => self.consume_balanced(),
181            Some(b'#') => self.consume_color(),
182            Some(_) => {
183                self.consume_bare();
184                // Trailing constructor / typed-array brackets: `Vector2(…)`, `Array[T]([…])`.
185                while matches!(self.peek(), Some(b'(' | b'[')) {
186                    self.consume_balanced();
187                }
188            }
189            None => {}
190        }
191        (start, self.pos)
192    }
193
194    /// Consume a `"…"` string: honors `\\`/`\"` escapes and **literal embedded newlines** (C12).
195    fn consume_quoted(&mut self) {
196        self.bump(); // opening quote
197        loop {
198            match self.peek() {
199                None => break,
200                Some(b'\\') => {
201                    self.bump();
202                    self.bump(); // skip the escaped byte
203                }
204                Some(b'"') => {
205                    self.bump();
206                    break;
207                }
208                Some(_) => self.bump(),
209            }
210        }
211    }
212
213    /// Consume a `(…)`/`[…]`/`{…}` value with combined bracket depth, quote- and color-aware,
214    /// across physical newlines (C2, C12).
215    fn consume_balanced(&mut self) {
216        let mut depth: u32 = 0;
217        loop {
218            match self.peek() {
219                None => break,
220                Some(b'"') => self.consume_quoted(),
221                Some(b'#') => self.consume_color(), // a Color literal, NOT a comment (C11)
222                Some(b';') => self.skip_to_eol(),
223                Some(b'(' | b'[' | b'{') => {
224                    depth += 1;
225                    self.bump();
226                }
227                Some(b')' | b']' | b'}') => {
228                    self.bump();
229                    depth = depth.saturating_sub(1);
230                    if depth == 0 {
231                        break;
232                    }
233                }
234                Some(_) => self.bump(),
235            }
236        }
237    }
238
239    /// Consume a `#RRGGBBAA` color token (hex run after `#`).
240    fn consume_color(&mut self) {
241        self.bump(); // '#'
242        while matches!(self.peek(), Some(b) if b.is_ascii_hexdigit()) {
243            self.bump();
244        }
245    }
246
247    /// Consume a bare token (ident / number / sign / `inf` / `nan` / `true` / `null`).
248    fn consume_bare(&mut self) {
249        while matches!(
250            self.peek(),
251            Some(b) if b.is_ascii_alphanumeric() || matches!(b, b'_' | b'+' | b'-' | b'.')
252        ) {
253            self.bump();
254        }
255    }
256
257    // ---- header + body ----
258
259    /// Parse a `[tag …]` header. Returns the tag and the recognized attrs. `pos` ends just after
260    /// the closing `]` (or EOF if malformed). Assumes `pos` is at `[`.
261    fn read_header(&mut self) -> (Option<SmolStr>, HeaderAttrs, bool) {
262        self.bump(); // '['
263        self.skip_inline_ws();
264        let tag = self.read_ident();
265        let mut attrs = HeaderAttrs::default();
266        let mut closed = false;
267        loop {
268            self.skip_inline_ws();
269            match self.peek() {
270                Some(b']') => {
271                    self.bump();
272                    closed = true;
273                    break;
274                }
275                // EOF or newline before `]` — a header never wraps, so this is an unclosed bracket.
276                None | Some(b'\n') => break,
277                Some(_) => {
278                    let Some(key) = self.read_ident() else {
279                        self.bump(); // stray byte — advance to avoid looping
280                        continue;
281                    };
282                    self.skip_inline_ws();
283                    if self.peek() != Some(b'=') {
284                        continue; // a bare flag (none expected); ignore
285                    }
286                    self.bump(); // '='
287                    let value = self.consume_value();
288                    attrs.set(&key, value);
289                }
290            }
291        }
292        (tag, attrs, closed)
293    }
294
295    /// Read the body property lines of the current section until the next header / EOF. When
296    /// `is_node`, capture `script =` and `unique_name_in_owner =`; otherwise skip every value
297    /// losslessly. Returns `(script, unique_name_in_owner)`.
298    fn consume_body(&mut self, is_node: bool) -> (Option<ExtId>, bool, Vec<NodeProp>) {
299        let mut script = None;
300        let mut unique = false;
301        let mut props = Vec::new();
302        loop {
303            self.skip_trivia();
304            match self.peek() {
305                None | Some(b'[') => break, // EOF or next section
306                Some(_) => {}
307            }
308            let key_start = self.pos;
309            let Some(key) = self.read_ident() else {
310                self.skip_to_eol(); // not a key line — skip it
311                continue;
312            };
313            let key_span = TextRange::new(to_u32(key_start), to_u32(self.pos));
314            self.skip_inline_ws();
315            if self.peek() != Some(b'=') {
316                self.skip_to_eol();
317                continue;
318            }
319            self.bump(); // '='
320            let (vs, ve) = self.consume_value();
321            if is_node {
322                match key.as_str() {
323                    "script" => script = self.extract_ext_id(vs, ve),
324                    "unique_name_in_owner" => {
325                        unique = self.src.get(vs..ve).is_some_and(|v| v.trim() == "true");
326                    }
327                    _ => {}
328                }
329                props.push(NodeProp { key, key_span });
330            }
331            self.skip_to_eol();
332        }
333        (script, unique, props)
334    }
335
336    // ---- value extraction (interpret a recorded span) ----
337
338    /// The content of a quoted-string value (escapes resolved), or the bare token text. `None` for
339    /// an empty value.
340    fn extract_string(&self, span: Span) -> Option<SmolStr> {
341        let raw = self.src.get(span.0..span.1)?.trim();
342        if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
343            Some(SmolStr::new(unescape(&raw[1..raw.len() - 1])))
344        } else if raw.is_empty() {
345            None
346        } else {
347            Some(SmolStr::new(raw))
348        }
349    }
350
351    /// Parse a `format=`/numeric value to `u8` (best effort).
352    fn extract_u8(&self, span: Span) -> Option<u8> {
353        self.extract_string(span)?.trim().parse().ok()
354    }
355
356    /// Extract the id from an `ExtResource("id")` / `ExtResource(1)` value → the string `"id"`/`"1"`.
357    /// Returns `None` for any other constructor (notably `SubResource("…")`, an *inline* script /
358    /// resource that has no external path — M0 records no attachment for it; M1 types the node by
359    /// its declared `type=` instead).
360    fn extract_ext_id(&self, start: usize, end: usize) -> Option<ExtId> {
361        let v = self.src.get(start..end)?;
362        let open = v.find('(')?;
363        if v.get(..open)?.trim() != "ExtResource" {
364            return None;
365        }
366        let close = v.rfind(')')?;
367        if close <= open {
368            return None;
369        }
370        let inner = v.get(open + 1..close)?.trim().trim_matches('"').trim();
371        (!inner.is_empty()).then(|| ExtId(SmolStr::new(inner)))
372    }
373
374    // ---- pass 1: sectionize ----
375
376    fn run(&mut self) {
377        loop {
378            self.skip_trivia();
379            if self.at_eof() {
380                break;
381            }
382            if self.peek() == Some(b'[') {
383                self.section();
384            } else {
385                self.skip_to_eol(); // stray content outside a section — skip
386            }
387        }
388    }
389
390    fn section(&mut self) {
391        let start = self.pos;
392        let (tag, attrs, closed) = self.read_header();
393        let header_span = TextRange::new(to_u32(start), to_u32(self.pos));
394        if !closed {
395            self.model
396                .problems
397                .push(SceneProblem::MalformedHeader { at: header_span });
398            // body (if any) is consumed by the dispatch's consume_body below as a best effort
399        }
400        match tag.as_deref() {
401            Some("gd_scene") => {
402                self.model.kind = SceneKind::Scene;
403                self.read_scene_header(&attrs);
404                self.consume_body(false);
405            }
406            Some("gd_resource") => {
407                self.model.kind = SceneKind::Resource;
408                self.read_resource_header(&attrs);
409                self.consume_body(false);
410            }
411            Some("ext_resource") => {
412                self.add_ext_resource(&attrs, header_span);
413                self.consume_body(false);
414            }
415            Some("sub_resource") => {
416                self.add_sub_resource(&attrs, header_span);
417                self.consume_body(false);
418            }
419            Some("node") => self.add_node(&attrs, header_span),
420            Some("connection") => {
421                self.add_connection(&attrs, header_span);
422                self.consume_body(false); // a connection has no body, but stay robust
423            }
424            Some("editable" | "resource") => {
425                self.consume_body(false); // recognized, structurally ignored
426            }
427            Some(_) => {
428                self.model
429                    .problems
430                    .push(SceneProblem::UnknownTag { at: header_span });
431                self.consume_body(false);
432            }
433            None => {
434                self.model
435                    .problems
436                    .push(SceneProblem::MalformedHeader { at: header_span });
437                self.consume_body(false);
438            }
439        }
440    }
441
442    fn read_scene_header(&mut self, a: &HeaderAttrs) {
443        self.model.format = a.format.and_then(|s| self.extract_u8(s));
444        self.model.uid = a.uid.and_then(|s| self.extract_string(s));
445        self.model.script_class = a.script_class.and_then(|s| self.extract_string(s));
446    }
447
448    fn read_resource_header(&mut self, a: &HeaderAttrs) {
449        self.model.format = a.format.and_then(|s| self.extract_u8(s));
450        self.model.uid = a.uid.and_then(|s| self.extract_string(s));
451        self.model.script_class = a.script_class.and_then(|s| self.extract_string(s));
452        self.model.resource_type = a.typ.and_then(|s| self.extract_string(s));
453    }
454
455    fn add_ext_resource(&mut self, a: &HeaderAttrs, span: TextRange) {
456        let res_type = a.typ.and_then(|s| self.extract_string(s));
457        let path = a.path.and_then(|s| self.extract_string(s));
458        let uid = a.uid.and_then(|s| self.extract_string(s));
459        let id = a.id.and_then(|s| self.extract_string(s));
460        match id {
461            Some(id) => {
462                if res_type.is_none() || path.is_none() {
463                    self.model
464                        .problems
465                        .push(SceneProblem::MissingExtField { at: span });
466                }
467                self.model.ext_resources.insert(
468                    ExtId(id),
469                    ExtResource {
470                        res_type: res_type.unwrap_or_default(),
471                        path,
472                        uid,
473                        span,
474                    },
475                );
476            }
477            None => self
478                .model
479                .problems
480                .push(SceneProblem::MissingExtField { at: span }),
481        }
482    }
483
484    fn add_sub_resource(&mut self, a: &HeaderAttrs, span: TextRange) {
485        let res_type = a
486            .typ
487            .and_then(|s| self.extract_string(s))
488            .unwrap_or_default();
489        if let Some(id) = a.id.and_then(|s| self.extract_string(s)) {
490            self.model
491                .sub_resources
492                .insert(ExtId(id), SubResource { res_type, span });
493        }
494    }
495
496    fn add_node(&mut self, a: &HeaderAttrs, header_span: TextRange) {
497        let name = a
498            .name
499            .and_then(|s| self.extract_string(s))
500            .unwrap_or_default();
501        // The **inner** name span (quotes excluded) — a precise focus range for go-to-definition and
502        // the exact rewrite target for scene-aware rename (W8).
503        let name_span = a.name.map_or(header_span, |sp| self.inner_span(sp));
504        let decl_type = a.typ.and_then(|s| self.extract_string(s));
505        let parent_path = a.parent.and_then(|s| self.extract_string(s));
506        let parent_span = a.parent.map(|sp| self.inner_span(sp));
507        let instance = a.instance.and_then(|(s, e)| self.extract_ext_id(s, e));
508        let instance_placeholder = a.instance_placeholder.is_some();
509        let (script, unique_name_in_owner, properties) = self.consume_body(true);
510        self.model.nodes.push(SceneNode {
511            name,
512            decl_type,
513            parent_path,
514            parent_span,
515            parent_idx: None,
516            script,
517            instance,
518            instance_is_inherited_root: false,
519            instance_placeholder,
520            unique_name_in_owner,
521            header_span,
522            name_span,
523            properties,
524        });
525    }
526
527    fn add_connection(&mut self, a: &HeaderAttrs, header_span: TextRange) {
528        // A connection requires `signal`/`from`/`to`/`method`; a malformed one degrades to empty
529        // fields (rename simply finds no match). The spans are the inner identifier ranges so a
530        // rewrite replaces exactly the name, never the surrounding quotes.
531        let value = |s: Option<Span>| s.and_then(|sp| self.extract_string(sp)).unwrap_or_default();
532        let span = |s: Option<Span>| s.map_or(header_span, |sp| self.inner_span(sp));
533        self.model.connections.push(SceneConnection {
534            signal: value(a.signal),
535            signal_span: span(a.signal),
536            from: value(a.from),
537            from_span: span(a.from),
538            to: value(a.to),
539            to_span: span(a.to),
540            method: value(a.method),
541            method_span: span(a.method),
542            header_span,
543        });
544    }
545
546    /// The **inner** byte range of an attribute value `(start, end)`: leading/trailing whitespace
547    /// trimmed, and a single pair of surrounding `"` quotes excluded. (Identifier-valued attributes —
548    /// node names, signal/method names, node paths — carry no escapes, so this maps 1:1 to the
549    /// decoded value's bytes, which is what a rename rewrites.)
550    fn inner_span(&self, (s, e): Span) -> TextRange {
551        let raw = self.src.get(s..e).unwrap_or("");
552        let lead = raw.len() - raw.trim_start().len();
553        let trail = raw.len() - raw.trim_end().len();
554        let mut lo = s + lead;
555        let mut hi = e - trail;
556        let trimmed = &raw[lead..raw.len() - trail];
557        if trimmed.len() >= 2 && trimmed.starts_with('"') && trimmed.ends_with('"') {
558            lo += 1;
559            hi -= 1;
560        }
561        TextRange::new(to_u32(lo), to_u32(hi))
562    }
563
564    // ---- pass 2: build the tree ----
565
566    fn build_tree(&mut self) {
567        let n = self.model.nodes.len();
568        if n == 0 {
569            return;
570        }
571        // 1. Root(s): the parent-less nodes.
572        let roots: Vec<NodeIdx> = (0..n)
573            .filter(|&i| self.model.nodes[i].parent_path.is_none())
574            .map(|i| NodeIdx(to_u32(i)))
575            .collect();
576        self.model.root = roots.first().copied();
577        if roots.len() > 1 {
578            self.model.problems.push(SceneProblem::MultipleRoots {
579                roots: roots.clone(),
580            });
581        } else if roots.is_empty() {
582            self.model.problems.push(SceneProblem::NoRoot);
583        }
584        let root = self.model.root;
585
586        // 2. Resolve parents in file order (pre-order ⇒ ancestors already registered), building the
587        //    child index, the children lists, and the full-path map.
588        let mut child_index: FxHashMap<(NodeIdx, SmolStr), NodeIdx> = FxHashMap::default();
589        let mut children: FxHashMap<NodeIdx, Vec<NodeIdx>> = FxHashMap::default();
590        let mut full_paths: Vec<SmolStr> = vec![SmolStr::default(); n];
591        // The intended full paths of nodes whose parent did NOT resolve (a dangling node detaches its
592        // whole subtree). A later node parented *into* such a subtree misses only because its detached
593        // ancestor was never indexed — a cascade we suppress so only the root-cause node is flagged.
594        let mut dangling_subtrees: FxHashSet<SmolStr> = FxHashSet::default();
595
596        for i in 0..n {
597            let idx = NodeIdx(to_u32(i));
598            let parent_path = self.model.nodes[i].parent_path.clone();
599            let name = self.model.nodes[i].name.clone();
600
601            // Inherited-scene root: the chosen root carrying `instance=` (`set_base_scene`). Set
602            // BEFORE resolving any child paths so the into-instance check below can see it. Gated on
603            // being THE root (a spurious extra parent-less node in a MultipleRoots scene is not one).
604            if Some(idx) == root && self.model.nodes[i].instance.is_some() {
605                self.model.nodes[i].instance_is_inherited_root = true;
606            }
607
608            let parent_idx = match parent_path.as_deref() {
609                None => None,
610                Some(".") => root,
611                Some(p) => match walk_path(root, p, &child_index) {
612                    Walk::Resolved(found) => Some(found),
613                    // An absolute/`..` escape is out of the slice → silently unresolved, never a
614                    // dangling parent (Playbook §5/§7 — M1 degrades it to `Node`).
615                    Walk::Escaped => None,
616                    Walk::Missed(deepest) => {
617                        // This node's parent didn't resolve, so it detaches its own subtree — record
618                        // its intended path so a *descendant*'s later miss is recognized as a cascade
619                        // (its detached ancestor was never indexed) rather than a separate root cause.
620                        let is_cascade = within_dangling_subtree(p, &dangling_subtrees);
621                        dangling_subtrees.insert(SmolStr::new(format!("{p}/{name}")));
622                        // Flag only a genuine, non-cascading miss into a non-instance subtree. If the
623                        // deepest node reached — or any ancestor up to the root — is an instance
624                        // boundary, the missing tail lives in an instanced/inherited sub-scene we don't
625                        // recurse into (an override line) — expected, NOT dangling (Playbook
626                        // C12/C13/C20). The root being an inherited scene makes every override child's
627                        // missing segment a base-scene node.
628                        if !is_cascade && !self.model.descends_from_instance(deepest) {
629                            self.model.problems.push(SceneProblem::DanglingParent {
630                                node: idx,
631                                parent_path: SmolStr::new(p),
632                            });
633                        }
634                        None
635                    }
636                },
637            };
638            self.model.nodes[i].parent_idx = parent_idx;
639
640            if let Some(p) = parent_idx {
641                // First sibling of a given name keeps the navigable slot (matches `unique_nodes`'
642                // first-wins; Godot auto-uniquifies sibling names anyway).
643                child_index.entry((p, name.clone())).or_insert(idx);
644                children.entry(p).or_default().push(idx);
645                let pfp = &full_paths[p.0 as usize];
646                let fp = if pfp.is_empty() {
647                    name
648                } else {
649                    SmolStr::new(format!("{pfp}/{name}"))
650                };
651                full_paths[i] = fp.clone();
652                self.model.by_path.entry(fp).or_insert(idx);
653            }
654        }
655
656        // 3. Unique-name index (first wins on collision).
657        for i in 0..n {
658            if self.model.nodes[i].unique_name_in_owner {
659                self.model
660                    .unique_nodes
661                    .entry(self.model.nodes[i].name.clone())
662                    .or_insert(NodeIdx(to_u32(i)));
663            }
664        }
665
666        // 4. Validate `script=`/`instance=` ids against the ext-resource table.
667        for i in 0..n {
668            let span = self.model.nodes[i].header_span;
669            let refs = [
670                self.model.nodes[i].script.clone(),
671                self.model.nodes[i].instance.clone(),
672            ];
673            for id in refs.into_iter().flatten() {
674                if !self.model.ext_resources.contains_key(&id) {
675                    self.model
676                        .problems
677                        .push(SceneProblem::UnknownExtResource { id, at: span });
678                }
679            }
680        }
681
682        self.model.set_indices(child_index, children);
683    }
684}
685
686/// Whether the parent path `p` lies within a detached (dangling) node's subtree — `p` equals, or is
687/// a `/`-descendant of, some already-recorded dangling intended path. Such a miss is a cascade of the
688/// upstream dangling node, not a separate root cause, so it is not re-flagged.
689fn within_dangling_subtree(p: &str, dangling: &FxHashSet<SmolStr>) -> bool {
690    dangling.iter().any(|d| {
691        p == d.as_str()
692            || p.strip_prefix(d.as_str())
693                .is_some_and(|rest| rest.starts_with('/'))
694    })
695}
696
697/// The outcome of resolving a `parent=`/node path against the in-scene tree.
698enum Walk {
699    /// Fully resolved to a node.
700    Resolved(NodeIdx),
701    /// The path escapes the scene (an absolute `/root/…` or a `..` segment). Out of the M0 slice —
702    /// resolves to nothing **silently** (not a dangling parent; M1 degrades it to `Node`).
703    Escaped,
704    /// A genuine in-scene child miss. `deepest` is the last node reached (so the caller can tell an
705    /// override-into-an-instance from a real dangling parent).
706    Missed(Option<NodeIdx>),
707}
708
709/// Walk a relative name-path from `root`, segment by segment, via the incrementally-built child
710/// index.
711fn walk_path(
712    root: Option<NodeIdx>,
713    path: &str,
714    child_index: &FxHashMap<(NodeIdx, SmolStr), NodeIdx>,
715) -> Walk {
716    if path.starts_with('/') {
717        return Walk::Escaped; // absolute `/root/…` — detect before splitting (leading "" segment)
718    }
719    let Some(mut cur) = root else {
720        return Walk::Missed(None);
721    };
722    for seg in path.split('/') {
723        if seg.is_empty() || seg == "." {
724            continue;
725        }
726        if seg == ".." {
727            return Walk::Escaped; // parent escape — needs the runtime tree
728        }
729        match child_index.get(&(cur, SmolStr::new(seg))) {
730            Some(&next) => cur = next,
731            None => return Walk::Missed(Some(cur)),
732        }
733    }
734    Walk::Resolved(cur)
735}
736
737/// Resolve the C-style escapes a `.tscn` quoted string may carry, including `\uXXXX` / `\UXXXXXX`
738/// Unicode and the `\a \b \f \v` control escapes — mirroring Godot's `variant_parser.cpp` /
739/// tokenizer string decoding. Unknown escapes pass through (the backslash is dropped, the next char
740/// kept). Resolved consistently for both `name=` and `parent=`, so path matching is unaffected.
741fn unescape(s: &str) -> String {
742    if !s.contains('\\') {
743        return s.to_owned();
744    }
745    // Index over a char vector so a `\u` surrogate pair can look ahead cheaply.
746    let chars: Vec<char> = s.chars().collect();
747    let mut out = String::with_capacity(s.len());
748    let mut i = 0;
749    while i < chars.len() {
750        let c = chars[i];
751        if c != '\\' {
752            out.push(c);
753            i += 1;
754            continue;
755        }
756        i += 1; // consume the backslash
757        let Some(&esc) = chars.get(i) else {
758            out.push('\\'); // a trailing lone backslash
759            break;
760        };
761        i += 1; // consume the escape selector
762        match esc {
763            'n' => out.push('\n'),
764            't' => out.push('\t'),
765            'r' => out.push('\r'),
766            'a' => out.push('\u{07}'),
767            'b' => out.push('\u{08}'),
768            'f' => out.push('\u{0C}'),
769            'v' => out.push('\u{0B}'),
770            // `\uXXXX` — a 4-hex-digit UTF-16 code unit (with surrogate-pair combining);
771            // `\UXXXXXX` — a 6-hex-digit code point.
772            'u' => i = push_unicode_escape(&mut out, &chars, i, 4),
773            'U' => i = push_unicode_escape(&mut out, &chars, i, 6),
774            other => out.push(other), // \" \\ \' and anything else → the literal char
775        }
776    }
777    out
778}
779
780/// Decode up to `max_digits` hex digits at `chars[start..]` into a code point and push it, combining
781/// a UTF-16 surrogate pair for the `\u` form (a high surrogate followed by a `\uXXXX` low surrogate
782/// becomes one scalar). Returns the index past everything consumed. A run with no hex digit pushes
783/// nothing and leaves the index unchanged (Godot treats an escape with no digits as empty).
784fn push_unicode_escape(out: &mut String, chars: &[char], start: usize, max_digits: usize) -> usize {
785    let (code, i) = read_hex(chars, start, max_digits);
786    let Some(code) = code else { return start };
787    // A high surrogate (`\uD800`..=`\uDBFF`) combines with a following `\uXXXX` low surrogate.
788    if (0xD800..=0xDBFF).contains(&code)
789        && chars.get(i) == Some(&'\\')
790        && chars.get(i + 1) == Some(&'u')
791    {
792        let (low, j) = read_hex(chars, i + 2, 4);
793        if let Some(low) = low
794            && (0xDC00..=0xDFFF).contains(&low)
795        {
796            let combined = 0x1_0000 + ((code - 0xD800) << 10) + (low - 0xDC00);
797            if let Some(c) = char::from_u32(combined) {
798                out.push(c);
799                return j;
800            }
801        }
802    }
803    out.push(char::from_u32(code).unwrap_or('\u{FFFD}'));
804    i
805}
806
807/// Read up to `max_digits` hex digits at `chars[start..]`, returning `(value, next index)`. `value`
808/// is `None` (and the index unmoved) when no hex digit is present.
809fn read_hex(chars: &[char], start: usize, max_digits: usize) -> (Option<u32>, usize) {
810    let mut value: u32 = 0;
811    let mut count = 0;
812    let mut i = start;
813    while count < max_digits {
814        let Some(d) = chars.get(i).and_then(|c| c.to_digit(16)) else {
815            break;
816        };
817        value = value * 16 + d;
818        i += 1;
819        count += 1;
820    }
821    if count == 0 {
822        (None, start)
823    } else {
824        (Some(value), i)
825    }
826}
827
828/// `usize → u32`, saturating (a `.tscn` over 4 GiB / 4 G nodes is not a real input).
829fn to_u32(v: usize) -> u32 {
830    u32::try_from(v).unwrap_or(u32::MAX)
831}
832
833#[cfg(test)]
834mod unescape_tests {
835    use super::unescape;
836
837    #[test]
838    fn passes_plain_strings_and_simple_escapes() {
839        assert_eq!(unescape("Node"), "Node");
840        assert_eq!(unescape("a\\nb"), "a\nb");
841        assert_eq!(unescape("tab\\there"), "tab\there");
842        assert_eq!(unescape("quote\\\"end"), "quote\"end");
843        assert_eq!(unescape("back\\\\slash"), "back\\slash");
844    }
845
846    #[test]
847    fn decodes_control_escapes() {
848        assert_eq!(unescape("\\b"), "\u{08}");
849        assert_eq!(unescape("\\f"), "\u{0C}");
850        assert_eq!(unescape("\\a"), "\u{07}");
851        assert_eq!(unescape("\\v"), "\u{0B}");
852    }
853
854    #[test]
855    fn decodes_lowercase_u_4_hex() {
856        // `A` is the letter `A` — the bug fixed here (it used to decode to `u0041`).
857        assert_eq!(unescape("\\u0041"), "A");
858        assert_eq!(unescape("X\\u00e9Y"), "XéY");
859    }
860
861    #[test]
862    fn decodes_uppercase_u_6_hex() {
863        // U+1F600 GRINNING FACE.
864        assert_eq!(unescape("\\U01F600"), "😀");
865    }
866
867    #[test]
868    fn combines_a_utf16_surrogate_pair() {
869        // The same emoji written as a `😀` surrogate pair.
870        assert_eq!(unescape("\\uD83D\\uDE00"), "😀");
871    }
872
873    #[test]
874    fn invalid_or_empty_escapes_are_safe() {
875        // A lone backslash at EOF is kept.
876        assert_eq!(unescape("ends\\"), "ends\\");
877        // `\u` with no hex digits emits nothing for the escape (the chars after are kept).
878        assert_eq!(unescape("\\uZ"), "Z");
879        // A lone high surrogate (no low surrogate) falls back to U+FFFD.
880        assert_eq!(unescape("\\uD83D!"), "\u{FFFD}!");
881    }
882}