Skip to main content

weaveffi_core/
capabilities.rs

1//! Per-target **feature capability declarations** and the loud-failure
2//! contract that replaces silent feature skipping.
3//!
4//! Historically a backend that did not implement an IDL feature simply
5//! omitted it from its output: Go and Ruby dropped `async` functions, nine
6//! of eleven wrappers skipped callbacks and listeners, and nothing told the
7//! user. That class of silent degradation is banned: every generator now
8//! declares a [`TargetCapabilities`] and the orchestrator refuses to run a
9//! generator against an API that uses a feature the target does not support,
10//! listing each offending declaration by path.
11//!
12//! A backend that gains a feature flips the corresponding flag and the gate
13//! opens; a backend that loses one (or a new feature lands in the IR before
14//! every backend implements it) fails generation with an actionable error
15//! instead of producing incomplete bindings.
16
17use std::collections::BTreeMap;
18use std::fmt;
19
20use weaveffi_ir::ir::{Api, Module, TypeRef};
21
22/// An IDL feature whose support varies (or could vary) per target.
23///
24/// Core types — scalars, strings, bytes, structs, enums, optionals, lists,
25/// maps, handles — are mandatory for every backend and are not gated.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
27pub enum Feature {
28    /// `async: true` functions (callback-completed launchers).
29    AsyncFunctions,
30    /// Module-level callback typedefs (`callbacks:`).
31    Callbacks,
32    /// Listener register/unregister pairs (`listeners:`).
33    Listeners,
34    /// `iter<T>` returns (opaque iterator handle + `next`/`destroy`).
35    Iterators,
36}
37
38impl Feature {
39    pub const ALL: [Feature; 4] = [
40        Feature::AsyncFunctions,
41        Feature::Callbacks,
42        Feature::Listeners,
43        Feature::Iterators,
44    ];
45
46    /// The IDL-facing name used in error messages.
47    pub fn idl_name(&self) -> &'static str {
48        match self {
49            Feature::AsyncFunctions => "async functions",
50            Feature::Callbacks => "callbacks",
51            Feature::Listeners => "listeners",
52            Feature::Iterators => "iterator returns (iter<T>)",
53        }
54    }
55}
56
57impl fmt::Display for Feature {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.write_str(self.idl_name())
60    }
61}
62
63/// The feature set a generator implements. Declared by every backend via
64/// [`LanguageBackend::capabilities`](crate::backend::LanguageBackend::capabilities)
65/// / [`Generator::capabilities`](crate::codegen::Generator::capabilities).
66///
67/// There is intentionally no `Default` impl: a backend must state what it
68/// supports explicitly so a new gated feature cannot be claimed by omission.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub struct TargetCapabilities {
71    pub async_functions: bool,
72    pub callbacks: bool,
73    pub listeners: bool,
74    pub iterators: bool,
75}
76
77impl TargetCapabilities {
78    /// Full support for every gated feature. Every shipped WeaveFFI backend
79    /// declares this; partial sets exist for backends under development.
80    pub const fn full() -> Self {
81        Self {
82            async_functions: true,
83            callbacks: true,
84            listeners: true,
85            iterators: true,
86        }
87    }
88
89    pub const fn supports(&self, feature: Feature) -> bool {
90        match feature {
91            Feature::AsyncFunctions => self.async_functions,
92            Feature::Callbacks => self.callbacks,
93            Feature::Listeners => self.listeners,
94            Feature::Iterators => self.iterators,
95        }
96    }
97}
98
99/// Every gated feature `api` uses, mapped to the locations (dotted IDL paths)
100/// that use it. Deterministic ordering for stable error output.
101pub fn used_features(api: &Api) -> BTreeMap<Feature, Vec<String>> {
102    let mut used: BTreeMap<Feature, Vec<String>> = BTreeMap::new();
103    for module in &api.modules {
104        collect_module(module, "", &mut used);
105    }
106    used
107}
108
109fn collect_module(module: &Module, parent: &str, used: &mut BTreeMap<Feature, Vec<String>>) {
110    let path = if parent.is_empty() {
111        module.name.clone()
112    } else {
113        format!("{parent}.{}", module.name)
114    };
115    for cb in &module.callbacks {
116        used.entry(Feature::Callbacks)
117            .or_default()
118            .push(format!("{path}.{}", cb.name));
119    }
120    for l in &module.listeners {
121        used.entry(Feature::Listeners)
122            .or_default()
123            .push(format!("{path}.{}", l.name));
124    }
125    for f in &module.functions {
126        let loc = format!("{path}.{}", f.name);
127        if f.r#async {
128            used.entry(Feature::AsyncFunctions)
129                .or_default()
130                .push(loc.clone());
131        }
132        if matches!(f.returns, Some(TypeRef::Iterator(_))) {
133            used.entry(Feature::Iterators).or_default().push(loc);
134        }
135    }
136    for child in &module.modules {
137        collect_module(child, &path, used);
138    }
139}
140
141/// A target was asked to generate bindings for an API that uses features it
142/// does not support. Carries every violation so the user sees the complete
143/// picture in one failure.
144#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
145pub struct UnsupportedFeatures {
146    /// The `--target` token of the failing generator.
147    pub target: String,
148    /// Each unsupported feature with the IDL paths that use it.
149    pub violations: Vec<(Feature, Vec<String>)>,
150}
151
152impl fmt::Display for UnsupportedFeatures {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        writeln!(
155            f,
156            "target '{}' does not support every feature this IDL uses:",
157            self.target
158        )?;
159        for (feature, locations) in &self.violations {
160            writeln!(f, "  - {feature} (used by: {})", locations.join(", "))?;
161        }
162        write!(
163            f,
164            "remove the unsupported declarations, drop '{}' from --target, or set \
165             `generators.{}.allow_unsupported: true` in the IDL to generate the supported \
166             surface anyway (unsupported entry points become explicit throwing stubs)",
167            self.target, self.target
168        )
169    }
170}
171
172/// Check `api` against one target's declared capabilities. `Ok(())` when the
173/// target supports every feature the API uses.
174pub fn check(
175    api: &Api,
176    target: &str,
177    caps: &TargetCapabilities,
178) -> Result<(), UnsupportedFeatures> {
179    let violations: Vec<(Feature, Vec<String>)> = used_features(api)
180        .into_iter()
181        .filter(|(feature, _)| !caps.supports(*feature))
182        .collect();
183    if violations.is_empty() {
184        Ok(())
185    } else {
186        Err(UnsupportedFeatures {
187            target: target.to_string(),
188            violations,
189        })
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use weaveffi_ir::ir::{CallbackDef, Function, ListenerDef, Param};
197
198    fn func(name: &str, is_async: bool, returns: Option<TypeRef>) -> Function {
199        Function {
200            name: name.into(),
201            params: vec![Param {
202                name: "x".into(),
203                ty: TypeRef::I32,
204                mutable: false,
205                doc: None,
206            }],
207            returns,
208            doc: None,
209            r#async: is_async,
210            cancellable: false,
211            deprecated: None,
212            since: None,
213        }
214    }
215
216    fn module(name: &str) -> Module {
217        Module {
218            name: name.into(),
219            functions: vec![],
220            structs: vec![],
221            enums: vec![],
222            callbacks: vec![],
223            listeners: vec![],
224            errors: None,
225            modules: vec![],
226        }
227    }
228
229    fn api(modules: Vec<Module>) -> Api {
230        Api {
231            version: "0.3.0".into(),
232            modules,
233            generators: None,
234            package: None,
235        }
236    }
237
238    fn events_api() -> Api {
239        api(vec![Module {
240            callbacks: vec![CallbackDef {
241                name: "OnMessage".into(),
242                params: vec![],
243                doc: None,
244            }],
245            listeners: vec![ListenerDef {
246                name: "message_listener".into(),
247                event_callback: "OnMessage".into(),
248                doc: None,
249            }],
250            functions: vec![
251                func("send", false, None),
252                func("fetch", true, Some(TypeRef::StringUtf8)),
253                func(
254                    "all",
255                    false,
256                    Some(TypeRef::Iterator(Box::new(TypeRef::StringUtf8))),
257                ),
258            ],
259            ..module("events")
260        }])
261    }
262
263    #[test]
264    fn full_capabilities_pass_everything() {
265        assert!(check(&events_api(), "c", &TargetCapabilities::full()).is_ok());
266    }
267
268    #[test]
269    fn plain_api_uses_no_gated_features() {
270        let plain = api(vec![Module {
271            functions: vec![func("add", false, Some(TypeRef::I32))],
272            ..module("math")
273        }]);
274        assert!(used_features(&plain).is_empty());
275    }
276
277    #[test]
278    fn used_features_collects_locations() {
279        let used = used_features(&events_api());
280        assert_eq!(
281            used[&Feature::Callbacks],
282            vec!["events.OnMessage".to_string()]
283        );
284        assert_eq!(
285            used[&Feature::Listeners],
286            vec!["events.message_listener".to_string()]
287        );
288        assert_eq!(
289            used[&Feature::AsyncFunctions],
290            vec!["events.fetch".to_string()]
291        );
292        assert_eq!(used[&Feature::Iterators], vec!["events.all".to_string()]);
293    }
294
295    #[test]
296    fn nested_modules_use_dotted_paths() {
297        let nested = api(vec![Module {
298            modules: vec![Module {
299                functions: vec![func("fetch", true, None)],
300                ..module("inner")
301            }],
302            ..module("outer")
303        }]);
304        let used = used_features(&nested);
305        assert_eq!(
306            used[&Feature::AsyncFunctions],
307            vec!["outer.inner.fetch".to_string()]
308        );
309    }
310
311    #[test]
312    fn missing_capability_is_reported_with_locations() {
313        let caps = TargetCapabilities {
314            async_functions: false,
315            listeners: false,
316            ..TargetCapabilities::full()
317        };
318        let err = check(&events_api(), "go", &caps).unwrap_err();
319        assert_eq!(err.target, "go");
320        assert_eq!(err.violations.len(), 2);
321        let msg = err.to_string();
322        assert!(msg.contains("target 'go' does not support"), "{msg}");
323        assert!(
324            msg.contains("async functions (used by: events.fetch)"),
325            "{msg}"
326        );
327        assert!(
328            msg.contains("listeners (used by: events.message_listener)"),
329            "{msg}"
330        );
331    }
332}