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 ]
497}
498
499fn function_completions() -> Vec<Completion> {
501 vec![
502 function("SUM(", "Sum of values"),
504 function("COUNT(", "Count of rows"),
505 function("MIN(", "Minimum value"),
506 function("MAX(", "Maximum value"),
507 function("AVG(", "Average value"),
508 function("FIRST(", "First value"),
509 function("LAST(", "Last value"),
510 function("YEAR(", "Extract year"),
512 function("MONTH(", "Extract month"),
513 function("DAY(", "Extract day"),
514 function("QUARTER(", "Extract quarter"),
515 function("WEEKDAY(", "Day of week (0=Mon)"),
516 function("YMONTH(", "Year-month format"),
517 function("TODAY()", "Current date"),
518 function("LENGTH(", "String length"),
520 function("UPPER(", "Uppercase"),
521 function("LOWER(", "Lowercase"),
522 function("TRIM(", "Trim whitespace"),
523 function("SUBSTR(", "Substring"),
524 function("COALESCE(", "First non-null"),
525 function("PARENT(", "Parent account"),
527 function("LEAF(", "Leaf component"),
528 function("ROOT(", "Root components"),
529 function("NUMBER(", "Extract number"),
531 function("CURRENCY(", "Extract currency"),
532 function("ABS(", "Absolute value"),
533 function("ROUND(", "Round number"),
534 ]
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn test_complete_start() {
543 let result = complete("", 0);
544 assert_eq!(result.context, BqlContext::Start);
545 assert!(result.completions.iter().any(|c| c.text == "SELECT"));
546 }
547
548 #[test]
549 fn test_complete_after_select() {
550 let result = complete("SELECT ", 7);
551 assert_eq!(result.context, BqlContext::AfterSelect);
552 assert!(result.completions.iter().any(|c| c.text == "account"));
553 assert!(result.completions.iter().any(|c| c.text == "SUM("));
554 }
555
556 #[test]
557 fn test_complete_after_where() {
558 let result = complete("SELECT * WHERE ", 15);
559 assert_eq!(result.context, BqlContext::AfterWhere);
560 assert!(result.completions.iter().any(|c| c.text == "account"));
561 }
562
563 #[test]
564 fn test_complete_in_where_expr() {
565 let result = complete("SELECT * WHERE account ", 23);
566 assert_eq!(result.context, BqlContext::InWhereExpr);
567 assert!(result.completions.iter().any(|c| c.text == "="));
568 assert!(result.completions.iter().any(|c| c.text == "~"));
569 }
570
571 #[test]
572 fn test_complete_group_by() {
573 let result = complete("SELECT * GROUP ", 15);
574 assert_eq!(result.context, BqlContext::AfterGroup);
575 assert!(result.completions.iter().any(|c| c.text == "BY"));
576 }
577
578 #[test]
579 fn test_tokenize_bql() {
580 let tokens = tokenize_bql("SELECT account, SUM(position)");
581 assert_eq!(
582 tokens,
583 vec!["SELECT", "account", ",", "SUM", "(", "position", ")"]
584 );
585 }
586
587 #[test]
588 fn test_tokenize_bql_with_string() {
589 let tokens = tokenize_bql("WHERE account ~ \"Expenses\"");
590 assert_eq!(tokens, vec!["WHERE", "account", "~", "\"Expenses\""]);
591 }
592
593 #[test]
594 fn test_tokenize_multi_char_operators() {
595 let tokens = tokenize_bql("WHERE x >= 10 AND y != 5");
596 assert!(tokens.contains(&">=".to_string()));
597 assert!(tokens.contains(&"!=".to_string()));
598 }
599}