[JS] Support named capture groups in Regex #KT-51775

This commit is contained in:
Abduqodiri Qurbonzoda
2021-09-28 02:20:22 +03:00
committed by Space
parent cf79752c14
commit c3f5d03b36
9 changed files with 217 additions and 33 deletions
@@ -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
+3
View File
@@ -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
@@ -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? {
+86 -24
View File
@@ -31,6 +31,23 @@ private fun Iterable<RegexOption>.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<Regex
/**
* Replaces all occurrences of this regular expression in the specified [input] string with specified [replacement] expression.
*
* The replacement string may contain references to the captured groups during a match. Occurrences of `$index`
* in the replacement string will be substituted with the subsequences corresponding to the captured groups with the specified index.
* The first digit after '$' is always treated as part of group reference. Subsequent digits are incorporated
* 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 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
* a letter.
*
* Backslash character '\' can be used to include the succeeding character as a literal in the replacement string, e.g, `\$` or `\\`.
* [Regex.escapeReplacement] can be used if [replacement] have to be treated as a literal string.
*
* Note that referring named capturing groups by name is currently not supported in Kotlin/JS.
* However, you can still refer them by index.
*
* @param input the char sequence to find matches of this regular expression in
* @param replacement the expression to replace found matches with
* @return the result of replacing each occurrence of this regular expression in [input] with the result of evaluating the [replacement] expression
@@ -202,18 +218,17 @@ public actual class Regex actual constructor(pattern: String, options: Set<Regex
/**
* Replaces the first occurrence of this regular expression in the specified [input] string with specified [replacement] expression.
*
* The replacement string may contain references to the captured groups during a match. Occurrences of `$index`
* in the replacement string will be substituted with the subsequences corresponding to the captured groups with the specified index.
* The first digit after '$' is always treated as part of group reference. Subsequent digits are incorporated
* 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 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
* a letter.
*
* Backslash character '\' can be used to include the succeeding character as a literal in the replacement string, e.g, `\$` or `\\`.
* [Regex.escapeReplacement] can be used if [replacement] have to be treated as a literal string.
*
* Note that referring named capturing groups by name is not supported currently in Kotlin/JS.
* However, you can still refer them by index.
*
* @param input the char sequence to find a match of this regular expression in
* @param replacement the expression to replace the found match with
* @return the result of replacing the first occurrence of this regular expression in [input] with the result of evaluating the [replacement] expression
@@ -338,10 +353,29 @@ private fun RegExp.findNext(input: String, from: Int, nextPattern: RegExp): Matc
override val value: String
get() = match[0]!!
override val groups: MatchGroupCollection = object : MatchGroupCollection, AbstractCollection<MatchGroup?>() {
override val groups: MatchGroupCollection = object : MatchNamedGroupCollection, AbstractCollection<MatchGroup?>() {
override val size: Int get() = match.length
override fun iterator(): Iterator<MatchGroup?> = 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<Boolean>()
}
@@ -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
+1 -2
View File
@@ -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
@@ -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("(?<first_part>\\d+)-(?<second_part>\\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<IllegalArgumentException> { regex.replace(input, "\${first}/\${second}") }
assertFailsWith<IllegalArgumentException> { regex.replaceFirst(input, "\${first}/\${second}") }
}
Regex("(?<first>\\d+)-(?<second>\\d+)").let { regex ->
assertFailsWith<IllegalArgumentException> { regex.replace(input, "\${\$first}/\${\$second}") }
assertFailsWith<IllegalArgumentException> { regex.replaceFirst(input, "\${\$first}/\${\$second}") }
}
}
}
@@ -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 `\\`.
@@ -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?
}
+80 -1
View File
@@ -155,6 +155,68 @@ class RegexTest {
}
}
@Test fun matchNamedGroups() {
val regex = "\\b(?<city>[A-Za-z\\s]+),\\s(?<state>[A-Z]{2}):\\s(?<areaCode>[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>hi)|(?<bye>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<IllegalArgumentException> { 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<IllegalArgumentException> { byeGroups["goodbye"] }
}
"(?<hi>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<IllegalArgumentException> { 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<IllegalArgumentException> { byeGroups["bye"] }
}
}
@Test fun matchWithBackReference() {
val regex = "(?<title>\\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()