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