1use rustc_hash::FxHashSet;
2use std::fmt;
3
4use enum_iterator::Sequence;
5use enum_iterator::all;
6pub use ignore::Ignore;
7use ignore::find_ignores;
8use ignore::has_disable_assume_in_transaction;
9use ignore_index::IgnoreIndex;
10use rowan::TextRange;
11use rowan::TextSize;
12use serde::Deserialize;
13
14use squawk_syntax::SyntaxNode;
15use squawk_syntax::{Parse, SourceFile};
16
17pub use version::Version;
18
19pub mod analyze;
20pub mod ignore;
21mod ignore_index;
22mod version;
23mod visitors;
24
25mod rules;
26
27#[cfg(test)]
28mod test_utils;
29use rules::adding_field_with_default;
30use rules::adding_foreign_key_constraint;
31use rules::adding_not_null_field;
32use rules::adding_primary_key_constraint;
33use rules::adding_required_field;
34use rules::ban_alter_domain_with_add_constraint;
35use rules::ban_char_field;
36use rules::ban_concurrent_index_creation_in_transaction;
37use rules::ban_create_domain_with_constraint;
38use rules::ban_drop_column;
39use rules::ban_drop_database;
40use rules::ban_drop_not_null;
41use rules::ban_drop_table;
42use rules::ban_truncate_cascade;
43use rules::ban_uncommitted_transaction;
44use rules::changing_column_type;
45use rules::constraint_missing_not_valid;
46use rules::disallow_unique_constraint;
47use rules::identifier_too_long;
48use rules::prefer_bigint_over_int;
49use rules::prefer_bigint_over_smallint;
50use rules::prefer_identity;
51use rules::prefer_repack;
52use rules::prefer_robust_stmts;
53use rules::prefer_text_field;
54use rules::prefer_timestamptz;
55use rules::renaming_column;
56use rules::renaming_table;
57use rules::require_concurrent_index_creation;
58use rules::require_concurrent_index_deletion;
59use rules::require_concurrent_partition_detach;
60use rules::require_concurrent_reindex;
61use rules::require_enum_value_ordering;
62use rules::require_table_schema;
63use rules::require_timeout_settings;
64use rules::transaction_nesting;
65#[derive(Debug, PartialEq, Clone, Copy, Hash, Eq, Sequence)]
68pub enum Rule {
69 RequireConcurrentIndexCreation,
70 RequireConcurrentIndexDeletion,
71 ConstraintMissingNotValid,
72 AddingFieldWithDefault,
73 AddingForeignKeyConstraint,
74 ChangingColumnType,
75 AddingNotNullableField,
76 AddingSerialPrimaryKeyField,
77 RenamingColumn,
78 RenamingTable,
79 DisallowedUniqueConstraint,
80 BanDropDatabase,
81 PreferBigintOverInt,
82 PreferBigintOverSmallint,
83 PreferIdentity,
84 PreferRepack,
85 PreferRobustStmts,
86 PreferTextField,
87 PreferTimestampTz,
88 BanCharField,
89 BanDropColumn,
90 BanDropTable,
91 BanDropNotNull,
92 TransactionNesting,
93 AddingRequiredField,
94 BanConcurrentIndexCreationInTransaction,
95 UnusedIgnore,
96 BanCreateDomainWithConstraint,
97 BanAlterDomainWithAddConstraint,
98 BanTruncateCascade,
99 RequireTimeoutSettings,
100 BanUncommittedTransaction,
101 RequireEnumValueOrdering,
102 RequireTableSchema,
103 IdentifierTooLong,
104 RequireConcurrentPartitionDetach,
105 RequireConcurrentReindex,
106 }
108
109impl Rule {
110 pub fn is_opt_in(&self) -> bool {
113 matches!(self, Rule::RequireTableSchema)
114 }
115}
116
117impl TryFrom<&str> for Rule {
118 type Error = String;
119
120 fn try_from(s: &str) -> Result<Self, Self::Error> {
121 match s {
122 "require-concurrent-index-creation" => Ok(Rule::RequireConcurrentIndexCreation),
123 "require-concurrent-index-deletion" => Ok(Rule::RequireConcurrentIndexDeletion),
124 "constraint-missing-not-valid" => Ok(Rule::ConstraintMissingNotValid),
125 "adding-field-with-default" => Ok(Rule::AddingFieldWithDefault),
126 "adding-foreign-key-constraint" => Ok(Rule::AddingForeignKeyConstraint),
127 "changing-column-type" => Ok(Rule::ChangingColumnType),
128 "adding-not-nullable-field" => Ok(Rule::AddingNotNullableField),
129 "adding-serial-primary-key-field" => Ok(Rule::AddingSerialPrimaryKeyField),
130 "renaming-column" => Ok(Rule::RenamingColumn),
131 "renaming-table" => Ok(Rule::RenamingTable),
132 "disallowed-unique-constraint" => Ok(Rule::DisallowedUniqueConstraint),
133 "ban-drop-database" => Ok(Rule::BanDropDatabase),
134 "prefer-bigint-over-int" => Ok(Rule::PreferBigintOverInt),
135 "prefer-bigint-over-smallint" => Ok(Rule::PreferBigintOverSmallint),
136 "prefer-identity" => Ok(Rule::PreferIdentity),
137 "prefer-repack" => Ok(Rule::PreferRepack),
138 "prefer-robust-stmts" => Ok(Rule::PreferRobustStmts),
139 "prefer-text-field" => Ok(Rule::PreferTextField),
140 "prefer-timestamptz" => Ok(Rule::PreferTimestampTz),
142 "prefer-timestamp-tz" => Ok(Rule::PreferTimestampTz),
143 "ban-char-field" => Ok(Rule::BanCharField),
144 "ban-drop-column" => Ok(Rule::BanDropColumn),
145 "ban-drop-table" => Ok(Rule::BanDropTable),
146 "ban-drop-not-null" => Ok(Rule::BanDropNotNull),
147 "transaction-nesting" => Ok(Rule::TransactionNesting),
148 "adding-required-field" => Ok(Rule::AddingRequiredField),
149 "ban-concurrent-index-creation-in-transaction" => {
150 Ok(Rule::BanConcurrentIndexCreationInTransaction)
151 }
152 "ban-create-domain-with-constraint" => Ok(Rule::BanCreateDomainWithConstraint),
153 "ban-alter-domain-with-add-constraint" => Ok(Rule::BanAlterDomainWithAddConstraint),
154 "ban-truncate-cascade" => Ok(Rule::BanTruncateCascade),
155 "require-timeout-settings" => Ok(Rule::RequireTimeoutSettings),
156 "ban-uncommitted-transaction" => Ok(Rule::BanUncommittedTransaction),
157 "require-enum-value-ordering" => Ok(Rule::RequireEnumValueOrdering),
158 "require-table-schema" => Ok(Rule::RequireTableSchema),
159 "identifier-too-long" => Ok(Rule::IdentifierTooLong),
160 "require-concurrent-partition-detach" => Ok(Rule::RequireConcurrentPartitionDetach),
161 "require-concurrent-reindex" => Ok(Rule::RequireConcurrentReindex),
162 _ => Err(format!("Unknown violation name: {s}")),
164 }
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct UnknownRuleName {
170 val: String,
171}
172
173impl std::fmt::Display for UnknownRuleName {
174 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
175 write!(f, "invalid rule name {}", self.val)
176 }
177}
178
179impl std::error::Error for UnknownRuleName {}
180
181impl std::str::FromStr for Rule {
182 type Err = UnknownRuleName;
183 fn from_str(s: &str) -> Result<Self, Self::Err> {
184 Rule::try_from(s).map_err(|_| UnknownRuleName { val: s.to_string() })
185 }
186}
187
188impl fmt::Display for Rule {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 let val = match &self {
191 Rule::RequireConcurrentIndexCreation => "require-concurrent-index-creation",
192 Rule::RequireConcurrentIndexDeletion => "require-concurrent-index-deletion",
193 Rule::ConstraintMissingNotValid => "constraint-missing-not-valid",
194 Rule::AddingFieldWithDefault => "adding-field-with-default",
195 Rule::AddingForeignKeyConstraint => "adding-foreign-key-constraint",
196 Rule::ChangingColumnType => "changing-column-type",
197 Rule::AddingNotNullableField => "adding-not-nullable-field",
198 Rule::AddingSerialPrimaryKeyField => "adding-serial-primary-key-field",
199 Rule::RenamingColumn => "renaming-column",
200 Rule::RenamingTable => "renaming-table",
201 Rule::DisallowedUniqueConstraint => "disallowed-unique-constraint",
202 Rule::BanDropDatabase => "ban-drop-database",
203 Rule::PreferBigintOverInt => "prefer-bigint-over-int",
204 Rule::PreferBigintOverSmallint => "prefer-bigint-over-smallint",
205 Rule::PreferIdentity => "prefer-identity",
206 Rule::PreferRepack => "prefer-repack",
207 Rule::PreferRobustStmts => "prefer-robust-stmts",
208 Rule::PreferTextField => "prefer-text-field",
209 Rule::PreferTimestampTz => "prefer-timestamp-tz",
210 Rule::BanCharField => "ban-char-field",
211 Rule::BanDropColumn => "ban-drop-column",
212 Rule::BanDropTable => "ban-drop-table",
213 Rule::BanDropNotNull => "ban-drop-not-null",
214 Rule::TransactionNesting => "transaction-nesting",
215 Rule::AddingRequiredField => "adding-required-field",
216 Rule::BanConcurrentIndexCreationInTransaction => {
217 "ban-concurrent-index-creation-in-transaction"
218 }
219 Rule::BanCreateDomainWithConstraint => "ban-create-domain-with-constraint",
220 Rule::UnusedIgnore => "unused-ignore",
221 Rule::BanAlterDomainWithAddConstraint => "ban-alter-domain-with-add-constraint",
222 Rule::BanTruncateCascade => "ban-truncate-cascade",
223 Rule::RequireTimeoutSettings => "require-timeout-settings",
224 Rule::BanUncommittedTransaction => "ban-uncommitted-transaction",
225 Rule::RequireEnumValueOrdering => "require-enum-value-ordering",
226 Rule::RequireTableSchema => "require-table-schema",
227 Rule::IdentifierTooLong => "identifier-too-long",
228 Rule::RequireConcurrentPartitionDetach => "require-concurrent-partition-detach",
229 Rule::RequireConcurrentReindex => "require-concurrent-reindex",
230 };
232 write!(f, "{val}")
233 }
234}
235
236impl<'de> Deserialize<'de> for Rule {
237 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
238 where
239 D: serde::Deserializer<'de>,
240 {
241 let s = String::deserialize(deserializer)?;
242 s.parse().map_err(serde::de::Error::custom)
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct Fix {
248 pub title: String,
249 pub edits: Vec<Edit>,
250}
251
252impl Fix {
253 fn new<T: Into<String>>(title: T, edits: Vec<Edit>) -> Fix {
254 Fix {
255 title: title.into(),
256 edits,
257 }
258 }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct Edit {
263 pub text_range: TextRange,
264 pub text: Option<String>,
266}
267impl Edit {
268 pub fn insert<T: Into<String>>(text: T, at: TextSize) -> Self {
269 Self {
270 text_range: TextRange::new(at, at),
271 text: Some(text.into()),
272 }
273 }
274 pub fn replace<T: Into<String>>(text_range: TextRange, text: T) -> Self {
275 Self {
276 text_range,
277 text: Some(text.into()),
278 }
279 }
280 pub fn delete(text_range: TextRange) -> Self {
281 Self {
282 text_range,
283 text: None,
284 }
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct Violation {
290 pub code: Rule,
292 pub message: String,
293 pub text_range: TextRange,
294 pub help: Option<String>,
295 pub fix: Option<Fix>,
296}
297
298impl Violation {
299 #[must_use]
300 pub fn for_node(code: Rule, message: String, node: &SyntaxNode) -> Self {
301 let range = node.text_range();
302
303 let start = node
304 .children_with_tokens()
305 .find(|x| !x.kind().is_trivia())
306 .map(|x| x.text_range().start())
307 .unwrap_or_else(|| range.start());
309
310 Self {
311 code,
312 text_range: TextRange::new(start, range.end()),
313 message,
314 help: None,
315 fix: None,
316 }
317 }
318
319 #[must_use]
320 pub fn for_range(code: Rule, message: String, text_range: TextRange) -> Self {
321 Self {
322 code,
323 text_range,
324 message,
325 help: None,
326 fix: None,
327 }
328 }
329
330 fn fix<F: Into<Option<Fix>>>(mut self, fix: F) -> Violation {
331 self.fix = fix.into();
332 self
333 }
334 fn help(mut self, help: impl Into<String>) -> Violation {
335 self.help = Some(help.into());
336 self
337 }
338}
339
340#[derive(Clone, Default)]
341pub struct LinterSettings {
342 pub pg_version: Version,
343 pub assume_in_transaction: bool,
344}
345
346pub struct Linter {
347 errors: Vec<Violation>,
348 ignores: Vec<Ignore>,
349 pub rules: FxHashSet<Rule>,
350 pub settings: LinterSettings,
351}
352
353impl Linter {
354 fn report(&mut self, error: Violation) {
355 self.errors.push(error);
356 }
357
358 fn ignore(&mut self, ignore: Ignore) {
359 self.ignores.push(ignore);
360 }
361
362 #[must_use]
363 pub fn lint(&mut self, file: &Parse<SourceFile>, text: &str) -> Vec<Violation> {
364 if has_disable_assume_in_transaction(&file.syntax_node()) {
365 self.settings.assume_in_transaction = false;
366 }
367
368 if self.rules.contains(&Rule::AddingFieldWithDefault) {
369 adding_field_with_default(self, file);
370 }
371 if self.rules.contains(&Rule::AddingForeignKeyConstraint) {
372 adding_foreign_key_constraint(self, file);
373 }
374 if self.rules.contains(&Rule::AddingNotNullableField) {
375 adding_not_null_field(self, file);
376 }
377 if self.rules.contains(&Rule::AddingSerialPrimaryKeyField) {
378 adding_primary_key_constraint(self, file);
379 }
380 if self.rules.contains(&Rule::AddingRequiredField) {
381 adding_required_field(self, file);
382 }
383 if self.rules.contains(&Rule::BanDropDatabase) {
384 ban_drop_database(self, file);
385 }
386 if self.rules.contains(&Rule::BanCharField) {
387 ban_char_field(self, file);
388 }
389 if self
390 .rules
391 .contains(&Rule::BanConcurrentIndexCreationInTransaction)
392 {
393 ban_concurrent_index_creation_in_transaction(self, file);
394 }
395 if self.rules.contains(&Rule::BanDropColumn) {
396 ban_drop_column(self, file);
397 }
398 if self.rules.contains(&Rule::BanDropNotNull) {
399 ban_drop_not_null(self, file);
400 }
401 if self.rules.contains(&Rule::BanDropTable) {
402 ban_drop_table(self, file);
403 }
404 if self.rules.contains(&Rule::ChangingColumnType) {
405 changing_column_type(self, file);
406 }
407 if self.rules.contains(&Rule::ConstraintMissingNotValid) {
408 constraint_missing_not_valid(self, file);
409 }
410 if self.rules.contains(&Rule::DisallowedUniqueConstraint) {
411 disallow_unique_constraint(self, file);
412 }
413 if self.rules.contains(&Rule::PreferBigintOverInt) {
414 prefer_bigint_over_int(self, file);
415 }
416 if self.rules.contains(&Rule::PreferBigintOverSmallint) {
417 prefer_bigint_over_smallint(self, file);
418 }
419 if self.rules.contains(&Rule::PreferIdentity) {
420 prefer_identity(self, file);
421 }
422 if self.rules.contains(&Rule::PreferRepack) {
423 prefer_repack(self, file);
424 }
425 if self.rules.contains(&Rule::PreferRobustStmts) {
426 prefer_robust_stmts(self, file);
427 }
428 if self.rules.contains(&Rule::PreferTextField) {
429 prefer_text_field(self, file);
430 }
431 if self.rules.contains(&Rule::PreferTimestampTz) {
432 prefer_timestamptz(self, file);
433 }
434 if self.rules.contains(&Rule::RenamingColumn) {
435 renaming_column(self, file);
436 }
437 if self.rules.contains(&Rule::RenamingTable) {
438 renaming_table(self, file);
439 }
440 if self.rules.contains(&Rule::RequireConcurrentIndexCreation) {
441 require_concurrent_index_creation(self, file);
442 }
443 if self.rules.contains(&Rule::RequireConcurrentIndexDeletion) {
444 require_concurrent_index_deletion(self, file);
445 }
446 if self.rules.contains(&Rule::BanCreateDomainWithConstraint) {
447 ban_create_domain_with_constraint(self, file);
448 }
449 if self.rules.contains(&Rule::BanAlterDomainWithAddConstraint) {
450 ban_alter_domain_with_add_constraint(self, file);
451 }
452 if self.rules.contains(&Rule::TransactionNesting) {
453 transaction_nesting(self, file);
454 }
455 if self.rules.contains(&Rule::BanTruncateCascade) {
456 ban_truncate_cascade(self, file);
457 }
458 if self.rules.contains(&Rule::RequireTimeoutSettings) {
459 require_timeout_settings(self, file);
460 }
461 if self.rules.contains(&Rule::BanUncommittedTransaction) {
462 ban_uncommitted_transaction(self, file);
463 }
464 if self.rules.contains(&Rule::RequireEnumValueOrdering) {
465 require_enum_value_ordering(self, file);
466 }
467 if self.rules.contains(&Rule::RequireTableSchema) {
468 require_table_schema(self, file);
469 }
470 if self.rules.contains(&Rule::IdentifierTooLong) {
471 identifier_too_long(self, file);
472 }
473 if self.rules.contains(&Rule::RequireConcurrentPartitionDetach) {
474 require_concurrent_partition_detach(self, file);
475 }
476 if self.rules.contains(&Rule::RequireConcurrentReindex) {
477 require_concurrent_reindex(self, file);
478 }
479 find_ignores(self, &file.syntax_node());
483
484 self.errors(text)
485 }
486
487 fn errors(&mut self, text: &str) -> Vec<Violation> {
488 let ignore_index = IgnoreIndex::new(text, &self.ignores);
489 let mut errors: Vec<Violation> = self
490 .errors
491 .iter()
492 .filter(|err| !ignore_index.contains(err.text_range, err.code))
495 .cloned()
496 .collect::<Vec<_>>();
497 errors.sort_by_key(|x| x.text_range.start());
499 errors
500 }
501
502 fn default_rules() -> FxHashSet<Rule> {
503 all::<Rule>()
504 .filter(|r| !r.is_opt_in())
505 .collect::<FxHashSet<_>>()
506 }
507
508 pub fn with_default_rules() -> Self {
509 let rules = Linter::default_rules();
510 Linter::from(rules)
511 }
512
513 pub fn with_rules(include: &[Rule], exclude: &[Rule]) -> Self {
514 let mut default_rules = Linter::default_rules();
515
516 for rule in include {
517 default_rules.insert(*rule);
518 }
519
520 for rule in exclude {
521 default_rules.remove(rule);
522 }
523
524 Linter::from(default_rules)
525 }
526
527 pub fn from(rules: impl IntoIterator<Item = Rule>) -> Self {
528 Self {
529 errors: vec![],
530 ignores: vec![],
531 rules: rules.into_iter().collect(),
532 settings: Default::default(),
533 }
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use insta::assert_debug_snapshot;
540
541 use super::*;
542
543 #[test]
544 fn prefer_timestamp_aliases() {
545 let rule1: Rule = "prefer-timestamp-tz".parse().unwrap();
546 let rule2: Rule = "prefer-timestamptz".parse().unwrap();
547 assert_eq!(rule1, rule2);
548 assert_debug_snapshot!(rule1, @"PreferTimestampTz");
549 }
550
551 #[test]
552 fn invalid_rule_name() {
553 let result: Result<Rule, _> = "invalid-rule-name".parse();
554 assert!(result.is_err());
555 }
556
557 #[test]
558 fn with_rules_opt_in_disabled_by_default() {
559 let linter = Linter::with_rules(&[], &[]);
560 assert!(!linter.rules.contains(&Rule::RequireTableSchema));
561 }
562
563 #[test]
564 fn with_rules_opt_in_enabled_via_include() {
565 let linter = Linter::with_rules(&[Rule::RequireTableSchema], &[]);
566 assert!(linter.rules.contains(&Rule::RequireTableSchema));
567 }
568
569 #[test]
570 fn with_rules_exclude_takes_precedence_over_include() {
571 let linter = Linter::with_rules(&[Rule::RequireTableSchema], &[Rule::RequireTableSchema]);
572 assert!(!linter.rules.contains(&Rule::RequireTableSchema));
573 }
574
575 #[test]
576 fn with_rules_exclude_removes_default_rule() {
577 let linter = Linter::with_rules(&[], &[Rule::BanDropTable]);
578 assert!(!linter.rules.contains(&Rule::BanDropTable));
579 }
580}