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
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
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_eq!(errors.len(), 1);
262    }
263
264    #[test]
265    fn regression_unknown_name() {
266        let mut linter = Linter::with_all_rules();
267        let sql = r#"
268-- squawk-ignore prefer-robust-stmts
269create table test_table (
270  -- squawk-ignore prefer-timestamp-tz
271  created_at timestamp default current_timestamp,
272  other_field text
273);
274        "#;
275
276        let parse = squawk_syntax::SourceFile::parse(sql);
277        let errors = linter.lint(&parse, sql);
278        assert_eq!(errors.len(), 0);
279    }
280
281    #[test]
282    fn file_single_rule() {
283        let sql = r#"
284-- squawk-ignore-file ban-drop-column
285alter table t drop column c cascade;
286        "#;
287        let parse = squawk_syntax::SourceFile::parse(sql);
288
289        let mut linter = Linter::from([]);
290        find_ignores(&mut linter, &parse.syntax_node());
291
292        assert_eq!(linter.ignores.len(), 1);
293        let ignore = &linter.ignores[0];
294        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
295        assert!(matches!(ignore.kind, IgnoreKind::File));
296    }
297
298    #[test]
299    fn file_ignore_with_all_rules() {
300        let sql = r#"
301-- squawk-ignore-file
302alter table t drop column c cascade;
303        "#;
304        let parse = squawk_syntax::SourceFile::parse(sql);
305
306        let mut linter = Linter::from([]);
307        find_ignores(&mut linter, &parse.syntax_node());
308
309        assert_eq!(linter.ignores.len(), 1);
310        let ignore = &linter.ignores[0];
311        assert!(matches!(ignore.kind, IgnoreKind::File));
312        assert!(ignore.violation_names.is_empty());
313
314        let errors: Vec<_> = linter
315            .lint(&parse, sql)
316            .into_iter()
317            .map(|x| x.code)
318            .collect();
319        assert!(errors.is_empty());
320    }
321
322    #[test]
323    fn file_ignore_with_multiple_rules() {
324        let sql = r#"
325-- squawk-ignore-file ban-drop-column, renaming-column
326alter table t drop column c cascade;
327        "#;
328        let parse = squawk_syntax::SourceFile::parse(sql);
329
330        let mut linter = Linter::from([]);
331        find_ignores(&mut linter, &parse.syntax_node());
332
333        assert_eq!(linter.ignores.len(), 1);
334        let ignore = &linter.ignores[0];
335        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
336        assert!(ignore.violation_names.contains(&Rule::RenamingColumn));
337        assert!(matches!(ignore.kind, IgnoreKind::File));
338    }
339
340    #[test]
341    fn file_ignore_anywhere_works() {
342        let sql = r#"
343alter table t add column x int;
344-- squawk-ignore-file ban-drop-column
345alter table t drop column c cascade;
346        "#;
347        let parse = squawk_syntax::SourceFile::parse(sql);
348
349        let mut linter = Linter::from([]);
350        find_ignores(&mut linter, &parse.syntax_node());
351
352        assert_eq!(linter.ignores.len(), 1);
353        let ignore = &linter.ignores[0];
354        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
355        assert!(matches!(ignore.kind, IgnoreKind::File));
356    }
357
358    #[test]
359    fn file_ignore_c_style_comment() {
360        let sql = r#"
361/* squawk-ignore-file ban-drop-column */
362alter table t drop column c cascade;
363        "#;
364        let parse = squawk_syntax::SourceFile::parse(sql);
365
366        let mut linter = Linter::from([]);
367        find_ignores(&mut linter, &parse.syntax_node());
368
369        assert_eq!(linter.ignores.len(), 1);
370        let ignore = &linter.ignores[0];
371        assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
372        assert!(matches!(ignore.kind, IgnoreKind::File));
373    }
374
375    #[test]
376    fn file_level_only_ignores_specific_rules() {
377        let mut linter = Linter::with_all_rules();
378        let sql = r#"
379-- squawk-ignore-file ban-drop-column
380alter table t drop column c cascade;
381alter table t2 drop column c2 cascade;
382        "#;
383
384        let parse = squawk_syntax::SourceFile::parse(sql);
385        let errors: Vec<_> = linter
386            .lint(&parse, sql)
387            .into_iter()
388            .map(|x| x.code)
389            .collect();
390
391        assert_debug_snapshot!(errors, @r"
392        [
393            PreferRobustStmts,
394            PreferRobustStmts,
395        ]
396        ");
397    }
398
399    #[test]
400    fn file_ignore_at_end_of_file_is_fine() {
401        let mut linter = Linter::with_all_rules();
402        let sql = r#"
403alter table t drop column c cascade;
404alter table t2 drop column c2 cascade;
405-- squawk-ignore-file ban-drop-column
406        "#;
407
408        let parse = squawk_syntax::SourceFile::parse(sql);
409        let errors: Vec<_> = linter
410            .lint(&parse, sql)
411            .into_iter()
412            .map(|x| x.code)
413            .collect();
414
415        assert_debug_snapshot!(errors, @r"
416        [
417            PreferRobustStmts,
418            PreferRobustStmts,
419        ]
420        ");
421    }
422}