TASK-049: C/C++ highlighting and multi-line comment coloring

Adds a CPP language, preprocessor coloring, hex and digit-separator numbers, and block-comment state carried across lines. CodeBlock now highlights a file once instead of on every recomposition.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
최민영 2026-09-28 13:17:33 +09:00
parent d9536289e9
commit 10b38a3ac4
5 changed files with 400 additions and 27 deletions

View File

@ -87,7 +87,7 @@ import com.ubisam.ubgitea.domain.model.TreeEntryType
import com.ubisam.ubgitea.feature.codebrowser.content.CodeSyntaxColors import com.ubisam.ubgitea.feature.codebrowser.content.CodeSyntaxColors
import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification import com.ubisam.ubgitea.feature.codebrowser.content.ContentClassification
import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage import com.ubisam.ubgitea.feature.codebrowser.content.SyntaxLanguage
import com.ubisam.ubgitea.feature.codebrowser.content.highlightLine import com.ubisam.ubgitea.feature.codebrowser.content.highlightLines
import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors import com.ubisam.ubgitea.feature.codebrowser.content.rememberCodeSyntaxColors
import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath import com.ubisam.ubgitea.feature.codebrowser.content.syntaxLanguageForPath
import java.io.File import java.io.File
@ -308,16 +308,18 @@ private fun FileContent(
private fun CodeBlock(text: String, language: SyntaxLanguage, wordWrap: Boolean, modifier: Modifier = Modifier) { private fun CodeBlock(text: String, language: SyntaxLanguage, wordWrap: Boolean, modifier: Modifier = Modifier) {
val colors = rememberCodeSyntaxColors() val colors = rememberCodeSyntaxColors()
val lines = remember(text) { text.split("\n") } val lines = remember(text) { text.split("\n") }
// Whole-file, once per file: block comments carry state across lines (see tokenizeLines).
val highlighted = remember(lines, language, colors) { highlightLines(lines, language, colors) }
val lineNumberWidth = remember(lines.size) { lines.size.toString().length } val lineNumberWidth = remember(lines.size) { lines.size.toString().length }
val gutterWidth = (lineNumberWidth * 8 + 12).dp val gutterWidth = (lineNumberWidth * 8 + 12).dp
SelectionContainer(modifier = modifier) { SelectionContainer(modifier = modifier) {
if (wordWrap) { if (wordWrap) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
lines.forEachIndexed { index, line -> highlighted.forEachIndexed { index, line ->
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
LineNumber(index, gutterWidth, colors) LineNumber(index, gutterWidth, colors)
Text( Text(
text = highlightLine(line, language, colors), text = line,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 13.sp, fontSize = 13.sp,
color = colors.text, color = colors.text,
@ -333,9 +335,9 @@ private fun CodeBlock(text: String, language: SyntaxLanguage, wordWrap: Boolean,
lines.indices.forEach { index -> LineNumber(index, gutterWidth, colors) } lines.indices.forEach { index -> LineNumber(index, gutterWidth, colors) }
} }
Column(Modifier.horizontalScroll(rememberScrollState())) { Column(Modifier.horizontalScroll(rememberScrollState())) {
lines.forEach { line -> highlighted.forEach { line ->
Text( Text(
text = highlightLine(line, language, colors), text = line,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 13.sp, fontSize = 13.sp,
color = colors.text, color = colors.text,

View File

@ -12,18 +12,18 @@ import androidx.compose.ui.text.withStyle
import com.ubisam.ubgitea.domain.model.RepositoryPath import com.ubisam.ubgitea.domain.model.RepositoryPath
/** /**
* A deliberately lightweight, regex-based highlighter -- not a real per-language parser. Each line * A deliberately lightweight, regex-based highlighter -- not a real per-language parser. Lines are
* is tokenized independently with no cross-line state, so a `/* ... */` block comment or a * tokenized one at a time; the only state carried from one line to the next is "still inside a
* triple-quoted string that spans multiple lines is only colored correctly on the line(s) where it * block comment" (see [tokenizeLines]), so a multi-line `/* ... */` or `<!-- ... -->` comment is
* actually starts and ends, not on every line in between. A real tokenizer for every language this * colored on every line it covers. A string that spans multiple lines (C++ raw strings, C# verbatim
* app might show source from would be a much larger undertaking than this file viewer needs; this * strings, Python triple quotes) is still only colored on the line where it starts. A real
* covers the common, single-line case (which is the vast majority of real code) at a fraction of the * tokenizer for every language this app might show source from would be a much larger undertaking
* cost, matching this codebase's established preference for hand-rolled solutions over new * than this file viewer needs; this covers the common cases at a fraction of the cost, matching
* dependencies (see the launcher icon's and the favorite-push logo's own comments on the same * this codebase's established preference for hand-rolled solutions over new dependencies (see the
* point). * launcher icon's and the favorite-push logo's own comments on the same point).
*/ */
enum class SyntaxLanguage { enum class SyntaxLanguage {
KOTLIN, JAVA, CSHARP, JAVASCRIPT, PYTHON, SHELL, YAML, XML, JSON, CSS, SQL, PLAIN KOTLIN, JAVA, CSHARP, CPP, JAVASCRIPT, PYTHON, SHELL, YAML, XML, JSON, CSS, SQL, PLAIN
} }
fun syntaxLanguageForPath(path: RepositoryPath): SyntaxLanguage { fun syntaxLanguageForPath(path: RepositoryPath): SyntaxLanguage {
@ -32,6 +32,7 @@ fun syntaxLanguageForPath(path: RepositoryPath): SyntaxLanguage {
"kt", "kts" -> SyntaxLanguage.KOTLIN "kt", "kts" -> SyntaxLanguage.KOTLIN
"java" -> SyntaxLanguage.JAVA "java" -> SyntaxLanguage.JAVA
"cs" -> SyntaxLanguage.CSHARP "cs" -> SyntaxLanguage.CSHARP
"c", "h", "cc", "cpp", "cxx", "hpp", "hh", "hxx", "inl", "ipp" -> SyntaxLanguage.CPP
"js", "jsx", "mjs", "ts", "tsx" -> SyntaxLanguage.JAVASCRIPT "js", "jsx", "mjs", "ts", "tsx" -> SyntaxLanguage.JAVASCRIPT
"py" -> SyntaxLanguage.PYTHON "py" -> SyntaxLanguage.PYTHON
"sh", "bash", "zsh" -> SyntaxLanguage.SHELL "sh", "bash", "zsh" -> SyntaxLanguage.SHELL
@ -50,6 +51,7 @@ data class CodeSyntaxColors(
val string: Color, val string: Color,
val comment: Color, val comment: Color,
val number: Color, val number: Color,
val preprocessor: Color,
) )
// Colors are fixed (not derived from the app's own sky-blue Material theme) and only switch // Colors are fixed (not derived from the app's own sky-blue Material theme) and only switch
@ -67,6 +69,7 @@ fun rememberCodeSyntaxColors(): CodeSyntaxColors {
string = Color(0xFFCE9178), string = Color(0xFFCE9178),
comment = Color(0xFF6A9955), comment = Color(0xFF6A9955),
number = Color(0xFFB5CEA8), number = Color(0xFFB5CEA8),
preprocessor = Color(0xFF9B9B9B),
) )
} else { } else {
CodeSyntaxColors( CodeSyntaxColors(
@ -75,6 +78,7 @@ fun rememberCodeSyntaxColors(): CodeSyntaxColors {
string = Color(0xFFA31515), string = Color(0xFFA31515),
comment = Color(0xFF008000), comment = Color(0xFF008000),
number = Color(0xFF098658), number = Color(0xFF098658),
preprocessor = Color(0xFF6B6B6B),
) )
} }
} }
@ -86,6 +90,7 @@ private data class LangSpec(
val lineComment: String? = null, val lineComment: String? = null,
val blockComment: Boolean = false, val blockComment: Boolean = false,
val xmlComment: Boolean = false, val xmlComment: Boolean = false,
val preprocessor: Boolean = false,
) )
private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf( private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
@ -127,6 +132,25 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
), ),
lineComment = "//", lineComment = "//",
blockComment = true, blockComment = true,
preprocessor = true,
),
SyntaxLanguage.CPP to LangSpec(
keywords = setOf(
"alignas", "alignof", "auto", "bool", "break", "case", "catch", "char", "char8_t",
"char16_t", "char32_t", "class", "concept", "const", "consteval", "constexpr",
"constinit", "const_cast", "continue", "co_await", "co_return", "co_yield", "decltype",
"default", "delete", "do", "double", "dynamic_cast", "else", "enum", "explicit",
"export", "extern", "false", "final", "float", "for", "friend", "goto", "if", "inline",
"int", "long", "mutable", "namespace", "new", "noexcept", "nullptr", "operator",
"override", "private", "protected", "public", "register", "reinterpret_cast",
"requires", "return", "short", "signed", "sizeof", "static", "static_assert",
"static_cast", "struct", "switch", "template", "this", "thread_local", "throw", "true",
"try", "typedef", "typeid", "typename", "union", "unsigned", "using", "virtual", "void",
"volatile", "wchar_t", "while",
),
lineComment = "//",
blockComment = true,
preprocessor = true,
), ),
SyntaxLanguage.JAVASCRIPT to LangSpec( SyntaxLanguage.JAVASCRIPT to LangSpec(
keywords = setOf( keywords = setOf(
@ -181,9 +205,13 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = mapOf(
private val STRING_OR_NUMBER_OR_WORD = private val STRING_OR_NUMBER_OR_WORD =
"\"(?:[^\"\\\\]|\\\\.)*\"" + "|" + "\"(?:[^\"\\\\]|\\\\.)*\"" + "|" +
"'(?:[^'\\\\]|\\\\.)*'" + "|" + "'(?:[^'\\\\]|\\\\.)*'" + "|" +
"\\b\\d+(?:\\.\\d+)?[a-zA-Z]?\\b" + "|" + // Hex, and decimal with digit separators (C#/Kotlin/Java `30_000`, C++ `1'000'000`).
"\\b(?:0[xX][0-9a-fA-F_']+|\\d[\\d_']*(?:\\.\\d+)?(?:[eE][+-]?\\d+)?)[a-zA-Z]*\\b" + "|" +
"\\b[A-Za-z_][A-Za-z0-9_]*\\b" "\\b[A-Za-z_][A-Za-z0-9_]*\\b"
private val PREPROCESSOR_DIRECTIVE = Regex("^\\s*#\\s*[A-Za-z_]+")
private val INCLUDE_TARGET = Regex("^\\s*<[^>]*>")
private val patternCache: Map<SyntaxLanguage, Regex> = SPECS.mapValues { (_, spec) -> private val patternCache: Map<SyntaxLanguage, Regex> = SPECS.mapValues { (_, spec) ->
val parts = mutableListOf<String>() val parts = mutableListOf<String>()
if (spec.blockComment) parts += "/\\*.*?\\*/|/\\*.*$" if (spec.blockComment) parts += "/\\*.*?\\*/|/\\*.*$"
@ -193,27 +221,94 @@ private val patternCache: Map<SyntaxLanguage, Regex> = SPECS.mapValues { (_, spe
Regex(parts.joinToString("|")) Regex(parts.joinToString("|"))
} }
fun highlightLine(line: String, language: SyntaxLanguage, colors: CodeSyntaxColors): AnnotatedString { /** One colored stretch of a line: `[start, end)` in that line's own character offsets. */
internal data class SyntaxToken(val start: Int, val end: Int, val role: TokenRole)
private enum class BlockComment(val open: String, val close: String) {
C_STYLE("/*", "*/"),
XML("<!--", "-->"),
}
/**
* Tokenizes a whole file, carrying "still inside a block comment" from each line to the next. Kept
* free of Compose types so it can be unit-tested directly.
*/
internal fun tokenizeLines(lines: List<String>, language: SyntaxLanguage): List<List<SyntaxToken>> {
val spec = SPECS.getValue(language) val spec = SPECS.getValue(language)
val pattern = patternCache.getValue(language) val pattern = patternCache.getValue(language)
return buildAnnotatedString { var open: BlockComment? = null
var lastIndex = 0 return lines.map { line ->
for (match in pattern.findAll(line)) { val tokens = mutableListOf<SyntaxToken>()
if (match.range.first > lastIndex) append(line.substring(lastIndex, match.range.first)) var from = 0
val text = match.value open?.let { block ->
val style = classify(text, spec)?.let { role -> SpanStyle(color = role.colorOf(colors), fontWeight = role.weight) } val close = line.indexOf(block.close)
if (style != null) withStyle(style) { append(text) } else append(text) if (close < 0) {
lastIndex = match.range.last + 1 if (line.isNotEmpty()) tokens += SyntaxToken(0, line.length, TokenRole.COMMENT)
return@map tokens
} }
if (lastIndex < line.length) append(line.substring(lastIndex)) from = close + block.close.length
tokens += SyntaxToken(0, from, TokenRole.COMMENT)
open = null
}
if (from == 0 && spec.preprocessor) {
PREPROCESSOR_DIRECTIVE.find(line)?.let { directive ->
tokens += SyntaxToken(directive.range.first, directive.range.last + 1, TokenRole.PREPROCESSOR)
from = directive.range.last + 1
if (directive.value.endsWith("include") || directive.value.endsWith("import")) {
INCLUDE_TARGET.find(line.substring(from))?.let { target ->
tokens += SyntaxToken(from + target.range.first, from + target.range.last + 1, TokenRole.STRING)
from += target.range.last + 1
}
}
}
}
for (match in pattern.findAll(line, from)) {
val text = match.value
val role = classify(text, spec) ?: continue
tokens += SyntaxToken(match.range.first, match.range.last + 1, role)
if (role == TokenRole.COMMENT) open = unterminatedBlockComment(text, spec)
}
tokens
} }
} }
private enum class TokenRole(val weight: FontWeight?) { private fun unterminatedBlockComment(text: String, spec: LangSpec): BlockComment? {
val block = when {
spec.blockComment && text.startsWith(BlockComment.C_STYLE.open) -> BlockComment.C_STYLE
spec.xmlComment && text.startsWith(BlockComment.XML.open) -> BlockComment.XML
else -> return null
}
// "/*/" opens a comment without closing it: the close must come after the open delimiter.
val closed = text.length >= block.open.length + block.close.length && text.endsWith(block.close)
return if (closed) null else block
}
/** Highlights a whole file at once, so multi-line comments are colored on every line. */
fun highlightLines(lines: List<String>, language: SyntaxLanguage, colors: CodeSyntaxColors): List<AnnotatedString> =
tokenizeLines(lines, language).mapIndexed { index, tokens -> annotate(lines[index], tokens, colors) }
fun highlightLine(line: String, language: SyntaxLanguage, colors: CodeSyntaxColors): AnnotatedString =
highlightLines(listOf(line), language, colors).single()
private fun annotate(line: String, tokens: List<SyntaxToken>, colors: CodeSyntaxColors): AnnotatedString =
buildAnnotatedString {
var lastIndex = 0
for (token in tokens) {
if (token.start > lastIndex) append(line.substring(lastIndex, token.start))
withStyle(SpanStyle(color = token.role.colorOf(colors), fontWeight = token.role.weight)) {
append(line.substring(token.start, token.end))
}
lastIndex = token.end
}
if (lastIndex < line.length) append(line.substring(lastIndex))
}
internal enum class TokenRole(val weight: FontWeight?) {
COMMENT(null), COMMENT(null),
STRING(null), STRING(null),
NUMBER(null), NUMBER(null),
KEYWORD(FontWeight.SemiBold), KEYWORD(FontWeight.SemiBold),
PREPROCESSOR(null),
; ;
fun colorOf(colors: CodeSyntaxColors): Color = when (this) { fun colorOf(colors: CodeSyntaxColors): Color = when (this) {
@ -221,6 +316,7 @@ private enum class TokenRole(val weight: FontWeight?) {
STRING -> colors.string STRING -> colors.string
NUMBER -> colors.number NUMBER -> colors.number
KEYWORD -> colors.keyword KEYWORD -> colors.keyword
PREPROCESSOR -> colors.preprocessor
} }
} }

View File

@ -0,0 +1,176 @@
package com.ubisam.ubgitea.feature.codebrowser.content
import com.ubisam.ubgitea.domain.model.RepositoryPath
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SyntaxHighlighterTest {
private fun tokens(language: SyntaxLanguage, vararg lines: String): List<List<Pair<String, TokenRole>>> =
tokenizeLines(lines.toList(), language).mapIndexed { index, line ->
line.map { lines[index].substring(it.start, it.end) to it.role }
}
// --- language detection ---
@Test
fun resolvesCAndCppExtensions() {
listOf("c", "h", "cc", "cpp", "cxx", "hpp", "hh", "hxx", "inl", "ipp").forEach { extension ->
assertEquals(extension, SyntaxLanguage.CPP, syntaxLanguageForPath(RepositoryPath.of("src/RetryPolicy.$extension")))
}
}
@Test
fun resolvesUppercaseHeaderExtension() {
assertEquals(SyntaxLanguage.CPP, syntaxLanguageForPath(RepositoryPath.of("include/Types.H")))
}
@Test
fun keepsCSharpSeparateFromCpp() {
assertEquals(SyntaxLanguage.CSHARP, syntaxLanguageForPath(RepositoryPath.of("src/RetryPolicy.cs")))
}
// --- C++ ---
@Test
fun colorsCppKeywords() {
val line = tokens(SyntaxLanguage.CPP, "static constexpr int kMax = nullptr;").single()
assertEquals(
listOf("static", "constexpr", "int", "nullptr").map { it to TokenRole.KEYWORD },
line,
)
}
@Test
fun colorsIncludeDirectiveAndAngleTarget() {
val line = tokens(SyntaxLanguage.CPP, "#include <chrono>").single()
assertEquals(listOf("#include" to TokenRole.PREPROCESSOR, " <chrono>" to TokenRole.STRING), line)
}
@Test
fun colorsIncludeDirectiveAndQuotedTarget() {
val line = tokens(SyntaxLanguage.CPP, "#include \"RetryPolicy.h\"").single()
assertEquals(listOf("#include" to TokenRole.PREPROCESSOR, "\"RetryPolicy.h\"" to TokenRole.STRING), line)
}
@Test
fun colorsIndentedDirectiveAndItsValue() {
val line = tokens(SyntaxLanguage.CPP, " #define MAX_RETRY 8 // limit").single()
assertEquals(
listOf(" #define" to TokenRole.PREPROCESSOR, "8" to TokenRole.NUMBER, "// limit" to TokenRole.COMMENT),
line,
)
}
@Test
fun doesNotTreatHashInsideCodeAsDirective() {
val line = tokens(SyntaxLanguage.CPP, "int x = 1; #pragma once").single()
assertTrue(line.none { it.second == TokenRole.PREPROCESSOR })
}
@Test
fun colorsCSharpRegionAsPreprocessor() {
val line = tokens(SyntaxLanguage.CSHARP, "#region Fields").single()
assertEquals("#region" to TokenRole.PREPROCESSOR, line.first())
}
// --- numbers ---
@Test
fun colorsNumbersWithDigitSeparators() {
assertEquals(listOf("30_000" to TokenRole.NUMBER), tokens(SyntaxLanguage.CSHARP, "x = 30_000;").single())
assertEquals(listOf("1'000'000" to TokenRole.NUMBER), tokens(SyntaxLanguage.CPP, "x = 1'000'000;").single())
}
@Test
fun colorsHexAndSuffixedNumbers() {
assertEquals(
listOf("0xFF" to TokenRole.NUMBER, "2.5f" to TokenRole.NUMBER, "10UL" to TokenRole.NUMBER),
tokens(SyntaxLanguage.CPP, "a(0xFF, 2.5f, 10UL);").single(),
)
}
// --- multi-line comments ---
@Test
fun colorsEveryLineOfMultiLineBlockComment() {
val result = tokens(
SyntaxLanguage.CPP,
"/*",
" * waits before retrying",
" */",
"int x;",
)
assertEquals(listOf("/*" to TokenRole.COMMENT), result[0])
assertEquals(listOf(" * waits before retrying" to TokenRole.COMMENT), result[1])
assertEquals(listOf(" */" to TokenRole.COMMENT), result[2])
assertEquals(listOf("int" to TokenRole.KEYWORD), result[3])
}
@Test
fun highlightsCodeAfterCommentClosesOnSameLine() {
val result = tokens(SyntaxLanguage.CPP, "int a; /* starts", "ends */ return 1;")
assertEquals(listOf("int" to TokenRole.KEYWORD, "/* starts" to TokenRole.COMMENT), result[0])
assertEquals(
listOf("ends */" to TokenRole.COMMENT, "return" to TokenRole.KEYWORD, "1" to TokenRole.NUMBER),
result[1],
)
}
@Test
fun keepsBlankLinesInsideCommentAndStaysOpen() {
val result = tokens(SyntaxLanguage.CSHARP, "/* a", "", "b */", "var x;")
assertTrue(result[1].isEmpty())
assertEquals(listOf("b */" to TokenRole.COMMENT), result[2])
assertEquals(listOf("var" to TokenRole.KEYWORD), result[3])
}
@Test
fun singleLineBlockCommentDoesNotLeakToNextLine() {
val result = tokens(SyntaxLanguage.CPP, "/* done */ int a;", "int b;")
assertEquals(listOf("/* done */" to TokenRole.COMMENT, "int" to TokenRole.KEYWORD), result[0])
assertEquals(listOf("int" to TokenRole.KEYWORD), result[1])
}
@Test
fun slashStarSlashOpensCommentWithoutClosingIt() {
val result = tokens(SyntaxLanguage.CPP, "/*/", "still comment */", "int a;")
assertEquals(listOf("still comment */" to TokenRole.COMMENT), result[1])
assertEquals(listOf("int" to TokenRole.KEYWORD), result[2])
}
@Test
fun commentOpenerInsideStringDoesNotOpenComment() {
val result = tokens(SyntaxLanguage.CPP, "auto s = \"/* not a comment\";", "int a;")
assertEquals(listOf("auto" to TokenRole.KEYWORD, "\"/* not a comment\"" to TokenRole.STRING), result[0])
assertEquals(listOf("int" to TokenRole.KEYWORD), result[1])
}
@Test
fun commentOpenerInsideLineCommentDoesNotOpenComment() {
val result = tokens(SyntaxLanguage.CPP, "// see /* below", "int a;")
assertEquals(listOf("// see /* below" to TokenRole.COMMENT), result[0])
assertEquals(listOf("int" to TokenRole.KEYWORD), result[1])
}
@Test
fun colorsEveryLineOfMultiLineXmlComment() {
val result = tokens(SyntaxLanguage.XML, "<!-- first", "second", "third --> <a>")
assertEquals(listOf("<!-- first" to TokenRole.COMMENT), result[0])
assertEquals(listOf("second" to TokenRole.COMMENT), result[1])
assertEquals("third -->" to TokenRole.COMMENT, result[2].first())
}
@Test
fun languageWithoutBlockCommentsNeverCarriesState() {
val result = tokens(SyntaxLanguage.PYTHON, "x = 1 # /* note", "return x")
assertEquals(listOf("return" to TokenRole.KEYWORD), result[1])
}
@Test
fun unterminatedCommentColorsRestOfFile() {
val result = tokens(SyntaxLanguage.CPP, "/* never closed", "int a;", "int b;")
assertEquals(listOf("int a;" to TokenRole.COMMENT), result[1])
assertEquals(listOf("int b;" to TokenRole.COMMENT), result[2])
}
}

View File

@ -1269,3 +1269,27 @@ This is the append-only implementation and handoff record shared by Codex and Cl
- Next recommended task: none queued from this incident. - Next recommended task: none queued from this incident.
- Next owner: Claude, continuing per the standing direction. - Next owner: Claude, continuing per the standing direction.
## 2026-09-28 — TASK-049: C/C++ syntax highlighting and multi-line comment coloring
- Request: during the redesign discussion the human product owner said the team mainly reads C# and
C++, asked for C++ to be implemented, and asked for TASK-048's known limitation (a multi-line
block comment only colored on its first and last line) to be fixed.
- What changed: `SyntaxHighlighter.kt` gained a `CPP` language (`.c .h .cc .cpp .cxx .hpp .hh .hxx
.inl .ipp`), a preprocessor token role and color, a number pattern that accepts hex and digit
separators, and `tokenizeLines`/`highlightLines`, which carry "inside a block comment" from line
to line for `/* */` and `<!-- -->`. `CodeBlock` now highlights the whole file once inside
`remember` instead of re-highlighting each line on every recomposition.
- Where it was done: the worktree `awesome-pascal-3f2ada` (branch
`claude/app-features-purpose-c45235`), fast-forwarded to `39be1b7` with the uncommitted
TASK-045..048 changes copied in from the main checkout, because the session's guardrail blocks
edits to the main checkout. Nothing is committed yet; the main checkout is untouched.
- Checks and results: `./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest
assembleDebug lintDebug` BUILD SUCCESSFUL; 289 unit tests, 0 failures (21 new in
`SyntaxHighlighterTest`); lint 0 errors, 40 warnings.
- Live verification: not done -- no device was connected to this session.
- Known limitations: multi-line strings are still colored only on the line where they start; no
type or method coloring yet, so the result is closer to VS Code's defaults than to Visual Studio.
- Next recommended task: commit TASK-045..048 in the main checkout so this branch can be reconciled
with it, then the code-viewer redesign (standalone full-screen viewer, Visual Studio palette,
header/source pairing).
- Next owner: Claude.

View File

@ -0,0 +1,75 @@
# TASK-049: C/C++ syntax highlighting and multi-line comment coloring
Status: implemented; see Evidence for check results; device confirmation pending
Owner: Claude
Reviewer: human product owner
## Outcome
The human product owner's team mainly reads C# and C++ in this app. C/C++ files currently fall
back to plain, uncolored text, and a block comment that spans several lines is only colored on its
first and last line (TASK-048's recorded known limitation). After this task C/C++ sources are
highlighted, and a multi-line comment keeps its comment color on every line it covers, in every
language that has block comments.
## Scope
- Included: a `CPP` language (`.c .h .cc .cpp .cxx .hpp .hh .hxx .inl .ipp`) with its keyword set;
preprocessor directives (`#include`, `#pragma`, `#define`, C#'s `#region` ...) in their own color,
with an `#include <...>` target colored as a string; cross-line block-comment state (`/* */` and
`<!-- -->`) carried from one line to the next; numbers with digit separators (`30_000`,
`1'000'000`) and hex literals; unit tests for all of the above.
- Excluded: a real parser. Multi-line *strings* (C++ raw strings, C# verbatim/raw strings, Python
triple quotes) still only color on the line where they start. Type/method coloring, the Visual
Studio palette, the header/source pairing view and the standalone full-screen viewer belong to
the redesign tasks that follow.
## Writable paths
- `app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt`
- `app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt` (`CodeBlock` only)
- `app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighterTest.kt`
- `tasks/049-cpp-highlighting-and-multiline-comments.md`, `docs/development-log.md`
## Acceptance criteria
- [x] `.c .h .cc .cpp .cxx .hpp .hh .hxx .inl .ipp` files resolve to the C/C++ language.
- [x] C/C++ keywords, strings, numbers, line comments and block comments are colored.
- [x] `#include <chrono>` colors the directive as preprocessor and `<chrono>` as a string.
- [x] Every line inside a multi-line `/* ... */` or `<!-- ... -->` comment is colored as a comment,
and code after the closing delimiter on the same line is highlighted normally.
- [x] A `/*` inside a string literal or after `//` does not open a comment.
- [x] Highlighting is computed once per file, not on every recomposition.
- [x] Unit tests cover the criteria above.
- [ ] Confirmed on the human product owner's device against real C++ sources.
## Security and privacy
No additional concerns. No network, storage or logging is touched.
## Required checks
```text
./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest assembleDebug lintDebug
```
## Evidence
Recorded on 2026-09-28 in the worktree `awesome-pascal-3f2ada`, on top of `39be1b7` plus the
still-uncommitted TASK-045..048 changes copied from the main checkout, using Android Studio's
bundled JDK (`D:\AndroidStudio\jbr`, OpenJDK 21):
- `./gradlew compileDebugKotlin compileDebugUnitTestKotlin testDebugUnitTest` -- passed; 289 tests,
0 failures, 0 errors, 0 skipped. New `SyntaxHighlighterTest`: 21 tests, all passing.
- `./gradlew assembleDebug lintDebug` -- passed; lint 0 errors, 40 warnings.
- `SyntaxHighlighter.kt`: new `CPP` language and extension mapping; `tokenizeLines` (Compose-free,
carries open block-comment state across lines); `highlightLines`; preprocessor role and color;
number pattern extended to hex and digit separators. `highlightLine` kept as a single-line
wrapper.
- `CodeBrowserScreen.kt`: `CodeBlock` highlights the whole file once inside `remember` and renders
the cached lines in both the wrap and no-wrap branches.
- Not done: installing on a device. No device was connected to this session.
## Review findings
None yet.