Skip to main content

cqlsh_rs/
completer.rs

1//! Tab completion for the CQL shell.
2//!
3//! Implements rustyline's `Completer`, `Helper`, `Hinter`, `Highlighter`, and
4//! `Validator` traits to provide context-aware tab completion in the REPL.
5//! Uses the unified CQL lexer for grammar-aware context detection.
6//! Completions include CQL keywords, shell commands, schema objects (keyspaces,
7//! tables, columns), consistency levels, DESCRIBE sub-commands, and file paths.
8
9use 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
24/// CQL keywords that can start a statement.
25const CQL_KEYWORDS: &[&str] = &[
26    "ALTER", "APPLY", "BATCH", "BEGIN", "CREATE", "DELETE", "DESCRIBE", "DROP", "GRANT", "INSERT",
27    "LIST", "REVOKE", "SELECT", "TRUNCATE", "UPDATE", "USE",
28];
29
30/// CQL clause keywords used within statements (superseded by per-statement lists).
31#[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
135/// Built-in shell commands.
136const 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
156/// CQL consistency levels.
157const 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
171/// DESCRIBE sub-commands.
172const 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
190/// Keywords valid at the start of a SELECT column list.
191const 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/// CQL data types for CREATE TABLE column definitions.
289#[allow(dead_code)] // Will be used when CqlType completion context is implemented
290const 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/// Detected completion context based on the input up to the cursor.
320#[derive(Debug, PartialEq)]
321enum CompletionContext {
322    /// At the start of input — complete with statement keywords and shell commands.
323    Empty,
324    /// After a statement keyword — complete with generic clause keywords.
325    #[allow(dead_code)]
326    ClauseKeyword,
327    /// After FROM, INTO, UPDATE, etc. — complete with table names.
328    TableName { keyspace: Option<String> },
329    /// After SELECT ... FROM table WHERE — complete with column names.
330    ColumnName {
331        keyspace: Option<String>,
332        table: String,
333    },
334    /// After CONSISTENCY — complete with consistency levels.
335    ConsistencyLevel,
336    /// After DESCRIBE/DESC — complete with sub-commands or schema names.
337    DescribeTarget,
338    /// After SOURCE or CAPTURE — complete with file paths.
339    FilePath,
340    /// After USE — complete with keyspace names.
341    KeyspaceName,
342    /// After SELECT — complete with *, DISTINCT, JSON, or column names.
343    SelectColumnList,
344    /// After CREATE — complete with TABLE, KEYSPACE, INDEX, etc.
345    CreateTarget,
346    /// After ALTER — complete with TABLE, KEYSPACE, TYPE, etc.
347    AlterTarget,
348    /// After DROP — complete with TABLE, KEYSPACE, INDEX, etc.
349    DropTarget,
350    /// After DELETE — complete with FROM or column names.
351    DeleteTarget,
352    /// After GRANT/REVOKE — complete with permission names.
353    GrantRevoke,
354    /// After INSERT — complete with INTO.
355    InsertTarget,
356    /// After BEGIN — complete with BATCH, UNLOGGED, COUNTER.
357    BeginTarget,
358    /// After SELECT * — only FROM is valid next.
359    SelectPostStar,
360    /// After SELECT ... FROM table — complete with WHERE, ORDER BY, LIMIT, etc.
361    SelectPostFrom,
362    /// After INSERT ... VALUES(...) — complete with IF NOT EXISTS, USING.
363    InsertPostValues,
364    /// After DELETE ... FROM table — complete with WHERE, IF, USING TIMESTAMP.
365    DeletePostFrom,
366    /// After UPDATE table — complete with SET, USING.
367    UpdateClause,
368    /// After UPDATE ... SET col = val — complete with WHERE, IF.
369    UpdatePostSet,
370    /// Generic clause fallback — common clause keywords.
371    GenericClause,
372}
373
374/// Tab completer for the CQL shell REPL.
375pub struct CqlCompleter {
376    /// Shared schema cache for keyspace/table/column lookups.
377    cache: Arc<RwLock<SchemaCache>>,
378    /// Current keyspace (shared with session via USE command).
379    current_keyspace: Arc<RwLock<Option<String>>>,
380    /// Tokio runtime handle for blocking cache reads inside sync complete().
381    rt_handle: Handle,
382    /// Syntax colorizer for highlighting.
383    colorizer: CqlColorizer,
384}
385
386impl CqlCompleter {
387    /// Create a new completer with shared cache and keyspace state.
388    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    /// Detect completion context from the input line up to the cursor position.
403    ///
404    /// Uses the unified CQL lexer for grammar-aware context detection.
405    fn detect_context(&self, line: &str, pos: usize) -> CompletionContext {
406        let before_cursor = &line[..pos];
407
408        // Multi-statement: only consider text after the last semicolon
409        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        // Special case: SOURCE/CAPTURE always means file path completion
422        let first_upper = sig[0].text.to_uppercase();
423        if first_upper == "SOURCE" || first_upper == "CAPTURE" {
424            return CompletionContext::FilePath;
425        }
426
427        // Mid-word context fix: when the user is typing a partial word (no trailing
428        // space), strip it and get grammar context from everything BEFORE it.
429        // e.g. "SELECT * F" → context of "SELECT * " → ExpectColumnList
430        // e.g. "SELECT * FROM system.h" → context of "SELECT * FROM system." → ExpectQualifiedPart
431        // Exception: don't strip if the last token is a dot — the dot IS the context
432        // indicator for qualified name completion (e.g. "FROM system." → ExpectQualifiedPart).
433        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                // Check if the user is typing a qualified name (ks.)
449                let keyspace = self.extract_qualifying_keyspace(&sig);
450                CompletionContext::TableName { keyspace }
451            }
452            GrammarContext::ExpectKeyspace => CompletionContext::KeyspaceName,
453            GrammarContext::ExpectColumn | GrammarContext::ExpectSetClause => {
454                // Find the table name from the token stream
455                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                // After CREATE/ALTER TABLE ks. — don't suggest existing tables
469                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                // If * already appears after SELECT, only offer FROM
482                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    /// Extract the keyspace qualifier from a dot-qualified name in the token stream.
514    /// Handles both `ks.` (dot is last) and `ks.partial` (identifier after dot).
515    fn extract_qualifying_keyspace(&self, sig: &[&cql_lexer::Token]) -> Option<String> {
516        let len = sig.len();
517        // Pattern: identifier . (dot is last token)
518        if len >= 2 && sig[len - 1].text == "." {
519            return Some(sig[len - 2].text.clone());
520        }
521        // Pattern: identifier . partial_name (user typing after dot)
522        if len >= 3 && sig[len - 2].text == "." {
523            return Some(sig[len - 3].text.clone());
524        }
525        None
526    }
527
528    /// Extract table name from the token stream by finding FROM/INTO/UPDATE <table>.
529    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                // Check for qualified name (ks.table)
544                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    /// Generate completions for the detected context.
560    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                        // No keyspace context — offer keyspace names for qualification
598                        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
668/// Filter candidates by prefix, returning matching `Pair`s.
669fn 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
693/// Complete file paths for SOURCE and CAPTURE commands.
694fn complete_file_path(prefix: &str) -> Vec<Pair> {
695    // Strip surrounding quotes if present
696    let path_str = prefix
697        .strip_prefix('\'')
698        .or_else(|| prefix.strip_prefix('"'))
699        .unwrap_or(prefix);
700
701    // Expand ~ to home directory
702    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        // block_in_place: complete() is called from within the Tokio runtime (sync rustyline trait)
765        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            // Best-effort refresh — don't block on errors
771            tokio::task::block_in_place(|| {
772                self.rt_handle.block_on(async {
773                    // Try to get write lock without blocking other completions
774                    if let Ok(mut cache) = self.cache.try_write() {
775                        // Re-check staleness after acquiring lock
776                        if cache.is_stale() {
777                            // We can't refresh without a session reference here.
778                            // The REPL pre-refreshes the cache; this is a fallback mark.
779                            cache.invalidate();
780                        }
781                    }
782                })
783            });
784        }
785
786        let context = self.detect_context(line, pos);
787
788        // Find the start of the word being completed
789        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        // Return true to trigger re-highlighting on every keystroke
835        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    // --- Post-statement context detection ---
983
984    #[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    // --- Negative tests: statement-specific contexts must NOT suggest wrong keywords ---
1030
1031    #[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}