Files
md-manuscript/src/placeholder_scan.rs
T
landon 7854a961a9 Bundle the project template into the binary; add outline-to-draft
links, a sprint timer, placeholder scanning, corkboard, and a
quick-capture inbox

- Compile templates/snowflake into the binary via include_dir, so New
  project… never depends on files installed beside the executable;
  Settings ▸ Template becomes an optional external override, and a
  broken saved override now falls back to the bundled template
  instead of failing project generation.
- Outline beats now link to a matching chapter for a one-click jump.
- Sprint window: a daily whole-manuscript word goal plus a
  Pomodoro-style writing sprint timer, with a compact countdown in the
  status bar while in Distraction-free mode.
- Placeholders window: find [TODO]/[TK]/[FIXME]/[XXX]/[NOTE]/[???]
  markers left in the draft and jump straight to any of them.
- Snowflake progress strip in the file panel: a nudge-only readout of
  which snowflake stage folders actually have content.
- Corkboard window: drag-reorderable synopsis cards, one per chapter.
- Quick-capture popup (Ctrl+Shift+N): jot a stray idea straight into
  the project's Scratch Pad without losing your place in the draft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-30 11:38:31 -05:00

147 lines
4.6 KiB
Rust

//! 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<Placeholder> {
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());
}
}