Skip to content

Commit 7fac520

Browse files
committed
feat(lint): imprecise_version_requirements
Add a new `cargo::imprecise_version_requirements` lint: Only check if dependency has a single caret requirement. All other requirements (multiple, tilde, wildcard) are not linted by this rule.
1 parent 31d38fb commit 7fac520

File tree

4 files changed

+361
-12
lines changed

4 files changed

+361
-12
lines changed

src/cargo/core/workspace.rs

Lines changed: 14 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::imprecise_version_requirements;
3233
use crate::util::toml::{InheritableFields, read_manifest};
3334
use crate::util::{
3435
Filesystem, GlobalContext, IntoUrl, context::CargoResolverConfig, context::ConfigRelativePath,
@@ -1296,6 +1297,16 @@ impl<'gctx> Workspace<'gctx> {
12961297
self.gctx,
12971298
)?;
12981299
check_im_a_teapot(pkg, &path, &cargo_lints, &mut error_count, self.gctx)?;
1300+
imprecise_version_requirements(
1301+
pkg,
1302+
&path,
1303+
&cargo_lints,
1304+
ws_contents,
1305+
ws_document,
1306+
self.root_manifest(),
1307+
&mut error_count,
1308+
self.gctx,
1309+
)?;
12991310
}
13001311

