@@ -1,3 +1,5 @@
1+@file:Suppress("TooManyFunctions")
1package soy.iko.opencode.ui.components
3package soy.iko.opencode.ui.components
3import androidx.compose.material3.MaterialTheme
5import androidx.compose.material3.MaterialTheme
@@ -27,27 +29,41 @@ import androidx.compose.ui.text.withStyle
28/** Language families recognized by [highlightLine]. The set of keywords is shared
30/** 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
31 * 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. */
32+ * common keywords overlap heavily; python, ruby, shell, json each have their own. */
31internal enum class Language {
33internal enum class Language {
32 C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
34 C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
34 SHELL, // sh, bash, zsh
36 SHELL, // sh, bash, zsh
35 MARKUP, // html, xml, svg
37 MARKUP, // html, xml, svg
39private fun languageFor(filename: String): Language {
43private fun languageFor(filename: String): Language {
40 val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
44 val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
41− return when (ext) {
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].
53+internal fun languageForTag(tag: String): Language = languageForToken(tag.trim().lowercase())
55+private fun languageForToken(token: String): Language = when (token) {
42− "kt", "kts", "java", "js", "mjs", "cjs", "ts", "tsx",
56+ "kt", "kotlin", "kts", "ktm", "java", "js", "javascript", "mjs", "cjs",
57+ "ts", "typescript", "tsx", "jsx",
43− "swift", "rs", "go", "c", "h", "cpp", "cc", "cxx", "hpp", "hh",
58+ "swift", "rs", "rust", "go", "golang", "c", "h", "cpp", "c++", "cc", "cxx", "hpp", "hh",
44− "cs", "scala", "groovy", "dart", "gradle",
59+ "cs", "csharp", "c#", "scala", "groovy", "gradle", "dart",
45− -> Language.C_FAMILY
60+ -> Language.C_FAMILY
61+ "py", "python", "pyw" -> Language.PYTHON
46− "py", "pyw", "rb" -> Language.PYTHON
62+ "rb", "ruby" -> Language.RUBY
47− "sh", "bash", "zsh", "fish" -> Language.SHELL
63+ "sh", "bash", "zsh", "fish", "shell", "shellscript", "console" -> Language.SHELL
48− "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
64+ "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
65+ "json", "json5", "jsonc" -> Language.JSON
49− else -> Language.NONE
66+ else -> Language.NONE
53private val cFamilyKeywords = setOf(
69private val cFamilyKeywords = setOf(
@@ -76,6 +92,15 @@ private val shellKeywords = setOf(
76 "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
92 "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
95+private 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__",
102+private val jsonKeywords = setOf("true", "false", "null")
79/** Theme-derived colors for the highlighter. Captured in a @Composable so the styles
104/** 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). */
105 * re-resolve when the color scheme changes (e.g. dynamic color or a theme switch). */
81data class HighlightPalette(
106data class HighlightPalette(
@@ -136,6 +161,50 @@ fun syntaxFor(filename: String): FileSyntax {
136 return FileSyntax(lang, keywordsFor(lang))
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.
170+fun 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.
184+fun 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))
202+ if (end == -1) break
205+ return builder.toAnnotatedString()
139/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
208/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
140fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
209fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
141 val lang = syntax.lang
210 val lang = syntax.lang
@@ -211,6 +280,8 @@ private fun keywordsFor(lang: Language): Set<String> = when (lang) {
211 Language.C_FAMILY -> cFamilyKeywords
280 Language.C_FAMILY -> cFamilyKeywords
212 Language.PYTHON -> pythonKeywords
281 Language.PYTHON -> pythonKeywords
213 Language.SHELL -> shellKeywords
282 Language.SHELL -> shellKeywords
283+ Language.RUBY -> rubyKeywords
284+ Language.JSON -> jsonKeywords
214 Language.MARKUP -> emptySet()
285 Language.MARKUP -> emptySet()
215 Language.NONE -> emptySet()
286 Language.NONE -> emptySet()
@@ -219,9 +290,9 @@ private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): B
219 // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
290 // `//` 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.
291 // `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] == '/') ||
292 (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
293+ // `#` is a line comment in Python, shell and Ruby. It is NOT one in C-family
223 // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
294 // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
224− (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL))
295+ (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL || lang == Language.RUBY))
226private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
297private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='