View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.artifact.versioning;
20  
21  import java.math.BigInteger;
22  import java.util.ArrayDeque;
23  import java.util.ArrayList;
24  import java.util.Arrays;
25  import java.util.Deque;
26  import java.util.Iterator;
27  import java.util.List;
28  import java.util.Locale;
29  import java.util.Objects;
30  import java.util.Properties;
31  
32  /**
33   * <p>
34   * Generic implementation of version comparison.
35   * </p>
36   * <p>
37   * Features:
38   * <ul>
39   * <li>Mixing of '<code>-</code>' (hyphen) and '<code>.</code>' (dot) separators,</li>
40   * <li>Transition between characters and digits also constitutes a separator:
41   *     <code>1.0alpha1 =&gt; [1, [alpha, 1]]</code></li>
42   * <li>Unlimited number of version components,</li>
43   * <li>Version components in the text can be digits or strings,</li>
44   * <li>Strings are checked for well-known qualifiers, and the qualifier ordering is used for version ordering.
45   *     Well-known qualifiers (case-insensitive) are, in order from least to greatest:<ol>
46   *     <li><code>alpha</code> or <code>a</code></li>
47   *     <li><code>beta</code> or <code>b</code></li>
48   *     <li><code>milestone</code> or <code>m</code></li>
49   *     <li><code>rc</code> or <code>cr</code></li>
50   *     <li><code>snapshot</code></li>
51   *     <li><code>ga</code> or <code>final</code></li>
52   *     <li><code>sp</code></li>
53   *     </ol>
54   *     Unknown qualifiers are considered after known qualifiers,
55   *     with lexical order (case-insensitive in the English locale).
56   *     <code>ga</code> and <code>final</code> sort the same as not having a qualifier.
57   *   </li>
58   * <li>A hyphen usually precedes a qualifier, and is always less important than digits/number. For example
59   *   {@code 1.0.RC2 < 1.0-RC3 < 1.0.1}; but prefer {@code 1.0.0-RC2} over {@code 1.0.0.RC2}, and more
60   *   generally: {@code 1.0.X2 < 1.0-X3 < 1.0.1} for any string {@code X}; but prefer {@code 1.0.0-X1}
61   *   over {@code 1.0.0.X1}.</li>
62   * </ul>
63   *
64   * @see <a href="https://maven.apache.org/pom.html#version-order-specification">"Versioning" in the POM reference</a>
65   */
66  public class ComparableVersion implements Comparable<ComparableVersion> {
67      private static final int MAX_INTITEM_LENGTH = 9;
68  
69      private static final int MAX_LONGITEM_LENGTH = 18;
70  
71      /**
72       * Maximum accepted length of a version string. Version strings routinely come from external
73       * repository metadata. Without a bound, every {@code -} separator nests another list whose
74       * comparison, equality, hash code, and canonicalization recurse one frame per level, and digit
75       * runs longer than {@value #MAX_LONGITEM_LENGTH} characters are parsed into {@link BigInteger}
76       * at quadratic cost. 256 characters is far beyond any real-world version identifier while
77       * keeping the nesting depth (at most about half the length) and numeric items small.
78       */
79      private static final int MAX_VERSION_LENGTH = 256;
80  
81      private String value;
82  
83      private String canonical;
84  
85      private ListItem items;
86  
87      private interface Item {
88          int INT_ITEM = 3;
89          int LONG_ITEM = 4;
90          int BIGINTEGER_ITEM = 0;
91          int STRING_ITEM = 1;
92          int LIST_ITEM = 2;
93          int COMBINATION_ITEM = 5;
94  
95          int compareTo(Item item);
96  
97          int getType();
98  
99          boolean isNull();
100     }
101 
102     /**
103      * Represents a numeric item in the version item list that can be represented with an int.
104      */
105     private static class IntItem implements Item {
106         private final int value;
107 
108         public static final IntItem ZERO = new IntItem();
109 
110         private IntItem() {
111             this.value = 0;
112         }
113 
114         IntItem(String str) {
115             this.value = Integer.parseInt(str);
116         }
117 
118         @Override
119         public int getType() {
120             return INT_ITEM;
121         }
122 
123         @Override
124         public boolean isNull() {
125             return value == 0;
126         }
127 
128         @Override
129         public int compareTo(Item item) {
130             if (item == null) {
131                 return (value == 0) ? 0 : 1; // 1.0 == 1, 1.1 > 1
132             }
133 
134             return switch (item.getType()) {
135                 case INT_ITEM -> {
136                     int itemValue = ((IntItem) item).value;
137                     yield Integer.compare(value, itemValue);
138                 }
139                 case LONG_ITEM, BIGINTEGER_ITEM -> -1;
140                 case STRING_ITEM -> 1;
141                 case COMBINATION_ITEM -> 1; // 1.1 > 1-sp
142 
143                 case LIST_ITEM -> 1; // 1.1 > 1-1
144 
145                 default -> throw new IllegalStateException("invalid item: " + item.getClass());
146             };
147         }
148 
149         @Override
150         public boolean equals(Object o) {
151             if (this == o) {
152                 return true;
153             }
154             if (o == null || getClass() != o.getClass()) {
155                 return false;
156             }
157 
158             IntItem intItem = (IntItem) o;
159 
160             return value == intItem.value;
161         }
162 
163         @Override
164         public int hashCode() {
165             return value;
166         }
167 
168         @Override
169         public String toString() {
170             return Integer.toString(value);
171         }
172     }
173 
174     /**
175      * Represents a numeric item in the version item list that can be represented with a long.
176      */
177     private static class LongItem implements Item {
178         private final long value;
179 
180         LongItem(String str) {
181             this.value = Long.parseLong(str);
182         }
183 
184         @Override
185         public int getType() {
186             return LONG_ITEM;
187         }
188 
189         @Override
190         public boolean isNull() {
191             return value == 0;
192         }
193 
194         @Override
195         public int compareTo(Item item) {
196             if (item == null) {
197                 return (value == 0) ? 0 : 1; // 1.0 == 1, 1.1 > 1
198             }
199 
200             return switch (item.getType()) {
201                 case INT_ITEM -> 1;
202                 case LONG_ITEM -> {
203                     long itemValue = ((LongItem) item).value;
204                     yield Long.compare(value, itemValue);
205                 }
206                 case BIGINTEGER_ITEM -> -1;
207                 case STRING_ITEM -> 1;
208                 case COMBINATION_ITEM -> 1; // 1.1 > 1-sp
209 
210                 case LIST_ITEM -> 1; // 1.1 > 1-1
211 
212                 default -> throw new IllegalStateException("invalid item: " + item.getClass());
213             };
214         }
215 
216         @Override
217         public boolean equals(Object o) {
218             if (this == o) {
219                 return true;
220             }
221             if (o == null || getClass() != o.getClass()) {
222                 return false;
223             }
224 
225             LongItem longItem = (LongItem) o;
226 
227             return value == longItem.value;
228         }
229 
230         @Override
231         public int hashCode() {
232             return (int) (value ^ (value >>> 32));
233         }
234 
235         @Override
236         public String toString() {
237             return Long.toString(value);
238         }
239     }
240 
241     /**
242      * Represents a numeric item in the version item list.
243      */
244     private static class BigIntegerItem implements Item {
245         private final BigInteger value;
246 
247         BigIntegerItem(String str) {
248             this.value = new BigInteger(str);
249         }
250 
251         @Override
252         public int getType() {
253             return BIGINTEGER_ITEM;
254         }
255 
256         @Override
257         public boolean isNull() {
258             return BigInteger.ZERO.equals(value);
259         }
260 
261         @Override
262         public int compareTo(Item item) {
263             if (item == null) {
264                 return BigInteger.ZERO.equals(value) ? 0 : 1; // 1.0 == 1, 1.1 > 1
265             }
266 
267             return switch (item.getType()) {
268                 case INT_ITEM, LONG_ITEM -> 1;
269                 case BIGINTEGER_ITEM -> value.compareTo(((BigIntegerItem) item).value);
270                 case STRING_ITEM -> 1;
271                 case COMBINATION_ITEM -> 1; // 1.1 > 1-sp
272 
273                 case LIST_ITEM -> 1; // 1.1 > 1-1
274 
275                 default -> throw new IllegalStateException("invalid item: " + item.getClass());
276             };
277         }
278 
279         @Override
280         public boolean equals(Object o) {
281             if (this == o) {
282                 return true;
283             }
284             if (o == null || getClass() != o.getClass()) {
285                 return false;
286             }
287 
288             BigIntegerItem that = (BigIntegerItem) o;
289 
290             return value.equals(that.value);
291         }
292 
293         @Override
294         public int hashCode() {
295             return value.hashCode();
296         }
297 
298         @Override
299         public String toString() {
300             return value.toString();
301         }
302     }
303 
304     /**
305      * Represents a string in the version item list, usually a qualifier.
306      */
307     private static class StringItem implements Item {
308         private static final List<String> QUALIFIERS =
309                 Arrays.asList("alpha", "beta", "milestone", "rc", "snapshot", "", "sp");
310         private static final List<String> RELEASE_QUALIFIERS = Arrays.asList("ga", "final", "release");
311 
312         private static final Properties ALIASES = new Properties();
313 
314         static {
315             ALIASES.put("cr", "rc");
316         }
317 
318         /**
319          * A comparable value for the empty-string qualifier. This one is used to determine if a given qualifier makes
320          * the version older than one without a qualifier, or more recent.
321          */
322         private static final String RELEASE_VERSION_INDEX = String.valueOf(QUALIFIERS.indexOf(""));
323 
324         private final String value;
325 
326         StringItem(String value, boolean followedByDigit) {
327             if (followedByDigit && value.length() == 1) {
328                 // a1 = alpha-1, b1 = beta-1, m1 = milestone-1
329                 switch (value.charAt(0)) {
330                     case 'a':
331                         value = "alpha";
332                         break;
333                     case 'b':
334                         value = "beta";
335                         break;
336                     case 'm':
337                         value = "milestone";
338                         break;
339                     default:
340                 }
341             }
342             this.value = ALIASES.getProperty(value, value);
343         }
344 
345         @Override
346         public int getType() {
347             return STRING_ITEM;
348         }
349 
350         @Override
351         public boolean isNull() {
352             return value == null || value.isEmpty();
353         }
354 
355         /**
356          * Returns a comparable value for a qualifier.
357          * <p>
358          * This method takes into account the ordering of known qualifiers then unknown qualifiers with lexical
359          * ordering.
360          * <p>
361          *
362          * @param qualifier
363          * @return an equivalent value that can be used with lexical comparison
364          */
365         public static String comparableQualifier(String qualifier) {
366             if (RELEASE_QUALIFIERS.contains(qualifier)) {
367                 return String.valueOf(QUALIFIERS.indexOf(""));
368             }
369 
370             int i = QUALIFIERS.indexOf(qualifier);
371 
372             // Just returning an Integer with the index here is faster, but requires a lot of if/then/else to check for
373             // -1
374             //  or QUALIFIERS.size and then resort to lexical ordering. Most comparisons are decided by the first
375             // character,
376             // so this is still fast. If more characters are needed then it requires a lexical sort anyway.
377             return i == -1 ? (QUALIFIERS.size() + "-" + qualifier) : String.valueOf(i);
378         }
379 
380         @Override
381         public int compareTo(Item item) {
382             if (item == null) {
383                 // 1-rc < 1, 1-ga > 1
384                 return comparableQualifier(value).compareTo(RELEASE_VERSION_INDEX);
385             }
386             switch (item.getType()) {
387                 case INT_ITEM:
388                 case LONG_ITEM:
389                 case BIGINTEGER_ITEM:
390                     return -1; // 1.any < 1.1 ?
391 
392                 case STRING_ITEM:
393                     return comparableQualifier(value).compareTo(comparableQualifier(((StringItem) item).value));
394 
395                 case COMBINATION_ITEM:
396                     int result = this.compareTo(((CombinationItem) item).getStringPart());
397                     if (result == 0) {
398                         if (compareTo(null) == 0) {
399                             return -((CombinationItem) item).getDigitPart().compareTo(null);
400                         }
401                         return -1;
402                     }
403                     return result;
404 
405                 case LIST_ITEM:
406                     return -item.compareTo(this);
407 
408                 default:
409                     throw new IllegalStateException("invalid item: " + item.getClass());
410             }
411         }
412 
413         @Override
414         public boolean equals(Object o) {
415             if (this == o) {
416                 return true;
417             }
418             if (o == null || getClass() != o.getClass()) {
419                 return false;
420             }
421 
422             StringItem that = (StringItem) o;
423 
424             return value.equals(that.value);
425         }
426 
427         @Override
428         public int hashCode() {
429             return value.hashCode();
430         }
431 
432         @Override
433         public String toString() {
434             return value;
435         }
436     }
437 
438     /**
439      * Represents a combination in the version item list.
440      * It is usually a combination of a string and a number, with the string first and the number second.
441      */
442     private static class CombinationItem implements Item {
443 
444         StringItem stringPart;
445 
446         Item digitPart;
447 
448         CombinationItem(String value) {
449             int index = 0;
450             for (int i = 0; i < value.length(); i++) {
451                 char c = value.charAt(i);
452                 if (Character.isDigit(c)) {
453                     index = i;
454                     break;
455                 }
456             }
457 
458             stringPart = new StringItem(value.substring(0, index), true);
459             digitPart = parseItem(true, value.substring(index));
460         }
461 
462         @Override
463         public int compareTo(Item item) {
464             if (item == null) {
465                 // 1-rc1 < 1, 1-ga1 > 1
466                 int result = stringPart.compareTo(item);
467                 if (result == 0) {
468                     // the string part is equivalent to the release qualifier ("ga", "final", "release"),
469                     // so the digit part decides: 1-ga1 > 1. Returning 0 here would break compareTo
470                     // transitivity, since 1-ga1 < 1-ga2 while both would compare equal to 1.
471                     return digitPart.compareTo(null);
472                 }
473                 return result;
474             }
475             int result = 0;
476             switch (item.getType()) {
477                 case INT_ITEM:
478                 case LONG_ITEM:
479                 case BIGINTEGER_ITEM:
480                     return -1;
481 
482                 case STRING_ITEM:
483                     result = stringPart.compareTo(item);
484                     if (result == 0) {
485                         if (stringPart.compareTo(null) == 0) {
486                             return digitPart.compareTo(null);
487                         }
488                         // X1 > X
489                         return 1;
490                     }
491                     return result;
492 
493                 case LIST_ITEM:
494                     return -item.compareTo(this);
495 
496                 case COMBINATION_ITEM:
497                     result = stringPart.compareTo(((CombinationItem) item).getStringPart());
498                     if (result == 0) {
499                         return digitPart.compareTo(((CombinationItem) item).getDigitPart());
500                     }
501                     return result;
502                 default:
503                     return 0;
504             }
505         }
506 
507         public StringItem getStringPart() {
508             return stringPart;
509         }
510 
511         public Item getDigitPart() {
512             return digitPart;
513         }
514 
515         @Override
516         public int getType() {
517             return COMBINATION_ITEM;
518         }
519 
520         @Override
521         public boolean isNull() {
522             return false;
523         }
524 
525         @Override
526         public boolean equals(Object o) {
527             if (this == o) {
528                 return true;
529             }
530             if (o == null || getClass() != o.getClass()) {
531                 return false;
532             }
533             CombinationItem that = (CombinationItem) o;
534             return Objects.equals(stringPart, that.stringPart) && Objects.equals(digitPart, that.digitPart);
535         }
536 
537         @Override
538         public int hashCode() {
539             return Objects.hash(stringPart, digitPart);
540         }
541 
542         @Override
543         public String toString() {
544             return stringPart.toString() + digitPart.toString();
545         }
546     }
547 
548     /**
549      * Represents a version list item. This class is used both for the global item list and for sub-lists (which start
550      * with '-(number)' in the version specification).
551      */
552     private static class ListItem extends ArrayList<Item> implements Item {
553         @Override
554         public int getType() {
555             return LIST_ITEM;
556         }
557 
558         @Override
559         public boolean isNull() {
560             return (size() == 0);
561         }
562 
563         void normalize() {
564             for (int i = size() - 1; i >= 0; i--) {
565                 Item lastItem = get(i);
566 
567                 if (lastItem.isNull()) {
568                     if (i == size() - 1 || get(i + 1).getType() == STRING_ITEM) {
569                         remove(i);
570                     } else if (get(i + 1).getType() == LIST_ITEM) {
571                         Item item = ((ListItem) get(i + 1)).get(0);
572                         if (item.getType() == COMBINATION_ITEM || item.getType() == STRING_ITEM) {
573                             remove(i);
574                         }
575                     }
576                 }
577             }
578 
579             if (size() == 1 && get(0) instanceof ListItem list) {
580                 // Removing a zero prefix must not leave an extra qualifier nesting level.
581                 clear();
582                 addAll(list);
583             }
584         }
585 
586         @Override
587         public int compareTo(Item item) {
588             if (item == null) {
589                 if (size() == 0) {
590                     return 0; // 1-0 = 1- (normalize) = 1
591                 }
592                 // Compare the entire list of items with null - not just the first one, MNG-6964
593                 for (Item i : this) {
594                     int result = i.compareTo(null);
595                     if (result != 0) {
596                         return result;
597                     }
598                 }
599                 return 0;
600             }
601             switch (item.getType()) {
602                 case INT_ITEM:
603                 case LONG_ITEM:
604                 case BIGINTEGER_ITEM:
605                     return -1; // 1-1 < 1.0.x
606 
607                 case STRING_ITEM:
608                 case COMBINATION_ITEM:
609                     int scalarResult = isEmpty() ? -item.compareTo(null) : get(0).compareTo(item);
610                     for (int i = 1; scalarResult == 0 && i < size(); i++) {
611                         scalarResult = get(i).compareTo(null);
612                     }
613                     return scalarResult;
614 
615                 case LIST_ITEM:
616                     Iterator<Item> left = iterator();
617                     Iterator<Item> right = ((ListItem) item).iterator();
618 
619                     while (left.hasNext() || right.hasNext()) {
620                         Item l = left.hasNext() ? left.next() : null;
621                         Item r = right.hasNext() ? right.next() : null;
622 
623                         // if this is shorter, then invert the compare and mul with -1
624                         int result = l == null ? (r == null ? 0 : -1 * r.compareTo(l)) : l.compareTo(r);
625 
626                         if (result != 0) {
627                             return result;
628                         }
629                     }
630 
631                     return 0;
632 
633                 default:
634                     throw new IllegalStateException("invalid item: " + item.getClass());
635             }
636         }
637 
638         @Override
639         public String toString() {
640             StringBuilder buffer = new StringBuilder();
641             for (Item item : this) {
642                 if (buffer.length() > 0) {
643                     buffer.append((item instanceof ListItem) ? '-' : '.');
644                 }
645                 buffer.append(item);
646             }
647             return buffer.toString();
648         }
649 
650         /**
651          * Return the contents in the same format that is used when you call toString() on a List.
652          */
653         private String toListString() {
654             StringBuilder buffer = new StringBuilder();
655             buffer.append("[");
656             for (Item item : this) {
657                 if (buffer.length() > 1) {
658                     buffer.append(", ");
659                 }
660                 if (item instanceof ListItem listItem) {
661                     buffer.append(listItem.toListString());
662                 } else {
663                     buffer.append(item);
664                 }
665             }
666             buffer.append("]");
667             return buffer.toString();
668         }
669     }
670 
671     public ComparableVersion(String version) {
672         parseVersion(version);
673     }
674 
675     /**
676      * @throws IllegalArgumentException if the version string is longer than {@value #MAX_VERSION_LENGTH}
677      *         characters, to bound the parsing, comparison and canonicalization cost of arbitrarily large input
678      */
679     @SuppressWarnings("checkstyle:innerassignment")
680     public final void parseVersion(String version) {
681         if (version.length() > MAX_VERSION_LENGTH) {
682             throw new IllegalArgumentException("Version string is too long (" + version.length() + " > "
683                     + MAX_VERSION_LENGTH + " characters): "
684                     + version.substring(0, 32) + "...");
685         }
686 
687         this.value = version;
688 
689         items = new ListItem();
690 
691         version = version.toLowerCase(Locale.ENGLISH);
692 
693         ListItem list = items;
694 
695         Deque<Item> stack = new ArrayDeque<>();
696         stack.push(list);
697 
698         boolean isDigit = false;
699 
700         boolean isCombination = false;
701 
702         int startIndex = 0;
703 
704         for (int i = 0; i < version.length(); i++) {
705             char character = version.charAt(i);
706             int c = character;
707             if (Character.isHighSurrogate(character)) {
708                 // read the next character as a low surrogate and combine into a single int
709                 try {
710                     char low = version.charAt(i + 1);
711                     char[] both = {character, low};
712                     c = Character.codePointAt(both, 0);
713                     i++;
714                 } catch (IndexOutOfBoundsException ex) {
715                     // high surrogate without low surrogate. Not a lot we can do here except treat it as a regular
716                     // character
717                 }
718             }
719 
720             if (c == '.') {
721                 if (i == startIndex) {
722                     list.add(IntItem.ZERO);
723                 } else {
724                     list.add(parseItem(isCombination, isDigit, version.substring(startIndex, i)));
725                 }
726                 isCombination = false;
727                 startIndex = i + 1;
728             } else if (c == '-') {
729                 if (i == startIndex) {
730                     list.add(IntItem.ZERO);
731                 } else {
732                     // X-1 is going to be treated as X1
733                     if (!isDigit && i != version.length() - 1) {
734                         char c1 = version.charAt(i + 1);
735                         if (Character.isDigit(c1)) {
736                             isCombination = true;
737                             continue;
738                         }
739                     }
740                     list.add(parseItem(isCombination, isDigit, version.substring(startIndex, i)));
741                 }
742                 startIndex = i + 1;
743 
744                 if (!list.isEmpty()) {
745                     list.add(list = new ListItem());
746                     stack.push(list);
747                 }
748                 isCombination = false;
749             } else if (c >= '0' && c <= '9') { // Check for ASCII digits only
750                 if (!isDigit && i > startIndex) {
751                     // X1
752                     isCombination = true;
753 
754                     if (!list.isEmpty()) {
755                         list.add(list = new ListItem());
756                         stack.push(list);
757                     }
758                 }
759 
760                 isDigit = true;
761             } else {
762                 if (isDigit && i > startIndex) {
763                     list.add(parseItem(isCombination, true, version.substring(startIndex, i)));
764                     startIndex = i;
765 
766                     list.add(list = new ListItem());
767                     stack.push(list);
768                     isCombination = false;
769                 }
770 
771                 isDigit = false;
772             }
773         }
774 
775         if (version.length() > startIndex) {
776             // 1.0.0.X1 < 1.0.0-X2
777             // treat .X as -X for any string qualifier X
778             if (!isDigit && !list.isEmpty()) {
779                 list.add(list = new ListItem());
780                 stack.push(list);
781             }
782 
783             list.add(parseItem(isCombination, isDigit, version.substring(startIndex)));
784         }
785 
786         while (!stack.isEmpty()) {
787             list = (ListItem) stack.pop();
788             list.normalize();
789         }
790     }
791 
792     private static Item parseItem(boolean isDigit, String buf) {
793         return parseItem(false, isDigit, buf);
794     }
795 
796     private static Item parseItem(boolean isCombination, boolean isDigit, String buf) {
797         if (isCombination) {
798             return new CombinationItem(buf.replace("-", ""));
799         } else if (isDigit) {
800             buf = stripLeadingZeroes(buf);
801             if (buf.length() <= MAX_INTITEM_LENGTH) {
802                 // lower than 2^31
803                 return new IntItem(buf);
804             } else if (buf.length() <= MAX_LONGITEM_LENGTH) {
805                 // lower than 2^63
806                 return new LongItem(buf);
807             }
808             return new BigIntegerItem(buf);
809         }
810         return new StringItem(buf, false);
811     }
812 
813     private static String stripLeadingZeroes(String buf) {
814         if (buf == null || buf.isEmpty()) {
815             return "0";
816         }
817         for (int i = 0; i < buf.length(); ++i) {
818             char c = buf.charAt(i);
819             if (c != '0') {
820                 return buf.substring(i);
821             }
822         }
823         return "0";
824     }
825 
826     @Override
827     public int compareTo(ComparableVersion o) {
828         return items.compareTo(o.items);
829     }
830 
831     @Override
832     public String toString() {
833         return value;
834     }
835 
836     public String getCanonical() {
837         if (canonical == null) {
838             canonical = items.toString();
839         }
840         return canonical;
841     }
842 
843     @Override
844     public boolean equals(Object o) {
845         return o instanceof ComparableVersion comparableVersion && items.equals(comparableVersion.items);
846     }
847 
848     @Override
849     public int hashCode() {
850         return items.hashCode();
851     }
852 
853     /**
854      * Returns a hash code consistent with the ordering defined by {@link #compareTo(ComparableVersion)}:
855      * two versions that compare as equal get the same value even when their parsed representations
856      * differ, e.g. {@code 1-ga} ({@code [1, [ga]]}) and {@code 1} ({@code [1]}).
857      * <p>
858      * {@link #hashCode()} cannot provide this: it is structural, matching the structural
859      * {@link #equals(Object)} of this class. This method exists for classes such as
860      * {@link DefaultArtifactVersion} whose {@code equals} is defined as {@code compareTo == 0} and
861      * whose {@code hashCode} must therefore follow ordering equality (two equal objects must have
862      * equal hash codes).
863      *
864      * @return a hash code such that {@code a.compareTo(b) == 0} implies {@code a.orderingHashCode() == b.orderingHashCode()}
865      */
866     int orderingHashCode() {
867         return orderingHash(items);
868     }
869 
870     private static int orderingHash(Item item) {
871         return switch (item.getType()) {
872             case Item.LIST_ITEM -> {
873                 ListItem list = (ListItem) item;
874                 int end = list.size();
875                 // trailing items that compare as equal to null do not affect ordering: 1-ga == 1
876                 while (end > 0 && list.get(end - 1).compareTo(null) == 0) {
877                     end--;
878                 }
879                 if (end == 1) {
880                     yield orderingHash(list.get(0));
881                 }
882                 int hash = 1;
883                 for (int i = 0; i < end; i++) {
884                     hash = 31 * hash + orderingHash(list.get(i));
885                 }
886                 yield hash;
887             }
888             // qualifiers that compare as equal ("ga", "final", "release" and the empty qualifier) must hash alike
889             case Item.STRING_ITEM ->
890                 StringItem.comparableQualifier(((StringItem) item).value).hashCode();
891             case Item.COMBINATION_ITEM -> {
892                 CombinationItem combination = (CombinationItem) item;
893                 if (combination.stringPart.compareTo(null) == 0 && combination.digitPart.isNull()) {
894                     yield orderingHash(combination.stringPart);
895                 }
896                 yield 31
897                                 * StringItem.comparableQualifier(combination.stringPart.value)
898                                         .hashCode()
899                         + orderingHash(combination.digitPart);
900             }
901             // numeric items only compare as equal to items of the same type with the same value
902             default -> item.hashCode();
903         };
904     }
905 
906     // CHECKSTYLE_OFF: LineLength
907 
908     /**
909      * Main to test version parsing and comparison.
910      * <p>
911      * To check how "1.2.7" compares to "1.2-SNAPSHOT", for example, you can issue
912      * <pre>java -jar ${maven.repo.local}/org/apache/maven/maven-artifact/${maven.version}/maven-artifact-${maven.version}.jar "1.2.7" "1.2-SNAPSHOT"</pre>
913      * command to command line. Result of given command will be something like this:
914      * <pre>
915      * Display parameters as parsed by Maven (in canonical form) and comparison result:
916      * 1. 1.2.7 == 1.2.7
917      *    1.2.7 &gt; 1.2-SNAPSHOT
918      * 2. 1.2-SNAPSHOT == 1.2-snapshot
919      * </pre>
920      *
921      * @param args the version strings to parse and compare. You can pass arbitrary number of version strings and always
922      *             two adjacent will be compared.
923      */
924     // CHECKSTYLE_ON: LineLength
925     public static void main(String... args) {
926         System.out.println("Display parameters as parsed by Maven (in canonical form and as a list of tokens) and"
927                 + " comparison result:");
928         if (args.length == 0) {
929             return;
930         }
931 
932         ComparableVersion prev = null;
933         int i = 1;
934         for (String version : args) {
935             ComparableVersion c = new ComparableVersion(version);
936 
937             if (prev != null) {
938                 int compare = prev.compareTo(c);
939                 System.out.println(
940                         "   " + prev + ' ' + ((compare == 0) ? "==" : ((compare < 0) ? "<" : ">")) + ' ' + version);
941             }
942 
943             System.out.println(
944                     (i++) + ". " + version + " -> " + c.getCanonical() + "; tokens: " + c.items.toListString());
945 
946             prev = c;
947         }
948     }
949 }