| 1 | //! Version requirements, as `ignore` rules and manifests write them in |
| 2 | //! each ecosystem's own syntax: npm's `^1.2.0 || 2.x`, Cargo's `1.2` |
| 3 | //! (a caret), pip's `~=1.4, !=1.4.2`, Bundler's `~> 2.0`, NuGet's `7.*`, |
| 4 | //! Maven's `[1.4,)`. And what kind of update one version is from another. |
| 5 | |
| 6 | use std::cmp::Ordering; |
| 7 | |
| 8 | use g1t_scan::version; |
| 9 | |
| 10 | /// How a bare version, with no operator, is read. |
| 11 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 12 | pub enum Bare { |
| 13 | /// That version exactly (npm, pip, an `ignore` rule). |
| 14 | Exact, |
| 15 | /// A caret requirement (Cargo). |
| 16 | Caret, |
| 17 | } |
| 18 | |
| 19 | #[derive(Clone, Debug, PartialEq)] |
| 20 | enum Bound { |
| 21 | Min(String, bool), |
| 22 | Max(String, bool), |
| 23 | Not(String), |
| 24 | Exact(String), |
| 25 | } |
| 26 | |
| 27 | /// The leading numbers of a version: `v1.2.3-rc.1` is `[1, 2, 3]`. |
| 28 | fn numbers(text: &str) -> Vec<u64> { |
| 29 | let text = text.trim().trim_start_matches(['v', 'V', '=']); |
| 30 | let release = text.split(['-', '+']).next().unwrap_or(text); |
| 31 | let mut out = Vec::new(); |
| 32 | for part in release.split('.') { |
| 33 | let digits: String = part.chars().take_while(char::is_ascii_digit).collect(); |
| 34 | if digits.is_empty() { |
| 35 | break; |
| 36 | } |
| 37 | out.push(digits.parse().unwrap_or(u64::MAX)); |
| 38 | if digits.len() != part.len() { |
| 39 | break; |
| 40 | } |
| 41 | } |
| 42 | out |
| 43 | } |
| 44 | |
| 45 | fn join(parts: &[u64]) -> String { |
| 46 | parts.iter().map(u64::to_string).collect::<Vec<_>>().join(".") |
| 47 | } |
| 48 | |
| 49 | /// The version just past every one starting with `parts`: `[1, 2]` → `1.3`. |
| 50 | fn next_after(parts: &[u64]) -> String { |
| 51 | let mut parts = parts.to_vec(); |
| 52 | if let Some(last) = parts.last_mut() { |
| 53 | *last += 1; |
| 54 | } |
| 55 | join(&parts) |
| 56 | } |
| 57 | |
| 58 | /// A version written with wildcards: `1.2.x`, `1.*`, `*`. Returns the |
| 59 | /// fixed numbers before the first wildcard. |
| 60 | fn wildcard(text: &str) -> Option<Vec<u64>> { |
| 61 | let text = text.trim().trim_start_matches(['v', '=']); |
| 62 | let parts: Vec<&str> = text.split('.').collect(); |
| 63 | let at = parts.iter().position(|part| matches!(*part, "x" | "X" | "*"))?; |
| 64 | parts[..at].iter().map(|part| part.parse().ok()).collect() |
| 65 | } |
| 66 | |
| 67 | /// The bounds one comparator sets. |
| 68 | fn comparator(text: &str, bare: Bare) -> Option<Vec<Bound>> { |
| 69 | let text = text.trim(); |
| 70 | let operators = ["===", "==", ">=", "<=", "!=", "~>", "~=", "^", "~", ">", "<", "="]; |
| 71 | let (operator, rest) = operators |
| 72 | .iter() |
| 73 | .find_map(|op| text.strip_prefix(op).map(|rest| (*op, rest.trim()))) |
| 74 | .unwrap_or(("", text)); |
| 75 | if rest.is_empty() { |
| 76 | return None; |
| 77 | } |
| 78 | if let Some(fixed) = wildcard(rest) { |
| 79 | let low = join(&fixed); |
| 80 | return Some(match operator { |
| 81 | "" | "=" | "==" | "===" | "^" | "~" => { |
| 82 | if fixed.is_empty() { |
| 83 | vec![] |
| 84 | } else { |
| 85 | vec![Bound::Min(low, true), Bound::Max(next_after(&fixed), false)] |
| 86 | } |
| 87 | } |
| 88 | "!=" => vec![Bound::Not(rest.to_owned())], |
| 89 | ">=" | ">" => vec![Bound::Min(low, true)], |
| 90 | "<" | "<=" => vec![Bound::Max(low, false)], |
| 91 | _ => return None, |
| 92 | }); |
| 93 | } |
| 94 | let parts = numbers(rest); |
| 95 | if parts.is_empty() { |
| 96 | return None; |
| 97 | } |
| 98 | let version = rest.to_owned(); |
| 99 | let caret = |parts: &[u64]| -> Vec<Bound> { |
| 100 | // The first non-zero part may not change; `^0.0` and `^0` hold their zeros. |
| 101 | let keep = parts.iter().position(|part| *part != 0).map_or(parts.len().max(1), |at| at + 1).min(parts.len()); |
| 102 | vec![Bound::Min(version.clone(), true), Bound::Max(next_after(&parts[..keep]), false)] |
| 103 | }; |
| 104 | Some(match operator { |
| 105 | "" if bare == Bare::Caret => caret(&parts), |
| 106 | "" | "=" | "==" | "===" => vec![Bound::Exact(version)], |
| 107 | "^" => caret(&parts), |
| 108 | "~" => { |
| 109 | let keep = if parts.len() >= 2 { 2 } else { 1 }; |
| 110 | vec![Bound::Min(version.clone(), true), Bound::Max(next_after(&parts[..keep]), false)] |
| 111 | } |
| 112 | "~>" | "~=" => { |
| 113 | let keep = parts.len().saturating_sub(1).max(1); |
| 114 | vec![Bound::Min(version.clone(), true), Bound::Max(next_after(&parts[..keep]), false)] |
| 115 | } |
| 116 | ">=" => vec![Bound::Min(version, true)], |
| 117 | ">" => vec![Bound::Min(version, false)], |
| 118 | "<=" => vec![Bound::Max(version, true)], |
| 119 | "<" => vec![Bound::Max(version, false)], |
| 120 | "!=" => vec![Bound::Not(version)], |
| 121 | _ => return None, |
| 122 | }) |
| 123 | } |
| 124 | |
| 125 | /// Maven's and NuGet's interval notation: `[1.0,2.0)`, `(,1.0]`, `[1.5,)`, `[1.0]`. |
| 126 | fn interval(text: &str) -> Option<Vec<Bound>> { |
| 127 | let text = text.trim(); |
| 128 | let open = text.chars().next().filter(|c| matches!(c, '[' | '('))?; |
| 129 | let close = text.chars().last().filter(|c| matches!(c, ']' | ')'))?; |
| 130 | let inner = &text[1..text.len() - 1]; |
| 131 | let Some((low, high)) = inner.split_once(',') else { |
| 132 | return (open == '[' && close == ']').then(|| vec![Bound::Exact(inner.trim().to_owned())]); |
| 133 | }; |
| 134 | let mut bounds = Vec::new(); |
| 135 | if !low.trim().is_empty() { |
| 136 | bounds.push(Bound::Min(low.trim().to_owned(), open == '[')); |
| 137 | } |
| 138 | if !high.trim().is_empty() { |
| 139 | bounds.push(Bound::Max(high.trim().to_owned(), close == ']')); |
| 140 | } |
| 141 | Some(bounds) |
| 142 | } |
| 143 | |
| 144 | /// One set of bounds that must all hold, from `>=1.2 <2`, `>= 1.2, < 2` or `1.2 - 2.3`. |
| 145 | fn conjunction(text: &str, bare: Bare) -> Option<Vec<Bound>> { |
| 146 | let text = text.trim(); |
| 147 | if text.is_empty() || text == "*" || text.eq_ignore_ascii_case("latest") { |
| 148 | return Some(Vec::new()); |
| 149 | } |
| 150 | if let Some(bounds) = interval(text) { |
| 151 | return Some(bounds); |
| 152 | } |
| 153 | if let Some((low, high)) = text.split_once(" - ") { |
| 154 | let high_parts = numbers(high); |
| 155 | let max = if high.trim().split('.').count() < 3 && !high_parts.is_empty() { |
| 156 | Bound::Max(next_after(&high_parts), false) |
| 157 | } else { |
| 158 | Bound::Max(high.trim().to_owned(), true) |
| 159 | }; |
| 160 | return Some(vec![Bound::Min(low.trim().to_owned(), true), max]); |
| 161 | } |
| 162 | // Operators may be followed by a space: `>= 1.2, < 2`. |
| 163 | let mut tokens: Vec<String> = Vec::new(); |
| 164 | for word in text.split([',', ' ']).filter(|word| !word.is_empty()) { |
| 165 | match tokens.last_mut() { |
| 166 | Some(last) if last.chars().all(|c| "<>=!~^".contains(c)) => last.push_str(word), |
| 167 | _ => tokens.push(word.to_owned()), |
| 168 | } |
| 169 | } |
| 170 | let mut bounds = Vec::new(); |
| 171 | for token in tokens { |
| 172 | bounds.extend(comparator(&token, bare)?); |
| 173 | } |
| 174 | Some(bounds) |
| 175 | } |
| 176 | |
| 177 | fn holds(bound: &Bound, candidate: &str) -> bool { |
| 178 | let order = version::compare(candidate, match bound { |
| 179 | Bound::Min(v, _) | Bound::Max(v, _) | Bound::Not(v) | Bound::Exact(v) => v, |
| 180 | }); |
| 181 | match bound { |
| 182 | Bound::Min(_, inclusive) => order == Ordering::Greater || (*inclusive && order == Ordering::Equal), |
| 183 | Bound::Max(_, inclusive) => order == Ordering::Less || (*inclusive && order == Ordering::Equal), |
| 184 | Bound::Not(text) => match wildcard(text) { |
| 185 | Some(fixed) => !numbers(candidate).starts_with(&fixed), |
| 186 | None => order != Ordering::Equal, |
| 187 | }, |
| 188 | Bound::Exact(_) => order == Ordering::Equal, |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | /// Whether `candidate` meets `requirement`. `None` when the requirement |
| 193 | /// cannot be read. |
| 194 | pub fn satisfies(requirement: &str, candidate: &str, bare: Bare) -> Option<bool> { |
| 195 | let mut any = false; |
| 196 | for alternative in requirement.split("||") { |
| 197 | let bounds = conjunction(alternative, bare)?; |
| 198 | any |= bounds.iter().all(|bound| holds(bound, candidate)); |
| 199 | } |
| 200 | Some(any) |
| 201 | } |
| 202 | |
| 203 | /// Whether an `ignore` rule's `versions` entry covers `candidate`. One |
| 204 | /// that cannot be read covers the version it names, and nothing else. |
| 205 | pub fn ignored_by(versions: &str, candidate: &str) -> bool { |
| 206 | satisfies(versions, candidate, Bare::Exact).unwrap_or_else(|| versions.trim() == candidate.trim()) |
| 207 | } |
| 208 | |
| 209 | /// `major`, `minor` or `patch`: the first of the three numbers that |
| 210 | /// differs from `from` to `to`. |
| 211 | pub fn update_level(from: &str, to: &str) -> &'static str { |
| 212 | let (a, b) = (numbers(from), numbers(to)); |
| 213 | let at = |parts: &[u64], index: usize| parts.get(index).copied().unwrap_or(0); |
| 214 | if at(&a, 0) != at(&b, 0) { |
| 215 | "major" |
| 216 | } else if at(&a, 1) != at(&b, 1) { |
| 217 | "minor" |
| 218 | } else { |
| 219 | "patch" |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | /// `version-update:semver-<level>`. |
| 224 | pub fn update_type(from: &str, to: &str) -> String { |
| 225 | format!("version-update:semver-{}", update_level(from, to)) |
| 226 | } |
| 227 | |
| 228 | /// The requirement an `@g1t ignore this <level> version` comment records, |
| 229 | /// as an `ignore` rule's `versions` would say it: `>= 5.a, < 6` for a major |
| 230 | /// version, `>= 5.1.a, < 5.2` for a minor one, `5.1.3` for a patch. |
| 231 | pub fn ignore_level(to: &str, level: &str) -> String { |
| 232 | let parts = numbers(to); |
| 233 | let at = |index: usize| parts.get(index).copied().unwrap_or(0); |
| 234 | match level { |
| 235 | "major" => format!(">= {}.a, < {}", at(0), at(0) + 1), |
| 236 | "minor" => format!(">= {}.{}.a, < {}.{}", at(0), at(1), at(0), at(1) + 1), |
| 237 | _ => to.trim().to_owned(), |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | /// Whether a version is a pre-release: `2.0.0-rc.1`, `2.0.0b1`, `2.0.0.dev1`. |
| 242 | pub fn prerelease(text: &str) -> bool { |
| 243 | let text = text.trim().trim_start_matches(['v', 'V']); |
| 244 | let release = text.split('+').next().unwrap_or(text); |
| 245 | release.contains('-') |
| 246 | || release |
| 247 | .split('.') |
| 248 | .any(|part| part.chars().any(|c| c.is_ascii_alphabetic()) && !part.starts_with("post") && !part.starts_with("final")) |
| 249 | } |
| 250 | |
| 251 | #[cfg(test)] |
| 252 | mod tests { |
| 253 | use super::*; |
| 254 | |
| 255 | fn npm(requirement: &str, candidate: &str) -> bool { |
| 256 | satisfies(requirement, candidate, Bare::Exact).unwrap() |
| 257 | } |
| 258 | |
| 259 | #[test] |
| 260 | fn npm_ranges() { |
| 261 | assert!(npm("^1.2.3", "1.9.0") && !npm("^1.2.3", "2.0.0") && !npm("^1.2.3", "1.2.2")); |
| 262 | assert!(npm("^0.2.3", "0.2.9") && !npm("^0.2.3", "0.3.0")); |
| 263 | assert!(npm("^0.0.3", "0.0.3") && !npm("^0.0.3", "0.0.4")); |
| 264 | assert!(npm("~1.2.3", "1.2.9") && !npm("~1.2.3", "1.3.0")); |
| 265 | assert!(npm("1.2.x", "1.2.7") && !npm("1.2.x", "1.3.0")); |
| 266 | assert!(npm("1.x || >=3", "3.1.0") && npm("1.x || >=3", "1.4.0") && !npm("1.x || >=3", "2.0.0")); |
| 267 | assert!(npm(">=1.2 <2", "1.5.0") && !npm(">=1.2 <2", "2.0.0")); |
| 268 | assert!(npm("1.2 - 1.4", "1.4.9") && !npm("1.2 - 1.4", "1.5.0")); |
| 269 | assert!(npm("*", "9.9.9") && npm("", "1.0.0")); |
| 270 | assert!(npm("4.17.21", "4.17.21") && !npm("4.17.21", "4.17.20")); |
| 271 | } |
| 272 | |
| 273 | #[test] |
| 274 | fn other_ecosystems() { |
| 275 | // Cargo: a bare version is a caret. |
| 276 | assert!(satisfies("1.2", "1.9.0", Bare::Caret).unwrap()); |
| 277 | assert!(!satisfies("1.2", "2.0.0", Bare::Caret).unwrap()); |
| 278 | assert!(satisfies("0.3", "0.3.7", Bare::Caret).unwrap() && !satisfies("0.3", "0.4.0", Bare::Caret).unwrap()); |
| 279 | assert!(satisfies("=1.2.3", "1.2.3", Bare::Caret).unwrap()); |
| 280 | // pip. |
| 281 | assert!(npm("~=1.4.2", "1.4.9") && !npm("~=1.4.2", "1.5.0")); |
| 282 | assert!(npm("~=1.4", "1.9") && !npm("~=1.4", "2.0")); |
| 283 | assert!(npm(">=2.0,!=2.1.0,<3", "2.2.0") && !npm(">=2.0,!=2.1.0,<3", "2.1.0")); |
| 284 | assert!(npm("==1.*", "1.5") && !npm("==1.*", "2.0")); |
| 285 | assert!(npm("!=1.*", "2.0") && !npm("!=1.*", "1.3")); |
| 286 | // Bundler. |
| 287 | assert!(npm("~> 2.0", "2.9") && !npm("~> 2.0", "3.0")); |
| 288 | assert!(npm("~> 2.0.1", "2.0.9") && !npm("~> 2.0.1", "2.1.0")); |
| 289 | // NuGet and Maven. |
| 290 | assert!(npm("7.*", "7.3.1") && !npm("7.*", "8.0.0")); |
| 291 | assert!(npm("[1.4,)", "1.4") && npm("[1.4,)", "3.0") && !npm("[1.4,)", "1.3")); |
| 292 | assert!(npm("[1.0,2.0)", "1.9.9") && !npm("[1.0,2.0)", "2.0")); |
| 293 | assert!(npm("(,1.0]", "1.0") && !npm("(,1.0]", "1.0.1")); |
| 294 | assert!(npm("[1.0]", "1.0") && !npm("[1.0]", "1.1")); |
| 295 | // An ignore rule written with spaces after operators. |
| 296 | assert!(npm(">= 5.a, < 6", "5.0.0") && npm(">= 5.a, < 6", "5.9.1") && !npm(">= 5.a, < 6", "6.0.0") && !npm(">= 5.a, < 6", "4.9.0")); |
| 297 | assert_eq!(satisfies("^", "1.0.0", Bare::Exact), None); |
| 298 | assert!(ignored_by("not a range", "not a range")); |
| 299 | } |
| 300 | |
| 301 | #[test] |
| 302 | fn update_levels() { |
| 303 | assert_eq!(update_level("4.17.20", "4.17.21"), "patch"); |
| 304 | assert_eq!(update_level("4.17.20", "4.18.0"), "minor"); |
| 305 | assert_eq!(update_level("4.17.20", "5.0.0"), "major"); |
| 306 | assert_eq!(update_level("v0.7.0", "v0.23.0"), "minor"); |
| 307 | assert_eq!(update_level("1.2", "1.2.1"), "patch"); |
| 308 | assert_eq!(update_type("1.0.0", "2.0.0"), "version-update:semver-major"); |
| 309 | } |
| 310 | |
| 311 | #[test] |
| 312 | fn ignore_comments_become_requirements() { |
| 313 | assert_eq!(ignore_level("5.1.3", "major"), ">= 5.a, < 6"); |
| 314 | assert_eq!(ignore_level("5.1.3", "minor"), ">= 5.1.a, < 5.2"); |
| 315 | assert_eq!(ignore_level("5.1.3", "patch"), "5.1.3"); |
| 316 | assert!(ignored_by(&ignore_level("5.1.3", "minor"), "5.1.9")); |
| 317 | assert!(!ignored_by(&ignore_level("5.1.3", "minor"), "5.2.0")); |
| 318 | } |
| 319 | |
| 320 | #[test] |
| 321 | fn prereleases() { |
| 322 | for pre in ["2.0.0-rc.1", "2.0.0b1", "2.0.0.dev1", "v1.0.0-alpha", "1.0a1"] { |
| 323 | assert!(prerelease(pre), "{pre}"); |
| 324 | } |
| 325 | for release in ["2.0.0", "v1.2.3", "1.2.post1", "1.0.0+build.5"] { |
| 326 | assert!(!prerelease(release), "{release}"); |
| 327 | } |
| 328 | } |
| 329 | } |