From eb2683a85c7c0b5bc3e75700f885d16769745ae7 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Sat, 27 Dec 2025 15:38:42 -0500 Subject: [PATCH 1/2] ide: inlay hints for function calls --- PLAN.md | 9 ++ crates/squawk_ide/src/inlay_hints.rs | 210 +++++++++++++++++++++++++++ crates/squawk_ide/src/lib.rs | 1 + crates/squawk_server/src/lib.rs | 71 +++++++-- crates/squawk_server/src/lint.rs | 2 +- 5 files changed, 281 insertions(+), 12 deletions(-) create mode 100644 crates/squawk_ide/src/inlay_hints.rs diff --git a/PLAN.md b/PLAN.md index 1b63411e..b56d5ecd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -568,6 +568,15 @@ select a, b from t group by a, b; ``` +### Rule: unresolved column + +```sql +create function foo(a int, b int) returns int +as 'select $0' +-- ^^ unresolved column, did you mean `a` or `b`? +language sql; +``` + ### Rule: unused column ```sql diff --git a/crates/squawk_ide/src/inlay_hints.rs b/crates/squawk_ide/src/inlay_hints.rs new file mode 100644 index 00000000..56a35a5f --- /dev/null +++ b/crates/squawk_ide/src/inlay_hints.rs @@ -0,0 +1,210 @@ +use crate::binder; +use crate::binder::Binder; +use crate::resolve; +use rowan::TextSize; +use squawk_syntax::ast::{self, AstNode}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InlayHint { + pub position: TextSize, + pub label: String, +} + +pub fn inlay_hints(file: &ast::SourceFile) -> Vec { + let mut hints = vec![]; + let binder = binder::bind(file); + + for node in file.syntax().descendants() { + if let Some(call_expr) = ast::CallExpr::cast(node) { + inlay_hint_call_expr(&mut hints, file, &binder, call_expr); + } + } + + hints +} + +fn inlay_hint_call_expr( + hints: &mut Vec, + file: &ast::SourceFile, + binder: &Binder, + call_expr: ast::CallExpr, +) -> Option<()> { + let arg_list = call_expr.arg_list()?; + let expr = call_expr.expr()?; + + let name_ref = if let Some(name_ref) = ast::NameRef::cast(expr.syntax().clone()) { + name_ref + } else { + ast::FieldExpr::cast(expr.syntax().clone())?.field()? + }; + + let function_ptr = resolve::resolve_name_ref(&binder, &name_ref)?; + + let root = file.syntax(); + let function_name_node = function_ptr.to_node(root); + + if let Some(create_function) = function_name_node + .ancestors() + .find_map(ast::CreateFunction::cast) + && let Some(param_list) = create_function.param_list() + { + for (param, arg) in param_list.params().zip(arg_list.args()) { + if let Some(param_name) = param.name() { + let arg_start = arg.syntax().text_range().start(); + hints.push(InlayHint { + position: arg_start, + label: format!("{}: ", param_name.syntax().text()), + }); + } + } + }; + + Some(()) +} + +#[cfg(test)] +mod test { + use crate::inlay_hints::inlay_hints; + use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet, renderer::DecorStyle}; + use insta::assert_snapshot; + use squawk_syntax::ast; + + #[track_caller] + fn check_inlay_hints(sql: &str) -> String { + let parse = ast::SourceFile::parse(sql); + assert_eq!(parse.errors(), vec![]); + let file: ast::SourceFile = parse.tree(); + + let hints = inlay_hints(&file); + + if hints.is_empty() { + return String::new(); + } + + let mut modified_sql = sql.to_string(); + let mut insertions: Vec<(usize, String)> = hints + .iter() + .map(|hint| { + let offset: usize = hint.position.into(); + (offset, hint.label.clone()) + }) + .collect(); + + insertions.sort_by(|a, b| b.0.cmp(&a.0)); + + for (offset, label) in &insertions { + modified_sql.insert_str(*offset, label); + } + + let mut annotations = vec![]; + let mut cumulative_offset = 0; + + insertions.reverse(); + for (original_offset, label) in insertions { + let new_offset = original_offset + cumulative_offset; + annotations.push((new_offset, label.len())); + cumulative_offset += label.len(); + } + + let mut snippet = Snippet::source(&modified_sql).fold(true); + + for (offset, len) in annotations { + snippet = snippet.annotation(AnnotationKind::Context.span(offset..offset + len)); + } + + let group = Level::INFO.primary_title("inlay hints").element(snippet); + + let renderer = Renderer::plain().decor_style(DecorStyle::Unicode); + renderer + .render(&[group]) + .to_string() + .replace("info: inlay hints", "inlay hints:") + } + + #[test] + fn single_param() { + assert_snapshot!(check_inlay_hints(" +create function foo(a int) returns int as 'select $$1' language sql; +select foo(1); +"), @r" + inlay hints: + ╭▸ + 3 │ select foo(a: 1); + ╰╴ ─── + "); + } + + #[test] + fn multiple_params() { + assert_snapshot!(check_inlay_hints(" +create function add(a int, b int) returns int as 'select $$1 + $$2' language sql; +select add(1, 2); +"), @r" + inlay hints: + ╭▸ + 3 │ select add(a: 1, b: 2); + ╰╴ ─── ─── + "); + } + + #[test] + fn no_params() { + assert_snapshot!(check_inlay_hints(" +create function foo() returns int as 'select 1' language sql; +select foo(); +"), @""); + } + + #[test] + fn with_schema() { + assert_snapshot!(check_inlay_hints(" +create function public.foo(x int) returns int as 'select $$1' language sql; +select public.foo(42); +"), @r" + inlay hints: + ╭▸ + 3 │ select public.foo(x: 42); + ╰╴ ─── + "); + } + + #[test] + fn with_search_path() { + assert_snapshot!(check_inlay_hints(r#" +set search_path to myschema; +create function foo(val int) returns int as 'select $$1' language sql; +select foo(100); +"#), @r" + inlay hints: + ╭▸ + 4 │ select foo(val: 100); + ╰╴ ───── + "); + } + + #[test] + fn multiple_calls() { + assert_snapshot!(check_inlay_hints(" +create function inc(n int) returns int as 'select $$1 + 1' language sql; +select inc(1), inc(2); +"), @r" + inlay hints: + ╭▸ + 3 │ select inc(n: 1), inc(n: 2); + ╰╴ ─── ─── + "); + } + + #[test] + fn more_args_than_params() { + assert_snapshot!(check_inlay_hints(" +create function foo(a int) returns int as 'select $$1' language sql; +select foo(1, 2); +"), @r" + inlay hints: + ╭▸ + 3 │ select foo(a: 1, 2); + ╰╴ ─── + "); + } +} diff --git a/crates/squawk_ide/src/lib.rs b/crates/squawk_ide/src/lib.rs index 237562f5..4aeca3ad 100644 --- a/crates/squawk_ide/src/lib.rs +++ b/crates/squawk_ide/src/lib.rs @@ -6,6 +6,7 @@ pub mod find_references; mod generated; pub mod goto_definition; pub mod hover; +pub mod inlay_hints; mod offsets; mod resolve; mod scope; diff --git a/crates/squawk_server/src/lib.rs b/crates/squawk_server/src/lib.rs index 8fb8e965..0aa70b12 100644 --- a/crates/squawk_server/src/lib.rs +++ b/crates/squawk_server/src/lib.rs @@ -7,16 +7,17 @@ use lsp_types::{ CodeActionProviderCapability, CodeActionResponse, Command, Diagnostic, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams, - HoverProviderCapability, InitializeParams, LanguageString, Location, MarkedString, OneOf, - PublishDiagnosticsParams, ReferenceParams, SelectionRangeParams, - SelectionRangeProviderCapability, ServerCapabilities, TextDocumentSyncCapability, - TextDocumentSyncKind, Url, WorkDoneProgressOptions, WorkspaceEdit, + HoverProviderCapability, InitializeParams, InlayHint, InlayHintLabel, InlayHintParams, + LanguageString, Location, MarkedString, OneOf, PublishDiagnosticsParams, ReferenceParams, + SelectionRangeParams, SelectionRangeProviderCapability, ServerCapabilities, + TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgressOptions, WorkspaceEdit, notification::{ DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, Notification as _, PublishDiagnostics, }, request::{ - CodeActionRequest, GotoDefinition, HoverRequest, References, Request, SelectionRangeRequest, + CodeActionRequest, GotoDefinition, HoverRequest, InlayHintRequest, References, Request, + SelectionRangeRequest, }, }; use rowan::TextRange; @@ -24,6 +25,7 @@ use squawk_ide::code_actions::code_actions; use squawk_ide::find_references::find_references; use squawk_ide::goto_definition::goto_definition; use squawk_ide::hover::hover; +use squawk_ide::inlay_hints::inlay_hints; use squawk_syntax::{Parse, SourceFile}; use std::collections::HashMap; @@ -63,6 +65,7 @@ pub fn run() -> Result<()> { references_provider: Some(OneOf::Left(true)), definition_provider: Some(OneOf::Left(true)), hover_provider: Some(HoverProviderCapability::Simple(true)), + inlay_hint_provider: Some(OneOf::Left(true)), ..Default::default() }) .unwrap(); @@ -112,6 +115,9 @@ fn main_loop(connection: Connection, params: serde_json::Value) -> Result<()> { SelectionRangeRequest::METHOD => { handle_selection_range(&connection, req, &documents)?; } + InlayHintRequest::METHOD => { + handle_inlay_hints(&connection, req, &documents)?; + } "squawk/syntaxTree" => { handle_syntax_tree(&connection, req, &documents)?; } @@ -161,7 +167,7 @@ fn handle_goto_definition( let position = params.text_document_position_params.position; let content = documents.get(&uri).map_or("", |doc| &doc.content); - let parse: Parse = SourceFile::parse(content); + let parse = SourceFile::parse(content); let file = parse.tree(); let line_index = LineIndex::new(content); let offset = lsp_utils::offset(&line_index, position).unwrap(); @@ -202,7 +208,7 @@ fn handle_hover( let position = params.text_document_position_params.position; let content = documents.get(&uri).map_or("", |doc| &doc.content); - let parse: Parse = SourceFile::parse(content); + let parse = SourceFile::parse(content); let file = parse.tree(); let line_index = LineIndex::new(content); let offset = lsp_utils::offset(&line_index, position).unwrap(); @@ -227,6 +233,49 @@ fn handle_hover( Ok(()) } +fn handle_inlay_hints( + connection: &Connection, + req: lsp_server::Request, + documents: &HashMap, +) -> Result<()> { + let params: InlayHintParams = serde_json::from_value(req.params)?; + let uri = params.text_document.uri; + + let content = documents.get(&uri).map_or("", |doc| &doc.content); + let parse = SourceFile::parse(content); + let file = parse.tree(); + let line_index = LineIndex::new(content); + + let hints = inlay_hints(&file); + + let lsp_hints: Vec = hints + .into_iter() + .map(|hint| { + let line_col = line_index.line_col(hint.position); + let position = lsp_types::Position::new(line_col.line, line_col.col); + InlayHint { + position, + label: InlayHintLabel::String(hint.label), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + } + }) + .collect(); + + let resp = Response { + id: req.id, + result: Some(serde_json::to_value(&lsp_hints).unwrap()), + error: None, + }; + + connection.sender.send(Message::Response(resp))?; + Ok(()) +} + fn handle_selection_range( connection: &Connection, req: lsp_server::Request, @@ -236,7 +285,7 @@ fn handle_selection_range( let uri = params.text_document.uri; let content = documents.get(&uri).map_or("", |doc| &doc.content); - let parse: Parse = SourceFile::parse(content); + let parse = SourceFile::parse(content); let root = parse.syntax_node(); let line_index = LineIndex::new(content); @@ -294,7 +343,7 @@ fn handle_references( let position = params.text_document_position.position; let content = documents.get(&uri).map_or("", |doc| &doc.content); - let parse: Parse = SourceFile::parse(content); + let parse = SourceFile::parse(content); let file = parse.tree(); let line_index = LineIndex::new(content); let offset = lsp_utils::offset(&line_index, position).unwrap(); @@ -332,7 +381,7 @@ fn handle_code_action( let mut actions: CodeActionResponse = Vec::new(); let content = documents.get(&uri).map_or("", |doc| &doc.content); - let parse: Parse = SourceFile::parse(content); + let parse = SourceFile::parse(content); let file = parse.tree(); let line_index = LineIndex::new(content); let offset = lsp_utils::offset(&line_index, params.range.start).unwrap(); @@ -569,7 +618,7 @@ fn handle_syntax_tree( let content = documents.get(&uri).map_or("", |doc| &doc.content); - let parse: Parse = SourceFile::parse(content); + let parse = SourceFile::parse(content); let syntax_tree = format!("{:#?}", parse.syntax_node()); let resp = Response { diff --git a/crates/squawk_server/src/lint.rs b/crates/squawk_server/src/lint.rs index 887da3f5..55907b06 100644 --- a/crates/squawk_server/src/lint.rs +++ b/crates/squawk_server/src/lint.rs @@ -20,7 +20,7 @@ fn to_text_edit(edit: Edit, line_index: &LineIndex) -> Option { } pub(crate) fn lint(content: &str) -> Vec { - let parse: Parse = SourceFile::parse(content); + let parse = SourceFile::parse(content); let parse_errors = parse.errors(); let mut linter = Linter::with_all_rules(); let violations = linter.lint(&parse, content); From 21dd3e7d1c1e5b5abe4dcb2abaa0152caa95682f Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Sat, 27 Dec 2025 15:53:30 -0500 Subject: [PATCH 2/2] linter --- crates/squawk_ide/src/inlay_hints.rs | 11 ++++++++++- crates/squawk_server/src/lib.rs | 14 +++++++++----- crates/squawk_server/src/lint.rs | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/crates/squawk_ide/src/inlay_hints.rs b/crates/squawk_ide/src/inlay_hints.rs index 56a35a5f..60d34fa3 100644 --- a/crates/squawk_ide/src/inlay_hints.rs +++ b/crates/squawk_ide/src/inlay_hints.rs @@ -4,10 +4,18 @@ use crate::resolve; use rowan::TextSize; use squawk_syntax::ast::{self, AstNode}; +/// `VSCode` has some theming options based on these types. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InlayHintKind { + Type, + Parameter, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct InlayHint { pub position: TextSize, pub label: String, + pub kind: InlayHintKind, } pub fn inlay_hints(file: &ast::SourceFile) -> Vec { @@ -38,7 +46,7 @@ fn inlay_hint_call_expr( ast::FieldExpr::cast(expr.syntax().clone())?.field()? }; - let function_ptr = resolve::resolve_name_ref(&binder, &name_ref)?; + let function_ptr = resolve::resolve_name_ref(binder, &name_ref)?; let root = file.syntax(); let function_name_node = function_ptr.to_node(root); @@ -54,6 +62,7 @@ fn inlay_hint_call_expr( hints.push(InlayHint { position: arg_start, label: format!("{}: ", param_name.syntax().text()), + kind: InlayHintKind::Parameter, }); } } diff --git a/crates/squawk_server/src/lib.rs b/crates/squawk_server/src/lib.rs index 0aa70b12..2fde3531 100644 --- a/crates/squawk_server/src/lib.rs +++ b/crates/squawk_server/src/lib.rs @@ -7,9 +7,9 @@ use lsp_types::{ CodeActionProviderCapability, CodeActionResponse, Command, Diagnostic, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams, - HoverProviderCapability, InitializeParams, InlayHint, InlayHintLabel, InlayHintParams, - LanguageString, Location, MarkedString, OneOf, PublishDiagnosticsParams, ReferenceParams, - SelectionRangeParams, SelectionRangeProviderCapability, ServerCapabilities, + HoverProviderCapability, InitializeParams, InlayHint, InlayHintKind, InlayHintLabel, + InlayHintParams, LanguageString, Location, MarkedString, OneOf, PublishDiagnosticsParams, + ReferenceParams, SelectionRangeParams, SelectionRangeProviderCapability, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgressOptions, WorkspaceEdit, notification::{ DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, Notification as _, @@ -26,7 +26,7 @@ use squawk_ide::find_references::find_references; use squawk_ide::goto_definition::goto_definition; use squawk_ide::hover::hover; use squawk_ide::inlay_hints::inlay_hints; -use squawk_syntax::{Parse, SourceFile}; +use squawk_syntax::SourceFile; use std::collections::HashMap; use diagnostic::DIAGNOSTIC_NAME; @@ -253,10 +253,14 @@ fn handle_inlay_hints( .map(|hint| { let line_col = line_index.line_col(hint.position); let position = lsp_types::Position::new(line_col.line, line_col.col); + let kind = match hint.kind { + squawk_ide::inlay_hints::InlayHintKind::Type => InlayHintKind::TYPE, + squawk_ide::inlay_hints::InlayHintKind::Parameter => InlayHintKind::PARAMETER, + }; InlayHint { position, label: InlayHintLabel::String(hint.label), - kind: None, + kind: Some(kind), text_edits: None, tooltip: None, padding_left: None, diff --git a/crates/squawk_server/src/lint.rs b/crates/squawk_server/src/lint.rs index 55907b06..eb229980 100644 --- a/crates/squawk_server/src/lint.rs +++ b/crates/squawk_server/src/lint.rs @@ -1,7 +1,7 @@ use line_index::LineIndex; use lsp_types::{CodeDescription, Diagnostic, DiagnosticSeverity, Position, Range, TextEdit, Url}; use squawk_linter::{Edit, Linter}; -use squawk_syntax::{Parse, SourceFile}; +use squawk_syntax::SourceFile; use crate::{ DIAGNOSTIC_NAME,