Skip to main content

js_semver/
version.rs

1use core::cmp::Ordering;
2use core::fmt;
3use core::str::FromStr;
4
5use crate::error::SemverErrorKind;
6use crate::identifier::{BuildMetadata, PreRelease};
7use crate::number::MAX_SAFE_INTEGER;
8use crate::{MAX_LENGTH, SemverError};
9
10// --------------------------------------------------------------------------
11// Version
12// --------------------------------------------------------------------------
13
14/// A parsed semantic version.
15///
16/// Build metadata is stored and included in the version's string form.
17/// Direct [`Version`] comparison ignores build metadata.
18/// Use [`Version::cmp_build`] when build metadata should be used as a
19/// tiebreaker.
20///
21/// # Examples
22///
23/// ```rust
24/// use js_semver::Version;
25///
26/// let version = Version::parse("19.3.0-canary-044d56f3-20260330").unwrap();
27///
28/// assert_eq!(version.major, 19);
29/// assert_eq!(version.minor, 3);
30/// assert_eq!(version.patch, 0);
31/// assert_eq!(version.to_string(), "19.3.0-canary-044d56f3-20260330");
32/// ```
33#[derive(Debug, Clone, Eq)]
34pub struct Version {
35    /// The major version number.
36    pub major: u64,
37    /// The minor version number.
38    pub minor: u64,
39    /// The patch version number.
40    pub patch: u64,
41    /// The pre-release identifiers, if any.
42    pub pre_release: PreRelease,
43    /// The build metadata identifiers, if any.
44    pub build: BuildMetadata,
45}
46
47impl Version {
48    /// Create a new `Version` with no pre-release or build metadata.
49    ///
50    /// # Examples
51    ///
52    /// ```rust
53    /// use js_semver::Version;
54    ///
55    /// let version = Version::new(1, 2, 3);
56    ///
57    /// assert_eq!(version.to_string(), "1.2.3");
58    /// assert!(version.pre_release.is_empty());
59    /// assert!(version.build.is_empty());
60    /// ```
61    #[must_use]
62    pub fn new(major: u64, minor: u64, patch: u64) -> Self {
63        Self {
64            major,
65            minor,
66            patch,
67            pre_release: PreRelease::default(),
68            build: BuildMetadata::default(),
69        }
70    }
71
72    /// Parse a version string.
73    ///
74    /// # Examples
75    ///
76    /// ```rust
77    /// use js_semver::Version;
78    ///
79    /// let version = Version::parse("1.2.3-alpha.1").unwrap();
80    ///
81    /// assert_eq!(version.major, 1);
82    /// assert_eq!(version.minor, 2);
83    /// assert_eq!(version.patch, 3);
84    /// assert_eq!(version.pre_release.to_string(), "alpha.1");
85    /// ```
86    ///
87    /// # Errors
88    ///
89    /// Returns [`SemverError`] if `s` is not a valid semver string.
90    pub fn parse(s: &str) -> Result<Self, SemverError> {
91        if s.len() <= MAX_LENGTH {
92            if let Some(version) = parse_fixed_core_version(s.as_bytes()) {
93                return Ok(version);
94            }
95        }
96        let ascii_trimmed = trim_ascii_whitespace(s);
97        if ascii_trimmed.len() != s.len() && ascii_trimmed.len() <= MAX_LENGTH {
98            if let Some(version) = parse_fixed_core_version(ascii_trimmed.as_bytes()) {
99                return Ok(version);
100            }
101        }
102        parse_version(s)
103    }
104
105    /// Compare semantic version precedence with build metadata as a tiebreaker.
106    ///
107    /// This is equivalent to `node-semver`'s `compareBuild()`.
108    ///
109    /// # Examples
110    ///
111    /// ```rust
112    /// use core::cmp::Ordering;
113    /// use js_semver::Version;
114    ///
115    /// let left: Version = "1.2.3+build.1".parse().unwrap();
116    /// let right: Version = "1.2.3+build.2".parse().unwrap();
117    ///
118    /// assert_eq!(left.cmp(&right), Ordering::Equal);
119    /// assert_eq!(left.cmp_build(&right), Ordering::Less);
120    /// ```
121    #[must_use]
122    pub fn cmp_build(&self, other: &Self) -> Ordering {
123        match compare_core_and_prerelease(self, other) {
124            Ordering::Equal => self.build.cmp(&other.build),
125            ord @ (Ordering::Less | Ordering::Greater) => ord,
126        }
127    }
128}
129
130impl PartialEq for Version {
131    fn eq(&self, other: &Self) -> bool {
132        self.major == other.major
133            && self.minor == other.minor
134            && self.patch == other.patch
135            && self.pre_release == other.pre_release
136    }
137}
138
139impl PartialOrd for Version {
140    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
141        Some(self.cmp(other))
142    }
143}
144
145impl Ord for Version {
146    fn cmp(&self, other: &Self) -> Ordering {
147        compare_core_and_prerelease(self, other)
148    }
149}
150
151impl fmt::Display for Version {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
154        if !self.pre_release.is_empty() {
155            write!(f, "-{}", self.pre_release)?;
156        }
157        if !self.build.is_empty() {
158            write!(f, "+{}", self.build)?;
159        }
160        Ok(())
161    }
162}
163
164impl FromStr for Version {
165    type Err = SemverError;
166    fn from_str(s: &str) -> Result<Self, Self::Err> {
167        Self::parse(s)
168    }
169}
170
171// --------------------------------------------------------------------------
172// Version parsing (internal)
173// --------------------------------------------------------------------------
174
175fn parse_version(s: &str) -> Result<Version, SemverError> {
176    if s.len() <= MAX_LENGTH {
177        if let Some(version) = parse_fast_version(s) {
178            return Ok(version);
179        }
180    }
181    let ascii_trimmed = trim_ascii_whitespace(s);
182    if ascii_trimmed.len() != s.len() && ascii_trimmed.len() <= MAX_LENGTH {
183        if let Some(version) = parse_fast_version(ascii_trimmed) {
184            return Ok(version);
185        }
186    }
187    let raw = s.trim();
188    if raw.is_empty() {
189        return Err(SemverErrorKind::Empty.into());
190    }
191    if raw.len() > MAX_LENGTH {
192        return Err(SemverErrorKind::MaxLengthExceeded.into());
193    }
194    let b = raw.as_bytes();
195
196    // Skip optional leading v prefix.
197    let mut pos = usize::from(matches!(b.first(), Some(b'v')));
198
199    // Parse major.minor.patch in a single forward scan
200    let major = parse_nr_at(b, &mut pos)?;
201    if b.get(pos) != Some(&b'.') {
202        return Err(SemverErrorKind::MissingVersionSegment.into());
203    }
204    pos += 1;
205    let minor = parse_nr_at(b, &mut pos)?;
206    if b.get(pos) != Some(&b'.') {
207        return Err(SemverErrorKind::MissingVersionSegment.into());
208    }
209    pos += 1;
210    let patch = parse_nr_at(b, &mut pos)?;
211
212    // Optional pre-release
213    let pre_release = if b.get(pos) == Some(&b'-') {
214        pos += 1;
215        let start = pos;
216        while pos < b.len() && b[pos] != b'+' {
217            pos += 1;
218        }
219        let pre_str = &raw[start..pos];
220        if pre_str.is_empty() {
221            return Err(SemverErrorKind::EmptySegment.into());
222        }
223        PreRelease::new(pre_str)?
224    } else {
225        PreRelease::default()
226    };
227
228    // Optional build metadata
229    let build = if b.get(pos) == Some(&b'+') {
230        pos += 1;
231        BuildMetadata::new(&raw[pos..])?
232    } else if pos == b.len() {
233        BuildMetadata::default()
234    } else {
235        let unexpected = raw[pos..].chars().next().unwrap_or('\0');
236        return Err(SemverErrorKind::UnexpectedCharacter(unexpected).into());
237    };
238
239    Ok(Version {
240        major,
241        minor,
242        patch,
243        pre_release,
244        build,
245    })
246}
247
248fn parse_fixed_core_version(bytes: &[u8]) -> Option<Version> {
249    let pos = usize::from(matches!(bytes.first(), Some(b'v')));
250    if bytes.len() - pos == 5
251        && bytes[pos].is_ascii_digit()
252        && bytes[pos + 1] == b'.'
253        && bytes[pos + 2].is_ascii_digit()
254        && bytes[pos + 3] == b'.'
255        && bytes[pos + 4].is_ascii_digit()
256    {
257        return Some(Version::new(
258            u64::from(bytes[pos] - b'0'),
259            u64::from(bytes[pos + 2] - b'0'),
260            u64::from(bytes[pos + 4] - b'0'),
261        ));
262    }
263
264    None
265}
266
267fn parse_fast_version(input: &str) -> Option<Version> {
268    let bytes = input.as_bytes();
269    let mut pos = usize::from(matches!(bytes.first(), Some(b'v')));
270    let major = parse_simple_core_number(bytes, &mut pos)?;
271    if bytes.get(pos) != Some(&b'.') {
272        return None;
273    }
274    pos += 1;
275
276    let minor = parse_simple_core_number(bytes, &mut pos)?;
277    if bytes.get(pos) != Some(&b'.') {
278        return None;
279    }
280    pos += 1;
281
282    let patch = parse_simple_core_number(bytes, &mut pos)?;
283    if pos == bytes.len() {
284        return Some(Version::new(major, minor, patch));
285    }
286
287    let pre_release = if bytes.get(pos) == Some(&b'-') {
288        pos += 1;
289        let start = pos;
290        while pos < bytes.len() && bytes[pos] != b'+' {
291            pos += 1;
292        }
293        if start == pos {
294            return None;
295        }
296        PreRelease::new(&input[start..pos]).ok()?
297    } else {
298        PreRelease::default()
299    };
300
301    let build = if bytes.get(pos) == Some(&b'+') {
302        BuildMetadata::new(&input[pos + 1..]).ok()?
303    } else if pos == bytes.len() {
304        BuildMetadata::default()
305    } else {
306        return None;
307    };
308
309    Some(Version {
310        major,
311        minor,
312        patch,
313        pre_release,
314        build,
315    })
316}
317
318fn trim_ascii_whitespace(input: &str) -> &str {
319    let bytes = input.as_bytes();
320    let mut start = 0;
321    while bytes.get(start).is_some_and(u8::is_ascii_whitespace) {
322        start += 1;
323    }
324
325    let mut end = bytes.len();
326    while end > start && bytes[end - 1].is_ascii_whitespace() {
327        end -= 1;
328    }
329    &input[start..end]
330}
331
332fn parse_simple_core_number(bytes: &[u8], pos: &mut usize) -> Option<u64> {
333    let start = *pos;
334    let first = bytes.get(start).copied()?;
335    if !first.is_ascii_digit()
336        || (first == b'0' && bytes.get(start + 1).is_some_and(u8::is_ascii_digit))
337    {
338        return None;
339    }
340
341    let mut value = 0u64;
342    while let Some(digit @ b'0'..=b'9') = bytes.get(*pos).copied() {
343        if *pos - start == 16 {
344            return None;
345        }
346        value = value * 10 + u64::from(digit - b'0');
347        *pos += 1;
348    }
349    (value <= MAX_SAFE_INTEGER).then_some(value)
350}
351
352/// Parse a decimal integer from `b` starting at `*pos`, advancing `*pos` past the digits.
353fn parse_nr_at(b: &[u8], pos: &mut usize) -> Result<u64, SemverError> {
354    let start = *pos;
355    if start >= b.len() || !b[start].is_ascii_digit() {
356        return Err(SemverErrorKind::InvalidNumber.into());
357    }
358    // Leading-zero check
359    if b[start] == b'0' && b.get(start + 1).is_some_and(u8::is_ascii_digit) {
360        return Err(SemverErrorKind::LeadingZero.into());
361    }
362    let mut value = 0u64;
363    while let Some(&digit) = b.get(*pos).filter(|digit| digit.is_ascii_digit()) {
364        if *pos - start == 16 {
365            return Err(SemverErrorKind::MaxSafeIntegerExceeded.into());
366        }
367        value = value * 10 + u64::from(digit - b'0');
368        *pos += 1;
369    }
370    if value > MAX_SAFE_INTEGER {
371        return Err(SemverErrorKind::MaxSafeIntegerExceeded.into());
372    }
373    Ok(value)
374}
375
376pub(crate) fn compare_core_and_prerelease(left: &Version, right: &Version) -> Ordering {
377    macro_rules! cmp_field {
378        ($field:ident) => {
379            match left.$field.cmp(&right.$field) {
380                Ordering::Equal => {}
381                ord @ (Ordering::Less | Ordering::Greater) => return ord,
382            }
383        };
384    }
385    cmp_field!(major);
386    cmp_field!(minor);
387    cmp_field!(patch);
388    match (left.pre_release.is_empty(), right.pre_release.is_empty()) {
389        (true, false) => Ordering::Greater,
390        (false, true) => Ordering::Less,
391        (true, true) => Ordering::Equal,
392        (false, false) => left.pre_release.cmp_identifiers(&right.pre_release),
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::{Version, parse_nr_at};
399    use crate::number::MAX_SAFE_INTEGER;
400
401    #[test]
402    fn parse_nr_at_propagates_core_number_parse_errors() {
403        let bytes = b"9007199254740992";
404        let mut pos = 0;
405        assert!(parse_nr_at(bytes, &mut pos).is_err());
406    }
407
408    #[test]
409    fn parse_nr_at_parses_max_safe_integer() {
410        let bytes = b"9007199254740991";
411        let mut pos = 0;
412        assert_eq!(parse_nr_at(bytes, &mut pos).unwrap(), MAX_SAFE_INTEGER);
413        assert_eq!(pos, bytes.len());
414    }
415
416    #[test]
417    fn parse_covers_trimmed_fast_and_unicode_fallback_paths() {
418        assert_eq!(Version::parse(" 1.2.3 ").unwrap(), Version::new(1, 2, 3));
419        assert_eq!(Version::parse(" 10.2.3 ").unwrap(), Version::new(10, 2, 3));
420        assert_eq!(
421            Version::parse("\u{2003}1.2.3\u{2003}").unwrap(),
422            Version::new(1, 2, 3)
423        );
424    }
425}