1use 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};
18use crate::package::{PackageContext, PackagedFile};
19
20pub mod common;
21pub mod writer;
22
23pub use writer::CodeWriter;
24
25fn run_hook(label: &str, cmd: &str) -> Result<()> {
26 let status = if cfg!(target_os = "windows") {
27 std::process::Command::new("cmd")
28 .args(["/C", cmd])
29 .status()?
30 } else {
31 std::process::Command::new("sh")
32 .arg("-c")
33 .arg(cmd)
34 .status()?
35 };
36 if !status.success() {
37 bail!("{label} hook failed with {status}");
38 }
39 Ok(())
40}
41
42pub trait Generator: Send + Sync {
51 type Config: Serialize + Default + Clone + Send + Sync;
58
59 fn name(&self) -> &'static str;
62
63 fn capabilities(&self) -> TargetCapabilities;
68
69 fn allows_unsupported(&self, config: &Self::Config) -> bool {
77 let _ = config;
78 false
79 }
80
81 fn generate(&self, api: &Api, out_dir: &Utf8Path, config: &Self::Config) -> Result<()>;
88
89 fn output_files(&self, _api: &Api, _out_dir: &Utf8Path, _config: &Self::Config) -> Vec<String> {
94 vec![]
95 }
96
97 fn package(
103 &self,
104 _api: &Api,
105 _ctx: &PackageContext,
106 _out_dir: &Utf8Path,
107 _config: &Self::Config,
108 ) -> Option<Vec<PackagedFile>> {
109 None
110 }
111}
112
113pub trait DynGenerator: Send + Sync {
119 fn name(&self) -> &'static str;
121 fn capabilities(&self) -> TargetCapabilities;
124 fn allows_unsupported(&self) -> bool;
127 fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()>;
135 fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String>;
138 fn package(
142 &self,
143 api: &Api,
144 ctx: &PackageContext,
145 out_dir: &Utf8Path,
146 ) -> Option<Vec<PackagedFile>>;
147 fn config_hash_input(&self) -> Vec<u8>;
150}
151
152pub struct ConfiguredGenerator<G: Generator> {
160 inner: G,
161 config: G::Config,
162}
163
164impl<G: Generator> ConfiguredGenerator<G> {
165 pub fn new(inner: G, config: G::Config) -> Self {
167 Self { inner, config }
168 }
169
170 pub fn config(&self) -> &G::Config {
172 &self.config
173 }
174
175 pub fn inner(&self) -> &G {
177 &self.inner
178 }
179}
180
181impl<G: Generator> DynGenerator for ConfiguredGenerator<G> {
182 fn name(&self) -> &'static str {
183 self.inner.name()
184 }
185
186 fn capabilities(&self) -> TargetCapabilities {
187 self.inner.capabilities()
188 }
189
190 fn allows_unsupported(&self) -> bool {
191 self.inner.allows_unsupported(&self.config)
192 }
193
194 fn generate(&self, api: &Api, out_dir: &Utf8Path) -> Result<()> {
195 self.inner.generate(api, out_dir, &self.config)
196 }
197
198 fn output_files(&self, api: &Api, out_dir: &Utf8Path) -> Vec<String> {
199 self.inner.output_files(api, out_dir, &self.config)
200 }
201
202 fn package(
203 &self,
204 api: &Api,
205 ctx: &PackageContext,
206 out_dir: &Utf8Path,
207 ) -> Option<Vec<PackagedFile>> {
208 self.inner.package(api, ctx, out_dir, &self.config)
209 }
210
211 fn config_hash_input(&self) -> Vec<u8> {
212 let value =
213 serde_json::to_value(&self.config).expect("generator config should serialize to JSON");
214 serde_json::to_vec(&value).expect("JSON Value should serialize")
215 }
216}
217
218#[derive(Default, Debug, Clone)]
220pub struct OrchestratorHooks {
221 pub pre_generate: Option<String>,
224 pub post_generate: Option<String>,
226}
227
228#[derive(Default)]
232pub struct Orchestrator<'a> {
233 generators: Vec<&'a dyn DynGenerator>,
234}
235
236impl<'a> Orchestrator<'a> {
237 pub fn new() -> Self {
239 Self::default()
240 }
241
242 pub fn with_generator(mut self, gen: &'a dyn DynGenerator) -> Self {
244 self.generators.push(gen);
245 self
246 }
247
248 pub fn run(
262 &self,
263 api: &Api,
264 out_dir: &Utf8Path,
265 hooks: &OrchestratorHooks,
266 force: bool,
267 ) -> Result<()> {
268 let mut violations: Vec<String> = Vec::new();
275 for g in &self.generators {
276 let Err(err) = capabilities::check(api, g.name(), &g.capabilities()) else {
277 continue;
278 };
279 if g.allows_unsupported() {
280 eprintln!(
281 "warning: target '{}' does not support every feature this IDL uses; \
282 generating anyway because allow_unsupported is set:",
283 g.name()
284 );
285 for (feature, locations) in &err.violations {
286 eprintln!(" - {feature} (used by: {})", locations.join(", "));
287 }
288 } else {
289 violations.push(err.to_string());
290 }
291 }
292 if !violations.is_empty() {
293 bail!("{}", violations.join("\n"));
294 }
295
296 if force {
297 cache::invalidate_all(out_dir)?;
298 }
299
300 let mut pending: Vec<(&'a dyn DynGenerator, String)> = Vec::new();
304 for &g in &self.generators {
305 let cfg_bytes = g.config_hash_input();
306 let hash = cache::hash_generator_inputs(api, g.name(), &cfg_bytes);
307 let cached = cache::read_generator_cache(out_dir, g.name());
308 if cached.as_deref() != Some(hash.as_str()) {
309 pending.push((g, hash));
310 }
311 }
312
313 if pending.is_empty() {
314 println!("No changes detected, skipping code generation.");
315 return Ok(());
316 }
317
318 if let Some(cmd) = &hooks.pre_generate {
319 run_hook("pre_generate", cmd)?;
320 }
321
322 pending
323 .par_iter()
324 .map(|(g, _)| g.generate(api, out_dir))
325 .collect::<Result<Vec<_>>>()?;
326
327 if let Some(cmd) = &hooks.post_generate {
328 run_hook("post_generate", cmd)?;
329 }
330
331 for (g, hash) in &pending {
332 cache::write_generator_cache(out_dir, g.name(), hash)?;
333 }
334 Ok(())
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341 use std::sync::atomic::{AtomicUsize, Ordering};
342 use std::sync::Arc;
343 use weaveffi_ir::ir::{Function, Module, Param, TypeRef};
344
345 #[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
348 struct TestConfig {
349 knob: Option<String>,
350 allow_unsupported: bool,
351 }
352
353 struct CountingGenerator {
354 name: &'static str,
355 calls: Arc<AtomicUsize>,
356 caps: TargetCapabilities,
357 }
358
359 impl Generator for CountingGenerator {
360 type Config = TestConfig;
361
362 fn name(&self) -> &'static str {
363 self.name
364 }
365
366 fn capabilities(&self) -> TargetCapabilities {
367 self.caps
368 }
369
370 fn allows_unsupported(&self, config: &Self::Config) -> bool {
371 config.allow_unsupported
372 }
373
374 fn generate(&self, _api: &Api, out_dir: &Utf8Path, _config: &Self::Config) -> Result<()> {
375 self.calls.fetch_add(1, Ordering::SeqCst);
376 let dir = out_dir.join(self.name);
377 std::fs::create_dir_all(dir.as_std_path())?;
378 std::fs::write(dir.join("output.txt").as_std_path(), "generated")?;
379 Ok(())
380 }
381 }
382
383 fn test_api() -> Api {
384 Api {
385 version: "0.5.0".to_string(),
386 modules: vec![Module {
387 name: "math".to_string(),
388 functions: vec![Function {
389 name: "add".to_string(),
390 params: vec![
391 Param {
392 name: "a".to_string(),
393 ty: TypeRef::I32,
394 mutable: false,
395 doc: None,
396 },
397 Param {
398 name: "b".to_string(),
399 ty: TypeRef::I32,
400 mutable: false,
401 doc: None,
402 },
403 ],
404 returns: Some(TypeRef::I32),
405 doc: None,
406 throws: false,
407 r#async: false,
408 cancellable: false,
409 deprecated: None,
410 since: None,
411 }],
412 interfaces: vec![],
413 structs: vec![],
414 enums: vec![],
415 callbacks: vec![],
416 listeners: vec![],
417 errors: None,
418 modules: vec![],
419 }],
420 generators: None,
421 package: None,
422 }
423 }
424
425 fn configured(
426 name: &'static str,
427 calls: Arc<AtomicUsize>,
428 ) -> ConfiguredGenerator<CountingGenerator> {
429 ConfiguredGenerator::new(
430 CountingGenerator {
431 name,
432 calls,
433 caps: TargetCapabilities::full(),
434 },
435 TestConfig::default(),
436 )
437 }
438
439 fn listener_api() -> Api {
442 let mut api = test_api();
443 api.modules[0].listeners = vec![weaveffi_ir::ir::ListenerDef {
444 name: "on_change".to_string(),
445 event_callback: "OnChange".to_string(),
446 doc: None,
447 }];
448 api.modules[0].callbacks = vec![weaveffi_ir::ir::CallbackDef {
449 name: "OnChange".to_string(),
450 params: vec![],
451 doc: None,
452 }];
453 api
454 }
455
456 fn partial(
457 calls: Arc<AtomicUsize>,
458 allow_unsupported: bool,
459 ) -> ConfiguredGenerator<CountingGenerator> {
460 ConfiguredGenerator::new(
461 CountingGenerator {
462 name: "partial",
463 calls,
464 caps: TargetCapabilities {
465 callbacks: false,
466 listeners: false,
467 ..TargetCapabilities::full()
468 },
469 },
470 TestConfig {
471 knob: None,
472 allow_unsupported,
473 },
474 )
475 }
476
477 #[test]
478 fn capability_gate_blocks_unsupported_target() {
479 let dir = tempfile::tempdir().unwrap();
480 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
481 let calls = Arc::new(AtomicUsize::new(0));
482 let gen = partial(Arc::clone(&calls), false);
483
484 let err = Orchestrator::new()
485 .with_generator(&gen)
486 .run(
487 &listener_api(),
488 out_dir,
489 &OrchestratorHooks::default(),
490 false,
491 )
492 .unwrap_err();
493
494 let msg = err.to_string();
495 assert!(msg.contains("target 'partial' does not support"), "{msg}");
496 assert!(msg.contains("math.on_change"), "{msg}");
497 assert!(msg.contains("allow_unsupported"), "{msg}");
498 assert_eq!(
499 calls.load(Ordering::SeqCst),
500 0,
501 "gated generator must not run"
502 );
503 }
504
505 #[test]
506 fn allow_unsupported_downgrades_gate_to_warning() {
507 let dir = tempfile::tempdir().unwrap();
508 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
509 let calls = Arc::new(AtomicUsize::new(0));
510 let gen = partial(Arc::clone(&calls), true);
511
512 Orchestrator::new()
513 .with_generator(&gen)
514 .run(
515 &listener_api(),
516 out_dir,
517 &OrchestratorHooks::default(),
518 false,
519 )
520 .expect("allow_unsupported must let generation proceed");
521
522 assert_eq!(calls.load(Ordering::SeqCst), 1, "generator should run");
523 }
524
525 #[test]
526 fn allow_unsupported_does_not_relax_other_targets() {
527 let dir = tempfile::tempdir().unwrap();
528 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
529 let opted_calls = Arc::new(AtomicUsize::new(0));
530 let strict_calls = Arc::new(AtomicUsize::new(0));
531 let opted = partial(Arc::clone(&opted_calls), true);
532 let strict = ConfiguredGenerator::new(
533 CountingGenerator {
534 name: "strict",
535 calls: Arc::clone(&strict_calls),
536 caps: TargetCapabilities {
537 listeners: false,
538 ..TargetCapabilities::full()
539 },
540 },
541 TestConfig::default(),
542 );
543
544 let err = Orchestrator::new()
545 .with_generator(&opted)
546 .with_generator(&strict)
547 .run(
548 &listener_api(),
549 out_dir,
550 &OrchestratorHooks::default(),
551 false,
552 )
553 .unwrap_err();
554
555 let msg = err.to_string();
556 assert!(msg.contains("target 'strict'"), "{msg}");
557 assert!(!msg.contains("target 'partial'"), "{msg}");
558 assert_eq!(opted_calls.load(Ordering::SeqCst), 0);
559 assert_eq!(strict_calls.load(Ordering::SeqCst), 0);
560 }
561
562 #[test]
563 fn incremental_skips_when_unchanged() {
564 let dir = tempfile::tempdir().unwrap();
565 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
566 let api = test_api();
567 let hooks = OrchestratorHooks::default();
568 let calls = Arc::new(AtomicUsize::new(0));
569 let gen = configured("counting", Arc::clone(&calls));
570
571 let orch = Orchestrator::new().with_generator(&gen);
572
573 orch.run(&api, out_dir, &hooks, false).unwrap();
574 assert_eq!(calls.load(Ordering::SeqCst), 1);
575 let content_after_first =
576 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
577
578 orch.run(&api, out_dir, &hooks, false).unwrap();
579 assert_eq!(
580 calls.load(Ordering::SeqCst),
581 1,
582 "generator should not run again"
583 );
584 let content_after_second =
585 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
586
587 assert_eq!(content_after_first, content_after_second);
588 }
589
590 #[test]
591 fn force_bypasses_cache() {
592 let dir = tempfile::tempdir().unwrap();
593 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
594 let api = test_api();
595 let hooks = OrchestratorHooks::default();
596 let calls = Arc::new(AtomicUsize::new(0));
597 let gen = configured("counting", Arc::clone(&calls));
598
599 let orch = Orchestrator::new().with_generator(&gen);
600
601 orch.run(&api, out_dir, &hooks, false).unwrap();
602 assert_eq!(calls.load(Ordering::SeqCst), 1);
603
604 orch.run(&api, out_dir, &hooks, true).unwrap();
605 assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
606 }
607
608 #[test]
609 fn parallel_orchestrator_runs_all_generators() {
610 let dir = tempfile::tempdir().unwrap();
611 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
612 let api = test_api();
613 let hooks = OrchestratorHooks::default();
614
615 let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
616 let counters: Vec<Arc<AtomicUsize>> = names
617 .iter()
618 .map(|_| Arc::new(AtomicUsize::new(0)))
619 .collect();
620 let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
621 .iter()
622 .zip(counters.iter())
623 .map(|(name, calls)| configured(name, Arc::clone(calls)))
624 .collect();
625
626 let mut orch = Orchestrator::new();
627 for g in &gens {
628 orch = orch.with_generator(g);
629 }
630
631 orch.run(&api, out_dir, &hooks, false).unwrap();
632
633 for (name, calls) in names.iter().zip(counters.iter()) {
634 assert_eq!(
635 calls.load(Ordering::SeqCst),
636 1,
637 "generator '{name}' should have run exactly once",
638 );
639 assert!(
640 out_dir.join(name).join("output.txt").exists(),
641 "generator '{name}' should have written its output",
642 );
643 }
644 }
645
646 #[test]
647 fn single_generator_cache_invalidates_independently() {
648 let dir = tempfile::tempdir().unwrap();
649 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
650 let hooks = OrchestratorHooks::default();
651
652 let c_calls = Arc::new(AtomicUsize::new(0));
653 let s_calls = Arc::new(AtomicUsize::new(0));
654 let c_gen = configured("c", Arc::clone(&c_calls));
655 let s_gen = configured("swift", Arc::clone(&s_calls));
656
657 let orch = Orchestrator::new()
658 .with_generator(&c_gen)
659 .with_generator(&s_gen);
660
661 let api = test_api();
662 orch.run(&api, out_dir, &hooks, false).unwrap();
663 assert_eq!(c_calls.load(Ordering::SeqCst), 1);
664 assert_eq!(s_calls.load(Ordering::SeqCst), 1);
665
666 let mut modified = api.clone();
670 modified.modules[0].name = "math2".to_string();
671
672 let new_swift_hash =
673 cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
674 cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
675
676 orch.run(&modified, out_dir, &hooks, false).unwrap();
677 assert_eq!(
678 c_calls.load(Ordering::SeqCst),
679 2,
680 "C generator should re-run because its cache entry no longer matches",
681 );
682 assert_eq!(
683 s_calls.load(Ordering::SeqCst),
684 1,
685 "Swift generator's cache matched the new API and must be skipped",
686 );
687 }
688
689 #[test]
690 fn config_change_invalidates_cache() {
691 let dir = tempfile::tempdir().unwrap();
692 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
693 let hooks = OrchestratorHooks::default();
694 let api = test_api();
695
696 let calls = Arc::new(AtomicUsize::new(0));
697 let g1 = ConfiguredGenerator::new(
698 CountingGenerator {
699 name: "counting",
700 calls: Arc::clone(&calls),
701 caps: TargetCapabilities::full(),
702 },
703 TestConfig::default(),
704 );
705 Orchestrator::new()
706 .with_generator(&g1)
707 .run(&api, out_dir, &hooks, false)
708 .unwrap();
709 assert_eq!(calls.load(Ordering::SeqCst), 1);
710
711 let g2 = ConfiguredGenerator::new(
713 CountingGenerator {
714 name: "counting",
715 calls: Arc::clone(&calls),
716 caps: TargetCapabilities::full(),
717 },
718 TestConfig {
719 knob: Some("changed".into()),
720 allow_unsupported: false,
721 },
722 );
723 Orchestrator::new()
724 .with_generator(&g2)
725 .run(&api, out_dir, &hooks, false)
726 .unwrap();
727 assert_eq!(
728 calls.load(Ordering::SeqCst),
729 2,
730 "config-only change must invalidate the cache",
731 );
732 }
733}