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}