13011312
if error_count > 0 {

src/cargo/util/lints.rs

Lines changed: 201 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
11
use crate::core::{Edition, Feature, Features, Manifest, MaybePackage, Package};
22
use crate::{CargoResult, GlobalContext};
3+
34
use annotate_snippets::{AnnotationKind, Group, Level, Patch, Snippet};
4-
use cargo_util_schemas::manifest::{ProfilePackageSpec, TomlLintLevel, TomlToolLints};
5+
use cargo_util_schemas::manifest::ProfilePackageSpec;
6+
use cargo_util_schemas::manifest::TomlLintLevel;
7+
use cargo_util_schemas::manifest::TomlToolLints;
58
use pathdiff::diff_paths;
9+
610
use std::borrow::Cow;
11+
use std::collections::HashMap;
712
use std::fmt::Display;
813
use std::ops::Range;
914
use std::path::Path;
1015

1116
const LINT_GROUPS: &[LintGroup] = &[TEST_DUMMY_UNSTABLE];
12-
pub const LINTS: &[Lint] = &[BLANKET_HINT_MOSTLY_UNUSED, IM_A_TEAPOT, UNKNOWN_LINTS];
17+
18+
pub const LINTS: &[Lint] = &[
19+
BLANKET_HINT_MOSTLY_UNUSED,
20+
IMPRECISE_VERSION_REQUIREMENTS,
21+
IM_A_TEAPOT,
22+
UNKNOWN_LINTS,
23+
];
24+
25+
enum SpanOrigin {
26+
Specified(core::ops::Range<usize>),
27+
Inherited(core::ops::Range<usize>),
28+
}
1329

1430
pub fn analyze_cargo_lints_table(
1531
pkg: &Package,
@@ -743,6 +759,189 @@ fn output_unknown_lints(
743759
Ok(())
744760
}
745761

762+
const IMPRECISE_VERSION_REQUIREMENTS: Lint = Lint {
763+
name: "imprecise_version_requirements",
764+
desc: "dependency version requirement lacks full precision",
765+
groups: &[],
766+
default_level: LintLevel::Allow,
767+
edition_lint_opts: None,
768+
feature_gate: None,
769+
docs: Some(
770+
r#"
771+
### What it does
772+
773+
Checks for dependency version requirements that lack full `major.minor.patch` precision,
774+
such as `serde = "1"` or `serde = "1.0"`.
775+
776+
### Why it is bad
777+
778+
Imprecise version requirements can be misleading about the actual minimum supported version.
779+
For example,
780+
`serde = "1"` suggests that any version from `1.0.0` onwards is acceptable,
781+
but if your code actually requires features from `1.0.219`,
782+
the imprecise requirement gives a false impression about compatibility.
783+
784+
Specifying the full version helps with:
785+
786+
- Accurate minimum version documentation
787+
- Better compatibility with `-Z minimal-versions`
788+
- Clearer dependency constraints for consumers
789+
790+
### Drawbacks
791+
792+
Even with fully specified versions,
793+
the minimum bound might still be incorrect if untested.
794+
This lint helps improve precision but doesn't guarantee correctness.
795+
796+
### Example
797+
798+
```toml
799+
[dependencies]
800+
serde = "1"
801+
```
802+
803+
Should be written as a full specific version:
804+
805+
```toml
806+
[dependencies]
807+
serde = "1.0.219"
808+
```
809+
"#,
810+
),
811+
};
812+
813+
pub fn imprecise_version_requirements(
814+
pkg: &Package,
815+
path: &Path,
816+
pkg_lints: &TomlToolLints,
817+
ws_contents: &str,
818+
ws_document: &toml::Spanned<toml::de::DeTable<'static>>,
819+
ws_path: &Path,
820+
error_count: &mut usize,
821+
gctx: &GlobalContext,
822+
) -> CargoResult<()> {
823+
let manifest = pkg.manifest();
824+
let (lint_level, reason) = IMPRECISE_VERSION_REQUIREMENTS.level(
825+
pkg_lints,
826+
manifest.edition(),
827+
manifest.unstable_features(),
828+
);
829+
830+
if lint_level == LintLevel::Allow {
831+
return Ok(());
832+
}
833+
834+
let manifest_path = rel_cwd_manifest_path(path, gctx);
835+
836+
let platform_map: HashMap<cargo_platform::Platform, String> = manifest
837+
.normalized_toml()
838+
.target
839+
.as_ref()
840+
.map(|map| {
841+
map.keys()
842+
.map(|k| (k.parse().expect("already parsed"), k.clone()))
843+
.collect()
844+
})
845+
.unwrap_or_default();
846+
847+
for dep in manifest.dependencies().iter() {
848+
let crate::util::OptVersionReq::Req(req) = dep.version_req() else {
849+
continue;
850+
};
851+
let [cmp] = req.comparators.as_slice() else {
852+
continue;
853+
};
854+
if cmp.op != semver::Op::Caret {
855+
continue;
856+
}
857+
if cmp.minor.is_some() && cmp.patch.is_some() {
858+
continue;
859+
}
860+
861+
// Only focus on single caret requirement that has only `major` or `major.minor`
862+
let name_in_toml = dep.name_in_toml().as_str();
863+
864+
let key_path = if let Some(cfg) = dep.platform().and_then(|p| platform_map.get(p)) {
865+
&["target", &cfg, dep.kind().kind_table(), name_in_toml][..]
866+
} else {
867+
&[dep.kind().kind_table(), name_in_toml][..]
868+
};
869+
870+
let Some((_key, value)) = get_key_value(manifest.document(), key_path) else {
871+
continue;
872+
};
873+
874+
let span = match value.as_ref() {
875+
toml::de::DeValue::String(_) => SpanOrigin::Specified(value.span()),
876+
toml::de::DeValue::Table(map) => {
877+
if let Some(v) = map.get("version").filter(|v| v.as_ref().is_str()) {
878+
SpanOrigin::Specified(v.span())
879+
} else if let Some((k, v)) = map
880+
.get_key_value("workspace")
881+
.filter(|(_, v)| v.as_ref().is_bool())
882+
{
883+
SpanOrigin::Inherited(k.span().start..v.span().end)
884+
} else {
885+
panic!("version must be specified or workspace-inherited");
886+
}
887+
}
888+
_ => unreachable!("dependency must be string or table"),
889+
};
890+
891+
let level = lint_level.to_diagnostic_level();
892+
let title = IMPRECISE_VERSION_REQUIREMENTS.desc;
893+
let emitted_source = IMPRECISE_VERSION_REQUIREMENTS.emitted_source(lint_level, reason);
894+
let report = match span {
895+
SpanOrigin::Specified(span) => &[Group::with_title(level.clone().primary_title(title))
896+
.element(
897+
Snippet::source(manifest.contents())
898+
.path(&manifest_path)
899+
.annotation(AnnotationKind::Primary.span(span)),
900+
)
901+
.element(Level::NOTE.message(emitted_source))][..],
902+
SpanOrigin::Inherited(inherit_span) => {
903+
let key_path = &["workspace", "dependencies", name_in_toml];
904+
let (_, value) =
905+
get_key_value(ws_document, key_path).expect("must have workspace dep");
906+
let ws_span = match value.as_ref() {
907+
toml::de::DeValue::String(_) => value.span(),
908+
toml::de::DeValue::Table(map) => map
909+
.get("version")
910+
.filter(|v| v.as_ref().is_str())
911+
.map(|v| v.span())
912+
.expect("must have a version field"),
913+
_ => unreachable!("dependency must be string or table"),
914+
};
915+
916+
let ws_path = rel_cwd_manifest_path(ws_path, gctx);
917+
let second_title = format!("dependency `{name_in_toml}` was inherited");
918+
919+
&[
920+
Group::with_title(level.clone().primary_title(title)).element(
921+
Snippet::source(ws_contents)
922+
.path(ws_path)
923+
.annotation(AnnotationKind::Primary.span(ws_span)),
924+
),
925+
Group::with_title(Level::NOTE.secondary_title(second_title))
926+
.element(
927+
Snippet::source(manifest.contents())
928+
.path(&manifest_path)
929+
.annotation(AnnotationKind::Context.span(inherit_span)),
930+
)
931+
.element(Level::NOTE.message(emitted_source)),
932+
][..]
933+
}
934+
};
935+
936+
if lint_level.is_error() {
937+
*error_count += 1;
938+
}
939+
gctx.shell().print_report(report, lint_level.force())?;
940+
}
941+
942+
Ok(())
943+
}
944+
746945
#[cfg(test)]
747946
mod tests {
748947
use itertools::Itertools;

src/doc/src/reference/lints.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
Note: [Cargo's linting system is unstable](unstable.md#lintscargo) and can only be used on nightly toolchains
44

5+
## Allowed-by-default
6+
7+
These lints are all set to the 'allow' level by default.
8+
- [`imprecise_version_requirements`](#imprecise_version_requirements)
9+
510
## Warn-by-default
611

712
These lints are all set to the 'warn' level by default.
@@ -36,6 +41,49 @@ hint-mostly-unused = true
3641
```
3742

3843

44+
## `imprecise_version_requirements`
45+
Set to `allow` by default
46+
47+
### What it does
48+
49+
Checks for dependency version requirements that lack full `major.minor.patch` precision,
50+
such as `serde = "1"` or `serde = "1.0"`.
51+
52+
### Why it is bad
53+
54+
Imprecise version requirements can be misleading about the actual minimum supported version.
55+
For example,
56+
`serde = "1"` suggests that any version from `1.0.0` onwards is acceptable,
57+
but if your code actually requires features from `1.0.219`,
58+
the imprecise requirement gives a false impression about compatibility.
59+
60+
Specifying the full version helps with:
61+
62+
- Accurate minimum version documentation
63+
- Better compatibility with `-Z minimal-versions`
64+
- Clearer dependency constraints for consumers
65+
66+
### Drawbacks
67+
68+
Even with fully specified versions,
69+
the minimum bound might still be incorrect if untested.
70+
This lint helps improve precision but doesn't guarantee correctness.
71+
72+
### Example
73+
74+
```toml
75+
[dependencies]
76+
serde = "1"
77+
```
78+
79+
Should be written as a full specific version:
80+
81+
```toml
82+
[dependencies]
83+
serde = "1.0.219"
84+
```
85+
86+
3987
## `unknown_lints`
4088
Set to `warn` by default
4189

0 commit comments

Comments
 (0)