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