Skip to main content

alpm_lint/lint_rules/
store.rs

1//! Access and filtering to all registered lints.
2//!
3//! # Note
4//!
5//! All lints need to be registered in the private `LintStore::register` function when adding a new
6//! lint rule!
7
8use std::{
9    collections::{BTreeMap, btree_map},
10    fmt,
11};
12
13use alpm_lint_config::{LintConfiguration, LintRuleConfiguration, LintRuleConfigurationOptionName};
14use serde::Serialize;
15
16use crate::{
17    ScopedName,
18    internal_prelude::{Level, LintGroup, LintRule, LintScope},
19    lint_rules::source_info::{
20        duplicate_architecture::DuplicateArchitecture,
21        invalid_spdx_license::NotSPDX,
22        long_values_aurweb::LongValuesAurweb,
23        no_architecture::NoArchitecture,
24        openpgp_key_id::OpenPGPKeyId,
25        undefined_architecture::UndefinedArchitecture,
26        unknown_architecture::UnknownArchitecture,
27        unsafe_checksum::UnsafeChecksum,
28    },
29};
30
31/// The data representation of a singular lint rule.
32///
33/// This is used to expose lints via the CLI so that the lints can be used in website generation or
34/// for development integration.
35#[derive(Clone, Debug, Serialize)]
36pub struct SerializableLintRule {
37    name: String,
38    scoped_name: String,
39    scope: LintScope,
40    level: Level,
41    groups: Vec<LintGroup>,
42    documentation: String,
43    option_names: Vec<String>,
44}
45
46/// The constructor function type that is used by each implementation of [`LintRule`].
47///
48/// E.g. [`DuplicateArchitecture::new_boxed`]. These constructors are saved in the [`LintStore`].
49type LintConstructor = fn(&LintRuleConfiguration) -> Box<dyn LintRule>;
50
51/// A map of lint rule name and generic [`LintRule`] implementations.
52///
53/// Used in [`LintStore`] to describe tuples of lint rule names and [`LintRule`] implementations.
54type LintMap = BTreeMap<String, Box<dyn LintRule>>;
55
56/// The [`LintStore`], which contains all available and known lint rules.
57///
58/// It can be used to further filter and select lints based on various criteria.
59pub struct LintStore {
60    config: LintConfiguration,
61    lint_constructors: Vec<LintConstructor>,
62    initialized_lints: LintMap,
63}
64
65impl fmt::Debug for LintStore {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.debug_struct("LintStore")
68            .field("config", &self.config)
69            .field("lint_constructors", &"Vec<LintConstructor>")
70            .field("initialized_lints", &"LintMap")
71            .finish()
72    }
73}
74
75impl LintStore {
76    /// Creates a new [`LintStore`].
77    ///
78    /// This adds all known lint rules to the store.
79    pub fn new(config: LintConfiguration) -> Self {
80        let mut store = Self {
81            config,
82            lint_constructors: Vec::new(),
83            initialized_lints: BTreeMap::new(),
84        };
85        store.register();
86        store.initialize_lint_rules();
87
88        store
89    }
90
91    /// Registers all lints that are made available in the store.
92    ///
93    /// # Note
94    ///
95    /// New lints must be specified in this function!
96    fn register(&mut self) {
97        // **IMPORTANT** NOTE: ⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️
98        // When you edit this, please sort the array while at it :)
99        // Much appreciated!
100        self.lint_constructors = vec![
101            DuplicateArchitecture::new_boxed,
102            LongValuesAurweb::new_boxed,
103            NoArchitecture::new_boxed,
104            NotSPDX::new_boxed,
105            OpenPGPKeyId::new_boxed,
106            UndefinedArchitecture::new_boxed,
107            UnknownArchitecture::new_boxed,
108            UnsafeChecksum::new_boxed,
109        ];
110    }
111
112    /// Initializes and configures all linting rules.
113    ///
114    /// This function instantly returns if the lints have already been initialized.
115    fn initialize_lint_rules(&mut self) {
116        // Early return if the lints are already initialized.
117        if !self.initialized_lints.is_empty() {
118            return;
119        }
120
121        for lint in &self.lint_constructors {
122            let initialized = lint(&self.config.options);
123
124            self.initialized_lints
125                .insert(initialized.scoped_name(), initialized);
126        }
127    }
128
129    /// Returns a reference to the map of all available and configured lint rules.
130    pub fn lint_rules(&self) -> &LintMap {
131        &self.initialized_lints
132    }
133
134    /// Returns a specific lint rule by its scoped name.
135    ///
136    /// Returns [`None`] if no lint rule with a matching `name` exists.
137    // False positive lint warning on the return type.
138    #[allow(clippy::borrowed_box)]
139    pub fn lint_rule_by_name(&self, name: &ScopedName) -> Option<&Box<dyn LintRule>> {
140        self.initialized_lints.get(&name.to_string())
141    }
142
143    /// Returns a map of all available and configured lint rules as [`SerializableLintRule`].
144    pub fn serializable_lint_rules(&self) -> BTreeMap<String, SerializableLintRule> {
145        let mut map = BTreeMap::new();
146        for (scoped_name, lint) in &self.initialized_lints {
147            // Make sure that there's no duplicate key.
148            // We explicitly choose a `panic` as this is considered a hard consistency error.
149            //
150            // This is also covered by a test case, so it should really never happen in a release.
151            if map.contains_key(scoped_name) {
152                panic!("Encountered duplicate lint with name: {scoped_name}");
153            }
154
155            map.insert(
156                scoped_name.clone(),
157                SerializableLintRule {
158                    name: lint.name().to_string(),
159                    scoped_name: scoped_name.clone(),
160                    scope: lint.scope(),
161                    level: lint.level(),
162                    groups: lint.groups().to_vec(),
163                    documentation: lint.documentation().to_string(),
164                    option_names: lint
165                        .configuration_options()
166                        .iter()
167                        .map(LintRuleConfigurationOptionName::to_string)
168                        .collect(),
169                },
170            );
171        }
172
173        map
174    }
175
176    /// Returns lint rules that match a filter consisting of [`LintScope`] and [`Level`].
177    ///
178    /// This function filters out all lint rules that are not explicitly included **and**
179    /// - assigned to a deactivated group,
180    /// - **or** have a level above the max_level,
181    /// - **or** are explicitly ignored.
182    pub fn filtered_lint_rules<'a>(
183        &'a self,
184        scope: &LintScope,
185        max_level: Level,
186    ) -> FilteredLintRules<'a> {
187        FilteredLintRules::new(
188            &self.config,
189            self.initialized_lints.iter(),
190            *scope,
191            max_level,
192        )
193    }
194}
195
196/// The iterator that is returned by `LintConfiguration.initialized_lints.iter()`.
197type BTreeMapRuleIter<'a> = btree_map::Iter<'a, String, Box<dyn LintRule>>;
198
199/// An Iterator that allows iterating over lint rules filtered by a specific configuration file.
200///
201/// # Examples
202///
203/// ```
204/// use alpm_lint::{Level, LintScope, LintStore, config::LintConfiguration};
205///
206/// // Build a default config and use it to filter all lints from the store.
207/// let config = LintConfiguration::default();
208/// let store = LintStore::new(config);
209/// let mut iterator = store.filtered_lint_rules(&LintScope::SourceInfo, Level::Suggest);
210///
211/// // We get a lint
212/// assert!(iterator.next().is_some())
213/// ```
214pub struct FilteredLintRules<'a> {
215    /// The configuration used for filtering lint rules.
216    config: &'a LintConfiguration,
217    /// The unfiltered iterator over all lint rules.
218    rules_iter: BTreeMapRuleIter<'a>,
219    /// The scope in which lint rules should be.
220    scope: LintScope,
221    /// The lowest [`Level`] from which lint rules are considered.
222    min_level: Level,
223}
224
225impl<'a> FilteredLintRules<'a> {
226    /// Creates a new [`FilteredLintRules`].
227    pub fn new(
228        config: &'a LintConfiguration,
229        rules_iter: BTreeMapRuleIter<'a>,
230        scope: LintScope,
231        min_level: Level,
232    ) -> Self {
233        Self {
234            config,
235            rules_iter,
236            scope,
237            min_level,
238        }
239    }
240}
241
242impl std::fmt::Debug for FilteredLintRules<'_> {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        f.debug_struct("FilteredLintRules")
245            .field("config", &self.config)
246            .field("scope", &self.scope)
247            .field("min_level", &self.min_level)
248            .finish()
249    }
250}
251
252impl<'a> Iterator for FilteredLintRules<'a> {
253    type Item = (&'a String, &'a Box<dyn LintRule>);
254
255    // Allow while_let on an iterator. This pattern is required to give us more control
256    // over `self.rules_iter`.
257    #[allow(clippy::while_let_on_iterator)]
258    fn next(&mut self) -> Option<Self::Item> {
259        'outer: while let Some((name, rule)) = self.rules_iter.next() {
260            // Check whether this rule is explicitly disabled.
261            // If so immediately skip it.
262            if self.config.disabled_rules.contains(name) {
263                continue;
264            }
265
266            // Check whether this rule is explicitly enabled.
267            // If so immediately return it.
268            if self.config.enabled_rules.contains(name) {
269                return Some((name, rule));
270            }
271
272            // Skip any lint rules that're below the specified severity level threshold.
273            // The higher the number, the less important the Level.
274            // (e.g. Error=1, Suggest=4).
275            if rule.level() as isize > self.min_level as isize {
276                continue;
277            }
278
279            // If the groups are not empty, check whether all lint groups are enabled in the
280            // configuration file. If so, the lint will be returned, otherwise skip it.
281            let groups = rule.groups();
282            if !groups.is_empty() {
283                // As there are very few groups, an `n * m` lookup is reasonable.
284                for group in groups {
285                    if !self.config.groups.contains(group) {
286                        // A group isn't enabled, skip the rule.
287                        continue 'outer;
288                    }
289                }
290            }
291
292            // Make sure that the selected scope includes this specific lint rule.
293            let lint_rule_scope = rule.scope();
294            if !self.scope.contains(&lint_rule_scope) {
295                continue;
296            }
297
298            return Some((name, rule));
299        }
300
301        None
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// Unit tests for the LintStore itself
310    mod lint_store {
311        use std::collections::HashSet;
312
313        use alpm_lint_config::{LintConfiguration, LintRuleConfiguration};
314        use testresult::TestResult;
315
316        use super::LintStore;
317
318        /// Ensures that no two lint rules have the same scoped name.
319        ///
320        /// This is extremely important as to prevent naming conflicts and to ensure that each lint
321        /// rule has a unique identifier.
322        #[test]
323        fn no_duplicate_scoped_names() {
324            let store = LintStore::new(LintConfiguration::default());
325            let config = LintRuleConfiguration::default();
326
327            // Test the raw constructors for duplicate scoped names
328            let constructors = store.lint_constructors;
329            let mut scoped_names = HashSet::<String>::new();
330
331            for constructor in constructors {
332                let lint_rule = constructor(&config);
333                let scoped_name = lint_rule.scoped_name();
334
335                if scoped_names.contains(&scoped_name) {
336                    panic!("Found duplicate scoped lint rule name: {scoped_name}");
337                }
338                scoped_names.insert(scoped_name);
339            }
340        }
341
342        /// Ensures that all lint rule names only consist of lower-case alphanumerics or
343        /// underscores.
344        #[test]
345        fn lowercase_alphanum_underscore_names() -> TestResult {
346            let store = LintStore::new(LintConfiguration::default());
347            let config = LintRuleConfiguration::default();
348
349            for constructor in store.lint_constructors {
350                let lint_rule = constructor(&config);
351                let name = lint_rule.name();
352
353                let is_valid = name
354                    .chars()
355                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
356
357                if !is_valid {
358                    let scoped_name = lint_rule.scoped_name();
359                    panic!(
360                        "Found lint rule name with invalid character: '{scoped_name}'
361Lint rule names are only allowed to consist of lowercase alphanumeric characters and underscores."
362                    );
363                }
364            }
365
366            Ok(())
367        }
368    }
369
370    /// Tests for the the FilteredLintRules iterator
371    mod filtered_lint_rules {
372        use std::collections::BTreeMap;
373
374        use alpm_lint_config::{LintConfiguration, LintGroup};
375
376        use super::FilteredLintRules;
377        use crate::internal_prelude::*;
378        //
379        // The iterator is explicitly tested without the store as the store will always contain all
380        // lints, meaning that the list of tested lints might change over time.
381        //
382        // To isolate things a bit and to make testing deterministic, we create some "MockLintRule"s
383        // on which we perform the filtering.
384
385        /// Test implementation of [`LintRule`] for unit testing.
386        struct MockLintRule {
387            name: &'static str,
388            scope: LintScope,
389            level: Level,
390            groups: &'static [LintGroup],
391        }
392
393        impl LintRule for MockLintRule {
394            fn name(&self) -> &'static str {
395                self.name
396            }
397
398            fn scope(&self) -> LintScope {
399                self.scope
400            }
401
402            fn level(&self) -> Level {
403                self.level
404            }
405
406            fn groups(&self) -> &'static [LintGroup] {
407                self.groups
408            }
409
410            fn run(
411                &self,
412                _resources: &Resources,
413                _issues: &mut Vec<LintIssue>,
414            ) -> Result<(), Error> {
415                Ok(())
416            }
417
418            fn documentation(&self) -> String {
419                format!("Documentation for {}", self.name)
420            }
421
422            fn help_text(&self) -> String {
423                format!("Help for {}", self.name)
424            }
425        }
426
427        impl MockLintRule {
428            /// Creates a mock lint rules.
429            fn new_boxed(name: &'static str, scope: LintScope) -> Box<dyn LintRule> {
430                Box::new(Self {
431                    name,
432                    scope,
433                    level: Level::Warn,
434                    groups: &[],
435                })
436            }
437
438            /// Creates a mock lint rule with specified groups.
439            fn with_groups(
440                name: &'static str,
441                scope: LintScope,
442                groups: &'static [LintGroup],
443            ) -> Box<dyn LintRule> {
444                Box::new(Self {
445                    name,
446                    scope,
447                    level: Level::Warn,
448                    groups,
449                })
450            }
451
452            /// Creates a mock lint rule with a specific level.
453            fn with_level(name: &'static str, scope: LintScope, level: Level) -> Box<dyn LintRule> {
454                Box::new(Self {
455                    name,
456                    scope,
457                    level,
458                    groups: &[],
459                })
460            }
461        }
462
463        /// Helper function to assert the next rule name from a filtered iterator.
464        fn next_is(filtered: &mut FilteredLintRules, expected_name: &str) {
465            let (name, _) = filtered
466                .next()
467                .unwrap_or_else(|| panic!("Should have {expected_name}"));
468            assert_eq!(name, expected_name);
469        }
470
471        /// Helper function to assert that the filtered iterator has no more rules.
472        fn next_is_none(filtered: &mut FilteredLintRules) {
473            assert!(filtered.next().is_none(), "Should have no more rules");
474        }
475
476        /// Creates a set of mock lint rules for testing with differing properties.
477        fn create_mock_rules() -> BTreeMap<String, Box<dyn LintRule>> {
478            let mut rules = BTreeMap::new();
479
480            // Always enabled for SourceInfo
481            let rule1 = MockLintRule::new_boxed("test_rule_1", LintScope::SourceInfo);
482            // Always enabled for PackageBuild
483            let rule2 = MockLintRule::new_boxed("test_rule_2", LintScope::PackageBuild);
484            // Pedantic SourceInfo Rule
485            let rule3 = MockLintRule::with_groups(
486                "pedantic_rule",
487                LintScope::SourceInfo,
488                &[LintGroup::Pedantic],
489            );
490            // Testing Group SourceInfo Rule
491            let rule4 = MockLintRule::with_groups(
492                "testing_rule",
493                LintScope::SourceInfo,
494                &[LintGroup::Testing],
495            );
496            // Pedantic **and** Testing groups SourceInfo Rule
497            let rule5 = MockLintRule::with_groups(
498                "multi_group_rule",
499                LintScope::SourceInfo,
500                &[LintGroup::Pedantic, LintGroup::Testing],
501            );
502            let rule6 = MockLintRule::with_level("with_error", LintScope::SourceInfo, Level::Error);
503
504            rules.insert(rule1.scoped_name(), rule1);
505            rules.insert(rule2.scoped_name(), rule2);
506            rules.insert(rule3.scoped_name(), rule3);
507            rules.insert(rule4.scoped_name(), rule4);
508            rules.insert(rule5.scoped_name(), rule5);
509            rules.insert(rule6.scoped_name(), rule6);
510
511            rules
512        }
513
514        /// Ensures that filtering respects scope boundaries.
515        #[test]
516        fn filters_by_scope() {
517            let config = LintConfiguration::default();
518            let rules = create_mock_rules();
519            let mut filtered = FilteredLintRules::new(
520                &config,
521                rules.iter(),
522                LintScope::SourceInfo,
523                Level::Suggest,
524            );
525
526            // Should include only ungrouped SourceInfo scope rules
527            // test_rule_1 is the only rule that's by default enabled for the SourceInfo scope.
528            next_is(&mut filtered, "source_info::test_rule_1");
529            next_is(&mut filtered, "source_info::with_error");
530            next_is_none(&mut filtered);
531        }
532
533        /// Ensures that explicitly disabled rules are excluded.
534        #[test]
535        fn respects_disabled_rules() {
536            let config = LintConfiguration {
537                disabled_rules: vec![
538                    "source_info::test_rule_1".to_string(),
539                    "source_info::with_error".to_string(),
540                ],
541                ..Default::default()
542            };
543            let rules = create_mock_rules();
544            let mut filtered = FilteredLintRules::new(
545                &config,
546                rules.iter(),
547                LintScope::SourceInfo,
548                Level::Suggest,
549            );
550
551            // Should exclude the disabled rule.
552            next_is_none(&mut filtered);
553        }
554
555        /// Ensures that explicitly enabled rules bypass group filtering.
556        #[test]
557        fn includes_explicitly_enabled_rules() {
558            let config = LintConfiguration {
559                enabled_rules: vec!["source_info::pedantic_rule".to_string()],
560                groups: vec![], // No groups enabled
561                ..Default::default()
562            };
563            let rules = create_mock_rules();
564            let mut filtered = FilteredLintRules::new(
565                &config,
566                rules.iter(),
567                LintScope::SourceInfo,
568                Level::Suggest,
569            );
570
571            // Should include the explicitly enabled pedantic rule even with no groups
572            next_is(&mut filtered, "source_info::pedantic_rule");
573            next_is(&mut filtered, "source_info::test_rule_1");
574            next_is(&mut filtered, "source_info::with_error");
575            next_is_none(&mut filtered);
576        }
577
578        /// Ensures that disabling rules takes precedence over enabling rules.
579        #[test]
580        fn disabled_rules_take_precedence() {
581            let config = LintConfiguration {
582                disabled_rules: vec![
583                    "source_info::test_rule_1".to_string(),
584                    "source_info::with_error".to_string(),
585                ],
586                enabled_rules: vec!["source_info::test_rule_1".to_string()],
587                ..Default::default()
588            };
589            let rules = create_mock_rules();
590            let mut filtered = FilteredLintRules::new(
591                &config,
592                rules.iter(),
593                LintScope::SourceInfo,
594                Level::Suggest,
595            );
596
597            // Disabled rules are checked first and take precedence
598            next_is_none(&mut filtered);
599        }
600
601        /// Ensures that rules with multiple groups require *ALL* groups to be enabled.
602        #[test]
603        fn multi_group_requires_all_groups() {
604            let config = LintConfiguration {
605                groups: vec![LintGroup::Pedantic], // Only one group enabled
606                ..Default::default()
607            };
608            let rules = create_mock_rules();
609            let mut filtered = FilteredLintRules::new(
610                &config,
611                rules.iter(),
612                LintScope::SourceInfo,
613                Level::Suggest,
614            );
615
616            // Should get pedantic_rule and test_rule_1, but not multi_group_rule
617            next_is(&mut filtered, "source_info::pedantic_rule");
618            next_is(&mut filtered, "source_info::test_rule_1");
619            next_is(&mut filtered, "source_info::with_error");
620            next_is_none(&mut filtered);
621        }
622
623        /// Ensures that multi-group lint rules are included when all their groups are enabled.
624        #[test]
625        fn multi_group_included() {
626            let config = LintConfiguration {
627                groups: vec![LintGroup::Pedantic, LintGroup::Testing],
628                ..Default::default()
629            };
630            let rules = create_mock_rules();
631            let mut filtered = FilteredLintRules::new(
632                &config,
633                rules.iter(),
634                LintScope::SourceInfo,
635                Level::Suggest,
636            );
637
638            // Should get all SourceInfo rules: multi_group_rule, pedantic_rule, test_rule_1,
639            // testing_rule
640            next_is(&mut filtered, "source_info::multi_group_rule");
641            next_is(&mut filtered, "source_info::pedantic_rule");
642            next_is(&mut filtered, "source_info::test_rule_1");
643            next_is(&mut filtered, "source_info::testing_rule");
644            next_is(&mut filtered, "source_info::with_error");
645            next_is_none(&mut filtered);
646        }
647
648        /// Ensures that the scope hierarchy is respected in filtering.
649        #[test]
650        fn source_repository_scope() {
651            let config = LintConfiguration::default();
652            let rules = create_mock_rules();
653            let mut filtered = FilteredLintRules::new(
654                &config,
655                rules.iter(),
656                LintScope::SourceRepository,
657                Level::Suggest,
658            );
659
660            // SourceRepository scope should include both SourceInfo and PackageBuild rules
661            // Both test_rule_1 and test_rule_2 are ungrouped and match the scope
662            next_is(&mut filtered, "package_build::test_rule_2");
663            next_is(&mut filtered, "source_info::test_rule_1");
664            next_is(&mut filtered, "source_info::with_error");
665            next_is_none(&mut filtered);
666        }
667
668        /// Ensures that rules are filtered by minimum level threshold.
669        #[test]
670        fn filters_by_level() {
671            let config = LintConfiguration::default();
672            let rules = create_mock_rules();
673
674            // Test with Error level threshold
675            let mut filtered =
676                FilteredLintRules::new(&config, rules.iter(), LintScope::SourceInfo, Level::Error);
677            next_is(&mut filtered, "source_info::with_error");
678            next_is_none(&mut filtered);
679        }
680    }
681}