1#[derive(Debug)]
15pub struct Completion {
16 start: usize,
18 len: usize,
21 items: Vec<String>,
22 idx: Option<usize>,
24}
25
26impl Completion {
27 pub fn build<'a>(
30 text: &str,
31 cursor: usize,
32 emotes: impl Iterator<Item = &'a str>,
33 users: impl Iterator<Item = &'a str>,
34 ) -> Option<Completion> {
35 let chars: Vec<char> = text.chars().collect();
36 let cursor = cursor.min(chars.len());
37 let start = chars[..cursor]
38 .iter()
39 .rposition(|c| c.is_whitespace())
40 .map(|i| i + 1)
41 .unwrap_or(0);
42 if start == cursor {
43 return None; }
45 let word: String = chars[start..cursor].iter().collect();
46 let (prefix, at) = match word.strip_prefix('@') {
47 Some(rest) => (rest, true),
48 None => (word.as_str(), false),
49 };
50 if prefix.is_empty() {
51 return None;
52 }
53
54 let matches = |c: &str| {
57 c.get(..prefix.len())
58 .is_some_and(|h| h.eq_ignore_ascii_case(prefix))
59 };
60 let mut items: Vec<String> = Vec::new();
61 let mut push = |s: String| {
62 if !items.contains(&s) {
63 items.push(s);
64 }
65 };
66 if !at {
67 let mut hits: Vec<&str> = emotes.filter(|e| matches(e)).collect();
68 hits.sort_unstable_by_key(|a| twin_key(a, prefix));
69 for e in hits {
70 push(e.to_string());
71 }
72 }
73 let mut hits: Vec<&str> = users.filter(|u| matches(u)).collect();
74 hits.sort_unstable_by_key(|a| twin_key(a, prefix));
75 for u in hits {
76 push(if at { format!("@{u}") } else { u.to_string() });
77 }
78
79 (!items.is_empty()).then_some(Completion {
80 start,
81 len: cursor - start,
82 items,
83 idx: None,
84 })
85 }
86
87 pub fn advance(&mut self, dir: i32) -> (usize, usize, &str) {
91 let n = self.items.len();
92 let next = match (self.idx, dir >= 0) {
93 (None, true) => 0,
94 (None, false) => n - 1,
95 (Some(i), true) => (i + 1) % n,
96 (Some(i), false) => (i + n - 1) % n,
97 };
98 self.idx = Some(next);
99 let (lo, hi) = (self.start, self.start + self.len);
100 self.len = self.items[next].chars().count();
101 (lo, hi, &self.items[next])
102 }
103}
104
105fn twin_key(name: &str, typed: &str) -> (String, bool, String) {
108 let exact = name.get(..typed.len()).is_some_and(|h| h == typed);
109 (name.to_ascii_lowercase(), !exact, name.to_string())
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 fn build(text: &str, cursor: usize) -> Option<Completion> {
117 let emotes = ["KEKW", "Kappa", "kekHeim", "GAMBA"];
118 let users = ["kekuser", "Gamba_Andy", "mellen"];
119 Completion::build(text, cursor, emotes.iter().copied(), users.iter().copied())
120 }
121
122 #[test]
123 fn completes_emotes_case_insensitively_then_users() {
124 let mut c = build("hi kek", 6).unwrap();
125 assert_eq!(c.advance(1), (3, 6, "kekHeim"));
126 assert_eq!(c.advance(1).2, "KEKW");
127 assert_eq!(c.advance(1).2, "kekuser");
128 assert_eq!(c.advance(1).2, "kekHeim", "wraps");
129 }
130
131 #[test]
132 fn advance_replaces_the_previous_completion_not_the_word() {
133 let mut c = build("kek", 3).unwrap();
134 let (lo, hi, s) = c.advance(1);
135 assert_eq!((lo, hi, s), (0, 3, "kekHeim"));
136 let (lo, hi, s) = c.advance(1);
138 assert_eq!((lo, hi), (0, 7));
139 assert_eq!(s, "KEKW");
140 }
141
142 #[test]
143 fn backwards_starts_at_the_end_of_the_list() {
144 let mut c = build("kek", 3).unwrap();
145 assert_eq!(c.advance(-1).2, "kekuser");
146 assert_eq!(c.advance(-1).2, "KEKW");
147 }
148
149 #[test]
150 fn at_prefix_completes_users_only_and_keeps_the_at() {
151 let mut c = build("yo @ga", 6).unwrap();
152 assert_eq!(c.advance(1), (3, 6, "@Gamba_Andy"));
153 assert!(
154 build("yo @kekw", 8).is_none(),
155 "no user matches — emotes excluded"
156 );
157 }
158
159 #[test]
160 fn nothing_to_complete_yields_none() {
161 assert!(build("", 0).is_none());
162 assert!(build("word ", 5).is_none(), "cursor after a space");
163 assert!(build("zzz", 3).is_none(), "no matches");
164 assert!(build("@", 1).is_none(), "bare @");
165 }
166
167 #[test]
168 fn word_boundaries_are_char_safe() {
169 let mut c = build("キツネ kek", 7).unwrap();
171 assert_eq!(c.advance(1), (4, 7, "kekHeim"));
172 }
173
174 #[test]
175 fn case_twins_both_stay_and_the_typed_spelling_leads() {
176 let emotes = ["sadge", "Sadge", "SADGE"];
177 let run = |t: &str| {
178 let mut c =
179 Completion::build(t, t.len(), emotes.iter().copied(), [].into_iter()).unwrap();
180 (0..3)
181 .map(|_| c.advance(1).2.to_string())
182 .collect::<Vec<_>>()
183 };
184 assert_eq!(run("Sad"), ["Sadge", "SADGE", "sadge"]);
185 assert_eq!(run("sad"), ["sadge", "SADGE", "Sadge"]);
186 let rev = ["Sadge", "SADGE", "sadge"];
187 let mut c = Completion::build("sad", 3, rev.iter().copied(), [].into_iter()).unwrap();
188 assert_eq!(c.advance(1).2, "sadge", "input order never matters");
189 }
190}