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