Skip to main content

shape_runtime/
content_builders.rs

1//! Content namespace builder functions.
2//!
3//! Static constructor functions for creating ContentNode values from the
4//! `Content` namespace. These are intended to be registered as native
5//! functions accessible from Shape code.
6//!
7//! - `Content.text(string)` — plain text node
8//! - `Content.table(headers, rows)` — table node
9//! - `Content.chart(chart_type)` — chart node (empty series)
10//! - `Content.code(language, source)` — code block
11//! - `Content.kv(pairs)` — key-value pairs
12//! - `Content.fragment(parts)` — composition of content nodes
13
14use shape_ast::error::{Result, ShapeError};
15use shape_value::ValueWord;
16use shape_value::content::{
17    BorderStyle, ChartSpec, ChartType, ContentNode, ContentTable, NamedColor,
18};
19use std::sync::Arc;
20
21/// Create a plain text ContentNode.
22///
23/// `Content.text("hello")` → `ContentNode::plain("hello")`
24pub fn content_text(args: &[ValueWord]) -> Result<ValueWord> {
25    let text = args
26        .first()
27        .and_then(|nb| nb.as_str())
28        .ok_or_else(|| ShapeError::RuntimeError {
29            message: "Content.text() requires a string argument".to_string(),
30            location: None,
31        })?;
32    Ok(ValueWord::from_content(ContentNode::plain(text)))
33}
34
35/// Create a table ContentNode.
36///
37/// `Content.table(["Name", "Value"], [["a", "1"], ["b", "2"]])`
38pub fn content_table(args: &[ValueWord]) -> Result<ValueWord> {
39    // First arg: array of header strings
40    let headers_arr = args
41        .first()
42        .and_then(|nb| nb.as_any_array())
43        .ok_or_else(|| ShapeError::RuntimeError {
44            message: "Content.table() requires an array of headers as first argument".to_string(),
45            location: None,
46        })?
47        .to_generic();
48
49    let mut headers = Vec::new();
50    for h in headers_arr.iter() {
51        let s = h.as_str().ok_or_else(|| ShapeError::RuntimeError {
52            message: "Table headers must be strings".to_string(),
53            location: None,
54        })?;
55        headers.push(s.to_string());
56    }
57
58    // Second arg: array of rows (each row is an array of values)
59    let rows_arr = args
60        .get(1)
61        .and_then(|nb| nb.as_any_array())
62        .ok_or_else(|| ShapeError::RuntimeError {
63            message: "Content.table() requires an array of rows as second argument".to_string(),
64            location: None,
65        })?
66        .to_generic();
67
68    let mut rows = Vec::new();
69    for row_nb in rows_arr.iter() {
70        let row_arr = row_nb
71            .as_any_array()
72            .ok_or_else(|| ShapeError::RuntimeError {
73                message: "Each table row must be an array".to_string(),
74                location: None,
75            })?
76            .to_generic();
77        let mut cells = Vec::new();
78        for cell in row_arr.iter() {
79            // Convert each cell to a plain text ContentNode from its Display representation
80            let text = if let Some(s) = cell.as_str() {
81                s.to_string()
82            } else {
83                format!("{}", cell)
84            };
85            cells.push(ContentNode::plain(text));
86        }
87        rows.push(cells);
88    }
89
90    Ok(ValueWord::from_content(ContentNode::Table(ContentTable {
91        headers,
92        rows,
93        border: BorderStyle::default(),
94        max_rows: None,
95        column_types: None,
96        total_rows: None,
97        sortable: false,
98    })))
99}
100
101/// Create a chart ContentNode with empty series.
102///
103/// `Content.chart("line")` or `Content.chart("bar")`
104pub fn content_chart(args: &[ValueWord]) -> Result<ValueWord> {
105    let type_name =
106        args.first()
107            .and_then(|nb| nb.as_str())
108            .ok_or_else(|| ShapeError::RuntimeError {
109                message: "Content.chart() requires a chart type string".to_string(),
110                location: None,
111            })?;
112
113    let chart_type = match type_name.to_lowercase().as_str() {
114        "line" => ChartType::Line,
115        "bar" => ChartType::Bar,
116        "scatter" => ChartType::Scatter,
117        "area" => ChartType::Area,
118        "candlestick" => ChartType::Candlestick,
119        "histogram" => ChartType::Histogram,
120        other => {
121            return Err(ShapeError::RuntimeError {
122                message: format!(
123                    "Unknown chart type '{}'. Expected: line, bar, scatter, area, candlestick, histogram",
124                    other
125                ),
126                location: None,
127            });
128        }
129    };
130
131    Ok(ValueWord::from_content(ContentNode::Chart(ChartSpec {
132        chart_type,
133        series: vec![],
134        title: None,
135        x_label: None,
136        y_label: None,
137        width: None,
138        height: None,
139        echarts_options: None,
140        interactive: true,
141    })))
142}
143
144/// Create a code block ContentNode.
145///
146/// `Content.code("rust", "fn main() {}")` or `Content.code(none, "plain code")`
147pub fn content_code(args: &[ValueWord]) -> Result<ValueWord> {
148    let language = args.first().and_then(|nb| {
149        if nb.is_none() {
150            None
151        } else {
152            nb.as_str().map(|s| s.to_string())
153        }
154    });
155
156    let source =
157        args.get(1)
158            .and_then(|nb| nb.as_str())
159            .ok_or_else(|| ShapeError::RuntimeError {
160                message: "Content.code() requires a source string as second argument".to_string(),
161                location: None,
162            })?;
163
164    Ok(ValueWord::from_content(ContentNode::Code {
165        language,
166        source: source.to_string(),
167    }))
168}
169
170/// Create a key-value ContentNode.
171///
172/// Accepts an array of [key, value] pairs:
173/// `Content.kv([["name", "Alice"], ["age", "30"]])`
174pub fn content_kv(args: &[ValueWord]) -> Result<ValueWord> {
175    let pairs_arr = args
176        .first()
177        .and_then(|nb| nb.as_any_array())
178        .ok_or_else(|| ShapeError::RuntimeError {
179            message: "Content.kv() requires an array of [key, value] pairs".to_string(),
180            location: None,
181        })?
182        .to_generic();
183
184    let mut pairs = Vec::new();
185    for pair_nb in pairs_arr.iter() {
186        let pair_arr = pair_nb
187            .as_any_array()
188            .ok_or_else(|| ShapeError::RuntimeError {
189                message: "Each kv pair must be a [key, value] array".to_string(),
190                location: None,
191            })?
192            .to_generic();
193        let key = pair_arr
194            .first()
195            .and_then(|nb| nb.as_str())
196            .ok_or_else(|| ShapeError::RuntimeError {
197                message: "Key in kv pair must be a string".to_string(),
198                location: None,
199            })?
200            .to_string();
201
202        let value_nb = pair_arr.get(1).ok_or_else(|| ShapeError::RuntimeError {
203            message: "Each kv pair must have both key and value".to_string(),
204            location: None,
205        })?;
206
207        // If the value is already a ContentNode, use it directly; otherwise make plain text
208        let value = if let Some(content) = value_nb.as_content() {
209            content.clone()
210        } else if let Some(s) = value_nb.as_str() {
211            ContentNode::plain(s)
212        } else {
213            ContentNode::plain(format!("{}", value_nb))
214        };
215
216        pairs.push((key, value));
217    }
218
219    Ok(ValueWord::from_content(ContentNode::KeyValue(pairs)))
220}
221
222/// Create a fragment ContentNode (composition of multiple nodes).
223///
224/// `Content.fragment([node1, node2, node3])`
225pub fn content_fragment(args: &[ValueWord]) -> Result<ValueWord> {
226    let parts_arr = args
227        .first()
228        .and_then(|nb| nb.as_any_array())
229        .ok_or_else(|| ShapeError::RuntimeError {
230            message: "Content.fragment() requires an array of content nodes".to_string(),
231            location: None,
232        })?
233        .to_generic();
234
235    let mut parts = Vec::new();
236    for part_nb in parts_arr.iter() {
237        if let Some(content) = part_nb.as_content() {
238            parts.push(content.clone());
239        } else if let Some(s) = part_nb.as_str() {
240            parts.push(ContentNode::plain(s));
241        } else {
242            parts.push(ContentNode::plain(format!("{}", part_nb)));
243        }
244    }
245
246    Ok(ValueWord::from_content(ContentNode::Fragment(parts)))
247}
248
249// ========== Namespace value constructors ==========
250// These produce ValueWord values for Color, Border, ChartType, and Align
251// namespaces. They are designed to be called from the VM when accessing
252// static properties like `Color.red` or `Border.rounded`.
253
254/// Color namespace: `Color.red`, `Color.green`, etc.
255/// Returns a ValueWord string tag that content methods accept as color args.
256pub fn color_named(name: &str) -> Result<ValueWord> {
257    // Validate the color name
258    let _: NamedColor = match name.to_lowercase().as_str() {
259        "red" => NamedColor::Red,
260        "green" => NamedColor::Green,
261        "blue" => NamedColor::Blue,
262        "yellow" => NamedColor::Yellow,
263        "magenta" => NamedColor::Magenta,
264        "cyan" => NamedColor::Cyan,
265        "white" => NamedColor::White,
266        "default" => NamedColor::Default,
267        _ => {
268            return Err(ShapeError::RuntimeError {
269                message: format!("Unknown color name '{}'", name),
270                location: None,
271            });
272        }
273    };
274    Ok(ValueWord::from_string(Arc::new(name.to_lowercase())))
275}
276
277/// Color.rgb(r, g, b) — returns an RGB color string tag "rgb(r,g,b)".
278pub fn color_rgb(args: &[ValueWord]) -> Result<ValueWord> {
279    let r = args
280        .first()
281        .and_then(|nb| nb.as_number_coerce())
282        .ok_or_else(|| ShapeError::RuntimeError {
283            message: "Color.rgb() requires numeric r argument".to_string(),
284            location: None,
285        })? as u8;
286    let g = args
287        .get(1)
288        .and_then(|nb| nb.as_number_coerce())
289        .ok_or_else(|| ShapeError::RuntimeError {
290            message: "Color.rgb() requires numeric g argument".to_string(),
291            location: None,
292        })? as u8;
293    let b = args
294        .get(2)
295        .and_then(|nb| nb.as_number_coerce())
296        .ok_or_else(|| ShapeError::RuntimeError {
297            message: "Color.rgb() requires numeric b argument".to_string(),
298            location: None,
299        })? as u8;
300    Ok(ValueWord::from_string(Arc::new(format!(
301        "rgb({},{},{})",
302        r, g, b
303    ))))
304}
305
306/// Border namespace: `Border.rounded`, `Border.sharp`, etc.
307/// Returns a ValueWord string tag that content methods accept as border args.
308pub fn border_named(name: &str) -> Result<ValueWord> {
309    match name.to_lowercase().as_str() {
310        "rounded" | "sharp" | "heavy" | "double" | "minimal" | "none" => {}
311        _ => {
312            return Err(ShapeError::RuntimeError {
313                message: format!("Unknown border style '{}'", name),
314                location: None,
315            });
316        }
317    }
318    Ok(ValueWord::from_string(Arc::new(name.to_lowercase())))
319}
320
321/// ChartType namespace: `ChartType.line`, `ChartType.bar`, etc.
322/// Returns a ValueWord string tag that content builders accept as chart type args.
323pub fn chart_type_named(name: &str) -> Result<ValueWord> {
324    match name.to_lowercase().as_str() {
325        "line" | "bar" | "scatter" | "area" | "candlestick" | "histogram" => {}
326        _ => {
327            return Err(ShapeError::RuntimeError {
328                message: format!("Unknown chart type '{}'", name),
329                location: None,
330            });
331        }
332    }
333    Ok(ValueWord::from_string(Arc::new(name.to_lowercase())))
334}
335
336/// Align namespace: `Align.left`, `Align.center`, `Align.right`.
337/// Returns a ValueWord string tag for alignment directives.
338pub fn align_named(name: &str) -> Result<ValueWord> {
339    match name.to_lowercase().as_str() {
340        "left" | "center" | "right" => {}
341        _ => {
342            return Err(ShapeError::RuntimeError {
343                message: format!("Unknown alignment '{}'", name),
344                location: None,
345            });
346        }
347    }
348    Ok(ValueWord::from_string(Arc::new(name.to_lowercase())))
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use shape_value::content::ChartType;
355    use std::sync::Arc;
356
357    fn nb_str(s: &str) -> ValueWord {
358        ValueWord::from_string(Arc::new(s.to_string()))
359    }
360
361    #[test]
362    fn test_content_text() {
363        let result = content_text(&[nb_str("hello")]).unwrap();
364        let node = result.as_content().unwrap();
365        assert_eq!(node.to_string(), "hello");
366    }
367
368    #[test]
369    fn test_content_text_missing_arg() {
370        assert!(content_text(&[]).is_err());
371    }
372
373    #[test]
374    fn test_content_table() {
375        let headers = ValueWord::from_array(Arc::new(vec![nb_str("Name"), nb_str("Value")]));
376        let row1 = ValueWord::from_array(Arc::new(vec![nb_str("a"), nb_str("1")]));
377        let rows = ValueWord::from_array(Arc::new(vec![row1]));
378        let result = content_table(&[headers, rows]).unwrap();
379        let node = result.as_content().unwrap();
380        match node {
381            ContentNode::Table(t) => {
382                assert_eq!(t.headers, vec!["Name", "Value"]);
383                assert_eq!(t.rows.len(), 1);
384                assert_eq!(t.border, BorderStyle::Rounded);
385            }
386            _ => panic!("expected Table"),
387        }
388    }
389
390    #[test]
391    fn test_content_chart() {
392        let result = content_chart(&[nb_str("line")]).unwrap();
393        let node = result.as_content().unwrap();
394        match node {
395            ContentNode::Chart(spec) => {
396                assert_eq!(spec.chart_type, ChartType::Line);
397                assert!(spec.series.is_empty());
398            }
399            _ => panic!("expected Chart"),
400        }
401    }
402
403    #[test]
404    fn test_content_chart_invalid_type() {
405        assert!(content_chart(&[nb_str("pie")]).is_err());
406    }
407
408    #[test]
409    fn test_content_code() {
410        let result = content_code(&[nb_str("rust"), nb_str("fn main() {}")]).unwrap();
411        let node = result.as_content().unwrap();
412        match node {
413            ContentNode::Code { language, source } => {
414                assert_eq!(language.as_deref(), Some("rust"));
415                assert_eq!(source, "fn main() {}");
416            }
417            _ => panic!("expected Code"),
418        }
419    }
420
421    #[test]
422    fn test_content_code_no_language() {
423        let result = content_code(&[ValueWord::none(), nb_str("plain text")]).unwrap();
424        let node = result.as_content().unwrap();
425        match node {
426            ContentNode::Code { language, source } => {
427                assert!(language.is_none());
428                assert_eq!(source, "plain text");
429            }
430            _ => panic!("expected Code"),
431        }
432    }
433
434    #[test]
435    fn test_content_kv() {
436        let pair1 = ValueWord::from_array(Arc::new(vec![nb_str("name"), nb_str("Alice")]));
437        let pair2 = ValueWord::from_array(Arc::new(vec![nb_str("age"), nb_str("30")]));
438        let pairs = ValueWord::from_array(Arc::new(vec![pair1, pair2]));
439        let result = content_kv(&[pairs]).unwrap();
440        let node = result.as_content().unwrap();
441        match node {
442            ContentNode::KeyValue(kv) => {
443                assert_eq!(kv.len(), 2);
444                assert_eq!(kv[0].0, "name");
445                assert_eq!(kv[1].0, "age");
446            }
447            _ => panic!("expected KeyValue"),
448        }
449    }
450
451    #[test]
452    fn test_content_fragment() {
453        let n1 = ValueWord::from_content(ContentNode::plain("hello "));
454        let n2 = ValueWord::from_content(ContentNode::plain("world"));
455        let parts = ValueWord::from_array(Arc::new(vec![n1, n2]));
456        let result = content_fragment(&[parts]).unwrap();
457        let node = result.as_content().unwrap();
458        match node {
459            ContentNode::Fragment(parts) => {
460                assert_eq!(parts.len(), 2);
461                assert_eq!(parts[0].to_string(), "hello ");
462                assert_eq!(parts[1].to_string(), "world");
463            }
464            _ => panic!("expected Fragment"),
465        }
466    }
467
468    #[test]
469    fn test_content_fragment_with_string_coercion() {
470        let parts = ValueWord::from_array(Arc::new(vec![nb_str("text")]));
471        let result = content_fragment(&[parts]).unwrap();
472        let node = result.as_content().unwrap();
473        match node {
474            ContentNode::Fragment(parts) => {
475                assert_eq!(parts.len(), 1);
476                assert_eq!(parts[0].to_string(), "text");
477            }
478            _ => panic!("expected Fragment"),
479        }
480    }
481
482    #[test]
483    fn test_color_named_valid() {
484        let result = color_named("red").unwrap();
485        assert_eq!(result.as_str().unwrap(), "red");
486    }
487
488    #[test]
489    fn test_color_named_case_insensitive() {
490        let result = color_named("GREEN").unwrap();
491        assert_eq!(result.as_str().unwrap(), "green");
492    }
493
494    #[test]
495    fn test_color_named_invalid() {
496        assert!(color_named("purple").is_err());
497    }
498
499    #[test]
500    fn test_color_rgb() {
501        let result = color_rgb(&[
502            ValueWord::from_f64(255.0),
503            ValueWord::from_f64(128.0),
504            ValueWord::from_f64(0.0),
505        ])
506        .unwrap();
507        assert_eq!(result.as_str().unwrap(), "rgb(255,128,0)");
508    }
509
510    #[test]
511    fn test_border_named_valid() {
512        assert_eq!(
513            border_named("rounded").unwrap().as_str().unwrap(),
514            "rounded"
515        );
516        assert_eq!(border_named("Heavy").unwrap().as_str().unwrap(), "heavy");
517    }
518
519    #[test]
520    fn test_border_named_invalid() {
521        assert!(border_named("dotted").is_err());
522    }
523
524    #[test]
525    fn test_chart_type_named_valid() {
526        assert_eq!(chart_type_named("line").unwrap().as_str().unwrap(), "line");
527        assert_eq!(chart_type_named("Bar").unwrap().as_str().unwrap(), "bar");
528    }
529
530    #[test]
531    fn test_chart_type_named_invalid() {
532        assert!(chart_type_named("pie").is_err());
533    }
534
535    #[test]
536    fn test_align_named_valid() {
537        assert_eq!(align_named("left").unwrap().as_str().unwrap(), "left");
538        assert_eq!(align_named("Center").unwrap().as_str().unwrap(), "center");
539        assert_eq!(align_named("RIGHT").unwrap().as_str().unwrap(), "right");
540    }
541
542    #[test]
543    fn test_align_named_invalid() {
544        assert!(align_named("justify").is_err());
545    }
546}