-
-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add FlexVerUtil & Fix ReIndev Version Compare
- Loading branch information
1 parent
f66a560
commit 2cdef6a
Showing
5 changed files
with
357 additions
and
6 deletions.
There are no files selected for viewing
168 changes: 168 additions & 0 deletions
168
src/api/kotlin/xyz/wagyourtail/unimined/util/FlexVerUtil.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,168 @@ | ||
package xyz.wagyourtail.unimined.util | ||
|
||
import org.jetbrains.annotations.VisibleForTesting | ||
import java.util.* | ||
import kotlin.math.max | ||
import kotlin.math.min | ||
|
||
/** | ||
* Parse the given strings as freeform version strings, and compare them according to FlexVer. | ||
* @param b the second version string | ||
* @return `0` if the two versions are equal, a negative number if `a < b`, or a positive number if `a > b` | ||
*/ | ||
fun String.compareFlexVer(b: String): Int { | ||
return FlexVerComparator.compare(this, b) | ||
} | ||
|
||
/** | ||
* Implements FlexVer, a SemVer-compatible intuitive comparator for free-form versioning strings as | ||
* seen in the wild. It's designed to sort versions like people do, rather than attempting to force | ||
* conformance to a rigid and limited standard. As such, it imposes no restrictions. Comparing two | ||
* versions with differing formats will likely produce nonsensical results (garbage in, garbage out), | ||
* but best effort is made to correct for basic structural changes, and versions of differing length | ||
* will be parsed in a logical fashion. | ||
* | ||
* @author Unascribed | ||
*/ | ||
object FlexVerComparator { | ||
/** | ||
* Parse the given strings as freeform version strings, and compare them according to FlexVer. | ||
* | ||
* @param a the first version string | ||
* @param b the second version string | ||
* @return `0` if the two versions are equal, a negative number if `a < b`, or a positive number if `a > b` | ||
*/ | ||
fun compare(a: String, b: String): Int { | ||
val ad = decompose(a) | ||
val bd = decompose(b) | ||
for (i in 0 until max(ad.size.toDouble(), bd.size.toDouble()).toInt()) { | ||
val c = get(ad, i).compareTo(get(bd, i)) | ||
if (c != 0) return c | ||
} | ||
return 0 | ||
} | ||
|
||
|
||
private val NULL: VersionComponent = object : VersionComponent(IntArray(0)) { | ||
override fun compareTo(that: VersionComponent): Int { | ||
return if (that === this) 0 else -that.compareTo(this) | ||
} | ||
} | ||
|
||
/** | ||
* Break apart a string into intuitive version components, | ||
* by splitting it where a run of characters changes from numeric to non-numeric. | ||
* | ||
* @param str the version String | ||
*/ | ||
@VisibleForTesting | ||
fun decompose(str: String): List<VersionComponent> { | ||
if (str.isEmpty()) return emptyList() | ||
var lastWasNumber = isAsciiDigit(str.codePointAt(0)) | ||
val totalCodepoints = str.codePointCount(0, str.length) | ||
val accum = IntArray(totalCodepoints) | ||
val out: MutableList<VersionComponent> = ArrayList() | ||
var j = 0 | ||
var i = 0 | ||
while (i < str.length) { | ||
val cp = str.codePointAt(i) | ||
if (Character.charCount(cp) == 2) i++ | ||
if (cp == '+'.code) break // remove appendices | ||
|
||
val number = isAsciiDigit(cp) | ||
if (number != lastWasNumber || (cp == '-'.code && j > 0 && accum[0] != '-'.code)) { | ||
out.add(createComponent(lastWasNumber, accum, j)) | ||
j = 0 | ||
lastWasNumber = number | ||
} | ||
accum[j] = cp | ||
j++ | ||
i++ | ||
} | ||
out.add(createComponent(lastWasNumber, accum, j)) | ||
return out | ||
} | ||
|
||
private fun isAsciiDigit(cp: Int): Boolean { | ||
return cp >= '0'.code && cp <= '9'.code | ||
} | ||
|
||
private fun createComponent(number: Boolean, s: IntArray, j: Int): VersionComponent { | ||
var s = s | ||
s = Arrays.copyOfRange(s, 0, j) | ||
return if (number) { | ||
NumericVersionComponent(s) | ||
} else if (s.size > 1 && s[0] == '-'.code) { | ||
SemVerPrereleaseVersionComponent(s) | ||
} else { | ||
VersionComponent(s) | ||
} | ||
} | ||
|
||
private fun get(li: List<VersionComponent>, i: Int): VersionComponent { | ||
return if (i >= li.size) NULL else li[i] | ||
} | ||
|
||
@VisibleForTesting | ||
open class VersionComponent(private val codepoints: IntArray) { | ||
fun codepoints(): IntArray { | ||
return codepoints | ||
} | ||
|
||
open fun compareTo(that: VersionComponent): Int { | ||
if (that === NULL) return 1 | ||
val a = this.codepoints() | ||
val b = that.codepoints() | ||
|
||
for (i in 0 until min(a.size.toDouble(), b.size.toDouble()).toInt()) { | ||
val c1 = a[i] | ||
val c2 = b[i] | ||
if (c1 != c2) return c1 - c2 | ||
} | ||
|
||
return a.size - b.size | ||
} | ||
|
||
override fun toString(): String { | ||
return String(codepoints, 0, codepoints.size) | ||
} | ||
} | ||
|
||
@VisibleForTesting | ||
class SemVerPrereleaseVersionComponent(codepoints: IntArray) : VersionComponent(codepoints) { | ||
override fun compareTo(that: VersionComponent): Int { | ||
if (that === NULL) return -1 // opposite order | ||
|
||
return super.compareTo(that) | ||
} | ||
} | ||
|
||
@VisibleForTesting | ||
class NumericVersionComponent(codepoints: IntArray) : VersionComponent(codepoints) { | ||
override fun compareTo(that: VersionComponent): Int { | ||
if (that === NULL) return 1 | ||
if (that is NumericVersionComponent) { | ||
val a = removeLeadingZeroes(this.codepoints()) | ||
val b = removeLeadingZeroes(that.codepoints()) | ||
if (a.size != b.size) return a.size - b.size | ||
for (i in a.indices) { | ||
val ad = a[i] | ||
val bd = b[i] | ||
if (ad != bd) return ad - bd | ||
} | ||
return 0 | ||
} | ||
return super.compareTo(that) | ||
} | ||
|
||
private fun removeLeadingZeroes(a: IntArray): IntArray { | ||
if (a.size == 1) return a | ||
var i = 0 | ||
val stopIdx = a.size - 1 | ||
while (i < stopIdx && a[i] == '0'.code) { | ||
i++ | ||
} | ||
return Arrays.copyOfRange(a, i, a.size) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
116 changes: 116 additions & 0 deletions
116
src/test/kotlin/xyz/wagyourtail/unimined/util/FlexVerTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
package xyz.wagyourtail.unimined.util | ||
|
||
import org.junit.jupiter.api.TestInstance | ||
import org.junit.jupiter.params.ParameterizedTest | ||
import org.junit.jupiter.params.provider.Arguments | ||
import org.junit.jupiter.params.provider.MethodSource | ||
import xyz.wagyourtail.unimined.util.FlexVerComparator.compare | ||
import xyz.wagyourtail.unimined.util.FlexVerComparator.decompose | ||
import java.io.File | ||
import java.io.IOException | ||
import java.nio.file.Files | ||
import java.nio.file.Path | ||
import java.util.* | ||
import java.util.stream.Stream | ||
import kotlin.NoSuchElementException | ||
import kotlin.collections.ArrayList | ||
import kotlin.io.path.toPath | ||
import kotlin.test.assertEquals | ||
|
||
|
||
@TestInstance(TestInstance.Lifecycle.PER_CLASS) | ||
class FlexVerTest { | ||
companion object { | ||
val ENABLED_TESTS: Array<String> = arrayOf("test_vectors.txt", "large.txt") | ||
} | ||
|
||
@ParameterizedTest(name = "{0} {2} {1}") | ||
@MethodSource("getEqualityTests") | ||
fun testEquality(a: String, b: String, expected: Ordering) { | ||
println((formatDecomposedString(a) + " " + expected) + " " + formatDecomposedString(b)) | ||
|
||
val c: Ordering = Ordering.fromComparison(compare(a, b)) | ||
val c2: Ordering = Ordering.fromComparison(compare(b, a)) | ||
|
||
|
||
// When inverting the operands we're comparing, the ordering should be inverted too | ||
assertEquals(c2.invert(), c, "Comparison method violates its general contract! ($a <=> $b is not commutative)") | ||
|
||
assertEquals(expected, c, "Ordering.fromComparison produced $a $c $b") | ||
} | ||
|
||
private fun formatDecomposedString(str: String): String { | ||
val sb = StringBuilder("[") | ||
for (c in decompose(str)) { | ||
val color: Int = when (c) { | ||
is FlexVerComparator.NumericVersionComponent -> 96 | ||
is FlexVerComparator.SemVerPrereleaseVersionComponent -> 93 | ||
else -> 95 | ||
} | ||
sb.append("\u001B[").append(color).append("m") | ||
sb.append(c.toString()) | ||
} | ||
if (str.contains("+")) { | ||
sb.append("\u001B[90m") | ||
sb.append(str.substring(str.indexOf('+'))) | ||
} | ||
sb.append("\u001B[0m]") | ||
return sb.toString() | ||
} | ||
|
||
@Throws(IOException::class) | ||
fun getEqualityTests(): Stream<Arguments> { | ||
val lines: MutableList<String> = ArrayList() | ||
|
||
for (test in ENABLED_TESTS) { | ||
lines.addAll(Files.readAllLines(this::class.java.getResource("/$test")?.toURI()?.toPath())) | ||
} | ||
|
||
return lines.stream() | ||
.filter { line: String -> !line.startsWith("#") } | ||
.filter { line: String -> line.isNotEmpty() } | ||
.map { line -> | ||
val split = line.split(" ".toRegex()).toTypedArray() | ||
require(split.size == 3) { "Line formatted incorrectly, expected 2 spaces: $line" } | ||
Arguments.of(split[0], split[2], Ordering.fromString(split[1])) | ||
} | ||
} | ||
} | ||
|
||
enum class Ordering(val charRepresentation: String) { | ||
LESS("<"), | ||
EQUAL("="), | ||
GREATER(">"); | ||
|
||
override fun toString(): String { | ||
return charRepresentation | ||
} | ||
|
||
fun invert(): Ordering { | ||
return when (this) { | ||
LESS -> GREATER | ||
EQUAL -> this | ||
GREATER -> LESS | ||
} | ||
} | ||
|
||
companion object { | ||
fun fromString(str: String): Ordering { | ||
return Arrays.stream(values()) | ||
.filter { ord -> ord.charRepresentation == str } | ||
.findFirst() | ||
.orElseThrow { NoSuchElementException("'$str' is not a valid ordering") } | ||
} | ||
|
||
/** | ||
* Converts an integer returned by a method like [FlexVerComparator.compare] to an [Ordering] | ||
*/ | ||
fun fromComparison(i: Int): Ordering { | ||
return when { | ||
i < 0 -> LESS | ||
i == 0 -> EQUAL | ||
else -> GREATER | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
# The tests in this file may be disabled by some implementations | ||
|
||
# Too large for a 64-bit integer or double, checks if codepoint-wise or integer-parse is being used | ||
36893488147419103232 < 36893488147419103233 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
# This test file is formatted as "<lefthand> <operator> <righthand>", seperated by the space character | ||
# Implementations should ignore lines starting with "#" and lines that have a length of 0 | ||
|
||
# Basic numeric ordering (lexical string sort fails these) | ||
10 > 2 | ||
100 > 10 | ||
|
||
# Trivial common numerics | ||
1.0 < 1.1 | ||
1.0 < 1.0.1 | ||
1.1 > 1.0.1 | ||
|
||
# SemVer compatibility | ||
1.5 > 1.5-pre1 | ||
1.5 = 1.5+foobar | ||
|
||
# SemVer incompatibility | ||
1.5 < 1.5-2 | ||
1.5-pre10 > 1.5-pre2 | ||
|
||
# Empty strings | ||
= | ||
1 > | ||
< 1 | ||
|
||
# Check boundary between textual and prerelease | ||
a-a < a | ||
|
||
# Check boundary between textual and appendix | ||
a+a = a | ||
|
||
# Dash is included in prerelease comparison (if stripped it will be a smaller component) | ||
# Note that a-a < a=a regardless since the prerelease splits the component creating a smaller first component; 0 is added to force splitting regardless | ||
a0-a < a0=a | ||
|
||
# Pre-releases must contain only non-digit | ||
1.16.5-10 > 1.16.5 | ||
|
||
# Pre-releases can have multiple dashes (should not be split) | ||
# Reasoning for test data: "p-a!" > "p-a-" (correct); "p-a!" < "p-a t-" (what happens if every dash creates a new component) | ||
-a- > -a! | ||
|
||
# Misc | ||
b1.7.3 > a1.2.6 | ||
b1.2.6 > a1.7.3 | ||
a1.1.2 < a1.1.2_01 | ||
1.16.5-0.00.5 > 1.14.2-1.3.7 | ||
1.0.0 < 1.0.0_01 | ||
1.0.1 > 1.0.0_01 | ||
1.0.0_01 < 1.0.1 | ||
0.17.1-beta.1 < 0.17.1 | ||
0.17.1-beta.1 < 0.17.1-beta.2 | ||
1.4.5_01 = 1.4.5_01+fabric-1.17 | ||
1.4.5_01 = 1.4.5_01+fabric-1.17+ohgod | ||
14w16a < 18w40b | ||
18w40a < 18w40b | ||
1.4.5_01+fabric-1.17 < 18w40b | ||
13w02a < c0.3.0_01 | ||
0.6.0-1.18.x < 0.9.beta-1.18.x | ||
|
||
# removeLeadingZeroes (#17) | ||
0000.0.0 = 0.0.0 | ||
0000.00.0 = 0.00.0 | ||
0.0.0 = 0.00.0000 | ||
# General leading zeroes | ||
1.0.01 = 1.0.1 | ||
1.0.0001 = 1.0.01 |