Commit ecf09db5d3
Verified · cmc
Layout: unified · split
docs/plans/2026-10-05-commands.md added +1078
| @@ -0,0 +1,1078 @@ | |||
| 1 | # Command Model Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** The command model from the design (pure functions over an `EditContext`, revision-checked results, prompts, injected time), the stateful Emacs oracle, and the first heading commands (TODO cycle, priority up/down, promote/demote) proven byte- and caret-exact against Emacs 31.1 / Org 9.8.7. | ||
| 6 | |||
| 7 | **Architecture:** `OrgCommand.run(in:)` reads an `EditContext` and returns a `CommandStep`: a commit with edits and the caret, a prompt, or a failure message. Heading commands rewrite one heading line in a `LineBuffer` whose caret follows Emacs's rules for each kind of edit (`replace-match`, `insert-before-markers`, `insert` under `save-excursion`, tag alignment), because those rules decide where org leaves point. `DocumentState.run` applies a commit as one undo step; `OrgEditor.perform` applies it through the text view so undo and document sync work as for typing. The oracle sends cases (text, point, Emacs form) to one `emacs -Q --batch` process as JSON with org's settings pinned. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 with Org 9.8.7 for the oracle. | ||
| 10 | |||
| 11 | **Spec:** `docs/design.md`, "Commands and keymaps" and "Testing" (command oracle). | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - Commands never read the clock, the file system or global state; the same context gives the same result. | ||
| 16 | - Every command is checked against Emacs on every caret position of a set of heading variants (text and caret must both match), and against real headings with `ORGSTAR_ORACLE_CORPUS`. | ||
| 17 | - The oracle runs `-Q` with explicit settings: TODO/DONE, no logging, `org-tags-column` -77, priorities A–C default B, no tab indentation; each case runs as a fresh interactive command (`last-command` differs from `this-command`). | ||
| 18 | - Commands that org binds only on heading lines (M-left/right, S-up/down) apply only on heading lines. | ||
| 19 | |||
| 20 | ## What the oracle found | ||
| 21 | |||
| 22 | - Two parser differences from org, now fixed: a heading needs a space after its stars (`*` alone and `*` before a tab are not headings), and a TODO keyword counts only before a space or the end of the line. | ||
| 23 | - `org-todo`, `org-priority` and promote/demote all realign tags to column 77. | ||
| 24 | - `org-todo` replaces the blanks after the stars, the keyword and its blanks with " NEXT " using `insert-before-markers`, so extra blanks collapse and a caret anywhere in that span ends after the keyword. | ||
| 25 | - Promote and demote replace the stars and one space with `replace-match`, then `org-fix-position-after-promote` steps a caret at the end of the stars or keyword over the next space. | ||
| 26 | - In batch mode `org-priority` sees `last-command` equal to `this-command` and wraps instead of starting at the default; the oracle sets them apart, as in an interactive call. | ||
| 27 | |||
| 28 | Corpus 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 | ||
| 49 | diff --git a/Sources/OrgCore/Parser/Lines.swift b/Sources/OrgCore/Parser/Lines.swift | ||
| 50 | index 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 | } | ||
| 67 | diff --git a/Sources/OrgCore/Parser/Parser.swift b/Sources/OrgCore/Parser/Parser.swift | ||
| 68 | index 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 | } | ||
| 83 | diff --git a/Tests/OrgCoreTests/LinesTests.swift b/Tests/OrgCoreTests/LinesTests.swift | ||
| 84 | index 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")), | ||
| 98 | diff --git a/Tests/OrgCoreTests/ParserSectionTests.swift b/Tests/OrgCoreTests/ParserSectionTests.swift | ||
| 99 | index 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 | |||
| 117 | Run `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 | ||
| 128 | import 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. | ||
| 136 | enum 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. | ||
| 247 | func 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 | ||
| 263 | import Foundation | ||
| 264 | import Testing | ||
| 265 | @testable import OrgCore | ||
| 266 | |||
| 267 | struct 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. | ||
| 305 | struct 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 | ||
| 393 | import 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. | ||
| 397 | public 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 | |||
| 426 | public 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. | ||
| 438 | public enum Effect: Sendable, Equatable { | ||
| 439 | case message(String) | ||
| 440 | } | ||
| 441 | |||
| 442 | public 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 | |||
| 458 | public 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. | ||
| 468 | public 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 | |||
| 479 | public 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 | ||
| 493 | import 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). | ||
| 499 | func 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. | ||
| 507 | func 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. | ||
| 520 | struct 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. | ||
| 552 | struct 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 | |||
| 620 | extension 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. | ||
| 651 | func 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. | ||
| 678 | func 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 | |||
| 688 | public 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 | |||
| 744 | struct 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 | |||
| 801 | public 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 | |||
| 809 | public 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 | |||
| 819 | public 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 | |||
| 837 | public 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. | ||
| 856 | func 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 | ||
| 872 | diff --git a/Sources/OrgCore/Parser/Incremental.swift b/Sources/OrgCore/Parser/Incremental.swift | ||
| 873 | index 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 | |||
| 898 | Run: `swift test --filter HeadingOracleTests`, then `ORGSTAR_ORACLE_CORPUS=<folder> swift test --filter headingCommandsMatchEmacsOnACorpus` for each local folder. | ||
| 899 | |||
| 900 | ```bash | ||
| 901 | git 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 | ||
| 911 | diff --git a/Sources/OrgDocument/DocumentState.swift b/Sources/OrgDocument/DocumentState.swift | ||
| 912 | index 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 | ||
| 945 | diff --git a/Sources/OrgEditorAppKit/OrgEditor.swift b/Sources/OrgEditorAppKit/OrgEditor.swift | ||
| 946 | index 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 | ||
| 1003 | diff --git a/Tests/OrgDocumentTests/DocumentStateTests.swift b/Tests/OrgDocumentTests/DocumentStateTests.swift | ||
| 1004 | index 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 | +} | ||
| 1033 | diff --git a/Tests/OrgEditorAppKitTests/EditorTests.swift b/Tests/OrgEditorAppKitTests/EditorTests.swift | ||
| 1034 | index 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 | |||
| 1076 | Note: `NSUndoManager` groups by run-loop event; tests spin the run loop between commands, as key presses would. | ||
| 1077 | |||
| 1078 | Run `swift test`; commit: `git commit -m "Run commands from documents and the editor"`. | ||