From f6605b788886d48813bbb7e3c97dfa2e5d1927f4 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Fri, 26 Dec 2025 16:15:57 -0500 Subject: [PATCH] ide: add hover for column in create index --- crates/squawk_ide/src/hover.rs | 260 +++++++++++++++++++++++++++++++ crates/squawk_ide/src/lib.rs | 1 + crates/squawk_ide/src/resolve.rs | 52 ++++++- crates/squawk_ide/src/symbols.rs | 7 + crates/squawk_server/src/lib.rs | 47 +++++- 5 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 crates/squawk_ide/src/hover.rs diff --git a/crates/squawk_ide/src/hover.rs b/crates/squawk_ide/src/hover.rs new file mode 100644 index 00000000..5d7c2815 --- /dev/null +++ b/crates/squawk_ide/src/hover.rs @@ -0,0 +1,260 @@ +use crate::binder; +use crate::offsets::token_from_offset; +use crate::resolve; +use rowan::TextSize; +use squawk_syntax::ast::{self, AstNode}; + +pub fn hover(file: &ast::SourceFile, offset: TextSize) -> Option { + let token = token_from_offset(file, offset)?; + let parent = token.parent()?; + + let name_ref = ast::NameRef::cast(parent)?; + + if !is_column_ref(&name_ref) { + return None; + } + + let column_name = name_ref.syntax().text().to_string(); + + let create_index = name_ref + .syntax() + .ancestors() + .find_map(ast::CreateIndex::cast)?; + + let relation_name = create_index.relation_name()?; + let path = relation_name.path()?; + + let binder = binder::bind(file); + + let (schema, table_name) = resolve::resolve_table_info(&binder, &path)?; + + let column_ptr = resolve::resolve_name_ref(&binder, &name_ref)?; + + let root = file.syntax(); + let column_name_node = column_ptr.to_node(root); + + let column = column_name_node.ancestors().find_map(ast::Column::cast)?; + + let ty = column.ty()?; + + Some(format!( + "{schema}.{table_name}.{column_name} {}", + ty.syntax().text() + )) +} + +fn is_column_ref(name_ref: &ast::NameRef) -> bool { + for ancestor in name_ref.syntax().ancestors() { + if ast::PartitionItem::can_cast(ancestor.kind()) { + return true; + } + if ast::CreateIndex::can_cast(ancestor.kind()) { + return true; + } + } + false +} + +#[cfg(test)] +mod test { + use crate::hover::hover; + use crate::test_utils::fixture; + use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet, renderer::DecorStyle}; + use insta::assert_snapshot; + use squawk_syntax::ast; + + #[track_caller] + fn check_hover(sql: &str) -> String { + check_hover_(sql).expect("should find hover information") + } + + #[track_caller] + fn check_hover_(sql: &str) -> Option { + let (mut offset, sql) = fixture(sql); + offset = offset.checked_sub(1.into()).unwrap_or_default(); + let parse = ast::SourceFile::parse(&sql); + assert_eq!(parse.errors(), vec![]); + let file: ast::SourceFile = parse.tree(); + + if let Some(type_info) = hover(&file, offset) { + let offset_usize: usize = offset.into(); + let title = format!("hover: {}", type_info); + let group = Level::INFO.primary_title(&title).element( + Snippet::source(&sql).fold(true).annotation( + AnnotationKind::Context + .span(offset_usize..offset_usize + 1) + .label("hover"), + ), + ); + let renderer = Renderer::plain().decor_style(DecorStyle::Unicode); + return Some( + renderer + .render(&[group]) + .to_string() + // neater + .replace("info: hover:", "hover:"), + ); + } + None + } + + fn hover_not_found(sql: &str) { + assert!( + check_hover_(sql).is_none(), + "Should not find hover information" + ); + } + + #[test] + fn hover_column_in_create_index() { + assert_snapshot!(check_hover(" +create table users(id int, email text); +create index idx_email on users(email$0); +"), @r" + hover: public.users.email text + ╭▸ + 3 │ create index idx_email on users(email); + ╰╴ ─ hover + "); + } + + #[test] + fn hover_column_int_type() { + assert_snapshot!(check_hover(" +create table users(id int, email text); +create index idx_id on users(id$0); +"), @r" + hover: public.users.id int + ╭▸ + 3 │ create index idx_id on users(id); + ╰╴ ─ hover + "); + } + + #[test] + fn hover_column_with_schema() { + assert_snapshot!(check_hover(" +create table public.users(id int, email text); +create index idx_email on public.users(email$0); +"), @r" + hover: public.users.email text + ╭▸ + 3 │ create index idx_email on public.users(email); + ╰╴ ─ hover + "); + } + + #[test] + fn hover_column_temp_table() { + assert_snapshot!(check_hover(" +create temp table users(id int, email text); +create index idx_email on users(email$0); +"), @r" + hover: pg_temp.users.email text + ╭▸ + 3 │ create index idx_email on users(email); + ╰╴ ─ hover + "); + } + + #[test] + fn hover_column_multiple_columns() { + assert_snapshot!(check_hover(" +create table users(id int, email text, name varchar(100)); +create index idx_users on users(id, email$0, name); +"), @r" + hover: public.users.email text + ╭▸ + 3 │ create index idx_users on users(id, email, name); + ╰╴ ─ hover + "); + } + + #[test] + fn hover_column_varchar() { + assert_snapshot!(check_hover(" +create table users(id int, name varchar(100)); +create index idx_name on users(name$0); +"), @r" + hover: public.users.name varchar(100) + ╭▸ + 3 │ create index idx_name on users(name); + ╰╴ ─ hover + "); + } + + #[test] + fn hover_column_bigint() { + assert_snapshot!(check_hover(" +create table metrics(value bigint); +create index idx_value on metrics(value$0); +"), @r" + hover: public.metrics.value bigint + ╭▸ + 3 │ create index idx_value on metrics(value); + ╰╴ ─ hover + "); + } + + #[test] + fn hover_column_timestamp() { + assert_snapshot!(check_hover(" +create table events(created_at timestamp with time zone); +create index idx_created on events(created_at$0); +"), @r" + hover: public.events.created_at timestamp with time zone + ╭▸ + 3 │ create index idx_created on events(created_at); + ╰╴ ─ hover + "); + } + + #[test] + fn hover_column_with_search_path() { + assert_snapshot!(check_hover(r#" +set search_path to myschema; +create table myschema.users(id int, email text); +create index idx_email on users(email$0); +"#), @r" + hover: myschema.users.email text + ╭▸ + 4 │ create index idx_email on users(email); + ╰╴ ─ hover + "); + } + + #[test] + fn hover_column_explicit_schema_overrides_search_path() { + assert_snapshot!(check_hover(r#" +set search_path to myschema; +create table public.users(id int, email text); +create table myschema.users(value bigint); +create index idx_email on public.users(email$0); +"#), @r" + hover: public.users.email text + ╭▸ + 5 │ create index idx_email on public.users(email); + ╰╴ ─ hover + "); + } + + #[test] + fn hover_not_on_table_name() { + hover_not_found( + " +create table users(id int); +create index idx on users$0(id); +", + ); + } + + #[test] + fn hover_not_on_index_name() { + hover_not_found( + " +create table users(id int); +create index idx$0 on users(id); +", + ); + } +} diff --git a/crates/squawk_ide/src/lib.rs b/crates/squawk_ide/src/lib.rs index 0d05d1f2..237562f5 100644 --- a/crates/squawk_ide/src/lib.rs +++ b/crates/squawk_ide/src/lib.rs @@ -5,6 +5,7 @@ pub mod expand_selection; pub mod find_references; mod generated; pub mod goto_definition; +pub mod hover; mod offsets; mod resolve; mod scope; diff --git a/crates/squawk_ide/src/resolve.rs b/crates/squawk_ide/src/resolve.rs index f1962da3..bcdff05d 100644 --- a/crates/squawk_ide/src/resolve.rs +++ b/crates/squawk_ide/src/resolve.rs @@ -5,7 +5,8 @@ use squawk_syntax::{ }; use crate::binder::Binder; -use crate::symbols::{Name, Schema, SymbolKind}; +pub(crate) use crate::symbols::Schema; +use crate::symbols::{Name, SymbolKind}; use squawk_syntax::SyntaxNode; #[derive(Debug)] @@ -180,3 +181,52 @@ fn extract_schema_name(path: &ast::Path) -> Option { .and_then(|s| s.name_ref()) .map(|name_ref| Schema(Name::new(name_ref.syntax().text().to_string()))) } + +pub(crate) fn resolve_table_info(binder: &Binder, path: &ast::Path) -> Option<(Schema, String)> { + let table_name_str = extract_table_name_from_path(path)?; + let schema = extract_schema_from_path(path); + + let table_name_normalized = Name::new(table_name_str.clone()); + let symbols = binder.scopes[binder.root_scope()].get(&table_name_normalized)?; + + if let Some(schema_name) = schema { + let schema_normalized = Schema::new(schema_name); + let symbol_id = symbols.iter().copied().find(|id| { + let symbol = &binder.symbols[*id]; + symbol.kind == SymbolKind::Table && symbol.schema == schema_normalized + })?; + let symbol = &binder.symbols[symbol_id]; + return Some((symbol.schema.clone(), table_name_str)); + } else { + let position = path.syntax().text_range().start(); + let search_path = binder.search_path_at(position); + for search_schema in search_path { + if let Some(symbol_id) = symbols.iter().copied().find(|id| { + let symbol = &binder.symbols[*id]; + symbol.kind == SymbolKind::Table && &symbol.schema == search_schema + }) { + let symbol = &binder.symbols[symbol_id]; + return Some((symbol.schema.clone(), table_name_str)); + } + } + } + None +} + +fn extract_table_name_from_path(path: &ast::Path) -> Option { + let segment = path.segment()?; + if let Some(name_ref) = segment.name_ref() { + return Some(name_ref.syntax().text().to_string()); + } + if let Some(name) = segment.name() { + return Some(name.syntax().text().to_string()); + } + None +} + +fn extract_schema_from_path(path: &ast::Path) -> Option { + path.qualifier() + .and_then(|q| q.segment()) + .and_then(|s| s.name_ref()) + .map(|name_ref| name_ref.syntax().text().to_string()) +} diff --git a/crates/squawk_ide/src/symbols.rs b/crates/squawk_ide/src/symbols.rs index c0d295c4..2e515d42 100644 --- a/crates/squawk_ide/src/symbols.rs +++ b/crates/squawk_ide/src/symbols.rs @@ -1,6 +1,7 @@ use la_arena::Idx; use smol_str::SmolStr; use squawk_syntax::SyntaxNodePtr; +use std::fmt; #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub(crate) struct Name(pub(crate) SmolStr); @@ -14,6 +15,12 @@ impl Schema { } } +impl fmt::Display for Schema { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0.0) + } +} + impl Name { pub(crate) fn new(text: impl Into) -> Self { let text = text.into(); diff --git a/crates/squawk_server/src/lib.rs b/crates/squawk_server/src/lib.rs index be0d8aea..8fb8e965 100644 --- a/crates/squawk_server/src/lib.rs +++ b/crates/squawk_server/src/lib.rs @@ -6,7 +6,8 @@ use lsp_types::{ CodeAction, CodeActionKind, CodeActionOptions, CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, Command, Diagnostic, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, - GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, Location, OneOf, + GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverContents, HoverParams, + HoverProviderCapability, InitializeParams, LanguageString, Location, MarkedString, OneOf, PublishDiagnosticsParams, ReferenceParams, SelectionRangeParams, SelectionRangeProviderCapability, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgressOptions, WorkspaceEdit, @@ -14,12 +15,15 @@ use lsp_types::{ DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, Notification as _, PublishDiagnostics, }, - request::{CodeActionRequest, GotoDefinition, References, Request, SelectionRangeRequest}, + request::{ + CodeActionRequest, GotoDefinition, HoverRequest, References, Request, SelectionRangeRequest, + }, }; use rowan::TextRange; 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_syntax::{Parse, SourceFile}; use std::collections::HashMap; @@ -58,6 +62,7 @@ pub fn run() -> Result<()> { selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)), references_provider: Some(OneOf::Left(true)), definition_provider: Some(OneOf::Left(true)), + hover_provider: Some(HoverProviderCapability::Simple(true)), ..Default::default() }) .unwrap(); @@ -98,6 +103,9 @@ fn main_loop(connection: Connection, params: serde_json::Value) -> Result<()> { GotoDefinition::METHOD => { handle_goto_definition(&connection, req, &documents)?; } + HoverRequest::METHOD => { + handle_hover(&connection, req, &documents)?; + } CodeActionRequest::METHOD => { handle_code_action(&connection, req, &documents)?; } @@ -184,6 +192,41 @@ fn handle_goto_definition( Ok(()) } +fn handle_hover( + connection: &Connection, + req: lsp_server::Request, + documents: &HashMap, +) -> Result<()> { + let params: HoverParams = serde_json::from_value(req.params)?; + let uri = params.text_document_position_params.text_document.uri; + 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 file = parse.tree(); + let line_index = LineIndex::new(content); + let offset = lsp_utils::offset(&line_index, position).unwrap(); + + let type_info = hover(&file, offset); + + let result = type_info.map(|type_str| Hover { + contents: HoverContents::Scalar(MarkedString::LanguageString(LanguageString { + language: "sql".to_string(), + value: type_str, + })), + range: None, + }); + + let resp = Response { + id: req.id, + result: Some(serde_json::to_value(&result).unwrap()), + error: None, + }; + + connection.sender.send(Message::Response(resp))?; + Ok(()) +} + fn handle_selection_range( connection: &Connection, req: lsp_server::Request,