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