Skip to main content

forma_ir/
slot.rs

1use crate::format::{IrError, SlotType};
2use crate::parser::{IrModule, SlotTable};
3
4/// Runtime values populated by page handlers before IR walking.
5#[derive(Debug, Clone)]
6pub enum SlotValue {
7    Null,
8    Text(String),
9    Bool(bool),
10    Number(f64),
11    Array(Vec<SlotValue>),
12    Object(Vec<(String, SlotValue)>),
13}
14
15/// A static Null reference returned for out-of-bounds slot lookups.
16static NULL_SLOT: SlotValue = SlotValue::Null;
17
18impl SlotValue {
19    /// Render to string for DYN_TEXT output.
20    pub fn to_text(&self) -> String {
21        match self {
22            SlotValue::Null => String::new(),
23            SlotValue::Text(s) => s.clone(),
24            SlotValue::Bool(true) => "true".to_string(),
25            SlotValue::Bool(false) => "false".to_string(),
26            SlotValue::Number(n) => {
27                if n.fract() == 0.0 && n.is_finite() {
28                    format!("{}", *n as i64)
29                } else {
30                    // Format with enough precision, then trim trailing zeros
31                    let s = format!("{}", n);
32                    s
33                }
34            }
35            SlotValue::Array(_) => "[Array]".to_string(),
36            SlotValue::Object(_) => "[Object]".to_string(),
37        }
38    }
39
40    /// Truthiness for SHOW_IF evaluation.
41    pub fn as_bool(&self) -> bool {
42        match self {
43            SlotValue::Null => false,
44            SlotValue::Bool(b) => *b,
45            SlotValue::Text(s) => !s.is_empty(),
46            SlotValue::Number(n) => *n != 0.0,
47            SlotValue::Array(a) => !a.is_empty(),
48            SlotValue::Object(_) => true,
49        }
50    }
51
52    /// Extract a named property from an Object. Returns Null if not Object or key not found.
53    pub fn get_property(&self, name: &str) -> SlotValue {
54        match self {
55            SlotValue::Object(pairs) => {
56                for (k, v) in pairs {
57                    if k == name {
58                        return v.clone();
59                    }
60                }
61                SlotValue::Null
62            }
63            _ => SlotValue::Null,
64        }
65    }
66
67    /// For LIST iteration — returns the inner slice if Array, None otherwise.
68    pub fn as_array(&self) -> Option<&[SlotValue]> {
69        match self {
70            SlotValue::Array(a) => Some(a),
71            _ => None,
72        }
73    }
74
75    /// Borrow text without cloning. Returns the inner &str for Text variant,
76    /// "" for all others (Number requires allocation via to_text()).
77    pub fn as_text_ref(&self) -> &str {
78        match self {
79            SlotValue::Text(s) => s.as_str(),
80            _ => "",
81        }
82    }
83
84    /// Convert to a serde_json::Value for props serialization.
85    pub fn to_json(&self) -> serde_json::Value {
86        match self {
87            SlotValue::Null => serde_json::Value::Null,
88            SlotValue::Text(s) => serde_json::Value::String(s.clone()),
89            SlotValue::Bool(b) => serde_json::Value::Bool(*b),
90            SlotValue::Number(n) => {
91                if n.fract() == 0.0 && n.is_finite() {
92                    serde_json::Value::Number(serde_json::Number::from(*n as i64))
93                } else {
94                    serde_json::Number::from_f64(*n)
95                        .map(serde_json::Value::Number)
96                        .unwrap_or(serde_json::Value::Null)
97                }
98            }
99            SlotValue::Array(items) => {
100                serde_json::Value::Array(items.iter().map(|v| v.to_json()).collect())
101            }
102            SlotValue::Object(pairs) => {
103                let map: serde_json::Map<String, serde_json::Value> = pairs
104                    .iter()
105                    .map(|(k, v)| (k.clone(), v.to_json()))
106                    .collect();
107                serde_json::Value::Object(map)
108            }
109        }
110    }
111}
112
113/// Dense Vec indexed by slot_id — the bridge between Rust page handlers and the IR walker.
114///
115/// Handlers create a SlotData, populate it with values (title, user name, nav items, etc.),
116/// then the walker reads slot values when it encounters DYN_TEXT, DYN_ATTR, SHOW_IF, LIST opcodes.
117#[derive(Debug, Clone)]
118pub struct SlotData {
119    slots: Vec<SlotValue>,
120}
121
122impl SlotData {
123    /// Create a new SlotData with `capacity` Null-initialized slots.
124    pub fn new(capacity: usize) -> Self {
125        let mut slots = Vec::with_capacity(capacity);
126        slots.resize_with(capacity, || SlotValue::Null);
127        Self { slots }
128    }
129
130    /// Set a slot value. No-op if slot_id is out of bounds.
131    pub fn set(&mut self, slot_id: u16, value: SlotValue) {
132        let idx = slot_id as usize;
133        if idx < self.slots.len() {
134            self.slots[idx] = value;
135        }
136    }
137
138    /// Get a reference to a slot value. Returns &Null if out of bounds.
139    pub fn get(&self, slot_id: u16) -> &SlotValue {
140        let idx = slot_id as usize;
141        self.slots.get(idx).unwrap_or(&NULL_SLOT)
142    }
143
144    /// Get the text content of a slot. Returns Some(&str) if Text, None otherwise.
145    pub fn get_text(&self, slot_id: u16) -> Option<&str> {
146        match self.get(slot_id) {
147            SlotValue::Text(s) => Some(s.as_str()),
148            _ => None,
149        }
150    }
151
152    /// Create SlotData from a named-key JSON string, resolving names to slot IDs
153    /// via the module's string table and slot table.
154    ///
155    /// The JSON must be an object (e.g., `{"title": "Hello", "count": 42}`).
156    /// Each key is resolved against the slot table: the slot entry's `name_str_idx`
157    /// is looked up in the string table to find the slot name. If it matches a JSON
158    /// key, the JSON value is converted to a `SlotValue` and stored at that slot_id.
159    ///
160    /// Unknown JSON keys (not in the slot table) are silently ignored.
161    /// Starts from slot table defaults so missing keys retain their defaults.
162    pub fn from_json(json_str: &str, module: &IrModule) -> Result<Self, IrError> {
163        let parsed: serde_json::Value =
164            serde_json::from_str(json_str).map_err(|e| IrError::JsonParseError(e.to_string()))?;
165
166        let obj = match parsed {
167            serde_json::Value::Object(map) => map,
168            _ => return Err(IrError::JsonParseError("expected JSON object".to_string())),
169        };
170
171        // Build name → slot_id map from the slot table + string table
172        let mut name_to_slot: std::collections::HashMap<String, u16> =
173            std::collections::HashMap::new();
174        for entry in module.slots.entries() {
175            if let Ok(name) = module.strings.get(entry.name_str_idx) {
176                name_to_slot.insert(name.to_string(), entry.slot_id);
177            }
178        }
179
180        // Start from defaults
181        let mut data = Self::new_from_defaults(&module.slots);
182
183        // Override with JSON values
184        for (key, value) in &obj {
185            if let Some(&slot_id) = name_to_slot.get(key) {
186                data.set(slot_id, json_to_slot_value(value));
187            }
188            // Unknown keys silently ignored
189        }
190
191        Ok(data)
192    }
193
194    /// Create SlotData pre-populated from the IR slot table defaults.
195    /// Server-sourced slots with no default -> Null.
196    /// Client-sourced slots with defaults -> parsed from default_bytes.
197    pub fn new_from_defaults(table: &SlotTable) -> Self {
198        let entries = table.entries();
199        let capacity = entries
200            .iter()
201            .map(|e| e.slot_id as usize + 1)
202            .max()
203            .unwrap_or(0);
204        let mut slots = Vec::with_capacity(capacity);
205        slots.resize_with(capacity, || SlotValue::Null);
206
207        for entry in entries {
208            let idx = entry.slot_id as usize;
209            if idx >= slots.len() {
210                continue;
211            }
212            if entry.default_bytes.is_empty() {
213                continue;
214            }
215
216            let default_str = std::str::from_utf8(&entry.default_bytes).unwrap_or("");
217            let value = match entry.type_hint {
218                SlotType::Bool => match default_str {
219                    "true" => SlotValue::Bool(true),
220                    "false" => SlotValue::Bool(false),
221                    _ => SlotValue::Null,
222                },
223                SlotType::Text => SlotValue::Text(default_str.to_string()),
224                SlotType::Number => default_str
225                    .parse::<f64>()
226                    .map(SlotValue::Number)
227                    .unwrap_or(SlotValue::Null),
228                SlotType::Array => {
229                    if default_str == "[]" {
230                        SlotValue::Array(vec![])
231                    } else {
232                        SlotValue::Null
233                    }
234                }
235                SlotType::Object => SlotValue::Null,
236            };
237            slots[idx] = value;
238        }
239
240        Self { slots }
241    }
242}
243
244/// Convert a `serde_json::Value` to a `SlotValue` recursively.
245///
246/// Type mapping:
247/// - null → Null
248/// - string → Text
249/// - bool → Bool
250/// - number → Number (as f64)
251/// - array → Array (each element converted recursively)
252/// - object → Object (each key-value pair converted recursively)
253pub fn json_to_slot_value(value: &serde_json::Value) -> SlotValue {
254    match value {
255        serde_json::Value::Null => SlotValue::Null,
256        serde_json::Value::String(s) => SlotValue::Text(s.clone()),
257        serde_json::Value::Bool(b) => SlotValue::Bool(*b),
258        serde_json::Value::Number(n) => SlotValue::Number(n.as_f64().unwrap_or(0.0)),
259        serde_json::Value::Array(arr) => {
260            SlotValue::Array(arr.iter().map(json_to_slot_value).collect())
261        }
262        serde_json::Value::Object(map) => SlotValue::Object(
263            map.iter()
264                .map(|(k, v)| (k.clone(), json_to_slot_value(v)))
265                .collect(),
266        ),
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::parser::{IrModule, SlotTable};
274
275    /// Build valid v2 slot table binary data from a slice of
276    /// (slot_id, name_str_idx, type_hint_byte, source_byte, default_bytes).
277    fn build_slot_table_bytes(entries: &[(u16, u32, u8, u8, &[u8])]) -> Vec<u8> {
278        let mut buf = Vec::new();
279        buf.extend_from_slice(&(entries.len() as u16).to_le_bytes());
280        for &(slot_id, name_str_idx, type_hint, source, default_bytes) in entries {
281            buf.extend_from_slice(&slot_id.to_le_bytes());
282            buf.extend_from_slice(&name_str_idx.to_le_bytes());
283            buf.push(type_hint);
284            buf.push(source);
285            buf.extend_from_slice(&(default_bytes.len() as u16).to_le_bytes());
286            buf.extend_from_slice(default_bytes);
287        }
288        buf
289    }
290
291    #[test]
292    fn slot_value_text_to_text() {
293        assert_eq!(SlotValue::Text("hello".to_string()).to_text(), "hello");
294    }
295
296    #[test]
297    fn slot_value_bool_to_text() {
298        assert_eq!(SlotValue::Bool(true).to_text(), "true");
299        assert_eq!(SlotValue::Bool(false).to_text(), "false");
300    }
301
302    #[test]
303    fn slot_value_number_integer() {
304        assert_eq!(SlotValue::Number(42.0).to_text(), "42");
305    }
306
307    #[test]
308    fn slot_value_number_float() {
309        assert_eq!(SlotValue::Number(3.15).to_text(), "3.15");
310    }
311
312    #[test]
313    fn slot_value_null_to_text() {
314        assert_eq!(SlotValue::Null.to_text(), "");
315    }
316
317    #[test]
318    fn slot_value_as_bool_truthy() {
319        assert!(SlotValue::Text("x".to_string()).as_bool());
320        assert!(SlotValue::Number(1.0).as_bool());
321        assert!(SlotValue::Bool(true).as_bool());
322        assert!(SlotValue::Array(vec![SlotValue::Null]).as_bool());
323        assert!(SlotValue::Object(vec![]).as_bool());
324    }
325
326    #[test]
327    fn slot_value_as_bool_falsy() {
328        assert!(!SlotValue::Null.as_bool());
329        assert!(!SlotValue::Text("".to_string()).as_bool());
330        assert!(!SlotValue::Number(0.0).as_bool());
331        assert!(!SlotValue::Bool(false).as_bool());
332        assert!(!SlotValue::Array(vec![]).as_bool());
333    }
334
335    #[test]
336    fn slot_value_as_array() {
337        let arr = SlotValue::Array(vec![SlotValue::Text("a".to_string())]);
338        assert!(arr.as_array().is_some());
339        assert_eq!(arr.as_array().unwrap().len(), 1);
340
341        assert!(SlotValue::Null.as_array().is_none());
342        assert!(SlotValue::Text("x".to_string()).as_array().is_none());
343        assert!(SlotValue::Number(1.0).as_array().is_none());
344        assert!(SlotValue::Bool(true).as_array().is_none());
345        assert!(SlotValue::Object(vec![]).as_array().is_none());
346    }
347
348    #[test]
349    fn slot_data_set_and_get() {
350        let mut data = SlotData::new(4);
351        data.set(0, SlotValue::Text("title".to_string()));
352        data.set(2, SlotValue::Number(99.0));
353
354        assert_eq!(data.get(0).to_text(), "title");
355        assert_eq!(data.get(2).to_text(), "99");
356        // Unset slots remain Null
357        assert_eq!(data.get(1).to_text(), "");
358    }
359
360    #[test]
361    fn slot_data_out_of_bounds() {
362        let data = SlotData::new(2);
363        // Beyond capacity returns Null
364        assert_eq!(data.get(5).to_text(), "");
365        assert!(!data.get(100).as_bool());
366    }
367
368    #[test]
369    fn slot_data_get_text() {
370        let mut data = SlotData::new(3);
371        data.set(0, SlotValue::Text("hello".to_string()));
372        data.set(1, SlotValue::Number(42.0));
373
374        assert_eq!(data.get_text(0), Some("hello"));
375        assert_eq!(data.get_text(1), None); // Number, not Text
376        assert_eq!(data.get_text(2), None); // Null, not Text
377    }
378
379    // -- new_from_defaults tests --------------------------------------------
380
381    #[test]
382    fn slot_data_new_from_defaults_basic() {
383        // Build a v2 slot table with 3 slots:
384        // Slot 0: Array, Server, default "[]"
385        // Slot 1: Bool, Client, default "false"
386        // Slot 2: Text, Client, default "hello"
387        let bytes = build_slot_table_bytes(&[
388            (0, 0, 0x04, 0x00, b"[]"),    // Array, Server, default "[]"
389            (1, 1, 0x02, 0x01, b"false"), // Bool, Client, default "false"
390            (2, 2, 0x01, 0x01, b"hello"), // Text, Client, default "hello"
391        ]);
392        let table = SlotTable::parse(&bytes).unwrap();
393        let data = SlotData::new_from_defaults(&table);
394
395        // Slot 0: Array with default "[]" -> empty array
396        assert!(matches!(data.get(0), SlotValue::Array(v) if v.is_empty()));
397        // Slot 1: Bool with default "false" -> Bool(false)
398        assert!(matches!(data.get(1), SlotValue::Bool(false)));
399        // Slot 2: Text with default "hello" -> Text("hello")
400        assert_eq!(data.get_text(2), Some("hello"));
401    }
402
403    #[test]
404    fn slot_data_new_from_defaults_empty_table() {
405        // Empty slot table -> empty SlotData
406        let bytes = build_slot_table_bytes(&[]);
407        let table = SlotTable::parse(&bytes).unwrap();
408        let data = SlotData::new_from_defaults(&table);
409
410        // Out of bounds returns Null
411        assert!(matches!(data.get(0), SlotValue::Null));
412    }
413
414    #[test]
415    fn slot_data_new_from_defaults_no_default_bytes() {
416        // Slot with empty default_bytes -> Null
417        let bytes = build_slot_table_bytes(&[
418            (0, 0, 0x01, 0x00, b""), // Text, Server, no default
419            (1, 1, 0x03, 0x01, b""), // Number, Client, no default
420        ]);
421        let table = SlotTable::parse(&bytes).unwrap();
422        let data = SlotData::new_from_defaults(&table);
423
424        assert!(matches!(data.get(0), SlotValue::Null));
425        assert!(matches!(data.get(1), SlotValue::Null));
426    }
427
428    #[test]
429    fn slot_data_new_from_defaults_number() {
430        // Verify Number parsing
431        let bytes = build_slot_table_bytes(&[
432            (0, 0, 0x03, 0x00, b"42"),   // Number, Server, default "42"
433            (1, 1, 0x03, 0x01, b"3.15"), // Number, Client, default "3.15"
434            (2, 2, 0x03, 0x00, b"nope"), // Number, Server, invalid default
435        ]);
436        let table = SlotTable::parse(&bytes).unwrap();
437        let data = SlotData::new_from_defaults(&table);
438
439        assert!(matches!(data.get(0), SlotValue::Number(n) if (*n - 42.0).abs() < f64::EPSILON));
440        assert!(matches!(data.get(1), SlotValue::Number(n) if (*n - 3.15).abs() < f64::EPSILON));
441        // Invalid number -> Null
442        assert!(matches!(data.get(2), SlotValue::Null));
443    }
444
445    #[test]
446    fn slot_data_new_from_defaults_bool_true() {
447        let bytes = build_slot_table_bytes(&[
448            (0, 0, 0x02, 0x00, b"true"), // Bool, Server, default "true"
449        ]);
450        let table = SlotTable::parse(&bytes).unwrap();
451        let data = SlotData::new_from_defaults(&table);
452
453        assert!(matches!(data.get(0), SlotValue::Bool(true)));
454    }
455
456    #[test]
457    fn slot_data_new_from_defaults_object_ignored() {
458        // Object type always yields Null even with default bytes
459        let bytes = build_slot_table_bytes(&[
460            (0, 0, 0x05, 0x00, b"{}"), // Object, Server, default "{}"
461        ]);
462        let table = SlotTable::parse(&bytes).unwrap();
463        let data = SlotData::new_from_defaults(&table);
464
465        assert!(matches!(data.get(0), SlotValue::Null));
466    }
467
468    // -- from_json tests ---------------------------------------------------
469
470    use crate::parser::test_helpers::{build_minimal_ir, encode_text};
471
472    /// Helper to build an IrModule with slots whose names are in the string table.
473    fn build_module_for_json(
474        strings: &[&str],
475        slot_decls: &[(u16, u32, u8, u8, &[u8])],
476    ) -> IrModule {
477        // Minimal opcodes: just a TEXT referencing string 0
478        let opcodes = encode_text(0);
479        let data = build_minimal_ir(strings, slot_decls, &opcodes, &[]);
480        IrModule::parse(&data).unwrap()
481    }
482
483    #[test]
484    fn from_json_basic() {
485        // strings: 0="title", 1="count"
486        // slot 0: name_str_idx=0 (title), type=Text
487        // slot 1: name_str_idx=1 (count), type=Number
488        let module = build_module_for_json(
489            &["title", "count"],
490            &[
491                (0, 0, 0x01, 0x00, &[]), // slot_id=0, name="title", Text, Server
492                (1, 1, 0x03, 0x00, &[]), // slot_id=1, name="count", Number, Server
493            ],
494        );
495
496        let data = SlotData::from_json(r#"{"title": "Hello", "count": 42}"#, &module).unwrap();
497        assert_eq!(data.get(0).to_text(), "Hello");
498        assert_eq!(data.get(1).to_text(), "42");
499    }
500
501    #[test]
502    fn from_json_missing_key_uses_default() {
503        // slot 0: name="greeting", Text, default "hi"
504        let module = build_module_for_json(
505            &["greeting"],
506            &[
507                (0, 0, 0x01, 0x00, b"hi"), // slot_id=0, name="greeting", Text, Server, default "hi"
508            ],
509        );
510
511        // JSON does not contain "greeting" key
512        let data = SlotData::from_json(r#"{}"#, &module).unwrap();
513        assert_eq!(data.get(0).to_text(), "hi");
514    }
515
516    #[test]
517    fn from_json_null_value() {
518        let module = build_module_for_json(&["name"], &[(0, 0, 0x01, 0x00, &[])]);
519
520        let data = SlotData::from_json(r#"{"name": null}"#, &module).unwrap();
521        assert!(matches!(data.get(0), SlotValue::Null));
522    }
523
524    #[test]
525    fn from_json_bool_values() {
526        let module = build_module_for_json(
527            &["active", "hidden"],
528            &[
529                (0, 0, 0x02, 0x00, &[]), // Bool
530                (1, 1, 0x02, 0x00, &[]), // Bool
531            ],
532        );
533
534        let data = SlotData::from_json(r#"{"active": true, "hidden": false}"#, &module).unwrap();
535        assert!(matches!(data.get(0), SlotValue::Bool(true)));
536        assert!(matches!(data.get(1), SlotValue::Bool(false)));
537    }
538
539    #[test]
540    fn from_json_array() {
541        let module = build_module_for_json(
542            &["items"],
543            &[(0, 0, 0x04, 0x00, &[])], // Array
544        );
545
546        let data = SlotData::from_json(r#"{"items": ["a", "b", 3]}"#, &module).unwrap();
547        if let SlotValue::Array(arr) = data.get(0) {
548            assert_eq!(arr.len(), 3);
549            assert_eq!(arr[0].to_text(), "a");
550            assert_eq!(arr[1].to_text(), "b");
551            assert_eq!(arr[2].to_text(), "3");
552        } else {
553            panic!("expected Array, got {:?}", data.get(0));
554        }
555    }
556
557    #[test]
558    fn from_json_nested_object() {
559        let module = build_module_for_json(
560            &["config"],
561            &[(0, 0, 0x05, 0x00, &[])], // Object
562        );
563
564        let data = SlotData::from_json(r#"{"config": {"key": "value", "n": 7}}"#, &module).unwrap();
565        if let SlotValue::Object(pairs) = data.get(0) {
566            assert_eq!(pairs.len(), 2);
567            assert_eq!(pairs[0].0, "key");
568            assert_eq!(pairs[0].1.to_text(), "value");
569            assert_eq!(pairs[1].0, "n");
570            assert_eq!(pairs[1].1.to_text(), "7");
571        } else {
572            panic!("expected Object, got {:?}", data.get(0));
573        }
574    }
575
576    #[test]
577    fn from_json_unknown_key_ignored() {
578        let module = build_module_for_json(&["title"], &[(0, 0, 0x01, 0x00, &[])]);
579
580        // "extra_key" is not in the slot table — should be silently ignored
581        let data =
582            SlotData::from_json(r#"{"title": "Hi", "extra_key": "ignored"}"#, &module).unwrap();
583        assert_eq!(data.get(0).to_text(), "Hi");
584    }
585
586    #[test]
587    fn from_json_invalid_json() {
588        let module = build_module_for_json(&["x"], &[(0, 0, 0x01, 0x00, &[])]);
589
590        let result = SlotData::from_json(r#"not valid json"#, &module);
591        assert!(result.is_err());
592        match result.unwrap_err() {
593            crate::format::IrError::JsonParseError(_) => {} // expected
594            other => panic!("expected JsonParseError, got {other:?}"),
595        }
596    }
597}