Skip to main content

gdscript_scene/
model.rs

1//! The parsed scene/resource model produced by [`crate::parse_scene`] (Phase-4 M0).
2//!
3//! Pure data + read-only lookups — **no `FileId`, no db, no engine model**. M1 wraps the parser in
4//! a salsa query and maps the recorded `type=`/`script=`/`instance=` data onto a `Ty`; M0 only
5//! records the structure + byte spans (for go-to-definition into the `.tscn`).
6//!
7//! The shape follows `PHASE-4-M0-PLAYBOOK.md` §3, using the workspace conventions
8//! ([`SmolStr`]/[`FxHashMap`]) — not the playbook prose's `EcoString` (which the crate does not use).
9
10use gdscript_base::TextRange;
11use rustc_hash::FxHashMap;
12use smol_str::SmolStr;
13
14/// Index of a node into [`SceneModel::nodes`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct NodeIdx(pub u32);
17
18/// An `ext_resource`/`sub_resource` id — the opaque, quoted-string key as written
19/// (`"1"`, `"1_app"`, `"StyleBoxFlat_x"`). A 3.x bare-int id is normalized to its string form.
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct ExtId(pub SmolStr);
22
23/// Whether the parsed file is a `.tscn` scene (`gd_scene`) or a `.tres` resource (`gd_resource`).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum SceneKind {
26    /// A `.tscn` scene — has a `[node]` tree.
27    Scene,
28    /// A `.tres` resource — has a `[resource]` body, no node tree.
29    Resource,
30}
31
32/// One parsed `.tscn`/`.tres`. Produced by [`crate::parse_scene`]; **never** an `Err` — every
33/// malformed/binary/unknown form degrades to an empty-or-partial model plus a [`SceneProblem`].
34/// `PartialEq`/`Eq` so it can be a backdated salsa query result (`Arc<SceneModel>`) in M1.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct SceneModel {
37    /// Scene vs resource (from the header tag).
38    pub kind: SceneKind,
39    /// The `format=` version: `>=3` is the Godot-4.x family, `2` is 3.x, `None` if absent.
40    pub format: Option<u8>,
41    /// The scene/resource `uid="uid://…"`, if present.
42    pub uid: Option<SmolStr>,
43    /// The header `script_class="…"` shortcut — the root/resource's `class_name` without resolving
44    /// the script file.
45    pub script_class: Option<SmolStr>,
46    /// A `.tres`'s own resource class (`gd_resource type="…"`).
47    pub resource_type: Option<SmolStr>,
48
49    /// `id → ext_resource` (external scripts, packed scenes, textures, …).
50    pub ext_resources: FxHashMap<ExtId, ExtResource>,
51    /// `id → sub_resource` type (the value body is skipped — we keep only the declared type).
52    pub sub_resources: FxHashMap<ExtId, SubResource>,
53
54    /// Every node, in file order (which is tree pre-order for siblings). `index = NodeIdx.0`.
55    pub nodes: Vec<SceneNode>,
56    /// The single parent-less node, if the scene has one.
57    pub root: Option<NodeIdx>,
58
59    /// Full name-path from the root (`"Panel/VBox/StartButton"`, root name excluded) → node.
60    pub by_path: FxHashMap<SmolStr, NodeIdx>,
61    /// `unique_name_in_owner` nodes: bare name → node (the `%Name` lookup; scene-wide in the slice).
62    pub unique_nodes: FxHashMap<SmolStr, NodeIdx>,
63
64    /// Every `[connection …]` (a signal wired to a method), in file order. Drives scene-aware rename
65    /// of a script's connected methods/signals (W8); read-side typing ignores it.
66    pub connections: Vec<SceneConnection>,
67
68    /// Non-fatal problems found while parsing (the parser never errors).
69    pub problems: Vec<SceneProblem>,
70
71    /// `(parent, child-name) → child` — the segment-by-segment path walk index. Built in pass 2.
72    child_index: FxHashMap<(NodeIdx, SmolStr), NodeIdx>,
73    /// `parent → ordered children` — for `$`/`get_node` child completion.
74    children: FxHashMap<NodeIdx, Vec<NodeIdx>>,
75}
76
77/// One `[node …]` section: its header attributes + the two body properties we read.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct SceneNode {
80    /// The node name (unescaped; may contain spaces).
81    pub name: SmolStr,
82    /// `type="X"` — the declared class (native **or** a custom `class_name`). `None` ⇒ instanced.
83    pub decl_type: Option<SmolStr>,
84    /// The raw `parent="…"` path (`"."` = child of root; relative, root-excluded). `None` ⇒ root.
85    pub parent_path: Option<SmolStr>,
86    /// Byte span of the `parent=` value (quotes excluded), for scene-aware rename of a path segment.
87    pub parent_span: Option<TextRange>,
88    /// The resolved parent (pass 2). `None` ⇒ root, or an unresolved/dangling parent.
89    pub parent_idx: Option<NodeIdx>,
90    /// Body `script = ExtResource("id")`.
91    pub script: Option<ExtId>,
92    /// Header `instance=ExtResource("id")` (an instanced sub-scene; its type comes from that scene).
93    pub instance: Option<ExtId>,
94    /// `instance=` on a parent-less node ⇒ an *inherited* scene (`set_base_scene`), not a child.
95    pub instance_is_inherited_root: bool,
96    /// `instance_placeholder="res://…"` (the lazy-instance variant).
97    pub instance_placeholder: bool,
98    /// Body `unique_name_in_owner = true` (the `%Name` marker — distinct from the header `unique_id`).
99    pub unique_name_in_owner: bool,
100    /// Byte span of the whole `[node …]` header line (coarse go-to-definition).
101    pub header_span: TextRange,
102    /// Byte span of the `name="…"` value (finer go-to-definition / highlight).
103    pub name_span: TextRange,
104    /// The node's body property keys (`key = value`; values skipped). Drives renaming an `@export`
105    /// variable set as a scene property (W8 A3); read-side typing ignores them.
106    pub properties: Vec<NodeProp>,
107}
108
109/// One body property line of a `[node …]` — `key = value` (value skipped). The key may be a bare
110/// identifier (an `@export` var or an engine property) or a `group/sub` override path.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct NodeProp {
113    /// The property key.
114    pub key: SmolStr,
115    /// Byte span of the key.
116    pub key_span: TextRange,
117}
118
119/// One `[connection …]` section — a signal wired to a method in the editor. The four spans are the
120/// **inner identifier** byte ranges (surrounding quotes excluded), so a rename rewrites exactly the
121/// name. A connection has no body.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct SceneConnection {
124    /// `signal="…"` — the emitted signal name (a member of the `from` node's type).
125    pub signal: SmolStr,
126    /// Byte span of the `signal=` value (quotes excluded).
127    pub signal_span: TextRange,
128    /// `from="…"` — the emitter node path (root-relative; `"."` = root). Empty if absent.
129    pub from: SmolStr,
130    /// Byte span of the `from=` value (quotes excluded).
131    pub from_span: TextRange,
132    /// `to="…"` — the receiver node path (the node whose attached script declares `method`).
133    pub to: SmolStr,
134    /// Byte span of the `to=` value (quotes excluded).
135    pub to_span: TextRange,
136    /// `method="…"` — the receiving method name (a member of the `to` node's attached script).
137    pub method: SmolStr,
138    /// Byte span of the `method=` value (quotes excluded).
139    pub method_span: TextRange,
140    /// Byte span of the whole `[connection …]` header line.
141    pub header_span: TextRange,
142}
143
144/// An `[ext_resource …]` declaration.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ExtResource {
147    /// `type="Script" | "PackedScene" | "Texture2D" | …`.
148    pub res_type: SmolStr,
149    /// `path="res://…"`, if present (prefer this over `uid` in the slice).
150    pub path: Option<SmolStr>,
151    /// `uid="uid://…"`, if present (resolved via the project UID map in M1).
152    pub uid: Option<SmolStr>,
153    /// Byte span of the `[ext_resource …]` header line.
154    pub span: TextRange,
155}
156
157/// A `[sub_resource …]` declaration (type only — the value body is skipped).
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct SubResource {
160    /// `type="…"`.
161    pub res_type: SmolStr,
162    /// Byte span of the header line.
163    pub span: TextRange,
164}
165
166/// A non-fatal problem found during parsing. The parser records these and keeps going — the floor
167/// is always parity with the engine's `Node`-everywhere baseline.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum SceneProblem {
170    /// A binary `.scn`/`.res` (RSRC/RSCC magic) — detected and degraded to an empty model.
171    BinaryResource,
172    /// A section tag not in the 8 the engine recognizes — the section was skipped.
173    UnknownTag {
174        /// The header line span.
175        at: TextRange,
176    },
177    /// A bracketed header that could not be lexed — the section was skipped.
178    MalformedHeader {
179        /// The (best-effort) header span.
180        at: TextRange,
181    },
182    /// An `ext_resource` missing a required `type`/`path`/`id`.
183    MissingExtField {
184        /// The header line span.
185        at: TextRange,
186    },
187    /// A `script=`/`instance=` referencing an id with no matching `ext_resource`.
188    UnknownExtResource {
189        /// The dangling id.
190        id: ExtId,
191        /// The referencing node's header span.
192        at: TextRange,
193    },
194    /// More than one parent-less node (the first is kept as root).
195    MultipleRoots {
196        /// All parent-less nodes.
197        roots: Vec<NodeIdx>,
198    },
199    /// A scene with `[node]`s but no parent-less node.
200    NoRoot,
201    /// A node whose `parent="…"` path resolves to no known node.
202    DanglingParent {
203        /// The orphaned node.
204        node: NodeIdx,
205        /// The unresolved parent path.
206        parent_path: SmolStr,
207    },
208}
209
210impl SceneModel {
211    /// An empty model of `kind` (the degrade target).
212    #[must_use]
213    pub(crate) fn empty(kind: SceneKind) -> Self {
214        Self {
215            kind,
216            format: None,
217            uid: None,
218            script_class: None,
219            resource_type: None,
220            ext_resources: FxHashMap::default(),
221            sub_resources: FxHashMap::default(),
222            nodes: Vec::new(),
223            root: None,
224            by_path: FxHashMap::default(),
225            unique_nodes: FxHashMap::default(),
226            connections: Vec::new(),
227            problems: Vec::new(),
228            child_index: FxHashMap::default(),
229            children: FxHashMap::default(),
230        }
231    }
232
233    /// Set the pass-2-built navigation indices (called by the parser).
234    pub(crate) fn set_indices(
235        &mut self,
236        child_index: FxHashMap<(NodeIdx, SmolStr), NodeIdx>,
237        children: FxHashMap<NodeIdx, Vec<NodeIdx>>,
238    ) {
239        self.child_index = child_index;
240        self.children = children;
241    }
242
243    /// The node at `idx`, if it exists.
244    #[must_use]
245    pub fn node(&self, idx: NodeIdx) -> Option<&SceneNode> {
246        self.nodes.get(idx.0 as usize)
247    }
248
249    /// The node's full name-path from (and **including**) the scene root, e.g.
250    /// `"Main/Panel/StartButton"` — the stable cross-reference identity of a node (the same value
251    /// whether reached from a `$Path` in a script or the node's `name="…"` in the scene). Walks
252    /// `parent_idx` to the root (depth-bounded against a cycle). `None` for an out-of-range index.
253    #[must_use]
254    pub fn node_full_path(&self, idx: NodeIdx) -> Option<SmolStr> {
255        let mut names: Vec<&str> = Vec::new();
256        let mut cur = Some(idx);
257        for _ in 0..1024 {
258            let Some(i) = cur else {
259                names.reverse();
260                return Some(SmolStr::new(names.join("/")));
261            };
262            let n = self.node(i)?;
263            names.push(n.name.as_str());
264            cur = n.parent_idx;
265        }
266        None // cyclic parent chain (degrade rather than loop)
267    }
268
269    /// The node whose [`node_full_path`](Self::node_full_path) equals `path` (the inverse lookup —
270    /// the node identified by a [`GodotDef::SceneNode`]'s stable path). `None` if no node matches.
271    #[must_use]
272    pub fn node_by_full_path(&self, path: &str) -> Option<NodeIdx> {
273        (0..self.nodes.len())
274            .filter_map(|i| u32::try_from(i).ok())
275            .map(NodeIdx)
276            .find(|&idx| self.node_full_path(idx).as_deref() == Some(path))
277    }
278
279    /// Walk a name-path from the scene root. `""`/`"."` ⇒ the root. `None` ⇒ no such node (M1 reads
280    /// that as "degrade to `Node`", never an error). A leading `/` (absolute) or a `..` segment is
281    /// out of the slice and yields `None`.
282    #[must_use]
283    pub fn resolve_path(&self, path: &str) -> Option<NodeIdx> {
284        self.resolve_path_from(self.root?, path)
285    }
286
287    /// Walk a name-path from an arbitrary `base` node (the node a script attaches to — `$X` is
288    /// relative to *that* node, which is usually but not always the root). A `%Name` segment is a
289    /// **unique-name** lookup (scene-wide, owner-relative), so the idiomatic `Foo/%Bar` and the
290    /// string forms `$"%Bar"` / `get_node("%Bar")` resolve like the engine; a plain segment is a
291    /// child lookup.
292    #[must_use]
293    pub fn resolve_path_from(&self, base: NodeIdx, path: &str) -> Option<NodeIdx> {
294        let p = path.trim();
295        if p.is_empty() || p == "." {
296            return Some(base);
297        }
298        if p.starts_with('/') {
299            return None; // absolute /root/... — out of the slice
300        }
301        let mut cur = base;
302        for seg in p.split('/') {
303            if seg.is_empty() || seg == "." {
304                continue;
305            }
306            if seg == ".." {
307                return None; // parent escape — needs the runtime tree
308            }
309            cur = self.step_segment(cur, seg)?;
310        }
311        Some(cur)
312    }
313
314    /// Resolve one path segment from `cur`: a `%Name` segment is a scene-wide unique-name lookup
315    /// (the `cur` base is irrelevant for it); a plain `Name` segment is a child of `cur`.
316    fn step_segment(&self, cur: NodeIdx, seg: &str) -> Option<NodeIdx> {
317        if let Some(unique) = seg.strip_prefix('%') {
318            self.unique_nodes.get(unique).copied()
319        } else {
320            self.child_index.get(&(cur, SmolStr::new(seg))).copied()
321        }
322    }
323
324    /// Resolve a `%`-sigil path (`%Name` / `%Name/Child/…`). The leading segment is a unique name
325    /// even though the `%` sigil was a separate token (so it isn't in `path`); subsequent segments
326    /// walk as normal children. Delegates to the `%`-aware [`resolve_path_from`](Self::resolve_path_from).
327    #[must_use]
328    pub fn resolve_unique(&self, path: &str) -> Option<NodeIdx> {
329        self.resolve_path_from(self.root?, &Self::with_unique_head(path))
330    }
331
332    /// Mark the first segment of a `%`-sigil path as a unique name (`"Box/Btn"` → `"%Box/Btn"`),
333    /// leaving an already-`%`-prefixed path untouched.
334    fn with_unique_head(path: &str) -> String {
335        if path.starts_with('%') {
336            path.to_owned()
337        } else {
338            format!("%{path}")
339        }
340    }
341
342    /// The node whose body `script = ExtResource(id)` resolves (via `ext_resources[id].path`) to
343    /// `script_path` — the per-scene half of the script↔scene association.
344    #[must_use]
345    pub fn node_with_script(&self, script_path: &str) -> Option<NodeIdx> {
346        self.nodes.iter().enumerate().find_map(|(i, n)| {
347            let ext = self.ext_resources.get(n.script.as_ref()?)?;
348            (ext.path.as_deref() == Some(script_path))
349                .then(|| NodeIdx(u32::try_from(i).unwrap_or(u32::MAX)))
350        })
351    }
352
353    /// The child nodes of `idx` (`None` ⇒ the root's children), in file order.
354    pub fn children_of(&self, idx: Option<NodeIdx>) -> impl Iterator<Item = (NodeIdx, &SceneNode)> {
355        idx.or(self.root)
356            .and_then(|t| self.children.get(&t))
357            .into_iter()
358            .flatten()
359            .filter_map(move |&c| self.node(c).map(|n| (c, n)))
360    }
361
362    /// Resolve a name-path from `base`, distinguishing the *reason* a path doesn't resolve — so a
363    /// caller can warn on a genuine [`Missing`](NodePathResolution::Missing) node while staying
364    /// silent on an [`Escaped`](NodePathResolution::Escaped) (`..`/absolute) or an
365    /// [`IntoInstance`](NodePathResolution::IntoInstance) override (the M1 typing uses
366    /// [`resolve_path_from`](Self::resolve_path_from); this is for the `INVALID_NODE_PATH` decision).
367    #[must_use]
368    pub fn classify_path_from(&self, base: NodeIdx, path: &str) -> NodePathResolution {
369        let p = path.trim();
370        if p.is_empty() || p == "." {
371            return NodePathResolution::Resolved(base);
372        }
373        if p.starts_with('/') {
374            return NodePathResolution::Escaped; // absolute `/root/…`
375        }
376        let mut cur = base;
377        for seg in p.split('/') {
378            if seg.is_empty() || seg == "." {
379                continue;
380            }
381            if seg == ".." {
382                return NodePathResolution::Escaped;
383            }
384            match self.step_segment(cur, seg) {
385                Some(next) => cur = next,
386                None => {
387                    // A `%Name` segment is scene-wide with no instance boundary, so a miss is a
388                    // genuine `Missing`; a plain child miss below an instance is `IntoInstance`.
389                    return if !seg.starts_with('%') && self.descends_from_instance(Some(cur)) {
390                        NodePathResolution::IntoInstance
391                    } else {
392                        NodePathResolution::Missing
393                    };
394                }
395            }
396        }
397        NodePathResolution::Resolved(cur)
398    }
399
400    /// Resolve a `%`-sigil path (`%Name` / `%Name/Child`). The leading segment is a unique name
401    /// (the `%` sigil was a separate token, so it isn't in `path`); subsequent segments walk as
402    /// children. A missing leading unique name is genuinely [`Missing`](NodePathResolution::Missing)
403    /// (no instance ambiguity — `%` is scene-wide).
404    #[must_use]
405    pub fn classify_unique(&self, path: &str) -> NodePathResolution {
406        match self.root {
407            Some(root) => self.classify_path_from(root, &Self::with_unique_head(path)),
408            None => NodePathResolution::Missing,
409        }
410    }
411
412    /// If walking `path` from `base` descends INTO an instanced sub-scene — the walk reaches an
413    /// instance node whose next (plain) child segment lives *inside* that sub-scene — return
414    /// `(instance_node, remaining_tail)` so the caller can continue the walk in the sub-scene's own
415    /// tree (`$Enemy/Sprite` → resolve `Enemy`, then resolve `Sprite` in `enemy.tscn`). `None` if the
416    /// path resolves wholly within this scene, escapes (`..`/absolute), or misses somewhere that is
417    /// not exactly an instance node — an override child *under* an instance stays unresolved here
418    /// (typed `Node`, no false warning), since mapping it back into the sub-scene needs more than the
419    /// boundary node. The boundary returned is the instance node itself.
420    #[must_use]
421    pub fn resolve_into_instance(&self, base: NodeIdx, path: &str) -> Option<(NodeIdx, String)> {
422        let p = path.trim();
423        if p.is_empty() || p == "." || p.starts_with('/') {
424            return None;
425        }
426        let segs: Vec<&str> = p
427            .split('/')
428            .filter(|s| !s.is_empty() && *s != ".")
429            .collect();
430        let mut cur = base;
431        for (i, seg) in segs.iter().enumerate() {
432            if *seg == ".." {
433                return None;
434            }
435            match self.step_segment(cur, seg) {
436                Some(next) => cur = next,
437                None => {
438                    return if !seg.starts_with('%')
439                        && self.node(cur).is_some_and(|n| n.instance.is_some())
440                    {
441                        Some((cur, segs[i..].join("/")))
442                    } else {
443                        None
444                    };
445                }
446            }
447        }
448        None // resolved wholly within this scene
449    }
450
451    /// The `%`-sigil counterpart of [`resolve_into_instance`](Self::resolve_into_instance): a
452    /// `%Unique/Tail` whose `%Unique` is an instance node and `Tail` descends into the sub-scene.
453    #[must_use]
454    pub fn resolve_unique_into_instance(&self, path: &str) -> Option<(NodeIdx, String)> {
455        self.resolve_into_instance(self.root?, &Self::with_unique_head(path))
456    }
457
458    /// Whether `start` or any ancestor (up to the root) is an instance boundary (`instance=` /
459    /// `instance_placeholder` / an inherited-scene root) — i.e. a missing tail below it lives in a
460    /// sub-scene we don't recurse into, not a genuine dangling/missing node. Depth-bounded.
461    pub(crate) fn descends_from_instance(&self, start: Option<NodeIdx>) -> bool {
462        let mut cur = start;
463        let mut guard = 0u32;
464        while let Some(c) = cur {
465            let Some(node) = self.nodes.get(c.0 as usize) else {
466                break;
467            };
468            if node.instance.is_some()
469                || node.instance_placeholder
470                || node.instance_is_inherited_root
471            {
472                return true;
473            }
474            cur = node.parent_idx;
475            guard += 1;
476            if guard > 4096 {
477                break;
478            }
479        }
480        false
481    }
482}
483
484/// The reason a node path did (not) resolve — for the `INVALID_NODE_PATH` decision (M2).
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
486pub enum NodePathResolution {
487    /// Resolved to a concrete node.
488    Resolved(NodeIdx),
489    /// The path escapes the scene (`..` / absolute `/root/…`) — out of the slice; never warn.
490    Escaped,
491    /// The miss descends into an instanced/inherited sub-scene we don't recurse into; never warn.
492    IntoInstance,
493    /// A genuinely absent in-scene node — the `INVALID_NODE_PATH` trigger.
494    Missing,
495}