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("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
437fn 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
479fn 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
509fn function_completions() -> Vec<Completion> {
511 vec![
512 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 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 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 function("PARENT(", "Parent account"),
537 function("LEAF(", "Leaf component"),
538 function("ROOT(", "Root components"),
539 function("NUMBER(", "Extract number"),
541 function("CURRENCY(", "Extract currency"),
542 function("ABS(", "Absolute value"),
543 function("ROUND(", "Round number"),
544 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}