krz/orgstar

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

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

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

1078 lines · 49111 bytes

7 symbols in this file

Command Model Implementation Plan

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.

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.

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.

Tech Stack: Swift 6.2 tools, Swift Testing, Emacs 31.1 with Org 9.8.7 for the oracle.

Spec: docs/design.md, "Commands and keymaps" and "Testing" (command oracle).

Global Constraints

  • Commands never read the clock, the file system or global state; the same context gives the same result.
  • 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.
  • 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).
  • Commands that org binds only on heading lines (M-left/right, S-up/down) apply only on heading lines.

What the oracle found

  • 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.
  • org-todo, org-priority and promote/demote all realign tags to column 77.
  • 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.
  • 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.
  • 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.

Corpus check: every sampled heading (up to 400 per folder) from three real folders matches Emacs for all five commands.

File structure

File Responsibility
Sources/OrgCore/Parser/Lines.swift, Parser.swift Heading needs a space after the stars; keyword needs a space after it
Sources/OrgCore/Commands/Command.swift EditContext, Prompt, Effect, EditResult, CommandStep, OrgCommand, Commands
Sources/OrgCore/Commands/HeadingCommands.swift LineBuffer, HeadingLine, tag alignment, the five commands
Sources/OrgDocument/DocumentState.swift run(_:selection:now:calendar:answers:)
Sources/OrgEditorAppKit/OrgEditor.swift perform(_:now:answers:), onMessage
Tests/OrgCoreTests/EmacsOracle.swift The oracle and runCommand
Tests/OrgCoreTests/HeadingCommandTests.swift Unit tests, oracle over variants, oracle over a corpus

Task 1: Match org's heading rules

Files: Sources/OrgCore/Parser/Lines.swift, Sources/OrgCore/Parser/Parser.swift, Tests/OrgCoreTests/LinesTests.swift, Tests/OrgCoreTests/ParserSectionTests.swift

