1use std::borrow::Cow;
10use std::sync::Arc;
11
12use rustyline::completion::{Completer, Pair};
13use rustyline::highlight::Highlighter;
14use rustyline::hint::Hinter;
15use rustyline::validate::Validator;
16use rustyline::{Context, Helper};
17use tokio::runtime::Handle;
18use tokio::sync::RwLock;
19
20use crate::colorizer::CqlColorizer;
21use crate::cql_lexer::{self, GrammarContext, TokenKind};
22use crate::schema_cache::SchemaCache;
23
24const CQL_KEYWORDS: &[&str] = &[
26 "ALTER", "APPLY", "BATCH", "BEGIN", "CREATE", "DELETE", "DESCRIBE", "DROP", "GRANT", "INSERT",
27 "LIST", "REVOKE", "SELECT", "TRUNCATE", "UPDATE", "USE",
28];
29
30#[allow(dead_code)]
32const CQL_CLAUSE_KEYWORDS: &[&str] = &[
33 "ADD",
34 "AGGREGATE",
35 "ALL",
36 "ALLOW",
37 "AND",
38 "AS",
39 "ASC",
40 "AUTHORIZE",
41 "BATCH",
42 "BY",
43 "CALLED",
44 "CLUSTERING",
45 "COLUMN",
46 "COMPACT",
47 "CONTAINS",
48 "COUNT",
49 "CUSTOM",
50 "DELETE",
51 "DESC",
52 "DESCRIBE",
53 "DISTINCT",
54 "DROP",
55 "ENTRIES",
56 "EXECUTE",
57 "EXISTS",
58 "FILTERING",
59 "FINALFUNC",
60 "FROM",
61 "FROZEN",
62 "FULL",
63 "FUNCTION",
64 "FUNCTIONS",
65 "IF",
66 "IN",
67 "INDEX",
68 "INITCOND",
69 "INPUT",
70 "INSERT",
71 "INTO",
72 "IS",
73 "JSON",
74 "KEY",
75 "KEYS",
76 "KEYSPACE",
77 "KEYSPACES",
78 "LANGUAGE",
79 "LIKE",
80 "LIMIT",
81 "LIST",
82 "LOGIN",
83 "MAP",
84 "MATERIALIZED",
85 "MODIFY",
86 "NAMESPACE",
87 "NORECURSIVE",
88 "NOT",
89 "NULL",
90 "OF",
91 "ON",
92 "OR",
93 "ORDER",
94 "PARTITION",
95 "PASSWORD",
96 "PER",
97 "PERMISSION",
98 "PERMISSIONS",
99 "PRIMARY",
100 "RENAME",
101 "REPLACE",
102 "RETURNS",
103 "REVOKE",
104 "SCHEMA",
105 "SELECT",
106 "SET",
107 "SFUNC",
108 "STATIC",
109 "STORAGE",
110 "STYPE",
111 "SUPERUSER",
112 "TABLE",
113 "TABLES",
114 "TEXT",
115 "TIMESTAMP",
116 "TO",
117 "TOKEN",
118 "TRIGGER",
119 "TRUNCATE",
120 "TTL",
121 "TUPLE",
122 "TYPE",
123 "UNLOGGED",
124 "UPDATE",
125 "USER",
126 "USERS",
127 "USING",
128 "VALUES",
129 "VIEW",
130 "WHERE",
131 "WITH",
132 "WRITETIME",
133];
134
135const SHELL_COMMANDS: &[&str] = &[
137 "CAPTURE",
138 "CLEAR",
139 "CLS",
140 "CONSISTENCY",
141 "COPY",
142 "DESCRIBE",
143 "DESC",
144 "EXIT",
145 "EXPAND",
146 "HELP",
147 "LOGIN",
148 "PAGING",
149 "QUIT",
150 "SERIAL",
151 "SHOW",
152 "SOURCE",
153 "TRACING",
154];
155
156const CONSISTENCY_LEVELS: &[&str] = &[
158 "ALL",
159 "ANY",
160 "EACH_QUORUM",
161 "LOCAL_ONE",
162 "LOCAL_QUORUM",
163 "LOCAL_SERIAL",
164 "ONE",
165 "QUORUM",
166 "SERIAL",
167 "THREE",
168 "TWO",
169];
170
171const DESCRIBE_SUB_COMMANDS: &[&str] = &[
173 "AGGREGATE",
174 "AGGREGATES",
175 "CLUSTER",
176 "FULL",
177 "FUNCTION",
178 "FUNCTIONS",
179 "INDEX",
180 "KEYSPACE",
181 "KEYSPACES",
182 "MATERIALIZED",
183 "SCHEMA",
184 "TABLE",
185 "TABLES",
186 "TYPE",
187 "TYPES",
188];
189
190const SELECT_COLUMN_KEYWORDS: &[&str] = &[
192 "*",
193 "COUNT(",
194 "DISTINCT",
195 "FROM",
196 "JSON",
197 "TTL(",
198 "WRITETIME(",
199];
200
201const CREATE_TARGET_KEYWORDS: &[&str] = &[
202 "AGGREGATE",
203 "CUSTOM INDEX",
204 "FUNCTION",
205 "INDEX",
206 "KEYSPACE",
207 "MATERIALIZED VIEW",
208 "ROLE",
209 "TABLE",
210 "TRIGGER",
211 "TYPE",
212 "USER",
213];
214
215const ALTER_TARGET_KEYWORDS: &[&str] = &[
216 "KEYSPACE",
217 "MATERIALIZED VIEW",
218 "ROLE",
219 "TABLE",
220 "TYPE",
221 "USER",
222];
223
224const DROP_TARGET_KEYWORDS: &[&str] = &[
225 "AGGREGATE",
226 "FUNCTION",
227 "INDEX",
228 "KEYSPACE",
229 "MATERIALIZED VIEW",
230 "ROLE",
231 "TABLE",
232 "TRIGGER",
233 "TYPE",
234 "USER",
235];
236
237const DELETE_TARGET_KEYWORDS: &[&str] = &["FROM"];
238
239const GRANT_REVOKE_KEYWORDS: &[&str] = &[
240 "ALL",
241 "ALTER",
242 "AUTHORIZE",
243 "CREATE",
244 "DESCRIBE",
245 "DROP",
246 "EXECUTE",
247 "MODIFY",
248 "SELECT",
249];
250
251const INSERT_TARGET_KEYWORDS: &[&str] = &["INTO"];
252
253const BEGIN_TARGET_KEYWORDS: &[&str] = &["BATCH", "COUNTER", "UNLOGGED"];
254
255const SELECT_POST_FROM_KEYWORDS: &[&str] = &[
256 "ALLOW FILTERING",
257 "GROUP BY",
258 "LIMIT",
259 "ORDER BY",
260 "PER PARTITION LIMIT",
261 "WHERE",
262];
263
264const INSERT_POST_VALUES_KEYWORDS: &[&str] = &["IF NOT EXISTS", "USING"];
265
266const DELETE_POST_FROM_KEYWORDS: &[&str] = &["IF", "USING TIMESTAMP", "WHERE"];
267
268const UPDATE_CLAUSE_KEYWORDS: &[&str] = &["SET", "USING"];
269
270const UPDATE_POST_SET_KEYWORDS: &[&str] = &["IF", "WHERE"];
271
272const GENERIC_CLAUSE_KEYWORDS: &[&str] = &[
273 "ALLOW FILTERING",
274 "AND",
275 "FROM",
276 "GROUP BY",
277 "IF",
278 "INTO",
279 "LIMIT",
280 "ORDER BY",
281 "SET",
282 "USING",
283 "VALUES",
284 "WHERE",
285 "WITH",
286];
287
288#[allow(dead_code)] const CQL_TYPES: &[&str] = &[
291 "ascii",
292 "bigint",
293 "blob",
294 "boolean",
295 "counter",
296 "date",
297 "decimal",
298 "double",
299 "duration",
300 "float",
301 "frozen",
302 "inet",
303 "int",
304 "list",
305 "map",
306 "set",
307 "smallint",
308 "text",
309 "time",
310 "timestamp",
311 "timeuuid",
312 "tinyint",
313 "tuple",
314 "uuid",
315 "varchar",
316 "varint",
317];
318
319#[derive(Debug, PartialEq)]
321enum CompletionContext {
322 Empty,
324 #[allow(dead_code)]
326 ClauseKeyword,
327 TableName { keyspace: Option<String> },
329 ColumnName {
331 keyspace: Option<String>,
332 table: String,
333 },
334 ConsistencyLevel,
336 DescribeTarget,
338 FilePath,
340 KeyspaceName,
342 SelectColumnList,
344 CreateTarget,
346 AlterTarget,
348 DropTarget,
350 DeleteTarget,
352 GrantRevoke,
354 InsertTarget,
356 BeginTarget,
358 SelectPostStar,
360 SelectPostFrom,
362 InsertPostValues,
364 DeletePostFrom,
366 UpdateClause,
368 UpdatePostSet,
370 GenericClause,
372}
373
374pub struct CqlCompleter {
376 cache: Arc<RwLock<SchemaCache>>,
378 current_keyspace: Arc<RwLock<Option<String>>>,
380 rt_handle: Handle,
382 colorizer: CqlColorizer,
384}
385
386impl CqlCompleter {
387 pub fn new(
389 cache: Arc<RwLock<SchemaCache>>,
390 current_keyspace: Arc<RwLock<Option<String>>>,
391 rt_handle: Handle,
392 color_enabled: bool,
393 ) -> Self {
394 Self {
395 cache,
396 current_keyspace,
397 rt_handle,
398 colorizer: CqlColorizer::new(color_enabled),
399 }
400 }
401
402 fn detect_context(&self, line: &str, pos: usize) -> CompletionContext {
406 let before_cursor = &line[..pos];
407
408 let before_cursor = match before_cursor.rfind(';') {
410 Some(idx) => &before_cursor[idx + 1..],
411 None => before_cursor,
412 };
413
414 let tokens = cql_lexer::tokenize(before_cursor);
415 let sig: Vec<_> = cql_lexer::significant_tokens(&tokens);
416
417 if sig.is_empty() {
418 return CompletionContext::Empty;
419 }
420
421 let first_upper = sig[0].text.to_uppercase();
423 if first_upper == "SOURCE" || first_upper == "CAPTURE" {
424 return CompletionContext::FilePath;
425 }
426
427 let last_is_dot = sig
434 .last()
435 .is_some_and(|t| t.kind == TokenKind::Punctuation && t.text == ".");
436 let grammar_ctx = if !before_cursor.ends_with(' ') && sig.len() > 1 && !last_is_dot {
437 let last_token = sig.last().unwrap();
438 cql_lexer::grammar_context_at_end(&before_cursor[..last_token.start])
439 } else if !before_cursor.ends_with(' ') && sig.len() == 1 {
440 GrammarContext::Start
441 } else {
442 cql_lexer::grammar_context_at_end(before_cursor)
443 };
444
445 match grammar_ctx {
446 GrammarContext::Start => CompletionContext::Empty,
447 GrammarContext::ExpectTable => {
448 let keyspace = self.extract_qualifying_keyspace(&sig);
450 CompletionContext::TableName { keyspace }
451 }
452 GrammarContext::ExpectKeyspace => CompletionContext::KeyspaceName,
453 GrammarContext::ExpectColumn | GrammarContext::ExpectSetClause => {
454 let (ks, table) = self.extract_table_from_tokens(&sig);
456 match table {
457 Some(t) => CompletionContext::ColumnName {
458 keyspace: ks,
459 table: t,
460 },
461 None => CompletionContext::GenericClause,
462 }
463 }
464 GrammarContext::ExpectConsistencyLevel => CompletionContext::ConsistencyLevel,
465 GrammarContext::ExpectDescribeTarget => CompletionContext::DescribeTarget,
466 GrammarContext::ExpectFilePath => CompletionContext::FilePath,
467 GrammarContext::ExpectQualifiedPart => {
468 let has_create_or_alter = sig.iter().any(|t| {
470 let u = t.text.to_uppercase();
471 u == "CREATE" || u == "ALTER"
472 });
473 if has_create_or_alter {
474 CompletionContext::GenericClause
475 } else {
476 let keyspace = self.extract_qualifying_keyspace(&sig);
477 CompletionContext::TableName { keyspace }
478 }
479 }
480 GrammarContext::ExpectColumnList => {
481 let has_star = sig
483 .iter()
484 .any(|t| t.kind == TokenKind::Punctuation && t.text == "*");
485 if has_star {
486 CompletionContext::SelectPostStar
487 } else {
488 CompletionContext::SelectColumnList
489 }
490 }
491 GrammarContext::ExpectCreateTarget => CompletionContext::CreateTarget,
492 GrammarContext::ExpectAlterTarget => CompletionContext::AlterTarget,
493 GrammarContext::ExpectDropTarget => CompletionContext::DropTarget,
494 GrammarContext::ExpectDeleteTarget => CompletionContext::DeleteTarget,
495 GrammarContext::ExpectGrantRevoke => CompletionContext::GrantRevoke,
496 GrammarContext::ExpectInsertTarget => CompletionContext::InsertTarget,
497 GrammarContext::ExpectBeginTarget => CompletionContext::BeginTarget,
498 GrammarContext::ExpectSelectPostFrom => CompletionContext::SelectPostFrom,
499 GrammarContext::ExpectInsertPostValues => CompletionContext::InsertPostValues,
500 GrammarContext::ExpectDeletePostFrom => CompletionContext::DeletePostFrom,
501 GrammarContext::ExpectUpdateClause => CompletionContext::UpdateClause,
502 GrammarContext::ExpectUpdatePostSet => CompletionContext::UpdatePostSet,
503 _ => {
504 if sig.len() == 1 && !before_cursor.ends_with(' ') {
505 CompletionContext::Empty
506 } else {
507 CompletionContext::GenericClause
508 }
509 }
510 }
511 }
512
513 fn extract_qualifying_keyspace(&self, sig: &[&cql_lexer::Token]) -> Option<String> {
516 let len = sig.len();
517 if len >= 2 && sig[len - 1].text == "." {
519 return Some(sig[len - 2].text.clone());
520 }
521 if len >= 3 && sig[len - 2].text == "." {
523 return Some(sig[len - 3].text.clone());
524 }
525 None
526 }
527
528 fn extract_table_from_tokens(
530 &self,
531 sig: &[&cql_lexer::Token],
532 ) -> (Option<String>, Option<String>) {
533 for (i, tok) in sig.iter().enumerate() {
534 let upper = tok.text.to_uppercase();
535 if matches!(upper.as_str(), "FROM" | "INTO" | "UPDATE" | "TABLE")
536 && i + 1 < sig.len()
537 && matches!(
538 sig[i + 1].kind,
539 TokenKind::Identifier | TokenKind::QuotedIdentifier
540 )
541 {
542 let table = sig[i + 1].text.clone();
543 if i + 3 < sig.len() && sig[i + 2].text == "." {
545 let ks = table;
546 let tbl = sig[i + 3].text.clone();
547 return (Some(ks), Some(tbl));
548 }
549 let ks = tokio::task::block_in_place(|| {
550 self.rt_handle
551 .block_on(async { self.current_keyspace.read().await.clone() })
552 });
553 return (ks, Some(table));
554 }
555 }
556 (None, None)
557 }
558
559 fn complete_for_context(&self, ctx: &CompletionContext, prefix: &str) -> Vec<Pair> {
561 let prefix_upper = prefix.to_uppercase();
562
563 match ctx {
564 CompletionContext::Empty => {
565 let mut candidates: Vec<&str> = Vec::new();
566 candidates.extend_from_slice(CQL_KEYWORDS);
567 candidates.extend_from_slice(SHELL_COMMANDS);
568 filter_candidates(&candidates, &prefix_upper, true)
569 }
570 CompletionContext::ConsistencyLevel => {
571 filter_candidates(CONSISTENCY_LEVELS, &prefix_upper, true)
572 }
573 CompletionContext::DescribeTarget => {
574 filter_candidates(DESCRIBE_SUB_COMMANDS, &prefix_upper, true)
575 }
576 CompletionContext::KeyspaceName => {
577 let cache =
578 tokio::task::block_in_place(|| self.rt_handle.block_on(self.cache.read()));
579 let names = cache.keyspace_names();
580 filter_candidates(&names, prefix, false)
581 }
582 CompletionContext::TableName { keyspace } => {
583 let cache =
584 tokio::task::block_in_place(|| self.rt_handle.block_on(self.cache.read()));
585 let ks = keyspace.clone().or_else(|| {
586 tokio::task::block_in_place(|| {
587 self.rt_handle
588 .block_on(async { self.current_keyspace.read().await.clone() })
589 })
590 });
591 match ks {
592 Some(ref ks_name) => {
593 let names = cache.table_names(ks_name);
594 filter_candidates(&names, prefix, false)
595 }
596 None => {
597 let names = cache.keyspace_names();
599 filter_candidates(&names, prefix, false)
600 }
601 }
602 }
603 CompletionContext::ColumnName { keyspace, table } => {
604 let cache =
605 tokio::task::block_in_place(|| self.rt_handle.block_on(self.cache.read()));
606 let ks = keyspace.clone().or_else(|| {
607 tokio::task::block_in_place(|| {
608 self.rt_handle
609 .block_on(async { self.current_keyspace.read().await.clone() })
610 })
611 });
612 match ks {
613 Some(ref ks_name) => {
614 let names = cache.column_names(ks_name, table);
615 filter_candidates(&names, prefix, false)
616 }
617 None => vec![],
618 }
619 }
620 CompletionContext::FilePath => complete_file_path(prefix),
621 CompletionContext::SelectColumnList => {
622 filter_candidates(SELECT_COLUMN_KEYWORDS, &prefix_upper, true)
623 }
624 CompletionContext::SelectPostStar => filter_candidates(&["FROM"], &prefix_upper, true),
625 CompletionContext::CreateTarget => {
626 filter_candidates(CREATE_TARGET_KEYWORDS, &prefix_upper, true)
627 }
628 CompletionContext::AlterTarget => {
629 filter_candidates(ALTER_TARGET_KEYWORDS, &prefix_upper, true)
630 }
631 CompletionContext::DropTarget => {
632 filter_candidates(DROP_TARGET_KEYWORDS, &prefix_upper, true)
633 }
634 CompletionContext::DeleteTarget => {
635 filter_candidates(DELETE_TARGET_KEYWORDS, &prefix_upper, true)
636 }
637 CompletionContext::GrantRevoke => {
638 filter_candidates(GRANT_REVOKE_KEYWORDS, &prefix_upper, true)
639 }
640 CompletionContext::InsertTarget => {
641 filter_candidates(INSERT_TARGET_KEYWORDS, &prefix_upper, true)
642 }
643 CompletionContext::BeginTarget => {
644 filter_candidates(BEGIN_TARGET_KEYWORDS, &prefix_upper, true)
645 }
646 CompletionContext::SelectPostFrom => {
647 filter_candidates(SELECT_POST_FROM_KEYWORDS, &prefix_upper, true)
648 }
649 CompletionContext::InsertPostValues => {
650 filter_candidates(INSERT_POST_VALUES_KEYWORDS, &prefix_upper, true)
651 }
652 CompletionContext::DeletePostFrom => {
653 filter_candidates(DELETE_POST_FROM_KEYWORDS, &prefix_upper, true)
654 }
655 CompletionContext::UpdateClause => {
656 filter_candidates(UPDATE_CLAUSE_KEYWORDS, &prefix_upper, true)
657 }
658 CompletionContext::UpdatePostSet => {
659 filter_candidates(UPDATE_POST_SET_KEYWORDS, &prefix_upper, true)
660 }
661 CompletionContext::GenericClause | CompletionContext::ClauseKeyword => {
662 filter_candidates(GENERIC_CLAUSE_KEYWORDS, &prefix_upper, true)
663 }
664 }
665 }
666}
667
668fn filter_candidates(candidates: &[&str], prefix: &str, uppercase: bool) -> Vec<Pair> {
670 candidates
671 .iter()
672 .filter(|c| {
673 if uppercase {
674 c.to_uppercase().starts_with(&prefix.to_uppercase())
675 } else {
676 c.starts_with(prefix)
677 }
678 })
679 .map(|c| {
680 let display = if uppercase {
681 c.to_uppercase()
682 } else {
683 c.to_string()
684 };
685 Pair {
686 display: display.clone(),
687 replacement: display,
688 }
689 })
690 .collect()
691}
692
693fn complete_file_path(prefix: &str) -> Vec<Pair> {
695 let path_str = prefix
697 .strip_prefix('\'')
698 .or_else(|| prefix.strip_prefix('"'))
699 .unwrap_or(prefix);
700
701 let expanded = if path_str.starts_with('~') {
703 if let Some(home) = dirs::home_dir() {
704 path_str.replacen('~', &home.to_string_lossy(), 1)
705 } else {
706 path_str.to_string()
707 }
708 } else {
709 path_str.to_string()
710 };
711
712 let (dir, file_prefix) = if expanded.ends_with('/') {
713 (expanded.as_str(), "")
714 } else {
715 let path = std::path::Path::new(&expanded);
716 let parent = path
717 .parent()
718 .map(|p| p.to_str().unwrap_or("."))
719 .unwrap_or(".");
720 let file = path.file_name().and_then(|f| f.to_str()).unwrap_or("");
721 (parent, file)
722 };
723
724 let dir_to_read = if dir.is_empty() { "." } else { dir };
725
726 let Ok(entries) = std::fs::read_dir(dir_to_read) else {
727 return vec![];
728 };
729
730 entries
731 .filter_map(|entry| entry.ok())
732 .filter_map(|entry| {
733 let name = entry.file_name().to_string_lossy().to_string();
734 if name.starts_with(file_prefix) {
735 let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
736 let suffix = if is_dir { "/" } else { "" };
737 let full = if dir.is_empty() || dir == "." {
738 format!("{name}{suffix}")
739 } else if dir.ends_with('/') {
740 format!("{dir}{name}{suffix}")
741 } else {
742 format!("{dir}/{name}{suffix}")
743 };
744 Some(Pair {
745 display: name + suffix,
746 replacement: full,
747 })
748 } else {
749 None
750 }
751 })
752 .collect()
753}
754
755impl Completer for CqlCompleter {
756 type Candidate = Pair;
757
758 fn complete(
759 &self,
760 line: &str,
761 pos: usize,
762 _ctx: &Context<'_>,
763 ) -> rustyline::Result<(usize, Vec<Pair>)> {
764 let needs_refresh = tokio::task::block_in_place(|| {
766 self.rt_handle
767 .block_on(async { self.cache.read().await.is_stale() })
768 });
769 if needs_refresh {
770 tokio::task::block_in_place(|| {
772 self.rt_handle.block_on(async {
773 if let Ok(mut cache) = self.cache.try_write() {
775 if cache.is_stale() {
777 cache.invalidate();
780 }
781 }
782 })
783 });
784 }
785
786 let context = self.detect_context(line, pos);
787
788 let before_cursor = &line[..pos];
790 let word_start = before_cursor
791 .rfind(|c: char| c.is_whitespace() || c == '.' || c == '\'' || c == '"')
792 .map(|i| i + 1)
793 .unwrap_or(0);
794 let prefix = &line[word_start..pos];
795
796 let completions = self.complete_for_context(&context, prefix);
797
798 Ok((word_start, completions))
799 }
800}
801
802impl Hinter for CqlCompleter {
803 type Hint = String;
804
805 fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> {
806 None
807 }
808}
809
810impl Highlighter for CqlCompleter {
811 fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> {
812 let colored = self.colorizer.colorize_line(line);
813 if colored == line {
814 Cow::Borrowed(line)
815 } else {
816 Cow::Owned(colored)
817 }
818 }
819
820 fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
821 &'s self,
822 prompt: &'p str,
823 _default: bool,
824 ) -> Cow<'b, str> {
825 Cow::Borrowed(prompt)
826 }
827
828 fn highlight_char(
829 &self,
830 _line: &str,
831 _pos: usize,
832 _forced: rustyline::highlight::CmdKind,
833 ) -> bool {
834 true
836 }
837}
838
839impl Validator for CqlCompleter {}
840
841impl Helper for CqlCompleter {}
842
843#[cfg(test)]
844mod tests {
845 use super::*;
846
847 fn make_completer() -> CqlCompleter {
848 let rt = tokio::runtime::Runtime::new().unwrap();
849 let cache = Arc::new(RwLock::new(SchemaCache::new()));
850 let current_ks = Arc::new(RwLock::new(None::<String>));
851 CqlCompleter::new(cache, current_ks, rt.handle().clone(), false)
852 }
853
854 #[test]
855 fn completer_can_be_created() {
856 let _c = make_completer();
857 }
858
859 #[test]
860 fn detect_empty_context() {
861 let c = make_completer();
862 assert_eq!(c.detect_context("", 0), CompletionContext::Empty);
863 }
864
865 #[test]
866 fn detect_keyword_prefix() {
867 let c = make_completer();
868 assert_eq!(c.detect_context("SEL", 3), CompletionContext::Empty);
869 }
870
871 #[test]
872 fn detect_consistency_context() {
873 let c = make_completer();
874 assert_eq!(
875 c.detect_context("CONSISTENCY ", 12),
876 CompletionContext::ConsistencyLevel
877 );
878 }
879
880 #[test]
881 fn detect_serial_consistency_context() {
882 let c = make_completer();
883 assert_eq!(
884 c.detect_context("SERIAL CONSISTENCY ", 19),
885 CompletionContext::ConsistencyLevel
886 );
887 }
888
889 #[test]
890 fn detect_use_keyspace_context() {
891 let c = make_completer();
892 assert_eq!(c.detect_context("USE ", 4), CompletionContext::KeyspaceName);
893 }
894
895 #[test]
896 fn detect_describe_sub_command() {
897 let c = make_completer();
898 assert_eq!(
899 c.detect_context("DESCRIBE ", 9),
900 CompletionContext::DescribeTarget
901 );
902 }
903
904 #[test]
905 fn detect_describe_table_name() {
906 let c = make_completer();
907 assert_eq!(
908 c.detect_context("DESCRIBE TABLE ", 15),
909 CompletionContext::TableName { keyspace: None }
910 );
911 }
912
913 #[test]
914 fn detect_describe_keyspace_name() {
915 let c = make_completer();
916 assert_eq!(
917 c.detect_context("DESCRIBE KEYSPACE ", 18),
918 CompletionContext::KeyspaceName
919 );
920 }
921
922 #[test]
923 fn detect_source_file_path() {
924 let c = make_completer();
925 assert_eq!(
926 c.detect_context("SOURCE '/tmp/", 13),
927 CompletionContext::FilePath
928 );
929 }
930
931 #[test]
932 fn detect_capture_file_path() {
933 let c = make_completer();
934 assert_eq!(c.detect_context("CAPTURE ", 8), CompletionContext::FilePath);
935 }
936
937 #[test]
938 fn detect_from_table_context() {
939 let c = make_completer();
940 assert_eq!(
941 c.detect_context("SELECT * FROM ", 14),
942 CompletionContext::TableName { keyspace: None }
943 );
944 }
945
946 #[test]
947 fn complete_keyword_prefix() {
948 let c = make_completer();
949 let pairs = c.complete_for_context(&CompletionContext::Empty, "SEL");
950 assert!(pairs.iter().any(|p| p.replacement == "SELECT"));
951 }
952
953 #[test]
954 fn complete_consistency_level_prefix() {
955 let c = make_completer();
956 let pairs = c.complete_for_context(&CompletionContext::ConsistencyLevel, "QU");
957 assert!(pairs.iter().any(|p| p.replacement == "QUORUM"));
958 }
959
960 #[test]
961 fn complete_describe_sub_command() {
962 let c = make_completer();
963 let pairs = c.complete_for_context(&CompletionContext::DescribeTarget, "KEY");
964 assert!(pairs.iter().any(|p| p.replacement == "KEYSPACE"));
965 assert!(pairs.iter().any(|p| p.replacement == "KEYSPACES"));
966 }
967
968 #[test]
969 fn filter_is_case_insensitive_for_keywords() {
970 let pairs = filter_candidates(CQL_KEYWORDS, "sel", true);
971 assert!(pairs.iter().any(|p| p.replacement == "SELECT"));
972 }
973
974 #[test]
975 fn file_path_completion_tmp() {
976 let pairs = complete_file_path("/tmp/");
977 assert!(
978 !pairs.is_empty() || std::fs::read_dir("/tmp").map(|d| d.count()).unwrap_or(0) == 0
979 );
980 }
981
982 #[test]
985 fn detect_select_post_from() {
986 let c = make_completer();
987 assert_eq!(
988 c.detect_context("SELECT * FROM users ", 20),
989 CompletionContext::SelectPostFrom
990 );
991 }
992
993 #[test]
994 fn detect_delete_post_from() {
995 let c = make_completer();
996 assert_eq!(
997 c.detect_context("DELETE FROM users ", 18),
998 CompletionContext::DeletePostFrom
999 );
1000 }
1001
1002 #[test]
1003 fn detect_update_clause() {
1004 let c = make_completer();
1005 assert_eq!(
1006 c.detect_context("UPDATE users ", 13),
1007 CompletionContext::UpdateClause
1008 );
1009 }
1010
1011 #[test]
1012 fn detect_update_post_set() {
1013 let c = make_completer();
1014 assert_eq!(
1015 c.detect_context("UPDATE users SET name = 'x' ", 28),
1016 CompletionContext::UpdatePostSet
1017 );
1018 }
1019
1020 #[test]
1021 fn detect_insert_post_values() {
1022 let c = make_completer();
1023 assert_eq!(
1024 c.detect_context("INSERT INTO users (id) VALUES (1) ", 34),
1025 CompletionContext::InsertPostValues
1026 );
1027 }
1028
1029 #[test]
1032 fn select_post_from_must_not_suggest_drop() {
1033 let c = make_completer();
1034 let pairs = c.complete_for_context(&CompletionContext::SelectPostFrom, "");
1035 assert!(
1036 !pairs.iter().any(|p| p.replacement == "DROP"),
1037 "SELECT post-FROM must not suggest DROP"
1038 );
1039 }
1040
1041 #[test]
1042 fn create_target_must_not_suggest_where() {
1043 let c = make_completer();
1044 let pairs = c.complete_for_context(&CompletionContext::CreateTarget, "");
1045 assert!(
1046 !pairs.iter().any(|p| p.replacement == "WHERE"),
1047 "CREATE target must not suggest WHERE"
1048 );
1049 }
1050
1051 #[test]
1052 fn update_clause_suggests_set() {
1053 let c = make_completer();
1054 let pairs = c.complete_for_context(&CompletionContext::UpdateClause, "");
1055 assert!(pairs.iter().any(|p| p.replacement == "SET"));
1056 assert!(!pairs.iter().any(|p| p.replacement == "FROM"));
1057 }
1058
1059 #[test]
1060 fn delete_post_from_suggests_where() {
1061 let c = make_completer();
1062 let pairs = c.complete_for_context(&CompletionContext::DeletePostFrom, "");
1063 assert!(pairs.iter().any(|p| p.replacement == "WHERE"));
1064 assert!(!pairs.iter().any(|p| p.replacement == "ORDER BY"));
1065 }
1066
1067 #[test]
1068 fn midword_select_star_f_gives_select_post_star() {
1069 let c = make_completer();
1070 assert_eq!(
1071 c.detect_context("SELECT * F", 10),
1072 CompletionContext::SelectPostStar
1073 );
1074 let pairs = c.complete_for_context(&CompletionContext::SelectPostStar, "F");
1075 assert!(pairs.iter().any(|p| p.replacement == "FROM"));
1076 assert!(!pairs.iter().any(|p| p.replacement == "FILTERING"));
1077 assert!(!pairs.iter().any(|p| p.replacement == "FUNCTION"));
1078 }
1079
1080 #[test]
1081 fn midword_qualified_table_name() {
1082 let c = make_completer();
1083 assert_eq!(
1084 c.detect_context("SELECT * FROM system.h", 22),
1085 CompletionContext::TableName {
1086 keyspace: Some("system".to_string())
1087 }
1088 );
1089 }
1090
1091 #[test]
1092 fn select_space_gives_select_column_list() {
1093 let c = make_completer();
1094 assert_eq!(
1095 c.detect_context("SELECT ", 7),
1096 CompletionContext::SelectColumnList
1097 );
1098 }
1099
1100 #[test]
1101 fn qualified_dot_without_partial_gives_table_name() {
1102 let c = make_completer();
1103 assert_eq!(
1104 c.detect_context("SELECT * FROM system.", 21),
1105 CompletionContext::TableName {
1106 keyspace: Some("system".to_string())
1107 }
1108 );
1109 assert_eq!(
1110 c.detect_context("SELECT * FROM test_ks.", 22),
1111 CompletionContext::TableName {
1112 keyspace: Some("test_ks".to_string())
1113 }
1114 );
1115 }
1116
1117 #[test]
1118 fn select_star_space_gives_post_star() {
1119 let c = make_completer();
1120 assert_eq!(
1121 c.detect_context("SELECT * ", 9),
1122 CompletionContext::SelectPostStar
1123 );
1124 let pairs = c.complete_for_context(&CompletionContext::SelectPostStar, "");
1125 assert_eq!(pairs.len(), 1);
1126 assert_eq!(pairs[0].replacement, "FROM");
1127 }
1128
1129 #[test]
1130 fn multi_statement_resets_context() {
1131 let c = make_completer();
1132 assert_eq!(c.detect_context("SELECT 1; ", 10), CompletionContext::Empty);
1133 assert_eq!(
1134 c.detect_context("SELECT 1; S", 11),
1135 CompletionContext::Empty
1136 );
1137 }
1138
1139 #[test]
1140 fn create_table_qualified_does_not_suggest_tables() {
1141 let c = make_completer();
1142 assert_eq!(
1143 c.detect_context("CREATE TABLE test_ks.", 21),
1144 CompletionContext::GenericClause
1145 );
1146 }
1147
1148 #[test]
1149 fn alter_table_qualified_does_not_suggest_tables() {
1150 let c = make_completer();
1151 assert_eq!(
1152 c.detect_context("ALTER TABLE test_ks.", 20),
1153 CompletionContext::GenericClause
1154 );
1155 }
1156
1157 #[test]
1158 fn select_post_from_qualified_table() {
1159 let c = make_completer();
1160 assert_eq!(
1161 c.detect_context("SELECT * FROM test_ks.users ", 28),
1162 CompletionContext::SelectPostFrom
1163 );
1164 }
1165
1166 #[test]
1167 fn update_clause_qualified_table() {
1168 let c = make_completer();
1169 assert_eq!(
1170 c.detect_context("UPDATE test_ks.users ", 21),
1171 CompletionContext::UpdateClause
1172 );
1173 }
1174
1175 #[test]
1176 fn delete_post_from_qualified_table() {
1177 let c = make_completer();
1178 assert_eq!(
1179 c.detect_context("DELETE FROM test_ks.users ", 26),
1180 CompletionContext::DeletePostFrom
1181 );
1182 }
1183
1184 #[test]
1185 fn file_path_completion_nonexistent_dir() {
1186 let pairs = complete_file_path("/nonexistent_dir_xyz_123/");
1187 assert!(pairs.is_empty());
1188 }
1189
1190 #[test]
1191 fn file_path_completion_with_single_quote() {
1192 let pairs = complete_file_path("'/tmp/");
1193 assert!(
1194 !pairs.is_empty() || std::fs::read_dir("/tmp").map(|d| d.count()).unwrap_or(0) == 0
1195 );
1196 }
1197
1198 #[test]
1199 fn file_path_completion_with_double_quote() {
1200 let pairs = complete_file_path("\"/tmp/");
1201 assert!(
1202 !pairs.is_empty() || std::fs::read_dir("/tmp").map(|d| d.count()).unwrap_or(0) == 0
1203 );
1204 }
1205
1206 #[test]
1207 fn file_path_completion_with_tilde() {
1208 let pairs = complete_file_path("~/");
1209 if dirs::home_dir().is_some() {
1210 let _ = pairs;
1211 }
1212 }
1213
1214 #[test]
1215 fn file_path_completion_with_file_prefix() {
1216 let pairs = complete_file_path("/tmp/nonexistent_prefix_xyz");
1217 assert!(pairs.is_empty());
1218 }
1219
1220 #[test]
1221 fn filter_candidates_empty_prefix() {
1222 let candidates = &["SELECT", "INSERT", "UPDATE"];
1223 let pairs = filter_candidates(candidates, "", true);
1224 assert_eq!(pairs.len(), 3);
1225 }
1226
1227 #[test]
1228 fn filter_candidates_no_match() {
1229 let candidates = &["SELECT", "INSERT", "UPDATE"];
1230 let pairs = filter_candidates(candidates, "XYZ", true);
1231 assert!(pairs.is_empty());
1232 }
1233
1234 #[test]
1235 fn filter_candidates_case_insensitive() {
1236 let candidates = &["SELECT", "INSERT", "UPDATE"];
1237 let pairs = filter_candidates(candidates, "ins", true);
1238 assert_eq!(pairs.len(), 1);
1239 assert_eq!(pairs[0].replacement, "INSERT");
1240 }
1241
1242 #[test]
1243 fn filter_candidates_case_sensitive_mode() {
1244 let candidates = &["myTable", "myOther", "yours"];
1245 let pairs = filter_candidates(candidates, "my", false);
1246 assert_eq!(pairs.len(), 2);
1247 }
1248
1249 #[test]
1250 fn filter_candidates_case_sensitive_no_match() {
1251 let candidates = &["myTable", "myOther"];
1252 let pairs = filter_candidates(candidates, "MY", false);
1253 assert!(pairs.is_empty());
1254 }
1255
1256 #[test]
1257 fn detect_insert_into_table_context() {
1258 let c = make_completer();
1259 assert_eq!(
1260 c.detect_context("INSERT INTO ", 12),
1261 CompletionContext::TableName { keyspace: None }
1262 );
1263 }
1264
1265 #[test]
1266 fn detect_update_table_context() {
1267 let c = make_completer();
1268 assert_eq!(
1269 c.detect_context("UPDATE ", 7),
1270 CompletionContext::TableName { keyspace: None }
1271 );
1272 }
1273
1274 #[test]
1275 fn detect_desc_shorthand() {
1276 let c = make_completer();
1277 assert_eq!(
1278 c.detect_context("DESC ", 5),
1279 CompletionContext::DescribeTarget
1280 );
1281 }
1282
1283 #[test]
1284 fn detect_qualified_table_name() {
1285 let c = make_completer();
1286 let ctx = c.detect_context("SELECT * FROM mykeyspace.", 25);
1287 assert_eq!(
1288 ctx,
1289 CompletionContext::TableName {
1290 keyspace: Some("mykeyspace".to_string())
1291 }
1292 );
1293 }
1294
1295 #[test]
1296 fn complete_empty_context_includes_shell_commands() {
1297 let c = make_completer();
1298 let pairs = c.complete_for_context(&CompletionContext::Empty, "EX");
1299 assert!(pairs.iter().any(|p| p.replacement == "EXIT"));
1300 assert!(pairs.iter().any(|p| p.replacement == "EXPAND"));
1301 }
1302
1303 #[test]
1304 fn complete_consistency_all_levels() {
1305 let c = make_completer();
1306 let pairs = c.complete_for_context(&CompletionContext::ConsistencyLevel, "");
1307 assert_eq!(pairs.len(), CONSISTENCY_LEVELS.len());
1308 }
1309
1310 #[test]
1311 fn complete_consistency_local_prefix() {
1312 let c = make_completer();
1313 let pairs = c.complete_for_context(&CompletionContext::ConsistencyLevel, "LOCAL");
1314 assert_eq!(pairs.len(), 3);
1315 }
1316
1317 #[test]
1318 fn complete_keyspace_name_empty_cache() {
1319 let c = make_completer();
1320 let pairs = c.complete_for_context(&CompletionContext::KeyspaceName, "");
1321 assert!(pairs.is_empty());
1322 }
1323
1324 #[test]
1325 fn complete_table_name_no_keyspace() {
1326 let c = make_completer();
1327 let pairs = c.complete_for_context(&CompletionContext::TableName { keyspace: None }, "");
1328 assert!(pairs.is_empty());
1329 }
1330
1331 #[test]
1332 fn complete_column_name_no_keyspace() {
1333 let c = make_completer();
1334 let pairs = c.complete_for_context(
1335 &CompletionContext::ColumnName {
1336 keyspace: None,
1337 table: "users".to_string(),
1338 },
1339 "",
1340 );
1341 assert!(pairs.is_empty());
1342 }
1343}