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