Functional Weave
Code in Rust

todo.parse-quick-add@1.0.0

impl/rust.rs

6,457 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::add_days;  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_day_of_week::day_of_week;  ← from dates.day-of-week ^1.0.0 · built alongside by fune
use super::todo_item::{Recurrence, TodoDraft};  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_item_validate_draft::todo_draft_to_value;
use super::todo_normalise_tags::normalise_tags;  ← from todo.normalise-tags ^1.0.0 · built alongside by fune
use super::todo_parse_date_phrase::parse_date_phrase;  ← from todo.parse-date-phrase ^1.0.0 · built alongside by fune

// The longest phrase todo.parse-date-phrase accepts: "due in 3 days".
const MAX_DATE_WORDS: usize = 4;

struct Repeat {
    frequency: &'static str,
    interval: i64,
    weekday: Option<String>,
    length: usize,
}

fn priority_of(word: &str) -> Option<&'static str> {
    match word {
        "!high" | "!h" | "!1" | "!!!" => Some("high"),
        "!medium" | "!med" | "!m" | "!2" | "!!" => Some("medium"),
        "!low" | "!l" | "!3" => Some("low"),
        "!none" => Some("none"),
        _ => None,
    }
}

/// Short words that are also ordinary title words ("Read the sun"): on their
/// own they stay in the title; "on sun" or "due sat" still sets the date.
fn is_guarded(word: &str) -> bool {
    matches!(
        word,
        "mon" | "tue" | "tues" | "wed" | "thu" | "thur" | "thurs" | "fri" | "sat" | "sun" | "tod" | "weekend"
    )
}

fn is_weekday_word(word: &str) -> bool {
    matches!(
        word,
        "monday" | "mon" | "tuesday" | "tue" | "tues" | "wednesday" | "wed" | "thursday" | "thu" | "thur"
            | "thurs" | "friday" | "fri" | "saturday" | "sat" | "sunday" | "sun"
    )
}

fn unit_frequency(word: &str) -> Option<&'static str> {
    match word {
        "day" | "days" => Some("daily"),
        "week" | "weeks" => Some("weekly"),
        "month" | "months" => Some("monthly"),
        "year" | "years" => Some("yearly"),
        _ => None,
    }
}

/// 1 to 999 in ASCII digits without a leading zero, or one...ten.
fn count(word: &str) -> Option<i64> {
    let b = word.as_bytes();
    if !b.is_empty() && b.len() <= 3 && b[0] != b'0' && b.iter().all(|c| c.is_ascii_digit()) {
        return Some(word.parse().unwrap());
    }
    match word {
        "one" => Some(1),
        "two" => Some(2),
        "three" => Some(3),
        "four" => Some(4),
        "five" => Some(5),
        "six" => Some(6),
        "seven" => Some(7),
        "eight" => Some(8),
        "nine" => Some(9),
        "ten" => Some(10),
        _ => None,
    }
}

/// The repeat phrase starting with "every" at words[i], or None.
fn repeat_at(words: &[&str], i: usize) -> Option<Repeat> {
    if i + 1 >= words.len() {
        return None;
    }
    let a = words[i + 1].to_ascii_lowercase();
    if a == "weekday" || a == "weekdays" {
        return Some(Repeat { frequency: "weekdays", interval: 1, weekday: None, length: 2 });
    }
    if let Some(frequency) = unit_frequency(&a) {
        return Some(Repeat { frequency, interval: 1, weekday: None, length: 2 });
    }
    if is_weekday_word(&a) {
        return Some(Repeat { frequency: "weekly", interval: 1, weekday: Some(a), length: 2 });
    }
    if i + 2 >= words.len() {
        return None;
    }
    let frequency = unit_frequency(&words[i + 2].to_ascii_lowercase())?;
    let interval = if a == "other" { 2 } else { count(&a)? };
    Some(Repeat { frequency, interval, weekday: None, length: 3 })
}

fn is_space(c: char) -> bool {
    matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{0B}' | '\u{0C}')
}

/// One typed line into a TodoDraft: #tags, a !priority, the first date phrase
/// and the first "every ..." repeat come out; every other word stays in the
/// title, in order, single-spaced. The title can come out empty.
///
/// # Panics
/// Panics when `today` is not a real ISO date.
pub fn parse_quick_add(text: &str, today: &str) -> TodoDraft {
    // add_days(x, 0) is the date check: it panics with dates.add-days's message.
    add_days(today, 0);
    let words: Vec<&str> = text.split(is_space).filter(|w| !w.is_empty()).collect();
    let mut title: Vec<&str> = Vec::new();
    let mut tag_words: Vec<String> = Vec::new();
    let mut priority: Option<&'static str> = None;
    let mut due: Option<String> = None;
    let mut repeat: Option<Repeat> = None;

    let mut i = 0;
    while i < words.len() {
        let word = words[i];
        let lower = word.to_ascii_lowercase();
        if repeat.is_none() && lower == "every" {
            if let Some(found) = repeat_at(&words, i) {
                i += found.length;
                repeat = Some(found);
                continue;
            }
        }
        if priority.is_none() {
            if let Some(p) = priority_of(&lower) {
                priority = Some(p);
                i += 1;
                continue;
            }
        }
        if word.chars().count() > 1 && word.starts_with('#') && !normalise_tags(&[word.to_string()]).is_empty() {
            tag_words.push(word.to_string());
            i += 1;
            continue;
        }
        if due.is_none() {
            let mut taken = 0;
            let longest = MAX_DATE_WORDS.min(words.len() - i);
            for length in (1..=longest).rev() {
                if length == 1 && is_guarded(&lower) {
                    continue;
                }
                if let Some(date) = parse_date_phrase(&words[i..i + length].join(" "), today) {
                    due = Some(date);
                    taken = length;
                    break;
                }
            }
            if taken > 0 {
                i += taken;
                continue;
            }
        }
        title.push(word);
        i += 1;
    }

    let recurrence = repeat.map(|r| {
        let date = match due.take() {
            Some(d) => d,
            None if r.frequency == "weekdays" => {
                let dow = day_of_week(today);
                if dow <= 5 { today.to_string() } else { add_days(today, 8 - dow) }
            }
            None => match &r.weekday {
                Some(w) => parse_date_phrase(w, today).expect("a weekday is a date phrase"),
                None => today.to_string(),
            },
        };
        due = Some(date.clone());
        Recurrence { frequency: r.frequency.to_string(), interval: r.interval, anchor: date }
    });
    TodoDraft {
        title: title.join(" "),
        notes: None,
        priority: priority.unwrap_or("none").to_string(),
        due,
        tags: normalise_tags(&tag_words),
        recurrence,
    }
}

pub fn fune_vector(args: &[Value]) -> Value {
    todo_draft_to_value(&parse_quick_add(args[0].as_str(), args[1].as_str()))
}