git.iko.soy/opencode-android
on bc6b45e help sign in
262 lines 11 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 (c == '/' && i + 1 < line.length && line[i + 1] == '/') ||
200 (c == '#' && lang != Language.MARKUP)
202private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
204private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
205 c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
207/** Scan forward to the end of a string literal starting at [start] (the opening quote
208 * is line[start]). Returns the index just past the closing quote (or line.length if
209 * the string is unterminated on this line). Honors backslash escapes. */
210private fun findStringEnd(line: String, start: Int, quote: Char): Int {
211 var i = start + 1
212 val n = line.length
213 while (i < n && line[i] != quote) {
214 if (line[i] == '\\' && i + 1 < n) i += 2 else i++
215 }
216 return if (i < n) i + 1 else n
219/** Scan forward to the end of a number literal starting at [start].
220 *
221 * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
222 * and `123abc` must split rather than merge into one token.
223 * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
224 * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
225 * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
226 * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
227 */
228private fun findNumberEnd(line: String, start: Int): Int {
229 val n = line.length
230 // Detect a hex prefix ("0x"/"0X") right at the number start.
231 val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
232 var i = if (hex) start + 2 else start + 1
233 var seenDot = line[start] == '.'
234 var seenExp = false
235 while (i < n) {
236 val c = line[i]
237 val prev = line[i - 1]
238 when {
239 c.isDigit() || c == '_' -> i++
240 hex && isHexDigit(c) -> i++
241 c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
242 !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
243 !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
244 else -> break
245 }
246 }
247 return i
250private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
252/** Scan forward to the end of an identifier starting at [start]. */
253private fun findIdentifierEnd(line: String, start: Int): Int {
254 var i = start
255 if (line[i] == '@') i++
256 val n = line.length
257 while (i < n && isIdentifierPartChar(line[i])) i++
258 return i
261private fun isIdentifierPartChar(c: Char): Boolean =
262 c.isLetterOrDigit() || c == '_' || c == '$'