cargo/util/
lints.rs

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