Skip to main content

opendev_tui/autocomplete/
mod.rs

1//! Autocomplete engine for the TUI input widget.
2//!
3//! Manages completion state, detects triggers (`/` for commands, `@` for file
4//! mentions, Tab for general completion), and renders a popup of ranked
5//! completion items.
6
7pub 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// ── Completion item ────────────────────────────────────────────────
18
19/// The kind of completion a [`CompletionItem`] represents.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum CompletionKind {
22    /// A slash command (e.g. `/help`).
23    Command,
24    /// A file path (triggered by `@`).
25    File,
26    /// A code symbol.
27    Symbol,
28}
29
30/// A single completion suggestion.
31#[derive(Debug, Clone)]
32pub struct CompletionItem {
33    /// Text inserted when the completion is accepted.
34    pub insert_text: String,
35    /// Short label shown in the popup.
36    pub label: String,
37    /// Optional description / meta shown to the right.
38    pub description: String,
39    /// Kind of completion (command, file, symbol).
40    pub kind: CompletionKind,
41    /// Score used for ranking (higher = better).
42    pub score: f64,
43}
44
45// ── Trigger detection ──────────────────────────────────────────────
46
47/// Trigger character that activated autocompletion.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Trigger {
50    /// `/` at the beginning or after whitespace (slash commands).
51    Slash,
52    /// Slash command argument: the command name has been typed and the user
53    /// is now typing an argument. `command` is the command name (without `/`),
54    /// and the query is the partial argument text.
55    SlashArg { command: String },
56    /// `@` for file mentions.
57    At,
58    /// Tab key for general completion.
59    Tab,
60}
61
62/// Detect the active trigger and the partial word in `text_before_cursor`.
63///
64/// Returns `None` when no trigger is active.
65pub fn detect_trigger(text_before_cursor: &str) -> Option<(Trigger, String)> {
66    // Walk backwards to find the last `@` or `/` not preceded by a non-whitespace char.
67    if let Some(pos) = text_before_cursor.rfind('@') {
68        // `@` can appear anywhere
69        let after_at = &text_before_cursor[pos + 1..];
70        // Valid if no spaces in the partial word after @
71        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        // Only trigger if `/` is at position 0 or preceded by whitespace
78        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                // User has typed a command and a space — argument mode
88                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
100// ── AutocompleteEngine ─────────────────────────────────────────────
101
102/// Central autocomplete engine that drives the popup.
103impl 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    /// Currently visible completions.
120    items: Vec<CompletionItem>,
121    /// Index of the selected item inside `items`.
122    selected: usize,
123    /// Whether the popup is visible.
124    visible: bool,
125    /// Length of the trigger + query text to delete on accept.
126    trigger_len: usize,
127}
128
129impl AutocompleteEngine {
130    /// Create a new engine rooted at `working_dir`.
131    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    /// Update completions based on the text before the cursor.
145    ///
146    /// Call this on every keystroke (or after a small debounce).
147    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(); // '/' + query
155            }
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                // Delete only the argument portion (not the command)
162                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(); // '@' + query
170            }
171            Some((Trigger::Tab, ref query)) => {
172                // Tab completion: try files, then symbols
173                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    /// Accept the currently selected completion.
188    ///
189    /// Returns the text to insert and the number of characters to delete
190    /// before the cursor (the trigger + partial word).
191    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    /// Move selection up.
203    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    /// Move selection down.
214    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    /// Hide the popup.
221    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    /// Whether the popup is currently visible.
229    pub fn is_visible(&self) -> bool {
230        self.visible
231    }
232
233    /// Currently visible completion items.
234    pub fn items(&self) -> &[CompletionItem] {
235        &self.items
236    }
237
238    /// Index of the selected item.
239    pub fn selected_index(&self) -> usize {
240        self.selected
241    }
242
243    /// Render the popup as a list of formatted display lines.
244    ///
245    /// Each line is `(label, description, is_selected)`.
246    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    /// Register a frecency access for the given text.
258    pub fn record_frecency(&mut self, text: &str) {
259        self.strategy.record_access(text);
260    }
261
262    /// Add custom slash commands (extends the built-in set).
263    pub fn add_commands(&mut self, commands: &[SlashCommand]) {
264        self.command_completer.add_commands(commands);
265    }
266
267    /// Update the working directory for file completion.
268    pub fn set_working_dir(&mut self, dir: std::path::PathBuf) {
269        self.file_completer = FileCompleter::new(dir);
270    }
271}
272
273// ── Tests ──────────────────────────────────────────────────────────
274
275#[cfg(test)]
276mod tests;