Colour code with a scanner of our own instead of the library
dev.snipme:highlights 1.1.0 found comments before it knew the language and paired /* with */ by ordinal, so `//` in any URL commented out the rest of its line, every Rust `#[derive(...)]` greyed out as a comment, a `#` inside a Kotlin string swallowed the line, and `x '*/a/*'` in shell produced a span whose end preceded its start -- the one that crashed a card holding `-path '*/.git/*'`. None of that could be post-processed away, because comments won over strings before the language was known. Highlighter.kt is one left-to-right scanner: at each position it is in a line comment, a block comment, a string, or ordinary code, and every span is emitted by advancing an index, so spans cannot overlap, arrive out of order or run backwards. Languages.kt is a `Rules` row per language -- comment tokens, block comment and whether it nests, the string forms, what opens an attribute, and the keyword set -- so a new language is a table entry. The keyword lists came from the library's SyntaxTokens.kt (Apache-2.0, noted at the table) so nothing that is coloured today turns plain, and RON, TOML, fish and JSON are coloured for the first time. HighlighterTest.kt is a new JVM unit test source set -- 24 cases, the library's mistakes kept as regressions, plus a sweep asserting no span escapes the code for any language on unterminated and empty input. AGENTS.md's app line now runs :androidApp:testDebugUnitTest. Measured on the ai-app emulator, debug build, a ~200-line Kotlin fence sent into a sandbox session: before code highlighted: 1, 101.9ms total, 101.9ms mean, 101.9ms worst after code highlighted: 1, 15.0ms total, 15.0ms mean, 15.0ms worst and a second fence in the same run took 13.9ms, so that is the steady cost rather than class loading. stream-bench.sh after the change: code highlighted: 1, 12.1ms total, 12.1ms mean, 12.1ms worst markdown reparsed while streaming: 1329, 2130.7ms total, 1.6ms mean, 8.7ms worst record: one block: 131, 11.4ms total, 0.1ms mean, 0.4ms worst draw phase 1.21ms per frame, the transcript 0.23ms of it transcript-bench.sh after: draw phase 1.10ms per frame, the transcript 0.49ms (place 0.48), worst place 4.3ms -- unchanged within run-to-run noise, as expected, since the scan happens in `warm` and not while drawing. Looked at on the emulator: a URL inside a Kotlin string, a Rust attribute with a lifetime and a raw string, a shell line with globs and `$#`, a RON fence and a TOML fence all colour correctly; a Bash tool card still colours its command; a plain Python fence -- which this change had no reason to touch -- looks as it did; an unknown language stays plain; and a fence is plain while it streams and colours when it freezes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3d90e0947c
commit
a2b11d516f
13 files changed
+1136
-495
No files matched your search
@@ -220,8 +220,11 @@ first if a remote spawn ever mangles an argument.
|
||||
warning-clean and rustfmt-clean at the defaults — there is no
|
||||
`rustfmt.toml` and there should not be one.
|
||||
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:ktfmtFormat
|
||||
:androidApp:compileDebugKotlin :androidApp:lintDebug` — format, typecheck
|
||||
and lint, the app-side equivalent of the line above. Then `./build-apk.sh`
|
||||
:androidApp:compileDebugKotlin :androidApp:lintDebug
|
||||
:androidApp:testDebugUnitTest` — format, typecheck, lint and test, the
|
||||
app-side equivalent of the line above. The unit tests are JVM-only and
|
||||
cover the syntax highlighter's scanner, which is the app's one piece of
|
||||
pure logic with no Android in it. Then `./build-apk.sh`
|
||||
to produce the APK to install on a phone (through Dev Updater), or
|
||||
`./run-android.sh` to build, install, and launch on the emulator.
|
||||
**The phone gets the release build**, signed with a key the script
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
# 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.
|
||||
+36
-43
@@ -107,17 +107,18 @@ time went from 2412ms of reparsing to 674ms, and `record: one block` from
|
||||
1.8ms worst to 0.7ms.
|
||||
|
||||
**Fences are highlighted off the drawing thread, and a fence still being
|
||||
written is drawn plain.** `CodeFence.kt` holds `highlight` (shared with a
|
||||
tool call's input, so the same code is the same colours wherever it
|
||||
appears), the `fenceLanguage` alias table, and `fenceContent`. A word not in
|
||||
the table stays plain, because a fence coloured by the wrong language's
|
||||
rules looks highlighted and is wrong in a way the reader cannot see.
|
||||
Highlighting is warmed and cached exactly as parsing is
|
||||
(`ParsedReplies.highlighted`, filled by `warm` from `fences(parse)`), and
|
||||
`highlight` takes no colour from the theme, which is what lets it run off
|
||||
the drawing thread: a two-hundred-line Kotlin fence costs 174ms to lex, and
|
||||
a `remember` inside the fence was charged that again every time the block
|
||||
scrolled back into composition. Because the warming has to ask for the same
|
||||
written is drawn plain.** `Highlighter.kt` holds `highlight` and the scanner
|
||||
behind it (shared with a tool call's input, so the same code is the same
|
||||
colours wherever it appears); `CodeFence.kt` holds the `fenceLanguage` alias
|
||||
table and `fenceContent`. A word not in the table stays plain, because a
|
||||
fence coloured by the wrong language's rules looks highlighted and is wrong
|
||||
in a way the reader cannot see. Highlighting is warmed and cached exactly as
|
||||
parsing is (`ParsedReplies.highlighted`, filled by `warm` from
|
||||
`fences(parse)`), and `highlight` takes no colour from the theme, which is
|
||||
what lets it run off the drawing thread: a two-hundred-line Kotlin fence
|
||||
costs 15ms to scan on the emulator's debug build -- it cost 102ms through
|
||||
the library that used to do this -- and a `remember` inside the fence was
|
||||
charged that again every time the block scrolled back into composition. Because the warming has to ask for the same
|
||||
string the drawing does, `fenceContent` extracts the code and the language
|
||||
word itself -- two extractions would be two keys, and the warmed answer
|
||||
would be missed at every fence with nothing saying so. A fence still
|
||||
@@ -209,25 +210,29 @@ and are the reason several tempting simplifications were rejected.
|
||||
- **Compose `DropdownMenu` in an edge-to-edge activity** needs
|
||||
`PopupProperties(clippingEnabled = false)` or it opens a status bar's
|
||||
height away from its anchor (`~/.claude/TOOLCHAIN.md`).
|
||||
- **highlights 1.1.0 finds comments before it knows the language, and pairs
|
||||
`/*` with `*/` by ordinal.** Measured against the library directly with
|
||||
`app/highlights-repro.sh`, which asks it for a piece of code outside the
|
||||
app and prints every span with the text under it -- the mistakes are
|
||||
invisible on a phone, where a line greyed out as a comment looks like a
|
||||
comment. In `MultilineCommentLocator` and `CommentLocator`: it collects every `/*` and
|
||||
every `*/` in the code, zips the two lists by position and never checks
|
||||
that the end follows the start, so `x '*/a/*'` yields `start=6, end=5` --
|
||||
a range `AnnotatedString` rejects, which crashed a card holding
|
||||
`-path '*/.git/*'` until `highlight` started dropping such spans. The same
|
||||
delimiters are used for every language, so a shell glob is read as a
|
||||
comment opener and, worse, `//` in any URL comments out the rest of its
|
||||
line: in `curl https://example.com/x && echo done` the comment runs to the
|
||||
end and takes `echo` with it, and in Kotlin `val url = "https://..."` the
|
||||
string span disappears inside it. Comments are located before strings and
|
||||
win over them. What we can reach is `getCodeStructure()`, which is public,
|
||||
plus a nine-line reconstruction of the library's private
|
||||
`constructHighlights`; the locators themselves are `internal`. 1.1.0 is
|
||||
the newest release, so there is nothing to upgrade to.
|
||||
- **The syntax highlighter is ours: `Highlighter.kt` and `Languages.kt`.**
|
||||
One left-to-right scanner with a small state -- in a line comment, in a
|
||||
block comment, in a string, or in ordinary code -- and a `Rules` row per
|
||||
language, so a new language is a table entry rather than code. Every span
|
||||
is emitted by advancing an index, so spans cannot overlap, arrive out of
|
||||
order or run backwards, and an unterminated string or comment simply runs
|
||||
to the end of the code. `HighlighterTest.kt` is the JVM unit test
|
||||
(`./gradlew :androidApp:testDebugUnitTest`); the cases in it are the
|
||||
library's mistakes, kept as regressions.
|
||||
It replaced dev.snipme:highlights 1.1.0 on 2026-09-03, which found
|
||||
comments before it knew the language and paired `/*` with `*/` by
|
||||
ordinal. That library used one set of delimiters for every language, so
|
||||
`//` in any URL commented out the rest of its line (in `curl
|
||||
https://example.com/x && echo done` the comment ran to the end and took
|
||||
`echo` with it, and in Kotlin `val url = "https://..."` the string
|
||||
disappeared inside it), every Rust `#[derive(...)]` greyed out as a
|
||||
comment, a `#` inside a Kotlin string swallowed the line, and `x '*/a/*'`
|
||||
in shell yielded `start=6, end=5` -- a range `AnnotatedString` rejects,
|
||||
which crashed a card holding `-path '*/.git/*'`. Comments were located
|
||||
before strings and won over them, so post-processing could not recover
|
||||
what a wrong comment range had already suppressed. The scanner is also
|
||||
about seven times faster on the same fixture, and it colours RON, TOML,
|
||||
fish and JSON, which the library did not know at all.
|
||||
|
||||
## Rejected, and why
|
||||
|
||||
@@ -254,19 +259,7 @@ and are the reason several tempting simplifications were rejected.
|
||||
against. The restore's one-event-per-request bug was part of what made it
|
||||
so visible and has been fixed; whether this survives that fix is the
|
||||
first thing to find out.
|
||||
2. **Decide what to do about the highlighting bug above.** Two options, both
|
||||
ours: post-process `getCodeStructure()` -- pair `/*` with the next `*/`
|
||||
after it, and only for languages that have them, ignore `#` and `//`
|
||||
where the language does not use them -- then build the annotated string
|
||||
from the corrected structure, which costs no extra lexing but cannot
|
||||
recover keywords and strings the wrong comment range already suppressed;
|
||||
or fork the twenty-file library and fix the locators. Filing it upstream
|
||||
needs Bryan or a token, since there is no `gh` and no GitHub credential
|
||||
in this VM. One-line repro: lexing `x '*/a/*'` as `SyntaxLanguage.SHELL`
|
||||
in highlights 1.1.0 returns a highlight whose `location.end` precedes its
|
||||
`location.start`. Whichever is chosen, `app/highlights-repro.sh` is what
|
||||
checks it.
|
||||
3. **Regression runs.** `transcript-bench.sh` and `stream-bench.sh` before
|
||||
2. **Regression runs.** `transcript-bench.sh` and `stream-bench.sh` before
|
||||
and after any change to the files above, with the report in the commit.
|
||||
The numbers to watch are the worst `record: one block`, the reparse mean
|
||||
while streaming, and the draw phase's accounting line.
|
||||
@@ -173,6 +173,15 @@ dependencies {
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.zxing.embedded)
|
||||
implementation(libs.markdown.renderer)
|
||||
implementation(libs.highlights)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
|
||||
// The syntax scanner (Highlighter.kt) is pure logic with no Android imports, which is what
|
||||
// lets it be tested on the JVM: `./gradlew :androidApp:testDebugUnitTest`. The assertions are
|
||||
// `kotlin.test`, so the tests name no framework; JUnit is what runs them.
|
||||
testImplementation(libs.kotlin.test.junit5)
|
||||
testImplementation(libs.junit.jupiter)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
// JUnit 6 runs on the Platform, which is not Gradle's default for a Test task.
|
||||
tasks.withType<Test>().configureEach { useJUnitPlatform() }
|
||||
@@ -11,23 +11,14 @@ import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownPadding
|
||||
import com.mikepenz.markdown.model.State
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.BoldHighlight
|
||||
import dev.snipme.highlights.model.ColorHighlight
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
import org.intellij.markdown.MarkdownElementTypes
|
||||
import org.intellij.markdown.MarkdownTokenTypes
|
||||
import org.intellij.markdown.ast.ASTNode
|
||||
@@ -38,10 +29,10 @@ import org.intellij.markdown.ast.getTextInNode
|
||||
* A fenced code block in a reply: the code highlighted, on the dark surface every verbatim thing
|
||||
* sits on, scrolling sideways rather than wrapping.
|
||||
*
|
||||
* The renderer's own fence drew the same block in plain text. The lexer that colours a tool call's
|
||||
* command colours a reply's code the same way, through [highlighted] and one theme, so a `kotlin`
|
||||
* fence and the Kotlin a tool wrote are the same colours. A fence in a language the lexer has no
|
||||
* rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and
|
||||
* The renderer's own fence drew the same block in plain text. The scanner that colours a tool
|
||||
* call's command colours a reply's code the same way, through [highlighted] and one palette, so a
|
||||
* `kotlin` fence and the Kotlin a tool wrote are the same colours. A fence in a language [scan] has
|
||||
* no rules for is plain rather than wrongly coloured: [fenceLanguage] answers null for those, and
|
||||
* plain is what the reader would have seen before.
|
||||
*
|
||||
* Finding the code is still the library's: which children of the node are the fence markers, the
|
||||
@@ -74,7 +65,7 @@ fun CodeBlock(
|
||||
}
|
||||
|
||||
/**
|
||||
* The code inside a fence or indented block, and the lexer's language for its info word.
|
||||
* The code inside a fence or indented block, and the highlighter's language for its info word.
|
||||
*
|
||||
* Which children of the node are the fence markers, the language word and the code between them is
|
||||
* the library's knowledge of the parser, copied from its `MarkdownCodeFence` rather than called:
|
||||
@@ -85,7 +76,7 @@ fun CodeBlock(
|
||||
* Null for a fence too short to hold anything -- an unterminated one still arriving, which the
|
||||
* library skips as invalid.
|
||||
*/
|
||||
fun fenceContent(content: String, node: ASTNode): Pair<String, SyntaxLanguage?>? {
|
||||
fun fenceContent(content: String, node: ASTNode): Pair<String, Language?>? {
|
||||
val word =
|
||||
node.findChildOfType(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)?.toString()
|
||||
val language = fenceLanguage(word)
|
||||
@@ -111,7 +102,7 @@ fun fenceContent(content: String, node: ASTNode): Pair<String, SyntaxLanguage?>?
|
||||
@Composable
|
||||
private fun CodeBlockText(
|
||||
code: String,
|
||||
language: SyntaxLanguage?,
|
||||
language: Language?,
|
||||
style: TextStyle,
|
||||
replies: ParsedReplies,
|
||||
streaming: Boolean,
|
||||
@@ -136,122 +127,72 @@ private fun CodeBlockText(
|
||||
}
|
||||
|
||||
/**
|
||||
* The lexer's language for a fence's info word, or null for one it has no lexer for.
|
||||
* The highlighter's language for a fence's info word, or null for one it has no rules for.
|
||||
*
|
||||
* The aliases are what people actually write after the backticks: the file extension as often as
|
||||
* the name. A word not here gets no colour rather than the nearest lexer's, because a fence
|
||||
* the name. A word not here gets no colour rather than the nearest language's, because a fence
|
||||
* coloured by the wrong language's rules looks highlighted and is wrong in a way the reader cannot
|
||||
* see.
|
||||
*/
|
||||
fun fenceLanguage(name: String?): SyntaxLanguage? =
|
||||
fun fenceLanguage(name: String?): Language? =
|
||||
FENCE_LANGUAGES[name?.trim()?.lowercase() ?: return null]
|
||||
|
||||
private val FENCE_LANGUAGES: Map<String, SyntaxLanguage> =
|
||||
private val FENCE_LANGUAGES: Map<String, Language> =
|
||||
mapOf(
|
||||
"kotlin" to SyntaxLanguage.KOTLIN,
|
||||
"kt" to SyntaxLanguage.KOTLIN,
|
||||
"kts" to SyntaxLanguage.KOTLIN,
|
||||
"rust" to SyntaxLanguage.RUST,
|
||||
"rs" to SyntaxLanguage.RUST,
|
||||
"sh" to SyntaxLanguage.SHELL,
|
||||
"bash" to SyntaxLanguage.SHELL,
|
||||
"shell" to SyntaxLanguage.SHELL,
|
||||
"zsh" to SyntaxLanguage.SHELL,
|
||||
"console" to SyntaxLanguage.SHELL,
|
||||
"python" to SyntaxLanguage.PYTHON,
|
||||
"py" to SyntaxLanguage.PYTHON,
|
||||
"javascript" to SyntaxLanguage.JAVASCRIPT,
|
||||
"js" to SyntaxLanguage.JAVASCRIPT,
|
||||
"jsx" to SyntaxLanguage.JAVASCRIPT,
|
||||
"typescript" to SyntaxLanguage.TYPESCRIPT,
|
||||
"ts" to SyntaxLanguage.TYPESCRIPT,
|
||||
"tsx" to SyntaxLanguage.TYPESCRIPT,
|
||||
"java" to SyntaxLanguage.JAVA,
|
||||
"c" to SyntaxLanguage.C,
|
||||
"h" to SyntaxLanguage.C,
|
||||
"cpp" to SyntaxLanguage.CPP,
|
||||
"c++" to SyntaxLanguage.CPP,
|
||||
"cc" to SyntaxLanguage.CPP,
|
||||
"hpp" to SyntaxLanguage.CPP,
|
||||
"csharp" to SyntaxLanguage.CSHARP,
|
||||
"cs" to SyntaxLanguage.CSHARP,
|
||||
"c#" to SyntaxLanguage.CSHARP,
|
||||
"go" to SyntaxLanguage.GO,
|
||||
"golang" to SyntaxLanguage.GO,
|
||||
"swift" to SyntaxLanguage.SWIFT,
|
||||
"dart" to SyntaxLanguage.DART,
|
||||
"ruby" to SyntaxLanguage.RUBY,
|
||||
"rb" to SyntaxLanguage.RUBY,
|
||||
"php" to SyntaxLanguage.PHP,
|
||||
"perl" to SyntaxLanguage.PERL,
|
||||
"pl" to SyntaxLanguage.PERL,
|
||||
"coffeescript" to SyntaxLanguage.COFFEESCRIPT,
|
||||
"coffee" to SyntaxLanguage.COFFEESCRIPT,
|
||||
"kotlin" to Language.KOTLIN,
|
||||
"kt" to Language.KOTLIN,
|
||||
"kts" to Language.KOTLIN,
|
||||
"rust" to Language.RUST,
|
||||
"rs" to Language.RUST,
|
||||
"sh" to Language.SHELL,
|
||||
"bash" to Language.SHELL,
|
||||
"shell" to Language.SHELL,
|
||||
"zsh" to Language.SHELL,
|
||||
"console" to Language.SHELL,
|
||||
"python" to Language.PYTHON,
|
||||
"py" to Language.PYTHON,
|
||||
"javascript" to Language.JAVASCRIPT,
|
||||
"js" to Language.JAVASCRIPT,
|
||||
"jsx" to Language.JAVASCRIPT,
|
||||
"typescript" to Language.TYPESCRIPT,
|
||||
"ts" to Language.TYPESCRIPT,
|
||||
"tsx" to Language.TYPESCRIPT,
|
||||
"java" to Language.JAVA,
|
||||
"c" to Language.C,
|
||||
"h" to Language.C,
|
||||
"cpp" to Language.CPP,
|
||||
"c++" to Language.CPP,
|
||||
"cc" to Language.CPP,
|
||||
"hpp" to Language.CPP,
|
||||
"csharp" to Language.CSHARP,
|
||||
"cs" to Language.CSHARP,
|
||||
"c#" to Language.CSHARP,
|
||||
"go" to Language.GO,
|
||||
"golang" to Language.GO,
|
||||
"swift" to Language.SWIFT,
|
||||
"dart" to Language.DART,
|
||||
"ruby" to Language.RUBY,
|
||||
"rb" to Language.RUBY,
|
||||
"php" to Language.PHP,
|
||||
"perl" to Language.PERL,
|
||||
"pl" to Language.PERL,
|
||||
"coffeescript" to Language.COFFEESCRIPT,
|
||||
"coffee" to Language.COFFEESCRIPT,
|
||||
"ron" to Language.RON,
|
||||
"toml" to Language.TOML,
|
||||
"fish" to Language.FISH,
|
||||
"json" to Language.JSON,
|
||||
)
|
||||
|
||||
/**
|
||||
* [code] with its keywords and strings coloured, or plain if there is no language for it.
|
||||
*
|
||||
* The lexing is dev.snipme:highlights. The colours are this app's, mapped in [catppuccinSyntax] --
|
||||
* a library's default theme would be the one place in the app whose palette came from somewhere
|
||||
* else. Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the
|
||||
* same code is the same colours wherever it appears.
|
||||
*
|
||||
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
|
||||
* off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text
|
||||
* which needs no colour of its own -- the style the caller draws it with carries that.
|
||||
*
|
||||
* Measured on the emulator before it was cached: a two-hundred-line Kotlin fence costs **174ms** to
|
||||
* lex, and the lazy list charged that again every time the block scrolled back into composition.
|
||||
* That is the whole reason [ParsedReplies.highlighted] exists rather than a `remember`.
|
||||
*/
|
||||
fun highlight(code: String, language: SyntaxLanguage?): AnnotatedString {
|
||||
if (language == null) return AnnotatedString(code)
|
||||
val marks =
|
||||
DebugStats.timed("code highlighted") {
|
||||
Highlights.Builder(code = code, language = language, theme = catppuccinSyntax())
|
||||
.build()
|
||||
.getHighlights()
|
||||
// highlights 1.1.0's shell lexer answers a quoted glob that looks like a comment
|
||||
// -- `x '*/a/*'` is the smallest input -- with a span whose end is before its
|
||||
// start, and AnnotatedString refuses such a range. That crashed the app the
|
||||
// moment a card holding `-path '*/.git/*'` was opened. Dropped rather than
|
||||
// clamped: a span the lexer got backwards is not one it knows the colour of.
|
||||
// Delete when snipme/highlights fixes it.
|
||||
.filter {
|
||||
it.location.start in 0..it.location.end && it.location.end <= code.length
|
||||
}
|
||||
}
|
||||
return buildAnnotatedString {
|
||||
append(code)
|
||||
marks.forEach { mark ->
|
||||
when (mark) {
|
||||
is ColorHighlight ->
|
||||
addStyle(
|
||||
SpanStyle(color = Color(mark.rgb or 0xFF000000.toInt())),
|
||||
mark.location.start,
|
||||
mark.location.end,
|
||||
)
|
||||
is BoldHighlight ->
|
||||
addStyle(
|
||||
SpanStyle(fontWeight = FontWeight.Bold),
|
||||
mark.location.start,
|
||||
mark.location.end,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every fence in [parse], as the code and language [highlight] will be asked for.
|
||||
*
|
||||
* Walks the whole tree rather than the top level: a fence inside a list item or a quote is drawn
|
||||
* the same way and costs the same to lex.
|
||||
*/
|
||||
fun fences(parse: State): List<Pair<String, SyntaxLanguage?>> {
|
||||
fun fences(parse: State): List<Pair<String, Language?>> {
|
||||
val success = parse as? State.Success ?: return emptyList()
|
||||
val out = ArrayList<Pair<String, SyntaxLanguage?>>()
|
||||
val out = ArrayList<Pair<String, Language?>>()
|
||||
fun walk(node: ASTNode) {
|
||||
if (
|
||||
node.type == MarkdownElementTypes.CODE_FENCE ||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
|
||||
/** What a span of code is, in the terms the palette has a colour for. */
|
||||
enum class Kind {
|
||||
KEYWORD,
|
||||
STRING,
|
||||
LITERAL,
|
||||
COMMENT,
|
||||
METADATA,
|
||||
PUNCTUATION,
|
||||
MARK,
|
||||
}
|
||||
|
||||
/** A run of [Kind] in the code, as a half-open range. */
|
||||
data class Span(val start: Int, val end: Int, val kind: Kind)
|
||||
|
||||
/**
|
||||
* The colours the highlighter draws with, ours rather than a library's; [catppuccinSyntax] is the
|
||||
* one instance and lives with the rest of the palette.
|
||||
*/
|
||||
data class SyntaxPalette(
|
||||
val keyword: Color,
|
||||
val string: Color,
|
||||
val literal: Color,
|
||||
val comment: Color,
|
||||
val metadata: Color,
|
||||
val punctuation: Color,
|
||||
val mark: Color,
|
||||
) {
|
||||
fun of(kind: Kind): Color =
|
||||
when (kind) {
|
||||
Kind.KEYWORD -> keyword
|
||||
Kind.STRING -> string
|
||||
Kind.LITERAL -> literal
|
||||
Kind.COMMENT -> comment
|
||||
Kind.METADATA -> metadata
|
||||
Kind.PUNCTUATION -> punctuation
|
||||
Kind.MARK -> mark
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [code] with its keywords, strings and comments coloured, or plain if there is no language for it.
|
||||
*
|
||||
* Shared by a tool call's input ([ToolInputView]) and a reply's fences ([CodeFence]), so the same
|
||||
* code is the same colours wherever it appears.
|
||||
*
|
||||
* Not a composable, and it takes no colour from the theme, because that is what lets [warm] run it
|
||||
* off the drawing thread: the syntax palette is fixed, and a fence with no language is plain text
|
||||
* which needs no colour of its own -- the style the caller draws it with carries that.
|
||||
*
|
||||
* The timing is the number the highlighter is judged by: the library this replaced took **174ms**
|
||||
* on the emulator for a two-hundred-line Kotlin fence, which is why [ParsedReplies.highlighted]
|
||||
* caches the answer rather than a `remember` inside the fence recomputing it on every scroll back.
|
||||
*/
|
||||
fun highlight(code: String, language: Language?): AnnotatedString {
|
||||
if (language == null) return AnnotatedString(code)
|
||||
val spans = DebugStats.timed("code highlighted") { scan(code, rulesOf(language)) }
|
||||
val palette = catppuccinSyntax()
|
||||
return buildAnnotatedString {
|
||||
append(code)
|
||||
spans.forEach { addStyle(SpanStyle(color = palette.of(it.kind)), it.start, it.end) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [code] read once, left to right, into the spans that carry a colour.
|
||||
*
|
||||
* One pass with a small state -- in a comment, in a string, or in ordinary code -- rather than a
|
||||
* locator per token kind over the whole text, which is what the library did and is why it found
|
||||
* comments before it knew the language: a `#` inside a shell string, a `//` inside a URL and a
|
||||
* block-comment opener inside a shell glob each commented out the rest of a line that was nothing
|
||||
* of the sort.
|
||||
*
|
||||
* Every span is produced by advancing an index forward, so the result is ordered, non-overlapping
|
||||
* and inside the code by construction. Nothing here throws: an unterminated string or comment runs
|
||||
* to the end of the code, which is also what it looks like while a fence is still being written.
|
||||
*
|
||||
* In ordinary code the order of recognition is comment, string, attribute, number, word, and
|
||||
* finally a single punctuation or mark character. Punctuation and marks are coloured only in
|
||||
* ordinary code, never inside a string or a comment.
|
||||
*/
|
||||
fun scan(code: String, rules: Rules): List<Span> = Scanner(code, rules).run()
|
||||
|
||||
/** Characters coloured as punctuation, and as marks. Both sets are the ones the library used. */
|
||||
private const val PUNCTUATION = ",.:;"
|
||||
private const val MARKS = "()={}<>-+[]|&"
|
||||
|
||||
private class Scanner(private val code: String, private val rules: Rules) {
|
||||
private val spans = ArrayList<Span>()
|
||||
private var at = 0
|
||||
|
||||
fun run(): List<Span> {
|
||||
while (at < code.length) {
|
||||
// Every branch that answers true has advanced `at`, so this terminates.
|
||||
val consumed =
|
||||
blockComment() ||
|
||||
lineComment() ||
|
||||
rawString() ||
|
||||
characterOrLifetime() ||
|
||||
string() ||
|
||||
attribute() ||
|
||||
number() ||
|
||||
word() ||
|
||||
singleCharacter()
|
||||
if (!consumed) at++
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
private fun emit(start: Int, kind: Kind) {
|
||||
if (at > start) spans.add(Span(start, at, kind))
|
||||
}
|
||||
|
||||
private fun starts(token: String) = code.startsWith(token, at)
|
||||
|
||||
/** Whether a line comment token here opens one; see [Rules.lineCommentsAtWordStart]. */
|
||||
private fun atWordStart() = at == 0 || code[at - 1].isWhitespace() || code[at - 1] in ";|&("
|
||||
|
||||
/** Whether only whitespace stands between the start of this line and here. */
|
||||
private fun atLineStart(): Boolean {
|
||||
var back = at - 1
|
||||
while (back >= 0 && code[back] != '\n') {
|
||||
if (!code[back].isWhitespace()) return false
|
||||
back--
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun toEndOfLine() {
|
||||
while (at < code.length && code[at] != '\n') at++
|
||||
}
|
||||
|
||||
/** From an open bracket through the one that matches it, or to the end if none does. */
|
||||
private fun toMatchingBracket() {
|
||||
var depth = 0
|
||||
while (at < code.length) {
|
||||
when (code[at]) {
|
||||
'[' -> depth++
|
||||
']' -> depth--
|
||||
}
|
||||
at++
|
||||
if (depth == 0) return
|
||||
}
|
||||
}
|
||||
|
||||
private fun blockComment(): Boolean {
|
||||
val comment = rules.blockComment ?: return false
|
||||
if (!starts(comment.open)) return false
|
||||
val start = at
|
||||
at += comment.open.length
|
||||
var depth = 1
|
||||
while (at < code.length && depth > 0) {
|
||||
// The closer is tried first so that a language whose two delimiters are the same
|
||||
// string -- CoffeeScript's `###` -- closes rather than nesting forever.
|
||||
if (starts(comment.close)) {
|
||||
depth--
|
||||
at += comment.close.length
|
||||
} else if (comment.nests && starts(comment.open)) {
|
||||
depth++
|
||||
at += comment.open.length
|
||||
} else {
|
||||
at++
|
||||
}
|
||||
}
|
||||
emit(start, Kind.COMMENT)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun lineComment(): Boolean {
|
||||
if (rules.lineComments.none { starts(it) }) return false
|
||||
if (rules.lineCommentsAtWordStart && !atWordStart()) return false
|
||||
val start = at
|
||||
toEndOfLine()
|
||||
emit(start, Kind.COMMENT)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Rust and RON: `b`? `r` `#`* `"` … `"` `#`*, with no escapes inside. */
|
||||
private fun rawString(): Boolean {
|
||||
if (!rules.rawStrings) return false
|
||||
var ahead = at
|
||||
if (code.getOrNull(ahead) == 'b') ahead++
|
||||
if (code.getOrNull(ahead) != 'r') return false
|
||||
ahead++
|
||||
var hashes = 0
|
||||
while (code.getOrNull(ahead) == '#') {
|
||||
ahead++
|
||||
hashes++
|
||||
}
|
||||
if (code.getOrNull(ahead) != '"') return false
|
||||
val start = at
|
||||
val closer = "\"" + "#".repeat(hashes)
|
||||
val closed = code.indexOf(closer, ahead + 1)
|
||||
at = if (closed < 0) code.length else closed + closer.length
|
||||
emit(start, Kind.STRING)
|
||||
return true
|
||||
}
|
||||
|
||||
/** See [Rules.lifetimes]: an apostrophe that is not a character literal opens nothing. */
|
||||
private fun characterOrLifetime(): Boolean {
|
||||
if (!rules.lifetimes || code[at] != '\'') return false
|
||||
val next = code.getOrNull(at + 1) ?: return false
|
||||
if (next == '\\' || code.getOrNull(at + 2) == '\'') {
|
||||
quoted(Quote("'", "'", escapes = true))
|
||||
} else {
|
||||
at++
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun string(): Boolean {
|
||||
// Longest opener wins, so Kotlin's `"""` is one delimiter rather than an empty string
|
||||
// followed by a quote. A loop rather than filter/maxBy: this runs at every character of
|
||||
// ordinary code, and the pair of lists that would allocate is the whole cost of the scan.
|
||||
var quote: Quote? = null
|
||||
for (candidate in rules.quotes) {
|
||||
if (starts(candidate.open) && candidate.open.length > (quote?.open?.length ?: 0)) {
|
||||
quote = candidate
|
||||
}
|
||||
}
|
||||
quoted(quote ?: return false)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun quoted(quote: Quote) {
|
||||
val start = at
|
||||
at += quote.open.length
|
||||
while (at < code.length) {
|
||||
if (quote.escapes && code[at] == '\\' && at + 1 < code.length) {
|
||||
at += 2
|
||||
continue
|
||||
}
|
||||
if (starts(quote.close)) {
|
||||
at += quote.close.length
|
||||
break
|
||||
}
|
||||
at++
|
||||
}
|
||||
at = at.coerceAtMost(code.length)
|
||||
emit(start, Kind.STRING)
|
||||
}
|
||||
|
||||
private fun attribute(): Boolean {
|
||||
val start = at
|
||||
when (rules.attributes) {
|
||||
Attributes.NONE -> return false
|
||||
Attributes.AT_WORD -> {
|
||||
if (code[at] != '@' || !isWordStart(code.getOrNull(at + 1))) return false
|
||||
at++
|
||||
while (at < code.length && isWordPart(code[at])) at++
|
||||
}
|
||||
Attributes.HASH_BRACKET -> {
|
||||
if (code[at] != '#') return false
|
||||
var ahead = at + 1
|
||||
if (code.getOrNull(ahead) == '!') ahead++
|
||||
if (code.getOrNull(ahead) != '[') return false
|
||||
at = ahead
|
||||
toMatchingBracket()
|
||||
}
|
||||
Attributes.HASH_LINE -> {
|
||||
if (code[at] != '#' || !atLineStart()) return false
|
||||
toEndOfLine()
|
||||
}
|
||||
Attributes.LINE_BRACKET -> {
|
||||
if (code[at] != '[' || !atLineStart()) return false
|
||||
toMatchingBracket()
|
||||
}
|
||||
}
|
||||
emit(start, Kind.METADATA)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* A number is a run starting with a digit and carrying on through letters, digits, `_` and `.`
|
||||
* -- which covers `0xFF`, `1_000`, `1u32` and `3.14` without a grammar for any of them.
|
||||
*/
|
||||
private fun number(): Boolean {
|
||||
if (!code[at].isDigit()) return false
|
||||
val start = at
|
||||
while (
|
||||
at < code.length && (code[at].isLetterOrDigit() || code[at] == '_' || code[at] == '.')
|
||||
) {
|
||||
at++
|
||||
}
|
||||
emit(start, Kind.LITERAL)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun word(): Boolean {
|
||||
if (!isWordStart(code[at])) return false
|
||||
val start = at
|
||||
while (at < code.length && isWordPart(code[at])) at++
|
||||
if (code.substring(start, at) in rules.keywords) emit(start, Kind.KEYWORD)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun singleCharacter(): Boolean {
|
||||
val kind =
|
||||
when (code[at]) {
|
||||
in PUNCTUATION -> Kind.PUNCTUATION
|
||||
in MARKS -> Kind.MARK
|
||||
else -> return false
|
||||
}
|
||||
at++
|
||||
emit(at - 1, kind)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun isWordStart(c: Char?) = c != null && (c.isLetter() || c == '_')
|
||||
|
||||
private fun isWordPart(c: Char) = c.isLetterOrDigit() || c == '_'
|
||||
@@ -0,0 +1,437 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* A language the highlighter has rules for.
|
||||
*
|
||||
* The names the reader writes after the backticks are aliases onto these; [fenceLanguage] holds
|
||||
* that table. A word with no entry there is null, and null is drawn plain, because a fence coloured
|
||||
* by another language's rules looks highlighted and is wrong in a way the reader cannot see.
|
||||
*/
|
||||
enum class Language {
|
||||
C,
|
||||
COFFEESCRIPT,
|
||||
CPP,
|
||||
CSHARP,
|
||||
DART,
|
||||
FISH,
|
||||
GO,
|
||||
JAVA,
|
||||
JAVASCRIPT,
|
||||
JSON,
|
||||
KOTLIN,
|
||||
PERL,
|
||||
PHP,
|
||||
PYTHON,
|
||||
RON,
|
||||
RUBY,
|
||||
RUST,
|
||||
SHELL,
|
||||
SWIFT,
|
||||
TOML,
|
||||
TYPESCRIPT,
|
||||
}
|
||||
|
||||
/**
|
||||
* What [scan] needs to know about one language -- data, not code, so that adding a language is a
|
||||
* row in [RULES] rather than a branch anywhere.
|
||||
*
|
||||
* The two forms that could not be expressed as data are flags here and a few lines in the scanner:
|
||||
* [rawStrings], because the closing delimiter depends on how many hashes the opener had, and
|
||||
* [lifetimes], because whether `'` opens anything at all depends on what follows it.
|
||||
*/
|
||||
data class Rules(
|
||||
/** Words drawn as keywords. Only plain words; the scanner cannot reach anything else. */
|
||||
val keywords: Set<String>,
|
||||
/** Tokens that open a comment running to the end of the line. */
|
||||
val lineComments: List<String> = emptyList(),
|
||||
/**
|
||||
* Whether [lineComments] count only at the start of a word.
|
||||
*
|
||||
* The shells need it: `$#`, `${#x}` and `a#b` are not comments, and greying the rest of those
|
||||
* lines is one of the mistakes this scanner exists to stop.
|
||||
*/
|
||||
val lineCommentsAtWordStart: Boolean = false,
|
||||
val blockComment: BlockComment? = null,
|
||||
/** The string forms. The longest opener that matches wins, so `"""` is tried before `"`. */
|
||||
val quotes: List<Quote> = emptyList(),
|
||||
val attributes: Attributes = Attributes.NONE,
|
||||
/** Rust and RON: an optional `b`, `r`, n hashes, `"`, closing at `"` and n hashes. */
|
||||
val rawStrings: Boolean = false,
|
||||
/**
|
||||
* Rust: `'` opens a character literal only when a backslash or one character and a `'` follow.
|
||||
* Otherwise it is a lifetime or a label and no string starts -- without this, `'a` opens a
|
||||
* string that runs to the next apostrophe in the block.
|
||||
*/
|
||||
val lifetimes: Boolean = false,
|
||||
)
|
||||
|
||||
data class BlockComment(val open: String, val close: String, val nests: Boolean)
|
||||
|
||||
/** One string form. [escapes] is whether a backslash escapes the closer (and itself). */
|
||||
data class Quote(val open: String, val close: String, val escapes: Boolean)
|
||||
|
||||
/** What opens a metadata span, of the shapes that exist across these languages. */
|
||||
enum class Attributes {
|
||||
NONE,
|
||||
/** `@` and a word: Kotlin and Java annotations, Python decorators. */
|
||||
AT_WORD,
|
||||
/** `#[` or `#![` through the matching `]`: Rust and RON attributes. */
|
||||
HASH_BRACKET,
|
||||
/** `#` at the start of a line, to the end of it: the C preprocessor. */
|
||||
HASH_LINE,
|
||||
/** `[` at the start of a line through the matching `]`: a TOML table header. */
|
||||
LINE_BRACKET,
|
||||
}
|
||||
|
||||
/** The rules for [language]. */
|
||||
fun rulesOf(language: Language): Rules = RULES.getValue(language)
|
||||
|
||||
private val C_STYLE = BlockComment("/*", "*/", nests = false)
|
||||
private val NESTING = BlockComment("/*", "*/", nests = true)
|
||||
|
||||
private val DOUBLE = Quote("\"", "\"", escapes = true)
|
||||
private val SINGLE = Quote("'", "'", escapes = true)
|
||||
private val TRIPLE_DOUBLE = Quote("\"\"\"", "\"\"\"", escapes = true)
|
||||
private val TRIPLE_SINGLE = Quote("'''", "'''", escapes = true)
|
||||
|
||||
// Lazy because the keyword sets below are top-level properties too, and a file's properties
|
||||
// initialize in the order they are written: read eagerly here, every set would be null.
|
||||
private val RULES: Map<Language, Rules> by lazy {
|
||||
mapOf(
|
||||
Language.C to
|
||||
Rules(
|
||||
keywords = KEYWORDS_C,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
attributes = Attributes.HASH_LINE,
|
||||
),
|
||||
Language.CPP to
|
||||
Rules(
|
||||
keywords = KEYWORDS_CPP,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
attributes = Attributes.HASH_LINE,
|
||||
),
|
||||
Language.CSHARP to
|
||||
Rules(
|
||||
keywords = KEYWORDS_CSHARP,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
),
|
||||
// `###` opens and closes a block comment and `#` opens a line one, which is why the
|
||||
// scanner tries the block opener first.
|
||||
Language.COFFEESCRIPT to
|
||||
Rules(
|
||||
keywords = KEYWORDS_COFFEESCRIPT,
|
||||
lineComments = listOf("#"),
|
||||
blockComment = BlockComment("###", "###", nests = false),
|
||||
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
|
||||
),
|
||||
Language.DART to
|
||||
Rules(
|
||||
keywords = KEYWORDS_DART,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = NESTING,
|
||||
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.FISH to
|
||||
Rules(
|
||||
keywords = KEYWORDS_FISH,
|
||||
lineComments = listOf("#"),
|
||||
lineCommentsAtWordStart = true,
|
||||
// fish's single quotes escape only `\'` and `\\`, which is what "skip the
|
||||
// character after a backslash" already does.
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
),
|
||||
Language.GO to
|
||||
Rules(
|
||||
keywords = KEYWORDS_GO,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = false)),
|
||||
),
|
||||
Language.JAVA to
|
||||
Rules(
|
||||
keywords = KEYWORDS_JAVA,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.JAVASCRIPT to
|
||||
Rules(
|
||||
keywords = KEYWORDS_JAVASCRIPT,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)),
|
||||
),
|
||||
Language.JSON to Rules(keywords = KEYWORDS_JSON, quotes = listOf(DOUBLE)),
|
||||
Language.KOTLIN to
|
||||
Rules(
|
||||
keywords = KEYWORDS_KOTLIN,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = NESTING,
|
||||
quotes = listOf(Quote("\"\"\"", "\"\"\"", escapes = false), DOUBLE, SINGLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.PERL to
|
||||
Rules(
|
||||
keywords = KEYWORDS_PERL,
|
||||
lineComments = listOf("#"),
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
),
|
||||
Language.PHP to
|
||||
Rules(
|
||||
keywords = KEYWORDS_PHP,
|
||||
lineComments = listOf("//", "#"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.PYTHON to
|
||||
Rules(
|
||||
keywords = KEYWORDS_PYTHON,
|
||||
lineComments = listOf("#"),
|
||||
quotes = listOf(TRIPLE_DOUBLE, TRIPLE_SINGLE, DOUBLE, SINGLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.RON to
|
||||
Rules(
|
||||
keywords = KEYWORDS_RON,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = NESTING,
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
attributes = Attributes.HASH_BRACKET,
|
||||
rawStrings = true,
|
||||
),
|
||||
Language.RUBY to
|
||||
Rules(
|
||||
keywords = KEYWORDS_RUBY,
|
||||
lineComments = listOf("#"),
|
||||
quotes = listOf(DOUBLE, SINGLE),
|
||||
),
|
||||
Language.RUST to
|
||||
Rules(
|
||||
keywords = KEYWORDS_RUST,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = NESTING,
|
||||
// No `'` here: [Rules.lifetimes] decides when one opens a character literal.
|
||||
quotes = listOf(DOUBLE),
|
||||
attributes = Attributes.HASH_BRACKET,
|
||||
rawStrings = true,
|
||||
lifetimes = true,
|
||||
),
|
||||
Language.SHELL to
|
||||
Rules(
|
||||
keywords = KEYWORDS_SHELL,
|
||||
lineComments = listOf("#"),
|
||||
lineCommentsAtWordStart = true,
|
||||
// A shell's single quotes are literal: `'a\'` is not one string.
|
||||
quotes = listOf(DOUBLE, Quote("'", "'", escapes = false)),
|
||||
),
|
||||
Language.SWIFT to
|
||||
Rules(
|
||||
keywords = KEYWORDS_SWIFT,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = NESTING,
|
||||
quotes = listOf(TRIPLE_DOUBLE, DOUBLE),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
Language.TOML to
|
||||
Rules(
|
||||
keywords = KEYWORDS_TOML,
|
||||
lineComments = listOf("#"),
|
||||
quotes =
|
||||
listOf(
|
||||
TRIPLE_DOUBLE,
|
||||
Quote("'''", "'''", escapes = false),
|
||||
DOUBLE,
|
||||
Quote("'", "'", escapes = false),
|
||||
),
|
||||
attributes = Attributes.LINE_BRACKET,
|
||||
),
|
||||
Language.TYPESCRIPT to
|
||||
Rules(
|
||||
keywords = KEYWORDS_TYPESCRIPT,
|
||||
lineComments = listOf("//"),
|
||||
blockComment = C_STYLE,
|
||||
quotes = listOf(DOUBLE, SINGLE, Quote("`", "`", escapes = true)),
|
||||
attributes = Attributes.AT_WORD,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The keyword sets.
|
||||
*
|
||||
* Every list below other than RON, TOML, fish and JSON came from dev.snipme:highlights 1.1.0
|
||||
* (`SyntaxTokens.kt`, Apache-2.0), the library this scanner replaced, so that no fence which is
|
||||
* coloured today turns plain. Entries that are not plain words were dropped -- Kotlin's `as?`,
|
||||
* `!in` and `!is`, Swift's `#if` family, Ruby's `defined?`, CoffeeScript's `=` and `->` -- because
|
||||
* the word scanner cannot reach them and the library only matched them by luck.
|
||||
*/
|
||||
private fun words(list: String): Set<String> =
|
||||
list.split(Regex("\\s+")).filterNot(String::isEmpty).toSet()
|
||||
|
||||
private val KEYWORDS_C =
|
||||
words(
|
||||
"""auto break case char const continue default do double else enum extern float for goto if
|
||||
int long register return short signed sizeof static struct switch typedef union unsigned
|
||||
void volatile while"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_CPP =
|
||||
words(
|
||||
"""asm auto bool break case catch char class const const_cast continue default delete do
|
||||
double dynamic_cast else enum explicit export extern false float for friend goto if inline
|
||||
int long mutable namespace new operator private protected public register reinterpret_cast
|
||||
return short signed sizeof static static_cast struct switch template this throw true try
|
||||
typedef typeid typename union unsigned using virtual void volatile wchar_t while"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_CSHARP =
|
||||
words(
|
||||
"""abstract as base bool break byte case catch char checked class const continue decimal
|
||||
default delegate do double else enum event explicit extern false finally fixed float for
|
||||
foreach goto if implicit in int interface internal is lock long namespace new null object
|
||||
operator out override params private protected public readonly ref return sbyte sealed short
|
||||
sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked
|
||||
unsafe ushort using virtual void volatile while"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_COFFEESCRIPT =
|
||||
words(
|
||||
"""Infinity NaN and arguments await break by case catch class continue debugger delete defer
|
||||
default do else export extends false finally for function if import in instanceof is isnt
|
||||
let loop new no not null of on or package return super switch this throw true try typeof
|
||||
unless undefined var wait when with yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_DART =
|
||||
words(
|
||||
"""abstract as assert async await base break case catch class const continue covariant
|
||||
default deferred do dynamic else enum export extends external factory false final finally
|
||||
for get if implements import in interface is late library mixin new null on operator part
|
||||
required rethrow return sealed set show static super switch this throw true try var void
|
||||
when with while yield"""
|
||||
)
|
||||
|
||||
/**
|
||||
* fish is not in the library at all, so its fences are drawn plain today. The list is the shell's
|
||||
* own words, which is what a fish fence is mostly made of.
|
||||
*/
|
||||
private val KEYWORDS_FISH =
|
||||
words(
|
||||
"""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"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_GO =
|
||||
words(
|
||||
"""break case chan const continue default defer else fallthrough false for func go goto if
|
||||
import interface map package range return select struct switch true type var"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_JAVA =
|
||||
words(
|
||||
"""abstract assert boolean break byte case catch char class const continue default do double
|
||||
else enum extends final finally float for goto if implements import instanceof int interface
|
||||
long native new null package private protected public return short static strictfp super
|
||||
switch synchronized this throw throws transient try void volatile while"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_JAVASCRIPT =
|
||||
words(
|
||||
"""async await boolean break case catch class const continue debugger default delete do else
|
||||
enum export extends false finally for function if implements import in instanceof interface
|
||||
let new null package private protected public return super switch this throw true try typeof
|
||||
var void while with yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_JSON = words("true false null")
|
||||
|
||||
private val KEYWORDS_KOTLIN =
|
||||
words(
|
||||
"""actual abstract annotation as break by catch class companion const constructor continue
|
||||
coroutine crossinline data delegate dynamic do else enum expect external false final finally
|
||||
for fun get if import in infix inline interface internal is lazy lateinit native null object
|
||||
open operator out override package private protected public reified return sealed set super
|
||||
suspend tailrec this throw true try typealias typeof val var vararg when while yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_PERL =
|
||||
words(
|
||||
"""__DATA__ __END__ __FILE__ __LINE__ __PACKAGE__ and cmp continue do else elsif eq eval for
|
||||
foreach goto gt if last le lt my ne next no not or package redo ref return sub unless until
|
||||
use while xor"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_PHP =
|
||||
words(
|
||||
"""__halt_compiler abstract and array as break callable case catch class clone const continue
|
||||
declare default die do echo else elseif empty enddeclare endfor endforeach endif endswitch
|
||||
endwhile eval exit extends final finally fn for foreach function global goto if implements
|
||||
include include_once instanceof insteadof interface isset list match new or print private
|
||||
protected public require require_once return static switch throw trait try unset use var
|
||||
while xor yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_PYTHON =
|
||||
words(
|
||||
"""False True and as assert async await break class continue def del elif else except finally
|
||||
for from global if import in is lambda nonlocal not or pass raise return try while with
|
||||
yield"""
|
||||
)
|
||||
|
||||
/** RON is not in the library either; these are the words a RON file can hold. */
|
||||
private val KEYWORDS_RON = words("true false Some None inf NaN")
|
||||
|
||||
private val KEYWORDS_RUBY =
|
||||
words(
|
||||
"""__ENCODING__ __END__ __FILE__ __LINE__ BEGIN END alias and begin break case class def do
|
||||
else elsif end ensure false for if in module next nil not or redo rescue retry return self
|
||||
super then true undef unless until when while yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_RUST =
|
||||
words(
|
||||
"""as async await break const continue crate dyn else enum extern false fn for if impl in
|
||||
let loop match mod move mut pub ref return Self self static struct super trait true type
|
||||
union unsafe use where while abstract become box do final macro override priv try typeof
|
||||
unsized virtual yield"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_SHELL =
|
||||
words(
|
||||
"""alias bg bind break builtin caller cd command compgen complete compopt continue declare
|
||||
dirs disown echo enable eval exec exit export fc fg getopts hash help history jobs kill let
|
||||
local logout popd printf pushd pwd read readonly return set shift shopt source suspend
|
||||
test"""
|
||||
)
|
||||
|
||||
private val KEYWORDS_SWIFT =
|
||||
words(
|
||||
"""_ associatedtype class deinit enum extension fileprivate func import init inout internal
|
||||
let open operator private precedencegroup protocol public rethrows static struct subscript
|
||||
typealias var break case catch continue default defer do else fallthrough for guard if in
|
||||
repeat return throw switch where while Any as await false is nil self Self super throws true
|
||||
try associativity convenience didSet dynamic final get indirect infix lazy left mutating none
|
||||
nonmutating optional override postfix precedence prefix Protocol required right set some Type
|
||||
unowned weak willSet"""
|
||||
)
|
||||
|
||||
/** TOML is not in the library; `inf` and `nan` are values rather than names, like the booleans. */
|
||||
private val KEYWORDS_TOML = words("true false inf nan")
|
||||
|
||||
private val KEYWORDS_TYPESCRIPT =
|
||||
words(
|
||||
"""abstract as asserts await break case catch class const constructor continue debugger
|
||||
default delete do else enum export extends false finally for from function get if implements
|
||||
import in infer instanceof interface is keyof let module namespace new null number object
|
||||
package private protected public readonly require global return set static string super
|
||||
switch this throw true try type typeof undefined unique unknown var void while with yield"""
|
||||
)
|
||||
@@ -55,7 +55,6 @@ import com.mikepenz.markdown.model.markdownAnimations
|
||||
import com.mikepenz.markdown.model.markdownDimens
|
||||
import com.mikepenz.markdown.model.markdownPadding
|
||||
import com.mikepenz.markdown.model.parseMarkdown
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -670,7 +669,7 @@ class ParsedReplies {
|
||||
*
|
||||
* The key carries the language, because the same code lexes differently under two of them.
|
||||
*/
|
||||
fun highlighted(code: String, language: SyntaxLanguage?): AnnotatedString =
|
||||
fun highlighted(code: String, language: Language?): AnnotatedString =
|
||||
if (language == null) AnnotatedString(code)
|
||||
else highlights.computeIfAbsent("$language\n$code") { highlight(code, language) }
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import dev.snipme.highlights.model.SyntaxTheme
|
||||
|
||||
/**
|
||||
* Catppuccin Mocha, as published in `catppuccin/palette`.
|
||||
@@ -198,24 +196,24 @@ val rawSurface: Color
|
||||
@Composable get() = Mocha.Crust
|
||||
|
||||
/**
|
||||
* Catppuccin Mocha as a syntax theme, for the highlighter used on a tool call's input.
|
||||
* Catppuccin Mocha as the highlighter's palette; see [SyntaxPalette].
|
||||
*
|
||||
* Here with the rest of the palette rather than beside the code that highlights: a library's own
|
||||
* theme would otherwise be the one surface in the app whose colours came from somewhere else, and
|
||||
* the accents below are the same ones every other coloured thing already uses.
|
||||
* Here with the rest of the palette rather than beside the code that highlights: the colours a
|
||||
* fence is drawn in are the same accents every other coloured thing in the app already uses, and
|
||||
* splitting them out would make code the one surface whose palette came from somewhere else.
|
||||
*
|
||||
* Not a composable, because [highlight] runs off the drawing thread; these colours never vary with
|
||||
* the theme.
|
||||
*/
|
||||
fun catppuccinSyntax(): SyntaxTheme =
|
||||
SyntaxTheme(
|
||||
key = "catppuccin-mocha",
|
||||
code = Mocha.Text.toArgb(),
|
||||
keyword = Mocha.Mauve.toArgb(),
|
||||
string = Mocha.Green.toArgb(),
|
||||
literal = Mocha.Peach.toArgb(),
|
||||
comment = Mocha.Overlay0.toArgb(),
|
||||
metadata = Mocha.Yellow.toArgb(),
|
||||
multilineComment = Mocha.Overlay0.toArgb(),
|
||||
punctuation = Mocha.Subtext0.toArgb(),
|
||||
mark = Mocha.Sky.toArgb(),
|
||||
fun catppuccinSyntax(): SyntaxPalette =
|
||||
SyntaxPalette(
|
||||
keyword = Mocha.Mauve,
|
||||
string = Mocha.Green,
|
||||
literal = Mocha.Peach,
|
||||
comment = Mocha.Overlay0,
|
||||
metadata = Mocha.Yellow,
|
||||
punctuation = Mocha.Subtext0,
|
||||
mark = Mocha.Sky,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.snipme.highlights.model.SyntaxLanguage
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
@@ -27,7 +26,7 @@ data class ToolInput(
|
||||
/** The thing that will actually be run or read, if this tool has one. */
|
||||
val subject: String?,
|
||||
/** The language [subject] is written in, for highlighting. */
|
||||
val language: SyntaxLanguage?,
|
||||
val language: Language?,
|
||||
/** The tool's own one-line summary, when it wrote one. */
|
||||
val description: String?,
|
||||
/**
|
||||
@@ -50,9 +49,9 @@ data class ToolInput(
|
||||
* from being the special case that gets its own code path. Unknown tools fall through to "no
|
||||
* subject, everything is rest", which is what the card always did.
|
||||
*/
|
||||
private val SUBJECTS: Map<String, Pair<String, SyntaxLanguage?>> =
|
||||
private val SUBJECTS: Map<String, Pair<String, Language?>> =
|
||||
mapOf(
|
||||
"Bash" to ("command" to SyntaxLanguage.SHELL),
|
||||
"Bash" to ("command" to Language.SHELL),
|
||||
"Read" to ("file_path" to null),
|
||||
"Write" to ("file_path" to null),
|
||||
"Edit" to ("file_path" to null),
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* What the scanner colours, asserted as the text under each span rather than as offsets, so a
|
||||
* failure prints the code that was got wrong instead of a pair of numbers.
|
||||
*
|
||||
* Most of these are the mistakes dev.snipme:highlights 1.1.0 made -- the library this scanner
|
||||
* replaced -- measured against it directly before it was removed. They are here rather than in the
|
||||
* `highlights-repro.sh` script they came from because a case that only a script can ask about is a
|
||||
* case nobody asks about.
|
||||
*/
|
||||
class HighlighterTest {
|
||||
/** Every span of [kind] in [code], as the text it covers. */
|
||||
private fun spans(code: String, language: Language, kind: Kind): List<String> =
|
||||
scan(code, rulesOf(language))
|
||||
.filter { it.kind == kind }
|
||||
.map { code.substring(it.start, it.end) }
|
||||
|
||||
private fun assertSpans(
|
||||
code: String,
|
||||
language: Language,
|
||||
kind: Kind,
|
||||
vararg expected: String,
|
||||
) {
|
||||
assertEquals(expected.toList(), spans(code, language, kind), "$kind in: $code")
|
||||
}
|
||||
|
||||
// The four inputs the repro script asked the library about, and what it answered.
|
||||
|
||||
@Test
|
||||
fun `a quoted glob is one string, not a comment`() {
|
||||
// The library answered a span whose end preceded its start here, which crashed the app.
|
||||
assertSpans("x '*/a/*'", Language.SHELL, Kind.STRING, "'*/a/*'")
|
||||
assertSpans("x '*/a/*'", Language.SHELL, Kind.COMMENT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a find with globs has no comment in it`() {
|
||||
val code = "find . -path '*/.git/*' -prune -o -name '*.kt' -print"
|
||||
assertSpans(code, Language.SHELL, Kind.STRING, "'*/.git/*'", "'*.kt'")
|
||||
assertSpans(code, Language.SHELL, Kind.COMMENT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a URL does not comment out the rest of a shell line`() {
|
||||
val code = "curl https://example.com/x && echo done"
|
||||
assertSpans(code, Language.SHELL, Kind.COMMENT)
|
||||
assertSpans(code, Language.SHELL, Kind.KEYWORD, "echo")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a URL inside a Kotlin string stays a string`() {
|
||||
val code = "val url = \"https://example.com\"\nfun f() = 1"
|
||||
assertSpans(code, Language.KOTLIN, Kind.COMMENT)
|
||||
assertSpans(code, Language.KOTLIN, Kind.STRING, "\"https://example.com\"")
|
||||
assertSpans(code, Language.KOTLIN, Kind.KEYWORD, "val", "fun")
|
||||
}
|
||||
|
||||
// Attributes, which the library greyed out as comments.
|
||||
|
||||
@Test
|
||||
fun `a Rust attribute is metadata and the struct after it still colours`() {
|
||||
val code = "#[derive(Debug)]\nstruct A { b: u8 }"
|
||||
assertSpans(code, Language.RUST, Kind.METADATA, "#[derive(Debug)]")
|
||||
assertSpans(code, Language.RUST, Kind.COMMENT)
|
||||
assertSpans(code, Language.RUST, Kind.KEYWORD, "struct")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an inner Rust attribute closes at its own bracket`() {
|
||||
val code = "#![allow(dead_code)]\nfn f() {}"
|
||||
assertSpans(code, Language.RUST, Kind.METADATA, "#![allow(dead_code)]")
|
||||
assertSpans(code, Language.RUST, Kind.KEYWORD, "fn")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a C preprocessor line is metadata rather than a comment`() {
|
||||
val code = "#include <stdio.h>\nint main() { return 0; }"
|
||||
assertSpans(code, Language.C, Kind.METADATA, "#include <stdio.h>")
|
||||
assertSpans(code, Language.C, Kind.COMMENT)
|
||||
assertSpans(code, Language.C, Kind.KEYWORD, "int", "return")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Kotlin annotation is metadata`() {
|
||||
assertSpans("@Composable fun f() {}", Language.KOTLIN, Kind.METADATA, "@Composable")
|
||||
}
|
||||
|
||||
// Strings whose contents the library read as code.
|
||||
|
||||
@Test
|
||||
fun `a hash inside a Kotlin string is not a comment`() {
|
||||
val code = "val c = \"#FF0000\"\nval d = 1"
|
||||
assertSpans(code, Language.KOTLIN, Kind.COMMENT)
|
||||
assertSpans(code, Language.KOTLIN, Kind.STRING, "\"#FF0000\"")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an apostrophe inside a Kotlin string does not open one`() {
|
||||
val code = "val a = \"don't\"\nval b = \"x\""
|
||||
assertSpans(code, Language.KOTLIN, Kind.STRING, "\"don't\"", "\"x\"")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Rust lifetime does not open a string but a character literal does`() {
|
||||
val code = "fn f<'a>(x: &'a str) { let c = 'x'; }"
|
||||
assertSpans(code, Language.RUST, Kind.STRING, "'x'")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an escaped quote is inside the Rust character literal`() {
|
||||
assertSpans("let c = '\\'';", Language.RUST, Kind.STRING, "'\\''")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Rust raw string keeps its inner quotes`() {
|
||||
val code = "let s = r#\"a \"quoted\" b\"#;"
|
||||
assertSpans(code, Language.RUST, Kind.STRING, "r#\"a \"quoted\" b\"#")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a Kotlin triple quoted string is one string`() {
|
||||
assertSpans(
|
||||
"val s = \"\"\"a \"b\" c\"\"\"",
|
||||
Language.KOTLIN,
|
||||
Kind.STRING,
|
||||
"\"\"\"a \"b\" c\"\"\"",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shell single quoted string takes no escapes`() {
|
||||
// `\` is literal inside shell single quotes, so the string ends at the next apostrophe.
|
||||
assertSpans("echo 'a\\' b", Language.SHELL, Kind.STRING, "'a\\'")
|
||||
}
|
||||
|
||||
// Comments.
|
||||
|
||||
@Test
|
||||
fun `Rust and Kotlin nest block comments`() {
|
||||
val code = "/* a /* b */ c */ x"
|
||||
assertSpans(code, Language.RUST, Kind.COMMENT, "/* a /* b */ c */")
|
||||
assertSpans(code, Language.KOTLIN, Kind.COMMENT, "/* a /* b */ c */")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `C ends a block comment at the first close`() {
|
||||
assertSpans("/* a /* b */ c */ x", Language.C, Kind.COMMENT, "/* a /* b */")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shell comment starts only at a word boundary`() {
|
||||
val code = "\${#x} \$# a#b # real"
|
||||
assertSpans(code, Language.SHELL, Kind.COMMENT, "# real")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hash anywhere is a Python comment`() {
|
||||
assertSpans("x = 1 # note", Language.PYTHON, Kind.COMMENT, "# note")
|
||||
}
|
||||
|
||||
// TOML, which the library has no rules for at all.
|
||||
|
||||
@Test
|
||||
fun `a TOML table header is metadata and a hash in a value is not a comment`() {
|
||||
val code = "[server]\ncolour = \"#FF0000\"\nport = 8080 # the real one"
|
||||
assertSpans(code, Language.TOML, Kind.METADATA, "[server]")
|
||||
assertSpans(code, Language.TOML, Kind.STRING, "\"#FF0000\"")
|
||||
assertSpans(code, Language.TOML, Kind.COMMENT, "# the real one")
|
||||
assertSpans(code, Language.TOML, Kind.LITERAL, "8080")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a RON attribute and its values colour`() {
|
||||
val code = "#![enable(implicit_some)]\n(count: 3, on: true)"
|
||||
assertSpans(code, Language.RON, Kind.METADATA, "#![enable(implicit_some)]")
|
||||
assertSpans(code, Language.RON, Kind.KEYWORD, "true")
|
||||
assertSpans(code, Language.RON, Kind.LITERAL, "3")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown fence language is drawn plain`() {
|
||||
assertEquals(null, fenceLanguage("brainfuck"))
|
||||
assertEquals("+[-]", highlight("+[-]", fenceLanguage("brainfuck")).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every alias the fence table knows has rules`() {
|
||||
Language.entries.forEach { rulesOf(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The scanner must never throw and must never answer a span the code does not contain: the
|
||||
* library's reversed range is exactly the shape that crashed a card, and a fence still being
|
||||
* written is an unterminated string or comment on every keystroke.
|
||||
*/
|
||||
@Test
|
||||
fun `spans stay inside the code for every language and every nasty input`() {
|
||||
val nasty =
|
||||
listOf(
|
||||
"",
|
||||
"'",
|
||||
"\"",
|
||||
"\"unterminated",
|
||||
"/* unterminated",
|
||||
"###",
|
||||
"#",
|
||||
"#![",
|
||||
"[",
|
||||
"r#\"",
|
||||
"\\",
|
||||
"'''",
|
||||
"\"\"\"",
|
||||
"0x",
|
||||
"1.2.3",
|
||||
"a#b//c/*d*/'e\"f",
|
||||
"\n\n \n",
|
||||
)
|
||||
for (language in Language.entries) {
|
||||
for (code in nasty) {
|
||||
val spans = scan(code, rulesOf(language))
|
||||
spans.forEach {
|
||||
assertTrue(
|
||||
it.start in 0..it.end && it.end <= code.length,
|
||||
"$language answered $it for ${code.replace("\n", "\\n")}",
|
||||
)
|
||||
}
|
||||
assertEquals(
|
||||
spans.sortedBy { it.start },
|
||||
spans,
|
||||
"$language answered spans out of order for ${code.replace("\n", "\\n")}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,10 +27,6 @@ zxing-embedded = "4.3.0"
|
||||
# https://repo1.maven.org/maven2/com/mikepenz/multiplatform-markdown-renderer-m3/maven-metadata.xml
|
||||
# Latest stable there, checked 2026-08-29.
|
||||
markdown-renderer = "0.45.0"
|
||||
# Syntax highlighting for a tool call's input. Same reasoning as the markdown
|
||||
# renderer: a language's lexical rules are somebody else's specification.
|
||||
# Latest stable, checked 2026-08-31 against Maven Central.
|
||||
highlights = "1.1.0"
|
||||
# The support ExifInterface rather than android.media's, which lint warns off:
|
||||
# the framework one is missing formats and the fixes for parsing hostile
|
||||
# images, and images here arrive from outside the phone. Latest stable,
|
||||
@@ -46,6 +42,12 @@ androidx-lifecycle = "2.11.0"
|
||||
# itself is Kotlin-org owned and has almost nothing to configure, which is
|
||||
# the point; this is the Gradle wrapper for it. Checked 2026-08-28.
|
||||
ktfmt-gradle = "0.27.0"
|
||||
# The test framework for the app's JVM unit tests (Highlighter.kt's scanner).
|
||||
# JUnit 6 is the current line -- Jupiter and the Platform ship on one version
|
||||
# now -- and the tests themselves are written against `kotlin.test`, so the
|
||||
# framework is a build-file choice rather than something the source names.
|
||||
# Latest stable, checked 2026-09-03 against Maven Central.
|
||||
junit = "6.1.3"
|
||||
# Backports java.time (and more) to API 24, which UsageScreen needs: its
|
||||
# reset countdown is OffsetDateTime/Duration, both API 26. Checked
|
||||
# 2026-08-28 against Google Maven.
|
||||
@@ -64,8 +66,15 @@ desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref
|
||||
# The -m3 flavour: it takes its colours and type from the ambient Material 3
|
||||
# theme, so the app's Catppuccin scheme is what it draws with.
|
||||
markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
|
||||
highlights = { module = "dev.snipme:highlights", version.ref = "highlights" }
|
||||
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" }
|
||||
# The framework-neutral assertions the tests are written against. The `-junit5`
|
||||
# artifact rather than plain `kotlin-test`: AGP 9 compiles Kotlin itself rather
|
||||
# than through the Kotlin Android plugin, so nothing here resolves the variant
|
||||
# of `kotlin-test` that carries `kotlin.test.Test` -- naming the artifact is
|
||||
# what makes the annotation exist.
|
||||
kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5", version.ref = "kotlin" }
|
||||
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
|
||||
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit" }
|
||||
# Declared directly rather than through the plugin's `compose.*` accessors,
|
||||
# which are deprecated as of CMP 1.11.
|
||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# What dev.snipme:highlights actually answers for a piece of code, outside the app.
|
||||
#
|
||||
# The lexer's mistakes are invisible on a phone -- a line greyed out as a comment looks
|
||||
# like a comment -- so this asks it directly and prints every span with the text under
|
||||
# it. It is how the `/*`-pairing and URL-comment bugs in TRANSCRIPT_RENDERING.md were
|
||||
# measured, and it is what any fix has to be checked against.
|
||||
#
|
||||
# Usage: ./highlights-repro.sh [LANGUAGE FILE] (default: the known-bad cases)
|
||||
set -euo pipefail
|
||||
|
||||
cache=~/.gradle/caches/modules-2/files-2.1
|
||||
jar() { find "$cache/$1" -name "$2" ! -name '*sources*' | sort | tail -1; }
|
||||
|
||||
cp=$(jar dev.snipme/highlights-jvm/1.1.0 '*.jar')
|
||||
cp=$cp:$(jar org.jetbrains.kotlin/kotlin-stdlib '*[0-9].jar')
|
||||
cp=$cp:$(jar org.jetbrains.kotlinx/kotlinx-coroutines-core-jvm '*.jar')
|
||||
cp=$cp:$(jar org.jetbrains.kotlinx/kotlinx-serialization-core-jvm '*.jar')
|
||||
cp=$cp:$(jar org.jetbrains.kotlinx/kotlinx-serialization-json-jvm '*.jar')
|
||||
for entry in ${cp//:/ }; do
|
||||
[ -f "$entry" ] || { echo "missing jar in the gradle cache: build the app once first" >&2; exit 1; }
|
||||
done
|
||||
|
||||
out=$(mktemp -d)
|
||||
trap 'rm -rf "$out"' EXIT
|
||||
cat > "$out/Repro.java" <<'JAVA'
|
||||
import dev.snipme.highlights.Highlights;
|
||||
import dev.snipme.highlights.model.*;
|
||||
import java.nio.file.*;
|
||||
|
||||
public class Repro {
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length == 2) {
|
||||
report(SyntaxLanguage.valueOf(args[0].toUpperCase()), Files.readString(Path.of(args[1])));
|
||||
return;
|
||||
}
|
||||
report(SyntaxLanguage.SHELL, "x '*/a/*'");
|
||||
report(SyntaxLanguage.SHELL, "find . -path '*/.git/*' -prune -o -name '*.kt' -print");
|
||||
report(SyntaxLanguage.SHELL, "curl https://example.com/x && echo done");
|
||||
report(SyntaxLanguage.KOTLIN, "val url = \"https://example.com\"\nfun f() = 1");
|
||||
}
|
||||
|
||||
static void report(SyntaxLanguage language, String code) {
|
||||
System.out.println("== [" + language + "] " + code.replace("\n", "\\n"));
|
||||
for (CodeHighlight highlight : new Highlights.Builder().code(code).language(language)
|
||||
.build().getHighlights()) {
|
||||
PhraseLocation at = highlight instanceof ColorHighlight
|
||||
? ((ColorHighlight) highlight).getLocation()
|
||||
: ((BoldHighlight) highlight).getLocation();
|
||||
boolean sane = at.getStart() >= 0 && at.getStart() <= at.getEnd()
|
||||
&& at.getEnd() <= code.length();
|
||||
System.out.println((sane ? " " : " BAD ") + at + " "
|
||||
+ (sane ? "\"" + code.substring(at.getStart(), at.getEnd()) + "\"" : ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
JAVA
|
||||
javac -cp "$cp" -d "$out" "$out/Repro.java"
|
||||
java -cp "$cp:$out" Repro "$@"
|
||||
Reference in new issue
Block a user