Skip to main content

weaveffi_core/codegen/
mod.rs

1//! Generator trait, dyn-erasure wrapper, and orchestration.
2//!
3//! Each language target implements [`Generator`] with its own associated
4//! `Config` type. The orchestrator works on the object-safe [`DynGenerator`]
5//! trait, which erases the concrete config and is what tests and the CLI
6//! pass into [`Orchestrator::with_generator`]. The recommended way to
7//! produce a `&dyn DynGenerator` is to build a [`ConfiguredGenerator`]
8//! that pairs a typed generator with its concrete config value.
9
10use anyhow::{bail, Result};
11use camino::Utf8Path;
12use rayon::prelude::*;
13use serde::Serialize;
14use weaveffi_ir::ir::Api;
15
16use crate::cache;
17
18pub mod common;
19pub mod writer;
20
21fn run_hook(label: &str, cmd: &str) -> Result<()> {
22    let status = if cfg!(target_os = "windows") {
23        std::process::Command::new("cmd")
24            .args(["/C", cmd])
25            .status()?
26    } else {
27        std::process::Command::new("sh")
28            .arg("-c")
29            .arg(cmd)
30            .status()?
31    };
32    if !status.success() {
33        bail!("{label} hook failed with {status}");
34    }
35    Ok(())
36}
37
38/// A language code generator.
39///
40/// Generators are dispatched in parallel, so every implementation must be
41/// safe to share across threads. The associated [`Config`] type is owned
42/// by the generator crate so `weaveffi-core` does not have to know about
43/// target-specific options like `swift_module_name` or `cpp_namespace`.
44///
45/// [`Config`]: Generator::Config
46pub trait Generator: Send + Sync {
47    /// Per-target, fully-typed configuration consumed by [`generate`] and
48    /// [`output_files`]. Must round-trip through `serde_json` so the
49    /// orchestrator can hash it as part of the cache key.
50    ///
51    /// [`generate`]: Generator::generate
52    /// [`output_files`]: Generator::output_files
53    type Config: Serialize + Default + Clone + Send + Sync;
54
55    /// Stable short name for the target (`"swift"`, `"c"`, `"node"`, …).
56    /// Used as the cache file basename and the `--target` filter token.
57    fn name(&self) -> &'static str;
58
59    /// Render the bindings under `out_dir`.
60    fn generate(&self, api: &Api, out_dir: &Utf8Path, config: &Self::Config) -> Result<()>;
61
62    /// Files that [`generate`](Generator::generate) would write, relative
63    /// to (or anchored under) `out_dir`. Used by `--dry-run` and `diff`.
64    /// Default implementation returns the empty list; generators override
65    /// to surface the list without doing any I/O.
66    fn output_files(&self, _api: &Api, _out_dir: &Utf8Path, _config: &Self::Config) -> Vec<String> {
67        vec![]
68    }
69}
70
71/// Object-safe view of a [`Generator`] paired with a concrete config.
72///
73/// The orchestrator stores generators as `&dyn DynGenerator` so it can
74/// hold a heterogeneous set of targets whose `Config` types differ.
75/// [`ConfiguredGenerator`] is the canonical adapter.
76pub trait DynGenerator: Send + Sync {
77    fn name(&self) -> &'static str;
78    fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
79    fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String>;
80    /// Canonical-JSON encoding of the bound config, fed into the cache
81    /// hash so a config-only change invalidates the entry.
82    fn config_hash_input(&self) -> Vec<u8>;
83}
84
85/// Binds a [`Generator`] to a concrete [`Generator::Config`] value so it
86/// can be erased to `&dyn DynGenerator`.
87///
88/// ```ignore
89/// let swift = ConfiguredGenerator::new(SwiftGenerator, SwiftConfig::default());
90/// orchestrator.with_generator(&swift);
91/// ```
92pub struct ConfiguredGenerator<G: Generator> {
93    inner: G,
94    config: G::Config,
95}
96
97impl<G: Generator> ConfiguredGenerator<G> {
98    pub fn new(inner: G, config: G::Config) -> Self {
99        Self { inner, config }
100    }
101
102    pub fn config(&self) -> &G::Config {
103        &self.config
104    }
105
106    pub fn inner(&self) -> &G {
107        &self.inner
108    }
109}
110
111impl<G: Generator> DynGenerator for ConfiguredGenerator<G> {
112    fn name(&self) -> &'static str {
113        self.inner.name()
114    }
115
116    fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()> {
117        self.inner.generate(api, out_dir, &self.config)
118    }
119
120    fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String> {
121        self.inner.output_files(api, out_dir, &self.config)
122    }
123
124    fn config_hash_input(&self) -> Vec<u8> {
125        let value =
126            serde_json::to_value(&self.config).expect("generator config should serialize to JSON");
127        serde_json::to_vec(&value).expect("JSON Value should serialize")
128    }
129}
130
131/// Global hooks the orchestrator runs around the parallel codegen pass.
132#[derive(Default, Debug, Clone)]
133pub struct OrchestratorHooks {
134    pub pre_generate: Option<String>,
135    pub post_generate: Option<String>,
136}
137
138#[derive(Default)]
139pub struct Orchestrator<'a> {
140    generators: Vec<&'a dyn DynGenerator>,
141}
142
143impl<'a> Orchestrator<'a> {
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    pub fn with_generator(mut self, gen: &'a dyn DynGenerator) -> Self {
149        self.generators.push(gen);
150        self
151    }
152
153    pub fn run(
154        &self,
155        api: &Api,
156        out_dir: &Utf8Path,
157        hooks: &OrchestratorHooks,
158        force: bool,
159    ) -> Result<()> {
160        if force {
161            cache::invalidate_all(out_dir)?;
162        }
163
164        // Pair each generator with its expected hash and decide individually
165        // whether it needs to run, so a single generator can be re-run while
166        // the others stay cached.
167        let mut pending: Vec<(&'a dyn DynGenerator, String)> = Vec::new();
168        for &g in &self.generators {
169            let cfg_bytes = g.config_hash_input();
170            let hash = cache::hash_generator_inputs(api, g.name(), &cfg_bytes);
171            let cached = cache::read_generator_cache(out_dir, g.name());
172            if cached.as_deref() != Some(hash.as_str()) {
173                pending.push((g, hash));
174            }
175        }
176
177        if pending.is_empty() {
178            println!("No changes detected, skipping code generation.");
179            return Ok(());
180        }
181
182        if let Some(cmd) = &hooks.pre_generate {
183            run_hook("pre_generate", cmd)?;
184        }
185
186        pending
187            .par_iter()
188            .map(|(g, _)| g.generate(api, out_dir))
189            .collect::<Result<Vec<_>>>()?;
190
191        if let Some(cmd) = &hooks.post_generate {
192            run_hook("post_generate", cmd)?;
193        }
194
195        for (g, hash) in &pending {
196            cache::write_generator_cache(out_dir, g.name(), hash)?;
197        }
198        Ok(())
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use std::sync::atomic::{AtomicUsize, Ordering};
206    use std::sync::Arc;
207    use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
208
209    /// Test generator with a minimal config so tests don't have to depend
210    /// on any real per-language generator crate.
211    #[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
212    struct TestConfig {
213        knob: Option<String>,
214    }
215
216    struct CountingGenerator {
217        name: &'static str,
218        calls: Arc<AtomicUsize>,
219    }
220
221    impl Generator for CountingGenerator {
222        type Config = TestConfig;
223
224        fn name(&self) -> &'static str {
225            self.name
226        }
227
228        fn generate(&self, _api: &Api, out_dir: &Utf8Path, _config: &Self::Config) -> Result<()> {
229            self.calls.fetch_add(1, Ordering::SeqCst);
230            let dir = out_dir.join(self.name);
231            std::fs::create_dir_all(dir.as_std_path())?;
232            std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
233            Ok(())
234        }
235    }
236
237    fn test_api() -> Api {
238        Api {
239            version: "0.1.0".to_string(),
240            modules: vec![Module {
241                name: "math".to_string(),
242                functions: vec![Function {
243                    name: "add".to_string(),
244                    params: vec![
245                        Param {
246                            name: "a".to_string(),
247                            ty: TypeRef::I32,
248                            mutable: false,
249                            doc: None,
250                        },
251                        Param {
252                            name: "b".to_string(),
253                            ty: TypeRef::I32,
254                            mutable: false,
255                            doc: None,
256                        },
257                    ],
258                    returns: Some(TypeRef::I32),
259                    doc: None,
260                    r#async: false,
261                    cancellable: false,
262                    deprecated: None,
263                    since: None,
264                }],
265                structs: vec![],
266                enums: vec![],
267                callbacks: vec![],
268                listeners: vec![],
269                errors: None,
270                modules: vec![],
271            }],
272            generators: None,
273        }
274    }
275
276    fn configured(
277        name: &'static str,
278        calls: Arc<AtomicUsize>,
279    ) -> ConfiguredGenerator<CountingGenerator> {
280        ConfiguredGenerator::new(CountingGenerator { name, calls }, TestConfig::default())
281    }
282
283    #[test]
284    fn incremental_skips_when_unchanged() {
285        let dir = tempfile::tempdir().unwrap();
286        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
287        let api = test_api();
288        let hooks = OrchestratorHooks::default();
289        let calls = Arc::new(AtomicUsize::new(0));
290        let gen = configured("counting", Arc::clone(&calls));
291
292        let orch = Orchestrator::new().with_generator(&gen);
293
294        orch.run(&api, out_dir, &hooks, false).unwrap();
295        assert_eq!(calls.load(Ordering::SeqCst), 1);
296        let content_after_first =
297            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
298
299        orch.run(&api, out_dir, &hooks, false).unwrap();
300        assert_eq!(
301            calls.load(Ordering::SeqCst),
302            1,
303            "generator should not run again"
304        );
305        let content_after_second =
306            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
307
308        assert_eq!(content_after_first, content_after_second);
309    }
310
311    #[test]
312    fn force_bypasses_cache() {
313        let dir = tempfile::tempdir().unwrap();
314        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
315        let api = test_api();
316        let hooks = OrchestratorHooks::default();
317        let calls = Arc::new(AtomicUsize::new(0));
318        let gen = configured("counting", Arc::clone(&calls));
319
320        let orch = Orchestrator::new().with_generator(&gen);
321
322        orch.run(&api, out_dir, &hooks, false).unwrap();
323        assert_eq!(calls.load(Ordering::SeqCst), 1);
324
325        orch.run(&api, out_dir, &hooks, true).unwrap();
326        assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
327    }
328
329    #[test]
330    fn parallel_orchestrator_runs_all_generators() {
331        let dir = tempfile::tempdir().unwrap();
332        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
333        let api = test_api();
334        let hooks = OrchestratorHooks::default();
335
336        let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
337        let counters: Vec<Arc<AtomicUsize>> = names
338            .iter()
339            .map(|_| Arc::new(AtomicUsize::new(0)))
340            .collect();
341        let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
342            .iter()
343            .zip(counters.iter())
344            .map(|(name, calls)| configured(name, Arc::clone(calls)))
345            .collect();
346
347        let mut orch = Orchestrator::new();
348        for g in &gens {
349            orch = orch.with_generator(g);
350        }
351
352        orch.run(&api, out_dir, &hooks, false).unwrap();
353
354        for (name, calls) in names.iter().zip(counters.iter()) {
355            assert_eq!(
356                calls.load(Ordering::SeqCst),
357                1,
358                "generator '{name}' should have run exactly once",
359            );
360            assert!(
361                out_dir.join(name).join("output.txt").exists(),
362                "generator '{name}' should have written its output",
363            );
364        }
365    }
366
367    #[test]
368    fn single_generator_cache_invalidates_independently() {
369        let dir = tempfile::tempdir().unwrap();
370        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
371        let hooks = OrchestratorHooks::default();
372
373        let c_calls = Arc::new(AtomicUsize::new(0));
374        let s_calls = Arc::new(AtomicUsize::new(0));
375        let c_gen = configured("c", Arc::clone(&c_calls));
376        let s_gen = configured("swift", Arc::clone(&s_calls));
377
378        let orch = Orchestrator::new()
379            .with_generator(&c_gen)
380            .with_generator(&s_gen);
381
382        let api = test_api();
383        orch.run(&api, out_dir, &hooks, false).unwrap();
384        assert_eq!(c_calls.load(Ordering::SeqCst), 1);
385        assert_eq!(s_calls.load(Ordering::SeqCst), 1);
386
387        // Mutate the API in a way that affects both generators' hashes by
388        // renaming a module. Then pre-seed the Swift cache with the *new*
389        // expected hash so only the C entry stays stale and re-runs.
390        let mut modified = api.clone();
391        modified.modules[0].name = "math2".to_string();
392
393        let new_swift_hash =
394            cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
395        cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
396
397        orch.run(&modified, out_dir, &hooks, false).unwrap();
398        assert_eq!(
399            c_calls.load(Ordering::SeqCst),
400            2,
401            "C generator should re-run because its cache entry no longer matches",
402        );
403        assert_eq!(
404            s_calls.load(Ordering::SeqCst),
405            1,
406            "Swift generator's cache matched the new API and must be skipped",
407        );
408    }
409
410    #[test]
411    fn config_change_invalidates_cache() {
412        let dir = tempfile::tempdir().unwrap();
413        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
414        let hooks = OrchestratorHooks::default();
415        let api = test_api();
416
417        let calls = Arc::new(AtomicUsize::new(0));
418        let g1 = ConfiguredGenerator::new(
419            CountingGenerator {
420                name: "counting",
421                calls: Arc::clone(&calls),
422            },
423            TestConfig::default(),
424        );
425        Orchestrator::new()
426            .with_generator(&g1)
427            .run(&api, out_dir, &hooks, false)
428            .unwrap();
429        assert_eq!(calls.load(Ordering::SeqCst), 1);
430
431        // Same generator, different config value: must re-run.
432        let g2 = ConfiguredGenerator::new(
433            CountingGenerator {
434                name: "counting",
435                calls: Arc::clone(&calls),
436            },
437            TestConfig {
438                knob: Some("changed".into()),
439            },
440        );
441        Orchestrator::new()
442            .with_generator(&g2)
443            .run(&api, out_dir, &hooks, false)
444            .unwrap();
445        assert_eq!(
446            calls.load(Ordering::SeqCst),
447            2,
448            "config-only change must invalidate the cache",
449        );
450    }
451}