Skip to main content

squawk_linter/
lib.rs

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