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