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