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