Something went wrong. Try again.
A Rust clone of doing by Brett Terpstra — a command line tool for remembering what you were doing and tracking what you've done. doing.aaronmallen.dev
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680use std::sync::LazyLock;
use regex::Regex;
use crate::colors;
static COLOR_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"%((?:[fb]g?)?#[a-fA-F0-9]{6}|[a-zA-Z_]+)").unwrap());static PLACEHOLDER_RE: LazyLock<Regex> = LazyLock::new(|| { Regex::new(concat!( r"%(?:(?P<min>\d+)-)?(?:(?P<stretch>\*)|(?P<width>-?\d+))?", r"(?:\^(?P<marker>.))?", r"(?:(?P<ichar>[ _t]|[^a-zA-Z0-9\s])(?P<icount>\d+))?", r"(?P<prefix>.[ _t]?)?", r"(?P<kind>shortdate|date|title|section|odnote|idnote|chompnote|note", r"|interval|duration|tags|hr_under|hr|n|t)\b", )) .unwrap()});
const ESCAPE_SENTINEL: &str = "\u{E000}";
/// Indentation specification for wrapped/continuation lines.#[derive(Clone, Debug, Eq, PartialEq)]pub struct Indent { pub count: u32, pub kind: IndentChar,}
/// The type of indent character used for wrapped/continuation lines.#[derive(Clone, Copy, Debug, Eq, PartialEq)]pub enum IndentChar { Custom(char), Space, Tab,}
/// A parsed template element — either literal text, a color token, or a placeholder token.#[derive(Clone, Debug, Eq, PartialEq)]pub enum Token { Color(colors::Color), Literal(String), Placeholder { indent: Option<Indent>, kind: TokenKind, marker: Option<char>, min_width: Option<u32>, prefix: Option<String>, stretch: bool, width: Option<i32>, },}
/// The type of placeholder token in a template string.#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]pub enum TokenKind { Chompnote, Date, Duration, Hr, HrUnder, Idnote, Interval, Newline, Note, Odnote, Section, Shortdate, Tab, Tags, Title,}
enum TokenMatch<'a> { Color { color: colors::Color, end: usize, start: usize, }, Placeholder { caps: regex::Captures<'a>, end: usize, start: usize, },}
impl TokenMatch<'_> { fn span(&self) -> (usize, usize) { match self { Self::Color { end, start, .. } => (*start, *end), Self::Placeholder { end, start, .. } => (*start, *end), } }}
/// Parse a template string into a sequence of tokens.////// Template strings contain literal text interspersed with `%` placeholder tokens./// Recognized tokens include `%date`, `%title`, `%note`, etc., with optional/// width, alignment, marker, indent, and prefix modifiers. Color tokens like/// `%cyan`, `%boldwhite`, `%reset`, and `%#FF5500` are also recognized.////// Escaped percent signs (`\%`) become literal `%` in the output. Unrecognized/// `%` sequences are preserved as literal text.pub fn parse(template: &str) -> Vec<Token> { let escaped = template.replace("\\%", ESCAPE_SENTINEL);
// Build a combined list of all matches sorted by position let mut matches: Vec<TokenMatch> = Vec::new();
for caps in PLACEHOLDER_RE.captures_iter(&escaped) { let m = caps.get(0).unwrap(); matches.push(TokenMatch::Placeholder { caps, end: m.end(), start: m.start(), }); }
for caps in COLOR_RE.captures_iter(&escaped) { let m = caps.get(0).unwrap(); let color_str = caps.get(1).unwrap().as_str(); if let Some((valid, orig_len)) = colors::validate_color(color_str) { // Only add if not overlapping with a placeholder match let start = m.start(); let end = start + 1 + orig_len; // +1 for the % prefix let overlaps = matches.iter().any(|tm| { let (ts, te) = tm.span(); start < te && end > ts }); if !overlaps && let Some(color) = colors::Color::parse(&valid) { matches.push(TokenMatch::Color { color, end, start, }); } } }
matches.sort_by_key(|m| m.span().0);
let mut tokens = Vec::new(); let mut last_end = 0;
for tm in &matches { let (start, end) = tm.span();
if start > last_end { tokens.push(Token::Literal(unescape(&escaped[last_end..start]))); }
match tm { TokenMatch::Color { color, .. } => { tokens.push(Token::Color(color.clone())); } TokenMatch::Placeholder { caps, .. } => { let stretch = caps.name("stretch").is_some(); let min_width = caps.name("min").and_then(|m| m.as_str().parse::<u32>().ok()); let width = caps .name("width") .map(|m| m.as_str().parse::<i32>().unwrap_or(i32::MAX)); let marker = caps.name("marker").and_then(|m| m.as_str().chars().next());
let indent = caps.name("ichar").and_then(|ic| { caps.name("icount").map(|cnt| { let count = cnt.as_str().parse::<u32>().unwrap_or(u32::MAX); let kind = match ic.as_str().chars().next().unwrap() { ' ' | '_' => IndentChar::Space, 't' => IndentChar::Tab, c => IndentChar::Custom(c), }; Indent { count, kind, } }) });
let prefix = caps.name("prefix").map(|m| m.as_str().to_string());
let kind = match caps.name("kind").unwrap().as_str() { "chompnote" => TokenKind::Chompnote, "date" => TokenKind::Date, "duration" => TokenKind::Duration, "hr" => TokenKind::Hr, "hr_under" => TokenKind::HrUnder, "idnote" => TokenKind::Idnote, "interval" => TokenKind::Interval, "n" => TokenKind::Newline, "note" => TokenKind::Note, "odnote" => TokenKind::Odnote, "section" => TokenKind::Section, "shortdate" => TokenKind::Shortdate, "t" => TokenKind::Tab, "tags" => TokenKind::Tags, "title" => TokenKind::Title, _ => unreachable!(), };
tokens.push(Token::Placeholder { indent, kind, marker, min_width, prefix, stretch, width, }); } }
last_end = end; }
if last_end < escaped.len() { tokens.push(Token::Literal(unescape(&escaped[last_end..]))); }
tokens}
fn unescape(s: &str) -> String { s.replace(ESCAPE_SENTINEL, "%")}
#[cfg(test)]mod test { use super::*;
fn placeholder(kind: TokenKind) -> Token { Token::Placeholder { indent: None, kind, marker: None, min_width: None, prefix: None, stretch: false, width: None, } }
mod parse { use pretty_assertions::assert_eq;
use super::*;
#[test] fn it_handles_escaped_percent() { let tokens = parse("\\%date is literal");
assert_eq!(tokens, vec![Token::Literal("%date is literal".into())]); }
#[test] fn it_parses_all_token_kinds() { for (input, expected) in [ ("%chompnote", TokenKind::Chompnote), ("%date", TokenKind::Date), ("%duration", TokenKind::Duration), ("%hr", TokenKind::Hr), ("%hr_under", TokenKind::HrUnder), ("%idnote", TokenKind::Idnote), ("%interval", TokenKind::Interval), ("%n", TokenKind::Newline), ("%note", TokenKind::Note), ("%odnote", TokenKind::Odnote), ("%section", TokenKind::Section), ("%shortdate", TokenKind::Shortdate), ("%t", TokenKind::Tab), ("%tags", TokenKind::Tags), ("%title", TokenKind::Title), ] { let tokens = parse(input);
assert_eq!(tokens.len(), 1, "expected one token for {input}"); match &tokens[0] { Token::Placeholder { kind, .. } => { assert_eq!(*kind, expected, "wrong kind for {input}") } _ => panic!("expected placeholder for {input}"), } } }
#[test] fn it_parses_color_tokens() { let tokens = parse("%cyan%date%reset");
assert_eq!( tokens, vec![ Token::Color(colors::Color::Named(colors::NamedColor::Cyan)), placeholder(TokenKind::Date), Token::Color(colors::Color::Named(colors::NamedColor::Reset)), ] ); }
#[test] fn it_parses_color_with_underscores() { let tokens = parse("%bold_white%title");
assert_eq!( tokens, vec![ Token::Color(colors::Color::Named(colors::NamedColor::BoldWhite)), placeholder(TokenKind::Title), ] ); }
#[test] fn it_parses_combined_width_indent_and_prefix() { let tokens = parse("%80_14\u{2503} note");
assert_eq!( tokens, vec![Token::Placeholder { indent: Some(Indent { count: 14, kind: IndentChar::Space, }), kind: TokenKind::Note, marker: None, min_width: None, prefix: Some("\u{2503} ".into()), stretch: false, width: Some(80), }] ); }
#[test] fn it_parses_empty_string() { let tokens = parse("");
assert_eq!(tokens, vec![]); }
#[test] fn it_parses_full_note_modifiers() { let tokens = parse("%^> 8: note");
assert_eq!( tokens, vec![Token::Placeholder { indent: Some(Indent { count: 8, kind: IndentChar::Space, }), kind: TokenKind::Note, marker: Some('>'), min_width: None, prefix: Some(": ".into()), stretch: false, width: None, }] ); }
#[test] fn it_parses_hex_color_tokens() { let tokens = parse("%#FF5500hello");
assert_eq!( tokens, vec![ Token::Color(colors::Color::Hex { background: false, b: 0x00, g: 0x55, r: 0xFF, }), Token::Literal("hello".into()), ] ); }
#[test] fn it_parses_literal_text() { let tokens = parse("hello world");
assert_eq!(tokens, vec![Token::Literal("hello world".into())]); }
#[test] fn it_parses_marker_modifier() { let tokens = parse("%^>note");
assert_eq!( tokens, vec![Token::Placeholder { indent: None, kind: TokenKind::Note, marker: Some('>'), min_width: None, prefix: None, stretch: false, width: None, }] ); }
#[test] fn it_parses_mixed_literals_and_placeholders() { let tokens = parse("hello %title world");
assert_eq!( tokens, vec![ Token::Literal("hello ".into()), placeholder(TokenKind::Title), Token::Literal(" world".into()), ] ); }
#[test] fn it_handles_overflow_width_gracefully() { let tokens = parse("%999999999999title");
assert_eq!( tokens, vec![Token::Placeholder { indent: None, kind: TokenKind::Title, marker: None, min_width: None, prefix: None, stretch: false, width: Some(i32::MAX), }] ); }
#[test] fn it_parses_negative_width_modifier() { let tokens = parse("%-10section");
assert_eq!( tokens, vec![Token::Placeholder { indent: None, kind: TokenKind::Section, marker: None, min_width: None, prefix: None, stretch: false, width: Some(-10), }] ); }
#[test] fn it_parses_positive_width_modifier() { let tokens = parse("%80title");
assert_eq!( tokens, vec![Token::Placeholder { indent: None, kind: TokenKind::Title, marker: None, min_width: None, prefix: None, stretch: false, width: Some(80), }] ); }
#[test] fn it_parses_prefix_modifier() { let tokens = parse("%: note");
assert_eq!( tokens, vec![Token::Placeholder { indent: None, kind: TokenKind::Note, marker: None, min_width: None, prefix: Some(": ".into()), stretch: false, width: None, }] ); }
#[test] fn it_parses_prefix_with_separator() { let tokens = parse("%80\u{2551} title");
assert_eq!( tokens, vec![Token::Placeholder { indent: None, kind: TokenKind::Title, marker: None, min_width: None, prefix: Some("\u{2551} ".into()), stretch: false, width: Some(80), }] ); }
#[test] fn it_parses_space_indent_modifier() { let tokens = parse("% 4note");
assert_eq!( tokens, vec![Token::Placeholder { indent: Some(Indent { count: 4, kind: IndentChar::Space, }), kind: TokenKind::Note, marker: None, min_width: None, prefix: None, stretch: false, width: None, }] ); }
#[test] fn it_parses_tab_indent_modifier() { let tokens = parse("%t2note");
assert_eq!( tokens, vec![Token::Placeholder { indent: Some(Indent { count: 2, kind: IndentChar::Tab, }), kind: TokenKind::Note, marker: None, min_width: None, prefix: None, stretch: false, width: None, }] ); }
#[test] fn it_handles_control_characters_in_input() { // Entries containing old sentinel characters (\x01, \x02) should // render correctly without corruption now that we use PUA codepoints. let tokens = parse("hello \x01 and \x02 world");
assert_eq!(tokens, vec![Token::Literal("hello \x01 and \x02 world".into())]); }
#[test] fn it_parses_underscore_indent_modifier() { let tokens = parse("%_14note");
assert_eq!( tokens, vec![Token::Placeholder { indent: Some(Indent { count: 14, kind: IndentChar::Space, }), kind: TokenKind::Note, marker: None, min_width: None, prefix: None, stretch: false, width: None, }] ); }
#[test] fn it_parses_stretch_modifier() { let tokens = parse("%*title");
assert_eq!( tokens, vec![Token::Placeholder { indent: None, kind: TokenKind::Title, marker: None, min_width: None, prefix: None, stretch: true, width: None, }] ); }
#[test] fn it_parses_min_width_with_stretch() { let tokens = parse("%20-*title");
assert_eq!( tokens, vec![Token::Placeholder { indent: None, kind: TokenKind::Title, marker: None, min_width: Some(20), prefix: None, stretch: true, width: None, }] ); }
#[test] fn it_parses_min_width_stretch_with_indent() { let tokens = parse("%20-*_4title");
assert_eq!( tokens, vec![Token::Placeholder { indent: Some(Indent { count: 4, kind: IndentChar::Space, }), kind: TokenKind::Title, marker: None, min_width: Some(20), prefix: None, stretch: true, width: None, }] ); }
#[test] fn it_parses_stretch_note() { let tokens = parse("%*note");
assert_eq!( tokens, vec![Token::Placeholder { indent: None, kind: TokenKind::Note, marker: None, min_width: None, prefix: None, stretch: true, width: None, }] ); }
#[test] fn it_preserves_unknown_percent_sequences() { let tokens = parse("%xyz%date");
assert_eq!( tokens, vec![Token::Literal("%xyz".into()), placeholder(TokenKind::Date),] ); } }}