1use 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
21pub 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
35pub fn content_table(args: &[ValueWord]) -> Result<ValueWord> {
39 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 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 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
101pub 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
148pub 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
174pub 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 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
226pub 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
253pub fn color_named(name: &str) -> Result<ValueWord> {
261 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
281pub 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
310pub 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
325pub 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
341pub 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}