Skip to main content

lumen_script/
unlinked.rs

1//! Builtins whose subsystem the running engine was linked without.
2//!
3//! A packaged app can ship an engine relinked for it, carrying only the
4//! optional subsystems it uses. A builtin stays callable whether or not its
5//! subsystem is linked in: its body queues a command, and with the subsystem
6//! gone no system applies that command, so the call does nothing. This module
7//! is how such a call is told why, once per builtin.
8//!
9//! What it knows is what the app's artifact recorded: each left-out subsystem's
10//! name and the markers its selection rule scans sources for. Both are opaque
11//! strings here; nothing in this crate names a subsystem. The check sits in
12//! [`ScriptFn::invoke_into`](crate::ScriptFn::invoke_into), which every host
13//! adapter goes through, so it reaches every language the same way.
14
15use std::collections::HashSet;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Mutex, RwLock};
18
19use lumen_core::warn_line;
20
21/// Whether anything was recorded. Read on every builtin call, so the usual
22/// answer, nothing, costs one relaxed load.
23static ANY: AtomicBool = AtomicBool::new(false);
24
25/// What the engine was linked without: a subsystem's name and its markers.
26static UNLINKED: RwLock<Vec<(String, Vec<String>)>> = RwLock::new(Vec::new());
27
28/// The builtins already warned about, so each warns once per process.
29static WARNED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
30
31/// Record the subsystems the running engine was linked without, each with the
32/// markers that reach it. Replaces what an earlier call recorded; an empty
33/// list turns the check off.
34pub fn record_unlinked(unlinked: impl IntoIterator<Item = (String, Vec<String>)>) {
35    let unlinked: Vec<(String, Vec<String>)> = unlinked.into_iter().collect();
36    ANY.store(!unlinked.is_empty(), Ordering::Relaxed);
37    *UNLINKED.write().unwrap_or_else(|e| e.into_inner()) = unlinked;
38}
39
40/// The subsystem a call to `builtin` needs and the engine lacks, if any.
41pub fn unlinked_subsystem(builtin: &str) -> Option<String> {
42    if !ANY.load(Ordering::Relaxed) {
43        return None;
44    }
45    UNLINKED
46        .read()
47        .unwrap_or_else(|e| e.into_inner())
48        .iter()
49        .find(|(_, markers)| markers.iter().any(|m| reaches(builtin, m)))
50        .map(|(name, _)| name.clone())
51}
52
53/// Warn, once per builtin, when a call to `builtin` needs a subsystem the
54/// engine was linked without.
55pub(crate) fn warn_if_unlinked(builtin: &str) {
56    let Some(subsystem) = unlinked_subsystem(builtin) else {
57        return;
58    };
59    let mut warned = WARNED.lock().unwrap_or_else(|e| e.into_inner());
60    if warned
61        .get_or_insert_with(HashSet::new)
62        .insert(builtin.to_string())
63    {
64        warn_line!(
65            "lumen: warning: `{builtin}` does nothing here: this package was built without \
66             {subsystem}. Name it in lumen.toml [capabilities] to package it in."
67        );
68    }
69}
70
71/// Whether a call to `builtin` contains `marker`, which is the question the
72/// source scan asked of the app's text. A marker ending in `(` is a call to a
73/// builtin of exactly that name or ending in it; a bare marker is part of
74/// every builtin name holding it.
75fn reaches(builtin: &str, marker: &str) -> bool {
76    match marker.strip_suffix('(') {
77        Some(callee) => !callee.is_empty() && builtin.ends_with(callee),
78        None => !marker.is_empty() && builtin.contains(marker),
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn a_marker_reaches_the_builtins_a_scan_would_have_counted() {
88        assert!(reaches("tray_icon", "tray_icon"));
89        assert!(reaches("tray_icon_menu", "tray_icon"));
90        assert!(!reaches("notify", "tray_icon"));
91        assert!(reaches("fetch", "fetch("));
92        assert!(!reaches("fetch_all", "fetch("));
93        assert!(!reaches("anything", ""));
94        assert!(!reaches("anything", "("));
95    }
96
97    /// One test for the whole process-global table, so no other test in this
98    /// binary races it.
99    #[test]
100    fn a_recorded_subsystem_answers_for_its_builtins_until_cleared() {
101        assert_eq!(unlinked_subsystem("tray_icon"), None);
102        record_unlinked([(
103            "os-tray".to_string(),
104            vec!["tray_icon".to_string(), "fetch(".to_string()],
105        )]);
106        assert_eq!(unlinked_subsystem("tray_icon").as_deref(), Some("os-tray"));
107        assert_eq!(unlinked_subsystem("fetch").as_deref(), Some("os-tray"));
108        assert_eq!(unlinked_subsystem("notify"), None);
109        warn_if_unlinked("tray_icon");
110        warn_if_unlinked("tray_icon");
111        assert_eq!(
112            WARNED
113                .lock()
114                .unwrap()
115                .as_ref()
116                .map(|w| w.contains("tray_icon")),
117            Some(true)
118        );
119        record_unlinked(Vec::new());
120        assert_eq!(unlinked_subsystem("tray_icon"), None);
121    }
122}