Commit 056befa97f
Verified · cmc
Layout: unified · split
Sources/OrgCore/Keymap/Keymap.swift added +173
| @@ -0,0 +1,173 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | public struct KeyBinding: Sendable, Equatable { | ||
| 4 | public let keys: [KeyChord] | ||
| 5 | /// A command id, or `none` to unbind keys an earlier layer bound. | ||
| 6 | public let command: String | ||
| 7 | /// A context name (`KeyContext`); nil binds everywhere. | ||
| 8 | public let when: String? | ||
| 9 | /// A modal state; nil binds outside modal editing. | ||
| 10 | public let mode: String? | ||
| 11 | |||
| 12 | public init(keys: [KeyChord], command: String, when: String? = nil, mode: String? = nil) { | ||
| 13 | self.keys = keys | ||
| 14 | self.command = command | ||
| 15 | self.when = when | ||
| 16 | self.mode = mode | ||
| 17 | } | ||
| 18 | |||
| 19 | public var unbinds: Bool { command == "none" } | ||
| 20 | } | ||
| 21 | |||
| 22 | /// Bindings in priority order: later bindings win over earlier ones for the same keys. | ||
| 23 | /// Layers are concatenated: preset, then the user's file. | ||
| 24 | public struct Keymap: Sendable, Equatable { | ||
| 25 | public private(set) var bindings: [KeyBinding] | ||
| 26 | |||
| 27 | public init(_ bindings: [KeyBinding] = []) { | ||
| 28 | self.bindings = bindings | ||
| 29 | } | ||
| 30 | |||
| 31 | /// Reads `[[bind]]` tables. Entries that can't be used are skipped and reported in | ||
| 32 | /// `problems`; a file that isn't valid TOML throws. | ||
| 33 | public init(toml: String, problems: inout [String]) throws { | ||
| 34 | bindings = [] | ||
| 35 | for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) { | ||
| 36 | guard table.name == "bind", table.isArrayElement else { | ||
| 37 | problems.append("line \(table.line): unknown table [\(table.name)]") | ||
| 38 | continue | ||
| 39 | } | ||
| 40 | guard let keyText = table.values["keys"]?.string, let keys = KeySequence.parse(keyText) else { | ||
| 41 | problems.append("line \(table.line): bad or missing keys") | ||
| 42 | continue | ||
| 43 | } | ||
| 44 | guard let command = table.values["command"]?.string, !command.isEmpty else { | ||
| 45 | problems.append("line \(table.line): missing command") | ||
| 46 | continue | ||
| 47 | } | ||
| 48 | let when = table.values["when"]?.string | ||
| 49 | if let when, !KeyContext.names.contains(when) { | ||
| 50 | problems.append("line \(table.line): unknown context \(when)") | ||
| 51 | continue | ||
| 52 | } | ||
| 53 | bindings.append(KeyBinding(keys: keys, command: command, when: when, mode: table.values["mode"]?.string)) | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | public static func layered(_ layers: [Keymap]) -> Keymap { | ||
| 58 | Keymap(layers.flatMap(\.bindings)) | ||
| 59 | } | ||
| 60 | |||
| 61 | /// Bindings for exactly `keys` in `mode`, highest priority first. An unbinding with no | ||
| 62 | /// context hides everything below it. | ||
| 63 | public func candidates(for keys: [KeyChord], mode: String? = nil) -> [KeyBinding] { | ||
| 64 | var result: [KeyBinding] = [] | ||
| 65 | for binding in bindings.reversed() where binding.mode == mode && binding.keys == keys { | ||
| 66 | if binding.unbinds { | ||
| 67 | if binding.when == nil { break } | ||
| 68 | continue | ||
| 69 | } | ||
| 70 | result.append(binding) | ||
| 71 | } | ||
| 72 | return result | ||
| 73 | } | ||
| 74 | |||
| 75 | /// Whether some live binding in `mode` starts with `keys` and is longer. | ||
| 76 | public func isPrefix(_ keys: [KeyChord], mode: String? = nil) -> Bool { | ||
| 77 | let longer = Set(bindings.filter { $0.mode == mode && $0.keys.count > keys.count && Array($0.keys.prefix(keys.count)) == keys }.map(\.keys)) | ||
| 78 | return longer.contains { !candidates(for: $0, mode: mode).isEmpty } | ||
| 79 | } | ||
| 80 | |||
| 81 | /// What can follow `prefix`: each next key with the command it runs, or nil when it | ||
| 82 | /// leads to a longer sequence. Sorted by key. | ||
| 83 | public func continuations(of prefix: [KeyChord], mode: String? = nil) -> [(key: KeyChord, command: String?)] { | ||
| 84 | var seen: [KeyChord: String?] = [:] | ||
| 85 | for binding in bindings where binding.mode == mode && binding.keys.count > prefix.count && Array(binding.keys.prefix(prefix.count)) == prefix { | ||
| 86 | let next = binding.keys[prefix.count] | ||
| 87 | let sequence = prefix + [next] | ||
| 88 | guard seen[next] == nil else { continue } | ||
| 89 | if isPrefix(sequence, mode: mode) { | ||
| 90 | seen[next] = .some(nil) | ||
| 91 | } else if let command = candidates(for: sequence, mode: mode).first?.command { | ||
| 92 | seen[next] = .some(command) | ||
| 93 | } | ||
| 94 | } | ||
| 95 | return seen.map { (key: $0.key, command: $0.value) }.sorted { $0.key.description < $1.key.description } | ||
| 96 | } | ||
| 97 | |||
| 98 | /// The first live binding of `command`, for showing its keys. | ||
| 99 | public func keys(for command: String, mode: String? = nil) -> [KeyChord]? { | ||
| 100 | bindings.reversed().first { $0.mode == mode && $0.command == command && candidates(for: $0.keys, mode: mode).contains($0) }?.keys | ||
| 101 | } | ||
| 102 | } | ||
| 103 | |||
| 104 | /// Context names a binding's `when` can use. | ||
| 105 | public enum KeyContext { | ||
| 106 | public static let names: Set<String> = ["heading", "table", "item", "region"] | ||
| 107 | |||
| 108 | public static func holds(_ name: String, in context: EditContext) -> Bool { | ||
| 109 | switch name { | ||
| 110 | case "heading": return headingOnLine(at: context.caret, in: context.tree) != nil | ||
| 111 | case "table": return enclosing(.table, context) != nil | ||
| 112 | case "item": return enclosing(.item, context) != nil | ||
| 113 | case "region": return context.selection.contains { !$0.isEmpty } | ||
| 114 | default: return false | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | private static func enclosing(_ kind: SyntaxKind, _ context: EditContext) -> SyntaxNode? { | ||
| 119 | var node = context.tree.root | ||
| 120 | while let child = node.child(containing: context.caret) { | ||
| 121 | if child.kind == kind { return child } | ||
| 122 | node = child | ||
| 123 | } | ||
| 124 | return nil | ||
| 125 | } | ||
| 126 | } | ||
| 127 | |||
| 128 | /// Turns key presses into bindings, holding a pending prefix between presses. | ||
| 129 | public struct KeyDispatcher: Sendable { | ||
| 130 | public enum Outcome: Sendable, Equatable { | ||
| 131 | /// A prefix; waiting for the next key. | ||
| 132 | case pending([KeyChord]) | ||
| 133 | /// Bindings for the completed sequence, highest priority first. | ||
| 134 | case complete([KeyChord], [KeyBinding]) | ||
| 135 | /// A sequence of two or more keys that nothing binds. | ||
| 136 | case undefined([KeyChord]) | ||
| 137 | /// A single key nothing binds: let the text system have it. | ||
| 138 | case unbound | ||
| 139 | /// C-g during a prefix. | ||
| 140 | case cancelled | ||
| 141 | } | ||
| 142 | |||
| 143 | public var keymap: Keymap | ||
| 144 | public var mode: String? | ||
| 145 | public private(set) var pending: [KeyChord] = [] | ||
| 146 | |||
| 147 | public init(keymap: Keymap, mode: String? = nil) { | ||
| 148 | self.keymap = keymap | ||
| 149 | self.mode = mode | ||
| 150 | } | ||
| 151 | |||
| 152 | public static let quit = KeyChord("g", .control) | ||
| 153 | |||
| 154 | public mutating func feed(_ chord: KeyChord) -> Outcome { | ||
| 155 | if !pending.isEmpty, chord == Self.quit { | ||
| 156 | pending = [] | ||
| 157 | return .cancelled | ||
| 158 | } | ||
| 159 | let sequence = pending + [chord] | ||
| 160 | if keymap.isPrefix(sequence, mode: mode) { | ||
| 161 | pending = sequence | ||
| 162 | return .pending(sequence) | ||
| 163 | } | ||
| 164 | pending = [] | ||
| 165 | let candidates = keymap.candidates(for: sequence, mode: mode) | ||
| 166 | if !candidates.isEmpty { return .complete(sequence, candidates) } | ||
| 167 | return sequence.count == 1 ? .unbound : .undefined(sequence) | ||
| 168 | } | ||
| 169 | |||
| 170 | public mutating func reset() { | ||
| 171 | pending = [] | ||
| 172 | } | ||
| 173 | } | ||
Sources/OrgCore/Keymap/Keys.swift added +86
| @@ -0,0 +1,86 @@ | |||
| 1 | /// One key press in Emacs notation: `C-x`, `M-<`, `S-<left>`, `s-s`, `TAB`. | ||
| 2 | /// | ||
| 3 | /// `C` is Control, `M` Meta, `S` Shift, `s` Super (⌘). Shift on a letter is folded into the | ||
| 4 | /// letter (`S-a` is `A`); on other keys it stays a modifier (`S-TAB`, `S-<up>`). | ||
| 5 | public struct KeyChord: Hashable, Sendable, CustomStringConvertible { | ||
| 6 | public struct Modifiers: OptionSet, Hashable, Sendable { | ||
| 7 | public let rawValue: Int | ||
| 8 | public init(rawValue: Int) { self.rawValue = rawValue } | ||
| 9 | public static let control = Modifiers(rawValue: 1) | ||
| 10 | public static let meta = Modifiers(rawValue: 2) | ||
| 11 | public static let shift = Modifiers(rawValue: 4) | ||
| 12 | public static let command = Modifiers(rawValue: 8) | ||
| 13 | } | ||
| 14 | |||
| 15 | /// A single character, a named key (`TAB`, `RET`, `SPC`, `ESC`, `DEL`), or a bracketed | ||
| 16 | /// function key (`<left>`, `<f5>`, `<delete>`). | ||
| 17 | public let key: String | ||
| 18 | public let modifiers: Modifiers | ||
| 19 | |||
| 20 | public init(_ key: String, _ modifiers: Modifiers = []) { | ||
| 21 | var key = Self.aliases[key] ?? key | ||
| 22 | var modifiers = modifiers | ||
| 23 | if key == "<backtab>" { | ||
| 24 | key = "TAB" | ||
| 25 | modifiers.insert(.shift) | ||
| 26 | } | ||
| 27 | if modifiers.contains(.shift), key.count == 1, let letter = key.first, letter.isLetter, letter.isLowercase { | ||
| 28 | key = key.uppercased() | ||
| 29 | modifiers.remove(.shift) | ||
| 30 | } | ||
| 31 | self.key = key | ||
| 32 | self.modifiers = modifiers | ||
| 33 | } | ||
| 34 | |||
| 35 | static let named: Set<String> = ["TAB", "RET", "SPC", "ESC", "DEL"] | ||
| 36 | static let aliases = [ | ||
| 37 | " ": "SPC", "<tab>": "TAB", "<return>": "RET", "<escape>": "ESC", "<backspace>": "DEL", | ||
| 38 | ] | ||
| 39 | |||
| 40 | /// Parses one token such as `C-M-x`; nil if it isn't a key. | ||
| 41 | public static func parse<S: StringProtocol>(_ token: S) -> KeyChord? { | ||
| 42 | var rest = Substring(token) | ||
| 43 | var modifiers: Modifiers = [] | ||
| 44 | while rest.count >= 3, rest.dropFirst().first == "-" { | ||
| 45 | switch rest.first { | ||
| 46 | case "C": modifiers.insert(.control) | ||
| 47 | case "M": modifiers.insert(.meta) | ||
| 48 | case "S": modifiers.insert(.shift) | ||
| 49 | case "s": modifiers.insert(.command) | ||
| 50 | default: return nil | ||
| 51 | } | ||
| 52 | rest = rest.dropFirst(2) | ||
| 53 | } | ||
| 54 | let key = String(rest) | ||
| 55 | let isFunctionKey = key.count > 2 && key.hasPrefix("<") && key.hasSuffix(">") | ||
| 56 | guard key.count == 1 || named.contains(key) || isFunctionKey || aliases[key] != nil else { return nil } | ||
| 57 | return KeyChord(key, modifiers) | ||
| 58 | } | ||
| 59 | |||
| 60 | public var description: String { | ||
| 61 | var text = "" | ||
| 62 | if modifiers.contains(.control) { text += "C-" } | ||
| 63 | if modifiers.contains(.meta) { text += "M-" } | ||
| 64 | if modifiers.contains(.shift) { text += "S-" } | ||
| 65 | if modifiers.contains(.command) { text += "s-" } | ||
| 66 | return text + key | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | public enum KeySequence { | ||
| 71 | /// Parses space-separated chords: `C-c C-x C-i`. Nil if any token isn't a key. | ||
| 72 | public static func parse(_ text: String) -> [KeyChord]? { | ||
| 73 | let tokens = text.split(separator: " ") | ||
| 74 | guard !tokens.isEmpty else { return nil } | ||
| 75 | var chords: [KeyChord] = [] | ||
| 76 | for token in tokens { | ||
| 77 | guard let chord = KeyChord.parse(token) else { return nil } | ||
| 78 | chords.append(chord) | ||
| 79 | } | ||
| 80 | return chords | ||
| 81 | } | ||
| 82 | |||
| 83 | public static func format(_ keys: [KeyChord]) -> String { | ||
| 84 | keys.map(\.description).joined(separator: " ") | ||
| 85 | } | ||
| 86 | } | ||
Sources/OrgCore/Keymap/Presets.swift added +192
| @@ -0,0 +1,192 @@ | |||
| 1 | /// The keymaps the app ships. The user's `keymap.toml` is layered on top of the chosen one. | ||
| 2 | public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 3 | case emacs, mac | ||
| 4 | |||
| 5 | public var title: String { | ||
| 6 | switch self { | ||
| 7 | case .emacs: "Emacs" | ||
| 8 | case .mac: "Mac" | ||
| 9 | } | ||
| 10 | } | ||
| 11 | |||
| 12 | public var keymap: Keymap { | ||
| 13 | var problems: [String] = [] | ||
| 14 | let map = try! Keymap(toml: toml, problems: &problems) | ||
| 15 | precondition(problems.isEmpty, "preset \(rawValue): \(problems)") | ||
| 16 | return map | ||
| 17 | } | ||
| 18 | |||
| 19 | public var toml: String { | ||
| 20 | switch self { | ||
| 21 | case .emacs: Self.common + Self.emacsBindings | ||
| 22 | case .mac: Self.common + Self.macBindings | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | static let common = """ | ||
| 27 | [[bind]] | ||
| 28 | keys = "TAB" | ||
| 29 | command = "org.cycle" | ||
| 30 | when = "heading" | ||
| 31 | |||
| 32 | [[bind]] | ||
| 33 | keys = "S-TAB" | ||
| 34 | command = "org.cycle-global" | ||
| 35 | |||
| 36 | """ | ||
| 37 | |||
| 38 | static let emacsBindings = """ | ||
| 39 | # Movement and editing. With a mark set (C-SPC), movement extends the region. | ||
| 40 | [[bind]] | ||
| 41 | keys = "C-f" | ||
| 42 | command = "edit.forward-char" | ||
| 43 | [[bind]] | ||
| 44 | keys = "C-b" | ||
| 45 | command = "edit.backward-char" | ||
| 46 | [[bind]] | ||
| 47 | keys = "C-n" | ||
| 48 | command = "edit.next-line" | ||
| 49 | [[bind]] | ||
| 50 | keys = "C-p" | ||
| 51 | command = "edit.previous-line" | ||
| 52 | [[bind]] | ||
| 53 | keys = "C-a" | ||
| 54 | command = "edit.beginning-of-line" | ||
| 55 | [[bind]] | ||
| 56 | keys = "C-e" | ||
| 57 | command = "edit.end-of-line" | ||
| 58 | [[bind]] | ||
| 59 | keys = "M-f" | ||
| 60 | command = "edit.forward-word" | ||
| 61 | [[bind]] | ||
| 62 | keys = "M-b" | ||
| 63 | command = "edit.backward-word" | ||
| 64 | [[bind]] | ||
| 65 | keys = "M-<" | ||
| 66 | command = "edit.beginning-of-buffer" | ||
| 67 | [[bind]] | ||
| 68 | keys = "M->" | ||
| 69 | command = "edit.end-of-buffer" | ||
| 70 | [[bind]] | ||
| 71 | keys = "C-v" | ||
| 72 | command = "edit.scroll-up" | ||
| 73 | [[bind]] | ||
| 74 | keys = "M-v" | ||
| 75 | command = "edit.scroll-down" | ||
| 76 | [[bind]] | ||
| 77 | keys = "C-l" | ||
| 78 | command = "edit.recenter" | ||
| 79 | [[bind]] | ||
| 80 | keys = "C-d" | ||
| 81 | command = "edit.delete-char" | ||
| 82 | [[bind]] | ||
| 83 | keys = "M-d" | ||
| 84 | command = "edit.kill-word" | ||
| 85 | [[bind]] | ||
| 86 | keys = "M-DEL" | ||
| 87 | command = "edit.backward-kill-word" | ||
| 88 | [[bind]] | ||
| 89 | keys = "C-k" | ||
| 90 | command = "edit.kill-line" | ||
| 91 | [[bind]] | ||
| 92 | keys = "C-y" | ||
| 93 | command = "edit.yank" | ||
| 94 | [[bind]] | ||
| 95 | keys = "C-SPC" | ||
| 96 | command = "edit.set-mark" | ||
| 97 | [[bind]] | ||
| 98 | keys = "C-x C-x" | ||
| 99 | command = "edit.exchange-point-and-mark" | ||
| 100 | [[bind]] | ||
| 101 | keys = "C-x h" | ||
| 102 | command = "edit.select-all" | ||
| 103 | [[bind]] | ||
| 104 | keys = "C-w" | ||
| 105 | command = "edit.kill-region" | ||
| 106 | [[bind]] | ||
| 107 | keys = "M-w" | ||
| 108 | command = "edit.copy-region" | ||
| 109 | [[bind]] | ||
| 110 | keys = "C-/" | ||
| 111 | command = "edit.undo" | ||
| 112 | [[bind]] | ||
| 113 | keys = "C-_" | ||
| 114 | command = "edit.undo" | ||
| 115 | [[bind]] | ||
| 116 | keys = "C-x u" | ||
| 117 | command = "edit.undo" | ||
| 118 | [[bind]] | ||
| 119 | keys = "C-g" | ||
| 120 | command = "edit.keyboard-quit" | ||
| 121 | [[bind]] | ||
| 122 | keys = "C-t" | ||
| 123 | command = "edit.transpose-chars" | ||
| 124 | [[bind]] | ||
| 125 | keys = "C-s" | ||
| 126 | command = "edit.find" | ||
| 127 | [[bind]] | ||
| 128 | keys = "C-r" | ||
| 129 | command = "edit.find" | ||
| 130 | |||
| 131 | # Files and commands | ||
| 132 | [[bind]] | ||
| 133 | keys = "C-x C-s" | ||
| 134 | command = "app.save" | ||
| 135 | [[bind]] | ||
| 136 | keys = "C-x C-f" | ||
| 137 | command = "app.quick-open" | ||
| 138 | [[bind]] | ||
| 139 | keys = "C-x b" | ||
| 140 | command = "app.quick-open" | ||
| 141 | [[bind]] | ||
| 142 | keys = "M-x" | ||
| 143 | command = "app.palette" | ||
| 144 | |||
| 145 | # Org | ||
| 146 | [[bind]] | ||
| 147 | keys = "C-c C-t" | ||
| 148 | command = "org.todo.cycle" | ||
| 149 | [[bind]] | ||
| 150 | keys = "S-<right>" | ||
| 151 | command = "org.todo.cycle" | ||
| 152 | when = "heading" | ||
| 153 | [[bind]] | ||
| 154 | keys = "S-<up>" | ||
| 155 | command = "org.priority.up" | ||
| 156 | when = "heading" | ||
| 157 | [[bind]] | ||
| 158 | keys = "S-<down>" | ||
| 159 | command = "org.priority.down" | ||
| 160 | when = "heading" | ||
| 161 | [[bind]] | ||
| 162 | keys = "M-<left>" | ||
| 163 | command = "org.heading.promote" | ||
| 164 | when = "heading" | ||
| 165 | [[bind]] | ||
| 166 | keys = "M-<right>" | ||
| 167 | command = "org.heading.demote" | ||
| 168 | when = "heading" | ||
| 169 | |||
| 170 | """ | ||
| 171 | |||
| 172 | /// Standard Mac text keys stay as they are; org commands use Control-Command chords, which | ||
| 173 | /// macOS text editing leaves free. | ||
| 174 | static let macBindings = """ | ||
| 175 | [[bind]] | ||
| 176 | keys = "C-s-t" | ||
| 177 | command = "org.todo.cycle" | ||
| 178 | [[bind]] | ||
| 179 | keys = "C-s-<up>" | ||
| 180 | command = "org.priority.up" | ||
| 181 | [[bind]] | ||
| 182 | keys = "C-s-<down>" | ||
| 183 | command = "org.priority.down" | ||
| 184 | [[bind]] | ||
| 185 | keys = "C-s-<left>" | ||
| 186 | command = "org.heading.promote" | ||
| 187 | [[bind]] | ||
| 188 | keys = "C-s-<right>" | ||
| 189 | command = "org.heading.demote" | ||
| 190 | |||
| 191 | """ | ||
| 192 | } | ||
Sources/OrgCore/Keymap/TOML.swift added +118
| @@ -0,0 +1,118 @@ | |||
| 1 | import 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. | ||
| 6 | public 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 | } | ||
Tests/OrgCoreTests/KeymapTests.swift added +128
| @@ -0,0 +1,128 @@ | |||
| 1 | import Testing | ||
| 2 | @testable import OrgCore | ||
| 3 | |||
| 4 | struct KeyNotationTests { | ||
| 5 | @Test func parsesAndFormats() { | ||
| 6 | #expect(KeySequence.parse("C-c C-x C-i").map(KeySequence.format) == "C-c C-x C-i") | ||
| 7 | #expect(KeyChord.parse("C-M-x") == KeyChord("x", [.control, .meta])) | ||
| 8 | #expect(KeyChord.parse("S-a") == KeyChord("A")) | ||
| 9 | #expect(KeyChord.parse("S-TAB") == KeyChord("TAB", .shift)) | ||
| 10 | #expect(KeyChord.parse("<backtab>") == KeyChord("TAB", .shift)) | ||
| 11 | #expect(KeyChord.parse("C--") == KeyChord("-", .control)) | ||
| 12 | #expect(KeyChord.parse("M-<") == KeyChord("<", .meta)) | ||
| 13 | #expect(KeyChord.parse("s-<left>")?.description == "s-<left>") | ||
| 14 | #expect(KeyChord.parse("<tab>") == KeyChord("TAB")) | ||
| 15 | #expect(KeyChord.parse("X-a") == nil) | ||
| 16 | #expect(KeyChord.parse("abc") == nil) | ||
| 17 | #expect(KeySequence.parse("") == nil) | ||
| 18 | } | ||
| 19 | } | ||
| 20 | |||
| 21 | struct TOMLTests { | ||
| 22 | @Test func parsesTables() throws { | ||
| 23 | let tables = try TOML.parse(""" | ||
| 24 | top = 1 # comment | ||
| 25 | [[bind]] | ||
| 26 | keys = "C-c \\"x\\"" | ||
| 27 | when = 'heading' | ||
| 28 | on = true | ||
| 29 | |||
| 30 | [options] | ||
| 31 | name = "a\\u00e9" | ||
| 32 | """) | ||
| 33 | #expect(tables.count == 3) | ||
| 34 | #expect(tables[0].values["top"] == .integer(1)) | ||
| 35 | #expect(tables[1].name == "bind" && tables[1].isArrayElement && tables[1].line == 2) | ||
| 36 | #expect(tables[1].values["keys"] == .string("C-c \"x\"")) | ||
| 37 | #expect(tables[1].values["when"] == .string("heading")) | ||
| 38 | #expect(tables[1].values["on"] == .bool(true)) | ||
| 39 | #expect(tables[2].values["name"] == .string("aé")) | ||
| 40 | } | ||
| 41 | |||
| 42 | @Test func reportsErrorsWithLines() { | ||
| 43 | #expect(throws: TOML.ParseError(line: 2, message: "unclosed string")) { try TOML.parse("[[bind]]\nkeys = \"C-c") } | ||
| 44 | #expect(throws: TOML.ParseError(line: 1, message: "expected key = value")) { try TOML.parse("keys") } | ||
| 45 | #expect(throws: TOML.ParseError(line: 2, message: "duplicate key a")) { try TOML.parse("a = 1\na = 2") } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | struct KeymapTests { | ||
| 50 | func map(_ toml: String) throws -> (Keymap, [String]) { | ||
| 51 | var problems: [String] = [] | ||
| 52 | let keymap = try Keymap(toml: toml, problems: &problems) | ||
| 53 | return (keymap, problems) | ||
| 54 | } | ||
| 55 | |||
| 56 | @Test func skipsBadEntries() throws { | ||
| 57 | let (keymap, problems) = try map(""" | ||
| 58 | [[bind]] | ||
| 59 | keys = "C-c C-t" | ||
| 60 | command = "org.todo.cycle" | ||
| 61 | [[bind]] | ||
| 62 | keys = "Q-x" | ||
| 63 | command = "a" | ||
| 64 | [[bind]] | ||
| 65 | keys = "TAB" | ||
| 66 | command = "a" | ||
| 67 | when = "nowhere" | ||
| 68 | [other] | ||
| 69 | """) | ||
| 70 | #expect(keymap.bindings.count == 1) | ||
| 71 | #expect(problems.count == 3) | ||
| 72 | } | ||
| 73 | |||
| 74 | @Test func laterLayersWinAndCanUnbind() throws { | ||
| 75 | let preset = KeymapPreset.emacs.keymap | ||
| 76 | let (user, _) = try map(""" | ||
| 77 | [[bind]] | ||
| 78 | keys = "C-c C-t" | ||
| 79 | command = "org.priority.up" | ||
| 80 | [[bind]] | ||
| 81 | keys = "C-k" | ||
| 82 | command = "none" | ||
| 83 | """) | ||
| 84 | let keymap = Keymap.layered([preset, user]) | ||
| 85 | #expect(keymap.candidates(for: KeySequence.parse("C-c C-t")!).first?.command == "org.priority.up") | ||
| 86 | #expect(keymap.candidates(for: KeySequence.parse("C-k")!).isEmpty) | ||
| 87 | #expect(keymap.keys(for: "org.todo.cycle").map(KeySequence.format) == "S-<right>") | ||
| 88 | } | ||
| 89 | |||
| 90 | @Test func dispatchesPrefixes() { | ||
| 91 | var dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap) | ||
| 92 | let c = KeyChord("c", .control), t = KeyChord("t", .control), z = KeyChord("z", .control) | ||
| 93 | #expect(dispatcher.feed(c) == .pending([c])) | ||
| 94 | guard case .complete(let keys, let candidates) = dispatcher.feed(t) else { | ||
| 95 | Issue.record("expected a binding") | ||
| 96 | return | ||
| 97 | } | ||
| 98 | #expect(keys == [c, t] && candidates.first?.command == "org.todo.cycle") | ||
| 99 | #expect(dispatcher.feed(c) == .pending([c])) | ||
| 100 | #expect(dispatcher.feed(z) == .undefined([c, z])) | ||
| 101 | #expect(dispatcher.feed(c) == .pending([c])) | ||
| 102 | #expect(dispatcher.feed(KeyDispatcher.quit) == .cancelled) | ||
| 103 | #expect(dispatcher.feed(KeyChord("a")) == .unbound) | ||
| 104 | } | ||
| 105 | |||
| 106 | @Test func continuationsListNextKeys() { | ||
| 107 | let keymap = KeymapPreset.emacs.keymap | ||
| 108 | let next = keymap.continuations(of: [KeyChord("x", .control)]) | ||
| 109 | #expect(next.contains { $0.key == KeyChord("s", .control) && $0.command == "app.save" }) | ||
| 110 | #expect(keymap.continuations(of: []).contains { $0.key == KeyChord("c", .control) && $0.command == nil }) | ||
| 111 | } | ||
| 112 | |||
| 113 | @Test func contexts() { | ||
| 114 | let text = "* a\n| x |\n- item\n" | ||
| 115 | let tree = OrgParser.parse(text) | ||
| 116 | func holds(_ name: String, _ caret: Int, _ selection: Range<Int>? = nil) -> Bool { | ||
| 117 | KeyContext.holds(name, in: EditContext(revision: 0, text: text, tree: tree, selection: [selection ?? caret..<caret])) | ||
| 118 | } | ||
| 119 | #expect(holds("heading", 1) && !holds("heading", 5)) | ||
| 120 | #expect(holds("table", 5) && !holds("table", 12)) | ||
| 121 | #expect(holds("item", 12)) | ||
| 122 | #expect(holds("region", 0, 0..<2) && !holds("region", 0)) | ||
| 123 | } | ||
| 124 | |||
| 125 | @Test func presetsParse() { | ||
| 126 | for preset in KeymapPreset.allCases { #expect(!preset.keymap.bindings.isEmpty) } | ||
| 127 | } | ||
| 128 | } | ||