From 9c4c1ed557a889bf57c754b81f4897a0d8405b0d Mon Sep 17 00:00:00 2001 From: Abduqodiri Qurbonzoda Date: Wed, 30 Mar 2022 17:28:09 +0300 Subject: [PATCH] [K/N and WASM] Implement named capturing group in Regexes #KT-41890 --- .../tests/stdlib_external/utils.kt | 2 +- .../native-wasm/src/kotlin/text/Regex.kt | 85 +++++++++--- .../src/kotlin/text/regex/Lexer.kt | 122 ++++++++++++------ .../src/kotlin/text/regex/MatchResultImpl.kt | 9 +- .../src/kotlin/text/regex/NamedGroup.kt | 13 ++ .../src/kotlin/text/regex/Pattern.kt | 45 +++++-- libraries/stdlib/test/text/RegexTest.kt | 95 +++++++++++--- libraries/stdlib/wasm/test/testUtils.kt | 3 +- 8 files changed, 280 insertions(+), 94 deletions(-) create mode 100644 libraries/stdlib/native-wasm/src/kotlin/text/regex/NamedGroup.kt diff --git a/kotlin-native/backend.native/tests/stdlib_external/utils.kt b/kotlin-native/backend.native/tests/stdlib_external/utils.kt index 8e84b81a61f..08b03e7ee97 100644 --- a/kotlin-native/backend.native/tests/stdlib_external/utils.kt +++ b/kotlin-native/backend.native/tests/stdlib_external/utils.kt @@ -30,6 +30,6 @@ public actual val isFloat32RangeEnforced: Boolean get() = true public actual val supportsSuppressedExceptions: Boolean get() = true -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/native-wasm/src/kotlin/text/Regex.kt b/libraries/stdlib/native-wasm/src/kotlin/text/Regex.kt index cb24f6a6225..a32ebd1ad2b 100644 --- a/libraries/stdlib/native-wasm/src/kotlin/text/Regex.kt +++ b/libraries/stdlib/native-wasm/src/kotlin/text/Regex.kt @@ -66,6 +66,24 @@ public actual enum class RegexOption(override val value: Int, override val mask: */ public actual data class MatchGroup(actual val value: String, val range: IntRange) + +/** + * 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. @@ -223,17 +241,17 @@ public actual class Regex internal constructor(internal val nativePattern: Patte /** * 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 named capturing groups are not supported in Kotlin/Native. - * * @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 @@ -271,17 +289,17 @@ public actual class Regex internal constructor(internal val nativePattern: Patte /** * 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 named capturing groups are not supported in Kotlin/Native. - * * @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 @@ -388,20 +406,34 @@ 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) + val groupName = replacement.substring(index, endIndex) - if (replacement[index] !in '0'..'9') - throw IllegalArgumentException("Invalid capturing group reference") + if (groupName.isEmpty()) + throw IllegalArgumentException("Named capturing group reference should have a non-empty name") + if (groupName[0] in '0'..'9') + throw IllegalArgumentException("Named capturing group reference {$groupName} should start with a letter") - val endIndex = replacement.readGroupIndex(index, match.groupValues.size) - val groupIndex = replacement.substring(index, endIndex).toInt() + if (endIndex == replacement.length || replacement[endIndex] != '}') + throw IllegalArgumentException("Named capturing group reference is missing trailing '}'") - 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) } @@ -409,6 +441,19 @@ private fun substituteGroupRefs(match: MatchResult, replacement: String): String return result.toString() } +private fun String.readGroupName(startIndex: Int): Int { + var index = startIndex + while (index < length) { + val char = this[index] + if (char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9') { + index++ + } else { + break + } + } + 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/native-wasm/src/kotlin/text/regex/Lexer.kt b/libraries/stdlib/native-wasm/src/kotlin/text/regex/Lexer.kt index a0055e12ff1..e41364c8a05 100644 --- a/libraries/stdlib/native-wasm/src/kotlin/text/regex/Lexer.kt +++ b/libraries/stdlib/native-wasm/src/kotlin/text/regex/Lexer.kt @@ -33,12 +33,14 @@ internal abstract class SpecialToken { * Returns the type of the token, may return following values: * TOK_CHARCLASS - token representing character class; * TOK_QUANTIFIER - token representing quantifier; + * TOK_NAMED_GROUP - token representing named capturing group; */ abstract val type: Type enum class Type { CHARCLASS, - QUANTIFIER + QUANTIFIER, + NAMED_GROUP } } @@ -333,52 +335,58 @@ internal class Lexer(val patternString: String, flags: Int) { // Group lookAhead = CHAR_LEFT_PARENTHESIS } else { - // Special constructs (non-capturing groups, look ahead/look behind etc). + // Special constructs (non-capturing groups, named capturing groups, look ahead/look behind etc). nextIndex() var char = pattern[index] - var isLookBehind = false - do { - if (!isLookBehind) { - when (char) { - // Look ahead or an atomic group. - '!' -> { lookAhead = CHAR_NEG_LOOKAHEAD; nextIndex() } - '=' -> { lookAhead = CHAR_POS_LOOKAHEAD; nextIndex() } - '>' -> { lookAhead = CHAR_ATOMIC_GROUP; nextIndex() } - // Positive / negaitve look behind - need to check the next char. - '<' -> { - nextIndex() - char = pattern[index] - isLookBehind = true - } - // Flags. - else -> { - lookAhead = readFlags() - - // We return `res = res or 1 shl 8` from readFlags() if we read (?idmsux-idmsux) - if (lookAhead >= 256) { - // Just flags (no non-capturing group with them). Erase auxiliary bit. - lookAhead = lookAhead and 0xff - flags = lookAhead - lookAhead = lookAhead shl 16 - lookAhead = CHAR_FLAGS or lookAhead - } else { - // A non-capturing group with flags: (?:Foo) - flags = lookAhead - lookAhead = lookAhead shl 16 - lookAhead = CHAR_NONCAP_GROUP or lookAhead - } - } - } - } else { + when (char) { + // Look ahead or an atomic group. + '!' -> { + lookAhead = CHAR_NEG_LOOKAHEAD; nextIndex() + } + '=' -> { + lookAhead = CHAR_POS_LOOKAHEAD; nextIndex() + } + '>' -> { + lookAhead = CHAR_ATOMIC_GROUP; nextIndex() + } + // named capturing group or positive / negative look behind - need to check the next char. + '<' -> { + nextIndex() + char = pattern[index] // Process the second char for look behind construction. - isLookBehind = false when (char) { - '!' -> { lookAhead = CHAR_NEG_LOOKBEHIND; nextIndex() } - '=' -> { lookAhead = CHAR_POS_LOOKBEHIND; nextIndex() } - else -> throw PatternSyntaxException("Unknown look behind", patternString, curTokenIndex) + '!' -> { + lookAhead = CHAR_NEG_LOOKBEHIND; nextIndex() + } + '=' -> { + lookAhead = CHAR_POS_LOOKBEHIND; nextIndex() + } + else -> { + val name = readGroupName() + lookAhead = CHAR_NAMED_GROUP + lookAheadSpecialToken = NamedGroup(name) + } } } - } while (isLookBehind) + // Flags. + else -> { + lookAhead = readFlags() + + // We return `res = res or 1 shl 8` from readFlags() if we read (?idmsux-idmsux) + if (lookAhead >= 256) { + // Just flags (no non-capturing group with them). Erase auxiliary bit. + lookAhead = lookAhead and 0xff + flags = lookAhead + lookAhead = lookAhead shl 16 + lookAhead = CHAR_FLAGS or lookAhead + } else { + // A non-capturing group with flags: (?:Foo) + flags = lookAhead + lookAhead = lookAhead shl 16 + lookAhead = CHAR_NONCAP_GROUP or lookAhead + } + } + } } } @@ -459,11 +467,21 @@ internal class Lexer(val patternString: String, flags: Int) { 'e' -> lookAhead = '\u001B'.toInt() // Back references to capturing groups. + // \n '1', '2', '3', '4', '5', '6', '7', '8', '9' -> { if (mode == Mode.PATTERN) { lookAhead = 0x80000000.toInt() or lookAhead // Captured group reference is 0x80... } } + // \k + 'k' -> { + if (pattern[nextIndex()] != '<') { + throw PatternSyntaxException("Invalid syntax for named group back reference", patternString, curTokenIndex) + } + val name = readGroupName() + lookAhead = CHAR_NAMED_GROUP_REF + lookAheadSpecialToken = NamedGroup(name) + } // A literal: octal, hex, or hex unicode. '0' -> lookAhead = readOctals() @@ -489,7 +507,7 @@ internal class Lexer(val patternString: String, flags: Int) { } } - 'C', 'E', 'F', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'T', 'U', 'X', 'Y', 'g', 'i', 'j', 'k', 'l', 'm', 'o', 'q', 'y' -> + 'C', 'E', 'F', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'T', 'U', 'X', 'Y', 'g', 'i', 'j', 'l', 'm', 'o', 'q', 'y' -> throw PatternSyntaxException("Illegal escape sequence", patternString, curTokenIndex) } return false @@ -680,6 +698,24 @@ internal class Lexer(val patternString: String, flags: Int) { return result } + private fun readGroupName(): String { + var char = pattern[nextIndex()] + if (char !in 'a'..'z' && char !in 'A'..'Z') { + throw PatternSyntaxException("Capturing group name should start with a letter", patternString, curTokenIndex) + } + + val sb = StringBuilder() + do { + sb.append(char) + char = pattern[nextIndex()] + } while (char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9') + + if (char != '>') { + throw PatternSyntaxException("Invalid group name syntax", patternString, curTokenIndex) + } + return sb.toString() + } + companion object { // Special characters. val CHAR_DOLLAR = 0xe0000000.toInt() or '$'.toInt() @@ -692,6 +728,7 @@ internal class Lexer(val patternString: String, flags: Int) { val CHAR_HYPHEN = 0xe0000000.toInt() or '-'.toInt() val CHAR_DOT = 0xe0000000.toInt() or '.'.toInt() val CHAR_LEFT_PARENTHESIS = 0x80000000.toInt() or '('.toInt() + val CHAR_NAMED_GROUP = 0x90000000.toInt() or '('.toInt() val CHAR_NONCAP_GROUP = 0xc0000000.toInt() or '('.toInt() val CHAR_POS_LOOKAHEAD = 0xe0000000.toInt() or '('.toInt() val CHAR_NEG_LOOKAHEAD = 0xf0000000.toInt() or '('.toInt() @@ -703,6 +740,7 @@ internal class Lexer(val patternString: String, flags: Int) { val CHAR_WORD_BOUND = 0x80000000.toInt() or 'b'.toInt() val CHAR_NONWORD_BOUND = 0x80000000.toInt() or 'B'.toInt() val CHAR_PREVIOUS_MATCH = 0x80000000.toInt() or 'G'.toInt() + val CHAR_NAMED_GROUP_REF = 0x80000000.toInt() or 'k'.toInt() val CHAR_END_OF_INPUT = 0x80000000.toInt() or 'z'.toInt() val CHAR_END_OF_LINE = 0x80000000.toInt() or 'Z'.toInt() val CHAR_LINEBREAK = 0x80000000.toInt() or 'R'.toInt() diff --git a/libraries/stdlib/native-wasm/src/kotlin/text/regex/MatchResultImpl.kt b/libraries/stdlib/native-wasm/src/kotlin/text/regex/MatchResultImpl.kt index 4ee08d3eb56..e552647626f 100644 --- a/libraries/stdlib/native-wasm/src/kotlin/text/regex/MatchResultImpl.kt +++ b/libraries/stdlib/native-wasm/src/kotlin/text/regex/MatchResultImpl.kt @@ -30,7 +30,6 @@ internal class MatchResultImpl /** * @param input an input sequence for matching/searching. * @param regex a [Regex] instance used for matching/searching. - * @param rightBound index in the [input] used as a right bound for matching/searching. Exclusive. */ constructor (internal val input: CharSequence, internal val regex: Regex) : MatchResult { @@ -92,7 +91,7 @@ constructor (internal val input: CharSequence, * Groups are indexed from 1 to `groupCount` and group with the index 0 corresponds to the entire match. */ // Create one object or several ones? - override val groups: MatchGroupCollection = object: MatchGroupCollection, AbstractCollection() { + override val groups: MatchGroupCollection = object: MatchNamedGroupCollection, AbstractCollection() { override val size: Int get() = this@MatchResultImpl.groupCount @@ -118,6 +117,12 @@ constructor (internal val input: CharSequence, val value = group(index) ?: return null return MatchGroup(value, getStart(index) until getEnd(index)) } + + override fun get(name: String): MatchGroup? { + val index = nativePattern.groupNameToIndex[name] + ?: throw IllegalArgumentException("Capturing group with name {$name} does not exist") + return get(index) + } } /** diff --git a/libraries/stdlib/native-wasm/src/kotlin/text/regex/NamedGroup.kt b/libraries/stdlib/native-wasm/src/kotlin/text/regex/NamedGroup.kt new file mode 100644 index 00000000000..89fd38283e4 --- /dev/null +++ b/libraries/stdlib/native-wasm/src/kotlin/text/regex/NamedGroup.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2010-2022 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 kotlin.text.regex + +internal class NamedGroup(val name: String) : SpecialToken() { + + override fun toString() = "NamedGroup(name=$name)" + + override val type: Type = SpecialToken.Type.NAMED_GROUP +} \ No newline at end of file diff --git a/libraries/stdlib/native-wasm/src/kotlin/text/regex/Pattern.kt b/libraries/stdlib/native-wasm/src/kotlin/text/regex/Pattern.kt index 01bdf00509c..f5e874c63dd 100644 --- a/libraries/stdlib/native-wasm/src/kotlin/text/regex/Pattern.kt +++ b/libraries/stdlib/native-wasm/src/kotlin/text/regex/Pattern.kt @@ -32,9 +32,12 @@ internal class Pattern(val pattern: String, flags: Int = 0) { /** A lexer instance used to get tokens from the pattern. */ private val lexemes = Lexer(pattern, flags) - /* All back references that may be used in pattern. */ + /** All back references that may be used in pattern. */ private val backRefs = arrayOfNulls(BACK_REF_NUMBER) + /** Mapping from group name to its index */ + val groupNameToIndex = hashMapOf() + /** Is true if back referenced sets replacement by second compilation pass is needed.*/ private var needsBackRefReplacement = false @@ -129,10 +132,18 @@ internal class Pattern(val pattern: String, flags: Int = 0) { if (capturingGroupCount < BACK_REF_NUMBER) { backRefs[capturingGroupCount] = fSet } + if (ch == Lexer.CHAR_NAMED_GROUP) { + val name = (lexemes.curSpecialToken as NamedGroup).name + groupNameToIndex[name] = capturingGroupCount + } capturingGroupCount++ } } + if (last != null) { + lexemes.next() + } + //Process to EOF or ')' do { val child: AbstractSet @@ -466,7 +477,6 @@ internal class Pattern(val pattern: String, flags: Int = 0) { } // The terminal is some kind of group: (E). Call processExpression for it. if (char and 0x8000ffff.toInt() == Lexer.CHAR_LEFT_PARENTHESIS) { - lexemes.next() var newFlags = flags if (char and 0xff00ffff.toInt() == Lexer.CHAR_NONCAP_GROUP) { newFlags = (char shr 16) and flagsBitMask @@ -569,16 +579,16 @@ internal class Pattern(val pattern: String, flags: Int = 0) { 0x80000000.toInt() or '7'.toInt(), 0x80000000.toInt() or '8'.toInt(), 0x80000000.toInt() or '9'.toInt() -> { - val number = (char and 0x7FFFFFFF) - '0'.toInt() - if (number < capturingGroupCount) { // All is ok - the group exists. - lexemes.next() - term = BackReferenceSet(number, consumersCount++, hasFlag(CASE_INSENSITIVE)) - // backRefs[number] is proved to be not null because the group is already created (number < capturingGroupCount) - backRefs[number]!!.isBackReferenced = true - needsBackRefReplacement = true // And process back references in the second pass. - } else { - throw PatternSyntaxException("No such group yet exists at this point in the pattern", pattern, lexemes.curTokenIndex) - } + val groupIndex = (char and 0x7FFFFFFF) - '0'.toInt() + term = createBackReference(groupIndex) + lexemes.next() + } + + Lexer.CHAR_NAMED_GROUP_REF -> { + val name = (lexemes.curSpecialToken as NamedGroup).name + val groupIndex = groupNameToIndex[name] ?: -1 + term = createBackReference(groupIndex) + lexemes.next() } // A special token (\D, \w etc), 'u0000' or the end of the pattern. @@ -624,6 +634,17 @@ internal class Pattern(val pattern: String, flags: Int = 0) { return term } + /** Creates a back reference to the group with specified [groupIndex], or throws if the group doesn't exist yet. */ + private fun createBackReference(groupIndex: Int): BackReferenceSet { + if (groupIndex >= 0 && groupIndex < capturingGroupCount) { // All is ok - the group exists. + // backRefs[groupIndex] is proved to be not null because the group is already created (groupIndex < capturingGroupCount) + backRefs[groupIndex]!!.isBackReferenced = true + needsBackRefReplacement = true // And process back references in the second pass. + return BackReferenceSet(groupIndex, consumersCount++, hasFlag(CASE_INSENSITIVE)) + } else { + throw PatternSyntaxException("No such group yet exists at this point in the pattern", pattern, lexemes.curTokenIndex) + } + } /** * Process [...] ranges diff --git a/libraries/stdlib/test/text/RegexTest.kt b/libraries/stdlib/test/text/RegexTest.kt index c71af5d1c5a..584487069a5 100644 --- a/libraries/stdlib/test/text/RegexTest.kt +++ b/libraries/stdlib/test/text/RegexTest.kt @@ -156,20 +156,19 @@ class RegexTest { } @Test fun matchNamedGroups() { + if (!supportsNamedCapturingGroup) return + 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) + val match = regex.find(input)!! + 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) - } - } + 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() { @@ -208,15 +207,81 @@ class RegexTest { } @Test fun matchWithBackReference() { - val regex = "(?\\w+), yes \\k<title>".toRegex() + "(\\w+), yes \\1".toRegex().let { regex -> + val match = regex.find("Do you copy? Sir, yes Sir!")!! + assertEquals("Sir, yes Sir", match.value) + assertEquals("Sir", match.groups[1]?.value) - 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!")) + } - assertNull(regex.find("Do you copy? Sir, yes I do!")) + // capture the largest valid group index + "(\\w+), yes \\12".toRegex().let { regex -> + val match = regex.find("Do you copy? Sir, yes Sir2")!! + assertEquals("Sir, yes Sir2", match.value) + assertEquals("Sir", match.groups[1]?.value) + } + // back reference to non-existent group + assertNull("a(a)\\21".toRegex().find("aaaa1")) + + // back reference to a group with large index + "0(1(2(3(4(5(6(7(8(9(A(B(C))))))))\\11))))".toRegex().let { regex -> + val match = regex.find("0123456789ABCBC")!! + assertEquals("BC", match.groups[11]?.value) + assertEquals("56789ABC", match.groups[5]?.value) + assertEquals("456789ABCBC", match.groups[4]?.value) + } + + // back reference to an enclosing group + assertNull("a(a\\1)".toRegex().find("aaaa")) + // back reference to not yet available group + assertNull("a\\1(a)".toRegex().find("aaaa")) } + @Test fun matchNamedGroupsWithBackReference() { + if (!supportsNamedCapturingGroup) return + + "(?<title>\\w+), yes \\k<title>".toRegex().let { regex -> + 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!")) + } + + // back reference to an enclosing group + assertNull("a(?<first>a\\k<first>)".toRegex().find("aaaa")) + // back reference to not yet available group + assertFailsWith<IllegalArgumentException> { + "a\\k<first>(?<first>a)".toRegex() + } + } + + @Test fun incompleteNamedGroupDeclaration() { + if (!supportsNamedCapturingGroup) return + + assertFailsWith<IllegalArgumentException> { + "(?<".toRegex() + } + assertFailsWith<IllegalArgumentException> { + "(?<)".toRegex() + } + assertFailsWith<IllegalArgumentException> { + "(?<name".toRegex() + } + assertFailsWith<IllegalArgumentException> { + "(?<name)".toRegex() + } + assertFailsWith<IllegalArgumentException> { + "(?<name>".toRegex() + } + assertFailsWith<IllegalArgumentException> { + "(?<>\\w+), yes \\k<>".toRegex() + } + } + + // TODO: Test comment mode enabled and group name is separated by space, (before, in the middle, after) + @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() diff --git a/libraries/stdlib/wasm/test/testUtils.kt b/libraries/stdlib/wasm/test/testUtils.kt index f46457bd932..522fd9c8f60 100644 --- a/libraries/stdlib/wasm/test/testUtils.kt +++ b/libraries/stdlib/wasm/test/testUtils.kt @@ -28,7 +28,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