1use rustc_hash::FxHashSet;
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: FxHashSet<Rule>,
18 pub ignore_all: bool,
19 pub kind: IgnoreKind,
20}
21
22pub(crate) fn comment_body(token: &SyntaxToken) -> Option<(&str, TextRange)> {
23 let range = token.text_range();
24 if token.kind() == SyntaxKind::COMMENT {
25 let text = token.text();
26 if let Some(trimmed) = text.strip_prefix("--")
27 && let Some(start) = range.start().checked_add(2.into())
28 {
29 let end = range.end();
30 let updated_range = TextRange::new(start, end);
31 return Some((trimmed, updated_range));
32 }
33 if let Some(trimmed) = text.strip_prefix("/*").and_then(|x| x.strip_suffix("*/"))
34 && let Some(start) = range.start().checked_add(2.into())
35 && let Some(end) = range.end().checked_sub(2.into())
36 {
37 let updated_range = TextRange::new(start, end);
38 return Some((trimmed, updated_range));
39 }
40 }
41 None
42}
43
44pub(crate) fn trim_trailing_comment(text: &str) -> &str {
52 let trimmed = text.trim();
53 trimmed
54 .find("--")
55 .map_or(trimmed, |idx| trimmed[..idx].trim_end())
56}
57
58pub const IGNORE_LINE_TEXT: &str = "squawk-ignore";
60pub const IGNORE_FILE_TEXT: &str = "squawk-ignore-file";
61
62pub fn ignore_rule_info(token: &SyntaxToken) -> Option<(&str, TextRange, IgnoreKind)> {
63 if let Some((comment_body, range)) = comment_body(token) {
64 let without_start = comment_body.trim_start();
65 let trim_start_size = comment_body.len() - without_start.len();
66
67 let without_end = trim_trailing_comment(without_start);
68 let trim_end_size = without_start.len() - without_end.len();
69
70 for (prefix, kind) in [
71 (IGNORE_FILE_TEXT, IgnoreKind::File),
72 (IGNORE_LINE_TEXT, IgnoreKind::Line),
73 ] {
74 if let Some(without_prefix) = without_end.strip_prefix(prefix) {
75 let start = range.start() + TextSize::new((trim_start_size + prefix.len()) as u32);
76 let end = range.end() - TextSize::new(trim_end_size as u32);
77
78 let range = TextRange::new(start, end);
79 return Some((without_prefix, range, kind));
80 }
81 }
82 }
83 None
84}
85
86pub(crate) fn find_ignores(ctx: &mut Linter, file: &SyntaxNode) {
87 for event in file.preorder_with_tokens() {
88 match event {
89 rowan::WalkEvent::Enter(NodeOrToken::Token(token))
90 if token.kind() == SyntaxKind::COMMENT =>
91 {
92 if let Some((rule_names, range, kind)) = ignore_rule_info(&token) {
93 let mut set = FxHashSet::default();
94 let mut offset = 0usize;
95 let ignore_all = rule_names.trim().is_empty();
99
100 for x in rule_names.split(",") {
104 if x.is_empty() {
105 continue;
106 }
107 if let Ok(violation_name) = Rule::try_from(x.trim()) {
108 set.insert(violation_name);
109 set.extend(violation_name.expands_to());
110 } else {
111 let without_start = x.trim_start();
112 let trim_start_size = x.len() - without_start.len();
113 let trimmed = without_start.trim_end();
114
115 let range = range.checked_add(TextSize::new(offset as u32)).unwrap();
116
117 let start = range.start() + TextSize::new(trim_start_size as u32);
118 let end = start + TextSize::new(trimmed.len() as u32);
119 let range = TextRange::new(start, end);
120
121 ctx.report(Violation::for_range(
122 Rule::UnusedIgnore,
123 format!("unknown name {trimmed}"),
124 range,
125 ));
126 }
127
128 offset += x.len() + 1;
129 }
130 ctx.ignore(Ignore {
131 range,
132 violation_names: set,
133 ignore_all,
134 kind,
135 });
136 }
137 }
138 _ => (),
139 }
140 }
141}
142
143const DISABLE_ASSUME_IN_TRANSACTION: &str = "squawk-disable-assume-in-transaction";
144
145pub fn has_disable_assume_in_transaction(file: &SyntaxNode) -> bool {
146 for event in file.preorder_with_tokens() {
147 match event {
148 rowan::WalkEvent::Enter(NodeOrToken::Token(token))
149 if token.kind() == SyntaxKind::COMMENT =>
150 {
151 if let Some((body, _range)) = comment_body(&token) {
152 if trim_trailing_comment(body) == DISABLE_ASSUME_IN_TRANSACTION {
153 return true;
154 }
155 }
156 }
157 _ => (),
158 }
159 }
160 false
161}
162
163#[cfg(test)]
164mod test {
165
166 use insta::assert_debug_snapshot;
167
168 use super::IgnoreKind;
169 use crate::{Linter, Rule, find_ignores};
170
171 #[test]
172 fn single_ignore() {
173 let sql = r#"
174-- squawk-ignore ban-drop-column
175alter table t drop column c cascade;
176 "#;
177 let parse = squawk_syntax::SourceFile::parse(sql);
178
179 let mut linter = Linter::from([]);
180 find_ignores(&mut linter, &parse.syntax_node());
181
182 assert_eq!(linter.ignores.len(), 1);
183 let ignore = &linter.ignores[0];
184 assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
185 }
186
187 #[test]
188 fn multiple_sql_comments_with_ignore_is_ok() {
189 let sql = "
190-- fooo bar
191-- buzz
192-- squawk-ignore prefer-robust-stmts, require-timeout-settings
193create table x();
194
195select 1;
196";
197
198 let parse = squawk_syntax::SourceFile::parse(sql);
199 let mut linter = Linter::with_default_rules();
200 find_ignores(&mut linter, &parse.syntax_node());
201
202 assert_eq!(linter.ignores.len(), 1);
203 let ignore = &linter.ignores[0];
204 assert!(
205 ignore.violation_names.contains(&Rule::PreferRobustStmts),
206 "Make sure we picked up the ignore"
207 );
208
209 let errors = linter.lint(&parse, sql);
210
211 assert_eq!(
212 errors,
213 vec![],
214 "We shouldn't have any errors because we have the ignore setup"
215 );
216 }
217
218 #[test]
219 fn single_ignore_c_style_comment() {
220 let sql = r#"
221/* squawk-ignore ban-drop-column */
222alter table t drop column c cascade;
223 "#;
224 let parse = squawk_syntax::SourceFile::parse(sql);
225
226 let mut linter = Linter::from([]);
227
228 find_ignores(&mut linter, &parse.syntax_node());
229
230 assert_eq!(linter.ignores.len(), 1);
231 let ignore = &linter.ignores[0];
232 assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
233 }
234
235 #[test]
236 fn multi_ignore() {
237 let sql = r#"
238-- squawk-ignore ban-drop-column, renaming-column,ban-drop-database
239alter table t drop column c cascade;
240 "#;
241 let parse = squawk_syntax::SourceFile::parse(sql);
242
243 let mut linter = Linter::from([]);
244
245 find_ignores(&mut linter, &parse.syntax_node());
246
247 assert_eq!(linter.ignores.len(), 1);
248 let ignore = &linter.ignores[0];
249 assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
250 assert!(ignore.violation_names.contains(&Rule::RenamingColumn));
251 assert!(ignore.violation_names.contains(&Rule::BanDropDatabase));
252 }
253
254 #[test]
255 fn multi_ignore_c_style_comment() {
256 let sql = r#"
257/* squawk-ignore ban-drop-column, renaming-column,ban-drop-database */
258alter table t drop column c cascade;
259 "#;
260 let parse = squawk_syntax::SourceFile::parse(sql);
261
262 let mut linter = Linter::from([]);
263
264 find_ignores(&mut linter, &parse.syntax_node());
265
266 assert_eq!(linter.ignores.len(), 1);
267 let ignore = &linter.ignores[0];
268 assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
269 assert!(ignore.violation_names.contains(&Rule::RenamingColumn));
270 assert!(ignore.violation_names.contains(&Rule::BanDropDatabase));
271 }
272
273 #[test]
274 fn ignore_multiple_stmts() {
275 let mut linter = Linter::with_default_rules();
276 let sql = r#"
277-- squawk-ignore ban-char-field,prefer-robust-stmts,require-timeout-settings
278alter table t add column c char;
279
280ALTER TABLE foo
281-- squawk-ignore adding-field-with-default,prefer-robust-stmts
282ADD COLUMN bar numeric GENERATED
283 ALWAYS AS (bar + baz) STORED;
284
285-- squawk-ignore prefer-robust-stmts
286create table users (
287);
288"#;
289
290 let parse = squawk_syntax::SourceFile::parse(sql);
291 let errors = linter.lint(&parse, sql);
292 assert_eq!(errors.len(), 0);
293 }
294
295 #[test]
296 fn starting_line_aka_zero() {
297 let mut linter = Linter::with_default_rules();
298 let sql = r#"alter table t add column c char;"#;
299
300 let parse = squawk_syntax::SourceFile::parse(sql);
301 let errors = linter.lint(&parse, sql);
302 assert_debug_snapshot!(errors, @r#"
303 [
304 Violation {
305 code: RequireLockTimeout,
306 message: "Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations",
307 text_range: 0..32,
308 help: Some(
309 "Configure a `lock_timeout` before this statement. Statement requires: ACCESS EXCLUSIVE lock; blocking: reads, writes, schema changes.",
310 ),
311 fix: Some(
312 Fix {
313 title: "Add lock timeout",
314 edits: [
315 Edit {
316 text_range: 0..0,
317 text: Some(
318 "set lock_timeout = '1s';\n",
319 ),
320 },
321 ],
322 },
323 ),
324 },
325 Violation {
326 code: RequireStatementTimeout,
327 message: "Missing `set statement_timeout` before potentially slow operations",
328 text_range: 0..32,
329 help: Some(
330 "Configure a `statement_timeout` before this statement",
331 ),
332 fix: Some(
333 Fix {
334 title: "Add statement timeout",
335 edits: [
336 Edit {
337 text_range: 0..0,
338 text: Some(
339 "set statement_timeout = '5s';\n",
340 ),
341 },
342 ],
343 },
344 ),
345 },
346 Violation {
347 code: PreferRobustStmts,
348 message: "Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.",
349 text_range: 14..31,
350 help: None,
351 fix: Some(
352 Fix {
353 title: "Insert `if not exists`",
354 edits: [
355 Edit {
356 text_range: 24..24,
357 text: Some(
358 " if not exists",
359 ),
360 },
361 ],
362 },
363 ),
364 },
365 Violation {
366 code: BanCharField,
367 message: "Using `character` is likely a mistake and should almost always be replaced by `text` or `varchar`.",
368 text_range: 27..31,
369 help: None,
370 fix: Some(
371 Fix {
372 title: "Replace with `text`",
373 edits: [
374 Edit {
375 text_range: 27..31,
376 text: Some(
377 "text",
378 ),
379 },
380 ],
381 },
382 ),
383 },
384 ]
385 "#);
386 }
387
388 #[test]
389 fn ignore_timeout_settings_alias_covers_granular_rules() {
390 let sql = r#"
391-- squawk-ignore require-timeout-settings
392alter table t add column c boolean;
393 "#;
394 let parse = squawk_syntax::SourceFile::parse(sql);
395
396 let mut linter = Linter::from([Rule::RequireLockTimeout, Rule::RequireStatementTimeout]);
397 let errors = linter.lint(&parse, sql);
398 assert_eq!(errors, vec![]);
399 }
400
401 #[test]
402 fn ignore_granular_timeout_rule() {
403 let sql = r#"
404-- squawk-ignore require-lock-timeout
405alter table t add column c boolean;
406 "#;
407 let parse = squawk_syntax::SourceFile::parse(sql);
408
409 let mut linter = Linter::from([Rule::RequireLockTimeout, Rule::RequireStatementTimeout]);
410 let errors: Vec<_> = linter
411 .lint(&parse, sql)
412 .into_iter()
413 .map(|x| x.code)
414 .collect();
415 assert_eq!(errors, vec![Rule::RequireStatementTimeout]);
416 }
417
418 #[test]
419 fn regression_unknown_name() {
420 let mut linter = Linter::with_default_rules();
421 let sql = r#"
422-- squawk-ignore prefer-robust-stmts, require-timeout-settings
423create table test_table (
424 -- squawk-ignore prefer-timestamp-tz
425 created_at timestamp default current_timestamp,
426 other_field text
427);
428 "#;
429
430 let parse = squawk_syntax::SourceFile::parse(sql);
431 let errors = linter.lint(&parse, sql);
432 assert_debug_snapshot!(errors, @"[]");
433 assert_eq!(errors.len(), 0);
434 }
435
436 #[test]
437 fn file_single_rule() {
438 let sql = r#"
439-- squawk-ignore-file ban-drop-column
440alter table t drop column c cascade;
441 "#;
442 let parse = squawk_syntax::SourceFile::parse(sql);
443
444 let mut linter = Linter::from([]);
445 find_ignores(&mut linter, &parse.syntax_node());
446
447 assert_eq!(linter.ignores.len(), 1);
448 let ignore = &linter.ignores[0];
449 assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
450 assert!(matches!(ignore.kind, IgnoreKind::File));
451 }
452
453 #[test]
454 fn file_ignore_with_all_rules() {
455 let sql = r#"
456-- squawk-ignore-file
457alter table t drop column c cascade;
458 "#;
459 let parse = squawk_syntax::SourceFile::parse(sql);
460
461 let mut linter = Linter::from([]);
462 find_ignores(&mut linter, &parse.syntax_node());
463
464 assert_eq!(linter.ignores.len(), 1);
465 let ignore = &linter.ignores[0];
466 assert!(matches!(ignore.kind, IgnoreKind::File));
467 assert!(ignore.violation_names.is_empty());
468
469 let errors: Vec<_> = linter
470 .lint(&parse, sql)
471 .into_iter()
472 .map(|x| x.code)
473 .collect();
474 assert!(errors.is_empty());
475 }
476
477 #[test]
478 fn file_ignore_with_multiple_rules() {
479 let sql = r#"
480-- squawk-ignore-file ban-drop-column, renaming-column
481alter table t drop column c cascade;
482 "#;
483 let parse = squawk_syntax::SourceFile::parse(sql);
484
485 let mut linter = Linter::from([]);
486 find_ignores(&mut linter, &parse.syntax_node());
487
488 assert_eq!(linter.ignores.len(), 1);
489 let ignore = &linter.ignores[0];
490 assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
491 assert!(ignore.violation_names.contains(&Rule::RenamingColumn));
492 assert!(matches!(ignore.kind, IgnoreKind::File));
493 }
494
495 #[test]
496 fn file_ignore_anywhere_works() {
497 let sql = r#"
498alter table t add column x int;
499-- squawk-ignore-file ban-drop-column
500alter table t drop column c cascade;
501 "#;
502 let parse = squawk_syntax::SourceFile::parse(sql);
503
504 let mut linter = Linter::from([]);
505 find_ignores(&mut linter, &parse.syntax_node());
506
507 assert_eq!(linter.ignores.len(), 1);
508 let ignore = &linter.ignores[0];
509 assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
510 assert!(matches!(ignore.kind, IgnoreKind::File));
511 }
512
513 #[test]
514 fn file_ignore_c_style_comment() {
515 let sql = r#"
516/* squawk-ignore-file ban-drop-column */
517alter table t drop column c cascade;
518 "#;
519 let parse = squawk_syntax::SourceFile::parse(sql);
520
521 let mut linter = Linter::from([]);
522 find_ignores(&mut linter, &parse.syntax_node());
523
524 assert_eq!(linter.ignores.len(), 1);
525 let ignore = &linter.ignores[0];
526 assert!(ignore.violation_names.contains(&Rule::BanDropColumn));
527 assert!(matches!(ignore.kind, IgnoreKind::File));
528 }
529
530 #[test]
531 fn file_level_only_ignores_specific_rules() {
532 let mut linter = Linter::with_default_rules();
533 let sql = r#"
534-- squawk-ignore-file ban-drop-column
535alter table t drop column c cascade;
536alter table t2 drop column c2 cascade;
537 "#;
538
539 let parse = squawk_syntax::SourceFile::parse(sql);
540 let errors: Vec<_> = linter
541 .lint(&parse, sql)
542 .into_iter()
543 .map(|x| x.code)
544 .collect();
545
546 assert_debug_snapshot!(errors, @"
547 [
548 RequireLockTimeout,
549 RequireStatementTimeout,
550 PreferRobustStmts,
551 PreferRobustStmts,
552 ]
553 ");
554 }
555
556 #[test]
557 fn file_ignore_at_end_of_file_is_fine() {
558 let mut linter = Linter::with_default_rules();
559 let sql = r#"
560alter table t drop column c cascade;
561alter table t2 drop column c2 cascade;
562-- squawk-ignore-file ban-drop-column
563 "#;
564
565 let parse = squawk_syntax::SourceFile::parse(sql);
566 let errors: Vec<_> = linter
567 .lint(&parse, sql)
568 .into_iter()
569 .map(|x| x.code)
570 .collect();
571
572 assert_debug_snapshot!(errors, @"
573 [
574 RequireLockTimeout,
575 RequireStatementTimeout,
576 PreferRobustStmts,
577 PreferRobustStmts,
578 ]
579 ");
580 }
581
582 #[test]
583 fn file_ignore_with_invalid_rules() {
584 let mut linter = Linter::with_default_rules();
585 let sql = r#"
586-- squawk-ignore-file ban-ban-ban-drop-column ignore-something hmm
587alter table t drop column c cascade;
588alter table t2 drop column c2 cascade;
589 "#;
590
591 let parse = squawk_syntax::SourceFile::parse(sql);
592 let errors = linter.lint(&parse, sql);
593 let errors: Vec<_> = errors.iter().map(|x| (&x.code, &x.message)).collect();
594
595 assert_debug_snapshot!(errors, @r#"
596 [
597 (
598 UnusedIgnore,
599 "unknown name ban-ban-ban-drop-column ignore-something hmm",
600 ),
601 (
602 RequireLockTimeout,
603 "Missing `set lock_timeout` before potentially slow ACCESS EXCLUSIVE lock operations",
604 ),
605 (
606 RequireStatementTimeout,
607 "Missing `set statement_timeout` before potentially slow operations",
608 ),
609 (
610 BanDropColumn,
611 "Dropping a column may break existing clients.",
612 ),
613 (
614 PreferRobustStmts,
615 "Missing `IF EXISTS`, the migration can't be rerun if it fails part way through.",
616 ),
617 (
618 BanDropColumn,
619 "Dropping a column may break existing clients.",
620 ),
621 (
622 PreferRobustStmts,
623 "Missing `IF EXISTS`, the migration can't be rerun if it fails part way through.",
624 ),
625 ]
626 "#);
627 }
628
629 #[test]
630 fn file_ignore_with_trailing_comment() {
631 let mut linter = Linter::with_default_rules();
632 let sql = r#"
633-- squawk-ignore-file ban-drop-column -- some comment here
634alter table t drop column c cascade;
635alter table t2 drop column c2 cascade;
636 "#;
637
638 let parse = squawk_syntax::SourceFile::parse(sql);
639 let errors: Vec<_> = linter
640 .lint(&parse, sql)
641 .into_iter()
642 .map(|x| x.code)
643 .collect();
644
645 assert_debug_snapshot!(errors, @"
646 [
647 RequireLockTimeout,
648 RequireStatementTimeout,
649 PreferRobustStmts,
650 PreferRobustStmts,
651 ]
652 ");
653 }
654
655 #[test]
656 fn line_ignore_with_trailing_comment() {
657 let mut linter = Linter::with_default_rules();
658 let sql = r#"
659-- squawk-ignore ban-drop-column,prefer-robust-stmts -- drop is intentional
660alter table t drop column c cascade;
661 "#;
662
663 let parse = squawk_syntax::SourceFile::parse(sql);
664 let errors: Vec<_> = linter
665 .lint(&parse, sql)
666 .into_iter()
667 .map(|x| x.code)
668 .collect();
669
670 assert_debug_snapshot!(errors, @"
671 [
672 RequireLockTimeout,
673 RequireStatementTimeout,
674 ]
675 ");
676 }
677
678 #[test]
679 fn disable_assume_in_transaction() {
680 use super::has_disable_assume_in_transaction;
681 let sql = "-- squawk-disable-assume-in-transaction\nSELECT 1;";
682 let parse = squawk_syntax::SourceFile::parse(sql);
683 assert!(has_disable_assume_in_transaction(&parse.syntax_node()));
684 }
685
686 #[test]
687 fn disable_assume_in_transaction_c_style_comment() {
688 use super::has_disable_assume_in_transaction;
689 let sql = "/* squawk-disable-assume-in-transaction */\nSELECT 1;";
690 let parse = squawk_syntax::SourceFile::parse(sql);
691 assert!(has_disable_assume_in_transaction(&parse.syntax_node()));
692 }
693
694 #[test]
695 fn disable_assume_in_transaction_with_trailing_comment() {
696 use super::has_disable_assume_in_transaction;
697 let sql = "-- squawk-disable-assume-in-transaction -- not in a transaction\nSELECT 1;";
698 let parse = squawk_syntax::SourceFile::parse(sql);
699 assert!(has_disable_assume_in_transaction(&parse.syntax_node()));
700 }
701
702 #[test]
703 fn transaction_override_none_when_absent() {
704 use super::has_disable_assume_in_transaction;
705 let sql = "SELECT 1;";
706 let parse = squawk_syntax::SourceFile::parse(sql);
707 assert!(!has_disable_assume_in_transaction(&parse.syntax_node()));
708 }
709
710 fn lint_line_ignore(line_ending: &str) -> Vec<Rule> {
711 let sql = [
712 "-- squawk-ignore ban-drop-column",
713 "alter table t drop column c cascade;",
714 "alter table u drop column d cascade;",
715 "",
716 ]
717 .join(line_ending);
718
719 let parse = squawk_syntax::SourceFile::parse(&sql);
720 let mut linter = Linter::from([Rule::BanDropColumn]);
721 linter
722 .lint(&parse, &sql)
723 .into_iter()
724 .map(|x| x.code)
725 .collect()
726 }
727
728 #[test]
729 fn line_ignore_with_lf_line_endings() {
730 assert_debug_snapshot!(lint_line_ignore("\n"), @"
731 [
732 BanDropColumn,
733 ]
734 ");
735 }
736
737 #[test]
738 fn line_ignore_with_crlf_line_endings() {
739 assert_debug_snapshot!(lint_line_ignore("\r\n"), @"
740 [
741 BanDropColumn,
742 ]
743 ");
744 }
745
746 #[test]
747 fn line_ignore_with_cr_line_endings() {
748 assert_debug_snapshot!(lint_line_ignore("\r"), @"
749 [
750 BanDropColumn,
751 ]
752 ");
753 }
754}