diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 959b8f4a31..623b6c7437 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -134,38 +134,42 @@ class RichTextParser { ): String { if (urlList.isEmpty()) return input - // Escape and join words: (word1|word2) - val wordsPattern = urlList.sortedByDescending { it.length }.joinToString("|") { Regex.escape(it) } + // Walk the text, and wherever one of the detected URLs sits glued to a + // non-space/non-newline neighbour, insert a single separating space so the + // word-by-word segmenter downstream can recognise it as a standalone URL. + // + // This used to be a `Regex("([^ \n])?($escapedWords)([^ \n])?")` replace, + // but Kotlin/Native's regex engine mishandles the optional capture groups + // `([^ \n])?` (it fails to backtrack them to zero width), corrupting every + // URL on iOS — e.g. "https://x" came back as "h https://x". A direct scan + // sidesteps the engine entirely and is platform-independent. + // + // Longest-first so a URL that is a prefix of another never shadows it. + val urls = urlList.filter { it.isNotEmpty() }.sortedByDescending { it.length } + val result = StringBuilder(input.length) + var i = 0 + while (i < input.length) { + val match = urls.firstOrNull { input.startsWith(it, i) } + if (match != null) { + // Separate from a glued prefix character. + val prev = result.lastOrNull() + if (prev != null && prev != ' ' && prev != '\n') result.append(' ') - // Regex breakdown: - // ([^ ])? -> Group 1: Optional character that is NOT a space or new line (Prefix) - // ($wordsPattern) -> Group 2: One of your target words - // ([^ ])? -> Group 3: Optional character that is NOT a space or new line (Suffix) - val regex = Regex("([^ \n])?($wordsPattern)([^ \n])?") + result.append(match) + i += match.length - return regex.replace(input) { match -> - val prefix = match.groups[1]?.value ?: "" - val word = match.groups[2]?.value ?: "" - val suffix = match.groups[3]?.value ?: "" - - val result = StringBuilder() - - // Add prefix + space if the prefix exists - if (prefix.isNotEmpty()) { - result.append(prefix) - result.append(" ") + // Separate from a glued suffix character. + if (i < input.length) { + val next = input[i] + if (next != ' ' && next != '\n') result.append(' ') + } + } else { + result.append(input[i]) + i++ } - - result.append(word) - - // Add space + suffix if the suffix exists - if (suffix.isNotEmpty()) { - result.append(" ") - result.append(suffix) - } - - result.toString() } + + return result.toString() } fun parseText( diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/FixMissingSpacesTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/FixMissingSpacesTest.kt new file mode 100644 index 0000000000..0890bd96cd --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/FixMissingSpacesTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.richtext + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Focused guard for [RichTextParser.fixMissingSpaces]. The earlier + * `Regex("([^ \n])?(url)([^ \n])?")` implementation corrupted every URL on + * Kotlin/Native (e.g. iOS) because the engine fails to backtrack the optional + * `([^ \n])?` groups to zero width — `"https://x"` came back as `"h https://x"`. + * These cases run on every target (incl. iosSimulatorArm64) and pin the behaviour. + */ +class FixMissingSpacesTest { + private val parser = RichTextParser() + + @Test + fun leavesAlreadySeparatedUrlUntouched() { + val url = "https://example.com/audio/track.f4a" + assertEquals(url, parser.fixMissingSpaces(url, setOf(url))) + } + + @Test + fun leavesUrlWithRegexMetacharactersUntouched() { + val url = "universe.nostrich.land?lang=zh" + val text = "foo $url bar" + assertEquals(text, parser.fixMissingSpaces(text, setOf(url))) + } + + @Test + fun insertsSpacesAroundGluedUrl() { + assertEquals( + "a https://example.com/x b", + parser.fixMissingSpaces("ahttps://example.com/xb", setOf("https://example.com/x")), + ) + } + + @Test + fun emptyUrlSetIsNoOp() { + val text = "no urls here" + assertEquals(text, parser.fixMissingSpaces(text, emptySet())) + } +}