Sem Version (#164)

* Sem Version

* Review: Add missing logic

* Review code

- Removed @JvmField
- Comments
- Fix Compare Logic
- Add tests from SemVer.org

* Deleted redundant statement

* Rename RangeChecker to RangeRequirement

* Code Review

- Move SemVersion#compareTo to SemVersionInternal#compareInternal
- Move top-level functions to companion object
- Make SemVersion comparable

* KDoc

* Update KDoc; fix parseRangeRequirement

* Update KDoc

* Update comment

* Update KDoc

* Update KDoc

* Typo

* Typo
This commit is contained in:
Karlatemp 2020-09-17 20:52:57 +08:00 committed by GitHub
parent 466b067d9f
commit dc81835b68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 579 additions and 0 deletions

View File

@ -0,0 +1,224 @@
/*
* Copyright 2019-2020 Mamoe Technologies and contributors.
*
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
* Use of this source code is governed by the GNU AFFERO GENERAL PUBLIC LICENSE version 3 license that can be found via the following link.
*
* https://github.com/mamoe/mirai/blob/master/LICENSE
*
*/
/*
* @author Karlatemp <karlatemp@vip.qq.com> <https://github.com/Karlatemp>
*/
package net.mamoe.mirai.console.internal.util
import net.mamoe.mirai.console.util.SemVersion
import kotlin.math.max
import kotlin.math.min
@Suppress("RegExpRedundantEscape")
internal object SemVersionInternal {
private val directVersion = """^[0-9]+(\.[0-9]+)+(|[\-+].+)$""".toRegex()
private val versionSelect = """^[0-9]+(\.[0-9]+)*\.x$""".toRegex()
private val versionRange = """([0-9]+(\.[0-9]+)+(|[\-+].+))\s*\-\s*([0-9]+(\.[0-9]+)+(|[\-+].+))""".toRegex()
private val versionMathRange =
"""\[([0-9]+(\.[0-9]+)+(|[\-+].+))\s*\,\s*([0-9]+(\.[0-9]+)+(|[\-+].+))\]""".toRegex()
private val versionRule = """^((\>\=)|(\<\=)|(\=)|(\>)|(\<))\s*([0-9]+(\.[0-9]+)+(|[\-+].+))$""".toRegex()
private fun Collection<*>.dump() {
forEachIndexed { index, value ->
println("$index, $value")
}
}
@JvmStatic
private fun String.parseRule(): SemVersion.RangeRequirement {
val trimmed = trim()
if (directVersion.matches(trimmed)) {
val parsed = SemVersion.parse(trimmed)
return SemVersion.RangeRequirement {
it.compareTo(parsed) == 0
}
}
if (versionSelect.matches(trimmed)) {
val regex = ("^" +
trimmed.replace(".", "\\.")
.replace("x", ".+") +
"$"
).toRegex()
return SemVersion.RangeRequirement {
regex.matches(it.toString())
}
}
(versionRange.matchEntire(trimmed) ?: versionMathRange.matchEntire(trimmed))?.let { range ->
var start = SemVersion.parse(range.groupValues[1])
var end = SemVersion.parse(range.groupValues[4])
if (start > end) {
val c = end
end = start
start = c
}
val compareRange = start..end
return SemVersion.RangeRequirement {
it in compareRange
}
}
versionRule.matchEntire(trimmed)?.let { result ->
val operator = result.groupValues[1]
val version = SemVersion.parse(result.groupValues[7])
return when (operator) {
">=" -> {
SemVersion.RangeRequirement { it >= version }
}
">" -> {
SemVersion.RangeRequirement { it > version }
}
"<=" -> {
SemVersion.RangeRequirement { it <= version }
}
"<" -> {
SemVersion.RangeRequirement { it < version }
}
"=" -> {
SemVersion.RangeRequirement { it.compareTo(version) == 0 }
}
else -> throw AssertionError("operator=$operator, version=$version")
}
}
throw UnsupportedOperationException("Cannot parse $this")
}
private fun SemVersion.RangeRequirement.withRule(rule: String): SemVersion.RangeRequirement {
return object : SemVersion.RangeRequirement {
override fun check(version: SemVersion): Boolean {
return this@withRule.check(version)
}
override fun toString(): String {
return rule
}
}
}
@JvmStatic
fun parseRangeRequirement(requirement: String): SemVersion.RangeRequirement {
if (requirement.isBlank()) {
throw IllegalArgumentException("Invalid requirement: Empty requirement rule.")
}
return requirement.split("||").map {
it.parseRule().withRule(it)
}.let { checks ->
if (checks.size == 1) return checks[0]
SemVersion.RangeRequirement {
checks.forEach { rule ->
if (rule.check(it)) return@RangeRequirement true
}
return@RangeRequirement false
}.withRule(requirement)
}
}
@JvmStatic
fun SemVersion.compareInternal(other: SemVersion): Int {
// ignored metadata in comparing
// If $this equals $other (without metadata),
// return same.
if (other.mainVersion.contentEquals(mainVersion) && identifier == other.identifier) {
return 0
}
fun IntArray.getSafe(index: Int) = getOrElse(index) { 0 }
// Compare main-version
for (index in 0 until (max(mainVersion.size, other.mainVersion.size))) {
val result = mainVersion.getSafe(index).compareTo(other.mainVersion.getSafe(index))
if (result != 0) return result
}
// If main-versions are same.
var identifier0 = identifier
var identifier1 = other.identifier
// If anyone doesn't have the identifier...
if (identifier0 == null || identifier1 == null) {
return when (identifier0) {
identifier1 -> { // null == null
// Nobody has identifier
0
}
null -> {
// $other has identifier, but $this don't have identifier
// E.g:
// this = 1.0.0
// other = 1.0.0-dev
1
}
// It is the opposite of the above.
else -> -1
}
}
fun String.getSafe(index: Int) = getOrElse(index) { ' ' }
// ignored same prefix
fun getSameSize(s1: String, s2: String): Int {
val size = min(s1.length, s2.length)
// 1.0-RC19 -> 19
// 1.0-RC107 -> 107
var realSameSize = 0
for (index in 0 until size) {
if (s1[index] != s2[index]) {
return realSameSize
} else {
if (!s1[index].isDigit()) {
realSameSize = index + 1
}
}
}
return realSameSize
}
// We ignore the same parts. Because we only care about the differences.
// E.g:
// 1.0-RC1 -> 1
// 1.0-RC2 -> 2
val ignoredSize = getSameSize(identifier0, identifier1)
identifier0 = identifier0.substring(ignoredSize)
identifier1 = identifier1.substring(ignoredSize)
// Multi-chunk comparing
val chunks0 = identifier0.split('-', '.', '_')
val chunks1 = identifier1.split('-', '.', '_')
chunkLoop@ for (index in 0 until (max(chunks0.size, chunks1.size))) {
val value0 = chunks0.getOrNull(index)
val value1 = chunks1.getOrNull(index)
// Any chunk is null
if (value0 == null || value1 == null) {
// value0 == null && value1 == null is impossible
return if (value0 == null) {
// E.g:
// value0 = 1.0-RC-dev
// value1 = 1.0-RC-dev-1
-1
} else {
// E.g:
// value0 = 1.0-RC-dev-1
// value1 = 1.0-RC-dev
1
}
}
try {
val result = value0.toInt().compareTo(value1.toInt())
if (result != 0) {
return result
}
continue@chunkLoop
} catch (ignored: NumberFormatException) {
}
// compare chars
for (index0 in 0 until (max(value0.length, value1.length))) {
val result = value0.getSafe(index0).compareTo(value1.getSafe(index0))
if (result != 0)
return result
}
}
return 0
}
}

View File

@ -0,0 +1,239 @@
/*
* Copyright 2019-2020 Mamoe Technologies and contributors.
*
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
* Use of this source code is governed by the GNU AFFERO GENERAL PUBLIC LICENSE version 3 license that can be found via the following link.
*
* https://github.com/mamoe/mirai/blob/master/LICENSE
*
*/
/*
* @author Karlatemp <karlatemp@vip.qq.com> <https://github.com/Karlatemp>
*/
package net.mamoe.mirai.console.util
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import net.mamoe.mirai.console.internal.util.SemVersionInternal
import net.mamoe.mirai.console.util.SemVersion.Companion.equals
/**
* 语义化版本支持
*
* 在阅读此文件前, 请先阅读 https://semver.org/lang/zh-CN/
* 该文档说明了语义化版本是什么, 此文件不再过多描述
*
* ----
*
* 这是一个例子 `1.0.0-M4+c25733b8`
*
* 将会解析出三个内容, mainVersion(核心版本号), identifier(先行版本号) metadata(元数据).
*
* 对这个例子进行解析会得到
* ```
* SemVersion(
* mainVersion = IntArray [1, 0, 0],
* identifier = "M4"
* metadata = "c25733b8"
* )
* ```
* 其中 identifier metadata 都是可选的, 该实现对于 mainVersion 的最大长度不作出限制,
* 也建议 mainVersion 的长度不要过长或过短
* 但是必须至少拥有两位及以上的版本描述符, (即必须拥有主版本号和次版本号).
*
* 比如 `1-M4` 是不合法的, 但是 `1.0-M4` 是合法的
*
*/
@Serializable
public data class SemVersion internal constructor(
/** 核心版本号, 至少包含一个主版本号和一个次版本号 */
public val mainVersion: IntArray,
/** 先行版本号识别符 */
public val identifier: String? = null,
/** 版本号元数据, 不参与版本号对比([compareTo]), 但是参与版本号严格对比([equals]) */
public val metadata: String? = null
) : Comparable<SemVersion> {
/**
* 一条依赖规则
* @see [parseRangeRequirement]
*/
public fun interface RangeRequirement {
/** 在 [version] 满足此要求时返回 true */
public fun check(version: SemVersion): Boolean
}
public companion object {
/** 解析核心版本号, eg: `1.0.0` -> IntArray[1, 0, 0] */
@JvmStatic
private fun String.parseMainVersion(): IntArray =
split('.').map { it.toInt() }.toIntArray()
/**
* 解析一个版本号, 将会返回一个 [SemVersion],
* 如果发生解析错误将会抛出一个 [IllegalArgumentException] 或者 [NumberFormatException]
*
* 对于版本号的组成, 我们有以下规定:
* - 必须包含主版本号和次版本号
* - 存在 先行版本号 的时候 先行版本号 不能为空
* - 存在 元数据 的时候 元数据 不能为空
*
* 注意情况:
* - 第一个 `+` 之后的所有内容全部识别为元数据
* - `1.0+METADATA-M4`, metadata="METADATA-M4"
*/
@Throws(IllegalArgumentException::class, NumberFormatException::class)
@JvmStatic
public fun parse(version: String): SemVersion {
var mainVersionEnd: Int = 0
kotlin.run {
val iterator = version.iterator()
while (iterator.hasNext()) {
val next = iterator.next()
if (next == '-' || next == '+') {
break
}
mainVersionEnd++
}
}
var identifier: String? = null
var metadata: String? = null
if (mainVersionEnd != version.length) {
when (version[mainVersionEnd]) {
'-' -> {
val metadataSplitter = version.indexOf('+', startIndex = mainVersionEnd)
if (metadataSplitter == -1) {
identifier = version.substring(mainVersionEnd + 1)
} else {
identifier = version.substring(mainVersionEnd + 1, metadataSplitter)
metadata = version.substring(metadataSplitter + 1)
}
}
'+' -> {
metadata = version.substring(mainVersionEnd + 1)
}
}
}
return SemVersion(
mainVersion = version.substring(0, mainVersionEnd).also { mainVersion ->
if (mainVersion.indexOf('.') == -1) {
throw IllegalArgumentException("$mainVersion must has more than one label")
}
if (mainVersion.last() == '.') {
throw IllegalArgumentException("Version string cannot end-with `.`")
}
}.parseMainVersion(),
identifier = identifier?.also {
if (it.isBlank()) {
throw IllegalArgumentException("The identifier cannot be blank.")
}
},
metadata = metadata?.also {
if (it.isBlank()) {
throw IllegalArgumentException("The metadata cannot be blank.")
}
}
)
}
/**
* 解析一条依赖需求描述, 在无法解析的时候抛出 [IllegalArgumentException]
*
* 对于一条规则, 有以下方式可选
*
* - `1.0.0-M4` 要求 1.0.0-M4 版本, 且只能是 1.0.0-M4 版本
* - `1.x` 要求 1.x 版本
* - `1.0.0 - 1.2.0` 要求 1.0.0 1.2.0 的任意版本, 注意 `-` 两边必须要有空格
* - `[1.0.0, 1.2.0]` `1.0.0 - 1.2.0` 一致
* - `> 1.0.0-RC` 要求 1.0.0-RC 之后的版本, 不能是 1.0.0-RC
* - `>= 1.0.0-RC` 要求 1.0.0-RC 或之后的版本, 可以是 1.0.0-RC
* - `< 1.0.0-RC` 要求 1.0.0-RC 之前的版本, 不能是 1.0.0-RC
* - `<= 1.0.0-RC` 要求 1.0.0-RC 或之前的版本, 可以是 1.0.0-RC
*
* 对于多个规则, 也允许使用 `||` 拼接在一起.
* 例如:
* - `1.x || 2.x || 3.0`
* - `<= 0.5.3 || >= 1.0.0`
*
* 特别注意:
* - 依赖规则版本号不需要携带版本号元数据, 元数据不参与依赖需求的检查
* - 如果目标版本号携带有先行版本号, 请不要忘记先行版本号
*/
@Throws(IllegalArgumentException::class)
@JvmStatic
public fun parseRangeRequirement(requirement: String): RangeRequirement {
return SemVersionInternal.parseRangeRequirement(requirement)
}
/** @see [RangeRequirement.check] */
@JvmStatic
public fun RangeRequirement.check(version: String): Boolean = check(parse(version))
/**
* 当满足 [requirement] 时返回 true, 否则返回 false
*/
@JvmStatic
public fun SemVersion.satisfies(requirement: RangeRequirement): Boolean = requirement.check(this)
/** for Kotlin only */
@JvmStatic
@JvmSynthetic
public operator fun RangeRequirement.contains(version: SemVersion): Boolean = check(version)
/** for Kotlin only */
@JvmStatic
@JvmSynthetic
public operator fun RangeRequirement.contains(version: String): Boolean = check(version)
}
@Transient
private var toString: String? = null // For cache.
override fun toString(): String {
return toString ?: kotlin.run {
buildString {
mainVersion.joinTo(this, ".")
identifier?.let { identifier ->
append('-')
append(identifier)
}
metadata?.let { metadata ->
append('+')
append(metadata)
}
}.also { toString = it }
}
}
/**
* [SemVersion] 转为 Kotlin data class 风格的 [String]
*/
public fun toStructuredString(): String {
return "SemVersion(mainVersion=${mainVersion.contentToString()}, identifier=$identifier, metadata=$metadata)"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SemVersion
return compareTo(other) == 0 && other.identifier == identifier && other.metadata == metadata
}
override fun hashCode(): Int {
var result = mainVersion.contentHashCode()
result = 31 * result + (identifier?.hashCode() ?: 0)
result = 31 * result + (metadata?.hashCode() ?: 0)
return result
}
/**
* Compares this object with the specified object for order. Returns zero if this object is equal
* to the specified [other] object, a negative number if it's less than [other], or a positive number
* if it's greater than [other].
*/
public override operator fun compareTo(other: SemVersion): Int {
return SemVersionInternal.run { compareInternal(other) }
}
}

View File

@ -0,0 +1,116 @@
/*
* Copyright 2019-2020 Mamoe Technologies and contributors.
*
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
* Use of this source code is governed by the GNU AFFERO GENERAL PUBLIC LICENSE version 3 license that can be found via the following link.
*
* https://github.com/mamoe/mirai/blob/master/LICENSE
*
*/
/*
* @author Karlatemp <karlatemp@vip.qq.com> <https://github.com/Karlatemp>
*/
package net.mamoe.mirai.console.util
import net.mamoe.mirai.console.util.SemVersion.Companion.check
import org.junit.jupiter.api.Test
internal class TestSemVersion {
@Test
internal fun testCompare() {
fun String.sem(): SemVersion = SemVersion.parse(this)
assert("1.0".sem() < "1.0.1".sem())
assert("1.0.0".sem() == "1.0".sem())
assert("1.1".sem() > "1.0.0.1".sem())
assert("1.0-M4".sem() < "1.0-M5".sem())
assert("1.0-M5-dev-7".sem() < "1.0-M5-dev-15".sem())
assert("1.0-M5-dev-79".sem() < "1.0-M5-dev-7001".sem())
assert("1.0-M6".sem() > "1.0-M5-dev-15".sem())
assert("1.0-RC".sem() > "1.0-M5-dev-15".sem())
assert("1.0-RC2".sem() > "1.0-RC".sem())
// example on semver
// 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0
assert("1.0.0-alpha".sem() < "1.0.0-alpha.1".sem())
assert("1.0.0-alpha.1".sem() < "1.0.0-alpha.beta".sem())
assert("1.0.0-alpha.beta".sem() < "1.0.0-beta".sem())
assert("1.0.0-beta".sem() < "1.0.0-beta.2".sem())
assert("1.0.0-beta.2".sem() < "1.0.0-beta.11".sem())
assert("1.0.0-beta.11".sem() < "1.0.0-rc.1".sem())
assert("1.0.0-rc.1".sem() < "1.0.0".sem())
}
@Test
internal fun testRequirement() {
fun SemVersion.RangeRequirement.assert(version: String): SemVersion.RangeRequirement {
assert(check(version)) { version }
return this
}
fun SemVersion.RangeRequirement.assertFalse(version: String): SemVersion.RangeRequirement {
assert(!check(version)) { version }
return this
}
SemVersion.parseRangeRequirement("1.0")
.assert("1.0").assert("1.0.0")
.assert("1.0.0.0")
.assertFalse("1.1.0").assertFalse("2.0.0")
SemVersion.parseRangeRequirement("1.x")
.assert("1.0").assert("1.1")
.assert("1.5").assert("1.14514")
.assertFalse("2.33")
SemVersion.parseRangeRequirement("2.0 || 1.2.x")
.assert("2.0").assert("2.0.0")
.assertFalse("2.1").assertFalse("2.0.0.1")
.assert("1.2.5").assert("1.2.0").assertFalse("1.2")
.assertFalse("1.0.0")
SemVersion.parseRangeRequirement("1.0.0 - 114.514.1919.810")
.assert("1.0.0")
.assert("114.514").assert("114.514.1919.810")
.assertFalse("0.0.1")
.assertFalse("4444.4444")
SemVersion.parseRangeRequirement("[1.0.0, 19190.0]")
.assert("1.0.0").assertFalse("0.1.0")
.assert("19190.0").assertFalse("19198.10")
SemVersion.parseRangeRequirement(" >= 1.0.0")
.assert("1.0.0")
.assert("114.514.1919.810")
.assertFalse("0.0.0")
.assertFalse("0.98774587")
SemVersion.parseRangeRequirement("> 1.0.0")
.assertFalse("1.0.0")
kotlin.runCatching { SemVersion.parseRangeRequirement("WPOXAXW") }
.onSuccess { assert(false) }
}
@Test
internal fun testSemVersionParsing() {
fun String.check() {
val sem = SemVersion.parse(this)
assert(this == sem.toString()) { "$this != $sem" }
}
fun String.checkInvalid() {
kotlin.runCatching { SemVersion.parse(this) }
.onSuccess { assert(false) { "$this not a invalid sem-version" } }
.onFailure { println("$this - $it") }
}
"0.0".check()
"1.0.0".check()
"1.2.3.4.5.6.7.8".check()
"5555.0-A".check()
"5555.0-A+METADATA".check()
"5555.0+METADATA".check()
"987.0+wwwxx-wk".check()
"NOT.NUMBER".checkInvalid()
"0".checkInvalid()
"".checkInvalid()
"1.".checkInvalid()
"0.1-".checkInvalid()
"1.9+".checkInvalid()
"5.1+68-7".check()
"5.1+68-".check()
}
}