Skip to main content

shape_runtime/
content_methods.rs

1//! Content method dispatch for ContentNode instance methods.
2//!
3//! Provides method handler functions for ContentNode values. These follow the
4//! same signature pattern as other method handlers in the codebase: they receive
5//! a receiver (the ContentNode) + args as `Vec<ValueWord>` and return `Result<ValueWord>`.
6//!
7//! Methods:
8//! - Style: `fg(color)`, `bg(color)`, `bold()`, `italic()`, `underline()`, `dim()`
9//! - Table: `border(style)`, `max_rows(n)`
10//! - Chart: `series(label, data)`, `title(s)`, `x_label(s)`, `y_label(s)`
11
12use shape_ast::error::{Result, ShapeError};
13use shape_value::ValueWord;
14use shape_value::content::{BorderStyle, ChartSeries, Color, ContentNode, NamedColor};
15
16/// Look up and call a content method by name.
17///
18/// Returns `Some(result)` if the method was found, `None` if not recognized.
19pub fn call_content_method(
20    method_name: &str,
21    receiver: ValueWord,
22    args: Vec<ValueWord>,
23) -> Option<Result<ValueWord>> {
24    match method_name {
25        // Style methods
26        "fg" => Some(handle_fg(receiver, args)),
27        "bg" => Some(handle_bg(receiver, args)),
28        "bold" => Some(handle_bold(receiver, args)),
29        "italic" => Some(handle_italic(receiver, args)),
30        "underline" => Some(handle_underline(receiver, args)),
31        "dim" => Some(handle_dim(receiver, args)),
32        // Table methods
33        "border" => Some(handle_border(receiver, args)),
34        "max_rows" | "maxRows" => Some(handle_max_rows(receiver, args)),
35        // Chart methods
36        "series" => Some(handle_series(receiver, args)),
37        "title" => Some(handle_title(receiver, args)),
38        "x_label" | "xLabel" => Some(handle_x_label(receiver, args)),
39        "y_label" | "yLabel" => Some(handle_y_label(receiver, args)),
40        _ => None,
41    }
42}
43
44/// Parse a color string into a Color value.
45fn parse_color(s: &str) -> Result<Color> {
46    match s.to_lowercase().as_str() {
47        "red" => Ok(Color::Named(NamedColor::Red)),
48        "green" => Ok(Color::Named(NamedColor::Green)),
49        "blue" => Ok(Color::Named(NamedColor::Blue)),
50        "yellow" => Ok(Color::Named(NamedColor::Yellow)),
51        "magenta" => Ok(Color::Named(NamedColor::Magenta)),
52        "cyan" => Ok(Color::Named(NamedColor::Cyan)),
53        "white" => Ok(Color::Named(NamedColor::White)),
54        "default" => Ok(Color::Named(NamedColor::Default)),
55        other => Err(ShapeError::RuntimeError {
56            message: format!(
57                "Unknown color '{}'. Expected: red, green, blue, yellow, magenta, cyan, white, default",
58                other
59            ),
60            location: None,
61        }),
62    }
63}
64
65/// Extract a ContentNode from the receiver ValueWord.
66fn extract_content(receiver: &ValueWord) -> Result<ContentNode> {
67    receiver
68        .as_content()
69        .cloned()
70        .ok_or_else(|| ShapeError::RuntimeError {
71            message: "Expected a ContentNode receiver".to_string(),
72            location: None,
73        })
74}
75
76/// Extract a required string argument.
77fn require_string_arg(args: &[ValueWord], index: usize, label: &str) -> Result<String> {
78    args.get(index)
79        .and_then(|nb| nb.as_str().map(|s| s.to_string()))
80        .ok_or_else(|| ShapeError::RuntimeError {
81            message: format!("{} requires a string argument", label),
82            location: None,
83        })
84}
85
86fn handle_fg(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
87    let node = extract_content(&receiver)?;
88    let color_name = require_string_arg(&args, 0, "fg")?;
89    let color = parse_color(&color_name)?;
90    Ok(ValueWord::from_content(node.with_fg(color)))
91}
92
93fn handle_bg(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
94    let node = extract_content(&receiver)?;
95    let color_name = require_string_arg(&args, 0, "bg")?;
96    let color = parse_color(&color_name)?;
97    Ok(ValueWord::from_content(node.with_bg(color)))
98}
99
100fn handle_bold(receiver: ValueWord, _args: Vec<ValueWord>) -> Result<ValueWord> {
101    let node = extract_content(&receiver)?;
102    Ok(ValueWord::from_content(node.with_bold()))
103}
104
105fn handle_italic(receiver: ValueWord, _args: Vec<ValueWord>) -> Result<ValueWord> {
106    let node = extract_content(&receiver)?;
107    Ok(ValueWord::from_content(node.with_italic()))
108}
109
110fn handle_underline(receiver: ValueWord, _args: Vec<ValueWord>) -> Result<ValueWord> {
111    let node = extract_content(&receiver)?;
112    Ok(ValueWord::from_content(node.with_underline()))
113}
114
115fn handle_dim(receiver: ValueWord, _args: Vec<ValueWord>) -> Result<ValueWord> {
116    let node = extract_content(&receiver)?;
117    Ok(ValueWord::from_content(node.with_dim()))
118}
119
120fn handle_border(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
121    let node = extract_content(&receiver)?;
122    let style_name = require_string_arg(&args, 0, "border")?;
123    let border = match style_name.to_lowercase().as_str() {
124        "rounded" => BorderStyle::Rounded,
125        "sharp" => BorderStyle::Sharp,
126        "heavy" => BorderStyle::Heavy,
127        "double" => BorderStyle::Double,
128        "minimal" => BorderStyle::Minimal,
129        "none" => BorderStyle::None,
130        other => {
131            return Err(ShapeError::RuntimeError {
132                message: format!(
133                    "Unknown border style '{}'. Expected: rounded, sharp, heavy, double, minimal, none",
134                    other
135                ),
136                location: None,
137            });
138        }
139    };
140    match node {
141        ContentNode::Table(mut table) => {
142            table.border = border;
143            Ok(ValueWord::from_content(ContentNode::Table(table)))
144        }
145        other => Ok(ValueWord::from_content(other)),
146    }
147}
148
149fn handle_max_rows(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
150    let node = extract_content(&receiver)?;
151    let n = args
152        .first()
153        .and_then(|nb| nb.as_number_coerce())
154        .ok_or_else(|| ShapeError::RuntimeError {
155            message: "max_rows requires a numeric argument".to_string(),
156            location: None,
157        })? as usize;
158    match node {
159        ContentNode::Table(mut table) => {
160            table.max_rows = Some(n);
161            Ok(ValueWord::from_content(ContentNode::Table(table)))
162        }
163        other => Ok(ValueWord::from_content(other)),
164    }
165}
166
167fn handle_series(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
168    let node = extract_content(&receiver)?;
169    let label = require_string_arg(&args, 0, "series")?;
170    // Second arg: data as array of [x, y] pairs
171    let data = if let Some(view) = args.get(1).and_then(|nb| nb.as_any_array()) {
172        let arr = view.to_generic();
173        arr.iter()
174            .filter_map(|item| {
175                if let Some(inner) = item.as_any_array() {
176                    let inner = inner.to_generic();
177                    if inner.len() >= 2 {
178                        let x = inner[0].as_number_coerce()?;
179                        let y = inner[1].as_number_coerce()?;
180                        return Some((x, y));
181                    }
182                }
183                None
184            })
185            .collect()
186    } else {
187        vec![]
188    };
189    match node {
190        ContentNode::Chart(mut spec) => {
191            spec.series.push(ChartSeries {
192                label,
193                data,
194                color: None,
195            });
196            Ok(ValueWord::from_content(ContentNode::Chart(spec)))
197        }
198        other => Ok(ValueWord::from_content(other)),
199    }
200}
201
202fn handle_title(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
203    let node = extract_content(&receiver)?;
204    let title = require_string_arg(&args, 0, "title")?;
205    match node {
206        ContentNode::Chart(mut spec) => {
207            spec.title = Some(title);
208            Ok(ValueWord::from_content(ContentNode::Chart(spec)))
209        }
210        other => Ok(ValueWord::from_content(other)),
211    }
212}
213
214fn handle_x_label(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
215    let node = extract_content(&receiver)?;
216    let label = require_string_arg(&args, 0, "x_label")?;
217    match node {
218        ContentNode::Chart(mut spec) => {
219            spec.x_label = Some(label);
220            Ok(ValueWord::from_content(ContentNode::Chart(spec)))
221        }
222        other => Ok(ValueWord::from_content(other)),
223    }
224}
225
226fn handle_y_label(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
227    let node = extract_content(&receiver)?;
228    let label = require_string_arg(&args, 0, "y_label")?;
229    match node {
230        ContentNode::Chart(mut spec) => {
231            spec.y_label = Some(label);
232            Ok(ValueWord::from_content(ContentNode::Chart(spec)))
233        }
234        other => Ok(ValueWord::from_content(other)),
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use shape_value::content::ContentTable;
242    use std::sync::Arc;
243
244    fn nb_str(s: &str) -> ValueWord {
245        ValueWord::from_string(Arc::new(s.to_string()))
246    }
247
248    #[test]
249    fn test_call_content_method_lookup() {
250        let node = ValueWord::from_content(ContentNode::plain("hello"));
251        assert!(call_content_method("bold", node.clone(), vec![]).is_some());
252        assert!(call_content_method("italic", node.clone(), vec![]).is_some());
253        assert!(call_content_method("underline", node.clone(), vec![]).is_some());
254        assert!(call_content_method("dim", node.clone(), vec![]).is_some());
255        assert!(call_content_method("unknown", node, vec![]).is_none());
256    }
257
258    #[test]
259    fn test_fg_method() {
260        let node = ValueWord::from_content(ContentNode::plain("text"));
261        let result = handle_fg(node, vec![nb_str("red")]).unwrap();
262        let content = result.as_content().unwrap();
263        match content {
264            ContentNode::Text(st) => {
265                assert_eq!(st.spans[0].style.fg, Some(Color::Named(NamedColor::Red)));
266            }
267            _ => panic!("expected Text"),
268        }
269    }
270
271    #[test]
272    fn test_bg_method() {
273        let node = ValueWord::from_content(ContentNode::plain("text"));
274        let result = handle_bg(node, vec![nb_str("blue")]).unwrap();
275        let content = result.as_content().unwrap();
276        match content {
277            ContentNode::Text(st) => {
278                assert_eq!(st.spans[0].style.bg, Some(Color::Named(NamedColor::Blue)));
279            }
280            _ => panic!("expected Text"),
281        }
282    }
283
284    #[test]
285    fn test_bold_method() {
286        let node = ValueWord::from_content(ContentNode::plain("text"));
287        let result = handle_bold(node, vec![]).unwrap();
288        let content = result.as_content().unwrap();
289        match content {
290            ContentNode::Text(st) => assert!(st.spans[0].style.bold),
291            _ => panic!("expected Text"),
292        }
293    }
294
295    #[test]
296    fn test_italic_method() {
297        let node = ValueWord::from_content(ContentNode::plain("text"));
298        let result = handle_italic(node, vec![]).unwrap();
299        let content = result.as_content().unwrap();
300        match content {
301            ContentNode::Text(st) => assert!(st.spans[0].style.italic),
302            _ => panic!("expected Text"),
303        }
304    }
305
306    #[test]
307    fn test_underline_method() {
308        let node = ValueWord::from_content(ContentNode::plain("text"));
309        let result = handle_underline(node, vec![]).unwrap();
310        let content = result.as_content().unwrap();
311        match content {
312            ContentNode::Text(st) => assert!(st.spans[0].style.underline),
313            _ => panic!("expected Text"),
314        }
315    }
316
317    #[test]
318    fn test_dim_method() {
319        let node = ValueWord::from_content(ContentNode::plain("text"));
320        let result = handle_dim(node, vec![]).unwrap();
321        let content = result.as_content().unwrap();
322        match content {
323            ContentNode::Text(st) => assert!(st.spans[0].style.dim),
324            _ => panic!("expected Text"),
325        }
326    }
327
328    #[test]
329    fn test_border_method_on_table() {
330        let table = ContentNode::Table(ContentTable {
331            headers: vec!["A".into()],
332            rows: vec![vec![ContentNode::plain("1")]],
333            border: BorderStyle::Rounded,
334            max_rows: None,
335            column_types: None,
336            total_rows: None,
337            sortable: false,
338        });
339        let node = ValueWord::from_content(table);
340        let result = handle_border(node, vec![nb_str("heavy")]).unwrap();
341        let content = result.as_content().unwrap();
342        match content {
343            ContentNode::Table(t) => assert_eq!(t.border, BorderStyle::Heavy),
344            _ => panic!("expected Table"),
345        }
346    }
347
348    #[test]
349    fn test_border_method_on_non_table() {
350        let node = ValueWord::from_content(ContentNode::plain("text"));
351        let result = handle_border(node, vec![nb_str("sharp")]).unwrap();
352        let content = result.as_content().unwrap();
353        match content {
354            ContentNode::Text(st) => assert_eq!(st.spans[0].text, "text"),
355            _ => panic!("expected Text passthrough"),
356        }
357    }
358
359    #[test]
360    fn test_max_rows_method() {
361        let table = ContentNode::Table(ContentTable {
362            headers: vec!["X".into()],
363            rows: vec![
364                vec![ContentNode::plain("1")],
365                vec![ContentNode::plain("2")],
366                vec![ContentNode::plain("3")],
367            ],
368            border: BorderStyle::default(),
369            max_rows: None,
370            column_types: None,
371            total_rows: None,
372            sortable: false,
373        });
374        let node = ValueWord::from_content(table);
375        let result = handle_max_rows(node, vec![ValueWord::from_i64(2)]).unwrap();
376        let content = result.as_content().unwrap();
377        match content {
378            ContentNode::Table(t) => assert_eq!(t.max_rows, Some(2)),
379            _ => panic!("expected Table"),
380        }
381    }
382
383    #[test]
384    fn test_parse_color_valid() {
385        assert_eq!(parse_color("red").unwrap(), Color::Named(NamedColor::Red));
386        assert_eq!(
387            parse_color("GREEN").unwrap(),
388            Color::Named(NamedColor::Green)
389        );
390        assert_eq!(parse_color("Blue").unwrap(), Color::Named(NamedColor::Blue));
391    }
392
393    #[test]
394    fn test_parse_color_invalid() {
395        assert!(parse_color("purple").is_err());
396    }
397
398    #[test]
399    fn test_fg_invalid_color() {
400        let node = ValueWord::from_content(ContentNode::plain("text"));
401        let result = handle_fg(node, vec![nb_str("purple")]);
402        assert!(result.is_err());
403    }
404
405    #[test]
406    fn test_style_chaining_via_methods() {
407        let node = ValueWord::from_content(ContentNode::plain("text"));
408        let bold_result = handle_bold(node, vec![]).unwrap();
409        let fg_result = handle_fg(bold_result, vec![nb_str("cyan")]).unwrap();
410        let content = fg_result.as_content().unwrap();
411        match content {
412            ContentNode::Text(st) => {
413                assert!(st.spans[0].style.bold);
414                assert_eq!(st.spans[0].style.fg, Some(Color::Named(NamedColor::Cyan)));
415            }
416            _ => panic!("expected Text"),
417        }
418    }
419
420    #[test]
421    fn test_chart_title_method() {
422        use shape_value::content::{ChartSpec, ChartType};
423        let chart = ContentNode::Chart(ChartSpec {
424            chart_type: ChartType::Line,
425            series: vec![],
426            title: None,
427            x_label: None,
428            y_label: None,
429            width: None,
430            height: None,
431            echarts_options: None,
432            interactive: true,
433        });
434        let node = ValueWord::from_content(chart);
435        let result = handle_title(node, vec![nb_str("Revenue")]).unwrap();
436        let content = result.as_content().unwrap();
437        match content {
438            ContentNode::Chart(spec) => assert_eq!(spec.title.as_deref(), Some("Revenue")),
439            _ => panic!("expected Chart"),
440        }
441    }
442
443    #[test]
444    fn test_chart_x_label_method() {
445        use shape_value::content::{ChartSpec, ChartType};
446        let chart = ContentNode::Chart(ChartSpec {
447            chart_type: ChartType::Bar,
448            series: vec![],
449            title: None,
450            x_label: None,
451            y_label: None,
452            width: None,
453            height: None,
454            echarts_options: None,
455            interactive: true,
456        });
457        let node = ValueWord::from_content(chart);
458        let result = handle_x_label(node, vec![nb_str("Time")]).unwrap();
459        let content = result.as_content().unwrap();
460        match content {
461            ContentNode::Chart(spec) => assert_eq!(spec.x_label.as_deref(), Some("Time")),
462            _ => panic!("expected Chart"),
463        }
464    }
465
466    #[test]
467    fn test_chart_y_label_method() {
468        use shape_value::content::{ChartSpec, ChartType};
469        let chart = ContentNode::Chart(ChartSpec {
470            chart_type: ChartType::Line,
471            series: vec![],
472            title: None,
473            x_label: None,
474            y_label: None,
475            width: None,
476            height: None,
477            echarts_options: None,
478            interactive: true,
479        });
480        let node = ValueWord::from_content(chart);
481        let result = handle_y_label(node, vec![nb_str("Value")]).unwrap();
482        let content = result.as_content().unwrap();
483        match content {
484            ContentNode::Chart(spec) => assert_eq!(spec.y_label.as_deref(), Some("Value")),
485            _ => panic!("expected Chart"),
486        }
487    }
488
489    #[test]
490    fn test_chart_series_method() {
491        use shape_value::content::{ChartSpec, ChartType};
492        let chart = ContentNode::Chart(ChartSpec {
493            chart_type: ChartType::Line,
494            series: vec![],
495            title: None,
496            x_label: None,
497            y_label: None,
498            width: None,
499            height: None,
500            echarts_options: None,
501            interactive: true,
502        });
503        let node = ValueWord::from_content(chart);
504        let data_points = ValueWord::from_array(Arc::new(vec![
505            ValueWord::from_array(Arc::new(vec![
506                ValueWord::from_f64(1.0),
507                ValueWord::from_f64(10.0),
508            ])),
509            ValueWord::from_array(Arc::new(vec![
510                ValueWord::from_f64(2.0),
511                ValueWord::from_f64(20.0),
512            ])),
513        ]));
514        let result = handle_series(node, vec![nb_str("Sales"), data_points]).unwrap();
515        let content = result.as_content().unwrap();
516        match content {
517            ContentNode::Chart(spec) => {
518                assert_eq!(spec.series.len(), 1);
519                assert_eq!(spec.series[0].label, "Sales");
520                assert_eq!(spec.series[0].data, vec![(1.0, 10.0), (2.0, 20.0)]);
521            }
522            _ => panic!("expected Chart"),
523        }
524    }
525
526    #[test]
527    fn test_chart_method_lookup() {
528        let node = ValueWord::from_content(ContentNode::plain("text"));
529        assert!(call_content_method("title", node.clone(), vec![nb_str("t")]).is_some());
530        assert!(call_content_method("series", node.clone(), vec![]).is_some());
531        assert!(call_content_method("xLabel", node.clone(), vec![nb_str("x")]).is_some());
532        assert!(call_content_method("yLabel", node, vec![nb_str("y")]).is_some());
533    }
534}