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
16use crate::warnings::{WarnLevel, WarningCode, WarningSettings};
17
18/// One `[autoload]` entry from `project.godot`.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct AutoloadEntry {
21    /// The autoload (and, when `is_singleton`, the global identifier) name — the bare LHS.
22    pub name: SmolStr,
23    /// The resource path, with the leading `*` already stripped (e.g. `res://game.gd`).
24    pub path: SmolStr,
25    /// Whether the entry was `*`-flagged: a **global singleton** (the bare name resolves in code).
26    /// A non-singleton autoload is loaded-but-not-global.
27    pub is_singleton: bool,
28}
29
30/// Parse the `[autoload]` entries from `project.godot` text. Robust to comments, blank lines, and
31/// other sections; never panics on malformed input (a bad line is skipped).
32#[must_use]
33pub fn parse_autoloads(text: &str) -> Vec<AutoloadEntry> {
34    let mut entries = Vec::new();
35    let mut in_autoload = false;
36    for raw_line in text.lines() {
37        let line = raw_line.trim();
38        // Skip blanks and `;`-comments (ConfigFile comment marker).
39        if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
40            continue;
41        }
42        // A `[section]` header switches context.
43        if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
44            in_autoload = inner.trim() == "autoload";
45            continue;
46        }
47        if !in_autoload {
48            continue;
49        }
50        // `Name="*res://path"` — split on the first `=`.
51        let Some((name, value)) = line.split_once('=') else {
52            continue;
53        };
54        let name = name.trim();
55        if name.is_empty() {
56            continue;
57        }
58        // Dequote the value (real files always quote the path); then strip the `*` singleton flag.
59        let value = dequote(value.trim());
60        let (is_singleton, path) = match value.strip_prefix('*') {
61            Some(rest) => (true, rest),
62            None => (false, value),
63        };
64        if path.is_empty() {
65            continue;
66        }
67        entries.push(AutoloadEntry {
68            name: SmolStr::new(name),
69            path: SmolStr::new(path),
70            is_singleton,
71        });
72    }
73    entries
74}
75
76/// Parse the Godot engine `(major, minor)` version from `project.godot`'s `[application]`
77/// `config/features=PackedStringArray("4.3", "Forward Plus", …)` line. Godot writes the engine
78/// version as the first `<major>.<minor>` entry of that array; the rest are rendering/feature tags.
79/// Returns the first version-shaped entry, or `None` if the line is absent or carries none. A
80/// deliberate minimal scan (not a `VariantParser` port); robust to malformed input (never panics).
81#[must_use]
82pub fn parse_engine_version(text: &str) -> Option<(u32, u32)> {
83    let mut in_application = false;
84    for raw_line in text.lines() {
85        let line = raw_line.trim();
86        if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
87            continue;
88        }
89        if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
90            in_application = inner.trim() == "application";
91            continue;
92        }
93        if !in_application {
94            continue;
95        }
96        let Some((key, value)) = line.split_once('=') else {
97            continue;
98        };
99        if key.trim() != "config/features" {
100            continue;
101        }
102        // `PackedStringArray("4.3", "Forward Plus")` → the parenthesized list (tolerate a bare
103        // quoted value, and the Godot-3.x `PoolStringArray` name, defensively).
104        let value = value.trim();
105        let inner = value
106            .strip_prefix("PackedStringArray(")
107            .or_else(|| value.strip_prefix("PoolStringArray("))
108            .and_then(|s| s.strip_suffix(')'))
109            .unwrap_or(value);
110        return inner
111            .split(',')
112            .find_map(|part| parse_major_minor(dequote(part.trim())));
113    }
114    None
115}
116
117/// Parse the `debug/gdscript/warnings/*` settings from `project.godot` into a [`WarningSettings`],
118/// starting from the engine default for `engine`. Keys live under `[debug]` as
119/// `gdscript/warnings/<tail>` (Godot groups a setting by its first path segment). `<tail>` is
120/// `enable` / `treat_warnings_as_errors` / `exclude_addons` (bools) or a code's lowercased
121/// setting-name mapped to a `0|1|2` (Ignore/Warn/Error) level. A deliberate minimal scan (not a
122/// `VariantParser` port); robust to malformed input (a bad line is skipped). `directory_rules`
123/// (Godot master, a typed-Variant dict) is not parsed — see `TECH_DEBT.md`.
124#[must_use]
125pub fn parse_warning_settings(text: &str, engine: (u32, u32)) -> WarningSettings {
126    let mut settings = WarningSettings::engine_default(engine);
127    let mut in_debug = false;
128    for raw_line in text.lines() {
129        let line = raw_line.trim();
130        if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
131            continue;
132        }
133        if let Some(inner) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
134            in_debug = inner.trim() == "debug";
135            continue;
136        }
137        if !in_debug {
138            continue;
139        }
140        let Some((key, value)) = line.split_once('=') else {
141            continue;
142        };
143        let Some(tail) = key.trim().strip_prefix("gdscript/warnings/") else {
144            continue;
145        };
146        let value = dequote(value.trim());
147        match tail {
148            "enable" => {
149                if let Some(b) = parse_bool(value) {
150                    settings.enabled = b;
151                }
152            }
153            "treat_warnings_as_errors" => {
154                if let Some(b) = parse_bool(value) {
155                    settings.treat_as_errors = b;
156                }
157            }
158            "exclude_addons" => {
159                if let Some(b) = parse_bool(value) {
160                    settings.exclude_addons = b;
161                }
162            }
163            _ => {
164                if let Some(code) = WarningCode::from_setting_name(tail)
165                    && let Some(level) = parse_warn_level(value)
166                {
167                    settings.per_code.insert(code, level);
168                }
169            }
170        }
171    }
172    settings
173}
174
175/// Parse a Godot `ConfigFile` boolean (`true`/`false`), or `None`.
176fn parse_bool(s: &str) -> Option<bool> {
177    match s.trim() {
178        "true" => Some(true),
179        "false" => Some(false),
180        _ => None,
181    }
182}
183
184/// Parse a warning level: the `0|1|2` int Godot 4.x writes, tolerating a legacy `true`/`false`.
185fn parse_warn_level(s: &str) -> Option<WarnLevel> {
186    let s = s.trim();
187    if let Ok(n) = s.parse::<u32>() {
188        return WarnLevel::from_int(n);
189    }
190    match s {
191        "true" => Some(WarnLevel::Warn),
192        "false" => Some(WarnLevel::Ignore),
193        _ => None,
194    }
195}
196
197/// Parse a `<major>.<minor>` string (ignoring any trailing `.patch`) into `(major, minor)`.
198/// `None` for any non-numeric or single-component string (e.g. a feature tag like `"Vulkan"`).
199fn parse_major_minor(s: &str) -> Option<(u32, u32)> {
200    let mut parts = s.split('.');
201    let major = parts.next()?.parse::<u32>().ok()?;
202    let minor = parts.next()?.parse::<u32>().ok()?;
203    Some((major, minor))
204}
205
206/// Strip one layer of matching surrounding quotes (`"…"` / `'…'`), else return as-is.
207fn dequote(s: &str) -> &str {
208    let bytes = s.as_bytes();
209    if bytes.len() >= 2
210        && (bytes[0] == b'"' || bytes[0] == b'\'')
211        && bytes[bytes.len() - 1] == bytes[0]
212    {
213        &s[1..s.len() - 1]
214    } else {
215        s
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn parses_singleton_and_strips_star() {
225        let e = parse_autoloads("[autoload]\nGame=\"*res://game.gd\"\n");
226        assert_eq!(e.len(), 1);
227        assert_eq!(e[0].name, "Game");
228        assert_eq!(e[0].path, "res://game.gd");
229        assert!(e[0].is_singleton);
230    }
231
232    #[test]
233    fn non_star_is_not_a_singleton() {
234        let e = parse_autoloads("[autoload]\nHelper=\"res://helper.gd\"\n");
235        assert_eq!(e.len(), 1);
236        assert_eq!(e[0].path, "res://helper.gd");
237        assert!(!e[0].is_singleton, "no leading * → loaded-but-not-global");
238    }
239
240    #[test]
241    fn only_the_autoload_section_is_read() {
242        let src = "config_version=5\n\
243            [application]\n\
244            config/name=\"Demo\"\n\
245            config/features=PackedStringArray(\"4.6\")\n\
246            \n\
247            [autoload]\n\
248            ; a comment\n\
249            Log=\"*res://utils/system_log.gd\"\n\
250            Music=\"*res://music.tscn\"\n\
251            \n\
252            [rendering]\n\
253            renderer/rendering_method=\"gl_compatibility\"\n";
254        let e = parse_autoloads(src);
255        assert_eq!(e.len(), 2);
256        assert_eq!(e[0].name, "Log");
257        assert_eq!(e[0].path, "res://utils/system_log.gd");
258        assert!(e[0].is_singleton);
259        // A `.tscn` (PackedScene) autoload is captured the same way (typed at resolution time).
260        assert_eq!(e[1].name, "Music");
261        assert_eq!(e[1].path, "res://music.tscn");
262        // The `config/name` line under [application] is NOT mistaken for an autoload.
263    }
264
265    #[test]
266    fn empty_or_no_autoload_section_is_empty() {
267        assert!(parse_autoloads("").is_empty());
268        assert!(parse_autoloads("[application]\nconfig/name=\"X\"\n").is_empty());
269    }
270
271    #[test]
272    fn parses_engine_version_from_config_features() {
273        let src = "config_version=5\n\
274            [application]\n\
275            config/name=\"Demo\"\n\
276            config/features=PackedStringArray(\"4.3\", \"Forward Plus\")\n";
277        assert_eq!(parse_engine_version(src), Some((4, 3)));
278    }
279
280    #[test]
281    fn engine_version_picks_the_version_shaped_entry_anywhere_in_the_array() {
282        // The version need not be first; feature tags (rendering, etc.) are skipped.
283        let src = "[application]\nconfig/features=PackedStringArray(\"Forward Plus\", \"4.6\", \"Mobile\")\n";
284        assert_eq!(parse_engine_version(src), Some((4, 6)));
285    }
286
287    #[test]
288    fn engine_version_ignores_patch_and_tolerates_bare_value() {
289        assert_eq!(
290            parse_engine_version("[application]\nconfig/features=PackedStringArray(\"4.2.1\")\n"),
291            Some((4, 2)),
292        );
293        assert_eq!(
294            parse_engine_version("[application]\nconfig/features=\"4.5\"\n"),
295            Some((4, 5)),
296        );
297    }
298
299    #[test]
300    fn engine_version_none_when_absent_or_no_version_entry() {
301        assert_eq!(parse_engine_version(""), None);
302        assert_eq!(
303            parse_engine_version("[application]\nconfig/name=\"X\"\n"),
304            None
305        );
306        // config/features outside [application] is not the engine version.
307        assert_eq!(
308            parse_engine_version("[rendering]\nconfig/features=PackedStringArray(\"4.6\")\n"),
309            None,
310        );
311        // A features array with no version-shaped entry → None (not a panic).
312        assert_eq!(
313            parse_engine_version("[application]\nconfig/features=PackedStringArray(\"Vulkan\")\n"),
314            None,
315        );
316    }
317}