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}