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}