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.4.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 r#async: false,
407 cancellable: false,
408 deprecated: None,
409 since: None,
410 }],
411 structs: vec![],
412 enums: vec![],
413 callbacks: vec![],
414 listeners: vec![],
415 errors: None,
416 modules: vec![],
417 }],
418 generators: None,
419 package: None,
420 }
421 }
422
423 fn configured(
424 name: &'static str,
425 calls: Arc<AtomicUsize>,
426 ) -> ConfiguredGenerator<CountingGenerator> {
427 ConfiguredGenerator::new(
428 CountingGenerator {
429 name,
430 calls,
431 caps: TargetCapabilities::full(),
432 },
433 TestConfig::default(),
434 )
435 }
436
437 fn listener_api() -> Api {
440 let mut api = test_api();
441 api.modules[0].listeners = vec![weaveffi_ir::ir::ListenerDef {
442 name: "on_change".to_string(),
443 event_callback: "OnChange".to_string(),
444 doc: None,
445 }];
446 api.modules[0].callbacks = vec![weaveffi_ir::ir::CallbackDef {
447 name: "OnChange".to_string(),
448 params: vec![],
449 doc: None,
450 }];
451 api
452 }
453
454 fn partial(
455 calls: Arc<AtomicUsize>,
456 allow_unsupported: bool,
457 ) -> ConfiguredGenerator<CountingGenerator> {
458 ConfiguredGenerator::new(
459 CountingGenerator {
460 name: "partial",
461 calls,
462 caps: TargetCapabilities {
463 callbacks: false,
464 listeners: false,
465 ..TargetCapabilities::full()
466 },
467 },
468 TestConfig {
469 knob: None,
470 allow_unsupported,
471 },
472 )
473 }
474
475 #[test]
476 fn capability_gate_blocks_unsupported_target() {
477 let dir = tempfile::tempdir().unwrap();
478 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
479 let calls = Arc::new(AtomicUsize::new(0));
480 let gen = partial(Arc::clone(&calls), false);
481
482 let err = Orchestrator::new()
483 .with_generator(&gen)
484 .run(
485 &listener_api(),
486 out_dir,
487 &OrchestratorHooks::default(),
488 false,
489 )
490 .unwrap_err();
491
492 let msg = err.to_string();
493 assert!(msg.contains("target 'partial' does not support"), "{msg}");
494 assert!(msg.contains("math.on_change"), "{msg}");
495 assert!(msg.contains("allow_unsupported"), "{msg}");
496 assert_eq!(
497 calls.load(Ordering::SeqCst),
498 0,
499 "gated generator must not run"
500 );
501 }
502
503 #[test]
504 fn allow_unsupported_downgrades_gate_to_warning() {
505 let dir = tempfile::tempdir().unwrap();
506 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
507 let calls = Arc::new(AtomicUsize::new(0));
508 let gen = partial(Arc::clone(&calls), true);
509
510 Orchestrator::new()
511 .with_generator(&gen)
512 .run(
513 &listener_api(),
514 out_dir,
515 &OrchestratorHooks::default(),
516 false,
517 )
518 .expect("allow_unsupported must let generation proceed");
519
520 assert_eq!(calls.load(Ordering::SeqCst), 1, "generator should run");
521 }
522
523 #[test]
524 fn allow_unsupported_does_not_relax_other_targets() {
525 let dir = tempfile::tempdir().unwrap();
526 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
527 let opted_calls = Arc::new(AtomicUsize::new(0));
528 let strict_calls = Arc::new(AtomicUsize::new(0));
529 let opted = partial(Arc::clone(&opted_calls), true);
530 let strict = ConfiguredGenerator::new(
531 CountingGenerator {
532 name: "strict",
533 calls: Arc::clone(&strict_calls),
534 caps: TargetCapabilities {
535 listeners: false,
536 ..TargetCapabilities::full()
537 },
538 },
539 TestConfig::default(),
540 );
541
542 let err = Orchestrator::new()
543 .with_generator(&opted)
544 .with_generator(&strict)
545 .run(
546 &listener_api(),
547 out_dir,
548 &OrchestratorHooks::default(),
549 false,
550 )
551 .unwrap_err();
552
553 let msg = err.to_string();
554 assert!(msg.contains("target 'strict'"), "{msg}");
555 assert!(!msg.contains("target 'partial'"), "{msg}");
556 assert_eq!(opted_calls.load(Ordering::SeqCst), 0);
557 assert_eq!(strict_calls.load(Ordering::SeqCst), 0);
558 }
559
560 #[test]
561 fn incremental_skips_when_unchanged() {
562 let dir = tempfile::tempdir().unwrap();
563 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
564 let api = test_api();
565 let hooks = OrchestratorHooks::default();
566 let calls = Arc::new(AtomicUsize::new(0));
567 let gen = configured("counting", Arc::clone(&calls));
568
569 let orch = Orchestrator::new().with_generator(&gen);
570
571 orch.run(&api, out_dir, &hooks, false).unwrap();
572 assert_eq!(calls.load(Ordering::SeqCst), 1);
573 let content_after_first =
574 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
575
576 orch.run(&api, out_dir, &hooks, false).unwrap();
577 assert_eq!(
578 calls.load(Ordering::SeqCst),
579 1,
580 "generator should not run again"
581 );
582 let content_after_second =
583 std::fs::read_to_string(out_dir.join("counting/output.txt")).unwrap();
584
585 assert_eq!(content_after_first, content_after_second);
586 }
587
588 #[test]
589 fn force_bypasses_cache() {
590 let dir = tempfile::tempdir().unwrap();
591 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
592 let api = test_api();
593 let hooks = OrchestratorHooks::default();
594 let calls = Arc::new(AtomicUsize::new(0));
595 let gen = configured("counting", Arc::clone(&calls));
596
597 let orch = Orchestrator::new().with_generator(&gen);
598
599 orch.run(&api, out_dir, &hooks, false).unwrap();
600 assert_eq!(calls.load(Ordering::SeqCst), 1);
601
602 orch.run(&api, out_dir, &hooks, true).unwrap();
603 assert_eq!(calls.load(Ordering::SeqCst), 2, "force should bypass cache");
604 }
605
606 #[test]
607 fn parallel_orchestrator_runs_all_generators() {
608 let dir = tempfile::tempdir().unwrap();
609 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
610 let api = test_api();
611 let hooks = OrchestratorHooks::default();
612
613 let names = ["g0", "g1", "g2", "g3", "g4", "g5"];
614 let counters: Vec<Arc<AtomicUsize>> = names
615 .iter()
616 .map(|_| Arc::new(AtomicUsize::new(0)))
617 .collect();
618 let gens: Vec<ConfiguredGenerator<CountingGenerator>> = names
619 .iter()
620 .zip(counters.iter())
621 .map(|(name, calls)| configured(name, Arc::clone(calls)))
622 .collect();
623
624 let mut orch = Orchestrator::new();
625 for g in &gens {
626 orch = orch.with_generator(g);
627 }
628
629 orch.run(&api, out_dir, &hooks, false).unwrap();
630
631 for (name, calls) in names.iter().zip(counters.iter()) {
632 assert_eq!(
633 calls.load(Ordering::SeqCst),
634 1,
635 "generator '{name}' should have run exactly once",
636 );
637 assert!(
638 out_dir.join(name).join("output.txt").exists(),
639 "generator '{name}' should have written its output",
640 );
641 }
642 }
643
644 #[test]
645 fn single_generator_cache_invalidates_independently() {
646 let dir = tempfile::tempdir().unwrap();
647 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
648 let hooks = OrchestratorHooks::default();
649
650 let c_calls = Arc::new(AtomicUsize::new(0));
651 let s_calls = Arc::new(AtomicUsize::new(0));
652 let c_gen = configured("c", Arc::clone(&c_calls));
653 let s_gen = configured("swift", Arc::clone(&s_calls));
654
655 let orch = Orchestrator::new()
656 .with_generator(&c_gen)
657 .with_generator(&s_gen);
658
659 let api = test_api();
660 orch.run(&api, out_dir, &hooks, false).unwrap();
661 assert_eq!(c_calls.load(Ordering::SeqCst), 1);
662 assert_eq!(s_calls.load(Ordering::SeqCst), 1);
663
664 let mut modified = api.clone();
668 modified.modules[0].name = "math2".to_string();
669
670 let new_swift_hash =
671 cache::hash_generator_inputs(&modified, "swift", &s_gen.config_hash_input());
672 cache::write_generator_cache(out_dir, "swift", &new_swift_hash).unwrap();
673
674 orch.run(&modified, out_dir, &hooks, false).unwrap();
675 assert_eq!(
676 c_calls.load(Ordering::SeqCst),
677 2,
678 "C generator should re-run because its cache entry no longer matches",
679 );
680 assert_eq!(
681 s_calls.load(Ordering::SeqCst),
682 1,
683 "Swift generator's cache matched the new API and must be skipped",
684 );
685 }
686
687 #[test]
688 fn config_change_invalidates_cache() {
689 let dir = tempfile::tempdir().unwrap();
690 let out_dir = Utf8Path::from_path(dir.path()).unwrap();
691 let hooks = OrchestratorHooks::default();
692 let api = test_api();
693
694 let calls = Arc::new(AtomicUsize::new(0));
695 let g1 = ConfiguredGenerator::new(
696 CountingGenerator {
697 name: "counting",
698 calls: Arc::clone(&calls),
699 caps: TargetCapabilities::full(),
700 },
701 TestConfig::default(),
702 );
703 Orchestrator::new()
704 .with_generator(&g1)
705 .run(&api, out_dir, &hooks, false)
706 .unwrap();
707 assert_eq!(calls.load(Ordering::SeqCst), 1);
708
709 let g2 = ConfiguredGenerator::new(
711 CountingGenerator {
712 name: "counting",
713 calls: Arc::clone(&calls),
714 caps: TargetCapabilities::full(),
715 },
716 TestConfig {
717 knob: Some("changed".into()),
718 allow_unsupported: false,
719 },
720 );
721 Orchestrator::new()
722 .with_generator(&g2)
723 .run(&api, out_dir, &hooks, false)
724 .unwrap();
725 assert_eq!(
726 calls.load(Ordering::SeqCst),
727 2,
728 "config-only change must invalidate the cache",
729 );
730 }
731}