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 if last.starts_with('"') && !last.ends_with('"') {
208 return BqlContext::InString;
209 }
210 }
211
212 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
224fn determine_select_context(tokens: &[String]) -> BqlContext {
226 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 if last == "AS" || last_as_pos == Some(last_idx) {
251 return BqlContext::AfterAs;
252 }
253
254 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 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 if ["OPEN", "CLOSE", "CLEAR", "ON"].contains(&last) {
306 return BqlContext::AfterFromModifiers;
307 }
308 return BqlContext::AfterFromModifiers;
309 }
310
311 if last_idx == 0 {
313 return BqlContext::AfterSelect;
314 }
315
316 if last == "," || last == "(" {
318 return BqlContext::AfterSelect;
319 }
320
321 BqlContext::AfterSelectTargets
322}
323
324fn 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
436fn 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
478fn 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 column("currency", "Posting currency"),
497 column("number", "Posting amount number"),
498 column("cost_number", "Per-unit cost number"),
499 column("cost_currency", "Cost currency"),
500 column("cost_date", "Cost lot date"),
501 column("cost_label", "Cost lot label"),
502 column("has_cost", "Whether posting has cost"),
503 column("entry", "Parent transaction object"),
504 column("meta", "All metadata as object"),
505 ]
506}
507
508fn function_completions() -> Vec<Completion> {
510 vec![
511 function("SUM(", "Sum of values"),
513 function("COUNT(", "Count of rows"),
514 function("MIN(", "Minimum value"),
515 function("MAX(", "Maximum value"),
516 function("AVG(", "Average value"),
517 function("FIRST(", "First value"),
518 function("LAST(", "Last value"),
519 function("YEAR(", "Extract year"),
521 function("MONTH(", "Extract month"),
522 function("DAY(", "Extract day"),
523 function("QUARTER(", "Extract quarter"),
524 function("WEEKDAY(", "Day of week (0=Mon)"),
525 function("YMONTH(", "Year-month format"),
526 function("TODAY()", "Current date"),
527 function("LENGTH(", "String length"),
529 function("UPPER(", "Uppercase"),
530 function("LOWER(", "Lowercase"),
531 function("TRIM(", "Trim whitespace"),
532 function("SUBSTR(", "Substring"),
533 function("COALESCE(", "First non-null"),
534 function("PARENT(", "Parent account"),
536 function("LEAF(", "Leaf component"),
537 function("ROOT(", "Root components"),
538 function("NUMBER(", "Extract number"),
540 function("CURRENCY(", "Extract currency"),
541 function("ABS(", "Absolute value"),
542 function("ROUND(", "Round number"),
543 function("META(", "Get metadata value (posting or entry)"),
545 function("ENTRY_META(", "Get entry metadata value"),
546 function("POSTING_META(", "Get posting metadata value"),
547 ]
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn test_complete_start() {
556 let result = complete("", 0);
557 assert_eq!(result.context, BqlContext::Start);
558 assert!(result.completions.iter().any(|c| c.text == "SELECT"));
559 }
560
561 #[test]
562 fn test_complete_after_select() {
563 let result = complete("SELECT ", 7);
564 assert_eq!(result.context, BqlContext::AfterSelect);
565 assert!(result.completions.iter().any(|c| c.text == "account"));
566 assert!(result.completions.iter().any(|c| c.text == "SUM("));
567 }
568
569 #[test]
570 fn test_complete_after_where() {
571 let result = complete("SELECT * WHERE ", 15);
572 assert_eq!(result.context, BqlContext::AfterWhere);
573 assert!(result.completions.iter().any(|c| c.text == "account"));
574 }
575
576 #[test]
577 fn test_complete_in_where_expr() {
578 let result = complete("SELECT * WHERE account ", 23);
579 assert_eq!(result.context, BqlContext::InWhereExpr);
580 assert!(result.completions.iter().any(|c| c.text == "="));
581 assert!(result.completions.iter().any(|c| c.text == "~"));
582 }
583
584 #[test]
585 fn test_complete_group_by() {
586 let result = complete("SELECT * GROUP ", 15);
587 assert_eq!(result.context, BqlContext::AfterGroup);
588 assert!(result.completions.iter().any(|c| c.text == "BY"));
589 }
590
591 #[test]
592 fn test_tokenize_bql() {
593 let tokens = tokenize_bql("SELECT account, SUM(position)");
594 assert_eq!(
595 tokens,
596 vec!["SELECT", "account", ",", "SUM", "(", "position", ")"]
597 );
598 }
599
600 #[test]
601 fn test_tokenize_bql_with_string() {
602 let tokens = tokenize_bql("WHERE account ~ \"Expenses\"");
603 assert_eq!(tokens, vec!["WHERE", "account", "~", "\"Expenses\""]);
604 }
605
606 #[test]
607 fn test_tokenize_multi_char_operators() {
608 let tokens = tokenize_bql("WHERE x >= 10 AND y != 5");
609 assert!(tokens.contains(&">=".to_string()));
610 assert!(tokens.contains(&"!=".to_string()));
611 }
612}