1
0
Fork 0
gobject-generator/Sources/GObjectGeneratorCore/TOMLReader.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
}
}