Skip to content

Commit

Permalink
Add FlexVerUtil & Fix ReIndev Version Compare
Browse files Browse the repository at this point in the history
  • Loading branch information
halotroop2288 committed Aug 1, 2024
1 parent f66a560 commit 2cdef6a
Show file tree
Hide file tree
Showing 5 changed files with 357 additions and 6 deletions.
168 changes: 168 additions & 0 deletions src/api/kotlin/xyz/wagyourtail/unimined/util/FlexVerUtil.kt
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import org.gradle.api.Project
import xyz.wagyourtail.unimined.api.unimined
import xyz.wagyourtail.unimined.internal.minecraft.MinecraftProvider
import xyz.wagyourtail.unimined.internal.minecraft.patch.AbstractMinecraftTransformer
import xyz.wagyourtail.unimined.internal.minecraft.resolver.Library
import xyz.wagyourtail.unimined.util.SemVerUtils
import xyz.wagyourtail.unimined.util.compareFlexVer

abstract class AbstractReIndevTransformer(
project: Project,
Expand All @@ -17,9 +16,6 @@ abstract class AbstractReIndevTransformer(
project.unimined.fox2codeMaven()
}

/**
* Strips the revision number before comparing the version. Technically only the major and minor is needed.
*/
override var canCombine: Boolean = SemVerUtils.matches(provider.version.replace(Regex("_[0-9]*$"), ""), ">=2.9")
override var canCombine: Boolean = provider.version.compareFlexVer("2.9") >= 0

}
116 changes: 116 additions & 0 deletions src/test/kotlin/xyz/wagyourtail/unimined/util/FlexVerTest.kt
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
}
}
}
}
4 changes: 4 additions & 0 deletions src/test/resources/large.txt
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
67 changes: 67 additions & 0 deletions src/test/resources/test_vectors.txt
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

0 comments on commit 2cdef6a

Please sign in to comment.