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      private String value;
72  
73      private String canonical;
74  
75      private ListItem items;
76  
77      private interface Item {
78          int INT_ITEM = 3;
79          int LONG_ITEM = 4;
80          int BIGINTEGER_ITEM = 0;
81          int STRING_ITEM = 1;
82          int LIST_ITEM = 2;
83          int COMBINATION_ITEM = 5;
84  
85          int compareTo(Item item);
86  
87          int getType();
88  
89          boolean isNull();
90      }
91  
92      /**
93       * Represents a numeric item in the version item list that can be represented with an int.
94       */
95      private static class IntItem implements Item {
96          private final int value;
97  
98          public static final IntItem ZERO = new IntItem();
99  
100         private IntItem() {
101             this.value = 0;
102         }
103 
104         IntItem(String str) {
105             this.value = Integer.parseInt(str);
106         }
107 
108         @Override
109         public int getType() {
110             return INT_ITEM;
111         }
112 
113         @Override
114         public boolean isNull() {
115             return value == 0;
116         }
117 
118         @Override
119         public int compareTo(Item item) {
120             if (item == null) {
121                 return (value == 0) ? 0 : 1; // 1.0 == 1, 1.1 > 1
122             }
123 
124             return switch (item.getType()) {
125                 case INT_ITEM -> {
126                     int itemValue = ((IntItem) item).value;
127                     yield Integer.compare(value, itemValue);
128                 }
129                 case LONG_ITEM, BIGINTEGER_ITEM -> -1;
130                 case STRING_ITEM -> 1;
131                 case COMBINATION_ITEM -> 1; // 1.1 > 1-sp
132 
133                 case LIST_ITEM -> 1; // 1.1 > 1-1
134 
135                 default -> throw new IllegalStateException("invalid item: " + item.getClass());
136             };
137         }
138 
139         @Override
140         public boolean equals(Object o) {
141             if (this == o) {
142                 return true;
143             }
144             if (o == null || getClass() != o.getClass()) {
145                 return false;
146             }
147 
148             IntItem intItem = (IntItem) o;
149 
150             return value == intItem.value;
151         }
152 
153         @Override
154         public int hashCode() {
155             return value;
156         }
157 
158         @Override
159         public String toString() {
160             return Integer.toString(value);
161         }
162     }
163 
164     /**
165      * Represents a numeric item in the version item list that can be represented with a long.
166      */
167     private static class LongItem implements Item {
168         private final long value;
169 
170         LongItem(String str) {
171             this.value = Long.parseLong(str);
172         }
173 
174         @Override
175         public int getType() {
176             return LONG_ITEM;
177         }
178 
179         @Override
180         public boolean isNull() {
181             return value == 0;
182         }
183 
184         @Override
185         public int compareTo(Item item) {
186             if (item == null) {
187                 return (value == 0) ? 0 : 1; // 1.0 == 1, 1.1 > 1
188             }
189 
190             return switch (item.getType()) {
191                 case INT_ITEM -> 1;
192                 case LONG_ITEM -> {
193                     long itemValue = ((LongItem) item).value;
194                     yield Long.compare(value, itemValue);
195                 }
196                 case BIGINTEGER_ITEM -> -1;
197                 case STRING_ITEM -> 1;
198                 case COMBINATION_ITEM -> 1; // 1.1 > 1-sp
199 
200                 case LIST_ITEM -> 1; // 1.1 > 1-1
201 
202                 default -> throw new IllegalStateException("invalid item: " + item.getClass());
203             };
204         }
205 
206         @Override
207         public boolean equals(Object o) {
208             if (this == o) {
209                 return true;
210             }
211             if (o == null || getClass() != o.getClass()) {
212                 return false;
213             }
214 
215             LongItem longItem = (LongItem) o;
216 
217             return value == longItem.value;
218         }
219 
220         @Override
221         public int hashCode() {
222             return (int) (value ^ (value >>> 32));
223         }
224 
225         @Override
226         public String toString() {
227             return Long.toString(value);
228         }
229     }
230 
231     /**
232      * Represents a numeric item in the version item list.
233      */
234     private static class BigIntegerItem implements Item {
235         private final BigInteger value;
236 
237         BigIntegerItem(String str) {
238             this.value = new BigInteger(str);
239         }
240 
241         @Override
242         public int getType() {
243             return BIGINTEGER_ITEM;
244         }
245 
246         @Override
247         public boolean isNull() {
248             return BigInteger.ZERO.equals(value);
249         }
250 
251         @Override
252         public int compareTo(Item item) {
253             if (item == null) {
254                 return BigInteger.ZERO.equals(value) ? 0 : 1; // 1.0 == 1, 1.1 > 1
255             }
256 
257             return switch (item.getType()) {
258                 case INT_ITEM, LONG_ITEM -> 1;
259                 case BIGINTEGER_ITEM -> value.compareTo(((BigIntegerItem) item).value);
260                 case STRING_ITEM -> 1;
261                 case COMBINATION_ITEM -> 1; // 1.1 > 1-sp
262 
263                 case LIST_ITEM -> 1; // 1.1 > 1-1
264 
265                 default -> throw new IllegalStateException("invalid item: " + item.getClass());
266             };
267         }
268 
269         @Override
270         public boolean equals(Object o) {
271             if (this == o) {
272                 return true;
273             }
274             if (o == null || getClass() != o.getClass()) {
275                 return false;
276             }
277 
278             BigIntegerItem that = (BigIntegerItem) o;
279 
280             return value.equals(that.value);
281         }
282 
283         @Override
284         public int hashCode() {
285             return value.hashCode();
286         }
287 
288         @Override
289         public String toString() {
290             return value.toString();
291         }
292     }
293 
294     /**
295      * Represents a string in the version item list, usually a qualifier.
296      */
297     private static class StringItem implements Item {
298         private static final List<String> QUALIFIERS =
299                 Arrays.asList("alpha", "beta", "milestone", "rc", "snapshot", "", "sp");
300         private static final List<String> RELEASE_QUALIFIERS = Arrays.asList("ga", "final", "release");
301 
302         private static final Properties ALIASES = new Properties();
303 
304         static {
305             ALIASES.put("cr", "rc");
306         }
307 
308         /**
309          * A comparable value for the empty-string qualifier. This one is used to determine if a given qualifier makes
310          * the version older than one without a qualifier, or more recent.
311          */
312         private static final String RELEASE_VERSION_INDEX = String.valueOf(QUALIFIERS.indexOf(""));
313 
314         private final String value;
315 
316         StringItem(String value, boolean followedByDigit) {
317             if (followedByDigit && value.length() == 1) {
318                 // a1 = alpha-1, b1 = beta-1, m1 = milestone-1
319                 switch (value.charAt(0)) {
320                     case 'a':
321                         value = "alpha";
322                         break;
323                     case 'b':
324                         value = "beta";
325                         break;
326                     case 'm':
327                         value = "milestone";
328                         break;
329                     default:
330                 }
331             }
332             this.value = ALIASES.getProperty(value, value);
333         }
334 
335         @Override
336         public int getType() {
337             return STRING_ITEM;
338         }
339 
340         @Override
341         public boolean isNull() {
342             return value == null || value.isEmpty();
343         }
344 
345         /**
346          * Returns a comparable value for a qualifier.
347          * <p>
348          * This method takes into account the ordering of known qualifiers then unknown qualifiers with lexical
349          * ordering.
350          * <p>
351          *
352          * @param qualifier
353          * @return an equivalent value that can be used with lexical comparison
354          */
355         public static String comparableQualifier(String qualifier) {
356             if (RELEASE_QUALIFIERS.contains(qualifier)) {
357                 return String.valueOf(QUALIFIERS.indexOf(""));
358             }
359 
360             int i = QUALIFIERS.indexOf(qualifier);
361 
362             // Just returning an Integer with the index here is faster, but requires a lot of if/then/else to check for
363             // -1
364             //  or QUALIFIERS.size and then resort to lexical ordering. Most comparisons are decided by the first
365             // character,
366             // so this is still fast. If more characters are needed then it requires a lexical sort anyway.
367             return i == -1 ? (QUALIFIERS.size() + "-" + qualifier) : String.valueOf(i);
368         }
369 
370         @Override
371         public int compareTo(Item item) {
372             if (item == null) {
373                 // 1-rc < 1, 1-ga > 1
374                 return comparableQualifier(value).compareTo(RELEASE_VERSION_INDEX);
375             }
376             switch (item.getType()) {
377                 case INT_ITEM:
378                 case LONG_ITEM:
379                 case BIGINTEGER_ITEM:
380                     return -1; // 1.any < 1.1 ?
381 
382                 case STRING_ITEM:
383                     return comparableQualifier(value).compareTo(comparableQualifier(((StringItem) item).value));
384 
385                 case COMBINATION_ITEM:
386                     int result = this.compareTo(((CombinationItem) item).getStringPart());
387                     if (result == 0) {
388                         return -1;
389                     }
390                     return result;
391 
392                 case LIST_ITEM:
393                     return -1; // 1.any < 1-1
394 
395                 default:
396                     throw new IllegalStateException("invalid item: " + item.getClass());
397             }
398         }
399 
400         @Override
401         public boolean equals(Object o) {
402             if (this == o) {
403                 return true;
404             }
405             if (o == null || getClass() != o.getClass()) {
406                 return false;
407             }
408 
409             StringItem that = (StringItem) o;
410 
411             return value.equals(that.value);
412         }
413 
414         @Override
415         public int hashCode() {
416             return value.hashCode();
417         }
418 
419         @Override
420         public String toString() {
421             return value;
422         }
423     }
424 
425     /**
426      * Represents a combination in the version item list.
427      * It is usually a combination of a string and a number, with the string first and the number second.
428      */
429     private static class CombinationItem implements Item {
430 
431         StringItem stringPart;
432 
433         Item digitPart;
434 
435         CombinationItem(String value) {
436             int index = 0;
437             for (int i = 0; i < value.length(); i++) {
438                 char c = value.charAt(i);
439                 if (Character.isDigit(c)) {
440                     index = i;
441                     break;
442                 }
443             }
444 
445             stringPart = new StringItem(value.substring(0, index), true);
446             digitPart = parseItem(true, value.substring(index));
447         }
448 
449         @Override
450         public int compareTo(Item item) {
451             if (item == null) {
452                 // 1-rc1 < 1, 1-ga1 > 1
453                 return stringPart.compareTo(item);
454             }
455             int result = 0;
456             switch (item.getType()) {
457                 case INT_ITEM:
458                 case LONG_ITEM:
459                 case BIGINTEGER_ITEM:
460                     return -1;
461 
462                 case STRING_ITEM:
463                     result = stringPart.compareTo(item);
464                     if (result == 0) {
465                         // X1 > X
466                         return 1;
467                     }
468                     return result;
469 
470                 case LIST_ITEM:
471                     return -1;
472 
473                 case COMBINATION_ITEM:
474                     result = stringPart.compareTo(((CombinationItem) item).getStringPart());
475                     if (result == 0) {
476                         return digitPart.compareTo(((CombinationItem) item).getDigitPart());
477                     }
478                     return result;
479                 default:
480                     return 0;
481             }
482         }
483 
484         public StringItem getStringPart() {
485             return stringPart;
486         }
487 
488         public Item getDigitPart() {
489             return digitPart;
490         }
491 
492         @Override
493         public int getType() {
494             return COMBINATION_ITEM;
495         }
496 
497         @Override
498         public boolean isNull() {
499             return false;
500         }
501 
502         @Override
503         public boolean equals(Object o) {
504             if (this == o) {
505                 return true;
506             }
507             if (o == null || getClass() != o.getClass()) {
508                 return false;
509             }
510             CombinationItem that = (CombinationItem) o;
511             return Objects.equals(stringPart, that.stringPart) && Objects.equals(digitPart, that.digitPart);
512         }
513 
514         @Override
515         public int hashCode() {
516             return Objects.hash(stringPart, digitPart);
517         }
518 
519         @Override
520         public String toString() {
521             return stringPart.toString() + digitPart.toString();
522         }
523     }
524 
525     /**
526      * Represents a version list item. This class is used both for the global item list and for sub-lists (which start
527      * with '-(number)' in the version specification).
528      */
529     private static class ListItem extends ArrayList<Item> implements Item {
530         @Override
531         public int getType() {
532             return LIST_ITEM;
533         }
534 
535         @Override
536         public boolean isNull() {
537             return (size() == 0);
538         }
539 
540         void normalize() {
541             for (int i = size() - 1; i >= 0; i--) {
542                 Item lastItem = get(i);
543 
544                 if (lastItem.isNull()) {
545                     if (i == size() - 1 || get(i + 1).getType() == STRING_ITEM) {
546                         remove(i);
547                     } else if (get(i + 1).getType() == LIST_ITEM) {
548                         Item item = ((ListItem) get(i + 1)).get(0);
549                         if (item.getType() == COMBINATION_ITEM || item.getType() == STRING_ITEM) {
550                             remove(i);
551                         }
552                     }
553                 }
554             }
555         }
556 
557         @Override
558         public int compareTo(Item item) {
559             if (item == null) {
560                 if (size() == 0) {
561                     return 0; // 1-0 = 1- (normalize) = 1
562                 }
563                 // Compare the entire list of items with null - not just the first one, MNG-6964
564                 for (Item i : this) {
565                     int result = i.compareTo(null);
566                     if (result != 0) {
567                         return result;
568                     }
569                 }
570                 return 0;
571             }
572             switch (item.getType()) {
573                 case INT_ITEM:
574                 case LONG_ITEM:
575                 case BIGINTEGER_ITEM:
576                     return -1; // 1-1 < 1.0.x
577 
578                 case STRING_ITEM:
579                     return 1;
580                 case COMBINATION_ITEM:
581                     return 1; // 1-1 > 1-sp
582 
583                 case LIST_ITEM:
584                     Iterator<Item> left = iterator();
585                     Iterator<Item> right = ((ListItem) item).iterator();
586 
587                     while (left.hasNext() || right.hasNext()) {
588                         Item l = left.hasNext() ? left.next() : null;
589                         Item r = right.hasNext() ? right.next() : null;
590 
591                         // if this is shorter, then invert the compare and mul with -1
592                         int result = l == null ? (r == null ? 0 : -1 * r.compareTo(l)) : l.compareTo(r);
593 
594                         if (result != 0) {
595                             return result;
596                         }
597                     }
598 
599                     return 0;
600 
601                 default:
602                     throw new IllegalStateException("invalid item: " + item.getClass());
603             }
604         }
605 
606         @Override
607         public String toString() {
608             StringBuilder buffer = new StringBuilder();
609             for (Item item : this) {
610                 if (buffer.length() > 0) {
611                     buffer.append((item instanceof ListItem) ? '-' : '.');
612                 }
613                 buffer.append(item);
614             }
615             return buffer.toString();
616         }
617 
618         /**
619          * Return the contents in the same format that is used when you call toString() on a List.
620          */
621         private String toListString() {
622             StringBuilder buffer = new StringBuilder();
623             buffer.append("[");
624             for (Item item : this) {
625                 if (buffer.length() > 1) {
626                     buffer.append(", ");
627                 }
628                 if (item instanceof ListItem listItem) {
629                     buffer.append(listItem.toListString());
630                 } else {
631                     buffer.append(item);
632                 }
633             }
634             buffer.append("]");
635             return buffer.toString();
636         }
637     }
638 
639     public ComparableVersion(String version) {
640         parseVersion(version);
641     }
642 
643     @SuppressWarnings("checkstyle:innerassignment")
644     public final void parseVersion(String version) {
645         this.value = version;
646 
647         items = new ListItem();
648 
649         version = version.toLowerCase(Locale.ENGLISH);
650 
651         ListItem list = items;
652 
653         Deque<Item> stack = new ArrayDeque<>();
654         stack.push(list);
655 
656         boolean isDigit = false;
657 
658         boolean isCombination = false;
659 
660         int startIndex = 0;
661 
662         for (int i = 0; i < version.length(); i++) {
663             char character = version.charAt(i);
664             int c = character;
665             if (Character.isHighSurrogate(character)) {
666                 // read the next character as a low surrogate and combine into a single int
667                 try {
668                     char low = version.charAt(i + 1);
669                     char[] both = {character, low};
670                     c = Character.codePointAt(both, 0);
671                     i++;
672                 } catch (IndexOutOfBoundsException ex) {
673                     // high surrogate without low surrogate. Not a lot we can do here except treat it as a regular
674                     // character
675                 }
676             }
677 
678             if (c == '.') {
679                 if (i == startIndex) {
680                     list.add(IntItem.ZERO);
681                 } else {
682                     list.add(parseItem(isCombination, isDigit, version.substring(startIndex, i)));
683                 }
684                 isCombination = false;
685                 startIndex = i + 1;
686             } else if (c == '-') {
687                 if (i == startIndex) {
688                     list.add(IntItem.ZERO);
689                 } else {
690                     // X-1 is going to be treated as X1
691                     if (!isDigit && i != version.length() - 1) {
692                         char c1 = version.charAt(i + 1);
693                         if (Character.isDigit(c1)) {
694                             isCombination = true;
695                             continue;
696                         }
697                     }
698                     list.add(parseItem(isCombination, isDigit, version.substring(startIndex, i)));
699                 }
700                 startIndex = i + 1;
701 
702                 if (!list.isEmpty()) {
703                     list.add(list = new ListItem());
704                     stack.push(list);
705                 }
706                 isCombination = false;
707             } else if (c >= '0' && c <= '9') { // Check for ASCII digits only
708                 if (!isDigit && i > startIndex) {
709                     // X1
710                     isCombination = true;
711 
712                     if (!list.isEmpty()) {
713                         list.add(list = new ListItem());
714                         stack.push(list);
715                     }
716                 }
717 
718                 isDigit = true;
719             } else {
720                 if (isDigit && i > startIndex) {
721                     list.add(parseItem(isCombination, true, version.substring(startIndex, i)));
722                     startIndex = i;
723 
724                     list.add(list = new ListItem());
725                     stack.push(list);
726                     isCombination = false;
727                 }
728 
729                 isDigit = false;
730             }
731         }
732 
733         if (version.length() > startIndex) {
734             // 1.0.0.X1 < 1.0.0-X2
735             // treat .X as -X for any string qualifier X
736             if (!isDigit && !list.isEmpty()) {
737                 list.add(list = new ListItem());
738                 stack.push(list);
739             }
740 
741             list.add(parseItem(isCombination, isDigit, version.substring(startIndex)));
742         }
743 
744         while (!stack.isEmpty()) {
745             list = (ListItem) stack.pop();
746             list.normalize();
747         }
748     }
749 
750     private static Item parseItem(boolean isDigit, String buf) {
751         return parseItem(false, isDigit, buf);
752     }
753 
754     private static Item parseItem(boolean isCombination, boolean isDigit, String buf) {
755         if (isCombination) {
756             return new CombinationItem(buf.replace("-", ""));
757         } else if (isDigit) {
758             buf = stripLeadingZeroes(buf);
759             if (buf.length() <= MAX_INTITEM_LENGTH) {
760                 // lower than 2^31
761                 return new IntItem(buf);
762             } else if (buf.length() <= MAX_LONGITEM_LENGTH) {
763                 // lower than 2^63
764                 return new LongItem(buf);
765             }
766             return new BigIntegerItem(buf);
767         }
768         return new StringItem(buf, false);
769     }
770 
771     private static String stripLeadingZeroes(String buf) {
772         if (buf == null || buf.isEmpty()) {
773             return "0";
774         }
775         for (int i = 0; i < buf.length(); ++i) {
776             char c = buf.charAt(i);
777             if (c != '0') {
778                 return buf.substring(i);
779             }
780         }
781         return buf;
782     }
783 
784     @Override
785     public int compareTo(ComparableVersion o) {
786         return items.compareTo(o.items);
787     }
788 
789     @Override
790     public String toString() {
791         return value;
792     }
793 
794     public String getCanonical() {
795         if (canonical == null) {
796             canonical = items.toString();
797         }
798         return canonical;
799     }
800 
801     @Override
802     public boolean equals(Object o) {
803         return o instanceof ComparableVersion comparableVersion && items.equals(comparableVersion.items);
804     }
805 
806     @Override
807     public int hashCode() {
808         return items.hashCode();
809     }
810 
811     // CHECKSTYLE_OFF: LineLength
812 
813     /**
814      * Main to test version parsing and comparison.
815      * <p>
816      * To check how "1.2.7" compares to "1.2-SNAPSHOT", for example, you can issue
817      * <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>
818      * command to command line. Result of given command will be something like this:
819      * <pre>
820      * Display parameters as parsed by Maven (in canonical form) and comparison result:
821      * 1. 1.2.7 == 1.2.7
822      *    1.2.7 &gt; 1.2-SNAPSHOT
823      * 2. 1.2-SNAPSHOT == 1.2-snapshot
824      * </pre>
825      *
826      * @param args the version strings to parse and compare. You can pass arbitrary number of version strings and always
827      *             two adjacent will be compared.
828      */
829     // CHECKSTYLE_ON: LineLength
830     public static void main(String... args) {
831         System.out.println("Display parameters as parsed by Maven (in canonical form and as a list of tokens) and"
832                 + " comparison result:");
833         if (args.length == 0) {
834             return;
835         }
836 
837         ComparableVersion prev = null;
838         int i = 1;
839         for (String version : args) {
840             ComparableVersion c = new ComparableVersion(version);
841 
842             if (prev != null) {
843                 int compare = prev.compareTo(c);
844                 System.out.println(
845                         "   " + prev + ' ' + ((compare == 0) ? "==" : ((compare < 0) ? "<" : ">")) + ' ' + version);
846             }
847 
848             System.out.println(
849                     (i++) + ". " + version + " -> " + c.getCanonical() + "; tokens: " + c.items.toListString());
850 
851             prev = c;
852         }
853     }
854 }