Skip to main content

lumen_script/
lib.rs

1//! Scripting backend trait (v2) + the `ScriptCommand` enum scripts emit.
2//!
3//! `lumen-core` does not depend on this crate. Scripts run inside
4//! host-generic systems provided by `lumen-script`; those systems
5//! drain the [`ScriptCommand`]s the host produced this tick and forward
6//! them onto the ECS message bus for the embedder's applier.
7//!
8//! Concrete implementations live in runtime modules (`lumen-candela` and the
9//! other hosts under `std/`). Each provides a [`ScriptHost`] that runs
10//! whatever language it speaks, exposing the host-neutral registries (command
11//! sink, signal mirror, per-id handlers, derivations) the generic runtime
12//! drives, and registers its language through [`language::ScriptLanguage`].
13
14#![forbid(unsafe_code)]
15#![warn(missing_docs)]
16
17/// Binding a browser add-on's described functions into the hosts, on every
18/// target.
19pub mod addon;
20pub mod builtins;
21
22/// The applier behind the `clipboard_write` and `clipboard_read` builtins,
23/// running on whichever clipboard backend the app was assembled with.
24pub mod clipboard;
25
26/// Host-neutral read side of the dynamic DOM API: `query` / `get_by_id` /
27/// traversal over the per-tick [`lumen_core::node::DomIndex`] snapshot.
28pub mod node_query;
29
30/// Host-neutral event object, binding registry, and propagation driver
31/// (phase 4): `n.on(type, handler)` / `off()`, the current-event cell, and
32/// the capture -> target -> bubble dispatch.
33pub mod event;
34
35/// Host-neutral low-level introspection read surface (phase 5): post-layout
36/// geometry, full computed style + provenance, typed component reads, tree
37/// serialization, and global runtime state.
38pub mod introspect;
39
40/// Runtime event dispatch (phase 4): input messages -> DOM events ->
41/// propagation, one pass per event over every installed host. See
42/// [`dom_events::deliver_input_events`].
43pub mod dom_events;
44
45/// Script languages as host modules register them, and the table that
46/// reaches an installed host. See [`language::ScriptLanguage`].
47pub mod language;
48
49/// Script-facing drag-and-drop event dispatchers (`on_drop` /
50/// `on_drag_start`). See the module docs.
51pub mod dnd;
52
53/// Builtins whose subsystem the running engine was linked without, and the
54/// once-per-builtin warning a call to one of them prints.
55pub mod unlinked;
56
57/// The HTTP seam behind `fetch()` / `http()`: the
58/// [`HttpClient`](http::HttpClient) trait and its request and response types,
59/// plus the [`HttpDispatch`](http::HttpDispatch) trait that decides who runs a
60/// request. `lumen-http-ureq` is the client Lumen ships.
61pub mod http;
62
63/// Host-generic ECS driver: dispatchers, derivation fixed-point, mirror
64/// sync, timers, HTTP fetch, and [`runtime::ScriptPlugin`]. Formerly the
65/// `lumen-script-runtime` crate; folded in with its flat surface
66/// re-exported at the crate root for source compatibility.
67pub mod runtime;
68
69/// The host-neutral native-function description ([`ScriptFn`]) and the
70/// app-wide channel plugins register them through.
71pub mod script_fn;
72
73/// The builtin surface every host shares, described once as [`ScriptFn`]s.
74pub mod builtin_fns;
75
76/// The `parse_json` and `parse_markdown` walks behind two of those builtins,
77/// each gated by its own Cargo feature. See the module docs.
78pub mod text_parse;
79
80/// The encoded form of the script surface: the version the shapes below are
81/// pinned at, and the adapters for the two property-store types
82/// [`ScriptCommand::SetProperty`] carries.
83pub mod wire;
84
85use std::collections::HashSet;
86
87use lumen_core::warn_line;
88use serde::{Deserialize, Serialize};
89use thiserror::Error;
90
91use crate::runtime::prefix;
92
93pub use builtin_fns::{builtin_script_fns, parse_dialog_filter_spec};
94pub use builtins::{BuiltinFn, BuiltinParam};
95pub use dnd::{dispatch_drag_start_to_script, dispatch_drops_to_script};
96pub use dom_events::{
97    EventHosts, PendingDomEvents, deliver_input_events, deliver_state_events,
98    queue_pointer_and_key_events, queue_state_events, register_dom_event_messages,
99    register_event_host,
100};
101pub use http::{
102    Credentials, DisabledHttpClient, HttpClient, HttpDispatch, HttpDone, HttpHook, HttpRequest,
103    HttpResponse, ThreadDispatch, UnknownCredentials,
104};
105pub use language::{
106    MarkupBlock, MarkupBlockError, ScriptCompile, ScriptHostAccess, ScriptLanguage,
107    ScriptLanguageAppExt, ScriptLanguages, ScriptProgram, UnknownLanguage, install_program,
108};
109pub use lumen_ir::source_map::SourceMap;
110pub use runtime::*;
111pub use script_fn::{
112    Arity0, CallScratch, IntoScriptFn, MAX_VARIADIC_ARITY, ScriptField, ScriptFn, ScriptFnAppExt,
113    ScriptFnBody, ScriptFnBuilder, ScriptFnCx, ScriptFnRegistry, ScriptFnStore, ScriptNs,
114    ScriptParam, ScriptPrelude, ScriptResult, ScriptRet, ScriptSig, ScriptStruct, ScriptTy,
115    ScriptType, with_call_scratch,
116};
117pub use wire::{PluginEvent, SCRIPT_WIRE_VERSION, push_plugin_event};
118
119/// Errors a [`ScriptHost`] can surface from `load` or `tick`.
120///
121/// Structured compile errors carry the source URI and the offending
122/// `(line, col)` so editor / LSP layers can place a squiggle without
123/// re-parsing the error message. `Runtime` is still a free-form string;
124/// Rhai's runtime errors don't always carry positions and the
125/// trait-level shape is "best effort".
126#[derive(Debug, Error)]
127pub enum ScriptError {
128    /// Source failed to parse / compile.
129    #[error("script compile error at {uri}:{line}:{col}: {message}")]
130    Compile {
131        /// Origin URI (e.g. `"main.cdl"` or `"lumen://app/main.cdl"`);
132        /// `"<inline>"` when the source has no associated URI.
133        uri: String,
134        /// 1-based line number reported by the parser. `0` when the
135        /// position is unknown.
136        line: u32,
137        /// 1-based column number reported by the parser. `0` when the
138        /// position is unknown.
139        col: u32,
140        /// Stringified parser message (sans position prefix).
141        message: String,
142    },
143    /// Source compiled but execution failed.
144    #[error("script runtime error: {0}")]
145    Runtime(String),
146}
147
148impl ScriptError {
149    /// Point a compile error in a concatenated program at the file and line
150    /// the author wrote.
151    ///
152    /// `uri` is the program's own name and `map` says where each of its
153    /// pieces was read from. An error about another file (a module the
154    /// program imports) already names that file and is returned unchanged,
155    /// as is a runtime error or one with no known line.
156    #[must_use]
157    pub fn relocate(self, uri: &str, map: &lumen_ir::source_map::SourceMap) -> Self {
158        match self {
159            Self::Compile {
160                uri: at,
161                line,
162                col,
163                message,
164            } if at == uri && line > 0 => match map.locate(line) {
165                Some((file, line)) if !file.is_empty() => Self::Compile {
166                    uri: file.to_owned(),
167                    line,
168                    col,
169                    message,
170                },
171                _ => Self::Compile {
172                    uri: at,
173                    line,
174                    col,
175                    message,
176                },
177            },
178            other => other,
179        }
180    }
181
182    /// Build a `Compile` with no specific position info. Equivalent to
183    /// the previous `Compile(String)` shape; used by backends that
184    /// can't surface line/col yet.
185    pub fn compile(message: impl Into<String>) -> Self {
186        Self::Compile {
187            uri: "<inline>".to_string(),
188            line: 0,
189            col: 0,
190            message: message.into(),
191        }
192    }
193}
194
195/// One side-effect a script wants to apply to the app this tick.
196///
197/// V1 is intentionally small: just enough to demo end-to-end. Future
198/// variants will cover `SetComponent`, `SpawnEntity`, `DespawnEntity`,
199/// `RegisterTimer`, etc., keyed by an entity id type that's stable across
200/// the script ABI boundary.
201///
202/// Variants are append-only; see [`SCRIPT_WIRE_VERSION`].
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub enum ScriptCommand {
205    /// Append a line to the app's diagnostic output.
206    Print(String),
207    /// Add `n` to the app's click counter (consumed in app-defined ways -
208    /// the host doesn't know about Clicks, it just emits this token).
209    AddClicks(i32),
210    /// Free-form key/value the app can interpret. Strings only for now.
211    SetString {
212        /// Key (e.g. `"title"`, `"label"`).
213        key: String,
214        /// Value.
215        value: String,
216    },
217    /// Replace the `TextContent` of the entity whose `LumenId` matches
218    /// `target_id` (set in markup via `id="..."`).
219    SetText {
220        /// Target entity id.
221        target_id: String,
222        /// New text content.
223        text: String,
224    },
225    /// Replace the asset path on an `<image>` entity. The runtime
226    /// strips the old `LoadedImage` / `LoadedSvg` / `ImageLoadFailed`
227    /// component, installs a fresh `ImageSource`, and the asset
228    /// pipeline re-decodes on the next tick. Used by scripts to swap
229    /// icons in response to runtime state (e.g. weather code ->
230    /// `icons/sun.png`).
231    SetSrc {
232        /// Target entity id.
233        target_id: String,
234        /// New asset path. Resolved relative to the app directory at
235        /// load time on the parser side; runtime mutations pass paths
236        /// through verbatim, so authors should compose relative paths
237        /// against their own app root (e.g. `"icons/sun.png"`).
238        path: String,
239    },
240    /// Schedule a named timer. The runtime calls the script's
241    /// `on_timer(name)` handler when it fires.
242    SetTimer {
243        /// Timer name, also passed as the `on_timer` argument.
244        name: String,
245        /// Delay until first fire, in milliseconds.
246        millis: u64,
247        /// If true, reschedule with the same interval after each fire.
248        repeat: bool,
249    },
250    /// Cancel a pending or repeating timer by name. No-op if absent.
251    CancelTimer {
252        /// Timer name to cancel.
253        name: String,
254    },
255    /// Issue an HTTP GET. The runtime spawns the request off-thread and
256    /// fires `on_fetch(tag, body)` when the response arrives.
257    ///
258    /// This is the simple sugar; [`Self::Http`] is the general form and
259    /// shares the same off-thread transport and completion channel.
260    Fetch {
261        /// URL to GET.
262        url: String,
263        /// Identifier the script gets back in `on_fetch(tag, body)`.
264        tag: String,
265    },
266    /// Issue a general HTTP request. The runtime performs it off-thread
267    /// (one worker per request) and, once the reply is marshalled back
268    /// onto the ECS/UI thread, fires `on_http(tag, response)` where
269    /// `response` is a map `#{ ok, status, headers, body, error }`.
270    ///
271    /// Request/reply shape is modeled on Qt's
272    /// `QNetworkRequest`/`QNetworkReply` (method + url + headers + body
273    /// in; status + headers + body + error out); the worker->UI-thread
274    /// marshalling before any signal is touched mirrors Slint's
275    /// `invoke_from_event_loop` discipline. `web`-`fetch`-like: a 4xx /
276    /// 5xx is a *completed* reply the script can branch on (via
277    /// `response.ok` / `response.status`), not an error.
278    Http {
279        /// HTTP method (`GET`, `POST`, `PUT`, `DELETE`, ...). Case-insensitive.
280        method: String,
281        /// Target URL.
282        url: String,
283        /// Request headers as ordered key/value pairs.
284        headers: Vec<(String, String)>,
285        /// Optional request body. `None` sends no body.
286        body: Option<String>,
287        /// Optional per-request timeout in milliseconds (`None` = no
288        /// client-imposed deadline).
289        timeout_ms: Option<u64>,
290        /// Whether the request carries the page's credentials. Only a
291        /// browser transport acts on it.
292        credentials: crate::http::Credentials,
293        /// Identifier echoed back in `on_http(tag, response)`.
294        tag: String,
295    },
296    /// Set the status code of the response the document is being rendered
297    /// for.
298    ///
299    /// A page rendered per request answers with more than markup: a page for
300    /// a record nobody has says so with a 404, and a form that rejects what
301    /// it was sent says so with a 422. Nothing outside a server render reads
302    /// this.
303    SetResponseStatus {
304        /// HTTP status code.
305        status: u16,
306    },
307    /// Set a header on the response the document is being rendered for.
308    ///
309    /// Setting the same name twice replaces the value. The renderer decides
310    /// what a script may set: a name that would rewrite how the body is
311    /// framed, or one carrying a line break, is refused and reported.
312    SetResponseHeader {
313        /// Header name.
314        name: String,
315        /// Header value.
316        value: String,
317    },
318    /// Answer the request with a redirect instead of a document.
319    ///
320    /// The render stops where it is: whatever the app had rendered is
321    /// dropped, because a visitor being sent elsewhere never sees it.
322    Redirect {
323        /// Where the visitor is sent, as a path or an absolute URL.
324        location: String,
325    },
326    /// Write `value` into the named slot of the reactive [`Signals`]
327    /// map. Any entity carrying a `Bind*` component pointing at this name
328    /// re-renders next tick. Strings only at this layer; numeric / bool
329    /// signals are stringified before storage.
330    SetSignal {
331        /// Signal name.
332        name: String,
333        /// New value.
334        value: String,
335    },
336    /// Typed write into [`lumen_core::property_store::PropertyStore`].
337    /// Unlike [`Self::SetSignal`], this variant carries a typed
338    /// [`PropertyValue`] (i64 / f64 / bool / color / vec2 / arc-str /
339    /// custom) and lands directly in the typed cell - no stringify, no
340    /// [`Signals`] mirror. Used by typed Rhai builtins
341    /// (`signal_set_int` / `_float` / `_bool` / `_color`) and the C
342    /// FFI typed setters when they want to enqueue through the script
343    /// command bus instead of the cross-thread `push_external_property`
344    /// channel. The Rhai host today prefers the channel path
345    /// (immediate, no tick round-trip); this variant is the
346    /// equivalent for hosts that want to defer until the next
347    /// `apply_script_commands` drain (gives the system one
348    /// frame to coalesce typed writes).
349    SetProperty {
350        /// Property key (global or entity-scoped).
351        #[serde(with = "crate::wire::property_key")]
352        key: lumen_core::property_store::PropertyKey,
353        /// New typed value. A
354        /// [`Custom`](lumen_core::property_store::PropertyValue::Custom)
355        /// payload cannot be encoded; see [`crate::wire::property_value`].
356        #[serde(with = "crate::wire::property_value")]
357        value: lumen_core::property_store::PropertyValue,
358    },
359    /// Replace the named entry in the reactive `ArraySignals` map with a
360    /// fresh ordered vector of records. Each record's field values are
361    /// pre-stringified at the script boundary so the receiving system
362    /// doesn't need to deserialize per-frame. Any `<for each="<name>">`
363    /// reconciler then spawns / despawns child markup to match.
364    SetArray {
365        /// Array name.
366        name: String,
367        /// Items, each a flat map of field -> stringified value.
368        items: Vec<std::collections::HashMap<String, String>>,
369    },
370    /// Show a native desktop notification. Cross-platform via
371    /// `notify-rust` (libnotify on Linux, NSUserNotification on macOS,
372    /// Toast on Windows).
373    Notify {
374        /// Notification summary / title.
375        title: String,
376        /// Body text.
377        body: String,
378    },
379    /// Show a native desktop notification carrying an icon, an urgency,
380    /// and action buttons. Pressing a button fires
381    /// `on_notification_action(id, action_id)`.
382    NotifyEx {
383        /// Stable id echoed back in `on_notification_action`.
384        id: String,
385        /// Notification summary / title.
386        title: String,
387        /// Body text.
388        body: String,
389        /// Settings as `"icon:name-or-path|urgency:critical"`. Unknown
390        /// keys are ignored; empty means no icon and normal urgency.
391        options: String,
392        /// Buttons as `"id:Label|id2:Label2"`. Empty means no buttons.
393        actions: String,
394    },
395    /// Put `text` on the system clipboard.
396    ClipboardWrite {
397        /// Text to copy.
398        text: String,
399    },
400    /// Request the system clipboard's text. The runtime reads it and
401    /// fires `on_clipboard(tag, text)`.
402    ClipboardRead {
403        /// Identifier the script gets back in the event handler.
404        tag: String,
405    },
406    /// Open a URL with the user's default handler (browser, mail client).
407    OpenUrl {
408        /// Absolute URL, including its scheme.
409        url: String,
410    },
411    /// Open a filesystem path with the platform's default application.
412    /// Relative paths resolve against the app directory.
413    OpenPath {
414        /// Path to open.
415        path: String,
416    },
417    /// Show a file in the platform's file manager. Relative paths resolve
418    /// against the app directory.
419    RevealPath {
420        /// Path to reveal.
421        path: String,
422    },
423    /// Hold off the screensaver and system sleep under `name` until a
424    /// matching [`ScriptCommand::AllowSleep`]. Repeating a live name
425    /// replaces its request rather than stacking a second one.
426    KeepAwake {
427        /// Identifier used to release the request later.
428        name: String,
429        /// Human-readable reason shown by the platform's power settings.
430        reason: String,
431    },
432    /// Release the sleep inhibit registered under `name`. No-op when the
433    /// name is unknown.
434    AllowSleep {
435        /// Identifier matching the previous `KeepAwake` call.
436        name: String,
437    },
438    /// Reads the PNG at `path` and writes its pixels to the system clipboard.
439    /// `path` is resolved relative to the app directory by the runtime handler.
440    CopyImageToClipboard {
441        /// Source PNG path.
442        path: String,
443    },
444    /// Pulls the current clipboard image (when present) and writes it as PNG to `path`. Failures log to stderr.
445    SaveClipboardImage {
446        /// Destination PNG path.
447        path: String,
448    },
449    /// Registers or replaces a system tray icon on macOS, Windows, and
450    /// Linux. `icon_path` resolves relative to the app dir.
451    RegisterTrayIcon {
452        /// Stable id; clicks fire `on_tray(id)`.
453        id: String,
454        /// Path to a PNG icon.
455        icon_path: String,
456        /// Optional hover tooltip.
457        tooltip: Option<String>,
458        /// Context-menu items as `"id:Label|-|id2:Label2"`, where `-` is a
459        /// separator. Picking one fires `on_menu(id)`. Empty means no menu.
460        menu: String,
461        /// macOS template-image flag: treat a monochrome icon as a
462        /// template and recolour it for the active menu-bar theme.
463        template: bool,
464    },
465    /// Drops a previously-registered tray icon by id.
466    UnregisterTrayIcon {
467        /// Matching id from `RegisterTrayIcon`.
468        id: String,
469    },
470    /// Replace the `LumenClasses` of the entity whose `LumenId`
471    /// matches `target_id` (or the root entity when `target_id` is
472    /// `"<root>"`). Triggers a runtime CSS re-apply (K9) - the
473    /// downstream system detects `Changed<LumenClasses>` on the root
474    /// and re-spawns the tree with the new class set.
475    SetClasses {
476        /// Target entity id, or the sentinel `<root>` to address the
477        /// markup root.
478        target_id: String,
479        /// Whitespace-separated class names - same shape as
480        /// `class="..."` markup attr.
481        classes: String,
482    },
483    /// Switch the app's color scheme. The runtime parses `name` into a
484    /// `lumen_core::components::ColorScheme` and hands it to the style
485    /// manager, which re-resolves every theme token. An unknown name is
486    /// logged and ignored.
487    SetColorScheme {
488        /// One of `"default"`, `"force-light"`, `"force-dark"`,
489        /// `"prefer-light"`, `"prefer-dark"`. `"default"` follows the OS.
490        name: String,
491    },
492    /// Open a native file dialog (open, multi-open, save, or folder
493    /// pick). The runtime serves the dialog on the main thread via
494    /// `rfd`, then fires `on_file_picked(tag, path)` /
495    /// `on_files_picked(tag, paths_joined_by_pipe)` /
496    /// `on_folder_picked(tag, path)` (or the per-id `on()` route)
497    /// once the user closes it.
498    OpenFileDialog {
499        /// What kind of dialog to show.
500        kind: FileDialogKind,
501        /// Identifier the script gets back in the event handler.
502        tag: String,
503        /// Filter list: `(label, extensions)` pairs. Empty = no
504        /// filter (show all files).
505        filters: Vec<(String, Vec<String>)>,
506        /// Default filename suggestion for `Save`. Ignored for the
507        /// other kinds.
508        default_name: Option<String>,
509    },
510    /// Register an OS-level global hotkey. Accelerator strings follow
511    /// the `keyboard-types`/Electron convention (`"CommandOrControl+S"`,
512    /// `"Alt+Space"`, `"F11"`). `on_hotkey(name)` fires every time the
513    /// OS dispatches the chord regardless of window focus.
514    RegisterHotkey {
515        /// Identifier the script gets back in `on_hotkey(name)`.
516        name: String,
517        /// Accelerator string. See `global-hotkey::HotKey::from_str`.
518        accelerator: String,
519    },
520    /// Unregister a previously-registered hotkey by name. No-op if
521    /// the name is unknown.
522    UnregisterHotkey {
523        /// Identifier matching the previous `RegisterHotkey` call.
524        name: String,
525    },
526    // -- dynamic DOM: change things (phase 2) --------------------------
527    /// Set an attribute on a node addressed by packed handle. KNOWN attrs
528    /// (`id`, `class`, `src`, `text`, `disabled`, ...) route to their typed
529    /// component; everything else lands in the generic
530    /// [`lumen_core::components::LumenAttributes`] map.
531    SetAttr {
532        /// Target node (packed handle or reserved spawn token).
533        node: u64,
534        /// Attribute name.
535        name: String,
536        /// New value.
537        value: String,
538    },
539    /// Remove an attribute from a node addressed by packed handle.
540    RemoveAttr {
541        /// Target node.
542        node: u64,
543        /// Attribute name to clear.
544        name: String,
545    },
546    /// Replace a node's text content (`node.set_text`).
547    SetNodeText {
548        /// Target node.
549        node: u64,
550        /// New text.
551        text: String,
552    },
553    /// Add one class to a node's class list (incremental, idempotent).
554    ClassAdd {
555        /// Target node.
556        node: u64,
557        /// Class name to add.
558        class: String,
559    },
560    /// Remove one class from a node's class list.
561    ClassRemove {
562        /// Target node.
563        node: u64,
564        /// Class name to remove.
565        class: String,
566    },
567    /// Toggle one class on a node's class list.
568    ClassToggle {
569        /// Target node.
570        node: u64,
571        /// Class name to toggle.
572        class: String,
573    },
574    /// Set an inline style property (`element.style`), the highest cascade
575    /// tier the runtime re-apply reads.
576    SetStyleProp {
577        /// Target node.
578        node: u64,
579        /// CSS property name.
580        name: String,
581        /// CSS value.
582        value: String,
583    },
584    /// Remove an inline style property.
585    RemoveStyleProp {
586        /// Target node.
587        node: u64,
588        /// CSS property name to clear.
589        name: String,
590    },
591
592    // -- dynamic DOM: build things (phase 3) ---------------------------
593    /// Create a fresh detached element with markup tag `tag`. `reserved`
594    /// is the token the host minted synchronously so the same tick's
595    /// chained mutations address the node; the applier maps it onto the
596    /// entity it spawns.
597    Spawn {
598        /// Markup tag (`div`, `button`, ...).
599        tag: String,
600        /// Reserved spawn token (see
601        /// [`lumen_core::node::reserve_node_token`]).
602        reserved: u64,
603    },
604    /// Attach `node` under `parent`. `before` (when non-zero) is a
605    /// reference child to insert ahead of (`insert_before`); zero appends
606    /// at the end (`append` / `set_parent` / `move_to` / `reparent` all
607    /// route here).
608    Insert {
609        /// Parent node.
610        parent: u64,
611        /// Node to attach (detaching it from any current parent).
612        node: u64,
613        /// Reference child to insert before, or `0` to append.
614        before: u64,
615    },
616    /// Replace `old` with `new` in `old`'s parent, then despawn `old`'s
617    /// subtree (`replaceWith`).
618    ReplaceWith {
619        /// Node being replaced (removed after).
620        old: u64,
621        /// Replacement node, moved into `old`'s slot.
622        new: u64,
623    },
624    /// Detach and despawn a node and its whole subtree (`node.remove`).
625    RemoveNode {
626        /// Node to remove.
627        node: u64,
628    },
629    /// Deep-clone `source`'s subtree into a fresh detached node. `reserved`
630    /// is the token the host minted for the clone root.
631    CloneNode {
632        /// Node to clone (with descendants).
633        source: u64,
634        /// Reserved spawn token for the clone root.
635        reserved: u64,
636    },
637    /// Replace `node`'s children with the subtree parsed from `markup`
638    /// (`element.innerHTML = ...`). The markup is parsed by the injected
639    /// front-end and spawned through the same path the `<for>` reconciler
640    /// uses, so layout / style / paint stay consistent.
641    ///
642    /// Guarded: parsing needs the injected markup front-end
643    /// ([`SourceParser`](crate) is a runtime concept), which is present on
644    /// the dev / from-source run path but absent in the precompiled-artifact
645    /// path; there it is a no-op. Do not feed untrusted content; this
646    /// injects live markup (XSS-adjacent).
647    SetInnerMarkup {
648        /// Target node whose children are replaced.
649        node: u64,
650        /// Markup fragment to parse and spawn as the new children.
651        markup: String,
652    },
653    /// Instantiate the compiled fragment `key` into a fresh detached
654    /// subtree. `reserved` is the token the host minted for the instance
655    /// root, so the same tick's `Insert` attaches it.
656    ///
657    /// `args` binds the fragment's parameters, in use-site order; a
658    /// parameter absent here takes its declared default, and one with
659    /// neither resolves empty. Arguments substitute once, at instantiation:
660    /// a value that changes while the app runs belongs in a `bind-*`
661    /// attribute inside the fragment body.
662    ///
663    /// `children` maps a slot name to a node that already exists, which the
664    /// applier moves into that slot. Passing live handles is what keeps the
665    /// applier off the script VM: a caller composing nested fragments
666    /// instantiates the inner ones first and passes their roots in.
667    SpawnFragment {
668        /// Key of the fragment to instantiate.
669        key: String,
670        /// Arguments as `(parameter name, value)` pairs.
671        args: Vec<(String, String)>,
672        /// Slot content as `(slot name, node handle)` pairs.
673        children: Vec<(String, u64)>,
674        /// Reserved spawn token for the instance root.
675        reserved: u64,
676    },
677
678    // -- dynamic DOM: events (phase 4) ---------------------------------
679    /// Bind an event handler to a node. `token` is the off token the host
680    /// minted synchronously; the host holds the handler closure keyed by
681    /// the same token, and the applier records `(node, event_type, capture)`
682    /// in the host-neutral binding registry the dispatcher consults.
683    /// `handler_ref` is the token (a host-neutral reference), consistent
684    /// with the reserved-token model the structural commands use.
685    BindEvent {
686        /// Target node (packed handle or reserved spawn token).
687        node: u64,
688        /// Event type (`"click"`, `"keydown"`, ...).
689        event_type: String,
690        /// `true` for a capture-phase listener.
691        capture: bool,
692        /// Off / handler token (`handler_ref`).
693        token: u64,
694    },
695    /// Unbind a previously-bound event handler by its off token
696    /// (`removeEventListener`). No-op if the token is unknown.
697    UnbindEvent {
698        /// Off token returned by the matching `on(...)`.
699        token: u64,
700    },
701
702    // -- window / document (section 4.8) -------------------------------
703    /// Set the OS window title (`window.set_title`).
704    WindowSetTitle {
705        /// New title string.
706        title: String,
707    },
708    /// Resize the OS window to `width` x `height` logical pixels
709    /// (`window.set_size`).
710    WindowSetSize {
711        /// Logical width.
712        width: f32,
713        /// Logical height.
714        height: f32,
715    },
716
717    // -- app lifecycle (recent files, autostart) ------------------------
718    /// Record `path` as recently opened (move-to-front, capped list).
719    /// Relative paths resolve against the app directory.
720    AddRecentFile {
721        /// File path to record.
722        path: String,
723        /// Display label; empty derives one from the path's file name.
724        label: String,
725    },
726    /// Request the recent-files list. The runtime reads it and fires
727    /// `on_recent_files(tag, paths)`, most recent first, paths joined by
728    /// `|`.
729    ListRecentFiles {
730        /// Identifier the script gets back in the event handler.
731        tag: String,
732    },
733    /// Remove every entry from the recent-files list.
734    ClearRecentFiles,
735    /// Enable or disable launching this app at login.
736    SetAutostart {
737        /// Whether the app should autostart.
738        on: bool,
739    },
740    /// Request the current autostart state. The runtime reads it and fires
741    /// `on_autostart_enabled(tag)` or `on_autostart_disabled(tag)`.
742    QueryAutostart {
743        /// Identifier the script gets back in the event handler.
744        tag: String,
745    },
746    /// Ask for one `on_frame(dt)` call on the next tick.
747    ///
748    /// The browser's `requestAnimationFrame`: one request buys one callback,
749    /// and a callback that wants another asks again. An app that stops asking
750    /// stops being ticked for it, so an animation costs frames only while it
751    /// is running.
752    RequestFrame,
753    /// Switch the app to another locale. The catalogue, the locale-aware
754    /// formatters and the base writing direction all move together, and
755    /// every element the markup marked is rebuilt from the new catalogue on
756    /// the tick the switch lands.
757    ///
758    /// A locale the app has no catalogue for is not an error: every message
759    /// falls back to the text the author wrote, which is what naming such a
760    /// locale in `lumen.toml` already does. A `tag` that is not BCP-47 is
761    /// logged and ignored.
762    SetLocale {
763        /// BCP-47 tag, such as `"de-DE"`.
764        tag: String,
765    },
766}
767
768impl ScriptCommand {
769    /// Whether the command changes the tree rather than the state behind it,
770    /// which is what the DOM applier answers for.
771    pub fn mutates_dom(&self) -> bool {
772        matches!(
773            self,
774            ScriptCommand::SetAttr { .. }
775                | ScriptCommand::RemoveAttr { .. }
776                | ScriptCommand::SetNodeText { .. }
777                | ScriptCommand::SetClasses { .. }
778                | ScriptCommand::ClassAdd { .. }
779                | ScriptCommand::ClassRemove { .. }
780                | ScriptCommand::ClassToggle { .. }
781                | ScriptCommand::SetStyleProp { .. }
782                | ScriptCommand::RemoveStyleProp { .. }
783                | ScriptCommand::Spawn { .. }
784                | ScriptCommand::Insert { .. }
785                | ScriptCommand::ReplaceWith { .. }
786                | ScriptCommand::RemoveNode { .. }
787                | ScriptCommand::CloneNode { .. }
788                | ScriptCommand::SetInnerMarkup { .. }
789                | ScriptCommand::SpawnFragment { .. }
790                | ScriptCommand::BindEvent { .. }
791                | ScriptCommand::UnbindEvent { .. }
792                | ScriptCommand::WindowSetTitle { .. }
793                | ScriptCommand::WindowSetSize { .. }
794        )
795    }
796
797    /// Whether the command adds, removes or moves a node, rather than writing
798    /// onto one the markup already declares.
799    ///
800    /// A node a script builds is built by running the script, so anything
801    /// producing a document without running one for the full life of the app
802    /// has to know that these went by: a page rendered on a server carries the
803    /// markup, the state and what the run wrote onto the nodes it found, and
804    /// the nodes a script went on to create arrive when the browser runs it.
805    pub fn builds_nodes(&self) -> bool {
806        matches!(
807            self,
808            ScriptCommand::Spawn { .. }
809                | ScriptCommand::Insert { .. }
810                | ScriptCommand::ReplaceWith { .. }
811                | ScriptCommand::RemoveNode { .. }
812                | ScriptCommand::CloneNode { .. }
813                | ScriptCommand::SetInnerMarkup { .. }
814                | ScriptCommand::SpawnFragment { .. }
815        )
816    }
817}
818
819/// The builtins that open a file dialog, as the prefixes a source scan looks
820/// for: `pick_file` also covers `pick_files` and `pick_file_filtered`. What
821/// installs a dialog executor, and what compiles one in, both key off these.
822pub const FILE_DIALOG_BUILTINS: &[&str] = &["pick_file", "save_file", "pick_folder"];
823
824/// File dialog flavour for [`ScriptCommand::OpenFileDialog`].
825#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
826pub enum FileDialogKind {
827    /// Pick one existing file. Fires `on_file_picked(tag, path)`.
828    Open,
829    /// Pick multiple existing files. Fires
830    /// `on_files_picked(tag, "p1|p2|p3")` (paths joined with `|`).
831    OpenMulti,
832    /// Pick a save destination (may not exist yet). Fires
833    /// `on_file_picked(tag, path)` (same handler as `Open` since the
834    /// shape is identical).
835    Save,
836    /// Pick a folder. Fires `on_folder_picked(tag, path)`.
837    PickFolder,
838}
839
840/// Typed sum the script-host trait understands across the boundary.
841///
842/// Mirrors what a dynamic script value carries - scalars, arrays, maps -
843/// without leaking any host's value type into the trait. Backends translate
844/// to / from their native value type.
845///
846/// Variants are append-only; see [`SCRIPT_WIRE_VERSION`].
847#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
848pub enum ScriptValue {
849    /// Absence of a value (Rhai `()`).
850    Unit,
851    /// Boolean payload.
852    Bool(bool),
853    /// Signed 64-bit integer payload.
854    I64(i64),
855    /// 64-bit float payload.
856    F64(f64),
857    /// Owned string payload.
858    Str(String),
859    /// Heterogeneous list.
860    Array(Vec<ScriptValue>),
861    /// Field map (insertion order is not preserved across the boundary).
862    Map(std::collections::HashMap<String, ScriptValue>),
863}
864
865impl From<bool> for ScriptValue {
866    fn from(v: bool) -> Self {
867        Self::Bool(v)
868    }
869}
870
871impl From<i64> for ScriptValue {
872    fn from(v: i64) -> Self {
873        Self::I64(v)
874    }
875}
876
877impl From<f64> for ScriptValue {
878    fn from(v: f64) -> Self {
879        Self::F64(v)
880    }
881}
882
883impl From<String> for ScriptValue {
884    fn from(v: String) -> Self {
885        Self::Str(v)
886    }
887}
888
889impl<'a> From<&'a str> for ScriptValue {
890    fn from(v: &'a str) -> Self {
891        Self::Str(v.to_string())
892    }
893}
894
895impl ScriptValue {
896    /// Stringify any variant. Strings stay verbatim; everything else
897    /// uses the canonical `Display` form. UNIT becomes the empty string.
898    pub fn stringify(&self) -> String {
899        match self {
900            Self::Unit => String::new(),
901            Self::Bool(b) => b.to_string(),
902            Self::I64(i) => i.to_string(),
903            Self::F64(f) => f.to_string(),
904            Self::Str(s) => s.clone(),
905            Self::Array(arr) => {
906                let parts: Vec<String> = arr.iter().map(Self::stringify).collect();
907                format!("[{}]", parts.join(", "))
908            }
909            Self::Map(m) => {
910                let mut keys: Vec<&String> = m.keys().collect();
911                keys.sort();
912                let parts: Vec<String> = keys
913                    .iter()
914                    .map(|k| format!("{}: {}", k, m[*k].stringify()))
915                    .collect();
916                format!("{{{}}}", parts.join(", "))
917            }
918        }
919    }
920}
921
922/// Backend-agnostic facade exposing the reactive property store to
923/// script hosts. Mirrors QML's `QQmlContext::setContextProperty` /
924/// `contextProperty()` shape: scripts read + write through a thin layer
925/// the host keeps internally so callers don't have to thread an
926/// `Engine` or a per-backend `Dynamic` type through.
927///
928/// Concrete impls own the underlying signal store. The trait is intentionally small:
929/// every method maps to one host-side write so backends can rebuild
930/// their internal mirror without re-implementing scalar/array policy
931/// on top of `Dynamic`.
932pub trait ScriptContext {
933    /// Read a named scalar / structured property. `None` => the property
934    /// has never been written.
935    fn get(&self, name: &str) -> Option<ScriptValue>;
936
937    /// Write a named scalar / structured property. The backend mirrors
938    /// the write into its native value type and queues a stringified
939    /// `SetSignal` for the ECS-side `Signals` resource.
940    fn set(&mut self, name: &str, value: ScriptValue);
941
942    /// Append `value` onto the named array property. Lazy-initialises
943    /// the array if absent. Drives `<for each="<name>">` reconciliation.
944    fn array_push(&mut self, name: &str, value: ScriptValue);
945
946    /// Clear the named array property (set to empty).
947    fn array_clear(&mut self, name: &str);
948
949    /// Entity-scoped read for properties keyed by `(entity_id, name)`.
950    /// Returns `None` when the backend doesn't model entity-scoped
951    /// properties (most backends do not).
952    fn entity(&self, entity_id: &str, name: &str) -> Option<ScriptValue> {
953        let _ = (entity_id, name);
954        None
955    }
956}
957
958/// Outcome of one [`ScriptHost::call`] invocation.
959///
960/// `commands` is the full drain of the host's command sink at return. It is
961/// drained even when `found == false`, because builtins invoked outside
962/// handlers may have queued commands.
963#[derive(Debug, Clone)]
964pub struct CallOutcome {
965    /// Commands the host's builtins pushed into the sink during (and
966    /// before) the call, drained at return.
967    pub commands: Vec<ScriptCommand>,
968    /// The script function's return value. `None` when the function was
969    /// not found (or no program is loaded). Carries meaning for hooks
970    /// like `on_close()` -> `Bool(false)` vetoes the close.
971    pub ret: Option<ScriptValue>,
972    /// `false` when the function does not exist - the runtime probes
973    /// optional handlers and treats a miss as silent success.
974    pub found: bool,
975}
976
977/// A [`ScriptHost::call`] whose function raised an error.
978///
979/// `commands` holds what the function queued before it failed, drained from
980/// the sink like a successful call's. The runtime applies them, the way a
981/// browser keeps the DOM changes a throwing handler made before the throw:
982/// a `print` placed before the failing line still reaches the log, and a
983/// timer armed there still fires.
984#[derive(Debug)]
985pub struct CallFailure {
986    /// The error the function raised.
987    pub error: ScriptError,
988    /// Commands queued before the failure.
989    pub commands: Vec<ScriptCommand>,
990}
991
992impl std::fmt::Display for CallFailure {
993    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
994        self.error.fmt(f)
995    }
996}
997
998impl std::error::Error for CallFailure {
999    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1000        Some(&self.error)
1001    }
1002}
1003
1004/// Merge a pre-reload registry snapshot back under the entries the reloaded
1005/// script just registered, so a reload never silently loses a registration.
1006///
1007/// `current` holds what the new top level registered and wins every collision;
1008/// `prior` fills in the rest. Every [`ScriptHost::replace`] implementation runs
1009/// this over its handler, event-handler, and derivation maps on the success
1010/// path. The registrations that need it are the ones an app makes from
1011/// `on_start`: that callback fires once at app construction and never again, so
1012/// its handlers cannot re-register themselves during a reload.
1013///
1014/// One case stays stale until the app restarts: renaming a handler function
1015/// together with the `on(...)` call that binds it leaves the old name in the
1016/// map under its old key, because nothing in the new source collides with it.
1017pub fn carry_forward<K, V>(
1018    current: &mut std::collections::HashMap<K, V>,
1019    prior: std::collections::HashMap<K, V>,
1020) where
1021    K: std::hash::Hash + Eq,
1022{
1023    for (key, value) in prior {
1024        current.entry(key).or_insert(value);
1025    }
1026}
1027
1028/// Backend that compiles + executes scripts and exposes the host-neutral
1029/// registries the generic runtime (`lumen-script`) drives: the
1030/// command sink, the rich-typed signal mirror, the per-id handler
1031/// registry, and the derivation registry.
1032///
1033/// `Send + Sync` bound: hosts sit in a plain bevy `Resource` and
1034/// participate in parallel scheduling. A host that cannot be `Send` needs a
1035/// `NonSend` variant of the install path.
1036///
1037/// One script-side callable the generic runtime can re-invoke is modeled
1038/// by [`Self::Closure`] - Rhai: `FnPtr`; candela: a function handle once its
1039/// embedding API lands.
1040pub trait ScriptHost: Send + Sync + 'static {
1041    /// Host-native handle to a script closure (a `derive(...)` body).
1042    type Closure: Clone + Send + Sync + 'static;
1043
1044    /// Whether this host is the one a component marker in the tree names a
1045    /// function in.
1046    ///
1047    /// A marker is what the build leaves where a component has to run, and
1048    /// markup blocks are one language's macro, so exactly one host answers
1049    /// for them. Every other host leaves a marker alone rather than reporting
1050    /// a function it was never going to have.
1051    const FILLS_COMPONENTS: bool = false;
1052
1053    // -- lifecycle -----------------------------------------------------
1054
1055    /// Compile-only, side-effect free, with the exact engine settings
1056    /// [`Self::load`] uses - `lumenc check` and `run` must agree on what
1057    /// parses. `uri` seeds [`ScriptError::Compile::uri`].
1058    fn compile_check(&self, source: &str, uri: &str) -> Result<(), ScriptError>;
1059
1060    /// Compile + evaluate the top level into persistent state.
1061    /// Fresh-start load; replaces any previously loaded program.
1062    fn load(&mut self, source: &str, uri: &str) -> Result<(), ScriptError>;
1063
1064    /// Hot reload: compile first (no state touched on a parse error), then
1065    /// swap atomically. Snapshot the registries, clear them, re-evaluate the
1066    /// top level into the existing persistent scope, and roll the snapshot
1067    /// back in full if that evaluation fails. Persistent across the swap:
1068    /// engine registrations, scope bindings, the signal mirror, and the
1069    /// in-flight command sink.
1070    ///
1071    /// On success the snapshot is merged back under whatever the new top
1072    /// level registered, via [`carry_forward`]. Lifecycle callbacks do not
1073    /// re-fire on reload, so a handler an app bound from `on_start` has no
1074    /// second chance to register itself; without the merge every such handler
1075    /// would go quiet after the first reload.
1076    fn replace(&mut self, source: &str, uri: &str) -> Result<(), ScriptError>;
1077
1078    /// Drop the loaded program and all persistent state. Genuine
1079    /// restart; hot reload should use [`Self::replace`].
1080    fn reset(&mut self);
1081
1082    // -- invocation (reactive-only: no per-frame hook in this trait) ---
1083
1084    /// Call a script function by name. A missing function is silent
1085    /// success (`found: false`); see [`CallOutcome`]. A function that raises
1086    /// returns [`CallFailure`], which still carries the commands it queued
1087    /// before the error.
1088    fn call(&mut self, fn_name: &str, args: &[ScriptValue]) -> Result<CallOutcome, CallFailure>;
1089
1090    /// Re-entrant closure invocation. Must be callable while the generic
1091    /// runtime holds NO host locks (snapshot-then-call pattern): the
1092    /// closure body may re-enter builtins that touch the sink / mirror.
1093    fn call_closure(
1094        &mut self,
1095        closure: &Self::Closure,
1096        args: &[ScriptValue],
1097    ) -> Result<ScriptValue, ScriptError>;
1098
1099    /// Evaluate one derivation: read the dep values, invoke `closure`,
1100    /// store the rich result into the mirror under `name`, and return
1101    /// the host-canonical stringification for the ECS property store.
1102    ///
1103    /// Deviation from the original v2 sketch (documented in the design
1104    /// doc section 1.9): the default impl composes `mirror_get` -> `call_closure`
1105    /// -> `mirror_set` -> [`ScriptValue::stringify`], but hosts should
1106    /// override with a native path - round-tripping dep values through
1107    /// [`ScriptValue`] loses fidelity for host-native structured values,
1108    /// and stringification is host-canonical (Rhai renders `1.0` where
1109    /// Rust's `f64` Display renders `1`; store strings feed `bind-text`
1110    /// and the parse-back policy, so the host must own the format).
1111    fn eval_derivation(
1112        &mut self,
1113        closure: &Self::Closure,
1114        deps: &[String],
1115        name: &str,
1116    ) -> Result<String, ScriptError> {
1117        let args: Vec<ScriptValue> = deps
1118            .iter()
1119            .map(|d| self.mirror_get(d).unwrap_or(ScriptValue::Unit))
1120            .collect();
1121        let value = self.call_closure(closure, &args)?;
1122        let text = value.stringify();
1123        self.mirror_set(name, value);
1124        Ok(text)
1125    }
1126
1127    // -- command sink --------------------------------------------------
1128
1129    /// Drain commands queued by builtins since the last drain. Default: a
1130    /// host with no command sink has nothing to drain.
1131    fn drain_commands(&mut self) -> Vec<ScriptCommand> {
1132        Vec::new()
1133    }
1134
1135    /// Put commands back into the sink so they flow through the next
1136    /// tick's normal drain (the `on_start` re-stash). Default: dropped.
1137    fn push_commands(&mut self, cmds: Vec<ScriptCommand>) {
1138        let _ = cmds;
1139    }
1140
1141    // -- signal mirror (host-local, rich-typed) ------------------------
1142
1143    /// Read a mirror entry. `None` => never written. Default: no mirror.
1144    fn mirror_get(&self, name: &str) -> Option<ScriptValue> {
1145        let _ = name;
1146        None
1147    }
1148
1149    /// Write a mirror entry (host-native conversion of `value`). Default:
1150    /// no mirror, so the write is dropped.
1151    fn mirror_set(&mut self, name: &str, value: ScriptValue) {
1152        let _ = (name, value);
1153    }
1154
1155    /// section 1.3 type-preserving parse-back of a store string. The trait pins
1156    /// the POLICY: a mirror entry currently holding a scalar (bool / int
1157    /// / float) parses the string back into that SAME type; structured
1158    /// mirror values (arrays, maps) stay authoritative and ignore the
1159    /// string; unparseable strings leave the mirror untouched; absent /
1160    /// string entries take the store string verbatim. Default: no mirror.
1161    fn mirror_sync_str(&mut self, name: &str, value: &str) {
1162        let _ = (name, value);
1163    }
1164
1165    // -- registries populated by host builtins, read by the runtime ----
1166
1167    /// Per-id handler lookup (`on(event, id, fn)`), including the
1168    /// template-suffix fallback: a handler registered for `save` also
1169    /// matches `user-card:save` via the last-`:` suffix. Default: no
1170    /// handler registry.
1171    fn handler_for(&self, event: &str, key: &str) -> Option<String> {
1172        let _ = (event, key);
1173        None
1174    }
1175
1176    /// Snapshot of derivations matching `dirty` plus `pending`
1177    /// `(name, deps, closure)` - taken OUTSIDE any host lock so the
1178    /// driver can invoke closures re-entrantly. Default: no derivation
1179    /// registry.
1180    fn derivations_matching(
1181        &self,
1182        dirty: &HashSet<&str>,
1183        pending: &HashSet<String>,
1184    ) -> Vec<(String, Vec<String>, Self::Closure)> {
1185        let _ = (dirty, pending);
1186        Vec::new()
1187    }
1188
1189    /// Names of derivations registered but never successfully evaluated;
1190    /// they all run on the next derivation pass regardless of dirt.
1191    /// Default: none.
1192    fn pending_initial(&self) -> HashSet<String> {
1193        HashSet::new()
1194    }
1195
1196    /// Remove successfully-evaluated names from the pending-initial set.
1197    /// Erroring derivations stay pending and retry next tick. Default: no-op.
1198    fn clear_pending(&mut self, evaluated: &[String]) {
1199        let _ = evaluated;
1200    }
1201
1202    // -- event handlers (phase 4) --------------------------------------
1203
1204    /// Invoke the event-handler closure the host registered under `token`
1205    /// (via its `on(node, type, handler)` builtin). The host builds its
1206    /// native event object, which reads the process-global current-event
1207    /// cell ([`crate::event`]); commands the handler queues are drained via
1208    /// [`Self::drain_commands`] afterwards by the caller. Returns whether a
1209    /// closure was found and run.
1210    ///
1211    /// Default: not supported (no closure registry) -> `Ok(false)`. Hosts
1212    /// that expose `on(...)` with real closures override this.
1213    fn dispatch_event_handler(&mut self, token: u64) -> Result<bool, ScriptError> {
1214        let _ = token;
1215        Ok(false)
1216    }
1217
1218    /// Drop the event-handler closure registered under `token` (the host
1219    /// side of `off()` / `UnbindEvent`). Default: no-op.
1220    fn drop_event_handler(&mut self, token: u64) {
1221        let _ = token;
1222    }
1223
1224    // -- extension -----------------------------------------------------
1225
1226    /// Bind one [`ScriptFn`] so the script can call it.
1227    ///
1228    /// The host maps [`ScriptNs`] onto its own name space, binds the
1229    /// signature's arity range, and retains `f` in a [`ScriptFnStore`] so
1230    /// [`Self::reset`] can put it back. A host's own engine accessors
1231    /// remain available for anything this shape cannot express.
1232    ///
1233    /// # Errors
1234    ///
1235    /// The host could not bind the function: its binding window has closed
1236    /// (candela binds host declarations when the artifact loads) or the engine
1237    /// rejected the registration.
1238    fn register_script_fn(&mut self, f: &ScriptFn) -> Result<(), ScriptError>;
1239
1240    /// Take source a plugin ships alongside the functions it registered under
1241    /// `ns`, to be compiled ahead of the app's own program.
1242    ///
1243    /// This is how a plugin offers sugar over its functions: a struct and an
1244    /// `impl` block in this host's own language, so a script calls
1245    /// `pin(21).level()` instead of the free function. The registry hands a
1246    /// host only the sources written in its language.
1247    ///
1248    /// A host that cannot stage source says so rather than dropping it, which
1249    /// is what the default does.
1250    fn add_prelude(&mut self, ns: &str, source: &str) {
1251        let _ = source;
1252        warn_line!(
1253            "{}: the `{ns}` plugin ships source this host cannot compile ahead of the program; \
1254             call its functions directly",
1255            prefix(self.lang())
1256        );
1257    }
1258
1259    // -- metadata ------------------------------------------------------
1260
1261    /// Language tag, such as `"candela"`. Used in diagnostics prefixes
1262    /// (`lumen-<lang>: ...`).
1263    fn lang(&self) -> &'static str;
1264
1265    /// The builtin-function metadata table feeding LSP completion /
1266    /// hover and the parity test. Default: none.
1267    fn builtins(&self) -> &'static [BuiltinFn] {
1268        &[]
1269    }
1270}
1271
1272/// Marker trait reserving the script-side state-proxy contract. Currently empty.
1273pub trait StateProxy {}