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;
17use crate::capabilities::{self, TargetCapabilities};
18
19pub mod common;
20pub mod writer;
21
22fn run_hook(label: &str, cmd: &str) -> Result<()> {
23    let status = if cfg!(target_os = "windows") {
24        std::process::Command::new("cmd")
25            .args(["/C", cmd])
26            .status()?
27    } else {
28        std::process::Command::new("sh")
29            .arg("-c")
30            .arg(cmd)
31            .status()?
32    };
33    if !status.success() {
34        bail!("{label} hook failed with {status}");
35    }
36    Ok(())
37}
38
39/// A language code generator.
40///
41/// Generators are dispatched in parallel, so every implementation must be
42/// safe to share across threads. The associated [`Config`] type is owned
43/// by the generator crate so `weaveffi-core` does not have to know about
44/// target-specific options like `swift_module_name` or `cpp_namespace`.
45///
46/// [`Config`]: Generator::Config
47pub trait Generator: Send + Sync {
48    /// Per-target, fully-typed configuration consumed by [`generate`] and
49    /// [`output_files`]. Must round-trip through `serde_json` so the
50    /// orchestrator can hash it as part of the cache key.
51    ///
52    /// [`generate`]: Generator::generate
53    /// [`output_files`]: Generator::output_files
54    type Config: Serialize + Default + Clone + Send + Sync;
55
56    /// Stable short name for the target (`"swift"`, `"c"`, `"node"`, …).
57    /// Used as the cache file basename and the `--target` filter token.
58    fn name(&self) -> &'static str;
59
60    /// The gated IDL features this target implements. The orchestrator
61    /// refuses to run a generator against an API that uses a feature its
62    /// declared capabilities do not cover — a target either generates a
63    /// feature or fails loudly; it never silently omits one.
64    fn capabilities(&self) -> TargetCapabilities;
65
66    /// Whether the user explicitly opted in to generating this target even
67    /// though the API uses features the target does not support (for example
68    /// `generators.wasm.allow_unsupported: true`). When `true` the
69    /// orchestrator downgrades the capability failure to a loud warning and
70    /// the generator must emit an explicit unsupported surface (throwing
71    /// stubs, documentation) rather than silently omitting the feature.
72    /// Default: `false` — opting in must always be an explicit config act.
73    fn allows_unsupported(&self, config: &Self::Config) -> bool {
74        let _ = config;
75        false
76    }
77
78    /// Render the bindings under `out_dir`.
79    fn generate(&self, api: &Api, out_dir: &Utf8Path, config: &Self::Config) -> Result<()>;
80
81    /// Files that [`generate`](Generator::generate) would write, relative
82    /// to (or anchored under) `out_dir`. Used by `--dry-run` and `diff`.
83    /// Default implementation returns the empty list; generators override
84    /// to surface the list without doing any I/O.
85    fn output_files(&self, _api: &Api, _out_dir: &Utf8Path, _config: &Self::Config) -> Vec<String> {
86        vec![]
87    }
88}
89
90/// Object-safe view of a [`Generator`] paired with a concrete config.
91///
92/// The orchestrator stores generators as `&dyn DynGenerator` so it can
93/// hold a heterogeneous set of targets whose `Config` types differ.
94/// [`ConfiguredGenerator`] is the canonical adapter.
95pub trait DynGenerator: Send + Sync {
96    fn name(&self) -> &'static str;
97    fn capabilities(&self) -> TargetCapabilities;
98    /// See [`Generator::allows_unsupported`] — evaluated against the bound
99    /// config.
100    fn allows_unsupported(&self) -> bool;
101    fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
102    fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String>;
103    /// Canonical-JSON encoding of the bound config, fed into the cache
104    /// hash so a config-only change invalidates the entry.
105    fn config_hash_input(&self) -> Vec<u8>;
106}
107
108/// Binds a [`Generator`] to a concrete [`Generator::Config`] value so it
109/// can be erased to `&dyn DynGenerator`.
110///
111/// ```ignore
112/// let swift = ConfiguredGenerator::new(SwiftGenerator, SwiftConfig::default());
113/// orchestrator.with_generator(&swift);
114/// ```
115pub struct ConfiguredGenerator<G: Generator> {
116    inner: G,
117    config: G::Config,
118}
119
120impl<G: Generator> ConfiguredGenerator<G> {
121    pub fn new(inner: G, config: G::Config) -> Self {
122        Self { inner, config }
123    }
124
125    pub fn config(&self) -> &G::Config {
126        &self.config
127    }
128
129    pub fn inner(&self) -> &G {
130        &self.inner
131    }
132}
133
134impl<G: Generator> DynGenerator for ConfiguredGenerator<G> {
135    fn name(&self) -> &'static str {
136        self.inner.name()
137    }
138
139    fn capabilities(&self) -> TargetCapabilities {
140        self.inner.capabilities()
141    }
142
143    fn allows_unsupported(&self) -> bool {
144        self.inner.allows_unsupported(&self.config)
145    }
146
147    fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()> {
148        self.inner.generate(api, out_dir, &self.config)
149    }
150
151    fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String> {
152        self.inner.output_files(api, out_dir, &self.config)
153    }
154
155    fn config_hash_input(&self) -> Vec<u8> {
156        let value =
157            serde_json::to_value(&self.config).expect("generator config should serialize to JSON");
158        serde_json::to_vec(&value).expect("JSON Value should serialize")
159    }
160}
161
162/// Global hooks the orchestrator runs around the parallel codegen pass.
163#[derive(Default, Debug, Clone)]
164pub struct OrchestratorHooks {
165    pub pre_generate: Option<String>,
166    pub post_generate: Option<String>,
167}
168
169#[derive(Default)]
170pub struct Orchestrator<'a> {
171    generators: Vec<&'a dyn DynGenerator>,
172}
173
174impl<'a> Orchestrator<'a> {
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    pub fn with_generator(mut self, gen: &'a dyn DynGenerator) -> Self {
180        self.generators.push(gen);
181        self
182    }
183
184    pub fn run(
185        &self,
186        api: &Api,
187        out_dir: &Utf8Path,
188        hooks: &OrchestratorHooks,
189        force: bool,
190    ) -> Result<()> {
191        // Capability gate: every selected target must support every gated
192        // feature the API uses. Collect all violations before failing so the
193        // user sees the complete picture in one run. A generator whose config
194        // explicitly opted in via `allow_unsupported` downgrades its failure
195        // to a loud warning: the generator emits an explicit unsupported
196        // surface (throwing stubs) for the missing features instead.
197        let mut violations: Vec<String> = Vec::new();
198        for g in &self.generators {
199            let Err(err) = capabilities::check(api, g.name(), &g.capabilities()) else {
200                continue;
201            };
202            if g.allows_unsupported() {
203                eprintln!(
204                    "warning: target '{}' does not support every feature this IDL uses; \
205                     generating anyway because allow_unsupported is set:",
206                    g.name()
207                );
208                for (feature, locations) in &err.violations {
209                    eprintln!("  - {feature} (used by: {})", locations.join(", "));
210                }
211            } else {
212                violations.push(err.to_string());
213            }
214        }
215        if !violations.is_empty() {
216            bail!("{}", violations.join("\n"));
217        }
218
219        if force {
220            cache::invalidate_all(out_dir)?;
221        }
222
223        // Pair each generator with its expected hash and decide individually
224        // whether it needs to run, so a single generator can be re-run while
225        // the others stay cached.
226        let mut pending: Vec<(&'a dyn DynGenerator, String)> = Vec::new();
227        for &g in &self.generators {
228            let cfg_bytes = g.config_hash_input();
229            let hash = cache::hash_generator_inputs(api, g.name(), &cfg_bytes);
230            let cached = cache::read_generator_cache(out_dir, g.name());
231            if cached.as_deref() != Some(hash.as_str()) {
232                pending.push((g, hash));
233            }
234        }
235
236        if pending.is_empty() {
237            println!("No changes detected, skipping code generation.");
238            return Ok(());
239        }
240
241        if let Some(cmd) = &hooks.pre_generate {
242            run_hook("pre_generate", cmd)?;
243        }
244
245        pending
246            .par_iter()
247            .map(|(g, _)| g.generate(api, out_dir))
248            .collect::<Result<Vec<_>>>()?;
249
250        if let Some(cmd) = &hooks.post_generate {
251            run_hook("post_generate", cmd)?;
252        }
253
254        for (g, hash) in &pending {
255            cache::write_generator_cache(out_dir, g.name(), hash)?;
256        }
257        Ok(())
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::sync::atomic::{AtomicUsize, Ordering};
265    use std::sync::Arc;
266    use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
267
268    /// Test generator with a minimal config so tests don't have to depend
269    /// on any real per-language generator crate.
270    #[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
271    struct TestConfig {
272        knob: Option<String>,
273        allow_unsupported: bool,
274    }
275
276    struct CountingGenerator {
277        name: &'static str,
278        calls: Arc<AtomicUsize>,
279        caps: TargetCapabilities,
280    }
281
282    impl Generator for CountingGenerator {
283        type Config = TestConfig;
284
285        fn name(&self) -> &'static str {
286            self.name
287        }
288
289        fn capabilities(&self) -> TargetCapabilities {
290            self.caps
291        }
292
293        fn allows_unsupported(&self, config: &Self::Config) -> bool {
294            config.allow_unsupported
295        }
296
297        fn generate(&self, _api: &Api, out_dir: &Utf8Path, _config: &Self::Config) -> Result<()> {
298            self.calls.fetch_add(1, Ordering::SeqCst);
299            let dir = out_dir.join(self.name);
300            std::fs::create_dir_all(dir.as_std_path())?;
301            std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
302            Ok(())
303        }
304    }
305
306    fn test_api() -> Api {
307        Api {
308            version: "0.3.0".to_string(),
309            modules: vec![Module {
310                name: "math".to_string(),
311                functions: vec![Function {
312                    name: "add".to_string(),
313                    params: vec![
314                        Param {
315                            name: "a".to_string(),
316                            ty: TypeRef::I32,
317                            mutable: false,
318                            doc: None,
319                        },
320                        Param {
321                            name: "b".to_string(),
322                            ty: TypeRef::I32,
323                            mutable: false,
324                            doc: None,
325                        },
326                    ],
327                    returns: Some(TypeRef::I32),
328                    doc: None,
329                    r#async: false,
330                    cancellable: false,
331                    deprecated: None,
332                    since: None,
333                }],
334                structs: vec![],
335                enums: vec![],
336                callbacks: vec![],
337                listeners: vec![],
338                errors: None,
339                modules: vec![],
340            }],
341            generators: None,
342            package: None,
343        }
344    }
345
346    fn configured(
347        name: &'static str,
348        calls: Arc<AtomicUsize>,
349    ) -> ConfiguredGenerator<CountingGenerator> {
350        ConfiguredGenerator::new(
351            CountingGenerator {
352                name,
353                calls,
354                caps: TargetCapabilities::full(),
355            },
356            TestConfig::default(),
357        )
358    }
359
360    /// An API that uses listeners, so a target without listener support
361    /// trips the capability gate.
362    fn listener_api() -> Api {
363        let mut api = test_api();
364        api.modules[0].listeners = vec![weaveffi_ir::ir::ListenerDef {
365            name: "on_change".to_string(),
366            event_callback: "OnChange".to_string(),
367            doc: None,
368        }];
369        api.modules[0].callbacks = vec![weaveffi_ir::ir::CallbackDef {
370            name: "OnChange".to_string(),
371            params: vec![],
372            doc: None,
373        }];
374        api
375    }
376
377    fn partial(
378        calls: Arc<AtomicUsize>,
379        allow_unsupported: bool,
380    ) -> ConfiguredGenerator<CountingGenerator> {
381        ConfiguredGenerator::new(
382            CountingGenerator {
383                name: "partial",
384                calls,
385                caps: TargetCapabilities {
386                    callbacks: false,
387                    listeners: false,
388                    ..TargetCapabilities::full()
389                },
390            },
391            TestConfig {
392                knob: None,
393                allow_unsupported,
394            },
395        )
396    }
397
398    #[test]
399    fn capability_gate_blocks_unsupported_target() {
400        let dir = tempfile::tempdir().unwrap();
401        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
402        let calls = Arc::new(AtomicUsize::new(0));
403        let gen = partial(Arc::clone(&calls), false);
404
405        let err = Orchestrator::new()
406            .with_generator(&gen)
407            .run(
408                &listener_api(),
409                out_dir,
410                &OrchestratorHooks::default(),
411                false,
412            )
413            .unwrap_err();
414
415        let msg = err.to_string();
416        assert!(msg.contains("target 'partial' does not support"), "{msg}");
417        assert!(msg.contains("math.on_change"), "{msg}");
418        assert!(msg.contains("allow_unsupported"), "{msg}");
419        assert_eq!(
420            calls.load(Ordering::SeqCst),
421            0,
422            "gated generator must not run"
423        );
424    }
425
426    #[test]
427    fn allow_unsupported_downgrades_gate_to_warning() {
428        let dir = tempfile::tempdir().unwrap();
429        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
430        let calls = Arc::new(AtomicUsize::new(0));
431        let gen = partial(Arc::clone(&calls), true);
432
433        Orchestrator::new()
434            .with_generator(&gen)
435            .run(
436                &listener_api(),
437                out_dir,
438                &OrchestratorHooks::default(),
439                false,
440            )
441            .expect("allow_unsupported must let generation proceed");
442
443        assert_eq!(calls.load(Ordering::SeqCst), 1, "generator should run");
444    }
445
446    #[test]
447    fn allow_unsupported_does_not_relax_other_targets() {
448        let dir = tempfile::tempdir().unwrap();
449        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
450        let opted_calls = Arc::new(AtomicUsize::new(0));
451        let strict_calls = Arc::new(AtomicUsize::new(0));
452        let opted = partial(Arc::clone(&opted_calls), true);
453        let strict = ConfiguredGenerator::new(
454            CountingGenerator {
455                name: "strict",
456                calls: Arc::clone(&strict_calls),
457                caps: TargetCapabilities {
458                    listeners: false,
459                    ..TargetCapabilities::full()
460                },
461            },
462            TestConfig::default(),
463        );
464
465        let err = Orchestrator::new()
466            .with_generator(&opted)
467            .with_generator(&strict)
468            .run(
469                &listener_api(),
470                out_dir,
471                &OrchestratorHooks::default(),
472                false,
473            )
474            .unwrap_err();
475
476        let msg = err.to_string();
477        assert!(msg.contains("target 'strict'"), "{msg}");
478        assert!(!msg.contains("target 'partial'"), "{msg}");
479        assert_eq!(opted_calls.load(Ordering::SeqCst), 0);
480        assert_eq!(strict_calls.load(Ordering::SeqCst), 0);
481    }
482
483    #[test]
484    fn incremental_skips_when_unchanged() {
485        let dir = tempfile::tempdir().unwrap();
486        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
487        let api = test_api();
488        let hooks = OrchestratorHooks::default();
489        let calls = Arc::new(AtomicUsize::new(0));
490        let gen = configured("counting", Arc::clone(&calls));
491
492        let orch = Orchestrator::new().with_generator(&gen);
493
494        orch.run(&api, out_dir, &hooks, false).unwrap();
495        assert_eq!(calls.load(Ordering::SeqCst), 1);
496        let content_after_first =
497            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
498
499        orch.run(&api, out_dir, &hooks, false).unwrap();
500        assert_eq!(
501            calls.load(Ordering::SeqCst),
502            1,
503            "generator should not run again"
504        );
505        let content_after_second =
506            std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
507
508        assert_eq!(content_after_first, content_after_second);
509    }
510
511    #[test]
512    fn force_bypasses_cache() {
513        let dir = tempfile::tempdir().unwrap();
514        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
515        let api = test_api();
516        let hooks = OrchestratorHooks::default();
517        let calls = Arc::new(AtomicUsize::new(0));
518        let gen = configured("counting", Arc::clone(&calls));
519
520        let orch = Orchestrator::new().with_generator(&gen);
521
522        orch.run(&api, out_dir, &hooks, false).unwrap();
523        assert_eq!(calls.load(Ordering::SeqCst), 1);
524
525        orch.run(&api, out_dir, &hooks, true).unwrap();
526        assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
527    }
528
529    #[test]
530    fn parallel_orchestrator_runs_all_generators() {
531        let dir = tempfile::tempdir().unwrap();
532        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
533        let api = test_api();
534        let hooks = OrchestratorHooks::default();
535
536        let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
537        let counters: Vec<Arc<AtomicUsize>> = names
538            .iter()
539            .map(|_| Arc::new(AtomicUsize::new(0)))
540            .collect();
541        let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
542            .iter()
543            .zip(counters.iter())
544            .map(|(name, calls)| configured(name, Arc::clone(calls)))
545            .collect();
546
547        let mut orch = Orchestrator::new();
548        for g in &gens {
549            orch = orch.with_generator(g);
550        }
551
552        orch.run(&api, out_dir, &hooks, false).unwrap();
553
554        for (name, calls) in names.iter().zip(counters.iter()) {
555            assert_eq!(
556                calls.load(Ordering::SeqCst),
557                1,
558                "generator '{name}' should have run exactly once",
559            );
560            assert!(
561                out_dir.join(name).join("output.txt").exists(),
562                "generator '{name}' should have written its output",
563            );
564        }
565    }
566
567    #[test]
568    fn single_generator_cache_invalidates_independently() {
569        let dir = tempfile::tempdir().unwrap();
570        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
571        let hooks = OrchestratorHooks::default();
572
573        let c_calls = Arc::new(AtomicUsize::new(0));
574        let s_calls = Arc::new(AtomicUsize::new(0));
575        let c_gen = configured("c", Arc::clone(&c_calls));
576        let s_gen = configured("swift", Arc::clone(&s_calls));
577
578        let orch = Orchestrator::new()
579            .with_generator(&c_gen)
580            .with_generator(&s_gen);
581
582        let api = test_api();
583        orch.run(&api, out_dir, &hooks, false).unwrap();
584        assert_eq!(c_calls.load(Ordering::SeqCst), 1);
585        assert_eq!(s_calls.load(Ordering::SeqCst), 1);
586
587        // Mutate the API in a way that affects both generators' hashes by
588        // renaming a module. Then pre-seed the Swift cache with the *new*
589        // expected hash so only the C entry stays stale and re-runs.
590        let mut modified = api.clone();
591        modified.modules[0].name = "math2".to_string();
592
593        let new_swift_hash =
594            cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
595        cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
596
597        orch.run(&modified, out_dir, &hooks, false).unwrap();
598        assert_eq!(
599            c_calls.load(Ordering::SeqCst),
600            2,
601            "C generator should re-run because its cache entry no longer matches",
602        );
603        assert_eq!(
604            s_calls.load(Ordering::SeqCst),
605            1,
606            "Swift generator's cache matched the new API and must be skipped",
607        );
608    }
609
610    #[test]
611    fn config_change_invalidates_cache() {
612        let dir = tempfile::tempdir().unwrap();
613        let out_dir = Utf8Path::from_path(dir.path()).unwrap();
614        let hooks = OrchestratorHooks::default();
615        let api = test_api();
616
617        let calls = Arc::new(AtomicUsize::new(0));
618        let g1 = ConfiguredGenerator::new(
619            CountingGenerator {
620                name: "counting",
621                calls: Arc::clone(&calls),
622                caps: TargetCapabilities::full(),
623            },
624            TestConfig::default(),
625        );
626        Orchestrator::new()
627            .with_generator(&g1)
628            .run(&api, out_dir, &hooks, false)
629            .unwrap();
630        assert_eq!(calls.load(Ordering::SeqCst), 1);
631
632        // Same generator, different config value: must re-run.
633        let g2 = ConfiguredGenerator::new(
634            CountingGenerator {
635                name: "counting",
636                calls: Arc::clone(&calls),
637                caps: TargetCapabilities::full(),
638            },
639            TestConfig {
640                knob: Some("changed".into()),
641                allow_unsupported: false,
642            },
643        );
644        Orchestrator::new()
645            .with_generator(&g2)
646            .run(&api, out_dir, &hooks, false)
647            .unwrap();
648        assert_eq!(
649            calls.load(Ordering::SeqCst),
650            2,
651            "config-only change must invalidate the cache",
652        );
653    }
654}