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