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, ChartChannel, 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 mut x_values = Vec::new();
172    let mut y_values = Vec::new();
173    if let Some(view) = args.get(1).and_then(|nb| nb.as_any_array()) {
174        let arr = view.to_generic();
175        for item in arr.iter() {
176            if let Some(inner) = item.as_any_array() {
177                let inner = inner.to_generic();
178                if inner.len() >= 2 {
179                    if let (Some(x), Some(y)) =
180                        (inner[0].as_number_coerce(), inner[1].as_number_coerce())
181                    {
182                        x_values.push(x);
183                        y_values.push(y);
184                    }
185                }
186            }
187        }
188    }
189    match node {
190        ContentNode::Chart(mut spec) => {
191            // Add x channel if not already present
192            if spec.channel("x").is_none() && !x_values.is_empty() {
193                spec.channels.push(ChartChannel {
194                    name: "x".to_string(),
195                    label: "x".to_string(),
196                    values: x_values,
197                    color: None,
198                });
199            }
200            spec.channels.push(ChartChannel {
201                name: "y".to_string(),
202                label,
203                values: y_values,
204                color: None,
205            });
206            Ok(ValueWord::from_content(ContentNode::Chart(spec)))
207        }
208        other => Ok(ValueWord::from_content(other)),
209    }
210}
211
212fn handle_title(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
213    let node = extract_content(&receiver)?;
214    let title = require_string_arg(&args, 0, "title")?;
215    match node {
216        ContentNode::Chart(mut spec) => {
217            spec.title = Some(title);
218            Ok(ValueWord::from_content(ContentNode::Chart(spec)))
219        }
220        other => Ok(ValueWord::from_content(other)),
221    }
222}
223
224fn handle_x_label(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
225    let node = extract_content(&receiver)?;
226    let label = require_string_arg(&args, 0, "x_label")?;
227    match node {
228        ContentNode::Chart(mut spec) => {
229            spec.x_label = Some(label);
230            Ok(ValueWord::from_content(ContentNode::Chart(spec)))
231        }
232        other => Ok(ValueWord::from_content(other)),
233    }
234}
235
236fn handle_y_label(receiver: ValueWord, args: Vec<ValueWord>) -> Result<ValueWord> {
237    let node = extract_content(&receiver)?;
238    let label = require_string_arg(&args, 0, "y_label")?;
239    match node {
240        ContentNode::Chart(mut spec) => {
241            spec.y_label = Some(label);
242            Ok(ValueWord::from_content(ContentNode::Chart(spec)))
243        }
244        other => Ok(ValueWord::from_content(other)),
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use shape_value::content::ContentTable;
252    use std::sync::Arc;
253
254    fn nb_str(s: &str) -> ValueWord {
255        ValueWord::from_string(Arc::new(s.to_string()))
256    }
257
258    #[test]
259    fn test_call_content_method_lookup() {
260        let node = ValueWord::from_content(ContentNode::plain("hello"));
261        assert!(call_content_method("bold", node.clone(), vec![]).is_some());
262        assert!(call_content_method("italic", node.clone(), vec![]).is_some());
263        assert!(call_content_method("underline", node.clone(), vec![]).is_some());
264        assert!(call_content_method("dim", node.clone(), vec![]).is_some());
265        assert!(call_content_method("unknown", node, vec![]).is_none());
266    }
267
268    #[test]
269    fn test_fg_method() {
270        let node = ValueWord::from_content(ContentNode::plain("text"));
271        let result = handle_fg(node, vec![nb_str("red")]).unwrap();
272        let content = result.as_content().unwrap();
273        match content {
274            ContentNode::Text(st) => {
275                assert_eq!(st.spans[0].style.fg, Some(Color::Named(NamedColor::Red)));
276            }
277            _ => panic!("expected Text"),
278        }
279    }
280
281    #[test]
282    fn test_bg_method() {
283        let node = ValueWord::from_content(ContentNode::plain("text"));
284        let result = handle_bg(node, vec![nb_str("blue")]).unwrap();
285        let content = result.as_content().unwrap();
286        match content {
287            ContentNode::Text(st) => {
288                assert_eq!(st.spans[0].style.bg, Some(Color::Named(NamedColor::Blue)));
289            }
290            _ => panic!("expected Text"),
291        }
292    }
293
294    #[test]
295    fn test_bold_method() {
296        let node = ValueWord::from_content(ContentNode::plain("text"));
297        let result = handle_bold(node, vec![]).unwrap();
298        let content = result.as_content().unwrap();
299        match content {
300            ContentNode::Text(st) => assert!(st.spans[0].style.bold),
301            _ => panic!("expected Text"),
302        }
303    }
304
305    #[test]
306    fn test_italic_method() {
307        let node = ValueWord::from_content(ContentNode::plain("text"));
308        let result = handle_italic(node, vec![]).unwrap();
309        let content = result.as_content().unwrap();
310        match content {
311            ContentNode::Text(st) => assert!(st.spans[0].style.italic),
312            _ => panic!("expected Text"),
313        }
314    }
315
316    #[test]
317    fn test_underline_method() {
318        let node = ValueWord::from_content(ContentNode::plain("text"));
319        let result = handle_underline(node, vec![]).unwrap();
320        let content = result.as_content().unwrap();
321        match content {
322            ContentNode::Text(st) => assert!(st.spans[0].style.underline),
323            _ => panic!("expected Text"),
324        }
325    }
326
327    #[test]
328    fn test_dim_method() {
329        let node = ValueWord::from_content(ContentNode::plain("text"));
330        let result = handle_dim(node, vec![]).unwrap();
331        let content = result.as_content().unwrap();
332        match content {
333            ContentNode::Text(st) => assert!(st.spans[0].style.dim),
334            _ => panic!("expected Text"),
335        }
336    }
337
338    #[test]
339    fn test_border_method_on_table() {
340        let table = ContentNode::Table(ContentTable {
341            headers: vec!["A".into()],
342            rows: vec![vec![ContentNode::plain("1")]],
343            border: BorderStyle::Rounded,
344            max_rows: None,
345            column_types: None,
346            total_rows: None,
347            sortable: false,
348        });
349        let node = ValueWord::from_content(table);
350        let result = handle_border(node, vec![nb_str("heavy")]).unwrap();
351        let content = result.as_content().unwrap();
352        match content {
353            ContentNode::Table(t) => assert_eq!(t.border, BorderStyle::Heavy),
354            _ => panic!("expected Table"),
355        }
356    }
357
358    #[test]
359    fn test_border_method_on_non_table() {
360        let node = ValueWord::from_content(ContentNode::plain("text"));
361        let result = handle_border(node, vec![nb_str("sharp")]).unwrap();
362        let content = result.as_content().unwrap();
363        match content {
364            ContentNode::Text(st) => assert_eq!(st.spans[0].text, "text"),
365            _ => panic!("expected Text passthrough"),
366        }
367    }
368
369    #[test]
370    fn test_max_rows_method() {
371        let table = ContentNode::Table(ContentTable {
372            headers: vec!["X".into()],
373            rows: vec![
374                vec![ContentNode::plain("1")],
375                vec![ContentNode::plain("2")],
376                vec![ContentNode::plain("3")],
377            ],
378            border: BorderStyle::default(),
379            max_rows: None,
380            column_types: None,
381            total_rows: None,
382            sortable: false,
383        });
384        let node = ValueWord::from_content(table);
385        let result = handle_max_rows(node, vec![ValueWord::from_i64(2)]).unwrap();
386        let content = result.as_content().unwrap();
387        match content {
388            ContentNode::Table(t) => assert_eq!(t.max_rows, Some(2)),
389            _ => panic!("expected Table"),
390        }
391    }
392
393    #[test]
394    fn test_parse_color_valid() {
395        assert_eq!(parse_color("red").unwrap(), Color::Named(NamedColor::Red));
396        assert_eq!(
397            parse_color("GREEN").unwrap(),
398            Color::Named(NamedColor::Green)
399        );
400        assert_eq!(parse_color("Blue").unwrap(), Color::Named(NamedColor::Blue));
401    }
402
403    #[test]
404    fn test_parse_color_invalid() {
405        assert!(parse_color("purple").is_err());
406    }
407
408    #[test]
409    fn test_fg_invalid_color() {
410        let node = ValueWord::from_content(ContentNode::plain("text"));
411        let result = handle_fg(node, vec![nb_str("purple")]);
412        assert!(result.is_err());
413    }
414
415    #[test]
416    fn test_style_chaining_via_methods() {
417        let node = ValueWord::from_content(ContentNode::plain("text"));
418        let bold_result = handle_bold(node, vec![]).unwrap();
419        let fg_result = handle_fg(bold_result, vec![nb_str("cyan")]).unwrap();
420        let content = fg_result.as_content().unwrap();
421        match content {
422            ContentNode::Text(st) => {
423                assert!(st.spans[0].style.bold);
424                assert_eq!(st.spans[0].style.fg, Some(Color::Named(NamedColor::Cyan)));
425            }
426            _ => panic!("expected Text"),
427        }
428    }
429
430    #[test]
431    fn test_chart_title_method() {
432        use shape_value::content::{ChartSpec, ChartType};
433        let chart = ContentNode::Chart(ChartSpec {
434            chart_type: ChartType::Line,
435            channels: vec![],
436            x_categories: None,
437            title: None,
438            x_label: None,
439            y_label: None,
440            width: None,
441            height: None,
442            echarts_options: None,
443            interactive: true,
444        });
445        let node = ValueWord::from_content(chart);
446        let result = handle_title(node, vec![nb_str("Revenue")]).unwrap();
447        let content = result.as_content().unwrap();
448        match content {
449            ContentNode::Chart(spec) => assert_eq!(spec.title.as_deref(), Some("Revenue")),
450            _ => panic!("expected Chart"),
451        }
452    }
453
454    #[test]
455    fn test_chart_x_label_method() {
456        use shape_value::content::{ChartSpec, ChartType};
457        let chart = ContentNode::Chart(ChartSpec {
458            chart_type: ChartType::Bar,
459            channels: vec![],
460            x_categories: None,
461            title: None,
462            x_label: None,
463            y_label: None,
464            width: None,
465            height: None,
466            echarts_options: None,
467            interactive: true,
468        });
469        let node = ValueWord::from_content(chart);
470        let result = handle_x_label(node, vec![nb_str("Time")]).unwrap();
471        let content = result.as_content().unwrap();
472        match content {
473            ContentNode::Chart(spec) => assert_eq!(spec.x_label.as_deref(), Some("Time")),
474            _ => panic!("expected Chart"),
475        }
476    }
477
478    #[test]
479    fn test_chart_y_label_method() {
480        use shape_value::content::{ChartSpec, ChartType};
481        let chart = ContentNode::Chart(ChartSpec {
482            chart_type: ChartType::Line,
483            channels: vec![],
484            x_categories: None,
485            title: None,
486            x_label: None,
487            y_label: None,
488            width: None,
489            height: None,
490            echarts_options: None,
491            interactive: true,
492        });
493        let node = ValueWord::from_content(chart);
494        let result = handle_y_label(node, vec![nb_str("Value")]).unwrap();
495        let content = result.as_content().unwrap();
496        match content {
497            ContentNode::Chart(spec) => assert_eq!(spec.y_label.as_deref(), Some("Value")),
498            _ => panic!("expected Chart"),
499        }
500    }
501
502    #[test]
503    fn test_chart_series_method() {
504        use shape_value::content::{ChartSpec, ChartType};
505        let chart = ContentNode::Chart(ChartSpec {
506            chart_type: ChartType::Line,
507            channels: vec![],
508            x_categories: None,
509            title: None,
510            x_label: None,
511            y_label: None,
512            width: None,
513            height: None,
514            echarts_options: None,
515            interactive: true,
516        });
517        let node = ValueWord::from_content(chart);
518        let data_points = ValueWord::from_array(Arc::new(vec![
519            ValueWord::from_array(Arc::new(vec![
520                ValueWord::from_f64(1.0),
521                ValueWord::from_f64(10.0),
522            ])),
523            ValueWord::from_array(Arc::new(vec![
524                ValueWord::from_f64(2.0),
525                ValueWord::from_f64(20.0),
526            ])),
527        ]));
528        let result = handle_series(node, vec![nb_str("Sales"), data_points]).unwrap();
529        let content = result.as_content().unwrap();
530        match content {
531            ContentNode::Chart(spec) => {
532                // x channel + y channel = 2 channels
533                assert_eq!(spec.channels.len(), 2);
534                assert_eq!(spec.channel("x").unwrap().values, vec![1.0, 2.0]);
535                let y = spec.channels_by_name("y");
536                assert_eq!(y.len(), 1);
537                assert_eq!(y[0].label, "Sales");
538                assert_eq!(y[0].values, vec![10.0, 20.0]);
539            }
540            _ => panic!("expected Chart"),
541        }
542    }
543
544    #[test]
545    fn test_chart_method_lookup() {
546        let node = ValueWord::from_content(ContentNode::plain("text"));
547        assert!(call_content_method("title", node.clone(), vec![nb_str("t")]).is_some());
548        assert!(call_content_method("series", node.clone(), vec![]).is_some());
549        assert!(call_content_method("xLabel", node.clone(), vec![nb_str("x")]).is_some());
550        assert!(call_content_method("yLabel", node, vec![nb_str("y")]).is_some());
551    }
552}