Skip to main content

deps_dart/
version.rs

1//! Version comparison and constraint matching for Dart packages.
2
3use deps_core::normalize_operator_spacing;
4use std::cmp::Ordering;
5
6/// A single dot-separated SemVer 2.0.0 prerelease identifier (semver spec §11).
7///
8/// A purely numeric identifier compares numerically and always sorts below an
9/// alphanumeric one; alphanumeric identifiers compare lexically by ASCII byte value
10/// (case-sensitive — unlike NuGet's case-folded prerelease scheme, plain SemVer 2.0.0
11/// does not fold case). Declaring `Numeric` before `AlphaNumeric` makes the derived
12/// `Ord` rank every numeric identifier below every alphanumeric one, matching the spec.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
14enum PrereleaseIdentifier {
15    Numeric(u64),
16    AlphaNumeric(String),
17}
18
19impl PrereleaseIdentifier {
20    fn parse(s: &str) -> Self {
21        if !s.is_empty()
22            && s.bytes().all(|b| b.is_ascii_digit())
23            && let Ok(n) = s.parse::<u64>()
24        {
25            return Self::Numeric(n);
26        }
27        Self::AlphaNumeric(s.to_string())
28    }
29}
30
31/// Splits `version` into its bare numeric-dot core and, if present, its raw prerelease
32/// suffix. Build metadata (after `+`) is discarded first; the prerelease suffix is
33/// everything after the first `-` that follows.
34fn split_core_and_prerelease(version: &str) -> (&str, Option<&str>) {
35    let without_build = version.split('+').next().unwrap_or(version);
36    match without_build.split_once('-') {
37        Some((core, pre)) => (core, Some(pre)),
38        None => (without_build, None),
39    }
40}
41
42/// Parses a bare numeric-dot core string into its components, leniently taking each
43/// dot-separated segment's leading digit run (defaulting to `0` for a non-numeric segment).
44fn parse_core_parts(core: &str) -> Vec<u64> {
45    core.split('.')
46        .map(|s| {
47            s.chars()
48                .take_while(char::is_ascii_digit)
49                .collect::<String>()
50                .parse()
51                .unwrap_or(0)
52        })
53        .collect()
54}
55
56/// Compares two Dart version strings.
57///
58/// Numeric core components are compared first (a missing trailing component is treated as
59/// `0`, so `"1.0"` and `"1.0.0"` compare equal), then SemVer 2.0.0 prerelease precedence
60/// (spec §11) applies: a version with no prerelease outranks one with a prerelease of the
61/// same core, and two prereleases are compared identifier-by-identifier, with a longer
62/// identifier list outranking a shared-prefix shorter one. Build metadata (`+...`) is
63/// ignored.
64///
65/// # Examples
66///
67/// ```
68/// use deps_dart::version::compare_versions;
69/// use std::cmp::Ordering;
70///
71/// assert_eq!(compare_versions("2.0.0", "2.0.0-beta1"), Ordering::Greater);
72/// assert_eq!(compare_versions("2.0.0-alpha", "2.0.0-beta"), Ordering::Less);
73/// assert_eq!(compare_versions("1.0.0-alpha", "1.0.0-alpha.1"), Ordering::Less);
74/// ```
75pub fn compare_versions(a: &str, b: &str) -> Ordering {
76    let (a_core, a_pre) = split_core_and_prerelease(a);
77    let (b_core, b_pre) = split_core_and_prerelease(b);
78
79    let a_core_parts = parse_core_parts(a_core);
80    let b_core_parts = parse_core_parts(b_core);
81
82    let max_len = a_core_parts.len().max(b_core_parts.len());
83    for i in 0..max_len {
84        let ap = a_core_parts.get(i).copied().unwrap_or(0);
85        let bp = b_core_parts.get(i).copied().unwrap_or(0);
86        match ap.cmp(&bp) {
87            Ordering::Equal => {}
88            other => return other,
89        }
90    }
91
92    match (a_pre, b_pre) {
93        (None, None) => Ordering::Equal,
94        (None, Some(_)) => Ordering::Greater,
95        (Some(_), None) => Ordering::Less,
96        (Some(a_pre), Some(b_pre)) => {
97            let a_ids: Vec<PrereleaseIdentifier> =
98                a_pre.split('.').map(PrereleaseIdentifier::parse).collect();
99            let b_ids: Vec<PrereleaseIdentifier> =
100                b_pre.split('.').map(PrereleaseIdentifier::parse).collect();
101            a_ids.cmp(&b_ids)
102        }
103    }
104}
105
106/// Checks if a version satisfies a Dart version constraint.
107///
108/// Supports: ^, >=, >, <=, <, exact, any, and space-separated AND constraints.
109pub fn version_matches_constraint(version: &str, constraint: &str) -> bool {
110    let constraint = normalize_operator_spacing(constraint.trim());
111    version_matches_normalized_constraint(version, &constraint)
112}
113
114/// Same as [`version_matches_constraint`], but takes a constraint that has already been
115/// run through [`normalize_operator_spacing`]. Callers that check many candidate versions
116/// against the same constraint (e.g. a compiled [`RequirementMatcher`](deps_core::lsp_helpers::RequirementMatcher))
117/// should normalize once and reuse it here instead of re-normalizing per candidate.
118pub(crate) fn version_matches_normalized_constraint(version: &str, constraint: &str) -> bool {
119    if constraint.is_empty() || constraint == "any" || constraint == "*" {
120        return true;
121    }
122
123    // Space-separated constraints are AND logic (pub_semver intersects each comparator,
124    // including a leading caret comparator combined with further clauses).
125    if constraint.contains(' ') {
126        return constraint
127            .split_whitespace()
128            .all(|c| match_single_constraint(version, c));
129    }
130
131    match_single_constraint(version, constraint)
132}
133
134fn match_single_constraint(version: &str, constraint: &str) -> bool {
135    let constraint = constraint.trim();
136
137    if constraint.starts_with('^') {
138        let req_ver = constraint.trim_start_matches('^');
139        return matches_caret(version, req_ver);
140    }
141
142    if constraint.starts_with(">=") {
143        let req_ver = constraint.trim_start_matches(">=").trim();
144        return compare_versions(version, req_ver) != Ordering::Less;
145    }
146
147    if constraint.starts_with('>') {
148        let req_ver = constraint.trim_start_matches('>').trim();
149        return compare_versions(version, req_ver) == Ordering::Greater;
150    }
151
152    if constraint.starts_with("<=") {
153        let req_ver = constraint.trim_start_matches("<=").trim();
154        return compare_versions(version, req_ver) != Ordering::Greater;
155    }
156
157    if constraint.starts_with('<') {
158        let req_ver = constraint.trim_start_matches('<').trim();
159        return compare_versions(version, req_ver) == Ordering::Less;
160    }
161
162    // Exact match
163    compare_versions(version, constraint) == Ordering::Equal
164}
165
166fn matches_caret(version: &str, requirement: &str) -> bool {
167    // Same leading-digit-run extraction as `ver_parts` below, so a requirement segment with
168    // a stray suffix (e.g. `"0-beta"`) zeroes to `0` instead of being dropped and shifting
169    // every later segment's index.
170    let req_parts: Vec<u64> = requirement
171        .split('.')
172        .filter_map(|p| p.split(|c: char| !c.is_ascii_digit()).next())
173        .filter_map(|p| p.parse().ok())
174        .collect();
175    let ver_parts: Vec<u64> = version
176        .split('.')
177        .filter_map(|p| p.split(|c: char| !c.is_ascii_digit()).next())
178        .filter_map(|p| p.parse().ok())
179        .collect();
180
181    if ver_parts.is_empty() || req_parts.is_empty() {
182        return false;
183    }
184
185    if compare_versions(version, requirement) == Ordering::Less {
186        return false;
187    }
188
189    let req_major = req_parts.first().copied().unwrap_or(0);
190    let ver_major = ver_parts.first().copied().unwrap_or(0);
191
192    if req_major == 0 {
193        // ^0.x.y means >=0.x.y <0.(x+1).0
194        let req_minor = req_parts.get(1).copied().unwrap_or(0);
195        let ver_minor = ver_parts.get(1).copied().unwrap_or(0);
196        ver_major == 0 && ver_minor == req_minor
197    } else {
198        // ^x.y.z means >=x.y.z <(x+1).0.0
199        ver_major == req_major
200    }
201}
202
203/// Whether `version` has a semver 2.0.0 prerelease component.
204///
205/// Pub requires published versions to be strict semver, so any hyphen
206/// preceding optional `+build` metadata reliably marks a prerelease —
207/// unlike deps-core's default keyword-based heuristic, this also catches
208/// conventions not in its fixed list, e.g. Dart's `nullsafety` preview tag
209/// (`2.10.0-nullsafety.1`) (#322).
210pub fn is_prerelease(version: &str) -> bool {
211    version.split('+').next().unwrap_or(version).contains('-')
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_compare_versions() {
220        assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal);
221        assert_eq!(compare_versions("1.0.1", "1.0.0"), Ordering::Greater);
222        assert_eq!(compare_versions("1.0.0", "1.0.1"), Ordering::Less);
223        assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
224        assert_eq!(compare_versions("1.0.0", "1.0"), Ordering::Equal);
225    }
226
227    /// Regression test for #418: a prerelease/qualifier suffix must not be silently
228    /// truncated and tie with its stable counterpart.
229    #[test]
230    fn test_compare_versions_prerelease_vs_stable() {
231        assert_eq!(compare_versions("2.0.0", "2.0.0-beta1"), Ordering::Greater);
232        assert_eq!(compare_versions("2.0.0-beta1", "2.0.0"), Ordering::Less);
233        assert_ne!(compare_versions("2.0.0", "2.0.0-beta1"), Ordering::Equal);
234        assert_ne!(
235            compare_versions("2.10.0-nullsafety.1", "2.10.0"),
236            Ordering::Equal
237        );
238    }
239
240    #[test]
241    fn test_compare_versions_prerelease_identifier_ordering() {
242        // Numeric identifiers always outrank below alphanumeric ones.
243        assert_eq!(compare_versions("1.0.0-1", "1.0.0-alpha"), Ordering::Less);
244        // Alphanumeric identifiers compare lexically, case-sensitively.
245        assert_eq!(
246            compare_versions("1.0.0-alpha", "1.0.0-beta"),
247            Ordering::Less
248        );
249        // A numeric identifier compares numerically, not lexically.
250        assert_eq!(
251            compare_versions("1.0.0-alpha.2", "1.0.0-alpha.10"),
252            Ordering::Less
253        );
254        // More prerelease fields outrank fewer when the shared prefix is equal.
255        assert_eq!(
256            compare_versions("1.0.0-alpha.1", "1.0.0-alpha"),
257            Ordering::Greater
258        );
259    }
260
261    #[test]
262    fn test_compare_versions_build_metadata_ignored() {
263        assert_eq!(
264            compare_versions("1.0.0+build1", "1.0.0+build2"),
265            Ordering::Equal
266        );
267        assert_eq!(
268            compare_versions("1.0.0-beta+build1", "1.0.0-beta+build2"),
269            Ordering::Equal
270        );
271    }
272
273    /// Regression test for #418: sorting a version list must move every prerelease
274    /// below its own base release, not tie with it.
275    #[test]
276    fn test_compare_versions_sorts_prerelease_below_stable() {
277        let mut versions = vec!["2.0.0-beta1", "2.0.0", "2.0.0-alpha"];
278        versions.sort_by(|a, b| compare_versions(a, b));
279        assert_eq!(versions, vec!["2.0.0-alpha", "2.0.0-beta1", "2.0.0"]);
280    }
281
282    #[test]
283    fn test_is_prerelease() {
284        assert!(!is_prerelease("1.0.0"));
285        assert!(!is_prerelease("1.0.0+build.1"));
286        assert!(is_prerelease("1.0.0-dev.1"));
287        // Not in deps-core's default keyword list, but still a valid semver
288        // prerelease tag.
289        assert!(is_prerelease("2.10.0-nullsafety.1"));
290        assert!(is_prerelease("1.0.0-nullsafety.1+build"));
291    }
292
293    #[test]
294    fn test_caret_constraint() {
295        assert!(version_matches_constraint("1.0.0", "^1.0.0"));
296        assert!(version_matches_constraint("1.5.0", "^1.0.0"));
297        assert!(version_matches_constraint("1.99.99", "^1.0.0"));
298        assert!(!version_matches_constraint("2.0.0", "^1.0.0"));
299        assert!(!version_matches_constraint("0.9.0", "^1.0.0"));
300    }
301
302    /// Regression test for impl-critic M4/#418: a prerelease of the caret floor must not
303    /// satisfy the constraint — pub_semver agrees `^1.0.0` excludes `1.0.0-beta`. Before the
304    /// #418 fix, `compare_versions("1.0.0-beta", "1.0.0")` wrongly returned `Equal`, so the
305    /// `Less`-rejection in `matches_caret` never triggered for this case.
306    #[test]
307    fn test_caret_constraint_excludes_own_floor_prerelease() {
308        assert!(!version_matches_constraint("1.0.0-beta", "^1.0.0"));
309    }
310
311    #[test]
312    fn test_caret_constraint_zero_major() {
313        // ^0.1.0 means >=0.1.0 <0.2.0
314        assert!(version_matches_constraint("0.1.0", "^0.1.0"));
315        assert!(version_matches_constraint("0.1.5", "^0.1.0"));
316        assert!(!version_matches_constraint("0.2.0", "^0.1.0"));
317        assert!(!version_matches_constraint("0.99.0", "^0.1.0"));
318        assert!(!version_matches_constraint("1.0.0", "^0.1.0"));
319    }
320
321    #[test]
322    fn test_range_constraint() {
323        assert!(version_matches_constraint("1.5.0", ">=1.0.0 <2.0.0"));
324        assert!(version_matches_constraint("1.0.0", ">=1.0.0 <2.0.0"));
325        assert!(!version_matches_constraint("2.0.0", ">=1.0.0 <2.0.0"));
326        assert!(!version_matches_constraint("0.9.0", ">=1.0.0 <2.0.0"));
327    }
328
329    #[test]
330    fn test_range_constraint_spaced_operators() {
331        assert!(version_matches_constraint("1.15.0", ">= 1.15.0 < 2.0.0"));
332        assert!(version_matches_constraint("1.99.0", ">= 1.15.0 < 2.0.0"));
333        assert!(!version_matches_constraint("1.14.0", ">= 1.15.0 < 2.0.0"));
334        assert!(!version_matches_constraint("2.0.0", ">= 1.15.0 < 2.0.0"));
335    }
336
337    #[test]
338    fn test_caret_combined_with_spaced_upper_bound() {
339        // pub_semver intersects space-separated comparators, so a caret comparator can be
340        // combined with a further clause instead of split into garbage input.
341        assert!(version_matches_constraint("1.5.0", "^1.0.0 < 2.0.0"));
342        assert!(version_matches_constraint("1.99.0", "^1.0.0 < 2.0.0"));
343        assert!(!version_matches_constraint("2.0.0", "^1.0.0 < 2.0.0"));
344        assert!(!version_matches_constraint("0.9.0", "^1.0.0 < 2.0.0"));
345    }
346
347    #[test]
348    fn test_exact_constraint() {
349        assert!(version_matches_constraint("1.0.0", "1.0.0"));
350        assert!(!version_matches_constraint("1.0.1", "1.0.0"));
351    }
352
353    #[test]
354    fn test_any_constraint() {
355        assert!(version_matches_constraint("1.0.0", "any"));
356        assert!(version_matches_constraint("99.0.0", "any"));
357        assert!(version_matches_constraint("1.0.0", ""));
358    }
359
360    #[test]
361    fn test_comparison_operators() {
362        assert!(version_matches_constraint("1.5.0", ">=1.0.0"));
363        assert!(version_matches_constraint("1.0.0", ">=1.0.0"));
364        assert!(!version_matches_constraint("0.9.0", ">=1.0.0"));
365
366        assert!(version_matches_constraint("2.0.0", ">1.0.0"));
367        assert!(!version_matches_constraint("1.0.0", ">1.0.0"));
368
369        assert!(version_matches_constraint("1.0.0", "<=1.0.0"));
370        assert!(!version_matches_constraint("1.1.0", "<=1.0.0"));
371
372        assert!(version_matches_constraint("0.9.0", "<1.0.0"));
373        assert!(!version_matches_constraint("1.0.0", "<1.0.0"));
374    }
375}