222 lines
8.7 KiB
Swift
222 lines
8.7 KiB
Swift
/// Errors thrown by `TOMLReader` when parsing fails.
|
|
public enum TOMLReaderError: Error, Equatable {
|
|
/// The input contains invalid or unexpected syntax at a given line.
|
|
case parseError(line: Int, message: String)
|
|
}
|
|
|
|
/// A minimal, hand-rolled TOML parser suitable for reading `config.toml` files.
|
|
///
|
|
/// Supports string, integer, boolean values; arrays; nested tables via
|
|
/// `[section]` headers; dotted keys (`a.b.c = val`); inline tables
|
|
/// (`key = { a = 1, b = 2 }`); and `#`-style line comments. Designed solely
|
|
/// for the GIR generator's configuration surface and does not implement the
|
|
/// full TOML spec (no datetime, multiline literals, or array-of-tables).
|
|
public enum TOMLReader {
|
|
|
|
/// Parses a TOML-formatted string into a dictionary.
|
|
///
|
|
/// - Parameter raw: The raw TOML content.
|
|
/// - Returns: A `[String: Any]` where values are `String`, `Int`, `Bool`,
|
|
/// `[String]`, `[String: Any]`, or nested dictionaries.
|
|
/// - Throws: `TOMLReaderError` if parsing fails.
|
|
public static func parse(_ raw: String) throws -> [String: Any] {
|
|
var result: [String: Any] = [:]
|
|
var currentSectionPath: [String] = []
|
|
let lines = raw.components(separatedBy: "\n")
|
|
|
|
for (idx, line) in lines.enumerated() {
|
|
let trimmed = stripComment(line).trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty else { continue }
|
|
|
|
let lineNumber = idx + 1
|
|
|
|
if trimmed.hasPrefix("[") && !trimmed.hasPrefix("[[") && trimmed.hasSuffix("]") {
|
|
// Section header: [section.sub]
|
|
let inner = String(trimmed.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces)
|
|
currentSectionPath = splitDottedKey(inner).map { unquote($0) }
|
|
continue
|
|
}
|
|
|
|
if let eqIdx = trimmed.firstIndex(of: "=") {
|
|
let key = String(trimmed[..<eqIdx]).trimmingCharacters(in: .whitespaces)
|
|
var valueStr = String(trimmed[trimmed.index(after: eqIdx)...]).trimmingCharacters(in: .whitespaces)
|
|
|
|
let value = try parseValue(&valueStr, lineNumber: lineNumber)
|
|
let keyParts = splitDottedKey(key).map { unquote($0) }
|
|
|
|
if keyParts.count == 1 {
|
|
if currentSectionPath.isEmpty {
|
|
result[keyParts[0]] = value
|
|
} else {
|
|
setNested(&result, path: currentSectionPath + [keyParts[0]], value: value)
|
|
}
|
|
} else {
|
|
let fullPath = currentSectionPath + keyParts
|
|
setNested(&result, path: fullPath, value: value)
|
|
}
|
|
} else {
|
|
throw TOMLReaderError.parseError(line: lineNumber, message: "Expected key = value, got '\(trimmed)'")
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// MARK: - Private helpers
|
|
|
|
private static func stripComment(_ line: String) -> String {
|
|
var inString = false
|
|
for (i, ch) in line.enumerated() {
|
|
if ch == "\"" { inString.toggle() }
|
|
if ch == "#" && !inString {
|
|
let prevIdx = line.index(line.startIndex, offsetBy: i > 0 ? i - 1 : 0)
|
|
if i == 0 || line[prevIdx] == " " || line[prevIdx] == "\t" {
|
|
return String(line[..<line.index(line.startIndex, offsetBy: i)])
|
|
}
|
|
}
|
|
}
|
|
return line
|
|
}
|
|
|
|
private static func unquote(_ s: String) -> String {
|
|
let t = s.trimmingCharacters(in: .whitespaces)
|
|
guard t.hasPrefix("\"") && t.hasSuffix("\"") && t.count >= 2 else { return t }
|
|
return String(t.dropFirst().dropLast())
|
|
}
|
|
|
|
private static func splitDottedKey(_ key: String) -> [String] {
|
|
var parts: [String] = []
|
|
var current = ""
|
|
var inQuotes = false
|
|
for ch in key {
|
|
if ch == "\"" { inQuotes.toggle(); continue }
|
|
if ch == "." && !inQuotes {
|
|
if !current.isEmpty { parts.append(current); current = "" }
|
|
continue
|
|
}
|
|
current.append(ch)
|
|
}
|
|
if !current.isEmpty { parts.append(current) }
|
|
return parts
|
|
}
|
|
|
|
fileprivate static func parseValue(_ s: inout String, lineNumber: Int) throws -> Any {
|
|
s = s.trimmingCharacters(in: .whitespaces)
|
|
|
|
if s.hasPrefix("[") {
|
|
let closing = findClosing(s, open: "[", close: "]")
|
|
let inner = String(s[s.index(s.startIndex, offsetBy: 1)..<closing])
|
|
s = String(s[s.index(after: closing)...]).trimmingCharacters(in: .whitespaces)
|
|
let arrParser = ArrayParser(inner)
|
|
return try arrParser.parse(lineNumber: lineNumber)
|
|
}
|
|
|
|
if s.hasPrefix("{") {
|
|
let closing = findClosing(s, open: "{", close: "}")
|
|
let inner = String(s[s.index(s.startIndex, offsetBy: 1)..<closing])
|
|
s = String(s[s.index(after: closing)...])
|
|
return try TOMLReader.parse(convertInlineTable(inner))
|
|
}
|
|
|
|
if s.hasPrefix("\"") {
|
|
var closing = s.startIndex
|
|
for i in s.dropFirst().indices {
|
|
if s[i] == "\"" { closing = i; break }
|
|
}
|
|
guard closing != s.startIndex else {
|
|
throw TOMLReaderError.parseError(line: lineNumber, message: "Unterminated string in value")
|
|
}
|
|
let value = String(s[s.index(after: s.startIndex)..<closing])
|
|
s = String(s[s.index(after: closing)...]).trimmingCharacters(in: .whitespaces)
|
|
return value
|
|
}
|
|
|
|
if s.hasSuffix(",") { s = String(s.dropLast()).trimmingCharacters(in: .whitespaces) }
|
|
|
|
if s == "true" { return true }
|
|
if s == "false" { return false }
|
|
if let intVal = Int(s) { return intVal }
|
|
|
|
throw TOMLReaderError.parseError(line: lineNumber, message: "Unexpected value: \(s)")
|
|
}
|
|
|
|
private static func findClosing(_ s: String, open: Character, close: Character) -> String.Index {
|
|
var depth = 0
|
|
var inStr = false
|
|
for i in s.indices {
|
|
if s[i] == "\"" { inStr.toggle(); continue }
|
|
if inStr { continue }
|
|
if s[i] == open { depth += 1 }
|
|
else if s[i] == close { depth -= 1; if depth == 0 { return i } }
|
|
}
|
|
return s.endIndex
|
|
}
|
|
|
|
private static func convertInlineTable(_ inner: String) -> String {
|
|
var result = ""
|
|
var current = ""
|
|
var depth = 0
|
|
var inStr = false
|
|
for ch in inner {
|
|
if ch == "\"" { inStr.toggle(); current.append(ch); continue }
|
|
if inStr { current.append(ch); continue }
|
|
if ch == "{" || ch == "[" { depth += 1; current.append(ch) }
|
|
else if ch == "}" || ch == "]" { depth -= 1; current.append(ch) }
|
|
else if ch == "," && depth == 0 {
|
|
let part = current.trimmingCharacters(in: .whitespaces)
|
|
if !part.isEmpty { result += part + "\n" }
|
|
current = ""
|
|
} else {
|
|
current.append(ch)
|
|
}
|
|
}
|
|
let part = current.trimmingCharacters(in: .whitespaces)
|
|
if !part.isEmpty { result += part + "\n" }
|
|
return result
|
|
}
|
|
|
|
private static func setNested(_ root: inout [String: Any], path: [String], value: Any) {
|
|
guard let first = path.first else { return }
|
|
if path.count == 1 {
|
|
root[first] = value
|
|
} else {
|
|
var inner = (root[first] as? [String: Any]) ?? [String: Any]()
|
|
setNested(&inner, path: Array(path.dropFirst()), value: value)
|
|
root[first] = inner
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ArrayParser {
|
|
let text: String
|
|
init(_ text: String) { self.text = text }
|
|
|
|
func parse(lineNumber: Int) throws -> [Any] {
|
|
var result: [Any] = []
|
|
var current = ""
|
|
var depth = 0
|
|
var inStr = false
|
|
for ch in text {
|
|
if ch == "\"" { inStr.toggle(); current.append(ch); continue }
|
|
if inStr { current.append(ch); continue }
|
|
if ch == "[" || ch == "{" { depth += 1; current.append(ch) }
|
|
else if ch == "]" || ch == "}" { depth -= 1; current.append(ch) }
|
|
else if ch == "," && depth == 0 {
|
|
let val = current.trimmingCharacters(in: .whitespaces)
|
|
if !val.isEmpty {
|
|
var v = val
|
|
result.append(try TOMLReader.parseValue(&v, lineNumber: lineNumber))
|
|
}
|
|
current = ""
|
|
} else {
|
|
current.append(ch)
|
|
}
|
|
}
|
|
let last = current.trimmingCharacters(in: .whitespaces)
|
|
if !last.isEmpty {
|
|
var v = last
|
|
result.append(try TOMLReader.parseValue(&v, lineNumber: lineNumber))
|
|
}
|
|
return result
|
|
}
|
|
}
|