fix(config): SimpleToml multi-line string arrays

The line-based parser treated 'conventions = [' as the complete value,
binding conventions to listOf("[") and dropping every entry on the
following lines. Accumulate value lines until the top-level bracket
closes (quote-aware), skipping comments inside the array.
This commit is contained in:
2026-06-11 22:30:32 +04:00
parent 50602b0f11
commit 99bca1703b
2 changed files with 52 additions and 3 deletions
@@ -17,8 +17,9 @@ internal object SimpleToml {
val rootKeys = mutableMapOf<String, Any>() val rootKeys = mutableMapOf<String, Any>()
val sections = mutableMapOf<String, MutableMap<String, Any>>() val sections = mutableMapOf<String, MutableMap<String, Any>>()
for (line in lines) { var i = 0
val trimmed = line.trim() while (i < lines.size) {
val trimmed = lines[i].trim()
when { when {
trimmed.isEmpty() || trimmed.startsWith("#") -> Unit trimmed.isEmpty() || trimmed.startsWith("#") -> Unit
trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> { trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> {
@@ -29,7 +30,14 @@ internal object SimpleToml {
val eqIndex = trimmed.indexOf("=") val eqIndex = trimmed.indexOf("=")
if (eqIndex > 0) { if (eqIndex > 0) {
val key = trimmed.substring(0, eqIndex).trim() val key = trimmed.substring(0, eqIndex).trim()
val valueStr = trimmed.substring(eqIndex + 1).trim() var valueStr = trimmed.substring(eqIndex + 1).trim()
// Multi-line array: accumulate lines until the top-level bracket closes.
while (valueStr.startsWith("[") && !arrayClosed(valueStr) && i + 1 < lines.size) {
i++
val next = lines[i].trim()
if (next.isEmpty() || next.startsWith("#")) continue
valueStr += " $next"
}
val parsed = parseSimpleValue(valueStr) val parsed = parseSimpleValue(valueStr)
if (currentSection.isEmpty()) { if (currentSection.isEmpty()) {
rootKeys[key] = parsed rootKeys[key] = parsed
@@ -39,10 +47,27 @@ internal object SimpleToml {
} }
} }
} }
i++
} }
return Pair(rootKeys, sections) return Pair(rootKeys, sections)
} }
/** True when every `[` opened outside quotes has been balanced by a `]`. */
private fun arrayClosed(value: String): Boolean {
var depth = 0
var inQuotes = false
var quoteChar = ' '
for (ch in value) {
when {
(ch == '"' || ch == '\'') && !inQuotes -> { inQuotes = true; quoteChar = ch }
ch == quoteChar && inQuotes -> inQuotes = false
ch == '[' && !inQuotes -> depth++
ch == ']' && !inQuotes -> depth--
}
}
return depth <= 0
}
fun asString(value: Any?, default: String = ""): String = fun asString(value: Any?, default: String = ""): String =
if (value is String) value else default if (value is String) value else default
@@ -38,6 +38,30 @@ class ProjectProfileLoaderTest {
assertEquals(mapOf("build" to "./gradlew build", "test" to "./gradlew check"), p.commands) assertEquals(mapOf("build" to "./gradlew build", "test" to "./gradlew check"), p.commands)
} }
@Test
fun `multi-line conventions array parses all entries`() {
// Idiomatic TOML splits long string arrays across lines; the line-based parser used
// to swallow everything after the opening bracket (conventions became listOf("[")).
val root = tempRoot()
Files.writeString(
Paths.get(root, ".correx", "project.toml"),
"""
about = "kernel"
conventions = [
"no bare try-catch",
# comment inside the array
"events are the only source of truth",
]
[commands]
test = "./gradlew check"
""".trimIndent(),
)
val p = ProjectProfileLoader.load(root)
assertEquals(listOf("no bare try-catch", "events are the only source of truth"), p.conventions)
assertEquals(mapOf("test" to "./gradlew check"), p.commands)
}
@Test @Test
fun `malformed file returns default without throwing`() { fun `malformed file returns default without throwing`() {
val root = tempRoot() val root = tempRoot()