use std::collections::HashMap;
use chrono::NaiveDate;
use crate::Error;
use crate::gantt::{GanttDiagram, GanttSection, GanttTask};
use crate::parser::common::strip_inline_comment;
#[derive(Debug, Clone)]
enum StartSpec {
Date(NaiveDate),
After(String),
Implicit,
}
type DurationDays = i64;
#[derive(Debug, Clone)]
struct RawTask {
name: String,
id: Option<String>,
start_spec: StartSpec,
duration: DurationDays,
}
#[derive(Debug, Clone)]
struct RawSection {
name: Option<String>,
tasks: Vec<RawTask>,
}
pub fn parse(src: &str) -> Result<GanttDiagram, Error> {
let (mut diag, raw_sections) = collect_raw(src)?;
let mut resolved_ends: HashMap<String, NaiveDate> = HashMap::new();
for raw_sec in raw_sections {
let mut resolved_tasks: Vec<GanttTask> = Vec::with_capacity(raw_sec.tasks.len());
let mut prev_end: Option<NaiveDate> = None;
for raw in &raw_sec.tasks {
let start = resolve_start(&raw.start_spec, prev_end, &resolved_ends)?;
let end = start + chrono::Duration::days(raw.duration - 1);
if let Some(id) = &raw.id {
if resolved_ends.contains_key(id) {
return Err(Error::ParseError(format!(
"gantt: duplicate task id {id:?}"
)));
}
resolved_ends.insert(id.clone(), end);
}
prev_end = Some(end);
resolved_tasks.push(GanttTask {
name: raw.name.clone(),
id: raw.id.clone(),
start,
end,
});
}
diag.sections.push(GanttSection {
name: raw_sec.name,
tasks: resolved_tasks,
});
}
Ok(diag)
}
fn collect_raw(src: &str) -> Result<(GanttDiagram, Vec<RawSection>), Error> {
let mut diag = GanttDiagram::default();
let mut header_seen = false;
let mut raw_sections: Vec<RawSection> = Vec::new();
let mut current_section: Option<usize> = None;
for raw_line in src.lines() {
let line = strip_inline_comment(raw_line).trim();
if line.is_empty() {
continue;
}
if !header_seen {
if !line.eq_ignore_ascii_case("gantt") {
return Err(Error::ParseError(format!(
"expected `gantt` header, got {line:?}"
)));
}
header_seen = true;
continue;
}
if let Some(rest) = strip_keyword_ci(line, "title") {
diag.title = Some(rest.to_string());
continue;
}
if let Some(rest) = strip_keyword_ci(line, "dateFormat") {
if rest != "YYYY-MM-DD" {
return Err(Error::ParseError(format!(
"gantt: only YYYY-MM-DD dateFormat is supported, got {rest:?}"
)));
}
diag.date_format = rest.to_string();
continue;
}
if let Some(rest) = strip_keyword_ci(line, "axisFormat") {
diag.axis_format = rest.to_string();
continue;
}
if is_ignored_directive(line) {
continue;
}
if let Some(rest) = strip_keyword_ci(line, "section") {
raw_sections.push(RawSection {
name: Some(rest.to_string()),
tasks: Vec::new(),
});
current_section = Some(raw_sections.len() - 1);
continue;
}
let raw_task = parse_task_line(line)?;
let idx = match current_section {
Some(i) => i,
None => {
raw_sections.push(RawSection {
name: None,
tasks: Vec::new(),
});
let i = raw_sections.len() - 1;
current_section = Some(i);
i
}
};
raw_sections[idx].tasks.push(raw_task);
}
if !header_seen {
return Err(Error::ParseError("missing `gantt` header line".to_string()));
}
Ok((diag, raw_sections))
}
fn resolve_start(
spec: &StartSpec,
prev_end: Option<NaiveDate>,
resolved_ends: &HashMap<String, NaiveDate>,
) -> Result<NaiveDate, Error> {
match spec {
StartSpec::Date(d) => Ok(*d),
StartSpec::After(id) => {
let predecessor_end = resolved_ends.get(id).ok_or_else(|| {
Error::ParseError(format!(
"gantt: `after {id}` references unknown or unresolved task id"
))
})?;
Ok(*predecessor_end + chrono::Duration::days(1))
}
StartSpec::Implicit => {
Ok(match prev_end {
Some(e) => e + chrono::Duration::days(1),
None => chrono::Local::now().date_naive(),
})
}
}
}
const STATUS_TAGS: &[&str] = &["done", "active", "crit", "milestone"];
fn parse_task_line(line: &str) -> Result<RawTask, Error> {
let (name_part, spec_part) = line.split_once(':').ok_or_else(|| {
Error::ParseError(format!("gantt: task line has no colon separator: {line:?}"))
})?;
let name = name_part.trim();
if name.is_empty() {
return Err(Error::ParseError(format!(
"gantt: task line has an empty name: {line:?}"
)));
}
let fields: Vec<&str> = spec_part
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.filter(|s| !STATUS_TAGS.contains(&s.to_lowercase().as_str()))
.collect();
parse_spec_fields(name, &fields)
}
fn parse_spec_fields(name: &str, fields: &[&str]) -> Result<RawTask, Error> {
if fields.is_empty() {
return Err(Error::ParseError(format!(
"gantt: task {name:?} has no spec fields (duration required)"
)));
}
let duration = parse_duration(fields.last().expect("non-empty"), name)?;
let prefix = &fields[..fields.len() - 1];
let (id, start_spec) = match prefix {
[] => {
(None, StartSpec::Implicit)
}
[single] => {
if looks_like_date(single) {
(None, StartSpec::Date(parse_date(single, name)?))
} else if let Some(dep_id) = parse_after(single) {
(None, StartSpec::After(dep_id))
} else {
let id = validate_id(single, name)?;
(Some(id), StartSpec::Implicit)
}
}
[first, second] => {
if looks_like_date(second) {
let id = validate_id(first, name)?;
(Some(id), StartSpec::Date(parse_date(second, name)?))
} else if let Some(dep_id) = parse_after(second) {
let id = validate_id(first, name)?;
(Some(id), StartSpec::After(dep_id))
} else {
return Err(Error::ParseError(format!(
"gantt: task {name:?} spec field {second:?} is not a date, `after X`, or duration"
)));
}
}
_ => {
return Err(Error::ParseError(format!(
"gantt: task {name:?} has too many spec fields: {prefix:?}"
)));
}
};
Ok(RawTask {
name: name.to_string(),
id,
start_spec,
duration,
})
}
fn parse_duration(s: &str, task_name: &str) -> Result<DurationDays, Error> {
let (num_str, unit) = if let Some(n) = s.strip_suffix('d') {
(n, 'd')
} else if let Some(n) = s.strip_suffix('w') {
(n, 'w')
} else if let Some(n) = s.strip_suffix('h') {
(n, 'h')
} else {
return Err(Error::ParseError(format!(
"gantt: task {task_name:?} duration {s:?} has no unit suffix (expected d/w/h)"
)));
};
let n: i64 = num_str.parse().map_err(|_| {
Error::ParseError(format!(
"gantt: task {task_name:?} duration {s:?} is not an integer + unit"
))
})?;
if n <= 0 {
return Err(Error::ParseError(format!(
"gantt: task {task_name:?} duration must be > 0, got {n}"
)));
}
let days = match unit {
'd' => n,
'w' => n * 7,
'h' => ((n + 23) / 24).max(1),
_ => unreachable!("unit already validated"),
};
Ok(days)
}
fn looks_like_date(s: &str) -> bool {
if s.len() != 10 {
return false;
}
let b = s.as_bytes();
b[4] == b'-'
&& b[7] == b'-'
&& b[..4].iter().all(u8::is_ascii_digit)
&& b[5..7].iter().all(u8::is_ascii_digit)
&& b[8..10].iter().all(u8::is_ascii_digit)
}
fn parse_date(s: &str, task_name: &str) -> Result<NaiveDate, Error> {
NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| {
Error::ParseError(format!(
"gantt: task {task_name:?} date {s:?} is not a valid YYYY-MM-DD date"
))
})
}
fn parse_after(s: &str) -> Option<String> {
let rest = s
.strip_prefix("after ")
.or_else(|| s.strip_prefix("After "))?;
let id = rest.trim();
if id.is_empty() {
None
} else {
Some(id.to_string())
}
}
fn validate_id(s: &str, task_name: &str) -> Result<String, Error> {
if s.is_empty() {
return Err(Error::ParseError(format!(
"gantt: task {task_name:?} has an empty id field"
)));
}
if !s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return Err(Error::ParseError(format!(
"gantt: task {task_name:?} id {s:?} contains invalid characters (alphanumeric + _ only)"
)));
}
Ok(s.to_string())
}
fn strip_keyword_ci<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
let klen = keyword.len();
if line.len() > klen
&& line[..klen].eq_ignore_ascii_case(keyword)
&& line.as_bytes()[klen].is_ascii_whitespace()
{
Some(line[klen..].trim())
} else {
None
}
}
fn is_ignored_directive(line: &str) -> bool {
let first = line.split_whitespace().next().unwrap_or(line);
matches!(
first.to_lowercase().as_str(),
"excludes" | "includes" | "tickinterval" | "weekday" | "click" | "todaymarker"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
#[test]
fn minimal_gantt_title_and_one_task() {
let src = "gantt\n title My Plan\n dateFormat YYYY-MM-DD\n section Work\n Task :2024-01-01, 10d";
let diag = parse(src).unwrap();
assert_eq!(diag.title.as_deref(), Some("My Plan"));
assert_eq!(diag.sections.len(), 1);
let task = &diag.sections[0].tasks[0];
assert_eq!(task.name, "Task");
assert_eq!(task.start, date(2024, 1, 1));
assert_eq!(task.end, date(2024, 1, 10));
}
#[test]
fn multi_section_diagram() {
let src = "gantt\n\
dateFormat YYYY-MM-DD\n\
section Alpha\n\
A :2024-01-01, 5d\n\
section Beta\n\
B :2024-01-10, 3d\n\
C :2024-01-13, 7d";
let diag = parse(src).unwrap();
assert_eq!(diag.sections.len(), 2);
assert_eq!(diag.sections[0].name.as_deref(), Some("Alpha"));
assert_eq!(diag.sections[1].tasks.len(), 2);
}
#[test]
fn explicit_date_task() {
let src = "gantt\n dateFormat YYYY-MM-DD\n section S\n X :2014-06-15, 20d";
let diag = parse(src).unwrap();
let task = &diag.sections[0].tasks[0];
assert_eq!(task.start, date(2014, 6, 15));
assert_eq!(task.end, date(2014, 7, 4)); }
#[test]
fn after_dependency_resolved() {
let src = "gantt\n\
dateFormat YYYY-MM-DD\n\
section S\n\
Design :d1, 2024-01-01, 10d\n\
Build :after d1, 5d";
let diag = parse(src).unwrap();
let design = &diag.sections[0].tasks[0];
let build = &diag.sections[0].tasks[1];
assert_eq!(design.end, date(2024, 1, 10));
assert_eq!(build.start, date(2024, 1, 11));
assert_eq!(build.end, date(2024, 1, 15));
}
#[test]
fn chained_implicit_start() {
let src = "gantt\n\
dateFormat YYYY-MM-DD\n\
section S\n\
First :2024-03-01, 5d\n\
Second :5d\n\
Third :3d";
let diag = parse(src).unwrap();
let tasks = &diag.sections[0].tasks;
assert_eq!(tasks[0].start, date(2024, 3, 1));
assert_eq!(tasks[0].end, date(2024, 3, 5));
assert_eq!(tasks[1].start, date(2024, 3, 6));
assert_eq!(tasks[1].end, date(2024, 3, 10));
assert_eq!(tasks[2].start, date(2024, 3, 11));
assert_eq!(tasks[2].end, date(2024, 3, 13));
}
#[test]
fn task_id_present_and_absent() {
let src = "gantt\n\
dateFormat YYYY-MM-DD\n\
section S\n\
With id :myid, 2024-05-01, 7d\n\
Without id :2024-05-08, 3d";
let diag = parse(src).unwrap();
let tasks = &diag.sections[0].tasks;
assert_eq!(tasks[0].id.as_deref(), Some("myid"));
assert_eq!(tasks[1].id, None);
}
#[test]
fn comments_stripped() {
let src = "%% leading comment\n\
gantt\n\
%% this is a comment\n\
dateFormat YYYY-MM-DD\n\
section S\n\
Task :2024-01-01, 1d %% inline comment";
let diag = parse(src).unwrap();
assert_eq!(diag.sections[0].tasks.len(), 1);
}
#[test]
fn duration_units_d_and_w() {
let src = "gantt\n\
dateFormat YYYY-MM-DD\n\
section S\n\
Days :2024-02-01, 14d\n\
Weeks :2024-02-15, 2w";
let diag = parse(src).unwrap();
let tasks = &diag.sections[0].tasks;
assert_eq!(tasks[0].end, date(2024, 2, 14));
assert_eq!(tasks[1].end, date(2024, 2, 28));
}
#[test]
fn dependency_on_unknown_id_returns_parse_error() {
let src = "gantt\n\
dateFormat YYYY-MM-DD\n\
section S\n\
Task :after ghost, 5d";
let err = parse(src).unwrap_err();
assert!(
err.to_string().contains("unknown or unresolved"),
"unexpected error: {err}"
);
}
#[test]
fn unsupported_date_format_returns_error() {
let src = "gantt\n dateFormat DD/MM/YYYY\n section S\n Task :01/01/2024, 5d";
let err = parse(src).unwrap_err();
assert!(
err.to_string().contains("only YYYY-MM-DD"),
"unexpected error: {err}"
);
}
#[test]
fn missing_header_returns_error() {
let err = parse("section S\n Task :2024-01-01, 5d").unwrap_err();
assert!(err.to_string().contains("gantt"));
}
#[test]
fn week_unit_arithmetic() {
let src = "gantt\n dateFormat YYYY-MM-DD\n section S\n T :2024-01-01, 1w";
let diag = parse(src).unwrap();
assert_eq!(diag.sections[0].tasks[0].end, date(2024, 1, 7));
}
}