@@ -1,3 +1,5 @@
1+@file:Suppress("TooManyFunctions")
2+
13package soy.iko.opencode.ui.components
24
35import androidx.compose.material3.MaterialTheme
@@ -27,27 +29,41 @@ import androidx.compose.ui.text.withStyle
2729
2830/** Language families recognized by [highlightLine]. The set of keywords is shared
2931 * 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. */
3133internal enum class Language {
3234 C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
3335 PYTHON,
3436 SHELL, // sh, bash, zsh
3537 MARKUP, // html, xml, svg
38+ RUBY,
39+ JSON,
3640 NONE,
3741}
3842
3943private fun languageFor(filename: String): Language {
4044 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− }
45+ return languageForToken(ext)
46+}
47+
48+/**
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].
52+ */
53+internal fun languageForTag(tag: String): Language = languageForToken(tag.trim().lowercase())
54+
55+private 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",
60+ -> Language.C_FAMILY
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
66+ else -> Language.NONE
5167}
5268
5369private val cFamilyKeywords = setOf(
@@ -76,6 +92,15 @@ private val shellKeywords = setOf(
7692 "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
7793)
7894
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__",
100+)
101+
102+private val jsonKeywords = setOf("true", "false", "null")
103+
79104/** Theme-derived colors for the highlighter. Captured in a @Composable so the styles
80105 * re-resolve when the color scheme changes (e.g. dynamic color or a theme switch). */
81106data class HighlightPalette(
@@ -136,6 +161,50 @@ fun syntaxFor(filename: String): FileSyntax {
136161 return FileSyntax(lang, keywordsFor(lang))
137162}
138163
164+/**
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.
169+ */
170+fun syntaxForLanguageTag(tag: String): FileSyntax {
171+ val lang = languageForTag(tag)
172+ return FileSyntax(lang, keywordsFor(lang))
173+}
174+
175+/**
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]).
180+ *
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.
183+ */
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()
193+ }
194+ var start = 0
195+ var first = true
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))
201+ first = false
202+ if (end == -1) break
203+ start = end + 1
204+ }
205+ return builder.toAnnotatedString()
206+}
207+
139208/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
140209fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
141210 val lang = syntax.lang
@@ -211,6 +280,8 @@ private fun keywordsFor(lang: Language): Set<String> = when (lang) {
211280 Language.C_FAMILY -> cFamilyKeywords
212281 Language.PYTHON -> pythonKeywords
213282 Language.SHELL -> shellKeywords
283+ Language.RUBY -> rubyKeywords
284+ Language.JSON -> jsonKeywords
214285 Language.MARKUP -> emptySet()
215286 Language.NONE -> emptySet()
216287}
@@ -219,9 +290,9 @@ private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): B
219290 // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
220291 // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
221292 (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
223294 // (`#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))
225296
226297private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
227298