cargo/lints/
mod.rs

1use crate::core::{Edition, Feature, Features, MaybePackage, Package};
2use crate::{CargoResult, GlobalContext};
3
4use annotate_snippets::AnnotationKind;
5use annotate_snippets::Level;
6use annotate_snippets::Snippet;
7use cargo_util_schemas::manifest::TomlLintLevel;
8use cargo_util_schemas::manifest::TomlToolLints;
9use pathdiff::diff_paths;
10
11use std::borrow::Cow;
12use std::fmt::Display;
13use std::ops::Range;
14use std::path::Path;
15
16pub mod rules;
17pub use rules::LINTS;
18
19const LINT_GROUPS: &[LintGroup] = &[TEST_DUMMY_UNSTABLE];
20
21/// Scope at which a lint runs: package-level or workspace-level.
22pub enum ManifestFor<'a> {
23    /// Lint runs for a specific package.
24    Package(&'a Package),
25    /// Lint runs for workspace-level config.
26    Workspace(&'a MaybePackage),
27}
28
29impl ManifestFor<'_> {
30    fn lint_level(&self, pkg_lints: &TomlToolLints, lint: Lint) -> (LintLevel, LintLevelReason) {
31        lint.level(pkg_lints, self.edition(), self.unstable_features())
32    }
33
34    pub fn contents(&self) -> &str {
35        match self {
36            ManifestFor::Package(p) => p.manifest().contents(),
37            ManifestFor::Workspace(p) => p.contents(),
38        }
39    }
40
41    pub fn document(&self) -> &toml::Spanned<toml::de::DeTable<'static>> {
42        match self {
43            ManifestFor::Package(p) => p.manifest().document(),
44            ManifestFor::Workspace(p) => p.document(),
45        }
46    }
47
48    pub fn edition(&self) -> Edition {
49        match self {
50            ManifestFor::Package(p) => p.manifest().edition(),
51            ManifestFor::Workspace(p) => p.edition(),
52        }
53    }
54
55    pub fn unstable_features(&self) -> &Features {
56        match self {
57            ManifestFor::Package(p) => p.manifest().unstable_features(),
58            ManifestFor::Workspace(p) => p.unstable_features(),
59        }
60    }
61}
62
63impl<'a> From<&'a Package> for ManifestFor<'a> {
64    fn from(value: &'a Package) -> ManifestFor<'a> {
65        ManifestFor::Package(value)
66    }
67}
68
69impl<'a> From<&'a MaybePackage> for ManifestFor<'a> {
70    fn from(value: &'a MaybePackage) -> ManifestFor<'a> {
71        ManifestFor::Workspace(value)
72    }
73}
74
75pub fn analyze_cargo_lints_table(
76    manifest: ManifestFor<'_>,
77    manifest_path: &Path,
78    cargo_lints: &TomlToolLints,
79    error_count: &mut usize,
80    gctx: &GlobalContext,
81) -> CargoResult<()> {
82    let manifest_path = rel_cwd_manifest_path(manifest_path, gctx);
83    let mut unknown_lints = Vec::new();
84    for lint_name in cargo_lints.keys().map(|name| name) {
85        let Some((name, default_level, edition_lint_opts, feature_gate)) =
86            find_lint_or_group(lint_name)
87        else {
88            unknown_lints.push(lint_name);
89            continue;
90        };
91
92        let (_, reason, _) = level_priority(
93            name,
94            *default_level,
95            *edition_lint_opts,
96            cargo_lints,
97            manifest.edition(),
98        );
99
100        // Only run analysis on user-specified lints
101        if !reason.is_user_specified() {
102            continue;
103        }
104
105        // Only run this on lints that are gated by a feature
106        if let Some(feature_gate) = feature_gate
107            && !manifest.unstable_features().is_enabled(feature_gate)
108        {
109            report_feature_not_enabled(
110                name,
111                feature_gate,
112                &manifest,
113                &manifest_path,
114                error_count,
115                gctx,
116            )?;
117        }
118    }
119
120    rules::output_unknown_lints(
121        unknown_lints,
122        &manifest,
123        &manifest_path,
124        cargo_lints,
125        error_count,
126        gctx,
127    )?;
128
129    Ok(())
130}
131
132fn find_lint_or_group<'a>(
133    name: &str,
134) -> Option<(
135    &'static str,
136    &LintLevel,
137    &Option<(Edition, LintLevel)>,
138    &Option<&'static Feature>,
139)> {
140    if let Some(lint) = LINTS.iter().find(|l| l.name == name) {
141        Some((
142            lint.name,
143            &lint.default_level,
144            &lint.edition_lint_opts,
145            &lint.feature_gate,
146        ))
147    } else if let Some(group) = LINT_GROUPS.iter().find(|g| g.name == name) {
148        Some((
149            group.name,
150            &group.default_level,
151            &group.edition_lint_opts,
152            &group.feature_gate,
153        ))
154    } else {
155        None
156    }
157}
158
159fn report_feature_not_enabled(
160    lint_name: &str,
161    feature_gate: &Feature,
162    manifest: &ManifestFor<'_>,
163    manifest_path: &str,
164    error_count: &mut usize,
165    gctx: &GlobalContext,
166) -> CargoResult<()> {
167    let document = manifest.document();
168    let contents = manifest.contents();
169    let dash_feature_name = feature_gate.name().replace("_", "-");
170    let title = format!("use of unstable lint `{}`", lint_name);
171    let label = format!(
172        "this is behind `{}`, which is not enabled",
173        dash_feature_name
174    );
175    let help = format!(
176        "consider adding `cargo-features = [\"{}\"]` to the top of the manifest",
177        dash_feature_name
178    );
179
180    let key_path = match manifest {
181        ManifestFor::Package(_) => &["lints", "cargo", lint_name][..],
182        ManifestFor::Workspace(_) => &["workspace", "lints", "cargo", lint_name][..],
183    };
184    let Some(span) = get_key_value_span(document, key_path) else {
185        // This lint is handled by either package or workspace lint.
186        return Ok(());
187    };
188
189    let report = [Level::ERROR
190        .primary_title(title)
191        .element(
192            Snippet::source(contents)
193                .path(manifest_path)
194                .annotation(AnnotationKind::Primary.span(span.key).label(label)),
195        )
196        .element(Level::HELP.message(help))];
197
198    *error_count += 1;
199    gctx.shell().print_report(&report, true)?;
200
201    Ok(())
202}
203
204#[derive(Clone)]
205pub struct TomlSpan {
206    pub key: Range<usize>,
207    pub value: Range<usize>,
208}
209
210pub fn get_key_value<'doc>(
211    document: &'doc toml::Spanned<toml::de::DeTable<'static>>,
212    path: &[&str],
213) -> Option<(
214    &'doc toml::Spanned<Cow<'doc, str>>,
215    &'doc toml::Spanned<toml::de::DeValue<'static>>,
216)> {
217    let mut table = document.get_ref();
218    let mut iter = path.into_iter().peekable();
219    while let Some(key) = iter.next() {
220        let key_s: &str = key.as_ref();
221        let (key, item) = table.get_key_value(key_s)?;
222        if iter.peek().is_none() {
223            return Some((key, item));
224        }
225        if let Some(next_table) = item.get_ref().as_table() {
226            table = next_table;
227        }
228        if iter.peek().is_some() {
229            if let Some(array) = item.get_ref().as_array() {
230                let next = iter.next().unwrap();
231                return array.iter().find_map(|item| match item.get_ref() {
232                    toml::de::DeValue::String(s) if s == next => Some((key, item)),
233                    _ => None,
234                });
235            }
236        }
237    }
238    None
239}
240
241pub fn get_key_value_span(
242    document: &toml::Spanned<toml::de::DeTable<'static>>,
243    path: &[&str],
244) -> Option<TomlSpan> {
245    get_key_value(document, path).map(|(k, v)| TomlSpan {
246        key: k.span(),
247        value: v.span(),
248    })
249}
250
251/// Gets the relative path to a manifest from the current working directory, or
252/// the absolute path of the manifest if a relative path cannot be constructed
253pub fn rel_cwd_manifest_path(path: &Path, gctx: &GlobalContext) -> String {
254    diff_paths(path, gctx.cwd())
255        .unwrap_or_else(|| path.to_path_buf())
256        .display()
257        .to_string()
258}
259
260#[derive(Copy, Clone, Debug)]
261pub struct LintGroup {
262    pub name: &'static str,
263    pub default_level: LintLevel,
264    pub desc: &'static str,
265    pub edition_lint_opts: Option<(Edition, LintLevel)>,
266    pub feature_gate: Option<&'static Feature>,
267}
268
269/// This lint group is only to be used for testing purposes
270const TEST_DUMMY_UNSTABLE: LintGroup = LintGroup {
271    name: "test_dummy_unstable",
272    desc: "test_dummy_unstable is meant to only be used in tests",
273    default_level: LintLevel::Allow,
274    edition_lint_opts: None,
275    feature_gate: Some(Feature::test_dummy_unstable()),
276};
277
278#[derive(Copy, Clone, Debug)]
279pub struct Lint {
280    pub name: &'static str,
281    pub desc: &'static str,
282    pub groups: &'static [LintGroup],
283    pub default_level: LintLevel,
284    pub edition_lint_opts: Option<(Edition, LintLevel)>,
285    pub feature_gate: Option<&'static Feature>,
286    /// This is a markdown formatted string that will be used when generating
287    /// the lint documentation. If docs is `None`, the lint will not be
288    /// documented.
289    pub docs: Option<&'static str>,
290}
291
292impl Lint {
293    pub fn level(
294        &self,
295        pkg_lints: &TomlToolLints,
296        edition: Edition,
297        unstable_features: &Features,
298    ) -> (LintLevel, LintLevelReason) {
299        // We should return `Allow` if a lint is behind a feature, but it is
300        // not enabled, that way the lint does not run.
301        if self
302            .feature_gate
303            .is_some_and(|f| !unstable_features.is_enabled(f))
304        {
305            return (LintLevel::Allow, LintLevelReason::Default);
306        }
307
308        self.groups
309            .iter()
310            .map(|g| {
311                (
312                    g.name,
313                    level_priority(
314                        g.name,
315                        g.default_level,
316                        g.edition_lint_opts,
317                        pkg_lints,
318                        edition,
319                    ),
320                )
321            })
322            .chain(std::iter::once((
323                self.name,
324                level_priority(
325                    self.name,
326                    self.default_level,
327                    self.edition_lint_opts,
328                    pkg_lints,
329                    edition,
330                ),
331            )))
332            .max_by_key(|(n, (l, _, p))| (l == &LintLevel::Forbid, *p, std::cmp::Reverse(*n)))
333            .map(|(_, (l, r, _))| (l, r))
334            .unwrap()
335    }
336
337    fn emitted_source(&self, lint_level: LintLevel, reason: LintLevelReason) -> String {
338        format!("`cargo::{}` is set to `{lint_level}` {reason}", self.name,)
339    }
340}
341
342#[derive(Copy, Clone, Debug, PartialEq)]
343pub enum LintLevel {
344    Allow,
345    Warn,
346    Deny,
347    Forbid,
348}
349
350impl Display for LintLevel {
351    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352        match self {
353            LintLevel::Allow => write!(f, "allow"),
354            LintLevel::Warn => write!(f, "warn"),
355            LintLevel::Deny => write!(f, "deny"),
356            LintLevel::Forbid => write!(f, "forbid"),
357        }
358    }
359}
360
361impl LintLevel {
362    pub fn is_error(&self) -> bool {
363        self == &LintLevel::Forbid || self == &LintLevel::Deny
364    }
365
366    pub fn to_diagnostic_level(self) -> Level<'static> {
367        match self {
368            LintLevel::Allow => unreachable!("allow does not map to a diagnostic level"),
369            LintLevel::Warn => Level::WARNING,
370            LintLevel::Deny => Level::ERROR,
371            LintLevel::Forbid => Level::ERROR,
372        }
373    }
374
375    fn force(self) -> bool {
376        match self {
377            Self::Allow => false,
378            Self::Warn => true,
379            Self::Deny => true,
380            Self::Forbid => true,
381        }
382    }
383}
384
385impl From<TomlLintLevel> for LintLevel {
386    fn from(toml_lint_level: TomlLintLevel) -> LintLevel {
387        match toml_lint_level {
388            TomlLintLevel::Allow => LintLevel::Allow,
389            TomlLintLevel::Warn => LintLevel::Warn,
390            TomlLintLevel::Deny => LintLevel::Deny,
391            TomlLintLevel::Forbid => LintLevel::Forbid,
392        }
393    }
394}
395
396#[derive(Copy, Clone, Debug, PartialEq, Eq)]
397pub enum LintLevelReason {
398    Default,
399    Edition(Edition),
400    Package,
401}
402
403impl Display for LintLevelReason {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        match self {
406            LintLevelReason::Default => write!(f, "by default"),
407            LintLevelReason::Edition(edition) => write!(f, "in edition {}", edition),
408            LintLevelReason::Package => write!(f, "in `[lints]`"),
409        }
410    }
411}
412
413impl LintLevelReason {
414    fn is_user_specified(&self) -> bool {
415        match self {
416            LintLevelReason::Default => false,
417            LintLevelReason::Edition(_) => false,
418            LintLevelReason::Package => true,
419        }
420    }
421}
422
423fn level_priority(
424    name: &str,
425    default_level: LintLevel,
426    edition_lint_opts: Option<(Edition, LintLevel)>,
427    pkg_lints: &TomlToolLints,
428    edition: Edition,
429) -> (LintLevel, LintLevelReason, i8) {
430    let (unspecified_level, reason) = if let Some(level) = edition_lint_opts
431        .filter(|(e, _)| edition >= *e)
432        .map(|(_, l)| l)
433    {
434        (level, LintLevelReason::Edition(edition))
435    } else {
436        (default_level, LintLevelReason::Default)
437    };
438
439    // Don't allow the group to be overridden if the level is `Forbid`
440    if unspecified_level == LintLevel::Forbid {
441        return (unspecified_level, reason, 0);
442    }
443
444    if let Some(defined_level) = pkg_lints.get(name) {
445        (
446            defined_level.level().into(),
447            LintLevelReason::Package,
448            defined_level.priority(),
449        )
450    } else {
451        (unspecified_level, reason, 0)
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use itertools::Itertools;
458    use snapbox::ToDebug;
459    use std::collections::HashSet;
460
461    #[test]
462    fn ensure_sorted_lints() {
463        // This will be printed out if the fields are not sorted.
464        let location = std::panic::Location::caller();
465        println!("\nTo fix this test, sort `LINTS` in {}\n", location.file(),);
466
467        let actual = super::LINTS
468            .iter()
469            .map(|l| l.name.to_uppercase())
470            .collect::<Vec<_>>();
471
472        let mut expected = actual.clone();
473        expected.sort();
474        snapbox::assert_data_eq!(actual.to_debug(), expected.to_debug());
475    }
476
477    #[test]
478    fn ensure_sorted_lint_groups() {
479        // This will be printed out if the fields are not sorted.
480        let location = std::panic::Location::caller();
481        println!(
482            "\nTo fix this test, sort `LINT_GROUPS` in {}\n",
483            location.file(),
484        );
485        let actual = super::LINT_GROUPS
486            .iter()
487            .map(|l| l.name.to_uppercase())
488            .collect::<Vec<_>>();
489
490        let mut expected = actual.clone();
491        expected.sort();
492        snapbox::assert_data_eq!(actual.to_debug(), expected.to_debug());
493    }
494
495    #[test]
496    fn ensure_updated_lints() {
497        let dir = snapbox::utils::current_dir!().join("rules");
498        let mut expected = HashSet::new();
499        for entry in std::fs::read_dir(&dir).unwrap() {
500            let entry = entry.unwrap();
501            let path = entry.path();
502            if path.ends_with("mod.rs") {
503                continue;
504            }
505            let lint_name = path.file_stem().unwrap().to_string_lossy();
506            assert!(expected.insert(lint_name.into()), "duplicate lint found");
507        }
508
509        let actual = super::LINTS
510            .iter()
511            .map(|l| l.name.to_string())
512            .collect::<HashSet<_>>();
513        let diff = expected.difference(&actual).sorted().collect::<Vec<_>>();
514
515        let mut need_added = String::new();
516        for name in &diff {
517            need_added.push_str(&format!("{name}\n"));
518        }
519        assert!(
520            diff.is_empty(),
521            "\n`LINTS` did not contain all `Lint`s found in {}\n\
522            Please add the following to `LINTS`:\n\
523            {need_added}",
524            dir.display(),
525        );
526    }
527
528    #[test]
529    fn ensure_updated_lint_groups() {
530        let path = snapbox::utils::current_rs!();
531        let expected = std::fs::read_to_string(&path).unwrap();
532        let expected = expected
533            .lines()
534            .filter_map(|l| {
535                if l.ends_with(": LintGroup = LintGroup {") {
536                    Some(
537                        l.chars()
538                            .skip(6)
539                            .take_while(|c| *c != ':')
540                            .collect::<String>(),
541                    )
542                } else {
543                    None
544                }
545            })
546            .collect::<HashSet<_>>();
547        let actual = super::LINT_GROUPS
548            .iter()
549            .map(|l| l.name.to_uppercase())
550            .collect::<HashSet<_>>();
551        let diff = expected.difference(&actual).sorted().collect::<Vec<_>>();
552
553        let mut need_added = String::new();
554        for name in &diff {
555            need_added.push_str(&format!("{}\n", name));
556        }
557        assert!(
558            diff.is_empty(),
559            "\n`LINT_GROUPS` did not contain all `LintGroup`s found in {}\n\
560            Please add the following to `LINT_GROUPS`:\n\
561            {}",
562            path.display(),
563            need_added
564        );
565    }
566}