Skip to main content

lumen_capability/
lib.rs

1//! Optional runtime subsystems, and the list they put themselves on.
2//!
3//! The run loop installs a fixed core (layout, input, text, the reactive
4//! bindings) and then whatever optional subsystems the binary carries: a
5//! tray icon host, the HTTP client, the introspection server, the devtools
6//! overlay. It does not name any of them, nor the backends (the layout
7//! engine, the window system, the renderers) it finds in their registries. Each one is a [`Capability`] that
8//! its own crate registers before `main` through [`lumen_capability!`], and
9//! the run loop reads the list at the [`Phase`] each entry asked for.
10//!
11//! That shape is what lets a link decide which subsystems an app carries.
12//! The macro exports one symbol per capability, and a link that names the
13//! symbol pulls the subsystem in while a link that does not leaves it out,
14//! with no compiler in the loop; see the macro crate's docs for the
15//! mechanics. A plain `cargo build` links every capability its graph holds,
16//! so the default engine behaves as it always did.
17//!
18//! ```
19//! use lumen_capability::{CapabilityEnv, Phase, lumen_capability};
20//! use lumen_core::app::App;
21//! use lumen_core::tick::TickStage;
22//!
23//! fn poll_beeper() {}
24//!
25//! fn install(app: &mut App, env: &CapabilityEnv) {
26//!     // Skip the whole subsystem for an app that provably never beeps.
27//!     if env.sources_mention(&["beep("]) {
28//!         app.add_systems(TickStage::Systems, poll_beeper);
29//!     }
30//! }
31//!
32//! lumen_capability!("beeper", Phase::Platform, install);
33//! ```
34//!
35//! An entry may also carry a preflight, run before the app is built, for a
36//! subsystem that has to answer before any other work starts (a second
37//! instance of a single-instance app exits from there).
38
39#![forbid(unsafe_code)]
40#![warn(missing_docs)]
41
42use std::any::{Any, TypeId};
43use std::collections::HashMap;
44use std::path::{Path, PathBuf};
45use std::sync::Mutex;
46
47use lumen_core::app::App;
48use serde::de::DeserializeOwned;
49
50pub use lumen_capability_macros::lumen_capability;
51
52/// The prefix of the register symbol every capability exports; the declared
53/// name completes it. Naming it on a link line is what pulls a capability
54/// out of an archive that nothing else references.
55pub const REGISTER_PREFIX: &str = "lumen_capability_register_";
56
57/// The register symbol of the capability called `name`: the prefix, then the
58/// name with every character a symbol cannot carry replaced by `_`.
59///
60/// `lumen-capability-macros` spells the same name at the capability's
61/// compile time; the two spellings are the contract between a capability and
62/// a link that selects it.
63pub fn register_symbol(name: &str) -> String {
64    let mut symbol = String::with_capacity(REGISTER_PREFIX.len() + name.len());
65    symbol.push_str(REGISTER_PREFIX);
66    symbol.extend(
67        name.chars()
68            .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }),
69    );
70    symbol
71}
72
73/// Where in the app build a capability is installed.
74///
75/// The variants are in build order. Within one phase, capabilities install
76/// in name order, so the sequence is the same in every process.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
78pub enum Phase {
79    /// Before the core visual stack. A backend the stack is built on (the
80    /// layout engine, the window system) registers itself into its
81    /// registry here, so the run loop finds it when it installs the stack.
82    Backends,
83    /// After the core visual stack (layout, input, text editing, the
84    /// interaction primitives) and before the reactive bindings. Host
85    /// services live here: OS integration, the introspection server.
86    Platform,
87    /// After the command bus, before the app's runtime modules and script
88    /// hosts load. A service a script host binds to at construction, such as
89    /// the HTTP client behind `fetch()`, has to be in place by now.
90    BeforeScripts,
91    /// After the document is spawned, styled, and watched, and before the
92    /// embedder's hooks run. An overlay that mounts into the built document
93    /// belongs here.
94    AfterBuild,
95}
96
97/// When a link that picks its capabilities carries this one.
98///
99/// A plain build carries every capability in its graph; this is read by a
100/// link that replays a kit for one app (`lumenc package --static`), after
101/// the app's own `[capabilities]` table, which names a capability to keep
102/// or drop outright.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum Select {
105    /// Every app.
106    Always,
107    /// An app whose sources mention any of these: a builtin's name, a config
108    /// key. The scan errs toward yes, the same way [`CapabilityEnv::sources_mention`]
109    /// does.
110    OnUse(&'static [&'static str]),
111    /// Only an app that asks for it by name. For a subsystem that exists for
112    /// development rather than for the app's users.
113    OnRequest,
114    /// An app whose `lumen.toml` sets `key` to one of `any_of`. `key` is a
115    /// dotted path, `section.field`; `default` stands in for it when the file
116    /// leaves it unset. For one of several interchangeable implementations an
117    /// app picks between in its configuration.
118    OnConfig {
119        /// Dotted path of the key, for example `render.backend`.
120        key: &'static str,
121        /// The values that select this capability.
122        any_of: &'static [&'static str],
123        /// The value an absent key reads as.
124        default: &'static str,
125    },
126}
127
128/// What a preflight decides.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Preflight {
131    /// Build and run the app.
132    Continue,
133    /// Stop now, having done nothing else. The process exits successfully.
134    Exit,
135}
136
137/// What a capability is installed with: the app's location, identity, and
138/// configuration, the run mode, and a bounded view of its sources.
139pub struct CapabilityEnv {
140    /// The app directory.
141    pub app_dir: PathBuf,
142    /// The app's identity: `[app] id`, or the one derived from the
143    /// directory name when the file leaves it unset.
144    pub app_id: String,
145    /// `[app] id` exactly as declared, `None` when the file leaves it unset.
146    /// A subsystem whose platform keys state off an id the author chose
147    /// (notification attribution, for one) reads this rather than the
148    /// derived form.
149    pub declared_app_id: Option<String>,
150    /// The whole `lumen.toml`, parsed but untyped. A capability reads its own
151    /// section with [`Self::section`]; nothing here knows which sections
152    /// exist.
153    pub config: toml::Table,
154    /// No interactive session: a headless or bounded run, an automation
155    /// driver, a test. A subsystem that exists for a person at a window
156    /// stays idle.
157    pub headless: bool,
158    sources: String,
159    opaque: bool,
160    provided: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
161}
162
163impl CapabilityEnv {
164    /// An environment for the app at `app_dir`.
165    ///
166    /// `sources` is a bounded concatenation of the app's markup, script and
167    /// style files, which [`Self::sources_mention`] scans. `opaque` says
168    /// that scan cannot see everything the app may do (a precompiled artifact
169    /// with no readable source, an embedder's Rust hooks), in which case
170    /// every mention query answers yes.
171    pub fn new(
172        app_dir: impl Into<PathBuf>,
173        config: toml::Table,
174        sources: String,
175        opaque: bool,
176    ) -> Self {
177        let app_dir = app_dir.into();
178        let declared_app_id = config
179            .get("app")
180            .and_then(|app| app.get("id"))
181            .and_then(|id| id.as_str())
182            .filter(|id| !id.is_empty())
183            .map(str::to_owned);
184        let app_id = declared_app_id
185            .clone()
186            .unwrap_or_else(|| derive_app_id(&app_dir));
187        Self {
188            app_dir,
189            app_id,
190            declared_app_id,
191            config,
192            headless: false,
193            sources,
194            opaque,
195            provided: HashMap::new(),
196        }
197    }
198
199    /// Builder: mark the run as headless. See [`Self::headless`].
200    pub fn headless(mut self, headless: bool) -> Self {
201        self.headless = headless;
202        self
203    }
204
205    /// Builder: declare the sources opaque, so every [`Self::sources_mention`]
206    /// query answers yes. For a run the scan cannot see all of: an embedder's
207    /// Rust hooks may drive any subsystem.
208    pub fn opaque(mut self) -> Self {
209        self.opaque = true;
210        self
211    }
212
213    /// Hand a subsystem something the run has and this crate does not name:
214    /// the markup front end a run from source was given, for one. Keyed by
215    /// type; a second value of the same type replaces the first.
216    pub fn provide<T: Any + Send + Sync>(&mut self, value: T) {
217        self.provided.insert(TypeId::of::<T>(), Box::new(value));
218    }
219
220    /// What [`Self::provide`] was handed under `T`, when anything was.
221    pub fn provided<T: Any + Send + Sync>(&self) -> Option<&T> {
222        self.provided
223            .get(&TypeId::of::<T>())
224            .and_then(|value| value.downcast_ref::<T>())
225    }
226
227    /// Whether the app's sources contain any of `needles`.
228    ///
229    /// Errs toward yes: when the sources cannot be read in full, every
230    /// query answers `true`, so a subsystem gated on this is installed
231    /// whenever it might be used and skipped only when it provably is not.
232    pub fn sources_mention(&self, needles: &[&str]) -> bool {
233        self.opaque || needles.iter().any(|needle| self.sources.contains(needle))
234    }
235
236    /// The `[name]` table of `lumen.toml`, read into `T`.
237    ///
238    /// A missing table is `T::default()`. A table that does not fit `T` is
239    /// reported and also read as the default, so one bad key disables the
240    /// section rather than the app.
241    pub fn section<T: DeserializeOwned + Default>(&self, name: &str) -> T {
242        let Some(table) = self.config.get(name) else {
243            return T::default();
244        };
245        match table.clone().try_into() {
246            Ok(value) => value,
247            Err(e) => {
248                tracing::warn!("lumen.toml [{name}]: {e}; using the defaults");
249                T::default()
250            }
251        }
252    }
253}
254
255/// The app id a directory name gives: lowercased, every character that is
256/// not a letter or digit replaced by `-`, `lumen-app` when nothing is left.
257/// The same rule the runtime applies, spelled here so an environment built
258/// with no runtime in the process derives the same id.
259pub fn derive_app_id(dir: &Path) -> String {
260    dir.file_name()
261        .and_then(|n| n.to_str())
262        .map(|s| {
263            s.chars()
264                .map(|c| {
265                    if c.is_ascii_alphanumeric() {
266                        c.to_ascii_lowercase()
267                    } else {
268                        '-'
269                    }
270                })
271                .collect::<String>()
272        })
273        .filter(|s| !s.is_empty())
274        .unwrap_or_else(|| "lumen-app".to_string())
275}
276
277/// One optional subsystem linked into the running binary.
278#[derive(Debug, Clone, Copy)]
279pub struct Capability {
280    /// The name a link selects it by and a report lists it under.
281    pub name: &'static str,
282    /// When the run loop installs it.
283    pub phase: Phase,
284    /// Install it: insert resources, add plugins and systems.
285    pub install: fn(&mut App, &CapabilityEnv),
286    /// Run before the app is built, on an interactive launch only.
287    pub preflight: Option<fn(&CapabilityEnv) -> Preflight>,
288    /// When a link that picks its capabilities carries this one.
289    pub select: Select,
290    /// The package that registers it, which is the object a link pulls by
291    /// the register symbol. A kit lists a capability only when it carries
292    /// that package.
293    pub crate_name: &'static str,
294}
295
296/// Every capability registered so far.
297///
298/// A `Mutex` rather than a lock-free list because registration happens once
299/// per capability, before `main`, where a blocking lock has nobody to block
300/// against; the cost lands nowhere a frame can see it.
301static REGISTERED: Mutex<Vec<Capability>> = Mutex::new(Vec::new());
302
303/// Add a capability to the list.
304///
305/// Called from a capability's pre-main constructor, so it must not panic on
306/// a poisoned lock: a poisoned lock here would mean an earlier registration
307/// panicked, and taking the list as it stands is better than aborting the
308/// process before it starts. A name already on the list is left as it was.
309pub fn register(capability: Capability) {
310    let mut list = REGISTERED.lock().unwrap_or_else(|e| e.into_inner());
311    if list.iter().any(|c| c.name == capability.name) {
312        return;
313    }
314    list.push(capability);
315}
316
317/// The registered capabilities, in install order: by phase, then by name.
318/// Copied out so the caller holds no lock while it installs them.
319pub fn registered() -> Vec<Capability> {
320    let mut list = REGISTERED.lock().unwrap_or_else(|e| e.into_inner()).clone();
321    list.sort_by(|a, b| a.phase.cmp(&b.phase).then_with(|| a.name.cmp(b.name)));
322    list
323}
324
325/// Install every capability registered for `phase`, in name order.
326pub fn install_phase(app: &mut App, env: &CapabilityEnv, phase: Phase) {
327    for capability in registered().into_iter().filter(|c| c.phase == phase) {
328        (capability.install)(app, env);
329    }
330}
331
332/// Run every preflight, in install order. The first one that asks to exit
333/// ends the walk.
334pub fn preflight(env: &CapabilityEnv) -> Preflight {
335    for capability in registered() {
336        if let Some(preflight) = capability.preflight
337            && preflight(env) == Preflight::Exit
338        {
339            return Preflight::Exit;
340        }
341    }
342    Preflight::Continue
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn nothing(_app: &mut App, _env: &CapabilityEnv) {}
350
351    fn env(config: &str, sources: &str, opaque: bool) -> CapabilityEnv {
352        CapabilityEnv::new(
353            "/tmp/My App",
354            toml::from_str(config).expect("valid toml"),
355            sources.to_string(),
356            opaque,
357        )
358    }
359
360    #[test]
361    fn the_list_is_ordered_by_phase_then_name() {
362        register(Capability {
363            name: "order-test-b",
364            phase: Phase::Platform,
365            install: nothing,
366            preflight: None,
367            select: Select::Always,
368            crate_name: "registry-test",
369        });
370        register(Capability {
371            name: "order-test-a",
372            phase: Phase::AfterBuild,
373            install: nothing,
374            preflight: None,
375            select: Select::Always,
376            crate_name: "registry-test",
377        });
378        register(Capability {
379            name: "order-test-0",
380            phase: Phase::Platform,
381            install: nothing,
382            preflight: None,
383            select: Select::Always,
384            crate_name: "registry-test",
385        });
386        let names: Vec<&str> = registered()
387            .into_iter()
388            .filter(|c| c.name.starts_with("order-test-"))
389            .map(|c| c.name)
390            .collect();
391        assert_eq!(names, ["order-test-0", "order-test-b", "order-test-a"]);
392    }
393
394    #[test]
395    fn a_name_registers_once() {
396        register(Capability {
397            name: "registry-test-twice",
398            phase: Phase::Platform,
399            install: nothing,
400            preflight: None,
401            select: Select::Always,
402            crate_name: "registry-test",
403        });
404        register(Capability {
405            name: "registry-test-twice",
406            phase: Phase::AfterBuild,
407            install: nothing,
408            preflight: None,
409            select: Select::Always,
410            crate_name: "registry-test",
411        });
412        let entries: Vec<Capability> = registered()
413            .into_iter()
414            .filter(|c| c.name == "registry-test-twice")
415            .collect();
416        assert_eq!(entries.len(), 1);
417        assert_eq!(entries[0].phase, Phase::Platform);
418    }
419
420    #[test]
421    fn the_register_symbol_matches_the_macro_spelling() {
422        assert_eq!(
423            register_symbol("os-tray"),
424            "lumen_capability_register_os_tray"
425        );
426        assert_eq!(
427            register_symbol("http.fetch+2"),
428            "lumen_capability_register_http_fetch_2"
429        );
430    }
431
432    #[test]
433    fn the_app_id_comes_from_the_file_then_the_directory() {
434        let declared = env("[app]\nid = \"com.acme.notes\"\n", "", false);
435        assert_eq!(declared.app_id, "com.acme.notes");
436        assert_eq!(declared.declared_app_id.as_deref(), Some("com.acme.notes"));
437        let derived = env("", "", false);
438        assert_eq!(derived.app_id, "my-app");
439        assert_eq!(derived.declared_app_id, None);
440    }
441
442    #[test]
443    fn a_mention_is_found_in_the_sources_or_assumed_when_they_are_opaque() {
444        let plain = env(
445            "",
446            "fn f() { register_hotkey(\"Ctrl+S\", \"save\"); }",
447            false,
448        );
449        assert!(plain.sources_mention(&["register_hotkey"]));
450        assert!(!plain.sources_mention(&["pick_file"]));
451        let opaque = env("", "", true);
452        assert!(opaque.sources_mention(&["pick_file"]));
453    }
454
455    #[test]
456    fn a_provided_value_is_read_back_by_type() {
457        let mut env = env("", "", false);
458        assert!(env.provided::<String>().is_none());
459        env.provide(String::from("front end"));
460        assert_eq!(
461            env.provided::<String>().map(String::as_str),
462            Some("front end")
463        );
464        assert!(env.provided::<u32>().is_none());
465    }
466
467    #[test]
468    fn a_section_reads_its_table_and_defaults_when_absent_or_wrong() {
469        #[derive(Default, serde::Deserialize, PartialEq, Debug)]
470        struct Mcp {
471            port: Option<u16>,
472            simulate: Option<bool>,
473        }
474        let present = env("[mcp]\nport = 7000\nsimulate = true\n", "", false);
475        assert_eq!(
476            present.section::<Mcp>("mcp"),
477            Mcp {
478                port: Some(7000),
479                simulate: Some(true)
480            }
481        );
482        let absent = env("", "", false);
483        assert_eq!(absent.section::<Mcp>("mcp"), Mcp::default());
484        let wrong = env("[mcp]\nport = \"seven\"\n", "", false);
485        assert_eq!(wrong.section::<Mcp>("mcp"), Mcp::default());
486    }
487}