#
cargo/lints/rules/
implicit_minimum_version_req.rs

    1use std::collections::HashMap;
    2use std::path::Path;
    3
    4use annotate_snippets::AnnotationKind;
    5use annotate_snippets::Group;
    6use annotate_snippets::Level;
    7use annotate_snippets::Origin;
    8use annotate_snippets::Patch;
    9use annotate_snippets::Snippet;
    10use cargo_platform::Platform;
    11use cargo_util_schemas::manifest::TomlDependency;
    12use cargo_util_schemas::manifest::TomlToolLints;
    13use toml::de::DeValue;
    14
    15use crate::CargoResult;
    16use crate::GlobalContext;
    17use crate::core::Manifest;
    18use crate::core::MaybePackage;
    19use crate::core::Package;
    20use crate::lints::Lint;
    21use crate::lints::LintLevel;
    22use crate::lints::LintLevelReason;
    23use crate::lints::ManifestFor;
    24use crate::lints::PEDANTIC;
    25use crate::lints::get_key_value;
    26use crate::lints::rel_cwd_manifest_path;
    27use crate::util::OptVersionReq;
    28
    29pub const LINT: Lint = Lint {
    30    name: "implicit_minimum_version_req",
    31    desc: "dependency version requirement without an explicit minimum version",
    32    primary_group: &PEDANTIC,
    33    edition_lint_opts: None,
    34    feature_gate: None,
    35    docs: Some(
    36        r#"
    37### What it does
    38
    39Checks for dependency version requirements
    40that do not explicitly specify a full `major.minor.patch` version requirement,
    41such as `serde = "1"` or `serde = "1.0"`.
    42
    43This lint currently only applies to caret requirements
    44(the [default requirements](specifying-dependencies.md#default-requirements)).
    45
    46### Why it is bad
    47
    48Version requirements without an explicit full version
    49can be misleading about the actual minimum supported version.
    50For example,
    51`serde = "1"` has an implicit minimum bound of `1.0.0`.
    52If your code actually requires features from `1.0.219`,
    53the implicit minimum bound of `1.0.0` gives a false impression about compatibility.
    54
    55Specifying the full version helps with:
    56
    57- Accurate minimum version documentation
    58- Better compatibility with `-Z minimal-versions`
    59- Clearer dependency constraints for consumers
    60
    61### Drawbacks
    62
    63Even with a fully specified version,
    64the minimum bound might still be incorrect if untested.
    65This lint helps make the minimum version requirement explicit
    66but doesn't guarantee correctness.
    67
    68### Example
    69
    70```toml
    71[dependencies]
    72serde = "1"
    73```
    74
    75Should be written as a full specific version:
    76
    77```toml
    78[dependencies]
    79serde = "1.0.219"
    80```
    81"#,
    82    ),
    83};
    84
    85pub fn implicit_minimum_version_req(
    86    manifest: ManifestFor<'_>,
    87    manifest_path: &Path,
    88    cargo_lints: &TomlToolLints,
    89    error_count: &mut usize,
    90    gctx: &GlobalContext,
    91) -> CargoResult<()> {
    92    let (lint_level, reason) = manifest.lint_level(cargo_lints, LINT);
    93
    94    if lint_level == LintLevel::Allow {
    95        return Ok(());
    96    }
    97
    98    let manifest_path = rel_cwd_manifest_path(manifest_path, gctx);
    99
    100    match manifest {
    101        ManifestFor::Package(pkg) => {
    102            lint_package(pkg, manifest_path, lint_level, reason, error_count, gctx)
    103        }
    104        ManifestFor::Workspace(maybe_pkg) => lint_workspace(
    105            maybe_pkg,
    106            manifest_path,
    107            lint_level,
    108            reason,
    109            error_count,
    110            gctx,
    111        ),
    112    }
    113}
    114
    115pub fn lint_package(
    116    pkg: &Package,
    117    manifest_path: String,
    118    lint_level: LintLevel,
    119    reason: LintLevelReason,
    120    error_count: &mut usize,
    121    gctx: &GlobalContext,
    122) -> CargoResult<()> {
    123    let manifest = pkg.manifest();
    124
    125    let document = manifest.document();
    126    let contents = manifest.contents();
    127    let target_key_for_platform = target_key_for_platform(&manifest);
    128
    129    for dep in manifest.dependencies().iter() {
    130        let version_req = dep.version_req();
    131        let Some(suggested_req) = get_suggested_version_req(&version_req) else {
    132            continue;
    133        };
    134
    135        let name_in_toml = dep.name_in_toml().as_str();
    136        let key_path =
    137            if let Some(cfg) = dep.platform().and_then(|p| target_key_for_platform.get(p)) {
    138                &["target", &cfg, dep.kind().kind_table(), name_in_toml][..]
    139            } else {
    140                &[dep.kind().kind_table(), name_in_toml][..]
    141            };
    142
    143        let Some(report) = report(
    144            lint_level,
    145            reason,
    146            contents,
    147            document,
    148            key_path,
    149            &manifest_path,
    150            &suggested_req,
    151        ) else {
    152            continue;
    153        };
    154
    155        if lint_level.is_error() {
    156            *error_count += 1;
    157        }
    158        gctx.shell().print_report(&report, lint_level.force())?;
    159    }
    160
    161    Ok(())
    162}
    163
    164pub fn lint_workspace(
    165    maybe_pkg: &MaybePackage,
    166    manifest_path: String,
    167    lint_level: LintLevel,
    168    reason: LintLevelReason,
    169    error_count: &mut usize,
    170    gctx: &GlobalContext,
    171) -> CargoResult<()> {
    172    let document = maybe_pkg.document();
    173    let contents = maybe_pkg.contents();
    174    let toml = match maybe_pkg {
    175        MaybePackage::Package(p) => p.manifest().normalized_toml(),
    176        MaybePackage::Virtual(vm) => vm.normalized_toml(),
    177    };
    178    let dep_iter = toml
    179        .workspace
    180        .as_ref()
    181        .and_then(|ws| ws.dependencies.as_ref())
    182        .into_iter()
    183        .flat_map(|deps| deps.iter())
    184        .map(|(name, dep)| {
    185            let name = name.as_str();
    186            let ver = match dep {
    187                TomlDependency::Simple(ver) => ver,
    188                TomlDependency::Detailed(detailed) => {
    189                    let Some(ver) = detailed.version.as_ref() else {
    190                        return (name, OptVersionReq::Any);
    191                    };
    192                    ver
    193                }
    194            };
    195            let req = semver::VersionReq::parse(ver)
    196                .map(Into::into)
    197                .unwrap_or(OptVersionReq::Any);
    198            (name, req)
    199        });
    200
    201    for (name_in_toml, version_req) in dep_iter {
    202        let Some(suggested_req) = get_suggested_version_req(&version_req) else {
    203            continue;
    204        };
    205
    206        let key_path = ["workspace", "dependencies", name_in_toml];
    207
    208        let Some(report) = report(
    209            lint_level,
    210            reason,
    211            contents,
    212            document,
    213            &key_path,
    214            &manifest_path,
    215            &suggested_req,
    216        ) else {
    217            continue;
    218        };
    219
    220        if lint_level.is_error() {
    221            *error_count += 1;
    222        }
    223        gctx.shell().print_report(&report, lint_level.force())?;
    224    }
    225
    226    Ok(())
    227}
    228
    229pub fn span_of_version_req<'doc>(
    230    document: &'doc toml::Spanned<toml::de::DeTable<'static>>,
    231    path: &[&str],
    232) -> Option<std::ops::Range<usize>> {
    233    let (_key, value) = get_key_value(document, path)?;
    234
    235    match value.as_ref() {
    236        DeValue::String(_) => Some(value.span()),
    237        DeValue::Table(map) if map.get("workspace").is_some() => {
    238            // We only lint non-workspace-inherited dependencies
    239            None
    240        }
    241        DeValue::Table(map) => {
    242            let Some(v) = map.get("version") else {
    243                panic!("version must be specified or workspace-inherited");
    244            };
    245            Some(v.span())
    246        }
    247        _ => unreachable!("dependency must be string or table"),
    248    }
    249}
    250
    251fn report<'a>(
    252    lint_level: LintLevel,
    253    reason: LintLevelReason,
    254    contents: Option<&'a str>,
    255    document: Option<&toml::Spanned<toml::de::DeTable<'static>>>,
    256    key_path: &[&str],
    257    manifest_path: &str,
    258    suggested_req: &str,
    259) -> Option<[Group<'a>; 2]> {
    260    let level = lint_level.to_diagnostic_level();
    261    let emitted_source = LINT.emitted_source(lint_level, reason);
    262    let replacement = format!(r#""{suggested_req}""#);
    263    let label = "missing full version components";
    264    let secondary_title = "consider specifying full `major.minor.patch` version components";
    265
    266    let mut desc = Group::with_title(level.primary_title(LINT.desc));
    267    let mut help = Group::with_title(Level::HELP.secondary_title(secondary_title));
    268
    269    if let Some(document) = document
    270        && let Some(contents) = contents
    271    {
    272        let Some(span) = span_of_version_req(document, key_path) else {
    273            return None;
    274        };
    275        desc = desc.element(
    276            Snippet::source(contents)
    277                .path(manifest_path.to_owned())
    278                .annotation(AnnotationKind::Primary.span(span.clone()).label(label)),
    279        );
    280        help = help
    281            .element(Snippet::source(contents).patch(Patch::new(span.clone(), replacement)))
    282            .element(Level::NOTE.message(emitted_source));
    283    } else {
    284        desc = desc.element(Origin::path(manifest_path.to_owned()));
    285        help = help.element(Level::NOTE.message(emitted_source));
    286    }
    287
    288    Some([desc, help])
    289}
    290
    291fn get_suggested_version_req(req: &OptVersionReq) -> Option<String> {
    292    use semver::Op;
    293    let OptVersionReq::Req(req) = req else {
    294        return None;
    295    };
    296    let mut has_suggestions = false;
    297    let mut comparators = Vec::new();
    298
    299    for mut cmp in req.comparators.iter().cloned() {
    300        match cmp.op {
    301            Op::Caret | Op::GreaterEq => {
    302                // Only focus on comparator that has only `major` or `major.minor`
    303                if cmp.minor.is_some() && cmp.patch.is_some() {
    304                    comparators.push(cmp);
    305                    continue;
    306                } else {
    307                    has_suggestions = true;
    308                    cmp.minor.get_or_insert(0);
    309                    cmp.patch.get_or_insert(0);
    310                    comparators.push(cmp);
    311                }
    312            }
    313            Op::Exact | Op::Tilde | Op::Wildcard | Op::Greater | Op::Less | Op::LessEq => {
    314                comparators.push(cmp);
    315                continue;
    316            }
    317            _ => panic!("unknown comparator in `{cmp}`"),
    318        }
    319    }
    320
    321    if !has_suggestions {
    322        return None;
    323    }
    324
    325    // This is a lossy suggestion that
    326    //
    327    // * extra spaces are removed
    328    // * caret operator `^` is stripped
    329    let mut suggestion = String::new();
    330
    331    for cmp in &comparators {
    332        if !suggestion.is_empty() {
    333            suggestion.push_str(", ");
    334        }
    335        let s = cmp.to_string();
    336
    337        if cmp.op == Op::Caret {
    338            suggestion.push_str(s.strip_prefix('^').unwrap_or(&s));
    339        } else {
    340            suggestion.push_str(&s);
    341        }
    342    }
    343
    344    Some(suggestion)
    345}
    346
    347/// A map from parsed `Platform` to their original TOML key strings.
    348/// This is needed for constructing TOML key paths in diagnostics.
    349///
    350/// This is only relevant for package dependencies.
    351fn target_key_for_platform(manifest: &Manifest) -> HashMap<Platform, String> {
    352    manifest
    353        .normalized_toml()
    354        .target
    355        .as_ref()
    356        .map(|map| {
    357            map.keys()
    358                .map(|k| (k.parse().expect("already parsed"), k.clone()))
    359                .collect()
    360        })
    361        .unwrap_or_default()
    362}