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/// Strip one layer of matching surrounding quotes (`"…"` / `'…'`), else return as-is.
75fn dequote(s: &str) -> &str {
76    let bytes = s.as_bytes();
77    if bytes.len() >= 2
78        && (bytes[0] == b'"' || bytes[0] == b'\'')
79        && bytes[bytes.len() - 1] == bytes[0]
80    {
81        &s[1..s.len() - 1]
82    } else {
83        s
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn parses_singleton_and_strips_star() {
93        let e = parse_autoloads("[autoload]\nGame=\"*res://game.gd\"\n");
94        assert_eq!(e.len(), 1);
95        assert_eq!(e[0].name, "Game");
96        assert_eq!(e[0].path, "res://game.gd");
97        assert!(e[0].is_singleton);
98    }
99
100    #[test]
101    fn non_star_is_not_a_singleton() {
102        let e = parse_autoloads("[autoload]\nHelper=\"res://helper.gd\"\n");
103        assert_eq!(e.len(), 1);
104        assert_eq!(e[0].path, "res://helper.gd");
105        assert!(!e[0].is_singleton, "no leading * → loaded-but-not-global");
106    }
107
108    #[test]
109    fn only_the_autoload_section_is_read() {
110        let src = "config_version=5\n\
111            [application]\n\
112            config/name=\"Demo\"\n\
113            config/features=PackedStringArray(\"4.6\")\n\
114            \n\
115            [autoload]\n\
116            ; a comment\n\
117            Log=\"*res://utils/system_log.gd\"\n\
118            Music=\"*res://music.tscn\"\n\
119            \n\
120            [rendering]\n\
121            renderer/rendering_method=\"gl_compatibility\"\n";
122        let e = parse_autoloads(src);
123        assert_eq!(e.len(), 2);
124        assert_eq!(e[0].name, "Log");
125        assert_eq!(e[0].path, "res://utils/system_log.gd");
126        assert!(e[0].is_singleton);
127        // A `.tscn` (PackedScene) autoload is captured the same way (typed at resolution time).
128        assert_eq!(e[1].name, "Music");
129        assert_eq!(e[1].path, "res://music.tscn");
130        // The `config/name` line under [application] is NOT mistaken for an autoload.
131    }
132
133    #[test]
134    fn empty_or_no_autoload_section_is_empty() {
135        assert!(parse_autoloads("").is_empty());
136        assert!(parse_autoloads("[application]\nconfig/name=\"X\"\n").is_empty());
137    }
138}