use winnow::ascii::{digit1, space0, space1};
use winnow::combinator::{alt, delimited, opt, preceded};
use winnow::error::{ErrMode, ParserError};
use winnow::token::{take_until, take_while};
use winnow::ModalResult;
use winnow::Parser;
use crate::error::MermaidError;
use crate::types::RenderOptions;
#[derive(Debug, Clone)]
pub struct PieSlice {
pub label: String,
pub value: f64,
}
#[derive(Debug, Clone)]
pub struct PieChart {
pub title: Option<String>,
pub slices: Vec<PieSlice>,
#[allow(dead_code)] pub show_data: bool,
}
#[derive(Debug)]
enum PieLine {
Header { show_data: bool },
Title(String),
Slice { label: String, value: f64 },
Comment,
Empty,
}
fn parse_pie_header(input: &mut &str) -> ModalResult<bool> {
let _ = winnow::ascii::Caseless("pie").parse_next(input)?;
let _ = space0.parse_next(input)?;
let show_data = opt(winnow::ascii::Caseless("showdata"))
.parse_next(input)?
.is_some();
Ok(show_data)
}
fn parse_title_line(input: &mut &str) -> ModalResult<String> {
let _ = winnow::ascii::Caseless("title").parse_next(input)?;
let _ = space1.parse_next(input)?;
let title = take_while(1.., |c| c != '\n').parse_next(input)?;
Ok(title.trim().to_string())
}
fn parse_quoted_string(input: &mut &str) -> ModalResult<String> {
alt((
delimited('"', take_until(0.., "\""), '"'),
delimited('\'', take_until(0.., "'"), '\''),
))
.map(|s: &str| s.to_string())
.parse_next(input)
}
fn parse_number(input: &mut &str) -> ModalResult<f64> {
let int_part = digit1.parse_next(input)?;
let frac_part = opt(preceded('.', digit1)).parse_next(input)?;
let num_str = if let Some(frac) = frac_part {
format!("{}.{}", int_part, frac)
} else {
int_part.to_string()
};
num_str.parse().map_err(|_| ErrMode::from_input(input))
}
fn parse_slice_line(input: &mut &str) -> ModalResult<(String, f64)> {
let _ = space0.parse_next(input)?;
let label = parse_quoted_string.parse_next(input)?;
let _ = space0.parse_next(input)?;
let _ = ':'.parse_next(input)?;
let _ = space0.parse_next(input)?;
let value = parse_number.parse_next(input)?;
Ok((label, value))
}
fn parse_line(line: &str) -> PieLine {
let trimmed = line.trim();
if trimmed.is_empty() {
return PieLine::Empty;
}
if trimmed.starts_with("%%") {
return PieLine::Comment;
}
if let Ok(show_data) = parse_pie_header.parse(trimmed) {
return PieLine::Header { show_data };
}
if let Ok(title) = parse_title_line.parse(trimmed) {
return PieLine::Title(title);
}
if let Ok((label, value)) = parse_slice_line.parse(trimmed) {
return PieLine::Slice { label, value };
}
PieLine::Empty
}
pub fn parse_pie_chart(input: &str) -> Result<PieChart, MermaidError> {
let lines: Vec<&str> = input.lines().collect();
if lines.is_empty() || lines.iter().all(|l| l.trim().is_empty()) {
return Err(MermaidError::EmptyInput);
}
let mut show_data = false;
let mut title = None;
let mut slices = Vec::new();
let mut found_header = false;
for line in lines.iter() {
match parse_line(line) {
PieLine::Header { show_data: sd } => {
if !found_header {
found_header = true;
show_data = sd;
}
}
PieLine::Title(t) => {
title = Some(t);
}
PieLine::Slice { label, value } => {
slices.push(PieSlice { label, value });
}
PieLine::Comment | PieLine::Empty => {}
}
}
if !found_header {
return Err(MermaidError::ParseError {
line: 1,
message: "Expected 'pie' diagram type".to_string(),
suggestion: Some("Start with 'pie' or 'pie showData'".to_string()),
});
}
if slices.is_empty() {
return Err(MermaidError::ParseError {
line: 1,
message: "No pie chart data found".to_string(),
suggestion: Some("Add slices like '\"Chrome\" : 65'".to_string()),
});
}
Ok(PieChart {
title,
slices,
show_data,
})
}
pub fn render_pie_chart(chart: &PieChart, _options: &RenderOptions) -> String {
let mut output = String::new();
let total: f64 = chart.slices.iter().map(|s| s.value).sum();
if total == 0.0 {
return "No data".to_string();
}
if let Some(ref title) = chart.title {
output.push_str(&format!(" {}\n", title));
output.push_str(&format!(" {}\n\n", "─".repeat(title.len())));
}
let max_label_width = chart
.slices
.iter()
.map(|s| s.label.len())
.max()
.unwrap_or(10);
let bar_width = 30;
for slice in &chart.slices {
let percentage = (slice.value / total) * 100.0;
let bar_length = ((percentage / 100.0) * bar_width as f64).round() as usize;
let bar_char = if percentage >= 50.0 {
'█'
} else if percentage >= 25.0 {
'▓'
} else if percentage >= 10.0 {
'▒'
} else {
'░'
};
let bar: String = std::iter::repeat_n(bar_char, bar_length).collect();
let padding: String = " ".repeat(bar_width - bar_length);
output.push_str(&format!(
" {:width$} │{}{}│ {:.0} ({:.1}%)\n",
slice.label,
bar,
padding,
slice.value,
percentage,
width = max_label_width
));
}
output.push_str(&format!(
"\n {:width$} Total: {:.0}\n",
"",
total,
width = max_label_width
));
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pie_simple() {
let input = r#"pie
"Chrome" : 65
"Firefox" : 15
"#;
let chart = parse_pie_chart(input).unwrap();
assert_eq!(chart.slices.len(), 2);
assert_eq!(chart.slices[0].label, "Chrome");
assert_eq!(chart.slices[0].value, 65.0);
}
#[test]
fn test_parse_pie_with_title() {
let input = r#"pie
title Browser Share
"Chrome" : 65
"#;
let chart = parse_pie_chart(input).unwrap();
assert_eq!(chart.title, Some("Browser Share".to_string()));
}
#[test]
fn test_parse_pie_show_data() {
let input = r#"pie showData
"Yes" : 70
"#;
let chart = parse_pie_chart(input).unwrap();
assert!(chart.show_data);
}
#[test]
fn test_render_pie() {
let chart = PieChart {
title: Some("Test".to_string()),
slices: vec![
PieSlice {
label: "A".to_string(),
value: 60.0,
},
PieSlice {
label: "B".to_string(),
value: 40.0,
},
],
show_data: false,
};
let output = render_pie_chart(&chart, &RenderOptions::default());
assert!(output.contains("Test"));
assert!(output.contains("A"));
assert!(output.contains("B"));
assert!(output.contains("60"));
assert!(output.contains("40"));
}
#[test]
fn test_parse_quoted_string() {
assert_eq!(
parse_quoted_string.parse("\"Hello\"").unwrap(),
"Hello".to_string()
);
assert_eq!(
parse_quoted_string.parse("'World'").unwrap(),
"World".to_string()
);
}
#[test]
fn test_parse_number() {
assert_eq!(parse_number.parse("42").unwrap(), 42.0);
assert_eq!(parse_number.parse("3.14").unwrap(), 3.14);
}
#[test]
fn test_parse_slice_line() {
let result = parse_slice_line.parse("\"Chrome\" : 65").unwrap();
assert_eq!(result.0, "Chrome");
assert_eq!(result.1, 65.0);
}
}