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:
parent
d9536289e9
commit
10b38a3ac4
@ -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,
|
||||
|
||||
@ -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<SyntaxLanguage, LangSpec> = mapOf(
|
||||
@ -127,6 +132,25 @@ private val SPECS: Map<SyntaxLanguage, LangSpec> = 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<SyntaxLanguage, LangSpec> = 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<SyntaxLanguage, Regex> = SPECS.mapValues { (_, spec) ->
|
||||
val parts = mutableListOf<String>()
|
||||
if (spec.blockComment) parts += "/\\*.*?\\*/|/\\*.*$"
|
||||
@ -193,27 +221,94 @@ private val patternCache: Map<SyntaxLanguage, Regex> = 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<String>, language: SyntaxLanguage): List<List<SyntaxToken>> {
|
||||
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<SyntaxToken>()
|
||||
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<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),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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])
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
75
tasks/049-cpp-highlighting-and-multiline-comments.md
Normal file
75
tasks/049-cpp-highlighting-and-multiline-comments.md
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user