git.iko.soy/opencode-android
on 8bc2cce help sign in
266 lines 12 kB history raw
1package soy.iko.opencode.ui.components
3import androidx.compose.material3.MaterialTheme
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.remember
6import androidx.compose.ui.graphics.Color
7import androidx.compose.ui.text.AnnotatedString
8import androidx.compose.ui.text.SpanStyle
9import androidx.compose.ui.text.font.FontFamily
10import androidx.compose.ui.text.font.FontStyle
11import androidx.compose.ui.text.font.FontWeight
12import androidx.compose.ui.text.withStyle
14/**
15 * Lightweight, dependency-free syntax highlighting for the file viewer.
16 *
17 * A full tokenizer (TreeSitter, Prism, etc.) would add a heavy native or generated
18 * dependency that conflicts with the project's no-KSP / no-native-deps stance. This
19 * heuristic highlighter instead colors comments, strings, numbers, and a per-language
20 * keyword set via simple character/word scanning — enough to make a code file scannable
21 * in the viewer without pulling in a parser.
22 *
23 * The highlighter is per-line (the viewer renders line-by-line), so multi-line block
24 * comments and triple-quoted strings are not handled; that's an accepted trade-off for
25 * a viewer (not an editor) where the goal is visual orientation, not correctness.
26 */
28/** Language families recognized by [highlightLine]. The set of keywords is shared
29 * across C-family languages (kotlin, java, js, ts, swift, rust, go, c, cpp) since the
30 * common keywords overlap heavily; python has its own set; shell has its own. */
31private enum class Language {
32 C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
33 PYTHON,
34 SHELL, // sh, bash, zsh
35 MARKUP, // html, xml, svg
36 NONE,
39private fun languageFor(filename: String): Language {
40 val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
41 return when (ext) {
42 "kt", "kts", "java", "js", "mjs", "cjs", "ts", "tsx",
43 "swift", "rs", "go", "c", "h", "cpp", "cc", "cxx", "hpp", "hh",
44 "cs", "scala", "groovy", "dart", "gradle",
45 -> Language.C_FAMILY
46 "py", "pyw", "rb" -> Language.PYTHON
47 "sh", "bash", "zsh", "fish" -> Language.SHELL
48 "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
49 else -> Language.NONE
50 }
53private val cFamilyKeywords = setOf(
54 "abstract", "as", "assert", "async", "await", "break", "case", "catch", "class",
55 "const", "continue", "default", "defer", "do", "else", "enum", "export",
56 "extends", "external", "final", "finally", "fn", "for", "func", "fun", "go",
57 "if", "impl", "implements", "import", "in", "init", "inline", "instanceof", "is",
58 "let", "match", "mod", "module", "mut", "namespace", "new", "object", "of",
59 "override", "package", "private", "protected", "public", "pub", "raise", "return",
60 "sealed", "self", "static", "struct", "super", "suspend", "switch", "this", "throw",
61 "throws", "trait", "try", "type", "typeof", "union", "unsafe", "val", "var", "when",
62 "where", "while", "with", "yield", "true", "false", "null", "nil",
63 "and", "or", "not", "lambda", "elif", "pass", "print",
66private val pythonKeywords = setOf(
67 "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
68 "continue", "def", "del", "elif", "else", "except", "finally", "for", "from",
69 "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass",
70 "raise", "return", "try", "while", "with", "yield", "self", "cls", "print",
73private val shellKeywords = setOf(
74 "if", "then", "else", "elif", "fi", "for", "do", "done", "while", "case", "esac",
75 "function", "in", "return", "exit", "echo", "export", "local", "readonly", "unset",
76 "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
79/** Theme-derived colors for the highlighter. Captured in a @Composable so the styles
80 * re-resolve when the color scheme changes (e.g. dynamic color or a theme switch). */
81data class HighlightPalette(
82 val keyword: Color,
83 val comment: Color,
84 val string: Color,
85 val number: Color,
86 val annotation: Color,
87 val tag: Color,
88 val base: Color,
91@Composable
92fun rememberHighlightPalette(): HighlightPalette {
93 // Use muted theme roles so the highlighting reads as part of the UI, not a foreign
94 // palette. Keyword = primary, string = secondary, comment = outline (dimmed),
95 // number = tertiary. Falls back gracefully when dynamic color is on (the roles
96 // come from the wallpaper-derived scheme).
97 val scheme = MaterialTheme.colorScheme
98 return remember(scheme) {
99 HighlightPalette(
100 keyword = scheme.primary,
101 comment = scheme.outline,
102 string = scheme.secondary,
103 number = scheme.tertiary,
104 annotation = scheme.tertiary,
105 tag = scheme.primary,
106 base = scheme.onSurface,
107 )
108 }
111/**
112 * Produce a highlighted [AnnotatedString] for a single line of source code.
113 *
114 * The scanner walks the line character by character, classifying runs of chars as
115 * comments, strings, numbers, identifiers (checked against the keyword set), or plain
116 * text. It's O(n) per line and allocation-light (a single AnnotatedString.Builder),
117 * which matters because the file viewer renders lazily but can scroll through
118 * thousands of lines — a heavy highlighter would jank on fast scrolls.
119 */
120fun highlightLine(line: String, filename: String, palette: HighlightPalette): AnnotatedString {
121 val lang = languageFor(filename)
122 if (lang == Language.NONE) return AnnotatedString(line)
123 val keywords = keywordsFor(lang)
124 val builder = AnnotatedString.Builder(line.length + 8)
125 val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
126 val mono = FontFamily.Monospace
127 var i = 0
128 val n = line.length
129 while (i < n) {
130 val c = line[i]
131 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
132 if (emitted.consumed) {
133 i = emitted.nextIndex
134 } else {
135 builder.withStyle(baseStyle) { append(c) }
136 i++
137 }
138 }
139 return builder.toAnnotatedString()
142/** Result of attempting to emit one token starting at [start]: the next scan index and
143 * whether a token was consumed (vs. falling through to the default plain-text emit). */
144private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
146private fun emitToken(
147 builder: AnnotatedString.Builder,
148 line: String,
149 start: Int,
150 lang: Language,
151 keywords: Set<String>,
152 palette: HighlightPalette,
153 baseStyle: SpanStyle,
154 mono: FontFamily,
155): EmitResult {
156 val c = line[start]
157 if (isLineCommentStart(c, line, start, lang)) {
158 builder.withStyle(SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)) {
159 append(line.substring(start))
160 }
161 return EmitResult(line.length, true)
162 }
163 if (c == '"' || c == '\'') {
164 val end = findStringEnd(line, start, c)
165 builder.withStyle(SpanStyle(color = palette.string, fontFamily = mono)) { append(line.substring(start, end)) }
166 return EmitResult(end, true)
167 }
168 if (isNumberStart(c, line, start)) {
169 val end = findNumberEnd(line, start)
170 builder.withStyle(SpanStyle(color = palette.number, fontFamily = mono)) { append(line.substring(start, end)) }
171 return EmitResult(end, true)
172 }
173 if (c.isLetter() || c == '_' || c == '@') {
174 val end = findIdentifierEnd(line, start)
175 val word = line.substring(start, end)
176 when {
177 c == '@' -> builder.withStyle(SpanStyle(color = palette.annotation, fontFamily = mono)) { append(word) }
178 word in keywords -> builder.withStyle(SpanStyle(color = palette.keyword, fontWeight = FontWeight.SemiBold, fontFamily = mono)) { append(word) }
179 else -> builder.withStyle(baseStyle) { append(word) }
180 }
181 return EmitResult(end, true)
182 }
183 if (lang == Language.MARKUP && isMarkupDelim(c)) {
184 builder.withStyle(SpanStyle(color = palette.tag, fontFamily = mono)) { append(c) }
185 return EmitResult(start + 1, true)
186 }
187 return EmitResult(start + 1, false)
190private fun keywordsFor(lang: Language): Set<String> = when (lang) {
191 Language.C_FAMILY -> cFamilyKeywords
192 Language.PYTHON -> pythonKeywords
193 Language.SHELL -> shellKeywords
194 Language.MARKUP -> emptySet()
195 Language.NONE -> emptySet()
198private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
199 // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
200 // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
201 (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
202 // `#` is a line comment only in Python and shell. It is NOT one in C-family
203 // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
204 (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL))
206private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
208private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
209 c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
211/** Scan forward to the end of a string literal starting at [start] (the opening quote
212 * is line[start]). Returns the index just past the closing quote (or line.length if
213 * the string is unterminated on this line). Honors backslash escapes. */
214private fun findStringEnd(line: String, start: Int, quote: Char): Int {
215 var i = start + 1
216 val n = line.length
217 while (i < n && line[i] != quote) {
218 if (line[i] == '\\' && i + 1 < n) i += 2 else i++
219 }
220 return if (i < n) i + 1 else n
223/** Scan forward to the end of a number literal starting at [start].
224 *
225 * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
226 * and `123abc` must split rather than merge into one token.
227 * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
228 * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
229 * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
230 * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
231 */
232private fun findNumberEnd(line: String, start: Int): Int {
233 val n = line.length
234 // Detect a hex prefix ("0x"/"0X") right at the number start.
235 val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
236 var i = if (hex) start + 2 else start + 1
237 var seenDot = line[start] == '.'
238 var seenExp = false
239 while (i < n) {
240 val c = line[i]
241 val prev = line[i - 1]
242 when {
243 c.isDigit() || c == '_' -> i++
244 hex && isHexDigit(c) -> i++
245 c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
246 !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
247 !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
248 else -> break
249 }
250 }
251 return i
254private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
256/** Scan forward to the end of an identifier starting at [start]. */
257private fun findIdentifierEnd(line: String, start: Int): Int {
258 var i = start
259 if (line[i] == '@') i++
260 val n = line.length
261 while (i < n && isIdentifierPartChar(line[i])) i++
262 return i
265private fun isIdentifierPartChar(c: Char): Boolean =
266 c.isLetterOrDigit() || c == '_' || c == '$'