use super::{
find_header, first_word, parse_number, read_quoted, Envelope, ParseError, Preamble, Source,
TitleSyntax,
};
pub const KEYWORDS: &[&str] = &["xychart-beta", "xychart"];
pub const KEYWORD: &str = "xychart";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Orientation {
#[default]
Vertical,
Horizontal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlotKind {
Bar,
Line,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Plot {
pub kind: PlotKind,
pub title: String,
pub data: Vec<(String, f64)>,
pub point_labels: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum XAxis {
Band(Vec<String>),
Linear {
min: f64,
max: f64,
},
}
impl Default for XAxis {
fn default() -> XAxis {
XAxis::Band(Vec::new())
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct XyChart {
pub preamble: Preamble,
pub orientation: Orientation,
pub x_title: String,
pub y_title: String,
pub x: XAxis,
pub y: (f64, f64),
pub plots: Vec<Plot>,
}
pub fn is_xychart(src: &str) -> bool {
find_header(src, KEYWORDS).is_some()
}
pub fn parse(src: &str) -> Result<XyChart, ParseError> {
let Some(Source {
lines,
header_index,
header_rest,
front_matter_title,
}) = find_header(src, KEYWORDS)
else {
return Err(ParseError::NotThisChart {
expected: KEYWORD,
header: first_word(src),
});
};
let mut b = Builder {
chart: XyChart::default(),
env: Envelope::new(front_matter_title),
x_set: false,
y_set: false,
y_min: f64::INFINITY,
y_max: f64::NEG_INFINITY,
};
let mut rest = header_rest.as_str();
for (word, orientation) in [
("horizontal", Orientation::Horizontal),
("vertical", Orientation::Vertical),
] {
if let Some(after) = super::strip_ci(rest, word) {
if after.trim().is_empty() || after.starts_with([' ', '\t', ';']) {
b.chart.orientation = orientation;
rest = after.trim();
break;
}
}
}
if !rest.is_empty() {
b.line(rest, header_index + 1)?;
}
for (i, line) in lines.iter().enumerate().skip(header_index + 1) {
b.line(line, i + 1)?;
}
b.finish()
}
struct Builder {
chart: XyChart,
env: Envelope,
x_set: bool,
y_set: bool,
y_min: f64,
y_max: f64,
}
impl Builder {
fn line(&mut self, line: &str, number: usize) -> Result<(), ParseError> {
for stmt in line.split(';') {
self.statement(stmt, number)?;
}
Ok(())
}
fn statement(&mut self, stmt: &str, number: usize) -> Result<(), ParseError> {
if self.env.read(stmt, TitleSyntax::Text) {
return Ok(());
}
let t = stmt.trim();
if t.is_empty() {
return Ok(());
}
if let Some(rest) = keyword(t, "x-axis") {
return self.axis(rest, number, true);
}
if let Some(rest) = keyword(t, "y-axis") {
return self.axis(rest, number, false);
}
if let Some(rest) = keyword(t, "bar") {
return self.plot(PlotKind::Bar, rest, number);
}
if let Some(rest) = keyword(t, "line") {
return self.plot(PlotKind::Line, rest, number);
}
Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
})
}
fn axis(&mut self, rest: &str, number: usize, is_x: bool) -> Result<(), ParseError> {
let (title, tail) = split_title(rest);
if is_x {
self.chart.x_title = title;
} else {
self.chart.y_title = title;
}
let tail = tail.trim();
if tail.is_empty() {
return Ok(());
}
if let Some(inner) = tail.strip_prefix('[') {
if !is_x {
return Err(ParseError::Invalid {
line: number,
message: "a y-axis takes a numeric range, not a list of categories".to_string(),
});
}
let Some(end) = inner.find(']') else {
return Err(ParseError::Invalid {
line: number,
message: "the category list is never closed with `]`".to_string(),
});
};
if !inner[end + 1..].trim().is_empty() {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: inner[end + 1..].trim().to_string(),
});
}
let categories = split_texts(&inner[..end]);
self.chart.x = XAxis::Band(categories);
self.x_set = true;
return Ok(());
}
let Some((lo, hi)) = tail.split_once("-->") else {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: tail.to_string(),
});
};
let lo = number_at(lo.trim(), number)?;
let hi = number_at(hi.trim(), number)?;
if is_x {
self.chart.x = XAxis::Linear { min: lo, max: hi };
self.x_set = true;
} else {
self.chart.y = (lo, hi);
self.y_set = true;
}
Ok(())
}
fn plot(&mut self, kind: PlotKind, rest: &str, number: usize) -> Result<(), ParseError> {
let (title, tail) = split_plot_title(rest);
let tail = tail.trim();
let Some(inner) = tail.strip_prefix('[') else {
return Err(ParseError::Invalid {
line: number,
message: "a plot needs its data in `[ … ]`".to_string(),
});
};
let Some(end) = inner.find(']') else {
return Err(ParseError::Invalid {
line: number,
message: "the plot data is never closed with `]`".to_string(),
});
};
if !inner[end + 1..].trim().is_empty() {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: inner[end + 1..].trim().to_string(),
});
}
let mut values = Vec::new();
let mut labels = Vec::new();
for item in inner[..end].split(',') {
let item = item.trim();
if item.is_empty() {
continue;
}
let chars: Vec<char> = item.chars().collect();
let cut = chars
.iter()
.position(|c| *c == '"' || *c == '\'')
.unwrap_or(chars.len());
let value_text: String = chars[..cut].iter().collect();
values.push(number_at(value_text.trim(), number)?);
labels.push(match read_quoted(&chars, cut) {
Some((text, _)) => text,
None => String::new(),
});
}
if values.is_empty() {
return Err(ParseError::Invalid {
line: number,
message: "a plot needs at least one value".to_string(),
});
}
if !self.x_set {
let len = values.len() as f64;
let (prev_min, prev_max) = match self.chart.x {
XAxis::Linear { min, max } => (min, max),
XAxis::Band(_) => (f64::INFINITY, f64::NEG_INFINITY),
};
self.chart.x = XAxis::Linear {
min: prev_min.min(1.0),
max: prev_max.max(len),
};
self.x_set = true;
}
if let XAxis::Band(categories) = &self.chart.x {
if values.len() > categories.len() {
values.truncate(categories.len());
labels.truncate(categories.len());
}
}
if !self.y_set {
for v in &values {
self.y_min = self.y_min.min(*v);
self.y_max = self.y_max.max(*v);
}
}
let data: Vec<(String, f64)> = match &self.chart.x {
XAxis::Band(categories) => categories
.iter()
.zip(values.iter())
.map(|(c, v)| (c.clone(), *v))
.collect(),
XAxis::Linear { min, max } => {
if values.len() == 1 {
vec![(format_step(*min), values[0])]
} else {
let step = (max - min) / (values.len() as f64 - 1.0);
values
.iter()
.enumerate()
.map(|(i, v)| (format_step(min + i as f64 * step), *v))
.collect()
}
}
};
let has_label = labels.iter().any(|l| !l.is_empty());
self.chart.plots.push(Plot {
kind,
title,
data,
point_labels: if has_label { labels } else { Vec::new() },
});
Ok(())
}
fn finish(mut self) -> Result<XyChart, ParseError> {
self.chart.preamble = self.env.preamble;
if self.chart.plots.is_empty() {
return Err(ParseError::NoData {
kind: KEYWORD,
wanted: "plot",
});
}
if !self.y_set {
self.chart.y = (self.y_min, self.y_max);
}
Ok(self.chart)
}
}
fn keyword<'a>(stmt: &'a str, word: &str) -> Option<&'a str> {
let rest = super::strip_ci(stmt, word)?;
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_whitespace() || c == '[' || c == '"' || c == '\'' => Some(rest),
Some(_) => None,
}
}
fn split_title(rest: &str) -> (String, &str) {
let rest = rest.trim_start();
let chars: Vec<char> = rest.chars().collect();
if let Some((text, after)) = read_quoted(&chars, 0) {
let byte = chars[..after].iter().map(|c| c.len_utf8()).sum::<usize>();
return (text, &rest[byte..]);
}
for (i, c) in rest.char_indices() {
if c == '[' || starts_number(&rest[i..]) {
return (rest[..i].trim_end().to_string(), &rest[i..]);
}
}
(rest.trim_end().to_string(), "")
}
fn split_plot_title(rest: &str) -> (String, &str) {
let rest = rest.trim_start();
let chars: Vec<char> = rest.chars().collect();
if let Some((text, after)) = read_quoted(&chars, 0) {
let byte = chars[..after].iter().map(|c| c.len_utf8()).sum::<usize>();
return (text, &rest[byte..]);
}
match rest.find('[') {
Some(i) => (rest[..i].trim_end().to_string(), &rest[i..]),
None => (rest.trim_end().to_string(), ""),
}
}
fn starts_number(s: &str) -> bool {
let b = s.as_bytes();
let mut i = 0;
if i < b.len() && (b[i] == b'+' || b[i] == b'-') {
i += 1;
}
if i < b.len() && b[i] == b'.' {
i += 1;
}
i < b.len() && b[i].is_ascii_digit()
}
fn number_at(text: &str, line: usize) -> Result<f64, ParseError> {
parse_number(text, true).ok_or_else(|| ParseError::BadNumber {
line,
text: text.to_string(),
why: "expected a number",
})
}
fn split_texts(inner: &str) -> Vec<String> {
let mut out = Vec::new();
for part in split_top_level(inner) {
let part = part.trim();
let chars: Vec<char> = part.chars().collect();
match read_quoted(&chars, 0) {
Some((text, _)) => out.push(text),
None if part.is_empty() => {}
None => out.push(part.to_string()),
}
}
out
}
fn split_top_level(inner: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
for c in inner.chars() {
match quote {
Some(q) => {
cur.push(c);
if c == q {
quote = None;
}
}
None if c == '"' || c == '\'' => {
quote = Some(c);
cur.push(c);
}
None if c == ',' => {
out.push(std::mem::take(&mut cur));
}
None => cur.push(c),
}
}
out.push(cur);
out
}
fn format_step(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 {
format!("{}", v as i64)
} else {
format!("{v}")
}
}