krz/orgstar

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

Sources/OrgCore/Keymap/TOML.swift

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

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