cargo/util/
lints.rs

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