# 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 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** ```swift 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[.. 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.. $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)") } } ``` ```swift 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=` 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** ```swift 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] 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], 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]? public let effects: [Effect] public init(baseRevision: Int, edits: [TextEdit], selection: [Range]? = 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** ```swift 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 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.., _ 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, 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, 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 { 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.. 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..= 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..) -> Range { (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[..= 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 { 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.. 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 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= swift test --filter headingCommandsMatchEmacsOnACorpus` for each local folder. ```bash 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 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], 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.. $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"`.