Skip to main content

squawk_ide/
inlay_hints.rs

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