Skip to main content

lumen_script/
builtin_fns.rs

1//! The builtin surface every script host shares, described once.
2//!
3//! A builtin whose whole body is "push one [`ScriptCommand`]" or "read one
4//! process-global and return it" says nothing about the language calling it, so
5//! it lives here as a [`ScriptFn`] rather than three times over in the host
6//! crates. Each host binds this table through its own
7//! [`ScriptHost::register_script_fn`](crate::ScriptHost::register_script_fn)
8//! when it is constructed, so a bare host in a test, in `lumenc check`, or in a
9//! server render carries the same builtins a windowed app does.
10//!
11//! What stays in a host crate is what a host cannot share: the signal mirror
12//! and its handle types, closure registries (`on`, `derive`, event handlers),
13//! anything whose return shape differs per language, and the receiver-method
14//! surfaces (`node.set_text(..)`) each engine spells its own way.
15
16use crate::ScriptValue;
17use crate::{FileDialogKind, ScriptCommand, ScriptFn, ScriptFnCx, ScriptNs, ScriptTy as T};
18
19/// Describe a builtin whose whole effect is one queued [`ScriptCommand`].
20fn emit<F>(name: &str, doc: &str, params: &[(&str, T)], build: F) -> ScriptFn
21where
22    F: Fn(&ScriptFnCx<'_>) -> ScriptCommand + Send + Sync + 'static,
23{
24    let mut f = ScriptFn::new(name)
25        .ns(ScriptNs::Builtin)
26        .ret(T::Unit)
27        .doc(doc);
28    for (pname, ty) in params {
29        f = f.param(*pname, ty.clone());
30    }
31    f.build(move |cx| {
32        let cmd = build(cx);
33        cx.emit(cmd);
34        Ok(ScriptValue::Unit)
35    })
36}
37
38/// Describe a builtin that returns a value and queues nothing.
39fn value<F>(name: &str, doc: &str, params: &[(&str, T)], ret: T, body: F) -> ScriptFn
40where
41    F: Fn(&ScriptFnCx<'_>) -> ScriptValue + Send + Sync + 'static,
42{
43    let mut f = ScriptFn::new(name).ns(ScriptNs::Builtin).ret(ret).doc(doc);
44    for (pname, ty) in params {
45        f = f.param(*pname, ty.clone());
46    }
47    f.build(move |cx| Ok(body(cx)))
48}
49
50/// The whole shared builtin table, in registration order.
51///
52/// Every entry carries [`ScriptNs::Builtin`], so it lands in the host's global
53/// namespace on Rhai and Lua and in candela's `lumen` namespace. An app's own
54/// registration of the same name arrives later and shadows it.
55pub fn builtin_script_fns() -> Vec<ScriptFn> {
56    let mut fns = Vec::new();
57    fns.extend(state_fns());
58    fns.extend(timer_fns());
59    fns.extend(os_fns());
60    fns.extend(dialog_fns());
61    fns.extend(navigation_fns());
62    fns.extend(request_fns());
63    fns.extend(misc_fns());
64    fns.extend(text_fns());
65    fns
66}
67
68/// Element and signal writes that address their target by id.
69fn state_fns() -> Vec<ScriptFn> {
70    vec![
71        emit(
72            "add_clicks",
73            "Add to the demo click counter.",
74            &[("n", T::Int)],
75            |cx| ScriptCommand::AddClicks(cx.int_arg(0) as i32),
76        ),
77        emit(
78            "set_string",
79            "Write a string into the app's key-value state.",
80            &[("key", T::Str), ("value", T::Str)],
81            |cx| ScriptCommand::SetString {
82                key: cx.str_arg(0),
83                value: cx.str_arg(1),
84            },
85        ),
86        emit(
87            "set_text",
88            "Replace the text content of the element with that id.",
89            &[("target_id", T::Str), ("text", T::Str)],
90            |cx| ScriptCommand::SetText {
91                target_id: cx.str_arg(0),
92                text: cx.str_arg(1),
93            },
94        ),
95        // The runtime strips the old asset and queues a fresh decode. The path
96        // is taken verbatim and resolved against the app directory by the
97        // applier, so scripts pass app-relative paths like "icons/sun.png".
98        emit(
99            "set_src",
100            "Swap the asset path of an <image> at run time.",
101            &[("target_id", T::Str), ("path", T::Str)],
102            |cx| ScriptCommand::SetSrc {
103                target_id: cx.str_arg(0),
104                path: cx.str_arg(1),
105            },
106        ),
107        // The runtime detects a changed class list on the root and re-applies
108        // CSS, so theme-token selectors light up live.
109        emit(
110            "set_class",
111            "Replace the class list of the element with that id.",
112            &[("id", T::Str), ("classes", T::Str)],
113            |cx| ScriptCommand::SetClasses {
114                target_id: cx.str_arg(0),
115                classes: cx.str_arg(1),
116            },
117        ),
118        emit(
119            "set_root_class",
120            "Replace the class list of the root element.",
121            &[("classes", T::Str)],
122            |cx| ScriptCommand::SetClasses {
123                target_id: "<root>".to_string(),
124                classes: cx.str_arg(0),
125            },
126        ),
127        emit(
128            "set_color_scheme",
129            "Apply a color scheme: default, force-light, force-dark, \
130             prefer-light, or prefer-dark.",
131            &[("name", T::Str)],
132            |cx| ScriptCommand::SetColorScheme {
133                name: cx.str_arg(0),
134            },
135        ),
136        // Menus are modeled as a reserved `__menu_open:<id>` signal the markup
137        // binds its open state to.
138        emit(
139            "open_menu",
140            "Open the menu with that id.",
141            &[("id", T::Str)],
142            |cx| ScriptCommand::SetSignal {
143                name: menu_signal(&cx.str_arg(0)),
144                value: "true".to_string(),
145            },
146        ),
147        emit(
148            "close_menu",
149            "Close the menu with that id.",
150            &[("id", T::Str)],
151            |cx| ScriptCommand::SetSignal {
152                name: menu_signal(&cx.str_arg(0)),
153                value: "false".to_string(),
154            },
155        ),
156    ]
157}
158
159/// The reserved signal name carrying a menu's open state.
160fn menu_signal(id: &str) -> String {
161    format!("__menu_open:{id}")
162}
163
164/// Timers and the frame hook. The two timers fire `on_timer(name)` and the
165/// interval keeps firing until `cancel_timer`; `request_frame` buys exactly
166/// one `on_frame(dt)` and a loop is a callback that asks again.
167fn timer_fns() -> Vec<ScriptFn> {
168    vec![
169        emit(
170            "set_timeout",
171            "Fire on_timer(name) once, after that many milliseconds.",
172            &[("name", T::Str), ("ms", T::Int)],
173            |cx| ScriptCommand::SetTimer {
174                name: cx.str_arg(0),
175                millis: cx.int_arg(1).max(0) as u64,
176                repeat: false,
177            },
178        ),
179        emit(
180            "set_interval",
181            "Fire on_timer(name) every that many milliseconds.",
182            &[("name", T::Str), ("ms", T::Int)],
183            |cx| ScriptCommand::SetTimer {
184                name: cx.str_arg(0),
185                millis: cx.int_arg(1).max(0) as u64,
186                repeat: true,
187            },
188        ),
189        emit(
190            "cancel_timer",
191            "Stop the timer registered under that name.",
192            &[("name", T::Str)],
193            |cx| ScriptCommand::CancelTimer {
194                name: cx.str_arg(0),
195            },
196        ),
197        emit(
198            "request_frame",
199            "Ask for one on_frame(dt) call on the next tick.",
200            &[],
201            |_| ScriptCommand::RequestFrame,
202        ),
203    ]
204}
205
206/// Notifications, clipboard, tray, shell, power, and global hotkeys.
207fn os_fns() -> Vec<ScriptFn> {
208    vec![
209        emit(
210            "notify",
211            "Post a desktop notification.",
212            &[("title", T::Str), ("body", T::Str)],
213            |cx| ScriptCommand::Notify {
214                title: cx.str_arg(0),
215                body: cx.str_arg(1),
216            },
217        ),
218        emit(
219            "notify_ex",
220            "Post a desktop notification with an id, options, and actions.",
221            &[
222                ("id", T::Str),
223                ("title", T::Str),
224                ("body", T::Str),
225                ("options", T::Str),
226                ("actions", T::Str),
227            ],
228            |cx| ScriptCommand::NotifyEx {
229                id: cx.str_arg(0),
230                title: cx.str_arg(1),
231                body: cx.str_arg(2),
232                options: cx.str_arg(3),
233                actions: cx.str_arg(4),
234            },
235        ),
236        emit(
237            "clipboard_write",
238            "Put text on the system clipboard.",
239            &[("text", T::Str)],
240            |cx| ScriptCommand::ClipboardWrite {
241                text: cx.str_arg(0),
242            },
243        ),
244        emit(
245            "clipboard_read",
246            "Read the clipboard; delivers to on_clipboard(tag, text).",
247            &[("tag", T::Str)],
248            |cx| ScriptCommand::ClipboardRead { tag: cx.str_arg(0) },
249        ),
250        emit(
251            "copy_image",
252            "Put the image at that path on the clipboard.",
253            &[("path", T::Str)],
254            |cx| ScriptCommand::CopyImageToClipboard {
255                path: cx.str_arg(0),
256            },
257        ),
258        emit(
259            "save_clipboard_image",
260            "Write the clipboard image to that path.",
261            &[("path", T::Str)],
262            |cx| ScriptCommand::SaveClipboardImage {
263                path: cx.str_arg(0),
264            },
265        ),
266        emit(
267            "tray_icon",
268            "Register a tray icon.",
269            &[("id", T::Str), ("icon_path", T::Str), ("tooltip", T::Str)],
270            |cx| ScriptCommand::RegisterTrayIcon {
271                id: cx.str_arg(0),
272                icon_path: cx.str_arg(1),
273                tooltip: non_empty(cx.str_arg(2)),
274                menu: String::new(),
275                template: false,
276            },
277        ),
278        emit(
279            "tray_icon_menu",
280            "Register a tray icon carrying a menu.",
281            &[
282                ("id", T::Str),
283                ("icon_path", T::Str),
284                ("tooltip", T::Str),
285                ("menu", T::Str),
286                ("template", T::Bool),
287            ],
288            |cx| ScriptCommand::RegisterTrayIcon {
289                id: cx.str_arg(0),
290                icon_path: cx.str_arg(1),
291                tooltip: non_empty(cx.str_arg(2)),
292                menu: cx.str_arg(3),
293                template: cx.bool_arg(4),
294            },
295        ),
296        emit(
297            "unregister_tray",
298            "Remove the tray icon with that id.",
299            &[("id", T::Str)],
300            |cx| ScriptCommand::UnregisterTrayIcon { id: cx.str_arg(0) },
301        ),
302        emit(
303            "open_url",
304            "Open a URL in the system browser.",
305            &[("url", T::Str)],
306            |cx| ScriptCommand::OpenUrl { url: cx.str_arg(0) },
307        ),
308        emit(
309            "open_path",
310            "Open a path with its default application.",
311            &[("path", T::Str)],
312            |cx| ScriptCommand::OpenPath {
313                path: cx.str_arg(0),
314            },
315        ),
316        emit(
317            "reveal_path",
318            "Show a path in the system file manager.",
319            &[("path", T::Str)],
320            |cx| ScriptCommand::RevealPath {
321                path: cx.str_arg(0),
322            },
323        ),
324        emit(
325            "keep_awake",
326            "Hold a wake lock under that name until allow_sleep.",
327            &[("name", T::Str), ("reason", T::Str)],
328            |cx| ScriptCommand::KeepAwake {
329                name: cx.str_arg(0),
330                reason: cx.str_arg(1),
331            },
332        ),
333        emit(
334            "allow_sleep",
335            "Release the wake lock held under that name.",
336            &[("name", T::Str)],
337            |cx| ScriptCommand::AllowSleep {
338                name: cx.str_arg(0),
339            },
340        ),
341        // Accelerator syntax follows the global-hotkey conventions:
342        // "CommandOrControl+S", "Alt+Space", "F11".
343        emit(
344            "register_hotkey",
345            "Bind an OS-level global accelerator; fires on_hotkey(name).",
346            &[("name", T::Str), ("accelerator", T::Str)],
347            |cx| ScriptCommand::RegisterHotkey {
348                name: cx.str_arg(0),
349                accelerator: cx.str_arg(1),
350            },
351        ),
352        emit(
353            "unregister_hotkey",
354            "Release the global accelerator bound under that name.",
355            &[("name", T::Str)],
356            |cx| ScriptCommand::UnregisterHotkey {
357                name: cx.str_arg(0),
358            },
359        ),
360        emit(
361            "add_recent_file",
362            "Record path as recently opened; empty label derives one from the path.",
363            &[("path", T::Str), ("label", T::Str)],
364            |cx| ScriptCommand::AddRecentFile {
365                path: cx.str_arg(0),
366                label: cx.str_arg(1),
367            },
368        ),
369        emit(
370            "list_recent_files",
371            "Request the recent-files list; delivers to on_recent_files(tag, paths).",
372            &[("tag", T::Str)],
373            |cx| ScriptCommand::ListRecentFiles { tag: cx.str_arg(0) },
374        ),
375        emit(
376            "clear_recent_files",
377            "Remove every entry from the recent-files list.",
378            &[],
379            |_| ScriptCommand::ClearRecentFiles,
380        ),
381        emit(
382            "set_autostart",
383            "Enable or disable launching this app at login.",
384            &[("on", T::Bool)],
385            |cx| ScriptCommand::SetAutostart { on: cx.bool_arg(0) },
386        ),
387        emit(
388            "query_autostart",
389            "Request the autostart state; delivers to on_autostart_enabled(tag) or on_autostart_disabled(tag).",
390            &[("tag", T::Str)],
391            |cx| ScriptCommand::QueryAutostart { tag: cx.str_arg(0) },
392        ),
393    ]
394}
395
396/// `Some(text)` unless it is empty.
397fn non_empty(text: String) -> Option<String> {
398    (!text.is_empty()).then_some(text)
399}
400
401/// Native file dialogs. The runtime opens the dialog on the main thread and
402/// fires `on_file_picked` / `on_files_picked` / `on_folder_picked` once the
403/// user closes it; a cancelled dialog still fires once, with an empty path.
404fn dialog_fns() -> Vec<ScriptFn> {
405    let mut fns: Vec<ScriptFn> = [
406        ("pick_file", FileDialogKind::Open, "Pick one file."),
407        (
408            "pick_files",
409            FileDialogKind::OpenMulti,
410            "Pick several files.",
411        ),
412        ("pick_folder", FileDialogKind::PickFolder, "Pick a folder."),
413    ]
414    .into_iter()
415    .map(|(name, kind, doc)| {
416        emit(name, doc, &[("tag", T::Str)], move |cx| {
417            ScriptCommand::OpenFileDialog {
418                kind,
419                tag: cx.str_arg(0),
420                filters: Vec::new(),
421                default_name: None,
422            }
423        })
424    })
425    .collect();
426    fns.push(emit(
427        "save_file",
428        "Ask for a save destination, offering that file name.",
429        &[("tag", T::Str), ("default_name", T::Str)],
430        |cx| ScriptCommand::OpenFileDialog {
431            kind: FileDialogKind::Save,
432            tag: cx.str_arg(0),
433            filters: Vec::new(),
434            default_name: Some(cx.str_arg(1)),
435        },
436    ));
437    fns.push(emit(
438        "pick_file_filtered",
439        "Pick one file, restricted to a filter spec like \
440         \"Images:png,jpg|All:*\".",
441        &[("tag", T::Str), ("spec", T::Str)],
442        |cx| ScriptCommand::OpenFileDialog {
443            kind: FileDialogKind::Open,
444            tag: cx.str_arg(0),
445            filters: parse_dialog_filter_spec(&cx.str_arg(1)),
446            default_name: None,
447        },
448    ));
449    fns
450}
451
452/// Parse a `pick_file_filtered` spec like `"Images:png,jpg|All:*"` into the
453/// `(label, [extensions])` list the dialog backend takes. A group with no `:`
454/// is a label with no extensions, and a literal `*` extension is dropped (no
455/// extension filter means "all files").
456pub fn parse_dialog_filter_spec(spec: &str) -> Vec<(String, Vec<String>)> {
457    let mut out = Vec::new();
458    for group in spec.split('|') {
459        let group = group.trim();
460        if group.is_empty() {
461            continue;
462        }
463        let (label, exts) = match group.split_once(':') {
464            Some((l, e)) => (l.trim().to_string(), e),
465            None => (group.to_string(), ""),
466        };
467        let exts: Vec<String> = exts
468            .split(',')
469            .map(|e| e.trim().to_string())
470            .filter(|e| !e.is_empty() && e != "*")
471            .collect();
472        out.push((label, exts));
473    }
474    out
475}
476
477/// File-based pages: the reader. It rides the `lumen_core::nav` bus, the one an
478/// `<a href>` click, the C ABI, and the Rust SDK write.
479///
480/// The writer (`page`) and the history steps are shaped by the language
481/// calling them (whether a call can be arity-overloaded, whether a step
482/// reports a result), so each host registers its own over the same bus.
483fn navigation_fns() -> Vec<ScriptFn> {
484    vec![value(
485        "page_current",
486        "The page the app is on.",
487        &[],
488        T::Str,
489        |_| ScriptValue::Str(lumen_core::nav::current()),
490    )]
491}
492
493/// The request being rendered for, and the response being built.
494///
495/// The headers, the cookies, and the body are too large to publish as signals,
496/// so they stay in the per-thread request context and a script asks for one
497/// part at a time. Outside a server render nothing is installed, every reader
498/// gives back an empty string, and every response command is drained and
499/// dropped.
500fn request_fns() -> Vec<ScriptFn> {
501    vec![
502        value(
503            "request_header",
504            "The value of that request header, or an empty string.",
505            &[("name", T::Str)],
506            T::Str,
507            |cx| ScriptValue::Str(lumen_core::request::header(&cx.str_arg(0))),
508        ),
509        value(
510            "request_cookie",
511            "The value of that request cookie, or an empty string.",
512            &[("name", T::Str)],
513            T::Str,
514            |cx| ScriptValue::Str(lumen_core::request::cookie(&cx.str_arg(0))),
515        ),
516        value(
517            "request_body",
518            "The request body, or an empty string.",
519            &[],
520            T::Str,
521            |_| ScriptValue::Str(lumen_core::request::body()),
522        ),
523        emit(
524            "response_status",
525            "Answer with that HTTP status.",
526            &[("status", T::Int)],
527            |cx| ScriptCommand::SetResponseStatus {
528                status: cx.int_arg(0).clamp(100, 599) as u16,
529            },
530        ),
531        emit(
532            "response_header",
533            "Set a header on the response.",
534            &[("name", T::Str), ("value", T::Str)],
535            |cx| ScriptCommand::SetResponseHeader {
536                name: cx.str_arg(0),
537                value: cx.str_arg(1),
538            },
539        ),
540        emit(
541            "redirect",
542            "Answer with a redirect to that location.",
543            &[("location", T::Str)],
544            |cx| ScriptCommand::Redirect {
545                location: cx.str_arg(0),
546            },
547        ),
548        emit(
549            "fetch",
550            "Request a URL; delivers to on_fetch(tag, body).",
551            &[("url", T::Str), ("tag", T::Str)],
552            |cx| ScriptCommand::Fetch {
553                url: cx.str_arg(0),
554                tag: cx.str_arg(1),
555            },
556        ),
557    ]
558}
559
560/// One `format_*` builtin's answer: what the process-wide formatting hook
561/// makes of `(spec, value)`, or `value` as it stands when no formatter is
562/// installed or it cannot read the value. That is the fallback `t` makes
563/// when it echoes an unresolved key: a script gets a string either way and
564/// never an error to handle.
565fn formatted(spec: &str, value: &str) -> String {
566    lumen_core::i18n::format(spec, value).unwrap_or_else(|| value.to_string())
567}
568
569/// Translation, locale formatting, template-local ids, and the tree dump.
570fn misc_fns() -> Vec<ScriptFn> {
571    vec![
572        // The catalogue lives behind the process-wide i18n hook the runtime
573        // installs, so no host links Fluent or ICU itself. `tr` is Qt's
574        // spelling of the same call.
575        value(
576            "t",
577            "The active locale's string for that key, or the key itself.",
578            &[("key", T::Str)],
579            T::Str,
580            |cx| ScriptValue::Str(lumen_core::i18n::translate(&cx.str_arg(0))),
581        ),
582        value(
583            "tr",
584            "The active locale's string for that key, or the key itself.",
585            &[("key", T::Str)],
586            T::Str,
587            |cx| ScriptValue::Str(lumen_core::i18n::translate(&cx.str_arg(0))),
588        ),
589        // Changing the locale is a write, so it queues a command the
590        // runtime applies against the app's own half of the seam; reading
591        // it back is not, and answers from the process-wide value. A
592        // language menu needs both: the entry to mark is whichever locale
593        // the app started in, which may be the one the OS reports and which
594        // no script chose.
595        emit(
596            "set_locale",
597            "Switch the app to that BCP-47 locale, such as de-DE.",
598            &[("tag", T::Str)],
599            |cx| ScriptCommand::SetLocale { tag: cx.str_arg(0) },
600        ),
601        value(
602            "locale",
603            "The BCP-47 tag of the locale the app is running in.",
604            &[],
605            T::Str,
606            |_| ScriptValue::Str(lumen_core::i18n::active_locale()),
607        ),
608        // The app's locale formatters sit behind the process-wide
609        // formatting hook, so no host links ICU itself. One builtin per
610        // kind rather than one `format(spec, value)`: a builtin parameter
611        // may not be `any`, so a single entry would take its value as a
612        // string and every candela call site would have to stringify a
613        // number to pass it.
614        value(
615            "format_number",
616            "That number written the way the active locale writes numbers.",
617            &[("n", T::Float)],
618            T::Str,
619            |cx| ScriptValue::Str(formatted("number", &cx.float_arg(0).to_string())),
620        ),
621        value(
622            "format_currency",
623            "That amount as money in an ISO-4217 currency, for the active locale.",
624            &[("amount", T::Float), ("currency", T::Str)],
625            T::Str,
626            |cx| {
627                let spec = format!("currency:{}", cx.str_arg(1));
628                ScriptValue::Str(formatted(&spec, &cx.float_arg(0).to_string()))
629            },
630        ),
631        value(
632            "format_date",
633            "That `YYYY-MM-DD` date written for the active locale.",
634            &[("iso", T::Str)],
635            T::Str,
636            |cx| ScriptValue::Str(formatted("date", &cx.str_arg(0))),
637        ),
638        value(
639            "format_time",
640            "The time of that timestamp, written for the active locale.",
641            &[("iso", T::Str)],
642            T::Str,
643            |cx| ScriptValue::Str(formatted("time", &cx.str_arg(0))),
644        ),
645        value(
646            "format_datetime",
647            "That timestamp written for the active locale, date and time.",
648            &[("iso", T::Str)],
649            T::Str,
650            |cx| ScriptValue::Str(formatted("datetime", &cx.str_arg(0))),
651        ),
652        value(
653            "format_relative",
654            "That many seconds from now, as the active locale says it (past is negative).",
655            &[("seconds", T::Int)],
656            T::Str,
657            |cx| ScriptValue::Str(formatted("relative", &cx.int_arg(0).to_string())),
658        ),
659        // A template instance prefixes the ids inside it. Given `user-card:btn`
660        // as the source, `local_id(source, "label")` is `user-card:label`; a
661        // source with no `:` gives the suffix back unchanged, and multi-level
662        // prefixes stack.
663        value(
664            "local_id",
665            "The id of a sibling inside the same template instance.",
666            &[("source", T::Str), ("suffix", T::Str)],
667            T::Str,
668            |cx| {
669                let source = cx.str_arg(0);
670                let suffix = cx.str_arg(1);
671                ScriptValue::Str(match source.rfind(':') {
672                    Some(colon) => format!("{}:{suffix}", &source[..colon]),
673                    None => suffix,
674                })
675            },
676        ),
677        value(
678            "dump_tree",
679            "The element tree, rendered for debugging.",
680            &[],
681            T::Str,
682            |_| ScriptValue::Str(crate::introspect::dump_tree()),
683        ),
684    ]
685}
686
687/// `parse_json` and `parse_markdown`: text parsed into a dynamically-shaped
688/// value, the one case where the shared table declares a return type it
689/// cannot pin down further, which it spells [`T::Dynamic`]. Every host reads
690/// the result with its own downcasts (`as_map` / `as_list` / ... on candela;
691/// ordinary indexing on Rhai and Lua), so the walk itself is written once in
692/// [`crate::text_parse`] rather than once per host.
693///
694/// Each entry is missing when its Cargo feature (`json`, `markdown`) is off,
695/// which is how a build linking no script host avoids `serde_json` and
696/// `pulldown-cmark`.
697fn text_fns() -> Vec<ScriptFn> {
698    [parse_json_fn(), parse_markdown_fn()]
699        .into_iter()
700        .flatten()
701        .collect()
702}
703
704/// `parse_json`, or `None` when the `json` feature is off.
705#[cfg(feature = "json")]
706fn parse_json_fn() -> Option<ScriptFn> {
707    Some(value(
708        "parse_json",
709        "Parse a JSON string into a map, array, or scalar. Null on a parse error.",
710        &[("json", T::Str)],
711        T::Dynamic,
712        |cx| crate::text_parse::parse_json(&cx.str_arg(0)),
713    ))
714}
715
716#[cfg(not(feature = "json"))]
717fn parse_json_fn() -> Option<ScriptFn> {
718    None
719}
720
721/// `parse_markdown`, or `None` when the `markdown` feature is off.
722#[cfg(feature = "markdown")]
723fn parse_markdown_fn() -> Option<ScriptFn> {
724    Some(value(
725        "parse_markdown",
726        "Parse markdown into a list of block records: id, kind, level, text, lang.",
727        &[("src", T::Str)],
728        T::Dynamic,
729        |cx| crate::text_parse::parse_markdown(&cx.str_arg(0)),
730    ))
731}
732
733#[cfg(not(feature = "markdown"))]
734fn parse_markdown_fn() -> Option<ScriptFn> {
735    None
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use std::collections::HashSet;
742
743    /// Every entry is a builtin with a doc line, declared parameters and a
744    /// declared return: what the hosts, the editor tooling, and the candela
745    /// declaration generator all read.
746    ///
747    /// The builder's `ret` defaults to `Any` when a builtin never calls it, so
748    /// a return left at `Any` is a forgotten declaration, which is what this
749    /// asserts against, for every entry and not only the ones candela has to
750    /// name. A result that has no shape narrower than `any` declares
751    /// `Dynamic` and says so in the signature itself, as a parsed document
752    /// does. candela names such a return the way it already names a variadic
753    /// binding, `any name(...);`.
754    #[test]
755    fn every_entry_is_a_documented_builtin() {
756        for f in builtin_script_fns() {
757            assert_eq!(f.ns, ScriptNs::Builtin, "{}", f.name);
758            assert!(!f.sig.doc.is_empty(), "{} has no doc line", f.name);
759            assert!(!f.sig.variadic, "{} must declare its parameters", f.name);
760            for p in &f.sig.params {
761                assert!(
762                    !p.ty.is_dynamic(),
763                    "{}: parameter `{}` is untyped",
764                    f.name,
765                    p.name
766                );
767            }
768            assert_ne!(
769                f.sig.ret,
770                T::Any,
771                "{}: return type is undeclared; call .ret(..), or .ret(T::Dynamic) when \
772                 the result has no fixed shape",
773                f.name
774            );
775        }
776    }
777
778    /// Every name appears once: an entry whose shape depends on the language
779    /// belongs to that language's host.
780    #[test]
781    fn every_name_is_registered_once() {
782        let fns = builtin_script_fns();
783        let mut seen: HashSet<&str> = HashSet::new();
784        for f in &fns {
785            assert!(
786                seen.insert(f.name.as_str()),
787                "`{}` is registered twice",
788                f.name
789            );
790        }
791    }
792
793    #[test]
794    fn a_filter_spec_parses_into_labelled_extension_groups() {
795        assert_eq!(
796            parse_dialog_filter_spec("Images:png,jpg|All:*"),
797            vec![
798                (
799                    "Images".to_string(),
800                    vec!["png".to_string(), "jpg".to_string()]
801                ),
802                ("All".to_string(), Vec::new()),
803            ]
804        );
805    }
806}