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
17 * Lightweight, dependency-free syntax highlighting for the file viewer.
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.
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.
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
36 SHELL, // sh, bash, zsh
37 MARKUP, // html, xml, svg
43private fun languageFor(filename: String): Language {
44 val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
45 return languageForToken(ext)
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].
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",
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
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(
111 val annotation: Color,
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) {
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,
137 * Produce a highlighted [AnnotatedString] for a single line of source code.
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.
145fun highlightLine(line: String, filename: String, palette: HighlightPalette): AnnotatedString =
146 highlightLine(line, syntaxFor(filename), palette)
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.
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))
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.
170fun syntaxForLanguageTag(tag: String): FileSyntax {
171 val lang = languageForTag(tag)
172 return FileSyntax(lang, keywordsFor(lang))
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]).
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.
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()
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))
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
220 val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
221 if (emitted.consumed) {
222 i = emitted.nextIndex
224 builder.withStyle(baseStyle) { append(c) }
228 return builder.toAnnotatedString()
231/** Result of attempting to emit one token starting at [start]: the next scan index and
232 * whether a token was consumed (vs. falling through to the default plain-text emit). */
233private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
235private fun emitToken(
236 builder: AnnotatedString.Builder,
240 keywords: Set<String>,
241 palette: HighlightPalette,
242 baseStyle: SpanStyle,
246 if (isLineCommentStart(c, line, start, lang)) {
247 builder.withStyle(SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)) {
248 append(line.substring(start))
250 return EmitResult(line.length, true)
252 if (c == '"' || c == '\'') {
253 val end = findStringEnd(line, start, c)
254 builder.withStyle(SpanStyle(color = palette.string, fontFamily = mono)) { append(line.substring(start, end)) }
255 return EmitResult(end, true)
257 if (isNumberStart(c, line, start)) {
258 val end = findNumberEnd(line, start)
259 builder.withStyle(SpanStyle(color = palette.number, fontFamily = mono)) { append(line.substring(start, end)) }
260 return EmitResult(end, true)
262 if (c.isLetter() || c == '_' || c == '@') {
263 val end = findIdentifierEnd(line, start)
264 val word = line.substring(start, end)
266 c == '@' -> builder.withStyle(SpanStyle(color = palette.annotation, fontFamily = mono)) { append(word) }
267 word in keywords -> builder.withStyle(SpanStyle(color = palette.keyword, fontWeight = FontWeight.SemiBold, fontFamily = mono)) { append(word) }
268 else -> builder.withStyle(baseStyle) { append(word) }
270 return EmitResult(end, true)
272 if (lang == Language.MARKUP && isMarkupDelim(c)) {
273 builder.withStyle(SpanStyle(color = palette.tag, fontFamily = mono)) { append(c) }
274 return EmitResult(start + 1, true)
276 return EmitResult(start + 1, false)
279private fun keywordsFor(lang: Language): Set<String> = when (lang) {
280 Language.C_FAMILY -> cFamilyKeywords
281 Language.PYTHON -> pythonKeywords
282 Language.SHELL -> shellKeywords
283 Language.RUBY -> rubyKeywords
284 Language.JSON -> jsonKeywords
285 Language.MARKUP -> emptySet()
286 Language.NONE -> emptySet()
289private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
290 // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
291 // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
292 (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
293 // `#` is a line comment in Python, shell and Ruby. It is NOT one in C-family
294 // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
295 (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL || lang == Language.RUBY))
297private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
299private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
300 c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
302/** Scan forward to the end of a string literal starting at [start] (the opening quote
303 * is line[start]). Returns the index just past the closing quote (or line.length if
304 * the string is unterminated on this line). Honors backslash escapes. */
305private fun findStringEnd(line: String, start: Int, quote: Char): Int {
308 while (i < n && line[i] != quote) {
309 if (line[i] == '\\' && i + 1 < n) i += 2 else i++
311 return if (i < n) i + 1 else n
314/** Scan forward to the end of a number literal starting at [start].
316 * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
317 * and `123abc` must split rather than merge into one token.
318 * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
319 * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
320 * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
321 * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
323private fun findNumberEnd(line: String, start: Int): Int {
325 // Detect a hex prefix ("0x"/"0X") right at the number start.
326 val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
327 var i = if (hex) start + 2 else start + 1
328 var seenDot = line[start] == '.'
332 val prev = line[i - 1]
334 c.isDigit() || c == '_' -> i++
335 hex && isHexDigit(c) -> i++
336 c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
337 !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
338 !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
345private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
347/** Scan forward to the end of an identifier starting at [start]. */
348private fun findIdentifierEnd(line: String, start: Int): Int {
350 if (line[i] == '@') i++
352 while (i < n && isIdentifierPartChar(line[i])) i++
356private fun isIdentifierPartChar(c: Char): Boolean =
357 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