#![allow(dead_code)]
use crate::preview::mermaid::chart::{
find_header, first_word, Envelope, ParseError, Preamble, Source, TitleSyntax,
};
pub const KEYWORD: &str = "timeline";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Direction {
#[default]
LeftToRight,
TopToBottom,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Period {
pub section: String,
pub name: String,
pub events: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Timeline {
pub preamble: Preamble,
pub direction: Direction,
pub sections: Vec<String>,
pub periods: Vec<Period>,
}
pub fn is_timeline(src: &str) -> bool {
find_header(src, &[KEYWORD]).is_some()
}
pub fn parse(src: &str) -> Result<Timeline, ParseError> {
let Some(Source {
lines,
header_index,
header_rest,
front_matter_title,
}) = find_header(src, &[KEYWORD])
else {
return Err(ParseError::NotThisChart {
expected: KEYWORD,
header: first_word(src),
});
};
let mut timeline = Timeline::default();
let mut env = Envelope::new(front_matter_title);
let mut rest = header_rest.trim();
if let Some(after) = strip_word(rest, "LR") {
timeline.direction = Direction::LeftToRight;
rest = after;
} else if let Some(after) = strip_word(rest, "TD") {
timeline.direction = Direction::TopToBottom;
rest = after;
}
if !rest.is_empty() {
read_line(&mut timeline, &mut env, rest, header_index + 1)?;
}
for (i, line) in lines.iter().enumerate().skip(header_index + 1) {
read_line(&mut timeline, &mut env, line, i + 1)?;
}
timeline.preamble = env.preamble;
if timeline.periods.is_empty() {
return Err(ParseError::NoData {
kind: KEYWORD,
wanted: "period",
});
}
Ok(timeline)
}
fn strip_word<'a>(rest: &'a str, word: &str) -> Option<&'a str> {
let head = rest.get(..word.len())?;
if !head.eq_ignore_ascii_case(word) {
return None;
}
let after = &rest[word.len()..];
(after.is_empty() || after.starts_with([' ', '\t'])).then(|| after.trim_start())
}
fn strip_comment(line: &str) -> &str {
match line.find('#') {
Some(i) => &line[..i],
None => line,
}
}
fn read_line(
timeline: &mut Timeline,
env: &mut Envelope,
line: &str,
number: usize,
) -> Result<(), ParseError> {
if env.read(line, TitleSyntax::RawRestOfLine) {
return Ok(());
}
let line = strip_comment(line);
let t = line.trim();
if t.is_empty() {
return Ok(());
}
if let Some(rest) = strip_keyword(t, "section") {
let name = rest.split(':').next().unwrap_or("").trim().to_string();
timeline.sections.push(name);
return Ok(());
}
let mut cursor = t;
if !starts_event(cursor) {
let end = cursor.find(':').unwrap_or(cursor.len());
let name = cursor[..end].trim().to_string();
if name.is_empty() {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
});
}
timeline.periods.push(Period {
section: timeline.sections.last().cloned().unwrap_or_default(),
name,
events: Vec::new(),
});
cursor = cursor[end..].trim_start_matches(' ');
}
while !cursor.is_empty() {
if !starts_event(cursor) {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: cursor.trim().to_string(),
});
}
let body = &cursor[1..];
let end = next_event_start(body);
let text = body[..end].trim().to_string();
match timeline.periods.last_mut() {
Some(p) => p.events.push(text),
None => {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
});
}
}
cursor = body[end..].trim_start_matches(' ');
}
Ok(())
}
fn starts_event(s: &str) -> bool {
let mut it = s.chars();
it.next() == Some(':') && it.next().is_some_and(|c| c == ' ' || c == '\t')
}
fn next_event_start(body: &str) -> usize {
let bytes = body.as_bytes();
for (i, b) in bytes.iter().enumerate() {
if *b == b':' && matches!(bytes.get(i + 1), Some(b' ') | Some(b'\t')) {
return i;
}
}
body.len()
}
fn strip_keyword<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
if !line
.get(..keyword.len())
.is_some_and(|h| h.eq_ignore_ascii_case(keyword))
{
return None;
}
let after = &line[keyword.len()..];
after.starts_with([' ', '\t']).then(|| after.trim_start())
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(src: &str) -> Timeline {
parse(src).unwrap_or_else(|e| panic!("{e}\n{src}"))
}
#[test]
fn the_documented_social_media_example_reads_as_four_periods() {
let t = ok("timeline\n title History of Social Media Platform\n 2002 : LinkedIn\n 2004 : Facebook\n : Google\n 2005 : YouTube\n 2006 : Twitter\n");
assert_eq!(
t.preamble.title.as_deref(),
Some("History of Social Media Platform")
);
assert_eq!(t.periods.len(), 4);
assert_eq!(t.periods[1].name, "2004");
assert_eq!(t.periods[1].events, ["Facebook", "Google"]);
}
#[test]
fn two_events_on_one_line_are_two_events() {
let t = ok("timeline\n 2004 : Facebook : Google\n");
assert_eq!(t.periods.len(), 1);
assert_eq!(t.periods[0].events, ["Facebook", "Google"]);
}
#[test]
fn a_colon_not_followed_by_a_space_stays_inside_the_event() {
let t = ok("timeline\n Day 1 : Stand-up at 09:30 sharp\n");
assert_eq!(t.periods[0].events, ["Stand-up at 09:30 sharp"]);
}
#[test]
fn sections_group_the_periods_under_them() {
let t = ok("timeline\n section 17th-20th century\n Industry 1.0 : Steam\n section 21st century\n Industry 4.0 : Internet\n");
assert_eq!(t.sections, ["17th-20th century", "21st century"]);
assert_eq!(t.periods[0].section, "17th-20th century");
assert_eq!(t.periods[1].section, "21st century");
}
#[test]
fn the_direction_is_read_from_the_keyword_line() {
assert_eq!(
ok("timeline TD\n a : b\n").direction,
Direction::TopToBottom
);
assert_eq!(
ok("timeline LR\n a : b\n").direction,
Direction::LeftToRight
);
assert_eq!(ok("timeline\n a : b\n").direction, Direction::LeftToRight);
}
#[test]
fn a_word_beginning_with_td_is_not_the_direction() {
let t = ok("timeline TDX : first\n");
assert_eq!(t.direction, Direction::LeftToRight);
assert_eq!(t.periods[0].name, "TDX");
}
#[test]
fn a_colon_with_no_space_after_it_is_refused_rather_than_guessed() {
assert!(matches!(
parse("timeline\n 2004:Facebook\n"),
Err(ParseError::Unexpected { .. })
));
}
#[test]
fn a_hash_truncates_the_rest_of_the_line() {
let t = ok("timeline\n 2002 : LinkedIn # the first one\n");
assert_eq!(t.periods[0].events, ["LinkedIn"]);
}
#[test]
fn a_period_with_no_events_is_still_a_period() {
let t = ok("timeline\n Prehistory\n 2002 : LinkedIn\n");
assert_eq!(t.periods.len(), 2);
assert!(t.periods[0].events.is_empty());
}
#[test]
fn a_header_with_no_periods_is_refused() {
assert!(matches!(
parse("timeline\n title T\n"),
Err(ParseError::NoData { .. })
));
}
#[test]
fn the_routing_predicate_and_the_parser_agree() {
for src in [
"timeline\n a : b",
"TIMELINE\n a : b",
"timeline TD\n a : b",
"timeline",
"timelines\n a",
"journey\n a: 1",
"",
"%% only a comment\n",
] {
let refused = matches!(parse(src), Err(ParseError::NotThisChart { .. }));
assert_eq!(is_timeline(src), !refused, "{src:?}");
}
}
}