perf(commons): index URLs by first char in fixMissingSpaces

fixMissingSpaces runs on the main thread once per rendered note. The scan
introduced in the previous commit re-tested every detected URL at every
character position (and allocated an iterator per position via firstOrNull),
i.e. O(N*U*L) for a note with U URLs — noticeable on large notes that carry
many links.

Bucket the URLs by their first character once up front and only attempt a
match at positions whose character can actually start a URL; every other
character now costs a single map lookup, keeping the pass linear in the text
length for typical content. Buckets stay longest-first so prefix URLs still
don't shadow longer ones, so the output is identical (verified against the
commons richtext JVM corpus).
This commit is contained in:
Claude
2026-06-09 22:52:00 +00:00
parent f42cc836aa
commit 84497bbd47
@@ -144,22 +144,46 @@ class RichTextParser {
// 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 }
// 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) }
val result = StringBuilder(input.length)
val length = input.length
var i = 0
while (i < input.length) {
val match = urls.firstOrNull { input.startsWith(it, i) }
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
}
}
}
if (match != null) {
// Separate from a glued prefix character.
val prev = result.lastOrNull()
if (prev != null && prev != ' ' && prev != '\n') result.append(' ')
if (result.isNotEmpty()) {
val prev = result[result.length - 1]
if (prev != ' ' && prev != '\n') result.append(' ')
}
result.append(match)
i += match.length
// Separate from a glued suffix character.
if (i < input.length) {
if (i < length) {
val next = input[i]
if (next != ' ' && next != '\n') result.append(' ')
}