use std::fmt;
use std::ops::ControlFlow;
use comemo::Track;
use log::{debug, trace};
use thiserror::Error;
use typst::ROUTINES;
use typst::World;
use typst::engine::{Route, Sink, Traced};
use typst::foundations::{Content, Datetime, Module, StyleChain, Value};
use typst_library::foundations::SequenceElem;
use typst_library::introspection::MetadataElem;
use typst_library::model::{HeadingElem, ListItem};
#[derive(Debug, Error)]
pub enum EvalError {
#[error("file error: {0}")]
File(String),
#[error("eval error: {0}")]
Eval(String),
#[error("{0}")]
World(String),
#[error("file does not import mindtape package")]
NotMindtape,
}
impl EvalError {
fn from_file_error(err: &typst::diag::FileError) -> Self {
Self::File(err.to_string())
}
fn from_source_diagnostics(errors: &impl fmt::Debug) -> Self {
Self::Eval(format!("{errors:?}"))
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Task {
pub title: String,
pub done: bool,
pub due: Option<Datetime>,
pub start: Option<Datetime>,
pub rank: Option<i64>,
pub tags: Vec<String>,
pub id: Option<String>,
pub position: u32,
pub milestone: Option<String>,
}
#[derive(Debug, Clone)]
pub struct EvalResult {
pub tasks: Vec<Task>,
pub title: Option<String>,
pub bindings: Vec<(String, String, String)>,
pub dependencies: Vec<std::path::PathBuf>,
}
pub fn eval_file(world: &dyn World) -> Result<Vec<Task>, EvalError> {
eval_file_full(world).map(|r| r.tasks)
}
pub fn eval_file_full(world: &dyn World) -> Result<EvalResult, EvalError> {
let source = world
.source(world.main())
.map_err(|err| EvalError::from_file_error(&err))?;
if !has_mindtape_import(source.text()) {
return Err(EvalError::NotMindtape);
}
debug!(
"evaluating {}",
source.id().vpath().as_rooted_path().display()
);
let mut sink = Sink::new();
let traced = Traced::default();
let route = Route::default();
let module: Module = typst_eval::eval(
&ROUTINES,
world.track(),
traced.track(),
sink.track_mut(),
route.track(),
&source,
)
.map_err(|err| EvalError::from_source_diagnostics(&err))?;
let bindings = extract_bindings(module.scope());
trace!("extracted {} bindings", bindings.len());
let content: Content = module.content();
let title = extract_file_title(&content);
let mut tasks = Vec::new();
collect_tasks(&content, &mut tasks);
debug!("found {} tasks, {} bindings", tasks.len(), bindings.len());
Ok(EvalResult {
tasks,
title,
bindings,
dependencies: Vec::new(), })
}
pub fn eval_file_full_with_deps(
world: &crate::world::MindTapeWorld,
) -> Result<EvalResult, EvalError> {
let source = world
.source(world.main())
.map_err(|err| EvalError::from_file_error(&err))?;
if !has_mindtape_import(source.text()) {
return Err(EvalError::NotMindtape);
}
debug!(
"evaluating (with deps) {}",
source.id().vpath().as_rooted_path().display()
);
let mut sink = Sink::new();
let traced = Traced::default();
let route = Route::default();
let world_dyn: &dyn World = world;
let module: Module = typst_eval::eval(
&ROUTINES,
world_dyn.track(),
traced.track(),
sink.track_mut(),
route.track(),
&source,
)
.map_err(|err| EvalError::from_source_diagnostics(&err))?;
let bindings = extract_bindings(module.scope());
let content: Content = module.content();
let title = extract_file_title(&content);
let mut tasks = Vec::new();
collect_tasks(&content, &mut tasks);
let dependencies = world.get_dependencies()?;
debug!(
"found {} tasks, {} bindings, {} deps",
tasks.len(),
bindings.len(),
dependencies.len()
);
Ok(EvalResult {
tasks,
title,
bindings,
dependencies,
})
}
pub fn collect_tasks(content: &Content, tasks: &mut Vec<Task>) {
let mut position: u32 = 0;
let mut heading_stack: Vec<(usize, String)> = Vec::new();
let _ = content.traverse(&mut |node: Content| -> ControlFlow<()> {
if let Some(heading) = node.to_packed::<HeadingElem>() {
let depth = heading.depth.get(StyleChain::default()).get();
let text = heading.body.plain_text().trim().to_string();
heading_stack.retain(|(d, _)| *d < depth);
heading_stack.push((depth, text));
} else if let Some(item) = node.to_packed::<ListItem>()
&& let Some(mut task) = extract_task(item)
{
task.position = position;
task.milestone = if heading_stack.is_empty() {
None
} else {
Some(
heading_stack
.iter()
.map(|(_, t)| t.as_str())
.collect::<Vec<_>>()
.join(" > "),
)
};
position += 1;
tasks.push(task);
}
ControlFlow::Continue(())
});
}
fn plain_text_shallow(content: &Content) -> String {
if let Some(seq) = content.to_packed::<SequenceElem>() {
let mut text = String::new();
for child in &seq.children {
if child.to_packed::<ListItem>().is_none() {
text.push_str(&child.plain_text());
}
}
text
} else {
content.plain_text().into()
}
}
#[allow(clippy::indexing_slicing)]
pub fn extract_task(item: &ListItem) -> Option<Task> {
let body: &Content = &item.body;
let text = plain_text_shallow(body);
let (done, title) = if let Some(rest) = text
.strip_prefix("[x] ")
.or_else(|| text.strip_prefix("[X] "))
{
(true, rest.trim().to_string())
} else if let Some(rest) = text.strip_prefix("[ ] ") {
(false, rest.trim().to_string())
} else {
return None;
};
let mut due: Option<Datetime> = None;
let mut start: Option<Datetime> = None;
let mut rank: Option<i64> = None;
let mut tags: Vec<String> = Vec::new();
let mut id: Option<String> = None;
let _ = body.traverse(&mut |node: Content| -> ControlFlow<()> {
if let Some(meta) = node.to_packed::<MetadataElem>() {
let value = meta.value.clone();
if let Value::Array(arr) = value {
let slice = arr.as_slice();
if slice.len() == 2
&& let Value::Str(key) = &slice[0]
{
match key.as_str() {
"mindtape.due" => {
if let Value::Datetime(dt) = &slice[1] {
due = Some(*dt);
}
}
"mindtape.start" => {
if let Value::Datetime(dt) = &slice[1] {
start = Some(*dt);
}
}
"mindtape.rank" => {
if let Value::Int(n) = &slice[1] {
rank = Some(*n);
}
}
"mindtape.tag" => {
if let Value::Str(name) = &slice[1] {
tags.push(name.to_string());
}
}
"mindtape.id" => {
if let Value::Str(s) = &slice[1] {
id = Some(s.to_string());
}
}
_ => {}
}
}
}
}
ControlFlow::Continue(())
});
Some(Task {
title,
done,
due,
start,
rank,
tags,
id,
position: 0,
milestone: None,
})
}
pub fn extract_file_title(content: &Content) -> Option<String> {
let mut title = None;
let _ = content.traverse(&mut |node: Content| -> ControlFlow<()> {
if let Some(heading) = node.to_packed::<HeadingElem>() {
title = Some(heading.body.plain_text().trim().to_string());
return ControlFlow::Break(());
}
ControlFlow::Continue(())
});
title
}
#[must_use]
pub fn format_date(dt: &Datetime) -> String {
format!(
"{:04}-{:02}-{:02}",
dt.year().unwrap_or(0),
dt.month().unwrap_or(0),
dt.day().unwrap_or(0),
)
}
pub fn extract_bindings(scope: &typst::foundations::Scope) -> Vec<(String, String, String)> {
let mut bindings = Vec::new();
for (name, binding) in scope.iter() {
let value = binding.read();
let (vtype, vjson) = match value {
Value::Str(str_val) => ("string", format!("\"{}\"", str_val.as_str())),
Value::Int(int_val) => ("int", int_val.to_string()),
Value::Float(float_val) => ("float", float_val.to_string()),
Value::Bool(bool_val) => ("bool", bool_val.to_string()),
Value::Datetime(dt) => ("date", format!("\"{}\"", format_date(dt))),
Value::None => ("none", "null".to_string()),
_ => continue,
};
bindings.push((name.to_string(), vtype.to_string(), vjson));
}
bindings
}
#[must_use]
pub fn has_mindtape_import(text: &str) -> bool {
text.lines().any(|line| {
let trimmed = line.trim();
trimmed.starts_with("#import") && trimmed.contains("/mindtape:")
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn has_import_local() {
assert!(has_mindtape_import(
r#"#import "@local/mindtape:0.1.0": due, tag, id"#
));
}
#[test]
fn has_import_preview() {
assert!(has_mindtape_import(
r#"#import "@preview/mindtape:0.1.0": due, tag"#
));
}
#[test]
fn has_import_project_namespace() {
assert!(has_mindtape_import(
r#"#import "@mindtape/mindtape:0.1.0": due, tag, id"#
));
}
#[test]
fn has_import_with_surrounding_content() {
let text = "= My Tasks\n\n#import \"@local/mindtape:0.1.0\": due\n\n- [ ] do stuff";
assert!(has_mindtape_import(text));
}
#[test]
fn no_import_plain_typst() {
assert!(!has_mindtape_import("= Hello\n\n- [ ] a task\n"));
}
#[test]
fn no_import_other_package() {
assert!(!has_mindtape_import(
r#"#import "@preview/tablex:0.1.0": tablex"#
));
}
#[test]
fn no_import_empty() {
assert!(!has_mindtape_import(""));
}
#[test]
fn has_import_indented() {
assert!(has_mindtape_import(
r#" #import "@local/mindtape:0.1.0": due"#
));
}
}