Merge pull request #3164 from vitorpamplona/claude/focused-faraday-z9zs87

Work around Compose ui-uikit prebuilt cache linking failure
This commit is contained in:
Vitor Pamplona
2026-06-09 19:29:00 -04:00
committed by GitHub
3 changed files with 150 additions and 26 deletions
+34
View File
@@ -1,5 +1,23 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.DisableCacheInKotlinVersion
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeCacheApi
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.TestExecutable
// Disables the Kotlin/Native compiler cache for an iOS test binary so the
// Compose ui-uikit klib recompiles fresh instead of linking the broken prebuilt
// cache (see the call site in the `kotlin {}` block). The version guard makes
// Kotlin re-surface this workaround once we move past 2.3.21, so it can be
// dropped when a newer Compose/Kotlin pairing fixes the cache. Wrapped in a
// helper because @OptIn only applies to declarations, not bare statements.
@OptIn(KotlinNativeCacheApi::class)
fun TestExecutable.disableUiKitPrebuiltCache() =
disableNativeCache(
DisableCacheInKotlinVersion.`2_3_21`,
"Compose ui-uikit prebuilt cache references UIViewLayoutRegion (iOS 17+); " +
"linking the iOS test binary fails under Xcode 16.4.",
)
plugins {
alias(libs.plugins.kotlinMultiplatform)
@@ -50,6 +68,22 @@ kotlin {
iosArm64()
iosSimulatorArm64()
// Compose Multiplatform 1.11.0 ships an `org.jetbrains.compose.ui:ui-uikit`
// prebuilt Kotlin/Native cache whose CMPLayoutRegion object hard-references
// the UIKit class `UIViewLayoutRegion` (introduced in iOS 17). Linking the
// iOS *test* executable against that cache under Xcode 16.4 fails with
// ld: Undefined symbols: _OBJC_CLASS_$_UIViewLayoutRegion
// because the cached object was built for a newer simulator SDK (18.5) than
// the test binary is being linked for (14.0). Disabling the native cache for
// the iOS test binaries makes ui-uikit recompile against the active SDK,
// where the symbol resolves. See disableUiKitPrebuiltCache() below and
// https://kotl.in/disable-native-cache
targets.withType<KotlinNativeTarget>().configureEach {
binaries.withType<TestExecutable>().configureEach {
disableUiKitPrebuiltCache()
}
}
sourceSets {
commonMain {
dependencies {
@@ -134,38 +134,66 @@ 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.
//
// This runs on the main thread per rendered note, so it stays linear in the
// text length: URLs are bucketed by their first character, and the inner
// match attempt only fires at positions whose character can actually start
// a URL — every other character costs a single map lookup. Within a bucket
// the URLs are kept longest-first so a URL that is a prefix of a longer one
// never shadows it.
val byFirstChar = HashMap<Char, MutableList<String>>()
urlList
.asSequence()
.filter { it.isNotEmpty() }
.sortedByDescending { it.length }
.forEach { byFirstChar.getOrPut(it[0]) { ArrayList(1) }.add(it) }
// 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])?")
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(" ")
val result = StringBuilder(input.length)
val length = input.length
var i = 0
while (i < length) {
val candidates = byFirstChar[input[i]]
var match: String? = null
if (candidates != null) {
for (url in candidates) {
if (input.startsWith(url, i)) {
match = url
break
}
}
}
result.append(word)
if (match != null) {
// Separate from a glued prefix character.
if (result.isNotEmpty()) {
val prev = result[result.length - 1]
if (prev != ' ' && prev != '\n') result.append(' ')
}
// Add space + suffix if the suffix exists
if (suffix.isNotEmpty()) {
result.append(" ")
result.append(suffix)
result.append(match)
i += match.length
// Separate from a glued suffix character.
if (i < length) {
val next = input[i]
if (next != ' ' && next != '\n') result.append(' ')
}
} else {
result.append(input[i])
i++
}
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()))
}
}