From 55ce4fc99068a2be5be7e22e9f06fd5b363ae831 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Wed, 14 Jan 2026 23:52:22 -0500 Subject: [PATCH] ide: listen/notify/unlisten --- PLAN.md | 4 + crates/squawk_ide/src/binder.rs | 21 +++++ crates/squawk_ide/src/classify.rs | 12 +++ crates/squawk_ide/src/document_symbols.rs | 101 ++++++++++++++++++++++ crates/squawk_ide/src/goto_definition.rs | 28 ++++++ crates/squawk_ide/src/hover.rs | 62 +++++++++++++ crates/squawk_ide/src/resolve.rs | 8 ++ crates/squawk_ide/src/symbols.rs | 1 + crates/squawk_server/src/lib.rs | 1 + crates/squawk_wasm/src/lib.rs | 1 + 10 files changed, 239 insertions(+) diff --git a/PLAN.md b/PLAN.md index 7958bb1c..9e0ed374 100644 --- a/PLAN.md +++ b/PLAN.md @@ -187,6 +187,10 @@ sql for benchmarks maybe? https://github.com/sirrodgepodge/rrule_plpgsql +- pqrsafe + + https://github.com/tanelpoder/tpt-postgres/blob/main/demos/pqrsafe.sql + ### CLI from `deno` diff --git a/crates/squawk_ide/src/binder.rs b/crates/squawk_ide/src/binder.rs index e6f04129..c090e122 100644 --- a/crates/squawk_ide/src/binder.rs +++ b/crates/squawk_ide/src/binder.rs @@ -223,6 +223,7 @@ fn bind_stmt(b: &mut Binder, stmt: ast::Stmt) { ast::Stmt::CreateExtension(create_extension) => bind_create_extension(b, create_extension), ast::Stmt::Declare(declare) => bind_declare_cursor(b, declare), ast::Stmt::Prepare(prepare) => bind_prepare(b, prepare), + ast::Stmt::Listen(listen) => bind_listen(b, listen), ast::Stmt::Set(set) => bind_set(b, set), _ => {} } @@ -666,6 +667,26 @@ fn bind_prepare(b: &mut Binder, prepare: ast::Prepare) { b.scopes[root].insert(statement_name, statement_id); } +fn bind_listen(b: &mut Binder, listen: ast::Listen) { + let Some(name) = listen.name() else { + return; + }; + + let channel_name = Name::from_node(&name); + let name_ptr = SyntaxNodePtr::new(name.syntax()); + + let channel_id = b.symbols.alloc(Symbol { + kind: SymbolKind::Channel, + ptr: name_ptr, + schema: None, + params: None, + table: None, + }); + + let root = b.root_scope(); + b.scopes[root].insert(channel_name, channel_id); +} + fn item_name(path: &ast::Path) -> Option { let segment = path.segment()?; diff --git a/crates/squawk_ide/src/classify.rs b/crates/squawk_ide/src/classify.rs index 9f90e66e..92b92af8 100644 --- a/crates/squawk_ide/src/classify.rs +++ b/crates/squawk_ide/src/classify.rs @@ -96,6 +96,8 @@ pub(crate) enum NameRefClass { NamedArgParameter, Cursor, PreparedStatement, + NotifyChannel, + UnlistenChannel, TriggerFunctionCall, TriggerProcedureCall, } @@ -370,6 +372,12 @@ pub(crate) fn classify_name_ref(name_ref: &ast::NameRef) -> Option if ast::Execute::can_cast(ancestor.kind()) || ast::Deallocate::can_cast(ancestor.kind()) { return Some(NameRefClass::PreparedStatement); } + if ast::Notify::can_cast(ancestor.kind()) { + return Some(NameRefClass::NotifyChannel); + } + if ast::Unlisten::can_cast(ancestor.kind()) { + return Some(NameRefClass::UnlistenChannel); + } if ast::DropTable::can_cast(ancestor.kind()) { return Some(NameRefClass::DropTable); } @@ -752,6 +760,7 @@ pub(crate) enum NameClass { CreateView(ast::CreateView), DeclareCursor(ast::Declare), PrepareStatement(ast::Prepare), + Listen(ast::Listen), } pub(crate) fn classify_name(name: &ast::Name) -> Option { @@ -824,6 +833,9 @@ pub(crate) fn classify_name(name: &ast::Name) -> Option { if let Some(prepare) = ast::Prepare::cast(ancestor.clone()) { return Some(NameClass::PrepareStatement(prepare)); } + if let Some(listen) = ast::Listen::cast(ancestor.clone()) { + return Some(NameClass::Listen(listen)); + } } if let Some(with_table) = with_table_parent { diff --git a/crates/squawk_ide/src/document_symbols.rs b/crates/squawk_ide/src/document_symbols.rs index a6d76fbb..f8eb58d0 100644 --- a/crates/squawk_ide/src/document_symbols.rs +++ b/crates/squawk_ide/src/document_symbols.rs @@ -22,6 +22,7 @@ pub enum DocumentSymbolKind { Variant, Cursor, PreparedStatement, + Channel, } #[derive(Debug)] @@ -108,6 +109,21 @@ pub fn document_symbols(file: &ast::SourceFile) -> Vec { ast::Stmt::Delete(delete) => { symbols.extend(cte_table_symbols(delete)); } + ast::Stmt::Listen(listen) => { + if let Some(symbol) = create_listen_symbol(listen) { + symbols.push(symbol); + } + } + ast::Stmt::Notify(notify) => { + if let Some(symbol) = create_notify_symbol(notify) { + symbols.push(symbol); + } + } + ast::Stmt::Unlisten(unlisten) => { + if let Some(symbol) = create_unlisten_symbol(unlisten) { + symbols.push(symbol); + } + } _ => {} } @@ -467,6 +483,57 @@ fn create_prepare_symbol(prepare: ast::Prepare) -> Option { }) } +fn create_listen_symbol(listen: ast::Listen) -> Option { + let name_node = listen.name()?; + let name = name_node.syntax().text().to_string(); + + let full_range = listen.syntax().text_range(); + let focus_range = name_node.syntax().text_range(); + + Some(DocumentSymbol { + name, + detail: Some("listen".to_string()), + kind: DocumentSymbolKind::Channel, + full_range, + focus_range, + children: vec![], + }) +} + +fn create_notify_symbol(notify: ast::Notify) -> Option { + let name_node = notify.name_ref()?; + let name = name_node.syntax().text().to_string(); + + let full_range = notify.syntax().text_range(); + let focus_range = name_node.syntax().text_range(); + + Some(DocumentSymbol { + name, + detail: Some("notify".to_string()), + kind: DocumentSymbolKind::Channel, + full_range, + focus_range, + children: vec![], + }) +} + +fn create_unlisten_symbol(unlisten: ast::Unlisten) -> Option { + let name_node = unlisten.name_ref()?; + let name = name_node.syntax().text().to_string(); + + let full_range = unlisten.syntax().text_range(); + let focus_range = name_node.syntax().text_range(); + + Some(DocumentSymbol { + name, + detail: Some("unlisten".to_string()), + kind: DocumentSymbolKind::Channel, + full_range, + focus_range, + children: vec![], + }) +} + #[cfg(test)] mod tests { use super::*; @@ -518,6 +585,7 @@ mod tests { DocumentSymbolKind::Variant => "variant", DocumentSymbolKind::Cursor => "cursor", DocumentSymbolKind::PreparedStatement => "prepared statement", + DocumentSymbolKind::Channel => "channel", }; let title = if let Some(detail) = &symbol.detail { @@ -643,6 +711,39 @@ create schema authorization foo; "); } + #[test] + fn listen_notify_unlisten() { + assert_snapshot!(symbols(" +listen updates; +notify updates; +unlisten updates; +unlisten *; +"), @r" + info: channel: updates listen + ╭▸ + 2 │ listen updates; + │ ┬──────┯━━━━━━ + │ │ │ + │ │ focus range + │ full range + ╰╴ + info: channel: updates notify + ╭▸ + 3 │ notify updates; + │ ┬──────┯━━━━━━ + │ │ │ + │ │ focus range + ╰╴full range + info: channel: updates unlisten + ╭▸ + 4 │ unlisten updates; + │ ┬────────┯━━━━━━ + │ │ │ + │ │ focus range + ╰╴full range + "); + } + #[test] fn create_function() { assert_snapshot!( diff --git a/crates/squawk_ide/src/goto_definition.rs b/crates/squawk_ide/src/goto_definition.rs index 4469c091..f81ea53f 100644 --- a/crates/squawk_ide/src/goto_definition.rs +++ b/crates/squawk_ide/src/goto_definition.rs @@ -386,6 +386,34 @@ deallocate stmt$0; "); } + #[test] + fn goto_notify_channel() { + assert_snapshot!(goto(" +listen updates; +notify updates$0; +"), @r" + ╭▸ + 2 │ listen updates; + │ ─────── 2. destination + 3 │ notify updates; + ╰╴ ─ 1. source + "); + } + + #[test] + fn goto_unlisten_channel() { + assert_snapshot!(goto(" +listen updates; +unlisten updates$0; +"), @r" + ╭▸ + 2 │ listen updates; + │ ─────── 2. destination + 3 │ unlisten updates; + ╰╴ ─ 1. source + "); + } + #[test] fn goto_delete_where_current_of_cursor() { assert_snapshot!(goto(" diff --git a/crates/squawk_ide/src/hover.rs b/crates/squawk_ide/src/hover.rs index f6954498..fd46489b 100644 --- a/crates/squawk_ide/src/hover.rs +++ b/crates/squawk_ide/src/hover.rs @@ -178,6 +178,9 @@ pub fn hover(file: &ast::SourceFile, offset: TextSize) -> Option { NameRefClass::PreparedStatement => { return hover_prepared_statement(root, &name_ref, &binder); } + NameRefClass::NotifyChannel | NameRefClass::UnlistenChannel => { + return hover_channel(root, &name_ref, &binder); + } } } @@ -240,6 +243,9 @@ pub fn hover(file: &ast::SourceFile, offset: TextSize) -> Option { NameClass::PrepareStatement(prepare) => { return format_prepare(&prepare); } + NameClass::Listen(listen) => { + return format_listen(&listen); + } } } @@ -842,6 +848,19 @@ fn hover_prepared_statement( format_prepare(&prepare) } +fn hover_channel( + root: &SyntaxNode, + name_ref: &ast::NameRef, + binder: &binder::Binder, +) -> Option { + let channel_ptr = resolve::resolve_name_ref(binder, root, name_ref)? + .into_iter() + .next()?; + let channel_name_node = channel_ptr.to_node(root); + let listen = channel_name_node.ancestors().find_map(ast::Listen::cast)?; + format_listen(&listen) +} + fn hover_type( root: &SyntaxNode, name_ref: &ast::NameRef, @@ -878,6 +897,11 @@ fn format_prepare(prepare: &ast::Prepare) -> Option { )) } +fn format_listen(listen: &ast::Listen) -> Option { + let name = listen.name()?; + Some(format!("listen {}", name.syntax().text())) +} + fn format_create_table( create_table: &impl ast::HasCreateTable, binder: &binder::Binder, @@ -4123,4 +4147,42 @@ deallocate stmt$0; ╰╴ ─ hover "); } + + #[test] + fn hover_on_listen_definition() { + assert_snapshot!(check_hover(" +listen updates$0; +"), @r" + hover: listen updates + ╭▸ + 2 │ listen updates; + ╰╴ ─ hover + "); + } + + #[test] + fn hover_on_notify_channel() { + assert_snapshot!(check_hover(" +listen updates; +notify updates$0; +"), @r" + hover: listen updates + ╭▸ + 3 │ notify updates; + ╰╴ ─ hover + "); + } + + #[test] + fn hover_on_unlisten_channel() { + assert_snapshot!(check_hover(" +listen updates; +unlisten updates$0; +"), @r" + hover: listen updates + ╭▸ + 3 │ unlisten updates; + ╰╴ ─ hover + "); + } } diff --git a/crates/squawk_ide/src/resolve.rs b/crates/squawk_ide/src/resolve.rs index 9f5960de..5069efc6 100644 --- a/crates/squawk_ide/src/resolve.rs +++ b/crates/squawk_ide/src/resolve.rs @@ -69,6 +69,10 @@ pub(crate) fn resolve_name_ref( let statement_name = Name::from_node(name_ref); resolve_prepared_statement_name_ptr(binder, &statement_name).map(|ptr| smallvec![ptr]) } + NameRefClass::NotifyChannel | NameRefClass::UnlistenChannel => { + let channel_name = Name::from_node(name_ref); + resolve_channel_name_ptr(binder, &channel_name).map(|ptr| smallvec![ptr]) + } NameRefClass::SelectFromTable | NameRefClass::UpdateFromTable | NameRefClass::MergeUsingTable @@ -549,6 +553,10 @@ fn resolve_prepared_statement_name_ptr( binder.lookup(statement_name, SymbolKind::PreparedStatement) } +fn resolve_channel_name_ptr(binder: &Binder, channel_name: &Name) -> Option { + binder.lookup(channel_name, SymbolKind::Channel) +} + fn resolve_database_name_ptr(binder: &Binder, database_name: &Name) -> Option { binder.lookup(database_name, SymbolKind::Database) } diff --git a/crates/squawk_ide/src/symbols.rs b/crates/squawk_ide/src/symbols.rs index 93212d71..38e4adad 100644 --- a/crates/squawk_ide/src/symbols.rs +++ b/crates/squawk_ide/src/symbols.rs @@ -55,6 +55,7 @@ pub(crate) enum SymbolKind { Sequence, Cursor, PreparedStatement, + Channel, Tablespace, Database, Server, diff --git a/crates/squawk_server/src/lib.rs b/crates/squawk_server/src/lib.rs index ae0bb337..b97d2bc0 100644 --- a/crates/squawk_server/src/lib.rs +++ b/crates/squawk_server/src/lib.rs @@ -379,6 +379,7 @@ fn handle_document_symbol( DocumentSymbolKind::Variant => SymbolKind::ENUM_MEMBER, DocumentSymbolKind::Cursor => SymbolKind::VARIABLE, DocumentSymbolKind::PreparedStatement => SymbolKind::VARIABLE, + DocumentSymbolKind::Channel => SymbolKind::EVENT, }, tags: None, range, diff --git a/crates/squawk_wasm/src/lib.rs b/crates/squawk_wasm/src/lib.rs index 01c7b9a2..d327d7ea 100644 --- a/crates/squawk_wasm/src/lib.rs +++ b/crates/squawk_wasm/src/lib.rs @@ -414,6 +414,7 @@ fn convert_document_symbol( squawk_ide::document_symbols::DocumentSymbolKind::PreparedStatement => { "prepared_statement" } + squawk_ide::document_symbols::DocumentSymbolKind::Channel => "channel", } .to_string(), start_line: full_start_wide.line,