mago_linter/
lib.rs

1use std::sync::Arc;
2use std::sync::RwLock;
3use std::sync::RwLockReadGuard;
4
5use mago_interner::ThreadedInterner;
6use mago_project::module::Module;
7use mago_reflection::CodebaseReflection;
8use mago_reporting::IssueCollection;
9use mago_reporting::Level;
10
11use crate::plugin::Plugin;
12use crate::rule::ConfiguredRule;
13use crate::rule::Rule;
14use crate::runner::Runner;
15use crate::settings::RuleSettings;
16use crate::settings::Settings;
17
18pub mod consts;
19pub mod context;
20pub mod definition;
21pub mod directive;
22pub mod plugin;
23pub mod rule;
24pub mod scope;
25pub mod settings;
26
27mod ast;
28mod pragma;
29mod runner;
30mod utils;
31
32#[derive(Debug, Clone)]
33pub struct Linter {
34    settings: Settings,
35    interner: ThreadedInterner,
36    codebase: Arc<CodebaseReflection>,
37    rules: Arc<RwLock<Vec<ConfiguredRule>>>,
38}
39
40impl Linter {
41    /// Creates a new linter.
42    ///
43    /// This method will create a new linter with the given settings and interner.
44    ///
45    /// # Parameters
46    ///
47    /// - `settings`: The settings to use for the linter.
48    /// - `interner`: The interner to use for the linter, usually the same one used by the parser, and the module.
49    /// - `codebase`: The codebase reflection to use for the linter.
50    ///
51    /// # Returns
52    ///
53    /// A new linter.
54    pub fn new(settings: Settings, interner: ThreadedInterner, codebase: CodebaseReflection) -> Self {
55        Self { settings, interner, codebase: Arc::new(codebase), rules: Arc::new(RwLock::new(Vec::new())) }
56    }
57
58    /// Creates a new linter with all plugins enabled.
59    ///
60    /// This method will create a new linter with all plugins enabled. This is useful for
61    /// when you want to lint a source with all available rules.
62    ///
63    /// # Parameters
64    ///
65    /// - `settings`: The settings to use for the linter.
66    /// - `interner`: The interner to use for the linter, usually the same one used by the parser, and the module.
67    /// - `codebase`: The codebase reflection to use for the linter.
68    ///
69    /// # Returns
70    ///
71    /// A new linter with all plugins enabled.
72    pub fn with_all_plugins(settings: Settings, interner: ThreadedInterner, codebase: CodebaseReflection) -> Self {
73        let mut linter = Self::new(settings, interner, codebase);
74
75        crate::foreach_plugin!(|plugin| linter.add_plugin(plugin));
76
77        linter
78    }
79
80    /// Adds a plugin to the linter.
81    ///
82    /// This method will add a plugin to the linter. The plugin will be enabled if it is enabled in the settings.
83    /// If the plugin is not enabled in the settings, it will only be enabled if it is a default plugin.
84    ///
85    /// # Parameters
86    ///
87    /// - `plugin`: The plugin to add to the linter.
88    pub fn add_plugin(&mut self, plugin: impl Plugin) {
89        let plugin_definition = plugin.get_definition();
90        let plugin_slug = plugin_definition.get_slug();
91
92        tracing::debug!("Loading plugin: {plugin_slug}");
93
94        let enabled = self.settings.plugins.iter().any(|p| p.eq_ignore_ascii_case(&plugin_slug));
95        if !enabled {
96            if self.settings.default_plugins && plugin_definition.enabled_by_default {
97                tracing::debug!("Enabling default plugin: {plugin_slug}");
98            } else {
99                tracing::debug!("Plugin '{plugin_slug}' skipped, as it is not enabled by default or in the settings.",);
100
101                return;
102            }
103        } else {
104            tracing::debug!("Enabling plugin: {plugin_slug}");
105        }
106
107        for rule in plugin.get_rules() {
108            self.add_rule(&plugin_slug, rule);
109        }
110
111        tracing::debug!("Plugin '{plugin_slug}' loaded successfully.");
112    }
113
114    /// Adds a rule to the linter.
115    ///
116    /// This method will add a rule to the linter. The rule will be enabled if it is enabled in the settings.
117    ///
118    /// # Parameters
119    ///
120    /// - `plugin_slug`: The slug of the plugin that the rule belongs to.
121    /// - `rule`: The rule to add to the linter.
122    pub fn add_rule(&mut self, plugin_slug: impl Into<String>, rule: Box<dyn Rule>) {
123        let rule_definition = rule.get_definition();
124        let plugin_slug = plugin_slug.into();
125        let slug = format!("{}/{}", plugin_slug, rule_definition.get_slug());
126
127        tracing::debug!("Initializing rule `{slug}`...");
128
129        let settings = self.settings.get_rule_settings(slug.as_str());
130        if !rule_definition.supports_php_version(self.settings.php_version) {
131            tracing::debug!("Rule `{slug}` does not support PHP version `{}`.", self.settings.php_version);
132
133            if let Some(version) = rule_definition.minimum_supported_php_version {
134                tracing::debug!("Rule `{slug}` requires PHP >= `{version}`.");
135            }
136
137            if let Some(version) = rule_definition.maximum_supported_php_version {
138                tracing::debug!("Rule `{slug}` requires PHP < `{version}`.");
139            }
140
141            if settings.is_some() {
142                tracing::warn!("Configuration for rule `{slug}` ignored due to PHP version mismatch.");
143            }
144
145            tracing::debug!("Rule `{slug}` skipped due to PHP version mismatch.");
146
147            return;
148        }
149
150        let settings = settings.cloned().unwrap_or_else(|| {
151            tracing::debug!("No configuration found for rule `{slug}`, using default.");
152
153            RuleSettings::from_level(rule_definition.level)
154        });
155
156        if !settings.enabled {
157            tracing::debug!("Rule `{slug}` has been disabled.");
158
159            return;
160        }
161
162        let level = match settings.level {
163            Some(level) => level,
164            None => match rule_definition.level {
165                Some(level) => level,
166                None => {
167                    tracing::debug!("Rule `{slug}` is disabled");
168
169                    return;
170                }
171            },
172        };
173
174        tracing::debug!("Rule `{slug}` is enabled with level `{level}`.");
175
176        self.rules.write().expect("Unable to add rule: poisoned lock").push(ConfiguredRule {
177            slug,
178            level,
179            settings,
180            rule,
181        });
182    }
183
184    /// Returns a read lock for the vector of [`ConfiguredRule`] instances maintained by the linter.
185    ///
186    /// This method provides direct, read-only access to all currently configured rules.
187    /// You can iterate over them or inspect their fields (e.g., `slug`, `level`, etc.).
188    ///
189    /// # Panics
190    ///
191    /// If the underlying `RwLock` is poisoned (e.g. another thread panicked while holding
192    /// the lock), this method will panic with `"Unable to get rule: poisoned lock"`.
193    pub fn get_configured_rules(&self) -> RwLockReadGuard<'_, Vec<ConfiguredRule>> {
194        self.rules.read().expect("Unable to get rule: poisoned lock")
195    }
196
197    /// Retrieves the **level** of a rule by its fully qualified slug.
198    ///
199    /// This method looks up a configured rule by its slug (e.g., `"plugin-slug/rule-slug"`)
200    /// and returns the rule’s current level (e.g., `Level::Warning`).
201    ///
202    /// # Parameters
203    ///
204    /// - `slug`: The fully qualified slug of the rule (e.g. `"best-practices/excessive-nesting"`).
205    ///
206    /// # Returns
207    ///
208    /// An [`Option<Level>`]. Returns `Some(Level)` if the slug is found among the currently
209    /// configured rules, or `None` if no matching rule is found.
210    pub fn get_rule_level(&self, slug: &str) -> Option<Level> {
211        let configured_rules = self.rules.read().expect("Unable to get rule: poisoned lock");
212
213        configured_rules.iter().find(|r| r.slug == slug).map(|r| r.level)
214    }
215
216    /// Lints the given module.
217    ///
218    /// This method will lint the given module and return a collection of issues.
219    ///
220    /// # Parameters
221    ///
222    /// - `module`: The module to lint.
223    ///
224    /// # Returns
225    ///
226    /// A collection of issues.
227    pub fn lint(&self, module: &Module) -> IssueCollection {
228        let configured_rules = self.rules.read().expect("Unable to read rules: poisoned lock");
229        if configured_rules.is_empty() {
230            tracing::warn!("Linting aborted - no rules configured.");
231
232            return IssueCollection::new();
233        }
234
235        let program = module.parse(&self.interner);
236        let mut runner = Runner::new(self.settings.php_version, &self.interner, &self.codebase, module, &program);
237        for configured_rule in configured_rules.iter() {
238            runner.run(configured_rule);
239        }
240
241        runner.finish()
242    }
243}