Files
ai-app/HIGHLIGHTER_PLAN.md
T
irisandClaude Fable 5.1 f6bc868d55 Plan the replacement of the code highlighter with a scanner of our own
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>
2026-09-03 19:42:32 -04:00

247 lines
14 KiB
Markdown

# 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 until `highlight` started 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}` and `a#b` are 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:
optional `b`, then `r`, then `n` hashes, then `"`, closing at `"`
followed by `n` hashes, 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 `'a` opens 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's
`SyntaxTokens.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's `as?`, `!in`, `!is`; Swift's `#if` family; Ruby's `defined?`;
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`). Add `ron`, `toml`, `fish`
and `json` to the alias table in `CodeFence.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`. The
`DebugStats.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`: remove `highlight` and the library imports; retarget
`fenceLanguage` and `FENCE_LANGUAGES`; add the four aliases.
- `ToolInput.kt`: `SyntaxLanguage` becomes `Language` in `ToolInput` and
`SUBJECTS`. `ToolInputView` keeps calling `highlight` uncached.
- `Markdown.kt`: the import at line 58 and the signature of
`ParsedReplies.highlighted`. The cache key `"$language\n$code"` works
unchanged with an enum.
- `Theme.kt`: `catppuccinSyntax()` returns `SyntaxPalette` of `Color`s
rather than the library's theme of ints.
- `app/gradle/libs.versions.toml` and `app/androidApp/build.gradle.kts`:
remove `highlights` and 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.md` rewrite 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". In `AGENTS.md`, add `:androidApp:testDebugUnitTest` to
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), the
`find . -path '*/.git/*'` line, `curl https://example.com/x && echo
done`, and `val 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.
- `'a` as 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 # real` in 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: `highlight` returns 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
1. 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.
2. Timing. `TRANSCRIPT_RENDERING.md` gives the library at 174ms for a
two-hundred-line Kotlin fence on the emulator. Regenerate
`/tmp/longfence.md` from that description and read `code highlighted`
from the render report after `app/transcript-bench.sh`; expect single
milliseconds. Run `app/transcript-bench.sh` and `app/stream-bench.sh`
before and after, and put the report lines named in that doc's item 3
into the commit message.
3. Look at it. `app/run-android.sh`, send `/tmp/fixture.md` through
`app/ui-sandbox.sh` (both described in `TRANSCRIPT_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.
4. Commit on `main` with `git add -A`, and push.
## Traps
- `fenceContent` is shared by the drawing and the warming and must stay
the single extraction; nothing here should touch it.
- The `Color` constructor in `highlight` currently ORs in an alpha byte
because the library's ints had none. With `Color` values in the palette
that goes away; do not carry the mask over.
- Keyword lists are per language in the library's `DEFAULT` case merged
into one; there is no `DEFAULT` here, 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 `SHELL` for Bash and
null for everything else. Both must keep compiling without a cast.
- ktfmt will reflow the keyword tables; let it.