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.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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 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.
|
||||||
|
|||||||
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