rustledger_query/
completions.rs

1//! BQL query completion engine.
2//!
3//! Provides context-aware completions for the BQL query language,
4//! suitable for IDE integration, CLI autocomplete, or WASM playgrounds.
5//!
6//! # Example
7//!
8//! ```
9//! use rustledger_query::completions::{complete, Completion};
10//!
11//! let completions = complete("SELECT ", 7);
12//! assert!(completions.completions.iter().any(|c| c.text == "account"));
13//! ```
14
15/// A completion suggestion.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Completion {
18    /// The completion text to insert.
19    pub text: String,
20    /// Category: keyword, function, column, operator, literal.
21    pub category: CompletionCategory,
22    /// Optional description/documentation.
23    pub description: Option<String>,
24}
25
26/// Category of completion.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum CompletionCategory {
29    /// SQL keyword (SELECT, WHERE, etc.).
30    Keyword,
31    /// Aggregate or scalar function.
32    Function,
33    /// Column name.
34    Column,
35    /// Operator (+, -, =, etc.).
36    Operator,
37    /// Literal value.
38    Literal,
39}
40
41impl CompletionCategory {
42    /// Returns the category as a string for serialization.
43    #[must_use]
44    pub const fn as_str(&self) -> &'static str {
45        match self {
46            Self::Keyword => "keyword",
47            Self::Function => "function",
48            Self::Column => "column",
49            Self::Operator => "operator",
50            Self::Literal => "literal",
51        }
52    }
53}
54
55/// Result of a completion request.
56#[derive(Debug, Clone)]
57pub struct CompletionResult {
58    /// List of completions.
59    pub completions: Vec<Completion>,
60    /// Current parsing context (for debugging).
61    pub context: BqlContext,
62}
63
64/// BQL parsing context state.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum BqlContext {
67    /// At the start, expecting a statement keyword.
68    Start,
69    /// After SELECT, expecting columns/expressions.
70    AfterSelect,
71    /// After SELECT columns, could have FROM, WHERE, GROUP BY, etc.
72    AfterSelectTargets,
73    /// After FROM keyword.
74    AfterFrom,
75    /// After FROM clause modifiers (OPEN ON, CLOSE ON, CLEAR).
76    AfterFromModifiers,
77    /// After WHERE keyword, expecting expression.
78    AfterWhere,
79    /// Inside WHERE expression.
80    InWhereExpr,
81    /// After GROUP keyword, expecting BY.
82    AfterGroup,
83    /// After GROUP BY, expecting columns.
84    AfterGroupBy,
85    /// After ORDER keyword, expecting BY.
86    AfterOrder,
87    /// After ORDER BY, expecting columns.
88    AfterOrderBy,
89    /// After LIMIT keyword, expecting number.
90    AfterLimit,
91    /// After JOURNAL keyword.
92    AfterJournal,
93    /// After BALANCES keyword.
94    AfterBalances,
95    /// After PRINT keyword.
96    AfterPrint,
97    /// Inside a function call, after opening paren.
98    InFunction(String),
99    /// After a comparison operator, expecting value.
100    AfterOperator,
101    /// After AS keyword, expecting alias.
102    AfterAs,
103    /// Inside a string literal.
104    InString,
105}
106
107/// Get BQL query completions at cursor position.
108///
109/// Returns context-aware completions for the BQL query language.
110///
111/// # Arguments
112///
113/// * `partial_query` - The query text so far
114/// * `cursor_pos` - Byte offset of cursor position
115///
116/// # Example
117///
118/// ```
119/// use rustledger_query::completions::complete;
120///
121/// let result = complete("SELECT ", 7);
122/// assert!(!result.completions.is_empty());
123/// ```
124#[must_use]
125pub fn complete(partial_query: &str, cursor_pos: usize) -> CompletionResult {
126    // Get the text up to cursor
127    let text = if cursor_pos <= partial_query.len() {
128        &partial_query[..cursor_pos]
129    } else {
130        partial_query
131    };
132
133    // Tokenize (simple whitespace/punctuation split)
134    let tokens = tokenize_bql(text);
135    let context = determine_context(&tokens);
136    let completions = get_completions_for_context(&context);
137
138    CompletionResult {
139        completions,
140        context,
141    }
142}
143
144/// Simple tokenizer for BQL.
145fn tokenize_bql(text: &str) -> Vec<String> {
146    let mut tokens = Vec::new();
147    let mut current = String::new();
148    let mut in_string = false;
149    let mut chars = text.chars().peekable();
150
151    while let Some(c) = chars.next() {
152        if in_string {
153            current.push(c);
154            if c == '"' {
155                tokens.push(current.clone());
156                current.clear();
157                in_string = false;
158            }
159        } else if c == '"' {
160            if !current.is_empty() {
161                tokens.push(current.clone());
162                current.clear();
163            }
164            current.push(c);
165            in_string = true;
166        } else if c.is_whitespace() {
167            if !current.is_empty() {
168                tokens.push(current.clone());
169                current.clear();
170            }
171        } else if "(),*+-/=<>!~".contains(c) {
172            if !current.is_empty() {
173                tokens.push(current.clone());
174                current.clear();
175            }
176            // Handle multi-char operators (!=, <=, >=)
177            if (c == '!' || c == '<' || c == '>') && chars.peek() == Some(&'=') {
178                // Safety: we just checked peek() == Some(&'='), so next() is guaranteed
179                if let Some(next_char) = chars.next() {
180                    tokens.push(format!("{c}{next_char}"));
181                }
182            } else {
183                tokens.push(c.to_string());
184            }
185        } else {
186            current.push(c);
187        }
188    }
189
190    if !current.is_empty() {
191        tokens.push(current);
192    }
193
194    tokens
195}
196
197/// Determine the current context from tokens.
198fn determine_context(tokens: &[String]) -> BqlContext {
199    if tokens.is_empty() {
200        return BqlContext::Start;
201    }
202
203    let upper_tokens: Vec<String> = tokens.iter().map(|t| t.to_uppercase()).collect();
204
205    // Check for incomplete string
206    if let Some(last) = tokens.last() {
207        if last.starts_with('"') && !last.ends_with('"') {
208            return BqlContext::InString;
209        }
210    }
211
212    // Find the main statement type
213    let first = upper_tokens.first().map_or("", String::as_str);
214
215    match first {
216        "SELECT" => determine_select_context(&upper_tokens),
217        "JOURNAL" => BqlContext::AfterJournal,
218        "BALANCES" => BqlContext::AfterBalances,
219        "PRINT" => BqlContext::AfterPrint,
220        _ => BqlContext::Start,
221    }
222}
223
224/// Determine context within a SELECT statement.
225fn determine_select_context(tokens: &[String]) -> BqlContext {
226    // Find positions of key clauses
227    let mut from_pos = None;
228    let mut where_pos = None;
229    let mut group_pos = None;
230    let mut order_pos = None;
231    let mut limit_pos = None;
232    let mut last_as_pos = None;
233
234    for (i, token) in tokens.iter().enumerate() {
235        match token.as_str() {
236            "FROM" => from_pos = Some(i),
237            "WHERE" => where_pos = Some(i),
238            "GROUP" => group_pos = Some(i),
239            "ORDER" => order_pos = Some(i),
240            "LIMIT" => limit_pos = Some(i),
241            "AS" => last_as_pos = Some(i),
242            _ => {}
243        }
244    }
245
246    let last_idx = tokens.len() - 1;
247    let last = tokens.last().map_or("", String::as_str);
248
249    // Check for AS context
250    if last == "AS" || last_as_pos == Some(last_idx) {
251        return BqlContext::AfterAs;
252    }
253
254    // Determine context based on last keyword position
255    if let Some(pos) = limit_pos {
256        if last_idx == pos {
257            return BqlContext::AfterLimit;
258        }
259    }
260
261    if let Some(pos) = order_pos {
262        if last_idx == pos {
263            return BqlContext::AfterOrder;
264        }
265        if last_idx > pos {
266            if tokens.get(pos + 1).map(String::as_str) == Some("BY") {
267                return BqlContext::AfterOrderBy;
268            }
269            return BqlContext::AfterOrder;
270        }
271    }
272
273    if let Some(pos) = group_pos {
274        if last_idx == pos {
275            return BqlContext::AfterGroup;
276        }
277        if last_idx > pos {
278            if tokens.get(pos + 1).map(String::as_str) == Some("BY") {
279                return BqlContext::AfterGroupBy;
280            }
281            return BqlContext::AfterGroup;
282        }
283    }
284
285    if let Some(pos) = where_pos {
286        if last_idx == pos {
287            return BqlContext::AfterWhere;
288        }
289        // Check if last token is an operator
290        if [
291            "=", "!=", "<", "<=", ">", ">=", "~", "AND", "OR", "NOT", "IN",
292        ]
293        .contains(&last)
294        {
295            return BqlContext::AfterOperator;
296        }
297        return BqlContext::InWhereExpr;
298    }
299
300    if let Some(pos) = from_pos {
301        if last_idx == pos {
302            return BqlContext::AfterFrom;
303        }
304        // Check for FROM modifiers
305        if ["OPEN", "CLOSE", "CLEAR", "ON"].contains(&last) {
306            return BqlContext::AfterFromModifiers;
307        }
308        return BqlContext::AfterFromModifiers;
309    }
310
311    // We're still in SELECT targets
312    if last_idx == 0 {
313        return BqlContext::AfterSelect;
314    }
315
316    // Check if we just finished a function call or have comma
317    if last == "," || last == "(" {
318        return BqlContext::AfterSelect;
319    }
320
321    BqlContext::AfterSelectTargets
322}
323
324/// Get completions for the given context.
325fn get_completions_for_context(context: &BqlContext) -> Vec<Completion> {
326    match context {
327        BqlContext::Start => vec![
328            keyword("SELECT", Some("Query with filtering and aggregation")),
329            keyword("BALANCES", Some("Show account balances")),
330            keyword("JOURNAL", Some("Show account journal")),
331            keyword("PRINT", Some("Print transactions")),
332        ],
333
334        BqlContext::AfterSelect => {
335            let mut completions = vec![
336                keyword("DISTINCT", Some("Remove duplicate rows")),
337                keyword("*", Some("Select all columns")),
338            ];
339            completions.extend(column_completions());
340            completions.extend(function_completions());
341            completions
342        }
343
344        BqlContext::AfterSelectTargets => vec![
345            keyword("FROM", Some("Specify data source")),
346            keyword("WHERE", Some("Filter results")),
347            keyword("GROUP BY", Some("Group results")),
348            keyword("ORDER BY", Some("Sort results")),
349            keyword("LIMIT", Some("Limit result count")),
350            keyword("AS", Some("Alias column")),
351            operator(",", Some("Add another column")),
352        ],
353
354        BqlContext::AfterFrom => vec![
355            keyword("OPEN ON", Some("Summarize entries before date")),
356            keyword("CLOSE ON", Some("Truncate entries after date")),
357            keyword("CLEAR", Some("Transfer income/expense to equity")),
358            keyword("WHERE", Some("Filter results")),
359            keyword("GROUP BY", Some("Group results")),
360            keyword("ORDER BY", Some("Sort results")),
361        ],
362
363        BqlContext::AfterFromModifiers => vec![
364            keyword("WHERE", Some("Filter results")),
365            keyword("GROUP BY", Some("Group results")),
366            keyword("ORDER BY", Some("Sort results")),
367            keyword("LIMIT", Some("Limit result count")),
368        ],
369
370        BqlContext::AfterWhere | BqlContext::AfterOperator => {
371            let mut completions = column_completions();
372            completions.extend(function_completions());
373            completions.extend(vec![
374                literal("TRUE"),
375                literal("FALSE"),
376                literal("NULL"),
377                keyword("NOT", Some("Negate condition")),
378            ]);
379            completions
380        }
381
382        BqlContext::InWhereExpr => {
383            vec![
384                keyword("AND", Some("Logical AND")),
385                keyword("OR", Some("Logical OR")),
386                operator("=", Some("Equals")),
387                operator("!=", Some("Not equals")),
388                operator("~", Some("Regex match")),
389                operator("<", Some("Less than")),
390                operator(">", Some("Greater than")),
391                operator("<=", Some("Less or equal")),
392                operator(">=", Some("Greater or equal")),
393                keyword("IN", Some("Set membership")),
394                keyword("GROUP BY", Some("Group results")),
395                keyword("ORDER BY", Some("Sort results")),
396                keyword("LIMIT", Some("Limit result count")),
397            ]
398        }
399
400        BqlContext::AfterGroup => vec![keyword("BY", None)],
401
402        BqlContext::AfterGroupBy => {
403            let mut completions = column_completions();
404            completions.extend(vec![
405                keyword("ORDER BY", Some("Sort results")),
406                keyword("LIMIT", Some("Limit result count")),
407                operator(",", Some("Add another group column")),
408            ]);
409            completions
410        }
411
412        BqlContext::AfterOrder => vec![keyword("BY", None)],
413
414        BqlContext::AfterOrderBy => {
415            let mut completions = column_completions();
416            completions.extend(vec![
417                keyword("ASC", Some("Ascending order")),
418                keyword("DESC", Some("Descending order")),
419                keyword("LIMIT", Some("Limit result count")),
420                operator(",", Some("Add another sort column")),
421            ]);
422            completions
423        }
424
425        BqlContext::AfterLimit => vec![literal("10"), literal("100"), literal("1000")],
426
427        BqlContext::AfterJournal | BqlContext::AfterBalances | BqlContext::AfterPrint => vec![
428            keyword("AT", Some("Apply function to results")),
429            keyword("FROM", Some("Specify data source")),
430        ],
431
432        BqlContext::AfterAs | BqlContext::InString | BqlContext::InFunction(_) => vec![],
433    }
434}
435
436// Helper constructors
437
438fn keyword(text: &str, description: Option<&str>) -> Completion {
439    Completion {
440        text: text.to_string(),
441        category: CompletionCategory::Keyword,
442        description: description.map(String::from),
443    }
444}
445
446fn operator(text: &str, description: Option<&str>) -> Completion {
447    Completion {
448        text: text.to_string(),
449        category: CompletionCategory::Operator,
450        description: description.map(String::from),
451    }
452}
453
454fn literal(text: &str) -> Completion {
455    Completion {
456        text: text.to_string(),
457        category: CompletionCategory::Literal,
458        description: None,
459    }
460}
461
462fn column(text: &str, description: &str) -> Completion {
463    Completion {
464        text: text.to_string(),
465        category: CompletionCategory::Column,
466        description: Some(description.to_string()),
467    }
468}
469
470fn function(text: &str, description: &str) -> Completion {
471    Completion {
472        text: text.to_string(),
473        category: CompletionCategory::Function,
474        description: Some(description.to_string()),
475    }
476}
477
478/// Get column completions.
479fn column_completions() -> Vec<Completion> {
480    vec![
481        column("account", "Account name"),
482        column("date", "Transaction date"),
483        column("narration", "Transaction description"),
484        column("payee", "Transaction payee"),
485        column("flag", "Transaction flag"),
486        column("tags", "Transaction tags"),
487        column("links", "Document links"),
488        column("position", "Posting amount"),
489        column("units", "Posting units"),
490        column("cost", "Cost basis"),
491        column("weight", "Balancing weight"),
492        column("balance", "Running balance"),
493        column("year", "Transaction year"),
494        column("month", "Transaction month"),
495        column("day", "Transaction day"),
496    ]
497}
498
499/// Get function completions.
500fn function_completions() -> Vec<Completion> {
501    vec![
502        // Aggregates
503        function("SUM(", "Sum of values"),
504        function("COUNT(", "Count of rows"),
505        function("MIN(", "Minimum value"),
506        function("MAX(", "Maximum value"),
507        function("AVG(", "Average value"),
508        function("FIRST(", "First value"),
509        function("LAST(", "Last value"),
510        // Date functions
511        function("YEAR(", "Extract year"),
512        function("MONTH(", "Extract month"),
513        function("DAY(", "Extract day"),
514        function("QUARTER(", "Extract quarter"),
515        function("WEEKDAY(", "Day of week (0=Mon)"),
516        function("YMONTH(", "Year-month format"),
517        function("TODAY()", "Current date"),
518        // String functions
519        function("LENGTH(", "String length"),
520        function("UPPER(", "Uppercase"),
521        function("LOWER(", "Lowercase"),
522        function("TRIM(", "Trim whitespace"),
523        function("SUBSTR(", "Substring"),
524        function("COALESCE(", "First non-null"),
525        // Account functions
526        function("PARENT(", "Parent account"),
527        function("LEAF(", "Leaf component"),
528        function("ROOT(", "Root components"),
529        // Amount functions
530        function("NUMBER(", "Extract number"),
531        function("CURRENCY(", "Extract currency"),
532        function("ABS(", "Absolute value"),
533        function("ROUND(", "Round number"),
534    ]
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn test_complete_start() {
543        let result = complete("", 0);
544        assert_eq!(result.context, BqlContext::Start);
545        assert!(result.completions.iter().any(|c| c.text == "SELECT"));
546    }
547
548    #[test]
549    fn test_complete_after_select() {
550        let result = complete("SELECT ", 7);
551        assert_eq!(result.context, BqlContext::AfterSelect);
552        assert!(result.completions.iter().any(|c| c.text == "account"));
553        assert!(result.completions.iter().any(|c| c.text == "SUM("));
554    }
555
556    #[test]
557    fn test_complete_after_where() {
558        let result = complete("SELECT * WHERE ", 15);
559        assert_eq!(result.context, BqlContext::AfterWhere);
560        assert!(result.completions.iter().any(|c| c.text == "account"));
561    }
562
563    #[test]
564    fn test_complete_in_where_expr() {
565        let result = complete("SELECT * WHERE account ", 23);
566        assert_eq!(result.context, BqlContext::InWhereExpr);
567        assert!(result.completions.iter().any(|c| c.text == "="));
568        assert!(result.completions.iter().any(|c| c.text == "~"));
569    }
570
571    #[test]
572    fn test_complete_group_by() {
573        let result = complete("SELECT * GROUP ", 15);
574        assert_eq!(result.context, BqlContext::AfterGroup);
575        assert!(result.completions.iter().any(|c| c.text == "BY"));
576    }
577
578    #[test]
579    fn test_tokenize_bql() {
580        let tokens = tokenize_bql("SELECT account, SUM(position)");
581        assert_eq!(
582            tokens,
583            vec!["SELECT", "account", ",", "SUM", "(", "position", ")"]
584        );
585    }
586
587    #[test]
588    fn test_tokenize_bql_with_string() {
589        let tokens = tokenize_bql("WHERE account ~ \"Expenses\"");
590        assert_eq!(tokens, vec!["WHERE", "account", "~", "\"Expenses\""]);
591    }
592
593    #[test]
594    fn test_tokenize_multi_char_operators() {
595        let tokens = tokenize_bql("WHERE x >= 10 AND y != 5");
596        assert!(tokens.contains(&">=".to_string()));
597        assert!(tokens.contains(&"!=".to_string()));
598    }
599}