git.iko.soy/opencode-android
on 2794fda help sign in
534 lines 24 kB history raw
1@file:Suppress("TooManyFunctions")
3package soy.iko.opencode.ui.components
5import androidx.compose.material3.MaterialTheme
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.remember
8import androidx.compose.ui.graphics.Color
9import androidx.compose.ui.text.AnnotatedString
10import androidx.compose.ui.text.SpanStyle
11import androidx.compose.ui.text.font.FontFamily
12import androidx.compose.ui.text.font.FontStyle
13import androidx.compose.ui.text.font.FontWeight
14import androidx.compose.ui.text.withStyle
16/**
17 * Lightweight, dependency-free syntax highlighting for the file viewer.
18 *
19 * A full tokenizer (TreeSitter, Prism, etc.) would add a heavy native or generated
20 * dependency that conflicts with the project's no-KSP / no-native-deps stance. This
21 * heuristic highlighter instead colors comments, strings, numbers, and a per-language
22 * keyword set via simple character/word scanning — enough to make a code file scannable
23 * in the viewer without pulling in a parser.
24 *
25 * The highlighter is per-line (the viewer renders line-by-line), so multi-line block
26 * comments and triple-quoted strings are not handled; that's an accepted trade-off for
27 * a viewer (not an editor) where the goal is visual orientation, not correctness.
28 */
30/** Language families recognized by [highlightLine]. The set of keywords is shared
31 * across C-family languages (kotlin, java, js, ts, swift, rust, go, c, cpp) since the
32 * common keywords overlap heavily; python, ruby, shell, json each have their own. */
33internal enum class Language {
34 C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
35 PYTHON,
36 SHELL, // sh, bash, zsh
37 MARKUP, // html, xml, svg
38 RUBY,
39 JSON,
40 NONE,
43private fun languageFor(filename: String): Language {
44 val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
45 return languageForToken(ext)
48/**
49 * Resolve a language from a markdown fence info string (e.g. "kotlin" from ```kotlin).
50 * Accepts both full language names and file extensions so a fence tagged either way
51 * (` ```py ` or ` ```python `) highlights. Lowercased by the caller via [languageForToken].
52 */
53internal fun languageForTag(tag: String): Language = languageForToken(tag.trim().lowercase())
55private fun languageForToken(token: String): Language = when (token) {
56 "kt", "kotlin", "kts", "ktm", "java", "js", "javascript", "mjs", "cjs",
57 "ts", "typescript", "tsx", "jsx",
58 "swift", "rs", "rust", "go", "golang", "c", "h", "cpp", "c++", "cc", "cxx", "hpp", "hh",
59 "cs", "csharp", "c#", "scala", "groovy", "gradle", "dart",
60 -> Language.C_FAMILY
61 "py", "python", "pyw" -> Language.PYTHON
62 "rb", "ruby" -> Language.RUBY
63 "sh", "bash", "zsh", "fish", "shell", "shellscript", "console" -> Language.SHELL
64 "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
65 "json", "json5", "jsonc" -> Language.JSON
66 else -> Language.NONE
69private val cFamilyKeywords = setOf(
70 "abstract", "as", "assert", "async", "await", "break", "case", "catch", "class",
71 "const", "continue", "default", "defer", "do", "else", "enum", "export",
72 "extends", "external", "final", "finally", "fn", "for", "func", "fun", "go",
73 "if", "impl", "implements", "import", "in", "init", "inline", "instanceof", "is",
74 "let", "match", "mod", "module", "mut", "namespace", "new", "object", "of",
75 "override", "package", "private", "protected", "public", "pub", "raise", "return",
76 "sealed", "self", "static", "struct", "super", "suspend", "switch", "this", "throw",
77 "throws", "trait", "try", "type", "typeof", "union", "unsafe", "val", "var", "when",
78 "where", "while", "with", "yield", "true", "false", "null", "nil",
79 "and", "or", "not", "lambda", "elif", "pass", "print",
82private val pythonKeywords = setOf(
83 "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
84 "continue", "def", "del", "elif", "else", "except", "finally", "for", "from",
85 "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass",
86 "raise", "return", "try", "while", "with", "yield", "self", "cls", "print",
89private val shellKeywords = setOf(
90 "if", "then", "else", "elif", "fi", "for", "do", "done", "while", "case", "esac",
91 "function", "in", "return", "exit", "echo", "export", "local", "readonly", "unset",
92 "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
95private val rubyKeywords = setOf(
96 "BEGIN", "END", "alias", "and", "begin", "break", "case", "class", "def", "defined?",
97 "do", "else", "elsif", "end", "ensure", "false", "for", "if", "in", "module", "next",
98 "nil", "not", "or", "redo", "rescue", "retry", "return", "self", "super", "then",
99 "true", "undef", "unless", "until", "when", "while", "yield", "__FILE__", "__LINE__",
102private val jsonKeywords = setOf("true", "false", "null")
104/** Theme-derived colors for the highlighter. Captured in a @Composable so the styles
105 * re-resolve when the color scheme changes (e.g. dynamic color or a theme switch). */
106data class HighlightPalette(
107 val keyword: Color,
108 val comment: Color,
109 val string: Color,
110 val number: Color,
111 val annotation: Color,
112 val tag: Color,
113 val base: Color,
116@Composable
117fun rememberHighlightPalette(): HighlightPalette {
118 // Use muted theme roles so the highlighting reads as part of the UI, not a foreign
119 // palette. Keyword = primary, string = secondary, comment = outline (dimmed),
120 // number = tertiary. Falls back gracefully when dynamic color is on (the roles
121 // come from the wallpaper-derived scheme).
122 val scheme = MaterialTheme.colorScheme
123 return remember(scheme) {
124 HighlightPalette(
125 keyword = scheme.primary,
126 comment = scheme.outline,
127 string = scheme.secondary,
128 number = scheme.tertiary,
129 annotation = scheme.tertiary,
130 tag = scheme.primary,
131 base = scheme.onSurface,
132 )
133 }
136/**
137 * Produce a highlighted [AnnotatedString] for a single line of source code.
138 *
139 * The scanner walks the line character by character, classifying runs of chars as
140 * comments, strings, numbers, identifiers (checked against the keyword set), or plain
141 * text. It's O(n) per line and allocation-light (a single AnnotatedString.Builder),
142 * which matters because the file viewer renders lazily but can scroll through
143 * thousands of lines — a heavy highlighter would jank on fast scrolls.
144 */
145fun highlightLine(line: String, filename: String, palette: HighlightPalette): AnnotatedString =
146 highlightLine(line, syntaxFor(filename), palette)
148/**
149 * A file's language classification, resolved once from its name. Resolving this per file
150 * (instead of re-parsing the extension inside [highlightLine] for every line) keeps the
151 * hot per-line highlight path free of the `substringAfterLast`/`lowercase` work.
152 */
153class FileSyntax internal constructor(
154 internal val lang: Language,
155 internal val keywords: Set<String>,
158/** Resolve the [FileSyntax] for a filename. Cheap; call once per file and reuse. */
159fun syntaxFor(filename: String): FileSyntax {
160 val lang = languageFor(filename)
161 return FileSyntax(lang, keywordsFor(lang))
164/**
165 * Resolve the [FileSyntax] from a markdown fence language tag (e.g. "kotlin", "py",
166 * "json"). Used by the chat code-block renderer, which only has the fence's info string
167 * (not a filename). Returns a [FileSyntax] with [Language.NONE] for unknown tags so the
168 * caller can short-circuit to plain text.
169 */
170fun syntaxForLanguageTag(tag: String): FileSyntax {
171 val lang = languageForTag(tag)
172 return FileSyntax(lang, keywordsFor(lang))
175/**
176 * Highlight a whole (possibly multi-line) code snippet into a single [AnnotatedString].
177 * Each line is highlighted independently via [highlightLine], because the per-line
178 * scanner carries no state across lines (so multi-line block comments/triple-quoted
179 * strings are not handled — an accepted trade-off documented on [highlightLine]).
180 *
181 * Used by the chat code-block renderer, which renders the whole block in one [Text].
182 * The file viewer renders line-by-line instead and calls [highlightLine] directly.
183 */
184fun highlightCode(code: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
185 if (syntax.lang == Language.NONE) return AnnotatedString(code)
186 val builder = AnnotatedString.Builder(code.length + 16)
187 val firstNewline = code.indexOf('\n')
188 // Fast path for single-line code (common for short inline-ish fences): skip the
189 // split + per-line append loop and the leading-newline bookkeeping entirely.
190 if (firstNewline == -1) {
191 builder.append(highlightLine(code, syntax, palette))
192 return builder.toAnnotatedString()
193 }
194 var start = 0
195 var first = true
196 while (start <= code.length) {
197 val end = code.indexOf('\n', start)
198 val line = if (end == -1) code.substring(start) else code.substring(start, end)
199 if (!first) builder.append("\n")
200 builder.append(highlightLine(line, syntax, palette))
201 first = false
202 if (end == -1) break
203 start = end + 1
204 }
205 return builder.toAnnotatedString()
208/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
209fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
210 val lang = syntax.lang
211 if (lang == Language.NONE) return AnnotatedString(line)
212 val keywords = syntax.keywords
213 val builder = AnnotatedString.Builder(line.length + 8)
214 val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
215 val mono = FontFamily.Monospace
216 var i = 0
217 val n = line.length
218 while (i < n) {
219 val c = line[i]
220 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
221 if (emitted.consumed) {
222 i = emitted.nextIndex
223 } else {
224 builder.withStyle(baseStyle) { append(c) }
225 i++
226 }
227 }
228 return builder.toAnnotatedString()
231/**
232 * State carried across lines while highlighting a multi-line file, so block comments
233 * (C-family slash-star, Ruby =begin/=end) and triple-quoted strings (Python/Kotlin)
234 * are colored correctly on every line they span, not just the opening line.
235 *
236 * The per-line [highlightLine] carries no state and is still the right call for chat
237 * code fences (which render a whole snippet in one Text). The file viewer renders
238 * line-by-line and should use the stateful [highlightLine] overload instead so a
239 * C-family file with a 50-line block comment doesn't lose highlighting on lines 2..49.
240 */
241class BlockCommentState {
242 /** True when the previous line ended inside an unclosed C-family block comment. */
243 internal var inBlockComment: Boolean = false
244 /** True when the previous line ended inside an unclosed triple-quoted string. */
245 internal var inTripleString: Boolean = false
248/** Highlight a single line, carrying [state] across calls so multi-line block comments and
249 * triple-quoted strings stay colored on every line they span. Mutates [state] in place.
250 * Use this from line-by-line renderers (the file viewer); use [highlightLine] / [highlightCode]
251 * for self-contained snippets (chat code fences) where cross-line state isn't worth tracking. */
252fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette, state: BlockCommentState): AnnotatedString {
253 val lang = syntax.lang
254 if (lang == Language.NONE) return AnnotatedString(line)
255 val keywords = syntax.keywords
256 val builder = AnnotatedString.Builder(line.length + 8)
257 val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
258 val mono = FontFamily.Monospace
259 val commentStyle = SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)
260 val stringStyle = SpanStyle(color = palette.string, fontFamily = mono)
262 // If we're continuing inside an open block comment, scan for the closing `*/` first;
263 // everything before it is comment-colored. If none exists on this line, the whole line
264 // is comment-colored and we stay in the block.
265 if (state.inBlockComment) {
266 val closeIdx = line.indexOf("*/")
267 if (closeIdx < 0) {
268 builder.withStyle(commentStyle) { append(line) }
269 return builder.toAnnotatedString()
270 }
271 val end = closeIdx + 2
272 builder.withStyle(commentStyle) { append(line.substring(0, end)) }
273 state.inBlockComment = false
274 // Continue highlighting the remainder of the line normally.
275 var i = end
276 val n = line.length
277 while (i < n) {
278 val c = line[i]
279 // An opening `/*` inside the remainder re-enters the block.
280 if (isCBlockCommentOpen(c, line, i, n, lang)) {
281 val close = findBlockCommentEnd(line, i + 2)
282 if (close < 0) {
283 builder.withStyle(commentStyle) { append(line.substring(i)) }
284 state.inBlockComment = true
285 return builder.toAnnotatedString()
286 }
287 builder.withStyle(commentStyle) { append(line.substring(i, close)) }
288 i = close
289 continue
290 }
291 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
292 if (emitted.consumed) {
293 i = emitted.nextIndex
294 } else {
295 builder.withStyle(baseStyle) { append(c) }
296 i++
297 }
298 }
299 return builder.toAnnotatedString()
300 }
302 // If we're continuing inside a triple-quoted string, scan for the closing `"""`.
303 if (state.inTripleString) {
304 val closeIdx = line.indexOf("\"\"\"")
305 if (closeIdx < 0) {
306 builder.withStyle(stringStyle) { append(line) }
307 return builder.toAnnotatedString()
308 }
309 val end = closeIdx + 3
310 builder.withStyle(stringStyle) { append(line.substring(0, end)) }
311 state.inTripleString = false
312 var i = end
313 val n = line.length
314 while (i < n) {
315 val c = line[i]
316 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
317 if (emitted.consumed) {
318 i = emitted.nextIndex
319 } else {
320 builder.withStyle(baseStyle) { append(c) }
321 i++
322 }
323 }
324 return builder.toAnnotatedString()
325 }
327 // Not currently in a block — run the normal per-line scanner, but intercept `/*` and `"""`
328 // openings so we can flip state and color the rest of the line accordingly.
329 var i = 0
330 val n = line.length
331 while (i < n) {
332 val c = line[i]
333 // Block comment open: C-family `/* ... */`. (Ruby's `=begin`/`=end` is handled below.)
334 if (isCBlockCommentOpen(c, line, i, n, lang)) {
335 val close = findBlockCommentEnd(line, i + 2)
336 if (close < 0) {
337 builder.withStyle(commentStyle) { append(line.substring(i)) }
338 state.inBlockComment = true
339 return builder.toAnnotatedString()
340 }
341 builder.withStyle(commentStyle) { append(line.substring(i, close)) }
342 i = close
343 continue
344 }
345 // Triple-quoted string open: Python/Kotlin `""" ... """`. Only track for languages
346 // that actually have triple-quoted strings; in plain C/JS a `"` run is just adjacent
347 // string literals.
348 if (isTripleStringOpen(c, line, i, n, lang)) {
349 val close = line.indexOf("\"\"\"", i + 3)
350 if (close < 0) {
351 builder.withStyle(stringStyle) { append(line.substring(i)) }
352 state.inTripleString = true
353 return builder.toAnnotatedString()
354 }
355 val end = close + 3
356 builder.withStyle(stringStyle) { append(line.substring(i, end)) }
357 i = end
358 continue
359 }
360 // Ruby `=begin` … `=end` block comment (must be at the start of the line per Ruby
361 // syntax; we check the leading-run here).
362 if (isRubyBlockCommentOpen(c, line, i, lang)) {
363 val close = line.indexOf("=end", i + 6)
364 if (close < 0) {
365 builder.withStyle(commentStyle) { append(line.substring(i)) }
366 state.inBlockComment = true
367 return builder.toAnnotatedString()
368 }
369 val end = close + 4
370 builder.withStyle(commentStyle) { append(line.substring(i, end)) }
371 i = end
372 continue
373 }
374 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
375 if (emitted.consumed) {
376 i = emitted.nextIndex
377 } else {
378 builder.withStyle(baseStyle) { append(c) }
379 i++
380 }
381 }
382 return builder.toAnnotatedString()
385/** Index just past the closing block-comment delimiter for a block comment opened at [from],
386 * or -1 if the line ends before the block closes. */
387private fun findBlockCommentEnd(line: String, from: Int): Int {
388 val idx = line.indexOf("*/", from)
389 return if (idx < 0) -1 else idx + 2
392/** True when [line] at [i] opens a C-family block comment (`/` followed by `*`). */
393private fun isCBlockCommentOpen(c: Char, line: String, i: Int, n: Int, lang: Language): Boolean =
394 c == '/' && i + 1 < n && line[i + 1] == '*' && lang == Language.C_FAMILY
396/** True when [line] at [i] opens a triple-quoted string (three double-quotes), for languages
397 * that support it (Python, Kotlin/C-family). */
398private fun isTripleStringOpen(c: Char, line: String, i: Int, n: Int, lang: Language): Boolean =
399 c == '"' && i + 2 < n && line[i + 1] == '"' && line[i + 2] == '"' &&
400 (lang == Language.PYTHON || lang == Language.C_FAMILY)
402/** True when [line] at [i] opens a Ruby `=begin` block comment. Must be at the start of the
403 * line or preceded by whitespace per Ruby syntax. */
404private fun isRubyBlockCommentOpen(c: Char, line: String, i: Int, lang: Language): Boolean =
405 lang == Language.RUBY && c == '=' && line.startsWith("=begin", i) &&
406 (i == 0 || line[i - 1] == ' ' || line[i - 1] == '\t')
408/** Result of attempting to emit one token starting at [start]: the next scan index and
409 * whether a token was consumed (vs. falling through to the default plain-text emit). */
410private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
412private fun emitToken(
413 builder: AnnotatedString.Builder,
414 line: String,
415 start: Int,
416 lang: Language,
417 keywords: Set<String>,
418 palette: HighlightPalette,
419 baseStyle: SpanStyle,
420 mono: FontFamily,
421): EmitResult {
422 val c = line[start]
423 if (isLineCommentStart(c, line, start, lang)) {
424 builder.withStyle(SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)) {
425 append(line.substring(start))
426 }
427 return EmitResult(line.length, true)
428 }
429 if (c == '"' || c == '\'') {
430 val end = findStringEnd(line, start, c)
431 builder.withStyle(SpanStyle(color = palette.string, fontFamily = mono)) { append(line.substring(start, end)) }
432 return EmitResult(end, true)
433 }
434 if (isNumberStart(c, line, start)) {
435 val end = findNumberEnd(line, start)
436 builder.withStyle(SpanStyle(color = palette.number, fontFamily = mono)) { append(line.substring(start, end)) }
437 return EmitResult(end, true)
438 }
439 if (c.isLetter() || c == '_' || c == '@') {
440 val end = findIdentifierEnd(line, start)
441 val word = line.substring(start, end)
442 when {
443 c == '@' -> builder.withStyle(SpanStyle(color = palette.annotation, fontFamily = mono)) { append(word) }
444 word in keywords -> builder.withStyle(SpanStyle(color = palette.keyword, fontWeight = FontWeight.SemiBold, fontFamily = mono)) { append(word) }
445 else -> builder.withStyle(baseStyle) { append(word) }
446 }
447 return EmitResult(end, true)
448 }
449 if (lang == Language.MARKUP && isMarkupDelim(c)) {
450 builder.withStyle(SpanStyle(color = palette.tag, fontFamily = mono)) { append(c) }
451 return EmitResult(start + 1, true)
452 }
453 return EmitResult(start + 1, false)
456private fun keywordsFor(lang: Language): Set<String> = when (lang) {
457 Language.C_FAMILY -> cFamilyKeywords
458 Language.PYTHON -> pythonKeywords
459 Language.SHELL -> shellKeywords
460 Language.RUBY -> rubyKeywords
461 Language.JSON -> jsonKeywords
462 Language.MARKUP -> emptySet()
463 Language.NONE -> emptySet()
466private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
467 // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
468 // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
469 (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
470 // `#` is a line comment in Python, shell and Ruby. It is NOT one in C-family
471 // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
472 (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL || lang == Language.RUBY))
474private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
476private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
477 c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
479/** Scan forward to the end of a string literal starting at [start] (the opening quote
480 * is line[start]). Returns the index just past the closing quote (or line.length if
481 * the string is unterminated on this line). Honors backslash escapes. */
482private fun findStringEnd(line: String, start: Int, quote: Char): Int {
483 var i = start + 1
484 val n = line.length
485 while (i < n && line[i] != quote) {
486 if (line[i] == '\\' && i + 1 < n) i += 2 else i++
487 }
488 return if (i < n) i + 1 else n
491/** Scan forward to the end of a number literal starting at [start].
492 *
493 * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
494 * and `123abc` must split rather than merge into one token.
495 * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
496 * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
497 * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
498 * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
499 */
500private fun findNumberEnd(line: String, start: Int): Int {
501 val n = line.length
502 // Detect a hex prefix ("0x"/"0X") right at the number start.
503 val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
504 var i = if (hex) start + 2 else start + 1
505 var seenDot = line[start] == '.'
506 var seenExp = false
507 while (i < n) {
508 val c = line[i]
509 val prev = line[i - 1]
510 when {
511 c.isDigit() || c == '_' -> i++
512 hex && isHexDigit(c) -> i++
513 c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
514 !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
515 !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
516 else -> break
517 }
518 }
519 return i
522private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
524/** Scan forward to the end of an identifier starting at [start]. */
525private fun findIdentifierEnd(line: String, start: Int): Int {
526 var i = start
527 if (line[i] == '@') i++
528 val n = line.length
529 while (i < n && isIdentifierPartChar(line[i])) i++
530 return i
533private fun isIdentifierPartChar(c: Char): Boolean =
534 c.isLetterOrDigit() || c == '_' || c == '$'