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            package: None,
274        }
275    }
276
277    fn configured(
278        name: &'static str,
279        calls: Arc<AtomicUsize>,
280    ) -> ConfiguredGenerator<CountingGenerator> {
281        ConfiguredGenerator::new(CountingGenerator { name, calls }, TestConfig::default())
282    }
283
284    #[test]
285    fn incremental_skips_when_unchanged() {
286        let dir = tempfile::tempdir().unwrap();
287        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
288        let api = test_api();
289        let hooks = OrchestratorHooks::default();
290        let calls = Arc::new(AtomicUsize::new(0));
291        let gen = configured("counting", Arc::clone(&calls));
292
293        let orch = Orchestrator::new().with_generator(&gen);
294
295        orch.run(&api, out_dir, &hooks, false).unwrap();
296        assert_eq!(calls.load(Ordering::SeqCst), 1);
297        let content_after_first =
298            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
299
300        orch.run(&api, out_dir, &hooks, false).unwrap();
301        assert_eq!(
302            calls.load(Ordering::SeqCst),
303            1,
304            "generator should not run again"
305        );
306        let content_after_second =
307            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
308
309        assert_eq!(content_after_first, content_after_second);
310    }
311
312    #[test]
313    fn force_bypasses_cache() {
314        let dir = tempfile::tempdir().unwrap();
315        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
316        let api = test_api();
317        let hooks = OrchestratorHooks::default();
318        let calls = Arc::new(AtomicUsize::new(0));
319        let gen = configured("counting", Arc::clone(&calls));
320
321        let orch = Orchestrator::new().with_generator(&gen);
322
323        orch.run(&api, out_dir, &hooks, false).unwrap();
324        assert_eq!(calls.load(Ordering::SeqCst), 1);
325
326        orch.run(&api, out_dir, &hooks, true).unwrap();
327        assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
328    }
329
330    #[test]
331    fn parallel_orchestrator_runs_all_generators() {
332        let dir = tempfile::tempdir().unwrap();
333        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
334        let api = test_api();
335        let hooks = OrchestratorHooks::default();
336
337        let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
338        let counters: Vec<Arc<AtomicUsize>> = names
339            .iter()
340            .map(|_| Arc::new(AtomicUsize::new(0)))
341            .collect();
342        let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
343            .iter()
344            .zip(counters.iter())
345            .map(|(name, calls)| configured(name, Arc::clone(calls)))
346            .collect();
347
348        let mut orch = Orchestrator::new();
349        for g in &gens {
350            orch = orch.with_generator(g);
351        }
352
353        orch.run(&api, out_dir, &hooks, false).unwrap();
354
355        for (name, calls) in names.iter().zip(counters.iter()) {
356            assert_eq!(
357                calls.load(Ordering::SeqCst),
358                1,
359                "generator '{name}' should have run exactly once",
360            );
361            assert!(
362                out_dir.join(name).join("output.txt").exists(),
363                "generator '{name}' should have written its output",
364            );
365        }
366    }
367
368    #[test]
369    fn single_generator_cache_invalidates_independently() {
370        let dir = tempfile::tempdir().unwrap();
371        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
372        let hooks = OrchestratorHooks::default();
373
374        let c_calls = Arc::new(AtomicUsize::new(0));
375        let s_calls = Arc::new(AtomicUsize::new(0));
376        let c_gen = configured("c", Arc::clone(&c_calls));
377        let s_gen = configured("swift", Arc::clone(&s_calls));
378
379        let orch = Orchestrator::new()
380            .with_generator(&c_gen)
381            .with_generator(&s_gen);
382
383        let api = test_api();
384        orch.run(&api, out_dir, &hooks, false).unwrap();
385        assert_eq!(c_calls.load(Ordering::SeqCst), 1);
386        assert_eq!(s_calls.load(Ordering::SeqCst), 1);
387
388        // Mutate the API in a way that affects both generators' hashes by
389        // renaming a module. Then pre-seed the Swift cache with the *new*
390        // expected hash so only the C entry stays stale and re-runs.
391        let mut modified = api.clone();
392        modified.modules[0].name = "math2".to_string();
393
394        let new_swift_hash =
395            cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
396        cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
397
398        orch.run(&modified, out_dir, &hooks, false).unwrap();
399        assert_eq!(
400            c_calls.load(Ordering::SeqCst),
401            2,
402            "C generator should re-run because its cache entry no longer matches",
403        );
404        assert_eq!(
405            s_calls.load(Ordering::SeqCst),
406            1,
407            "Swift generator's cache matched the new API and must be skipped",
408        );
409    }
410
411    #[test]
412    fn config_change_invalidates_cache() {
413        let dir = tempfile::tempdir().unwrap();
414        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
415        let hooks = OrchestratorHooks::default();
416        let api = test_api();
417
418        let calls = Arc::new(AtomicUsize::new(0));
419        let g1 = ConfiguredGenerator::new(
420            CountingGenerator {
421                name: "counting",
422                calls: Arc::clone(&calls),
423            },
424            TestConfig::default(),
425        );
426        Orchestrator::new()
427            .with_generator(&g1)
428            .run(&api, out_dir, &hooks, false)
429            .unwrap();
430        assert_eq!(calls.load(Ordering::SeqCst), 1);
431
432        // Same generator, different config value: must re-run.
433        let g2 = ConfiguredGenerator::new(
434            CountingGenerator {
435                name: "counting",
436                calls: Arc::clone(&calls),
437            },
438            TestConfig {
439                knob: Some("changed".into()),
440            },
441        );
442        Orchestrator::new()
443            .with_generator(&g2)
444            .run(&api, out_dir, &hooks, false)
445            .unwrap();
446        assert_eq!(
447            calls.load(Ordering::SeqCst),
448            2,
449            "config-only change must invalidate the cache",
450        );
451    }
452}