diff --git a/util/src/main/kotlin/org/openrs2/util/Base37.kt b/util/src/main/kotlin/org/openrs2/util/Base37.kt new file mode 100644 index 00000000..3bfed311 --- /dev/null +++ b/util/src/main/kotlin/org/openrs2/util/Base37.kt @@ -0,0 +1,89 @@ +package org.openrs2.util + +public object Base37 { + private const val MAX_LENGTH: Int = 12 + + private val FIRST_VALID_NAME = encode("") + private val LAST_VALID_NAME = encode("999999999999") + + private val DECODE_TABLE = CharArray(37) { i -> + when (i) { + 0 -> '_' + in 1..26 -> 'a' + (i - 1) + else -> '0' + (i - 27) + } + } + + public fun encode(s: String): Long { + // casting to CharSequence avoids a copy + val trimmed = (s as CharSequence).trim { it == ' ' || it == '_' } + require(trimmed.length <= MAX_LENGTH) + + var n = 0L + + for (c in trimmed) { + n *= 37 + + when (c) { + in 'A'..'Z' -> n += 1 + (c - 'A') + in 'a'..'z' -> n += 1 + (c - 'a') + in '0'..'9' -> n += 27 + (c - '0') + ' ', '_' -> Unit + else -> throw IllegalArgumentException() + } + } + + return n + } + + public fun decodeLowerCase(n: Long): String { + require(n in FIRST_VALID_NAME..LAST_VALID_NAME) + require(n == 0L || n % 37 != 0L) + + val chars = CharArray(MAX_LENGTH) + var len = 0 + var temp = n + + while (temp != 0L) { + chars[len++] = DECODE_TABLE[(temp % 37).toInt()] + temp /= 37 + } + + chars.reverse(0, len) + return String(chars, 0, len) + } + + public fun decodeTitleCase(n: Long): String { + require(n in FIRST_VALID_NAME..LAST_VALID_NAME) + require(n == 0L || n % 37 != 0L) + + val chars = CharArray(MAX_LENGTH) + var len = 0 + var temp = n + + while (temp != 0L) { + var c = DECODE_TABLE[(temp % 37).toInt()] + temp /= 37 + + if (c == '_') { + c = ' ' + chars[len - 1] = chars[len - 1].uppercaseChar() + } + + chars[len++] = c + } + + chars.reverse(0, len) + chars[0] = chars[0].uppercaseChar() + + return String(chars, 0, len) + } + + public fun toLowerCase(s: String): String { + return decodeLowerCase(encode(s)) + } + + public fun toTitleCase(s: String): String { + return decodeTitleCase(encode(s)) + } +} diff --git a/util/src/test/kotlin/org/openrs2/util/Base37Test.kt b/util/src/test/kotlin/org/openrs2/util/Base37Test.kt new file mode 100644 index 00000000..b0a70a42 --- /dev/null +++ b/util/src/test/kotlin/org/openrs2/util/Base37Test.kt @@ -0,0 +1,120 @@ +package org.openrs2.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class Base37Test { + @Test + fun testEncodeBounds() { + assertEquals(0, Base37.encode("")) + assertEquals(1, Base37.encode("a")) + assertEquals(1, Base37.encode("A")) + assertEquals(6582952005840035279, Base37.encode("999999999998")) + assertEquals(6582952005840035280, Base37.encode("999999999999")) + + assertFailsWith { + Base37.encode("aaaaaaaaaaaaa") + } + } + + @Test + fun testEncodeBoundsWhitespace() { + assertEquals(6582952005840035280, Base37.encode("999999999999 ")) + assertEquals(6582952005840035280, Base37.encode(" 999999999999")) + assertEquals(6582952005840035280, Base37.encode(" 999999999999 ")) + + assertFailsWith { + Base37.encode("aaaaaaaaaaaaa ") + } + + assertFailsWith { + Base37.encode(" aaaaaaaaaaaaa") + } + + assertFailsWith { + Base37.encode(" aaaaaaaaaaaaa ") + } + } + + @Test + fun testDecodeLowerCaseBounds() { + assertFailsWith { + Base37.decodeLowerCase(-1) + } + + assertEquals("", Base37.decodeLowerCase(0)) + assertEquals("a", Base37.decodeLowerCase(1)) + assertEquals("999999999998", Base37.decodeLowerCase(6582952005840035279)) + assertEquals("999999999999", Base37.decodeLowerCase(6582952005840035280)) + + assertFailsWith { + Base37.decodeLowerCase(6582952005840035281) + } + } + + @Test + fun testDecodeTitleCaseBounds() { + assertFailsWith { + Base37.decodeTitleCase(-1) + } + + assertEquals("", Base37.decodeTitleCase(0)) + assertEquals("A", Base37.decodeTitleCase(1)) + assertEquals("999999999998", Base37.decodeTitleCase(6582952005840035279)) + assertEquals("999999999999", Base37.decodeTitleCase(6582952005840035280)) + + assertFailsWith { + Base37.decodeTitleCase(6582952005840035281) + } + } + + @Test + fun testEncodeInvalidChar() { + assertFailsWith { + Base37.encode("!") + } + } + + @Test + fun testEncodeWhitespace() { + assertEquals(1465402762952, Base37.encode("Open Rs2")) + assertEquals(1465402762952, Base37.encode("open_rs2")) + assertEquals(1465402762952, Base37.encode(" Open Rs2 ")) + assertEquals(1465402762952, Base37.encode("_Open Rs2_")) + } + + @Test + fun testDecodeLowerCaseWhitespace() { + assertEquals("open_rs2", Base37.decodeLowerCase(1465402762952)) + } + + @Test + fun testDecodeTitleCaseWhitespace() { + assertEquals("Open Rs2", Base37.decodeTitleCase(1465402762952)) + } + + @Test + fun testDecodeLowerCaseTrailingWhitespace() { + assertFailsWith { + Base37.decodeLowerCase(54219902229224) + } + } + + @Test + fun testDecodeTitleCaseTrailingWhitespace() { + assertFailsWith { + Base37.decodeTitleCase(54219902229224) + } + } + + @Test + fun testToLowerCase() { + assertEquals("open_rs2", Base37.toLowerCase(" OpEn rS2_")) + } + + @Test + fun testToTitleCase() { + assertEquals("Open Rs2", Base37.toTitleCase(" OpEn rS2_")) + } +}