Skip to main content

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: PreferRobustStmts,
306                message: "Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.",
307                text_range: 14..31,
308                help: None,
309                fix: Some(
310                    Fix {
311                        title: "Insert `if not exists`",
312                        edits: [
313                            Edit {
314                                text_range: 24..24,
315                                text: Some(
316                                    " if not exists",
317                                ),
318                            },
319                        ],
320                    },
321                ),
322            },
323            Violation {
324                code: BanCharField,
325                message: "Using `character` is likely a mistake and should almost always be replaced by `text` or `varchar`.",
326                text_range: 27..31,
327                help: None,
328                fix: Some(
329                    Fix {
330                        title: "Replace with `text`",
331                        edits: [
332                            Edit {
333                                text_range: 27..31,
334                                text: Some(
335                                    "text",
336                                ),
337                            },
338                        ],
339                    },
340                ),
341            },
342        ]
343        "#);
344    }
345
346    #[test]
347    fn regression_unknown_name() {
348        let mut linter = Linter::with_all_rules();
349        let sql = r#"
350-- squawk-ignore prefer-robust-stmts, require-timeout-settings
351create table test_table (
352  -- squawk-ignore prefer-timestamp-tz
353  created_at timestamp default current_timestamp,
354  other_field text
355);
356        "#;
357
358        let parse = squawk_syntax::SourceFile::parse(sql);
359        let errors = linter.lint(&parse, sql);
360        assert_debug_snapshot!(errors, @"[]");
361        assert_eq!(errors.len(), 0);
362    }
363
364    #[test]
365    fn file_single_rule() {
366        let sql = r#"
367-- squawk-ignore-file ban-drop-column
368alter table t drop column c cascade;
369        "#;
370        let parse = squawk_syntax::SourceFile::parse(sql);
371
372        let mut linter = Linter::from([]);
373        find_ignores(&mut linter, &parse.syntax_node());
374
375        assert_eq!(linter.ignores.len(), 1);
376        let ignore = &linter.ignores[0];
377        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
378        assert!(matches!(ignore.kind, IgnoreKind::File));
379    }
380
381    #[test]
382    fn file_ignore_with_all_rules() {
383        let sql = r#"
384-- squawk-ignore-file
385alter table t drop column c cascade;
386        "#;
387        let parse = squawk_syntax::SourceFile::parse(sql);
388
389        let mut linter = Linter::from([]);
390        find_ignores(&mut linter, &parse.syntax_node());
391
392        assert_eq!(linter.ignores.len(), 1);
393        let ignore = &linter.ignores[0];
394        assert!(matches!(ignore.kind, IgnoreKind::File));
395        assert!(ignore.violation_names.is_empty());
396
397        let errors: Vec<_> = linter
398            .lint(&parse, sql)
399            .into_iter()
400            .map(|x| x.code)
401            .collect();
402        assert!(errors.is_empty());
403    }
404
405    #[test]
406    fn file_ignore_with_multiple_rules() {
407        let sql = r#"
408-- squawk-ignore-file ban-drop-column, renaming-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!(ignore.violation_names.contains(&Rule::RenamingColumn));
420        assert!(matches!(ignore.kind, IgnoreKind::File));
421    }
422
423    #[test]
424    fn file_ignore_anywhere_works() {
425        let sql = r#"
426alter table t add column x int;
427-- squawk-ignore-file ban-drop-column
428alter table t drop column c cascade;
429        "#;
430        let parse = squawk_syntax::SourceFile::parse(sql);
431
432        let mut linter = Linter::from([]);
433        find_ignores(&mut linter, &parse.syntax_node());
434
435        assert_eq!(linter.ignores.len(), 1);
436        let ignore = &linter.ignores[0];
437        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
438        assert!(matches!(ignore.kind, IgnoreKind::File));
439    }
440
441    #[test]
442    fn file_ignore_c_style_comment() {
443        let sql = r#"
444/* squawk-ignore-file ban-drop-column */
445alter table t drop column c cascade;
446        "#;
447        let parse = squawk_syntax::SourceFile::parse(sql);
448
449        let mut linter = Linter::from([]);
450        find_ignores(&mut linter, &parse.syntax_node());
451
452        assert_eq!(linter.ignores.len(), 1);
453        let ignore = &linter.ignores[0];
454        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
455        assert!(matches!(ignore.kind, IgnoreKind::File));
456    }
457
458    #[test]
459    fn file_level_only_ignores_specific_rules() {
460        let mut linter = Linter::with_all_rules();
461        let sql = r#"
462-- squawk-ignore-file ban-drop-column
463alter table t drop column c cascade;
464alter table t2 drop column c2 cascade;
465        "#;
466
467        let parse = squawk_syntax::SourceFile::parse(sql);
468        let errors: Vec<_> = linter
469            .lint(&parse, sql)
470            .into_iter()
471            .map(|x| x.code)
472            .collect();
473
474        assert_debug_snapshot!(errors, @r"
475        [
476            RequireTimeoutSettings,
477            RequireTimeoutSettings,
478            PreferRobustStmts,
479            PreferRobustStmts,
480        ]
481        ");
482    }
483
484    #[test]
485    fn file_ignore_at_end_of_file_is_fine() {
486        let mut linter = Linter::with_all_rules();
487        let sql = r#"
488alter table t drop column c cascade;
489alter table t2 drop column c2 cascade;
490-- squawk-ignore-file ban-drop-column
491        "#;
492
493        let parse = squawk_syntax::SourceFile::parse(sql);
494        let errors: Vec<_> = linter
495            .lint(&parse, sql)
496            .into_iter()
497            .map(|x| x.code)
498            .collect();
499
500        assert_debug_snapshot!(errors, @r"
501        [
502            RequireTimeoutSettings,
503            RequireTimeoutSettings,
504            PreferRobustStmts,
505            PreferRobustStmts,
506        ]
507        ");
508    }
509}