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(
357                "OPEN ON",
358                Some("Inclusive lower bound: summarize entries before, include from"),
359            ),
360            keyword(
361                "CLOSE ON",
362                Some("Exclusive upper bound: include entries strictly before"),
363            ),
364            keyword("CLEAR", Some("Transfer income/expense to equity")),
365            keyword("WHERE", Some("Filter results")),
366            keyword("GROUP BY", Some("Group results")),
367            keyword("ORDER BY", Some("Sort results")),
368        ],
369
370        BqlContext::AfterFromModifiers => vec![
371            keyword("WHERE", Some("Filter results")),
372            keyword("GROUP BY", Some("Group results")),
373            keyword("ORDER BY", Some("Sort results")),
374            keyword("LIMIT", Some("Limit result count")),
375        ],
376
377        BqlContext::AfterWhere | BqlContext::AfterOperator => {
378            let mut completions = column_completions();
379            completions.extend(function_completions());
380            completions.extend(vec![
381                literal("TRUE"),
382                literal("FALSE"),
383                literal("NULL"),
384                keyword("NOT", Some("Negate condition")),
385            ]);
386            completions
387        }
388
389        BqlContext::InWhereExpr => {
390            vec![
391                keyword("AND", Some("Logical AND")),
392                keyword("OR", Some("Logical OR")),
393                operator("=", Some("Equals")),
394                operator("!=", Some("Not equals")),
395                operator("~", Some("Regex match")),
396                operator("<", Some("Less than")),
397                operator(">", Some("Greater than")),
398                operator("<=", Some("Less or equal")),
399                operator(">=", Some("Greater or equal")),
400                keyword("IN", Some("Set membership")),
401                keyword("GROUP BY", Some("Group results")),
402                keyword("ORDER BY", Some("Sort results")),
403                keyword("LIMIT", Some("Limit result count")),
404            ]
405        }
406
407        BqlContext::AfterGroup => vec![keyword("BY", None)],
408
409        BqlContext::AfterGroupBy => {
410            let mut completions = column_completions();
411            completions.extend(vec![
412                keyword("ORDER BY", Some("Sort results")),
413                keyword("LIMIT", Some("Limit result count")),
414                operator(",", Some("Add another group column")),
415            ]);
416            completions
417        }
418
419        BqlContext::AfterOrder => vec![keyword("BY", None)],
420
421        BqlContext::AfterOrderBy => {
422            let mut completions = column_completions();
423            completions.extend(vec![
424                keyword("ASC", Some("Ascending order")),
425                keyword("DESC", Some("Descending order")),
426                keyword("LIMIT", Some("Limit result count")),
427                operator(",", Some("Add another sort column")),
428            ]);
429            completions
430        }
431
432        BqlContext::AfterLimit => vec![literal("10"), literal("100"), literal("1000")],
433
434        BqlContext::AfterJournal | BqlContext::AfterBalances | BqlContext::AfterPrint => vec![
435            keyword("AT", Some("Apply function to results")),
436            keyword("FROM", Some("Specify data source")),
437        ],
438
439        BqlContext::AfterAs | BqlContext::InString | BqlContext::InFunction(_) => vec![],
440    }
441}
442
443// Helper constructors
444
445fn keyword(text: &str, description: Option<&str>) -> Completion {
446    Completion {
447        text: text.to_string(),
448        category: CompletionCategory::Keyword,
449        description: description.map(String::from),
450    }
451}
452
453fn operator(text: &str, description: Option<&str>) -> Completion {
454    Completion {
455        text: text.to_string(),
456        category: CompletionCategory::Operator,
457        description: description.map(String::from),
458    }
459}
460
461fn literal(text: &str) -> Completion {
462    Completion {
463        text: text.to_string(),
464        category: CompletionCategory::Literal,
465        description: None,
466    }
467}
468
469fn column(text: &str, description: &str) -> Completion {
470    Completion {
471        text: text.to_string(),
472        category: CompletionCategory::Column,
473        description: Some(description.to_string()),
474    }
475}
476
477fn function(text: &str, description: &str) -> Completion {
478    Completion {
479        text: text.to_string(),
480        category: CompletionCategory::Function,
481        description: Some(description.to_string()),
482    }
483}
484
485/// Get column completions.
486fn column_completions() -> Vec<Completion> {
487    vec![
488        column("account", "Account name"),
489        column("date", "Transaction date"),
490        column("narration", "Transaction description"),
491        column("payee", "Transaction payee"),
492        column("flag", "Transaction flag"),
493        column("tags", "Transaction tags"),
494        column("links", "Document links"),
495        column("position", "Posting amount"),
496        column("units", "Posting units"),
497        column("cost", "Cost basis"),
498        column("weight", "Balancing weight"),
499        column(
500            "balance",
501            "Cumulative running balance across WHERE-filtered postings",
502        ),
503        column("account_balance", "Per-account running balance"),
504        column("year", "Transaction year"),
505        column("month", "Transaction month"),
506        column("day", "Transaction day"),
507        column("currency", "Posting currency"),
508        column("number", "Posting amount number"),
509        column("cost_number", "Per-unit cost number"),
510        column("cost_currency", "Cost currency"),
511        column("cost_date", "Cost lot date"),
512        column("cost_label", "Cost lot label"),
513        column("has_cost", "Whether posting has cost"),
514        column("entry", "Parent transaction object"),
515        column("meta", "All metadata as object"),
516    ]
517}
518
519/// Get function completions.
520fn function_completions() -> Vec<Completion> {
521    vec![
522        // Aggregates
523        function("SUM(", "Sum of values"),
524        function("COUNT(", "Count of rows"),
525        function("MIN(", "Minimum value"),
526        function("MAX(", "Maximum value"),
527        function("AVG(", "Average value"),
528        function("FIRST(", "First value"),
529        function("LAST(", "Last value"),
530        // Date functions
531        function("YEAR(", "Extract year"),
532        function("MONTH(", "Extract month"),
533        function("DAY(", "Extract day"),
534        function("QUARTER(", "Extract quarter"),
535        function("WEEKDAY(", "Day-of-week name (Mon..Sun)"),
536        function("YMONTH(", "Year-month format"),
537        function("TODAY()", "Current date"),
538        // String functions
539        function("LENGTH(", "String length"),
540        function("UPPER(", "Uppercase"),
541        function("LOWER(", "Lowercase"),
542        function("TRIM(", "Trim whitespace"),
543        function("SUBSTR(", "Substring"),
544        function("COALESCE(", "First non-null"),
545        // Account functions
546        function("PARENT(", "Parent account"),
547        function("LEAF(", "Leaf component"),
548        function("ROOT(", "Root components"),
549        // Amount functions
550        function("NUMBER(", "Extract number"),
551        function("CURRENCY(", "Extract currency"),
552        function("ABS(", "Absolute value"),
553        function("ROUND(", "Round number"),
554        // Metadata functions
555        function("META(", "Get metadata value (posting or entry)"),
556        function("ENTRY_META(", "Get entry metadata value"),
557        function("POSTING_META(", "Get posting metadata value"),
558    ]
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    #[test]
566    fn test_complete_start() {
567        let result = complete("", 0);
568        assert_eq!(result.context, BqlContext::Start);
569        assert!(result.completions.iter().any(|c| c.text == "SELECT"));
570    }
571
572    #[test]
573    fn test_complete_after_select() {
574        let result = complete("SELECT ", 7);
575        assert_eq!(result.context, BqlContext::AfterSelect);
576        assert!(result.completions.iter().any(|c| c.text == "account"));
577        assert!(result.completions.iter().any(|c| c.text == "SUM("));
578    }
579
580    #[test]
581    fn test_complete_after_where() {
582        let result = complete("SELECT * WHERE ", 15);
583        assert_eq!(result.context, BqlContext::AfterWhere);
584        assert!(result.completions.iter().any(|c| c.text == "account"));
585    }
586
587    #[test]
588    fn test_complete_in_where_expr() {
589        let result = complete("SELECT * WHERE account ", 23);
590        assert_eq!(result.context, BqlContext::InWhereExpr);
591        assert!(result.completions.iter().any(|c| c.text == "="));
592        assert!(result.completions.iter().any(|c| c.text == "~"));
593    }
594
595    #[test]
596    fn test_complete_group_by() {
597        let result = complete("SELECT * GROUP ", 15);
598        assert_eq!(result.context, BqlContext::AfterGroup);
599        assert!(result.completions.iter().any(|c| c.text == "BY"));
600    }
601
602    #[test]
603    fn test_tokenize_bql() {
604        let tokens = tokenize_bql("SELECT account, SUM(position)");
605        assert_eq!(
606            tokens,
607            vec!["SELECT", "account", ",", "SUM", "(", "position", ")"]
608        );
609    }
610
611    #[test]
612    fn test_tokenize_bql_with_string() {
613        let tokens = tokenize_bql("WHERE account ~ \"Expenses\"");
614        assert_eq!(tokens, vec!["WHERE", "account", "~", "\"Expenses\""]);
615    }
616
617    #[test]
618    fn test_tokenize_multi_char_operators() {
619        let tokens = tokenize_bql("WHERE x >= 10 AND y != 5");
620        assert!(tokens.contains(&">=".to_string()));
621        assert!(tokens.contains(&"!=".to_string()));
622    }
623}