diff --git a/backend/mirai-console/src/main/kotlin/net/mamoe/mirai/console/internal/util/SemVersionInternal.kt b/backend/mirai-console/src/main/kotlin/net/mamoe/mirai/console/internal/util/SemVersionInternal.kt new file mode 100644 index 000000000..e09fd87cc --- /dev/null +++ b/backend/mirai-console/src/main/kotlin/net/mamoe/mirai/console/internal/util/SemVersionInternal.kt @@ -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 + */ + +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 + } +} \ No newline at end of file diff --git a/backend/mirai-console/src/main/kotlin/net/mamoe/mirai/console/util/SemVersion.kt b/backend/mirai-console/src/main/kotlin/net/mamoe/mirai/console/util/SemVersion.kt new file mode 100644 index 000000000..02a6f9587 --- /dev/null +++ b/backend/mirai-console/src/main/kotlin/net/mamoe/mirai/console/util/SemVersion.kt @@ -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 + */ + +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 { + /** + * 一条依赖规则 + * @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) } + } +} diff --git a/backend/mirai-console/src/test/kotlin/net/mamoe/mirai/console/util/TestSemVersion.kt b/backend/mirai-console/src/test/kotlin/net/mamoe/mirai/console/util/TestSemVersion.kt new file mode 100644 index 000000000..36923773b --- /dev/null +++ b/backend/mirai-console/src/test/kotlin/net/mamoe/mirai/console/util/TestSemVersion.kt @@ -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 + */ + +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() + } +} \ No newline at end of file