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
15 * Lightweight, dependency-free syntax highlighting for the file viewer.
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.
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.
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
34 SHELL, // sh, bash, zsh
35 MARKUP, // html, xml, svg
39private fun languageFor(filename: String): Language {
40 val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
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",
46 "py", "pyw", "rb" -> Language.PYTHON
47 "sh", "bash", "zsh", "fish" -> Language.SHELL
48 "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
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(
86 val annotation: Color,
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) {
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,
112 * Produce a highlighted [AnnotatedString] for a single line of source code.
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.
120fun highlightLine(line: String, filename: String, palette: HighlightPalette): AnnotatedString =
121 highlightLine(line, syntaxFor(filename), palette)
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.
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
151 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
152 if (emitted.consumed) {
153 i = emitted.nextIndex
155 builder.withStyle(baseStyle) { append(c) }
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,
171 keywords: Set<String>,
172 palette: HighlightPalette,
173 baseStyle: SpanStyle,
177 if (isLineCommentStart(c, line, start, lang)) {
178 builder.withStyle(SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)) {
179 append(line.substring(start))
181 return EmitResult(line.length, true)
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)
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)
193 if (c.isLetter() || c == '_' || c == '@') {
194 val end = findIdentifierEnd(line, start)
195 val word = line.substring(start, end)
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) }
201 return EmitResult(end, true)
203 if (lang == Language.MARKUP && isMarkupDelim(c)) {
204 builder.withStyle(SpanStyle(color = palette.tag, fontFamily = mono)) { append(c) }
205 return EmitResult(start + 1, true)
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 {
237 while (i < n && line[i] != quote) {
238 if (line[i] == '\\' && i + 1 < n) i += 2 else i++
240 return if (i < n) i + 1 else n
243/** Scan forward to the end of a number literal starting at [start].
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).
252private fun findNumberEnd(line: String, start: Int): Int {
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] == '.'
261 val prev = line[i - 1]
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++
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 {
279 if (line[i] == '@') i++
281 while (i < n && isIdentifierPartChar(line[i])) i++
285private fun isIdentifierPartChar(c: Char): Boolean =
286 c.isLetterOrDigit() || c == '_' || c == '$'
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list