use crate::diagrams::scan::{
split_statement_suffix_hash_or_semi, starts_with_case_insensitive, strip_line_ending,
};
use crate::{
EditorExpectedSyntax, EditorExpectedSyntaxKind, EditorSemanticFacts, EditorSemanticKind,
EditorSemanticSymbol, Error, ParseMetadata, Result, SourceSpan,
};
use serde_json::{Value, json};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TimelineRenderTask {
pub id: i64,
pub section: String,
#[serde(rename = "type")]
pub task_type: String,
pub task: String,
pub score: i64,
#[serde(default)]
pub events: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
pub struct TimelineDiagramRenderModel {
pub title: Option<String>,
#[serde(rename = "accTitle")]
pub acc_title: Option<String>,
#[serde(rename = "accDescr")]
pub acc_descr: Option<String>,
#[serde(default)]
pub sections: Vec<String>,
#[serde(default)]
pub tasks: Vec<TimelineRenderTask>,
}
impl TimelineDiagramRenderModel {
pub(crate) fn sanitize_common_db_fields(&mut self, config: &crate::MermaidConfig) {
crate::common_db::sanitize_optional_title(&mut self.title, config);
crate::common_db::sanitize_optional_acc_title(&mut self.acc_title, config);
crate::common_db::sanitize_optional_acc_descr(&mut self.acc_descr, config);
}
}
#[derive(Debug, Default)]
struct TimelineDb {
title: String,
acc_title: String,
acc_descr: String,
current_section: String,
sections: Vec<String>,
tasks: Vec<TimelineRenderTask>,
next_id: i64,
}
impl TimelineDb {
fn clear(&mut self) {
*self = Self::default();
}
fn add_section(&mut self, txt: &str) {
self.current_section = txt.to_string();
self.sections.push(txt.to_string());
}
fn add_task(&mut self, period: &str) {
let id = self.next_id;
self.next_id += 1;
self.tasks.push(TimelineRenderTask {
id,
section: self.current_section.clone(),
task_type: self.current_section.clone(),
task: period.to_string(),
score: 0,
events: Vec::new(),
});
}
fn add_event(&mut self, event: &str) -> Result<()> {
let Some(last) = self.tasks.last_mut() else {
return Err(Error::diagram_parse_fallback(
"timeline".to_string(),
"event without a preceding task".to_string(),
));
};
last.events.push(event.to_string());
Ok(())
}
}
enum TimelineParseOutput {
Empty,
Model(TimelineDiagramRenderModel),
}
fn parse_keyword_arg_full_line_after_one_ws<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
let t = line.trim_start();
if !starts_with_case_insensitive(t, keyword) {
return None;
}
let after = &t[keyword.len()..];
let ws = after.chars().next()?;
if !ws.is_whitespace() {
return None;
}
Some(&after[ws.len_utf8()..])
}
fn parse_title_value(line: &str) -> Option<String> {
let rest = parse_keyword_arg_full_line_after_one_ws(line, "title")?;
Some(rest.to_string())
}
fn parse_section_value(line: &str) -> Option<String> {
let rest = parse_keyword_arg_full_line_after_one_ws(line, "section")?;
let end = rest.find(':').unwrap_or(rest.len());
Some(rest[..end].to_string())
}
fn parse_key_colon_value_spanned<'a>(
line: &'a str,
line_start: usize,
key: &str,
) -> Option<SpannedText<'a>> {
let t = line.trim_start();
if !starts_with_case_insensitive(t, key) {
return None;
}
let rest = t[key.len()..].trim_start();
let rest = rest.strip_prefix(':')?;
let value = split_statement_suffix_hash_or_semi(rest).trim();
if value.is_empty() {
return None;
}
let value_rel = line.find(value)?;
Some(SpannedText {
text: value,
start: line_start + value_rel,
end: line_start + value_rel + value.len(),
})
}
fn parse_section_value_spanned<'a>(line: &'a str, line_start: usize) -> Option<SpannedText<'a>> {
let rest = parse_keyword_arg_full_line_after_one_ws(line, "section")?;
let end = rest.find(':').unwrap_or(rest.len());
let value = rest[..end].trim();
if value.is_empty() {
return None;
}
let value_rel = line.find(value)?;
Some(SpannedText {
text: value,
start: line_start + value_rel,
end: line_start + value_rel + value.len(),
})
}
fn push_timeline_payload_fact(
facts: &mut EditorSemanticFacts,
text: &str,
start: usize,
detail: &'static str,
kind: EditorSemanticKind,
) {
let end = start + text.len();
facts.push_expected_syntax(EditorExpectedSyntax::new(
EditorExpectedSyntaxKind::Payload,
SourceSpan::new(start, end),
));
facts.push_symbol(EditorSemanticSymbol::payload(
text.to_string(),
Some(detail.to_string()),
kind,
SourceSpan::new(start, end),
SourceSpan::new(start, end),
));
}
#[derive(Debug, Clone, Copy)]
struct SpannedText<'a> {
text: &'a str,
start: usize,
end: usize,
}
fn parse_key_colon_value_hash_or_semi(line: &str, key: &str) -> Option<String> {
let t = line.trim_start();
if !starts_with_case_insensitive(t, key) {
return None;
}
let rest = t[key.len()..].trim_start();
let rest = rest.strip_prefix(':')?;
Some(split_statement_suffix_hash_or_semi(rest).trim().to_string())
}
struct TimelineLineCursor<'a> {
segments: std::str::SplitInclusive<'a, char>,
offset: usize,
}
impl<'a> TimelineLineCursor<'a> {
fn new(code: &'a str) -> Self {
Self {
segments: code.split_inclusive('\n'),
offset: 0,
}
}
fn next_line(&mut self) -> Option<(&'a str, usize)> {
let segment = self.segments.next()?;
let line_start = self.offset;
self.offset += segment.len();
Some((strip_line_ending(segment), line_start))
}
}
fn parse_acc_descr_block_spanned(
cursor: &mut TimelineLineCursor<'_>,
first_line: &str,
) -> Option<String> {
let t = first_line.trim_start();
if !starts_with_case_insensitive(t, "accDescr") {
return None;
}
let rest = t["accDescr".len()..].trim_start();
let rest = rest.strip_prefix('{')?;
let mut buf = String::new();
if let Some(end) = rest.find('}') {
buf.push_str(&rest[..end]);
return Some(buf.trim().to_string());
}
buf.push_str(rest);
buf.push('\n');
while let Some((line, _line_start)) = cursor.next_line() {
if let Some(end) = line.find('}') {
buf.push_str(&line[..end]);
break;
}
buf.push_str(line);
buf.push('\n');
}
Some(buf.trim().to_string())
}
fn split_events_from_colon_whitespace_spanned(
input: &str,
input_start: usize,
) -> Result<Vec<String>> {
let mut s = input;
let mut s_start = input_start;
let mut out = Vec::new();
while !s.is_empty() {
let Some(colon) = s.find(':') else {
return Err(Error::diagram_parse_exact(
"timeline".to_string(),
format!("invalid event token: {input}"),
SourceSpan::new(s_start, s_start + s.len()),
));
};
if colon != 0 {
return Err(Error::diagram_parse_exact(
"timeline".to_string(),
format!("invalid event token: {input}"),
SourceSpan::new(s_start, s_start + s.len()),
));
}
let after_colon = &s[1..];
let Some(ws) = after_colon.chars().next() else {
return Err(Error::diagram_parse_insertion_point(
"timeline".to_string(),
"invalid event token: missing whitespace after ':'".to_string(),
s_start + 1,
));
};
if !ws.is_whitespace() {
return Err(Error::diagram_parse_insertion_point(
"timeline".to_string(),
"invalid event token: missing whitespace after ':'".to_string(),
s_start + 1,
));
}
s = &after_colon[ws.len_utf8()..];
s_start += 1 + ws.len_utf8();
let mut next_boundary: Option<usize> = None;
for (i, ch) in s.char_indices() {
if ch != ':' {
continue;
}
let Some(next) = s[i + 1..].chars().next() else {
continue;
};
if next.is_whitespace() {
next_boundary = Some(i);
break;
}
}
let (event, rest) = match next_boundary {
Some(i) => (&s[..i], &s[i..]),
None => (s, ""),
};
out.push(event.to_string());
s = rest;
s_start += event.len();
}
Ok(out)
}
pub fn parse_timeline(code: &str, meta: &ParseMetadata) -> Result<Value> {
match parse_timeline_model(code, meta)? {
TimelineParseOutput::Empty => Ok(json!({})),
TimelineParseOutput::Model(model) => Ok(json!({
"type": meta.diagram_type,
"title": model.title,
"accTitle": model.acc_title,
"accDescr": model.acc_descr,
"sections": model.sections,
"tasks": model.tasks,
})),
}
}
pub fn parse_timeline_model_for_render(
code: &str,
meta: &ParseMetadata,
) -> Result<TimelineDiagramRenderModel> {
match parse_timeline_model(code, meta)? {
TimelineParseOutput::Empty => Ok(TimelineDiagramRenderModel::default()),
TimelineParseOutput::Model(model) => Ok(model),
}
}
pub fn parse_timeline_editor_facts(code: &str, _meta: &ParseMetadata) -> EditorSemanticFacts {
let mut facts = EditorSemanticFacts::new();
let mut lines = code.split_inclusive('\n').peekable();
let mut offset = 0usize;
let mut header_seen = false;
while let Some(segment) = lines.next() {
let line_start = offset;
offset += segment.len();
let line = strip_line_ending(segment);
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("%%") {
continue;
}
if !header_seen {
if starts_with_case_insensitive(trimmed, "timeline") {
header_seen = true;
}
continue;
}
if let Some(value) = parse_keyword_arg_full_line_after_one_ws(line, "title") {
facts.push_directive_prefix("title");
push_timeline_payload_fact(
&mut facts,
value,
line_start + line.find(value).unwrap_or(0),
"timeline title",
EditorSemanticKind::String,
);
continue;
}
if let Some(value) = parse_key_colon_value_spanned(line, line_start, "accTitle") {
facts.push_directive_prefix("accTitle");
push_timeline_payload_fact(
&mut facts,
value.text,
value.start,
"timeline accessibility title",
EditorSemanticKind::String,
);
continue;
}
if let Some(value) = parse_key_colon_value_spanned(line, line_start, "accDescr") {
facts.push_directive_prefix("accDescr");
push_timeline_payload_fact(
&mut facts,
value.text,
value.start,
"timeline accessibility description",
EditorSemanticKind::String,
);
continue;
}
if let Some(value) = parse_section_value_spanned(line, line_start) {
facts.push_symbol(EditorSemanticSymbol::outline(
value.text.to_string(),
Some("timeline section".to_string()),
EditorSemanticKind::Namespace,
SourceSpan::new(line_start, line_start + line.len()),
SourceSpan::new(value.start, value.end),
));
continue;
}
let content = line.trim_start();
let content_start = line_start + (line.len() - content.len());
if content.starts_with(':') {
let payload = content[1..].trim_start();
if !payload.is_empty() {
let payload_start = content_start + 1 + content[1..].find(payload).unwrap_or(0);
push_timeline_payload_fact(
&mut facts,
payload,
payload_start,
"timeline event",
EditorSemanticKind::String,
);
}
continue;
}
let colon = content.find(':').unwrap_or(content.len());
let task_name = content[..colon].trim();
if task_name.is_empty() {
continue;
}
let task_start = content_start + content[..colon].find(task_name).unwrap_or(0);
let task_end = task_start + task_name.len();
facts.push_expected_syntax(EditorExpectedSyntax::new(
EditorExpectedSyntaxKind::NodeIdentifier,
SourceSpan::new(task_start, task_end),
));
facts.push_symbol(EditorSemanticSymbol::new(
task_name.to_string(),
Some("timeline task".to_string()),
EditorSemanticKind::Event,
SourceSpan::new(content_start, line_start + line.len()),
SourceSpan::new(task_start, task_end),
));
if colon < content.len() {
let payload = content[colon + 1..].trim_start();
if !payload.is_empty() {
let payload_start =
content_start + colon + 1 + content[colon + 1..].find(payload).unwrap_or(0);
push_timeline_payload_fact(
&mut facts,
payload,
payload_start,
"timeline event",
EditorSemanticKind::String,
);
}
}
}
facts
}
fn parse_timeline_model(code: &str, meta: &ParseMetadata) -> Result<TimelineParseOutput> {
let mut db = TimelineDb::default();
db.clear();
let mut lines = TimelineLineCursor::new(code);
let mut header_seen = false;
while let Some((line, line_start)) = lines.next_line() {
let t = line.trim();
if t.is_empty() {
continue;
}
if !header_seen {
if starts_with_case_insensitive(t, "timeline") {
header_seen = true;
let rest = t["timeline".len()..].trim_start();
if !rest.is_empty() {
return Err(Error::diagram_parse_fallback(
meta.diagram_type.clone(),
"unexpected content after timeline header".to_string(),
));
}
continue;
}
return Err(Error::diagram_parse_fallback(
meta.diagram_type.clone(),
"expected timeline header".to_string(),
));
}
let stripped = line.trim_start();
if stripped.starts_with('#') {
continue;
}
if let Some(v) = parse_title_value(line) {
db.title = v;
continue;
}
if let Some(v) = parse_key_colon_value_hash_or_semi(line, "accTitle") {
db.acc_title = v;
continue;
}
if let Some(v) = parse_key_colon_value_hash_or_semi(line, "accDescr") {
db.acc_descr = v;
continue;
}
if let Some(v) = parse_acc_descr_block_spanned(&mut lines, line) {
db.acc_descr = v;
continue;
}
if let Some(v) = parse_section_value(line) {
db.add_section(&v);
continue;
}
let trimmed = stripped;
let trimmed_start = line_start + line.len().saturating_sub(trimmed.len());
if trimmed.starts_with(':') {
let events = split_events_from_colon_whitespace_spanned(trimmed, trimmed_start)?;
for e in events {
db.add_event(&e)?;
}
continue;
}
let mut end = trimmed.len();
for (i, ch) in trimmed.char_indices() {
if ch == ':' || ch == '#' {
end = i;
break;
}
}
let period = trimmed[..end].to_string();
if period.trim().is_empty() {
continue;
}
db.add_task(&period);
let rest = &trimmed[end..];
if rest.starts_with('#') {
continue;
}
if rest.is_empty() {
continue;
}
if rest.starts_with(':') {
let rest_start = trimmed_start + end;
let events = split_events_from_colon_whitespace_spanned(rest, rest_start)?;
for e in events {
db.add_event(&e)?;
}
continue;
}
return Err(Error::diagram_parse_fallback(
meta.diagram_type.clone(),
format!("unrecognized statement: {trimmed}"),
));
}
if !header_seen {
return Ok(TimelineParseOutput::Empty);
}
Ok(TimelineParseOutput::Model(TimelineDiagramRenderModel {
title: if db.title.is_empty() {
None
} else {
Some(db.title)
},
acc_title: if db.acc_title.is_empty() {
None
} else {
Some(db.acc_title)
},
acc_descr: if db.acc_descr.is_empty() {
None
} else {
Some(db.acc_descr)
},
sections: db.sections,
tasks: db.tasks,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
EditorExpectedSyntaxKind, EditorSemanticKind, EditorSemanticRole, Engine,
ParseDiagnosticSpanKind, ParseOptions, SourceSpan,
};
use futures::executor::block_on;
fn parse(text: &str) -> Value {
let engine = Engine::new();
block_on(engine.parse_diagram(text, ParseOptions::default()))
.unwrap()
.unwrap()
.model
}
#[test]
fn timeline_simple_section_definition() {
let model = parse(
r#"
timeline
section abc-123
"#,
);
assert_eq!(model["sections"][0].as_str().unwrap(), "abc-123");
}
#[test]
fn timeline_section_with_two_tasks() {
let model = parse(
r#"
timeline
section abc-123
task1
task2
"#,
);
let tasks = model["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
for task in tasks {
assert_eq!(task["section"].as_str().unwrap(), "abc-123");
assert!(matches!(task["task"].as_str().unwrap(), "task1" | "task2"));
}
}
#[test]
fn timeline_two_sections_and_two_tasks_each() {
let model = parse(
r#"
timeline
section abc-123
task1
task2
section abc-456
task3
task4
"#,
);
assert_eq!(
model["sections"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>(),
vec!["abc-123".to_string(), "abc-456".to_string()]
);
let tasks = model["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 4);
for t in tasks {
let section = t["section"].as_str().unwrap();
let task = t["task"].as_str().unwrap().trim();
assert!(matches!(section, "abc-123" | "abc-456"));
assert!(matches!(task, "task1" | "task2" | "task3" | "task4"));
if section == "abc-123" {
assert!(matches!(task, "task1" | "task2"));
} else {
assert!(matches!(task, "task3" | "task4"));
}
}
}
#[test]
fn timeline_tasks_and_events() {
let model = parse(
r#"
timeline
section abc-123
task1: event1
task2: event2: event3
"#,
);
let tasks = model["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
for t in tasks {
let task = t["task"].as_str().unwrap().trim();
match task {
"task1" => {
assert_eq!(
t["events"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>(),
vec!["event1".to_string()]
);
}
"task2" => {
assert_eq!(
t["events"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>(),
vec!["event2".to_string(), "event3".to_string()]
);
}
_ => panic!("unexpected task: {task}"),
}
}
}
#[test]
fn timeline_events_support_markdown_link() {
let model = parse(
r#"
timeline
section abc-123
task1: [event1](http://example.com)
task2: event2: event3
"#,
);
let tasks = model["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
for t in tasks {
let task = t["task"].as_str().unwrap().trim();
match task {
"task1" => {
assert_eq!(
t["events"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>(),
vec".to_string()]
);
}
"task2" => {}
_ => panic!("unexpected task: {task}"),
}
}
}
#[test]
fn timeline_multiline_events_are_attached_to_previous_task() {
let model = parse(
r#"
timeline
section abc-123
task1: event1
task2: event2: event3
: event4: event5
"#,
);
let tasks = model["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
for t in tasks {
let task = t["task"].as_str().unwrap().trim();
match task {
"task1" => {
assert_eq!(t["events"].as_array().unwrap().len(), 1);
}
"task2" => {
assert_eq!(
t["events"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>(),
vec![
"event2".to_string(),
"event3".to_string(),
"event4".to_string(),
"event5".to_string()
]
);
}
_ => panic!("unexpected task: {task}"),
}
}
}
#[test]
fn timeline_allows_semicolons_in_title_section_and_events() {
let model = parse(
r#"
timeline
title ;my;title;
section ;a;bc-123;
;ta;sk1;: ;ev;ent1; : ;ev;ent2; : ;ev;ent3;
"#,
);
assert_eq!(model["title"].as_str().unwrap(), ";my;title;");
assert_eq!(model["sections"][0].as_str().unwrap(), ";a;bc-123;");
let tasks = model["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 1);
let events = tasks[0]["events"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>();
assert_eq!(
events,
vec![
";ev;ent1; ".to_string(),
";ev;ent2; ".to_string(),
";ev;ent3;".to_string()
]
);
}
#[test]
fn timeline_allows_hashtags_in_title_section_and_events() {
let model = parse(
r#"
timeline
title #my#title#
section #a#bc-123#
task1: #ev#ent1# : #ev#ent2# : #ev#ent3#
"#,
);
assert_eq!(model["title"].as_str().unwrap(), "#my#title#");
assert_eq!(model["sections"][0].as_str().unwrap(), "#a#bc-123#");
let tasks = model["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0]["task"].as_str().unwrap(), "task1");
let events = tasks[0]["events"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect::<Vec<_>>();
assert_eq!(
events,
vec![
"#ev#ent1# ".to_string(),
"#ev#ent2# ".to_string(),
"#ev#ent3#".to_string()
]
);
}
#[test]
fn timeline_event_missing_space_reports_insertion_point() {
let engine = Engine::new();
let text = "timeline\ntask1:event1\n";
let err = block_on(engine.parse_diagram(text, ParseOptions::default())).unwrap_err();
let Error::DiagramParse { diagnostic, .. } = err else {
panic!("expected timeline parse error");
};
let colon = text.find(':').unwrap();
assert_eq!(
diagnostic.message(),
"invalid event token: missing whitespace after ':'"
);
assert_eq!(
diagnostic.span(),
Some(SourceSpan::new(colon + 1, colon + 1))
);
assert_eq!(
diagnostic.span_kind(),
ParseDiagnosticSpanKind::InsertionPoint
);
}
#[test]
fn timeline_editor_facts_expose_parser_backed_spans() {
let engine = Engine::new();
let text = "timeline\n\
title My timeline\n\
accTitle: My acc title\n\
accDescr: My acc descr\n\
section alpha\n\
task1: event1\n\
task2: event2: event3\n";
let facts = engine
.parse_editor_semantic_facts_with_type_sync("timeline", text, ParseOptions::strict())
.unwrap()
.unwrap();
assert!(facts.directive_prefixes.iter().any(|p| p == "title"));
assert!(facts.directive_prefixes.iter().any(|p| p == "accTitle"));
assert!(facts.directive_prefixes.iter().any(|p| p == "accDescr"));
assert!(facts.symbols.iter().any(|symbol| {
symbol.name == "alpha"
&& symbol.kind == EditorSemanticKind::Namespace
&& symbol.role == EditorSemanticRole::Outline
}));
assert!(
facts
.symbols
.iter()
.any(|symbol| symbol.name == "task1" && symbol.kind == EditorSemanticKind::Event)
);
let task_start = text.find("task1").unwrap();
let event_start = text.find("event1").unwrap();
assert!(facts.expected_syntax.iter().any(|expected| {
expected.kind == EditorExpectedSyntaxKind::NodeIdentifier
&& expected.span == SourceSpan::new(task_start, task_start + "task1".len())
}));
assert!(facts.expected_syntax.iter().any(|expected| {
expected.kind == EditorExpectedSyntaxKind::Payload
&& expected.span == SourceSpan::new(event_start, event_start + "event1".len())
}));
}
}