Skip to main content

lumen_core/
nav.rs

1//! File-based-pages navigation primitive - the ONE surface every embedding
2//! reaches through.
3//!
4//! Navigation in Lumen is not a per-language script builtin: it is a command
5//! carried on the shared external-signal bus. A script host (candela, and
6//! the Rhai and Lua escape hatches), the Rust SDK, a C-ABI plugin, and the
7//! future Python / C# SDKs all reach navigation by writing the reserved
8//! [`REQUEST_SIGNAL`] cell through
9//! [`request`] (which routes through
10//! [`crate::signals::push_external_signal`] -> [`crate::property_store::PropertyStore`]).
11//! The runtime's `apply_navigation` system is the single resolver: it reads
12//! the request cell, resolves the target path against the registered pages
13//! (longest existing-file prefix - the framework never pattern-matches
14//! `:id` segments), and writes the reserved [`PATH_SIGNAL`] / [`SEGMENT_SIGNAL`]
15//! cells that `<if>` page gates, `bind-*`, and derivations react to.
16//!
17//! This mirrors real-HTML navigation semantics (an `<a href>` click and a
18//! programmatic `history.pushState` both end at one URL that the view reacts
19//! to) and Next.js / SvelteKit file-based routing (a page == a file), while
20//! staying host-neutral: nothing here belongs to one scripting language.
21//!
22//! ## Wire format
23//!
24//! The request cell carries a single opaque string so a repeated identical
25//! op (two `back()`s in a row) still edge-triggers: `"<seq>\u{1f}<kind>\u{1f}<arg>"`
26//! where `seq` is a process-monotonic nonce, `kind` is one of `{nav, back, forward}`,
27//! and `arg` is the target path for `nav` (empty otherwise). Producers build
28//! it with [`encode_request`]; the resolver parses it with [`parse_request`].
29
30use std::sync::atomic::{AtomicU64, Ordering};
31use std::sync::{Mutex, OnceLock};
32
33/// Reserved global signal the navigation resolver reads. Producers write it
34/// via [`request`]; it is not meant to be bound in markup.
35pub const REQUEST_SIGNAL: &str = "route.request";
36
37/// Reserved global signal holding the active page key (the resolved
38/// `.lmn` filename stem). `<if eq="settings">` page gates compare against it;
39/// `bind-text="route.path"` and derivations may read it.
40pub const PATH_SIGNAL: &str = "route.path";
41
42/// Reserved global signal holding the leftover path after the matched page
43/// prefix (e.g. navigating `/user/7` when only `user.lmn` exists leaves
44/// `/7` here). The framework never parses this into typed params - the
45/// page's own code does.
46pub const SEGMENT_SIGNAL: &str = "route.segment";
47
48/// A navigation operation. Host-neutral: every surface produces one of these.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub enum NavOp {
51    /// Navigate to a target path (`"settings"`, `"/user/7"`, `"/"`).
52    Navigate(String),
53    /// Step one entry back in the in-memory history stack.
54    Back,
55    /// Step one entry forward in the in-memory history stack.
56    Forward,
57}
58
59/// ASCII unit separator; will not appear in a page path or op token.
60const SEP: char = '\u{1f}';
61
62static SEQ: AtomicU64 = AtomicU64::new(1);
63
64fn next_seq() -> u64 {
65    SEQ.fetch_add(1, Ordering::Relaxed)
66}
67
68/// Encode `op` into the reserved-request wire string with a fresh nonce so an
69/// immediately-repeated op still registers as a change.
70pub fn encode_request(op: &NavOp) -> String {
71    let seq = next_seq();
72    match op {
73        NavOp::Navigate(path) => format!("{seq}{SEP}nav{SEP}{path}"),
74        NavOp::Back => format!("{seq}{SEP}back{SEP}"),
75        NavOp::Forward => format!("{seq}{SEP}forward{SEP}"),
76    }
77}
78
79/// Parse a reserved-request wire string back into `(seq, op)`. Returns `None`
80/// for an unrecognised / malformed value.
81pub fn parse_request(raw: &str) -> Option<(u64, NavOp)> {
82    let mut parts = raw.splitn(3, SEP);
83    let seq: u64 = parts.next()?.parse().ok()?;
84    let kind = parts.next()?;
85    let arg = parts.next().unwrap_or("");
86    let op = match kind {
87        "nav" => NavOp::Navigate(arg.to_string()),
88        "back" => NavOp::Back,
89        "forward" => NavOp::Forward,
90        _ => return None,
91    };
92    Some((seq, op))
93}
94
95/// Request a navigation from ANY thread / ANY surface. Writes the reserved
96/// request cell through the external-signal bus; the runtime's
97/// `apply_navigation` system resolves it on the next tick.
98///
99/// Returns `false` only when the external bus has been torn down.
100pub fn request(op: NavOp) -> bool {
101    crate::signals::push_external_signal(REQUEST_SIGNAL, encode_request(&op))
102}
103
104/// Convenience: navigate to `path` (equivalent to `request(NavOp::Navigate(..))`).
105pub fn navigate(path: impl Into<String>) -> bool {
106    request(NavOp::Navigate(path.into()))
107}
108
109/// Convenience: step back in history.
110pub fn back() -> bool {
111    request(NavOp::Back)
112}
113
114/// Convenience: step forward in history.
115pub fn forward() -> bool {
116    request(NavOp::Forward)
117}
118
119// -- current-page mirror -----------------------------------------------------
120//
121// The resolver publishes the resolved active page key here so a no-arg
122// `page()` read is answerable from any surface (Rhai `page()`, the Rust SDK,
123// the C-ABI `lumen_current_page`) without threading the running `App`'s world
124// across the boundary. Updated once per resolved navigation; lags the
125// PropertyStore cell by at most one tick.
126
127static CURRENT: OnceLock<Mutex<String>> = OnceLock::new();
128
129fn current_cell() -> &'static Mutex<String> {
130    CURRENT.get_or_init(|| Mutex::new(String::new()))
131}
132
133/// Publish the resolved active page key. Called by the runtime resolver.
134pub fn set_current(page: &str) {
135    if let Ok(mut g) = current_cell().lock() {
136        *g = page.to_string();
137    }
138}
139
140/// Read the current active page key. Empty before the first page mounts.
141pub fn current() -> String {
142    current_cell().lock().map(|g| g.clone()).unwrap_or_default()
143}
144
145// -- page-path resolution (longest existing-file prefix) ---------------------
146
147/// The page `path` names and the part of the path that page answers for, or
148/// `None` when no page answers for it.
149///
150/// Algorithm - the framework does not pattern-match segments:
151/// 1. Strip a leading `/`. An empty path names `entry` (the home page).
152/// 2. Try the full path as a page key; if absent, walk up one segment at a
153///    time to the longest existing prefix (`/user/7` -> `user` when only
154///    `user.lmn` exists).
155/// 3. The leftover tail after the matched prefix becomes the `segment`
156///    (`/7`), for the page's own code to parse.
157///
158/// Use this where an address that names nothing has an answer of its own,
159/// such as a server sending a 404. Somewhere that has to show a page either
160/// way, such as a window following a link, wants [`resolve_path`].
161pub fn match_path(path: &str, keys: &[String], entry: &str) -> Option<(String, String)> {
162    let norm = normalize_path(path);
163    if norm.is_empty() {
164        return Some((entry.to_string(), String::new()));
165    }
166    let segs: Vec<&str> = norm.split('/').filter(|s| !s.is_empty()).collect();
167    for i in (1..=segs.len()).rev() {
168        let candidate = segs[..i].join("/");
169        if keys.iter().any(|k| k == &candidate) {
170            let leftover = segs[i..].join("/");
171            let segment = if leftover.is_empty() {
172                String::new()
173            } else {
174                format!("/{leftover}")
175            };
176            return Some((candidate, segment));
177        }
178    }
179    None
180}
181
182/// Resolve a requested `path` against the set of known page `keys` (each a
183/// `.lmn` filename stem), returning `(page_key, segment)`.
184///
185/// [`match_path`] does the matching. What this adds is the fallback: a path
186/// no page answers for lands on `entry` with the whole requested path as the
187/// segment, so an app that wants to say "no such thing" can render that
188/// itself.
189pub fn resolve_path(path: &str, keys: &[String], entry: &str) -> (String, String) {
190    match_path(path, keys, entry)
191        .unwrap_or_else(|| (entry.to_string(), format!("/{}", normalize_path(path))))
192}
193
194/// A request path with the leading and trailing slashes off, which is the
195/// shape a page key is compared against.
196fn normalize_path(path: &str) -> &str {
197    path.trim_start_matches('/').trim_end_matches('/')
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn round_trip_ops() {
206        for op in [
207            NavOp::Navigate("settings".into()),
208            NavOp::Navigate("/user/7".into()),
209            NavOp::Back,
210            NavOp::Forward,
211        ] {
212            let (_, parsed) = parse_request(&encode_request(&op)).unwrap();
213            assert_eq!(parsed, op);
214        }
215    }
216
217    #[test]
218    fn nonce_makes_repeats_distinct() {
219        let a = encode_request(&NavOp::Back);
220        let b = encode_request(&NavOp::Back);
221        assert_ne!(a, b, "repeated op must differ so it edge-triggers");
222    }
223
224    #[test]
225    fn resolves_exact_and_prefix_and_root() {
226        let keys = vec![
227            "index".to_string(),
228            "settings".to_string(),
229            "user".to_string(),
230        ];
231        assert_eq!(
232            resolve_path("/", &keys, "index"),
233            ("index".into(), "".into())
234        );
235        assert_eq!(
236            resolve_path("settings", &keys, "index"),
237            ("settings".into(), "".into())
238        );
239        assert_eq!(
240            resolve_path("/user/7", &keys, "index"),
241            ("user".into(), "/7".into())
242        );
243        assert_eq!(
244            resolve_path("/user/7/edit", &keys, "index"),
245            ("user".into(), "/7/edit".into())
246        );
247        // Whole-path miss -> entry + full path as segment.
248        assert_eq!(
249            resolve_path("/nope", &keys, "index"),
250            ("index".into(), "/nope".into())
251        );
252    }
253
254    #[test]
255    fn an_address_no_page_answers_for_matches_nothing() {
256        let keys = vec!["index".to_string(), "user".to_string()];
257        // The root and a deep path a page does answer for both match, which
258        // is what keeps `/user/7` a page rather than a miss.
259        assert_eq!(
260            match_path("/", &keys, "index"),
261            Some(("index".into(), "".into()))
262        );
263        assert_eq!(
264            match_path("/user/7", &keys, "index"),
265            Some(("user".into(), "/7".into()))
266        );
267        assert_eq!(match_path("/nope", &keys, "index"), None);
268        assert_eq!(match_path("/nope/deeper", &keys, "index"), None);
269    }
270}