1use rustc_hash::FxHashSet;
31
32use rowan::{NodeOrToken, SyntaxText, TextRange};
33use salsa::Database as Db;
34use squawk_line_index::find_newline;
35use squawk_syntax::SyntaxKind;
36use squawk_syntax::ast::{self, AstNode, AstToken};
37
38use crate::comments::line_comment_group;
39use crate::db::{File, parse};
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum FoldKind {
43 ArgList,
44 Array,
45 Comment,
46 FunctionCall,
47 Join,
48 List,
49 Statement,
50 Subquery,
51 Tuple,
52 WhereClause,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Fold {
57 pub range: TextRange,
58 pub kind: FoldKind,
59}
60
61#[salsa::tracked]
62pub fn folding_ranges(db: &dyn Db, file: File) -> Vec<Fold> {
63 let parse = parse(db, file);
64
65 let mut folds = vec![];
66 let mut visited_comments = FxHashSet::default();
67
68 for element in parse.tree().syntax().descendants_with_tokens() {
69 match &element {
70 NodeOrToken::Token(token) => {
71 if let Some(comment) = ast::Comment::cast(token.clone())
72 && !visited_comments.contains(&comment)
73 && let Some(range) =
74 contiguous_range_for_comment(comment, &mut visited_comments)
75 {
76 folds.push(Fold {
77 range,
78 kind: FoldKind::Comment,
79 });
80 }
81 }
82 NodeOrToken::Node(node) => {
83 if let Some(kind) = fold_kind(node.kind()) {
84 if !contains_newline(&node.text()) {
85 continue;
86 }
87 let start = node
89 .children_with_tokens()
90 .find(|e| match e {
91 NodeOrToken::Token(t) => {
92 let kind = t.kind();
93 kind != SyntaxKind::COMMENT && kind != SyntaxKind::WHITESPACE
94 }
95 NodeOrToken::Node(_) => true,
96 })
97 .map(|e| e.text_range().start())
98 .unwrap_or_else(|| node.text_range().start());
99 folds.push(Fold {
100 range: TextRange::new(start, node.text_range().end()),
101 kind,
102 });
103 }
104 }
105 }
106 }
107
108 folds
109}
110
111fn contains_newline(text: &SyntaxText) -> bool {
112 text.try_for_each_chunk(|chunk| {
113 if find_newline(chunk).is_some() {
114 Err(())
115 } else {
116 Ok(())
117 }
118 })
119 .is_err()
120}
121
122fn fold_kind(kind: SyntaxKind) -> Option<FoldKind> {
123 if ast::Stmt::can_cast(kind) {
124 return Some(FoldKind::Statement);
125 }
126
127 match kind {
128 SyntaxKind::ARG_LIST | SyntaxKind::TABLE_ARG_LIST | SyntaxKind::PARAM_LIST => {
129 Some(FoldKind::ArgList)
130 }
131 SyntaxKind::ARRAY_EXPR => Some(FoldKind::Array),
132 SyntaxKind::CALL_EXPR => Some(FoldKind::FunctionCall),
133 SyntaxKind::JOIN => Some(FoldKind::Join),
134 SyntaxKind::PAREN_SELECT => Some(FoldKind::Subquery),
135 SyntaxKind::TUPLE_EXPR => Some(FoldKind::Tuple),
136 SyntaxKind::WHERE_CLAUSE => Some(FoldKind::WhereClause),
137 SyntaxKind::WHEN_CLAUSE_LIST
138 | SyntaxKind::ALTER_OPTION_LIST
139 | SyntaxKind::ALTER_TYPE_ATTRIBUTE_ACTION_LIST
140 | SyntaxKind::ATTRIBUTE_LIST
141 | SyntaxKind::BEGIN_FUNC_OPTION_LIST
142 | SyntaxKind::CHECKPOINT_OPTION_LIST
143 | SyntaxKind::COLUMN_DEF_LIST
144 | SyntaxKind::COLUMN_LIST
145 | SyntaxKind::COLUMN_REF_LIST
146 | SyntaxKind::COLUMN_TARGET_LIST
147 | SyntaxKind::COMPOSITE_FIELD_LIST
148 | SyntaxKind::CONFLICT_INDEX_ITEM_LIST
149 | SyntaxKind::CONSTRAINT_COLUMN_REF_LIST
150 | SyntaxKind::CONSTRAINT_EXCLUSION_LIST
151 | SyntaxKind::COPY_OPTION_LIST
152 | SyntaxKind::DATABASE_OPTION_LIST
153 | SyntaxKind::EXPLAIN_OPTION_LIST
154 | SyntaxKind::DROP_OP_CLASS_OPTION_LIST
155 | SyntaxKind::FDW_OPTION_LIST
156 | SyntaxKind::FOREIGN_KEY_COLUMN_LIST
157 | SyntaxKind::FUNCTION_SIG_LIST
158 | SyntaxKind::PROCEDURE_SIG_LIST
159 | SyntaxKind::ROUTINE_SIG_LIST
160 | SyntaxKind::FUNC_OPTION_LIST
161 | SyntaxKind::GRANT_ROLE_OPTION_LIST
162 | SyntaxKind::GROUP_BY_LIST
163 | SyntaxKind::JSON_TABLE_COLUMN_LIST
164 | SyntaxKind::OPERATOR_CLASS_OPTION_LIST
165 | SyntaxKind::OPTION_ALTER_OPTION_LIST
166 | SyntaxKind::OPTION_ITEM_LIST
167 | SyntaxKind::OP_SIG_LIST
168 | SyntaxKind::PARTITION_ITEM_LIST
169 | SyntaxKind::PARTITION_LIST
170 | SyntaxKind::TABLE_NAME_REF_LIST
171 | SyntaxKind::REINDEX_OPTION_LIST
172 | SyntaxKind::RELATION_LIST
173 | SyntaxKind::RETURNING_OPTION_LIST
174 | SyntaxKind::REVOKE_COMMAND_LIST
175 | SyntaxKind::ROLE_OPTION_LIST
176 | SyntaxKind::ROLE_REF_LIST
177 | SyntaxKind::ROW_LIST
178 | SyntaxKind::RULE_STMT_LIST
179 | SyntaxKind::SEQUENCE_OPTION_LIST
180 | SyntaxKind::SET_COLUMN_LIST
181 | SyntaxKind::SET_EXPR_LIST
182 | SyntaxKind::SET_OPTIONS_LIST
183 | SyntaxKind::SET_ALL_PUBLICATION_OBJECT_LIST
184 | SyntaxKind::SORT_BY_LIST
185 | SyntaxKind::TABLE_AND_COLUMNS_LIST
186 | SyntaxKind::TABLE_LIST
187 | SyntaxKind::TARGET_LIST
188 | SyntaxKind::TRANSACTION_MODE_LIST
189 | SyntaxKind::TRIGGER_EVENT_LIST
190 | SyntaxKind::VACUUM_OPTION_LIST
191 | SyntaxKind::VARIANT_LIST
192 | SyntaxKind::EXPR_AS_COLUMN_NAME_LIST
193 | SyntaxKind::EXPR_AS_ELEMENT_TAG_LIST
194 | SyntaxKind::EXPR_AS_PROPERTY_NAME_LIST
195 | SyntaxKind::EXPR_AS_XML_ATTR_LIST
196 | SyntaxKind::XML_COLUMN_OPTION_LIST
197 | SyntaxKind::XML_NAMESPACE_LIST
198 | SyntaxKind::XML_TABLE_COLUMN_LIST
199 | SyntaxKind::LABEL_AND_PROPERTIES_LIST
200 | SyntaxKind::PATH_PATTERN_LIST => Some(FoldKind::List),
201 _ => None,
202 }
203}
204
205fn contiguous_range_for_comment(
206 comment: ast::Comment,
207 visited: &mut FxHashSet<ast::Comment>,
208) -> Option<TextRange> {
209 visited.insert(comment.clone());
210
211 if !comment.kind().is_line() {
213 return None;
214 }
215
216 let group = line_comment_group(&comment);
217 visited.extend(group.iter().cloned());
218
219 let [first, .., last] = group.as_slice() else {
221 return None;
222 };
223
224 Some(TextRange::new(
225 first.syntax().text_range().start(),
226 last.syntax().text_range().end(),
227 ))
228}
229
230#[cfg(test)]
231mod tests {
232 use insta::assert_snapshot;
233
234 use crate::db::{Database, File};
235
236 use super::*;
237
238 fn fold_kind_str(kind: &FoldKind) -> &'static str {
239 match kind {
240 FoldKind::ArgList => "arglist",
241 FoldKind::Array => "array",
242 FoldKind::Comment => "comment",
243 FoldKind::FunctionCall => "function_call",
244 FoldKind::Join => "join",
245 FoldKind::List => "list",
246 FoldKind::Statement => "statement",
247 FoldKind::Subquery => "subquery",
248 FoldKind::Tuple => "tuple",
249 FoldKind::WhereClause => "where_clause",
250 }
251 }
252
253 #[must_use]
254 fn check(sql: &str) -> String {
255 let db = Database::default();
256 let file = File::new(&db, sql.to_string().into());
257 let folds = folding_ranges(&db, file);
258
259 if folds.is_empty() {
260 return sql.to_string();
261 }
262
263 #[derive(PartialEq, Eq, PartialOrd, Ord)]
264 struct Event<'a> {
265 offset: usize,
266 is_end: bool,
267 kind: &'a str,
268 }
269
270 let mut events: Vec<Event<'_>> = vec![];
271 for fold in &folds {
272 let start: usize = fold.range.start().into();
273 let end: usize = fold.range.end().into();
274 let kind = fold_kind_str(&fold.kind);
275 events.push(Event {
276 offset: start,
277 is_end: false,
278 kind,
279 });
280 events.push(Event {
281 offset: end,
282 is_end: true,
283 kind,
284 });
285 }
286 events.sort();
287
288 let mut output = String::new();
289 let mut pos = 0usize;
290 for event in &events {
291 if event.offset > pos {
292 output.push_str(&sql[pos..event.offset]);
293 pos = event.offset;
294 }
295 if event.is_end {
296 output.push_str("</fold>");
297 } else {
298 output.push_str(&format!("<fold {}>", event.kind));
299 }
300 }
301 if pos < sql.len() {
302 output.push_str(&sql[pos..]);
303 }
304 output
305 }
306
307 #[test]
308 fn fold_create_table() {
309 assert_snapshot!(check("
310create table t (
311 id int,
312 name text
313);"), @"
314 <fold statement>create table t <fold arglist>(
315 id int,
316 name text
317 )</fold>;</fold>
318 ");
319 }
320
321 #[test]
322 fn fold_select() {
323 assert_snapshot!(check("
324select
325 id,
326 name
327from t;"), @"
328 <fold statement>select
329 <fold list>id,
330 name</fold>
331 from t;</fold>
332 ");
333 }
334
335 #[test]
336 fn do_not_fold_single_line_comment() {
337 assert_snapshot!(check("
338-- a comment
339select 1;"), @"
340 -- a comment
341 select 1;
342 ");
343 }
344
345 #[test]
346 fn fold_comments_does_not_apply_when_diff_comment_types() {
347 assert_snapshot!(check("
348/* first part */
349-- second part
350select 1;"), @"
351 /* first part */
352 -- second part
353 select 1;
354 ");
355 }
356
357 #[test]
358 fn fold_comments_does_not_apply_when_block_comment_follows() {
359 assert_snapshot!(check("
360-- first part
361/* second part */
362select 1;"), @"
363 -- first part
364 /* second part */
365 select 1;
366 ");
367 }
368
369 #[test]
370 fn fold_comments_groups_across_statements() {
371 assert_snapshot!(check("
372select 1; -- a
373-- b
374select 2;"), @"
375 select 1; <fold comment>-- a
376 -- b</fold>
377 select 2;
378 ");
379 }
380
381 #[test]
382 fn fold_comments_groups_nested_in_a_list() {
383 assert_snapshot!(check("
384select 1, -- a
385 -- b
386 2;"), @"
387 <fold statement>select <fold list>1, <fold comment>-- a
388 -- b</fold>
389 2</fold>;</fold>
390 ");
391 }
392
393 #[test]
394 fn fold_comments_with_cr_line_endings() {
395 assert_snapshot!(check(&"
396-- this is
397-- a comment
398
399-- separate
400select 1;".replace('\n', "\r")).replace('\r', "\n"), @"
401 <fold comment>-- this is
402 -- a comment</fold>
403
404 -- separate
405 select 1;
406 ");
407 }
408
409 #[test]
410 fn fold_comments_and_multi_statements() {
411 assert_snapshot!(check("
412-- this is
413
414-- a comment
415-- with some more
416select a, b, 3
417 from t
418 where c > 10;"), @"
419 -- this is
420
421 <fold comment>-- a comment
422 -- with some more</fold>
423 <fold statement>select a, b, 3
424 from t
425 where c > 10;</fold>
426 ");
427 }
428
429 #[test]
430 fn fold_comments_does_not_apply_when_whitespace_between() {
431 assert_snapshot!(check("
432-- this is
433
434-- a comment
435-- with some more
436select 1;"), @"
437 -- this is
438
439 <fold comment>-- a comment
440 -- with some more</fold>
441 select 1;
442 ");
443 }
444
445 #[test]
446 fn fold_multiline_comments() {
447 assert_snapshot!(check("
448-- this is
449-- a comment
450select 1;"), @"
451 <fold comment>-- this is
452 -- a comment</fold>
453 select 1;
454 ");
455 }
456
457 #[test]
458 fn fold_multiline_comments_with_windows_line_endings() {
459 assert_snapshot!(
460 format!(
461 "{:?}",
462 check("-- this is\r\n-- a comment\r\nselect 1;")
463 ),
464 @r#""<fold comment>-- this is\r\n-- a comment</fold>\r\nselect 1;""#
465 );
466 }
467
468 #[test]
469 fn fold_multiline_comments_with_cr_line_endings() {
470 assert_snapshot!(
471 format!(
472 "{:?}",
473 check("-- this is\r-- a comment\rselect 1;")
474 ),
475 @r#""<fold comment>-- this is\r-- a comment</fold>\rselect 1;""#
476 );
477 }
478
479 #[test]
480 fn fold_comments_with_cr_line_endings_does_not_apply_when_whitespace_between() {
481 assert_snapshot!(
482 format!(
483 "{:?}",
484 check("-- this is\r\r-- a comment\r-- with some more\rselect 1;")
485 ),
486 @r#""-- this is\r\r<fold comment>-- a comment\r-- with some more</fold>\rselect 1;""#
487 );
488 }
489
490 #[test]
491 fn fold_statement_with_cr_line_endings() {
492 assert_snapshot!(
493 format!("{:?}", check("select\r id,\r name\rfrom t;")),
494 @r#""<fold statement>select\r <fold list>id,\r name</fold>\rfrom t;</fold>""#
495 );
496 }
497
498 #[test]
499 fn fold_single_line_no_fold() {
500 assert_snapshot!(check("select 1;"), @"select 1;");
501 }
502
503 #[test]
504 fn fold_subquery() {
505 assert_snapshot!(check("
506select * from (
507 select id from t
508);"), @"
509 <fold statement>select * from <fold statement>(
510 select id from t
511 )</fold>;</fold>
512 ");
513 }
514
515 #[test]
516 fn fold_case_when() {
517 assert_snapshot!(check("
518select
519 case
520 when x = 1 then 'a'
521 when x = 2 then 'b'
522 end
523from t;"), @"
524 <fold statement>select
525 <fold list>case
526 <fold list>when x = 1 then 'a'
527 when x = 2 then 'b'</fold>
528 end</fold>
529 from t;</fold>
530 ");
531 }
532
533 #[test]
534 fn fold_join() {
535 assert_snapshot!(check("
536select *
537from a
538join b
539 on a.id = b.id;"), @"
540 <fold statement>select *
541 from a
542 <fold join>join b
543 on a.id = b.id</fold>;</fold>
544 ");
545 }
546
547 #[test]
548 fn fold_where_clause() {
549 assert_snapshot!(check("
550select *
551from t
552where
553 a = 1
554 and b = 2;"), @"
555 <fold statement>select *
556 from t
557 <fold where_clause>where
558 a = 1
559 and b = 2</fold>;</fold>
560 ");
561 }
562
563 #[test]
564 fn fold_array_literal() {
565 assert_snapshot!(check("
566select * from t where
567 x = any(array[
568 1,
569 2,
570 3
571 ]);"), @"
572 <fold statement>select * from t <fold where_clause>where
573 x = <fold function_call>any(<fold array>array[
574 1,
575 2,
576 3
577 ]</fold>)</fold></fold>;</fold>
578 ");
579 }
580
581 #[test]
582 fn fold_tuple_literal() {
583 assert_snapshot!(check("
584select (
585 1,
586 2,
587 3
588);"), @"
589 <fold statement>select <fold list><fold tuple>(
590 1,
591 2,
592 3
593 )</fold></fold>;</fold>
594 ");
595 }
596
597 #[test]
598 fn fold_tuple_bin_expr() {
599 assert_snapshot!(check("
600select * from x
601 where z in (
602 1,
603 2,
604 3,
605 4,
606 5
607 );
608"), @"
609 <fold statement>select * from x
610 <fold where_clause>where z in <fold tuple>(
611 1,
612 2,
613 3,
614 4,
615 5
616 )</fold></fold>;</fold>
617 ");
618 }
619
620 #[test]
621 fn fold_function_call() {
622 assert_snapshot!(check("
623select coalesce(
624 a,
625 b,
626 c
627);"), @"
628 <fold statement>select <fold function_call><fold list>coalesce<fold arglist>(
629 a,
630 b,
631 c
632 )</fold></fold></fold>;</fold>
633 ");
634 }
635
636 #[test]
637 fn fold_create_enum() {
638 assert_snapshot!(check("
639create type status as enum (
640 'active',
641 'inactive'
642);"), @"
643 <fold statement>create type status as enum <fold list>(
644 'active',
645 'inactive'
646 )</fold>;</fold>
647 ");
648 }
649
650 #[test]
651 fn fold_insert_values() {
652 assert_snapshot!(check("
653insert into t (id, name)
654values
655 (1, 'a'),
656 (2, 'b');"), @"
657 <fold statement>insert into t (id, name)
658 <fold statement>values
659 <fold list>(1, 'a'),
660 (2, 'b')</fold></fold>;</fold>
661 ");
662 }
663
664 #[test]
665 fn no_fold_single_line_create_table() {
666 assert_snapshot!(check("create table t (id int);"), @"create table t (id int);");
667 }
668
669 #[test]
670 fn list_variants() {
671 let unhandled_list_kinds: Vec<SyntaxKind> = (0..SyntaxKind::__LAST as u16)
672 .map(SyntaxKind::from)
673 .filter(|kind| format!("{kind:?}").ends_with("_LIST"))
674 .filter(|kind| fold_kind(*kind).is_none())
675 .collect();
676
677 assert_eq!(
678 unhandled_list_kinds,
679 vec![],
680 "All _LIST SyntaxKind variants should be handled in fold_kind"
681 );
682 }
683}