sql_cli/
completion_manager.rs

1use std::collections::HashSet;
2
3/// Manages tab completion for SQL queries
4/// Extracted from the monolithic `enhanced_tui.rs`
5#[derive(Debug, Clone)]
6pub struct CompletionManager {
7    /// Current completion suggestions
8    suggestions: Vec<String>,
9
10    /// Current index in suggestions list
11    current_index: usize,
12
13    /// Last query we generated suggestions for
14    last_query: String,
15
16    /// Last cursor position for suggestions
17    last_cursor_pos: usize,
18
19    /// Available table names for completion
20    table_names: HashSet<String>,
21
22    /// Available column names per table
23    column_names: std::collections::HashMap<String, Vec<String>>,
24}
25
26impl Default for CompletionManager {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl CompletionManager {
33    #[must_use]
34    pub fn new() -> Self {
35        Self {
36            suggestions: Vec::new(),
37            current_index: 0,
38            last_query: String::new(),
39            last_cursor_pos: 0,
40            table_names: HashSet::new(),
41            column_names: std::collections::HashMap::new(),
42        }
43    }
44
45    /// Reset completion state
46    pub fn reset(&mut self) {
47        self.suggestions.clear();
48        self.current_index = 0;
49        self.last_query.clear();
50        self.last_cursor_pos = 0;
51    }
52
53    /// Check if we have active suggestions
54    #[must_use]
55    pub fn has_suggestions(&self) -> bool {
56        !self.suggestions.is_empty()
57    }
58
59    /// Get current suggestions
60    #[must_use]
61    pub fn suggestions(&self) -> &[String] {
62        &self.suggestions
63    }
64
65    /// Get current suggestion index
66    #[must_use]
67    pub fn current_index(&self) -> usize {
68        self.current_index
69    }
70
71    /// Get the currently selected suggestion
72    #[must_use]
73    pub fn current_suggestion(&self) -> Option<&str> {
74        if self.suggestions.is_empty() {
75            None
76        } else {
77            Some(&self.suggestions[self.current_index])
78        }
79    }
80
81    /// Move to next suggestion
82    pub fn next_suggestion(&mut self) {
83        if !self.suggestions.is_empty() {
84            self.current_index = (self.current_index + 1) % self.suggestions.len();
85        }
86    }
87
88    /// Move to previous suggestion
89    pub fn prev_suggestion(&mut self) {
90        if !self.suggestions.is_empty() {
91            self.current_index = if self.current_index == 0 {
92                self.suggestions.len() - 1
93            } else {
94                self.current_index - 1
95            };
96        }
97    }
98
99    /// Update available table names
100    pub fn set_table_names(&mut self, tables: HashSet<String>) {
101        self.table_names = tables;
102    }
103
104    /// Update column names for a table
105    pub fn set_column_names(&mut self, table: String, columns: Vec<String>) {
106        self.column_names.insert(table, columns);
107    }
108
109    /// Generate suggestions for a partial word at cursor position
110    pub fn generate_suggestions(
111        &mut self,
112        query: &str,
113        cursor_pos: usize,
114        partial_word: &str,
115    ) -> bool {
116        // Check if we already have suggestions for this position
117        if query == self.last_query && cursor_pos == self.last_cursor_pos {
118            return self.has_suggestions();
119        }
120
121        // Store query state
122        self.last_query = query.to_string();
123        self.last_cursor_pos = cursor_pos;
124        self.suggestions.clear();
125        self.current_index = 0;
126
127        // Determine context for completion
128        let context = self.analyze_context(query, cursor_pos);
129
130        // Generate suggestions based on context
131        match context {
132            CompletionContext::TableName => {
133                self.suggest_tables(partial_word);
134            }
135            CompletionContext::ColumnName(table) => {
136                self.suggest_columns(&table, partial_word);
137            }
138            CompletionContext::Keyword => {
139                self.suggest_keywords(partial_word);
140            }
141            CompletionContext::Unknown => {
142                // Try all categories
143                self.suggest_keywords(partial_word);
144                self.suggest_tables(partial_word);
145            }
146        }
147
148        self.has_suggestions()
149    }
150
151    /// Analyze query context at cursor position
152    fn analyze_context(&self, query: &str, cursor_pos: usize) -> CompletionContext {
153        let before_cursor = &query[..cursor_pos.min(query.len())];
154        let lower = before_cursor.to_lowercase();
155
156        // Simple heuristics for context detection
157        if lower.ends_with("from ") || lower.ends_with("join ") {
158            CompletionContext::TableName
159        } else if lower.contains("select ") && !lower.contains(" from") {
160            // In SELECT clause, suggest columns
161            // Try to find table name in FROM clause if it exists
162            if let Some(table) = self.extract_table_from_query(query) {
163                CompletionContext::ColumnName(table)
164            } else {
165                CompletionContext::Keyword
166            }
167        } else if lower.ends_with("where ") || lower.ends_with("and ") || lower.ends_with("or ") {
168            // In WHERE clause, suggest columns
169            if let Some(table) = self.extract_table_from_query(query) {
170                CompletionContext::ColumnName(table)
171            } else {
172                CompletionContext::Unknown
173            }
174        } else {
175            CompletionContext::Keyword
176        }
177    }
178
179    /// Extract table name from query (simple version)
180    fn extract_table_from_query(&self, query: &str) -> Option<String> {
181        let lower = query.to_lowercase();
182        if let Some(from_pos) = lower.find(" from ") {
183            let after_from = &query[from_pos + 6..];
184            let table_name = after_from
185                .split_whitespace()
186                .next()
187                .map(|s| s.trim_end_matches(',').trim_end_matches(';'))?;
188
189            // Check if it's a known table
190            if self.table_names.contains(table_name) {
191                Some(table_name.to_string())
192            } else {
193                None
194            }
195        } else {
196            None
197        }
198    }
199
200    /// Suggest table names
201    fn suggest_tables(&mut self, partial: &str) {
202        let lower_partial = partial.to_lowercase();
203        let mut suggestions: Vec<String> = self
204            .table_names
205            .iter()
206            .filter(|t| t.to_lowercase().starts_with(&lower_partial))
207            .cloned()
208            .collect();
209
210        suggestions.sort();
211        self.suggestions = suggestions;
212    }
213
214    /// Suggest column names for a table
215    fn suggest_columns(&mut self, table: &str, partial: &str) {
216        if let Some(columns) = self.column_names.get(table) {
217            let lower_partial = partial.to_lowercase();
218            let mut suggestions: Vec<String> = columns
219                .iter()
220                .filter(|c| c.to_lowercase().starts_with(&lower_partial))
221                .cloned()
222                .collect();
223
224            suggestions.sort();
225            self.suggestions = suggestions;
226        }
227    }
228
229    /// Suggest SQL keywords
230    fn suggest_keywords(&mut self, partial: &str) {
231        const SQL_KEYWORDS: &[&str] = &[
232            "SELECT",
233            "FROM",
234            "WHERE",
235            "GROUP BY",
236            "ORDER BY",
237            "HAVING",
238            "JOIN",
239            "LEFT JOIN",
240            "RIGHT JOIN",
241            "INNER JOIN",
242            "OUTER JOIN",
243            "ON",
244            "AND",
245            "OR",
246            "NOT",
247            "IN",
248            "EXISTS",
249            "BETWEEN",
250            "LIKE",
251            "AS",
252            "DISTINCT",
253            "COUNT",
254            "SUM",
255            "AVG",
256            "MIN",
257            "MAX",
258            "INSERT",
259            "UPDATE",
260            "DELETE",
261            "CREATE",
262            "DROP",
263            "ALTER",
264            "TABLE",
265            "INDEX",
266            "VIEW",
267            "UNION",
268            "ALL",
269            "LIMIT",
270            "OFFSET",
271        ];
272
273        let lower_partial = partial.to_lowercase();
274        let mut suggestions: Vec<String> = SQL_KEYWORDS
275            .iter()
276            .filter(|k| k.to_lowercase().starts_with(&lower_partial))
277            .map(|k| (*k).to_string())
278            .collect();
279
280        suggestions.sort();
281        self.suggestions = suggestions;
282    }
283}
284
285/// Context for completion
286#[derive(Debug, Clone)]
287enum CompletionContext {
288    TableName,
289    ColumnName(String), // Table name
290    Keyword,
291    Unknown,
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_completion_manager_creation() {
300        let cm = CompletionManager::new();
301        assert!(!cm.has_suggestions());
302        assert_eq!(cm.current_index(), 0);
303    }
304
305    #[test]
306    fn test_suggestion_navigation() {
307        let mut cm = CompletionManager::new();
308        cm.suggestions = vec![
309            "SELECT".to_string(),
310            "FROM".to_string(),
311            "WHERE".to_string(),
312        ];
313
314        assert_eq!(cm.current_suggestion(), Some("SELECT"));
315
316        cm.next_suggestion();
317        assert_eq!(cm.current_suggestion(), Some("FROM"));
318
319        cm.next_suggestion();
320        assert_eq!(cm.current_suggestion(), Some("WHERE"));
321
322        cm.next_suggestion();
323        assert_eq!(cm.current_suggestion(), Some("SELECT")); // Wraps around
324
325        cm.prev_suggestion();
326        assert_eq!(cm.current_suggestion(), Some("WHERE"));
327    }
328
329    #[test]
330    fn test_keyword_suggestions() {
331        let mut cm = CompletionManager::new();
332        cm.generate_suggestions("SEL", 3, "SEL");
333
334        assert!(cm.has_suggestions());
335        assert!(cm.suggestions().contains(&"SELECT".to_string()));
336    }
337
338    #[test]
339    fn test_table_suggestions() {
340        let mut cm = CompletionManager::new();
341        let mut tables = HashSet::new();
342        tables.insert("users".to_string());
343        tables.insert("orders".to_string());
344        tables.insert("products".to_string());
345        cm.set_table_names(tables);
346
347        // The query is "SELECT * FROM u" and cursor is after "u"
348        // This should trigger table name completion
349        cm.generate_suggestions("SELECT * FROM ", 14, "");
350
351        assert!(cm.has_suggestions());
352        assert!(cm.suggestions().contains(&"users".to_string()));
353    }
354}