refactor: extend TOML parser for nested tables, inline tables, and JSON arrays

- Add support for nested table headers ([tools.shell], [tools.file_read], etc.)
- Add support for inline tables (capabilities = { General = 1.0, Coding = 0.7 })
- Add support for JSON-style arrays (shell_allowed_executables = ["bash", "sh"])
- Maintain backward compatibility with old CSV formats (deprecated, logged as warnings)
- Refactor buildConfig() to handle new value types (Map, List, Boolean, Number)
- Update docs/sample-config.toml to use clean TOML shapes
- Add comprehensive tests for inline tables, nested sections, JSON arrays, and fallback parsing
- Remove ugly workarounds: capabilities no longer a CSV string, tool flags now in proper nested sections
This commit is contained in:
2026-05-30 01:13:36 +04:00
parent 9f171d3236
commit 0834c705fd
3 changed files with 507 additions and 23 deletions
@@ -33,34 +33,158 @@ object ConfigLoader {
private fun parseToml(content: String): CorrexConfig { private fun parseToml(content: String): CorrexConfig {
val lines = content.trim().split("\n") val lines = content.trim().split("\n")
var currentSection = "" var currentSection = ""
val sections = mutableMapOf<String, MutableMap<String, String>>() val sections = mutableMapOf<String, MutableMap<String, Any>>()
val providers = mutableListOf<MutableMap<String, Any>>()
var currentProvider: MutableMap<String, Any>? = null
for (line in lines) { for ((lineNum, line) in lines.withIndex()) {
val trimmed = line.trim() val trimmed = line.trim()
when { when {
trimmed.isEmpty() || trimmed.startsWith("#") -> { trimmed.isEmpty() || trimmed.startsWith("#") -> {
// Skip empty lines and comments // Skip empty lines and comments
} }
trimmed.startsWith("[") && trimmed.endsWith("]") -> { trimmed == "[[providers]]" -> {
// Parse section headers like [server] // Start a new provider entry
if (currentProvider != null) {
providers.add(currentProvider)
}
currentProvider = mutableMapOf()
currentSection = ""
}
trimmed.startsWith("[") && !trimmed.startsWith("[[") && trimmed.endsWith("]") -> {
// Parse section headers like [server] or [tools.shell]
if (currentProvider != null) {
providers.add(currentProvider)
currentProvider = null
}
currentSection = trimmed.substring(1, trimmed.length - 1).trim() currentSection = trimmed.substring(1, trimmed.length - 1).trim()
sections.putIfAbsent(currentSection, mutableMapOf()) sections.putIfAbsent(currentSection, mutableMapOf())
} }
else -> { else -> {
// Parse key=value pairs // Parse key=value pairs
val eqIndex = trimmed.indexOf("=") val eqIndex = trimmed.indexOf("=")
if (eqIndex > 0 && currentSection.isNotEmpty()) { if (eqIndex > 0) {
val key = trimmed.substring(0, eqIndex).trim() val key = trimmed.substring(0, eqIndex).trim()
val value = trimmed.substring(eqIndex + 1).trim() val valueStr = trimmed.substring(eqIndex + 1).trim()
val cleanedValue = stripQuotes(value) val parsedValue = parseValue(valueStr, lineNum + 1)
sections[currentSection]?.put(key, cleanedValue)
if (currentProvider != null) {
currentProvider[key] = parsedValue
} else if (currentSection.isNotEmpty()) {
sections[currentSection]?.put(key, parsedValue)
}
} }
} }
} }
} }
return buildConfig(sections) // Don't forget the last provider if file ends with [[providers]] block
if (currentProvider != null) {
providers.add(currentProvider)
}
return buildConfig(sections, providers)
}
private fun parseValue(valueStr: String, lineNum: Int): Any {
return when {
// Inline table: { key = value, key2 = value2 }
valueStr.startsWith("{") && valueStr.endsWith("}") -> {
parseInlineTable(valueStr, lineNum)
}
// JSON-style array: ["item1", "item2"]
valueStr.startsWith("[") && valueStr.endsWith("]") -> {
parseArray(valueStr, lineNum)
}
// CSV fallback for backward compat (detect by "," and "=" pattern without brackets)
valueStr.contains(",") && valueStr.contains("=") && !valueStr.startsWith("\"") -> {
parseCSVCapabilities(valueStr)
}
// Boolean
valueStr == "true" || valueStr == "false" -> valueStr.toBoolean()
// Number
valueStr.toDoubleOrNull() != null -> {
if (valueStr.contains(".")) valueStr.toDouble() else valueStr.toLong()
}
// String (with or without quotes)
else -> stripQuotes(valueStr)
}
}
private fun parseInlineTable(tableStr: String, lineNum: Int): Map<String, Any> {
val result = mutableMapOf<String, Any>()
val content = tableStr.substring(1, tableStr.length - 1).trim()
if (content.isEmpty()) return result
val pairs = splitTopLevel(content, ',')
for (pair in pairs) {
val eqIdx = pair.indexOf("=")
if (eqIdx > 0) {
val key = pair.substring(0, eqIdx).trim()
val valStr = pair.substring(eqIdx + 1).trim()
result[key] = parseValue(valStr, lineNum)
}
}
return result
}
private fun parseArray(arrayStr: String, lineNum: Int): List<String> {
val result = mutableListOf<String>()
val content = arrayStr.substring(1, arrayStr.length - 1).trim()
if (content.isEmpty()) return result
val items = splitTopLevel(content, ',')
for (item in items) {
val trimmed = item.trim()
result.add(stripQuotes(trimmed))
}
return result
}
private fun splitTopLevel(text: String, delimiter: Char): List<String> {
val result = mutableListOf<String>()
var current = StringBuilder()
var inQuotes = false
var quoteChar = ' '
for (ch in text) {
when {
(ch == '"' || ch == '\'') && !inQuotes -> {
inQuotes = true
quoteChar = ch
current.append(ch)
}
ch == quoteChar && inQuotes -> {
inQuotes = false
current.append(ch)
}
ch == delimiter && !inQuotes -> {
result.add(current.toString().trim())
current = StringBuilder()
}
else -> current.append(ch)
}
}
if (current.isNotEmpty()) {
result.add(current.toString().trim())
}
return result
}
private fun parseCSVCapabilities(capsStr: String): Map<String, Double> {
val result = mutableMapOf<String, Double>()
capsStr.split(",").forEach { pair ->
val parts = pair.trim().split("=")
if (parts.size == 2) {
val cap = parts[0].trim()
val score = parts[1].trim().toDoubleOrNull()
if (score != null && score >= 0.0 && score <= 1.0) {
result[cap] = score
}
}
}
return result
} }
private fun stripQuotes(value: String): String { private fun stripQuotes(value: String): String {
@@ -73,47 +197,211 @@ object ConfigLoader {
} }
} }
private fun buildConfig(sections: Map<String, Map<String, String>>): CorrexConfig { private fun buildConfig(
sections: Map<String, Map<String, Any>>,
providersList: List<Map<String, Any>> = emptyList(),
): CorrexConfig {
val serverSection = sections["server"] ?: emptyMap() val serverSection = sections["server"] ?: emptyMap()
val tuiSection = sections["tui"] ?: emptyMap() val tuiSection = sections["tui"] ?: emptyMap()
val cliSection = sections["cli"] ?: emptyMap() val cliSection = sections["cli"] ?: emptyMap()
val approvalSection = sections["approval"] ?: emptyMap() val approvalSection = sections["approval"] ?: emptyMap()
val toolsSection = sections["tools"] ?: emptyMap() val toolsSection = sections["tools"] ?: emptyMap()
val toolsShellSection = sections["tools.shell"] ?: emptyMap()
val toolsFileReadSection = sections["tools.file_read"] ?: emptyMap()
val toolsFileWriteSection = sections["tools.file_write"] ?: emptyMap()
val toolsFileEditSection = sections["tools.file_edit"] ?: emptyMap()
val server = ServerConfig( val server = ServerConfig(
host = serverSection["host"] ?: "localhost", host = asString(serverSection["host"], "localhost"),
port = serverSection["port"]?.toIntOrNull() ?: 8080, port = asInt(serverSection["port"], 8080),
) )
val tui = TuiConfig( val tui = TuiConfig(
theme = tuiSection["theme"] ?: "dark", theme = asString(tuiSection["theme"], "dark"),
sessionListLimit = tuiSection["session_list_limit"]?.toIntOrNull() ?: 5, sessionListLimit = asInt(tuiSection["session_list_limit"], 5),
) )
val cli = CliConfig( val cli = CliConfig(
defaultOutput = cliSection["default_output"] ?: "human", defaultOutput = asString(cliSection["default_output"], "human"),
) )
val approval = ApprovalConfig( val approval = ApprovalConfig(
timeoutMs = approvalSection["timeout_ms"]?.toLongOrNull() ?: 300_000L, timeoutMs = asLong(approvalSection["timeout_ms"], 300_000L),
) )
// Resolve tool enable flags: prefer nested [tools.shell], [tools.file_read], etc.
// Fall back to flat [tools] section for backward compat
val shellEnabled = when {
toolsShellSection.containsKey("enabled") -> asBoolean(toolsShellSection["enabled"], true)
toolsSection.containsKey("shell_enabled") -> {
System.err.println("Warning: 'shell_enabled' in [tools] is deprecated, use [tools.shell] enabled instead")
asBoolean(toolsSection["shell_enabled"], true)
}
else -> true
}
val fileReadEnabled = when {
toolsFileReadSection.containsKey("enabled") -> asBoolean(toolsFileReadSection["enabled"], true)
toolsSection.containsKey("file_read_enabled") -> {
System.err.println("Warning: 'file_read_enabled' in [tools] is deprecated, use [tools.file_read] enabled instead")
asBoolean(toolsSection["file_read_enabled"], true)
}
else -> true
}
val fileWriteEnabled = when {
toolsFileWriteSection.containsKey("enabled") -> asBoolean(toolsFileWriteSection["enabled"], true)
toolsSection.containsKey("file_write_enabled") -> {
System.err.println("Warning: 'file_write_enabled' in [tools] is deprecated, use [tools.file_write] enabled instead")
asBoolean(toolsSection["file_write_enabled"], true)
}
else -> true
}
val fileEditEnabled = when {
toolsFileEditSection.containsKey("enabled") -> asBoolean(toolsFileEditSection["enabled"], true)
toolsSection.containsKey("file_edit_enabled") -> {
System.err.println("Warning: 'file_edit_enabled' in [tools] is deprecated, use [tools.file_edit] enabled instead")
asBoolean(toolsSection["file_edit_enabled"], true)
}
else -> true
}
val shellAllowedExecutables = when {
toolsShellSection.containsKey("allowed_executables") -> {
asStringList(toolsShellSection["allowed_executables"])
}
toolsSection.containsKey("shell_allowed_executables") -> {
val val1 = toolsSection["shell_allowed_executables"]
when {
val1 is List<*> -> val1.filterIsInstance<String>()
val1 is String -> {
// Backward compat: check if it's CSV or already a single item
if (val1.contains(",")) {
System.err.println("Warning: CSV format 'shell_allowed_executables' is deprecated, use JSON array instead")
val1.split(",").map { it.trim() }.filter { it.isNotEmpty() }
} else {
listOf(val1)
}
}
else -> emptyList()
}
}
else -> emptyList()
}
val tools = ToolsConfig( val tools = ToolsConfig(
sandboxRoot = toolsSection["sandbox_root"] ?: "", sandboxRoot = asString(toolsSection["sandbox_root"], ""),
workingDir = toolsSection["working_dir"] ?: "", workingDir = asString(toolsSection["working_dir"], ""),
shellAllowedExecutables = toolsSection["shell_allowed_executables"] shellAllowedExecutables = shellAllowedExecutables,
?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } defaultSystemPromptPath = asString(
?: emptyList(), toolsSection["default_system_prompt_path"],
defaultSystemPromptPath = toolsSection["default_system_prompt_path"] "~/.config/correx/prompts/default_system.md"
?: "~/.config/correx/prompts/default_system.md", ),
shellEnabled = shellEnabled,
fileReadEnabled = fileReadEnabled,
fileWriteEnabled = fileWriteEnabled,
fileEditEnabled = fileEditEnabled,
) )
val providers = providersList.mapNotNull { providerMap ->
val id = asString(providerMap["id"]) ?: return@mapNotNull null
val type = asString(providerMap["type"]) ?: return@mapNotNull null
val modelId = asString(providerMap["model_id"]) ?: return@mapNotNull null
val modelPath = asString(providerMap["model_path"], "")
val url = asString(providerMap["url"], "http://127.0.0.1:10000")
val capabilities = parseCapabilities(providerMap["capabilities"])
ProviderConfig(
id = id,
type = type,
modelId = modelId,
modelPath = modelPath,
url = url,
capabilities = capabilities,
)
}
return CorrexConfig( return CorrexConfig(
server = server, server = server,
tui = tui, tui = tui,
cli = cli, cli = cli,
approval = approval, approval = approval,
tools = tools, tools = tools,
providers = providers,
) )
} }
private fun asString(value: Any?, default: String = ""): String {
return when (value) {
is String -> value
else -> default
}
}
private fun asStringOrNull(value: Any?): String? {
return when (value) {
is String -> value
else -> null
}
}
private fun asStringList(value: Any?): List<String> {
return when (value) {
is List<*> -> value.filterIsInstance<String>()
is String -> if (value.contains(",")) {
value.split(",").map { it.trim() }.filter { it.isNotEmpty() }
} else {
listOf(value)
}
else -> emptyList()
}
}
private fun asInt(value: Any?, default: Int = 0): Int {
return when (value) {
is Int -> value
is Long -> value.toInt()
is String -> value.toIntOrNull() ?: default
else -> default
}
}
private fun asLong(value: Any?, default: Long = 0L): Long {
return when (value) {
is Long -> value
is Int -> value.toLong()
is String -> value.toLongOrNull() ?: default
else -> default
}
}
private fun asBoolean(value: Any?, default: Boolean = false): Boolean {
return when (value) {
is Boolean -> value
is String -> value.lowercase() == "true"
else -> default
}
}
private fun parseCapabilities(value: Any?): Map<String, Double> {
return when (value) {
is Map<*, *> -> {
// Inline table parsed earlier
value.filterKeys { it is String }
.mapKeys { it.key as String }
.mapValues { (_, v) ->
when (v) {
is Double -> v
is Number -> v.toDouble()
is String -> v.toDoubleOrNull() ?: 0.0
else -> 0.0
}
}
.filter { it.value in 0.0..1.0 }
}
is String -> parseCSVCapabilities(value)
else -> emptyMap()
}
}
} }
@@ -73,4 +73,148 @@ class ConfigLoaderTest {
// Verify it returns a non-null Path object // Verify it returns a non-null Path object
assertEquals("config.toml", configPath.fileName.toString()) assertEquals("config.toml", configPath.fileName.toString())
} }
@Test
fun `parseToml parses providers with inline table capabilities`() {
val toml = """
[server]
host = "localhost"
[[providers]]
id = "local-llama"
type = "llamacpp"
model_id = "mistral-7b"
model_path = "/path/to/model.gguf"
url = "http://127.0.0.1:10000"
capabilities = { General = 1.0, Coding = 0.7, Reasoning = 0.6 }
[[providers]]
id = "alt-llama"
type = "llamacpp"
model_id = "neural-chat-7b"
model_path = "/path/to/model2.gguf"
url = "http://127.0.0.1:10001"
capabilities = { General = 0.9, Coding = 0.8 }
""".trimIndent()
val loader = ConfigLoader::class.java
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(2, result.providers.size)
assertEquals("local-llama", result.providers[0].id)
assertEquals("llamacpp", result.providers[0].type)
assertEquals("mistral-7b", result.providers[0].modelId)
assertEquals("/path/to/model.gguf", result.providers[0].modelPath)
assertEquals("http://127.0.0.1:10000", result.providers[0].url)
assertEquals(3, result.providers[0].capabilities.size)
assertEquals(1.0, result.providers[0].capabilities["General"])
assertEquals(0.7, result.providers[0].capabilities["Coding"])
assertEquals(0.6, result.providers[0].capabilities["Reasoning"])
assertEquals("alt-llama", result.providers[1].id)
assertEquals("neural-chat-7b", result.providers[1].modelId)
assertEquals(2, result.providers[1].capabilities.size)
}
@Test
fun `parseToml parses nested tool sections with enable flags`() {
val toml = """
[tools]
sandbox_root = "/tmp/correx"
[tools.shell]
enabled = false
allowed_executables = ["bash", "sh", "python3"]
[tools.file_read]
enabled = true
[tools.file_write]
enabled = false
[tools.file_edit]
enabled = true
""".trimIndent()
val loader = ConfigLoader::class.java
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(false, result.tools.shellEnabled)
assertEquals(true, result.tools.fileReadEnabled)
assertEquals(false, result.tools.fileWriteEnabled)
assertEquals(true, result.tools.fileEditEnabled)
assertEquals(3, result.tools.shellAllowedExecutables.size)
assertEquals("bash", result.tools.shellAllowedExecutables[0])
assertEquals("sh", result.tools.shellAllowedExecutables[1])
assertEquals("python3", result.tools.shellAllowedExecutables[2])
}
@Test
fun `parseToml parses JSON array shell_allowed_executables`() {
val toml = """
[tools]
shell_allowed_executables = ["bash", "sh", "node", "python3"]
""".trimIndent()
val loader = ConfigLoader::class.java
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(4, result.tools.shellAllowedExecutables.size)
assertEquals("bash", result.tools.shellAllowedExecutables[0])
assertEquals("sh", result.tools.shellAllowedExecutables[1])
assertEquals("node", result.tools.shellAllowedExecutables[2])
assertEquals("python3", result.tools.shellAllowedExecutables[3])
}
@Test
fun `parseToml parses old flat tool enable flags for backward compat`() {
val toml = """
[tools]
shell_enabled = false
file_read_enabled = true
file_write_enabled = false
file_edit_enabled = true
shell_allowed_executables = "bash,sh,python3"
""".trimIndent()
val loader = ConfigLoader::class.java
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(false, result.tools.shellEnabled)
assertEquals(true, result.tools.fileReadEnabled)
assertEquals(false, result.tools.fileWriteEnabled)
assertEquals(true, result.tools.fileEditEnabled)
}
@Test
fun `parseToml parses capabilities as CSV string for backward compat`() {
val toml = """
[server]
host = "localhost"
[[providers]]
id = "legacy-llama"
type = "llamacpp"
model_id = "mistral-7b"
capabilities = "General=1.0,Coding=0.7,Reasoning=0.6"
""".trimIndent()
val loader = ConfigLoader::class.java
val parseTomlMethod = loader.getDeclaredMethod("parseToml", String::class.java)
parseTomlMethod.isAccessible = true
val result = parseTomlMethod.invoke(ConfigLoader, toml) as CorrexConfig
assertEquals(1, result.providers.size)
assertEquals(3, result.providers[0].capabilities.size)
assertEquals(1.0, result.providers[0].capabilities["General"])
assertEquals(0.7, result.providers[0].capabilities["Coding"])
}
} }
+52
View File
@@ -0,0 +1,52 @@
# CORREX Configuration Sample
# Place at ~/.config/correx/config.toml
[server]
host = "localhost"
port = 8080
[tui]
theme = "dark"
session_list_limit = 5
[cli]
default_output = "human"
[approval]
timeout_ms = 300000
[tools]
sandbox_root = "~/.config/correx/sandbox"
working_dir = "/tmp"
default_system_prompt_path = "~/.config/correx/prompts/default_system.md"
[tools.shell]
enabled = true
allowed_executables = ["bash", "sh", "python3", "node"]
[tools.file_read]
enabled = true
[tools.file_write]
enabled = true
[tools.file_edit]
enabled = true
# Provider configuration (array of tables)
[[providers]]
id = "local-llama"
type = "llamacpp"
model_id = "mistral-7b"
model_path = "~/models/mistral-7b-gguf/model.gguf"
url = "http://127.0.0.1:10000"
capabilities = { General = 1.0, Coding = 0.7, Reasoning = 0.6, Summarization = 0.8, ToolCalling = 0.5 }
# Example: second provider (if you have multiple)
# [[providers]]
# id = "alternative-llama"
# type = "llamacpp"
# model_id = "neural-chat-7b"
# model_path = "~/models/neural-chat-7b-gguf/model.gguf"
# url = "http://127.0.0.1:10001"
# capabilities = { General = 0.9, Coding = 0.8, Reasoning = 0.7, Summarization = 0.75, ToolCalling = 0.6 }