1818 */
1919package grails.plugins
2020
21+ import java.util.regex.Matcher
22+ import java.util.regex.Pattern
23+
2124import groovy.transform.CompileStatic
2225
2326/**
24- * A comparator capable of sorting versions from from newest to oldest
27+ * A comparator that orders versions from oldest to newest, following the standard
28+ * {@link Comparator} contract (a negative result means the first version is older).
29+ *
30+ * <p >Versions are compared by their numeric components first (major, minor, patch, ...),
31+ * padding the shorter side with zeros so that {@code 7.0 } and {@code 7.0.0 } are equal.
32+ * When the numeric components are equal the version qualifier is used as a tie-breaker
33+ * following the same ordering as {@code org.grails.datastore.mapping.core.grailsversion.GrailsVersion }:</p>
34+ *
35+ * <pre >
36+ * 7.0.0-M1 < ; 7.0.0-M2 < ; 7.0.0-RC1 < ; 7.0.0-RC2 < ; 7.0.0-SNAPSHOT < ; 7.0.0
37+ * </pre>
38+ *
39+ * <p >In other words a milestone is older than a release candidate, a release candidate is
40+ * older than a snapshot, and any qualified (pre-release) version is older than the final
41+ * release of the same number. Both the dotted ({@code 7.0.0.M1 }) and hyphenated
42+ * ({@code 7.0.0-M1 }) qualifier forms are treated as equivalent. Unrecognised qualifiers are
43+ * treated as a final release to preserve backwards compatible behaviour.</p>
2544 */
2645@CompileStatic
2746class VersionComparator implements Comparator<String > {
2847
48+ private static final String SNAPSHOT = ' SNAPSHOT'
49+ private static final String RELEASE_CANDIDATE = ' RC'
50+ private static final String MILESTONE = ' M'
51+
2952 static private final List<String > SNAPSHOT_SUFFIXES = [' -SNAPSHOT' , ' .BUILD-SNAPSHOT' ]. asImmutable()
3053
54+ private static final Pattern DIGITS = ~/ \d +/
55+ private static final Pattern NUMERIC_PREFIX = ~/ (\d +)-(.+)/
56+ private static final Pattern TRAILING_DIGITS = ~/ (\d +)$/
57+
58+ private static final int TIER_FINAL = 4
59+ private static final int TIER_SNAPSHOT = 3
60+ private static final int TIER_RELEASE_CANDIDATE = 2
61+ private static final int TIER_MILESTONE = 1
62+
3163 int compare (String o1 , String o2 ) {
32- int result = 0
33- if (o1 == ' *' ) {
34- result = 1
35- }
36- else if (o2 == ' *' ) {
37- result = -1
38- }
39- else {
40- def nums1
41- try {
42- def tokens = deSnapshot(o1). split(/ \. / )
43- tokens = tokens. findAll { String it -> it. trim() ==~ / \d +/ }
44- nums1 = tokens* . toInteger()
45- }
46- catch (NumberFormatException e) {
47- throw new InvalidVersionException (" Cannot compare versions, left side [$o1 ] is invalid: ${ e.message} " )
48- }
49- def nums2
50- try {
51- def tokens = deSnapshot(o2). split(/ \. / )
52- tokens = tokens. findAll { String it -> it. trim() ==~ / \d +/ }
53- nums2 = tokens* . toInteger()
54- }
55- catch (NumberFormatException e) {
56- throw new InvalidVersionException (" Cannot compare versions, right side [$o2 ] is invalid: ${ e.message} " )
57- }
58- boolean bigRight = nums2. size() > nums1. size()
59- boolean bigLeft = nums1. size() > nums2. size()
60- for (int i in 0 .. < nums1. size()) {
61- if (nums2. size() > i) {
62- result = nums1[i]. compareTo(nums2[i])
63- if (result != 0 ) {
64- break
65- }
66- if (i == (nums1. size() - 1 ) && bigRight) {
67- if (nums2[i + 1 ] != 0 )
68- result = -1
69- break
70- }
71- }
72- else if (bigLeft) {
73- if (nums1[i] != 0 )
74- result = 1
75- break
76- }
77- }
64+ String left = o1?. trim()
65+ String right = o2?. trim()
66+
67+ if (left == ' *' ) {
68+ return 1
69+ }
70+ if (right == ' *' ) {
71+ return -1
7872 }
7973
80- if (result == 0 ) {
81- // Versions are equal, but one may be a snapshot.
82- // A snapshot version is considered less than a non snapshot version
83- def o1IsSnapshot = isSnapshot(o1)
84- def o2IsSnapshot = isSnapshot(o2)
85-
86- if (o1IsSnapshot && ! o2IsSnapshot) {
87- result = -1
88- } else if (! o1IsSnapshot && o2IsSnapshot) {
89- result = 1
90- }
74+ ParsedVersion v1
75+ try {
76+ v1 = parse(left)
77+ }
78+ catch (NumberFormatException e) {
79+ throw new InvalidVersionException (" Cannot compare versions, left side [$o1 ] is invalid: ${ e.message} " )
80+ }
81+ ParsedVersion v2
82+ try {
83+ v2 = parse(right)
84+ }
85+ catch (NumberFormatException e) {
86+ throw new InvalidVersionException (" Cannot compare versions, right side [$o2 ] is invalid: ${ e.message} " )
9187 }
9288
93- result
89+ int result = compareNumbers(v1. numbers, v2. numbers)
90+ if (result == 0 ) {
91+ result = compareQualifiers(v1, v2)
92+ }
93+ return result
9494 }
9595
9696 boolean equals (obj ) { false }
@@ -112,4 +112,111 @@ class VersionComparator implements Comparator<String> {
112112 protected boolean isSnapshot (String version ) {
113113 SNAPSHOT_SUFFIXES . any { String it -> version?. endsWith(it) }
114114 }
115+
116+ /**
117+ * Splits a version into its leading numeric components and an optional trailing qualifier.
118+ * The first token that is not purely numeric ends the numeric section. A token of the form
119+ * {@code <digits>-<qualifier> } (for example {@code 0-RC1 } from {@code 7.0.0-RC1 }) contributes
120+ * its leading digits to the numeric section and the remainder becomes the qualifier.
121+ *
122+ * @throws NumberFormatException if a numeric component cannot be parsed as an {@code int };
123+ * callers are expected to translate this into an {@link InvalidVersionException}.
124+ */
125+ private static ParsedVersion parse (String version ) {
126+ List<Integer > numbers = []
127+ String qualifier = null
128+ if (version) {
129+ for (String token : version. split(/ \. / )) {
130+ if (DIGITS . matcher(token). matches()) {
131+ numbers. add(Integer . parseInt(token))
132+ continue
133+ }
134+ Matcher matcher = NUMERIC_PREFIX . matcher(token)
135+ if (matcher. matches()) {
136+ numbers. add(Integer . parseInt(matcher. group(1 )))
137+ qualifier = normalizeQualifier(matcher. group(2 ))
138+ } else {
139+ qualifier = normalizeQualifier(token)
140+ }
141+ break
142+ }
143+ }
144+ int tier = qualifierTier(qualifier)
145+ // Only milestone and release candidate tiers use the trailing number for ordering.
146+ // Parsing it here (inside parse) keeps any overflow inside the caller's try/catch so it
147+ // is reported as an InvalidVersionException rather than a raw NumberFormatException.
148+ int number = (tier == TIER_MILESTONE || tier == TIER_RELEASE_CANDIDATE ) ? qualifierNumber(qualifier) : 0
149+ return new ParsedVersion (numbers, qualifier, tier, number)
150+ }
151+
152+ private static int compareNumbers (List<Integer > a , List<Integer > b ) {
153+ int max = Math . max(a. size(), b. size())
154+ for (int i = 0 ; i < max; i++ ) {
155+ int left = i < a. size() ? a. get(i) : 0
156+ int right = i < b. size() ? b. get(i) : 0
157+ int result = Integer . compare(left, right)
158+ if (result != 0 ) {
159+ return result
160+ }
161+ }
162+ return 0
163+ }
164+
165+ private static int compareQualifiers (ParsedVersion v1 , ParsedVersion v2 ) {
166+ if (v1. qualifierTier != v2. qualifierTier) {
167+ return Integer . compare(v1. qualifierTier, v2. qualifierTier)
168+ }
169+ if (v1. qualifierTier == TIER_MILESTONE || v1. qualifierTier == TIER_RELEASE_CANDIDATE ) {
170+ return Integer . compare(v1. qualifierNumber, v2. qualifierNumber)
171+ }
172+ return 0
173+ }
174+
175+ private static String normalizeQualifier (String qualifier ) {
176+ if (qualifier == null ) {
177+ return null
178+ }
179+ String upper = qualifier. toUpperCase()
180+ return upper. contains(SNAPSHOT ) ? SNAPSHOT : upper
181+ }
182+
183+ private static int qualifierTier (String qualifier ) {
184+ if (qualifier == null ) {
185+ return TIER_FINAL
186+ }
187+ if (qualifier. contains(SNAPSHOT )) {
188+ return TIER_SNAPSHOT
189+ }
190+ if (qualifier. startsWith(RELEASE_CANDIDATE )) {
191+ return TIER_RELEASE_CANDIDATE
192+ }
193+ if (qualifier. startsWith(MILESTONE )) {
194+ return TIER_MILESTONE
195+ }
196+ return TIER_FINAL
197+ }
198+
199+ private static int qualifierNumber (String qualifier ) {
200+ if (qualifier == null ) {
201+ return 0
202+ }
203+ Matcher matcher = TRAILING_DIGITS . matcher(qualifier)
204+ return matcher. find() ? Integer . parseInt(matcher. group(1 )) : 0
205+ }
206+
207+ @CompileStatic
208+ private static class ParsedVersion {
209+
210+ final List<Integer > numbers
211+ final String qualifier
212+ final int qualifierTier
213+ final int qualifierNumber
214+
215+ ParsedVersion (List<Integer > numbers , String qualifier , int qualifierTier , int qualifierNumber ) {
216+ this . numbers = numbers
217+ this . qualifier = qualifier
218+ this . qualifierTier = qualifierTier
219+ this . qualifierNumber = qualifierNumber
220+ }
221+ }
115222}
0 commit comments