Compare commits

...

2 Commits

Author SHA1 Message Date
Claude
c2602647f5 refactor: improve RichTextParser readability after index-based optimization
Extract responsibilities that were inlined during the no-split optimization:

- parseParagraphLine(): encapsulates single-line parsing (trimEnd, empty
  check, fast path, word classification) so findTextSegments() becomes a
  clean newline-scanning loop.
- isAllPlainText(): names the first-pass "all words plain?" loop that was
  previously embedded inside parseParagraphLine.
- isPlainWord(): renames isRegularByIndex() — the "ByIndex" suffix was an
  implementation detail; the new name states what the function checks.
- mergeIfAllRegular(): extracts the post-processing lambda in
  findTextSegments that re-joins all-RegularTextSegment paragraphs.
- parseSchemelessUrl(): extracts the 8-line nested schemeless-URL block
  from wordIdentifier() into its own function returning nullable.

Additional cleanup:
- Add category section comments to wordIdentifier() (media, emoji,
  Lightning/Cashu, Nostr/NIP-19, contact info, schemeless URL).
- Rename removeQueryParamsForExtensionComparison() →
  stripUrlForExtensionCheck(); also strips # fragments, which the old
  name didn't mention. Update all three call sites.
- Group companion object members with section headers (date/number
  patterns, file extensions, URL patterns, Nostr/NIP-19, hash tags).
- Remove stale comments that referenced the old split()-based
  implementation ("mirror ... from the original", etc.).

No behavior or performance changes.

https://claude.ai/code/session_01NXsow7yLBd6ModmGSsqR9g
2026-02-28 17:52:38 +00:00
Claude
66fb5d6f85 perf(commons/richtext): eliminate string splits in RichTextParser
Replace content.split('\n') and paragraph.split(' ') with index-based
scanning so no intermediate List<String> or line-substring allocations
are created during paragraph/word tokenization.

Key changes to findTextSegments:
- Scan for '\n' via indexOf to determine line boundaries without
  allocating a List<String> or any line substrings.
- Compute trimEnd boundary in-place (no trimEnd() String copy).
- For each line, do a first pass with isRegularByIndex() that checks
  every word using character-level inspection without creating substrings.
  If all words are plain text the entire paragraph is represented as a
  single RegularTextSegment(content.substring(lineStart, actualEnd)),
  avoiding M per-word String allocations and the later joinToString.
- Only when a paragraph contains special tokens (URLs, hashtags,
  mentions, emoji, etc.) fall back to the existing wordIdentifier()
  path, which produces individual Segment objects as before.
- Short-circuit the final map pass for single-segment paragraphs
  (already optimal) to skip an unnecessary joinToString + object creation.

New helpers:
- isRegularByIndex(): character-level fast path that detects all
  token types (http/https, lnbc, cashu, NIP-19, emoji, email, phone,
  schemeless URL, EmojiCoder variation selectors) without creating
  a substring.  Returns true only when the word is definitely plain text.
- isArabicInRange(): RTL detection directly on the source string range,
  replacing the substring-based isArabic().

Behaviour is identical: the same Segment types are produced in the same
order for every input, as validated by the existing test suite.

https://claude.ai/code/session_01NXsow7yLBd6ModmGSsqR9g
2026-02-28 00:59:57 +00:00

View File

