//! Finds bracketed placeholder markers (`[TODO]`, `[TK]`, `[???]`, …) dropped //! into prose while drafting fast, so nothing gets forgotten before a draft //! is called done. No `regex` dependency: a hand-rolled bracket scan is //! plenty for a short, fixed set of markers. /// A bracketed placeholder found in a file's text. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Placeholder { /// Byte offset of the `[` in the text. pub start: usize, /// Byte offset just past the `]` in the text. pub end: usize, /// 1-indexed line number the marker appears on. pub line: usize, /// The marker text as written, e.g. `TODO` or `???`. pub marker: String, } /// Marker keywords recognised inside brackets, matched case-insensitively. const MARKER_WORDS: &[&str] = &["TODO", "TK", "FIXME", "XXX", "NOTE"]; /// Longest a bracketed span can be and still be considered a placeholder /// rather than, say, a Markdown link's link text. const MAX_SPAN_LEN: usize = 40; /// Find every placeholder marker in `text`. pub fn find_placeholders(text: &str) -> Vec { let mut out = Vec::new(); let mut line = 1; let mut last_end = 0; let bytes = text.as_bytes(); let mut search_from = 0; while let Some(rel_start) = text[search_from..].find('[') { let start = search_from + rel_start; let Some(rel_close) = text[start + 1..].find(']') else { break; }; let close = start + 1 + rel_close; let inner = &text[start + 1..close]; // Count newlines passed since the last marker (or the start) to // keep `line` in step without rescanning from the top each time. line += text[last_end..start].matches('\n').count(); last_end = start; if inner.len() <= MAX_SPAN_LEN && !inner.contains('\n') && is_marker(inner) { out.push(Placeholder { start, end: close + 1, line, marker: inner.trim().to_string(), }); } search_from = close + 1; if search_from > bytes.len() { break; } } out } /// Whether bracketed inner text `s` reads as a placeholder: an exact (case /// -insensitive) marker keyword, or entirely `?` characters. fn is_marker(s: &str) -> bool { let trimmed = s.trim(); if trimmed.is_empty() { return false; } if trimmed.chars().all(|c| c == '?') { return true; } MARKER_WORDS .iter() .any(|m| m.eq_ignore_ascii_case(trimmed)) } #[cfg(test)] mod tests { use super::*; #[test] fn finds_each_known_marker_word() { for word in MARKER_WORDS { let text = format!("Some prose [{word}] continues."); let found = find_placeholders(&text); assert_eq!(found.len(), 1, "expected a match for [{word}]"); assert_eq!(found[0].marker, *word); } } #[test] fn finds_question_mark_placeholders() { let found = find_placeholders("She said [???] and left."); assert_eq!(found.len(), 1); assert_eq!(found[0].marker, "???"); } #[test] fn marker_matching_is_case_insensitive() { let found = find_placeholders("[todo] fix this scene"); assert_eq!(found.len(), 1); assert_eq!(found[0].marker, "todo"); } #[test] fn a_markdown_link_is_not_a_false_positive() { let found = find_placeholders("See [a markdown link](https://example.com) for more."); assert!(found.is_empty()); } #[test] fn an_ordinary_bracketed_aside_is_not_a_false_positive() { let found = find_placeholders("He shrugged [as he always did when cornered]."); assert!(found.is_empty()); } #[test] fn line_numbers_are_tracked_across_multiple_lines() { let text = "Line one.\nLine two has [TK] in it.\nLine three has [FIXME] too."; let found = find_placeholders(text); assert_eq!(found.len(), 2); assert_eq!(found[0].line, 2); assert_eq!(found[1].line, 3); } #[test] fn byte_offsets_cover_the_brackets_themselves() { let text = "abc [TODO] def"; let found = find_placeholders(text); assert_eq!(found.len(), 1); assert_eq!(&text[found[0].start..found[0].end], "[TODO]"); } #[test] fn an_unterminated_bracket_does_not_hang_or_panic() { let found = find_placeholders("This has an [unterminated bracket"); assert!(found.is_empty()); } #[test] fn no_brackets_at_all_yields_nothing() { assert!(find_placeholders("Plain prose, nothing to see here.").is_empty()); } }