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