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::ALIAS_COLUMN_LIST
139 | SyntaxKind::ALTER_OPTION_LIST
140 | SyntaxKind::ALTER_TYPE_ATTRIBUTE_ACTION_LIST
141 | SyntaxKind::ATTRIBUTE_LIST
142 | SyntaxKind::BEGIN_FUNC_OPTION_LIST
143 | SyntaxKind::CHECKPOINT_OPTION_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::SORT_BY_LIST
184 | SyntaxKind::TABLE_AND_COLUMNS_LIST
185 | SyntaxKind::TABLE_LIST
186 | SyntaxKind::TARGET_LIST
187 | SyntaxKind::TRANSACTION_MODE_LIST
188 | SyntaxKind::TRIGGER_EVENT_LIST
189 | SyntaxKind::VACUUM_OPTION_LIST
190 | SyntaxKind::VARIANT_LIST
191 | SyntaxKind::EXPR_AS_COLUMN_NAME_LIST
192 | SyntaxKind::EXPR_AS_ELEMENT_TAG_LIST
193 | SyntaxKind::EXPR_AS_PROPERTY_NAME_LIST
194 | SyntaxKind::EXPR_AS_XML_ATTR_LIST
195 | SyntaxKind::XML_COLUMN_OPTION_LIST
196 | SyntaxKind::XML_NAMESPACE_LIST
197 | SyntaxKind::XML_TABLE_COLUMN_LIST
198 | SyntaxKind::LABEL_AND_PROPERTIES_LIST
199 | SyntaxKind::PATH_PATTERN_LIST => Some(FoldKind::List),
200 _ => None,
201 }
202}
203
204fn contiguous_range_for_comment(
205 comment: ast::Comment,
206 visited: &mut FxHashSet<ast::Comment>,
207) -> Option<TextRange> {
208 visited.insert(comment.clone());
209
210 if !comment.kind().is_line() {
212 return None;
213 }
214
215 let group = line_comment_group(&comment);
216 visited.extend(group.iter().cloned());
217
218 let [first, .., last] = group.as_slice() else {
220 return None;
221 };
222
223 Some(TextRange::new(
224 first.syntax().text_range().start(),
225 last.syntax().text_range().end(),
226 ))
227}
228
229#[cfg(test)]
230mod tests {
231 use insta::assert_snapshot;
232
233 use crate::db::{Database, File};
234
235 use super::*;
236
237 fn fold_kind_str(kind: &FoldKind) -> &'static str {
238 match kind {
239 FoldKind::ArgList => "arglist",
240 FoldKind::Array => "array",
241 FoldKind::Comment => "comment",
242 FoldKind::FunctionCall => "function_call",
243 FoldKind::Join => "join",
244 FoldKind::List => "list",
245 FoldKind::Statement => "statement",
246 FoldKind::Subquery => "subquery",
247 FoldKind::Tuple => "tuple",
248 FoldKind::WhereClause => "where_clause",
249 }
250 }
251
252 #[must_use]
253 fn check(sql: &str) -> String {
254 let db = Database::default();
255 let file = File::new(&db, sql.to_string().into());
256 let folds = folding_ranges(&db, file);
257
258 if folds.is_empty() {
259 return sql.to_string();
260 }
261
262 #[derive(PartialEq, Eq, PartialOrd, Ord)]
263 struct Event<'a> {
264 offset: usize,
265 is_end: bool,
266 kind: &'a str,
267 }
268
269 let mut events: Vec<Event<'_>> = vec![];
270 for fold in &folds {
271 let start: usize = fold.range.start().into();
272 let end: usize = fold.range.end().into();
273 let kind = fold_kind_str(&fold.kind);
274 events.push(Event {
275 offset: start,
276 is_end: false,
277 kind,
278 });
279 events.push(Event {
280 offset: end,
281 is_end: true,
282 kind,
283 });
284 }
285 events.sort();
286
287 let mut output = String::new();
288 let mut pos = 0usize;
289 for event in &events {
290 if event.offset > pos {
291 output.push_str(&sql[pos..event.offset]);
292 pos = event.offset;
293 }
294 if event.is_end {
295 output.push_str("</fold>");
296 } else {
297 output.push_str(&format!("<fold {}>", event.kind));
298 }
299 }
300 if pos < sql.len() {
301 output.push_str(&sql[pos..]);
302 }
303 output
304 }
305
306 #[test]
307 fn fold_create_table() {
308 assert_snapshot!(check("
309create table t (
310 id int,
311 name text
312);"), @"
313 <fold statement>create table t <fold arglist>(
314 id int,
315 name text
316 )</fold>;</fold>
317 ");
318 }
319
320 #[test]
321 fn fold_select() {
322 assert_snapshot!(check("
323select
324 id,
325 name
326from t;"), @"
327 <fold statement>select
328 <fold list>id,
329 name</fold>
330 from t;</fold>
331 ");
332 }
333
334 #[test]
335 fn do_not_fold_single_line_comment() {
336 assert_snapshot!(check("
337-- a comment
338select 1;"), @"
339 -- a comment
340 select 1;
341 ");
342 }
343
344 #[test]
345 fn fold_comments_does_not_apply_when_diff_comment_types() {
346 assert_snapshot!(check("
347/* first part */
348-- second part
349select 1;"), @"
350 /* first part */
351 -- second part
352 select 1;
353 ");
354 }
355
356 #[test]
357 fn fold_comments_does_not_apply_when_block_comment_follows() {
358 assert_snapshot!(check("
359-- first part
360/* second part */
361select 1;"), @"
362 -- first part
363 /* second part */
364 select 1;
365 ");
366 }
367
368 #[test]
369 fn fold_comments_groups_across_statements() {
370 assert_snapshot!(check("
371select 1; -- a
372-- b
373select 2;"), @"
374 select 1; <fold comment>-- a
375 -- b</fold>
376 select 2;
377 ");
378 }
379
380 #[test]
381 fn fold_comments_groups_nested_in_a_list() {
382 assert_snapshot!(check("
383select 1, -- a
384 -- b
385 2;"), @"
386 <fold statement>select <fold list>1, <fold comment>-- a
387 -- b</fold>
388 2</fold>;</fold>
389 ");
390 }
391
392 #[test]
393 fn fold_comments_with_cr_line_endings() {
394 assert_snapshot!(check(&"
395-- this is
396-- a comment
397
398-- separate
399select 1;".replace('\n', "\r")).replace('\r', "\n"), @"
400 <fold comment>-- this is
401 -- a comment</fold>
402
403 -- separate
404 select 1;
405 ");
406 }
407
408 #[test]
409 fn fold_comments_and_multi_statements() {
410 assert_snapshot!(check("
411-- this is
412
413-- a comment
414-- with some more
415select a, b, 3
416 from t
417 where c > 10;"), @"
418 -- this is
419
420 <fold comment>-- a comment
421 -- with some more</fold>
422 <fold statement>select a, b, 3
423 from t
424 where c > 10;</fold>
425 ");
426 }
427
428 #[test]
429 fn fold_comments_does_not_apply_when_whitespace_between() {
430 assert_snapshot!(check("
431-- this is
432
433-- a comment
434-- with some more
435select 1;"), @"
436 -- this is
437
438 <fold comment>-- a comment
439 -- with some more</fold>
440 select 1;
441 ");
442 }
443
444 #[test]
445 fn fold_multiline_comments() {
446 assert_snapshot!(check("
447-- this is
448-- a comment
449select 1;"), @"
450 <fold comment>-- this is
451 -- a comment</fold>
452 select 1;
453 ");
454 }
455
456 #[test]
457 fn fold_multiline_comments_with_windows_line_endings() {
458 assert_snapshot!(
459 format!(
460 "{:?}",
461 check("-- this is\r\n-- a comment\r\nselect 1;")
462 ),
463 @r#""<fold comment>-- this is\r\n-- a comment</fold>\r\nselect 1;""#
464 );
465 }
466
467 #[test]
468 fn fold_multiline_comments_with_cr_line_endings() {
469 assert_snapshot!(
470 format!(
471 "{:?}",
472 check("-- this is\r-- a comment\rselect 1;")
473 ),
474 @r#""<fold comment>-- this is\r-- a comment</fold>\rselect 1;""#
475 );
476 }
477
478 #[test]
479 fn fold_comments_with_cr_line_endings_does_not_apply_when_whitespace_between() {
480 assert_snapshot!(
481 format!(
482 "{:?}",
483 check("-- this is\r\r-- a comment\r-- with some more\rselect 1;")
484 ),
485 @r#""-- this is\r\r<fold comment>-- a comment\r-- with some more</fold>\rselect 1;""#
486 );
487 }
488
489 #[test]
490 fn fold_statement_with_cr_line_endings() {
491 assert_snapshot!(
492 format!("{:?}", check("select\r id,\r name\rfrom t;")),
493 @r#""<fold statement>select\r <fold list>id,\r name</fold>\rfrom t;</fold>""#
494 );
495 }
496
497 #[test]
498 fn fold_single_line_no_fold() {
499 assert_snapshot!(check("select 1;"), @"select 1;");
500 }
501
502 #[test]
503 fn fold_subquery() {
504 assert_snapshot!(check("
505select * from (
506 select id from t
507);"), @"
508 <fold statement>select * from <fold statement>(
509 select id from t
510 )</fold>;</fold>
511 ");
512 }
513
514 #[test]
515 fn fold_case_when() {
516 assert_snapshot!(check("
517select
518 case
519 when x = 1 then 'a'
520 when x = 2 then 'b'
521 end
522from t;"), @"
523 <fold statement>select
524 <fold list>case
525 <fold list>when x = 1 then 'a'
526 when x = 2 then 'b'</fold>
527 end</fold>
528 from t;</fold>
529 ");
530 }
531
532 #[test]
533 fn fold_join() {
534 assert_snapshot!(check("
535select *
536from a
537join b
538 on a.id = b.id;"), @"
539 <fold statement>select *
540 from a
541 <fold join>join b
542 on a.id = b.id</fold>;</fold>
543 ");
544 }
545
546 #[test]
547 fn fold_where_clause() {
548 assert_snapshot!(check("
549select *
550from t
551where
552 a = 1
553 and b = 2;"), @"
554 <fold statement>select *
555 from t
556 <fold where_clause>where
557 a = 1
558 and b = 2</fold>;</fold>
559 ");
560 }
561
562 #[test]
563 fn fold_array_literal() {
564 assert_snapshot!(check("
565select * from t where
566 x = any(array[
567 1,
568 2,
569 3
570 ]);"), @"
571 <fold statement>select * from t <fold where_clause>where
572 x = <fold function_call>any(<fold array>array[
573 1,
574 2,
575 3
576 ]</fold>)</fold></fold>;</fold>
577 ");
578 }
579
580 #[test]
581 fn fold_tuple_literal() {
582 assert_snapshot!(check("
583select (
584 1,
585 2,
586 3
587);"), @"
588 <fold statement>select <fold list><fold tuple>(
589 1,
590 2,
591 3
592 )</fold></fold>;</fold>
593 ");
594 }
595
596 #[test]
597 fn fold_tuple_bin_expr() {
598 assert_snapshot!(check("
599select * from x
600 where z in (
601 1,
602 2,
603 3,
604 4,
605 5
606 );
607"), @"
608 <fold statement>select * from x
609 <fold where_clause>where z in <fold tuple>(
610 1,
611 2,
612 3,
613 4,
614 5
615 )</fold></fold>;</fold>
616 ");
617 }
618
619 #[test]
620 fn fold_function_call() {
621 assert_snapshot!(check("
622select coalesce(
623 a,
624 b,
625 c
626);"), @"
627 <fold statement>select <fold function_call><fold list>coalesce<fold arglist>(
628 a,
629 b,
630 c
631 )</fold></fold></fold>;</fold>
632 ");
633 }
634
635 #[test]
636 fn fold_create_enum() {
637 assert_snapshot!(check("
638create type status as enum (
639 'active',
640 'inactive'
641);"), @"
642 <fold statement>create type status as enum <fold list>(
643 'active',
644 'inactive'
645 )</fold>;</fold>
646 ");
647 }
648
649 #[test]
650 fn fold_insert_values() {
651 assert_snapshot!(check("
652insert into t (id, name)
653values
654 (1, 'a'),
655 (2, 'b');"), @"
656 <fold statement>insert into t (id, name)
657 <fold statement>values
658 <fold list>(1, 'a'),
659 (2, 'b')</fold></fold>;</fold>
660 ");
661 }
662
663 #[test]
664 fn no_fold_single_line_create_table() {
665 assert_snapshot!(check("create table t (id int);"), @"create table t (id int);");
666 }
667
668 #[test]
669 fn list_variants() {
670 let unhandled_list_kinds: Vec<SyntaxKind> = (0..SyntaxKind::__LAST as u16)
671 .map(SyntaxKind::from)
672 .filter(|kind| format!("{kind:?}").ends_with("_LIST"))
673 .filter(|kind| fold_kind(*kind).is_none())
674 .collect();
675
676 assert_eq!(
677 unhandled_list_kinds,
678 vec![],
679 "All _LIST SyntaxKind variants should be handled in fold_kind"
680 );
681 }
682}