squawk_ide/
completion.rs

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        // empty file
13        return default_completions();
14    };
15    // We don't support completions inside comments since we don't have doc
16    // comments a la JSDoc.
17    // And we don't have string literal types so we bail out early for strings too.
18    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        // empty file - definitely at top level
330        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}