From f42cc836aad5e04da3bd80c8f1d4da5f7500bf9e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:42:50 +0000 Subject: [PATCH] fix(commons): make fixMissingSpaces work on Kotlin/Native (iOS) RichTextParser.fixMissingSpaces used a Regex of the form `([^ \n])?(urls)([^ \n])?` to insert spaces around URLs glued to neighbouring text. Kotlin/Native's regex engine fails to backtrack the optional `([^ \n])?` capture groups to zero width, so on iOS every URL was corrupted (e.g. "https://x" became "h https://x"). That broke the downstream segmenter, which is why :commons:iosSimulatorArm64Test reported 19 failures across the RichText/Gallery/Pdf/F4a parsers once the test binary finally linked. Replace the regex with a direct left-to-right scan that inserts a single space wherever a detected URL touches a non-space/non-newline neighbour. The scan is engine-independent, so it behaves identically on JVM and Native. Verified equivalent to the old behaviour across the full commons richtext JVM corpus, and the new FixMissingSpacesTest pins the cases on every target (including iosSimulatorArm64). --- .../commons/richtext/RichTextParser.kt | 60 +++++++++--------- .../commons/richtext/FixMissingSpacesTest.kt | 62 +++++++++++++++++++ 2 files changed, 94 insertions(+), 28 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/FixMissingSpacesTest.kt 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())) + } +}