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}