diff --git a/Sources/OrgCore/Parser/Lines.swift b/Sources/OrgCore/Parser/Lines.swift
index 5e9a077..bf3919a 100644
--- a/Sources/OrgCore/Parser/Lines.swift
+++ b/Sources/OrgCore/Parser/Lines.swift
@@ -73,10 +73,11 @@ func classifyLine(_ line: Substring) -> ClassifiedLine {
 private func lineClass(_ rest: Substring, columnZero: Bool) -> LineClass {
     let trimmed = rest.trimmingTrailingWhitespace
 
+    // As org's `org-outline-regexp`: stars and then a space. A lone `*`, or stars before a tab,
+    // is not a heading.
     if columnZero, rest.first == "*" {
         let stars = rest.prefix { $0 == "*" }
-        let after = rest.dropFirst(stars.count)
-        if after.isEmpty || after.first == " " || after.first == "\t" {
+        if rest.dropFirst(stars.count).first == " " {
             return .heading(level: stars.count)
         }
     }
diff --git a/Sources/OrgCore/Parser/Parser.swift b/Sources/OrgCore/Parser/Parser.swift
index 2f2addf..9d0e2fa 100644
--- a/Sources/OrgCore/Parser/Parser.swift
+++ b/Sources/OrgCore/Parser/Parser.swift
@@ -390,8 +390,10 @@ struct Parser {
         builder.token(.stars, stars)
         rest = whitespace(rest.dropFirst(stars.count))
 
+        // As org: a keyword counts only before a space or the end of the line, not a tab.
         let word = rest.prefix { $0 != " " && $0 != "\t" }
-        if !word.isEmpty, settings.todoKeywordNames.contains(String(word)) {
+        let afterWord = rest.dropFirst(word.count).first
+        if !word.isEmpty, afterWord == nil || afterWord == " ", settings.todoKeywordNames.contains(String(word)) {
             builder.token(.todoKeyword, word)
             rest = whitespace(rest.dropFirst(word.count))
         }
diff --git a/Tests/OrgCoreTests/LinesTests.swift b/Tests/OrgCoreTests/LinesTests.swift
index d9f0cc6..fdd586a 100644
--- a/Tests/OrgCoreTests/LinesTests.swift
+++ b/Tests/OrgCoreTests/LinesTests.swift
@@ -20,7 +20,9 @@ struct LinesTests {
         ("   \t", .blank),
         ("* a", .heading(level: 1)),
         ("*** ", .heading(level: 3)),
-        ("*", .heading(level: 1)),
+        ("*", .plain),
+        ("*\ttab", .plain),
+        ("* ", .heading(level: 1)),
         ("*bold* text", .plain),
         (" * a", .listItem),
         ("#+BEGIN_SRC sh :results output", .blockBegin(name: "src")),
diff --git a/Tests/OrgCoreTests/ParserSectionTests.swift b/Tests/OrgCoreTests/ParserSectionTests.swift
index a8ec412..826f236 100644
--- a/Tests/OrgCoreTests/ParserSectionTests.swift
+++ b/Tests/OrgCoreTests/ParserSectionTests.swift
@@ -43,6 +43,12 @@ struct ParserSectionTests {
         #expect(todo == ["NEXT"])
     }
 
+    @Test func todoKeywordNeedsASpaceAfterIt() {
+        #expect(tokens(of: .heading, in: "* TODO a\n").contains { $0.kind == .todoKeyword })
+        #expect(tokens(of: .heading, in: "* TODO\n").contains { $0.kind == .todoKeyword })
+        #expect(!tokens(of: .heading, in: "* TODO\ta\n").contains { $0.kind == .todoKeyword })
+    }
+
     @Test func priorityNeedsValidValueAndSpace() {
         #expect(tokens(of: .heading, in: "* [#B] x\n").contains { $0.kind == .priority })
         #expect(tokens(of: .heading, in: "* [#10] x\n").contains { $0.kind == .priority })

Run swift test, the reparse gate and the corpus round trip; commit: git commit -m "Match org's heading and keyword rules".


Task 2: Command model, heading commands, oracle

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).

  • Step 1: The oracle and the tests
import Foundation
@testable import OrgCore

/// Runs cases through `emacs -Q --batch` with Org's settings pinned, so commands can be
/// compared with Emacs byte for byte. One Emacs process runs a whole batch.
///
/// Pinned versions: Emacs 31.1, Org 9.8.7 (design, "Testing"). `ORGSTAR_EMACS` overrides the
/// executable.
enum EmacsOracle {
    struct Case: Codable {
        var text: String
        /// Emacs point: 1 plus the number of characters (code points) before it.
        var point: Int
        /// An Emacs Lisp form to run with point there.
        var form: String
    }

    struct Result: Codable, Equatable {
        var text: String
        var point: Int
        /// The error message, or empty.
        var error: String
    }

    static let script = #"""
    ;;; -*- lexical-binding: t -*-
    (require 'org)
    (require 'json)
    (setq org-todo-keywords '((sequence "TODO" "DONE"))
          org-log-done nil
          org-log-repeat nil
          org-adapt-indentation nil
          org-tags-column -77
          org-priority-highest ?A
          org-priority-lowest ?C
          org-priority-default ?B
          indent-tabs-mode nil)
    (let* ((input (with-temp-buffer
                    (let ((coding-system-for-read 'utf-8-unix))
                      (insert-file-contents (getenv "ORACLE_INPUT")))
                    (json-parse-buffer :object-type 'alist :array-type 'list)))
           (results nil))
      (dolist (c input)
        (with-temp-buffer
          (insert (alist-get 'text c))
          (org-mode)
          (goto-char (alist-get 'point c))
          ;; As an interactive call, not a repeat: some commands cycle differently on repeats.
          (setq this-command 'orgstar-oracle last-command nil)
          (let ((err (condition-case e
                         (progn (eval (car (read-from-string (alist-get 'form c))) t) "")
                       (error (error-message-string e)))))
            (push `((text . ,(buffer-substring-no-properties (point-min) (point-max)))
                    (point . ,(point))
                    (error . ,err))
                  results))))
      (let ((coding-system-for-write 'utf-8-unix))
        (with-temp-file (getenv "ORACLE_OUTPUT")
          (insert (json-encode (nreverse results))))))
    """#

    static var executable: String { ProcessInfo.processInfo.environment["ORGSTAR_EMACS"] ?? "emacs" }

    static var isAvailable: Bool {
        (try? run(["--version"]))?.contains("GNU Emacs") ?? false
    }

    static func run(_ cases: [Case]) throws -> [Result] {
        let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-oracle-\(UUID().uuidString)")
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
        defer { try? FileManager.default.removeItem(at: folder) }
        let scriptURL = folder.appendingPathComponent("oracle.el")
        let input = folder.appendingPathComponent("input.json")
        let output = folder.appendingPathComponent("output.json")
        try script.write(to: scriptURL, atomically: true, encoding: .utf8)
        try JSONEncoder().encode(cases).write(to: input)
        _ = try run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_INPUT": input.path, "ORACLE_OUTPUT": output.path])
        return try JSONDecoder().decode([Result].self, from: Data(contentsOf: output))
    }

    @discardableResult
    static func run(_ arguments: [String], environment: [String: String] = [:]) throws -> String {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
        process.arguments = [executable] + arguments
        process.environment = ProcessInfo.processInfo.environment.merging(environment) { $1 }
        let pipe = Pipe()
        process.standardOutput = pipe
        process.standardError = pipe
        try process.run()
        let data = pipe.fileHandleForReading.readDataToEndOfFile()
        process.waitUntilExit()
        return String(decoding: data, as: UTF8.self)
    }

    /// UTF-16 offset to Emacs point.
    static func point(_ offset: Int, in text: String) -> Int {
        let index = String.Index(utf16Offset: offset, in: text)
        return text.unicodeScalars[..<index].count + 1
    }

    /// Emacs point to UTF-16 offset.
    static func offset(_ point: Int, in text: String) -> Int {
        text.unicodeScalars.prefix(point - 1).reduce(0) { $0 + $1.utf16.count }
    }

    /// UTF-16 offsets of every unicode scalar boundary, including the end.
    static func positions(_ text: String) -> [Int] {
        var positions = [0]
        var offset = 0
        for scalar in text.unicodeScalars {
            offset += scalar.utf16.count
            positions.append(offset)
        }
        return positions
    }
}

/// Runs a command the way an editor would: build a context, run, apply the edits.
func runCommand(_ command: any OrgCommand, _ text: String, caret: Int) -> (text: String, caret: Int, failure: String?) {
    let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret])
    switch command.run(in: context) {
    case .commit(let result):
        var new = text
        for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
        return (new, result.selection?.first?.lowerBound ?? caret, nil)
    case .failed(let message):
        return (text, caret, message)
    case .prompt(let prompt):
        return (text, caret, "prompt: \(prompt.key)")
    }
}
import Foundation
import Testing
@testable import OrgCore

struct HeadingCommandTests {
    @Test func todoCycle() {
        let cycle = TodoCycle()
        #expect(runCommand(cycle, "* a\n", caret: 2).text == "* TODO a\n")
        #expect(runCommand(cycle, "* TODO a\n", caret: 2).text == "* DONE a\n")
        #expect(runCommand(cycle, "* DONE a\n", caret: 2).text == "* a\n")
        #expect(runCommand(cycle, "* TODO\n", caret: 2).text == "* DONE \n")
        #expect(runCommand(cycle, "#+TODO: A B | C\n* B x\n", caret: 18).text == "#+TODO: A B | C\n* C x\n")
        #expect(runCommand(cycle, "text\n", caret: 1).failure != nil)
    }

    @Test func caretMovesToTheTitleWhenBeforeIt() {
        #expect(runCommand(TodoCycle(), "* a\n", caret: 2).caret == 7)
        #expect(runCommand(TodoCycle(), "* TODO abc\nbody\n", caret: 13).caret == 13)
    }

    @Test func priorities() {
        #expect(runCommand(PriorityUp(), "* a\n", caret: 2).text == "* [#B] a\n")
        #expect(runCommand(PriorityUp(), "* [#A] a\n", caret: 2).text == "* a\n")
        #expect(runCommand(PriorityDown(), "* TODO a\n", caret: 2).text == "* TODO [#B] a\n")
        #expect(runCommand(PriorityDown(), "* [#C] a\n", caret: 2).text == "* a\n")
    }

    @Test func promoteAndDemote() {
        #expect(runCommand(DemoteHeading(), "* a\nbody\n", caret: 2).text == "** a\nbody\n")
        #expect(runCommand(PromoteHeading(), "** a\n", caret: 3).text == "* a\n")
        #expect(runCommand(PromoteHeading(), "* a\n", caret: 0).failure != nil)
        #expect(runCommand(DemoteHeading(), "* a\nbody\n", caret: 6).failure != nil)
    }

    @Test func tagsAlignToColumn77() {
        let result = runCommand(PriorityUp(), "* TODO a :t:\n", caret: 2).text
        #expect(result == "* TODO [#B] a" + String(repeating: " ", count: 77 - 13 - 3) + ":t:\n")
    }
}

/// Every heading variant, every caret position, every heading command, against Emacs.
/// Fails when Emacs is missing unless `ORGSTAR_SKIP_ORACLE` is set.
struct HeadingOracleTests {
    static let variants = [
        "* a\nbody\n", "* TODO a\n", "* DONE a\n", "** [#B] two words :tag:\n", "* TODO [#A] x :a:b:\nbody\n",
        "* \n", "* TODO\n", "*** title with 日本 :t:\n", "* a😀 b\n", "* a  :t:\n", "text\n* a\n", "*  a\n",
        "* TODO  a\n", "* a\r\n",
        "#+TODO: NEXT WAIT | DONE CANCELED\n* WAIT a :t:\n",
        "* " + String(repeating: "long ", count: 16) + "title :t:\n",
        "* TODO\ttab\n",
    ]

    /// Commands, their Emacs forms, and whether they only act on heading lines.
    static let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [
        (TodoCycle(), "(org-todo)", false),
        (PriorityUp(), "(org-priority-up)", true),
        (PriorityDown(), "(org-priority-down)", true),
        (PromoteHeading(), "(org-do-promote)", true),
        (DemoteHeading(), "(org-do-demote)", true),
    ]

    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
    func headingCommandsMatchEmacs() throws {
        try compare(Self.variants.map { ($0, EmacsOracle.positions($0)) })
    }

    /// Real headings: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every command on up to 400 heading
    /// lines from the folder's files, with the caret at the line start, the title start and
    /// the line end.
    @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
    func headingCommandsMatchEmacsOnACorpus() throws {
        let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
        var samples: [(String, [Int])] = []
        let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
            .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
        for file in files where samples.count < 400 {
            guard let text = try? String(contentsOf: file, encoding: .utf8) else { continue }
            var settings = ""
            for line in text.components(separatedBy: "\n") where line.uppercased().hasPrefix("#+TODO:") || line.uppercased().hasPrefix("#+SEQ_TODO:") {
                settings += line + "\n"
            }
            for line in text.components(separatedBy: "\n") where line.hasPrefix("*") && samples.count < 400 {
                let sample = settings + line + "\n"
                guard let heading = entryHeading(at: (sample as NSString).length - 1, in: OrgParser.parse(sample)) else { continue }
                let parts = HeadingLine(heading)
                samples.append((sample, [parts.start, parts.titleStart, parts.contentEnd]))
            }
        }
        try compare(samples)
    }

    func compare(_ inputs: [(text: String, carets: [Int])]) throws {
        try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip")
        var cases: [EmacsOracle.Case] = []
        var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
        for (text, carets) in inputs {
            for offset in carets {
                let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil
                for (command, form, headingLineOnly) in Self.commands where onHeadingLine || !headingLineOnly {
                    cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form))
                    let result = runCommand(command, text, caret: offset)
                    ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
                }
            }
        }
        let theirs = try EmacsOracle.run(cases)
        var mismatches = 0
        for (mine, emacs) in zip(ours, theirs) {
            let emacsFailed = !emacs.error.isEmpty
            let emacsCaret = EmacsOracle.offset(emacs.point, in: emacs.text)
            let same = mine.failed == emacsFailed && (emacsFailed || (mine.text == emacs.text && mine.caret == emacsCaret))
            if !same {
                mismatches += 1
                if mismatches <= 15 {
                    Issue.record("""
                        \(mine.label)
                          ours:  \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")
                          emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(emacs.text.debugDescription) @\(emacsCaret)")
                        """)
                }
            }
        }
        #expect(mismatches == 0, "\(mismatches) of \(ours.count) cases differ from Emacs")
    }
}
  • Step 2: The command model
import Foundation

/// Everything a command may read. Commands never read the clock or the file system, so the
/// same context always gives the same result.
public struct EditContext: Sendable {
    /// The revision `text` and `tree` belong to; results are applied only at this revision.
    public let revision: Int
    public let text: String
    public let tree: OrgTree
    /// Selections in UTF-16 offsets. The first is the main caret.
    public let selection: [Range<Int>]
    public let now: Date
    /// Includes the time zone.
    public let calendar: Calendar
    /// Replies to earlier prompts, by prompt key.
    public let answers: [String: String]

    public init(
        revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
        now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:]
    ) {
        self.revision = revision
        self.text = text
        self.tree = tree
        self.selection = selection
        self.now = now
        self.calendar = calendar
        self.answers = answers
    }

    public var caret: Int { selection.first?.lowerBound ?? 0 }
}

public struct Prompt: Sendable, Equatable {
    /// The key the answer comes back under in `EditContext.answers`.
    public let key: String
    public let message: String

    public init(key: String, message: String) {
        self.key = key
        self.message = message
    }
}

/// Things a command asks for besides text changes; the platform layer carries them out.
public enum Effect: Sendable, Equatable {
    case message(String)
}

public struct EditResult: Sendable, Equatable {
    public let baseRevision: Int
    /// Non-overlapping, in `baseRevision` coordinates.
    public let edits: [TextEdit]
    /// Selection after the edits, in new coordinates; nil maps the old selection through.
    public let selection: [Range<Int>]?
    public let effects: [Effect]

    public init(baseRevision: Int, edits: [TextEdit], selection: [Range<Int>]? = nil, effects: [Effect] = []) {
        self.baseRevision = baseRevision
        self.edits = edits
        self.selection = selection
        self.effects = effects
    }
}

public enum CommandStep: Sendable, Equatable {
    case commit(EditResult)
    /// Ask, then run again with the answer in `EditContext.answers`.
    case prompt(Prompt)
    /// The command can't run here; nothing changes. The message is for the user, as org's
    /// `user-error`.
    case failed(String)
}

/// A named operation. Keys, menus, the palette and touch controls all run commands.
public protocol OrgCommand: Sendable {
    /// Stable identifier, used by keymaps: `org.todo.cycle`.
    var id: String { get }
    /// Shown in the command palette.
    var title: String { get }
    /// Whether the command means something at the caret; context dispatch (one key, several
    /// commands) runs the first that applies.
    func applies(in context: EditContext) -> Bool
    func run(in context: EditContext) -> CommandStep
}

public enum Commands {
    public static let all: [any OrgCommand] = [
        TodoCycle(), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(),
    ]

    public static func command(_ id: String) -> (any OrgCommand)? {
        all.first { $0.id == id }
    }
}
  • Step 3: The heading commands
import Foundation

// Heading-line commands, matched to Emacs 31.1 / Org 9.8.7 by the oracle tests.

/// The heading whose line holds `offset`, for commands that act only on heading lines (org's
/// M-left, M-right, S-up and S-down do something else elsewhere).
func headingOnLine(at offset: Int, in tree: OrgTree) -> SyntaxNode? {
    guard let heading = entryHeading(at: offset, in: tree) else { return nil }
    let line = HeadingLine(heading)
    return offset >= line.start && offset <= line.contentEnd ? heading : nil
}

/// The heading of the entry holding `offset`, as `org-back-to-heading`; nil before the first
/// heading.
func entryHeading(at offset: Int, in tree: OrgTree) -> SyntaxNode? {
    let root = tree.root
    let position = offset >= root.range.upperBound ? max(0, root.range.upperBound - 1) : offset
    var node = root
    var heading: SyntaxNode?
    while let child = node.child(containing: position), child.kind == .section {
        heading = child.firstChild(.heading)
        node = child
    }
    return heading
}

/// The parts of a heading line, in offsets of the whole text.
struct HeadingLine {
    let start: Int
    /// End of the line's content, before its line break.
    let contentEnd: Int
    let stars: Range<Int>
    let todo: SyntaxToken?
    let priority: SyntaxToken?
    let tags: SyntaxToken?
    /// Where the title begins: past the stars, keyword, cookie and the blanks after them.
    let titleStart: Int

    init(_ heading: SyntaxNode) {
        let tokens = heading.tokens
        start = heading.range.lowerBound
        contentEnd = tokens.first { $0.kind == .newline }?.range.lowerBound ?? heading.range.upperBound
        stars = tokens.first { $0.kind == .stars }?.range ?? start..<start
        todo = tokens.first { $0.kind == .todoKeyword }
        priority = tokens.first { $0.kind == .priority }
        tags = tokens.first { $0.kind == .tags }
        var at = start
        for child in heading.green.children {
            guard case .token(let token) = child, [.stars, .whitespace, .todoKeyword, .priority].contains(token.kind) else { break }
            at += token.length
        }
        titleStart = min(at, contentEnd)
    }

    var level: Int { stars.count }
}

/// One line of text being edited, with a caret that moves the way Emacs moves point and
/// markers for each kind of edit, so commands land the caret where Emacs does.
struct LineBuffer {
    var text: NSMutableString
    /// Relative to the line start; nil when the caret is elsewhere in the document.
    var caret: Int?

    init(_ line: String, caret: Int?) {
        text = NSMutableString(string: line)
        self.caret = caret
    }

    var string: String { text as String }
    var length: Int { text.length }

    private mutating func edit(_ range: Range<Int>, _ replacement: String) -> Int {
        text.replaceCharacters(in: NSRange(range), with: replacement)
        return (replacement as NSString).length - range.count
    }

    /// `replace-match`: a caret strictly inside moves to the start; at or after the end it
    /// shifts with the text.
    mutating func replace(_ range: Range<Int>, with replacement: String) {
        let delta = edit(range, replacement)
        guard let position = caret else { return }
        if position >= range.upperBound, position > range.lowerBound {
            caret = position + delta
        } else if position > range.lowerBound {
            caret = range.lowerBound
        }
    }

    /// Deleting `range`, then `insert-before-markers`: a caret anywhere from the start to the
    /// end of the range ends up after the new text.
    mutating func replaceBeforeMarkers(_ range: Range<Int>, with replacement: String) {
        let delta = edit(range, replacement)
        guard let position = caret else { return }
        if position > range.upperBound {
            caret = position + delta
        } else if position >= range.lowerBound {
            caret = range.lowerBound + (replacement as NSString).length
        }
    }

    /// `insert` under `save-excursion`: a caret at the insertion point stays before the text.
    mutating func insert(_ string: String, at position: Int) {
        let delta = edit(position..<position, string)
        if let caret, caret > position { self.caret = caret + delta }
    }

    /// `insert` at point: the caret moves past the text.
    mutating func insertAtCaret(_ string: String) {
        guard let position = caret else { return }
        let delta = edit(position..<position, string)
        caret = position + delta
    }

    /// Offset of display column `target` in the line, as `move-to-column`.
    func offset(ofColumn target: Int) -> Int {
        var column = 0
        var offset = 0
        for character in string {
            if column >= target { break }
            column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character)
            offset += String(character).utf16.count
        }
        return offset
    }
}

extension HeadingLine {
    /// Applies `edit` to the heading line in `context` and maps the caret: on the line it's
    /// placed by `edit`; after the line it shifts by the change in length.
    func commit(_ context: EditContext, _ edit: (inout LineBuffer) -> Void) -> CommandStep {
        let old = (context.text as NSString).substring(with: NSRange(start..<contentEnd))
        let caretOnLine = context.caret >= start && context.caret <= contentEnd
        var buffer = LineBuffer(old, caret: caretOnLine ? context.caret - start : nil)
        edit(&buffer)
        let new = buffer.string
        let delta = (new as NSString).length - (old as NSString).length
        let caret = buffer.caret.map { $0 + start } ?? (context.caret > contentEnd ? context.caret + delta : context.caret)
        guard new != old else { return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret])) }
        return .commit(EditResult(
            baseRevision: context.revision,
            edits: [TextEdit(range: start..<contentEnd, replacement: new)],
            selection: [caret..<caret]
        ))
    }

    /// Offsets relative to the line start.
    func local(_ range: Range<Int>) -> Range<Int> { (range.lowerBound - start)..<(range.upperBound - start) }
}

// MARK: - Tags

/// `org--align-tags-here` with `org-tags-column` -77: tags end at column 77, or sit one blank
/// after the title when it is too long. Nothing changes when they are already there.
///
/// Commands align under `save-excursion`, where the caret is a marker: in the blanks before
/// the tags it ends up where they start. Aligning tags directly (`preservingColumn`) keeps the
/// caret's column instead, as org does for point.
func alignTags(_ buffer: inout LineBuffer, tagsColumn: Int = -77, preservingColumn: Bool = false) {
    let line = buffer.string
    guard let match = line.range(of: "[ \\t]+(:[[:alnum:]_@#%]+)+:[ \\t]*$", options: .regularExpression) else { return }
    let matched = String(line[match])
    let tags = matched.trimmingCharacters(in: .whitespaces)
    let blankStart = (String(line[..<match.lowerBound]) as NSString).length
    let tagsStart = blankStart + (matched as NSString).range(of: tags).location
    let prefixColumn = column(of: String(line[..<match.lowerBound]))
    let currentColumn = column(of: (line as NSString).substring(to: tagsStart))
    let target = tagsColumn >= 0 ? tagsColumn : abs(tagsColumn) - displayWidth(tags)
    let newColumn = max(target, prefixColumn + 1)
    guard newColumn != currentColumn else { return }
    let inBlanks = buffer.caret.map { $0 > blankStart && $0 <= tagsStart } ?? false
    let caretColumn: Int? = inBlanks && preservingColumn ? column(of: (line as NSString).substring(to: buffer.caret!)) : nil
    let old = buffer.caret
    buffer.text.replaceCharacters(in: NSRange(blankStart..<tagsStart), with: String(repeating: " ", count: newColumn - prefixColumn))
    let delta = (newColumn - prefixColumn) - (tagsStart - blankStart)
    if let caretColumn {
        buffer.caret = buffer.offset(ofColumn: caretColumn)
    } else if inBlanks {
        buffer.caret = blankStart
    } else if let old, old > tagsStart {
        buffer.caret = old + delta
    }
}

/// Display column at the end of `text`, starting from column 0.
func column(of text: String) -> Int {
    var column = 0
    for character in text {
        column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character)
    }
    return column
}

// MARK: - TODO

public struct TodoCycle: OrgCommand {
    public init() {}
    public var id: String { "org.todo.cycle" }
    public var title: String { "Cycle TODO State" }

    public func applies(in context: EditContext) -> Bool {
        entryHeading(at: context.caret, in: context.tree) != nil
    }

    /// No keyword, then each keyword of its sequence in order, then no keyword again.
    static func next(after current: String?, in settings: OrgSettings) -> String? {
        guard let current else {
            guard let first = settings.todoSequences.first else { return nil }
            return (first.active + first.done).first?.name
        }
        for sequence in settings.todoSequences {
            let names = (sequence.active + sequence.done).map(\.name)
            if let index = names.firstIndex(of: current) {
                return index + 1 < names.count ? names[index + 1] : nil
            }
        }
        return nil
    }

    /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are
    /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags
    /// are aligned.
    public func run(in context: EditContext) -> CommandStep {
        guard let heading = entryHeading(at: context.caret, in: context.tree) else {
            return .failed("Before first headline")
        }
        let line = HeadingLine(heading)
        let next = Self.next(after: line.todo?.text, in: context.tree.settings)
        return line.commit(context) { buffer in
            let text = buffer.string as NSString
            let regionStart = line.stars.upperBound - line.start
            var regionEnd = regionStart
            while regionEnd < text.length, text.character(at: regionEnd) == 0x20 { regionEnd += 1 }
            if let todo = line.todo {
                regionEnd = line.local(todo.range).upperBound
                var blanks = regionEnd
                while blanks < text.length, text.character(at: blanks) == 0x20 { blanks += 1 }
                if blanks > regionEnd {
                    regionEnd = blanks
                } else if text.substring(from: regionEnd).allSatisfy({ $0 == " " || $0 == "\t" }) {
                    regionEnd = text.length
                }
            }
            buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ")
            alignTags(&buffer)
        }
    }
}

// MARK: - Priority

struct PriorityChange {
    let up: Bool

    func run(in context: EditContext) -> CommandStep {
        guard let heading = headingOnLine(at: context.caret, in: context.tree) else {
            return .failed("Not on a heading")
        }
        let line = HeadingLine(heading)
        let priorities = context.tree.settings.priorities
        guard let highest = Self.value(priorities.highest), let lowest = Self.value(priorities.lowest) else {
            return .failed("Unsupported priority range")
        }
        let numeric = Int(priorities.highest) != nil
        let current = line.priority.flatMap { Self.value(String($0.text.dropFirst(2).dropLast())) }
        // From no cookie, the first press sets the default, as `org-priority-start-cycle-with-default`.
        var new: Int?
        if let current {
            new = up ? current - 1 : current + 1
            if let value = new, value < highest || value > lowest { new = nil }
        } else {
            new = Self.value(priorities.default)
        }
        let cookie = new.map { "[#\(Self.label($0, numeric: numeric))]" }
        return line.commit(context) { buffer in
            if let existing = line.priority {
                let range = line.local(existing.range)
                if let cookie {
                    buffer.replace((range.lowerBound + 2)..<(range.upperBound - 1), with: String(cookie.dropFirst(2).dropLast()))
                } else {
                    // The cookie and one following space, as `org-priority-regexp` group 1.
                    let text = buffer.string as NSString
                    let space = range.upperBound < text.length && text.character(at: range.upperBound) == 0x20 ? 1 : 0
                    buffer.replace(range.lowerBound..<(range.upperBound + space), with: "")
                }
            } else if let cookie {
                if let todo = line.todo {
                    buffer.insert(" " + cookie, at: line.local(todo.range).upperBound)
                } else {
                    buffer.insert(cookie + " ", at: line.titleStart - line.start)
                }
            }
            alignTags(&buffer)
        }
    }

    /// A priority as a number: a letter's code point, or the number itself.
    static func value(_ text: String) -> Int? {
        if let number = Int(text) { return number }
        guard text.unicodeScalars.count == 1, let scalar = text.unicodeScalars.first else { return nil }
        return Int(scalar.value)
    }

    static func label(_ value: Int, numeric: Bool) -> String {
        numeric ? String(value) : String(Character(Unicode.Scalar(UInt32(value))!))
    }
}

public struct PriorityUp: OrgCommand {
    public init() {}
    public var id: String { "org.priority.up" }
    public var title: String { "Raise Priority" }
    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
    public func run(in context: EditContext) -> CommandStep { PriorityChange(up: true).run(in: context) }
}

public struct PriorityDown: OrgCommand {
    public init() {}
    public var id: String { "org.priority.down" }
    public var title: String { "Lower Priority" }
    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
    public func run(in context: EditContext) -> CommandStep { PriorityChange(up: false).run(in: context) }
}

// MARK: - Promote and demote

public struct PromoteHeading: OrgCommand {
    public init() {}
    public var id: String { "org.heading.promote" }
    public var title: String { "Promote Heading" }
    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }

    public func run(in context: EditContext) -> CommandStep {
        guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
        let line = HeadingLine(heading)
        guard line.level > 1 else { return .failed("Cannot promote to level 0") }
        return line.commit(context) { buffer in
            buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ")
            alignTags(&buffer)
            fixPositionAfterPromote(&buffer, settings: context.tree.settings)
        }
    }
}

public struct DemoteHeading: OrgCommand {
    public init() {}
    public var id: String { "org.heading.demote" }
    public var title: String { "Demote Heading" }
    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }

    public func run(in context: EditContext) -> CommandStep {
        guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
        let line = HeadingLine(heading)
        return line.commit(context) { buffer in
            buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ")
            alignTags(&buffer)
            fixPositionAfterPromote(&buffer, settings: context.tree.settings)
        }
    }
}

/// `org-fix-position-after-promote`: a caret right after the stars or the keyword steps over
/// the following space, or adds one at the end of the line.
func fixPositionAfterPromote(_ buffer: inout LineBuffer, settings: OrgSettings) {
    guard let caret = buffer.caret else { return }
    let tree = OrgParser.parse(buffer.string + "\n", defaults: settings)
    guard let heading = tree.root.firstChild(.section)?.firstChild(.heading) else { return }
    let line = HeadingLine(heading)
    let anchors = [line.stars.upperBound, line.todo?.range.upperBound].compactMap { $0 }
    guard anchors.contains(caret) else { return }
    if caret == buffer.length {
        buffer.insertAtCaret(" ")
    } else if buffer.text.character(at: caret) == 0x20 {
        buffer.caret = caret + 1
    }
}
diff --git a/Sources/OrgCore/Parser/Incremental.swift b/Sources/OrgCore/Parser/Incremental.swift
index 9f8347a..976b9b4 100644
--- a/Sources/OrgCore/Parser/Incremental.swift
+++ b/Sources/OrgCore/Parser/Incremental.swift
@@ -40,7 +40,7 @@ extension OrgParser {
         _ old: OrgTree, oldText: String, edit: TextEdit, defaults: OrgSettings = .default
     ) -> (tree: OrgTree, strategy: ReparseStrategy) {
         let newText = edit.apply(to: oldText)
-        let context = EditContext(oldText: oldText, newText: newText, edit: edit)
+        let context = ReparseContext(oldText: oldText, newText: newText, edit: edit)
         if context.touchesSettings() { return (parse(newText, defaults: defaults), .full) }
         if let tree = context.reparseElement(old) { return (tree, .element) }
         if let tree = context.reparseRegion(old) { return (tree, .region) }
@@ -49,7 +49,7 @@ extension OrgParser {
     }
 }
 
-private struct EditContext {
+private struct ReparseContext {
     let oldText: String
     let newText: String
     let edit: TextEdit
  • Step 4: Run the oracle, including a corpus; commit

Run: swift test --filter HeadingOracleTests, then ORGSTAR_ORACLE_CORPUS=<folder> swift test --filter headingCommandsMatchEmacsOnACorpus for each local folder.

git commit -m "Add the command model, heading commands and the Emacs oracle"

Task 3: Running commands

Files: Sources/OrgDocument/DocumentState.swift, Sources/OrgEditorAppKit/OrgEditor.swift, Tests/OrgDocumentTests/DocumentStateTests.swift, Tests/OrgEditorAppKitTests/EditorTests.swift

diff --git a/Sources/OrgDocument/DocumentState.swift b/Sources/OrgDocument/DocumentState.swift
index 614dd52..849c264 100644
--- a/Sources/OrgDocument/DocumentState.swift
+++ b/Sources/OrgDocument/DocumentState.swift
@@ -1,3 +1,4 @@
+import Foundation
 import OrgCore
 
 /// One open file: its text, tree, revision, undo history, and the bytes last read from or
@@ -95,6 +96,24 @@ public struct DocumentState: Sendable {
         return inverse
     }
 
+    // MARK: - Commands
+
+    /// Runs `command` at `selection` and applies its edits as one undo step.
+    public mutating func run(
+        _ command: any OrgCommand, selection: [Range<Int>], now: Date = Date(),
+        calendar: Calendar = .current, answers: [String: String] = [:]
+    ) throws -> CommandStep {
+        let context = EditContext(
+            revision: revision, text: text, tree: tree, selection: selection,
+            now: now, calendar: calendar, answers: answers
+        )
+        let step = command.run(in: context)
+        if case .commit(let result) = step, !result.edits.isEmpty {
+            try apply(result.edits, baseRevision: result.baseRevision)
+        }
+        return step
+    }
+
     // MARK: - Disk
 
     /// The file on disk now holds `bytes`. Reloads an unedited buffer, merges into an edited
diff --git a/Sources/OrgEditorAppKit/OrgEditor.swift b/Sources/OrgEditorAppKit/OrgEditor.swift
index 311f89d..2ccfffd 100644
--- a/Sources/OrgEditorAppKit/OrgEditor.swift
+++ b/Sources/OrgEditorAppKit/OrgEditor.swift
@@ -168,6 +168,53 @@ public final class OrgEditor: NSObject {
         return outcome
     }
 
+    // MARK: - Commands
+
+    /// Called with messages commands report, such as why one couldn't run here.
+    public var onMessage: ((String) -> Void)?
+
+    /// Runs `command` at the selection. Edits go through the text view, as typing does, so
+    /// they form one undo step and reach the document through the same path.
+    @discardableResult
+    public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
+        let selected = textView.selectedRange()
+        let context = EditContext(
+            revision: document.revision, text: document.text, tree: document.tree,
+            selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers
+        )
+        let step = command.run(in: context)
+        switch step {
+        case .commit(let result):
+            guard textView.isEditable || result.edits.isEmpty else {
+                onMessage?("This file is read-only.")
+                return .failed("read-only")
+            }
+            if !result.edits.isEmpty, let storage = textView.textStorage {
+                // Each command is its own undo step, not merged with typing around it.
+                textView.breakUndoCoalescing()
+                textView.undoManager?.beginUndoGrouping()
+                for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
+                    let range = NSRange(edit.range)
+                    guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue }
+                    storage.replaceCharacters(in: range, with: edit.replacement)
+                    textView.didChangeText()
+                }
+                textView.undoManager?.endUndoGrouping()
+                textView.breakUndoCoalescing()
+            }
+            if let selection = result.selection?.first {
+                setCaret(selection.lowerBound)
+                if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
+            }
+            for case .message(let text) in result.effects { onMessage?(text) }
+        case .failed(let message):
+            onMessage?(message)
+        case .prompt:
+            break
+        }
+        return step
+    }
+
     // MARK: - Disk
 
     /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
diff --git a/Tests/OrgDocumentTests/DocumentStateTests.swift b/Tests/OrgDocumentTests/DocumentStateTests.swift
index 8614737..1ad9293 100644
--- a/Tests/OrgDocumentTests/DocumentStateTests.swift
+++ b/Tests/OrgDocumentTests/DocumentStateTests.swift
@@ -115,3 +115,25 @@ struct ViewStateTests {
         #expect(ViewState(folds: [0, 4, 9]).pruned(to: tree).folds == [0, 9])
     }
 }
+
+struct CommandRunTests {
+    @Test func runAppliesEditsAsOneStep() throws {
+        var doc = state("* a\n")
+        let step = try doc.run(TodoCycle(), selection: [2..<2])
+        guard case .commit(let result) = step else {
+            Issue.record("expected a commit")
+            return
+        }
+        #expect(doc.text == "* TODO a\n")
+        #expect(result.selection == [7..<7])
+        #expect(doc.tree.green == OrgParser.parse(doc.text).green)
+        _ = doc.undo()
+        #expect(doc.text == "* a\n")
+    }
+
+    @Test func failuresChangeNothing() throws {
+        var doc = state("text\n")
+        #expect(try doc.run(TodoCycle(), selection: [0..<0]) == .failed("Before first headline"))
+        #expect(doc.revision == 0)
+    }
+}
diff --git a/Tests/OrgEditorAppKitTests/EditorTests.swift b/Tests/OrgEditorAppKitTests/EditorTests.swift
index cdf9b2d..5b1d469 100644
--- a/Tests/OrgEditorAppKitTests/EditorTests.swift
+++ b/Tests/OrgEditorAppKitTests/EditorTests.swift
@@ -320,3 +320,36 @@ struct HangingIndentTests {
         #expect(OrgEditor.hangingColumns(line) == expected)
     }
 }
+
+@MainActor
+struct CommandTests {
+    @Test func performEditsThroughTheTextView() {
+        let h = Harness("* a :t:\nbody\n")
+        var messages: [String] = []
+        h.editor.onMessage = { messages.append($0) }
+        h.caret(at: 2)
+        h.editor.perform(PriorityUp())
+        #expect(h.string.hasPrefix("* [#B] a "))
+        #expect(h.caret == 2)
+        h.checkInSync()
+        // Undo groups by event: let the run loop close the first command's group, as a key
+        // press would.
+        RunLoop.current.run(until: Date())
+        h.editor.perform(TodoCycle())
+        #expect(h.string.hasPrefix("* TODO [#B] a "))
+        RunLoop.current.run(until: Date())
+        h.textView.undoManager?.undo()
+        #expect(h.string.hasPrefix("* [#B] a "))
+        h.checkInSync()
+        h.caret(at: h.offset(of: "body"))
+        h.editor.perform(PromoteHeading())
+        #expect(messages == ["Not on a heading"])
+    }
+
+    @Test func readOnlyEditorsRefuseEdits() {
+        let editor = OrgEditor(document: DocumentState(bytes: Array("* a\n".utf8)), editable: false)
+        editor.textView.setSelectedRange(NSRange(location: 2, length: 0))
+        #expect(editor.perform(TodoCycle()) == .failed("read-only"))
+        #expect(editor.document.text == "* a\n")
+    }
+}

Note: NSUndoManager groups by run-loop event; tests spin the run loop between commands, as key presses would.

Run swift test; commit: git commit -m "Run commands from documents and the editor".