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;
9use toml_edit::ImDocument;
10
11const LINT_GROUPS: &[LintGroup] = &[TEST_DUMMY_UNSTABLE];
12pub const LINTS: &[Lint] = &[IM_A_TEAPOT, UNKNOWN_LINTS];
13
14pub fn analyze_cargo_lints_table(
15    pkg: &Package,
16    path: &Path,
17    pkg_lints: &TomlToolLints,
18    ws_contents: &str,
19    ws_document: &ImDocument<String>,
20    ws_path: &Path,
21    gctx: &GlobalContext,
22) -> CargoResult<()> {
23    let mut error_count = 0;
24    let manifest = pkg.manifest();
25    let manifest_path = rel_cwd_manifest_path(path, gctx);
26    let ws_path = rel_cwd_manifest_path(ws_path, gctx);
27    let mut unknown_lints = Vec::new();
28    for lint_name in pkg_lints.keys().map(|name| name) {
29        let Some((name, default_level, edition_lint_opts, feature_gate)) =
30            find_lint_or_group(lint_name)
31        else {
32            unknown_lints.push(lint_name);
33            continue;
34        };
35
36        let (_, reason, _) = level_priority(
37            name,
38            *default_level,
39            *edition_lint_opts,
40            pkg_lints,
41            manifest.edition(),
42        );
43
44        // Only run analysis on user-specified lints
45        if !reason.is_user_specified() {
46            continue;
47        }
48
49        // Only run this on lints that are gated by a feature
50        if let Some(feature_gate) = feature_gate {
51            verify_feature_enabled(
52                name,
53                feature_gate,
54                manifest,
55                &manifest_path,
56                ws_contents,
57                ws_document,
58                &ws_path,
59                &mut error_count,
60                gctx,
61            )?;
62        }
63    }
64
65    output_unknown_lints(
66        unknown_lints,
67        manifest,
68        &manifest_path,
69        pkg_lints,
70        ws_contents,
71        ws_document,
72        &ws_path,
73        &mut error_count,
74        gctx,
75    )?;
76
77    if error_count > 0 {
78        Err(anyhow::anyhow!(
79            "encountered {error_count} errors(s) while verifying lints",
80        ))
81    } else {
82        Ok(())
83    }
84}
85
86fn find_lint_or_group<'a>(
87    name: &str,
88) -> Option<(
89    &'static str,
90    &LintLevel,
91    &Option<(Edition, LintLevel)>,
92    &Option<&'static Feature>,
93)> {
94    if let Some(lint) = LINTS.iter().find(|l| l.name == name) {
95        Some((
96            lint.name,
97            &lint.default_level,
98            &lint.edition_lint_opts,
99            &lint.feature_gate,
100        ))
101    } else if let Some(group) = LINT_GROUPS.iter().find(|g| g.name == name) {
102        Some((
103            group.name,
104            &group.default_level,
105            &group.edition_lint_opts,
106            &group.feature_gate,
107        ))
108    } else {
109        None
110    }
111}
112
113fn verify_feature_enabled(
114    lint_name: &str,
115    feature_gate: &Feature,
116    manifest: &Manifest,
117    manifest_path: &str,
118    ws_contents: &str,
119    ws_document: &ImDocument<String>,
120    ws_path: &str,
121    error_count: &mut usize,
122    gctx: &GlobalContext,
123) -> CargoResult<()> {
124    if !manifest.unstable_features().is_enabled(feature_gate) {
125        let dash_feature_name = feature_gate.name().replace("_", "-");
126        let title = format!("use of unstable lint `{}`", lint_name);
127        let label = format!(
128            "this is behind `{}`, which is not enabled",
129            dash_feature_name
130        );
131        let second_title = format!("`cargo::{}` was inherited", lint_name);
132        let help = format!(
133            "consider adding `cargo-features = [\"{}\"]` to the top of the manifest",
134            dash_feature_name
135        );
136
137        let message = if let Some(span) =
138            get_span(manifest.document(), &["lints", "cargo", lint_name], false)
139        {
140            Level::Error
141                .title(&title)
142                .snippet(
143                    Snippet::source(manifest.contents())
144                        .origin(&manifest_path)
145                        .annotation(Level::Error.span(span).label(&label))
146                        .fold(true),
147                )
148                .footer(Level::Help.title(&help))
149        } else {
150            let lint_span = get_span(
151                ws_document,
152                &["workspace", "lints", "cargo", lint_name],
153                false,
154            )
155            .expect(&format!(
156                "could not find `cargo::{lint_name}` in `[lints]`, or `[workspace.lints]` "
157            ));
158
159            let inherited_note = if let (Some(inherit_span_key), Some(inherit_span_value)) = (
160                get_span(manifest.document(), &["lints", "workspace"], false),
161                get_span(manifest.document(), &["lints", "workspace"], true),
162            ) {
163                Level::Note.title(&second_title).snippet(
164                    Snippet::source(manifest.contents())
165                        .origin(&manifest_path)
166                        .annotation(
167                            Level::Note.span(inherit_span_key.start..inherit_span_value.end),
168                        )
169                        .fold(true),
170                )
171            } else {
172                Level::Note.title(&second_title)
173            };
174
175            Level::Error
176                .title(&title)
177                .snippet(
178                    Snippet::source(ws_contents)
179                        .origin(&ws_path)
180                        .annotation(Level::Error.span(lint_span).label(&label))
181                        .fold(true),
182                )
183                .footer(inherited_note)
184                .footer(Level::Help.title(&help))
185        };
186
187        *error_count += 1;
188        gctx.shell().print_message(message)?;
189    }
190    Ok(())
191}
192
193pub fn get_span(
194    document: &ImDocument<String>,
195    path: &[&str],
196    get_value: bool,
197) -> Option<Range<usize>> {
198    let mut table = document.as_item().as_table_like()?;
199    let mut iter = path.into_iter().peekable();
200    while let Some(key) = iter.next() {
201        let (key, item) = table.get_key_value(key)?;
202        if iter.peek().is_none() {
203            return if get_value {
204                item.span()
205            } else {
206                let leaf_decor = key.dotted_decor();
207                let leaf_prefix_span = leaf_decor.prefix().and_then(|p| p.span());
208                let leaf_suffix_span = leaf_decor.suffix().and_then(|s| s.span());
209                if let (Some(leaf_prefix_span), Some(leaf_suffix_span)) =
210                    (leaf_prefix_span, leaf_suffix_span)
211                {
212                    Some(leaf_prefix_span.start..leaf_suffix_span.end)
213                } else {
214                    key.span()
215                }
216            };
217        }
218        if item.is_table_like() {
219            table = item.as_table_like().unwrap();
220        }
221        if item.is_array() && iter.peek().is_some() {
222            let array = item.as_array().unwrap();
223            let next = iter.next().unwrap();
224            return array.iter().find_map(|item| {
225                if next == &item.to_string() {
226                    item.span()
227                } else {
228                    None
229                }
230            });
231        }
232    }
233    None
234}
235
236/// Gets the relative path to a manifest from the current working directory, or
237/// the absolute path of the manifest if a relative path cannot be constructed
238pub fn rel_cwd_manifest_path(path: &Path, gctx: &GlobalContext) -> String {
239    diff_paths(path, gctx.cwd())
240        .unwrap_or_else(|| path.to_path_buf())
241        .display()
242        .to_string()
243}
244
245#[derive(Copy, Clone, Debug)]
246pub struct LintGroup {
247    pub name: &'static str,
248    pub default_level: LintLevel,
249    pub desc: &'static str,
250    pub edition_lint_opts: Option<(Edition, LintLevel)>,
251    pub feature_gate: Option<&'static Feature>,
252}
253
254/// This lint group is only to be used for testing purposes
255const TEST_DUMMY_UNSTABLE: LintGroup = LintGroup {
256    name: "test_dummy_unstable",
257    desc: "test_dummy_unstable is meant to only be used in tests",
258    default_level: LintLevel::Allow,
259    edition_lint_opts: None,
260    feature_gate: Some(Feature::test_dummy_unstable()),
261};
262
263#[derive(Copy, Clone, Debug)]
264pub struct Lint {
265    pub name: &'static str,
266    pub desc: &'static str,
267    pub groups: &'static [LintGroup],
268    pub default_level: LintLevel,
269    pub edition_lint_opts: Option<(Edition, LintLevel)>,
270    pub feature_gate: Option<&'static Feature>,
271    /// This is a markdown formatted string that will be used when generating
272    /// the lint documentation. If docs is `None`, the lint will not be
273    /// documented.
274    pub docs: Option<&'static str>,
275}
276
277impl Lint {
278    pub fn level(
279        &self,
280        pkg_lints: &TomlToolLints,
281        edition: Edition,
282        unstable_features: &Features,
283    ) -> (LintLevel, LintLevelReason) {
284        // We should return `Allow` if a lint is behind a feature, but it is
285        // not enabled, that way the lint does not run.
286        if self
287            .feature_gate
288            .is_some_and(|f| !unstable_features.is_enabled(f))
289        {
290            return (LintLevel::Allow, LintLevelReason::Default);
291        }
292
293        self.groups
294            .iter()
295            .map(|g| {
296                (
297                    g.name,
298                    level_priority(
299                        g.name,
300                        g.default_level,
301                        g.edition_lint_opts,
302                        pkg_lints,
303                        edition,
304                    ),
305                )
306            })
307            .chain(std::iter::once((
308                self.name,
309                level_priority(
310                    self.name,
311                    self.default_level,
312                    self.edition_lint_opts,
313                    pkg_lints,
314                    edition,
315                ),
316            )))
317            .max_by_key(|(n, (l, _, p))| (l == &LintLevel::Forbid, *p, std::cmp::Reverse(*n)))
318            .map(|(_, (l, r, _))| (l, r))
319            .unwrap()
320    }
321}
322
323#[derive(Copy, Clone, Debug, PartialEq)]
324pub enum LintLevel {
325    Allow,
326    Warn,
327    Deny,
328    Forbid,
329}
330
331impl Display for LintLevel {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        match self {
334            LintLevel::Allow => write!(f, "allow"),
335            LintLevel::Warn => write!(f, "warn"),
336            LintLevel::Deny => write!(f, "deny"),
337            LintLevel::Forbid => write!(f, "forbid"),
338        }
339    }
340}
341
342impl LintLevel {
343    pub fn to_diagnostic_level(self) -> Level {
344        match self {
345            LintLevel::Allow => unreachable!("allow does not map to a diagnostic level"),
346            LintLevel::Warn => Level::Warning,
347            LintLevel::Deny => Level::Error,
348            LintLevel::Forbid => Level::Error,
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 == LintLevel::Forbid || lint_level == LintLevel::Deny {
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 = format!(
460            "`cargo::{}` is set to `{lint_level}` {reason}",
461            IM_A_TEAPOT.name
462        );
463
464        let key_span = get_span(manifest.document(), &["package", "im-a-teapot"], false).unwrap();
465        let value_span = get_span(manifest.document(), &["package", "im-a-teapot"], true).unwrap();
466        let message = level
467            .title(IM_A_TEAPOT.desc)
468            .snippet(
469                Snippet::source(manifest.contents())
470                    .origin(&manifest_path)
471                    .annotation(level.span(key_span.start..value_span.end))
472                    .fold(true),
473            )
474            .footer(Level::Note.title(&emitted_reason));
475
476        gctx.shell().print_message(message)?;
477    }
478    Ok(())
479}
480
481const UNKNOWN_LINTS: Lint = Lint {
482    name: "unknown_lints",
483    desc: "unknown lint",
484    groups: &[],
485    default_level: LintLevel::Warn,
486    edition_lint_opts: None,
487    feature_gate: None,
488    docs: Some(
489        r#"
490### What it does
491Checks for unknown lints in the `[lints.cargo]` table
492
493### Why it is bad
494- The lint name could be misspelled, leading to confusion as to why it is
495  not working as expected
496- The unknown lint could end up causing an error if `cargo` decides to make
497  a lint with the same name in the future
498
499### Example
500```toml
501[lints.cargo]
502this-lint-does-not-exist = "warn"
503```
504"#,
505    ),
506};
507
508fn output_unknown_lints(
509    unknown_lints: Vec<&String>,
510    manifest: &Manifest,
511    manifest_path: &str,
512    pkg_lints: &TomlToolLints,
513    ws_contents: &str,
514    ws_document: &ImDocument<String>,
515    ws_path: &str,
516    error_count: &mut usize,
517    gctx: &GlobalContext,
518) -> CargoResult<()> {
519    let (lint_level, reason) =
520        UNKNOWN_LINTS.level(pkg_lints, manifest.edition(), manifest.unstable_features());
521    if lint_level == LintLevel::Allow {
522        return Ok(());
523    }
524
525    let level = lint_level.to_diagnostic_level();
526    let mut emitted_source = None;
527    for lint_name in unknown_lints {
528        if lint_level == LintLevel::Forbid || lint_level == LintLevel::Deny {
529            *error_count += 1;
530        }
531        let title = format!("{}: `{lint_name}`", UNKNOWN_LINTS.desc);
532        let second_title = format!("`cargo::{}` was inherited", lint_name);
533        let underscore_lint_name = lint_name.replace("-", "_");
534        let matching = if let Some(lint) = LINTS.iter().find(|l| l.name == underscore_lint_name) {
535            Some((lint.name, "lint"))
536        } else if let Some(group) = LINT_GROUPS.iter().find(|g| g.name == underscore_lint_name) {
537            Some((group.name, "group"))
538        } else {
539            None
540        };
541        let help =
542            matching.map(|(name, kind)| format!("there is a {kind} with a similar name: `{name}`"));
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            .expect(&format!(
560                "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
579            level
580                .title(&title)
581                .snippet(
582                    Snippet::source(ws_contents)
583                        .origin(&ws_path)
584                        .annotation(Level::Error.span(lint_span))
585                        .fold(true),
586                )
587                .footer(inherited_note)
588        };
589
590        if emitted_source.is_none() {
591            emitted_source = Some(format!(
592                "`cargo::{}` is set to `{lint_level}` {reason}",
593                UNKNOWN_LINTS.name
594            ));
595            message = message.footer(Level::Note.title(emitted_source.as_ref().unwrap()));
596        }
597
598        if let Some(help) = help.as_ref() {
599            message = message.footer(Level::Help.title(help));
600        }
601
602        gctx.shell().print_message(message)?;
603    }
604
605    Ok(())
606}
607
608#[cfg(test)]
609mod tests {
610    use itertools::Itertools;
611    use snapbox::ToDebug;
612    use std::collections::HashSet;
613
614    #[test]
615    fn ensure_sorted_lints() {
616        // This will be printed out if the fields are not sorted.
617        let location = std::panic::Location::caller();
618        println!("\nTo fix this test, sort `LINTS` in {}\n", location.file(),);
619
620        let actual = super::LINTS
621            .iter()
622            .map(|l| l.name.to_uppercase())
623            .collect::<Vec<_>>();
624
625        let mut expected = actual.clone();
626        expected.sort();
627        snapbox::assert_data_eq!(actual.to_debug(), expected.to_debug());
628    }
629
630    #[test]
631    fn ensure_sorted_lint_groups() {
632        // This will be printed out if the fields are not sorted.
633        let location = std::panic::Location::caller();
634        println!(
635            "\nTo fix this test, sort `LINT_GROUPS` in {}\n",
636            location.file(),
637        );
638        let actual = super::LINT_GROUPS
639            .iter()
640            .map(|l| l.name.to_uppercase())
641            .collect::<Vec<_>>();
642
643        let mut expected = actual.clone();
644        expected.sort();
645        snapbox::assert_data_eq!(actual.to_debug(), expected.to_debug());
646    }
647
648    #[test]
649    fn ensure_updated_lints() {
650        let path = snapbox::utils::current_rs!();
651        let expected = std::fs::read_to_string(&path).unwrap();
652        let expected = expected
653            .lines()
654            .filter_map(|l| {
655                if l.ends_with(": Lint = Lint {") {
656                    Some(
657                        l.chars()
658                            .skip(6)
659                            .take_while(|c| *c != ':')
660                            .collect::<String>(),
661                    )
662                } else {
663                    None
664                }
665            })
666            .collect::<HashSet<_>>();
667        let actual = super::LINTS
668            .iter()
669            .map(|l| l.name.to_uppercase())
670            .collect::<HashSet<_>>();
671        let diff = expected.difference(&actual).sorted().collect::<Vec<_>>();
672
673        let mut need_added = String::new();
674        for name in &diff {
675            need_added.push_str(&format!("{}\n", name));
676        }
677        assert!(
678            diff.is_empty(),
679            "\n`LINTS` did not contain all `Lint`s found in {}\n\
680            Please add the following to `LINTS`:\n\
681            {}",
682            path.display(),
683            need_added
684        );
685    }
686
687    #[test]
688    fn ensure_updated_lint_groups() {
689        let path = snapbox::utils::current_rs!();
690        let expected = std::fs::read_to_string(&path).unwrap();
691        let expected = expected
692            .lines()
693            .filter_map(|l| {
694                if l.ends_with(": LintGroup = LintGroup {") {
695                    Some(
696                        l.chars()
697                            .skip(6)
698                            .take_while(|c| *c != ':')
699                            .collect::<String>(),
700                    )
701                } else {
702                    None
703                }
704            })
705            .collect::<HashSet<_>>();
706        let actual = super::LINT_GROUPS
707            .iter()
708            .map(|l| l.name.to_uppercase())
709            .collect::<HashSet<_>>();
710        let diff = expected.difference(&actual).sorted().collect::<Vec<_>>();
711
712        let mut need_added = String::new();
713        for name in &diff {
714            need_added.push_str(&format!("{}\n", name));
715        }
716        assert!(
717            diff.is_empty(),
718            "\n`LINT_GROUPS` did not contain all `LintGroup`s found in {}\n\
719            Please add the following to `LINT_GROUPS`:\n\
720            {}",
721            path.display(),
722            need_added
723        );
724    }
725}