Skip to main content

rux_reactive/
lib.rs

1//! Rux's shared value type.
2//!
3//! This crate began (M5) as the reactivity core: a flat `Signals` table plus a
4//! little expression evaluator. M8 replaced both with the `rhai` engine in
5//! `rux-script`, which owns state and evaluation now. What survives is `Value`
6//!, the untyped representation that `rux-script` and `rux-style` pass between
7//! each other for bindings, `r-for` locals, and props.
8//!
9//! The per-binding subscription model in `docs/04-architecture.md` is now built
10//! (v0.3): `rux-script` tracks which signals each binding reads and which a
11//! handler writes, and `rux-runtime` patches/reconciles just the affected nodes
12//! in place instead of rebuilding the whole tree. This crate stays the shared
13//! `Value` type those layers pass around.
14
15/// Something the document does that will not work, with where it is if that is
16/// known.
17///
18/// Lives here because both `rux-style` and `rux-script` raise these and neither
19/// depends on the other; this is already the crate that exists to hold what they
20/// pass between them. `rux-runtime` merges both sinks for the dev overlay, and
21/// `rux check` turns them into editor diagnostics.
22///
23/// `line` is a **1-based line in the file**, not in the section that produced
24/// it: a position relative to a `<style>` block would send a reader to the wrong
25/// part of the file, which is worse than sending them nowhere. It is `None`
26/// wherever the stage that noticed the problem does not know where it was, and
27/// that is not a placeholder to be filled with a guess.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Warning {
30    pub message: String,
31    pub line: Option<usize>,
32}
33
34impl Warning {
35    /// A warning whose position is not known.
36    pub fn new(message: impl Into<String>) -> Self {
37        Self { message: message.into(), line: None }
38    }
39
40    /// A warning at a known 1-based file line.
41    pub fn at(message: impl Into<String>, line: usize) -> Self {
42        Self { message: message.into(), line: Some(line) }
43    }
44
45    /// Attach `line` if there is one, leaving the warning unplaced otherwise.
46    pub fn maybe_at(message: impl Into<String>, line: Option<usize>) -> Self {
47        Self { message: message.into(), line }
48    }
49}
50
51/// Quote and escape `s` as a JSON string, per RFC 8259.
52///
53/// Lives beside [`Warning`] because both things that serialise one, the `rux
54/// check` CLI and the browser playground, need exactly this and nothing more.
55/// Two hand-rolled copies of an escaper is how the re-indenter went wrong.
56/// Windows paths carry backslashes and messages quote the author's source, so
57/// both of those have to survive the trip into an editor.
58pub fn json_string(s: &str) -> String {
59    let mut out = String::with_capacity(s.len() + 2);
60    out.push('"');
61    for ch in s.chars() {
62        match ch {
63            '"' => out.push_str("\\\""),
64            '\\' => out.push_str("\\\\"),
65            '\n' => out.push_str("\\n"),
66            '\r' => out.push_str("\\r"),
67            '\t' => out.push_str("\\t"),
68            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
69            c => out.push(c),
70        }
71    }
72    out.push('"');
73    out
74}
75
76impl Warning {
77    /// `{"message": …, "line": … }`, with `line` present and null when unplaced
78    /// so a consumer never has to tell "no position" from "field missing".
79    pub fn to_json(&self) -> String {
80        let line = match self.line {
81            Some(line) => line.to_string(),
82            None => "null".to_string(),
83        };
84        format!("{{\"message\": {}, \"line\": {line}}}", json_string(&self.message))
85    }
86}
87
88impl std::fmt::Display for Warning {
89    /// `line 12: message`, or just the message when it has no position. This is
90    /// what the dev overlay shows, so it stays prose rather than becoming a
91    /// `path:line:col` machine format.
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self.line {
94            Some(line) => write!(f, "line {line}: {}", self.message),
95            None => write!(f, "{}", self.message),
96        }
97    }
98}
99
100/// A dynamically-typed signal value. Untyped so template interpolation and the
101/// future script tier can share one representation.
102#[derive(Clone, Debug, PartialEq)]
103pub enum Value {
104    Number(f64),
105    Text(String),
106    Bool(bool),
107    List(Vec<Value>),
108    /// A rhai object map (`#{ key: value }`), key order as rhai yields it. Backs the
109    /// object forms of `:class` (`#{ active: cond }`) and `:style` (`#{ bg: c }`).
110    Map(Vec<(String, Value)>),
111}
112
113impl Value {
114    /// How the value appears when interpolated into text.
115    pub fn to_display(&self) -> String {
116        match self {
117            Value::Number(n) => {
118                if n.fract() == 0.0 {
119                    format!("{}", *n as i64)
120                } else {
121                    format!("{n}")
122                }
123            }
124            Value::Text(s) => s.clone(),
125            Value::Bool(b) => b.to_string(),
126            Value::List(items) => items
127                .iter()
128                .map(Value::to_display)
129                .collect::<Vec<_>>()
130                .join(", "),
131            Value::Map(entries) => entries
132                .iter()
133                .map(|(k, v)| format!("{k}: {}", v.to_display()))
134                .collect::<Vec<_>>()
135                .join(", "),
136        }
137    }
138
139    pub fn as_number(&self) -> Option<f64> {
140        match self {
141            Value::Number(n) => Some(*n),
142            _ => None,
143        }
144    }
145
146    pub fn as_list(&self) -> Option<&[Value]> {
147        match self {
148            Value::List(items) => Some(items),
149            _ => None,
150        }
151    }
152
153    /// Truthiness for conditions: non-zero / non-empty / true.
154    pub fn is_truthy(&self) -> bool {
155        match self {
156            Value::Number(n) => *n != 0.0,
157            Value::Text(s) => !s.is_empty(),
158            Value::Bool(b) => *b,
159            Value::List(items) => !items.is_empty(),
160            Value::Map(entries) => !entries.is_empty(),
161        }
162    }
163
164    /// The entries of a `Map`, for the object forms of `:class` / `:style`.
165    pub fn as_map(&self) -> Option<&[(String, Value)]> {
166        match self {
167            Value::Map(entries) => Some(entries),
168            _ => None,
169        }
170    }
171
172    /// Serialize the value as rhai source, a literal that re-creates it. Used to
173    /// bake an `r-for` loop binding into a `@tap` handler, which runs later in
174    /// global scope where the loop variable no longer exists.
175    pub fn to_rhai_literal(&self) -> String {
176        match self {
177            // Whole numbers become int literals (rhai's default numeric type, and
178            // what collection indices/counters are), fractions stay floats.
179            Value::Number(n) => {
180                if n.fract() == 0.0 {
181                    format!("{}", *n as i64)
182                } else {
183                    format!("{n}")
184                }
185            }
186            Value::Text(s) => {
187                let mut out = String::with_capacity(s.len() + 2);
188                out.push('"');
189                for c in s.chars() {
190                    match c {
191                        '\\' => out.push_str("\\\\"),
192                        '"' => out.push_str("\\\""),
193                        '\n' => out.push_str("\\n"),
194                        '\r' => out.push_str("\\r"),
195                        '\t' => out.push_str("\\t"),
196                        _ => out.push(c),
197                    }
198                }
199                out.push('"');
200                out
201            }
202            Value::Bool(b) => b.to_string(),
203            Value::List(items) => {
204                let inner: Vec<_> = items.iter().map(Value::to_rhai_literal).collect();
205                format!("[{}]", inner.join(", "))
206            }
207            Value::Map(entries) => {
208                let inner: Vec<_> = entries
209                    .iter()
210                    .map(|(k, v)| format!("{k}: {}", v.to_rhai_literal()))
211                    .collect();
212                format!("#{{{}}}", inner.join(", "))
213            }
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn displays_and_coerces() {
224        assert_eq!(Value::Number(82.0).to_display(), "82"); // whole floats lose the .0
225        assert_eq!(Value::Number(8.2).to_display(), "8.2");
226        assert_eq!(
227            Value::List(vec![Value::Text("a".into()), Value::Number(2.0)]).to_display(),
228            "a, 2"
229        );
230
231        assert!(Value::Number(1.0).is_truthy());
232        assert!(!Value::Number(0.0).is_truthy());
233        assert!(!Value::Text(String::new()).is_truthy());
234        assert!(!Value::List(Vec::new()).is_truthy());
235    }
236
237    #[test]
238    fn serializes_rhai_literals() {
239        assert_eq!(Value::Number(3.0).to_rhai_literal(), "3");
240        assert_eq!(Value::Number(2.5).to_rhai_literal(), "2.5");
241        assert_eq!(Value::Bool(true).to_rhai_literal(), "true");
242        assert_eq!(Value::Text("Charlie".into()).to_rhai_literal(), "\"Charlie\"");
243        // Quotes and backslashes must be escaped so the handler still parses.
244        assert_eq!(
245            Value::Text("say \"hi\"\\n".into()).to_rhai_literal(),
246            "\"say \\\"hi\\\"\\\\n\""
247        );
248        assert_eq!(
249            Value::List(vec![Value::Number(1.0), Value::Text("a".into())]).to_rhai_literal(),
250            "[1, \"a\"]"
251        );
252    }
253}