krz/orgstar

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

docs/plans/2026-10-05-commands.md

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/docs/plans/2026-10-05-commands.md rendered · source · history · blame · raw

1078 lines · 49111 bytes

   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"`.