Fix case-insensitive character-wise comparison KT-45496

- Step 1: add failing tests
- Step 2: fix common case insensitive Char.equals
- Step 3: fix case insensitive String.equals in K/JS
- Step 4: enable unicode case folding in K/JS Regexes and
  string replacement (KT-45928)
- Step 5: fix case insensitive char comparison in K/N in String functions
  String.replace, equals, compareTo with ignoreCase
This commit is contained in:
Ilya Gorbunov
2021-04-05 11:46:39 +03:00
parent fc8b75be80
commit 22ca412dd2
6 changed files with 76 additions and 32 deletions
@@ -63,13 +63,16 @@ public actual fun String.replace(oldChar: Char, newChar: Char, ignoreCase: Boole
private external fun String.replace(oldChar: Char, newChar: Char): String
private fun String.replaceIgnoreCase(oldChar: Char, newChar: Char): String {
val charArray = CharArray(length)
val oldCharLower = oldChar.lowercaseChar()
val charArray = this.toCharArray()
val oldCharUpper = oldChar.uppercaseChar()
val oldCharLower = oldCharUpper.lowercaseChar()
for (index in 0 until length) {
val thisChar = this[index]
val thisCharLower = thisChar.lowercaseChar()
charArray[index] = if (thisCharLower == oldCharLower) newChar else thisChar
if (thisChar != oldChar && thisChar.uppercaseChar().let { it != oldCharUpper && it.lowercaseChar() != oldCharLower }) {
continue
}
charArray[index] = newChar
}
return charArray.concatToString()
@@ -186,9 +189,9 @@ private external fun String.unsafeRangeEquals(thisOffset: Int, other: String, ot
// Bounds must be checked before calling this method
private fun String.unsafeRangeEqualsIgnoreCase(thisOffset: Int, other: String, otherOffset: Int, length: Int): Boolean {
for (index in 0 until length) {
val thisCharLower = this[thisOffset + index].lowercaseChar()
val otherCharLower = other[otherOffset + index].lowercaseChar()
if (thisCharLower != otherCharLower) {
val thisCharUpper = this[thisOffset + index].uppercaseChar()
val otherCharUpper = other[otherOffset + index].uppercaseChar()
if (thisCharUpper != otherCharUpper && thisCharUpper.lowercaseChar() != otherCharUpper.lowercaseChar()) {
return false
}
}
@@ -429,8 +432,8 @@ internal fun compareToIgnoreCase(thiz: String, other: String): Int {
val length = minOf(thiz.length, other.length)
for (index in 0 until length) {
val thisLowerChar = thiz[index].lowercaseChar()
val otherLowerChar = other[index].lowercaseChar()
val thisLowerChar = thiz[index].uppercaseChar().lowercaseChar()
val otherLowerChar = other[index].uppercaseChar().lowercaseChar()
if (thisLowerChar != otherLowerChar) {
return if (thisLowerChar < otherLowerChar) -1 else 1
}
+3 -3
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2010-2018 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Copyright 2010-2021 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
@@ -51,7 +51,7 @@ public actual class Regex actual constructor(pattern: String, options: Set<Regex
public actual val pattern: String = pattern
/** The set of options that were used to create this regular expression. */
public actual val options: Set<RegexOption> = options.toSet()
private val nativePattern: RegExp = RegExp(pattern, options.map { it.value }.joinToString(separator = "") + "g")
private val nativePattern: RegExp = RegExp(pattern, options.joinToString(separator = "", prefix = "gu") { it.value })
/** Indicates whether the regular expression matches the entire [input]. */
public actual infix fun matches(input: CharSequence): Boolean {
@@ -200,7 +200,7 @@ public actual class Regex actual constructor(pattern: String, options: Set<Regex
*/
public actual fun escapeReplacement(literal: String): String = literal.nativeReplace(replacementEscape, "$$$$")
private val patternEscape = RegExp("""[-\\^$*+?.()|[\]{}]""", "g")
private val patternEscape = RegExp("""[\\^$*+?.()|[\]{}]""", "g")
private val replacementEscape = RegExp("""\$""", "g")
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Copyright 2010-2021 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
@@ -57,12 +57,16 @@ public actual fun CharSequence.isBlank(): Boolean = length == 0 || (if (this is
@OptIn(ExperimentalStdlibApi::class)
@Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS")
public actual fun String?.equals(other: String?, ignoreCase: Boolean = false): Boolean =
if (this == null)
other == null
else if (!ignoreCase)
this == other
else
other != null && this.lowercase() == other.lowercase()
when {
this == null -> other == null
!ignoreCase -> this == other
other == null -> false
else -> {
val thisLower = this.lowercase()
val otherLower = other.lowercase()
thisLower == otherLower || (thisLower.uppercase() == otherLower.uppercase())
}
}
@Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS")
@@ -125,16 +129,16 @@ public actual fun CharSequence.repeat(n: Int): String {
@Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS")
public actual fun String.replace(oldValue: String, newValue: String, ignoreCase: Boolean = false): String =
nativeReplace(RegExp(Regex.escape(oldValue), if (ignoreCase) "gi" else "g"), Regex.escapeReplacement(newValue))
nativeReplace(RegExp(Regex.escape(oldValue), if (ignoreCase) "gui" else "gu"), Regex.escapeReplacement(newValue))
@Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS")
public actual fun String.replace(oldChar: Char, newChar: Char, ignoreCase: Boolean = false): String =
nativeReplace(RegExp(Regex.escape(oldChar.toString()), if (ignoreCase) "gi" else "g"), newChar.toString())
nativeReplace(RegExp(Regex.escape(oldChar.toString()), if (ignoreCase) "gui" else "gu"), newChar.toString())
@Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS")
public actual fun String.replaceFirst(oldValue: String, newValue: String, ignoreCase: Boolean = false): String =
nativeReplace(RegExp(Regex.escape(oldValue), if (ignoreCase) "i" else ""), Regex.escapeReplacement(newValue))
nativeReplace(RegExp(Regex.escape(oldValue), if (ignoreCase) "ui" else "u"), Regex.escapeReplacement(newValue))
@Suppress("ACTUAL_FUNCTION_WITH_DEFAULT_ARGUMENTS")
public actual fun String.replaceFirst(oldChar: Char, newChar: Char, ignoreCase: Boolean = false): String =
nativeReplace(RegExp(Regex.escape(oldChar.toString()), if (ignoreCase) "i" else ""), newChar.toString())
nativeReplace(RegExp(Regex.escape(oldChar.toString()), if (ignoreCase) "ui" else "u"), newChar.toString())
+4 -3
View File
@@ -230,9 +230,10 @@ public fun Char.equals(other: Char, ignoreCase: Boolean = false): Boolean {
if (this == other) return true
if (!ignoreCase) return false
if (this.uppercaseChar() == other.uppercaseChar()) return true
if (this.lowercaseChar() == other.lowercaseChar()) return true
return false
val thisUpper = this.uppercaseChar()
val otherUpper = other.uppercaseChar()
return thisUpper == otherUpper || thisUpper.lowercaseChar() == otherUpper.lowercaseChar()
}
/**
+25 -1
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Copyright 2010-2021 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
@@ -9,6 +9,16 @@ import kotlin.test.*
class CharTest {
companion object {
val equalIgnoreCaseGroups = listOf(
"Aa", "Zz", "üÜ", "öÖ", "äÄ",
"Kk", "Ssſ", "µΜμ", "ÅåÅ",
"DŽDždž", "LJLjlj", "NJNjnj", "DZDzdz", "ͅΙιι", "Ββϐ", "Εεϵ",
"Κκϰ", "Ππϖ", "Ρρϱ", "Σςσ", "Φφϕ", "ΩωΩ", "Ṡṡẛ",
"Θθϑϴ", "Iiİı",
)
}
@Test
fun charFromIntCode() {
val codes = listOf(48, 65, 122, 946, '+'.code, 'Ö'.code, 0xFFFC)
@@ -145,6 +155,20 @@ class CharTest {
testFails(100, radix = 110)
}
@Test
fun equalsIgnoreCase() {
val nonEqual = equalIgnoreCaseGroups.flatMap { allEqualChars ->
allEqualChars.flatMap { c1 -> allEqualChars.mapNotNull { c2 ->
if (!c1.equals(c2, ignoreCase = true)) "$c1 != $c2" else null
}
}
}
if (nonEqual.isNotEmpty()) {
fail("Expected chars to be equal ignoring case:\n${nonEqual.joinToString("\n")}")
}
}
private fun charToCategory() = mapOf(
'\u0378' to "Cn",
'A' to "Lu", // \u0041
+17 -5
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2010-2020 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Copyright 2010-2021 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
@@ -847,7 +847,7 @@ class StringTest {
val result = v1.compareTo(v2, ignoreCase = ignoreCase).sign
assertEquals(expectedResult, result, "Comparing '$v1' with '$v2', ignoreCase = $ignoreCase")
if (expectedResult == 0)
assertTrue(v1.equals(v2, ignoreCase = ignoreCase))
assertTrue(v1.equals(v2, ignoreCase = ignoreCase), "'$v1'=='$v2' should be consistent with ordering, ignoreCase = $ignoreCase")
if (!ignoreCase)
assertEquals(v1.compareTo(v2).sign, result)
}
@@ -886,6 +886,16 @@ class StringTest {
assertCompareResult(EQ, item1, item2, ignoreCase = true)
}
}
CharTest.equalIgnoreCaseGroups
.filterNot { "i" in it }
.forEach { equalGroup ->
for (char1 in equalGroup) {
for (char2 in equalGroup) {
assertCompareResult(EQ, char1.toString(), char2.toString(), ignoreCase = true)
}
}
}
}
@@ -915,12 +925,14 @@ class StringTest {
assertEquals(expectAllReplaced, chars.replace(c, '_', ignoreCase = true), "$message, ignoreCase")
assertEquals(expectOneReplaced, chars.replace(c.toString(), "_"), "$message, as string")
assertEquals(expectAllReplaced, chars.replace(c.toString(), "_", ignoreCase = true), "$message, as string, ignoreCase")
assertEquals(expectOneReplaced, chars.replace(Regex(Regex.escape(c.toString())), "_"), "$message, as regex")
assertEquals(expectAllReplaced, chars.replace(Regex(Regex.escape(c.toString()), RegexOption.IGNORE_CASE), "_"), "$message, as regex, ignoreCase")
}
}
testIgnoreCase("üÜ")
testIgnoreCase("öÖ")
testIgnoreCase("äÄ")
CharTest.equalIgnoreCaseGroups
.filterNot { "i" in it } // not supported by JS
.forEach { testIgnoreCase(it) }
}
@Test fun replaceFirst() {