use std::path::Path;
use thiserror::Error;
use typst_syntax::{LinkedNode, Source, SyntaxKind};
#[derive(Debug, Error)]
pub enum WriteError {
#[error("file read error: {0}")]
File(String),
#[error("task not found: {0}")]
TaskNotFound(String),
#[error("invalid task structure: {0}")]
InvalidTask(String),
#[error("invalid date: {0}")]
InvalidDate(String),
}
pub fn load_source(path: &Path) -> Result<Source, WriteError> {
std::fs::read_to_string(path)
.map_err(|e| WriteError::File(e.to_string()))
.map(|content| Source::detached(&content))
}
pub fn find_task_node<'a>(
source: &'a Source,
root: &LinkedNode<'a>,
task_id: &str,
) -> Option<LinkedNode<'a>> {
walk_tree(root, &mut |node| {
if node.kind() == SyntaxKind::ListItem {
let range = node.range();
let text = &source.text()[range];
if let Some(id) = extract_id_from_text(text)
&& id == task_id
{
return Some(node.clone());
}
}
None
})
}
fn walk_tree<'a, T>(
node: &LinkedNode<'a>,
visitor: &mut dyn FnMut(LinkedNode<'a>) -> Option<T>,
) -> Option<T> {
if let Some(result) = visitor(node.clone()) {
return Some(result);
}
for child in node.children() {
if let Some(result) = walk_tree(&child, visitor) {
return Some(result);
}
}
None
}
fn extract_id_from_text(text: &str) -> Option<String> {
let prefix = "#id(\"";
let suffix = "\")";
if let Some(start) = text.find(prefix) {
let id_start = start + prefix.len();
if let Some(end) = text[id_start..].find(suffix) {
return Some(text[id_start..id_start + end].to_string());
}
}
None
}
pub fn toggle_task_checkbox(source: &Source, task_id: &str) -> Result<String, WriteError> {
let root = LinkedNode::new(source.root());
let task_node = find_task_node(source, &root, task_id)
.ok_or_else(|| WriteError::TaskNotFound(task_id.to_string()))?;
let range = task_node.range();
let item_text = &source.text()[range.clone()];
let (old_checkbox, new_checkbox) = if item_text.trim_start().starts_with("- [ ]") {
("- [ ]", "- [x]")
} else if item_text.trim_start().starts_with("- [x]")
|| item_text.trim_start().starts_with("- [X]")
{
if item_text.trim_start().starts_with("- [x]") {
("- [x]", "- [ ]")
} else {
("- [X]", "- [ ]")
}
} else {
return Err(WriteError::InvalidTask(
"list item does not start with checkbox".to_string(),
));
};
let new_item_text = item_text.replacen(old_checkbox, new_checkbox, 1);
let before = &source.text()[..range.start];
let after = &source.text()[range.end..];
Ok(format!("{before}{new_item_text}{after}"))
}
fn modify_task_text(
source: &Source,
task_id: &str,
modify: impl FnOnce(&str) -> Result<String, WriteError>,
) -> Result<String, WriteError> {
let root = LinkedNode::new(source.root());
let task_node = find_task_node(source, &root, task_id)
.ok_or_else(|| WriteError::TaskNotFound(task_id.to_string()))?;
let range = task_node.range();
let item_text = &source.text()[range.clone()];
let new_item_text = modify(item_text)?;
let before = &source.text()[..range.start];
let after = &source.text()[range.end..];
Ok(format!("{before}{new_item_text}{after}"))
}
pub fn set_task_due(source: &Source, task_id: &str, date_str: &str) -> Result<String, WriteError> {
let args = format_typst_date_args(date_str)?;
let replacement = format!("#due({args})");
modify_task_text(source, task_id, |text| {
if let Some((start, end)) = find_due_span(text) {
Ok(format!("{}{replacement}{}", &text[..start], &text[end..]))
} else if let Some(insert) = find_property_insert_point(text) {
Ok(format!(
"{}{replacement} {}",
&text[..insert],
&text[insert..]
))
} else {
Err(WriteError::InvalidTask(
"task has no #id() — cannot determine insertion point".to_string(),
))
}
})
}
pub fn set_task_start(
source: &Source,
task_id: &str,
date_str: &str,
) -> Result<String, WriteError> {
let args = format_typst_date_args(date_str)?;
let replacement = format!("#start({args})");
modify_task_text(source, task_id, |text| {
if let Some((start, end)) = find_start_span(text) {
Ok(format!("{}{replacement}{}", &text[..start], &text[end..]))
} else if let Some(insert) = find_property_insert_point(text) {
Ok(format!(
"{}{replacement} {}",
&text[..insert],
&text[insert..]
))
} else {
Err(WriteError::InvalidTask(
"task has no #id() — cannot determine insertion point".to_string(),
))
}
})
}
pub fn remove_task_start(source: &Source, task_id: &str) -> Result<String, WriteError> {
modify_task_text(source, task_id, |text| {
if let Some((start, end)) = find_start_span(text) {
let (start, end) = trim_leading_space(text, start, end);
Ok(format!("{}{}", &text[..start], &text[end..]))
} else {
Ok(text.to_string())
}
})
}
pub fn set_task_rank(source: &Source, task_id: &str, rank: i64) -> Result<String, WriteError> {
let replacement = format!("#rank({rank})");
modify_task_text(source, task_id, |text| {
if let Some((start, end)) = find_rank_span(text) {
Ok(format!("{}{replacement}{}", &text[..start], &text[end..]))
} else if let Some(insert) = find_property_insert_point(text) {
Ok(format!(
"{}{replacement} {}",
&text[..insert],
&text[insert..]
))
} else {
Err(WriteError::InvalidTask(
"task has no #id() — cannot determine insertion point".to_string(),
))
}
})
}
pub fn remove_task_rank(source: &Source, task_id: &str) -> Result<String, WriteError> {
modify_task_text(source, task_id, |text| {
if let Some((start, end)) = find_rank_span(text) {
let (start, end) = trim_leading_space(text, start, end);
Ok(format!("{}{}", &text[..start], &text[end..]))
} else {
Ok(text.to_string())
}
})
}
pub fn remove_task_due(source: &Source, task_id: &str) -> Result<String, WriteError> {
modify_task_text(source, task_id, |text| {
if let Some((start, end)) = find_due_span(text) {
let (start, end) = trim_leading_space(text, start, end);
Ok(format!("{}{}", &text[..start], &text[end..]))
} else {
Ok(text.to_string())
}
})
}
pub fn add_task_tag(source: &Source, task_id: &str, tag: &str) -> Result<String, WriteError> {
let new_tag = format!("#tag(\"{tag}\")");
modify_task_text(source, task_id, |text| {
if let Some(insert) = find_property_insert_point(text) {
Ok(format!("{}{new_tag} {}", &text[..insert], &text[insert..]))
} else {
Err(WriteError::InvalidTask(
"task has no #id() — cannot determine insertion point".to_string(),
))
}
})
}
pub fn remove_task_tag(source: &Source, task_id: &str, tag: &str) -> Result<String, WriteError> {
modify_task_text(source, task_id, |text| {
if let Some((start, end)) = find_tag_span(text, tag) {
let (start, end) = trim_leading_space(text, start, end);
Ok(format!("{}{}", &text[..start], &text[end..]))
} else {
Ok(text.to_string())
}
})
}
fn find_call_span(text: &str, prefix: &str) -> Option<(usize, usize)> {
let start = text.find(prefix)?;
let end = find_matching_paren(text, start + prefix.len() - 1)?;
Some((start, end + 1))
}
fn find_due_span(text: &str) -> Option<(usize, usize)> {
find_call_span(text, "#due(")
}
fn find_start_span(text: &str) -> Option<(usize, usize)> {
find_call_span(text, "#start(")
}
fn find_rank_span(text: &str) -> Option<(usize, usize)> {
if let Some(span) = find_call_span(text, "#rank(") {
return Some(span);
}
for alias in &["#high", "#medium", "#low"] {
if let Some(start) = text.find(alias) {
let end = start + alias.len();
if text
.as_bytes()
.get(end)
.is_none_or(|&b| b == b' ' || b == b'#')
{
return Some((start, end));
}
}
}
None
}
fn find_tag_span(text: &str, tag: &str) -> Option<(usize, usize)> {
let needle = format!("#tag(\"{tag}\")");
let start = text.find(&needle)?;
Some((start, start + needle.len()))
}
fn find_property_insert_point(text: &str) -> Option<usize> {
text.find("#id(\"")
}
fn trim_leading_space(text: &str, start: usize, end: usize) -> (usize, usize) {
if start > 0 && text.as_bytes().get(start - 1) == Some(&b' ') {
(start - 1, end)
} else {
(start, end)
}
}
fn find_matching_paren(text: &str, open: usize) -> Option<usize> {
let bytes = text.as_bytes();
if bytes.get(open) != Some(&b'(') {
return None;
}
let mut depth: u32 = 1;
for (i, &byte) in bytes.iter().enumerate().skip(open + 1) {
match byte {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
}
None
}
fn format_typst_date_args(date_str: &str) -> Result<String, WriteError> {
let parts: Vec<&str> = date_str.split('-').collect();
let [year_str, month_str, day_str] = parts.as_slice() else {
return Err(WriteError::InvalidDate(format!(
"expected YYYY-MM-DD, got \"{date_str}\""
)));
};
let year: i32 = year_str
.parse()
.map_err(|_| WriteError::InvalidDate(format!("invalid year in \"{date_str}\"")))?;
let month: u32 = month_str
.parse()
.map_err(|_| WriteError::InvalidDate(format!("invalid month in \"{date_str}\"")))?;
let day: u32 = day_str
.parse()
.map_err(|_| WriteError::InvalidDate(format!("invalid day in \"{date_str}\"")))?;
if !(1..=12).contains(&month) {
return Err(WriteError::InvalidDate(format!(
"month {month} out of range 1..12"
)));
}
if !(1..=31).contains(&day) {
return Err(WriteError::InvalidDate(format!(
"day {day} out of range 1..31"
)));
}
Ok(format!("{year}, {month}, {day}"))
}
#[cfg(test)]
#[path = "write_tests.rs"]
#[allow(clippy::unwrap_used)]
mod tests;