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