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 }