|
| 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::TomlToolLints; |
| 11 | +use toml::de::DeValue; |
| 12 | + |
| 13 | +use crate::CargoResult; |
| 14 | +use crate::GlobalContext; |
| 15 | +use crate::core::Manifest; |
| 16 | +use crate::core::Package; |
| 17 | +use crate::util::OptVersionReq; |
| 18 | +use crate::util::lints::Lint; |
| 19 | +use crate::util::lints::LintLevel; |
| 20 | +use crate::util::lints::LintLevelReason; |
| 21 | +use crate::util::lints::get_key_value; |
| 22 | +use crate::util::lints::rel_cwd_manifest_path; |
| 23 | + |
| 24 | +pub const LINT: Lint = Lint { |
| 25 | + name: "implicit_minimum_version_req", |
| 26 | + desc: "dependency version requirement without an explicit minimum version", |
| 27 | + groups: &[], |
| 28 | + default_level: LintLevel::Allow, |
| 29 | + edition_lint_opts: None, |
| 30 | + feature_gate: None, |
| 31 | + docs: Some( |
| 32 | + r#" |
| 33 | +### What it does |
| 34 | +
|
| 35 | +Checks for dependency version requirements |
| 36 | +that do not explicitly specify a full `major.minor.patch` version requirement, |
| 37 | +such as `serde = "1"` or `serde = "1.0"`. |
| 38 | +
|
| 39 | +This lint currently only applies to caret requirements |
| 40 | +(the [default requirements](specifying-dependencies.md#default-requirements)). |
| 41 | +
|
| 42 | +### Why it is bad |
| 43 | +
|
| 44 | +Version requirements without an explicit full version |
| 45 | +can be misleading about the actual minimum supported version. |
| 46 | +For example, |
| 47 | +`serde = "1"` has an implicit minimum bound of `1.0.0`. |
| 48 | +If your code actually requires features from `1.0.219`, |
| 49 | +the implicit minimum bound of `1.0.0` gives a false impression about compatibility. |
| 50 | +
|
| 51 | +Specifying the full version helps with: |
| 52 | +
|
| 53 | +- Accurate minimum version documentation |
| 54 | +- Better compatibility with `-Z minimal-versions` |
| 55 | +- Clearer dependency constraints for consumers |
| 56 | +
|
| 57 | +### Drawbacks |
| 58 | +
|
| 59 | +Even with a fully specified version, |
| 60 | +the minimum bound might still be incorrect if untested. |
| 61 | +This lint helps make the minimum version requirement explicit |
| 62 | +but doesn't guarantee correctness. |
| 63 | +
|
| 64 | +### Example |
| 65 | +
|
| 66 | +```toml |
| 67 | +[dependencies] |
| 68 | +serde = "1" |
| 69 | +``` |
| 70 | +
|
| 71 | +Should be written as a full specific version: |
| 72 | +
|
| 73 | +```toml |
| 74 | +[dependencies] |
| 75 | +serde = "1.0.219" |
| 76 | +``` |
| 77 | +"#, |
| 78 | + ), |
| 79 | +}; |
| 80 | + |
| 81 | +pub fn implicit_minimum_version_req( |
| 82 | + pkg: &Package, |
| 83 | + manifest_path: &Path, |
| 84 | + cargo_lints: &TomlToolLints, |
| 85 | + error_count: &mut usize, |
| 86 | + gctx: &GlobalContext, |
| 87 | +) -> CargoResult<()> { |
| 88 | + let manifest = pkg.manifest(); |
| 89 | + let (lint_level, reason) = LINT.level( |
| 90 | + cargo_lints, |
| 91 | + manifest.edition(), |
| 92 | + manifest.unstable_features(), |
| 93 | + ); |
| 94 | + |
| 95 | + if lint_level == LintLevel::Allow { |
| 96 | + return Ok(()); |
| 97 | + } |
| 98 | + |
| 99 | + let document = manifest.document(); |
| 100 | + let contents = manifest.contents(); |
| 101 | + let manifest_path = rel_cwd_manifest_path(manifest_path, gctx); |
| 102 | + let target_key_for_platform = target_key_for_platform(&manifest); |
| 103 | + |
| 104 | + for dep in manifest.dependencies().iter() { |
| 105 | + let version_req = dep.version_req(); |
| 106 | + let Some(suggested_req) = get_suggested_version_req(&version_req) else { |
| 107 | + continue; |
| 108 | + }; |
| 109 | + |
| 110 | + let name_in_toml = dep.name_in_toml().as_str(); |
| 111 | + let key_path = |
| 112 | + if let Some(cfg) = dep.platform().and_then(|p| target_key_for_platform.get(p)) { |
| 113 | + &["target", &cfg, dep.kind().kind_table(), name_in_toml][..] |
| 114 | + } else { |
| 115 | + &[dep.kind().kind_table(), name_in_toml][..] |
| 116 | + }; |
| 117 | + |
| 118 | + let Some(span) = span_of_version_req(document, key_path) else { |
| 119 | + continue; |
| 120 | + }; |
| 121 | + |
| 122 | + let report = report( |
| 123 | + lint_level, |
| 124 | + reason, |
| 125 | + span, |
| 126 | + contents, |
| 127 | + &manifest_path, |
| 128 | + &suggested_req, |
| 129 | + ); |
| 130 | + |
| 131 | + if lint_level.is_error() { |
| 132 | + *error_count += 1; |
| 133 | + } |
| 134 | + gctx.shell().print_report(&report, lint_level.force())?; |
| 135 | + } |
| 136 | + |
| 137 | + Ok(()) |
| 138 | +} |
| 139 | + |
| 140 | +pub fn span_of_version_req<'doc>( |
| 141 | + document: &'doc toml::Spanned<toml::de::DeTable<'static>>, |
| 142 | + path: &[&str], |
| 143 | +) -> Option<std::ops::Range<usize>> { |
| 144 | + let (_key, value) = get_key_value(document, path)?; |
| 145 | + |
| 146 | + match value.as_ref() { |
| 147 | + DeValue::String(_) => Some(value.span()), |
| 148 | + DeValue::Table(map) if map.get("workspace").is_some() => { |
| 149 | + // We only lint non-workspace-inherited dependencies |
| 150 | + None |
| 151 | + } |
| 152 | + DeValue::Table(map) => { |
| 153 | + let Some(v) = map.get("version") else { |
| 154 | + panic!("version must be specified or workspace-inherited"); |
| 155 | + }; |
| 156 | + Some(v.span()) |
| 157 | + } |
| 158 | + _ => unreachable!("dependency must be string or table"), |
| 159 | + } |
| 160 | +} |
| 161 | + |
| 162 | +fn report<'a>( |
| 163 | + lint_level: LintLevel, |
| 164 | + reason: LintLevelReason, |
| 165 | + span: std::ops::Range<usize>, |
| 166 | + contents: &'a str, |
| 167 | + manifest_path: &str, |
| 168 | + suggested_req: &str, |
| 169 | +) -> [Group<'a>; 2] { |
| 170 | + let level = lint_level.to_diagnostic_level(); |
| 171 | + let emitted_source = LINT.emitted_source(lint_level, reason); |
| 172 | + let replacement = format!(r#""{suggested_req}""#); |
| 173 | + let label = "missing full version components"; |
| 174 | + let secondary_title = "consider specifying full `major.minor.patch` version components"; |
| 175 | + [ |
| 176 | + level.clone().primary_title(LINT.desc).element( |
| 177 | + Snippet::source(contents) |
| 178 | + .path(manifest_path.to_owned()) |
| 179 | + .annotation(AnnotationKind::Primary.span(span.clone()).label(label)), |
| 180 | + ), |
| 181 | + Level::HELP |
| 182 | + .secondary_title(secondary_title) |
| 183 | + .element(Snippet::source(contents).patch(Patch::new(span.clone(), replacement))) |
| 184 | + .element(Level::NOTE.message(emitted_source)), |
| 185 | + ] |
| 186 | +} |
| 187 | + |
| 188 | +fn get_suggested_version_req(req: &OptVersionReq) -> Option<String> { |
| 189 | + use semver::Op; |
| 190 | + let OptVersionReq::Req(req) = req else { |
| 191 | + return None; |
| 192 | + }; |
| 193 | + let mut has_suggestions = false; |
| 194 | + let mut comparators = Vec::new(); |
| 195 | + |
| 196 | + for mut cmp in req.comparators.iter().cloned() { |
| 197 | + match cmp.op { |
| 198 | + Op::Caret | Op::GreaterEq => { |
| 199 | + // Only focus on comparator that has only `major` or `major.minor` |
| 200 | + if cmp.minor.is_some() && cmp.patch.is_some() { |
| 201 | + comparators.push(cmp); |
| 202 | + continue; |
| 203 | + } else { |
| 204 | + has_suggestions = true; |
| 205 | + cmp.minor.get_or_insert(0); |
| 206 | + cmp.patch.get_or_insert(0); |
| 207 | + comparators.push(cmp); |
| 208 | + } |
| 209 | + } |
| 210 | + Op::Exact | Op::Tilde | Op::Wildcard | Op::Greater | Op::Less | Op::LessEq => { |
| 211 | + comparators.push(cmp); |
| 212 | + continue; |
| 213 | + } |
| 214 | + _ => panic!("unknown comparator in `{cmp}`"), |
| 215 | + } |
| 216 | + } |
| 217 | + |
| 218 | + if !has_suggestions { |
| 219 | + return None; |
| 220 | + } |
| 221 | + |
| 222 | + // This is a lossy suggestion that |
| 223 | + // |
| 224 | + // * extra spaces are removed |
| 225 | + // * caret operator `^` is stripped |
| 226 | + let mut suggestion = String::new(); |
| 227 | + |
| 228 | + for cmp in &comparators { |
| 229 | + if !suggestion.is_empty() { |
| 230 | + suggestion.push_str(", "); |
| 231 | + } |
| 232 | + let s = cmp.to_string(); |
| 233 | + |
| 234 | + if cmp.op == Op::Caret { |
| 235 | + suggestion.push_str(s.strip_prefix('^').unwrap_or(&s)); |
| 236 | + } else { |
| 237 | + suggestion.push_str(&s); |
| 238 | + } |
| 239 | + } |
| 240 | + |
| 241 | + Some(suggestion) |
| 242 | +} |
| 243 | + |
| 244 | +/// A map from parsed `Platform` to their original TOML key strings. |
| 245 | +/// This is needed for constructing TOML key paths in diagnostics. |
| 246 | +/// |
| 247 | +/// This is only relevant for package dependencies. |
| 248 | +fn target_key_for_platform(manifest: &Manifest) -> HashMap<Platform, String> { |
| 249 | + manifest |
| 250 | + .normalized_toml() |
| 251 | + .target |
| 252 | + .as_ref() |
| 253 | + .map(|map| { |
| 254 | + map.keys() |
| 255 | + .map(|k| (k.parse().expect("already parsed"), k.clone())) |
| 256 | + .collect() |
| 257 | + }) |
| 258 | + .unwrap_or_default() |
| 259 | +} |
0 commit comments