Skip to main content

cargo/diagnostics/rules/
implicit_minimum_version_req.rs

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