use super::{find_header, first_word, parse_number, Envelope, ParseError, Preamble, TitleSyntax};
pub const KEYWORDS: &[&str] = &["sankey-beta", "sankey"];
pub const KEYWORD: &str = "sankey";
#[derive(Debug, Clone, PartialEq)]
pub struct Link {
pub source: usize,
pub target: usize,
pub value: f64,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Sankey {
pub preamble: Preamble,
pub nodes: Vec<String>,
pub links: Vec<Link>,
}
pub fn is_sankey(src: &str) -> bool {
find_header(src, KEYWORDS).is_some()
}
pub fn parse(src: &str) -> Result<Sankey, ParseError> {
let Some(source) = find_header(src, KEYWORDS) else {
return Err(ParseError::NotThisChart {
expected: KEYWORD,
header: first_word(src),
});
};
let mut sankey = Sankey::default();
let mut env = Envelope::new(source.front_matter_title);
if !source.header_rest.is_empty() {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: source.header_index + 1,
text: source.header_rest.clone(),
});
}
let mut record = String::new();
let mut record_line = 0usize;
for (i, line) in source
.lines
.iter()
.enumerate()
.skip(source.header_index + 1)
{
let number = i + 1;
if record.is_empty() {
if line.trim().is_empty() {
continue;
}
if env.read(line, TitleSyntax::Terminal) {
continue;
}
record_line = number;
} else {
record.push('\n');
}
record.push_str(line);
if quotes_balanced(&record) {
let fields = split_record(&record);
read_record(&mut sankey, &fields, record_line)?;
record.clear();
}
}
if !record.is_empty() {
return Err(ParseError::Invalid {
line: record_line,
message: "a quoted field is never closed".to_string(),
});
}
sankey.preamble = env.preamble;
if sankey.links.is_empty() {
return Err(ParseError::NoData {
kind: KEYWORD,
wanted: "link",
});
}
if let Some(cycle) = find_cycle(&sankey) {
return Err(ParseError::Invalid {
line: 0,
message: format!("the flow loops back on itself at `{cycle}`"),
});
}
Ok(sankey)
}
fn read_record(sankey: &mut Sankey, fields: &[String], line: usize) -> Result<(), ParseError> {
if fields.len() != 3 {
return Err(ParseError::Invalid {
line,
message: format!(
"a link is written `source,target,value`; this row has {} field{}",
fields.len(),
if fields.len() == 1 { "" } else { "s" }
),
});
}
let source = node_id(sankey, fields[0].trim());
let target = node_id(sankey, fields[1].trim());
let text = fields[2].trim();
let Some(value) = parse_number(text, true) else {
return Err(ParseError::BadNumber {
line,
text: text.to_string(),
why: "a link's value must be a number",
});
};
if value < 0.0 {
return Err(ParseError::BadNumber {
line,
text: text.to_string(),
why: "a link's value must not be negative",
});
}
if source == target {
return Err(ParseError::Invalid {
line,
message: format!("`{}` flows into itself", sankey.nodes[source]),
});
}
sankey.links.push(Link {
source,
target,
value,
});
Ok(())
}
fn node_id(sankey: &mut Sankey, name: &str) -> usize {
match sankey.nodes.iter().position(|n| n == name) {
Some(i) => i,
None => {
sankey.nodes.push(name.to_string());
sankey.nodes.len() - 1
}
}
}
fn quotes_balanced(text: &str) -> bool {
let b: Vec<char> = text.chars().collect();
let mut i = 0;
let mut open = false;
while i < b.len() {
if b[i] == '"' {
if open && b.get(i + 1) == Some(&'"') {
i += 2;
continue;
}
open = !open;
}
i += 1;
}
!open
}
fn split_record(record: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let chars: Vec<char> = record.chars().collect();
let mut i = 0;
let mut quoted = false;
while i < chars.len() {
let c = chars[i];
if quoted {
if c == '"' {
if chars.get(i + 1) == Some(&'"') {
cur.push('"');
i += 2;
continue;
}
quoted = false;
i += 1;
continue;
}
cur.push(c);
i += 1;
continue;
}
match c {
'"' => {
quoted = true;
i += 1;
}
',' => {
out.push(std::mem::take(&mut cur));
i += 1;
}
_ => {
cur.push(c);
i += 1;
}
}
}
out.push(cur);
out
}
fn find_cycle(sankey: &Sankey) -> Option<String> {
#[derive(Clone, Copy, PartialEq)]
enum Mark {
White,
Grey,
Black,
}
let n = sankey.nodes.len();
let mut mark = vec![Mark::White; n];
let mut stack: Vec<(usize, usize)> = Vec::new();
let out: Vec<Vec<usize>> = (0..n)
.map(|i| {
sankey
.links
.iter()
.filter(|l| l.source == i)
.map(|l| l.target)
.collect()
})
.collect();
for start in 0..n {
if mark[start] != Mark::White {
continue;
}
mark[start] = Mark::Grey;
stack.push((start, 0));
while let Some((node, next)) = stack.pop() {
if next < out[node].len() {
stack.push((node, next + 1));
let to = out[node][next];
match mark[to] {
Mark::Grey => return Some(sankey.nodes[to].clone()),
Mark::White => {
mark[to] = Mark::Grey;
stack.push((to, 0));
}
Mark::Black => {}
}
} else {
mark[node] = Mark::Black;
}
}
}
None
}