Skip to main content

gdscript_hir/
project.rs

1//! `project.godot` autoload parsing (Playbook §3.M4).
2//!
3//! `project.godot` is a Godot `ConfigFile` (INI-like: `[section]` headers, `key=value`,
4//! typed-Variant values). We do **not** evaluate full Variant values — the analyzer needs only
5//! the `[autoload]` section, which is line-oriented `Name="*res://path"`. This is a deliberate
6//! minimal scan (not a `VariantParser` port): track the current `[section]`, and within
7//! `[autoload]` split each line on the first `=`, take the bare LHS as the autoload name and the
8//! dequoted RHS as the resource path. The leading `*` on the path is the **singleton/global**
9//! flag (`project_settings.cpp`: `begins_with("*")` → `is_singleton`, then `substr(1)` strips it);
10//! a non-`*` autoload is loaded at `/root/Name` but is **not** a global identifier.
11//!
12//! Pure (`fn(&str) -> Vec<AutoloadEntry>`) and wasm-clean — the host injects the text.
13
14use smol_str::SmolStr;
15
16/// One `[autoload]` entry from `project.godot`.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct AutoloadEntry {
19    /// The autoload (and, when `is_singleton`, the global identifier) name — the bare LHS.
20    pub name: SmolStr,
21    /// The resource path, with the leading `*` already stripped (e.g. `res://game.gd`).
22    pub path: SmolStr,
23    /// Whether the entry was `*`-flagged: a **global singleton** (the bare name resolves in code).
24    /// A non-singleton autoload is loaded-but-not-global.
25    pub is_singleton: bool,
26}
27
28/// Parse the `[autoload]` entries from `project.godot` text. Robust to comments, blank lines, and
29/// other sections; never panics on malformed input (a bad line is skipped).
30#[must_use]
31pub fn parse_autoloads(text: &str) -> Vec<AutoloadEntry> {
32    let mut entries = Vec::new();
33    let mut in_autoload = false;
34    for raw_line in text.lines() {
35        let line = raw_line.trim();
36        // Skip blanks and `;`-comments (ConfigFile comment marker).
37        if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
38            continue;
39        }
40        // A `[section]` header switches context.
41        if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
42            in_autoload = inner.trim() == "autoload";
43            continue;
44        }
45        if !in_autoload {
46            continue;
47        }
48        // `Name="*res://path"` — split on the first `=`.
49        let Some((name, value)) = line.split_once('=') else {
50            continue;
51        };
52        let name = name.trim();
53        if name.is_empty() {
54            continue;
55        }
56        // Dequote the value (real files always quote the path); then strip the `*` singleton flag.
57        let value = dequote(value.trim());
58        let (is_singleton, path) = match value.strip_prefix('*') {
59            Some(rest) => (true, rest),
60            None => (false, value),
61        };
62        if path.is_empty() {
63            continue;
64        }
65        entries.push(AutoloadEntry {
66            name: SmolStr::new(name),
67            path: SmolStr::new(path),
68            is_singleton,
69        });
70    }
71    entries
72}
73
74/// Parse the Godot engine `(major, minor)` version from `project.godot`'s `[application]`
75/// `config/features=PackedStringArray("4.3", "Forward Plus", …)` line. Godot writes the engine
76/// version as the first `<major>.<minor>` entry of that array; the rest are rendering/feature tags.
77/// Returns the first version-shaped entry, or `None` if the line is absent or carries none. A
78/// deliberate minimal scan (not a `VariantParser` port); robust to malformed input (never panics).
79#[must_use]
80pub fn parse_engine_version(text: &str) -> Option<(u32, u32)> {
81    let mut in_application = false;
82    for raw_line in text.lines() {
83        let line = raw_line.trim();
84        if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
85            continue;
86        }
87        if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
88            in_application = inner.trim() == "application";
89            continue;
90        }
91        if !in_application {
92            continue;
93        }
94        let Some((key, value)) = line.split_once('=') else {
95            continue;
96        };
97        if key.trim() != "config/features" {
98            continue;
99        }
100        // `PackedStringArray("4.3", "Forward Plus")` → the parenthesized list (tolerate a bare
101        // quoted value, and the Godot-3.x `PoolStringArray` name, defensively).
102        let value = value.trim();
103        let inner = value
104            .strip_prefix("PackedStringArray(")
105            .or_else(|| value.strip_prefix("PoolStringArray("))
106            .and_then(|s| s.strip_suffix(')'))
107            .unwrap_or(value);
108        return inner
109            .split(',')
110            .find_map(|part| parse_major_minor(dequote(part.trim())));
111    }
112    None
113}
114
115/// Parse a `<major>.<minor>` string (ignoring any trailing `.patch`) into `(major, minor)`.
116/// `None` for any non-numeric or single-component string (e.g. a feature tag like `"Vulkan"`).
117fn parse_major_minor(s: &str) -> Option<(u32, u32)> {
118    let mut parts = s.split('.');
119    let major = parts.next()?.parse::<u32>().ok()?;
120    let minor = parts.next()?.parse::<u32>().ok()?;
121    Some((major, minor))
122}
123
124/// Strip one layer of matching surrounding quotes (`"…"` / `'…'`), else return as-is.
125fn dequote(s: &str) -> &str {
126    let bytes = s.as_bytes();
127    if bytes.len() >= 2
128        && (bytes[0] == b'"' || bytes[0] == b'\'')
129        && bytes[bytes.len() - 1] == bytes[0]
130    {
131        &s[1..s.len() - 1]
132    } else {
133        s
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn parses_singleton_and_strips_star() {
143        let e = parse_autoloads("[autoload]\nGame=\"*res://game.gd\"\n");
144        assert_eq!(e.len(), 1);
145        assert_eq!(e[0].name, "Game");
146        assert_eq!(e[0].path, "res://game.gd");
147        assert!(e[0].is_singleton);
148    }
149
150    #[test]
151    fn non_star_is_not_a_singleton() {
152        let e = parse_autoloads("[autoload]\nHelper=\"res://helper.gd\"\n");
153        assert_eq!(e.len(), 1);
154        assert_eq!(e[0].path, "res://helper.gd");
155        assert!(!e[0].is_singleton, "no leading * → loaded-but-not-global");
156    }
157
158    #[test]
159    fn only_the_autoload_section_is_read() {
160        let src = "config_version=5\n\
161            [application]\n\
162            config/name=\"Demo\"\n\
163            config/features=PackedStringArray(\"4.6\")\n\
164            \n\
165            [autoload]\n\
166            ; a comment\n\
167            Log=\"*res://utils/system_log.gd\"\n\
168            Music=\"*res://music.tscn\"\n\
169            \n\
170            [rendering]\n\
171            renderer/rendering_method=\"gl_compatibility\"\n";
172        let e = parse_autoloads(src);
173        assert_eq!(e.len(), 2);
174        assert_eq!(e[0].name, "Log");
175        assert_eq!(e[0].path, "res://utils/system_log.gd");
176        assert!(e[0].is_singleton);
177        // A `.tscn` (PackedScene) autoload is captured the same way (typed at resolution time).
178        assert_eq!(e[1].name, "Music");
179        assert_eq!(e[1].path, "res://music.tscn");
180        // The `config/name` line under [application] is NOT mistaken for an autoload.
181    }
182
183    #[test]
184    fn empty_or_no_autoload_section_is_empty() {
185        assert!(parse_autoloads("").is_empty());
186        assert!(parse_autoloads("[application]\nconfig/name=\"X\"\n").is_empty());
187    }
188
189    #[test]
190    fn parses_engine_version_from_config_features() {
191        let src = "config_version=5\n\
192            [application]\n\
193            config/name=\"Demo\"\n\
194            config/features=PackedStringArray(\"4.3\", \"Forward Plus\")\n";
195        assert_eq!(parse_engine_version(src), Some((4, 3)));
196    }
197
198    #[test]
199    fn engine_version_picks_the_version_shaped_entry_anywhere_in_the_array() {
200        // The version need not be first; feature tags (rendering, etc.) are skipped.
201        let src = "[application]\nconfig/features=PackedStringArray(\"Forward Plus\", \"4.6\", \"Mobile\")\n";
202        assert_eq!(parse_engine_version(src), Some((4, 6)));
203    }
204
205    #[test]
206    fn engine_version_ignores_patch_and_tolerates_bare_value() {
207        assert_eq!(
208            parse_engine_version("[application]\nconfig/features=PackedStringArray(\"4.2.1\")\n"),
209            Some((4, 2)),
210        );
211        assert_eq!(
212            parse_engine_version("[application]\nconfig/features=\"4.5\"\n"),
213            Some((4, 5)),
214        );
215    }
216
217    #[test]
218    fn engine_version_none_when_absent_or_no_version_entry() {
219        assert_eq!(parse_engine_version(""), None);
220        assert_eq!(
221            parse_engine_version("[application]\nconfig/name=\"X\"\n"),
222            None
223        );
224        // config/features outside [application] is not the engine version.
225        assert_eq!(
226            parse_engine_version("[rendering]\nconfig/features=PackedStringArray(\"4.6\")\n"),
227            None,
228        );
229        // A features array with no version-shaped entry → None (not a panic).
230        assert_eq!(
231            parse_engine_version("[application]\nconfig/features=PackedStringArray(\"Vulkan\")\n"),
232            None,
233        );
234    }
235}