Skip to main content

lumen_script/
language.rs

1//! Script languages, as the app sees them: a registry each host fills in.
2//!
3//! The engine runs scripts without knowing any language. A host lives in a
4//! runtime module of its own; installing that module registers a
5//! [`ScriptLanguage`] here, under the language's name, and the app assembly
6//! reaches the host only through the entries of that record: install a
7//! program, swap it on a hot reload, check or compile one ahead of time, read
8//! the markup blocks a script writes, and reach the installed host's signals
9//! and functions.
10//!
11//! Which module provides which language, and for which file extensions, is
12//! the module's own descriptor (`lumen-language.toml`), read by
13//! `lumen_modules::language`. Nothing here, and nothing in the engine, names a
14//! language.
15
16use std::path::PathBuf;
17
18use bevy_ecs::component::Mutable;
19use bevy_ecs::prelude::{Resource, World};
20use lumen_core::prelude::App;
21use lumen_core::warn_line;
22use lumen_ir::fragment::FragmentComponent;
23use lumen_ir::source_map::SourceMap;
24
25use crate::{ScriptError, ScriptFn, ScriptHost, ScriptPrelude, ScriptValue};
26
27/// One program, handed to a host to run.
28#[derive(Debug, Clone, Default)]
29pub struct ScriptProgram {
30    /// The program's source text: every file of the language, concatenated in
31    /// source order. Empty when the program travels as bytecode alone.
32    pub source: String,
33    /// The program compiled ahead of time, for a language that has a
34    /// bytecode form. A host that runs bytecode reads this and ignores the
35    /// source.
36    pub bytecode: Option<Vec<u8>>,
37    /// Where the program came from, named in a load error.
38    pub uri: String,
39    /// Where each piece of [`Self::source`] was read from, so a load error
40    /// names the script file and line rather than [`Self::uri`].
41    pub source_map: SourceMap,
42    /// The app's `lib/` directory, where a native library a script imports is
43    /// looked for.
44    pub lib_dir: Option<PathBuf>,
45    /// Script libraries the app depends on: the name a script imports under,
46    /// and the directory holding its sources.
47    pub import_roots: Vec<(String, PathBuf)>,
48    /// The names a script's compile-time conditions are true for.
49    pub cfg_flags: Vec<String>,
50}
51
52/// What an ahead-of-time check or compile is done against: the same things a
53/// run hands the host, plus the functions and sources the app's modules
54/// register, declared but never called.
55#[derive(Debug, Clone, Copy)]
56pub struct ScriptCompile<'a> {
57    /// Where the program came from, named in a compile error.
58    pub uri: &'a str,
59    /// Where each piece of the source was read from; see
60    /// [`ScriptError::relocate`].
61    pub source_map: &'a SourceMap,
62    /// The app's `lib/` directory.
63    pub lib_dir: Option<&'a std::path::Path>,
64    /// Script libraries the app depends on.
65    pub import_roots: &'a [(String, PathBuf)],
66    /// The names a script's compile-time conditions are true for.
67    pub cfg_flags: &'a [&'a str],
68    /// Functions the program may call, declared to the compiler.
69    pub fns: &'a [ScriptFn],
70    /// Sources the modules register, compiled ahead of the program. A host
71    /// takes the ones written in its own language.
72    pub preludes: &'a [ScriptPrelude],
73}
74
75/// One markup block a script writes: a fragment of markup a script
76/// instantiates by key, read out of the script before anything runs.
77#[derive(Debug, Clone, PartialEq)]
78pub struct MarkupBlock {
79    /// Byte offset of the block's body in the script, for an error that names
80    /// a line.
81    pub offset: usize,
82    /// The block's markup, ready for the markup front-end.
83    pub markup: String,
84    /// The key a script instantiates the block by.
85    pub key: String,
86    /// The names the block's argument sites bind.
87    pub args: Vec<String>,
88    /// The function this block is the whole body of, when a use site can write
89    /// that function as a tag.
90    pub component: Option<FragmentComponent>,
91}
92
93/// A markup block the language could not read.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct MarkupBlockError {
96    /// Byte offset in the script the message is about.
97    pub offset: usize,
98    /// What is wrong.
99    pub message: String,
100}
101
102/// Reaching an installed host, in the terms a caller asks in.
103///
104/// Every entry resolves the host resource itself, so the app owns no borrow of
105/// it between calls.
106#[derive(Clone, Copy)]
107pub struct ScriptHostAccess {
108    /// The value the script last wrote to a signal, from the host's mirror.
109    pub signal: fn(&World, &str) -> Option<ScriptValue>,
110    /// Call an exported function with no arguments, returning what it returned
111    /// when the host has such a function.
112    pub call: fn(&mut World, &str) -> Result<Option<ScriptValue>, ScriptError>,
113    /// The names a caller may call.
114    pub exports: fn(&World) -> Vec<String>,
115}
116
117impl ScriptHostAccess {
118    /// The table an app with no script answers through: every entry reports
119    /// nothing rather than the caller having to ask whether there is a host.
120    pub fn absent() -> Self {
121        Self {
122            signal: |_, _| None,
123            call: |_, _| Err(ScriptError::Runtime("no script is loaded".to_owned())),
124            exports: |_| Vec::new(),
125        }
126    }
127
128    /// The table for a host stored as the resource `H`. `exports` is the
129    /// host's own rule for what a caller may call: [`ScriptHost`] carries no
130    /// export list, so a host without one answers with an empty list.
131    pub fn of<H>(exports: fn(&World) -> Vec<String>) -> Self
132    where
133        H: ScriptHost + Resource<Mutability = Mutable>,
134    {
135        Self {
136            signal: signal_of::<H>,
137            call: call_of::<H>,
138            exports,
139        }
140    }
141}
142
143/// Read `name` from the host's signal mirror.
144fn signal_of<H>(world: &World, name: &str) -> Option<ScriptValue>
145where
146    H: ScriptHost + Resource<Mutability = Mutable>,
147{
148    world.get_resource::<H>().and_then(|h| h.mirror_get(name))
149}
150
151/// Call `name` with no arguments. Commands the call queued are put back so the
152/// next tick carries them, exactly as the app's own dispatchers do.
153fn call_of<H>(world: &mut World, name: &str) -> Result<Option<ScriptValue>, ScriptError>
154where
155    H: ScriptHost + Resource<Mutability = Mutable>,
156{
157    let Some(mut host) = world.get_resource_mut::<H>() else {
158        return Err(ScriptError::Runtime("no script is loaded".to_owned()));
159    };
160    match host.call(name, &[]) {
161        Ok(outcome) => {
162            host.push_commands(outcome.commands);
163            Ok(outcome.ret.filter(|_| outcome.found))
164        }
165        Err(failure) => {
166            host.push_commands(failure.commands);
167            Err(failure.error)
168        }
169    }
170}
171
172/// Install a host over a program. `multi_host` is true when the app runs more
173/// than one language, which a host needs to keep its signal mirror current
174/// with the others.
175pub type InstallFn = fn(&mut App, ScriptProgram, bool);
176/// Swap a live host's program for a hot reload: `(world, source, uri, map)`,
177/// `map` saying where each piece of `source` was read from. `None` when the
178/// host is not installed.
179pub type ReloadFn = fn(&mut World, &str, &str, &SourceMap) -> Option<Result<(), ScriptError>>;
180/// Compile-check a program without running it: `(source, against)`.
181pub type CheckFn = fn(&str, &ScriptCompile<'_>) -> Result<(), String>;
182/// Compile a program to bytecode: `(source, against)`, giving the image and
183/// the compiler's warnings, one line each.
184pub type CompileFn = fn(&str, &ScriptCompile<'_>) -> Result<(Vec<u8>, Vec<String>), String>;
185/// The names a bytecode image can be called by, with `fns` bound beside the
186/// builtins and none of them called.
187pub type ImageExportsFn = fn(&[u8], &[ScriptFn]) -> Result<Vec<String>, String>;
188/// Read the markup blocks a script writes.
189pub type MarkupBlocksFn = fn(&str) -> Result<Vec<MarkupBlock>, MarkupBlockError>;
190
191/// One script language, as a host module registers it.
192///
193/// Only [`Self::install`] and [`Self::access`] are required: a host that runs
194/// precompiled bytecode alone has no compiler to check, compile, or reload
195/// with, and leaves those entries empty.
196#[derive(Clone, Copy)]
197pub struct ScriptLanguage {
198    /// The language's name, the one `[script] engine` and the module's
199    /// descriptor use.
200    pub name: &'static str,
201    /// Install the host over a program.
202    pub install: InstallFn,
203    /// Swap the program for a hot reload.
204    pub reload: Option<ReloadFn>,
205    /// Reach the installed host.
206    pub access: fn() -> ScriptHostAccess,
207    /// Compile-check a program.
208    pub check: Option<CheckFn>,
209    /// Compile a program to bytecode.
210    pub compile: Option<CompileFn>,
211    /// Read a bytecode image's exports.
212    pub image_exports: Option<ImageExportsFn>,
213    /// Read the markup blocks a script writes.
214    pub markup_blocks: Option<MarkupBlocksFn>,
215}
216
217impl ScriptLanguage {
218    /// A language whose host installs with `install` and is reached through
219    /// `access`, with every optional entry empty.
220    pub fn new(name: &'static str, install: InstallFn, access: fn() -> ScriptHostAccess) -> Self {
221        Self {
222            name,
223            install,
224            reload: None,
225            access,
226            check: None,
227            compile: None,
228            image_exports: None,
229            markup_blocks: None,
230        }
231    }
232}
233
234impl std::fmt::Debug for ScriptLanguage {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        f.debug_struct("ScriptLanguage")
237            .field("name", &self.name)
238            .field("reload", &self.reload.is_some())
239            .field("check", &self.check.is_some())
240            .field("compile", &self.compile.is_some())
241            .field("image_exports", &self.image_exports.is_some())
242            .field("markup_blocks", &self.markup_blocks.is_some())
243            .finish()
244    }
245}
246
247/// The languages the app's host modules registered, in registration order.
248#[derive(Resource, Default, Debug, Clone)]
249pub struct ScriptLanguages(Vec<ScriptLanguage>);
250
251impl ScriptLanguages {
252    /// The language registered under `name`.
253    pub fn get(&self, name: &str) -> Option<&ScriptLanguage> {
254        self.0.iter().find(|l| l.name == name)
255    }
256
257    /// Every registered language.
258    pub fn iter(&self) -> impl Iterator<Item = &ScriptLanguage> {
259        self.0.iter()
260    }
261
262    /// Whether no language is registered.
263    pub fn is_empty(&self) -> bool {
264        self.0.is_empty()
265    }
266
267    /// Register `language`. A second registration of a name already present
268    /// is refused with a warning, so the first module to provide a language
269    /// is the one that runs it.
270    pub fn add(&mut self, language: ScriptLanguage) {
271        if self.get(language.name).is_some() {
272            warn_line!(
273                "lumen-runtime: script language `{}` is registered twice; the first \
274                 registration runs it",
275                language.name
276            );
277            return;
278        }
279        self.0.push(language);
280    }
281}
282
283/// Registering a script language on an [`App`].
284pub trait ScriptLanguageAppExt {
285    /// Register `language`. See [`ScriptLanguages::add`].
286    fn add_script_language(&mut self, language: ScriptLanguage) -> &mut Self;
287}
288
289impl ScriptLanguageAppExt for App {
290    fn add_script_language(&mut self, language: ScriptLanguage) -> &mut Self {
291        self.world
292            .get_resource_or_insert_with(ScriptLanguages::default)
293            .add(language);
294        self
295    }
296}
297
298/// No host is registered for the language a program names.
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct UnknownLanguage {
301    /// The language the program names.
302    pub language: String,
303    /// The languages a host is registered for, so the message says what this
304    /// build can run instead.
305    pub registered: Vec<String>,
306}
307
308impl std::fmt::Display for UnknownLanguage {
309    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310        write!(f, "no script host for language \"{}\"", self.language)?;
311        if self.registered.is_empty() {
312            write!(f, "; no script host is installed")
313        } else {
314            write!(f, "; this build runs {}", self.registered.join(", "))
315        }
316    }
317}
318
319impl std::error::Error for UnknownLanguage {}
320
321/// Install the host registered for `language` over `program`, and return the
322/// table that reaches it.
323///
324/// # Errors
325///
326/// No module registered a host for `language`.
327pub fn install_program(
328    app: &mut App,
329    language: &str,
330    program: ScriptProgram,
331    multi_host: bool,
332) -> Result<ScriptHostAccess, UnknownLanguage> {
333    let languages = app.world.get_resource::<ScriptLanguages>();
334    let Some(entry) = languages.and_then(|l| l.get(language)).copied() else {
335        return Err(UnknownLanguage {
336            language: language.to_owned(),
337            registered: languages
338                .map(|l| l.iter().map(|entry| entry.name.to_owned()).collect())
339                .unwrap_or_default(),
340        });
341    };
342    (entry.install)(app, program, multi_host);
343    Ok((entry.access)())
344}
345
346/// The 1-based line and column of byte `offset` in `src`.
347pub fn line_col(src: &str, offset: usize) -> (usize, usize) {
348    let before = &src[..offset.min(src.len())];
349    let line = before.matches('\n').count() + 1;
350    let col = before.rfind('\n').map_or(before.chars().count(), |nl| {
351        before[nl + 1..].chars().count()
352    }) + 1;
353    (line, col)
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    fn install(_app: &mut App, _program: ScriptProgram, _multi: bool) {}
361
362    #[test]
363    fn the_first_registration_of_a_name_is_the_one_kept() {
364        let mut app = App::new();
365        let mut first = ScriptLanguage::new("toy", install, ScriptHostAccess::absent);
366        first.check = Some(|_, _| Ok(()));
367        app.add_script_language(first);
368        app.add_script_language(ScriptLanguage::new(
369            "toy",
370            install,
371            ScriptHostAccess::absent,
372        ));
373        let languages = app.world.resource::<ScriptLanguages>();
374        assert_eq!(languages.iter().count(), 1);
375        assert!(languages.get("toy").and_then(|l| l.check).is_some());
376    }
377
378    #[test]
379    fn a_program_for_an_unregistered_language_is_refused_by_name() {
380        let mut app = App::new();
381        let refusal = install_program(&mut app, "toy", ScriptProgram::default(), false)
382            .err()
383            .expect("nothing registered toy");
384        assert_eq!(
385            refusal.to_string(),
386            "no script host for language \"toy\"; no script host is installed"
387        );
388
389        app.add_script_language(ScriptLanguage::new(
390            "other",
391            install,
392            ScriptHostAccess::absent,
393        ));
394        let refusal = install_program(&mut app, "toy", ScriptProgram::default(), false)
395            .err()
396            .expect("still nothing registered toy");
397        assert_eq!(
398            refusal.to_string(),
399            "no script host for language \"toy\"; this build runs other"
400        );
401    }
402
403    #[test]
404    fn line_and_column_count_from_one() {
405        let src = "ab\ncd\nef";
406        assert_eq!(line_col(src, 0), (1, 1));
407        assert_eq!(line_col(src, 4), (2, 2));
408        assert_eq!(line_col(src, 99), (3, 3));
409    }
410}