Skip to main content

nu_command/semver/
parse.rs

1//! Loose semver parsing helpers.
2//!
3//! Strict SemVer 2.0.0 does not allow a leading `v` (or similar). In practice many
4//! tools publish tags like `v1.2.3`, `v.1.2.3`, or `v:1.2.3`. Loose mode strips a
5//! single leading prefix of that form before parsing.
6
7/// Strip a loose leading version prefix: `v`/`V`, optionally followed by `.`, `:`,
8/// `-`, or `_`, only when the remainder starts with a digit.
9///
10/// Returns `(prefix, rest)`. When no prefix is recognized, returns `("", s)`.
11///
12/// # Examples
13///
14/// - `v1.2.3` → `("v", "1.2.3")`
15/// - `V1.2.3` → `("V", "1.2.3")`
16/// - `v.1.2.3` → `("v.", "1.2.3")`
17/// - `v:1.2.3` → `("v:", "1.2.3")`
18/// - `v-1.2.3` → `("v-", "1.2.3")`
19/// - `v_1.2.3` → `("v_", "1.2.3")`
20/// - `1.2.3` → `("", "1.2.3")`
21pub fn strip_loose_version_prefix(s: &str) -> (&str, &str) {
22    let after_v = match s.strip_prefix('v').or_else(|| s.strip_prefix('V')) {
23        Some(rest) => rest,
24        None => return ("", s),
25    };
26
27    // v.1.2.3, v:1.2.3, v-1.2.3, or v_1.2.3
28    if let Some(after_sep) = after_v
29        .strip_prefix('.')
30        .or_else(|| after_v.strip_prefix(':'))
31        .or_else(|| after_v.strip_prefix('-'))
32        .or_else(|| after_v.strip_prefix('_'))
33        && after_sep.starts_with(|c: char| c.is_ascii_digit())
34    {
35        let prefix_len = s.len() - after_sep.len();
36        return (&s[..prefix_len], after_sep);
37    }
38
39    // v1.2.3
40    if after_v.starts_with(|c: char| c.is_ascii_digit()) {
41        let prefix_len = s.len() - after_v.len();
42        return (&s[..prefix_len], after_v);
43    }
44
45    ("", s)
46}
47
48/// Parse a version string, optionally accepting a loose leading prefix.
49///
50/// Returns the parsed version and the captured prefix (empty when strict or none).
51pub fn parse_version(s: &str, loose: bool) -> Result<(semver::Version, String), semver::Error> {
52    match semver::Version::parse(s) {
53        Ok(version) => Ok((version, String::new())),
54        Err(strict_err) => {
55            if !loose {
56                return Err(strict_err);
57            }
58            let (prefix, rest) = strip_loose_version_prefix(s);
59            if prefix.is_empty() {
60                return Err(strict_err);
61            }
62            let version = semver::Version::parse(rest)?;
63            Ok((version, prefix.to_string()))
64        }
65    }
66}
67
68/// Normalize a version requirement for loose parsing by stripping `v`/`V`
69/// (optionally followed by `.`, `:`, `-`, or `_`) before version numbers at
70/// comparator boundaries.
71///
72/// # Examples
73///
74/// - `>=v1.0.0` → `>=1.0.0`
75/// - `^v1.2.3` → `^1.2.3`
76/// - `v1.2.3` → `1.2.3`
77/// - `>=v:1.0.0, <v.2.0.0` → `>=1.0.0, <2.0.0`
78/// - `>=v-1.0.0, <v_2.0.0` → `>=1.0.0, <2.0.0`
79pub fn normalize_loose_range(s: &str) -> String {
80    let mut out = String::with_capacity(s.len());
81    let mut rest = s;
82
83    while !rest.is_empty() {
84        let at_boundary = out.is_empty()
85            || matches!(
86                out.chars().next_back(),
87                Some(' ' | ',' | '<' | '>' | '=' | '^' | '~' | '!')
88            );
89
90        if at_boundary {
91            let (prefix, after) = strip_loose_version_prefix(rest);
92            if !prefix.is_empty() {
93                rest = after;
94                continue;
95            }
96        }
97
98        let mut chars = rest.chars();
99        let Some(ch) = chars.next() else {
100            break;
101        };
102        out.push(ch);
103        rest = chars.as_str();
104    }
105
106    out
107}
108
109/// Parse a version requirement string, optionally accepting loose version prefixes.
110pub fn parse_version_req(s: &str, loose: bool) -> Result<semver::VersionReq, semver::Error> {
111    match semver::VersionReq::parse(s) {
112        Ok(req) => Ok(req),
113        Err(strict_err) => {
114            if !loose {
115                return Err(strict_err);
116            }
117            let normalized = normalize_loose_range(s);
118            if normalized == s {
119                return Err(strict_err);
120            }
121            semver::VersionReq::parse(&normalized)
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn strip_prefix_variants() {
132        assert_eq!(strip_loose_version_prefix("v1.2.3"), ("v", "1.2.3"));
133        assert_eq!(strip_loose_version_prefix("V1.2.3"), ("V", "1.2.3"));
134        assert_eq!(strip_loose_version_prefix("v.1.2.3"), ("v.", "1.2.3"));
135        assert_eq!(strip_loose_version_prefix("v:1.2.3"), ("v:", "1.2.3"));
136        assert_eq!(strip_loose_version_prefix("V:1.2.3"), ("V:", "1.2.3"));
137        assert_eq!(strip_loose_version_prefix("v-1.2.3"), ("v-", "1.2.3"));
138        assert_eq!(strip_loose_version_prefix("v_1.2.3"), ("v_", "1.2.3"));
139        assert_eq!(strip_loose_version_prefix("V-1.2.3"), ("V-", "1.2.3"));
140        assert_eq!(strip_loose_version_prefix("V_1.2.3"), ("V_", "1.2.3"));
141        assert_eq!(strip_loose_version_prefix("1.2.3"), ("", "1.2.3"));
142        assert_eq!(strip_loose_version_prefix("version"), ("", "version"));
143        assert_eq!(
144            strip_loose_version_prefix("v1.2.3-alpha.1+build"),
145            ("v", "1.2.3-alpha.1+build")
146        );
147        // Hyphen before a non-digit is not a loose prefix (prerelease-like).
148        assert_eq!(strip_loose_version_prefix("v-alpha"), ("", "v-alpha"));
149    }
150
151    #[test]
152    fn parse_version_loose() {
153        let (v, p) = parse_version("v1.2.3", true).unwrap();
154        assert_eq!(v.to_string(), "1.2.3");
155        assert_eq!(p, "v");
156
157        let (v, p) = parse_version("v.2.0.0", true).unwrap();
158        assert_eq!(v.to_string(), "2.0.0");
159        assert_eq!(p, "v.");
160
161        let (v, p) = parse_version("v:3.1.4", true).unwrap();
162        assert_eq!(v.to_string(), "3.1.4");
163        assert_eq!(p, "v:");
164
165        let (v, p) = parse_version("v-4.5.6", true).unwrap();
166        assert_eq!(v.to_string(), "4.5.6");
167        assert_eq!(p, "v-");
168
169        let (v, p) = parse_version("v_7.8.9", true).unwrap();
170        assert_eq!(v.to_string(), "7.8.9");
171        assert_eq!(p, "v_");
172
173        assert!(parse_version("v1.2.3", false).is_err());
174        let (v, p) = parse_version("1.2.3", true).unwrap();
175        assert_eq!(v.to_string(), "1.2.3");
176        assert!(p.is_empty());
177    }
178
179    #[test]
180    fn normalize_and_parse_range_loose() {
181        assert_eq!(normalize_loose_range(">=v1.0.0"), ">=1.0.0");
182        assert_eq!(normalize_loose_range("^v1.2.3"), "^1.2.3");
183        assert_eq!(normalize_loose_range("v1.2.3"), "1.2.3");
184        assert_eq!(
185            normalize_loose_range(">=v:1.0.0, <v.2.0.0"),
186            ">=1.0.0, <2.0.0"
187        );
188        assert_eq!(
189            normalize_loose_range(">=v-1.0.0, <v_2.0.0"),
190            ">=1.0.0, <2.0.0"
191        );
192
193        let req = parse_version_req(">=v1.0.0", true).unwrap();
194        assert_eq!(req.to_string(), ">=1.0.0");
195        assert!(parse_version_req(">=v1.0.0", false).is_err());
196
197        // Non-ASCII must not be split into replacement chars (byte-wise push).
198        assert_eq!(normalize_loose_range("≥v1.0.0"), "≥v1.0.0");
199        assert_eq!(normalize_loose_range("≥ v1.0.0"), "≥ 1.0.0");
200    }
201}