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::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// xtask:new-rule:rule-import
66
67#[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    // xtask:new-rule:error-name
107}
108
109impl Rule {
110    /// Rules that are opt-in are not enabled by default.
111    /// They must be explicitly included via configuration.
112    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            // this is typo'd so we just support both
141            "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            // xtask:new-rule:str-name
163            _ => 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            // xtask:new-rule:variant-to-name
231        };
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    // TODO: does this need to be an Option?
265    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    // TODO: should this be String instead?
291    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            // Not sure we actually hit this, but just being safe
308            .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        // xtask:new-rule:rule-call
480
481        // locate any ignores in the file
482        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            // TODO: we should have errors for when there was an ignore but that
493            // ignore didn't actually ignore anything
494            .filter(|err| !ignore_index.contains(err.text_range, err.code))
495            .cloned()
496            .collect::<Vec<_>>();
497        // ensure we order them by where they appear in the file
498        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}