1use pcre2::bytes::{Regex, RegexBuilder};
8use std::sync::OnceLock;
9
10pub const MODIFIER_REGEX: &str =
11 r"[._-]?(?:(stable|beta|b|RC|alpha|a|patch|pl|p)((?:[.-]?\d+)*+)?)?([.-]?dev)?";
12pub const STABILITIES_REGEX: &str = "stable|RC|beta|alpha|dev";
13pub const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev";
14
15#[derive(Debug, thiserror::Error, PartialEq, Eq, Clone)]
16#[error("{0}")]
17pub struct VersionError(pub String);
18
19pub(crate) fn regex(
21 cell: &'static OnceLock<Regex>,
22 pattern: &str,
23 caseless: bool,
24) -> &'static Regex {
25 cell.get_or_init(|| {
26 RegexBuilder::new()
27 .caseless(caseless)
28 .build(pattern)
29 .unwrap_or_else(|e| panic!("regex `{pattern}`: {e}"))
30 })
31}
32
33pub(crate) fn group<'a>(caps: &pcre2::bytes::Captures<'a>, i: usize) -> &'a str {
35 caps.get(i)
36 .map(|m| std::str::from_utf8(m.as_bytes()).unwrap_or(""))
37 .unwrap_or("")
38}
39
40pub fn parse_stability(version: &str) -> &'static str {
42 if !version.is_empty() && version.bytes().all(|c| c.is_ascii_digit() || c == b'.') {
45 return "stable";
46 }
47 static HASH: OnceLock<Regex> = OnceLock::new();
48 static MOD: OnceLock<Regex> = OnceLock::new();
49 let hash = regex(&HASH, r"#.+$", false);
50 let version: String = match hash.find(version.as_bytes()).ok().flatten() {
51 Some(m) => version[..m.start()].to_owned(),
52 None => version.to_owned(),
53 };
54 if version.starts_with("dev-") || version.ends_with("-dev") {
55 return "dev";
56 }
57 let re = regex(&MOD, &format!("{MODIFIER_REGEX}(?:\\+.*)?$"), true);
58 let lower = version.to_lowercase();
59 if let Ok(Some(caps)) = re.captures(lower.as_bytes()) {
60 if !group(&caps, 3).is_empty() {
61 return "dev";
62 }
63 match group(&caps, 1) {
64 "beta" | "b" => return "beta",
65 "alpha" | "a" => return "alpha",
66 "rc" => return "RC",
67 _ => {}
68 }
69 }
70 "stable"
71}
72
73pub fn stability_rank(stability: &str) -> i32 {
75 match stability {
76 "stable" => 0,
77 "RC" => 5,
78 "beta" => 10,
79 "alpha" => 15,
80 "dev" => 20,
81 _ => 0,
82 }
83}
84
85fn expand_stability(stability: &str) -> String {
86 match stability.to_lowercase().as_str() {
87 "a" => "alpha".to_owned(),
88 "b" => "beta".to_owned(),
89 "p" | "pl" => "patch".to_owned(),
90 "rc" => "RC".to_owned(),
91 other => other.to_owned(),
92 }
93}
94
95pub fn normalize(version: &str, full_version: Option<&str>) -> Result<String, VersionError> {
97 static AS: OnceLock<Regex> = OnceLock::new();
98 static AT: OnceLock<Regex> = OnceLock::new();
99 static PLUS: OnceLock<Regex> = OnceLock::new();
100 static NUM: OnceLock<Regex> = OnceLock::new();
101 static DATE: OnceLock<Regex> = OnceLock::new();
102 static DEV: OnceLock<Regex> = OnceLock::new();
103
104 let mut version = version.trim().to_owned();
105 let orig = version.clone();
106 let full_version = full_version
107 .map(str::to_owned)
108 .unwrap_or_else(|| version.clone());
109
110 if let Ok(Some(caps)) =
111 regex(&AS, r"^([^,\s]++) ++as ++([^,\s]++)$", false).captures(version.as_bytes())
112 {
113 version = group(&caps, 1).to_owned();
114 }
115 if let Ok(Some(m)) =
116 regex(&AT, &format!("@(?:{STABILITIES_REGEX})$"), true).find(version.as_bytes())
117 {
118 version.truncate(m.start());
119 }
120 if matches!(version.as_str(), "master" | "trunk" | "default") {
121 version = format!("dev-{version}");
122 }
123 if version.len() >= 4 && version[..4].eq_ignore_ascii_case("dev-") {
124 return Ok(format!("dev-{}", &version[4..]));
125 }
126 if let Ok(Some(caps)) =
127 regex(&PLUS, r"^([^,\s+]++)\+[^\s]++$", false).captures(version.as_bytes())
128 {
129 version = group(&caps, 1).to_owned();
130 }
131
132 let num = regex(
133 &NUM,
134 &format!(r"^v?(\d{{1,5}}+)(\.\d++)?(\.\d++)?(\.\d++)?{MODIFIER_REGEX}$"),
135 true,
136 );
137 let date = regex(
138 &DATE,
139 &format!(
140 r"^v?(\d{{4}}(?:[.:-]?\d{{2}}){{1,6}}(?:[.:-]?\d{{1,3}}){{0,2}}){MODIFIER_REGEX}$"
141 ),
142 true,
143 );
144 let capture_groups = |re: &Regex, subject: &str| -> Option<Vec<String>> {
146 let caps = re.captures(subject.as_bytes()).ok()??;
147 Some(
148 (0..caps.len())
149 .map(|i| group(&caps, i).to_owned())
150 .collect(),
151 )
152 };
153 let mut index: Option<usize> = None;
154 let mut groups: Vec<String> = Vec::new();
155 if let Some(g) = capture_groups(num, &version) {
156 let or0 = |s: &str| {
157 if s.is_empty() {
158 ".0".to_owned()
159 } else {
160 s.to_owned()
161 }
162 };
163 version = format!("{}{}{}{}", g[1], or0(&g[2]), or0(&g[3]), or0(&g[4]));
164 index = Some(5);
165 groups = g;
166 } else if let Some(g) = capture_groups(date, &version) {
167 version = g[1]
168 .chars()
169 .map(|c| if c.is_ascii_digit() { c } else { '.' })
170 .collect();
171 index = Some(2);
172 groups = g;
173 }
174 if let Some(index) = index {
175 let at = |i: usize| groups.get(i).map(String::as_str).unwrap_or("");
176 let stab = at(index);
177 if !stab.is_empty() {
178 if stab == "stable" {
179 return Ok(version);
180 }
181 let numbers = at(index + 1).trim_start_matches(['.', '-']);
182 version = format!("{version}-{}{numbers}", expand_stability(stab));
183 }
184 if !at(index + 2).is_empty() {
185 version.push_str("-dev");
186 }
187 return Ok(version);
188 }
189
190 if let Ok(Some(caps)) = regex(&DEV, r"(.*?)[.-]?dev$", true).captures(version.as_bytes()) {
191 let normalized = normalize_branch(group(&caps, 1));
192 if !normalized.contains("dev-") {
193 return Ok(normalized);
194 }
195 }
196
197 let mut extra = String::new();
198 let quoted = preg_quote(&version);
199 let as_alias = RegexBuilder::new()
200 .build(&format!(" +as +{quoted}(?:@(?:{STABILITIES_REGEX}))?$"))
201 .ok()
202 .and_then(|r| r.is_match(full_version.as_bytes()).ok())
203 .unwrap_or(false);
204 let as_source = RegexBuilder::new()
205 .build(&format!("^{quoted}(?:@(?:{STABILITIES_REGEX}))? +as +"))
206 .ok()
207 .and_then(|r| r.is_match(full_version.as_bytes()).ok())
208 .unwrap_or(false);
209 if as_alias {
210 extra = format!(" in \"{full_version}\", the alias must be an exact version");
211 } else if as_source {
212 extra = format!(
213 " in \"{full_version}\", the alias source must be an exact version, if it is a branch name you should prefix it with dev-"
214 );
215 }
216 Err(VersionError(format!(
217 "Invalid version string \"{orig}\"{extra}"
218 )))
219}
220
221pub fn preg_quote(s: &str) -> String {
223 let mut out = String::with_capacity(s.len());
224 for c in s.chars() {
225 if c == '\0' {
226 out.push_str("\\000");
227 continue;
228 }
229 if ".\\+*?[^]$(){}=!<>|:-#".contains(c) {
230 out.push('\\');
231 }
232 out.push(c);
233 }
234 out
235}
236
237pub fn normalize_branch(name: &str) -> String {
239 static RE: OnceLock<Regex> = OnceLock::new();
240 let name = name.trim();
241 let re = regex(
242 &RE,
243 r"^v?(\d++)(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?(\.(?:\d++|[xX*]))?$",
244 true,
245 );
246 if let Ok(Some(caps)) = re.captures(name.as_bytes()) {
247 let mut version = String::new();
248 for i in 1..5 {
249 let g = group(&caps, i);
250 if caps.get(i).is_some() {
251 version.push_str(&g.replace(['*', 'X'], "x"));
252 } else {
253 version.push_str(".x");
254 }
255 }
256 return format!("{}-dev", version.replace('x', "9999999"));
257 }
258 format!("dev-{name}")
259}
260
261pub fn parse_numeric_alias_prefix(branch: &str) -> Option<String> {
263 static RE: OnceLock<Regex> = OnceLock::new();
264 let re = regex(&RE, r"^(?P<version>(\d++\.)*\d++)(?:\.x)?-dev$", true);
265 let caps = re.captures(branch.as_bytes()).ok()??;
266 Some(format!("{}.", group(&caps, 1)))
267}
268
269pub fn normalize_default_branch(name: &str) -> String {
271 if matches!(name, "dev-master" | "dev-default" | "dev-trunk") {
272 DEFAULT_BRANCH_ALIAS.to_owned()
273 } else {
274 name.to_owned()
275 }
276}
277
278pub fn parse_name_version_pairs(pairs: &[String]) -> Vec<(String, Option<String>)> {
281 static SPLIT: OnceLock<Regex> = OnceLock::new();
282 static WILDCARD: OnceLock<Regex> = OnceLock::new();
283 let split = regex(&SPLIT, r"^([^=: ]+)[=: ](.*)$", false);
284 let wildcard = regex(&WILDCARD, r"(?<=[a-z0-9_/-])\*|\*(?=[a-z0-9_/-])", true);
285 let mut result = Vec::new();
286 let mut i = 0;
287 while i < pairs.len() {
288 let trimmed = pairs[i].trim();
289 let mut pair = match split.captures(trimmed.as_bytes()) {
290 Ok(Some(c)) => {
291 let g = |n: usize| {
292 c.get(n)
293 .and_then(|m| std::str::from_utf8(m.as_bytes()).ok())
294 .unwrap_or("")
295 };
296 format!("{} {}", g(1), g(2))
297 }
298 _ => trimmed.to_owned(),
299 };
300 if !pair.contains(' ') {
301 if let Some(next) = pairs.get(i + 1) {
302 if !next.contains('/')
303 && !wildcard.is_match(next.as_bytes()).unwrap_or(false)
304 && !crate::platform::is_platform_package(next)
305 {
306 pair.push(' ');
307 pair.push_str(next);
308 i += 1;
309 }
310 }
311 }
312 match pair.find(' ') {
313 Some(pos) if pos > 0 => {
314 result.push((pair[..pos].to_owned(), Some(pair[pos + 1..].to_owned())));
315 }
316 _ => result.push((pair, None)),
317 }
318 i += 1;
319 }
320 result
321}
322
323pub fn looks_too_strict(constraint: &str) -> bool {
326 static RE: OnceLock<Regex> = OnceLock::new();
327 regex(&RE, r"^\d+(\.\d+)?$", false)
328 .is_match(constraint.as_bytes())
329 .unwrap_or(false)
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 #[test]
337 fn normalizes_like_composer() {
338 assert_eq!(normalize("1.0", None).unwrap(), "1.0.0.0");
339 assert_eq!(normalize("v1.2.3-beta2", None).unwrap(), "1.2.3.0-beta2");
340 assert_eq!(normalize("1.0.0RC1", None).unwrap(), "1.0.0.0-RC1");
341 assert_eq!(normalize("dev-main", None).unwrap(), "dev-main");
342 assert_eq!(normalize("master", None).unwrap(), "dev-master");
343 assert_eq!(
344 normalize("1.x-dev", None).unwrap(),
345 "1.9999999.9999999.9999999-dev"
346 );
347 assert_eq!(
348 normalize("2.0.x-dev", None).unwrap(),
349 "2.0.9999999.9999999-dev"
350 );
351 assert_eq!(normalize("1.0.0+build.1", None).unwrap(), "1.0.0.0");
352 assert_eq!(normalize("1.0.0-dev", None).unwrap(), "1.0.0.0-dev");
353 assert_eq!(normalize("2020-01-02", None).unwrap(), "2020.01.02");
354 assert_eq!(normalize("1.0@dev", None).unwrap(), "1.0.0.0");
355 assert_eq!(normalize("dev-main as 1.0", None).unwrap(), "dev-main");
356 assert_eq!(normalize("1.0.0-p1", None).unwrap(), "1.0.0.0-patch1");
357 assert!(normalize("nope", None).is_err());
358 assert_eq!(parse_stability("1.0.0-RC1"), "RC");
359 assert_eq!(parse_stability("1.0.0-beta"), "beta");
360 assert_eq!(parse_stability("dev-main"), "dev");
361 assert_eq!(parse_stability("1.x-dev"), "dev");
362 assert_eq!(parse_stability("1.0.0"), "stable");
363 assert_eq!(parse_stability("1.0.0-alpha1#abc"), "alpha");
364 assert_eq!(normalize_branch("2.2"), "2.2.9999999.9999999-dev");
365 assert_eq!(parse_numeric_alias_prefix("1.2.x-dev"), Some("1.2.".into()));
366 assert_eq!(parse_numeric_alias_prefix("dev-main"), None);
367 }
368
369 #[test]
370 fn name_version_pairs_follow_composer() {
371 let pairs = |v: &[&str]| {
372 parse_name_version_pairs(&v.iter().map(|s| s.to_string()).collect::<Vec<_>>())
373 };
374 assert_eq!(
375 pairs(&["a/b:^1.0"]),
376 vec![("a/b".into(), Some("^1.0".into()))]
377 );
378 assert_eq!(
379 pairs(&["a/b=^1.0"]),
380 vec![("a/b".into(), Some("^1.0".into()))]
381 );
382 assert_eq!(
383 pairs(&["a/b", "^1.0"]),
384 vec![("a/b".into(), Some("^1.0".into()))]
385 );
386 assert_eq!(
387 pairs(&["a/b", "c/d"]),
388 vec![("a/b".into(), None), ("c/d".into(), None)]
389 );
390 assert_eq!(
391 pairs(&["a/b", "ext-json"]),
392 vec![("a/b".into(), None), ("ext-json".into(), None)]
393 );
394 assert_eq!(
395 pairs(&["a/b", "c/*"]),
396 vec![("a/b".into(), None), ("c/*".into(), None)]
397 );
398 assert_eq!(
399 pairs(&["a/b:dev-main as 1.x-dev"]),
400 vec![("a/b".into(), Some("dev-main as 1.x-dev".into()))]
401 );
402 }
403}