From cd1e71fd49ba6e6e75b4fb007ab1cc9c0f32bd93 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Fri, 3 Oct 2025 22:54:11 -0400 Subject: [PATCH 1/2] lsp: add expand selection support Using rust-analyzer's implementation with some minor tweaks --- Cargo.lock | 2 + Cargo.toml | 1 + crates/squawk_ide/Cargo.toml | 20 + crates/squawk_ide/src/expand_selection.rs | 544 ++++++++++++++++++ crates/squawk_ide/src/lib.rs | 1 + crates/squawk_parser/src/grammar.rs | 15 +- .../snapshots/tests__set_transaction_ok.snap | 89 +-- .../snapshots/tests__transaction_ok.snap | 340 +++++------ crates/squawk_server/Cargo.toml | 2 + crates/squawk_server/src/lib.rs | 68 ++- crates/squawk_server/src/lsp_utils.rs | 13 +- .../squawk_syntax/src/ast/generated/tokens.rs | 25 + crates/xtask/src/codegen.rs | 6 +- 13 files changed, 906 insertions(+), 220 deletions(-) create mode 100644 crates/squawk_ide/Cargo.toml create mode 100644 crates/squawk_ide/src/expand_selection.rs create mode 100644 crates/squawk_ide/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b5d7be98..10263060 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1925,9 +1925,11 @@ dependencies = [ "log", "lsp-server", "lsp-types", + "rowan", "serde", "serde_json", "simplelog", + "squawk-ide", "squawk-lexer", "squawk-linter", "squawk-syntax", diff --git a/Cargo.toml b/Cargo.toml index 7f37c320..23a08017 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ snapbox = { version = "0.6.0", features = ["diff", "term-svg", "cmd"] } # local # we have to make the versions explicit otherwise `cargo publish` won't work squawk-github = { path = "./crates/squawk_github", version = "2.27.0" } +squawk-ide = { path = "./crates/squawk_ide", version = "2.27.0" } squawk-lexer = { path = "./crates/squawk_lexer", version = "2.27.0" } squawk-parser = { path = "./crates/squawk_parser", version = "2.27.0" } squawk-syntax = { path = "./crates/squawk_syntax", version = "2.27.0" } diff --git a/crates/squawk_ide/Cargo.toml b/crates/squawk_ide/Cargo.toml new file mode 100644 index 00000000..f497624c --- /dev/null +++ b/crates/squawk_ide/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "squawk-ide" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +squawk-syntax.workspace = true +rowan.workspace = true +line-index.workspace = true +annotate-snippets.workspace = true +log.workspace = true + +[dev-dependencies] +insta.workspace = true + +[lints] +workspace = true diff --git a/crates/squawk_ide/src/expand_selection.rs b/crates/squawk_ide/src/expand_selection.rs new file mode 100644 index 00000000..3656b576 --- /dev/null +++ b/crates/squawk_ide/src/expand_selection.rs @@ -0,0 +1,544 @@ +// via https://github.com/rust-lang/rust-analyzer/blob/8d75311400a108d7ffe17dc9c38182c566952e6e/crates/ide/src/extend_selection.rs#L1C1-L1C1 +// +// Permission is hereby granted, free of charge, to any +// person obtaining a copy of this software and associated +// documentation files (the "Software"), to deal in the +// Software without restriction, including without +// limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software +// is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice +// shall be included in all copies or substantial portions +// of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +// NOTE: this is pretty much copied as is from rust analyzer with some +// simplifications. I imagine there's more we can do to adapt it for SQL. + +use rowan::{Direction, NodeOrToken, TextRange, TextSize}; +use squawk_syntax::{ + SyntaxKind, SyntaxNode, SyntaxToken, + ast::{self, AstToken}, +}; + +pub fn extend_selection(root: &SyntaxNode, range: TextRange) -> TextRange { + try_extend_selection(root, range).unwrap_or(range) +} + +fn try_extend_selection(root: &SyntaxNode, range: TextRange) -> Option { + // TODO: more list_kinds, and add the strings that rust analyzer has + let string_kinds = [ + SyntaxKind::COMMENT, + SyntaxKind::STRING, + SyntaxKind::BYTE_STRING, + SyntaxKind::BIT_STRING, + SyntaxKind::DOLLAR_QUOTED_STRING, + SyntaxKind::ESC_STRING, + ]; + + // anything that has a separater and whitespace + let list_kinds = [ + SyntaxKind::ARG_LIST, + SyntaxKind::ATTRIBUTE_LIST, + SyntaxKind::COLUMN_LIST, + // only separated by whitespace + // SyntaxKind::FUNC_OPTION_LIST, + SyntaxKind::JSON_TABLE_COLUMN_LIST, + SyntaxKind::OPTIONS_LIST, + SyntaxKind::PARAM_LIST, + // only separated by whitespace + // SyntaxKind::SEQUENCE_OPTION_LIST, + SyntaxKind::SET_OPTIONS_LIST, + SyntaxKind::TABLE_ARG_LIST, + SyntaxKind::TABLE_LIST, + SyntaxKind::TARGET_LIST, + SyntaxKind::TRANSACTION_MODE_LIST, + // only separated by whitespace + // SyntaxKind::XML_COLUMN_OPTION_LIST, + SyntaxKind::XML_TABLE_COLUMN_LIST, + ]; + + if range.is_empty() { + let offset = range.start(); + let mut leaves = root.token_at_offset(offset); + // Make sure that if we're on the whitespace at the start of a line, we + // expand to the node on that line instead of the previous one + if leaves.clone().all(|it| it.kind() == SyntaxKind::WHITESPACE) { + return Some(extend_ws(root, leaves.next()?, offset)); + } + let leaf_range = match root.token_at_offset(offset) { + rowan::TokenAtOffset::None => return None, + rowan::TokenAtOffset::Single(l) => { + if string_kinds.contains(&l.kind()) { + extend_single_word_in_comment_or_string(&l, offset) + .unwrap_or_else(|| l.text_range()) + } else { + l.text_range() + } + } + rowan::TokenAtOffset::Between(l, r) => pick_best(l, r).text_range(), + }; + return Some(leaf_range); + } + + let node = match root.covering_element(range) { + NodeOrToken::Token(token) => { + if token.text_range() != range { + return Some(token.text_range()); + } + if let Some(comment) = ast::Comment::cast(token.clone()) + && let Some(range) = extend_comments(comment) + { + return Some(range); + } + token.parent()? + } + NodeOrToken::Node(node) => node, + }; + + if node.text_range() != range { + return Some(node.text_range()); + } + + let node = shallowest_node(&node); + + if node + .parent() + .is_some_and(|n| list_kinds.contains(&n.kind())) + { + if let Some(range) = extend_list_item(&node) { + return Some(range); + } + } + + node.parent().map(|it| it.text_range()) +} + +/// Find the shallowest node with same range, which allows us to traverse siblings. +fn shallowest_node(node: &SyntaxNode) -> SyntaxNode { + node.ancestors() + .take_while(|n| n.text_range() == node.text_range()) + .last() + .unwrap() +} + +/// Expand to the current word instead the full text range of the node. +fn extend_single_word_in_comment_or_string( + leaf: &SyntaxToken, + offset: TextSize, +) -> Option { + let text: &str = leaf.text(); + let cursor_position: u32 = (offset - leaf.text_range().start()).into(); + + let (before, after) = text.split_at(cursor_position as usize); + + fn non_word_char(c: char) -> bool { + !(c.is_alphanumeric() || c == '_') + } + + let start_idx = before.rfind(non_word_char)? as u32; + let end_idx = after.find(non_word_char).unwrap_or(after.len()) as u32; + + // FIXME: use `ceil_char_boundary` from `std::str` when it gets stable + // https://github.com/rust-lang/rust/issues/93743 + fn ceil_char_boundary(text: &str, index: u32) -> u32 { + (index..) + .find(|&index| text.is_char_boundary(index as usize)) + .unwrap_or(text.len() as u32) + } + + let from: TextSize = ceil_char_boundary(text, start_idx + 1).into(); + let to: TextSize = (cursor_position + end_idx).into(); + + let range = TextRange::new(from, to); + if range.is_empty() { + None + } else { + Some(range + leaf.text_range().start()) + } +} + +fn extend_comments(comment: ast::Comment) -> Option { + let prev = adj_comments(&comment, Direction::Prev); + let next = adj_comments(&comment, Direction::Next); + if prev != next { + Some(TextRange::new( + prev.syntax().text_range().start(), + next.syntax().text_range().end(), + )) + } else { + None + } +} + +fn adj_comments(comment: &ast::Comment, dir: Direction) -> ast::Comment { + let mut res = comment.clone(); + for element in comment.syntax().siblings_with_tokens(dir) { + let token = match element.as_token() { + None => break, + Some(token) => token, + }; + if let Some(c) = ast::Comment::cast(token.clone()) { + res = c + } else if token.kind() != SyntaxKind::WHITESPACE || token.text().contains("\n\n") { + break; + } + } + res +} + +fn extend_ws(root: &SyntaxNode, ws: SyntaxToken, offset: TextSize) -> TextRange { + let ws_text = ws.text(); + let suffix = TextRange::new(offset, ws.text_range().end()) - ws.text_range().start(); + let prefix = TextRange::new(ws.text_range().start(), offset) - ws.text_range().start(); + let ws_suffix = &ws_text[suffix]; + let ws_prefix = &ws_text[prefix]; + if ws_text.contains('\n') + && !ws_suffix.contains('\n') + && let Some(node) = ws.next_sibling_or_token() + { + let start = match ws_prefix.rfind('\n') { + Some(idx) => ws.text_range().start() + TextSize::from((idx + 1) as u32), + None => node.text_range().start(), + }; + let end = if root.text().char_at(node.text_range().end()) == Some('\n') { + node.text_range().end() + TextSize::of('\n') + } else { + node.text_range().end() + }; + return TextRange::new(start, end); + } + ws.text_range() +} + +fn pick_best(l: SyntaxToken, r: SyntaxToken) -> SyntaxToken { + return if priority(&r) > priority(&l) { r } else { l }; + fn priority(n: &SyntaxToken) -> usize { + match n.kind() { + SyntaxKind::WHITESPACE => 0, + // TODO: we can probably include more here, rust analyzer includes a + // handful of keywords + SyntaxKind::IDENT => 2, + _ => 1, + } + } +} + +/// Extend list item selection to include nearby delimiter and whitespace. +fn extend_list_item(node: &SyntaxNode) -> Option { + fn is_single_line_ws(node: &SyntaxToken) -> bool { + node.kind() == SyntaxKind::WHITESPACE && !node.text().contains('\n') + } + + fn nearby_comma(node: &SyntaxNode, dir: Direction) -> Option { + node.siblings_with_tokens(dir) + .skip(1) + .find(|node| match node { + NodeOrToken::Node(_) => true, + NodeOrToken::Token(it) => !is_single_line_ws(it), + }) + .and_then(|it| it.into_token()) + .filter(|node| node.kind() == SyntaxKind::COMMA) + } + + if let Some(comma) = nearby_comma(node, Direction::Next) { + // Include any following whitespace when delimiter is after list item. + let final_node = comma + .next_sibling_or_token() + .and_then(|n| n.into_token()) + .filter(is_single_line_ws) + .unwrap_or_else(|| comma); + + return Some(TextRange::new( + node.text_range().start(), + final_node.text_range().end(), + )); + } + + if let Some(comma) = nearby_comma(node, Direction::Prev) { + return Some(TextRange::new( + comma.text_range().start(), + node.text_range().end(), + )); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use insta::assert_debug_snapshot; + use rowan::TextSize; + use squawk_syntax::{SourceFile, ast::AstNode}; + + fn expand(sql: &str) -> Vec { + let (offset, sql) = fixture(sql); + let parse = SourceFile::parse(&sql); + let file = parse.tree(); + let root = file.syntax(); + + let mut range = TextRange::empty(offset); + let mut results = Vec::new(); + + for _ in 0..20 { + let new_range = extend_selection(root, range); + if new_range == range { + break; + } + range = new_range; + results.push(sql[range].to_string()); + } + + results + } + + fn fixture(sql: &str) -> (TextSize, String) { + const MARKER: &str = "$0"; + if let Some(pos) = sql.find(MARKER) { + return (TextSize::new(pos as u32), sql.replace(MARKER, "")); + } + panic!("No marker found in test SQL"); + } + + #[test] + fn simple() { + assert_debug_snapshot!(expand(r#"select $01 + 1"#), @r#" + [ + "1", + "1 + 1", + "select 1 + 1", + ] + "#); + } + + #[test] + fn word_in_string_string() { + assert_debug_snapshot!(expand(r" +select 'some stret$0ched out words in a string' +"), @r#" + [ + "stretched", + "'some stretched out words in a string'", + "select 'some stretched out words in a string'", + "\nselect 'some stretched out words in a string'\n", + ] + "#); + } + + #[test] + fn string() { + assert_debug_snapshot!(expand(r" +select b'foo$0 bar' +'buzz'; +"), @r#" + [ + "foo", + "b'foo bar'", + "b'foo bar'\n'buzz'", + "select b'foo bar'\n'buzz'", + "\nselect b'foo bar'\n'buzz';\n", + ] + "#); + } + + #[test] + fn dollar_string() { + assert_debug_snapshot!(expand(r" +select $$foo$0 bar$$; +"), @r#" + [ + "foo", + "$$foo bar$$", + "select $$foo bar$$", + "\nselect $$foo bar$$;\n", + ] + "#); + } + + #[test] + fn comment_muli_line() { + assert_debug_snapshot!(expand(r" +-- foo bar +-- buzz$0 +-- boo +select 1 +"), @r#" + [ + "-- buzz", + "-- foo bar\n-- buzz\n-- boo", + "\n-- foo bar\n-- buzz\n-- boo\nselect 1\n", + ] + "#); + } + + #[test] + fn comment() { + assert_debug_snapshot!(expand(r" +-- foo bar$0 +select 1 +"), @r#" + [ + "-- foo bar", + "\n-- foo bar\nselect 1\n", + ] + "#); + + assert_debug_snapshot!(expand(r" +/* foo bar$0 */ +select 1 +"), @r#" + [ + "bar", + "/* foo bar */", + "\n/* foo bar */\nselect 1\n", + ] + "#); + } + + #[test] + fn create_table_with_comment() { + assert_debug_snapshot!(expand(r" +-- foo bar buzz +create table t( + x int$0, + y text +); +"), @r#" + [ + "int", + "x int", + "x int,", + "(\n x int,\n y text\n)", + "-- foo bar buzz\ncreate table t(\n x int,\n y text\n)", + "\n-- foo bar buzz\ncreate table t(\n x int,\n y text\n);\n", + ] + "#); + } + + #[test] + fn column_list() { + assert_debug_snapshot!(expand(r#"create table t($0x int)"#), @r#" + [ + "x", + "x int", + "(x int)", + "create table t(x int)", + ] + "#); + + assert_debug_snapshot!(expand(r#"create table t($0x int, y int)"#), @r#" + [ + "x", + "x int", + "x int, ", + "(x int, y int)", + "create table t(x int, y int)", + ] + "#); + + assert_debug_snapshot!(expand(r#"create table t(x int, $0y int)"#), @r#" + [ + "y", + "y int", + ", y int", + "(x int, y int)", + "create table t(x int, y int)", + ] + "#); + } + + #[test] + fn start_of_line_whitespace_select() { + assert_debug_snapshot!(expand(r#" +select 1; + +$0 select 2;"#), @r#" + [ + " select 2", + " \nselect 1;\n\n select 2;", + ] + "#); + } + + #[test] + fn select_list() { + assert_debug_snapshot!(expand(r#"select x$0, y from t"#), @r#" + [ + "x", + "x, ", + "x, y", + "select x, y", + "select x, y from t", + ] + "#); + + assert_debug_snapshot!(expand(r#"select x, y$0 from t"#), @r#" + [ + "y", + ", y", + "x, y", + "select x, y", + "select x, y from t", + ] + "#); + } + + #[test] + fn expand_whitespace() { + assert_debug_snapshot!(expand(r#"select 1 + +$0 +1;"#), @r#" + [ + " \n\n", + "1 + \n\n1", + "select 1 + \n\n1", + "select 1 + \n\n1;", + ] + "#); + } + + #[test] + fn function_args() { + assert_debug_snapshot!(expand(r#"select f(1$0, 2)"#), @r#" + [ + "1", + "1, ", + "(1, 2)", + "f(1, 2)", + "select f(1, 2)", + ] + "#); + } + + #[test] + fn prefer_idents() { + assert_debug_snapshot!(expand(r#"select foo$0+bar"#), @r#" + [ + "foo", + "foo+bar", + "select foo+bar", + ] + "#); + + assert_debug_snapshot!(expand(r#"select foo+$0bar"#), @r#" + [ + "bar", + "foo+bar", + "select foo+bar", + ] + "#); + } +} diff --git a/crates/squawk_ide/src/lib.rs b/crates/squawk_ide/src/lib.rs new file mode 100644 index 00000000..0466be5f --- /dev/null +++ b/crates/squawk_ide/src/lib.rs @@ -0,0 +1 @@ +pub mod expand_selection; diff --git a/crates/squawk_parser/src/grammar.rs b/crates/squawk_parser/src/grammar.rs index f5d3af65..9ed387ed 100644 --- a/crates/squawk_parser/src/grammar.rs +++ b/crates/squawk_parser/src/grammar.rs @@ -5227,12 +5227,12 @@ fn begin(p: &mut Parser<'_>) -> CompletedMarker { if p.eat(BEGIN_KW) { // [ WORK | TRANSACTION ] let _ = p.eat(WORK_KW) || p.eat(TRANSACTION_KW); - transaction_mode_list(p); + opt_transaction_mode_list(p); } else { // START TRANSACTION [ transaction_mode [, ...] ] p.bump(START_KW); p.expect(TRANSACTION_KW); - transaction_mode_list(p); + opt_transaction_mode_list(p); } m.complete(p, BEGIN) } @@ -10833,9 +10833,13 @@ fn set_session_auth(p: &mut Parser<'_>) -> CompletedMarker { m.complete(p, SET_SESSION_AUTH) } -fn transaction_mode_list(p: &mut Parser<'_>) { +fn opt_transaction_mode_list(p: &mut Parser<'_>) -> Option { // TODO: generalize // transaction_mode [, ...] + if !p.at_ts(TRANSACTION_MODE_FIRST) { + return None; + } + let m = p.start(); while !p.at(EOF) && p.at_ts(TRANSACTION_MODE_FIRST) { if !opt_transaction_mode(p) { p.error("expected transaction mode"); @@ -10843,6 +10847,7 @@ fn transaction_mode_list(p: &mut Parser<'_>) { // historical pg syntax doesn't require commas p.eat(COMMA); } + Some(m.complete(p, TRANSACTION_MODE_LIST)) } // SET TRANSACTION transaction_mode [, ...] @@ -10861,14 +10866,14 @@ fn set_transaction(p: &mut Parser<'_>) -> CompletedMarker { p.expect(CHARACTERISTICS_KW); p.expect(AS_KW); p.expect(TRANSACTION_KW); - transaction_mode_list(p); + opt_transaction_mode_list(p); } else { p.expect(TRANSACTION_KW); // [ SNAPSHOT snapshot_id ] if p.eat(SNAPSHOT_KW) { string_literal(p); } else { - transaction_mode_list(p); + opt_transaction_mode_list(p); } } m.complete(p, SET_TRANSACTION) diff --git a/crates/squawk_parser/tests/snapshots/tests__set_transaction_ok.snap b/crates/squawk_parser/tests/snapshots/tests__set_transaction_ok.snap index 7b8cb192..8e53fa67 100644 --- a/crates/squawk_parser/tests/snapshots/tests__set_transaction_ok.snap +++ b/crates/squawk_parser/tests/snapshots/tests__set_transaction_ok.snap @@ -27,20 +27,21 @@ SOURCE_FILE WHITESPACE " " TRANSACTION_KW "TRANSACTION" WHITESPACE " " - READ_COMMITTED - ISOLATION_KW "ISOLATION" + TRANSACTION_MODE_LIST + READ_COMMITTED + ISOLATION_KW "ISOLATION" + WHITESPACE " " + LEVEL_KW "LEVEL" + WHITESPACE " " + READ_KW "READ" + WHITESPACE " " + COMMITTED_KW "COMMITTED" + COMMA "," WHITESPACE " " - LEVEL_KW "LEVEL" - WHITESPACE " " - READ_KW "READ" - WHITESPACE " " - COMMITTED_KW "COMMITTED" - COMMA "," - WHITESPACE " " - READ_WRITE - READ_KW "read" - WHITESPACE " " - WRITE_KW "write" + READ_WRITE + READ_KW "read" + WHITESPACE " " + WRITE_KW "write" SEMICOLON ";" WHITESPACE "\n\n" SET_TRANSACTION @@ -48,24 +49,25 @@ SOURCE_FILE WHITESPACE " " TRANSACTION_KW "TRANSACTION" WHITESPACE " " - SERIALIZABLE - ISOLATION_KW "ISOLATION" - WHITESPACE " " - LEVEL_KW "LEVEL" - WHITESPACE " " - SERIALIZABLE_KW "SERIALIZABLE" - COMMA "," - WHITESPACE " " - READ_WRITE - READ_KW "READ" + TRANSACTION_MODE_LIST + SERIALIZABLE + ISOLATION_KW "ISOLATION" + WHITESPACE " " + LEVEL_KW "LEVEL" + WHITESPACE " " + SERIALIZABLE_KW "SERIALIZABLE" + COMMA "," WHITESPACE " " - WRITE_KW "WRITE" - COMMA "," - WHITESPACE " " - NOT_DEFERRABLE - NOT_KW "NOT" + READ_WRITE + READ_KW "READ" + WHITESPACE " " + WRITE_KW "WRITE" + COMMA "," WHITESPACE " " - DEFERRABLE_KW "DEFERRABLE" + NOT_DEFERRABLE + NOT_KW "NOT" + WHITESPACE " " + DEFERRABLE_KW "DEFERRABLE" SEMICOLON ";" WHITESPACE "\n\n\n" COMMENT "-- no commas is postgres historical according to gram.y" @@ -75,21 +77,22 @@ SOURCE_FILE WHITESPACE " " TRANSACTION_KW "TRANSACTION" WHITESPACE " " - SERIALIZABLE - ISOLATION_KW "ISOLATION" - WHITESPACE " " - LEVEL_KW "LEVEL" - WHITESPACE " " - SERIALIZABLE_KW "SERIALIZABLE" - WHITESPACE " " - READ_WRITE - READ_KW "READ" + TRANSACTION_MODE_LIST + SERIALIZABLE + ISOLATION_KW "ISOLATION" + WHITESPACE " " + LEVEL_KW "LEVEL" + WHITESPACE " " + SERIALIZABLE_KW "SERIALIZABLE" WHITESPACE " " - WRITE_KW "WRITE" - WHITESPACE " " - NOT_DEFERRABLE - NOT_KW "NOT" + READ_WRITE + READ_KW "READ" + WHITESPACE " " + WRITE_KW "WRITE" WHITESPACE " " - DEFERRABLE_KW "DEFERRABLE" + NOT_DEFERRABLE + NOT_KW "NOT" + WHITESPACE " " + DEFERRABLE_KW "DEFERRABLE" SEMICOLON ";" WHITESPACE "\n" diff --git a/crates/squawk_parser/tests/snapshots/tests__transaction_ok.snap b/crates/squawk_parser/tests/snapshots/tests__transaction_ok.snap index 38f91af6..c9f53de6 100644 --- a/crates/squawk_parser/tests/snapshots/tests__transaction_ok.snap +++ b/crates/squawk_parser/tests/snapshots/tests__transaction_ok.snap @@ -71,95 +71,97 @@ SOURCE_FILE BEGIN BEGIN_KW "begin" WHITESPACE " \n " - READ_COMMITTED - ISOLATION_KW "isolation" - WHITESPACE " " - LEVEL_KW "level" - WHITESPACE " " - READ_KW "read" - WHITESPACE " " - COMMITTED_KW "committed" - WHITESPACE "\n " - READ_ONLY - READ_KW "read" - WHITESPACE " " - ONLY_KW "only" - WHITESPACE "\n " - READ_WRITE - READ_KW "read" - WHITESPACE " " - WRITE_KW "write" - WHITESPACE "\n " - DEFERRABLE - DEFERRABLE_KW "deferrable" - WHITESPACE "\n " - NOT_DEFERRABLE - NOT_KW "not" - WHITESPACE " " - DEFERRABLE_KW "deferrable" + TRANSACTION_MODE_LIST + READ_COMMITTED + ISOLATION_KW "isolation" + WHITESPACE " " + LEVEL_KW "level" + WHITESPACE " " + READ_KW "read" + WHITESPACE " " + COMMITTED_KW "committed" + WHITESPACE "\n " + READ_ONLY + READ_KW "read" + WHITESPACE " " + ONLY_KW "only" + WHITESPACE "\n " + READ_WRITE + READ_KW "read" + WHITESPACE " " + WRITE_KW "write" + WHITESPACE "\n " + DEFERRABLE + DEFERRABLE_KW "deferrable" + WHITESPACE "\n " + NOT_DEFERRABLE + NOT_KW "not" + WHITESPACE " " + DEFERRABLE_KW "deferrable" SEMICOLON ";" WHITESPACE "\n\n" BEGIN BEGIN_KW "begin" WHITESPACE "\n " - READ_COMMITTED - ISOLATION_KW "isolation" - WHITESPACE " " - LEVEL_KW "level" - WHITESPACE " " - READ_KW "read" - WHITESPACE " " - COMMITTED_KW "committed" - COMMA "," - WHITESPACE "\n " - READ_UNCOMMITTED - ISOLATION_KW "isolation" - WHITESPACE " " - LEVEL_KW "level" - WHITESPACE " " - READ_KW "read" - WHITESPACE " " - UNCOMMITTED_KW "uncommitted" - COMMA "," - WHITESPACE "\n " - REPEATABLE_READ - ISOLATION_KW "isolation" - WHITESPACE " " - LEVEL_KW "level" - WHITESPACE " " - REPEATABLE_KW "repeatable" - WHITESPACE " " - READ_KW "read" - COMMA "," - WHITESPACE "\n " - SERIALIZABLE - ISOLATION_KW "isolation" - WHITESPACE " " - LEVEL_KW "level" - WHITESPACE " " - SERIALIZABLE_KW "serializable" - COMMA "," - WHITESPACE "\n " - READ_ONLY - READ_KW "read" - WHITESPACE " " - ONLY_KW "only" - COMMA "," - WHITESPACE "\n " - READ_WRITE - READ_KW "read" - WHITESPACE " " - WRITE_KW "write" - COMMA "," - WHITESPACE "\n " - DEFERRABLE - DEFERRABLE_KW "deferrable" - COMMA "," - WHITESPACE "\n " - NOT_DEFERRABLE - NOT_KW "not" - WHITESPACE " " - DEFERRABLE_KW "deferrable" + TRANSACTION_MODE_LIST + READ_COMMITTED + ISOLATION_KW "isolation" + WHITESPACE " " + LEVEL_KW "level" + WHITESPACE " " + READ_KW "read" + WHITESPACE " " + COMMITTED_KW "committed" + COMMA "," + WHITESPACE "\n " + READ_UNCOMMITTED + ISOLATION_KW "isolation" + WHITESPACE " " + LEVEL_KW "level" + WHITESPACE " " + READ_KW "read" + WHITESPACE " " + UNCOMMITTED_KW "uncommitted" + COMMA "," + WHITESPACE "\n " + REPEATABLE_READ + ISOLATION_KW "isolation" + WHITESPACE " " + LEVEL_KW "level" + WHITESPACE " " + REPEATABLE_KW "repeatable" + WHITESPACE " " + READ_KW "read" + COMMA "," + WHITESPACE "\n " + SERIALIZABLE + ISOLATION_KW "isolation" + WHITESPACE " " + LEVEL_KW "level" + WHITESPACE " " + SERIALIZABLE_KW "serializable" + COMMA "," + WHITESPACE "\n " + READ_ONLY + READ_KW "read" + WHITESPACE " " + ONLY_KW "only" + COMMA "," + WHITESPACE "\n " + READ_WRITE + READ_KW "read" + WHITESPACE " " + WRITE_KW "write" + COMMA "," + WHITESPACE "\n " + DEFERRABLE + DEFERRABLE_KW "deferrable" + COMMA "," + WHITESPACE "\n " + NOT_DEFERRABLE + NOT_KW "not" + WHITESPACE " " + DEFERRABLE_KW "deferrable" SEMICOLON ";" WHITESPACE "\n\n" BEGIN @@ -167,32 +169,33 @@ SOURCE_FILE WHITESPACE " " TRANSACTION_KW "transaction" WHITESPACE "\n " - READ_COMMITTED - ISOLATION_KW "isolation" - WHITESPACE " " - LEVEL_KW "level" - WHITESPACE " " - READ_KW "read" - WHITESPACE " " - COMMITTED_KW "committed" - WHITESPACE "\n " - READ_ONLY - READ_KW "read" - WHITESPACE " " - ONLY_KW "only" - WHITESPACE "\n " - READ_WRITE - READ_KW "read" - WHITESPACE " " - WRITE_KW "write" - WHITESPACE "\n " - DEFERRABLE - DEFERRABLE_KW "deferrable" - WHITESPACE "\n " - NOT_DEFERRABLE - NOT_KW "not" - WHITESPACE " " - DEFERRABLE_KW "deferrable" + TRANSACTION_MODE_LIST + READ_COMMITTED + ISOLATION_KW "isolation" + WHITESPACE " " + LEVEL_KW "level" + WHITESPACE " " + READ_KW "read" + WHITESPACE " " + COMMITTED_KW "committed" + WHITESPACE "\n " + READ_ONLY + READ_KW "read" + WHITESPACE " " + ONLY_KW "only" + WHITESPACE "\n " + READ_WRITE + READ_KW "read" + WHITESPACE " " + WRITE_KW "write" + WHITESPACE "\n " + DEFERRABLE + DEFERRABLE_KW "deferrable" + WHITESPACE "\n " + NOT_DEFERRABLE + NOT_KW "not" + WHITESPACE " " + DEFERRABLE_KW "deferrable" SEMICOLON ";" WHITESPACE "\n\n" BEGIN @@ -200,64 +203,65 @@ SOURCE_FILE WHITESPACE " " TRANSACTION_KW "transaction" WHITESPACE "\n " - READ_COMMITTED - ISOLATION_KW "isolation" - WHITESPACE " " - LEVEL_KW "level" - WHITESPACE " " - READ_KW "read" - WHITESPACE " " - COMMITTED_KW "committed" - COMMA "," - WHITESPACE "\n " - READ_UNCOMMITTED - ISOLATION_KW "isolation" - WHITESPACE " " - LEVEL_KW "level" - WHITESPACE " " - READ_KW "read" - WHITESPACE " " - UNCOMMITTED_KW "uncommitted" - COMMA "," - WHITESPACE "\n " - REPEATABLE_READ - ISOLATION_KW "isolation" - WHITESPACE " " - LEVEL_KW "level" - WHITESPACE " " - REPEATABLE_KW "repeatable" - WHITESPACE " " - READ_KW "read" - COMMA "," - WHITESPACE "\n " - SERIALIZABLE - ISOLATION_KW "isolation" - WHITESPACE " " - LEVEL_KW "level" - WHITESPACE " " - SERIALIZABLE_KW "serializable" - COMMA "," - WHITESPACE "\n " - READ_ONLY - READ_KW "read" - WHITESPACE " " - ONLY_KW "only" - COMMA "," - WHITESPACE "\n " - READ_WRITE - READ_KW "read" - WHITESPACE " " - WRITE_KW "write" - COMMA "," - WHITESPACE "\n " - DEFERRABLE - DEFERRABLE_KW "deferrable" - COMMA "," - WHITESPACE "\n " - NOT_DEFERRABLE - NOT_KW "not" - WHITESPACE " " - DEFERRABLE_KW "deferrable" + TRANSACTION_MODE_LIST + READ_COMMITTED + ISOLATION_KW "isolation" + WHITESPACE " " + LEVEL_KW "level" + WHITESPACE " " + READ_KW "read" + WHITESPACE " " + COMMITTED_KW "committed" + COMMA "," + WHITESPACE "\n " + READ_UNCOMMITTED + ISOLATION_KW "isolation" + WHITESPACE " " + LEVEL_KW "level" + WHITESPACE " " + READ_KW "read" + WHITESPACE " " + UNCOMMITTED_KW "uncommitted" + COMMA "," + WHITESPACE "\n " + REPEATABLE_READ + ISOLATION_KW "isolation" + WHITESPACE " " + LEVEL_KW "level" + WHITESPACE " " + REPEATABLE_KW "repeatable" + WHITESPACE " " + READ_KW "read" + COMMA "," + WHITESPACE "\n " + SERIALIZABLE + ISOLATION_KW "isolation" + WHITESPACE " " + LEVEL_KW "level" + WHITESPACE " " + SERIALIZABLE_KW "serializable" + COMMA "," + WHITESPACE "\n " + READ_ONLY + READ_KW "read" + WHITESPACE " " + ONLY_KW "only" + COMMA "," + WHITESPACE "\n " + READ_WRITE + READ_KW "read" + WHITESPACE " " + WRITE_KW "write" + COMMA "," + WHITESPACE "\n " + DEFERRABLE + DEFERRABLE_KW "deferrable" + COMMA "," + WHITESPACE "\n " + NOT_DEFERRABLE + NOT_KW "not" + WHITESPACE " " + DEFERRABLE_KW "deferrable" SEMICOLON ";" WHITESPACE "\n\n" PREPARE_TRANSACTION diff --git a/crates/squawk_server/Cargo.toml b/crates/squawk_server/Cargo.toml index 751451ce..33278367 100644 --- a/crates/squawk_server/Cargo.toml +++ b/crates/squawk_server/Cargo.toml @@ -16,8 +16,10 @@ log.workspace = true simplelog.workspace = true lsp-server.workspace = true lsp-types.workspace = true +rowan.workspace = true serde.workspace = true serde_json.workspace = true +squawk-ide.workspace = true squawk-lexer.workspace = true squawk-linter.workspace = true squawk-syntax.workspace = true diff --git a/crates/squawk_server/src/lib.rs b/crates/squawk_server/src/lib.rs index 30c8da99..f923778f 100644 --- a/crates/squawk_server/src/lib.rs +++ b/crates/squawk_server/src/lib.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use line_index::LineIndex; use log::info; use lsp_server::{Connection, Message, Notification, Response}; use lsp_types::{ @@ -6,14 +7,16 @@ use lsp_types::{ CodeActionProviderCapability, CodeActionResponse, Command, Diagnostic, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, Location, Position, - PublishDiagnosticsParams, Range, ServerCapabilities, TextDocumentSyncCapability, + PublishDiagnosticsParams, Range, SelectionRange, SelectionRangeParams, + SelectionRangeProviderCapability, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgressOptions, WorkspaceEdit, notification::{ DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, Notification as _, PublishDiagnostics, }, - request::{CodeActionRequest, GotoDefinition, Request}, + request::{CodeActionRequest, GotoDefinition, Request, SelectionRangeRequest}, }; +use rowan::TextRange; use squawk_syntax::{Parse, SourceFile}; use std::collections::HashMap; @@ -46,6 +49,7 @@ pub fn run() -> Result<()> { }, resolve_provider: None, })), + selection_range_provider: Some(SelectionRangeProviderCapability::Simple(true)), // definition_provider: Some(OneOf::Left(true)), ..Default::default() }) @@ -90,6 +94,9 @@ fn main_loop(connection: Connection, params: serde_json::Value) -> Result<()> { CodeActionRequest::METHOD => { handle_code_action(&connection, req, &documents)?; } + SelectionRangeRequest::METHOD => { + handle_selection_range(&connection, req, &documents)?; + } "squawk/syntaxTree" => { handle_syntax_tree(&connection, req, &documents)?; } @@ -145,6 +152,63 @@ fn handle_goto_definition(connection: &Connection, req: lsp_server::Request) -> Ok(()) } +fn handle_selection_range( + connection: &Connection, + req: lsp_server::Request, + documents: &HashMap, +) -> Result<()> { + let params: SelectionRangeParams = serde_json::from_value(req.params)?; + let uri = params.text_document.uri; + + let content = documents.get(&uri).map_or("", |doc| &doc.content); + let parse: Parse = SourceFile::parse(content); + let root = parse.syntax_node(); + let line_index = LineIndex::new(&content); + + let mut selection_ranges = vec![]; + + for position in params.positions { + let Some(offset) = lsp_utils::offset(&line_index, position) else { + continue; + }; + + let mut ranges = Vec::new(); + { + let mut range = TextRange::new(offset, offset); + loop { + ranges.push(range); + let next = squawk_ide::expand_selection::extend_selection(&root, range); + if next == range { + break; + } else { + range = next + } + } + } + + let mut range = lsp_types::SelectionRange { + range: lsp_utils::range(&line_index, *ranges.last().unwrap()), + parent: None, + }; + for &r in ranges.iter().rev().skip(1) { + range = lsp_types::SelectionRange { + range: lsp_utils::range(&line_index, r), + parent: Some(Box::new(range)), + } + } + selection_ranges.push(range); + } + + let resp = Response { + id: req.id, + result: Some(serde_json::to_value(&selection_ranges).unwrap()), + error: None, + }; + + connection.sender.send(Message::Response(resp))?; + Ok(()) +} + fn handle_code_action( connection: &Connection, req: lsp_server::Request, diff --git a/crates/squawk_server/src/lsp_utils.rs b/crates/squawk_server/src/lsp_utils.rs index fc881a9b..942f561b 100644 --- a/crates/squawk_server/src/lsp_utils.rs +++ b/crates/squawk_server/src/lsp_utils.rs @@ -17,7 +17,8 @@ fn text_range(index: &LineIndex, range: lsp_types::Range) -> Option { None } } -fn offset(index: &LineIndex, position: lsp_types::Position) -> Option { + +pub(crate) fn offset(index: &LineIndex, position: lsp_types::Position) -> Option { let line_range = index.line(position.line)?; let col = TextSize::from(position.character); @@ -35,6 +36,16 @@ fn offset(index: &LineIndex, position: lsp_types::Position) -> Option Some(line_range.start() + clamped_len) } +pub(crate) fn range(line_index: &LineIndex, range: TextRange) -> lsp_types::Range { + let start = line_index.line_col(range.start()); + let end = line_index.line_col(range.end()); + + lsp_types::Range::new( + lsp_types::Position::new(start.line, start.col), + lsp_types::Position::new(end.line, end.col), + ) +} + // base on rust-analyzer's // see: https://github.com/rust-lang/rust-analyzer/blob/3816d0ae53c19fe75532a8b41d8c546d94246b53/crates/rust-analyzer/src/lsp/utils.rs#L168C1-L168C1 pub(crate) fn apply_incremental_changes( diff --git a/crates/squawk_syntax/src/ast/generated/tokens.rs b/crates/squawk_syntax/src/ast/generated/tokens.rs index fd14e662..debbc3ea 100644 --- a/crates/squawk_syntax/src/ast/generated/tokens.rs +++ b/crates/squawk_syntax/src/ast/generated/tokens.rs @@ -1,5 +1,30 @@ use crate::{SyntaxKind, SyntaxToken, ast::AstToken}; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Comment { + pub(crate) syntax: SyntaxToken, +} +impl std::fmt::Display for Comment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.syntax, f) + } +} +impl AstToken for Comment { + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::COMMENT + } + fn cast(syntax: SyntaxToken) -> Option { + if Self::can_cast(syntax.kind()) { + Some(Self { syntax }) + } else { + None + } + } + fn syntax(&self) -> &SyntaxToken { + &self.syntax + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Null { pub(crate) syntax: SyntaxToken, diff --git a/crates/xtask/src/codegen.rs b/crates/xtask/src/codegen.rs index 1f7ca352..cc790c8b 100644 --- a/crates/xtask/src/codegen.rs +++ b/crates/xtask/src/codegen.rs @@ -495,7 +495,11 @@ struct AstEnumSrc { } fn lower(grammar: &Grammar) -> AstSrc { - let tokens = vec![("Null", "NULL_KW"), ("String", "STRING")]; + let tokens = vec![ + ("Null", "NULL_KW"), + ("String", "STRING"), + ("Comment", "COMMENT"), + ]; let mut res = AstSrc { tokens, ..Default::default() From 2e968798a1b5866e34cd669b06522f78025bd539 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Fri, 3 Oct 2025 22:58:40 -0400 Subject: [PATCH 2/2] lint --- crates/squawk_ide/src/expand_selection.rs | 7 +++---- crates/squawk_server/src/lib.rs | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/squawk_ide/src/expand_selection.rs b/crates/squawk_ide/src/expand_selection.rs index 3656b576..1417795e 100644 --- a/crates/squawk_ide/src/expand_selection.rs +++ b/crates/squawk_ide/src/expand_selection.rs @@ -186,9 +186,8 @@ fn extend_comments(comment: ast::Comment) -> Option { fn adj_comments(comment: &ast::Comment, dir: Direction) -> ast::Comment { let mut res = comment.clone(); for element in comment.syntax().siblings_with_tokens(dir) { - let token = match element.as_token() { - None => break, - Some(token) => token, + let Some(token) = element.as_token() else { + break; }; if let Some(c) = ast::Comment::cast(token.clone()) { res = c @@ -259,7 +258,7 @@ fn extend_list_item(node: &SyntaxNode) -> Option { .next_sibling_or_token() .and_then(|n| n.into_token()) .filter(is_single_line_ws) - .unwrap_or_else(|| comma); + .unwrap_or(comma); return Some(TextRange::new( node.text_range().start(), diff --git a/crates/squawk_server/src/lib.rs b/crates/squawk_server/src/lib.rs index f923778f..5b3dc255 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, InitializeParams, Location, Position, - PublishDiagnosticsParams, Range, SelectionRange, SelectionRangeParams, - SelectionRangeProviderCapability, ServerCapabilities, TextDocumentSyncCapability, - TextDocumentSyncKind, Url, WorkDoneProgressOptions, WorkspaceEdit, + PublishDiagnosticsParams, Range, SelectionRangeParams, SelectionRangeProviderCapability, + ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, Url, + WorkDoneProgressOptions, WorkspaceEdit, notification::{ DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, Notification as _, PublishDiagnostics, @@ -163,7 +163,7 @@ fn handle_selection_range( let content = documents.get(&uri).map_or("", |doc| &doc.content); let parse: Parse = SourceFile::parse(content); let root = parse.syntax_node(); - let line_index = LineIndex::new(&content); + let line_index = LineIndex::new(content); let mut selection_ranges = vec![];