Skip to main content

cqlite_cli/repl/
completion.rs

1// Command Line Completion Engine
2//
3// Provides intelligent auto-completion for CQL commands, table names, column names,
4// and meta-commands in the REPL environment.
5
6use super::{CompletionContext, ReplResult};
7use std::collections::HashSet;
8
9/// Completion suggestion with metadata
10#[derive(Debug, Clone, PartialEq)]
11pub struct CompletionSuggestion {
12    /// The completion text
13    pub text: String,
14    /// Display text (may be different from completion text)
15    pub display: String,
16    /// Description of the suggestion
17    pub description: Option<String>,
18    /// Type of completion
19    pub completion_type: CompletionType,
20    /// Priority (higher = more relevant)
21    pub priority: u8,
22}
23
24/// Types of completions
25#[derive(Debug, Clone, PartialEq)]
26pub enum CompletionType {
27    /// CQL keyword (SELECT, FROM, etc.)
28    Keyword,
29    /// Meta-command (:help, :quit, etc.)
30    MetaCommand,
31    /// Table name
32    Table,
33    /// Column name
34    Column,
35    /// Keyspace name
36    Keyspace,
37    /// Function name
38    Function,
39    /// Data type
40    DataType,
41    /// File path
42    FilePath,
43    /// Configuration option
44    ConfigOption,
45}
46
47/// Completion engine
48pub struct CompletionEngine {
49    /// CQL keywords cache
50    cql_keywords: HashSet<String>,
51    /// Meta-commands cache
52    meta_commands: HashSet<String>,
53    /// CQL functions cache
54    cql_functions: HashSet<String>,
55    /// Data types cache
56    data_types: HashSet<String>,
57    /// Configuration options cache
58    config_options: HashSet<String>,
59}
60
61impl CompletionEngine {
62    /// Create a new completion engine
63    pub fn new() -> Self {
64        let mut engine = Self {
65            cql_keywords: HashSet::new(),
66            meta_commands: HashSet::new(),
67            cql_functions: HashSet::new(),
68            data_types: HashSet::new(),
69            config_options: HashSet::new(),
70        };
71
72        engine.initialize_static_completions();
73        engine
74    }
75
76    /// Initialize static completion data
77    fn initialize_static_completions(&mut self) {
78        // CQL Keywords
79        let keywords = [
80            "SELECT",
81            "INSERT",
82            "UPDATE",
83            "DELETE",
84            "TRUNCATE",
85            "CREATE",
86            "ALTER",
87            "DROP",
88            "USE",
89            "DESCRIBE",
90            "FROM",
91            "WHERE",
92            "ORDER",
93            "GROUP",
94            "HAVING",
95            "BY",
96            "ASC",
97            "DESC",
98            "LIMIT",
99            "OFFSET",
100            "AND",
101            "OR",
102            "NOT",
103            "IN",
104            "LIKE",
105            "IS",
106            "NULL",
107            "PRIMARY",
108            "KEY",
109            "CLUSTERING",
110            "INDEX",
111            "TABLE",
112            "KEYSPACE",
113            "TYPE",
114            "FUNCTION",
115            "IF",
116            "EXISTS",
117            "WITH",
118            "OPTIONS",
119            "ALLOW",
120            "FILTERING",
121            "TOKEN",
122            "COUNT",
123            "SUM",
124            "AVG",
125            "MIN",
126            "MAX",
127            "DISTINCT",
128            "AS",
129            "CAST",
130            "TTL",
131            "WRITETIME",
132            "BATCH",
133            "BEGIN",
134            "UNLOGGED",
135            "COUNTER",
136            "STATIC",
137            "FROZEN",
138            "TUPLE",
139            "MAP",
140            "SET",
141            "LIST",
142        ];
143
144        for keyword in &keywords {
145            self.cql_keywords.insert(keyword.to_string());
146        }
147
148        // Meta-commands
149        let meta_commands = [
150            ":help",
151            ":quit",
152            ":exit",
153            ":q",
154            ":clear",
155            ":cls",
156            ":tables",
157            ":list",
158            ":describe",
159            ":desc",
160            ":use",
161            ":config",
162            ":show",
163            ":set",
164            ":history",
165            ":timing",
166            ":source",
167            ":load",
168            ":keyspaces",
169            ":info",
170            ":schema",
171        ];
172
173        for cmd in &meta_commands {
174            self.meta_commands.insert(cmd.to_string());
175        }
176
177        // CQL Functions
178        let functions = [
179            "now",
180            "uuid",
181            "timeuuid",
182            "dateof",
183            "unixTimestampOf",
184            "toDate",
185            "toTimestamp",
186            "toUnixTimestamp",
187            "minTimeuuid",
188            "maxTimeuuid",
189            "count",
190            "sum",
191            "avg",
192            "min",
193            "max",
194            "writetime",
195            "ttl",
196            "token",
197            "cast",
198            "toJson",
199            "fromJson",
200            "blobasbigint",
201            "blobAsInt",
202            "blobAsText",
203            "bigintAsBlob",
204            "intAsBlob",
205            "textAsBlob",
206        ];
207
208        for func in &functions {
209            self.cql_functions.insert(func.to_string());
210        }
211
212        // Data types
213        let data_types = [
214            "ascii",
215            "bigint",
216            "blob",
217            "boolean",
218            "counter",
219            "date",
220            "decimal",
221            "double",
222            "duration",
223            "float",
224            "inet",
225            "int",
226            "smallint",
227            "text",
228            "time",
229            "timestamp",
230            "timeuuid",
231            "tinyint",
232            "uuid",
233            "varchar",
234            "varint",
235            "map",
236            "set",
237            "list",
238            "tuple",
239            "frozen",
240        ];
241
242        for dtype in &data_types {
243            self.data_types.insert(dtype.to_string());
244        }
245
246        // Configuration options
247        let config_options = [
248            "output_format",
249            "page_size",
250            "show_timing",
251            "enable_paging",
252            "enable_colors",
253            "data_dir",
254            "keyspace",
255            "prompt",
256            "prompt_continuation",
257            "max_history_size",
258        ];
259
260        for option in &config_options {
261            self.config_options.insert(option.to_string());
262        }
263    }
264
265    /// Get completions for the given context
266    pub fn get_completions(
267        &self,
268        context: &CompletionContext,
269    ) -> ReplResult<Vec<CompletionSuggestion>> {
270        let mut suggestions = Vec::new();
271
272        // Parse the current input to understand context
273        let analysis = self.analyze_input(&context.line, context.pos);
274
275        match analysis.completion_context {
276            InputContext::MetaCommand => {
277                suggestions.extend(self.complete_meta_commands(&analysis.current_word));
278            }
279            InputContext::CqlKeyword => {
280                suggestions.extend(self.complete_cql_keywords(&analysis.current_word));
281                suggestions.extend(self.complete_functions(&analysis.current_word));
282            }
283            InputContext::TableName => {
284                suggestions
285                    .extend(self.complete_table_names(&analysis.current_word, &context.tables));
286            }
287            InputContext::ColumnName => {
288                suggestions
289                    .extend(self.complete_column_names(&analysis.current_word, &context.tables));
290            }
291            InputContext::KeyspaceName => {
292                suggestions.extend(
293                    self.complete_keyspace_names(&analysis.current_word, &context.keyspaces),
294                );
295            }
296            InputContext::DataType => {
297                suggestions.extend(self.complete_data_types(&analysis.current_word));
298            }
299            InputContext::ConfigOption => {
300                suggestions.extend(self.complete_config_options(&analysis.current_word));
301            }
302            InputContext::FilePath => {
303                suggestions.extend(self.complete_file_paths(&analysis.current_word));
304            }
305            InputContext::Unknown => {
306                // Provide general completions
307                suggestions.extend(self.complete_cql_keywords(&analysis.current_word));
308                suggestions.extend(self.complete_meta_commands(&analysis.current_word));
309            }
310        }
311
312        // Sort by priority and relevance
313        suggestions.sort_by(|a, b| {
314            b.priority
315                .cmp(&a.priority)
316                .then_with(|| a.text.len().cmp(&b.text.len()))
317                .then_with(|| a.text.cmp(&b.text))
318        });
319
320        // Limit number of suggestions
321        suggestions.truncate(50);
322
323        Ok(suggestions)
324    }
325
326    /// Analyze input to determine completion context
327    fn analyze_input(&self, line: &str, pos: usize) -> InputAnalysis {
328        let safe_pos = pos.min(line.len());
329        let text_before_cursor = &line[..safe_pos];
330
331        // Find the current word being typed
332        let current_word = self.extract_current_word(text_before_cursor);
333
334        // Determine context based on input structure
335        let context = if text_before_cursor.trim_start().starts_with(':') {
336            if text_before_cursor.contains(" config ") {
337                InputContext::ConfigOption
338            } else if text_before_cursor.contains(" use ") {
339                InputContext::KeyspaceName
340            } else if text_before_cursor.contains(" describe ")
341                || text_before_cursor.contains(" desc ")
342            {
343                InputContext::TableName
344            } else if text_before_cursor.contains(" source ")
345                || text_before_cursor.contains(" load ")
346            {
347                InputContext::FilePath
348            } else {
349                InputContext::MetaCommand
350            }
351        } else {
352            self.analyze_cql_context(text_before_cursor)
353        };
354
355        InputAnalysis {
356            current_word,
357            completion_context: context,
358            line_before_cursor: text_before_cursor.to_string(),
359        }
360    }
361
362    /// Analyze CQL context
363    fn analyze_cql_context(&self, text: &str) -> InputContext {
364        let upper_text = text.to_uppercase();
365        let words: Vec<&str> = upper_text.split_whitespace().collect();
366
367        if words.is_empty() {
368            return InputContext::CqlKeyword;
369        }
370
371        // Look for context clues
372        if let Some(last_word) = words.last() {
373            if words.len() >= 2 {
374                let prev_word = words[words.len() - 2];
375
376                // After FROM, join with, etc. - expect table names
377                if prev_word == "FROM"
378                    || prev_word == "JOIN"
379                    || prev_word == "UPDATE"
380                    || prev_word == "INTO"
381                {
382                    return InputContext::TableName;
383                }
384
385                // After USE - expect keyspace names
386                if prev_word == "USE" {
387                    return InputContext::KeyspaceName;
388                }
389
390                // After data type keywords - expect data types
391                if words.len() >= 3 {
392                    let context_phrase = format!("{} {}", words[words.len() - 3], prev_word);
393                    if context_phrase.contains("PRIMARY KEY")
394                        || context_phrase.contains("CLUSTERING KEY")
395                        || prev_word == "TYPE"
396                    {
397                        return InputContext::DataType;
398                    }
399                }
400            }
401
402            // If current word looks like a function call
403            if last_word.ends_with('(') || text.contains('(') {
404                return InputContext::CqlKeyword; // Functions are included in keyword completion
405            }
406        }
407
408        // Check if we're in a SELECT list (after SELECT but before FROM)
409        if upper_text.contains("SELECT") && !upper_text.contains("FROM") {
410            return InputContext::ColumnName;
411        }
412
413        // Default to keyword completion
414        InputContext::CqlKeyword
415    }
416
417    /// Extract the current word being typed
418    fn extract_current_word(&self, text: &str) -> String {
419        let chars: Vec<char> = text.chars().collect();
420        let mut start = chars.len();
421        let end = chars.len();
422
423        // Find the start of the current word
424        for i in (0..chars.len()).rev() {
425            let ch = chars[i];
426            if ch.is_whitespace() || ch == '(' || ch == ')' || ch == ',' || ch == ';' {
427                start = i + 1;
428                break;
429            }
430            if i == 0 {
431                start = 0;
432                break;
433            }
434        }
435
436        // Extract the word
437        if start < end {
438            chars[start..end].iter().collect()
439        } else {
440            String::new()
441        }
442    }
443
444    /// Complete meta-commands
445    fn complete_meta_commands(&self, prefix: &str) -> Vec<CompletionSuggestion> {
446        let mut suggestions = Vec::new();
447        let lower_prefix = prefix.to_lowercase();
448
449        for cmd in &self.meta_commands {
450            if cmd.to_lowercase().starts_with(&lower_prefix) {
451                let description = self.get_meta_command_description(cmd);
452                suggestions.push(CompletionSuggestion {
453                    text: cmd.clone(),
454                    display: cmd.clone(),
455                    description: Some(description),
456                    completion_type: CompletionType::MetaCommand,
457                    priority: self.calculate_priority(cmd, prefix, 8),
458                });
459            }
460        }
461
462        suggestions
463    }
464
465    /// Complete CQL keywords
466    fn complete_cql_keywords(&self, prefix: &str) -> Vec<CompletionSuggestion> {
467        let mut suggestions = Vec::new();
468        let upper_prefix = prefix.to_uppercase();
469
470        for keyword in &self.cql_keywords {
471            if keyword.starts_with(&upper_prefix) {
472                suggestions.push(CompletionSuggestion {
473                    text: keyword.clone(),
474                    display: keyword.clone(),
475                    description: None,
476                    completion_type: CompletionType::Keyword,
477                    priority: self.calculate_priority(keyword, prefix, 7),
478                });
479            }
480        }
481
482        suggestions
483    }
484
485    /// Complete function names
486    fn complete_functions(&self, prefix: &str) -> Vec<CompletionSuggestion> {
487        let mut suggestions = Vec::new();
488        let lower_prefix = prefix.to_lowercase();
489
490        for func in &self.cql_functions {
491            if func.to_lowercase().starts_with(&lower_prefix) {
492                suggestions.push(CompletionSuggestion {
493                    text: format!("{}()", func),
494                    display: format!("{}()", func),
495                    description: Some(format!("CQL function: {}", func)),
496                    completion_type: CompletionType::Function,
497                    priority: self.calculate_priority(func, prefix, 6),
498                });
499            }
500        }
501
502        suggestions
503    }
504
505    /// Complete table names
506    fn complete_table_names(&self, prefix: &str, tables: &[String]) -> Vec<CompletionSuggestion> {
507        let mut suggestions = Vec::new();
508        let lower_prefix = prefix.to_lowercase();
509
510        for table in tables {
511            if table.to_lowercase().starts_with(&lower_prefix) {
512                suggestions.push(CompletionSuggestion {
513                    text: table.clone(),
514                    display: table.clone(),
515                    description: Some("Table".to_string()),
516                    completion_type: CompletionType::Table,
517                    priority: self.calculate_priority(table, prefix, 9),
518                });
519            }
520        }
521
522        suggestions
523    }
524
525    /// Complete column names (simplified - would need schema info)
526    fn complete_column_names(&self, prefix: &str, _tables: &[String]) -> Vec<CompletionSuggestion> {
527        let mut suggestions = Vec::new();
528        let lower_prefix = prefix.to_lowercase();
529
530        // Common column names as fallback
531        let common_columns = [
532            "id",
533            "name",
534            "email",
535            "created_at",
536            "updated_at",
537            "user_id",
538            "timestamp",
539            "value",
540            "data",
541            "type",
542            "status",
543            "description",
544            "title",
545            "content",
546        ];
547
548        for col in &common_columns {
549            if col.starts_with(&lower_prefix) {
550                suggestions.push(CompletionSuggestion {
551                    text: col.to_string(),
552                    display: col.to_string(),
553                    description: Some("Column".to_string()),
554                    completion_type: CompletionType::Column,
555                    priority: self.calculate_priority(col, prefix, 5),
556                });
557            }
558        }
559
560        suggestions
561    }
562
563    /// Complete keyspace names
564    fn complete_keyspace_names(
565        &self,
566        prefix: &str,
567        keyspaces: &[String],
568    ) -> Vec<CompletionSuggestion> {
569        let mut suggestions = Vec::new();
570        let lower_prefix = prefix.to_lowercase();
571
572        for keyspace in keyspaces {
573            if keyspace.to_lowercase().starts_with(&lower_prefix) {
574                suggestions.push(CompletionSuggestion {
575                    text: keyspace.clone(),
576                    display: keyspace.clone(),
577                    description: Some("Keyspace".to_string()),
578                    completion_type: CompletionType::Keyspace,
579                    priority: self.calculate_priority(keyspace, prefix, 8),
580                });
581            }
582        }
583
584        suggestions
585    }
586
587    /// Complete data types
588    fn complete_data_types(&self, prefix: &str) -> Vec<CompletionSuggestion> {
589        let mut suggestions = Vec::new();
590        let lower_prefix = prefix.to_lowercase();
591
592        for dtype in &self.data_types {
593            if dtype.starts_with(&lower_prefix) {
594                suggestions.push(CompletionSuggestion {
595                    text: dtype.clone(),
596                    display: dtype.clone(),
597                    description: Some("Data type".to_string()),
598                    completion_type: CompletionType::DataType,
599                    priority: self.calculate_priority(dtype, prefix, 6),
600                });
601            }
602        }
603
604        suggestions
605    }
606
607    /// Complete configuration options
608    fn complete_config_options(&self, prefix: &str) -> Vec<CompletionSuggestion> {
609        let mut suggestions = Vec::new();
610        let lower_prefix = prefix.to_lowercase();
611
612        for option in &self.config_options {
613            if option.starts_with(&lower_prefix) {
614                let description = self.get_config_option_description(option);
615                suggestions.push(CompletionSuggestion {
616                    text: option.clone(),
617                    display: option.clone(),
618                    description: Some(description),
619                    completion_type: CompletionType::ConfigOption,
620                    priority: self.calculate_priority(option, prefix, 7),
621                });
622            }
623        }
624
625        suggestions
626    }
627
628    /// Complete file paths (basic implementation)
629    fn complete_file_paths(&self, prefix: &str) -> Vec<CompletionSuggestion> {
630        let mut suggestions = Vec::new();
631
632        // Basic file path completion
633        if prefix.ends_with('/') || prefix.is_empty() {
634            // Directory completion would go here
635            suggestions.push(CompletionSuggestion {
636                text: "./".to_string(),
637                display: "./".to_string(),
638                description: Some("Current directory".to_string()),
639                completion_type: CompletionType::FilePath,
640                priority: 5,
641            });
642        }
643
644        suggestions
645    }
646
647    /// Calculate completion priority based on relevance
648    fn calculate_priority(&self, suggestion: &str, prefix: &str, base_priority: u8) -> u8 {
649        if prefix.is_empty() {
650            return base_priority;
651        }
652
653        let lower_suggestion = suggestion.to_lowercase();
654        let lower_prefix = prefix.to_lowercase();
655
656        // Exact match gets highest priority
657        if lower_suggestion == lower_prefix {
658            return base_priority + 3;
659        }
660
661        // Starts with prefix gets high priority
662        if lower_suggestion.starts_with(&lower_prefix) {
663            return base_priority + 2;
664        }
665
666        // Contains prefix gets medium priority
667        if lower_suggestion.contains(&lower_prefix) {
668            return base_priority + 1;
669        }
670
671        base_priority
672    }
673
674    /// Get description for meta-commands
675    fn get_meta_command_description(&self, cmd: &str) -> String {
676        match cmd {
677            ":help" => "Show help information".to_string(),
678            ":quit" | ":exit" | ":q" => "Exit the REPL".to_string(),
679            ":clear" | ":cls" => "Clear the screen".to_string(),
680            ":tables" | ":list" => "List all tables".to_string(),
681            ":describe" | ":desc" => "Describe an object".to_string(),
682            ":use" => "Switch to a keyspace".to_string(),
683            ":config" => "Show/set configuration".to_string(),
684            ":history" => "Show command history".to_string(),
685            ":timing" => "Toggle timing display".to_string(),
686            ":source" | ":load" => "Execute commands from file".to_string(),
687            ":keyspaces" => "List all keyspaces".to_string(),
688            ":info" => "Show object information".to_string(),
689            ":schema" => "Show schema information".to_string(),
690            _ => "Meta-command".to_string(),
691        }
692    }
693
694    /// Get description for configuration options
695    fn get_config_option_description(&self, option: &str) -> String {
696        match option {
697            "output_format" => "Output format (table, csv, json, raw)".to_string(),
698            "page_size" => "Number of rows per page".to_string(),
699            "show_timing" => "Show query execution timing".to_string(),
700            "enable_paging" => "Enable result paging".to_string(),
701            "enable_colors" => "Enable colored output".to_string(),
702            "data_dir" => "Cassandra data directory path".to_string(),
703            "keyspace" => "Default keyspace".to_string(),
704            "prompt" => "REPL prompt string".to_string(),
705            "prompt_continuation" => "Multi-line prompt string".to_string(),
706            "max_history_size" => "Maximum history entries".to_string(),
707            _ => "Configuration option".to_string(),
708        }
709    }
710}
711
712/// Input analysis result
713#[derive(Debug)]
714struct InputAnalysis {
715    /// The current word being typed
716    current_word: String,
717    /// The completion context
718    completion_context: InputContext,
719    /// Text before cursor
720    line_before_cursor: String,
721}
722
723/// Context for completion
724#[derive(Debug, PartialEq)]
725enum InputContext {
726    MetaCommand,
727    CqlKeyword,
728    TableName,
729    ColumnName,
730    KeyspaceName,
731    DataType,
732    ConfigOption,
733    FilePath,
734    Unknown,
735}
736
737impl Default for CompletionEngine {
738    fn default() -> Self {
739        Self::new()
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::super::SessionState;
746    use super::*;
747
748    #[test]
749    fn test_meta_command_completion() {
750        let engine = CompletionEngine::new();
751        let context = CompletionContext {
752            line: ":he".to_string(),
753            pos: 3,
754            session_state: SessionState::Ready,
755            tables: vec![],
756            keyspaces: vec![],
757        };
758
759        let completions = engine.get_completions(&context).unwrap();
760        assert!(!completions.is_empty());
761        assert!(completions.iter().any(|c| c.text == ":help"));
762    }
763
764    #[test]
765    fn test_keyword_completion() {
766        let engine = CompletionEngine::new();
767        let context = CompletionContext {
768            line: "SEL".to_string(),
769            pos: 3,
770            session_state: SessionState::Ready,
771            tables: vec![],
772            keyspaces: vec![],
773        };
774
775        let completions = engine.get_completions(&context).unwrap();
776        assert!(!completions.is_empty());
777        assert!(completions.iter().any(|c| c.text == "SELECT"));
778    }
779
780    #[test]
781    fn test_table_completion() {
782        let engine = CompletionEngine::new();
783        let tables = vec!["users".to_string(), "orders".to_string()];
784        let context = CompletionContext {
785            line: "SELECT * FROM us".to_string(),
786            pos: 16,
787            session_state: SessionState::Ready,
788            tables,
789            keyspaces: vec![],
790        };
791
792        let completions = engine.get_completions(&context).unwrap();
793        assert!(completions.iter().any(|c| c.text == "users"));
794    }
795
796    #[test]
797    fn test_current_word_extraction() {
798        let engine = CompletionEngine::new();
799
800        assert_eq!(engine.extract_current_word("SELECT * FROM us"), "us");
801        assert_eq!(engine.extract_current_word(":he"), ":he");
802        // "(" is a word boundary, so "count(" returns empty string
803        assert_eq!(engine.extract_current_word("SELECT count("), "");
804        assert_eq!(
805            engine.extract_current_word("SELECT * FROM users WHERE name = 'jo"),
806            "'jo"
807        );
808    }
809}