Skip to main content

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    let mut emit_source = true;
130    for dep in manifest.dependencies().iter() {
131        let version_req = dep.version_req();
132        let Some(suggested_req) = get_suggested_version_req(&version_req) else {
133            continue;
134        };
135
136        let name_in_toml = dep.name_in_toml().as_str();
137        let key_path =
138            if let Some(cfg) = dep.platform().and_then(|p| target_key_for_platform.get(p)) {
139                &["target", &cfg, dep.kind().kind_table(), name_in_toml][..]
140            } else {
141                &[dep.kind().kind_table(), name_in_toml][..]
142            };
143
144        let Some(report) = report(
145            lint_level,
146            reason,
147            contents,
148            document,
149            key_path,
150            &manifest_path,
151            &suggested_req,
152            emit_source,
153        ) else {
154            continue;
155        };
156
157        if emit_source {
158            emit_source = false;
159        }
160
161        if lint_level.is_error() {
162            *error_count += 1;
163        }
164        gctx.shell().print_report(&report, lint_level.force())?;
165    }
166
167    Ok(())
168}
169
170pub fn lint_workspace(
171    maybe_pkg: &MaybePackage,
172    manifest_path: String,
173    lint_level: LintLevel,
174    reason: LintLevelReason,
175    error_count: &mut usize,
176    gctx: &GlobalContext,
177) -> CargoResult<()> {
178    let document = maybe_pkg.document();
179    let contents = maybe_pkg.contents();
180    let toml = match maybe_pkg {
181        MaybePackage::Package(p) => p.manifest().normalized_toml(),
182        MaybePackage::Virtual(vm) => vm.normalized_toml(),
183    };
184    let dep_iter = toml
185        .workspace
186        .as_ref()
187        .and_then(|ws| ws.dependencies.as_ref())
188        .into_iter()
189        .flat_map(|deps| deps.iter())
190        .map(|(name, dep)| {
191            let name = name.as_str();
192            let ver = match dep {
193                TomlDependency::Simple(ver) => ver,
194                TomlDependency::Detailed(detailed) => {
195                    let Some(ver) = detailed.version.as_ref() else {
196                        return (name, OptVersionReq::Any);
197                    };
198                    ver
199                }
200            };
201            let req = semver::VersionReq::parse(ver)
202                .map(Into::into)
203                .unwrap_or(OptVersionReq::Any);
204            (name, req)
205        });
206
207    let mut emit_source = true;
208    for (name_in_toml, version_req) in dep_iter {
209        let Some(suggested_req) = get_suggested_version_req(&version_req) else {
210            continue;
211        };
212
213        let key_path = ["workspace", "dependencies", name_in_toml];
214
215        let Some(report) = report(
216            lint_level,
217            reason,
218            contents,
219            document,
220            &key_path,
221            &manifest_path,
222            &suggested_req,
223            emit_source,
224        ) else {
225            continue;
226        };
227
228        if emit_source {
229            emit_source = false;
230        }
231
232        if lint_level.is_error() {
233            *error_count += 1;
234        }
235        gctx.shell().print_report(&report, lint_level.force())?;
236    }
237
238    Ok(())
239}
240
241pub fn span_of_version_req<'doc>(
242    document: &'doc toml::Spanned<toml::de::DeTable<'static>>,
243    path: &[&str],
244) -> Option<std::ops::Range<usize>> {
245    let (_key, value) = get_key_value(document, path)?;
246
247    match value.as_ref() {
248        DeValue::String(_) => Some(value.span()),
249        DeValue::Table(map) if map.get("workspace").is_some() => {
250            // We only lint non-workspace-inherited dependencies
251            None
252        }
253        DeValue::Table(map) => {
254            let Some(v) = map.get("version") else {
255                panic!("version must be specified or workspace-inherited");
256            };
257            Some(v.span())
258        }
259        _ => unreachable!("dependency must be string or table"),
260    }
261}
262
263fn report<'a>(
264    lint_level: LintLevel,
265    reason: LintLevelReason,
266    contents: Option<&'a str>,
267    document: Option<&toml::Spanned<toml::de::DeTable<'static>>>,
268    key_path: &[&str],
269    manifest_path: &str,
270    suggested_req: &str,
271    emit_source: bool,
272) -> Option<[Group<'a>; 2]> {
273    let level = lint_level.to_diagnostic_level();
274    let emitted_source = LINT.emitted_source(lint_level, reason);
275    let replacement = format!(r#""{suggested_req}""#);
276    let label = "missing full version components";
277    let secondary_title = "consider specifying full `major.minor.patch` version components";
278
279    let mut desc = Group::with_title(level.primary_title(LINT.desc));
280    let mut help = Group::with_title(Level::HELP.secondary_title(secondary_title));
281
282    if let Some(document) = document
283        && let Some(contents) = contents
284    {
285        let Some(span) = span_of_version_req(document, key_path) else {
286            return None;
287        };
288        desc = desc.element(
289            Snippet::source(contents)
290                .path(manifest_path.to_owned())
291                .annotation(AnnotationKind::Primary.span(span.clone()).label(label)),
292        );
293
294        help = help.element(Snippet::source(contents).patch(Patch::new(span.clone(), replacement)));
295    } else {
296        desc = desc.element(Origin::path(manifest_path.to_owned()));
297    }
298
299    if emit_source {
300        desc = desc.element(Level::NOTE.message(emitted_source));
301    }
302
303    Some([desc, help])
304}
305
306fn get_suggested_version_req(req: &OptVersionReq) -> Option<String> {
307    use semver::Op;
308    let OptVersionReq::Req(req) = req else {
309        return None;
310    };
311    let mut has_suggestions = false;
312    let mut comparators = Vec::new();
313
314    for mut cmp in req.comparators.iter().cloned() {
315        match cmp.op {
316            Op::Caret | Op::GreaterEq => {
317                // Only focus on comparator that has only `major` or `major.minor`
318                if cmp.minor.is_some() && cmp.patch.is_some() {
319                    comparators.push(cmp);
320                    continue;
321                } else {
322                    has_suggestions = true;
323                    cmp.minor.get_or_insert(0);
324                    cmp.patch.get_or_insert(0);
325                    comparators.push(cmp);
326                }
327            }
328            Op::Exact | Op::Tilde | Op::Wildcard | Op::Greater | Op::Less | Op::LessEq => {
329                comparators.push(cmp);
330                continue;
331            }
332            _ => panic!("unknown comparator in `{cmp}`"),
333        }
334    }
335
336    if !has_suggestions {
337        return None;
338    }
339
340    // This is a lossy suggestion that
341    //
342    // * extra spaces are removed
343    // * caret operator `^` is stripped
344    let mut suggestion = String::new();
345
346    for cmp in &comparators {
347        if !suggestion.is_empty() {
348            suggestion.push_str(", ");
349        }
350        let s = cmp.to_string();
351
352        if cmp.op == Op::Caret {
353            suggestion.push_str(s.strip_prefix('^').unwrap_or(&s));
354        } else {
355            suggestion.push_str(&s);
356        }
357    }
358
359    Some(suggestion)
360}
361
362/// A map from parsed `Platform` to their original TOML key strings.
363/// This is needed for constructing TOML key paths in diagnostics.
364///
365/// This is only relevant for package dependencies.
366fn target_key_for_platform(manifest: &Manifest) -> HashMap<Platform, String> {
367    manifest
368        .normalized_toml()
369        .target
370        .as_ref()
371        .map(|map| {
372            map.keys()
373                .map(|k| (k.parse().expect("already parsed"), k.clone()))
374                .collect()
375        })
376        .unwrap_or_default()
377}