Skip to main content

lumen_script/
addon.rs

1//! Binding a browser add-on's functions into the script hosts.
2//!
3//! An add-on describes its functions as data ([`lumen_ir::addon::Addon`]),
4//! and every target binds the same description: a page to the JavaScript
5//! module it loaded, a compile to a body that is never called, and everything
6//! else (a build-time render, a server, a desktop run) to a body that says the
7//! function runs only in a browser. One description, one signature per
8//! function, so a program compiled against one of them binds against all of
9//! them.
10//!
11//! An asynchronous function takes a tag after its declared arguments and
12//! returns nothing: its result arrives later as the `<event>` handler, called
13//! with the tag and the value, or `<event>_error`, called with the tag and the
14//! reason.
15
16use std::sync::Arc;
17
18use lumen_ir::addon::{Addon, AddonFunction};
19
20use crate::ScriptTy;
21use crate::script_fn::{ScriptFn, ScriptFnBody, ScriptNs, ScriptParam, ScriptResult, ScriptSig};
22
23/// The name of the argument an asynchronous function takes its tag in.
24pub const TAG_PARAM: &str = "tag";
25
26/// The event an asynchronous function's failure arrives as.
27pub fn error_event(event: &str) -> String {
28    format!("{event}_error")
29}
30
31/// The signature a script sees for `function`: its declared parameters, a
32/// trailing tag for an asynchronous one, and what it returns.
33///
34/// # Errors
35///
36/// A parameter or the return names no type.
37pub fn signature(addon: &Addon, function: &AddonFunction) -> Result<ScriptSig, String> {
38    let ty = |spelling: &str, what: &str| -> Result<ScriptTy, String> {
39        spelling.parse::<ScriptTy>().map_err(|e| {
40            format!(
41                "add-on '{}': {}::{} {what}: {e}",
42                addon.name, addon.namespace, function.name
43            )
44        })
45    };
46    let mut params = Vec::with_capacity(function.params.len() + 1);
47    for param in &function.params {
48        params.push(ScriptParam {
49            name: param.name.clone(),
50            ty: ty(&param.ty, &format!("parameter `{}`", param.name))?,
51        });
52    }
53    let ret = if function.event.is_some() {
54        params.push(ScriptParam {
55            name: TAG_PARAM.to_owned(),
56            ty: ScriptTy::Str,
57        });
58        ScriptTy::Unit
59    } else if function.returns.is_empty() {
60        ScriptTy::Unit
61    } else {
62        ty(&function.returns, "return")?
63    };
64    Ok(ScriptSig {
65        min_arity: params.len(),
66        params,
67        ret,
68        variadic: false,
69        doc: function.doc.clone(),
70    })
71}
72
73/// Every function `addon` offers, bound to the body `body` builds for it.
74///
75/// `body` is called once per function with its position in
76/// [`Addon::functions`] and its description.
77///
78/// # Errors
79///
80/// A function's signature names no type.
81pub fn script_fns(
82    addon: &Addon,
83    mut body: impl FnMut(usize, &AddonFunction) -> ScriptFnBody,
84) -> Result<Vec<ScriptFn>, String> {
85    let mut fns = Vec::with_capacity(addon.functions.len());
86    for (index, function) in addon.functions.iter().enumerate() {
87        fns.push(ScriptFn {
88            name: function.name.clone(),
89            ns: ScriptNs::Named(addon.namespace.clone()),
90            sig: signature(addon, function)?,
91            body: body(index, function),
92        });
93    }
94    Ok(fns)
95}
96
97/// Something told about a call a target answered with "only in a browser".
98pub type OnBrowserOnlyCall = Arc<dyn Fn(&str) + Send + Sync>;
99
100/// Every function `addon` offers, bound to a body that raises
101/// `<namespace>::<function> runs only in a browser` in the script that called
102/// it.
103///
104/// This is what a target with no page binds: the program was compiled against
105/// the add-on, so its calls have to resolve, and the answer to one is that it
106/// cannot run here. `on_call`, when given, hears the qualified name
107/// of each call before it raises, which is how a build reports them.
108///
109/// # Errors
110///
111/// A function's signature names no type.
112pub fn browser_only_fns(
113    addon: &Addon,
114    on_call: Option<OnBrowserOnlyCall>,
115) -> Result<Vec<ScriptFn>, String> {
116    script_fns(addon, |_, function| {
117        let qualified = format!("{}::{}", addon.namespace, function.name);
118        let on_call = on_call.clone();
119        Arc::new(move |_| -> ScriptResult {
120            if let Some(on_call) = &on_call {
121                on_call(&qualified);
122            }
123            Err(format!("{qualified} runs only in a browser"))
124        })
125    })
126}
127
128#[cfg(test)]
129mod tests {
130    use std::sync::Mutex;
131
132    use lumen_ir::addon::AddonParam;
133
134    use super::*;
135    use crate::ScriptValue;
136
137    fn echo() -> Addon {
138        Addon {
139            name: "echo".into(),
140            namespace: "echo".into(),
141            functions: vec![
142                AddonFunction {
143                    name: "shout".into(),
144                    params: vec![AddonParam {
145                        name: "text".into(),
146                        ty: "string".into(),
147                    }],
148                    returns: "string".into(),
149                    event: None,
150                    doc: "Upper-case it.".into(),
151                },
152                AddonFunction {
153                    name: "later".into(),
154                    params: vec![AddonParam {
155                        name: "ms".into(),
156                        ty: "int".into(),
157                    }],
158                    returns: String::new(),
159                    event: Some("on_echo".into()),
160                    doc: String::new(),
161                },
162            ],
163            elements: Vec::new(),
164        }
165    }
166
167    #[test]
168    fn a_function_binds_under_the_addons_namespace_with_its_declared_types() {
169        let fns = script_fns(&echo(), |_, _| Arc::new(|_| Ok(ScriptValue::Unit))).unwrap();
170        assert_eq!(fns[0].name, "shout");
171        assert_eq!(fns[0].ns, ScriptNs::Named("echo".into()));
172        assert_eq!(fns[0].sig.params[0].ty, ScriptTy::Str);
173        assert_eq!(fns[0].sig.ret, ScriptTy::Str);
174        assert_eq!(fns[0].sig.doc, "Upper-case it.");
175    }
176
177    #[test]
178    fn an_asynchronous_function_takes_a_tag_and_returns_nothing() {
179        let fns = script_fns(&echo(), |_, _| Arc::new(|_| Ok(ScriptValue::Unit))).unwrap();
180        let params: Vec<(&str, &ScriptTy)> = fns[1]
181            .sig
182            .params
183            .iter()
184            .map(|p| (p.name.as_str(), &p.ty))
185            .collect();
186        assert_eq!(
187            params,
188            [("ms", &ScriptTy::Int), (TAG_PARAM, &ScriptTy::Str)]
189        );
190        assert_eq!(fns[1].sig.ret, ScriptTy::Unit);
191        assert_eq!(fns[1].sig.min_arity, 2);
192    }
193
194    #[test]
195    fn a_function_that_declares_no_return_returns_nothing() {
196        let mut addon = echo();
197        addon.functions[0].returns = String::new();
198        let fns = script_fns(&addon, |_, _| Arc::new(|_| Ok(ScriptValue::Unit))).unwrap();
199        assert_eq!(fns[0].sig.ret, ScriptTy::Unit);
200        assert_eq!(fns[0].sig.min_arity, 1);
201    }
202
203    #[test]
204    fn an_asynchronous_failure_arrives_as_the_error_event() {
205        assert_eq!(error_event("on_echo"), "on_echo_error");
206    }
207
208    #[test]
209    fn a_type_no_host_reads_names_the_function() {
210        let mut addon = echo();
211        addon.functions[0].params[0].ty = "text".into();
212        let err = script_fns(&addon, |_, _| Arc::new(|_| Ok(ScriptValue::Unit))).unwrap_err();
213        assert!(err.contains("echo::shout parameter `text`"), "{err}");
214    }
215
216    #[test]
217    fn a_browser_only_call_raises_and_is_heard() {
218        let heard = Arc::new(Mutex::new(Vec::new()));
219        let sink = Arc::clone(&heard);
220        let fns = browser_only_fns(
221            &echo(),
222            Some(Arc::new(move |name: &str| {
223                sink.lock().unwrap().push(name.to_owned());
224            })),
225        )
226        .unwrap();
227        let (ret, _) = fns[0].invoke(&[ScriptValue::Str("hi".into())]);
228        assert_eq!(ret, Err("echo::shout runs only in a browser".to_owned()));
229        assert_eq!(*heard.lock().unwrap(), ["echo::shout"]);
230    }
231}