Skip to main content

heisenberg_macros/
lib.rs

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    // Two modes:
11    // 1. "name" (no slash) - always looks up in heisenberg.toml
12    // 2. "./path" (has slash) - always infers config from scanning the path
13    let (identifier, output_dir): (String, String) = if input_str.is_empty() {
14        // No argument - use default SPA from config
15        ("__default".to_string(), find_output_dir_from_config(None))
16    } else {
17        let arg = input_str.trim_matches('"');
18        if arg.contains('/') {
19            // Path mode: infer everything from scanning the directory
20            let identifier = derive_name_from_path(arg);
21            let output_dir = infer_output_dir_from_path(arg);
22            (identifier, output_dir)
23        } else {
24            // Name mode: look up in heisenberg.toml
25            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
57/// Name mode: look up output_dir from heisenberg.toml
58fn 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    // Fallback if config not found or SPA not in config
73    panic!(
74        "embed_spa!(\"{}\") requires a matching entry in heisenberg.toml",
75        spa_name.unwrap_or("default")
76    );
77}
78
79/// Path mode: infer output_dir by scanning the directory
80fn 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 path points to a working directory (has package.json), find output subdir
85    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    // Otherwise assume path is already the output directory
91    path.to_string()
92}
93
94fn find_output_subdir(working_dir: &Path) -> String {
95    // Check common output directories in order of preference
96    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    // Default to dist if nothing exists yet
102    "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            // No name provided - look for single [spa]
109            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            // Name provided - look for matching [[spa]] with name field
118            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    // Sanitize entire path to create a unique identifier
141    // "./admin-webapp" -> "admin_webapp"
142    // "./frontend/admin" -> "frontend_admin"
143    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            // Single [spa] doesn't support name lookup
175            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            // Nonexistent name
202            assert_eq!(find_matching_spa(&config, Some("backend")), None);
203            // No name arg with array format
204            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            // Default when nothing exists
225            assert_eq!(find_output_subdir(temp_dir.path()), "dist");
226
227            // Create dirs and verify priority order
228            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"); // dist > .next
233
234            fs::create_dir(temp_dir.path().join("build")).unwrap();
235            assert_eq!(find_output_subdir(temp_dir.path()), "build"); // build > dist
236        }
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}