squawk_linter/
ignore.rs

1use std::collections::HashSet;
2
3use rowan::{NodeOrToken, TextRange, TextSize};
4use squawk_syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
5
6use crate::{Linter, Rule, Violation};
7
8#[derive(Debug)]
9pub enum IgnoreKind {
10    File,
11    Line,
12}
13
14#[derive(Debug)]
15pub struct Ignore {
16    pub range: TextRange,
17    pub violation_names: HashSet<Rule>,
18    pub kind: IgnoreKind,
19}
20
21fn comment_body(token: &SyntaxToken) -> Option<(&str, TextRange)> {
22    let range = token.text_range();
23    if token.kind() == SyntaxKind::COMMENT {
24        let text = token.text();
25        if let Some(trimmed) = text.strip_prefix("--")
26            && let Some(start) = range.start().checked_add(2.into())
27        {
28            let end = range.end();
29            let updated_range = TextRange::new(start, end);
30            return Some((trimmed, updated_range));
31        }
32        if let Some(trimmed) = text.strip_prefix("/*").and_then(|x| x.strip_suffix("*/"))
33            && let Some(start) = range.start().checked_add(2.into())
34            && let Some(end) = range.end().checked_sub(2.into())
35        {
36            let updated_range = TextRange::new(start, end);
37            return Some((trimmed, updated_range));
38        }
39    }
40    None
41}
42
43// TODO: maybe in a future version we can rename this to squawk-ignore-line
44pub const IGNORE_LINE_TEXT: &str = "squawk-ignore";
45pub const IGNORE_FILE_TEXT: &str = "squawk-ignore-file";
46
47pub fn ignore_rule_info(token: &SyntaxToken) -> Option<(&str, TextRange, IgnoreKind)> {
48    if let Some((comment_body, range)) = comment_body(token) {
49        let without_start = comment_body.trim_start();
50        let trim_start_size = comment_body.len() - without_start.len();
51        let trimmed_comment = without_start.trim_end();
52        let trim_end_size = without_start.len() - trimmed_comment.len();
53
54        for (prefix, kind) in [
55            (IGNORE_FILE_TEXT, IgnoreKind::File),
56            (IGNORE_LINE_TEXT, IgnoreKind::Line),
57        ] {
58            if let Some(without_prefix) = trimmed_comment.strip_prefix(prefix) {
59                let range = TextRange::new(
60                    range.start() + TextSize::new((trim_start_size + prefix.len()) as u32),
61                    range.end() - TextSize::new(trim_end_size as u32),
62                );
63                return Some((without_prefix, range, kind));
64            }
65        }
66    }
67    None
68}
69
70pub(crate) fn find_ignores(ctx: &mut Linter, file: &SyntaxNode) {
71    for event in file.preorder_with_tokens() {
72        match event {
73            rowan::WalkEvent::Enter(NodeOrToken::Token(token))
74                if token.kind() == SyntaxKind::COMMENT =>
75            {
76                if let Some((rule_names, range, kind)) = ignore_rule_info(&token) {
77                    let mut set = HashSet::new();
78                    let mut offset = 0usize;
79
80                    // we need to keep track of our offset and report specific
81                    // ranges for any unknown names we encounter, which makes
82                    // this more complicated
83                    for x in rule_names.split(",") {
84                        if x.is_empty() {
85                            continue;
86                        }
87                        if let Ok(violation_name) = Rule::try_from(x.trim()) {
88                            set.insert(violation_name);
89                        } else {
90                            let without_start = x.trim_start();
91                            let trim_start_size = x.len() - without_start.len();
92                            let trimmed = without_start.trim_end();
93
94                            let range = range.checked_add(TextSize::new(offset as u32)).unwrap();
95
96                            let start = range.start() + TextSize::new(trim_start_size as u32);
97                            let end = start + TextSize::new(trimmed.len() as u32);
98                            let range = TextRange::new(start, end);
99
100                            ctx.report(Violation::for_range(
101                                Rule::UnusedIgnore,
102                                format!("unknown name {trimmed}"),
103                                range,
104                            ));
105                        }
106
107                        offset += x.len() + 1;
108                    }
109                    ctx.ignore(Ignore {
110                        range,
111                        violation_names: set,
112                        kind,
113                    });
114                }
115            }
116            _ => (),
117        }
118    }
119}
120
121#[cfg(test)]
122mod test {
123
124    use insta::assert_debug_snapshot;
125
126    use super::IgnoreKind;
127    use crate::{Linter, Rule, find_ignores};
128
129    #[test]
130    fn single_ignore() {
131        let sql = r#"
132-- squawk-ignore ban-drop-column
133alter table t drop column c cascade;
134        "#;
135        let parse = squawk_syntax::SourceFile::parse(sql);
136
137        let mut linter = Linter::from([]);
138        find_ignores(&mut linter, &parse.syntax_node());
139
140        assert_eq!(linter.ignores.len(), 1);
141        let ignore = &linter.ignores[0];
142        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
143    }
144
145    #[test]
146    fn multiple_sql_comments_with_ignore_is_ok() {
147        let sql = "
148-- fooo bar
149-- buzz
150-- squawk-ignore prefer-robust-stmts, require-timeout-settings
151create table x();
152
153select 1;
154";
155
156        let parse = squawk_syntax::SourceFile::parse(sql);
157        let mut linter = Linter::with_all_rules();
158        find_ignores(&mut linter, &parse.syntax_node());
159
160        assert_eq!(linter.ignores.len(), 1);
161        let ignore = &linter.ignores[0];
162        assert!(
163            ignore.violation_names.contains(&Rule::PreferRobustStmts),
164            "Make sure we picked up the ignore"
165        );
166
167        let errors = linter.lint(&parse, sql);
168
169        assert_eq!(
170            errors,
171            vec![],
172            "We shouldn't have any errors because we have the ignore setup"
173        );
174    }
175
176    #[test]
177    fn single_ignore_c_style_comment() {
178        let sql = r#"
179/* squawk-ignore ban-drop-column */
180alter table t drop column c cascade;
181        "#;
182        let parse = squawk_syntax::SourceFile::parse(sql);
183
184        let mut linter = Linter::from([]);
185
186        find_ignores(&mut linter, &parse.syntax_node());
187
188        assert_eq!(linter.ignores.len(), 1);
189        let ignore = &linter.ignores[0];
190        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
191    }
192
193    #[test]
194    fn multi_ignore() {
195        let sql = r#"
196-- squawk-ignore ban-drop-column, renaming-column,ban-drop-database
197alter table t drop column c cascade;
198        "#;
199        let parse = squawk_syntax::SourceFile::parse(sql);
200
201        let mut linter = Linter::from([]);
202
203        find_ignores(&mut linter, &parse.syntax_node());
204
205        assert_eq!(linter.ignores.len(), 1);
206        let ignore = &linter.ignores[0];
207        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
208        assert!(ignore.violation_names.contains(&Rule::RenamingColumn));
209        assert!(ignore.violation_names.contains(&Rule::BanDropDatabase));
210    }
211
212    #[test]
213    fn multi_ignore_c_style_comment() {
214        let sql = r#"
215/* squawk-ignore ban-drop-column, renaming-column,ban-drop-database */
216alter table t drop column c cascade;
217        "#;
218        let parse = squawk_syntax::SourceFile::parse(sql);
219
220        let mut linter = Linter::from([]);
221
222        find_ignores(&mut linter, &parse.syntax_node());
223
224        assert_eq!(linter.ignores.len(), 1);
225        let ignore = &linter.ignores[0];
226        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
227        assert!(ignore.violation_names.contains(&Rule::RenamingColumn));
228        assert!(ignore.violation_names.contains(&Rule::BanDropDatabase));
229    }
230
231    #[test]
232    fn ignore_multiple_stmts() {
233        let mut linter = Linter::with_all_rules();
234        let sql = r#"
235-- squawk-ignore ban-char-field,prefer-robust-stmts,require-timeout-settings
236alter table t add column c char;
237
238ALTER TABLE foo
239-- squawk-ignore adding-field-with-default,prefer-robust-stmts
240ADD COLUMN bar numeric GENERATED 
241  ALWAYS AS (bar + baz) STORED;
242
243-- squawk-ignore prefer-robust-stmts
244create table users (
245);
246"#;
247
248        let parse = squawk_syntax::SourceFile::parse(sql);
249        let errors = linter.lint(&parse, sql);
250        assert_eq!(errors.len(), 0);
251    }
252
253    #[test]
254    fn starting_line_aka_zero() {
255        let mut linter = Linter::with_all_rules();
256        let sql = r#"alter table t add column c char;"#;
257
258        let parse = squawk_syntax::SourceFile::parse(sql);
259        let errors = linter.lint(&parse, sql);
260        assert_debug_snapshot!(errors, @r#"
261        [
262            Violation {
263                code: RequireTimeoutSettings,
264                message: "Missing `set lock_timeout` before potentially slow operations",
265                text_range: 0..31,
266                help: Some(
267                    "Configure a `lock_timeout` before this statement.",
268                ),
269                fix: Some(
270                    Fix {
271                        title: "Add lock timeout",
272                        edits: [
273                            Edit {
274                                text_range: 0..0,
275                                text: Some(
276                                    "set lock_timeout = '1s';\n",
277                                ),
278                            },
279                        ],
280                    },
281                ),
282            },
283            Violation {
284                code: RequireTimeoutSettings,
285                message: "Missing `set statement_timeout` before potentially slow operations",
286                text_range: 0..31,
287                help: Some(
288                    "Configure a `statement_timeout` before this statement",
289                ),
290                fix: Some(
291                    Fix {
292                        title: "Add statement timeout",
293                        edits: [
294                            Edit {
295                                text_range: 0..0,
296                                text: Some(
297                                    "set statement_timeout = '5s';\n",
298                                ),
299                            },
300                        ],
301                    },
302                ),
303            },
304            Violation {
305                code: BanCharField,
306                message: "Using `character` is likely a mistake and should almost always be replaced by `text` or `varchar`.",
307                text_range: 27..31,
308                help: None,
309                fix: Some(
310                    Fix {
311                        title: "Replace with `text`",
312                        edits: [
313                            Edit {
314                                text_range: 27..31,
315                                text: Some(
316                                    "text",
317                                ),
318                            },
319                        ],
320                    },
321                ),
322            },
323        ]
324        "#);
325    }
326
327    #[test]
328    fn regression_unknown_name() {
329        let mut linter = Linter::with_all_rules();
330        let sql = r#"
331-- squawk-ignore prefer-robust-stmts, require-timeout-settings
332create table test_table (
333  -- squawk-ignore prefer-timestamp-tz
334  created_at timestamp default current_timestamp,
335  other_field text
336);
337        "#;
338
339        let parse = squawk_syntax::SourceFile::parse(sql);
340        let errors = linter.lint(&parse, sql);
341        assert_debug_snapshot!(errors, @"[]");
342        assert_eq!(errors.len(), 0);
343    }
344
345    #[test]
346    fn file_single_rule() {
347        let sql = r#"
348-- squawk-ignore-file ban-drop-column
349alter table t drop column c cascade;
350        "#;
351        let parse = squawk_syntax::SourceFile::parse(sql);
352
353        let mut linter = Linter::from([]);
354        find_ignores(&mut linter, &parse.syntax_node());
355
356        assert_eq!(linter.ignores.len(), 1);
357        let ignore = &linter.ignores[0];
358        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
359        assert!(matches!(ignore.kind, IgnoreKind::File));
360    }
361
362    #[test]
363    fn file_ignore_with_all_rules() {
364        let sql = r#"
365-- squawk-ignore-file
366alter table t drop column c cascade;
367        "#;
368        let parse = squawk_syntax::SourceFile::parse(sql);
369
370        let mut linter = Linter::from([]);
371        find_ignores(&mut linter, &parse.syntax_node());
372
373        assert_eq!(linter.ignores.len(), 1);
374        let ignore = &linter.ignores[0];
375        assert!(matches!(ignore.kind, IgnoreKind::File));
376        assert!(ignore.violation_names.is_empty());
377
378        let errors: Vec<_> = linter
379            .lint(&parse, sql)
380            .into_iter()
381            .map(|x| x.code)
382            .collect();
383        assert!(errors.is_empty());
384    }
385
386    #[test]
387    fn file_ignore_with_multiple_rules() {
388        let sql = r#"
389-- squawk-ignore-file ban-drop-column, renaming-column
390alter table t drop column c cascade;
391        "#;
392        let parse = squawk_syntax::SourceFile::parse(sql);
393
394        let mut linter = Linter::from([]);
395        find_ignores(&mut linter, &parse.syntax_node());
396
397        assert_eq!(linter.ignores.len(), 1);
398        let ignore = &linter.ignores[0];
399        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
400        assert!(ignore.violation_names.contains(&Rule::RenamingColumn));
401        assert!(matches!(ignore.kind, IgnoreKind::File));
402    }
403
404    #[test]
405    fn file_ignore_anywhere_works() {
406        let sql = r#"
407alter table t add column x int;
408-- squawk-ignore-file ban-drop-column
409alter table t drop column c cascade;
410        "#;
411        let parse = squawk_syntax::SourceFile::parse(sql);
412
413        let mut linter = Linter::from([]);
414        find_ignores(&mut linter, &parse.syntax_node());
415
416        assert_eq!(linter.ignores.len(), 1);
417        let ignore = &linter.ignores[0];
418        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
419        assert!(matches!(ignore.kind, IgnoreKind::File));
420    }
421
422    #[test]
423    fn file_ignore_c_style_comment() {
424        let sql = r#"
425/* squawk-ignore-file ban-drop-column */
426alter table t drop column c cascade;
427        "#;
428        let parse = squawk_syntax::SourceFile::parse(sql);
429
430        let mut linter = Linter::from([]);
431        find_ignores(&mut linter, &parse.syntax_node());
432
433        assert_eq!(linter.ignores.len(), 1);
434        let ignore = &linter.ignores[0];
435        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
436        assert!(matches!(ignore.kind, IgnoreKind::File));
437    }
438
439    #[test]
440    fn file_level_only_ignores_specific_rules() {
441        let mut linter = Linter::with_all_rules();
442        let sql = r#"
443-- squawk-ignore-file ban-drop-column
444alter table t drop column c cascade;
445alter table t2 drop column c2 cascade;
446        "#;
447
448        let parse = squawk_syntax::SourceFile::parse(sql);
449        let errors: Vec<_> = linter
450            .lint(&parse, sql)
451            .into_iter()
452            .map(|x| x.code)
453            .collect();
454
455        assert_debug_snapshot!(errors, @r"
456        [
457            RequireTimeoutSettings,
458            RequireTimeoutSettings,
459            PreferRobustStmts,
460            PreferRobustStmts,
461        ]
462        ");
463    }
464
465    #[test]
466    fn file_ignore_at_end_of_file_is_fine() {
467        let mut linter = Linter::with_all_rules();
468        let sql = r#"
469alter table t drop column c cascade;
470alter table t2 drop column c2 cascade;
471-- squawk-ignore-file ban-drop-column
472        "#;
473
474        let parse = squawk_syntax::SourceFile::parse(sql);
475        let errors: Vec<_> = linter
476            .lint(&parse, sql)
477            .into_iter()
478            .map(|x| x.code)
479            .collect();
480
481        assert_debug_snapshot!(errors, @r"
482        [
483            RequireTimeoutSettings,
484            RequireTimeoutSettings,
485            PreferRobustStmts,
486            PreferRobustStmts,
487        ]
488        ");
489    }
490}