1use std::sync::Arc;
4use std::time::Duration;
5
6use thiserror::Error;
7use wasmtime::component::{Component, HasSelf, Linker, ResourceTable};
8use wasmtime::{AsContextMut, Engine as WtEngine, Store, StoreLimits, StoreLimitsBuilder};
9use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
10
11use forge_ir::{Diagnostic, Ir, PluginInfo};
12use forge_ir_bindgen::bindings;
13use forge_ir_bindgen::convert::{self, ResourceKindRepr, StageErrorRepr};
14
15#[derive(Debug, Error)]
20pub enum StageError {
21 #[error("plugin rejected input: {reason}")]
22 Rejected {
23 reason: String,
24 diagnostics: Vec<Diagnostic>,
25 },
26 #[error("plugin trap or malformed return: {0}")]
27 PluginBug(String),
28 #[error("plugin config invalid: {0}")]
29 ConfigInvalid(String),
30 #[error("plugin exceeded {0:?}")]
31 ResourceExceeded(ResourceKind),
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum ResourceKind {
36 Fuel,
37 Memory,
38 Time,
39 OutputSize,
40}
41
42#[derive(Debug, Clone)]
43pub struct TransformOutput {
44 pub spec: Ir,
45 pub diagnostics: Vec<Diagnostic>,
46}
47
48#[derive(Debug, Clone)]
49pub struct GenerationOutput {
50 pub files: Vec<OutputFile>,
51 pub diagnostics: Vec<Diagnostic>,
52}
53
54#[derive(Debug, Clone)]
55pub struct OutputFile {
56 pub path: String,
57 pub content: Vec<u8>,
58 pub mode: FileMode,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum FileMode {
63 Text,
64 Binary,
65 Executable,
66}
67
68#[derive(Debug, Clone, Copy)]
73pub struct Limits {
74 pub fuel: u64,
75 pub memory_bytes: usize,
76 pub wall_clock_ms: u64,
77 pub output_files_max: u32,
78 pub output_total_bytes_max: u64,
79 pub output_per_file_bytes_max: u64,
80}
81
82impl Limits {
83 pub const fn transformer() -> Self {
84 Self {
85 fuel: 5_000_000_000,
86 memory_bytes: 128 * 1024 * 1024,
87 wall_clock_ms: 5_000,
88 output_files_max: 0,
89 output_total_bytes_max: 0,
90 output_per_file_bytes_max: 0,
91 }
92 }
93
94 pub const fn generator() -> Self {
95 Self {
96 fuel: 50_000_000_000,
97 memory_bytes: 512 * 1024 * 1024,
98 wall_clock_ms: 30_000,
99 output_files_max: 10_000,
100 output_total_bytes_max: 256 * 1024 * 1024,
101 output_per_file_bytes_max: 16 * 1024 * 1024,
102 }
103 }
104}
105
106#[derive(Clone)]
115pub struct Engine {
116 inner: Arc<EngineInner>,
117}
118
119struct EngineInner {
120 wt: WtEngine,
121 _ticker: EpochTicker,
123}
124
125impl std::fmt::Debug for Engine {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 f.debug_struct("Engine").finish_non_exhaustive()
128 }
129}
130
131impl Engine {
132 pub fn new() -> Result<Self, EngineError> {
136 Self::build(None)
137 }
138
139 pub fn with_cache(cache_dir: &std::path::Path) -> Result<Self, EngineError> {
153 let mut cc = wasmtime::CacheConfig::new();
154 cc.with_directory(cache_dir);
155 let cache = wasmtime::Cache::new(cc).map_err(|e| EngineError::Cache(e.to_string()))?;
156 Self::build(Some(cache))
157 }
158
159 fn build(cache: Option<wasmtime::Cache>) -> Result<Self, EngineError> {
160 let mut cfg = wasmtime::Config::new();
161 cfg.wasm_component_model(true)
162 .consume_fuel(true)
163 .epoch_interruption(true);
164 cfg.relaxed_simd_deterministic(true);
166 if let Some(cache) = cache {
167 cfg.cache(Some(cache));
168 }
169
170 let wt = WtEngine::new(&cfg).map_err(|e| EngineError::Init(e.to_string()))?;
171
172 let ticker = EpochTicker::spawn(wt.clone(), Duration::from_millis(10));
175
176 Ok(Engine {
177 inner: Arc::new(EngineInner {
178 wt,
179 _ticker: ticker,
180 }),
181 })
182 }
183
184 pub fn raw(&self) -> &WtEngine {
185 &self.inner.wt
186 }
187}
188
189#[derive(Debug, Error)]
190pub enum EngineError {
191 #[error("wasmtime engine init failed: {0}")]
192 Init(String),
193 #[error("compilation cache init failed: {0}")]
194 Cache(String),
195}
196
197struct EpochTicker {
200 stop: Arc<std::sync::atomic::AtomicBool>,
201 handle: Option<std::thread::JoinHandle<()>>,
202}
203
204impl EpochTicker {
205 fn spawn(engine: WtEngine, cadence: Duration) -> Self {
206 let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
207 let stop_t = stop.clone();
208 let handle = std::thread::spawn(move || {
209 while !stop_t.load(std::sync::atomic::Ordering::Relaxed) {
210 std::thread::sleep(cadence);
211 engine.increment_epoch();
212 }
213 });
214 EpochTicker {
215 stop,
216 handle: Some(handle),
217 }
218 }
219}
220
221impl Drop for EpochTicker {
222 fn drop(&mut self) {
223 self.stop.store(true, std::sync::atomic::Ordering::Relaxed);
224 if let Some(h) = self.handle.take() {
225 let _ = h.join();
226 }
227 }
228}
229
230pub struct HostState {
243 pub limits: Limits,
244 pub log_lines: Vec<(forge_ir::LogLevel, String)>,
245 pub store_limits: StoreLimits,
246 pub resource_table: ResourceTable,
247 pub wasi: WasiCtx,
248}
249
250impl std::fmt::Debug for HostState {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 f.debug_struct("HostState")
253 .field("limits", &self.limits)
254 .field("log_lines", &self.log_lines.len())
255 .finish_non_exhaustive()
256 }
257}
258
259impl HostState {
260 fn new(limits: Limits) -> Self {
261 let store_limits = StoreLimitsBuilder::new()
262 .memory_size(limits.memory_bytes)
263 .build();
264 let wasi = WasiCtxBuilder::new().build();
267 HostState {
268 limits,
269 log_lines: Vec::new(),
270 store_limits,
271 resource_table: ResourceTable::new(),
272 wasi,
273 }
274 }
275}
276
277impl WasiView for HostState {
278 fn ctx(&mut self) -> WasiCtxView<'_> {
279 WasiCtxView {
280 ctx: &mut self.wasi,
281 table: &mut self.resource_table,
282 }
283 }
284}
285
286macro_rules! impl_host_api {
293 ($world:ident) => {
294 impl bindings::$world::forge::plugin::types::Host for HostState {}
295 impl bindings::$world::forge::plugin::stage::Host for HostState {}
296
297 impl bindings::$world::forge::plugin::host_api::Host for HostState {
298 fn log(
299 &mut self,
300 level: bindings::$world::forge::plugin::host_api::LogLevel,
301 message: String,
302 ) -> wasmtime::Result<()> {
303 use bindings::$world::forge::plugin::host_api::LogLevel as L;
304 let lv = match level {
305 L::Trace => forge_ir::LogLevel::Trace,
306 L::Debug => forge_ir::LogLevel::Debug,
307 L::Info => forge_ir::LogLevel::Info,
308 L::Warn => forge_ir::LogLevel::Warn,
309 L::Error => forge_ir::LogLevel::Error,
310 };
311 match lv {
312 forge_ir::LogLevel::Trace => {
313 tracing::trace!(target: "plugin", "{message}")
314 }
315 forge_ir::LogLevel::Debug => {
316 tracing::debug!(target: "plugin", "{message}")
317 }
318 forge_ir::LogLevel::Info => {
319 tracing::info!(target: "plugin", "{message}")
320 }
321 forge_ir::LogLevel::Warn => {
322 tracing::warn!(target: "plugin", "{message}")
323 }
324 forge_ir::LogLevel::Error => {
325 tracing::error!(target: "plugin", "{message}")
326 }
327 }
328 self.log_lines.push((lv, message));
329 Ok(())
330 }
331
332 fn case_convert(
333 &mut self,
334 input: String,
335 style: bindings::$world::forge::plugin::host_api::CaseStyle,
336 ) -> wasmtime::Result<String> {
337 use bindings::$world::forge::plugin::host_api::CaseStyle as S;
338 let local = match style {
339 S::Snake => case::Style::Snake,
340 S::Kebab => case::Style::Kebab,
341 S::Camel => case::Style::Camel,
342 S::Pascal => case::Style::Pascal,
343 S::ScreamingSnake => case::Style::ScreamingSnake,
344 };
345 Ok(case::convert(&input, local))
346 }
347 }
348 };
349}
350
351impl_host_api!(transformer);
352impl_host_api!(generator);
353
354mod case {
355 #[derive(Debug, Clone, Copy)]
357 pub enum Style {
358 Snake,
359 Kebab,
360 Camel,
361 Pascal,
362 ScreamingSnake,
363 }
364
365 fn split(input: &str) -> Vec<String> {
369 let mut words: Vec<String> = Vec::new();
370 let mut cur = String::new();
371 let mut prev_lower = false;
372 for ch in input.chars() {
373 if ch == '_' || ch == '-' || ch.is_whitespace() {
374 if !cur.is_empty() {
375 words.push(std::mem::take(&mut cur));
376 }
377 prev_lower = false;
378 } else if ch.is_ascii_uppercase() {
379 if prev_lower && !cur.is_empty() {
380 words.push(std::mem::take(&mut cur));
381 }
382 cur.push(ch.to_ascii_lowercase());
383 prev_lower = false;
384 } else {
385 cur.push(ch);
386 prev_lower = ch.is_ascii_lowercase();
387 }
388 }
389 if !cur.is_empty() {
390 words.push(cur);
391 }
392 words
393 }
394
395 pub fn convert(input: &str, style: Style) -> String {
396 let words = split(input);
397 match style {
398 Style::Snake => words.join("_"),
399 Style::Kebab => words.join("-"),
400 Style::ScreamingSnake => words
401 .iter()
402 .map(|w| w.to_ascii_uppercase())
403 .collect::<Vec<_>>()
404 .join("_"),
405 Style::Camel => words
406 .iter()
407 .enumerate()
408 .map(|(i, w)| if i == 0 { w.clone() } else { capitalize(w) })
409 .collect::<String>(),
410 Style::Pascal => words.iter().map(|w| capitalize(w)).collect::<String>(),
411 }
412 }
413
414 fn capitalize(w: &str) -> String {
415 let mut chars = w.chars();
416 match chars.next() {
417 None => String::new(),
418 Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
419 }
420 }
421
422 #[cfg(test)]
423 mod tests {
424 use super::*;
425 #[test]
426 fn snake() {
427 assert_eq!(convert("HelloWorld", Style::Snake), "hello_world");
428 assert_eq!(convert("hello-world", Style::Snake), "hello_world");
429 assert_eq!(convert("hello world", Style::Snake), "hello_world");
430 }
431 #[test]
432 fn pascal() {
433 assert_eq!(convert("hello_world", Style::Pascal), "HelloWorld");
434 }
435 #[test]
436 fn camel() {
437 assert_eq!(convert("hello_world", Style::Camel), "helloWorld");
438 }
439 #[test]
440 fn kebab() {
441 assert_eq!(convert("HelloWorld", Style::Kebab), "hello-world");
442 }
443 #[test]
444 fn screaming() {
445 assert_eq!(convert("helloWorld", Style::ScreamingSnake), "HELLO_WORLD");
446 }
447 }
448}
449
450#[derive(Debug, Error)]
455pub enum LoadError {
456 #[error("failed to compile plugin component: {0}")]
457 Compile(String),
458 #[error("failed to link plugin: {0}")]
459 Link(String),
460 #[error("failed to instantiate plugin: {0}")]
461 Instantiate(String),
462 #[error("failed to fetch plugin info: {0}")]
463 Info(String),
464 #[error("plugin info failed conversion: {0}")]
465 Convert(String),
466}
467
468pub struct Plugin {
471 engine: Engine,
472 component: Component,
473 info: PluginInfo,
474 config_schema: String,
475 kind: PluginKind,
476}
477
478impl std::fmt::Debug for Plugin {
479 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
480 f.debug_struct("Plugin")
481 .field("info", &self.info)
482 .field("kind", &self.kind)
483 .finish_non_exhaustive()
484 }
485}
486
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488pub enum PluginKind {
489 Transformer,
490 Generator,
491}
492
493impl Plugin {
494 pub fn info(&self) -> &PluginInfo {
495 &self.info
496 }
497
498 pub fn config_schema(&self) -> &str {
499 &self.config_schema
500 }
501
502 pub fn kind(&self) -> PluginKind {
503 self.kind
504 }
505
506 pub fn load_transformer(engine: &Engine, bytes: &[u8]) -> Result<Self, LoadError> {
508 let component =
509 Component::new(engine.raw(), bytes).map_err(|e| LoadError::Compile(e.to_string()))?;
510 let linker = build_transformer_linker(engine, &component).map_err(LoadError::Link)?;
511 let mut store = make_store(engine, Limits::transformer());
512 let inst =
513 bindings::transformer::IrTransformer::instantiate(&mut store, &component, &linker)
514 .map_err(|e| LoadError::Instantiate(e.to_string()))?;
515 let info_wit = inst
516 .forge_plugin_transformer_api()
517 .call_info(&mut store)
518 .map_err(|e| LoadError::Info(e.to_string()))?;
519 let schema = inst
520 .forge_plugin_transformer_api()
521 .call_config_schema(&mut store)
522 .map_err(|e| LoadError::Info(e.to_string()))?;
523 let info = convert::transformer::plugin_info_from_wit(info_wit);
524 Ok(Plugin {
525 engine: engine.clone(),
526 component,
527 info,
528 config_schema: schema,
529 kind: PluginKind::Transformer,
530 })
531 }
532
533 pub fn load_generator(engine: &Engine, bytes: &[u8]) -> Result<Self, LoadError> {
535 let component =
536 Component::new(engine.raw(), bytes).map_err(|e| LoadError::Compile(e.to_string()))?;
537 let linker = build_generator_linker(engine, &component).map_err(LoadError::Link)?;
538 let mut store = make_store(engine, Limits::generator());
539 let inst = bindings::generator::CodeGenerator::instantiate(&mut store, &component, &linker)
540 .map_err(|e| LoadError::Instantiate(e.to_string()))?;
541 let info_wit = inst
542 .forge_plugin_generator_api()
543 .call_info(&mut store)
544 .map_err(|e| LoadError::Info(e.to_string()))?;
545 let schema = inst
546 .forge_plugin_generator_api()
547 .call_config_schema(&mut store)
548 .map_err(|e| LoadError::Info(e.to_string()))?;
549 let info = convert::generator::plugin_info_from_wit(info_wit);
550 Ok(Plugin {
551 engine: engine.clone(),
552 component,
553 info,
554 config_schema: schema,
555 kind: PluginKind::Generator,
556 })
557 }
558
559 pub fn transform(
561 &self,
562 spec: Ir,
563 config: &str,
564 limits: Limits,
565 ) -> Result<TransformOutput, StageError> {
566 if self.kind != PluginKind::Transformer {
567 return Err(StageError::PluginBug(
568 "plugin loaded as transformer but called as generator".into(),
569 ));
570 }
571 let linker = build_transformer_linker(&self.engine, &self.component)
572 .map_err(|e| StageError::PluginBug(format!("link: {e}")))?;
573 let mut store = make_store(&self.engine, limits);
574 let inst =
575 bindings::transformer::IrTransformer::instantiate(&mut store, &self.component, &linker)
576 .map_err(|e| StageError::PluginBug(format!("instantiate: {e}")))?;
577 let wit_ir = convert::transformer::ir_to_wit(spec);
578 let result = inst.forge_plugin_transformer_api().call_transform(
579 store.as_context_mut(),
580 &wit_ir,
581 config,
582 );
583 let result = map_call_error(result, &store)?;
584 match result {
585 Ok(out) => {
586 let spec = convert::transformer::ir_from_wit(out.spec)
587 .map_err(|e| StageError::PluginBug(format!("ir convert: {e}")))?;
588 let diagnostics = out
589 .diagnostics
590 .into_iter()
591 .map(convert::transformer::diagnostic_from_wit)
592 .collect::<Result<Vec<_>, _>>()
593 .map_err(|e| StageError::PluginBug(format!("diag convert: {e}")))?;
594 Ok(TransformOutput { spec, diagnostics })
595 }
596 Err(stage_err) => Err(stage_error_from_repr(
597 convert::transformer::stage_error_from_wit(stage_err),
598 )),
599 }
600 }
601
602 pub fn generate(
604 &self,
605 spec: Ir,
606 config: &str,
607 limits: Limits,
608 ) -> Result<GenerationOutput, StageError> {
609 if self.kind != PluginKind::Generator {
610 return Err(StageError::PluginBug(
611 "plugin loaded as generator but called as transformer".into(),
612 ));
613 }
614 let linker = build_generator_linker(&self.engine, &self.component)
615 .map_err(|e| StageError::PluginBug(format!("link: {e}")))?;
616 let mut store = make_store(&self.engine, limits);
617 let inst =
618 bindings::generator::CodeGenerator::instantiate(&mut store, &self.component, &linker)
619 .map_err(|e| StageError::PluginBug(format!("instantiate: {e}")))?;
620 let wit_ir = convert::generator::ir_to_wit(spec);
621 let result = inst.forge_plugin_generator_api().call_generate(
622 store.as_context_mut(),
623 &wit_ir,
624 config,
625 );
626 let result = map_call_error(result, &store)?;
627 match result {
628 Ok(out) => {
629 let mut total_bytes: u64 = 0;
630 let files: Vec<OutputFile> = out
631 .files
632 .into_iter()
633 .map(|f| {
634 total_bytes = total_bytes.saturating_add(f.content.len() as u64);
635 OutputFile {
636 path: f.path,
637 content: f.content,
638 mode: match f.mode {
639 bindings::generator::exports::forge::plugin::generator_api::FileMode::Text => FileMode::Text,
640 bindings::generator::exports::forge::plugin::generator_api::FileMode::Binary => FileMode::Binary,
641 bindings::generator::exports::forge::plugin::generator_api::FileMode::Executable => FileMode::Executable,
642 },
643 }
644 })
645 .collect();
646 if files.len() as u64 > limits.output_files_max as u64 {
647 return Err(StageError::ResourceExceeded(ResourceKind::OutputSize));
648 }
649 if total_bytes > limits.output_total_bytes_max {
650 return Err(StageError::ResourceExceeded(ResourceKind::OutputSize));
651 }
652 let diagnostics = out
653 .diagnostics
654 .into_iter()
655 .map(convert::generator::diagnostic_from_wit)
656 .collect::<Result<Vec<_>, _>>()
657 .map_err(|e| StageError::PluginBug(format!("diag convert: {e}")))?;
658 Ok(GenerationOutput { files, diagnostics })
659 }
660 Err(stage_err) => Err(stage_error_from_repr(
661 convert::generator::stage_error_from_wit(stage_err),
662 )),
663 }
664 }
665}
666
667fn stage_error_from_repr(r: StageErrorRepr) -> StageError {
671 match r {
672 StageErrorRepr::Rejected {
673 reason,
674 diagnostics,
675 } => StageError::Rejected {
676 reason,
677 diagnostics,
678 },
679 StageErrorRepr::PluginBug(s) => StageError::PluginBug(s),
680 StageErrorRepr::ConfigInvalid(s) => StageError::ConfigInvalid(s),
681 StageErrorRepr::ResourceExceeded(k) => StageError::ResourceExceeded(match k {
682 ResourceKindRepr::Fuel => ResourceKind::Fuel,
683 ResourceKindRepr::Memory => ResourceKind::Memory,
684 ResourceKindRepr::Time => ResourceKind::Time,
685 ResourceKindRepr::OutputSize => ResourceKind::OutputSize,
686 }),
687 }
688}
689
690fn build_transformer_linker(
699 engine: &Engine,
700 _component: &Component,
701) -> Result<Linker<HostState>, String> {
702 let mut linker = Linker::<HostState>::new(engine.raw());
703 bindings::transformer::IrTransformer::add_to_linker::<HostState, HasSelf<HostState>>(
704 &mut linker,
705 |s| s,
706 )
707 .map_err(|e| e.to_string())?;
708 wasmtime_wasi::p2::add_to_linker_sync(&mut linker).map_err(|e| e.to_string())?;
709 Ok(linker)
710}
711
712fn build_generator_linker(
713 engine: &Engine,
714 _component: &Component,
715) -> Result<Linker<HostState>, String> {
716 let mut linker = Linker::<HostState>::new(engine.raw());
717 bindings::generator::CodeGenerator::add_to_linker::<HostState, HasSelf<HostState>>(
718 &mut linker,
719 |s| s,
720 )
721 .map_err(|e| e.to_string())?;
722 wasmtime_wasi::p2::add_to_linker_sync(&mut linker).map_err(|e| e.to_string())?;
723 Ok(linker)
724}
725
726fn make_store(engine: &Engine, limits: Limits) -> Store<HostState> {
727 let mut store = Store::new(engine.raw(), HostState::new(limits));
728 let _ = store.set_fuel(limits.fuel);
729 let deadline = limits.wall_clock_ms.div_ceil(10).max(1);
731 store.set_epoch_deadline(deadline);
732 store.epoch_deadline_trap();
733 store.limiter(|s| &mut s.store_limits);
734 store
735}
736
737fn map_call_error<T>(res: wasmtime::Result<T>, store: &Store<HostState>) -> Result<T, StageError> {
742 match res {
743 Ok(v) => Ok(v),
744 Err(e) => {
745 if let Some(t) = e.downcast_ref::<wasmtime::Trap>() {
748 match t {
749 wasmtime::Trap::OutOfFuel => {
750 return Err(StageError::ResourceExceeded(ResourceKind::Fuel))
751 }
752 wasmtime::Trap::Interrupt => {
753 return Err(StageError::ResourceExceeded(ResourceKind::Time))
754 }
755 _ => {}
756 }
757 }
758 let msg = format!("{e:#}");
761 if msg.contains("memory") && store.data().limits.memory_bytes > 0 {
762 if msg.contains("grow") || msg.contains("limit") {
765 return Err(StageError::ResourceExceeded(ResourceKind::Memory));
766 }
767 }
768 Err(StageError::PluginBug(msg))
769 }
770 }
771}