From c3f5d03b36023d3523769e8b9c2cb981d6b3d7b5 Mon Sep 17 00:00:00 2001 From: Abduqodiri Qurbonzoda Date: Tue, 28 Sep 2021 02:20:22 +0300 Subject: [PATCH] [JS] Support named capture groups in Regex #KT-51775 --- libraries/stdlib/api/js-v1/kotlin.text.kt | 3 + libraries/stdlib/api/js/kotlin.text.kt | 3 + .../jdk8/src/kotlin/text/RegexExtensions.kt | 3 +- libraries/stdlib/js/src/kotlin/text/regex.kt | 110 ++++++++++++++---- libraries/stdlib/js/test/core/testUtils.kt | 3 +- libraries/stdlib/js/test/text/RegexJsTest.kt | 36 ++++++ .../stdlib/jvm/src/kotlin/text/regex/Regex.kt | 8 +- .../src/kotlin/text/regex/MatchResult.kt | 3 +- libraries/stdlib/test/text/RegexTest.kt | 81 ++++++++++++- 9 files changed, 217 insertions(+), 33 deletions(-) create mode 100644 libraries/stdlib/js/test/text/RegexJsTest.kt diff --git a/libraries/stdlib/api/js-v1/kotlin.text.kt b/libraries/stdlib/api/js-v1/kotlin.text.kt index 5a246737cdf..c58620f2339 100644 --- a/libraries/stdlib/api/js-v1/kotlin.text.kt +++ b/libraries/stdlib/api/js-v1/kotlin.text.kt @@ -330,6 +330,9 @@ public inline fun kotlin.CharSequence.forEach(action: (kotlin.Char) -> kotlin.Un public inline fun kotlin.CharSequence.forEachIndexed(action: (index: kotlin.Int, kotlin.Char) -> kotlin.Unit): kotlin.Unit +@kotlin.SinceKotlin(version = "1.7") +public operator fun kotlin.text.MatchGroupCollection.get(name: kotlin.String): kotlin.text.MatchGroup? + @kotlin.internal.InlineOnly public inline fun kotlin.CharSequence.getOrElse(index: kotlin.Int, defaultValue: (kotlin.Int) -> kotlin.Char): kotlin.Char diff --git a/libraries/stdlib/api/js/kotlin.text.kt b/libraries/stdlib/api/js/kotlin.text.kt index 5a246737cdf..c58620f2339 100644 --- a/libraries/stdlib/api/js/kotlin.text.kt +++ b/libraries/stdlib/api/js/kotlin.text.kt @@ -330,6 +330,9 @@ public inline fun kotlin.CharSequence.forEach(action: (kotlin.Char) -> kotlin.Un public inline fun kotlin.CharSequence.forEachIndexed(action: (index: kotlin.Int, kotlin.Char) -> kotlin.Unit): kotlin.Unit +@kotlin.SinceKotlin(version = "1.7") +public operator fun kotlin.text.MatchGroupCollection.get(name: kotlin.String): kotlin.text.MatchGroup? + @kotlin.internal.InlineOnly public inline fun kotlin.CharSequence.getOrElse(index: kotlin.Int, defaultValue: (kotlin.Int) -> kotlin.Char): kotlin.Char diff --git a/libraries/stdlib/jdk8/src/kotlin/text/RegexExtensions.kt b/libraries/stdlib/jdk8/src/kotlin/text/RegexExtensions.kt index 62c51fd875a..3809bb7f292 100644 --- a/libraries/stdlib/jdk8/src/kotlin/text/RegexExtensions.kt +++ b/libraries/stdlib/jdk8/src/kotlin/text/RegexExtensions.kt @@ -24,7 +24,8 @@ package kotlin.text * * @return An instance of [MatchGroup] if the group with the specified [name] was matched or `null` otherwise. * @throws IllegalArgumentException if there is no group with the specified [name] defined in the regex pattern. - * @throws UnsupportedOperationException if getting named groups isn't supported on the current platform. + * @throws UnsupportedOperationException if this match group collection doesn't support getting match groups by name, + * for example, when it's not supported by the current platform. */ @SinceKotlin("1.2") public operator fun MatchGroupCollection.get(name: String): MatchGroup? { diff --git a/libraries/stdlib/js/src/kotlin/text/regex.kt b/libraries/stdlib/js/src/kotlin/text/regex.kt index c9f282f0f8c..1506d74cd0b 100644 --- a/libraries/stdlib/js/src/kotlin/text/regex.kt +++ b/libraries/stdlib/js/src/kotlin/text/regex.kt @@ -31,6 +31,23 @@ private fun Iterable.toFlags(prepend: String): String = joinToStrin public actual data class MatchGroup(actual val value: String) +/** + * Returns a named group with the specified [name]. + * + * @return An instance of [MatchGroup] if the group with the specified [name] was matched or `null` otherwise. + * @throws IllegalArgumentException if there is no group with the specified [name] defined in the regex pattern. + * @throws UnsupportedOperationException if this match group collection doesn't support getting match groups by name, + * for example, when it's not supported by the current platform. + */ +@SinceKotlin("1.7") +public operator fun MatchGroupCollection.get(name: String): MatchGroup? { + val namedGroups = this as? MatchNamedGroupCollection + ?: throw UnsupportedOperationException("Retrieving groups by name is not supported on this platform.") + + return namedGroups[name] +} + + /** * Represents a compiled regular expression. * Provides functions to match strings in text with a pattern, replace the found occurrences and split text around matches. @@ -148,18 +165,17 @@ public actual class Regex actual constructor(pattern: String, options: Set() { + override val groups: MatchGroupCollection = object : MatchNamedGroupCollection, AbstractCollection() { override val size: Int get() = match.length override fun iterator(): Iterator = indices.asSequence().map { this[it] }.iterator() override fun get(index: Int): MatchGroup? = match[index]?.let { MatchGroup(it) } + + override fun get(name: String): MatchGroup? { + // An object of named capturing groups whose keys are the names and values are the capturing groups + // or undefined if no named capturing groups were defined. + val groups = match.asDynamic().groups + ?: throw IllegalArgumentException("Capturing group with name {$name} does not exist. No named capturing group was defined in Regex") + + // If the match was successful but the group specified failed to match any part of the input sequence, + // the associated value is 'undefined'. Value for a non-existent key is also 'undefined'. Thus, explicitly check if the key exists. + if (!hasOwnPrototypeProperty(groups, name)) + throw IllegalArgumentException("Capturing group with name {$name} does not exist") + + val value = groups[name] + return if (value == undefined) null else MatchGroup(value as String) + } + } + + private fun hasOwnPrototypeProperty(o: Any?, name: String): Boolean { + return js("Object").prototype.hasOwnProperty.call(o, name).unsafeCast() } @@ -379,7 +413,7 @@ private fun RegExp.findNext(input: String, from: Int, nextPattern: RegExp): Matc // The same code from K/N Regex.kt private fun substituteGroupRefs(match: MatchResult, replacement: String): String { var index = 0 - val result = StringBuilder(replacement.length) + val result = StringBuilder() while (index < replacement.length) { val char = replacement[index++] @@ -392,20 +426,32 @@ private fun substituteGroupRefs(match: MatchResult, replacement: String): String if (index == replacement.length) throw IllegalArgumentException("Capturing group index is missing") - if (replacement[index] == '{') - throw IllegalArgumentException("Named capturing group reference currently is not supported") + if (replacement[index] == '{') { + val endIndex = replacement.readGroupName(++index) - if (replacement[index] !in '0'..'9') - throw IllegalArgumentException("Invalid capturing group reference") + if (index == endIndex) + throw IllegalArgumentException("Named capturing group reference should have a non-empty name") + if (endIndex == replacement.length || replacement[endIndex] != '}') + throw IllegalArgumentException("Named capturing group reference is missing trailing '}'") - val endIndex = replacement.readGroupIndex(index, match.groupValues.size) - val groupIndex = replacement.substring(index, endIndex).toInt() + val groupName = replacement.substring(index, endIndex) - if (groupIndex >= match.groupValues.size) - throw IndexOutOfBoundsException("Group with index $groupIndex does not exist") + result.append(match.groups[groupName]?.value ?: "") + index = endIndex + 1 // skip past '}' + } else { + if (replacement[index] !in '0'..'9') + throw IllegalArgumentException("Invalid capturing group reference") - result.append(match.groupValues[groupIndex]) - index = endIndex + val groups = match.groups + val endIndex = replacement.readGroupIndex(index, groups.size) + val groupIndex = replacement.substring(index, endIndex).toInt() + + if (groupIndex >= groups.size) + throw IndexOutOfBoundsException("Group with index $groupIndex does not exist") + + result.append(groups[groupIndex]?.value ?: "") + index = endIndex + } } else { result.append(char) } @@ -413,6 +459,22 @@ private fun substituteGroupRefs(match: MatchResult, replacement: String): String return result.toString() } +// The name must be a legal JavaScript identifier. See https://262.ecma-international.org/5.1/#sec-7.6 +// Don't try to validate the referenced group name as it may be time-consuming. +// If the name is invalid, it won't be found in `match.groups` anyway and will throw. +// Group names in the target Regex are validated at creation time. +private fun String.readGroupName(startIndex: Int): Int { + var index = startIndex + while (index < length) { + if (this[index] == '}') { + break + } else { + index++ + } + } + return index +} + private fun String.readGroupIndex(startIndex: Int, groupCount: Int): Int { // at least one digit after '$' is always captured var index = startIndex + 1 diff --git a/libraries/stdlib/js/test/core/testUtils.kt b/libraries/stdlib/js/test/core/testUtils.kt index ae2aa84d243..8266e371fb1 100644 --- a/libraries/stdlib/js/test/core/testUtils.kt +++ b/libraries/stdlib/js/test/core/testUtils.kt @@ -27,7 +27,6 @@ public actual val isFloat32RangeEnforced: Boolean = false actual val supportsSuppressedExceptions: Boolean get() = true -// TODO: implement named group reference in replacement expression -public actual val supportsNamedCapturingGroup: Boolean get() = false +public actual val supportsNamedCapturingGroup: Boolean get() = true public actual val regexSplitUnicodeCodePointHandling: Boolean get() = true \ No newline at end of file diff --git a/libraries/stdlib/js/test/text/RegexJsTest.kt b/libraries/stdlib/js/test/text/RegexJsTest.kt new file mode 100644 index 00000000000..7c8971ab60c --- /dev/null +++ b/libraries/stdlib/js/test/text/RegexJsTest.kt @@ -0,0 +1,36 @@ +/* + * 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. + */ + +package test.text + +import kotlin.test.* + +class RegexJsTest { + @Test + fun replace() { + // js capturing group name can contain Unicode letters, $, _, and digits (0-9), but may not start with a digit. + // jvm capturing group name can contain a-z, A-Z, and 0-9, but may not start with a digit. + // make sure reference to capturing group name in K/JS Regex.replace(input, replacement) obeys K/JVM rules + val input = "123-456" + Regex("(?\\d+)-(?\\d+)").let { regex -> + assertEquals("123/456", regex.replace(input, "$1/$2")) + assertEquals("123/456", regex.replaceFirst(input, "$1/$2")) + + assertEquals("123/456", regex.replace(input, "\${first_part}/\${second_part}")) + assertEquals("123/456", regex.replaceFirst(input, "\${first_part}/\${second_part}")) + } + Regex("(?<\$first>\\d+)-(?<\$second>\\d+)").let { regex -> + assertEquals("123/456", regex.replace(input, "\${\$first}/\${\$second}")) + assertEquals("123/456", regex.replaceFirst(input, "\${\$first}/\${\$second}")) + + assertFailsWith { regex.replace(input, "\${first}/\${second}") } + assertFailsWith { regex.replaceFirst(input, "\${first}/\${second}") } + } + Regex("(?\\d+)-(?\\d+)").let { regex -> + assertFailsWith { regex.replace(input, "\${\$first}/\${\$second}") } + assertFailsWith { regex.replaceFirst(input, "\${\$first}/\${\$second}") } + } + } +} \ No newline at end of file diff --git a/libraries/stdlib/jvm/src/kotlin/text/regex/Regex.kt b/libraries/stdlib/jvm/src/kotlin/text/regex/Regex.kt index d25937a3c24..84367f5a243 100644 --- a/libraries/stdlib/jvm/src/kotlin/text/regex/Regex.kt +++ b/libraries/stdlib/jvm/src/kotlin/text/regex/Regex.kt @@ -160,10 +160,10 @@ internal constructor(private val nativePattern: Pattern) : Serializable { * * The replacement string may contain references to the captured groups during a match. Occurrences of `${name}` or `$index` * in the replacement string will be substituted with the subsequences corresponding to the captured groups with the specified name or index. - * In case of `$index` the first digit after '$' is always treated as part of group reference. Subsequent digits are incorporated + * In case of `$index`, the first digit after '$' is always treated as a part of group reference. Subsequent digits are incorporated * into `index` only if they would form a valid group reference. Only the digits '0'..'9' are considered as potential components * of the group reference. Note that indexes of captured groups start from 1, and the group with index 0 is the whole match. - * In case of `${name}` the `name` can consist of latin letters 'a'..'z' and 'A'..'Z', or digits '0'..'9'. The first character must be + * In case of `${name}`, the `name` can consist of latin letters 'a'..'z' and 'A'..'Z', or digits '0'..'9'. The first character must be * a letter. * * Backslash character '\' can be used to include the succeeding character as a literal in the replacement string, e.g, `\$` or `\\`. @@ -209,10 +209,10 @@ internal constructor(private val nativePattern: Pattern) : Serializable { * * The replacement string may contain references to the captured groups during a match. Occurrences of `${name}` or `$index` * in the replacement string will be substituted with the subsequences corresponding to the captured groups with the specified name or index. - * In case of `$index` the first digit after '$' is always treated as part of group reference. Subsequent digits are incorporated + * In case of `$index`, the first digit after '$' is always treated as a part of group reference. Subsequent digits are incorporated * into `index` only if they would form a valid group reference. Only the digits '0'..'9' are considered as potential components * of the group reference. Note that indexes of captured groups start from 1, and the group with index 0 is the whole match. - * In case of `${name}` the `name` can consist of latin letters 'a'..'z' and 'A'..'Z', or digits '0'..'9'. The first character must be + * In case of `${name}`, the `name` can consist of latin letters 'a'..'z' and 'A'..'Z', or digits '0'..'9'. The first character must be * a letter. * * Backslash character '\' can be used to include the succeeding character as a literal in the replacement string, e.g, `\$` or `\\`. diff --git a/libraries/stdlib/src/kotlin/text/regex/MatchResult.kt b/libraries/stdlib/src/kotlin/text/regex/MatchResult.kt index 4969e72c24d..2aa3cb54ea8 100644 --- a/libraries/stdlib/src/kotlin/text/regex/MatchResult.kt +++ b/libraries/stdlib/src/kotlin/text/regex/MatchResult.kt @@ -36,7 +36,8 @@ public interface MatchNamedGroupCollection : MatchGroupCollection { * Returns a named group with the specified [name]. * @return An instance of [MatchGroup] if the group with the specified [name] was matched or `null` otherwise. * @throws IllegalArgumentException if there is no group with the specified [name] defined in the regex pattern. - * @throws UnsupportedOperationException if getting named groups isn't supported on the current platform. + * @throws UnsupportedOperationException if this match group collection doesn't support getting match groups by name, + * for example, when it's not supported by the current platform. */ public operator fun get(name: String): MatchGroup? } diff --git a/libraries/stdlib/test/text/RegexTest.kt b/libraries/stdlib/test/text/RegexTest.kt index fea30a9402f..c71af5d1c5a 100644 --- a/libraries/stdlib/test/text/RegexTest.kt +++ b/libraries/stdlib/test/text/RegexTest.kt @@ -155,6 +155,68 @@ class RegexTest { } } + @Test fun matchNamedGroups() { + val regex = "\\b(?[A-Za-z\\s]+),\\s(?[A-Z]{2}):\\s(?[0-9]{3})\\b".toRegex() + val input = "Coordinates: Austin, TX: 123" + + regex.find(input)!!.let { match -> + assertEquals(listOf("Austin, TX: 123", "Austin", "TX", "123"), match.groupValues) + + if (supportsNamedCapturingGroup) { + val namedGroups = match.groups as MatchNamedGroupCollection + assertEquals(4, namedGroups.size) + assertEquals("Austin", namedGroups["city"]?.value) + assertEquals("TX", namedGroups["state"]?.value) + assertEquals("123", namedGroups["areaCode"]?.value) + } + } + } + + @Test fun matchOptionalNamedGroup() { + if (!supportsNamedCapturingGroup) return + + "(?hi)|(?bye)".toRegex(RegexOption.IGNORE_CASE).let { regex -> + val hiMatch = regex.find("Hi!")!! + val hiGroups = hiMatch.groups as MatchNamedGroupCollection + assertEquals(3, hiGroups.size) + assertEquals("Hi", hiGroups["hi"]?.value) + assertEquals(null, hiGroups["bye"]) + assertFailsWith { hiGroups["hello"] } + + val byeMatch = regex.find("bye...")!! + val byeGroups = byeMatch.groups as MatchNamedGroupCollection + assertEquals(3, byeGroups.size) + assertEquals(null, byeGroups["hi"]) + assertEquals("bye", byeGroups["bye"]?.value) + assertFailsWith { byeGroups["goodbye"] } + } + + "(?hi)|bye".toRegex(RegexOption.IGNORE_CASE).let { regex -> + val hiMatch = regex.find("Hi!")!! + val hiGroups = hiMatch.groups as MatchNamedGroupCollection + assertEquals(2, hiGroups.size) + assertEquals("Hi", hiGroups["hi"]?.value) + assertFailsWith { hiGroups["bye"] } + + // Named group collection consisting of a single 'null' group value + val byeMatch = regex.find("bye...")!! + val byeGroups = byeMatch.groups as MatchNamedGroupCollection + assertEquals(2, byeGroups.size) + assertEquals(null, byeGroups["hi"]) + assertFailsWith { byeGroups["bye"] } + } + } + + @Test fun matchWithBackReference() { + val regex = "(?\\w+), yes \\k<title>".toRegex() + + val match = regex.find("Do you copy? Sir, yes Sir!")!! + assertEquals("Sir, yes Sir", match.value) + assertEquals("Sir", (match.groups as MatchNamedGroupCollection)["title"]?.value) + + assertNull(regex.find("Do you copy? Sir, yes I do!")) + } + @Test fun matchMultiline() { val regex = "^[a-z]*$".toRegex(setOf(RegexOption.IGNORE_CASE, RegexOption.MULTILINE)) val matchedValues = regex.findAll("test\n\nLine").map { it.value }.toList() @@ -298,7 +360,7 @@ class RegexTest { "123-456".let { input -> assertEquals("(123-456)", pattern.replace(input, "($0)")) assertEquals("123+456", pattern.replace(input, "$1+$2")) - // take largest legal group number reference + // take the largest legal group number reference assertEquals("1230+456", pattern.replace(input, "$10+$2")) assertEquals("123+456", pattern.replace(input, "$01+$2")) // js refers to named capturing groups with "$<name>" syntax @@ -308,6 +370,11 @@ class RegexTest { // missing trailing '}' assertFailsWith<IllegalArgumentException>("\${first+\${second}") { pattern.replace(input, "\${first+\${second}") } assertFailsWith<IllegalArgumentException>("\${first}+\${second") { pattern.replace(input, "\${first}+\${second") } + + // non-existent group name + assertFailsWith<IllegalArgumentException>("\${first}+\${second}+\$third") { + pattern.replace(input, "\${first}+\${second}+\$third") + } } "123-456-789-012".let { input -> @@ -317,6 +384,18 @@ class RegexTest { } } + @Test fun replaceWithNamedOptionalGroups() { + if (!supportsNamedCapturingGroup) return + + val regex = "(?<hi>hi)|(?<bye>bye)".toRegex(RegexOption.IGNORE_CASE) + + assertEquals("[Hi, ]gh wall", regex.replace("High wall", "[$1, $2]")) + assertEquals("[Hi, ]gh wall", regex.replace("High wall", "[\${hi}, \${bye}]")) + + assertEquals("Good[, bye], Mr. Holmes", regex.replace("Goodbye, Mr. Holmes", "[$1, $2]")) + assertEquals("Good[, bye], Mr. Holmes", regex.replace("Goodbye, Mr. Holmes", "[\${hi}, \${bye}]")) + } + @Test fun replaceEvaluator() { val input = "/12/456/7890/" val pattern = "\\d+".toRegex()