krz/orgstar

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

Commit 1d5593eadf

1d5593eadf032a1cc3c0eb3230ceecd7e4fc151c

parent: 36627fe7d3

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:37 UTC

Report unknown keymap modes and commands

A binding whose mode isn't normal, insert or visual, or whose command
the editor doesn't have, is skipped and listed in the load problems.

Layout: unified · split

Sources/OrgApp/KeymapLoader.swift +4 −3
@@ -7,12 +7,13 @@ public enum KeymapLoader {
7 public static var userFile: URL { UserConfig.file("keymap.toml") } 7 public static var userFile: URL { UserConfig.file("keymap.toml") }
8 8
9 /// The preset with the user's file on top, and the problems found in the file. A missing 9 /// The preset with the user's file on top, and the problems found in the file. A missing
10 /// file adds nothing; a file that isn't valid TOML adds nothing and is reported. 10 /// file adds nothing; a file that isn't valid TOML adds nothing and is reported. With
11 public static func load(_ preset: KeymapPreset, userFile: URL = userFile) -> (keymap: Keymap, problems: [String]) { 11 /// `commands`, bindings of other command ids are reported and skipped.
12 public static func load(_ preset: KeymapPreset, userFile: URL = userFile, commands: Set<String>? = nil) -> (keymap: Keymap, problems: [String]) {
12 guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (preset.keymap, []) } 13 guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (preset.keymap, []) }
13 var problems: [String] = [] 14 var problems: [String] = []
14 do { 15 do {
15 let user = try Keymap(toml: text, problems: &problems) 16 let user = try Keymap(toml: text, problems: &problems, commands: commands)
16 return (.layered([preset.keymap, user]), problems) 17 return (.layered([preset.keymap, user]), problems)
17 } catch { 18 } catch {
18 return (preset.keymap, ["\(error)"]) 19 return (preset.keymap, ["\(error)"])
Sources/OrgCore/Keymap/Keymap.swift +16 −3
@@ -17,6 +17,9 @@ public struct KeyBinding: Sendable, Equatable {
17 } 17 }
18 18
19 public var unbinds: Bool { command == "none" } 19 public var unbinds: Bool { command == "none" }
20
21 /// Modal states a binding's `mode` can name.
22 public static let modes: Set<String> = ["normal", "insert", "visual"]
20} 23}
21 24
22/// Bindings in priority order: later bindings win over earlier ones for the same keys. 25/// Bindings in priority order: later bindings win over earlier ones for the same keys.
@@ -29,8 +32,9 @@ public struct Keymap: Sendable, Equatable {
29 } 32 }
30 33
31 /// Reads `[[bind]]` tables. Entries that can't be used are skipped and reported in 34 /// Reads `[[bind]]` tables. Entries that can't be used are skipped and reported in
32 /// `problems`; a file that isn't valid TOML throws. 35 /// `problems`; a file that isn't valid TOML throws. With `commands`, a command id outside
33 public init(toml: String, problems: inout [String]) throws { 36 /// it (other than `none`) is such an entry.
37 public init(toml: String, problems: inout [String], commands: Set<String>? = nil) throws {
34 bindings = [] 38 bindings = []
35 for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) { 39 for table in try TOML.parse(toml) where !(table.name.isEmpty && table.values.isEmpty) {
36 guard table.name == "bind", table.isArrayElement else { 40 guard table.name == "bind", table.isArrayElement else {
@@ -45,12 +49,21 @@ public struct Keymap: Sendable, Equatable {
45 problems.append("line \(table.line): missing command") 49 problems.append("line \(table.line): missing command")
46 continue 50 continue
47 } 51 }
52 if let commands, command != "none", !commands.contains(command) {
53 problems.append("line \(table.line): unknown command \(command)")
54 continue
55 }
48 let when = table.values["when"]?.string 56 let when = table.values["when"]?.string
49 if let when, !KeyContext.names.contains(when) { 57 if let when, !KeyContext.names.contains(when) {
50 problems.append("line \(table.line): unknown context \(when)") 58 problems.append("line \(table.line): unknown context \(when)")
51 continue 59 continue
52 } 60 }
53 bindings.append(KeyBinding(keys: keys, command: command, when: when, mode: table.values["mode"]?.string)) 61 let mode = table.values["mode"]?.string
62 if let mode, !KeyBinding.modes.contains(mode) {
63 problems.append("line \(table.line): unknown mode \(mode)")
64 continue
65 }
66 bindings.append(KeyBinding(keys: keys, command: command, when: when, mode: mode))
54 } 67 }
55 } 68 }
56 69
Sources/Orgstar/ContentView.swift +1 −1
@@ -498,7 +498,7 @@ struct ContentView: View {
498 } 498 }
499 499
500 private func loadKeymap() { 500 private func loadKeymap() {
501 let loaded = KeymapLoader.load(preset) 501 let loaded = KeymapLoader.load(preset, commands: Set(EditorCommands.all.map(\.id)))
502 keymap = loaded.keymap 502 keymap = loaded.keymap
503 commands.keymap = loaded.keymap 503 commands.keymap = loaded.keymap
504 commands.modal = preset.isModal 504 commands.modal = preset.isModal
Tests/OrgCoreTests/KeymapTests.swift +27
@@ -71,6 +71,33 @@ struct KeymapTests {
71 #expect(problems.count == 3) 71 #expect(problems.count == 3)
72 } 72 }
73 73
74 @Test func reportsUnknownModesAndCommands() throws {
75 var problems: [String] = []
76 let keymap = try Keymap(toml: """
77 [[bind]]
78 keys = "g x"
79 command = "org.todo.cycle"
80 mode = "visual-line"
81 [[bind]]
82 keys = "g y"
83 command = "org.todo.cycel"
84 mode = "normal"
85 [[bind]]
86 keys = "g z"
87 command = "none"
88 mode = "visual"
89 [[bind]]
90 keys = "C-c x"
91 command = "org.todo.cycle"
92 """, problems: &problems, commands: ["org.todo.cycle"])
93 #expect(problems == ["line 1: unknown mode visual-line", "line 5: unknown command org.todo.cycel"])
94 #expect(keymap.bindings.map(\.command) == ["none", "org.todo.cycle"])
95 // Without a command list, ids aren't checked.
96 problems = []
97 _ = try Keymap(toml: "[[bind]]\nkeys = \"x\"\ncommand = \"anything\"\nmode = \"insert\"\n", problems: &problems)
98 #expect(problems.isEmpty)
99 }
100
74 @Test func laterLayersWinAndCanUnbind() throws { 101 @Test func laterLayersWinAndCanUnbind() throws {
75 let preset = KeymapPreset.emacs.keymap 102 let preset = KeymapPreset.emacs.keymap
76 let (user, _) = try map(""" 103 let (user, _) = try map("""
Tests/OrgEditorAppKitTests/ModalTests.swift +12
@@ -160,3 +160,15 @@ struct ModalTests {
160 #expect(h.textView.selectedRange().location == 23) 160 #expect(h.textView.selectedRange().location == 23)
161 } 161 }
162} 162}
163
164struct PresetCommandTests {
165 /// Every command a preset binds is one the editor runs, and every mode is a state.
166 @Test func presetsBindKnownCommands() throws {
167 let known = Set(EditorCommands.all.map(\.id))
168 for preset in KeymapPreset.allCases {
169 var problems: [String] = []
170 _ = try Keymap(toml: preset.toml, problems: &problems, commands: known)
171 #expect(problems.isEmpty, "\(preset): \(problems)")
172 }
173 }
174}