|
| 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