#![allow(dead_code)]
use crate::preview::mermaid::chart::{
find_header, first_word, Envelope, ParseError, Preamble, Source, TitleSyntax,
};
pub mod time;
use time::Instant;
pub const KEYWORD: &str = "gantt";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Tags {
pub active: bool,
pub done: bool,
pub crit: bool,
pub milestone: bool,
pub vert: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Task {
pub id: String,
pub section: String,
pub name: String,
pub start: Instant,
pub end: Instant,
pub tags: Tags,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Gantt {
pub preamble: Preamble,
pub date_format: String,
pub axis_format: String,
pub tick_interval: String,
pub inclusive_end_dates: bool,
pub top_axis: bool,
pub today_marker: String,
pub excludes: Vec<String>,
pub includes: Vec<String>,
pub weekday: String,
pub weekend: String,
pub sections: Vec<String>,
pub tasks: Vec<Task>,
}
impl Gantt {
pub fn extent(&self) -> Option<(Instant, Instant)> {
let first = self.tasks.first()?;
let mut lo = first.start;
let mut hi = first.end;
for t in &self.tasks {
lo = lo.min(t.start);
hi = hi.max(t.end);
}
Some((lo, hi))
}
}
pub fn is_gantt(src: &str) -> bool {
find_header(src, &[KEYWORD]).is_some()
}
pub fn parse(src: &str) -> Result<Gantt, 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 gantt = Gantt {
weekday: "sunday".to_string(),
weekend: "saturday".to_string(),
..Gantt::default()
};
let mut env = Envelope::new(front_matter_title);
let mut section = String::new();
let mut rows: Vec<(usize, String, String, String)> = Vec::new();
let mut all: Vec<(usize, &str)> = Vec::new();
if !header_rest.is_empty() {
all.push((header_index + 1, header_rest.as_str()));
}
for (i, line) in lines.iter().enumerate().skip(header_index + 1) {
all.push((i + 1, line.as_str()));
}
for (number, line) in all {
if env.read(line, TitleSyntax::RawRestOfLine) {
continue;
}
let t = line.trim();
if t.is_empty() {
continue;
}
if let Some(v) = value_of(t, "dateFormat") {
gantt.date_format = v.to_string();
continue;
}
if let Some(v) = value_of(t, "axisFormat") {
gantt.axis_format = v.to_string();
continue;
}
if let Some(v) = value_of(t, "tickInterval") {
gantt.tick_interval = v.to_string();
continue;
}
if let Some(v) = value_of(t, "excludes") {
merge_tokens(&mut gantt.excludes, v);
continue;
}
if let Some(v) = value_of(t, "includes") {
merge_tokens(&mut gantt.includes, v);
continue;
}
if let Some(v) = value_of(t, "todayMarker") {
gantt.today_marker = v.to_string();
continue;
}
if let Some(v) = value_of(t, "section") {
section = v.trim().to_string();
gantt.sections.push(section.clone());
continue;
}
if let Some(v) = value_of(t, "weekday") {
gantt.weekday = v.trim().to_ascii_lowercase();
continue;
}
if let Some(v) = value_of(t, "weekend") {
gantt.weekend = v.trim().to_ascii_lowercase();
continue;
}
if eq_ci(t, "inclusiveEndDates") {
gantt.inclusive_end_dates = true;
continue;
}
if eq_ci(t, "topAxis") {
gantt.top_axis = true;
continue;
}
if value_of(t, "click").is_some() {
continue;
}
let Some(colon) = t.find(':') else {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
});
};
let name = t[..colon].trim().to_string();
let data: String = t[colon + 1..]
.split(['#', ';'])
.next()
.unwrap_or("")
.to_string();
if name.is_empty() {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: number,
text: t.to_string(),
});
}
rows.push((number, section.clone(), name, data));
}
gantt.preamble = env.preamble;
compile(&mut gantt, &rows)?;
if gantt.tasks.is_empty() {
return Err(ParseError::NoData {
kind: KEYWORD,
wanted: "task",
});
}
Ok(gantt)
}
fn compile(gantt: &mut Gantt, rows: &[(usize, String, String, String)]) -> Result<(), ParseError> {
let format = gantt.date_format.trim().to_string();
let mut auto = 0usize;
struct Raw {
number: usize,
section: String,
name: String,
id: String,
tags: Tags,
start: String,
end: String,
previous: Option<String>,
}
impl HasId for Raw {
fn id(&self) -> &str {
&self.id
}
}
let mut raws: Vec<Raw> = Vec::new();
let mut previous: Option<String> = None;
for (number, section, name, data) in rows {
let mut fields: Vec<String> = data.split(',').map(|s| s.trim().to_string()).collect();
let tags = take_tags(&mut fields);
let (id, start, end) = match fields.len() {
1 => {
auto += 1;
(format!("task{auto}"), String::new(), fields[0].clone())
}
2 => {
auto += 1;
(format!("task{auto}"), fields[0].clone(), fields[1].clone())
}
3 => (fields[0].clone(), fields[1].clone(), fields[2].clone()),
_ => {
return Err(ParseError::Unexpected {
kind: KEYWORD,
line: *number,
text: data.trim().to_string(),
})
}
};
raws.push(Raw {
number: *number,
section: section.clone(),
name: name.clone(),
id: id.clone(),
tags,
start,
end,
previous: previous.clone(),
});
previous = Some(id);
}
let mut resolved: Vec<Option<(Instant, Instant)>> = vec![None; raws.len()];
for _ in 0..=raws.len().min(10) {
let mut moved = false;
for i in 0..raws.len() {
let r = &raws[i];
let start = if r.start.is_empty() {
let Some(prev) = r.previous.as_deref() else {
continue;
};
let Some(j) = raws.iter().position(|x| x.id == prev) else {
continue;
};
match resolved[j] {
Some((_, end)) => end,
None => continue,
}
} else {
match start_of(&r.start, &format, &raws, &resolved) {
Some(s) => s,
None => {
if r.start
.trim_start()
.to_ascii_lowercase()
.starts_with("after")
{
continue;
}
return Err(ParseError::Invalid {
line: r.number,
message: format!("`{}` is not a date this chart can read", r.start),
});
}
}
};
let end = match end_of(
&r.end,
start,
&format,
gantt.inclusive_end_dates,
&raws,
&resolved,
) {
Some(e) => e,
None => {
if r.end.trim_start().to_ascii_lowercase().starts_with("until") {
continue;
}
return Err(ParseError::Invalid {
line: r.number,
message: format!(
"`{}` is neither a date nor a duration this chart can read",
r.end
),
});
}
};
let end = if r.tags.milestone { start } else { end };
let end = fix_end(start, end, gantt);
if resolved[i] != Some((start, end)) {
resolved[i] = Some((start, end));
moved = true;
}
}
if !moved {
break;
}
}
for (i, r) in raws.iter().enumerate() {
let Some((start, end)) = resolved[i] else {
return Err(ParseError::Invalid {
line: r.number,
message: format!("task `{}` has no date that can be worked out", r.name),
});
};
gantt.tasks.push(Task {
id: r.id.clone(),
section: r.section.clone(),
name: r.name.clone(),
start,
end: end.max(start),
tags: r.tags,
});
}
Ok(())
}
fn start_of(
text: &str,
format: &str,
raws: &[impl HasId],
resolved: &[Option<(Instant, Instant)>],
) -> Option<Instant> {
let text = text.trim();
if (format == "x" || format == "X")
&& text.chars().all(|c| c.is_ascii_digit())
&& !text.is_empty()
{
return Some(Instant::from_millis(text.parse::<i64>().ok()?));
}
if let Some(ids) = after_ids(text, "after") {
let mut latest: Option<Instant> = None;
for id in ids {
if let Some(j) = raws.iter().position(|r| r.id() == id) {
if let Some((_, end)) = resolved[j] {
latest = Some(match latest {
Some(l) if l >= end => l,
_ => end,
});
}
}
}
return latest;
}
time::parse(text, format)
}
fn end_of(
text: &str,
start: Instant,
format: &str,
inclusive: bool,
raws: &[impl HasId],
resolved: &[Option<(Instant, Instant)>],
) -> Option<Instant> {
let text = text.trim();
if let Some(ids) = after_ids(text, "until") {
let mut earliest: Option<Instant> = None;
for id in ids {
if let Some(j) = raws.iter().position(|r| r.id() == id) {
if let Some((s, _)) = resolved[j] {
earliest = Some(match earliest {
Some(e) if e <= s => e,
_ => s,
});
}
}
}
return earliest;
}
if let Some(d) = time::parse(text, format) {
return Some(if inclusive {
d.add(1, time::Unit::Day)
} else {
d
});
}
let (value, unit) = time::parse_duration(text)?;
Some(start.add_f64(value, unit))
}
fn after_ids<'a>(text: &'a str, keyword: &str) -> Option<Vec<&'a str>> {
let head = text.get(..keyword.len())?;
if !head.eq_ignore_ascii_case(keyword) {
return None;
}
let rest = &text[keyword.len()..];
if !rest.starts_with([' ', '\t']) {
return None;
}
let rest = rest.trim_start();
let end = rest
.find(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-' || c == ' '))
.unwrap_or(rest.len());
Some(rest[..end].split(' ').filter(|s| !s.is_empty()).collect())
}
trait HasId {
fn id(&self) -> &str;
}
fn fix_end(start: Instant, end: Instant, gantt: &Gantt) -> Instant {
if gantt.excludes.is_empty() {
return end;
}
let mut cursor = start.add(1, time::Unit::Day);
let mut end = end;
let limit = end.add(10_000, time::Unit::Day);
while cursor <= end {
if time::is_excluded(cursor, &gantt.excludes, &gantt.includes, &gantt.weekend) {
end = end.add(1, time::Unit::Day);
if end > limit {
break;
}
}
cursor = cursor.add(1, time::Unit::Day);
}
end
}
fn take_tags(fields: &mut Vec<String>) -> Tags {
let mut tags = Tags::default();
while let Some(first) = fields.first().map(|f| f.trim().to_ascii_lowercase()) {
let hit = match first.as_str() {
"active" => &mut tags.active,
"done" => &mut tags.done,
"crit" => &mut tags.crit,
"milestone" => &mut tags.milestone,
"vert" => &mut tags.vert,
_ => break,
};
*hit = true;
fields.remove(0);
}
tags
}
fn merge_tokens(into: &mut Vec<String>, text: &str) {
for token in text
.to_ascii_lowercase()
.split([' ', '\t', ','])
.filter(|t| !t.is_empty())
{
if !into.iter().any(|x| x == token) {
into.push(token.to_string());
}
}
}
fn value_of<'a>(line: &'a str, keyword: &str) -> Option<&'a str> {
let head = line.get(..keyword.len())?;
if !head.eq_ignore_ascii_case(keyword) {
return None;
}
let rest = &line[keyword.len()..];
if !rest.starts_with([' ', '\t']) {
return None;
}
let value = rest.trim_start();
if value.is_empty() {
return None;
}
if keyword.eq_ignore_ascii_case("section") {
return Some(value);
}
Some(value.split(['#', ';']).next().unwrap_or(value).trim_end())
}
fn eq_ci(a: &str, b: &str) -> bool {
a.eq_ignore_ascii_case(b)
}
#[cfg(test)]
mod tests;