krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Keymap/TOML.swift

056befa97fffa110959cb6010c1986461c09dc0b
orgstar/Sources/OrgCore/Keymap/TOML.swift history · blame · raw

118 lines · 5631 bytes

  1import Foundation
  2
  3/// The part of TOML keymaps use: `[table]` and `[[array-of-tables]]` headers, and
  4/// `key = value` lines with basic or literal strings, booleans and integers. Comments start
  5/// with `#` outside strings.
  6public enum TOML {
  7    public enum Value: Sendable, Equatable {
  8        case string(String)
  9        case bool(Bool)
 10        case integer(Int)
 11
 12        public var string: String? {
 13            if case .string(let value) = self { return value }
 14            return nil
 15        }
 16
 17        public var bool: Bool? {
 18            if case .bool(let value) = self { return value }
 19            return nil
 20        }
 21    }
 22
 23    public struct Table: Sendable, Equatable {
 24        /// Empty for keys before the first header.
 25        public var name: String
 26        /// Declared with `[[name]]`.
 27        public var isArrayElement: Bool
 28        public var values: [String: Value]
 29        /// 1-based line of the header.
 30        public var line: Int
 31    }
 32
 33    public struct ParseError: Error, Equatable, CustomStringConvertible {
 34        public let line: Int
 35        public let message: String
 36        public var description: String { "line \(line): \(message)" }
 37    }
 38
 39    /// Tables in file order.
 40    public static func parse(_ text: String) throws -> [Table] {
 41        var tables = [Table(name: "", isArrayElement: false, values: [:], line: 0)]
 42        for (index, rawLine) in text.split(separator: "\n", omittingEmptySubsequences: false).enumerated() {
 43            let number = index + 1
 44            let line = rawLine.trimmingCharacters(in: [" ", "\t", "\r"])
 45            if line.isEmpty || line.hasPrefix("#") { continue }
 46            if line.hasPrefix("[") {
 47                let isArray = line.hasPrefix("[[")
 48                let close = isArray ? "]]" : "]"
 49                let body = line.dropFirst(isArray ? 2 : 1)
 50                guard let end = body.range(of: close) else { throw ParseError(line: number, message: "unclosed table header") }
 51                let rest = body[end.upperBound...].trimmingCharacters(in: [" ", "\t"])
 52                guard rest.isEmpty || rest.hasPrefix("#") else { throw ParseError(line: number, message: "text after table header") }
 53                let name = body[..<end.lowerBound].trimmingCharacters(in: [" ", "\t"])
 54                guard !name.isEmpty else { throw ParseError(line: number, message: "empty table name") }
 55                tables.append(Table(name: name, isArrayElement: isArray, values: [:], line: number))
 56                continue
 57            }
 58            guard let equals = line.firstIndex(of: "=") else { throw ParseError(line: number, message: "expected key = value") }
 59            let key = line[..<equals].trimmingCharacters(in: [" ", "\t"])
 60            guard !key.isEmpty, key.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "-" }) else {
 61                throw ParseError(line: number, message: "bad key")
 62            }
 63            var rest = Substring(line[line.index(after: equals)...]).drop { $0 == " " || $0 == "\t" }
 64            let value = try parseValue(&rest, line: number)
 65            rest = rest.drop { $0 == " " || $0 == "\t" }
 66            guard rest.isEmpty || rest.first == "#" else { throw ParseError(line: number, message: "text after value") }
 67            guard tables[tables.count - 1].values[key] == nil else { throw ParseError(line: number, message: "duplicate key \(key)") }
 68            tables[tables.count - 1].values[key] = value
 69        }
 70        return tables
 71    }
 72
 73    private static func parseValue(_ rest: inout Substring, line: Int) throws -> Value {
 74        switch rest.first {
 75        case "\"":
 76            rest = rest.dropFirst()
 77            var value = ""
 78            while let c = rest.first {
 79                rest = rest.dropFirst()
 80                if c == "\"" { return .string(value) }
 81                guard c == "\\" else {
 82                    value.append(c)
 83                    continue
 84                }
 85                guard let escaped = rest.first else { break }
 86                rest = rest.dropFirst()
 87                switch escaped {
 88                case "\"": value.append("\"")
 89                case "\\": value.append("\\")
 90                case "n": value.append("\n")
 91                case "t": value.append("\t")
 92                case "u", "U":
 93                    let digits = escaped == "u" ? 4 : 8
 94                    guard let code = UInt32(rest.prefix(digits), radix: 16), let scalar = Unicode.Scalar(code), rest.count >= digits else {
 95                        throw ParseError(line: line, message: "bad unicode escape")
 96                    }
 97                    value.unicodeScalars.append(scalar)
 98                    rest = rest.dropFirst(digits)
 99                default: throw ParseError(line: line, message: "bad escape \\\(escaped)")
100                }
101            }
102            throw ParseError(line: line, message: "unclosed string")
103        case "'":
104            rest = rest.dropFirst()
105            guard let end = rest.firstIndex(of: "'") else { throw ParseError(line: line, message: "unclosed string") }
106            let value = String(rest[..<end])
107            rest = rest[rest.index(after: end)...]
108            return .string(value)
109        default:
110            let word = rest.prefix { !$0.isWhitespace && $0 != "#" }
111            rest = rest.dropFirst(word.count)
112            if word == "true" { return .bool(true) }
113            if word == "false" { return .bool(false) }
114            if let number = Int(word.replacingOccurrences(of: "_", with: "")) { return .integer(number) }
115            throw ParseError(line: line, message: "unsupported value \(word)")
116        }
117    }
118}