git.iko.soy/opencode-android
on 066cded help sign in
286 lines 13 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. */
31internal 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 highlightLine(line, syntaxFor(filename), palette)
123/**
124 * A file's language classification, resolved once from its name. Resolving this per file
125 * (instead of re-parsing the extension inside [highlightLine] for every line) keeps the
126 * hot per-line highlight path free of the `substringAfterLast`/`lowercase` work.
127 */
128class FileSyntax internal constructor(
129 internal val lang: Language,
130 internal val keywords: Set<String>,
133/** Resolve the [FileSyntax] for a filename. Cheap; call once per file and reuse. */
134fun syntaxFor(filename: String): FileSyntax {
135 val lang = languageFor(filename)
136 return FileSyntax(lang, keywordsFor(lang))
139/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
140fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
141 val lang = syntax.lang
142 if (lang == Language.NONE) return AnnotatedString(line)
143 val keywords = syntax.keywords
144 val builder = AnnotatedString.Builder(line.length + 8)
145 val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
146 val mono = FontFamily.Monospace
147 var i = 0
148 val n = line.length
149 while (i < n) {
150 val c = line[i]
151 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
152 if (emitted.consumed) {
153 i = emitted.nextIndex
154 } else {
155 builder.withStyle(baseStyle) { append(c) }
156 i++
157 }
158 }
159 return builder.toAnnotatedString()
162/** Result of attempting to emit one token starting at [start]: the next scan index and
163 * whether a token was consumed (vs. falling through to the default plain-text emit). */
164private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
166private fun emitToken(
167 builder: AnnotatedString.Builder,
168 line: String,
169 start: Int,
170 lang: Language,
171 keywords: Set<String>,
172 palette: HighlightPalette,
173 baseStyle: SpanStyle,
174 mono: FontFamily,
175): EmitResult {
176 val c = line[start]
177 if (isLineCommentStart(c, line, start, lang)) {
178 builder.withStyle(SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)) {
179 append(line.substring(start))
180 }
181 return EmitResult(line.length, true)
182 }
183 if (c == '"' || c == '\'') {
184 val end = findStringEnd(line, start, c)
185 builder.withStyle(SpanStyle(color = palette.string, fontFamily = mono)) { append(line.substring(start, end)) }
186 return EmitResult(end, true)
187 }
188 if (isNumberStart(c, line, start)) {
189 val end = findNumberEnd(line, start)
190 builder.withStyle(SpanStyle(color = palette.number, fontFamily = mono)) { append(line.substring(start, end)) }
191 return EmitResult(end, true)
192 }
193 if (c.isLetter() || c == '_' || c == '@') {
194 val end = findIdentifierEnd(line, start)
195 val word = line.substring(start, end)
196 when {
197 c == '@' -> builder.withStyle(SpanStyle(color = palette.annotation, fontFamily = mono)) { append(word) }
198 word in keywords -> builder.withStyle(SpanStyle(color = palette.keyword, fontWeight = FontWeight.SemiBold, fontFamily = mono)) { append(word) }
199 else -> builder.withStyle(baseStyle) { append(word) }
200 }
201 return EmitResult(end, true)
202 }
203 if (lang == Language.MARKUP && isMarkupDelim(c)) {
204 builder.withStyle(SpanStyle(color = palette.tag, fontFamily = mono)) { append(c) }
205 return EmitResult(start + 1, true)
206 }
207 return EmitResult(start + 1, false)
210private fun keywordsFor(lang: Language): Set<String> = when (lang) {
211 Language.C_FAMILY -> cFamilyKeywords
212 Language.PYTHON -> pythonKeywords
213 Language.SHELL -> shellKeywords
214 Language.MARKUP -> emptySet()
215 Language.NONE -> emptySet()
218private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
219 // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
220 // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
221 (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
222 // `#` is a line comment only in Python and shell. It is NOT one in C-family
223 // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
224 (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL))
226private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
228private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
229 c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
231/** Scan forward to the end of a string literal starting at [start] (the opening quote
232 * is line[start]). Returns the index just past the closing quote (or line.length if
233 * the string is unterminated on this line). Honors backslash escapes. */
234private fun findStringEnd(line: String, start: Int, quote: Char): Int {
235 var i = start + 1
236 val n = line.length
237 while (i < n && line[i] != quote) {
238 if (line[i] == '\\' && i + 1 < n) i += 2 else i++
239 }
240 return if (i < n) i + 1 else n
243/** Scan forward to the end of a number literal starting at [start].
244 *
245 * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
246 * and `123abc` must split rather than merge into one token.
247 * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
248 * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
249 * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
250 * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
251 */
252private fun findNumberEnd(line: String, start: Int): Int {
253 val n = line.length
254 // Detect a hex prefix ("0x"/"0X") right at the number start.
255 val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
256 var i = if (hex) start + 2 else start + 1
257 var seenDot = line[start] == '.'
258 var seenExp = false
259 while (i < n) {
260 val c = line[i]
261 val prev = line[i - 1]
262 when {
263 c.isDigit() || c == '_' -> i++
264 hex && isHexDigit(c) -> i++
265 c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
266 !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
267 !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
268 else -> break
269 }
270 }
271 return i
274private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
276/** Scan forward to the end of an identifier starting at [start]. */
277private fun findIdentifierEnd(line: String, start: Int): Int {
278 var i = start
279 if (line[i] == '@') i++
280 val n = line.length
281 while (i < n && isIdentifierPartChar(line[i])) i++
282 return i
285private fun isIdentifierPartChar(c: Char): Boolean =
286 c.isLetterOrDigit() || c == '_' || c == '$'