diff --git a/crates/squawk_ide/src/folding_ranges.rs b/crates/squawk_ide/src/folding_ranges.rs new file mode 100644 index 00000000..7bfb6019 --- /dev/null +++ b/crates/squawk_ide/src/folding_ranges.rs @@ -0,0 +1,556 @@ +// via https://github.com/rust-lang/rust-analyzer/blob/8d75311400a108d7ffe17dc9c38182c566952e6e/crates/ide/src/folding_ranges.rs#L47 +// +// 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: pretty much copied as is but simplfied a fair bit. I don't use folding +// much so not sure if this is optimal. + +use std::collections::HashSet; + +use rowan::{Direction, NodeOrToken, TextRange}; +use salsa::Database as Db; +use squawk_syntax::SyntaxKind; +use squawk_syntax::ast::{self, AstNode, AstToken}; + +use crate::db::{File, parse}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FoldKind { + ArgList, + Array, + Comment, + FunctionCall, + Join, + List, + Statement, + Subquery, + Tuple, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fold { + pub range: TextRange, + pub kind: FoldKind, +} + +#[salsa::tracked] +pub fn folding_ranges(db: &dyn Db, file: File) -> Vec { + let parse = parse(db, file); + + let mut folds = vec![]; + let mut visited_comments = HashSet::default(); + + for element in parse.tree().syntax().descendants_with_tokens() { + match &element { + NodeOrToken::Token(token) => { + if let Some(comment) = ast::Comment::cast(token.clone()) + && !visited_comments.contains(&comment) + && let Some(range) = + contiguous_range_for_comment(comment, &mut visited_comments) + { + folds.push(Fold { + range, + kind: FoldKind::Comment, + }); + } + } + NodeOrToken::Node(node) => { + if let Some(kind) = fold_kind(node.kind()) { + if !node.text().contains_char('\n') { + continue; + } + // skip any leading whitespace / comments + let start = node + .children_with_tokens() + .find(|e| match e { + NodeOrToken::Token(t) => { + let kind = t.kind(); + kind != SyntaxKind::COMMENT && kind != SyntaxKind::WHITESPACE + } + NodeOrToken::Node(_) => true, + }) + .map(|e| e.text_range().start()) + .unwrap_or_else(|| node.text_range().start()); + folds.push(Fold { + range: TextRange::new(start, node.text_range().end()), + kind, + }); + } + } + } + } + + folds +} + +fn fold_kind(kind: SyntaxKind) -> Option { + if ast::Stmt::can_cast(kind) { + return Some(FoldKind::Statement); + } + + match kind { + SyntaxKind::ARG_LIST | SyntaxKind::TABLE_ARG_LIST | SyntaxKind::PARAM_LIST => { + Some(FoldKind::ArgList) + } + SyntaxKind::ARRAY_EXPR => Some(FoldKind::Array), + SyntaxKind::CALL_EXPR => Some(FoldKind::FunctionCall), + SyntaxKind::JOIN => Some(FoldKind::Join), + SyntaxKind::PAREN_SELECT => Some(FoldKind::Subquery), + SyntaxKind::TUPLE_EXPR => Some(FoldKind::Tuple), + SyntaxKind::WHEN_CLAUSE_LIST + | SyntaxKind::ALTER_OPTION_LIST + | SyntaxKind::ATTRIBUTE_LIST + | SyntaxKind::BEGIN_FUNC_OPTION_LIST + | SyntaxKind::COLUMN_LIST + | SyntaxKind::CONFLICT_INDEX_ITEM_LIST + | SyntaxKind::CONSTRAINT_EXCLUSION_LIST + | SyntaxKind::COPY_OPTION_LIST + | SyntaxKind::CREATE_DATABASE_OPTION_LIST + | SyntaxKind::DROP_OP_CLASS_OPTION_LIST + | SyntaxKind::FDW_OPTION_LIST + | SyntaxKind::FUNCTION_SIG_LIST + | SyntaxKind::FUNC_OPTION_LIST + | SyntaxKind::GROUP_BY_LIST + | SyntaxKind::JSON_TABLE_COLUMN_LIST + | SyntaxKind::OPERATOR_CLASS_OPTION_LIST + | SyntaxKind::OPTION_ITEM_LIST + | SyntaxKind::OP_SIG_LIST + | SyntaxKind::PARTITION_ITEM_LIST + | SyntaxKind::PARTITION_LIST + | SyntaxKind::RETURNING_OPTION_LIST + | SyntaxKind::REVOKE_COMMAND_LIST + | SyntaxKind::ROLE_OPTION_LIST + | SyntaxKind::ROLE_REF_LIST + | SyntaxKind::ROW_LIST + | SyntaxKind::SEQUENCE_OPTION_LIST + | SyntaxKind::SET_COLUMN_LIST + | SyntaxKind::SET_EXPR_LIST + | SyntaxKind::SET_OPTIONS_LIST + | SyntaxKind::SORT_BY_LIST + | SyntaxKind::TABLE_AND_COLUMNS_LIST + | SyntaxKind::TABLE_LIST + | SyntaxKind::TARGET_LIST + | SyntaxKind::TRANSACTION_MODE_LIST + | SyntaxKind::TRIGGER_EVENT_LIST + | SyntaxKind::VACUUM_OPTION_LIST + | SyntaxKind::VARIANT_LIST + | SyntaxKind::XML_ATTRIBUTE_LIST + | SyntaxKind::XML_COLUMN_OPTION_LIST + | SyntaxKind::XML_NAMESPACE_LIST + | SyntaxKind::XML_TABLE_COLUMN_LIST => Some(FoldKind::List), + _ => None, + } +} + +fn contiguous_range_for_comment( + first: ast::Comment, + visited: &mut HashSet, +) -> Option { + visited.insert(first.clone()); + + // Only fold comments of the same flavor + let group_kind = first.kind(); + if !group_kind.is_line() { + return None; + } + + let mut last = first.clone(); + for element in first.syntax().siblings_with_tokens(Direction::Next) { + match element { + NodeOrToken::Token(token) => { + if let Some(ws) = ast::Whitespace::cast(token.clone()) + && !ws.spans_multiple_lines() + { + // Ignore whitespace without blank lines + continue; + } + if let Some(c) = ast::Comment::cast(token) { + visited.insert(c.clone()); + last = c; + continue; + } + // The comment group ends because either: + // * An element of a different kind was reached + // * A comment of a different flavor was reached + break; + } + NodeOrToken::Node(_) => break, + } + } + + if first != last { + Some(TextRange::new( + first.syntax().text_range().start(), + last.syntax().text_range().end(), + )) + } else { + // The group consists of only one element, therefore it cannot be folded + None + } +} + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + + use crate::db::{Database, File}; + + use super::*; + + fn fold_kind_str(kind: &FoldKind) -> &'static str { + match kind { + FoldKind::ArgList => "arglist", + FoldKind::Array => "array", + FoldKind::Comment => "comment", + FoldKind::FunctionCall => "function_call", + FoldKind::Join => "join", + FoldKind::List => "list", + FoldKind::Statement => "statement", + FoldKind::Subquery => "subquery", + FoldKind::Tuple => "tuple", + } + } + + fn check(sql: &str) -> String { + let db = Database::default(); + let file = File::new(&db, sql.to_string(), 0); + let folds = folding_ranges(&db, file); + + if folds.is_empty() { + return sql.to_string(); + } + + #[derive(PartialEq, Eq, PartialOrd, Ord)] + struct Event<'a> { + offset: usize, + is_end: bool, + kind: &'a str, + } + + let mut events: Vec> = vec![]; + for fold in &folds { + let start: usize = fold.range.start().into(); + let end: usize = fold.range.end().into(); + let kind = fold_kind_str(&fold.kind); + events.push(Event { + offset: start, + is_end: false, + kind, + }); + events.push(Event { + offset: end, + is_end: true, + kind, + }); + } + events.sort(); + + let mut output = String::new(); + let mut pos = 0usize; + for event in &events { + if event.offset > pos { + output.push_str(&sql[pos..event.offset]); + pos = event.offset; + } + if event.is_end { + output.push_str(""); + } else { + output.push_str(&format!("", event.kind)); + } + } + if pos < sql.len() { + output.push_str(&sql[pos..]); + } + output + } + + #[test] + fn fold_create_table() { + assert_snapshot!(check(" +create table t ( + id int, + name text +);"), @" + create table t ( + id int, + name text + ); + "); + } + + #[test] + fn fold_select() { + assert_snapshot!(check(" +select + id, + name +from t;"), @" + select + id, + name + from t; + "); + } + + #[test] + fn do_not_fold_single_line_comment() { + assert_snapshot!(check(" +-- a comment +select 1;"), @" + -- a comment + select 1; + "); + } + + #[test] + fn fold_comments_does_not_apply_when_diff_comment_types() { + assert_snapshot!(check(" +/* first part */ +-- second part +select 1;"), @" + /* first part */ + -- second part + select 1; + "); + } + + #[test] + fn fold_comments_and_multi_statements() { + assert_snapshot!(check(" +-- this is + +-- a comment +-- with some more +select a, b, 3 + from t + where c > 10;"), @" + -- this is + + -- a comment + -- with some more + select a, b, 3 + from t + where c > 10; + "); + } + + #[test] + fn fold_comments_does_not_apply_when_whitespace_between() { + assert_snapshot!(check(" +-- this is + +-- a comment +-- with some more +select 1;"), @" + -- this is + + -- a comment + -- with some more + select 1; + "); + } + + #[test] + fn fold_multiline_comments() { + assert_snapshot!(check(" +-- this is +-- a comment +select 1;"), @" + -- this is + -- a comment + select 1; + "); + } + + #[test] + fn fold_single_line_no_fold() { + assert_snapshot!(check("select 1;"), @"select 1;"); + } + + #[test] + fn fold_subquery() { + assert_snapshot!(check(" +select * from ( + select id from t +);"), @" + select * from ( + select id from t + ); + "); + } + + #[test] + fn fold_case_when() { + assert_snapshot!(check(" +select + case + when x = 1 then 'a' + when x = 2 then 'b' + end +from t;"), @" + select + case + when x = 1 then 'a' + when x = 2 then 'b' + end + from t; + "); + } + + #[test] + fn fold_join() { + assert_snapshot!(check(" +select * +from a +join b + on a.id = b.id;"), @" + select * + from a + join b + on a.id = b.id; + "); + } + + #[test] + fn fold_array_literal() { + assert_snapshot!(check(" +select * from t where + x = any(array[ + 1, + 2, + 3 + ]);"), @" + select * from t where + x = any(array[ + 1, + 2, + 3 + ]); + "); + } + + #[test] + fn fold_tuple_literal() { + assert_snapshot!(check(" +select ( + 1, + 2, + 3 +);"), @" + select ( + 1, + 2, + 3 + ); + "); + } + + #[test] + fn fold_tuple_bin_expr() { + assert_snapshot!(check(" +select * from x + where z in ( + 1, + 2, + 3, + 4, + 5 + ); +"), @" + select * from x + where z in ( + 1, + 2, + 3, + 4, + 5 + ); + "); + } + + #[test] + fn fold_function_call() { + assert_snapshot!(check(" +select coalesce( + a, + b, + c +);"), @" + select coalesce( + a, + b, + c + ); + "); + } + + #[test] + fn fold_create_enum() { + assert_snapshot!(check(" +create type status as enum ( + 'active', + 'inactive' +);"), @" + create type status as enum ( + 'active', + 'inactive' + ); + "); + } + + #[test] + fn fold_insert_values() { + assert_snapshot!(check(" +insert into t (id, name) +values + (1, 'a'), + (2, 'b');"), @" + insert into t (id, name) + values + (1, 'a'), + (2, 'b'); + "); + } + + #[test] + fn no_fold_single_line_create_table() { + assert_snapshot!(check("create table t (id int);"), @"create table t (id int);"); + } + + #[test] + fn list_variants() { + let unhandled_list_kinds: Vec = (0..SyntaxKind::__LAST as u16) + .map(SyntaxKind::from) + .filter(|kind| format!("{:?}", kind).ends_with("_LIST")) + .filter(|kind| fold_kind(*kind).is_none()) + .collect(); + + assert_eq!( + unhandled_list_kinds, + vec![], + "All _LIST SyntaxKind variants should be handled in fold_kind" + ); + } +} diff --git a/crates/squawk_ide/src/lib.rs b/crates/squawk_ide/src/lib.rs index 27216c33..2c2302ad 100644 --- a/crates/squawk_ide/src/lib.rs +++ b/crates/squawk_ide/src/lib.rs @@ -8,6 +8,7 @@ pub mod db; pub mod document_symbols; pub mod expand_selection; pub mod find_references; +pub mod folding_ranges; mod generated; pub mod goto_definition; pub mod hover; diff --git a/crates/squawk_server/src/lib.rs b/crates/squawk_server/src/lib.rs index f9673a3f..8fa1eec5 100644 --- a/crates/squawk_server/src/lib.rs +++ b/crates/squawk_server/src/lib.rs @@ -6,20 +6,20 @@ use lsp_types::{ CodeAction, CodeActionKind, CodeActionOptions, CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, Command, CompletionOptions, CompletionParams, CompletionResponse, Diagnostic, DidChangeTextDocumentParams, DidCloseTextDocumentParams, - DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams, GotoDefinitionParams, - GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability, - InitializeParams, InlayHint, InlayHintKind, InlayHintLabel, InlayHintLabelPart, - InlayHintParams, LanguageString, Location, MarkedString, OneOf, PublishDiagnosticsParams, - ReferenceParams, SelectionRangeParams, SelectionRangeProviderCapability, ServerCapabilities, - SymbolKind, TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgressOptions, - WorkspaceEdit, + DidOpenTextDocumentParams, DocumentSymbol, DocumentSymbolParams, FoldingRange, + FoldingRangeProviderCapability, GotoDefinitionParams, GotoDefinitionResponse, Hover, + HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InlayHint, + InlayHintKind, InlayHintLabel, InlayHintLabelPart, InlayHintParams, LanguageString, Location, + MarkedString, OneOf, PublishDiagnosticsParams, ReferenceParams, SelectionRangeParams, + SelectionRangeProviderCapability, ServerCapabilities, SymbolKind, TextDocumentSyncCapability, + TextDocumentSyncKind, Url, WorkDoneProgressOptions, WorkspaceEdit, notification::{ DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, Notification as _, PublishDiagnostics, }, request::{ - CodeActionRequest, Completion, DocumentSymbolRequest, GotoDefinition, HoverRequest, - InlayHintRequest, References, Request, SelectionRangeRequest, + CodeActionRequest, Completion, DocumentSymbolRequest, FoldingRangeRequest, GotoDefinition, + HoverRequest, InlayHintRequest, References, Request, SelectionRangeRequest, }, }; use rowan::TextRange; @@ -30,6 +30,7 @@ use squawk_ide::completion::completion; use squawk_ide::db::{Database, File, line_index, parse}; use squawk_ide::document_symbols::{DocumentSymbolKind, document_symbols}; use squawk_ide::find_references::find_references; +use squawk_ide::folding_ranges::folding_ranges; use squawk_ide::goto_definition::goto_definition; use squawk_ide::hover::hover; use squawk_ide::inlay_hints::inlay_hints; @@ -119,6 +120,7 @@ pub fn run() -> Result<()> { hover_provider: Some(HoverProviderCapability::Simple(true)), inlay_hint_provider: Some(OneOf::Left(true)), document_symbol_provider: Some(OneOf::Left(true)), + folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)), completion_provider: Some(CompletionOptions { resolve_provider: Some(false), trigger_characters: Some(vec![".".to_owned()]), @@ -183,6 +185,9 @@ fn main_loop(connection: Connection, params: serde_json::Value) -> Result<()> { DocumentSymbolRequest::METHOD => { handle_document_symbol(&connection, req, &file_system)?; } + FoldingRangeRequest::METHOD => { + handle_folding_range(&connection, req, &file_system)?; + } Completion::METHOD => { handle_completion(&connection, req, &file_system)?; } @@ -450,6 +455,33 @@ fn handle_document_symbol( Ok(()) } +fn handle_folding_range( + connection: &Connection, + req: lsp_server::Request, + file_system: &impl FileSystem, +) -> Result<()> { + let params: lsp_types::FoldingRangeParams = serde_json::from_value(req.params)?; + let uri = params.text_document.uri; + + let db = file_system.db(); + let file = file_system.file(&uri).unwrap(); + let line_idx = line_index(db, file); + + let lsp_folds: Vec = folding_ranges(db, file) + .into_iter() + .map(|fold| lsp_utils::folding_range(&line_idx, fold)) + .collect(); + + let resp = Response { + id: req.id, + result: Some(serde_json::to_value(&lsp_folds).unwrap()), + error: None, + }; + + connection.sender.send(Message::Response(resp))?; + Ok(()) +} + fn handle_selection_range( 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 d91007ac..7198bda7 100644 --- a/crates/squawk_server/src/lsp_utils.rs +++ b/crates/squawk_server/src/lsp_utils.rs @@ -2,8 +2,12 @@ use std::{collections::HashMap, ops::Range}; use line_index::{LineIndex, TextRange, TextSize}; use log::warn; -use lsp_types::{CodeAction, CodeActionKind, Url, WorkspaceEdit}; +use lsp_types::{ + CodeAction, CodeActionKind, FoldingRange, FoldingRangeKind as LspFoldingRangeKind, Url, + WorkspaceEdit, +}; use squawk_ide::code_actions::ActionKind; +use squawk_ide::folding_ranges::{Fold, FoldKind}; fn text_range(index: &LineIndex, range: lsp_types::Range) -> Option { let start = offset(index, range.start)?; @@ -143,6 +147,23 @@ pub(crate) fn range(line_index: &LineIndex, range: TextRange) -> lsp_types::Rang ) } +pub(crate) fn folding_range(line_index: &LineIndex, fold: Fold) -> FoldingRange { + let start = line_index.line_col(fold.range.start()); + let end = line_index.line_col(fold.range.end()); + let kind = match fold.kind { + FoldKind::Comment => Some(LspFoldingRangeKind::Comment), + _ => Some(LspFoldingRangeKind::Region), + }; + FoldingRange { + start_line: start.line, + start_character: Some(start.col), + end_line: end.line, + end_character: Some(end.col), + kind, + collapsed_text: None, + } +} + // 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.rs b/crates/squawk_syntax/src/ast.rs index 53dd25bf..6c35ae89 100644 --- a/crates/squawk_syntax/src/ast.rs +++ b/crates/squawk_syntax/src/ast.rs @@ -28,6 +28,7 @@ mod generated; mod node_ext; mod nodes; mod support; +mod token_ext; mod traits; use std::marker::PhantomData; diff --git a/crates/squawk_syntax/src/ast/generated/tokens.rs b/crates/squawk_syntax/src/ast/generated/tokens.rs index 017f34bb..259a168f 100644 --- a/crates/squawk_syntax/src/ast/generated/tokens.rs +++ b/crates/squawk_syntax/src/ast/generated/tokens.rs @@ -77,3 +77,28 @@ impl AstToken for String { &self.syntax } } + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Whitespace { + pub(crate) syntax: SyntaxToken, +} +impl std::fmt::Display for Whitespace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.syntax, f) + } +} +impl AstToken for Whitespace { + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::WHITESPACE + } + fn cast(syntax: SyntaxToken) -> Option { + if Self::can_cast(syntax.kind()) { + Some(Self { syntax }) + } else { + None + } + } + fn syntax(&self) -> &SyntaxToken { + &self.syntax + } +} diff --git a/crates/squawk_syntax/src/ast/token_ext.rs b/crates/squawk_syntax/src/ast/token_ext.rs new file mode 100644 index 00000000..b1fd5165 --- /dev/null +++ b/crates/squawk_syntax/src/ast/token_ext.rs @@ -0,0 +1,44 @@ +use crate::ast::{self, AstToken}; + +impl ast::Whitespace { + pub fn spans_multiple_lines(&self) -> bool { + let text = self.text(); + text.find('\n') + .is_some_and(|idx| text[idx + 1..].contains('\n')) + } +} + +impl ast::Comment { + pub fn kind(&self) -> CommentKind { + CommentKind::from_text(self.text()) + } +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum CommentKind { + Line, + Block, +} + +impl CommentKind { + const BY_PREFIX: [(&'static str, CommentKind); 3] = [ + ("/**/", CommentKind::Block), + ("/*", CommentKind::Block), + ("--", CommentKind::Line), + ]; + pub(crate) fn from_text(text: &str) -> CommentKind { + let &(_prefix, kind) = CommentKind::BY_PREFIX + .iter() + .find(|&(prefix, _kind)| text.starts_with(prefix)) + .unwrap(); + kind + } + + pub fn is_line(self) -> bool { + self == CommentKind::Line + } + + pub fn is_block(self) -> bool { + self == CommentKind::Block + } +} diff --git a/crates/xtask/src/codegen.rs b/crates/xtask/src/codegen.rs index 7e84ff83..b951649c 100644 --- a/crates/xtask/src/codegen.rs +++ b/crates/xtask/src/codegen.rs @@ -533,6 +533,7 @@ fn lower(grammar: &Grammar) -> AstSrc { ("Null", "NULL_KW"), ("String", "STRING"), ("Comment", "COMMENT"), + ("Whitespace", "WHITESPACE"), ]; let mut res = AstSrc { tokens,