Skip to main content

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        && last.starts_with('"')
208        && !last.ends_with('"')
209    {
210        return BqlContext::InString;
211    }
212
213    // Find the main statement type
214    let first = upper_tokens.first().map_or("", String::as_str);
215
216    match first {
217        "SELECT" => determine_select_context(&upper_tokens),
218        "JOURNAL" => BqlContext::AfterJournal,
219        "BALANCES" => BqlContext::AfterBalances,
220        "PRINT" => BqlContext::AfterPrint,
221        _ => BqlContext::Start,
222    }
223}
224
225/// Determine context within a SELECT statement.
226fn determine_select_context(tokens: &[String]) -> BqlContext {
227    // Find positions of key clauses
228    let mut from_pos = None;
229    let mut where_pos = None;
230    let mut group_pos = None;
231    let mut order_pos = None;
232    let mut limit_pos = None;
233    let mut last_as_pos = None;
234
235    for (i, token) in tokens.iter().enumerate() {
236        match token.as_str() {
237            "FROM" => from_pos = Some(i),
238            "WHERE" => where_pos = Some(i),
239            "GROUP" => group_pos = Some(i),
240            "ORDER" => order_pos = Some(i),
241            "LIMIT" => limit_pos = Some(i),
242            "AS" => last_as_pos = Some(i),
243            _ => {}
244        }
245    }
246
247    let last_idx = tokens.len() - 1;
248    let last = tokens.last().map_or("", String::as_str);
249
250    // Check for AS context
251    if last == "AS" || last_as_pos == Some(last_idx) {
252        return BqlContext::AfterAs;
253    }
254
255    // Determine context based on last keyword position
256    if let Some(pos) = limit_pos
257        && last_idx == pos
258    {
259        return BqlContext::AfterLimit;
260    }
261
262    if let Some(pos) = order_pos {
263        if last_idx == pos {
264            return BqlContext::AfterOrder;
265        }
266        if last_idx > pos {
267            if tokens.get(pos + 1).map(String::as_str) == Some("BY") {
268                return BqlContext::AfterOrderBy;
269            }
270            return BqlContext::AfterOrder;
271        }
272    }
273
274    if let Some(pos) = group_pos {
275        if last_idx == pos {
276            return BqlContext::AfterGroup;
277        }
278        if last_idx > pos {
279            if tokens.get(pos + 1).map(String::as_str) == Some("BY") {
280                return BqlContext::AfterGroupBy;
281            }
282            return BqlContext::AfterGroup;
283        }
284    }
285
286    if let Some(pos) = where_pos {
287        if last_idx == pos {
288            return BqlContext::AfterWhere;
289        }
290        // Check if last token is an operator
291        if [
292            "=", "!=", "<", "<=", ">", ">=", "~", "AND", "OR", "NOT", "IN",
293        ]
294        .contains(&last)
295        {
296            return BqlContext::AfterOperator;
297        }
298        return BqlContext::InWhereExpr;
299    }
300
301    if let Some(pos) = from_pos {
302        if last_idx == pos {
303            return BqlContext::AfterFrom;
304        }
305        // Check for FROM modifiers
306        if ["OPEN", "CLOSE", "CLEAR", "ON"].contains(&last) {
307            return BqlContext::AfterFromModifiers;
308        }
309        return BqlContext::AfterFromModifiers;
310    }
311
312    // We're still in SELECT targets
313    if last_idx == 0 {
314        return BqlContext::AfterSelect;
315    }
316
317    // Check if we just finished a function call or have comma
318    if last == "," || last == "(" {
319        return BqlContext::AfterSelect;
320    }
321
322    BqlContext::AfterSelectTargets
323}
324
325/// Get completions for the given context.
326fn get_completions_for_context(context: &BqlContext) -> Vec<Completion> {
327    match context {
328        BqlContext::Start => vec![
329            keyword("SELECT", Some("Query with filtering and aggregation")),
330            keyword("BALANCES", Some("Show account balances")),
331            keyword("JOURNAL", Some("Show account journal")),
332            keyword("PRINT", Some("Print transactions")),
333        ],
334
335        BqlContext::AfterSelect => {
336            let mut completions = vec![
337                keyword("DISTINCT", Some("Remove duplicate rows")),
338                keyword("*", Some("Select all columns")),
339            ];
340            completions.extend(column_completions());
341            completions.extend(function_completions());
342            completions
343        }
344
345        BqlContext::AfterSelectTargets => vec![
346            keyword("FROM", Some("Specify data source")),
347            keyword("WHERE", Some("Filter results")),
348            keyword("GROUP BY", Some("Group results")),
349            keyword("ORDER BY", Some("Sort results")),
350            keyword("LIMIT", Some("Limit result count")),
351            keyword("AS", Some("Alias column")),
352            operator(",", Some("Add another column")),
353        ],
354
355        BqlContext::AfterFrom => vec![
356            keyword("OPEN ON", Some("Summarize entries before date")),
357            keyword("CLOSE ON", Some("Truncate entries after date")),
358            keyword("CLEAR", Some("Transfer income/expense to equity")),
359            keyword("WHERE", Some("Filter results")),
360            keyword("GROUP BY", Some("Group results")),
361            keyword("ORDER BY", Some("Sort results")),
362        ],
363
364        BqlContext::AfterFromModifiers => vec![
365            keyword("WHERE", Some("Filter results")),
366            keyword("GROUP BY", Some("Group results")),
367            keyword("ORDER BY", Some("Sort results")),
368            keyword("LIMIT", Some("Limit result count")),
369        ],
370
371        BqlContext::AfterWhere | BqlContext::AfterOperator => {
372            let mut completions = column_completions();
373            completions.extend(function_completions());
374            completions.extend(vec![
375                literal("TRUE"),
376                literal("FALSE"),
377                literal("NULL"),
378                keyword("NOT", Some("Negate condition")),
379            ]);
380            completions
381        }
382
383        BqlContext::InWhereExpr => {
384            vec![
385                keyword("AND", Some("Logical AND")),
386                keyword("OR", Some("Logical OR")),
387                operator("=", Some("Equals")),
388                operator("!=", Some("Not equals")),
389                operator("~", Some("Regex match")),
390                operator("<", Some("Less than")),
391                operator(">", Some("Greater than")),
392                operator("<=", Some("Less or equal")),
393                operator(">=", Some("Greater or equal")),
394                keyword("IN", Some("Set membership")),
395                keyword("GROUP BY", Some("Group results")),
396                keyword("ORDER BY", Some("Sort results")),
397                keyword("LIMIT", Some("Limit result count")),
398            ]
399        }
400
401        BqlContext::AfterGroup => vec![keyword("BY", None)],
402
403        BqlContext::AfterGroupBy => {
404            let mut completions = column_completions();
405            completions.extend(vec![
406                keyword("ORDER BY", Some("Sort results")),
407                keyword("LIMIT", Some("Limit result count")),
408                operator(",", Some("Add another group column")),
409            ]);
410            completions
411        }
412
413        BqlContext::AfterOrder => vec![keyword("BY", None)],
414
415        BqlContext::AfterOrderBy => {
416            let mut completions = column_completions();
417            completions.extend(vec![
418                keyword("ASC", Some("Ascending order")),
419                keyword("DESC", Some("Descending order")),
420                keyword("LIMIT", Some("Limit result count")),
421                operator(",", Some("Add another sort column")),
422            ]);
423            completions
424        }
425
426        BqlContext::AfterLimit => vec![literal("10"), literal("100"), literal("1000")],
427
428        BqlContext::AfterJournal | BqlContext::AfterBalances | BqlContext::AfterPrint => vec![
429            keyword("AT", Some("Apply function to results")),
430            keyword("FROM", Some("Specify data source")),
431        ],
432
433        BqlContext::AfterAs | BqlContext::InString | BqlContext::InFunction(_) => vec![],
434    }
435}
436
437// Helper constructors
438
439fn keyword(text: &str, description: Option<&str>) -> Completion {
440    Completion {
441        text: text.to_string(),
442        category: CompletionCategory::Keyword,
443        description: description.map(String::from),
444    }
445}
446
447fn operator(text: &str, description: Option<&str>) -> Completion {
448    Completion {
449        text: text.to_string(),
450        category: CompletionCategory::Operator,
451        description: description.map(String::from),
452    }
453}
454
455fn literal(text: &str) -> Completion {
456    Completion {
457        text: text.to_string(),
458        category: CompletionCategory::Literal,
459        description: None,
460    }
461}
462
463fn column(text: &str, description: &str) -> Completion {
464    Completion {
465        text: text.to_string(),
466        category: CompletionCategory::Column,
467        description: Some(description.to_string()),
468    }
469}
470
471fn function(text: &str, description: &str) -> Completion {
472    Completion {
473        text: text.to_string(),
474        category: CompletionCategory::Function,
475        description: Some(description.to_string()),
476    }
477}
478
479/// Get column completions.
480fn column_completions() -> Vec<Completion> {
481    vec![
482        column("account", "Account name"),
483        column("date", "Transaction date"),
484        column("narration", "Transaction description"),
485        column("payee", "Transaction payee"),
486        column("flag", "Transaction flag"),
487        column("tags", "Transaction tags"),
488        column("links", "Document links"),
489        column("position", "Posting amount"),
490        column("units", "Posting units"),
491        column("cost", "Cost basis"),
492        column("weight", "Balancing weight"),
493        column("balance", "Running balance"),
494        column("year", "Transaction year"),
495        column("month", "Transaction month"),
496        column("day", "Transaction day"),
497        column("currency", "Posting currency"),
498        column("number", "Posting amount number"),
499        column("cost_number", "Per-unit cost number"),
500        column("cost_currency", "Cost currency"),
501        column("cost_date", "Cost lot date"),
502        column("cost_label", "Cost lot label"),
503        column("has_cost", "Whether posting has cost"),
504        column("entry", "Parent transaction object"),
505        column("meta", "All metadata as object"),
506    ]
507}
508
509/// Get function completions.
510fn function_completions() -> Vec<Completion> {
511    vec![
512        // Aggregates
513        function("SUM(", "Sum of values"),
514        function("COUNT(", "Count of rows"),
515        function("MIN(", "Minimum value"),
516        function("MAX(", "Maximum value"),
517        function("AVG(", "Average value"),
518        function("FIRST(", "First value"),
519        function("LAST(", "Last value"),
520        // Date functions
521        function("YEAR(", "Extract year"),
522        function("MONTH(", "Extract month"),
523        function("DAY(", "Extract day"),
524        function("QUARTER(", "Extract quarter"),
525        function("WEEKDAY(", "Day of week (0=Mon)"),
526        function("YMONTH(", "Year-month format"),
527        function("TODAY()", "Current date"),
528        // String functions
529        function("LENGTH(", "String length"),
530        function("UPPER(", "Uppercase"),
531        function("LOWER(", "Lowercase"),
532        function("TRIM(", "Trim whitespace"),
533        function("SUBSTR(", "Substring"),
534        function("COALESCE(", "First non-null"),
535        // Account functions
536        function("PARENT(", "Parent account"),
537        function("LEAF(", "Leaf component"),
538        function("ROOT(", "Root components"),
539        // Amount functions
540        function("NUMBER(", "Extract number"),
541        function("CURRENCY(", "Extract currency"),
542        function("ABS(", "Absolute value"),
543        function("ROUND(", "Round number"),
544        // Metadata functions
545        function("META(", "Get metadata value (posting or entry)"),
546        function("ENTRY_META(", "Get entry metadata value"),
547        function("POSTING_META(", "Get posting metadata value"),
548    ]
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn test_complete_start() {
557        let result = complete("", 0);
558        assert_eq!(result.context, BqlContext::Start);
559        assert!(result.completions.iter().any(|c| c.text == "SELECT"));
560    }
561
562    #[test]
563    fn test_complete_after_select() {
564        let result = complete("SELECT ", 7);
565        assert_eq!(result.context, BqlContext::AfterSelect);
566        assert!(result.completions.iter().any(|c| c.text == "account"));
567        assert!(result.completions.iter().any(|c| c.text == "SUM("));
568    }
569
570    #[test]
571    fn test_complete_after_where() {
572        let result = complete("SELECT * WHERE ", 15);
573        assert_eq!(result.context, BqlContext::AfterWhere);
574        assert!(result.completions.iter().any(|c| c.text == "account"));
575    }
576
577    #[test]
578    fn test_complete_in_where_expr() {
579        let result = complete("SELECT * WHERE account ", 23);
580        assert_eq!(result.context, BqlContext::InWhereExpr);
581        assert!(result.completions.iter().any(|c| c.text == "="));
582        assert!(result.completions.iter().any(|c| c.text == "~"));
583    }
584
585    #[test]
586    fn test_complete_group_by() {
587        let result = complete("SELECT * GROUP ", 15);
588        assert_eq!(result.context, BqlContext::AfterGroup);
589        assert!(result.completions.iter().any(|c| c.text == "BY"));
590    }
591
592    #[test]
593    fn test_tokenize_bql() {
594        let tokens = tokenize_bql("SELECT account, SUM(position)");
595        assert_eq!(
596            tokens,
597            vec!["SELECT", "account", ",", "SUM", "(", "position", ")"]
598        );
599    }
600
601    #[test]
602    fn test_tokenize_bql_with_string() {
603        let tokens = tokenize_bql("WHERE account ~ \"Expenses\"");
604        assert_eq!(tokens, vec!["WHERE", "account", "~", "\"Expenses\""]);
605    }
606
607    #[test]
608    fn test_tokenize_multi_char_operators() {
609        let tokens = tokenize_bql("WHERE x >= 10 AND y != 5");
610        assert!(tokens.contains(&">=".to_string()));
611        assert!(tokens.contains(&"!=".to_string()));
612    }
613}