use crate::content_renderer::{ContentRenderer, RendererCapabilities};
use shape_value::content::{
BorderStyle, ChartSpec, Color, ContentNode, ContentTable, NamedColor, Style,
};
use std::fmt::Write;
pub struct JsonRenderer;
impl ContentRenderer for JsonRenderer {
fn capabilities(&self) -> RendererCapabilities {
RendererCapabilities {
ansi: false,
unicode: true,
color: true,
interactive: false,
}
}
fn render(&self, content: &ContentNode) -> String {
render_node(content)
}
}
fn render_node(node: &ContentNode) -> String {
match node {
ContentNode::Text(st) => {
let spans: Vec<String> = st
.spans
.iter()
.map(|span| {
let style = render_style(&span.style);
format!(
"{{\"text\":{},\"style\":{}}}",
json_string(&span.text),
style
)
})
.collect();
format!("{{\"type\":\"text\",\"spans\":[{}]}}", spans.join(","))
}
ContentNode::Table(table) => render_table(table),
ContentNode::Code { language, source } => {
let lang = language
.as_deref()
.map(|l| json_string(l))
.unwrap_or_else(|| "null".to_string());
format!(
"{{\"type\":\"code\",\"language\":{},\"source\":{}}}",
lang,
json_string(source)
)
}
ContentNode::Chart(spec) => render_chart(spec),
ContentNode::KeyValue(pairs) => {
let entries: Vec<String> = pairs
.iter()
.map(|(k, v)| {
format!(
"{{\"key\":{},\"value\":{}}}",
json_string(k),
render_node(v)
)
})
.collect();
format!("{{\"type\":\"kv\",\"pairs\":[{}]}}", entries.join(","))
}
ContentNode::Fragment(parts) => {
let children: Vec<String> = parts.iter().map(render_node).collect();
format!(
"{{\"type\":\"fragment\",\"children\":[{}]}}",
children.join(",")
)
}
}
}
fn render_style(style: &Style) -> String {
let mut parts = Vec::new();
if style.bold {
parts.push("\"bold\":true".to_string());
}
if style.italic {
parts.push("\"italic\":true".to_string());
}
if style.underline {
parts.push("\"underline\":true".to_string());
}
if style.dim {
parts.push("\"dim\":true".to_string());
}
if let Some(ref color) = style.fg {
parts.push(format!("\"fg\":{}", render_color(color)));
}
if let Some(ref color) = style.bg {
parts.push(format!("\"bg\":{}", render_color(color)));
}
if parts.is_empty() {
"{}".to_string()
} else {
format!("{{{}}}", parts.join(","))
}
}
fn render_color(color: &Color) -> String {
match color {
Color::Named(named) => json_string(named_to_str(*named)),
Color::Rgb(r, g, b) => format!("{{\"r\":{},\"g\":{},\"b\":{}}}", r, g, b),
}
}
fn named_to_str(color: NamedColor) -> &'static str {
match color {
NamedColor::Red => "red",
NamedColor::Green => "green",
NamedColor::Blue => "blue",
NamedColor::Yellow => "yellow",
NamedColor::Magenta => "magenta",
NamedColor::Cyan => "cyan",
NamedColor::White => "white",
NamedColor::Default => "default",
}
}
fn render_table(table: &ContentTable) -> String {
let headers: Vec<String> = table.headers.iter().map(|h| json_string(h)).collect();
let limit = table.max_rows.unwrap_or(table.rows.len());
let display_rows = &table.rows[..limit.min(table.rows.len())];
let rows: Vec<String> = display_rows
.iter()
.map(|row| {
let cells: Vec<String> = row.iter().map(render_node).collect();
format!("[{}]", cells.join(","))
})
.collect();
let border = match table.border {
BorderStyle::Rounded => "\"rounded\"",
BorderStyle::Sharp => "\"sharp\"",
BorderStyle::Heavy => "\"heavy\"",
BorderStyle::Double => "\"double\"",
BorderStyle::Minimal => "\"minimal\"",
BorderStyle::None => "\"none\"",
};
let max_rows = table
.max_rows
.map(|n| n.to_string())
.unwrap_or_else(|| "null".to_string());
format!(
"{{\"type\":\"table\",\"headers\":[{}],\"rows\":[{}],\"border\":{},\"max_rows\":{},\"total_rows\":{}}}",
headers.join(","),
rows.join(","),
border,
max_rows,
table.rows.len()
)
}
fn render_chart(spec: &ChartSpec) -> String {
let chart_type = chart_type_json_str(spec.chart_type);
let title = spec
.title
.as_deref()
.map(|t| json_string(t))
.unwrap_or_else(|| "null".to_string());
let y_count = spec.channels_by_name("y").len();
let mut parts = vec![
"\"type\":\"chart\"".to_string(),
format!("\"chart_type\":{}", chart_type),
format!("\"title\":{}", title),
format!("\"channel_count\":{}", spec.channels.len()),
format!("\"series_count\":{}", y_count),
];
if let Some(ref xl) = spec.x_label {
parts.push(format!("\"x_label\":{}", json_string(xl)));
}
if let Some(ref yl) = spec.y_label {
parts.push(format!("\"y_label\":{}", json_string(yl)));
}
format!("{{{}}}", parts.join(","))
}
fn chart_type_json_str(ct: shape_value::content::ChartType) -> &'static str {
use shape_value::content::ChartType;
match ct {
ChartType::Line => "\"line\"",
ChartType::Bar => "\"bar\"",
ChartType::Scatter => "\"scatter\"",
ChartType::Area => "\"area\"",
ChartType::Candlestick => "\"candlestick\"",
ChartType::Histogram => "\"histogram\"",
ChartType::BoxPlot => "\"boxplot\"",
ChartType::Heatmap => "\"heatmap\"",
ChartType::Bubble => "\"bubble\"",
}
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c < '\x20' => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use shape_value::content::{ContentTable, NamedColor};
fn renderer() -> JsonRenderer {
JsonRenderer
}
#[test]
fn test_plain_text_json() {
let node = ContentNode::plain("hello");
let output = renderer().render(&node);
assert!(output.contains("\"type\":\"text\""));
assert!(output.contains("\"text\":\"hello\""));
}
#[test]
fn test_styled_text_json() {
let node = ContentNode::plain("bold")
.with_bold()
.with_fg(Color::Named(NamedColor::Red));
let output = renderer().render(&node);
assert!(output.contains("\"bold\":true"));
assert!(output.contains("\"fg\":\"red\""));
}
#[test]
fn test_rgb_color_json() {
let node = ContentNode::plain("rgb").with_fg(Color::Rgb(255, 0, 128));
let output = renderer().render(&node);
assert!(output.contains("\"r\":255"));
assert!(output.contains("\"g\":0"));
assert!(output.contains("\"b\":128"));
}
#[test]
fn test_table_json() {
let table = ContentNode::Table(ContentTable {
headers: vec!["A".into()],
rows: vec![vec![ContentNode::plain("1")]],
border: BorderStyle::Rounded,
max_rows: None,
column_types: None,
total_rows: None,
sortable: false,
});
let output = renderer().render(&table);
assert!(output.contains("\"type\":\"table\""));
assert!(output.contains("\"headers\":[\"A\"]"));
assert!(output.contains("\"border\":\"rounded\""));
assert!(output.contains("\"total_rows\":1"));
}
#[test]
fn test_code_json() {
let code = ContentNode::Code {
language: Some("rust".into()),
source: "fn main() {}".into(),
};
let output = renderer().render(&code);
assert!(output.contains("\"type\":\"code\""));
assert!(output.contains("\"language\":\"rust\""));
assert!(output.contains("\"source\":\"fn main() {}\""));
}
#[test]
fn test_code_no_language_json() {
let code = ContentNode::Code {
language: None,
source: "x".into(),
};
let output = renderer().render(&code);
assert!(output.contains("\"language\":null"));
}
#[test]
fn test_kv_json() {
let kv = ContentNode::KeyValue(vec![("k".into(), ContentNode::plain("v"))]);
let output = renderer().render(&kv);
assert!(output.contains("\"type\":\"kv\""));
assert!(output.contains("\"key\":\"k\""));
}
#[test]
fn test_fragment_json() {
let frag = ContentNode::Fragment(vec![ContentNode::plain("a"), ContentNode::plain("b")]);
let output = renderer().render(&frag);
assert!(output.contains("\"type\":\"fragment\""));
assert!(output.contains("\"children\":["));
}
#[test]
fn test_chart_json() {
let chart = ContentNode::Chart(shape_value::content::ChartSpec {
chart_type: shape_value::content::ChartType::Bar,
channels: vec![],
x_categories: None,
title: Some("Sales".into()),
x_label: None,
y_label: None,
width: None,
height: None,
echarts_options: None,
interactive: true,
});
let output = renderer().render(&chart);
assert!(output.contains("\"chart_type\":\"bar\""));
assert!(output.contains("\"title\":\"Sales\""));
}
#[test]
fn test_json_string_escaping() {
let node = ContentNode::plain("he said \"hello\" \\ \n\t");
let output = renderer().render(&node);
assert!(output.contains("\\\"hello\\\""));
assert!(output.contains("\\\\"));
assert!(output.contains("\\n"));
assert!(output.contains("\\t"));
}
}