@@ -69,7 +69,7 @@ class RichTextParser {
isImage = fullUrl.startsWith("data:image/")
isVideo = fullUrl.startsWith("data:video/")
} else {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
val removedParamsFromUrl = stripUrlForExtensionCheck(fullUrl)
isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
}
@@ -169,34 +169,200 @@ class RichTextParser {
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): ImmutableList<ParagraphState> {
val lines = content.split('\n')
val paragraphSegments = ArrayList<ParagraphState>(lines.size)
val paragraphs = ArrayList<ParagraphState>()
var lineStart = 0
lines.forEach { paragraph ->
val isRTL = isArabic(paragraph)
val wordList = paragraph.trimEnd().split(' ')
val segments = ArrayList<Segment>(wordList.size)
wordList.forEach { word ->
segments.add(wordIdentifier(word, images, videos, urls, emojis, tags))
}
paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL))
// Scan for newline boundaries without splitting the string into a List<String>.
while (lineStart <= content.length) {
val nlIdx = content.indexOf('\n', lineStart)
val lineEnd = if (nlIdx < 0) content.length else nlIdx
paragraphs.add(parseParagraphLine(content, lineStart, lineEnd, images, videos, urls, emojis, tags))
lineStart = lineEnd + 1
}
val segmentsWithGalleries = GalleryParser().processParagraphs(paragraphSegments)
return GalleryParser()
.processParagraphs(paragraphs)
.map { mergeIfAllRegular(it) }
.toImmutableList()
}
return segmentsWithGalleries
.map { paragraph ->
if (paragraph.words.isEmpty() || paragraph.words.any { it !is RegularTextSegment }) {
paragraph
} else {
ParagraphState(
persistentListOf<Segment>(RegularTextSegment(paragraph.words.joinToString(" ") { it.segmentText })),
paragraph.isRTL,
/**
* Parses a single line of [content] (from [lineStart] to [lineEnd], exclusive) into a
* [ParagraphState]. Trailing whitespace is ignored. For lines where every word is plain text,
* a single [RegularTextSegment] is returned without allocating per-word substrings.
*/
private fun parseParagraphLine(
content: String,
lineStart: Int,
lineEnd: Int,
images: Set<String>,
videos: Set<String>,
urls: Set<String>,
emojis: Map<String, String>,
tags: ImmutableListOfLists<String>,
): ParagraphState {
val isRTL = isArabicInRange(content, lineStart, lineEnd)
// Strip trailing whitespace without creating a substring.
var actualEnd = lineEnd
while (actualEnd > lineStart && content[actualEnd - 1].isWhitespace()) actualEnd--
if (actualEnd <= lineStart) {
// Empty or all-whitespace line.
return ParagraphState(persistentListOf(RegularTextSegment("")), isRTL)
}
// Fast path: if every word is plain text, return one segment for the whole line.
if (isAllPlainText(content, lineStart, actualEnd, emojis)) {
return ParagraphState(
persistentListOf(RegularTextSegment(content.substring(lineStart, actualEnd))),
isRTL,
)
}
// Mixed line: classify each space-delimited word individually.
val segments = ArrayList<Segment>()
var wordStart = lineStart
while (wordStart < actualEnd) {
val spIdx = content.indexOf(' ', wordStart)
val wordEnd = if (spIdx < 0 || spIdx >= actualEnd) actualEnd else spIdx
segments.add(wordIdentifier(content.substring(wordStart, wordEnd), images, videos, urls, emojis, tags))
wordStart = wordEnd + 1
}
return ParagraphState(segments.toPersistentList(), isRTL)
}
/**
* Returns true when every space-delimited word in content[lineStart, lineEnd) is plain text
* that needs no special rendering. A word is plain if [isPlainWord] returns true for it.
*/
private fun isAllPlainText(
content: String,
lineStart: Int,
lineEnd: Int,
emojis: Map<String, String>,
): Boolean {
var wordStart = lineStart
while (wordStart < lineEnd) {
val spIdx = content.indexOf(' ', wordStart)
val wordEnd = if (spIdx < 0 || spIdx >= lineEnd) lineEnd else spIdx
if (!isPlainWord(content, wordStart, wordEnd, emojis)) return false
wordStart = wordEnd + 1
}
return true
}
/**
* If [paragraph] contains only [RegularTextSegment] words, merges them back into a single
* segment joined by spaces. This collapses paragraphs that were split word-by-word but turned
* out to be entirely plain text (e.g. after gallery post-processing).
*/
private fun mergeIfAllRegular(paragraph: ParagraphState): ParagraphState {
if (paragraph.words.size <= 1) return paragraph
if (paragraph.words.any { it !is RegularTextSegment }) return paragraph
return ParagraphState(
persistentListOf(RegularTextSegment(paragraph.words.joinToString(" ") { it.segmentText })),
paragraph.isRTL,
)
}
/**
* Returns true when the word at content[wordStart, wordEnd) is definitely plain text and does
* not need a full [wordIdentifier] classification. Conservative: returning false only means
* the caller should run the full check; it never produces a wrong result.
*/
private fun isPlainWord(
content: String,
wordStart: Int,
wordEnd: Int,
emojis: Map<String, String>,
): Boolean {
val len = wordEnd - wordStart
if (len == 0) return true
val c0 = content[wordStart]
// Quick first-character reject for token types that always start with a known character.
when (c0) {
'#' -> return false // hashtag (#hashtag) or tag reference (#[n])
'@' -> return false // @npub… NIP-19 mention
'd', 'D' -> if (len > 11 && content.startsWith("data:image/", wordStart)) return false
'l', 'L' -> {
if (len > 4 && content.startsWith("lnbc", wordStart, ignoreCase = true)) return false
if (len > 5 && content.startsWith("lnurl", wordStart, ignoreCase = true)) return false
}
'c', 'C' -> {
if (len > 6 &&
(
content.startsWith("cashuA", wordStart, ignoreCase = true) ||
content.startsWith("cashuB", wordStart, ignoreCase = true)
)
) return false
}
'n', 'N' -> {
// nostr: prefix or NIP-19 bech32 schemes (npub1, note1, naddr1, nevent1, nprofile1, nembed)
if (len >= 5) {
if (content.startsWith("nostr:", wordStart, ignoreCase = true)) return false
if (wordStart + 1 < wordEnd) {
when (content[wordStart + 1]) {
'p', 'P' ->
if (content.startsWith("npub1", wordStart, ignoreCase = true) ||
content.startsWith("nprofile1", wordStart, ignoreCase = true)
) return false
'o', 'O' -> if (content.startsWith("note1", wordStart, ignoreCase = true)) return false
'a', 'A' -> if (content.startsWith("naddr1", wordStart, ignoreCase = true)) return false
'e', 'E' ->
if (content.startsWith("nevent1", wordStart, ignoreCase = true) ||
content.startsWith("nembed", wordStart, ignoreCase = true)
) return false
}
}
}
}.toImmutableList()
}
'h', 'H' -> {
// Only http(s):// words can be in the URL sets (parseValidUrls filters by HTTPRegex).
if (len >= 7 &&
(
content.startsWith("http://", wordStart, ignoreCase = true) ||
content.startsWith("https://", wordStart, ignoreCase = true)
)
) return false
}
}
// Single-pass character scan: detect markers that require full classification.
var isPotentialPhone = len in 7..14
var hasMidPeriod = false
for (i in wordStart until wordEnd) {
val c = content[i]
val code = c.code
when {
c == ':' && emojis.isNotEmpty() -> return false // custom emoji :name: format
c == '@' -> return false // email address
c == '.' && i > wordStart && i < wordEnd - 1 -> hasMidPeriod = true // possible schemeless URL
code in 0xFE00..0xFE0F -> return false // Unicode variation selectors (EmojiCoder)
code == 0xDB40 -> return false // high surrogate for variation-selector supplement (EmojiCoder)
isPotentialPhone && c !in '0'..'9' && c != '-' && c != ' ' && c != '.' -> isPotentialPhone = false
}
}
// Defer to wordIdentifier to confirm whether these are actually a phone / schemeless URL.
if (isPotentialPhone) return false
if (hasMidPeriod) return false
return true
}
private fun isArabicInRange(
content: String,
start: Int,
end: Int,
): Boolean {
for (i in start until end) {
val c = content[i]
if (c in '\u0600'..'\u06FF' || c in '\u0750'..'\u077F') return true
}
return false
}
private fun isNumber(word: String) = numberPattern.matches(word)
@@ -225,8 +391,6 @@ class RichTextParser {
fun isDate(word: String): Boolean = shortDatePattern.matches(word) || longDatePattern.matches(word)
private fun isArabic(text: String): Boolean = text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' }
private fun wordIdentifier(
word: String,
images: Set<String>,
@@ -237,58 +401,54 @@ class RichTextParser {
): Segment {
if (word.isEmpty()) return RegularTextSegment(word)
// Inline media and detected URL sets
if (word.startsWith("data:image/")) {
if (Patterns.BASE64_IMAGE.matches(word)) return Base64Segment(word)
}
if (images.contains(word)) return ImageSegment(word)
if (videos.contains(word)) return VideoSegment(word)
if (urls.contains(word)) return LinkSegment(word)
// Custom emoji
if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word)
// Lightning / Cashu payments
if (word.startsWith("lnbc", true)) return InvoiceSegment(word)
if (word.startsWith("lnurl", true)) return WithdrawSegment(word)
if (word.startsWith("cashuA", true) || word.startsWith("cashuB", true)) return CashuSegment(word)
// Nostr / NIP-19 identifiers
if (word.startsWith("#")) return parseHash(word, tags)
if (EmojiCoder.isCoded(word)) return SecretEmoji(word)
if (startsWithNIP19Scheme(word)) return BechSegment(word)
// Contact info
if (word.contains("@")) {
if (Patterns.EMAIL_ADDRESS.matches(word)) return EmailSegment(word)
}
if (startsWithNIP19Scheme(word)) return BechSegment(word)
if (isPotentialPhoneNumber(word) && !isDate(word)) {
if (Patterns.PHONE.matches(word)) return PhoneSegment(word)
}
val indexOfPeriod = word.indexOf(".")
if (indexOfPeriod > 0 && indexOfPeriod < word.length - 1) { // periods cannot be the last one
val schemelessMatcher = noProtocolUrlValidator.find(word)
if (schemelessMatcher != null) {
val url = schemelessMatcher.groups[1]?.value // url
val additionalChars = schemelessMatcher.groups[4]?.value?.ifEmpty { null } // additional chars
if (additionalUrlSchema.find(word) != null && url != null) {
return SchemelessUrlSegment(word, url, additionalChars)
}
}
}
// Schemeless URL fallback
return parseSchemelessUrl(word) ?: RegularTextSegment(word)
}
return RegularTextSegment(word)
private fun parseSchemelessUrl(word: String): SchemelessUrlSegment? {
val indexOfPeriod = word.indexOf('.')
if (indexOfPeriod <= 0 || indexOfPeriod >= word.length - 1) return null
val schemelessMatcher = noProtocolUrlValidator.find(word) ?: return null
val url = schemelessMatcher.groups[1]?.value ?: return null
val additionalChars = schemelessMatcher.groups[4]?.value?.ifEmpty { null }
if (additionalUrlSchema.find(word) == null) return null
return SchemelessUrlSegment(word, url, additionalChars)
}
private fun parseHash(
word: String,
tags: ImmutableListOfLists<String>,
): Segment {
// First #[n]
// First #[n] — tag index reference
try {
val matcher = tagIndex.find(word)
if (matcher != null) {
@@ -312,7 +472,7 @@ class RichTextParser {
Log.w("Tag Parser", "Couldn't link tag $word", e)
}
// Second #Amethyst
// Second #Amethyst — plain hashtag
try {
val hashtagMatcher = hashTagsPattern.find(word)
if (hashtagMatcher != null) {
@@ -330,11 +490,21 @@ class RichTextParser {
}
companion object {
// --- Date / number patterns ---
val longDatePattern: Regex = Regex("^\\d{4}-\\d{2}-\\d{2}$")
val shortDatePattern: Regex = Regex("^\\d{2}-\\d{2}-\\d{2}$")
val numberPattern: Regex = Regex("^(-?[\\d.]+)([a-zA-Z%]*)$")
// Android9 seems to have an issue starting this regex.
// --- File extension lists ---
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
// --- URL validation patterns ---
// Android 9 seems to have an issue starting this regex.
val noProtocolUrlValidator =
try {
Regex(
@@ -354,23 +524,20 @@ class RichTextParser {
"^((http|https)://)?([A-Za-z0-9-_]+(\\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\\?[^#]*)?(#.*)?"
.toRegex(RegexOption.IGNORE_CASE)
val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
val imageExtensions = imageExt + imageExt.map { it.uppercase() }
val videoExtensions = videoExt + videoExt.map { it.uppercase() }
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Regex =
Regex("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", RegexOption.IGNORE_CASE)
// --- Nostr / NIP-19 ---
val acceptedNIP19schemes =
listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1", "nembed") +
listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1", "nembed").map {
it.uppercase()
}
private fun removeQueryParamsForExtensionComparison(fullUrl: String): String =
// --- Hash tag / tag index ---
val tagIndex = Regex("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Regex =
Regex("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", RegexOption.IGNORE_CASE)
// Strips query string and fragment from a URL so the bare path extension can be compared.
private fun stripUrlForExtensionCheck(fullUrl: String): String =
if (fullUrl.contains("?")) {
fullUrl.split("?")[0]
} else if (fullUrl.contains("#")) {
@@ -380,20 +547,19 @@ class RichTextParser {
}
fun isImageOrVideoUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return imageExtensions.any { removedParamsFromUrl.endsWith(it) } ||
videoExtensions.any { removedParamsFromUrl.endsWith(it) }
val bare = stripUrlForExtensionCheck(url)
return imageExtensions.any { bare.endsWith(it) } ||
videoExtensions.any { bare.endsWith(it) }
}
fun isImageUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val bare = stripUrlForExtensionCheck(url)
return imageExtensions.any { bare.endsWith(it) }
}
fun isVideoUrl(url: String): Boolean {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(url)
return videoExtensions.any { removedParamsFromUrl.endsWith(it) }
val bare = stripUrlForExtensionCheck(url)
return videoExtensions.any { bare.endsWith(it) }
}
fun isValidURL(url: String?): Boolean =
@@ -407,9 +573,9 @@ class RichTextParser {
}
fun parseImageOrVideo(fullUrl: String): BaseMediaContent {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
val isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) }
val isVideo = videoExtensions.any { removedParamsFromUrl.endsWith(it) }
val bare = stripUrlForExtensionCheck(fullUrl)
val isImage = imageExtensions.any { bare.endsWith(it) }
val isVideo = videoExtensions.any { bare.endsWith(it) }
return if (isImage) {
MediaUrlImage(fullUrl)