Skip to content

Commit 14116aa

Browse files
authored
feat(lint): new implicit_minimum_version_req lint (#16321)
### What does this PR try to resolve? Add a new `cargo::implicit_minimum_version_req` lint: Only check if dependency has a single caret requirement. All other requirements (multiple, tilde, wildcard) are not linted by this rule, as they usually have significance on what version fields get specified. Fixes #15577 ### How to test and review this PR? * [x] Find a good lint name * #16321 (comment) * [x] Find a proper lint level (currently set to `allow` because this may get people a lot of warnings) * #16321 (comment) * [x] Should we lint beyond caret requirements? * #16321 (comment) * [x] (maybe for follow-up) Should Cargo suggest a minimal bound? * #16321 (comment) suggesting the lowerest bound (adding missing zeros)
2 parents 40086e8 + 864bf96 commit 14116aa

File tree

6 files changed

+1686
-4
lines changed

6 files changed

+1686
-4
lines changed

src/cargo/core/workspace.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ use crate::util::context::FeatureUnification;
2626
use crate::util::edit_distance;
2727
use crate::util::errors::{CargoResult, ManifestError};
2828
use crate::util::interning::InternedString;
29-
use crate::util::lints::{
30-
analyze_cargo_lints_table, blanket_hint_mostly_unused, check_im_a_teapot,
31-
};
29+
use crate::util::lints::analyze_cargo_lints_table;
30+
use crate::util::lints::blanket_hint_mostly_unused;
31+
use crate::util::lints::check_im_a_teapot;
32+
use crate::util::lints::implicit_minimum_version_req;
3233
use crate::util::toml::{InheritableFields, read_manifest};
3334
use crate::util::{
3435
Filesystem, GlobalContext, IntoUrl, context::CargoResolverConfig, context::ConfigRelativePath,
@@ -1296,6 +1297,13 @@ impl<'gctx> Workspace<'gctx> {
12961297
self.gctx,
12971298
)?;
12981299
check_im_a_teapot(pkg, &path, &cargo_lints, &mut error_count, self.gctx)?;
1300+
implicit_minimum_version_req(
1301+
pkg.into(),
1302+
&path,
1303+
&cargo_lints,
1304+
&mut error_count,
1305+
self.gctx,
1306+
)?;
12991307
}
13001308

13011309
if error_count > 0 {
@@ -1332,6 +1340,13 @@ impl<'gctx> Workspace<'gctx> {
13321340

13331341
if self.gctx.cli_unstable().cargo_lints {
13341342
// Calls to lint functions go in here
1343+
implicit_minimum_version_req(
1344+
self.root_maybe().into(),
1345+
self.root_manifest(),
1346+
&cargo_lints,
1347+
&mut error_count,
1348+
self.gctx,
1349+
)?;
13351350
}
13361351

13371352
// This is a short term hack to allow `blanket_hint_mostly_unused`
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
use std::collections::HashMap;
2+
use std::path::Path;
3+
4+
use annotate_snippets::AnnotationKind;
5+
use annotate_snippets::Group;
6+
use annotate_snippets::Level;
7+
use annotate_snippets::Patch;
8+
use annotate_snippets::Snippet;
9+
use cargo_platform::Platform;
10+
use cargo_util_schemas::manifest::TomlDependency;
11+
use cargo_util_schemas::manifest::TomlToolLints;
12+
use toml::de::DeValue;
13+
14+
use crate::CargoResult;
15+
use crate::GlobalContext;
16+
use crate::core::Manifest;
17+
use crate::core::MaybePackage;
18+
use crate::core::Package;
19+
use crate::util::OptVersionReq;
20+
use crate::util::lints::Lint;
21+
use crate::util::lints::LintLevel;
22+
use crate::util::lints::LintLevelReason;
23+
use crate::util::lints::ManifestFor;
24+
use crate::util::lints::get_key_value;
25+
use crate::util::lints::rel_cwd_manifest_path;
26+
27+
pub 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+
38+
Checks for dependency version requirements
39+
that do not explicitly specify a full `major.minor.patch` version requirement,
40+
such as `serde = "1"` or `serde = "1.0"`.
41+
42+
This lint currently only applies to caret requirements
43+
(the [default requirements](specifying-dependencies.md#default-requirements)).
44+
45+
### Why it is bad
46+
47+
Version requirements without an explicit full version
48+
can be misleading about the actual minimum supported version.
49+
For example,
50+
`serde = "1"` has an implicit minimum bound of `1.0.0`.
51+
If your code actually requires features from `1.0.219`,
52+
the implicit minimum bound of `1.0.0` gives a false impression about compatibility.
53+
54+
Specifying 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+
62+
Even with a fully specified version,
63+
the minimum bound might still be incorrect if untested.
64+
This lint helps make the minimum version requirement explicit
65+
but doesn't guarantee correctness.
66+
67+
### Example
68+
69+
```toml
70+
[dependencies]
71+
serde = "1"
72+
```
73+
74+
Should be written as a full specific version:
75+
76+
```toml
77+
[dependencies]
78+
serde = "1.0.219"
79+
```
80+
"#,
81+
),
82+
};
83+
84+
pub 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+
114+
pub 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+
164+
pub 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+
230+
pub 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+
252+
fn 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+
278+
fn 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.
338+
fn 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+
}

0 commit comments

Comments
 (0)