Skip to main content

mermaid_cli/domain/
file_mention.rs

1//! @-mention file references: pure token detection + fuzzy ranking.
2//!
3//! Typing `@` in the composer (at the buffer start or after whitespace)
4//! opens a fuzzy file picker over the project's non-ignored files;
5//! completing inserts the plain relative path as text (`@src/foo.rs `) —
6//! the model reads the file with its own tools, so the mention survives
7//! persistence, compaction, replay, and every provider adapter with zero
8//! new machinery. File enumeration is an effect (`Cmd::ListProjectFiles`);
9//! this module is pure computation over the resulting list.
10
11/// The active `@`-token under the cursor.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct AtToken {
14    /// Byte offset of the `@` itself.
15    pub start: usize,
16    /// Byte offset where the query text begins (start + 1).
17    pub query_start: usize,
18    /// Byte offset where the query ends (== cursor).
19    pub query_end: usize,
20}
21
22/// Detect an `@`-mention token containing the cursor: an `@` at the buffer
23/// start or right after whitespace, with no whitespace between it and the
24/// cursor. `user@host` never triggers (the `@` follows a non-space char);
25/// UTF-8 boundaries are respected throughout (byte-offset scanning only at
26/// char boundaries).
27pub 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    // Walk back from the cursor to the nearest `@`; whitespace before an `@`
33    // is found means no active token.
34    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    // The `@` must open a token: buffer start or preceded by whitespace.
47    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
60/// Rank `files` against `query`, best first, at most `limit` results.
61/// Deterministic: nucleo score descending, input order breaking ties; an
62/// empty query returns the lexicographic head of the (pre-sorted) list.
63pub 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    // Score DESC, input order (idx ASC) as the tie-break — fully stable.
80    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        // Cursor right after "@main.rs" (byte 16).
108        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        // Cursor inside "host".
117        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        // Cursor at end (a char boundary past multibyte chars).
136        let tok = active_at_token(buf, buf.len()).expect("token");
137        assert_eq!(&buf[tok.query_start..tok.query_end], "tê");
138        // A cursor on a non-boundary byte returns None rather than panicking.
139        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}