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,