Skip to main content

squawk_ide/
inlay_hints.rs

1use crate::builtins::parse_builtins;
2use crate::db::{File, parse};
3use crate::goto_definition::FileId;
4use crate::resolve;
5use crate::symbols::Name;
6use crate::{binder, goto_definition};
7use rowan::{TextRange, TextSize};
8use salsa::Database as Db;
9use squawk_syntax::ast::{self, AstNode};
10
11/// `VSCode` has some theming options based on these types.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum InlayHintKind {
14    Type,
15    Parameter,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct InlayHint {
20    pub position: TextSize,
21    pub label: String,
22    pub kind: InlayHintKind,
23    // Need this to be an Option because we can still inlay hints when we don't
24    // have the destination.
25    // For example: `insert into t(a, b) values (1, 2)`
26    pub target: Option<TextRange>,
27    // TODO: combine with the target range above
28    pub file: Option<FileId>,
29}
30
31#[salsa::tracked]
32pub fn inlay_hints(db: &dyn Db, file: File) -> Vec<InlayHint> {
33    let parse = parse(db, file);
34    let source_file = parse.tree();
35
36    let mut hints = vec![];
37    for node in source_file.syntax().descendants() {
38        if let Some(call_expr) = ast::CallExpr::cast(node.clone()) {
39            inlay_hint_call_expr(db, &mut hints, file, &source_file, call_expr);
40        } else if let Some(insert) = ast::Insert::cast(node) {
41            inlay_hint_insert(db, &mut hints, file, &source_file, insert);
42        }
43    }
44    hints
45}
46
47fn inlay_hint_call_expr(
48    db: &dyn Db,
49    hints: &mut Vec<InlayHint>,
50    file_id: File,
51    file: &ast::SourceFile,
52    call_expr: ast::CallExpr,
53) -> Option<()> {
54    let arg_list = call_expr.arg_list()?;
55    let expr = call_expr.expr()?;
56
57    let name_ref = if let Some(name_ref) = ast::NameRef::cast(expr.syntax().clone()) {
58        name_ref
59    } else {
60        ast::FieldExpr::cast(expr.syntax().clone())?.field()?
61    };
62
63    let location =
64        goto_definition::goto_definition(db, file_id, name_ref.syntax().text_range().start())
65            .into_iter()
66            .next()?;
67
68    let file = match location.file {
69        goto_definition::FileId::Current => file,
70        goto_definition::FileId::Builtins => &parse_builtins(db).tree(),
71    };
72
73    let function_name_node = file.syntax().covering_element(location.range);
74
75    if let Some(create_function) = function_name_node
76        .ancestors()
77        .find_map(ast::CreateFunction::cast)
78        && let Some(param_list) = create_function.param_list()
79    {
80        for (param, arg) in param_list.params().zip(arg_list.args()) {
81            if let Some(param_name) = param.name() {
82                let arg_start = arg.syntax().text_range().start();
83                let target = Some(param_name.syntax().text_range());
84                hints.push(InlayHint {
85                    position: arg_start,
86                    label: format!("{}: ", param_name.syntax().text()),
87                    kind: InlayHintKind::Parameter,
88                    target,
89                    file: Some(location.file),
90                });
91            }
92        }
93    };
94
95    Some(())
96}
97
98fn inlay_hint_insert(
99    db: &dyn Db,
100    hints: &mut Vec<InlayHint>,
101    file_id: File,
102    file: &ast::SourceFile,
103    insert: ast::Insert,
104) -> Option<()> {
105    let name_start = insert
106        .path()?
107        .segment()?
108        .name_ref()?
109        .syntax()
110        .text_range()
111        .start();
112    // We need to support the table definition not being found since we can
113    // still provide inlay hints when a column list is provided
114    let location = goto_definition::goto_definition(db, file_id, name_start)
115        .into_iter()
116        .next();
117
118    let file = match location.as_ref().map(|x| x.file) {
119        Some(goto_definition::FileId::Current) | None => file,
120        Some(goto_definition::FileId::Builtins) => &parse_builtins(db).tree(),
121    };
122
123    let create_table = {
124        let range = location.as_ref().map(|x| x.range);
125
126        range.and_then(|range| {
127            file.syntax()
128                .covering_element(range)
129                .ancestors()
130                .find_map(ast::CreateTableLike::cast)
131        })
132    };
133
134    // TODO: we should salsa this
135    let binder = binder::bind(file);
136
137    let columns = if let Some(column_list) = insert.column_list() {
138        // `insert into t(a, b, c) values (1, 2, 3)`
139        column_list
140            .columns()
141            .filter_map(|col| {
142                let col_name = resolve::extract_column_name(&col)?;
143                let target = create_table
144                    .as_ref()
145                    .and_then(|x| {
146                        resolve::find_column_in_create_table(&binder, file.syntax(), x, &col_name)
147                    })
148                    .map(|x| x.text_range());
149                Some((col_name, target, location.as_ref().map(|x| x.file)))
150            })
151            .collect()
152    } else {
153        // `insert into t values (1, 2, 3)`
154        resolve::collect_columns_from_create_table(&binder, file.syntax(), &create_table?)
155            .into_iter()
156            .map(|(col_name, ptr)| {
157                let target = ptr.map(|p| p.to_node(file.syntax()).text_range());
158                (col_name, target, location.as_ref().map(|x| x.file))
159            })
160            .collect()
161    };
162
163    let Some(values) = insert.values() else {
164        // `insert into t select 1, 2;`
165        return inlay_hint_insert_select(hints, columns, insert.stmt()?);
166    };
167    // `insert into t values (1, 2);`
168    for row in values.row_list()?.rows() {
169        for ((column_name, target, file_id), expr) in columns.iter().zip(row.exprs()) {
170            let expr_start = expr.syntax().text_range().start();
171            hints.push(InlayHint {
172                position: expr_start,
173                label: format!("{}: ", column_name),
174                kind: InlayHintKind::Parameter,
175                target: *target,
176                file: *file_id,
177            });
178        }
179    }
180
181    Some(())
182}
183
184fn inlay_hint_insert_select(
185    hints: &mut Vec<InlayHint>,
186    columns: Vec<(Name, Option<TextRange>, Option<FileId>)>,
187    stmt: ast::Stmt,
188) -> Option<()> {
189    let target_list = match stmt {
190        ast::Stmt::Select(select) => select.select_clause()?.target_list(),
191        ast::Stmt::SelectInto(select_into) => select_into.select_clause()?.target_list(),
192        ast::Stmt::ParenSelect(paren_select) => {
193            target_list_from_select_variant(paren_select.select()?)
194        }
195        _ => None,
196    }?;
197
198    for ((column_name, target, file_id), target_expr) in columns.iter().zip(target_list.targets()) {
199        let expr = target_expr.expr()?;
200        let expr_start = expr.syntax().text_range().start();
201        hints.push(InlayHint {
202            position: expr_start,
203            label: format!("{}: ", column_name),
204            kind: InlayHintKind::Parameter,
205            target: *target,
206            file: *file_id,
207        });
208    }
209
210    Some(())
211}
212
213fn target_list_from_select_variant(select: ast::SelectVariant) -> Option<ast::TargetList> {
214    let mut current = select;
215    for _ in 0..100 {
216        match current {
217            ast::SelectVariant::Select(select) => {
218                return select.select_clause()?.target_list();
219            }
220            ast::SelectVariant::SelectInto(select_into) => {
221                return select_into.select_clause()?.target_list();
222            }
223            ast::SelectVariant::ParenSelect(paren_select) => {
224                current = paren_select.select()?;
225            }
226            _ => return None,
227        }
228    }
229    None
230}
231
232#[cfg(test)]
233mod test {
234    use crate::db::{Database, File};
235    use crate::inlay_hints::inlay_hints;
236    use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet, renderer::DecorStyle};
237    use insta::assert_snapshot;
238
239    #[track_caller]
240    fn check_inlay_hints(sql: &str) -> String {
241        let db = Database::default();
242        let file = File::new(&db, sql.to_string().into());
243
244        assert_eq!(crate::db::parse(&db, file).errors(), vec![]);
245
246        let hints = inlay_hints(&db, file);
247
248        if hints.is_empty() {
249            return String::new();
250        }
251
252        let mut modified_sql = sql.to_string();
253        let mut insertions: Vec<(usize, String)> = hints
254            .iter()
255            .map(|hint| {
256                let offset: usize = hint.position.into();
257                (offset, hint.label.clone())
258            })
259            .collect();
260
261        insertions.sort_by(|a, b| b.0.cmp(&a.0));
262
263        for (offset, label) in &insertions {
264            modified_sql.insert_str(*offset, label);
265        }
266
267        let mut annotations = vec![];
268        let mut cumulative_offset = 0;
269
270        insertions.reverse();
271        for (original_offset, label) in insertions {
272            let new_offset = original_offset + cumulative_offset;
273            annotations.push((new_offset, label.len()));
274            cumulative_offset += label.len();
275        }
276
277        let mut snippet = Snippet::source(&modified_sql).fold(true);
278
279        for (offset, len) in annotations {
280            snippet = snippet.annotation(AnnotationKind::Context.span(offset..offset + len));
281        }
282
283        let group = Level::INFO.primary_title("inlay hints").element(snippet);
284
285        let renderer = Renderer::plain().decor_style(DecorStyle::Unicode);
286        renderer
287            .render(&[group])
288            .to_string()
289            .replace("info: inlay hints", "inlay hints:")
290    }
291
292    #[test]
293    fn single_param() {
294        assert_snapshot!(check_inlay_hints("
295create function foo(a int) returns int as 'select $$1' language sql;
296select foo(1);
297"), @r"
298        inlay hints:
299          ╭▸ 
300        3 │ select foo(a: 1);
301          ╰╴           ───
302        ");
303    }
304
305    #[test]
306    fn multiple_params() {
307        assert_snapshot!(check_inlay_hints("
308create function add(a int, b int) returns int as 'select $$1 + $$2' language sql;
309select add(1, 2);
310"), @r"
311        inlay hints:
312          ╭▸ 
313        3 │ select add(a: 1, b: 2);
314          ╰╴           ───   ───
315        ");
316    }
317
318    #[test]
319    fn no_params() {
320        assert_snapshot!(check_inlay_hints("
321create function foo() returns int as 'select 1' language sql;
322select foo();
323"), @"");
324    }
325
326    #[test]
327    fn with_schema() {
328        assert_snapshot!(check_inlay_hints("
329create function public.foo(x int) returns int as 'select $$1' language sql;
330select public.foo(42);
331"), @r"
332        inlay hints:
333          ╭▸ 
334        3 │ select public.foo(x: 42);
335          ╰╴                  ───
336        ");
337    }
338
339    #[test]
340    fn with_search_path() {
341        assert_snapshot!(check_inlay_hints(r#"
342set search_path to myschema;
343create function foo(val int) returns int as 'select $$1' language sql;
344select foo(100);
345"#), @r"
346        inlay hints:
347          ╭▸ 
348        4 │ select foo(val: 100);
349          ╰╴           ─────
350        ");
351    }
352
353    #[test]
354    fn multiple_calls() {
355        assert_snapshot!(check_inlay_hints("
356create function inc(n int) returns int as 'select $$1 + 1' language sql;
357select inc(1), inc(2);
358"), @r"
359        inlay hints:
360          ╭▸ 
361        3 │ select inc(n: 1), inc(n: 2);
362          ╰╴           ───        ───
363        ");
364    }
365
366    #[test]
367    fn more_args_than_params() {
368        assert_snapshot!(check_inlay_hints("
369create function foo(a int) returns int as 'select $$1' language sql;
370select foo(1, 2);
371"), @r"
372        inlay hints:
373          ╭▸ 
374        3 │ select foo(a: 1, 2);
375          ╰╴           ───
376        ");
377    }
378
379    #[test]
380    fn builtin_function() {
381        assert_snapshot!(check_inlay_hints("
382select json_strip_nulls('[1, null]', true);
383"), @r"
384        inlay hints:
385          ╭▸ 
386        2 │ select json_strip_nulls(target: '[1, null]', strip_in_arrays: true);
387          ╰╴                        ────────             ─────────────────
388        ");
389    }
390
391    #[test]
392    fn insert_with_column_list() {
393        assert_snapshot!(check_inlay_hints("
394create table t (column_a int, column_b int, column_c text);
395insert into t (column_a, column_c) values (1, 'foo');
396"), @r"
397        inlay hints:
398          ╭▸ 
399        3 │ insert into t (column_a, column_c) values (column_a: 1, column_c: 'foo');
400          ╰╴                                           ──────────   ──────────
401        ");
402    }
403
404    #[test]
405    fn insert_without_column_list() {
406        assert_snapshot!(check_inlay_hints("
407create table t (column_a int, column_b int, column_c text);
408insert into t values (1, 2, 'foo');
409"), @r"
410        inlay hints:
411          ╭▸ 
412        3 │ insert into t values (column_a: 1, column_b: 2, column_c: 'foo');
413          ╰╴                      ──────────   ──────────   ──────────
414        ");
415    }
416
417    #[test]
418    fn insert_multiple_rows() {
419        assert_snapshot!(check_inlay_hints("
420create table t (x int, y int);
421insert into t values (1, 2), (3, 4);
422"), @r"
423        inlay hints:
424          ╭▸ 
425        3 │ insert into t values (x: 1, y: 2), (x: 3, y: 4);
426          ╰╴                      ───   ───     ───   ───
427        ");
428    }
429
430    #[test]
431    fn insert_no_create_table() {
432        assert_snapshot!(check_inlay_hints("
433insert into t (a, b) values (1, 2);
434"), @r"
435        inlay hints:
436          ╭▸ 
437        2 │ insert into t (a, b) values (a: 1, b: 2);
438          ╰╴                             ───   ───
439        ");
440    }
441
442    #[test]
443    fn insert_more_values_than_columns() {
444        assert_snapshot!(check_inlay_hints("
445create table t (a int, b int);
446insert into t values (1, 2, 3);
447"), @r"
448        inlay hints:
449          ╭▸ 
450        3 │ insert into t values (a: 1, b: 2, 3);
451          ╰╴                      ───   ───
452        ");
453    }
454
455    #[test]
456    fn insert_table_inherits_select() {
457        assert_snapshot!(check_inlay_hints("
458create table t (a int, b int);
459create table u (c int) inherits (t);
460insert into u select 1, 2, 3;
461"), @r"
462        inlay hints:
463          ╭▸ 
464        4 │ insert into u select a: 1, b: 2, c: 3;
465          ╰╴                     ───   ───   ───
466        ");
467    }
468
469    #[test]
470    fn insert_table_like_select() {
471        assert_snapshot!(check_inlay_hints("
472create table x (a int, b int);
473create table y (c int, like x);
474insert into y select 1, 2, 3;
475"), @r"
476        inlay hints:
477          ╭▸ 
478        4 │ insert into y select c: 1, a: 2, b: 3;
479          ╰╴                     ───   ───   ───
480        ");
481    }
482
483    #[test]
484    fn insert_select() {
485        assert_snapshot!(check_inlay_hints("
486create table t (a int, b int);
487insert into t select 1, 2;
488"), @r"
489        inlay hints:
490          ╭▸ 
491        3 │ insert into t select a: 1, b: 2;
492          ╰╴                     ───   ───
493        ");
494    }
495}