opendev_tui/autocomplete/
mod.rs1pub mod completers;
8pub mod file_finder;
9pub mod formatters;
10pub mod strategies;
11
12use crate::controllers::SlashCommand;
13use completers::{CommandCompleter, Completer, FileCompleter, SymbolCompleter};
14use formatters::CompletionFormatter;
15use strategies::CompletionStrategy;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum CompletionKind {
22 Command,
24 File,
26 Symbol,
28}
29
30#[derive(Debug, Clone)]
32pub struct CompletionItem {
33 pub insert_text: String,
35 pub label: String,
37 pub description: String,
39 pub kind: CompletionKind,
41 pub score: f64,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Trigger {
50 Slash,
52 SlashArg { command: String },
56 At,
58 Tab,
60}
61
62pub fn detect_trigger(text_before_cursor: &str) -> Option<(Trigger, String)> {
66 if let Some(pos) = text_before_cursor.rfind('@') {
68 let after_at = &text_before_cursor[pos + 1..];
70 if !after_at.contains(' ') {
72 return Some((Trigger::At, after_at.to_string()));
73 }
74 }
75
76 if let Some(pos) = text_before_cursor.rfind('/') {
77 let valid_start = pos == 0
79 || text_before_cursor
80 .as_bytes()
81 .get(pos - 1)
82 .map(|&b| b == b' ' || b == b'\t' || b == b'\n')
83 .unwrap_or(false);
84 if valid_start {
85 let after_slash = &text_before_cursor[pos + 1..];
86 if after_slash.contains(' ') {
87 let parts: Vec<&str> = after_slash.splitn(2, ' ').collect();
89 let command = parts[0].to_string();
90 let arg_query = parts.get(1).copied().unwrap_or("").to_string();
91 return Some((Trigger::SlashArg { command }, arg_query));
92 }
93 return Some((Trigger::Slash, after_slash.to_string()));
94 }
95 }
96
97 None
98}
99
100impl std::fmt::Debug for AutocompleteEngine {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct("AutocompleteEngine")
106 .field("visible", &self.visible)
107 .field("selected", &self.selected)
108 .field("items_count", &self.items.len())
109 .finish()
110 }
111}
112
113pub struct AutocompleteEngine {
114 command_completer: CommandCompleter,
115 file_completer: FileCompleter,
116 symbol_completer: SymbolCompleter,
117 strategy: CompletionStrategy,
118
119 items: Vec<CompletionItem>,
121 selected: usize,
123 visible: bool,
125 trigger_len: usize,
127}
128
129impl AutocompleteEngine {
130 pub fn new(working_dir: std::path::PathBuf) -> Self {
132 Self {
133 command_completer: CommandCompleter::new(None),
134 file_completer: FileCompleter::new(working_dir),
135 symbol_completer: SymbolCompleter::new(),
136 strategy: CompletionStrategy::default(),
137 items: Vec::new(),
138 selected: 0,
139 visible: false,
140 trigger_len: 0,
141 }
142 }
143
144 pub fn update(&mut self, text_before_cursor: &str) {
148 match detect_trigger(text_before_cursor) {
149 Some((Trigger::Slash, ref query)) => {
150 self.items = self.command_completer.complete(query);
151 self.strategy.sort(&mut self.items);
152 self.selected = 0;
153 self.visible = !self.items.is_empty();
154 self.trigger_len = 1 + query.len(); }
156 Some((Trigger::SlashArg { ref command }, ref query)) => {
157 self.items = self.command_completer.complete_args(command, query);
158 self.strategy.sort(&mut self.items);
159 self.selected = 0;
160 self.visible = !self.items.is_empty();
161 self.trigger_len = query.len();
163 }
164 Some((Trigger::At, ref query)) => {
165 self.items = self.file_completer.complete(query);
166 self.strategy.sort(&mut self.items);
167 self.selected = 0;
168 self.visible = !self.items.is_empty();
169 self.trigger_len = 1 + query.len(); }
171 Some((Trigger::Tab, ref query)) => {
172 let mut results = self.file_completer.complete(query);
174 results.extend(self.symbol_completer.complete(query));
175 self.strategy.sort(&mut results);
176 self.items = results;
177 self.selected = 0;
178 self.visible = !self.items.is_empty();
179 self.trigger_len = query.len();
180 }
181 None => {
182 self.dismiss();
183 }
184 }
185 }
186
187 pub fn accept(&mut self) -> Option<(String, usize)> {
192 if !self.visible || self.items.is_empty() {
193 return None;
194 }
195 let item = &self.items[self.selected];
196 let insert = item.insert_text.clone();
197 let delete_count = self.trigger_len;
198 self.dismiss();
199 Some((insert, delete_count))
200 }
201
202 pub fn select_prev(&mut self) {
204 if !self.items.is_empty() {
205 self.selected = if self.selected == 0 {
206 self.items.len() - 1
207 } else {
208 self.selected - 1
209 };
210 }
211 }
212
213 pub fn select_next(&mut self) {
215 if !self.items.is_empty() {
216 self.selected = (self.selected + 1) % self.items.len();
217 }
218 }
219
220 pub fn dismiss(&mut self) {
222 self.visible = false;
223 self.items.clear();
224 self.selected = 0;
225 self.trigger_len = 0;
226 }
227
228 pub fn is_visible(&self) -> bool {
230 self.visible
231 }
232
233 pub fn items(&self) -> &[CompletionItem] {
235 &self.items
236 }
237
238 pub fn selected_index(&self) -> usize {
240 self.selected
241 }
242
243 pub fn render_popup(&self) -> Vec<(String, String, bool)> {
247 self.items
248 .iter()
249 .enumerate()
250 .map(|(i, item)| {
251 let display = CompletionFormatter::format(item);
252 (display.0, display.1, i == self.selected)
253 })
254 .collect()
255 }
256
257 pub fn record_frecency(&mut self, text: &str) {
259 self.strategy.record_access(text);
260 }
261
262 pub fn add_commands(&mut self, commands: &[SlashCommand]) {
264 self.command_completer.add_commands(commands);
265 }
266
267 pub fn set_working_dir(&mut self, dir: std::path::PathBuf) {
269 self.file_completer = FileCompleter::new(dir);
270 }
271}
272
273#[cfg(test)]
276mod tests;