1#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Completion {
18 pub text: String,
20 pub category: CompletionCategory,
22 pub description: Option<String>,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum CompletionCategory {
29 Keyword,
31 Function,
33 Column,
35 Operator,
37 Literal,
39}
40
41impl CompletionCategory {
42 #[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#[derive(Debug, Clone)]
57pub struct CompletionResult {
58 pub completions: Vec<Completion>,
60 pub context: BqlContext,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum BqlContext {
67 Start,
69 AfterSelect,
71 AfterSelectTargets,
73 AfterFrom,
75 AfterFromModifiers,
77 AfterWhere,
79 InWhereExpr,
81 AfterGroup,
83 AfterGroupBy,
85 AfterOrder,
87 AfterOrderBy,
89 AfterLimit,
91 AfterJournal,
93 AfterBalances,
95 AfterPrint,
97 InFunction(String),
99 AfterOperator,
101 AfterAs,
103 InString,
105}
106
107#[must_use]
125pub fn complete(partial_query: &str, cursor_pos: usize) -> CompletionResult {
126 let text = if cursor_pos <= partial_query.len() {
128 &partial_query[..cursor_pos]
129 } else {
130 partial_query
131 };
132
133 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
144fn 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 if (c == '!' || c == '<' || c == '>') && chars.peek() == Some(&'=') {
178 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
197fn 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 if let Some(last) = tokens.last()
207 && last.starts_with('"')
208 && !last.ends_with('"')
209 {
210 return BqlContext::InString;
211 }
212
213 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
225fn determine_select_context(tokens: &[String]) -> BqlContext {
227 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 if last == "AS" || last_as_pos == Some(last_idx) {
252 return BqlContext::AfterAs;
253 }
254
255 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 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 if ["OPEN", "CLOSE", "CLEAR", "ON"].contains(&last) {
307 return BqlContext::AfterFromModifiers;
308 }
309 return BqlContext::AfterFromModifiers;
310 }
311
312 if last_idx == 0 {
314 return BqlContext::AfterSelect;
315 }
316
317 if last == "," || last == "(" {
319 return BqlContext::AfterSelect;
320 }
321
322 BqlContext::AfterSelectTargets
323}
324
325fn 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
443fn 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
485fn 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
519fn function_completions() -> Vec<Completion> {
521 vec![
522 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 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 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 function("PARENT(", "Parent account"),
547 function("LEAF(", "Leaf component"),
548 function("ROOT(", "Root components"),
549 function("NUMBER(", "Extract number"),
551 function("CURRENCY(", "Extract currency"),
552 function("ABS(", "Absolute value"),
553 function("ROUND(", "Round number"),
554 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}