The highlights library finds comments before it knows the language and pairs quotes and comment delimiters by ordinal; measured on 2026-09-03, that greys out every Rust attribute, comments out the rest of a line at a URL or a # inside a string, and opens a bogus string at an apostrophe. Upstream's only fix since 1.1.0 is unreleased and covers just the reversed range we already drop. The plan replaces it with one sequential scanner and a table of languages, adding RON, TOML, fish and JSON, which real transcripts use and the library never knew. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
14 KiB
Replacing the code highlighter with a scanner of our own
Written 2026-09-03 for whichever session does the work. Bryan decided on
this after the assessment below; TRANSCRIPT_RENDERING.md records the
measurements that led here and stays the record of the transcript work,
and PLAN.md stays the design source of truth for the app as a whole.
Nothing here contradicts either. Work goes on main, committed and pushed
when it is verified, as usual.
Why
The app colours code in two places -- a fenced block in a reply and the
command in a tool call's card -- through one function, highlight in
app/androidApp/src/main/kotlin/com/example/aiapp/CodeFence.kt, backed by
the library dev.snipme:highlights 1.1.0. That library finds comments
before it knows the language, using //, # and /* */ for every
language, then suppresses keywords and strings under whatever it found;
it pairs quotes by ordinal per quote character; and it pairs /* with
*/ by ordinal too. Measured against the library directly on 2026-09-03:
- Any language:
//inside a URL comments out the rest of the line, and a string holding a URL loses its colour inside the comment. - Rust: every attribute line,
#[derive(Debug)], is greyed as a comment. - Kotlin: a
#inside a string ("#FF0000") comments out the rest of the line; an apostrophe inside a double-quoted string ("don't") opens a bogus single-quoted string that runs to the next apostrophe in the block. - Shell:
x '*/a/*'produced a span whose end preceded its start, which crashed the app untilhighlightstarted dropping such spans.
Upstream merged a one-line fix for the last of these in August 2026 and has
not released it; nothing else moved. Post-processing the library's output
cannot restore what it already suppressed, and forking it means rewriting
its analyzer, which is the part that is wrong. No maintained pure-Kotlin
alternative exists on Maven Central; tree-sitter would cost the NDK on
every machine that builds the APK and one to five megabytes per language
per ABI. So the highlighter becomes ours: one sequential scanner and a
table of languages. The rule in prefer-libraries-for-specs still holds
in general; this is the case it allows, where the only library is a worse
hand-rolled scanner than the one we would write.
Counting fence words across the Claude Code transcripts on the dev VM says which languages matter: shell in its variants 181, kotlin 119, rust 98, ron 67, then toml, python, java, js, groovy, json and diff at three to eight each. RON, TOML and fish are not known to the library at all today, so those fences are drawn plain; the scanner colours them from day one.
Shape
Two new files under app/androidApp/src/main/kotlin/com/example/aiapp/,
and the library removed.
Highlighter.kt holds the scanner, the palette type, and highlight.
The signature stays fun highlight(code: String, language: Language?): AnnotatedString, returning plain text for a null language, so the two
callers and the cache do not change shape. It is not a composable and
takes no colour from the theme, for the reason CodeFence.kt already
states: ParsedReplies.warm runs it off the drawing thread. The palette
is a plain SyntaxPalette value class of our own -- keyword, string,
literal, comment, metadata, punctuation, mark, as Color -- returned by
catppuccinSyntax() in Theme.kt, which currently builds the library's
SyntaxTheme from ARGB ints. The library's separate multiline-comment
colour collapses into comment; the palette already gave both Overlay0.
The scanner is one pass over the code with a small state: at each
position it is in a line comment, a block comment (with a nesting depth
where the language nests them), a string (with its closing delimiter and
whether backslash escapes apply), or ordinary code. In ordinary code it
recognises, in this order, a comment opener, a string opener, an
attribute, a number, a word, and finally single punctuation or mark
characters. Every span it emits is produced by advancing an index
forward, so spans are non-overlapping and ordered by construction and the
reversed-range filter goes with the library. Spans map onto the palette
as: keyword, string, literal (numbers), comment, metadata (attributes and
annotations), punctuation (, . : ;) and mark (( ) = { } < > - + [ ] | &). Those two character sets are the library's, kept so nothing the
reader is used to changes colour except where it was wrong; punctuation
and marks are only coloured in ordinary code, never inside a string or a
comment, which is the one visible difference on correct input (a . in a
URL inside a string used to take the punctuation colour).
A word is [A-Za-z_][A-Za-z0-9_]* and is a keyword if the language's set
contains it. A number is a word starting with a digit and continuing
through letters, digits, _ and ., which covers 0xFF, 1_000,
1u32 and 3.14 without a grammar. A date in TOML comes out as digits
with marks between; leave that, it is untidy rather than misleading.
Languages.kt holds enum class Language and a Rules value per
member. Keep the vocabulary the code already uses: the enum replaces the
library's SyntaxLanguage under the name Language, and fenceLanguage
in CodeFence.kt keeps its name and its alias table, retargeted. Rules
is data, not code, and is what makes a new language a table row:
lineComments: the tokens that open a comment to end of line, and whether they count only at the start of a word. Shell and fish need the word-start rule, since$#,${#x}anda#bare not comments; a#counts there when it is preceded by nothing, whitespace, or one of;,|,&,(. Python, Ruby, Perl, TOML:#anywhere. C-family, Kotlin, Rust, RON, Go, Swift, Dart, JavaScript, TypeScript, Java, C#, PHP://; PHP also#.blockComment: the pair, or none, and whether it nests./* */for the C-family and friends; Rust, Kotlin and RON nest; C, Java, JavaScript, Go do not. Python, Ruby, shell, fish, TOML: none.quotes: each string form as opener, closer, and whether backslash escapes the closer. Double quotes with escapes nearly everywhere; single quotes with escapes in the C-family (a character literal reads fine as a string); single quotes with no escapes in shell and TOML; fish single quotes escape only\'and\\; triple quotes in Kotlin (""", no escapes), Python ("""and''') and TOML ("""and'''). Longest opener wins, so"""is tried before".- Two string forms need a line of code rather than a row, and belong in
the scanner behind a flag on
Rules. Rust and RON raw strings: optionalb, thenr, thennhashes, then", closing at"followed bynhashes, no escapes. Rust lifetimes: a'followed by a backslash or by one character and a'is a character literal; otherwise it is a lifetime or label and no string starts. Without that rule'aopens a string that runs to the next apostrophe. attributes: what opens a metadata span.@followed by a word for Kotlin, Java, Dart, Swift, TypeScript, PHP, Python (decorators);#[or#![through the matching]for Rust and RON; a#at the start of a line through end of line for C and C++ (preprocessor directives, which the library greyed out as comments); a[at the start of a line through]for TOML (table headers). Nothing for the rest.keywords: the set. Bring the lists over from the library'sSyntaxTokens.kt(Apache-2.0; say so in a comment at the table), for every language the alias table names today, so no fence that is coloured now turns plain. Drop entries that are not plain words -- Kotlin'sas?,!in,!is; Swift's#iffamily; Ruby'sdefined?; CoffeeScript's=and->-- since the word scanner cannot reach them and the library only matched them by luck. Add rows for RON (true false Some None inf NaN), TOML (true false inf nan), fish (and begin break builtin case command continue else end exec for function if in not or return switch while set echo test string math read source) and JSON (true false null). Addron,toml,fishandjsonto the alias table inCodeFence.kt.
The streaming arrangement stays as it is: a fence still being written
is drawn plain and coloured when it freezes. That decision was made
against a lexer costing 174ms for two hundred lines; a linear scanner may
make live colouring affordable, but that is a follow-up to measure with
app/stream-bench.sh, not something to assume here.
Files
Highlighter.kt, new: scanner,SyntaxPalette,highlight. TheDebugStats.timed("code highlighted")wrapper stays, as the number the before-and-after is read from.Languages.kt, new:Language,Rules, the rows and keyword sets.CodeFence.kt: removehighlightand the library imports; retargetfenceLanguageandFENCE_LANGUAGES; add the four aliases.ToolInput.kt:SyntaxLanguagebecomesLanguageinToolInputandSUBJECTS.ToolInputViewkeeps callinghighlightuncached.Markdown.kt: the import at line 58 and the signature ofParsedReplies.highlighted. The cache key"$language\n$code"works unchanged with an enum.Theme.kt:catppuccinSyntax()returnsSyntaxPaletteofColors rather than the library's theme of ints.app/gradle/libs.versions.tomlandapp/androidApp/build.gradle.kts: removehighlightsand its comment.app/highlights-repro.sh: delete. It exists only to interrogate the library, and its cases move into the test below; this is the harness being replaced by a better one, not thrown away.app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt, new: see next section.- Docs, in the same commit: in
TRANSCRIPT_RENDERING.mdrewrite the fences paragraph's mention of the library, replace the "highlights 1.1.0 finds comments before it knows the language" bullet with a short description of the scanner and where its rows live, and drop item 2 of "What is next". InAGENTS.md, add:androidApp:testDebugUnitTestto the app line under "Checking your work". Prune rather than append.
Tests
The app has no unit test source set today. The scanner is pure logic
with no Android imports, which is exactly the case CODE_RULES names for
adding tests, and the cost of the harness is one line,
testImplementation(kotlin("test")), run by
./gradlew :androidApp:testDebugUnitTest. Keep Highlighter.kt and
Languages.kt free of Android and Compose imports other than
AnnotatedString, SpanStyle and Color (which are plain JVM classes),
so the test runs on the JVM without Robolectric. Better still, have the
scanner return a list of (range, kind) spans and let highlight be the
thin wrapper that turns them into an AnnotatedString: the test then
asserts on the list, which prints legibly on failure, and the test does
not depend on Compose loading at all.
Cases, each asserting the spans against the code so a failure prints what was coloured:
- The four inputs from the deleted script:
x '*/a/*'(shell), thefind . -path '*/.git/*'line,curl https://example.com/x && echo done, andval url = "https://example.com"\nfun f() = 1. #[derive(Debug)]then a struct in Rust: metadata, then keywords."#FF0000"and"don't"in Kotlin: two strings, no comment.'aas a Rust lifetime beside'x'as a character: one string.r#"a "quoted" b"#in Rust: one string.- Nested
/* a /* b */ c */in Rust and Kotlin: one comment to the end; the same in C: comment ends at the first*/. ${#x} $# a#b # realin shell: one comment, starting at the last#."""a "b" c"""in Kotlin: one string.- A TOML table header and a key with a
#in a quoted value. - A language the alias table does not know:
highlightreturns the plain string. - Every emitted span is inside the code and starts before it ends, over
a handful of nasty inputs (unterminated string, unterminated block
comment, a lone
', empty code). The scanner must never throw; an unterminated thing runs to the end of the code.
Verification
- From
app/:. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat :androidApp:compileDebugKotlin :androidApp:lintDebug :androidApp:testDebugUnitTest. Lint must stay clean; an unused dependency or import it reports is a finding. - Timing.
TRANSCRIPT_RENDERING.mdgives the library at 174ms for a two-hundred-line Kotlin fence on the emulator. Regenerate/tmp/longfence.mdfrom that description and readcode highlightedfrom the render report afterapp/transcript-bench.sh; expect single milliseconds. Runapp/transcript-bench.shandapp/stream-bench.shbefore and after, and put the report lines named in that doc's item 3 into the commit message. - Look at it.
app/run-android.sh, send/tmp/fixture.mdthroughapp/ui-sandbox.sh(both described inTRANSCRIPT_RENDERING.md), and screenshot a fence holding a URL in a string, a Rust attribute, a shell line with a glob and a$#, a RON fence, and a TOML fence. Check a tool card's Bash command still colours, since that is the other caller. Check a fence in an unknown language is plain, and that a fence still streaming is plain and colours when the next block starts. Per the code rule about trying a fix on the half that cannot fail, look at a fence the change had no reason to touch -- a plain Python function -- and confirm it looks as it did. - Commit on
mainwithgit add -A, and push.
Traps
fenceContentis shared by the drawing and the warming and must stay the single extraction; nothing here should touch it.- The
Colorconstructor inhighlightcurrently ORs in an alpha byte because the library's ints had none. WithColorvalues in the palette that goes away; do not carry the mask over. - Keyword lists are per language in the library's
DEFAULTcase merged into one; there is noDEFAULThere, since a null language is plain. - The two callers pass different things: fences pass what the alias
table answered for the info word, tool cards pass
SHELLfor Bash and null for everything else. Both must keep compiling without a cast. - ktfmt will reflow the keyword tables; let it.