Skip to main content

weaveffi_core/
codegen.rs

1use anyhow::{bail, Result};
2use camino::Utf8Path;
3use rayon::prelude::*;
4use weaveffi_ir::ir::Api;
5
6use crate::cache;
7use crate::config::GeneratorConfig;
8use crate::templates::TemplateEngine;
9
10fn run_hook(label: &str, cmd: &str) -> Result<()> {
11    let status = if cfg!(target_os = "windows") {
12        std::process::Command::new("cmd")
13            .args(["/C", cmd])
14            .status()?
15    } else {
16        std::process::Command::new("sh")
17            .arg("-c")
18            .arg(cmd)
19            .status()?
20    };
21    if !status.success() {
22        bail!("{label} hook failed with {status}");
23    }
24    Ok(())
25}
26
27/// Generators are dispatched in parallel by the orchestrator, so every
28/// implementation must be safe to share across threads.
29pub trait Generator: Send + Sync {
30    fn name(&self) -> &'static str;
31    fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
32
33    fn generate_with_config(
34        &self,
35        api: &Api,
36        out_dir: &Utf8Path,
37        _config: &GeneratorConfig,
38    ) -> Result<()> {
39        self.generate(api, out_dir)
40    }
41
42    fn generate_with_templates(
43        &self,
44        api: &Api,
45        out_dir: &Utf8Path,
46        config: &GeneratorConfig,
47        _templates: Option<&TemplateEngine>,
48    ) -> Result<()> {
49        self.generate_with_config(api, out_dir, config)
50    }
51
52    fn output_files(&self, _api: &Api, _out_dir: &Utf8Path) -> Vec<String> {
53        vec![]
54    }
55
56    fn output_files_with_config(
57        &self,
58        api: &Api,
59        out_dir: &Utf8Path,
60        _config: &GeneratorConfig,
61    ) -> Vec<String> {
62        self.output_files(api, out_dir)
63    }
64}
65
66#[derive(Default)]
67pub struct Orchestrator<'a> {
68    generators: Vec<&'a dyn Generator>,
69}
70
71impl<'a> Orchestrator<'a> {
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    pub fn with_generator(mut self, gen: &'a dyn Generator) -> Self {
77        self.generators.push(gen);
78        self
79    }
80
81    pub fn run(
82        &self,
83        api: &Api,
84        out_dir: &Utf8Path,
85        config: &GeneratorConfig,
86        force: bool,
87        templates: Option<&TemplateEngine>,
88    ) -> Result<()> {
89        if force {
90            cache::invalidate_all(out_dir)?;
91        }
92
93        // Pair each generator with its expected hash and decide individually
94        // whether it needs to run, so a single generator can be re-run while
95        // the others stay cached.
96        let mut pending: Vec<(&'a dyn Generator, String)> = Vec::new();
97        for &g in &self.generators {
98            let hash = cache::hash_api_for_generator(api, g.name());
99            let cached = cache::read_generator_cache(out_dir, g.name());
100            if cached.as_deref() != Some(hash.as_str()) {
101                pending.push((g, hash));
102            }
103        }
104
105        if pending.is_empty() {
106            println!("No changes detected, skipping code generation.");
107            return Ok(());
108        }
109
110        if let Some(cmd) = &config.pre_generate {
111            run_hook("pre_generate", cmd)?;
112        }
113
114        pending
115            .par_iter()
116            .map(|(g, _)| g.generate_with_templates(api, out_dir, config, templates))
117            .collect::<Result<Vec<_>>>()?;
118
119        if let Some(cmd) = &config.post_generate {
120            run_hook("post_generate", cmd)?;
121        }
122
123        for (g, hash) in &pending {
124            cache::write_generator_cache(out_dir, g.name(), hash)?;
125        }
126        Ok(())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::sync::atomic::{AtomicUsize, Ordering};
134    use std::sync::Arc;
135    use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
136
137    struct CountingGenerator {
138        name: &'static str,
139        calls: Arc<AtomicUsize>,
140    }
141
142    impl Generator for CountingGenerator {
143        fn name(&self) -> &'static str {
144            self.name
145        }
146
147        fn generate(&self, _api: &Api, out_dir: &Utf8Path) -> Result<()> {
148            self.calls.fetch_add(1, Ordering::SeqCst);
149            let dir = out_dir.join(self.name);
150            std::fs::create_dir_all(dir.as_std_path())?;
151            std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
152            Ok(())
153        }
154    }
155
156    fn test_api() -> Api {
157        Api {
158            version: "0.1.0".to_string(),
159            modules: vec![Module {
160                name: "math".to_string(),
161                functions: vec![Function {
162                    name: "add".to_string(),
163                    params: vec![
164                        Param {
165                            name: "a".to_string(),
166                            ty: TypeRef::I32,
167                            mutable: false,
168                            doc: None,
169                        },
170                        Param {
171                            name: "b".to_string(),
172                            ty: TypeRef::I32,
173                            mutable: false,
174                            doc: None,
175                        },
176                    ],
177                    returns: Some(TypeRef::I32),
178                    doc: None,
179                    r#async: false,
180                    cancellable: false,
181                    deprecated: None,
182                    since: None,
183                }],
184                structs: vec![],
185                enums: vec![],
186                callbacks: vec![],
187                listeners: vec![],
188                errors: None,
189                modules: vec![],
190            }],
191            generators: None,
192        }
193    }
194
195    #[test]
196    fn incremental_skips_when_unchanged() {
197        let dir = tempfile::tempdir().unwrap();
198        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
199        let api = test_api();
200        let config = GeneratorConfig::default();
201        let calls = Arc::new(AtomicUsize::new(0));
202        let gen = CountingGenerator {
203            name: "counting",
204            calls: Arc::clone(&calls),
205        };
206
207        let orch = Orchestrator::new().with_generator(&gen);
208
209        orch.run(&api, out_dir, &config, false, None).unwrap();
210        assert_eq!(calls.load(Ordering::SeqCst), 1);
211        let content_after_first =
212            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
213
214        orch.run(&api, out_dir, &config, false, None).unwrap();
215        assert_eq!(
216            calls.load(Ordering::SeqCst),
217            1,
218            "generator should not run again"
219        );
220        let content_after_second =
221            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
222
223        assert_eq!(content_after_first, content_after_second);
224    }
225
226    #[test]
227    fn force_bypasses_cache() {
228        let dir = tempfile::tempdir().unwrap();
229        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
230        let api = test_api();
231        let config = GeneratorConfig::default();
232        let calls = Arc::new(AtomicUsize::new(0));
233        let gen = CountingGenerator {
234            name: "counting",
235            calls: Arc::clone(&calls),
236        };
237
238        let orch = Orchestrator::new().with_generator(&gen);
239
240        orch.run(&api, out_dir, &config, false, None).unwrap();
241        assert_eq!(calls.load(Ordering::SeqCst), 1);
242
243        orch.run(&api, out_dir, &config, true, None).unwrap();
244        assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
245    }
246
247    #[test]
248    fn generate_with_custom_templates_dir() {
249        use crate::templates::TemplateEngine;
250
251        let tpl_dir = tempfile::tempdir().unwrap();
252        let tpl_path = Utf8Path::from_path(tpl_dir.path()).unwrap();
253        std::fs::write(tpl_path.join("greeting.tera"), "Hello from {{ name }}!").unwrap();
254
255        let mut engine = TemplateEngine::new();
256        engine.load_dir(tpl_path).unwrap();
257
258        let mut ctx = tera::Context::new();
259        ctx.insert("name", "user-templates");
260        let rendered = engine.render("greeting.tera", &ctx).unwrap();
261        assert_eq!(rendered, "Hello from user-templates!");
262
263        let dir = tempfile::tempdir().unwrap();
264        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
265        let api = test_api();
266        let config = GeneratorConfig::default();
267        let calls = Arc::new(AtomicUsize::new(0));
268        let gen = CountingGenerator {
269            name: "counting",
270            calls: Arc::clone(&calls),
271        };
272
273        let orch = Orchestrator::new().with_generator(&gen);
274        orch.run(&api, out_dir, &config, true, Some(&engine))
275            .unwrap();
276        assert_eq!(calls.load(Ordering::SeqCst), 1);
277    }
278
279    #[test]
280    fn parallel_orchestrator_runs_all_generators() {
281        let dir = tempfile::tempdir().unwrap();
282        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
283        let api = test_api();
284        let config = GeneratorConfig::default();
285
286        let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
287        let counters: Vec<Arc<AtomicUsize>> = names
288            .iter()
289            .map(|_| Arc::new(AtomicUsize::new(0)))
290            .collect();
291        let gens: Vec<CountingGenerator> = names
292            .iter()
293            .zip(counters.iter())
294            .map(|(name, calls)| CountingGenerator {
295                name,
296                calls: Arc::clone(calls),
297            })
298            .collect();
299
300        let mut orch = Orchestrator::new();
301        for g in &gens {
302            orch = orch.with_generator(g);
303        }
304
305        orch.run(&api, out_dir, &config, false, None).unwrap();
306
307        for (name, calls) in names.iter().zip(counters.iter()) {
308            assert_eq!(
309                calls.load(Ordering::SeqCst),
310                1,
311                "generator '{name}' should have run exactly once",
312            );
313            assert!(
314                out_dir.join(name).join("output.txt").exists(),
315                "generator '{name}' should have written its output",
316            );
317        }
318    }
319
320    #[test]
321    fn single_generator_cache_invalidates_independently() {
322        let dir = tempfile::tempdir().unwrap();
323        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
324        let config = GeneratorConfig::default();
325
326        let c_calls = Arc::new(AtomicUsize::new(0));
327        let s_calls = Arc::new(AtomicUsize::new(0));
328        let c_gen = CountingGenerator {
329            name: "c",
330            calls: Arc::clone(&c_calls),
331        };
332        let s_gen = CountingGenerator {
333            name: "swift",
334            calls: Arc::clone(&s_calls),
335        };
336
337        let orch = Orchestrator::new()
338            .with_generator(&c_gen)
339            .with_generator(&s_gen);
340
341        let api = test_api();
342        orch.run(&api, out_dir, &config, false, None).unwrap();
343        assert_eq!(c_calls.load(Ordering::SeqCst), 1);
344        assert_eq!(s_calls.load(Ordering::SeqCst), 1);
345
346        // Mutate the API in a way that only affects the C generator's hash by
347        // tweaking the C symbol prefix; the Swift hash still keys on its own
348        // generator name and the unchanged IR.
349        let mut modified = api.clone();
350        modified.modules[0].name = "math2".to_string();
351
352        // Restore the Swift cache entry to point at the unchanged hash so the
353        // orchestrator skips it. The C entry stays stale relative to the new
354        // API, so only C should re-run.
355        let new_swift_hash = cache::hash_api_for_generator(&modified, "swift");
356        cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
357
358        orch.run(&modified, out_dir, &config, false, None).unwrap();
359        assert_eq!(
360            c_calls.load(Ordering::SeqCst),
361            2,
362            "C generator should re-run because its cache entry no longer matches",
363        );
364        assert_eq!(
365            s_calls.load(Ordering::SeqCst),
366            1,
367            "Swift generator's cache matched the new API and must be skipped",
368        );
369    }
370}