git.lucas.co / cce-notes
notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git

src/complete.rs (4.7K)

  1 //! `[[` completion in source mode: find the link being typed at the caret,
  2 //! and write the chosen note back in the shortest form that still reaches
  3 //! it, as Obsidian does. Positions are char indices (the TextBox's own
  4 //! `cursor_idx` unit), not bytes.
  5 
  6 use cce_vault::index::stem;
  7 use cce_vault::{FileKind, Index};
  8 
  9 /// How many choices the popup offers.
 10 pub const LIMIT: usize = 8;
 11 
 12 /// An open `[[` before `cursor` on the caret's line, with nothing closing
 13 /// it yet: the char index just past the `[[`, and what has been typed
 14 /// since. A `|` (display text) or `#` (a heading) ends completion — the
 15 /// note part is already chosen by then.
 16 pub fn open_link(text: &str, cursor: usize) -> Option<(usize, String)> {
 17     let chars: Vec<char> = text.chars().collect();
 18     let cursor = cursor.min(chars.len());
 19     let mut i = cursor;
 20     while i >= 2 {
 21         let c = chars[i - 1];
 22         if c == '\n' || c == ']' || c == '|' || c == '#' {
 23             return None;
 24         }
 25         if c == '[' && chars[i - 2] == '[' {
 26             let query: String = chars[i..cursor].iter().collect();
 27             return Some((i, query));
 28         }
 29         i -= 1;
 30     }
 31     None
 32 }
 33 
 34 /// What to write between the brackets for `path`: its bare name when that
 35 /// resolves back to it from `from`, else its vault path; `.md` dropped,
 36 /// other extensions kept.
 37 pub fn link_text(ix: &Index, from: Option<&str>, path: &str) -> String {
 38     let is_note = FileKind::of(path) == FileKind::Note;
 39     let file = path.rsplit('/').next().unwrap_or(path);
 40     let short = if is_note { stem(path).to_string() } else { file.to_string() };
 41     if ix.resolve_text(from, &short).as_deref() == Some(path) {
 42         short
 43     } else if is_note {
 44         path.strip_suffix(".md").unwrap_or(path).to_string()
 45     } else {
 46         path.to_string()
 47     }
 48 }
 49 
 50 /// Choices for a query: fuzzy over names, aliases and paths.
 51 pub fn choices(ix: &Index, query: &str) -> Vec<String> {
 52     if query.trim().is_empty() {
 53         let mut notes: Vec<(&String, u64)> =
 54             ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, e)| (p, e.mtime)).collect();
 55         notes.sort_by(|a, b| b.1.cmp(&a.1));
 56         return notes.into_iter().take(LIMIT).map(|(p, _)| p.clone()).collect();
 57     }
 58     ix.find(query, LIMIT).into_iter().map(|m| m.path).collect()
 59 }
 60 
 61 /// Replace chars `start..cursor` with `link`, closing it with `]]` unless
 62 /// the text already closes it there. Returns the new text and the caret
 63 /// just past the closing brackets.
 64 pub fn apply(text: &str, start: usize, cursor: usize, link: &str) -> (String, usize) {
 65     let chars: Vec<char> = text.chars().collect();
 66     let cursor = cursor.min(chars.len());
 67     let closed = chars.get(cursor) == Some(&']') && chars.get(cursor + 1) == Some(&']');
 68     let mut out: String = chars[..start].iter().collect();
 69     out.push_str(link);
 70     if !closed {
 71         out.push_str("]]");
 72     }
 73     let caret = start + link.chars().count() + 2;
 74     out.extend(&chars[cursor..]);
 75     (out, caret)
 76 }
 77 
 78 #[cfg(test)]
 79 mod tests {
 80     use super::*;
 81 
 82     #[test]
 83     fn finds_the_open_link_at_the_caret() {
 84         let t = "see [[Pro";
 85         assert_eq!(open_link(t, 9), Some((6, "Pro".into())));
 86         assert_eq!(open_link("x [[", 4), Some((4, String::new())));
 87         assert_eq!(open_link("[[A]] b", 7), None);
 88         assert_eq!(open_link("[[A|sh", 6), None);
 89         assert_eq!(open_link("[[A#h", 5), None);
 90         assert_eq!(open_link("[[A\nb", 5), None);
 91         assert_eq!(open_link("[x", 2), None);
 92         // Non-ASCII before the link: indices are chars.
 93         assert_eq!(open_link("é [[ab", 6), Some((4, "ab".into())));
 94     }
 95 
 96     #[test]
 97     fn applies_and_closes_once() {
 98         assert_eq!(apply("see [[Pro", 6, 9, "Projects"), ("see [[Projects]]".into(), 16));
 99         assert_eq!(apply("[[Pro]] x", 2, 5, "Projects"), ("[[Projects]] x".into(), 12));
100         assert_eq!(apply("é [[a tail", 4, 5, "Alpha"), ("é [[Alpha]] tail".into(), 11));
101     }
102 
103     #[test]
104     fn link_text_is_shortest_that_resolves() {
105         let dir = tempfile::tempdir().unwrap();
106         for p in ["a/Note.md", "b/Note.md", "Solo.md", "img/pic.png"] {
107             let abs = dir.path().join(p);
108             std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
109             std::fs::write(abs, "x").unwrap();
110         }
111         let ix = Index::open(dir.path(), false).unwrap();
112         assert_eq!(link_text(&ix, None, "Solo.md"), "Solo");
113         assert_eq!(link_text(&ix, None, "img/pic.png"), "pic.png");
114         // Two notes share the name: from a/, the bare name reaches a/Note.
115         assert_eq!(link_text(&ix, Some("a/Other.md"), "a/Note.md"), "Note");
116         assert_eq!(link_text(&ix, Some("a/Other.md"), "b/Note.md"), "b/Note");
117     }
118 }