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    /// Non-fatal problems found while parsing (the parser never errors).
65    pub problems: Vec<SceneProblem>,
66
67    /// `(parent, child-name) → child` — the segment-by-segment path walk index. Built in pass 2.
68    child_index: FxHashMap<(NodeIdx, SmolStr), NodeIdx>,
69    /// `parent → ordered children` — for `$`/`get_node` child completion.
70    children: FxHashMap<NodeIdx, Vec<NodeIdx>>,
71}
72
73/// One `[node …]` section: its header attributes + the two body properties we read.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct SceneNode {
76    /// The node name (unescaped; may contain spaces).
77    pub name: SmolStr,
78    /// `type="X"` — the declared class (native **or** a custom `class_name`). `None` ⇒ instanced.
79    pub decl_type: Option<SmolStr>,
80    /// The raw `parent="…"` path (`"."` = child of root; relative, root-excluded). `None` ⇒ root.
81    pub parent_path: Option<SmolStr>,
82    /// The resolved parent (pass 2). `None` ⇒ root, or an unresolved/dangling parent.
83    pub parent_idx: Option<NodeIdx>,
84    /// Body `script = ExtResource("id")`.
85    pub script: Option<ExtId>,
86    /// Header `instance=ExtResource("id")` (an instanced sub-scene; its type comes from that scene).
87    pub instance: Option<ExtId>,
88    /// `instance=` on a parent-less node ⇒ an *inherited* scene (`set_base_scene`), not a child.
89    pub instance_is_inherited_root: bool,
90    /// `instance_placeholder="res://…"` (the lazy-instance variant).
91    pub instance_placeholder: bool,
92    /// Body `unique_name_in_owner = true` (the `%Name` marker — distinct from the header `unique_id`).
93    pub unique_name_in_owner: bool,
94    /// Byte span of the whole `[node …]` header line (coarse go-to-definition).
95    pub header_span: TextRange,
96    /// Byte span of the `name="…"` value (finer go-to-definition / highlight).
97    pub name_span: TextRange,
98}
99
100/// An `[ext_resource …]` declaration.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ExtResource {
103    /// `type="Script" | "PackedScene" | "Texture2D" | …`.
104    pub res_type: SmolStr,
105    /// `path="res://…"`, if present (prefer this over `uid` in the slice).
106    pub path: Option<SmolStr>,
107    /// `uid="uid://…"`, if present (resolved via the project UID map in M1).
108    pub uid: Option<SmolStr>,
109    /// Byte span of the `[ext_resource …]` header line.
110    pub span: TextRange,
111}
112
113/// A `[sub_resource …]` declaration (type only — the value body is skipped).
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct SubResource {
116    /// `type="…"`.
117    pub res_type: SmolStr,
118    /// Byte span of the header line.
119    pub span: TextRange,
120}
121
122/// A non-fatal problem found during parsing. The parser records these and keeps going — the floor
123/// is always parity with the engine's `Node`-everywhere baseline.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum SceneProblem {
126    /// A binary `.scn`/`.res` (RSRC/RSCC magic) — detected and degraded to an empty model.
127    BinaryResource,
128    /// A section tag not in the 8 the engine recognizes — the section was skipped.
129    UnknownTag {
130        /// The header line span.
131        at: TextRange,
132    },
133    /// A bracketed header that could not be lexed — the section was skipped.
134    MalformedHeader {
135        /// The (best-effort) header span.
136        at: TextRange,
137    },
138    /// An `ext_resource` missing a required `type`/`path`/`id`.
139    MissingExtField {
140        /// The header line span.
141        at: TextRange,
142    },
143    /// A `script=`/`instance=` referencing an id with no matching `ext_resource`.
144    UnknownExtResource {
145        /// The dangling id.
146        id: ExtId,
147        /// The referencing node's header span.
148        at: TextRange,
149    },
150    /// More than one parent-less node (the first is kept as root).
151    MultipleRoots {
152        /// All parent-less nodes.
153        roots: Vec<NodeIdx>,
154    },
155    /// A scene with `[node]`s but no parent-less node.
156    NoRoot,
157    /// A node whose `parent="…"` path resolves to no known node.
158    DanglingParent {
159        /// The orphaned node.
160        node: NodeIdx,
161        /// The unresolved parent path.
162        parent_path: SmolStr,
163    },
164}
165
166impl SceneModel {
167    /// An empty model of `kind` (the degrade target).
168    #[must_use]
169    pub(crate) fn empty(kind: SceneKind) -> Self {
170        Self {
171            kind,
172            format: None,
173            uid: None,
174            script_class: None,
175            resource_type: None,
176            ext_resources: FxHashMap::default(),
177            sub_resources: FxHashMap::default(),
178            nodes: Vec::new(),
179            root: None,
180            by_path: FxHashMap::default(),
181            unique_nodes: FxHashMap::default(),
182            problems: Vec::new(),
183            child_index: FxHashMap::default(),
184            children: FxHashMap::default(),
185        }
186    }
187
188    /// Set the pass-2-built navigation indices (called by the parser).
189    pub(crate) fn set_indices(
190        &mut self,
191        child_index: FxHashMap<(NodeIdx, SmolStr), NodeIdx>,
192        children: FxHashMap<NodeIdx, Vec<NodeIdx>>,
193    ) {
194        self.child_index = child_index;
195        self.children = children;
196    }
197
198    /// The node at `idx`, if it exists.
199    #[must_use]
200    pub fn node(&self, idx: NodeIdx) -> Option<&SceneNode> {
201        self.nodes.get(idx.0 as usize)
202    }
203
204    /// Walk a name-path from the scene root. `""`/`"."` ⇒ the root. `None` ⇒ no such node (M1 reads
205    /// that as "degrade to `Node`", never an error). A leading `/` (absolute) or a `..` segment is
206    /// out of the slice and yields `None`.
207    #[must_use]
208    pub fn resolve_path(&self, path: &str) -> Option<NodeIdx> {
209        self.resolve_path_from(self.root?, path)
210    }
211
212    /// Walk a name-path from an arbitrary `base` node (the node a script attaches to — `$X` is
213    /// relative to *that* node, which is usually but not always the root). A `%Name` segment is a
214    /// **unique-name** lookup (scene-wide, owner-relative), so the idiomatic `Foo/%Bar` and the
215    /// string forms `$"%Bar"` / `get_node("%Bar")` resolve like the engine; a plain segment is a
216    /// child lookup.
217    #[must_use]
218    pub fn resolve_path_from(&self, base: NodeIdx, path: &str) -> Option<NodeIdx> {
219        let p = path.trim();
220        if p.is_empty() || p == "." {
221            return Some(base);
222        }
223        if p.starts_with('/') {
224            return None; // absolute /root/... — out of the slice
225        }
226        let mut cur = base;
227        for seg in p.split('/') {
228            if seg.is_empty() || seg == "." {
229                continue;
230            }
231            if seg == ".." {
232                return None; // parent escape — needs the runtime tree
233            }
234            cur = self.step_segment(cur, seg)?;
235        }
236        Some(cur)
237    }
238
239    /// Resolve one path segment from `cur`: a `%Name` segment is a scene-wide unique-name lookup
240    /// (the `cur` base is irrelevant for it); a plain `Name` segment is a child of `cur`.
241    fn step_segment(&self, cur: NodeIdx, seg: &str) -> Option<NodeIdx> {
242        if let Some(unique) = seg.strip_prefix('%') {
243            self.unique_nodes.get(unique).copied()
244        } else {
245            self.child_index.get(&(cur, SmolStr::new(seg))).copied()
246        }
247    }
248
249    /// Resolve a `%`-sigil path (`%Name` / `%Name/Child/…`). The leading segment is a unique name
250    /// even though the `%` sigil was a separate token (so it isn't in `path`); subsequent segments
251    /// walk as normal children. Delegates to the `%`-aware [`resolve_path_from`](Self::resolve_path_from).
252    #[must_use]
253    pub fn resolve_unique(&self, path: &str) -> Option<NodeIdx> {
254        self.resolve_path_from(self.root?, &Self::with_unique_head(path))
255    }
256
257    /// Mark the first segment of a `%`-sigil path as a unique name (`"Box/Btn"` → `"%Box/Btn"`),
258    /// leaving an already-`%`-prefixed path untouched.
259    fn with_unique_head(path: &str) -> String {
260        if path.starts_with('%') {
261            path.to_owned()
262        } else {
263            format!("%{path}")
264        }
265    }
266
267    /// The node whose body `script = ExtResource(id)` resolves (via `ext_resources[id].path`) to
268    /// `script_path` — the per-scene half of the script↔scene association.
269    #[must_use]
270    pub fn node_with_script(&self, script_path: &str) -> Option<NodeIdx> {
271        self.nodes.iter().enumerate().find_map(|(i, n)| {
272            let ext = self.ext_resources.get(n.script.as_ref()?)?;
273            (ext.path.as_deref() == Some(script_path))
274                .then(|| NodeIdx(u32::try_from(i).unwrap_or(u32::MAX)))
275        })
276    }
277
278    /// The child nodes of `idx` (`None` ⇒ the root's children), in file order.
279    pub fn children_of(&self, idx: Option<NodeIdx>) -> impl Iterator<Item = (NodeIdx, &SceneNode)> {
280        idx.or(self.root)
281            .and_then(|t| self.children.get(&t))
282            .into_iter()
283            .flatten()
284            .filter_map(move |&c| self.node(c).map(|n| (c, n)))
285    }
286
287    /// Resolve a name-path from `base`, distinguishing the *reason* a path doesn't resolve — so a
288    /// caller can warn on a genuine [`Missing`](NodePathResolution::Missing) node while staying
289    /// silent on an [`Escaped`](NodePathResolution::Escaped) (`..`/absolute) or an
290    /// [`IntoInstance`](NodePathResolution::IntoInstance) override (the M1 typing uses
291    /// [`resolve_path_from`](Self::resolve_path_from); this is for the `INVALID_NODE_PATH` decision).
292    #[must_use]
293    pub fn classify_path_from(&self, base: NodeIdx, path: &str) -> NodePathResolution {
294        let p = path.trim();
295        if p.is_empty() || p == "." {
296            return NodePathResolution::Resolved(base);
297        }
298        if p.starts_with('/') {
299            return NodePathResolution::Escaped; // absolute `/root/…`
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 NodePathResolution::Escaped;
308            }
309            match self.step_segment(cur, seg) {
310                Some(next) => cur = next,
311                None => {
312                    // A `%Name` segment is scene-wide with no instance boundary, so a miss is a
313                    // genuine `Missing`; a plain child miss below an instance is `IntoInstance`.
314                    return if !seg.starts_with('%') && self.descends_from_instance(Some(cur)) {
315                        NodePathResolution::IntoInstance
316                    } else {
317                        NodePathResolution::Missing
318                    };
319                }
320            }
321        }
322        NodePathResolution::Resolved(cur)
323    }
324
325    /// Resolve a `%`-sigil path (`%Name` / `%Name/Child`). The leading segment is a unique name
326    /// (the `%` sigil was a separate token, so it isn't in `path`); subsequent segments walk as
327    /// children. A missing leading unique name is genuinely [`Missing`](NodePathResolution::Missing)
328    /// (no instance ambiguity — `%` is scene-wide).
329    #[must_use]
330    pub fn classify_unique(&self, path: &str) -> NodePathResolution {
331        match self.root {
332            Some(root) => self.classify_path_from(root, &Self::with_unique_head(path)),
333            None => NodePathResolution::Missing,
334        }
335    }
336
337    /// Whether `start` or any ancestor (up to the root) is an instance boundary (`instance=` /
338    /// `instance_placeholder` / an inherited-scene root) — i.e. a missing tail below it lives in a
339    /// sub-scene we don't recurse into, not a genuine dangling/missing node. Depth-bounded.
340    pub(crate) fn descends_from_instance(&self, start: Option<NodeIdx>) -> bool {
341        let mut cur = start;
342        let mut guard = 0u32;
343        while let Some(c) = cur {
344            let Some(node) = self.nodes.get(c.0 as usize) else {
345                break;
346            };
347            if node.instance.is_some()
348                || node.instance_placeholder
349                || node.instance_is_inherited_root
350            {
351                return true;
352            }
353            cur = node.parent_idx;
354            guard += 1;
355            if guard > 4096 {
356                break;
357            }
358        }
359        false
360    }
361}
362
363/// The reason a node path did (not) resolve — for the `INVALID_NODE_PATH` decision (M2).
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum NodePathResolution {
366    /// Resolved to a concrete node.
367    Resolved(NodeIdx),
368    /// The path escapes the scene (`..` / absolute `/root/…`) — out of the slice; never warn.
369    Escaped,
370    /// The miss descends into an instanced/inherited sub-scene we don't recurse into; never warn.
371    IntoInstance,
372    /// A genuinely absent in-scene node — the `INVALID_NODE_PATH` trigger.
373    Missing,
374}