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).
This commit is contained in:
Claude
2026-06-09 22:42:50 +00:00
parent 9671fc4d04
commit f42cc836aa
2 changed files with 94 additions and 28 deletions
@@ -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(
@@ -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()))
}
}