1use proc_macro::TokenStream;
2use quote::quote;
3use std::path::{Path, PathBuf};
4use syn::Ident;
5
6#[proc_macro]
7pub fn embed_spa(input: TokenStream) -> TokenStream {
8 let input_str = input.to_string().trim().to_string();
9
10 let (identifier, output_dir): (String, String) = if input_str.is_empty() {
14 ("__default".to_string(), find_output_dir_from_config(None))
16 } else {
17 let arg = input_str.trim_matches('"');
18 if arg.contains('/') {
19 let identifier = derive_name_from_path(arg);
21 let output_dir = infer_output_dir_from_path(arg);
22 (identifier, output_dir)
23 } else {
24 let identifier = sanitize_name(arg);
26 let output_dir = find_output_dir_from_config(Some(arg));
27 (identifier, output_dir)
28 }
29 };
30
31 let name_ident = syn::parse_str::<Ident>(&identifier).unwrap();
32
33 let expanded = quote! {
34 {
35 ::heisenberg::paste::paste! {
36 #[derive(::heisenberg::rust_embed::RustEmbed)]
37 #[folder = #output_dir]
38 struct [<__HeisenbergEmbeddedAssets_ #name_ident>];
39
40 #[::heisenberg::ctor::ctor]
41 fn [<__register_heisenberg_assets_ #name_ident>]() {
42 use ::heisenberg::rust_embed::RustEmbed;
43 ::heisenberg::services::embed_registry::register_embedded_assets(
44 #output_dir,
45 |path: &str| [<__HeisenbergEmbeddedAssets_ #name_ident>]::get(path).map(|f| f.data.to_vec()),
46 );
47 }
48 }
49
50 ::heisenberg::EmbeddedSpa::new(#output_dir, "")
51 }
52 };
53
54 TokenStream::from(expanded)
55}
56
57fn find_output_dir_from_config(spa_name: Option<&str>) -> String {
59 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
60 let config_path = PathBuf::from(&manifest_dir).join("heisenberg.toml");
61
62 if config_path.exists() {
63 if let Ok(content) = std::fs::read_to_string(&config_path) {
64 if let Ok(config) = toml::from_str::<toml::Value>(&content) {
65 if let Some(output) = find_matching_spa(&config, spa_name) {
66 return output;
67 }
68 }
69 }
70 }
71
72 panic!(
74 "embed_spa!(\"{}\") requires a matching entry in heisenberg.toml",
75 spa_name.unwrap_or("default")
76 );
77}
78
79fn infer_output_dir_from_path(path: &str) -> String {
81 let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
82 let full_path = PathBuf::from(&manifest_dir).join(path);
83
84 if full_path.join("package.json").exists() {
86 let output_subdir = find_output_subdir(&full_path);
87 return format!("{}/{}", path, output_subdir);
88 }
89
90 path.to_string()
92}
93
94fn find_output_subdir(working_dir: &Path) -> String {
95 for output_name in &["build", "dist", ".next", ".svelte-kit/output"] {
97 if working_dir.join(output_name).exists() {
98 return output_name.to_string();
99 }
100 }
101 "dist".to_string()
103}
104
105fn find_matching_spa(config: &toml::Value, spa_name: Option<&str>) -> Option<String> {
106 match spa_name {
107 None => {
108 if let Some(spa) = config.get("spa").and_then(|v| v.as_table()) {
110 return spa
111 .get("output_dir")
112 .and_then(|v| v.as_str())
113 .map(String::from);
114 }
115 }
116 Some(name) => {
117 if let Some(spas) = config.get("spa").and_then(|v| v.as_array()) {
119 for spa in spas {
120 if let Some(spa_table) = spa.as_table() {
121 if spa_table.get("name").and_then(|v| v.as_str()) == Some(name) {
122 return spa_table
123 .get("output_dir")
124 .and_then(|v| v.as_str())
125 .map(String::from);
126 }
127 }
128 }
129 }
130 }
131 }
132 None
133}
134
135fn sanitize_name(name: &str) -> String {
136 name.replace(['-', '.'], "_")
137}
138
139fn derive_name_from_path(path: &str) -> String {
140 path.chars()
144 .map(|c| if c.is_alphanumeric() { c } else { '_' })
145 .collect::<String>()
146 .trim_matches('_')
147 .to_string()
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use std::fs;
154 use std::sync::Mutex;
155 use tempfile::TempDir;
156
157 #[test]
158 fn test_sanitize_name() {
159 assert_eq!(sanitize_name("my-app.v2"), "my_app_v2");
160 }
161
162 #[test]
163 fn test_derive_name_from_path() {
164 assert_eq!(derive_name_from_path("./my-app/dist"), "my_app_dist");
165 }
166
167 mod find_matching_spa_tests {
168 use super::*;
169
170 #[test]
171 fn single_spa_table() {
172 let config: toml::Value = toml::from_str("[spa]\noutput_dir = \"./dist\"").unwrap();
173 assert_eq!(find_matching_spa(&config, None), Some("./dist".to_string()));
174 assert_eq!(find_matching_spa(&config, Some("foo")), None);
176 }
177
178 #[test]
179 fn spa_array_with_names() {
180 let config: toml::Value = toml::from_str(
181 r#"
182 [[spa]]
183 name = "frontend"
184 output_dir = "./frontend/dist"
185
186 [[spa]]
187 name = "admin"
188 output_dir = "./admin/dist"
189 "#,
190 )
191 .unwrap();
192
193 assert_eq!(
194 find_matching_spa(&config, Some("frontend")),
195 Some("./frontend/dist".to_string())
196 );
197 assert_eq!(
198 find_matching_spa(&config, Some("admin")),
199 Some("./admin/dist".to_string())
200 );
201 assert_eq!(find_matching_spa(&config, Some("backend")), None);
203 assert_eq!(find_matching_spa(&config, None), None);
205 }
206
207 #[test]
208 fn returns_none_for_missing_or_incomplete_config() {
209 let empty: toml::Value = toml::from_str("").unwrap();
210 assert_eq!(find_matching_spa(&empty, None), None);
211
212 let no_output: toml::Value = toml::from_str("[spa]\nname = \"app\"").unwrap();
213 assert_eq!(find_matching_spa(&no_output, None), None);
214 }
215 }
216
217 mod find_output_subdir_tests {
218 use super::*;
219
220 #[test]
221 fn detects_framework_output_dirs() {
222 let temp_dir = TempDir::new().unwrap();
223
224 assert_eq!(find_output_subdir(temp_dir.path()), "dist");
226
227 fs::create_dir(temp_dir.path().join(".next")).unwrap();
229 assert_eq!(find_output_subdir(temp_dir.path()), ".next");
230
231 fs::create_dir(temp_dir.path().join("dist")).unwrap();
232 assert_eq!(find_output_subdir(temp_dir.path()), "dist"); fs::create_dir(temp_dir.path().join("build")).unwrap();
235 assert_eq!(find_output_subdir(temp_dir.path()), "build"); }
237
238 #[test]
239 fn finds_sveltekit_directory() {
240 let temp_dir = TempDir::new().unwrap();
241 fs::create_dir_all(temp_dir.path().join(".svelte-kit/output")).unwrap();
242 assert_eq!(find_output_subdir(temp_dir.path()), ".svelte-kit/output");
243 }
244 }
245
246 mod find_output_dir_from_config_tests {
247 use super::*;
248
249 static ENV_MUTEX: Mutex<()> = Mutex::new(());
250
251 fn acquire_lock() -> std::sync::MutexGuard<'static, ()> {
252 ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
253 }
254
255 struct EnvGuard {
256 key: &'static str,
257 original: Option<String>,
258 }
259
260 impl EnvGuard {
261 fn new(key: &'static str, value: &str) -> Self {
262 let original = std::env::var(key).ok();
263 std::env::set_var(key, value);
264 Self { key, original }
265 }
266 }
267
268 impl Drop for EnvGuard {
269 fn drop(&mut self) {
270 match &self.original {
271 Some(val) => std::env::set_var(self.key, val),
272 None => std::env::remove_var(self.key),
273 }
274 }
275 }
276
277 #[test]
278 fn reads_from_heisenberg_toml() {
279 let _lock = acquire_lock();
280 let temp_dir = TempDir::new().unwrap();
281 fs::write(
282 temp_dir.path().join("heisenberg.toml"),
283 "[spa]\noutput_dir = \"./my-app/dist\"",
284 )
285 .unwrap();
286
287 let _guard = EnvGuard::new("CARGO_MANIFEST_DIR", temp_dir.path().to_str().unwrap());
288 assert_eq!(find_output_dir_from_config(None), "./my-app/dist");
289 }
290
291 #[test]
292 #[should_panic(expected = "requires a matching entry in heisenberg.toml")]
293 fn panics_when_config_missing() {
294 let _lock = acquire_lock();
295 let temp_dir = TempDir::new().unwrap();
296 let _guard = EnvGuard::new("CARGO_MANIFEST_DIR", temp_dir.path().to_str().unwrap());
297 find_output_dir_from_config(None);
298 }
299
300 #[test]
301 #[should_panic(expected = "requires a matching entry in heisenberg.toml")]
302 fn panics_when_spa_not_found() {
303 let _lock = acquire_lock();
304 let temp_dir = TempDir::new().unwrap();
305 fs::write(
306 temp_dir.path().join("heisenberg.toml"),
307 "[[spa]]\nname = \"other\"\noutput_dir = \"./x\"",
308 )
309 .unwrap();
310
311 let _guard = EnvGuard::new("CARGO_MANIFEST_DIR", temp_dir.path().to_str().unwrap());
312 find_output_dir_from_config(Some("nonexistent"));
313 }
314 }
315}