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 | } | |