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}