diff --git a/src/main/java/com/eekboom/utils/Strings.java b/src/main/java/com/eekboom/utils/Strings.java new file mode 100644 index 0000000..a74df43 --- /dev/null +++ b/src/main/java/com/eekboom/utils/Strings.java @@ -0,0 +1,353 @@ +/* + * Copyright (c) 2006, Stephen Kelvin Friedrich, All rights reserved. + * + * This a BSD license. If you use or enhance the code, I'd be pleased if you sent a mail to s.friedrich@eekboom.com + * + * Redistribution and use in source and binary forms, with or without modification, are permitted provided that the + * following conditions are met: + * * Redistributions of source code must retain the above copyright notice, this list of conditions and the + * following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other materials provided with the distribution. + * * Neither the name of the "Stephen Kelvin Friedrich" nor the names of its contributors may be used to endorse + * or promote products derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.eekboom.utils; + +import java.text.Collator; +import java.util.Comparator; + +/** + * Utility class for common String operations + */ +public final class Strings { + /** + *

A string comparator that does case sensitive comparisons and handles embedded numbers correctly.

+ *

Do not use if your app might ever run on any locale that uses more than 7-bit ascii characters.

+ */ + private static final Comparator NATURAL_COMPARATOR_ASCII = new Comparator() { + public int compare(String o1, String o2) { + return compareNaturalAscii(o1, o2); + } + }; + + /** + *

A string comparator that does case insensitive comparisons and handles embedded numbers correctly.

+ *

Do not use if your app might ever run on any locale that uses more than 7-bit ascii characters.

+ */ + private static final Comparator IGNORE_CASE_NATURAL_COMPARATOR_ASCII = new Comparator() { + public int compare(String o1, String o2) { + return compareNaturalIgnoreCaseAscii(o1, o2); + } + }; + + /** + * This is a utility class (static methods only), don't instantiate. + */ + private Strings() { + } + + /** + * Returns a comparator that compares contained numbers based on their numeric values and compares other parts + * using the current locale's order rules. + *

For example in German locale this will be a comparator that handles umlauts correctly and ignores + * upper/lower case differences.

+ * + * @return

A string comparator that uses the current locale's order rules and handles embedded numbers + * correctly.

+ * @see #getNaturalComparator(java.text.Collator) + */ + public static Comparator getNaturalComparator() { + Collator collator = Collator.getInstance(); + return getNaturalComparator(collator); + } + + /** + * Returns a comparator that compares contained numbers based on their numeric values and compares other parts + * using the given collator. + * + * @param collator used for locale specific comparison of text (non-number) subwords - must not be null + * @return

A string comparator that uses the given Collator to compare subwords and handles embedded numbers + * correctly.

+ * @see #getNaturalComparator() + */ + public static Comparator getNaturalComparator(final Collator collator) { + if(collator == null) { + // it's important to explicitly handle this here - else the bug will manifest anytime later in possibly + // unrelated code that tries to use the comparator + throw new NullPointerException("collator must not be null"); + } + return new Comparator() { + public int compare(String o1, String o2) { + return compareNatural(collator, o1, o2); + } + }; + } + + /** + * Returns a comparator that compares contained numbers based on their numeric values and compares other parts + * based on each character's Unicode value. + * + * @return

a string comparator that does case sensitive comparisons on pure ascii strings and handles embedded + * numbers correctly.

+ * Do not use if your app might ever run on any locale that uses more than 7-bit ascii characters. + * @see #getNaturalComparator() + * @see #getNaturalComparator(java.text.Collator) + */ + public static Comparator getNaturalComparatorAscii() { + return NATURAL_COMPARATOR_ASCII; + } + + /** + * Returns a comparator that compares contained numbers based on their numeric values and compares other parts + * based on each character's Unicode value while ignore upper/lower case differences. + * Do not use if your app might ever run on any locale that uses more than 7-bit ascii characters. + * + * @return

a string comparator that does case insensitive comparisons on pure ascii strings and handles embedded + * numbers correctly.

+ * @see #getNaturalComparator() + * @see #getNaturalComparator(java.text.Collator) + */ + public static Comparator getNaturalComparatorIgnoreCaseAscii() { + return IGNORE_CASE_NATURAL_COMPARATOR_ASCII; + } + + /** + *

Compares two strings using the current locale's rules and comparing contained numbers based on their numeric + * values.

+ *

This is probably the best default comparison to use.

+ *

If you know that the texts to be compared are in a certain language that differs from the default locale's + * langage, then get a collator for the desired locale ({@link java.text.Collator#getInstance(java.util.Locale)}) + * and pass it to {@link #compareNatural(java.text.Collator, String, String)}

+ * + * @param s first string + * @param t second string + * @return zero iff s and t are equal, + * a value less than zero iff s lexicographically precedes t + * and a value larger than zero iff s lexicographically follows t + */ + public static int compareNatural(String s, String t) { + return compareNatural(s, t, false, Collator.getInstance()); + } + + /** + *

Compares two strings using the given collator and comparing contained numbers based on their numeric + * values.

+ * + * @param s first string + * @param t second string + * @return zero iff s and t are equal, + * a value less than zero iff s lexicographically precedes t + * and a value larger than zero iff s lexicographically follows t + */ + public static int compareNatural(Collator collator, String s, String t) { + return compareNatural(s, t, true, collator); + } + + /** + *

Compares two strings using each character's Unicode value for non-digit characters and the numeric values off + * any contained numbers.

+ *

(This will probably make sense only for strings containing 7-bit ascii characters only.)

+ * + * @return zero iff s and t are equal, + * a value less than zero iff s lexicographically precedes t + * and a value larger than zero iff s lexicographically follows t + */ + public static int compareNaturalAscii(String s, String t) { + return compareNatural(s, t, true, null); + } + + /** + *

Compares two strings using each character's Unicode value - ignoring upper/lower case - for non-digit + * characters and the numeric values of any contained numbers.

+ *

(This will probably make sense only for strings containing 7-bit ascii characters only.)

+ * + * @return zero iff s and t are equal, + * a value less than zero iff s lexicographically precedes t + * and a value larger than zero iff s lexicographically follows t + */ + public static int compareNaturalIgnoreCaseAscii(String s, String t) { + return compareNatural(s, t, false, null); + } + + /** + * @param s first string + * @param t second string + * @param caseSensitive treat characters differing in case only as equal - will be ignored if a collator is given + * @param collator used to compare subwords that aren't numbers - if null, characters will be compared + * individually based on their Unicode value + * @return zero iff s and t are equal, + * a value less than zero iff s lexicographically precedes t + * and a value larger than zero iff s lexicographically follows t + */ + private static int compareNatural(String s, String t, boolean caseSensitive, Collator collator) { + int sIndex = 0; + int tIndex = 0; + + int sLength = s.length(); + int tLength = t.length(); + + while(true) { + // both character indices are after a subword (or at zero) + + // Check if one string is at end + if(sIndex == sLength && tIndex == tLength) { + return 0; + } + if(sIndex == sLength) { + return -1; + } + if(tIndex == tLength) { + return 1; + } + + // Compare sub word + char sChar = s.charAt(sIndex); + char tChar = t.charAt(tIndex); + + boolean sCharIsDigit = Character.isDigit(sChar); + boolean tCharIsDigit = Character.isDigit(tChar); + + if(sCharIsDigit && tCharIsDigit) { + // Compare numbers + + // skip leading 0s + int sLeadingZeroCount = 0; + while(sChar == '0') { + ++sLeadingZeroCount; + ++sIndex; + if(sIndex == sLength) { + break; + } + sChar = s.charAt(sIndex); + } + int tLeadingZeroCount = 0; + while(tChar == '0') { + ++tLeadingZeroCount; + ++tIndex; + if(tIndex == tLength) { + break; + } + tChar = t.charAt(tIndex); + } + boolean sAllZero = sIndex == sLength || !Character.isDigit(sChar); + boolean tAllZero = tIndex == tLength || !Character.isDigit(tChar); + if(sAllZero && tAllZero) { + continue; + } + if(sAllZero && !tAllZero) { + return -1; + } + if(tAllZero) { + return 1; + } + + int diff = 0; + do { + if(diff == 0) { + diff = sChar - tChar; + } + ++sIndex; + ++tIndex; + if(sIndex == sLength && tIndex == tLength) { + return diff != 0 ? diff : sLeadingZeroCount - tLeadingZeroCount; + } + if(sIndex == sLength) { + if(diff == 0) { + return -1; + } + return Character.isDigit(t.charAt(tIndex)) ? -1 : diff; + } + if(tIndex == tLength) { + if(diff == 0) { + return 1; + } + return Character.isDigit(s.charAt(sIndex)) ? 1 : diff; + } + sChar = s.charAt(sIndex); + tChar = t.charAt(tIndex); + sCharIsDigit = Character.isDigit(sChar); + tCharIsDigit = Character.isDigit(tChar); + if(!sCharIsDigit && !tCharIsDigit) { + // both number sub words have the same length + if(diff != 0) { + return diff; + } + break; + } + if(!sCharIsDigit) { + return -1; + } + if(!tCharIsDigit) { + return 1; + } + } while(true); + } + else { + // Compare words + if(collator != null) { + // To use the collator the whole subwords have to be compared - character-by-character comparision + // is not possible. So find the two subwords first + int aw = sIndex; + int bw = tIndex; + do { + ++sIndex; + } while(sIndex < sLength && !Character.isDigit(s.charAt(sIndex))); + do { + ++tIndex; + } while(tIndex < tLength && !Character.isDigit(t.charAt(tIndex))); + + String as = s.substring(aw, sIndex); + String bs = t.substring(bw, tIndex); + int subwordResult = collator.compare(as, bs); + if(subwordResult != 0) { + return subwordResult; + } + } + else { + // No collator specified. All characters should be ascii only. Compare character-by-character. + do { + if(sChar != tChar) { + if(caseSensitive) { + return sChar - tChar; + } + sChar = Character.toUpperCase(sChar); + tChar = Character.toUpperCase(tChar); + if(sChar != tChar) { + sChar = Character.toLowerCase(sChar); + tChar = Character.toLowerCase(tChar); + if(sChar != tChar) { + return sChar - tChar; + } + } + } + ++sIndex; + ++tIndex; + if(sIndex == sLength && tIndex == tLength) { + return 0; + } + if(sIndex == sLength) { + return -1; + } + if(tIndex == tLength) { + return 1; + } + sChar = s.charAt(sIndex); + tChar = t.charAt(tIndex); + sCharIsDigit = Character.isDigit(sChar); + tCharIsDigit = Character.isDigit(tChar); + } while(!sCharIsDigit && !tCharIsDigit); + } + } + } + } +} \ No newline at end of file diff --git a/src/main/java/org/jenkinsci/plugins/repoclient/client/Version.java b/src/main/java/org/jenkinsci/plugins/repoclient/client/Version.java index 7f4243d..f636e92 100644 --- a/src/main/java/org/jenkinsci/plugins/repoclient/client/Version.java +++ b/src/main/java/org/jenkinsci/plugins/repoclient/client/Version.java @@ -1,7 +1,8 @@ package org.jenkinsci.plugins.repoclient.client; +import com.eekboom.utils.Strings; + import java.io.Serializable; -import java.text.Collator; import java.util.ArrayList; import java.util.List; import java.util.StringTokenizer; @@ -102,9 +103,11 @@ public Version(String ver) { int idx = -1; char[] chars = ver.toCharArray(); int j = 0; + char delimiter = '.'; for (char c : chars) { if ((c < '0' || '9' < c)) { idx = j; + delimiter = c; break; } j++; @@ -115,7 +118,7 @@ public Version(String ver) { String fragment = ver.substring(0, idx); try { Integer.valueOf(fragment); - if (idx < ver.length()) { + if (idx < ver.length() && delimiter == '.') { String check = stripVersion(ver.substring(idx + 1)); if (check != null) { version = fragment + "." + check; @@ -269,9 +272,8 @@ public int compareTo(Version ver) { } } if (result == 0) { - result = Collator.getInstance().compare(qualifier, - ver.getQualifier()); - } + result = Strings.compareNaturalAscii(qualifier, ver.getQualifier()); + } return result; } } diff --git a/src/test/java/com/eekboom/utils/StringsTest.java b/src/test/java/com/eekboom/utils/StringsTest.java new file mode 100644 index 0000000..3dd41a7 --- /dev/null +++ b/src/test/java/com/eekboom/utils/StringsTest.java @@ -0,0 +1,174 @@ +package com.eekboom.utils; +import junit.framework.TestCase; + +import java.text.Collator; +import java.util.*; + +public class StringsTest extends TestCase { + public void testCompareNatural() { + assertEquals(0, c("", "")); + assertEquals(1, c("1-02", "1-2")); + assertEquals(-1, c("1-2", "1-02")); + assertEquals(-1, c("catch 22", "catch 022")); + assertEquals(0, c("a", "a")); + assertEquals(-1, c("2a", "2a2")); + assertEquals(1, c("b", "a")); + assertEquals(-1, c("a", "b")); + assertEquals(-1, c("002", "11")); + assertEquals(-1, c("2", "11")); + assertEquals(1, c("22", "11")); + assertEquals(1, c("222", "99")); + assertEquals(-1, c("a 2", "a 11")); + assertEquals(-1, c("c23", "c111")); + assertEquals(-1, c("a2", "aa2")); + assertEquals(1, c("a 22", "a 2")); + assertEquals(1, c("a", "A")); + assertEquals(-1, c("a 2 h", "a 2 h 2")); + assertEquals(-1, c("abcd 234 huj", "abcd 234 huj 2")); + assertEquals(0, c("abcd 234 huj", "abcd 234 huj")); + assertEquals(1, c("abcd 234 huj 33", "abcd 234 huj 9")); + assertEquals(-1, c("1.9.2-r9abc", "1.10.1-r9abc")); + assertEquals(1, c("1.9.2-r10abc", "1.9.2-r9abc")); + } + + public void testNaturalCompareWhitespace() { + String[] strings = { "p4", "p 3" }; + List sortedStrings = Arrays.asList(strings); + List testStrings = new ArrayList(sortedStrings); + for(int i = 0; i < 10; ++i) { + Collections.shuffle(testStrings); + Collections.sort(testStrings, Strings.getNaturalComparator()); + assertEquals(sortedStrings, testStrings); + } + } + + + public void testCompareNaturalIgnoreCase() { + assertEquals(0, ci("a", "a")); + assertEquals(1, ci("b", "a")); + assertEquals(0, ci("A", "a")); + assertEquals(0, ci("A12", "a12")); + assertEquals(1, ci("A12 11", "a12 9")); + assertEquals(-1, ci("catch 22", "cAtCh 022")); + assertEquals(1, ci("pic 5", "pic 4 else")); + assertEquals(1, ci("p 5 s", "p 5")); + assertEquals(-1, ci("p 5", "p 5 s")); + } + + public void testCompareNaturalCollator() { + Collator c = Collator.getInstance(Locale.GERMANY); + c.setStrength(Collator.SECONDARY); + assertEquals(0, c(c, "a", "a")); + assertEquals(0, c(c, "a", "A")); + assertEquals(1, c(c, "รค", "a")); + assertEquals(1, c(c, "B", "a")); + } + + private int c(Collator c, String a, String b) { + int result = Strings.compareNatural(c, a, b); + result = result < 0 ? -1 : result > 0 ? 1 : 0; + return result; + } + + private int c(String a, String b) { + int result = Strings.compareNaturalAscii(a, b); + result = result < 0 ? -1 : !(result <= 0) ? 1 : 0; + return result; + } + + private int ci(String a, String b) { + int result = Strings.compareNaturalIgnoreCaseAscii(a, b); + result = result < 0 ? -1 : !(result <= 0) ? 1 : 0; + return result; + } + + public static void testListSort() { + String[] strings = new String[]{"1-2", "1-02", "1-20", "10-20", "fred", "jane", "pic 7", "pic 4 else", + "pic 5", "pic 5", "pic 5 something", "pic 6", "pic01", "pic2", "pic02", + "pic02a", "pic3", "pic4", "pic05", "pic100", "pic100a", "pic120", "pic121", + "pic02000", "tom", "x2-g8", "x2-y7", "x2-y08", "x8-y8"}; + + List expectedSorted = new ArrayList(Arrays.asList(strings)); + List actualSorted = new ArrayList(Arrays.asList(strings)); + + for(int i = 0; i < 1000; ++i) { + Collections.shuffle(actualSorted); + Collections.sort(actualSorted, Strings.getNaturalComparatorIgnoreCaseAscii()); + + assertEquals(expectedSorted, actualSorted); + } + } + + public void testCompareNaturalPerformance() { + int wordCount = 5000; + int maxSubWordCount = 20; + int maxSubWordLength = 3; + Random random = new Random(); + List words = new ArrayList(); + for(int i = 0; i < wordCount; ++i) { + String word = createRandomWord(random, maxSubWordCount, maxSubWordLength, 0.3); + words.add(word); + } + + int runCount = 100; + double totalTimeDefaultSort = 0.0; + double totalTimeNaturalSort = 0.0; + for(int runIndex = 0; runIndex < runCount; ++runIndex) { + List defaultSortedWords = new ArrayList(words); + double timeDefaultSort = timeDefaultSort(defaultSortedWords); + List naturalSortedWords = new ArrayList(words); + + double timeNaturalSort = timeNaturalSort(naturalSortedWords); + Collections.shuffle(words); + + totalTimeDefaultSort += timeDefaultSort; + totalTimeNaturalSort += timeNaturalSort; + } + + double avgTimeDefaultSort = totalTimeDefaultSort / runCount; + System.out.println("avgTimeDefaultSort = " + avgTimeDefaultSort); + + double avgTimeNaturalSort = totalTimeNaturalSort / runCount; + System.out.println("avgTimeNaturalSort = " + avgTimeNaturalSort); + + assertTrue(avgTimeNaturalSort < 5.0 * avgTimeDefaultSort); + } + + private double timeDefaultSort(List words) { + long startTime = System.currentTimeMillis(); + Collections.sort(words); + long endTime = System.currentTimeMillis(); + + return endTime - startTime; + } + + private double timeNaturalSort(List words) { + long startTime = System.currentTimeMillis(); + Collections.sort(words, Strings.getNaturalComparatorAscii()); + long endTime = System.currentTimeMillis(); + + return endTime - startTime; + } + + private static String createRandomWord(Random random, int maxSubWordCount, int maxSubWordLength, + double numberSubWordProbability) { + int subWordCount = random.nextInt(maxSubWordCount); + StringBuilder builder = new StringBuilder(); + for(int i = 0; i < subWordCount; ++i) { + boolean isNumberSubWord = random.nextDouble() < numberSubWordProbability; + int subWordLength = 1 + random.nextInt(maxSubWordLength - 1); + for(int subWordCharIndex = 0; subWordCharIndex < subWordLength; ++subWordCharIndex) { + char c; + if(isNumberSubWord) { + c = (char) ('0' + random.nextInt(10)); + } + else { + c = (char) ('a' + random.nextInt(5)); + } + builder.append(c); + } + } + return new String(builder); + } + +} diff --git a/src/test/java/org/jenkinsci/plugins/repoclient/client/VersionTest.java b/src/test/java/org/jenkinsci/plugins/repoclient/client/VersionTest.java index 402b56c..7b61dee 100644 --- a/src/test/java/org/jenkinsci/plugins/repoclient/client/VersionTest.java +++ b/src/test/java/org/jenkinsci/plugins/repoclient/client/VersionTest.java @@ -121,6 +121,11 @@ public void testQualifierDash() { Version v = new Version("1.2.3.4-qualifier"); checkVersion(v, "1", "2", "3", "4", "qualifier"); } + @Test + public void testQualifierDashNumeric() { + Version v = new Version("1.2.3-4"); + checkVersion(v, "1", "2", "3","0", "4"); + } @Test public void testQualifierHash() { @@ -180,6 +185,18 @@ public void testCompareLess() { Version v2 = new Version("1"); assertTrue(v1.compareTo(v2) == -1); } + @Test + public void testCompareQualifier() { + Version v1 = new Version("1.1.2-r2"); + Version v2 = new Version("1.1.2-r3"); + assertTrue(v1.compareTo(v2) == -1); + } + @Test + public void testCompareQualifierNumeric() { + Version v1 = new Version("1.1.2-r9"); + Version v2 = new Version("1.1.2-r10"); + assertTrue(v1.compareTo(v2) == -1); + } @Test public void testCompareGreater() {