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
43pub 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 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}