diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt index 6c63b4a..7685a7f 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/CodeBrowserScreen.kt @@ -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.ContentClassification 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.syntaxLanguageForPath import java.io.File @@ -308,16 +308,18 @@ private fun FileContent( private fun CodeBlock(text: String, language: SyntaxLanguage, wordWrap: Boolean, modifier: Modifier = Modifier) { val colors = rememberCodeSyntaxColors() 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 gutterWidth = (lineNumberWidth * 8 + 12).dp SelectionContainer(modifier = modifier) { if (wordWrap) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { - lines.forEachIndexed { index, line -> + highlighted.forEachIndexed { index, line -> Row(Modifier.fillMaxWidth()) { LineNumber(index, gutterWidth, colors) Text( - text = highlightLine(line, language, colors), + text = line, fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = colors.text, @@ -333,9 +335,9 @@ private fun CodeBlock(text: String, language: SyntaxLanguage, wordWrap: Boolean, lines.indices.forEach { index -> LineNumber(index, gutterWidth, colors) } } Column(Modifier.horizontalScroll(rememberScrollState())) { - lines.forEach { line -> + highlighted.forEach { line -> Text( - text = highlightLine(line, language, colors), + text = line, fontFamily = FontFamily.Monospace, fontSize = 13.sp, color = colors.text, diff --git a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt index cb5a78f..9d0d55f 100644 --- a/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt +++ b/app/src/main/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighter.kt @@ -12,18 +12,18 @@ import androidx.compose.ui.text.withStyle import com.ubisam.ubgitea.domain.model.RepositoryPath /** - * A deliberately lightweight, regex-based highlighter -- not a real per-language parser. Each line - * is tokenized independently with no cross-line state, so a `/* ... */` block comment or a - * triple-quoted string that spans multiple lines is only colored correctly on the line(s) where it - * actually starts and ends, not on every line in between. A real tokenizer for every language this - * app might show source from would be a much larger undertaking than this file viewer needs; this - * covers the common, single-line case (which is the vast majority of real code) at a fraction of the - * cost, matching this codebase's established preference for hand-rolled solutions over new - * dependencies (see the launcher icon's and the favorite-push logo's own comments on the same - * point). + * A deliberately lightweight, regex-based highlighter -- not a real per-language parser. Lines are + * tokenized one at a time; the only state carried from one line to the next is "still inside a + * block comment" (see [tokenizeLines]), so a multi-line `/* ... */` or `` comment is + * colored on every line it covers. A string that spans multiple lines (C++ raw strings, C# verbatim + * strings, Python triple quotes) is still only colored on the line where it starts. A real + * tokenizer for every language this app might show source from would be a much larger undertaking + * than this file viewer needs; this covers the common cases at a fraction of the cost, matching + * this codebase's established preference for hand-rolled solutions over new dependencies (see the + * launcher icon's and the favorite-push logo's own comments on the same point). */ 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 { @@ -32,6 +32,7 @@ fun syntaxLanguageForPath(path: RepositoryPath): SyntaxLanguage { "kt", "kts" -> SyntaxLanguage.KOTLIN "java" -> SyntaxLanguage.JAVA "cs" -> SyntaxLanguage.CSHARP + "c", "h", "cc", "cpp", "cxx", "hpp", "hh", "hxx", "inl", "ipp" -> SyntaxLanguage.CPP "js", "jsx", "mjs", "ts", "tsx" -> SyntaxLanguage.JAVASCRIPT "py" -> SyntaxLanguage.PYTHON "sh", "bash", "zsh" -> SyntaxLanguage.SHELL @@ -50,6 +51,7 @@ data class CodeSyntaxColors( val string: Color, val comment: Color, val number: Color, + val preprocessor: Color, ) // 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), comment = Color(0xFF6A9955), number = Color(0xFFB5CEA8), + preprocessor = Color(0xFF9B9B9B), ) } else { CodeSyntaxColors( @@ -75,6 +78,7 @@ fun rememberCodeSyntaxColors(): CodeSyntaxColors { string = Color(0xFFA31515), comment = Color(0xFF008000), number = Color(0xFF098658), + preprocessor = Color(0xFF6B6B6B), ) } } @@ -86,6 +90,7 @@ private data class LangSpec( val lineComment: String? = null, val blockComment: Boolean = false, val xmlComment: Boolean = false, + val preprocessor: Boolean = false, ) private val SPECS: Map = mapOf( @@ -127,6 +132,25 @@ private val SPECS: Map = mapOf( ), lineComment = "//", 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( keywords = setOf( @@ -181,9 +205,13 @@ private val SPECS: Map = mapOf( 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" +private val PREPROCESSOR_DIRECTIVE = Regex("^\\s*#\\s*[A-Za-z_]+") +private val INCLUDE_TARGET = Regex("^\\s*<[^>]*>") + private val patternCache: Map = SPECS.mapValues { (_, spec) -> val parts = mutableListOf() if (spec.blockComment) parts += "/\\*.*?\\*/|/\\*.*$" @@ -193,27 +221,94 @@ private val patternCache: Map = SPECS.mapValues { (_, spe 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, language: SyntaxLanguage): List> { val spec = SPECS.getValue(language) val pattern = patternCache.getValue(language) - return buildAnnotatedString { - var lastIndex = 0 - for (match in pattern.findAll(line)) { - if (match.range.first > lastIndex) append(line.substring(lastIndex, match.range.first)) - val text = match.value - val style = classify(text, spec)?.let { role -> SpanStyle(color = role.colorOf(colors), fontWeight = role.weight) } - if (style != null) withStyle(style) { append(text) } else append(text) - lastIndex = match.range.last + 1 + var open: BlockComment? = null + return lines.map { line -> + val tokens = mutableListOf() + var from = 0 + open?.let { block -> + val close = line.indexOf(block.close) + if (close < 0) { + if (line.isNotEmpty()) tokens += SyntaxToken(0, line.length, TokenRole.COMMENT) + return@map tokens + } + from = close + block.close.length + tokens += SyntaxToken(0, from, TokenRole.COMMENT) + open = null } - if (lastIndex < line.length) append(line.substring(lastIndex)) + 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, language: SyntaxLanguage, colors: CodeSyntaxColors): List = + 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, 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), STRING(null), NUMBER(null), KEYWORD(FontWeight.SemiBold), + PREPROCESSOR(null), ; fun colorOf(colors: CodeSyntaxColors): Color = when (this) { @@ -221,6 +316,7 @@ private enum class TokenRole(val weight: FontWeight?) { STRING -> colors.string NUMBER -> colors.number KEYWORD -> colors.keyword + PREPROCESSOR -> colors.preprocessor } } diff --git a/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighterTest.kt b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighterTest.kt new file mode 100644 index 0000000..c5f6bcd --- /dev/null +++ b/app/src/test/java/com/ubisam/ubgitea/feature/codebrowser/content/SyntaxHighlighterTest.kt @@ -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>> = + 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 ").single() + assertEquals(listOf("#include" to TokenRole.PREPROCESSOR, " " 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, " ") + assertEquals(listOf("" 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]) + } +} diff --git a/docs/development-log.md b/docs/development-log.md index 72e690e..6ce9afe 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -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 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. diff --git a/tasks/049-cpp-highlighting-and-multiline-comments.md b/tasks/049-cpp-highlighting-and-multiline-comments.md new file mode 100644 index 0000000..d62099b --- /dev/null +++ b/tasks/049-cpp-highlighting-and-multiline-comments.md @@ -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 ` colors the directive as preprocessor and `` 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.