Command model and Emacs oracle !10

merged merged by cmc on 2026-10-05 03:36 UTC · krz/orgstar:phase2-commands into main

14 files changed, +1944 −6

Layout: unified · split

Sources/OrgCore/Commands/Command.swift added +95
@@ -0,0 +1,95 @@
1import Foundation
2
3/// Everything a command may read. Commands never read the clock or the file system, so the
4/// same context always gives the same result.
5public struct EditContext: Sendable {
6 /// The revision `text` and `tree` belong to; results are applied only at this revision.
7 public let revision: Int
8 public let text: String
9 public let tree: OrgTree
10 /// Selections in UTF-16 offsets. The first is the main caret.
11 public let selection: [Range<Int>]
12 public let now: Date
13 /// Includes the time zone.
14 public let calendar: Calendar
15 /// Replies to earlier prompts, by prompt key.
16 public let answers: [String: String]
17
18 public init(
19 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
20 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:]
21 ) {
22 self.revision = revision
23 self.text = text
24 self.tree = tree
25 self.selection = selection
26 self.now = now
27 self.calendar = calendar
28 self.answers = answers
29 }
30
31 public var caret: Int { selection.first?.lowerBound ?? 0 }
32}
33
34public struct Prompt: Sendable, Equatable {
35 /// The key the answer comes back under in `EditContext.answers`.
36 public let key: String
37 public let message: String
38
39 public init(key: String, message: String) {
40 self.key = key
41 self.message = message
42 }
43}
44
45/// Things a command asks for besides text changes; the platform layer carries them out.
46public enum Effect: Sendable, Equatable {
47 case message(String)
48}
49
50public struct EditResult: Sendable, Equatable {
51 public let baseRevision: Int
52 /// Non-overlapping, in `baseRevision` coordinates.
53 public let edits: [TextEdit]
54 /// Selection after the edits, in new coordinates; nil maps the old selection through.
55 public let selection: [Range<Int>]?
56 public let effects: [Effect]
57
58 public init(baseRevision: Int, edits: [TextEdit], selection: [Range<Int>]? = nil, effects: [Effect] = []) {
59 self.baseRevision = baseRevision
60 self.edits = edits
61 self.selection = selection
62 self.effects = effects
63 }
64}
65
66public enum CommandStep: Sendable, Equatable {
67 case commit(EditResult)
68 /// Ask, then run again with the answer in `EditContext.answers`.
69 case prompt(Prompt)
70 /// The command can't run here; nothing changes. The message is for the user, as org's
71 /// `user-error`.
72 case failed(String)
73}
74
75/// A named operation. Keys, menus, the palette and touch controls all run commands.
76public protocol OrgCommand: Sendable {
77 /// Stable identifier, used by keymaps: `org.todo.cycle`.
78 var id: String { get }
79 /// Shown in the command palette.
80 var title: String { get }
81 /// Whether the command means something at the caret; context dispatch (one key, several
82 /// commands) runs the first that applies.
83 func applies(in context: EditContext) -> Bool
84 func run(in context: EditContext) -> CommandStep
85}
86
87public enum Commands {
88 public static let all: [any OrgCommand] = [
89 TodoCycle(), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(),
90 ]
91
92 public static func command(_ id: String) -> (any OrgCommand)? {
93 all.first { $0.id == id }
94 }
95}
Sources/OrgCore/Commands/HeadingCommands.swift added +376
@@ -0,0 +1,376 @@
1import Foundation
2
3// Heading-line commands, matched to Emacs 31.1 / Org 9.8.7 by the oracle tests.
4
5/// The heading whose line holds `offset`, for commands that act only on heading lines (org's
6/// M-left, M-right, S-up and S-down do something else elsewhere).
7func headingOnLine(at offset: Int, in tree: OrgTree) -> SyntaxNode? {
8 guard let heading = entryHeading(at: offset, in: tree) else { return nil }
9 let line = HeadingLine(heading)
10 return offset >= line.start && offset <= line.contentEnd ? heading : nil
11}
12
13/// The heading of the entry holding `offset`, as `org-back-to-heading`; nil before the first
14/// heading.
15func entryHeading(at offset: Int, in tree: OrgTree) -> SyntaxNode? {
16 let root = tree.root
17 let position = offset >= root.range.upperBound ? max(0, root.range.upperBound - 1) : offset
18 var node = root
19 var heading: SyntaxNode?
20 while let child = node.child(containing: position), child.kind == .section {
21 heading = child.firstChild(.heading)
22 node = child
23 }
24 return heading
25}
26
27/// The parts of a heading line, in offsets of the whole text.
28struct HeadingLine {
29 let start: Int
30 /// End of the line's content, before its line break.
31 let contentEnd: Int
32 let stars: Range<Int>
33 let todo: SyntaxToken?
34 let priority: SyntaxToken?
35 let tags: SyntaxToken?
36 /// Where the title begins: past the stars, keyword, cookie and the blanks after them.
37 let titleStart: Int
38
39 init(_ heading: SyntaxNode) {
40 let tokens = heading.tokens
41 start = heading.range.lowerBound
42 contentEnd = tokens.first { $0.kind == .newline }?.range.lowerBound ?? heading.range.upperBound
43 stars = tokens.first { $0.kind == .stars }?.range ?? start..<start
44 todo = tokens.first { $0.kind == .todoKeyword }
45 priority = tokens.first { $0.kind == .priority }
46 tags = tokens.first { $0.kind == .tags }
47 var at = start
48 for child in heading.green.children {
49 guard case .token(let token) = child, [.stars, .whitespace, .todoKeyword, .priority].contains(token.kind) else { break }
50 at += token.length
51 }
52 titleStart = min(at, contentEnd)
53 }
54
55 var level: Int { stars.count }
56}
57
58/// One line of text being edited, with a caret that moves the way Emacs moves point and
59/// markers for each kind of edit, so commands land the caret where Emacs does.
60struct LineBuffer {
61 var text: NSMutableString
62 /// Relative to the line start; nil when the caret is elsewhere in the document.
63 var caret: Int?
64
65 init(_ line: String, caret: Int?) {
66 text = NSMutableString(string: line)
67 self.caret = caret
68 }
69
70 var string: String { text as String }
71 var length: Int { text.length }
72
73 private mutating func edit(_ range: Range<Int>, _ replacement: String) -> Int {
74 text.replaceCharacters(in: NSRange(range), with: replacement)
75 return (replacement as NSString).length - range.count
76 }
77
78 /// `replace-match`: a caret strictly inside moves to the start; at or after the end it
79 /// shifts with the text.
80 mutating func replace(_ range: Range<Int>, with replacement: String) {
81 let delta = edit(range, replacement)
82 guard let position = caret else { return }
83 if position >= range.upperBound, position > range.lowerBound {
84 caret = position + delta
85 } else if position > range.lowerBound {
86 caret = range.lowerBound
87 }
88 }
89
90 /// Deleting `range`, then `insert-before-markers`: a caret anywhere from the start to the
91 /// end of the range ends up after the new text.
92 mutating func replaceBeforeMarkers(_ range: Range<Int>, with replacement: String) {
93 let delta = edit(range, replacement)
94 guard let position = caret else { return }
95 if position > range.upperBound {
96 caret = position + delta
97 } else if position >= range.lowerBound {
98 caret = range.lowerBound + (replacement as NSString).length
99 }
100 }
101
102 /// `insert` under `save-excursion`: a caret at the insertion point stays before the text.
103 mutating func insert(_ string: String, at position: Int) {
104 let delta = edit(position..<position, string)
105 if let caret, caret > position { self.caret = caret + delta }
106 }
107
108 /// `insert` at point: the caret moves past the text.
109 mutating func insertAtCaret(_ string: String) {
110 guard let position = caret else { return }
111 let delta = edit(position..<position, string)
112 caret = position + delta
113 }
114
115 /// Offset of display column `target` in the line, as `move-to-column`.
116 func offset(ofColumn target: Int) -> Int {
117 var column = 0
118 var offset = 0
119 for character in string {
120 if column >= target { break }
121 column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character)
122 offset += String(character).utf16.count
123 }
124 return offset
125 }
126}
127
128extension HeadingLine {
129 /// Applies `edit` to the heading line in `context` and maps the caret: on the line it's
130 /// placed by `edit`; after the line it shifts by the change in length.
131 func commit(_ context: EditContext, _ edit: (inout LineBuffer) -> Void) -> CommandStep {
132 let old = (context.text as NSString).substring(with: NSRange(start..<contentEnd))
133 let caretOnLine = context.caret >= start && context.caret <= contentEnd
134 var buffer = LineBuffer(old, caret: caretOnLine ? context.caret - start : nil)
135 edit(&buffer)
136 let new = buffer.string
137 let delta = (new as NSString).length - (old as NSString).length
138 let caret = buffer.caret.map { $0 + start } ?? (context.caret > contentEnd ? context.caret + delta : context.caret)
139 guard new != old else { return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret])) }
140 return .commit(EditResult(
141 baseRevision: context.revision,
142 edits: [TextEdit(range: start..<contentEnd, replacement: new)],
143 selection: [caret..<caret]
144 ))
145 }
146
147 /// Offsets relative to the line start.
148 func local(_ range: Range<Int>) -> Range<Int> { (range.lowerBound - start)..<(range.upperBound - start) }
149}
150
151// MARK: - Tags
152
153/// `org--align-tags-here` with `org-tags-column` -77: tags end at column 77, or sit one blank
154/// after the title when it is too long. Nothing changes when they are already there.
155///
156/// Commands align under `save-excursion`, where the caret is a marker: in the blanks before
157/// the tags it ends up where they start. Aligning tags directly (`preservingColumn`) keeps the
158/// caret's column instead, as org does for point.
159func alignTags(_ buffer: inout LineBuffer, tagsColumn: Int = -77, preservingColumn: Bool = false) {
160 let line = buffer.string
161 guard let match = line.range(of: "[ \\t]+(:[[:alnum:]_@#%]+)+:[ \\t]*$", options: .regularExpression) else { return }
162 let matched = String(line[match])
163 let tags = matched.trimmingCharacters(in: .whitespaces)
164 let blankStart = (String(line[..<match.lowerBound]) as NSString).length
165 let tagsStart = blankStart + (matched as NSString).range(of: tags).location
166 let prefixColumn = column(of: String(line[..<match.lowerBound]))
167 let currentColumn = column(of: (line as NSString).substring(to: tagsStart))
168 let target = tagsColumn >= 0 ? tagsColumn : abs(tagsColumn) - displayWidth(tags)
169 let newColumn = max(target, prefixColumn + 1)
170 guard newColumn != currentColumn else { return }
171 let inBlanks = buffer.caret.map { $0 > blankStart && $0 <= tagsStart } ?? false
172 let caretColumn: Int? = inBlanks && preservingColumn ? column(of: (line as NSString).substring(to: buffer.caret!)) : nil
173 let old = buffer.caret
174 buffer.text.replaceCharacters(in: NSRange(blankStart..<tagsStart), with: String(repeating: " ", count: newColumn - prefixColumn))
175 let delta = (newColumn - prefixColumn) - (tagsStart - blankStart)
176 if let caretColumn {
177 buffer.caret = buffer.offset(ofColumn: caretColumn)
178 } else if inBlanks {
179 buffer.caret = blankStart
180 } else if let old, old > tagsStart {
181 buffer.caret = old + delta
182 }
183}
184
185/// Display column at the end of `text`, starting from column 0.
186func column(of text: String) -> Int {
187 var column = 0
188 for character in text {
189 column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character)
190 }
191 return column
192}
193
194// MARK: - TODO
195
196public struct TodoCycle: OrgCommand {
197 public init() {}
198 public var id: String { "org.todo.cycle" }
199 public var title: String { "Cycle TODO State" }
200
201 public func applies(in context: EditContext) -> Bool {
202 entryHeading(at: context.caret, in: context.tree) != nil
203 }
204
205 /// No keyword, then each keyword of its sequence in order, then no keyword again.
206 static func next(after current: String?, in settings: OrgSettings) -> String? {
207 guard let current else {
208 guard let first = settings.todoSequences.first else { return nil }
209 return (first.active + first.done).first?.name
210 }
211 for sequence in settings.todoSequences {
212 let names = (sequence.active + sequence.done).map(\.name)
213 if let index = names.firstIndex(of: current) {
214 return index + 1 < names.count ? names[index + 1] : nil
215 }
216 }
217 return nil
218 }
219
220 /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are
221 /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags
222 /// are aligned.
223 public func run(in context: EditContext) -> CommandStep {
224 guard let heading = entryHeading(at: context.caret, in: context.tree) else {
225 return .failed("Before first headline")
226 }
227 let line = HeadingLine(heading)
228 let next = Self.next(after: line.todo?.text, in: context.tree.settings)
229 return line.commit(context) { buffer in
230 let text = buffer.string as NSString
231 let regionStart = line.stars.upperBound - line.start
232 var regionEnd = regionStart
233 while regionEnd < text.length, text.character(at: regionEnd) == 0x20 { regionEnd += 1 }
234 if let todo = line.todo {
235 regionEnd = line.local(todo.range).upperBound
236 var blanks = regionEnd
237 while blanks < text.length, text.character(at: blanks) == 0x20 { blanks += 1 }
238 if blanks > regionEnd {
239 regionEnd = blanks
240 } else if text.substring(from: regionEnd).allSatisfy({ $0 == " " || $0 == "\t" }) {
241 regionEnd = text.length
242 }
243 }
244 buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ")
245 alignTags(&buffer)
246 }
247 }
248}
249
250// MARK: - Priority
251
252struct PriorityChange {
253 let up: Bool
254
255 func run(in context: EditContext) -> CommandStep {
256 guard let heading = headingOnLine(at: context.caret, in: context.tree) else {
257 return .failed("Not on a heading")
258 }
259 let line = HeadingLine(heading)
260 let priorities = context.tree.settings.priorities
261 guard let highest = Self.value(priorities.highest), let lowest = Self.value(priorities.lowest) else {
262 return .failed("Unsupported priority range")
263 }
264 let numeric = Int(priorities.highest) != nil
265 let current = line.priority.flatMap { Self.value(String($0.text.dropFirst(2).dropLast())) }
266 // From no cookie, the first press sets the default, as `org-priority-start-cycle-with-default`.
267 var new: Int?
268 if let current {
269 new = up ? current - 1 : current + 1
270 if let value = new, value < highest || value > lowest { new = nil }
271 } else {
272 new = Self.value(priorities.default)
273 }
274 let cookie = new.map { "[#\(Self.label($0, numeric: numeric))]" }
275 return line.commit(context) { buffer in
276 if let existing = line.priority {
277 let range = line.local(existing.range)
278 if let cookie {
279 buffer.replace((range.lowerBound + 2)..<(range.upperBound - 1), with: String(cookie.dropFirst(2).dropLast()))
280 } else {
281 // The cookie and one following space, as `org-priority-regexp` group 1.
282 let text = buffer.string as NSString
283 let space = range.upperBound < text.length && text.character(at: range.upperBound) == 0x20 ? 1 : 0
284 buffer.replace(range.lowerBound..<(range.upperBound + space), with: "")
285 }
286 } else if let cookie {
287 if let todo = line.todo {
288 buffer.insert(" " + cookie, at: line.local(todo.range).upperBound)
289 } else {
290 buffer.insert(cookie + " ", at: line.titleStart - line.start)
291 }
292 }
293 alignTags(&buffer)
294 }
295 }
296
297 /// A priority as a number: a letter's code point, or the number itself.
298 static func value(_ text: String) -> Int? {
299 if let number = Int(text) { return number }
300 guard text.unicodeScalars.count == 1, let scalar = text.unicodeScalars.first else { return nil }
301 return Int(scalar.value)
302 }
303
304 static func label(_ value: Int, numeric: Bool) -> String {
305 numeric ? String(value) : String(Character(Unicode.Scalar(UInt32(value))!))
306 }
307}
308
309public struct PriorityUp: OrgCommand {
310 public init() {}
311 public var id: String { "org.priority.up" }
312 public var title: String { "Raise Priority" }
313 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
314 public func run(in context: EditContext) -> CommandStep { PriorityChange(up: true).run(in: context) }
315}
316
317public struct PriorityDown: OrgCommand {
318 public init() {}
319 public var id: String { "org.priority.down" }
320 public var title: String { "Lower Priority" }
321 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
322 public func run(in context: EditContext) -> CommandStep { PriorityChange(up: false).run(in: context) }
323}
324
325// MARK: - Promote and demote
326
327public struct PromoteHeading: OrgCommand {
328 public init() {}
329 public var id: String { "org.heading.promote" }
330 public var title: String { "Promote Heading" }
331 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
332
333 public func run(in context: EditContext) -> CommandStep {
334 guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
335 let line = HeadingLine(heading)
336 guard line.level > 1 else { return .failed("Cannot promote to level 0") }
337 return line.commit(context) { buffer in
338 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ")
339 alignTags(&buffer)
340 fixPositionAfterPromote(&buffer, settings: context.tree.settings)
341 }
342 }
343}
344
345public struct DemoteHeading: OrgCommand {
346 public init() {}
347 public var id: String { "org.heading.demote" }
348 public var title: String { "Demote Heading" }
349 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
350
351 public func run(in context: EditContext) -> CommandStep {
352 guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
353 let line = HeadingLine(heading)
354 return line.commit(context) { buffer in
355 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ")
356 alignTags(&buffer)
357 fixPositionAfterPromote(&buffer, settings: context.tree.settings)
358 }
359 }
360}
361
362/// `org-fix-position-after-promote`: a caret right after the stars or the keyword steps over
363/// the following space, or adds one at the end of the line.
364func fixPositionAfterPromote(_ buffer: inout LineBuffer, settings: OrgSettings) {
365 guard let caret = buffer.caret else { return }
366 let tree = OrgParser.parse(buffer.string + "\n", defaults: settings)
367 guard let heading = tree.root.firstChild(.section)?.firstChild(.heading) else { return }
368 let line = HeadingLine(heading)
369 let anchors = [line.stars.upperBound, line.todo?.range.upperBound].compactMap { $0 }
370 guard anchors.contains(caret) else { return }
371 if caret == buffer.length {
372 buffer.insertAtCaret(" ")
373 } else if buffer.text.character(at: caret) == 0x20 {
374 buffer.caret = caret + 1
375 }
376}
Sources/OrgCore/Parser/Incremental.swift +2 −2
@@ -40,7 +40,7 @@ extension OrgParser {
4040 _ old: OrgTree, oldText: String, edit: TextEdit, defaults: OrgSettings = .default
4141 ) -> (tree: OrgTree, strategy: ReparseStrategy) {
4242 let newText = edit.apply(to: oldText)
43 let context = EditContext(oldText: oldText, newText: newText, edit: edit)
43 let context = ReparseContext(oldText: oldText, newText: newText, edit: edit)
4444 if context.touchesSettings() { return (parse(newText, defaults: defaults), .full) }
4545 if let tree = context.reparseElement(old) { return (tree, .element) }
4646 if let tree = context.reparseRegion(old) { return (tree, .region) }
@@ -49,7 +49,7 @@ extension OrgParser {
4949 }
5050}
5151
52private struct EditContext {
52private struct ReparseContext {
5353 let oldText: String
5454 let newText: String
5555 let edit: TextEdit
Sources/OrgCore/Parser/Lines.swift +3 −2
@@ -73,10 +73,11 @@ func classifyLine(_ line: Substring) -> ClassifiedLine {
7373private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass {
7474 let trimmed = rest.trimmingTrailingWhitespace
7575
76 // As org's `org-outline-regexp`: stars and then a space. A lone `*`, or stars before a tab,
77 // is not a heading.
7678 if columnZero, rest.first == "*" {
7779 let stars = rest.prefix { $0 == "*" }
78 let after = rest.dropFirst(stars.count)
79 if after.isEmpty || after.first == " " || after.first == "\t" {
80 if rest.dropFirst(stars.count).first == " " {
8081 return .heading(level: stars.count)
8182 }
8283 }
Sources/OrgCore/Parser/Parser.swift +3 −1
@@ -390,8 +390,10 @@ struct Parser {
390390 builder.token(.stars, stars)
391391 rest = whitespace(rest.dropFirst(stars.count))
392392
393 // As org: a keyword counts only before a space or the end of the line, not a tab.
393394 let word = rest.prefix { $0 != " " && $0 != "\t" }
394 if !word.isEmpty, settings.todoKeywordNames.contains(String(word)) {
395 let afterWord = rest.dropFirst(word.count).first
396 if !word.isEmpty, afterWord == nil || afterWord == " ", settings.todoKeywordNames.contains(String(word)) {
395397 builder.token(.todoKeyword, word)
396398 rest = whitespace(rest.dropFirst(word.count))
397399 }
Sources/OrgDocument/DocumentState.swift +19
@@ -1,3 +1,4 @@
1import Foundation
12import OrgCore
23
34/// One open file: its text, tree, revision, undo history, and the bytes last read from or
@@ -95,6 +96,24 @@ public struct DocumentState: Sendable {
9596 return inverse
9697 }
9798
99 // MARK: - Commands
100
101 /// Runs `command` at `selection` and applies its edits as one undo step.
102 public mutating func run(
103 _ command: any OrgCommand, selection: [Range<Int>], now: Date = Date(),
104 calendar: Calendar = .current, answers: [String: String] = [:]
105 ) throws -> CommandStep {
106 let context = EditContext(
107 revision: revision, text: text, tree: tree, selection: selection,
108 now: now, calendar: calendar, answers: answers
109 )
110 let step = command.run(in: context)
111 if case .commit(let result) = step, !result.edits.isEmpty {
112 try apply(result.edits, baseRevision: result.baseRevision)
113 }
114 return step
115 }
116
98117 // MARK: - Disk
99118
100119 /// The file on disk now holds `bytes`. Reloads an unedited buffer, merges into an edited
Sources/OrgEditorAppKit/OrgEditor.swift +47
@@ -168,6 +168,53 @@ public final class OrgEditor: NSObject {
168168 return outcome
169169 }
170170
171 // MARK: - Commands
172
173 /// Called with messages commands report, such as why one couldn't run here.
174 public var onMessage: ((String) -> Void)?
175
176 /// Runs `command` at the selection. Edits go through the text view, as typing does, so
177 /// they form one undo step and reach the document through the same path.
178 @discardableResult
179 public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
180 let selected = textView.selectedRange()
181 let context = EditContext(
182 revision: document.revision, text: document.text, tree: document.tree,
183 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers
184 )
185 let step = command.run(in: context)
186 switch step {
187 case .commit(let result):
188 guard textView.isEditable || result.edits.isEmpty else {
189 onMessage?("This file is read-only.")
190 return .failed("read-only")
191 }
192 if !result.edits.isEmpty, let storage = textView.textStorage {
193 // Each command is its own undo step, not merged with typing around it.
194 textView.breakUndoCoalescing()
195 textView.undoManager?.beginUndoGrouping()
196 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
197 let range = NSRange(edit.range)
198 guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue }
199 storage.replaceCharacters(in: range, with: edit.replacement)
200 textView.didChangeText()
201 }
202 textView.undoManager?.endUndoGrouping()
203 textView.breakUndoCoalescing()
204 }
205 if let selection = result.selection?.first {
206 setCaret(selection.lowerBound)
207 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
208 }
209 for case .message(let text) in result.effects { onMessage?(text) }
210 case .failed(let message):
211 onMessage?(message)
212 case .prompt:
213 break
214 }
215 return step
216 }
217
171218 // MARK: - Disk
172219
173220 /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
Tests/OrgCoreTests/EmacsOracle.swift added +132
@@ -0,0 +1,132 @@
1import Foundation
2@testable import OrgCore
3
4/// Runs cases through `emacs -Q --batch` with Org's settings pinned, so commands can be
5/// compared with Emacs byte for byte. One Emacs process runs a whole batch.
6///
7/// Pinned versions: Emacs 31.1, Org 9.8.7 (design, "Testing"). `ORGSTAR_EMACS` overrides the
8/// executable.
9enum EmacsOracle {
10 struct Case: Codable {
11 var text: String
12 /// Emacs point: 1 plus the number of characters (code points) before it.
13 var point: Int
14 /// An Emacs Lisp form to run with point there.
15 var form: String
16 }
17
18 struct Result: Codable, Equatable {
19 var text: String
20 var point: Int
21 /// The error message, or empty.
22 var error: String
23 }
24
25 static let script = #"""
26 ;;; -*- lexical-binding: t -*-
27 (require 'org)
28 (require 'json)
29 (setq org-todo-keywords '((sequence "TODO" "DONE"))
30 org-log-done nil
31 org-log-repeat nil
32 org-adapt-indentation nil
33 org-tags-column -77
34 org-priority-highest ?A
35 org-priority-lowest ?C
36 org-priority-default ?B
37 indent-tabs-mode nil)
38 (let* ((input (with-temp-buffer
39 (let ((coding-system-for-read 'utf-8-unix))
40 (insert-file-contents (getenv "ORACLE_INPUT")))
41 (json-parse-buffer :object-type 'alist :array-type 'list)))
42 (results nil))
43 (dolist (c input)
44 (with-temp-buffer
45 (insert (alist-get 'text c))
46 (org-mode)
47 (goto-char (alist-get 'point c))
48 ;; As an interactive call, not a repeat: some commands cycle differently on repeats.
49 (setq this-command 'orgstar-oracle last-command nil)
50 (let ((err (condition-case e
51 (progn (eval (car (read-from-string (alist-get 'form c))) t) "")
52 (error (error-message-string e)))))
53 (push `((text . ,(buffer-substring-no-properties (point-min) (point-max)))
54 (point . ,(point))
55 (error . ,err))
56 results))))
57 (let ((coding-system-for-write 'utf-8-unix))
58 (with-temp-file (getenv "ORACLE_OUTPUT")
59 (insert (json-encode (nreverse results))))))
60 """#
61
62 static var executable: String { ProcessInfo.processInfo.environment["ORGSTAR_EMACS"] ?? "emacs" }
63
64 static var isAvailable: Bool {
65 (try? run(["--version"]))?.contains("GNU Emacs") ?? false
66 }
67
68 static func run(_ cases: [Case]) throws -> [Result] {
69 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-oracle-\(UUID().uuidString)")
70 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
71 defer { try? FileManager.default.removeItem(at: folder) }
72 let scriptURL = folder.appendingPathComponent("oracle.el")
73 let input = folder.appendingPathComponent("input.json")
74 let output = folder.appendingPathComponent("output.json")
75 try script.write(to: scriptURL, atomically: true, encoding: .utf8)
76 try JSONEncoder().encode(cases).write(to: input)
77 _ = try run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_INPUT": input.path, "ORACLE_OUTPUT": output.path])
78 return try JSONDecoder().decode([Result].self, from: Data(contentsOf: output))
79 }
80
81 @discardableResult
82 static func run(_ arguments: [String], environment: [String: String] = [:]) throws -> String {
83 let process = Process()
84 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
85 process.arguments = [executable] + arguments
86 process.environment = ProcessInfo.processInfo.environment.merging(environment) { $1 }
87 let pipe = Pipe()
88 process.standardOutput = pipe
89 process.standardError = pipe
90 try process.run()
91 let data = pipe.fileHandleForReading.readDataToEndOfFile()
92 process.waitUntilExit()
93 return String(decoding: data, as: UTF8.self)
94 }
95
96 /// UTF-16 offset to Emacs point.
97 static func point(_ offset: Int, in text: String) -> Int {
98 let index = String.Index(utf16Offset: offset, in: text)
99 return text.unicodeScalars[..<index].count + 1
100 }
101
102 /// Emacs point to UTF-16 offset.
103 static func offset(_ point: Int, in text: String) -> Int {
104 text.unicodeScalars.prefix(point - 1).reduce(0) { $0 + $1.utf16.count }
105 }
106
107 /// UTF-16 offsets of every unicode scalar boundary, including the end.
108 static func positions(_ text: String) -> [Int] {
109 var positions = [0]
110 var offset = 0
111 for scalar in text.unicodeScalars {
112 offset += scalar.utf16.count
113 positions.append(offset)
114 }
115 return positions
116 }
117}
118
119/// Runs a command the way an editor would: build a context, run, apply the edits.
120func runCommand(_ command: any OrgCommand, _ text: String, caret: Int) -> (text: String, caret: Int, failure: String?) {
121 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret])
122 switch command.run(in: context) {
123 case .commit(let result):
124 var new = text
125 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
126 return (new, result.selection?.first?.lowerBound ?? caret, nil)
127 case .failed(let message):
128 return (text, caret, message)
129 case .prompt(let prompt):
130 return (text, caret, "prompt: \(prompt.key)")
131 }
132}
Tests/OrgCoreTests/HeadingCommandTests.swift added +125
@@ -0,0 +1,125 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct HeadingCommandTests {
6 @Test func todoCycle() {
7 let cycle = TodoCycle()
8 #expect(runCommand(cycle, "* a\n", caret: 2).text == "* TODO a\n")
9 #expect(runCommand(cycle, "* TODO a\n", caret: 2).text == "* DONE a\n")
10 #expect(runCommand(cycle, "* DONE a\n", caret: 2).text == "* a\n")
11 #expect(runCommand(cycle, "* TODO\n", caret: 2).text == "* DONE \n")
12 #expect(runCommand(cycle, "#+TODO: A B | C\n* B x\n", caret: 18).text == "#+TODO: A B | C\n* C x\n")
13 #expect(runCommand(cycle, "text\n", caret: 1).failure != nil)
14 }
15
16 @Test func caretMovesToTheTitleWhenBeforeIt() {
17 #expect(runCommand(TodoCycle(), "* a\n", caret: 2).caret == 7)
18 #expect(runCommand(TodoCycle(), "* TODO abc\nbody\n", caret: 13).caret == 13)
19 }
20
21 @Test func priorities() {
22 #expect(runCommand(PriorityUp(), "* a\n", caret: 2).text == "* [#B] a\n")
23 #expect(runCommand(PriorityUp(), "* [#A] a\n", caret: 2).text == "* a\n")
24 #expect(runCommand(PriorityDown(), "* TODO a\n", caret: 2).text == "* TODO [#B] a\n")
25 #expect(runCommand(PriorityDown(), "* [#C] a\n", caret: 2).text == "* a\n")
26 }
27
28 @Test func promoteAndDemote() {
29 #expect(runCommand(DemoteHeading(), "* a\nbody\n", caret: 2).text == "** a\nbody\n")
30 #expect(runCommand(PromoteHeading(), "** a\n", caret: 3).text == "* a\n")
31 #expect(runCommand(PromoteHeading(), "* a\n", caret: 0).failure != nil)
32 #expect(runCommand(DemoteHeading(), "* a\nbody\n", caret: 6).failure != nil)
33 }
34
35 @Test func tagsAlignToColumn77() {
36 let result = runCommand(PriorityUp(), "* TODO a :t:\n", caret: 2).text
37 #expect(result == "* TODO [#B] a" + String(repeating: " ", count: 77 - 13 - 3) + ":t:\n")
38 }
39}
40
41/// Every heading variant, every caret position, every heading command, against Emacs.
42/// Fails when Emacs is missing unless `ORGSTAR_SKIP_ORACLE` is set.
43struct HeadingOracleTests {
44 static let variants = [
45 "* a\nbody\n", "* TODO a\n", "* DONE a\n", "** [#B] two words :tag:\n", "* TODO [#A] x :a:b:\nbody\n",
46 "* \n", "* TODO\n", "*** title with 日本 :t:\n", "* a😀 b\n", "* a :t:\n", "text\n* a\n", "* a\n",
47 "* TODO a\n", "* a\r\n",
48 "#+TODO: NEXT WAIT | DONE CANCELED\n* WAIT a :t:\n",
49 "* " + String(repeating: "long ", count: 16) + "title :t:\n",
50 "* TODO\ttab\n",
51 ]
52
53 /// Commands, their Emacs forms, and whether they only act on heading lines.
54 static let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [
55 (TodoCycle(), "(org-todo)", false),
56 (PriorityUp(), "(org-priority-up)", true),
57 (PriorityDown(), "(org-priority-down)", true),
58 (PromoteHeading(), "(org-do-promote)", true),
59 (DemoteHeading(), "(org-do-demote)", true),
60 ]
61
62 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
63 func headingCommandsMatchEmacs() throws {
64 try compare(Self.variants.map { ($0, EmacsOracle.positions($0)) })
65 }
66
67 /// Real headings: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every command on up to 400 heading
68 /// lines from the folder's files, with the caret at the line start, the title start and
69 /// the line end.
70 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
71 func headingCommandsMatchEmacsOnACorpus() throws {
72 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
73 var samples: [(String, [Int])] = []
74 let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
75 .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
76 for file in files where samples.count < 400 {
77 guard let text = try? String(contentsOf: file, encoding: .utf8) else { continue }
78 var settings = ""
79 for line in text.components(separatedBy: "\n") where line.uppercased().hasPrefix("#+TODO:") || line.uppercased().hasPrefix("#+SEQ_TODO:") {
80 settings += line + "\n"
81 }
82 for line in text.components(separatedBy: "\n") where line.hasPrefix("*") && samples.count < 400 {
83 let sample = settings + line + "\n"
84 guard let heading = entryHeading(at: (sample as NSString).length - 1, in: OrgParser.parse(sample)) else { continue }
85 let parts = HeadingLine(heading)
86 samples.append((sample, [parts.start, parts.titleStart, parts.contentEnd]))
87 }
88 }
89 try compare(samples)
90 }
91
92 func compare(_ inputs: [(text: String, carets: [Int])]) throws {
93 try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip")
94 var cases: [EmacsOracle.Case] = []
95 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
96 for (text, carets) in inputs {
97 for offset in carets {
98 let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil
99 for (command, form, headingLineOnly) in Self.commands where onHeadingLine || !headingLineOnly {
100 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form))
101 let result = runCommand(command, text, caret: offset)
102 ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
103 }
104 }
105 }
106 let theirs = try EmacsOracle.run(cases)
107 var mismatches = 0
108 for (mine, emacs) in zip(ours, theirs) {
109 let emacsFailed = !emacs.error.isEmpty
110 let emacsCaret = EmacsOracle.offset(emacs.point, in: emacs.text)
111 let same = mine.failed == emacsFailed && (emacsFailed || (mine.text == emacs.text && mine.caret == emacsCaret))
112 if !same {
113 mismatches += 1
114 if mismatches <= 15 {
115 Issue.record("""
116 \(mine.label)
117 ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")
118 emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(emacs.text.debugDescription) @\(emacsCaret)")
119 """)
120 }
121 }
122 }
123 #expect(mismatches == 0, "\(mismatches) of \(ours.count) cases differ from Emacs")
124 }
125}
Tests/OrgCoreTests/LinesTests.swift +3 −1
@@ -20,7 +20,9 @@ struct LinesTests {
2020 (" \t", .blank),
2121 ("* a", .heading(level: 1)),
2222 ("*** ", .heading(level: 3)),
23 ("*", .heading(level: 1)),
23 ("*", .plain),
24 ("*\ttab", .plain),
25 ("* ", .heading(level: 1)),
2426 ("*bold* text", .plain),
2527 (" * a", .listItem),
2628 ("#+BEGIN_SRC sh :results output", .blockBegin(name: "src")),
Tests/OrgCoreTests/ParserSectionTests.swift +6
@@ -43,6 +43,12 @@ struct ParserSectionTests {
4343 #expect(todo == ["NEXT"])
4444 }
4545
46 @Test func todoKeywordNeedsASpaceAfterIt() {
47 #expect(tokens(of: .heading, in: "* TODO a\n").contains { $0.kind == .todoKeyword })
48 #expect(tokens(of: .heading, in: "* TODO\n").contains { $0.kind == .todoKeyword })
49 #expect(!tokens(of: .heading, in: "* TODO\ta\n").contains { $0.kind == .todoKeyword })
50 }
51
4652 @Test func priorityNeedsValidValueAndSpace() {
4753 #expect(tokens(of: .heading, in: "* [#B] x\n").contains { $0.kind == .priority })
4854 #expect(tokens(of: .heading, in: "* [#10] x\n").contains { $0.kind == .priority })
Tests/OrgDocumentTests/DocumentStateTests.swift +22
@@ -115,3 +115,25 @@ struct ViewStateTests {
115115 #expect(ViewState(folds: [0, 4, 9]).pruned(to: tree).folds == [0, 9])
116116 }
117117}
118
119struct CommandRunTests {
120 @Test func runAppliesEditsAsOneStep() throws {
121 var doc = state("* a\n")
122 let step = try doc.run(TodoCycle(), selection: [2..<2])
123 guard case .commit(let result) = step else {
124 Issue.record("expected a commit")
125 return
126 }
127 #expect(doc.text == "* TODO a\n")
128 #expect(result.selection == [7..<7])
129 #expect(doc.tree.green == OrgParser.parse(doc.text).green)
130 _ = doc.undo()
131 #expect(doc.text == "* a\n")
132 }
133
134 @Test func failuresChangeNothing() throws {
135 var doc = state("text\n")
136 #expect(try doc.run(TodoCycle(), selection: [0..<0]) == .failed("Before first headline"))
137 #expect(doc.revision == 0)
138 }
139}
Tests/OrgEditorAppKitTests/EditorTests.swift +33
@@ -320,3 +320,36 @@ struct HangingIndentTests {
320320 #expect(OrgEditor.hangingColumns(line) == expected)
321321 }
322322}
323
324@MainActor
325struct CommandTests {
326 @Test func performEditsThroughTheTextView() {
327 let h = Harness("* a :t:\nbody\n")
328 var messages: [String] = []
329 h.editor.onMessage = { messages.append($0) }
330 h.caret(at: 2)
331 h.editor.perform(PriorityUp())
332 #expect(h.string.hasPrefix("* [#B] a "))
333 #expect(h.caret == 2)
334 h.checkInSync()
335 // Undo groups by event: let the run loop close the first command's group, as a key
336 // press would.
337 RunLoop.current.run(until: Date())
338 h.editor.perform(TodoCycle())
339 #expect(h.string.hasPrefix("* TODO [#B] a "))
340 RunLoop.current.run(until: Date())
341 h.textView.undoManager?.undo()
342 #expect(h.string.hasPrefix("* [#B] a "))
343 h.checkInSync()
344 h.caret(at: h.offset(of: "body"))
345 h.editor.perform(PromoteHeading())
346 #expect(messages == ["Not on a heading"])
347 }
348
349 @Test func readOnlyEditorsRefuseEdits() {
350 let editor = OrgEditor(document: DocumentState(bytes: Array("* a\n".utf8)), editable: false)
351 editor.textView.setSelectedRange(NSRange(location: 2, length: 0))
352 #expect(editor.perform(TodoCycle()) == .failed("read-only"))
353 #expect(editor.document.text == "* a\n")
354 }
355}
docs/plans/2026-10-05-commands.md added +1078
@@ -0,0 +1,1078 @@
1# Command Model Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** The command model from the design (pure functions over an `EditContext`, revision-checked results, prompts, injected time), the stateful Emacs oracle, and the first heading commands (TODO cycle, priority up/down, promote/demote) proven byte- and caret-exact against Emacs 31.1 / Org 9.8.7.
6
7**Architecture:** `OrgCommand.run(in:)` reads an `EditContext` and returns a `CommandStep`: a commit with edits and the caret, a prompt, or a failure message. Heading commands rewrite one heading line in a `LineBuffer` whose caret follows Emacs's rules for each kind of edit (`replace-match`, `insert-before-markers`, `insert` under `save-excursion`, tag alignment), because those rules decide where org leaves point. `DocumentState.run` applies a commit as one undo step; `OrgEditor.perform` applies it through the text view so undo and document sync work as for typing. The oracle sends cases (text, point, Emacs form) to one `emacs -Q --batch` process as JSON with org's settings pinned.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 with Org 9.8.7 for the oracle.
10
11**Spec:** `docs/design.md`, "Commands and keymaps" and "Testing" (command oracle).
12
13## Global Constraints
14
15- Commands never read the clock, the file system or global state; the same context gives the same result.
16- Every command is checked against Emacs on every caret position of a set of heading variants (text and caret must both match), and against real headings with `ORGSTAR_ORACLE_CORPUS`.
17- The oracle runs `-Q` with explicit settings: TODO/DONE, no logging, `org-tags-column` -77, priorities A–C default B, no tab indentation; each case runs as a fresh interactive command (`last-command` differs from `this-command`).
18- Commands that org binds only on heading lines (M-left/right, S-up/down) apply only on heading lines.
19
20## What the oracle found
21
22- Two parser differences from org, now fixed: a heading needs a space after its stars (`*` alone and `*` before a tab are not headings), and a TODO keyword counts only before a space or the end of the line.
23- `org-todo`, `org-priority` and promote/demote all realign tags to column 77.
24- `org-todo` replaces the blanks after the stars, the keyword and its blanks with " NEXT " using `insert-before-markers`, so extra blanks collapse and a caret anywhere in that span ends after the keyword.
25- Promote and demote replace the stars and one space with `replace-match`, then `org-fix-position-after-promote` steps a caret at the end of the stars or keyword over the next space.
26- In batch mode `org-priority` sees `last-command` equal to `this-command` and wraps instead of starting at the default; the oracle sets them apart, as in an interactive call.
27
28Corpus check: every sampled heading (up to 400 per folder) from three real folders matches Emacs for all five commands.
29
30## File structure
31
32| File | Responsibility |
33| --- | --- |
34| `Sources/OrgCore/Parser/Lines.swift`, `Parser.swift` | Heading needs a space after the stars; keyword needs a space after it |
35| `Sources/OrgCore/Commands/Command.swift` | `EditContext`, `Prompt`, `Effect`, `EditResult`, `CommandStep`, `OrgCommand`, `Commands` |
36| `Sources/OrgCore/Commands/HeadingCommands.swift` | `LineBuffer`, `HeadingLine`, tag alignment, the five commands |
37| `Sources/OrgDocument/DocumentState.swift` | `run(_:selection:now:calendar:answers:)` |
38| `Sources/OrgEditorAppKit/OrgEditor.swift` | `perform(_:now:answers:)`, `onMessage` |
39| `Tests/OrgCoreTests/EmacsOracle.swift` | The oracle and `runCommand` |
40| `Tests/OrgCoreTests/HeadingCommandTests.swift` | Unit tests, oracle over variants, oracle over a corpus |
41
42---
43
44### Task 1: Match org's heading rules
45
46**Files:** `Sources/OrgCore/Parser/Lines.swift`, `Sources/OrgCore/Parser/Parser.swift`, `Tests/OrgCoreTests/LinesTests.swift`, `Tests/OrgCoreTests/ParserSectionTests.swift`
47
48```diff
49diff --git a/Sources/OrgCore/Parser/Lines.swift b/Sources/OrgCore/Parser/Lines.swift
50index 5e9a077..bf3919a 100644
51--- a/Sources/OrgCore/Parser/Lines.swift
52+++ b/Sources/OrgCore/Parser/Lines.swift
53@@ -73,10 +73,11 @@ func classifyLine(_ line: Substring) -> ClassifiedLine {
54 private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass {
55 let trimmed = rest.trimmingTrailingWhitespace
56
57+ // As org's `org-outline-regexp`: stars and then a space. A lone `*`, or stars before a tab,
58+ // is not a heading.
59 if columnZero, rest.first == "*" {
60 let stars = rest.prefix { $0 == "*" }
61- let after = rest.dropFirst(stars.count)
62- if after.isEmpty || after.first == " " || after.first == "\t" {
63+ if rest.dropFirst(stars.count).first == " " {
64 return .heading(level: stars.count)
65 }
66 }
67diff --git a/Sources/OrgCore/Parser/Parser.swift b/Sources/OrgCore/Parser/Parser.swift
68index 2f2addf..9d0e2fa 100644
69--- a/Sources/OrgCore/Parser/Parser.swift
70+++ b/Sources/OrgCore/Parser/Parser.swift
71@@ -390,8 +390,10 @@ struct Parser {
72 builder.token(.stars, stars)
73 rest = whitespace(rest.dropFirst(stars.count))
74
75+ // As org: a keyword counts only before a space or the end of the line, not a tab.
76 let word = rest.prefix { $0 != " " && $0 != "\t" }
77- if !word.isEmpty, settings.todoKeywordNames.contains(String(word)) {
78+ let afterWord = rest.dropFirst(word.count).first
79+ if !word.isEmpty, afterWord == nil || afterWord == " ", settings.todoKeywordNames.contains(String(word)) {
80 builder.token(.todoKeyword, word)
81 rest = whitespace(rest.dropFirst(word.count))
82 }
83diff --git a/Tests/OrgCoreTests/LinesTests.swift b/Tests/OrgCoreTests/LinesTests.swift
84index d9f0cc6..fdd586a 100644
85--- a/Tests/OrgCoreTests/LinesTests.swift
86+++ b/Tests/OrgCoreTests/LinesTests.swift
87@@ -20,7 +20,9 @@ struct LinesTests {
88 (" \t", .blank),
89 ("* a", .heading(level: 1)),
90 ("*** ", .heading(level: 3)),
91- ("*", .heading(level: 1)),
92+ ("*", .plain),
93+ ("*\ttab", .plain),
94+ ("* ", .heading(level: 1)),
95 ("*bold* text", .plain),
96 (" * a", .listItem),
97 ("#+BEGIN_SRC sh :results output", .blockBegin(name: "src")),
98diff --git a/Tests/OrgCoreTests/ParserSectionTests.swift b/Tests/OrgCoreTests/ParserSectionTests.swift
99index a8ec412..826f236 100644
100--- a/Tests/OrgCoreTests/ParserSectionTests.swift
101+++ b/Tests/OrgCoreTests/ParserSectionTests.swift
102@@ -43,6 +43,12 @@ struct ParserSectionTests {
103 #expect(todo == ["NEXT"])
104 }
105
106+ @Test func todoKeywordNeedsASpaceAfterIt() {
107+ #expect(tokens(of: .heading, in: "* TODO a\n").contains { $0.kind == .todoKeyword })
108+ #expect(tokens(of: .heading, in: "* TODO\n").contains { $0.kind == .todoKeyword })
109+ #expect(!tokens(of: .heading, in: "* TODO\ta\n").contains { $0.kind == .todoKeyword })
110+ }
111+
112 @Test func priorityNeedsValidValueAndSpace() {
113 #expect(tokens(of: .heading, in: "* [#B] x\n").contains { $0.kind == .priority })
114 #expect(tokens(of: .heading, in: "* [#10] x\n").contains { $0.kind == .priority })
115```
116
117Run `swift test`, the reparse gate and the corpus round trip; commit: `git commit -m "Match org's heading and keyword rules"`.
118
119---
120
121### Task 2: Command model, heading commands, oracle
122
123**Files:** create `Sources/OrgCore/Commands/Command.swift`, `Sources/OrgCore/Commands/HeadingCommands.swift`, `Tests/OrgCoreTests/EmacsOracle.swift`, `Tests/OrgCoreTests/HeadingCommandTests.swift`; modify `Sources/OrgCore/Parser/Incremental.swift` (its private `EditContext` becomes `ReparseContext`).
124
125- [ ] **Step 1: The oracle and the tests**
126
127```swift
128import Foundation
129@testable import OrgCore
130
131/// Runs cases through `emacs -Q --batch` with Org's settings pinned, so commands can be
132/// compared with Emacs byte for byte. One Emacs process runs a whole batch.
133///
134/// Pinned versions: Emacs 31.1, Org 9.8.7 (design, "Testing"). `ORGSTAR_EMACS` overrides the
135/// executable.
136enum EmacsOracle {
137 struct Case: Codable {
138 var text: String
139 /// Emacs point: 1 plus the number of characters (code points) before it.
140 var point: Int
141 /// An Emacs Lisp form to run with point there.
142 var form: String
143 }
144
145 struct Result: Codable, Equatable {
146 var text: String
147 var point: Int
148 /// The error message, or empty.
149 var error: String
150 }
151
152 static let script = #"""
153 ;;; -*- lexical-binding: t -*-
154 (require 'org)
155 (require 'json)
156 (setq org-todo-keywords '((sequence "TODO" "DONE"))
157 org-log-done nil
158 org-log-repeat nil
159 org-adapt-indentation nil
160 org-tags-column -77
161 org-priority-highest ?A
162 org-priority-lowest ?C
163 org-priority-default ?B
164 indent-tabs-mode nil)
165 (let* ((input (with-temp-buffer
166 (let ((coding-system-for-read 'utf-8-unix))
167 (insert-file-contents (getenv "ORACLE_INPUT")))
168 (json-parse-buffer :object-type 'alist :array-type 'list)))
169 (results nil))
170 (dolist (c input)
171 (with-temp-buffer
172 (insert (alist-get 'text c))
173 (org-mode)
174 (goto-char (alist-get 'point c))
175 ;; As an interactive call, not a repeat: some commands cycle differently on repeats.
176 (setq this-command 'orgstar-oracle last-command nil)
177 (let ((err (condition-case e
178 (progn (eval (car (read-from-string (alist-get 'form c))) t) "")
179 (error (error-message-string e)))))
180 (push `((text . ,(buffer-substring-no-properties (point-min) (point-max)))
181 (point . ,(point))
182 (error . ,err))
183 results))))
184 (let ((coding-system-for-write 'utf-8-unix))
185 (with-temp-file (getenv "ORACLE_OUTPUT")
186 (insert (json-encode (nreverse results))))))
187 """#
188
189 static var executable: String { ProcessInfo.processInfo.environment["ORGSTAR_EMACS"] ?? "emacs" }
190
191 static var isAvailable: Bool {
192 (try? run(["--version"]))?.contains("GNU Emacs") ?? false
193 }
194
195 static func run(_ cases: [Case]) throws -> [Result] {
196 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-oracle-\(UUID().uuidString)")
197 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
198 defer { try? FileManager.default.removeItem(at: folder) }
199 let scriptURL = folder.appendingPathComponent("oracle.el")
200 let input = folder.appendingPathComponent("input.json")
201 let output = folder.appendingPathComponent("output.json")
202 try script.write(to: scriptURL, atomically: true, encoding: .utf8)
203 try JSONEncoder().encode(cases).write(to: input)
204 _ = try run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_INPUT": input.path, "ORACLE_OUTPUT": output.path])
205 return try JSONDecoder().decode([Result].self, from: Data(contentsOf: output))
206 }
207
208 @discardableResult
209 static func run(_ arguments: [String], environment: [String: String] = [:]) throws -> String {
210 let process = Process()
211 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
212 process.arguments = [executable] + arguments
213 process.environment = ProcessInfo.processInfo.environment.merging(environment) { $1 }
214 let pipe = Pipe()
215 process.standardOutput = pipe
216 process.standardError = pipe
217 try process.run()
218 let data = pipe.fileHandleForReading.readDataToEndOfFile()
219 process.waitUntilExit()
220 return String(decoding: data, as: UTF8.self)
221 }
222
223 /// UTF-16 offset to Emacs point.
224 static func point(_ offset: Int, in text: String) -> Int {
225 let index = String.Index(utf16Offset: offset, in: text)
226 return text.unicodeScalars[..<index].count + 1
227 }
228
229 /// Emacs point to UTF-16 offset.
230 static func offset(_ point: Int, in text: String) -> Int {
231 text.unicodeScalars.prefix(point - 1).reduce(0) { $0 + $1.utf16.count }
232 }
233
234 /// UTF-16 offsets of every unicode scalar boundary, including the end.
235 static func positions(_ text: String) -> [Int] {
236 var positions = [0]
237 var offset = 0
238 for scalar in text.unicodeScalars {
239 offset += scalar.utf16.count
240 positions.append(offset)
241 }
242 return positions
243 }
244}
245
246/// Runs a command the way an editor would: build a context, run, apply the edits.
247func runCommand(_ command: any OrgCommand, _ text: String, caret: Int) -> (text: String, caret: Int, failure: String?) {
248 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret])
249 switch command.run(in: context) {
250 case .commit(let result):
251 var new = text
252 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
253 return (new, result.selection?.first?.lowerBound ?? caret, nil)
254 case .failed(let message):
255 return (text, caret, message)
256 case .prompt(let prompt):
257 return (text, caret, "prompt: \(prompt.key)")
258 }
259}
260```
261
262```swift
263import Foundation
264import Testing
265@testable import OrgCore
266
267struct HeadingCommandTests {
268 @Test func todoCycle() {
269 let cycle = TodoCycle()
270 #expect(runCommand(cycle, "* a\n", caret: 2).text == "* TODO a\n")
271 #expect(runCommand(cycle, "* TODO a\n", caret: 2).text == "* DONE a\n")
272 #expect(runCommand(cycle, "* DONE a\n", caret: 2).text == "* a\n")
273 #expect(runCommand(cycle, "* TODO\n", caret: 2).text == "* DONE \n")
274 #expect(runCommand(cycle, "#+TODO: A B | C\n* B x\n", caret: 18).text == "#+TODO: A B | C\n* C x\n")
275 #expect(runCommand(cycle, "text\n", caret: 1).failure != nil)
276 }
277
278 @Test func caretMovesToTheTitleWhenBeforeIt() {
279 #expect(runCommand(TodoCycle(), "* a\n", caret: 2).caret == 7)
280 #expect(runCommand(TodoCycle(), "* TODO abc\nbody\n", caret: 13).caret == 13)
281 }
282
283 @Test func priorities() {
284 #expect(runCommand(PriorityUp(), "* a\n", caret: 2).text == "* [#B] a\n")
285 #expect(runCommand(PriorityUp(), "* [#A] a\n", caret: 2).text == "* a\n")
286 #expect(runCommand(PriorityDown(), "* TODO a\n", caret: 2).text == "* TODO [#B] a\n")
287 #expect(runCommand(PriorityDown(), "* [#C] a\n", caret: 2).text == "* a\n")
288 }
289
290 @Test func promoteAndDemote() {
291 #expect(runCommand(DemoteHeading(), "* a\nbody\n", caret: 2).text == "** a\nbody\n")
292 #expect(runCommand(PromoteHeading(), "** a\n", caret: 3).text == "* a\n")
293 #expect(runCommand(PromoteHeading(), "* a\n", caret: 0).failure != nil)
294 #expect(runCommand(DemoteHeading(), "* a\nbody\n", caret: 6).failure != nil)
295 }
296
297 @Test func tagsAlignToColumn77() {
298 let result = runCommand(PriorityUp(), "* TODO a :t:\n", caret: 2).text
299 #expect(result == "* TODO [#B] a" + String(repeating: " ", count: 77 - 13 - 3) + ":t:\n")
300 }
301}
302
303/// Every heading variant, every caret position, every heading command, against Emacs.
304/// Fails when Emacs is missing unless `ORGSTAR_SKIP_ORACLE` is set.
305struct HeadingOracleTests {
306 static let variants = [
307 "* a\nbody\n", "* TODO a\n", "* DONE a\n", "** [#B] two words :tag:\n", "* TODO [#A] x :a:b:\nbody\n",
308 "* \n", "* TODO\n", "*** title with 日本 :t:\n", "* a😀 b\n", "* a :t:\n", "text\n* a\n", "* a\n",
309 "* TODO a\n", "* a\r\n",
310 "#+TODO: NEXT WAIT | DONE CANCELED\n* WAIT a :t:\n",
311 "* " + String(repeating: "long ", count: 16) + "title :t:\n",
312 "* TODO\ttab\n",
313 ]
314
315 /// Commands, their Emacs forms, and whether they only act on heading lines.
316 static let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [
317 (TodoCycle(), "(org-todo)", false),
318 (PriorityUp(), "(org-priority-up)", true),
319 (PriorityDown(), "(org-priority-down)", true),
320 (PromoteHeading(), "(org-do-promote)", true),
321 (DemoteHeading(), "(org-do-demote)", true),
322 ]
323
324 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
325 func headingCommandsMatchEmacs() throws {
326 try compare(Self.variants.map { ($0, EmacsOracle.positions($0)) })
327 }
328
329 /// Real headings: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every command on up to 400 heading
330 /// lines from the folder's files, with the caret at the line start, the title start and
331 /// the line end.
332 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
333 func headingCommandsMatchEmacsOnACorpus() throws {
334 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
335 var samples: [(String, [Int])] = []
336 let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
337 .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
338 for file in files where samples.count < 400 {
339 guard let text = try? String(contentsOf: file, encoding: .utf8) else { continue }
340 var settings = ""
341 for line in text.components(separatedBy: "\n") where line.uppercased().hasPrefix("#+TODO:") || line.uppercased().hasPrefix("#+SEQ_TODO:") {
342 settings += line + "\n"
343 }
344 for line in text.components(separatedBy: "\n") where line.hasPrefix("*") && samples.count < 400 {
345 let sample = settings + line + "\n"
346 guard let heading = entryHeading(at: (sample as NSString).length - 1, in: OrgParser.parse(sample)) else { continue }
347 let parts = HeadingLine(heading)
348 samples.append((sample, [parts.start, parts.titleStart, parts.contentEnd]))
349 }
350 }
351 try compare(samples)
352 }
353
354 func compare(_ inputs: [(text: String, carets: [Int])]) throws {
355 try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip")
356 var cases: [EmacsOracle.Case] = []
357 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
358 for (text, carets) in inputs {
359 for offset in carets {
360 let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil
361 for (command, form, headingLineOnly) in Self.commands where onHeadingLine || !headingLineOnly {
362 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form))
363 let result = runCommand(command, text, caret: offset)
364 ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
365 }
366 }
367 }
368 let theirs = try EmacsOracle.run(cases)
369 var mismatches = 0
370 for (mine, emacs) in zip(ours, theirs) {
371 let emacsFailed = !emacs.error.isEmpty
372 let emacsCaret = EmacsOracle.offset(emacs.point, in: emacs.text)
373 let same = mine.failed == emacsFailed && (emacsFailed || (mine.text == emacs.text && mine.caret == emacsCaret))
374 if !same {
375 mismatches += 1
376 if mismatches <= 15 {
377 Issue.record("""
378 \(mine.label)
379 ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")
380 emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(emacs.text.debugDescription) @\(emacsCaret)")
381 """)
382 }
383 }
384 }
385 #expect(mismatches == 0, "\(mismatches) of \(ours.count) cases differ from Emacs")
386 }
387}
388```
389
390- [ ] **Step 2: The command model**
391
392```swift
393import Foundation
394
395/// Everything a command may read. Commands never read the clock or the file system, so the
396/// same context always gives the same result.
397public struct EditContext: Sendable {
398 /// The revision `text` and `tree` belong to; results are applied only at this revision.
399 public let revision: Int
400 public let text: String
401 public let tree: OrgTree
402 /// Selections in UTF-16 offsets. The first is the main caret.
403 public let selection: [Range<Int>]
404 public let now: Date
405 /// Includes the time zone.
406 public let calendar: Calendar
407 /// Replies to earlier prompts, by prompt key.
408 public let answers: [String: String]
409
410 public init(
411 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
412 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:]
413 ) {
414 self.revision = revision
415 self.text = text
416 self.tree = tree
417 self.selection = selection
418 self.now = now
419 self.calendar = calendar
420 self.answers = answers
421 }
422
423 public var caret: Int { selection.first?.lowerBound ?? 0 }
424}
425
426public struct Prompt: Sendable, Equatable {
427 /// The key the answer comes back under in `EditContext.answers`.
428 public let key: String
429 public let message: String
430
431 public init(key: String, message: String) {
432 self.key = key
433 self.message = message
434 }
435}
436
437/// Things a command asks for besides text changes; the platform layer carries them out.
438public enum Effect: Sendable, Equatable {
439 case message(String)
440}
441
442public struct EditResult: Sendable, Equatable {
443 public let baseRevision: Int
444 /// Non-overlapping, in `baseRevision` coordinates.
445 public let edits: [TextEdit]
446 /// Selection after the edits, in new coordinates; nil maps the old selection through.
447 public let selection: [Range<Int>]?
448 public let effects: [Effect]
449
450 public init(baseRevision: Int, edits: [TextEdit], selection: [Range<Int>]? = nil, effects: [Effect] = []) {
451 self.baseRevision = baseRevision
452 self.edits = edits
453 self.selection = selection
454 self.effects = effects
455 }
456}
457
458public enum CommandStep: Sendable, Equatable {
459 case commit(EditResult)
460 /// Ask, then run again with the answer in `EditContext.answers`.
461 case prompt(Prompt)
462 /// The command can't run here; nothing changes. The message is for the user, as org's
463 /// `user-error`.
464 case failed(String)
465}
466
467/// A named operation. Keys, menus, the palette and touch controls all run commands.
468public protocol OrgCommand: Sendable {
469 /// Stable identifier, used by keymaps: `org.todo.cycle`.
470 var id: String { get }
471 /// Shown in the command palette.
472 var title: String { get }
473 /// Whether the command means something at the caret; context dispatch (one key, several
474 /// commands) runs the first that applies.
475 func applies(in context: EditContext) -> Bool
476 func run(in context: EditContext) -> CommandStep
477}
478
479public enum Commands {
480 public static let all: [any OrgCommand] = [
481 TodoCycle(), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(),
482 ]
483
484 public static func command(_ id: String) -> (any OrgCommand)? {
485 all.first { $0.id == id }
486 }
487}
488```
489
490- [ ] **Step 3: The heading commands**
491
492```swift
493import Foundation
494
495// Heading-line commands, matched to Emacs 31.1 / Org 9.8.7 by the oracle tests.
496
497/// The heading whose line holds `offset`, for commands that act only on heading lines (org's
498/// M-left, M-right, S-up and S-down do something else elsewhere).
499func headingOnLine(at offset: Int, in tree: OrgTree) -> SyntaxNode? {
500 guard let heading = entryHeading(at: offset, in: tree) else { return nil }
501 let line = HeadingLine(heading)
502 return offset >= line.start && offset <= line.contentEnd ? heading : nil
503}
504
505/// The heading of the entry holding `offset`, as `org-back-to-heading`; nil before the first
506/// heading.
507func entryHeading(at offset: Int, in tree: OrgTree) -> SyntaxNode? {
508 let root = tree.root
509 let position = offset >= root.range.upperBound ? max(0, root.range.upperBound - 1) : offset
510 var node = root
511 var heading: SyntaxNode?
512 while let child = node.child(containing: position), child.kind == .section {
513 heading = child.firstChild(.heading)
514 node = child
515 }
516 return heading
517}
518
519/// The parts of a heading line, in offsets of the whole text.
520struct HeadingLine {
521 let start: Int
522 /// End of the line's content, before its line break.
523 let contentEnd: Int
524 let stars: Range<Int>
525 let todo: SyntaxToken?
526 let priority: SyntaxToken?
527 let tags: SyntaxToken?
528 /// Where the title begins: past the stars, keyword, cookie and the blanks after them.
529 let titleStart: Int
530
531 init(_ heading: SyntaxNode) {
532 let tokens = heading.tokens
533 start = heading.range.lowerBound
534 contentEnd = tokens.first { $0.kind == .newline }?.range.lowerBound ?? heading.range.upperBound
535 stars = tokens.first { $0.kind == .stars }?.range ?? start..<start
536 todo = tokens.first { $0.kind == .todoKeyword }
537 priority = tokens.first { $0.kind == .priority }
538 tags = tokens.first { $0.kind == .tags }
539 var at = start
540 for child in heading.green.children {
541 guard case .token(let token) = child, [.stars, .whitespace, .todoKeyword, .priority].contains(token.kind) else { break }
542 at += token.length
543 }
544 titleStart = min(at, contentEnd)
545 }
546
547 var level: Int { stars.count }
548}
549
550/// One line of text being edited, with a caret that moves the way Emacs moves point and
551/// markers for each kind of edit, so commands land the caret where Emacs does.
552struct LineBuffer {
553 var text: NSMutableString
554 /// Relative to the line start; nil when the caret is elsewhere in the document.
555 var caret: Int?
556
557 init(_ line: String, caret: Int?) {
558 text = NSMutableString(string: line)
559 self.caret = caret
560 }
561
562 var string: String { text as String }
563 var length: Int { text.length }
564
565 private mutating func edit(_ range: Range<Int>, _ replacement: String) -> Int {
566 text.replaceCharacters(in: NSRange(range), with: replacement)
567 return (replacement as NSString).length - range.count
568 }
569
570 /// `replace-match`: a caret strictly inside moves to the start; at or after the end it
571 /// shifts with the text.
572 mutating func replace(_ range: Range<Int>, with replacement: String) {
573 let delta = edit(range, replacement)
574 guard let position = caret else { return }
575 if position >= range.upperBound, position > range.lowerBound {
576 caret = position + delta
577 } else if position > range.lowerBound {
578 caret = range.lowerBound
579 }
580 }
581
582 /// Deleting `range`, then `insert-before-markers`: a caret anywhere from the start to the
583 /// end of the range ends up after the new text.
584 mutating func replaceBeforeMarkers(_ range: Range<Int>, with replacement: String) {
585 let delta = edit(range, replacement)
586 guard let position = caret else { return }
587 if position > range.upperBound {
588 caret = position + delta
589 } else if position >= range.lowerBound {
590 caret = range.lowerBound + (replacement as NSString).length
591 }
592 }
593
594 /// `insert` under `save-excursion`: a caret at the insertion point stays before the text.
595 mutating func insert(_ string: String, at position: Int) {
596 let delta = edit(position..<position, string)
597 if let caret, caret > position { self.caret = caret + delta }
598 }
599
600 /// `insert` at point: the caret moves past the text.
601 mutating func insertAtCaret(_ string: String) {
602 guard let position = caret else { return }
603 let delta = edit(position..<position, string)
604 caret = position + delta
605 }
606
607 /// Offset of display column `target` in the line, as `move-to-column`.
608 func offset(ofColumn target: Int) -> Int {
609 var column = 0
610 var offset = 0
611 for character in string {
612 if column >= target { break }
613 column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character)
614 offset += String(character).utf16.count
615 }
616 return offset
617 }
618}
619
620extension HeadingLine {
621 /// Applies `edit` to the heading line in `context` and maps the caret: on the line it's
622 /// placed by `edit`; after the line it shifts by the change in length.
623 func commit(_ context: EditContext, _ edit: (inout LineBuffer) -> Void) -> CommandStep {
624 let old = (context.text as NSString).substring(with: NSRange(start..<contentEnd))
625 let caretOnLine = context.caret >= start && context.caret <= contentEnd
626 var buffer = LineBuffer(old, caret: caretOnLine ? context.caret - start : nil)
627 edit(&buffer)
628 let new = buffer.string
629 let delta = (new as NSString).length - (old as NSString).length
630 let caret = buffer.caret.map { $0 + start } ?? (context.caret > contentEnd ? context.caret + delta : context.caret)
631 guard new != old else { return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret])) }
632 return .commit(EditResult(
633 baseRevision: context.revision,
634 edits: [TextEdit(range: start..<contentEnd, replacement: new)],
635 selection: [caret..<caret]
636 ))
637 }
638
639 /// Offsets relative to the line start.
640 func local(_ range: Range<Int>) -> Range<Int> { (range.lowerBound - start)..<(range.upperBound - start) }
641}
642
643// MARK: - Tags
644
645/// `org--align-tags-here` with `org-tags-column` -77: tags end at column 77, or sit one blank
646/// after the title when it is too long. Nothing changes when they are already there.
647///
648/// Commands align under `save-excursion`, where the caret is a marker: in the blanks before
649/// the tags it ends up where they start. Aligning tags directly (`preservingColumn`) keeps the
650/// caret's column instead, as org does for point.
651func alignTags(_ buffer: inout LineBuffer, tagsColumn: Int = -77, preservingColumn: Bool = false) {
652 let line = buffer.string
653 guard let match = line.range(of: "[ \\t]+(:[[:alnum:]_@#%]+)+:[ \\t]*$", options: .regularExpression) else { return }
654 let matched = String(line[match])
655 let tags = matched.trimmingCharacters(in: .whitespaces)
656 let blankStart = (String(line[..<match.lowerBound]) as NSString).length
657 let tagsStart = blankStart + (matched as NSString).range(of: tags).location
658 let prefixColumn = column(of: String(line[..<match.lowerBound]))
659 let currentColumn = column(of: (line as NSString).substring(to: tagsStart))
660 let target = tagsColumn >= 0 ? tagsColumn : abs(tagsColumn) - displayWidth(tags)
661 let newColumn = max(target, prefixColumn + 1)
662 guard newColumn != currentColumn else { return }
663 let inBlanks = buffer.caret.map { $0 > blankStart && $0 <= tagsStart } ?? false
664 let caretColumn: Int? = inBlanks && preservingColumn ? column(of: (line as NSString).substring(to: buffer.caret!)) : nil
665 let old = buffer.caret
666 buffer.text.replaceCharacters(in: NSRange(blankStart..<tagsStart), with: String(repeating: " ", count: newColumn - prefixColumn))
667 let delta = (newColumn - prefixColumn) - (tagsStart - blankStart)
668 if let caretColumn {
669 buffer.caret = buffer.offset(ofColumn: caretColumn)
670 } else if inBlanks {
671 buffer.caret = blankStart
672 } else if let old, old > tagsStart {
673 buffer.caret = old + delta
674 }
675}
676
677/// Display column at the end of `text`, starting from column 0.
678func column(of text: String) -> Int {
679 var column = 0
680 for character in text {
681 column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character)
682 }
683 return column
684}
685
686// MARK: - TODO
687
688public struct TodoCycle: OrgCommand {
689 public init() {}
690 public var id: String { "org.todo.cycle" }
691 public var title: String { "Cycle TODO State" }
692
693 public func applies(in context: EditContext) -> Bool {
694 entryHeading(at: context.caret, in: context.tree) != nil
695 }
696
697 /// No keyword, then each keyword of its sequence in order, then no keyword again.
698 static func next(after current: String?, in settings: OrgSettings) -> String? {
699 guard let current else {
700 guard let first = settings.todoSequences.first else { return nil }
701 return (first.active + first.done).first?.name
702 }
703 for sequence in settings.todoSequences {
704 let names = (sequence.active + sequence.done).map(\.name)
705 if let index = names.firstIndex(of: current) {
706 return index + 1 < names.count ? names[index + 1] : nil
707 }
708 }
709 return nil
710 }
711
712 /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are
713 /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags
714 /// are aligned.
715 public func run(in context: EditContext) -> CommandStep {
716 guard let heading = entryHeading(at: context.caret, in: context.tree) else {
717 return .failed("Before first headline")
718 }
719 let line = HeadingLine(heading)
720 let next = Self.next(after: line.todo?.text, in: context.tree.settings)
721 return line.commit(context) { buffer in
722 let text = buffer.string as NSString
723 let regionStart = line.stars.upperBound - line.start
724 var regionEnd = regionStart
725 while regionEnd < text.length, text.character(at: regionEnd) == 0x20 { regionEnd += 1 }
726 if let todo = line.todo {
727 regionEnd = line.local(todo.range).upperBound
728 var blanks = regionEnd
729 while blanks < text.length, text.character(at: blanks) == 0x20 { blanks += 1 }
730 if blanks > regionEnd {
731 regionEnd = blanks
732 } else if text.substring(from: regionEnd).allSatisfy({ $0 == " " || $0 == "\t" }) {
733 regionEnd = text.length
734 }
735 }
736 buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ")
737 alignTags(&buffer)
738 }
739 }
740}
741
742// MARK: - Priority
743
744struct PriorityChange {
745 let up: Bool
746
747 func run(in context: EditContext) -> CommandStep {
748 guard let heading = headingOnLine(at: context.caret, in: context.tree) else {
749 return .failed("Not on a heading")
750 }
751 let line = HeadingLine(heading)
752 let priorities = context.tree.settings.priorities
753 guard let highest = Self.value(priorities.highest), let lowest = Self.value(priorities.lowest) else {
754 return .failed("Unsupported priority range")
755 }
756 let numeric = Int(priorities.highest) != nil
757 let current = line.priority.flatMap { Self.value(String($0.text.dropFirst(2).dropLast())) }
758 // From no cookie, the first press sets the default, as `org-priority-start-cycle-with-default`.
759 var new: Int?
760 if let current {
761 new = up ? current - 1 : current + 1
762 if let value = new, value < highest || value > lowest { new = nil }
763 } else {
764 new = Self.value(priorities.default)
765 }
766 let cookie = new.map { "[#\(Self.label($0, numeric: numeric))]" }
767 return line.commit(context) { buffer in
768 if let existing = line.priority {
769 let range = line.local(existing.range)
770 if let cookie {
771 buffer.replace((range.lowerBound + 2)..<(range.upperBound - 1), with: String(cookie.dropFirst(2).dropLast()))
772 } else {
773 // The cookie and one following space, as `org-priority-regexp` group 1.
774 let text = buffer.string as NSString
775 let space = range.upperBound < text.length && text.character(at: range.upperBound) == 0x20 ? 1 : 0
776 buffer.replace(range.lowerBound..<(range.upperBound + space), with: "")
777 }
778 } else if let cookie {
779 if let todo = line.todo {
780 buffer.insert(" " + cookie, at: line.local(todo.range).upperBound)
781 } else {
782 buffer.insert(cookie + " ", at: line.titleStart - line.start)
783 }
784 }
785 alignTags(&buffer)
786 }
787 }
788
789 /// A priority as a number: a letter's code point, or the number itself.
790 static func value(_ text: String) -> Int? {
791 if let number = Int(text) { return number }
792 guard text.unicodeScalars.count == 1, let scalar = text.unicodeScalars.first else { return nil }
793 return Int(scalar.value)
794 }
795
796 static func label(_ value: Int, numeric: Bool) -> String {
797 numeric ? String(value) : String(Character(Unicode.Scalar(UInt32(value))!))
798 }
799}
800
801public struct PriorityUp: OrgCommand {
802 public init() {}
803 public var id: String { "org.priority.up" }
804 public var title: String { "Raise Priority" }
805 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
806 public func run(in context: EditContext) -> CommandStep { PriorityChange(up: true).run(in: context) }
807}
808
809public struct PriorityDown: OrgCommand {
810 public init() {}
811 public var id: String { "org.priority.down" }
812 public var title: String { "Lower Priority" }
813 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
814 public func run(in context: EditContext) -> CommandStep { PriorityChange(up: false).run(in: context) }
815}
816
817// MARK: - Promote and demote
818
819public struct PromoteHeading: OrgCommand {
820 public init() {}
821 public var id: String { "org.heading.promote" }
822 public var title: String { "Promote Heading" }
823 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
824
825 public func run(in context: EditContext) -> CommandStep {
826 guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
827 let line = HeadingLine(heading)
828 guard line.level > 1 else { return .failed("Cannot promote to level 0") }
829 return line.commit(context) { buffer in
830 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ")
831 alignTags(&buffer)
832 fixPositionAfterPromote(&buffer, settings: context.tree.settings)
833 }
834 }
835}
836
837public struct DemoteHeading: OrgCommand {
838 public init() {}
839 public var id: String { "org.heading.demote" }
840 public var title: String { "Demote Heading" }
841 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
842
843 public func run(in context: EditContext) -> CommandStep {
844 guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
845 let line = HeadingLine(heading)
846 return line.commit(context) { buffer in
847 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ")
848 alignTags(&buffer)
849 fixPositionAfterPromote(&buffer, settings: context.tree.settings)
850 }
851 }
852}
853
854/// `org-fix-position-after-promote`: a caret right after the stars or the keyword steps over
855/// the following space, or adds one at the end of the line.
856func fixPositionAfterPromote(_ buffer: inout LineBuffer, settings: OrgSettings) {
857 guard let caret = buffer.caret else { return }
858 let tree = OrgParser.parse(buffer.string + "\n", defaults: settings)
859 guard let heading = tree.root.firstChild(.section)?.firstChild(.heading) else { return }
860 let line = HeadingLine(heading)
861 let anchors = [line.stars.upperBound, line.todo?.range.upperBound].compactMap { $0 }
862 guard anchors.contains(caret) else { return }
863 if caret == buffer.length {
864 buffer.insertAtCaret(" ")
865 } else if buffer.text.character(at: caret) == 0x20 {
866 buffer.caret = caret + 1
867 }
868}
869```
870
871```diff
872diff --git a/Sources/OrgCore/Parser/Incremental.swift b/Sources/OrgCore/Parser/Incremental.swift
873index 9f8347a..976b9b4 100644
874--- a/Sources/OrgCore/Parser/Incremental.swift
875+++ b/Sources/OrgCore/Parser/Incremental.swift
876@@ -40,7 +40,7 @@ extension OrgParser {
877 _ old: OrgTree, oldText: String, edit: TextEdit, defaults: OrgSettings = .default
878 ) -> (tree: OrgTree, strategy: ReparseStrategy) {
879 let newText = edit.apply(to: oldText)
880- let context = EditContext(oldText: oldText, newText: newText, edit: edit)
881+ let context = ReparseContext(oldText: oldText, newText: newText, edit: edit)
882 if context.touchesSettings() { return (parse(newText, defaults: defaults), .full) }
883 if let tree = context.reparseElement(old) { return (tree, .element) }
884 if let tree = context.reparseRegion(old) { return (tree, .region) }
885@@ -49,7 +49,7 @@ extension OrgParser {
886 }
887 }
888
889-private struct EditContext {
890+private struct ReparseContext {
891 let oldText: String
892 let newText: String
893 let edit: TextEdit
894```
895
896- [ ] **Step 4: Run the oracle, including a corpus; commit**
897
898Run: `swift test --filter HeadingOracleTests`, then `ORGSTAR_ORACLE_CORPUS=<folder> swift test --filter headingCommandsMatchEmacsOnACorpus` for each local folder.
899
900```bash
901git commit -m "Add the command model, heading commands and the Emacs oracle"
902```
903
904---
905
906### Task 3: Running commands
907
908**Files:** `Sources/OrgDocument/DocumentState.swift`, `Sources/OrgEditorAppKit/OrgEditor.swift`, `Tests/OrgDocumentTests/DocumentStateTests.swift`, `Tests/OrgEditorAppKitTests/EditorTests.swift`
909
910```diff
911diff --git a/Sources/OrgDocument/DocumentState.swift b/Sources/OrgDocument/DocumentState.swift
912index 614dd52..849c264 100644
913--- a/Sources/OrgDocument/DocumentState.swift
914+++ b/Sources/OrgDocument/DocumentState.swift
915@@ -1,3 +1,4 @@
916+import Foundation
917 import OrgCore
918
919 /// One open file: its text, tree, revision, undo history, and the bytes last read from or
920@@ -95,6 +96,24 @@ public struct DocumentState: Sendable {
921 return inverse
922 }
923
924+ // MARK: - Commands
925+
926+ /// Runs `command` at `selection` and applies its edits as one undo step.
927+ public mutating func run(
928+ _ command: any OrgCommand, selection: [Range<Int>], now: Date = Date(),
929+ calendar: Calendar = .current, answers: [String: String] = [:]
930+ ) throws -> CommandStep {
931+ let context = EditContext(
932+ revision: revision, text: text, tree: tree, selection: selection,
933+ now: now, calendar: calendar, answers: answers
934+ )
935+ let step = command.run(in: context)
936+ if case .commit(let result) = step, !result.edits.isEmpty {
937+ try apply(result.edits, baseRevision: result.baseRevision)
938+ }
939+ return step
940+ }
941+
942 // MARK: - Disk
943
944 /// The file on disk now holds `bytes`. Reloads an unedited buffer, merges into an edited
945diff --git a/Sources/OrgEditorAppKit/OrgEditor.swift b/Sources/OrgEditorAppKit/OrgEditor.swift
946index 311f89d..2ccfffd 100644
947--- a/Sources/OrgEditorAppKit/OrgEditor.swift
948+++ b/Sources/OrgEditorAppKit/OrgEditor.swift
949@@ -168,6 +168,53 @@ public final class OrgEditor: NSObject {
950 return outcome
951 }
952
953+ // MARK: - Commands
954+
955+ /// Called with messages commands report, such as why one couldn't run here.
956+ public var onMessage: ((String) -> Void)?
957+
958+ /// Runs `command` at the selection. Edits go through the text view, as typing does, so
959+ /// they form one undo step and reach the document through the same path.
960+ @discardableResult
961+ public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
962+ let selected = textView.selectedRange()
963+ let context = EditContext(
964+ revision: document.revision, text: document.text, tree: document.tree,
965+ selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers
966+ )
967+ let step = command.run(in: context)
968+ switch step {
969+ case .commit(let result):
970+ guard textView.isEditable || result.edits.isEmpty else {
971+ onMessage?("This file is read-only.")
972+ return .failed("read-only")
973+ }
974+ if !result.edits.isEmpty, let storage = textView.textStorage {
975+ // Each command is its own undo step, not merged with typing around it.
976+ textView.breakUndoCoalescing()
977+ textView.undoManager?.beginUndoGrouping()
978+ for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
979+ let range = NSRange(edit.range)
980+ guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue }
981+ storage.replaceCharacters(in: range, with: edit.replacement)
982+ textView.didChangeText()
983+ }
984+ textView.undoManager?.endUndoGrouping()
985+ textView.breakUndoCoalescing()
986+ }
987+ if let selection = result.selection?.first {
988+ setCaret(selection.lowerBound)
989+ if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
990+ }
991+ for case .message(let text) in result.effects { onMessage?(text) }
992+ case .failed(let message):
993+ onMessage?(message)
994+ case .prompt:
995+ break
996+ }
997+ return step
998+ }
999+
1000 // MARK: - Disk
1001
1002 /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
1003diff --git a/Tests/OrgDocumentTests/DocumentStateTests.swift b/Tests/OrgDocumentTests/DocumentStateTests.swift
1004index 8614737..1ad9293 100644
1005--- a/Tests/OrgDocumentTests/DocumentStateTests.swift
1006+++ b/Tests/OrgDocumentTests/DocumentStateTests.swift
1007@@ -115,3 +115,25 @@ struct ViewStateTests {
1008 #expect(ViewState(folds: [0, 4, 9]).pruned(to: tree).folds == [0, 9])
1009 }
1010 }
1011+
1012+struct CommandRunTests {
1013+ @Test func runAppliesEditsAsOneStep() throws {
1014+ var doc = state("* a\n")
1015+ let step = try doc.run(TodoCycle(), selection: [2..<2])
1016+ guard case .commit(let result) = step else {
1017+ Issue.record("expected a commit")
1018+ return
1019+ }
1020+ #expect(doc.text == "* TODO a\n")
1021+ #expect(result.selection == [7..<7])
1022+ #expect(doc.tree.green == OrgParser.parse(doc.text).green)
1023+ _ = doc.undo()
1024+ #expect(doc.text == "* a\n")
1025+ }
1026+
1027+ @Test func failuresChangeNothing() throws {
1028+ var doc = state("text\n")
1029+ #expect(try doc.run(TodoCycle(), selection: [0..<0]) == .failed("Before first headline"))
1030+ #expect(doc.revision == 0)
1031+ }
1032+}
1033diff --git a/Tests/OrgEditorAppKitTests/EditorTests.swift b/Tests/OrgEditorAppKitTests/EditorTests.swift
1034index cdf9b2d..5b1d469 100644
1035--- a/Tests/OrgEditorAppKitTests/EditorTests.swift
1036+++ b/Tests/OrgEditorAppKitTests/EditorTests.swift
1037@@ -320,3 +320,36 @@ struct HangingIndentTests {
1038 #expect(OrgEditor.hangingColumns(line) == expected)
1039 }
1040 }
1041+
1042+@MainActor
1043+struct CommandTests {
1044+ @Test func performEditsThroughTheTextView() {
1045+ let h = Harness("* a :t:\nbody\n")
1046+ var messages: [String] = []
1047+ h.editor.onMessage = { messages.append($0) }
1048+ h.caret(at: 2)
1049+ h.editor.perform(PriorityUp())
1050+ #expect(h.string.hasPrefix("* [#B] a "))
1051+ #expect(h.caret == 2)
1052+ h.checkInSync()
1053+ // Undo groups by event: let the run loop close the first command's group, as a key
1054+ // press would.
1055+ RunLoop.current.run(until: Date())
1056+ h.editor.perform(TodoCycle())
1057+ #expect(h.string.hasPrefix("* TODO [#B] a "))
1058+ RunLoop.current.run(until: Date())
1059+ h.textView.undoManager?.undo()
1060+ #expect(h.string.hasPrefix("* [#B] a "))
1061+ h.checkInSync()
1062+ h.caret(at: h.offset(of: "body"))
1063+ h.editor.perform(PromoteHeading())
1064+ #expect(messages == ["Not on a heading"])
1065+ }
1066+
1067+ @Test func readOnlyEditorsRefuseEdits() {
1068+ let editor = OrgEditor(document: DocumentState(bytes: Array("* a\n".utf8)), editable: false)
1069+ editor.textView.setSelectedRange(NSRange(location: 2, length: 0))
1070+ #expect(editor.perform(TodoCycle()) == .failed("read-only"))
1071+ #expect(editor.document.text == "* a\n")
1072+ }
1073+}
1074```
1075
1076Note: `NSUndoManager` groups by run-loop event; tests spin the run loop between commands, as key presses would.
1077
1078Run `swift test`; commit: `git commit -m "Run commands from documents and the editor"`.