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