1use smol_str::SmolStr;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct AutoloadEntry {
19 pub name: SmolStr,
21 pub path: SmolStr,
23 pub is_singleton: bool,
26}
27
28#[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 if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
38 continue;
39 }
40 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 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 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#[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 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
115fn 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
124fn 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 assert_eq!(e[1].name, "Music");
179 assert_eq!(e[1].path, "res://music.tscn");
180 }
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 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 assert_eq!(
226 parse_engine_version("[rendering]\nconfig/features=PackedStringArray(\"4.6\")\n"),
227 None,
228 );
229 assert_eq!(
231 parse_engine_version("[application]\nconfig/features=PackedStringArray(\"Vulkan\")\n"),
232 None,
233 );
234 }
235}