mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
Merge pull request #3164 from vitorpamplona/claude/focused-faraday-z9zs87
Work around Compose ui-uikit prebuilt cache linking failure
This commit is contained in:
@@ -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 {
|
||||
|
||||
+54
-26
@@ -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(
|
||||
|
||||
+62
@@ -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()))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user