1use rowan::TextSize;
2use squawk_syntax::ast::{self, AstNode};
3use squawk_syntax::{SyntaxKind, SyntaxToken};
4
5use crate::binder;
6use crate::resolve;
7use crate::symbols::{Name, Schema, SymbolKind};
8use crate::tokens::is_string_or_comment;
9
10pub fn completion(file: &ast::SourceFile, offset: TextSize) -> Vec<CompletionItem> {
11 let Some(token) = token_at_offset(file, offset) else {
12 return default_completions();
14 };
15 if is_string_or_comment(token.kind()) {
19 return vec![];
20 }
21
22 match completion_context(&token) {
23 CompletionContext::TableOnly => table_completions(file, &token),
24 CompletionContext::Default => default_completions(),
25 CompletionContext::SelectClause(select_clause) => {
26 select_completions(file, select_clause, &token)
27 }
28 CompletionContext::DeleteClauses(delete) => delete_clauses_completions(&delete),
29 CompletionContext::DeleteExpr(delete) => delete_expr_completions(file, &delete, &token),
30 }
31}
32
33fn select_completions(
34 file: &ast::SourceFile,
35 select_clause: ast::SelectClause,
36 token: &SyntaxToken,
37) -> Vec<CompletionItem> {
38 let binder = binder::bind(file);
39 let mut completions = vec![];
40 let schema = schema_qualifier_at_token(token);
41 let functions = binder.all_symbols_by_kind(SymbolKind::Function, schema.as_ref());
42 completions.extend(functions.into_iter().map(|name| CompletionItem {
43 label: format!("{name}()"),
44 kind: CompletionItemKind::Function,
45 detail: None,
46 insert_text: None,
47 insert_text_format: None,
48 trigger_completion_after_insert: false,
49 sort_text: None,
50 }));
51
52 let tables = binder.all_symbols_by_kind(SymbolKind::Table, schema.as_ref());
53 completions.extend(tables.into_iter().map(|name| CompletionItem {
54 label: name.to_string(),
55 kind: CompletionItemKind::Table,
56 detail: None,
57 insert_text: None,
58 insert_text_format: None,
59 trigger_completion_after_insert: false,
60 sort_text: None,
61 }));
62
63 if schema.is_none() {
64 completions.extend(schema_completions(&binder));
65 }
66
67 if let Some(parent) = select_clause.syntax().parent()
68 && let Some(select) = ast::Select::cast(parent)
69 && let Some(from_clause) = select.from_clause()
70 {
71 for table_ptr in resolve::table_ptrs_from_clause(&binder, &from_clause) {
72 if let Some(create_table) = table_ptr
73 .to_node(file.syntax())
74 .ancestors()
75 .find_map(ast::CreateTableLike::cast)
76 {
77 let columns = resolve::collect_table_columns(&binder, file.syntax(), &create_table);
78 completions.extend(columns.into_iter().filter_map(|column| {
79 let name = column.name()?;
80 Some(CompletionItem {
81 label: crate::symbols::Name::from_node(&name).to_string(),
82 kind: CompletionItemKind::Column,
83 detail: None,
84 insert_text: None,
85 insert_text_format: None,
86 trigger_completion_after_insert: false,
87 sort_text: None,
88 })
89 }));
90 }
91 }
92 }
93
94 return completions;
95}
96
97fn schema_completions(binder: &binder::Binder) -> Vec<CompletionItem> {
98 let builtin_schemas = ["public", "pg_catalog", "pg_temp", "pg_toast", "postgres"];
99 let mut completions: Vec<CompletionItem> = builtin_schemas
100 .into_iter()
101 .enumerate()
102 .map(|(i, name)| CompletionItem {
103 label: name.to_string(),
104 kind: CompletionItemKind::Schema,
105 detail: None,
106 insert_text: None,
107 insert_text_format: None,
108 trigger_completion_after_insert: false,
109 sort_text: Some(format!("{i}")),
110 })
111 .collect();
112
113 for name in binder.all_symbols_by_kind(SymbolKind::Schema, None) {
114 completions.push(CompletionItem {
115 label: name.to_string(),
116 kind: CompletionItemKind::Schema,
117 detail: None,
118 insert_text: None,
119 insert_text_format: None,
120 trigger_completion_after_insert: false,
121 sort_text: None,
122 });
123 }
124
125 completions
126}
127
128fn table_completions(file: &ast::SourceFile, token: &SyntaxToken) -> Vec<CompletionItem> {
129 let binder = binder::bind(file);
130 let schema = schema_qualifier_at_token(token);
131 let tables = binder.all_symbols_by_kind(SymbolKind::Table, schema.as_ref());
132 let mut completions: Vec<CompletionItem> = tables
133 .into_iter()
134 .map(|name| CompletionItem {
135 label: name.to_string(),
136 kind: CompletionItemKind::Table,
137 detail: None,
138 insert_text: None,
139 insert_text_format: None,
140 trigger_completion_after_insert: false,
141 sort_text: None,
142 })
143 .collect();
144
145 if schema.is_none() {
146 completions.extend(schema_completions(&binder));
147 }
148
149 completions
150}
151
152fn delete_clauses_completions(delete: &ast::Delete) -> Vec<CompletionItem> {
153 let mut completions = vec![];
154
155 if delete.using_clause().is_none() {
156 completions.push(CompletionItem {
157 label: "using".to_owned(),
158 kind: CompletionItemKind::Keyword,
159 detail: None,
160 insert_text: Some("using $0".to_owned()),
161 insert_text_format: Some(CompletionInsertTextFormat::Snippet),
162 trigger_completion_after_insert: true,
163 sort_text: None,
164 });
165 }
166
167 if delete.where_clause().is_none() {
168 completions.push(CompletionItem {
169 label: "where".to_owned(),
170 kind: CompletionItemKind::Keyword,
171 detail: None,
172 insert_text: Some("where $0".to_owned()),
173 insert_text_format: Some(CompletionInsertTextFormat::Snippet),
174 trigger_completion_after_insert: true,
175 sort_text: None,
176 });
177 }
178
179 if delete.returning_clause().is_none() {
180 completions.push(CompletionItem {
181 label: "returning".to_owned(),
182 kind: CompletionItemKind::Keyword,
183 detail: None,
184 insert_text: Some("returning $0".to_owned()),
185 insert_text_format: Some(CompletionInsertTextFormat::Snippet),
186 trigger_completion_after_insert: true,
187 sort_text: None,
188 });
189 }
190
191 completions
192}
193
194fn delete_expr_completions(
195 file: &ast::SourceFile,
196 delete: &ast::Delete,
197 token: &SyntaxToken,
198) -> Vec<CompletionItem> {
199 let binder = binder::bind(file);
200 let mut completions = vec![];
201
202 let Some(path) = delete.relation_name().and_then(|r| r.path()) else {
203 return completions;
204 };
205 let Some(delete_table_name) = resolve::extract_table_name(&path) else {
206 return completions;
207 };
208
209 let has_table_qualifier = qualifier_at_token(token).is_some_and(|q| q == delete_table_name);
210
211 if has_table_qualifier {
212 let functions = binder.functions_with_single_param(&delete_table_name);
213 completions.extend(functions.into_iter().map(|name| CompletionItem {
214 label: name.to_string(),
215 kind: CompletionItemKind::Function,
216 detail: None,
217 insert_text: None,
218 insert_text_format: None,
219 trigger_completion_after_insert: false,
220 sort_text: None,
221 }));
222 } else {
223 let functions = binder.all_symbols_by_kind(SymbolKind::Function, None);
224 completions.extend(functions.into_iter().map(|name| CompletionItem {
225 label: format!("{name}()"),
226 kind: CompletionItemKind::Function,
227 detail: None,
228 insert_text: None,
229 insert_text_format: None,
230 trigger_completion_after_insert: false,
231 sort_text: None,
232 }));
233
234 completions.push(CompletionItem {
235 label: delete_table_name.to_string(),
236 kind: CompletionItemKind::Table,
237 detail: None,
238 insert_text: None,
239 insert_text_format: None,
240 trigger_completion_after_insert: false,
241 sort_text: None,
242 });
243 }
244
245 let schema = resolve::extract_schema_name(&path);
246 let position = path.syntax().text_range().start();
247 if let Some(table_ptr) =
248 binder.lookup_with(&delete_table_name, SymbolKind::Table, position, &schema)
249 && let Some(create_table) = table_ptr
250 .to_node(file.syntax())
251 .ancestors()
252 .find_map(ast::CreateTableLike::cast)
253 {
254 let columns = resolve::collect_table_columns(&binder, file.syntax(), &create_table);
255 completions.extend(columns.into_iter().filter_map(|column| {
256 let name = column.name()?;
257 Some(CompletionItem {
258 label: Name::from_node(&name).to_string(),
259 kind: CompletionItemKind::Column,
260 detail: None,
261 insert_text: None,
262 insert_text_format: None,
263 trigger_completion_after_insert: false,
264 sort_text: None,
265 })
266 }));
267 }
268
269 completions
270}
271
272fn qualifier_at_token(token: &SyntaxToken) -> Option<Name> {
273 let qualifier_token = if token.kind() == SyntaxKind::DOT {
274 token.prev_token()
275 } else if token.kind() == SyntaxKind::IDENT
276 && let Some(prev) = token.prev_token()
277 && prev.kind() == SyntaxKind::DOT
278 {
279 prev.prev_token()
280 } else {
281 None
282 };
283
284 qualifier_token
285 .filter(|tk| tk.kind() == SyntaxKind::IDENT)
286 .map(|tk| Name::from_string(tk.text().to_string()))
287}
288
289enum CompletionContext {
290 TableOnly,
291 Default,
292 SelectClause(ast::SelectClause),
293 DeleteClauses(ast::Delete),
294 DeleteExpr(ast::Delete),
295}
296
297fn completion_context(token: &SyntaxToken) -> CompletionContext {
298 if let Some(node) = token.parent() {
299 let mut inside_delete_clause = false;
300 for a in node.ancestors() {
301 if ast::Truncate::can_cast(a.kind()) || ast::Table::can_cast(a.kind()) {
302 return CompletionContext::TableOnly;
303 }
304 if ast::WhereClause::can_cast(a.kind())
305 || ast::UsingClause::can_cast(a.kind())
306 || ast::ReturningClause::can_cast(a.kind())
307 {
308 inside_delete_clause = true;
309 }
310 if let Some(delete) = ast::Delete::cast(a.clone()) {
311 if inside_delete_clause {
312 return CompletionContext::DeleteExpr(delete);
313 }
314 if delete.relation_name().is_some() {
315 return CompletionContext::DeleteClauses(delete);
316 }
317 return CompletionContext::TableOnly;
318 }
319 if let Some(select_clause) = ast::SelectClause::cast(a.clone()) {
320 return CompletionContext::SelectClause(select_clause);
321 }
322 }
323 }
324 CompletionContext::Default
325}
326
327fn token_at_offset(file: &ast::SourceFile, offset: TextSize) -> Option<SyntaxToken> {
328 let Some(mut token) = file.syntax().token_at_offset(offset).left_biased() else {
329 return None;
331 };
332 while token.kind() == SyntaxKind::WHITESPACE {
333 if let Some(tk) = token.prev_token() {
334 token = tk;
335 }
336 }
337 Some(token)
338}
339
340fn schema_qualifier_at_token(token: &SyntaxToken) -> Option<Schema> {
341 qualifier_at_token(token).map(Schema)
342}
343
344fn default_completions() -> Vec<CompletionItem> {
345 ["delete from", "select", "table", "truncate"]
346 .map(|stmt| CompletionItem {
347 label: stmt.to_owned(),
348 kind: CompletionItemKind::Keyword,
349 detail: None,
350 insert_text: Some(format!("{stmt} $0;")),
351 insert_text_format: Some(CompletionInsertTextFormat::Snippet),
352 trigger_completion_after_insert: true,
353 sort_text: None,
354 })
355 .into_iter()
356 .collect()
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum CompletionItemKind {
361 Keyword,
362 Table,
363 Column,
364 Function,
365 Schema,
366 Type,
367 Snippet,
368}
369
370impl CompletionItemKind {
371 fn sort_prefix(self) -> &'static str {
372 match self {
373 Self::Column => "0",
374 Self::Keyword => "1",
375 Self::Table => "1",
376 Self::Type => "1",
377 Self::Snippet => "1",
378 Self::Function => "2",
379 Self::Schema => "9",
380 }
381 }
382}
383
384impl CompletionItem {
385 pub fn sort_text(&self) -> String {
386 let prefix = self.kind.sort_prefix();
387 let suffix = self.sort_text.as_ref().unwrap_or(&self.label);
388 format!("{prefix}_{suffix}")
389 }
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
393pub enum CompletionInsertTextFormat {
394 PlainText,
395 Snippet,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct CompletionItem {
400 pub label: String,
401 pub kind: CompletionItemKind,
402 pub detail: Option<String>,
403 pub insert_text: Option<String>,
404 pub insert_text_format: Option<CompletionInsertTextFormat>,
405 pub trigger_completion_after_insert: bool,
406 pub sort_text: Option<String>,
407}
408
409#[cfg(test)]
410mod tests {
411 use super::completion;
412 use crate::test_utils::fixture;
413 use insta::assert_snapshot;
414 use squawk_syntax::ast;
415 use tabled::builder::Builder;
416 use tabled::settings::Style;
417
418 fn completions(sql: &str) -> String {
419 let (offset, sql) = fixture(sql);
420 let parse = ast::SourceFile::parse(&sql);
421 let file = parse.tree();
422 let items = completion(&file, offset);
423 assert!(
424 !items.is_empty(),
425 "No completions found. If this was intended, use `completions_not_found` instead."
426 );
427 format_items(items)
428 }
429
430 fn completions_not_found(sql: &str) {
431 let (offset, sql) = fixture(sql);
432 let parse = ast::SourceFile::parse(&sql);
433 let file = parse.tree();
434 let items = completion(&file, offset);
435 assert_eq!(
436 items,
437 vec![],
438 "Completions found. If this was unintended, use `completions` instead."
439 )
440 }
441
442 fn format_items(mut items: Vec<super::CompletionItem>) -> String {
443 items.sort_by_key(|a| a.sort_text());
444
445 let rows: Vec<Vec<String>> = items
446 .into_iter()
447 .map(|item| {
448 vec![
449 item.label,
450 format!("{:?}", item.kind),
451 item.detail.unwrap_or_default(),
452 item.insert_text.unwrap_or_default(),
453 ]
454 })
455 .collect();
456
457 let mut builder = Builder::default();
458 builder.push_record(["label", "kind", "detail", "insert_text"]);
459 for row in rows {
460 builder.push_record(row);
461 }
462
463 let mut table = builder.build();
464 table.with(Style::psql());
465 table.to_string()
466 }
467
468 #[test]
469 fn completion_at_start() {
470 assert_snapshot!(completions("$0"), @r"
471 label | kind | detail | insert_text
472 -------------+---------+--------+-----------------
473 delete from | Keyword | | delete from $0;
474 select | Keyword | | select $0;
475 table | Keyword | | table $0;
476 truncate | Keyword | | truncate $0;
477 ");
478 }
479
480 #[test]
481 fn completion_at_top_level() {
482 assert_snapshot!(completions("
483create table t(a int);
484$0
485"), @r"
486 label | kind | detail | insert_text
487 -------------+---------+--------+-----------------
488 delete from | Keyword | | delete from $0;
489 select | Keyword | | select $0;
490 table | Keyword | | table $0;
491 truncate | Keyword | | truncate $0;
492 ");
493 }
494
495 #[test]
496 fn completion_in_string() {
497 completions_not_found("select '$0';");
498 }
499
500 #[test]
501 fn completion_in_comment() {
502 completions_not_found("-- $0 ");
503 }
504
505 #[test]
506 fn completion_after_truncate() {
507 assert_snapshot!(completions("
508create table users (id int);
509truncate $0;
510"), @r"
511 label | kind | detail | insert_text
512 ------------+--------+--------+-------------
513 users | Table | |
514 public | Schema | |
515 pg_catalog | Schema | |
516 pg_temp | Schema | |
517 pg_toast | Schema | |
518 postgres | Schema | |
519 ");
520 }
521
522 #[test]
523 fn completion_table_at_top_level() {
524 assert_snapshot!(completions("$0"), @r"
525 label | kind | detail | insert_text
526 -------------+---------+--------+-----------------
527 delete from | Keyword | | delete from $0;
528 select | Keyword | | select $0;
529 table | Keyword | | table $0;
530 truncate | Keyword | | truncate $0;
531 ");
532 }
533
534 #[test]
535 fn completion_table_nested() {
536 assert_snapshot!(completions("select * from ($0)"), @r"
537 label | kind | detail | insert_text
538 -------------+---------+--------+-----------------
539 delete from | Keyword | | delete from $0;
540 select | Keyword | | select $0;
541 table | Keyword | | table $0;
542 truncate | Keyword | | truncate $0;
543 ");
544 }
545
546 #[test]
547 fn completion_after_table() {
548 assert_snapshot!(completions("
549create table users (id int);
550table $0;
551"), @r"
552 label | kind | detail | insert_text
553 ------------+--------+--------+-------------
554 users | Table | |
555 public | Schema | |
556 pg_catalog | Schema | |
557 pg_temp | Schema | |
558 pg_toast | Schema | |
559 postgres | Schema | |
560 ");
561 }
562
563 #[test]
564 fn completion_after_select() {
565 assert_snapshot!(completions("
566create table t(a text, b int);
567create function f() returns text as 'select 1::text' language sql;
568select $0 from t;
569"), @r"
570 label | kind | detail | insert_text
571 ------------+----------+--------+-------------
572 a | Column | |
573 b | Column | |
574 t | Table | |
575 f() | Function | |
576 public | Schema | |
577 pg_catalog | Schema | |
578 pg_temp | Schema | |
579 pg_toast | Schema | |
580 postgres | Schema | |
581 ");
582 }
583
584 #[test]
585 fn completion_with_schema_qualifier() {
586 assert_snapshot!(completions("
587create function f() returns int8 as 'select 1' language sql;
588create function foo.b() returns int8 as 'select 2' language sql;
589select public.$0;
590"), @r"
591 label | kind | detail | insert_text
592 -------+----------+--------+-------------
593 f() | Function | |
594 ");
595 }
596
597 #[test]
598 fn completion_truncate_with_schema_qualifier() {
599 assert_snapshot!(completions("
600create table users (id int);
601truncate public.$0;
602"), @r"
603 label | kind | detail | insert_text
604 -------+-------+--------+-------------
605 users | Table | |
606 ");
607 }
608
609 #[test]
610 fn completion_after_delete_from() {
611 assert_snapshot!(completions("
612create table users (id int);
613delete from $0;
614"), @r"
615 label | kind | detail | insert_text
616 ------------+--------+--------+-------------
617 users | Table | |
618 public | Schema | |
619 pg_catalog | Schema | |
620 pg_temp | Schema | |
621 pg_toast | Schema | |
622 postgres | Schema | |
623 ");
624 }
625
626 #[test]
627 fn completion_delete_clauses() {
628 assert_snapshot!(completions("
629create table t (id int);
630delete from t $0;
631"), @r"
632 label | kind | detail | insert_text
633 -----------+---------+--------+--------------
634 returning | Keyword | | returning $0
635 using | Keyword | | using $0
636 where | Keyword | | where $0
637 ");
638 }
639
640 #[test]
641 fn completion_delete_where_expr() {
642 assert_snapshot!(completions("
643create table t (id int, name text);
644create function is_active() returns bool as 'select true' language sql;
645delete from t where $0;
646"), @r"
647 label | kind | detail | insert_text
648 -------------+----------+--------+-------------
649 id | Column | |
650 name | Column | |
651 t | Table | |
652 is_active() | Function | |
653 ")
654 }
655
656 #[test]
657 fn completion_delete_returning_expr() {
658 assert_snapshot!(completions("
659create table t (id int, name text);
660delete from t returning $0;
661"), @r"
662 label | kind | detail | insert_text
663 -------+--------+--------+-------------
664 id | Column | |
665 name | Column | |
666 t | Table | |
667 ");
668 }
669
670 #[test]
671 fn completion_delete_where_qualified() {
672 assert_snapshot!(completions("
673-- different type than the table, so we shouldn't show this
674create function b(diff_type) returns int8
675 as 'select 1'
676 language sql;
677create function f(t) returns int8
678 as 'select 1'
679 language sql;
680create table t (a int, b text);
681delete from t where t.$0;
682"), @r"
683 label | kind | detail | insert_text
684 -------+----------+--------+-------------
685 a | Column | |
686 b | Column | |
687 f | Function | |
688 ");
689 }
690}