mermaid_cli/domain/
file_mention.rs1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct AtToken {
14 pub start: usize,
16 pub query_start: usize,
18 pub query_end: usize,
20}
21
22pub fn active_at_token(buf: &str, cursor: usize) -> Option<AtToken> {
28 let cursor = cursor.min(buf.len());
29 if !buf.is_char_boundary(cursor) {
30 return None;
31 }
32 let before = &buf[..cursor];
35 let mut start = None;
36 for (idx, ch) in before.char_indices().rev() {
37 if ch == '@' {
38 start = Some(idx);
39 break;
40 }
41 if ch.is_whitespace() {
42 return None;
43 }
44 }
45 let start = start?;
46 if start > 0 {
48 let prev = before[..start].chars().next_back()?;
49 if !prev.is_whitespace() {
50 return None;
51 }
52 }
53 Some(AtToken {
54 start,
55 query_start: start + 1,
56 query_end: cursor,
57 })
58}
59
60pub fn fuzzy_rank(files: &[String], query: &str, limit: usize) -> Vec<String> {
64 if query.is_empty() {
65 return files.iter().take(limit).cloned().collect();
66 }
67 use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
68 use nucleo_matcher::{Config, Matcher};
69 let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
70 let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart);
71 let mut scored: Vec<(u32, usize)> = Vec::new();
72 let mut haystack_buf = Vec::new();
73 for (idx, file) in files.iter().enumerate() {
74 let haystack = nucleo_matcher::Utf32Str::new(file, &mut haystack_buf);
75 if let Some(score) = pattern.score(haystack, &mut matcher) {
76 scored.push((score, idx));
77 }
78 }
79 scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
81 scored
82 .into_iter()
83 .take(limit)
84 .map(|(_, idx)| files[idx].clone())
85 .collect()
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 fn files(names: &[&str]) -> Vec<String> {
93 names.iter().map(|s| s.to_string()).collect()
94 }
95
96 #[test]
97 fn token_at_buffer_start() {
98 let buf = "@src";
99 let tok = active_at_token(buf, 4).expect("token");
100 assert_eq!(tok.start, 0);
101 assert_eq!(&buf[tok.query_start..tok.query_end], "src");
102 }
103
104 #[test]
105 fn token_after_whitespace() {
106 let buf = "look at @main.rs please";
107 let tok = active_at_token(buf, 16).expect("token");
109 assert_eq!(tok.start, 8);
110 assert_eq!(&buf[tok.query_start..tok.query_end], "main.rs");
111 }
112
113 #[test]
114 fn email_like_at_never_triggers() {
115 let buf = "mail user@host now";
116 assert_eq!(active_at_token(buf, 14), None);
118 }
119
120 #[test]
121 fn whitespace_between_at_and_cursor_closes_the_token() {
122 let buf = "@src and more";
123 assert_eq!(active_at_token(buf, 9), None);
124 }
125
126 #[test]
127 fn cursor_before_the_at_is_not_inside() {
128 let buf = "hi @src";
129 assert_eq!(active_at_token(buf, 2), None);
130 }
131
132 #[test]
133 fn multibyte_input_does_not_panic() {
134 let buf = "héllo @tê";
135 let tok = active_at_token(buf, buf.len()).expect("token");
137 assert_eq!(&buf[tok.query_start..tok.query_end], "tê");
138 assert_eq!(active_at_token("é@x", 1), None);
140 }
141
142 #[test]
143 fn empty_query_returns_lexicographic_head() {
144 let list = files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
145 assert_eq!(fuzzy_rank(&list, "", 2), files(&["a.rs", "b.rs"]));
146 }
147
148 #[test]
149 fn ranking_prefers_contiguous_filename_over_scattered_path() {
150 let list = files(&["mystic/awesome/insight/notes.md", "src/main.rs"]);
151 let ranked = fuzzy_rank(&list, "main", 10);
152 assert_eq!(
153 ranked[0], "src/main.rs",
154 "a contiguous basename match outranks letters scattered across segments: {ranked:?}"
155 );
156 }
157
158 #[test]
159 fn ranking_is_deterministic_across_calls() {
160 let list = files(&["alpha.rs", "beta.rs", "src/ab.rs", "docs/ab.md"]);
161 let first = fuzzy_rank(&list, "ab", 10);
162 let second = fuzzy_rank(&list, "ab", 10);
163 assert_eq!(first, second);
164 assert!(!first.is_empty());
165 }
166
167 #[test]
168 fn non_matching_query_yields_empty() {
169 let list = files(&["main.rs"]);
170 assert!(fuzzy_rank(&list, "zzzqqq", 10).is_empty());
171 }
172}