Skip to main content

squawk_ide/
folding_ranges.rs

1// via https://github.com/rust-lang/rust-analyzer/blob/8d75311400a108d7ffe17dc9c38182c566952e6e/crates/ide/src/folding_ranges.rs#L47
2//
3// Permission is hereby granted, free of charge, to any
4// person obtaining a copy of this software and associated
5// documentation files (the "Software"), to deal in the
6// Software without restriction, including without
7// limitation the rights to use, copy, modify, merge,
8// publish, distribute, sublicense, and/or sell copies of
9// the Software, and to permit persons to whom the Software
10// is furnished to do so, subject to the following
11// conditions:
12//
13// The above copyright notice and this permission notice
14// shall be included in all copies or substantial portions
15// of the Software.
16//
17// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
18// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
19// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
20// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
21// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
22// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
24// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
25// DEALINGS IN THE SOFTWARE.
26
27// NOTE: pretty much copied as is but simplfied a fair bit. I don't use folding
28// much so not sure if this is optimal.
29
30use 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                    // skip any leading whitespace / comments
88                    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    // Only fold comments of the same flavor
212    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    // A group of one element cannot be folded
220    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}