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
@@ -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"""
|
||||
)
|
||||
Reference in new issue
Block a user