krz/orgstar

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

Commit 5a1ddcb0e6

5a1ddcb0e617ab947930040fce97dfb1dbbad641

parent: 397c9ad69a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 04:05 UTC

Port heading structure commands from org

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +7 −1
@@ -14,10 +14,12 @@ public struct EditContext: Sendable {
14 public let calendar: Calendar 14 public let calendar: Calendar
15 /// Replies to earlier prompts, by prompt key. 15 /// Replies to earlier prompts, by prompt key.
16 public let answers: [String: String] 16 public let answers: [String: String]
17 /// Ranges the view hides (folded text); motion commands skip headings inside them.
18 public let hidden: [Range<Int>]
17 19
18 public init( 20 public init(
19 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>], 21 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
20 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:] 22 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = []
21 ) { 23 ) {
22 self.revision = revision 24 self.revision = revision
23 self.text = text 25 self.text = text
@@ -26,6 +28,7 @@ public struct EditContext: Sendable {
26 self.now = now 28 self.now = now
27 self.calendar = calendar 29 self.calendar = calendar
28 self.answers = answers 30 self.answers = answers
31 self.hidden = hidden
29 } 32 }
30 33
31 public var caret: Int { selection.first?.lowerBound ?? 0 } 34 public var caret: Int { selection.first?.lowerBound ?? 0 }
@@ -87,6 +90,9 @@ public protocol OrgCommand: Sendable {
87public enum Commands { 90public enum Commands {
88 public static let all: [any OrgCommand] = [ 91 public static let all: [any OrgCommand] = [
89 TodoCycle(), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(), 92 TodoCycle(), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(),
93 InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(),
94 MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(),
95 NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(),
90 ] 96 ]
91 97
92 public static func command(_ id: String) -> (any OrgCommand)? { 98 public static func command(_ id: String) -> (any OrgCommand)? {
Sources/OrgCore/Commands/EmacsBuffer.swift added +392
@@ -0,0 +1,392 @@
1import Foundation
2
3/// A whole-text buffer with Emacs's point and marker rules, for porting org commands that
4/// edit several lines. Offsets are UTF-16, as everywhere in `OrgCore`.
5///
6/// Inserting at point moves point past the text; a marker at the insertion point stays before
7/// it unless the insertion is `insertBeforeMarkers`. Deleting a range moves positions inside
8/// it to its start. `saveExcursion` restores point from a marker, as Emacs does.
9final class EmacsBuffer {
10 final class Marker {
11 var position: Int
12 init(_ position: Int) { self.position = position }
13 }
14
15 struct UserError: Error {
16 let message: String
17 }
18
19 private(set) var text: NSMutableString
20 var point: Int
21 private var markers: [Marker] = []
22 let settings: OrgSettings
23
24 init(_ text: String, point: Int, settings: OrgSettings = .default) {
25 self.text = NSMutableString(string: text)
26 self.point = point
27 self.settings = settings
28 }
29
30 var string: String { text as String }
31 var length: Int { text.length }
32
33 func character(at offset: Int) -> unichar? {
34 offset >= 0 && offset < length ? text.character(at: offset) : nil
35 }
36
37 static let newline = unichar(10)
38
39 // MARK: - Markers
40
41 func marker(_ position: Int) -> Marker {
42 let marker = Marker(position)
43 markers.append(marker)
44 return marker
45 }
46
47 func release(_ marker: Marker) {
48 markers.removeAll { $0 === marker }
49 }
50
51 /// Runs `body` and puts point back where it was, following edits as a marker does.
52 @discardableResult
53 func saveExcursion<T>(_ body: () throws -> T) rethrows -> T {
54 let saved = marker(point)
55 defer {
56 point = saved.position
57 release(saved)
58 }
59 return try body()
60 }
61
62 // MARK: - Editing
63
64 /// `insert` at point.
65 func insert(_ string: String) {
66 let count = (string as NSString).length
67 guard count > 0 else { return }
68 text.insert(string, at: point)
69 for marker in markers where marker.position > point { marker.position += count }
70 point += count
71 }
72
73 /// `insert-before-markers` at point: markers at point move past the text too.
74 func insertBeforeMarkers(_ string: String) {
75 let count = (string as NSString).length
76 guard count > 0 else { return }
77 text.insert(string, at: point)
78 for marker in markers where marker.position >= point { marker.position += count }
79 point += count
80 }
81
82 func deleteRegion(_ start: Int, _ end: Int) {
83 let (lower, upper) = (min(start, end), max(start, end))
84 guard upper > lower else { return }
85 text.deleteCharacters(in: NSRange(lower..<upper))
86 func map(_ position: Int) -> Int {
87 position <= lower ? position : position >= upper ? position - (upper - lower) : lower
88 }
89 point = map(point)
90 for marker in markers { marker.position = map(marker.position) }
91 }
92
93 func substring(_ start: Int, _ end: Int) -> String {
94 text.substring(with: NSRange(min(start, end)..<max(start, end)))
95 }
96
97 /// `replace-match` over `range`: positions strictly inside go to its start, positions at
98 /// or after its end shift with the text.
99 func replace(_ range: Range<Int>, with replacement: String) {
100 let delta = (replacement as NSString).length - range.count
101 text.replaceCharacters(in: NSRange(range), with: replacement)
102 func map(_ position: Int) -> Int {
103 if position >= range.upperBound, position > range.lowerBound { return position + delta }
104 return position > range.lowerBound ? range.lowerBound : position
105 }
106 point = map(point)
107 for marker in markers { marker.position = map(marker.position) }
108 }
109
110 // MARK: - Motion
111
112 var bolp: Bool { point == 0 || character(at: point - 1) == Self.newline }
113 var eolp: Bool { point == length || character(at: point) == Self.newline }
114 var bobp: Bool { point == 0 }
115 var eobp: Bool { point == length }
116
117 func lineStart(_ offset: Int) -> Int {
118 var at = min(offset, length)
119 while at > 0, character(at: at - 1) != Self.newline { at -= 1 }
120 return at
121 }
122
123 func lineEnd(_ offset: Int) -> Int {
124 var at = max(offset, 0)
125 while at < length, character(at: at) != Self.newline { at += 1 }
126 return at
127 }
128
129 func beginningOfLine() { point = lineStart(point) }
130 func endOfLine() { point = lineEnd(point) }
131
132 /// `forward-line`: to the start of the line `n` lines away, stopping at the buffer ends.
133 @discardableResult
134 func forwardLine(_ n: Int) -> Int {
135 point = lineStart(point)
136 var remaining = n
137 while remaining > 0 {
138 let end = lineEnd(point)
139 if end == length {
140 point = length
141 return remaining
142 }
143 point = end + 1
144 remaining -= 1
145 }
146 while remaining < 0 {
147 if point == 0 { return remaining }
148 point = lineStart(point - 1)
149 remaining += 1
150 }
151 return 0
152 }
153
154 func skipForward(_ set: Set<unichar>) {
155 while let c = character(at: point), set.contains(c) { point += 1 }
156 }
157
158 func skipBackward(_ set: Set<unichar>) {
159 while let c = character(at: point - 1), set.contains(c) { point -= 1 }
160 }
161
162 static let blanks: Set<unichar> = [32, 9]
163 static let whitespace: Set<unichar> = [32, 9, 10, 13]
164
165 var currentColumn: Int { column(of: substring(lineStart(point), point)) }
166
167 /// `move-to-column`: as far as `target` on this line, or the line's end.
168 func moveToColumn(_ target: Int) {
169 let start = lineStart(point)
170 let line = LineBuffer(substring(start, lineEnd(point)), caret: nil)
171 point = start + line.offset(ofColumn: target)
172 }
173
174 // MARK: - Outline
175
176 /// Stars of a heading line starting at `lineStart`, or nil.
177 func headingLevel(atLine lineStart: Int) -> Int? {
178 var at = lineStart
179 while character(at: at) == 42 { at += 1 }
180 return at > lineStart && character(at: at) == 32 ? at - lineStart : nil
181 }
182
183 var atHeading: Bool { headingLevel(atLine: lineStart(point)) != nil }
184
185 /// Start of the first heading line after `offset`'s line, or nil.
186 func nextHeadingStart(after offset: Int) -> Int? {
187 var line = lineEnd(offset)
188 while line < length {
189 line += 1
190 if headingLevel(atLine: line) != nil { return line }
191 line = lineEnd(line)
192 }
193 return nil
194 }
195
196 /// Start of the last heading line before `offset`'s line, or nil.
197 func previousHeadingStart(before offset: Int) -> Int? {
198 var line = lineStart(offset)
199 while line > 0 {
200 line = lineStart(line - 1)
201 if headingLevel(atLine: line) != nil { return line }
202 }
203 return nil
204 }
205
206 /// `org-outline-level`: the level of the heading at or before point's line, or 0.
207 var outlineLevel: Int {
208 let start = lineStart(point)
209 if let level = headingLevel(atLine: start) { return level }
210 return previousHeadingStart(before: start).flatMap { headingLevel(atLine: $0) } ?? 0
211 }
212
213 var currentLevel: Int? { outlineLevel > 0 ? outlineLevel : nil }
214
215 var beforeFirstHeading: Bool { outlineLevel == 0 }
216
217 /// `org-back-to-heading`.
218 @discardableResult
219 func backToHeading() throws -> Int {
220 let start = lineStart(point)
221 if headingLevel(atLine: start) != nil {
222 point = start
223 } else if let previous = previousHeadingStart(before: start) {
224 point = previous
225 } else {
226 throw UserError(message: "Before first headline at position \(point + 1) in buffer")
227 }
228 return point
229 }
230
231 /// `outline-next-heading`: the next heading line, or the buffer end. True when found.
232 @discardableResult
233 func outlineNextHeading() -> Bool {
234 if bolp, !eobp { point += 1 }
235 // A heading line starting at point counts, as `re-search-forward "^\\*+ "` would.
236 if bolp, headingLevel(atLine: point) != nil { return true }
237 guard let next = nextHeadingStart(after: point) else {
238 point = length
239 return false
240 }
241 point = next
242 return true
243 }
244
245 /// `outline-previous-heading`: the previous heading line, or the buffer start.
246 func outlinePreviousHeading() {
247 // `re-search-backward` matches must end by point: a heading on this line counts once
248 // point is past its stars and space.
249 let start = lineStart(point)
250 if let level = headingLevel(atLine: start), point >= start + level + 1 {
251 point = start
252 return
253 }
254 point = previousHeadingStart(before: point) ?? 0
255 }
256
257 /// `org-end-of-subtree`.
258 @discardableResult
259 func endOfSubtree(toHeading: Bool = false) -> Int {
260 if beforeFirstHeading {
261 point = length
262 } else {
263 let start = try! backToHeading()
264 let level = headingLevel(atLine: start)!
265 var next = nextHeadingStart(after: start)
266 while let candidate = next, headingLevel(atLine: candidate)! > level {
267 next = nextHeadingStart(after: candidate)
268 }
269 point = next ?? length
270 }
271 if !toHeading, let c = character(at: point - 1), c == 10 || c == 13 {
272 point -= 1
273 skipBackward([10, 13, 9, 32])
274 }
275 return point
276 }
277
278 /// `org-get-next-sibling`.
279 func nextSibling() -> Int? {
280 let level = outlineLevel
281 outlineNextHeading()
282 while !eobp, outlineLevel > level { outlineNextHeading() }
283 return eobp || outlineLevel < level ? nil : point
284 }
285
286 /// `org-get-previous-sibling`.
287 func previousSibling() -> Int? {
288 let start = point
289 let level = outlineLevel
290 outlinePreviousHeading()
291 guard point != start, atHeading else { return nil }
292 while outlineLevel > level, !bobp { outlinePreviousHeading() }
293 return outlineLevel < level ? nil : point
294 }
295
296 /// `org--line-empty-p -1`.
297 var previousLineEmpty: Bool {
298 guard !bobp else { return false }
299 return saveExcursion {
300 forwardLine(-1)
301 skipForward(Self.blanks)
302 return eolp
303 }
304 }
305
306 // MARK: - Regular expressions
307
308 /// `looking-at`: the groups of `pattern` matched at point (`^` and `$` match at lines),
309 /// or nil. Unmatched groups are nil.
310 func lookingAt(_ pattern: String) -> [Range<Int>?]? {
311 let regex = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines])
312 guard let match = regex.firstMatch(in: string, options: [.anchored], range: NSRange(point..<length)) else { return nil }
313 return (0..<match.numberOfRanges).map { i in
314 let range = match.range(at: i)
315 return range.location == NSNotFound ? nil : range.location..<NSMaxRange(range)
316 }
317 }
318
319 /// `org-complex-heading-regexp`: 1 stars, 2 keyword, 3 priority, 4 title, 5 tags.
320 var complexHeadingPattern: String {
321 "^(\\*+)(?: +(\(keywordAlternation)))?(?: +(\\[#(?:[A-Z]|[0-9]|[1-5][0-9]|6[0-4])\\]))?(?: +(.*?))??(?:[ \t]+(:[[:alnum:]_@#%:]+:))?[ \t]*$"
322 }
323
324 /// `org-todo-line-regexp`: 1 stars, 2 keyword, 3 title.
325 var todoLinePattern: String {
326 "^(\\*+)(?: +(\(keywordAlternation)))?(?: +(.*?))?[ \t]*$"
327 }
328
329 private var keywordAlternation: String {
330 keywordsInOrder.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|")
331 }
332
333 /// `org-todo-keywords-1`.
334 var keywordsInOrder: [String] {
335 settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) }
336 }
337
338 // MARK: - Tags
339
340 /// `org-align-tags` on the line at `offset`, moving point and markers on the line as the
341 /// tag alignment rules say.
342 func alignTags(onLineAt offset: Int) {
343 let start = lineStart(offset)
344 let end = lineEnd(offset)
345 let line = substring(start, end)
346 var plain = LineBuffer(line, caret: nil)
347 OrgCore.alignTags(&plain)
348 guard plain.string != line else { return }
349 func map(_ position: Int) -> Int {
350 guard position >= start, position <= end else {
351 return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position
352 }
353 var buffer = LineBuffer(line, caret: position - start)
354 OrgCore.alignTags(&buffer)
355 return start + buffer.caret!
356 }
357 let newPoint = map(point)
358 let newMarkers = markers.map { map($0.position) }
359 text.replaceCharacters(in: NSRange(start..<end), with: plain.string)
360 point = newPoint
361 for (marker, position) in zip(markers, newMarkers) { marker.position = position }
362 }
363}
364
365/// Runs a buffer-editing body against a context and turns the result into one minimal edit
366/// and the new caret. A user error becomes `.failed`.
367func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
368 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
369 do {
370 try body(buffer)
371 } catch let error as EmacsBuffer.UserError {
372 return .failed(error.message)
373 } catch {
374 return .failed("\(error)")
375 }
376 let old = context.text as NSString
377 let new = buffer.text
378 var prefix = 0
379 let shorter = min(old.length, new.length)
380 while prefix < shorter, old.character(at: prefix) == new.character(at: prefix) { prefix += 1 }
381 var suffix = 0
382 while suffix < shorter - prefix, old.character(at: old.length - 1 - suffix) == new.character(at: new.length - 1 - suffix) { suffix += 1 }
383 // Keep surrogate pairs whole.
384 while prefix > 0, prefix < old.length, UTF16.isTrailSurrogate(old.character(at: prefix)) { prefix -= 1 }
385 while suffix > 0, UTF16.isTrailSurrogate(old.character(at: old.length - suffix)) { suffix -= 1 }
386 let caret = buffer.point
387 guard old.length != new.length || prefix != old.length else {
388 return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret]))
389 }
390 let edit = TextEdit(range: prefix..<(old.length - suffix), replacement: new.substring(with: NSRange(prefix..<(new.length - suffix))))
391 return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret]))
392}
Sources/OrgCore/Commands/StructureCommands.swift added +438
@@ -0,0 +1,438 @@
1import Foundation
2
3// Structure commands, ported from Org 9.8.7 onto `EmacsBuffer` and matched to Emacs by the
4// oracle tests. Defaults as in org: `org-blank-before-new-entry` heading `auto`,
5// `org-M-RET-may-split-line` t, `org-auto-align-tags` t.
6
7extension EmacsBuffer {
8 /// `org--blank-before-heading-p` for headings.
9 var blankBeforeHeading: Bool {
10 saveExcursion {
11 if beforeFirstHeading, !outlineNextHeading() { return false }
12 _ = try? backToHeading()
13 if !bobp { return previousLineEmpty }
14 if outlineNextHeading() { return previousLineEmpty }
15 skipBackward(Self.blanks)
16 return bolp ? previousLineEmpty : false
17 }
18 }
19
20 /// `org-N-empty-lines-before-current`.
21 func emptyLinesBeforeCurrent(_ n: Int) {
22 let column = currentColumn
23 forwardLine(0)
24 if !bobp {
25 let start = saveExcursion {
26 skipBackward(Self.whitespace)
27 return lineEnd(point)
28 }
29 deleteRegion(start, point - 1)
30 }
31 insert(String(repeating: "\n", count: n))
32 moveToColumn(column)
33 }
34
35 /// The blank line `org-insert-heading` keeps before the next heading.
36 func addBlankAfter(_ blank: Bool) {
37 saveExcursion {
38 endOfLine()
39 guard !eobp else { return }
40 point += 1
41 if blank, atHeading { insert("\n") }
42 }
43 }
44
45 /// `org-insert-heading` with no prefix, or with `respectContent` as C-RET.
46 func insertHeading(respectContent: Bool, hidden: [Range<Int>]) throws {
47 let blank = blankBeforeHeading
48 let level = currentLevel
49 let stars = String(repeating: "*", count: level ?? 1)
50 let invisible = hidden.contains { $0.contains(max(point - 1, 0)) }
51 if respectContent || invisible {
52 if level == nil {
53 outlineNextHeading()
54 } else {
55 try backToHeading()
56 endOfSubtree(toHeading: true)
57 }
58 if !bolp { insert("\n") }
59 if blank, saveExcursion({ point -= 1; return beforeFirstHeading }) {
60 insert("\n")
61 point -= 1
62 }
63 if level == nil, !eobp, !bobp {
64 if atHeading { insert("\n") }
65 point -= 1
66 }
67 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
68 insert(stars + " \n")
69 point -= 1
70 addBlankAfter(blank)
71 } else if atHeading {
72 if bolp {
73 if blank { saveExcursion { insert("\n") } }
74 saveExcursion { insert(stars + " \n") }
75 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
76 endOfLine()
77 } else if let match = saveExcursion({ () -> [Range<Int>?]? in beginningOfLine(); return lookingAt(complexHeadingPattern) }),
78 let title = match[4], title.lowerBound <= point, point <= title.upperBound {
79 let split = substring(point, title.upperBound)
80 deleteRegion(point, title.upperBound)
81 if let blanks = lookingAt("[ \\t]*$") {
82 deleteRegion(blanks[0]!.lowerBound, blanks[0]!.upperBound)
83 } else {
84 alignTags(onLineAt: point)
85 }
86 endOfLine()
87 if blank { insert("\n") }
88 insert("\n" + stars + " ")
89 addBlankAfter(blank)
90 if split.contains(where: { !$0.isWhitespace }) { insert(split) }
91 } else {
92 endOfLine()
93 if blank { insert("\n") }
94 insert("\n" + stars + " ")
95 addBlankAfter(blank)
96 }
97 } else if bolp {
98 insert(stars + " ")
99 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
100 addBlankAfter(blank)
101 } else {
102 insert("\n" + stars + " ")
103 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
104 addBlankAfter(blank)
105 }
106 }
107
108 /// `org-forward-heading-same-level` over all headings, visible or not.
109 func forwardHeadingSameLevel(_ count: Int) throws {
110 let backward = count < 0
111 if beforeFirstHeading {
112 if backward { point = 0 } else { outlineNextHeading() }
113 return
114 }
115 try backToHeading()
116 if !backward { endOfLine() }
117 let level = currentLevel ?? 0
118 var remaining = abs(count)
119 var result = point
120 while remaining > 0, let found = backward ? searchHeadingBackward() : searchHeadingForward() {
121 let l = headingLevel(atLine: found)!
122 if l < level {
123 remaining = 0
124 } else if l == level {
125 remaining -= 1
126 result = point
127 }
128 }
129 point = result
130 forwardLine(0)
131 }
132
133 /// `re-search-forward "^\\*+ " nil 'move`: point to the match end, or the buffer end.
134 /// Returns the heading's line start.
135 func searchHeadingForward() -> Int? {
136 var line = bolp ? point : lineEnd(point) + 1
137 while line <= length {
138 if let level = headingLevel(atLine: line) {
139 point = line + level + 1
140 return line
141 }
142 line = lineEnd(line) + 1
143 }
144 point = length
145 return nil
146 }
147
148 /// `re-search-backward "^\\*+ " nil 'move`: point to the match start, or the buffer start.
149 /// The match must end by point.
150 func searchHeadingBackward() -> Int? {
151 var line = lineStart(point)
152 if let level = headingLevel(atLine: line), line + level + 1 <= point {
153 point = line
154 return line
155 }
156 while line > 0 {
157 line = lineStart(line - 1)
158 if headingLevel(atLine: line) != nil {
159 point = line
160 return line
161 }
162 }
163 point = 0
164 return nil
165 }
166
167 /// `org-promote` or `org-demote` on the heading at point.
168 func changeLevel(by delta: Int) throws {
169 try saveExcursion {
170 try backToHeading()
171 let level = headingLevel(atLine: point)!
172 guard level + delta >= 1 else { throw UserError(message: "Cannot promote to level 0. UNDO to recover if necessary") }
173 replace(point..<(point + level + 1), with: String(repeating: "*", count: level + delta) + " ")
174 alignTags(onLineAt: point)
175 }
176 }
177
178 /// `org-fix-position-after-promote`.
179 func fixPositionAfterPromote() {
180 let position = point
181 let match = saveExcursion { () -> [Range<Int>?]? in
182 forwardLine(0)
183 return lookingAt(todoLinePattern)
184 }
185 guard let match, match[1]?.upperBound == position || match[2]?.upperBound == position else { return }
186 if eobp || eolp {
187 insert(" ")
188 } else if character(at: point) == 32 {
189 point += 1
190 }
191 }
192
193 /// `org-map-tree` over the subtree at point.
194 func mapTree(_ body: () throws -> Void) throws {
195 try backToHeading()
196 let level = outlineLevel
197 try saveExcursion {
198 try body()
199 while true {
200 outlineNextHeading()
201 guard outlineLevel > level, !eobp else { break }
202 try body()
203 }
204 }
205 }
206
207 /// `org-back-over-empty-lines` with blank lines before headings on (`auto`).
208 func backOverEmptyLines() {
209 let position = point
210 skipBackward(Self.whitespace)
211 forwardLine(1)
212 point = min(point, position)
213 }
214
215 /// `org-move-subtree-down`; negative counts move up.
216 func moveSubtree(_ count: Int) throws {
217 let column = currentColumn
218 try backToHeading()
219 let begin = point
220 endOfSubtree(toHeading: true)
221 if !eobp { point -= 1 }
222 outlineNextHeading()
223 let end = point
224 point = begin
225 for _ in 0..<abs(count) {
226 let found = count > 0 ? nextSibling() : previousSibling()
227 guard found != nil, atHeading, bolp else {
228 point = begin
229 throw UserError(message: "Cannot move past superior level or buffer limit")
230 }
231 }
232 if count > 0 {
233 endOfSubtree(toHeading: true)
234 saveExcursion {
235 backOverEmptyLines()
236 if !bolp { insert("\n") }
237 }
238 }
239 let insertion = marker(point)
240 defer { release(insertion) }
241 let subtree = substring(begin, end)
242 deleteRegion(begin, end)
243 if !bolp, character(at: point) == Self.newline { point += 1 }
244 let at = point
245 insertBeforeMarkers(subtree)
246 insertion.position = at
247 if !bolp { insert("\n") }
248 point = insertion.position
249 skipForward(Self.whitespace)
250 moveToColumn(column)
251 }
252}
253
254private func headingCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
255 commitBuffer(context, body)
256}
257
258// MARK: - Inserting
259
260/// M-RET on a heading or text: `org-insert-heading`.
261public struct InsertHeading: OrgCommand {
262 public init() {}
263 public var id: String { "org.heading.insert" }
264 public var title: String { "Insert Heading" }
265 public func applies(in context: EditContext) -> Bool { true }
266 public func run(in context: EditContext) -> CommandStep {
267 headingCommand(context) { try $0.insertHeading(respectContent: false, hidden: context.hidden) }
268 }
269}
270
271/// C-RET: `org-insert-heading-respect-content`.
272public struct InsertHeadingAfterSubtree: OrgCommand {
273 public init() {}
274 public var id: String { "org.heading.insert-after-subtree" }
275 public var title: String { "Insert Heading After Subtree" }
276 public func applies(in context: EditContext) -> Bool { true }
277 public func run(in context: EditContext) -> CommandStep {
278 headingCommand(context) { try $0.insertHeading(respectContent: true, hidden: context.hidden) }
279 }
280}
281
282/// M-S-RET on a heading or text: `org-insert-todo-heading`.
283public struct InsertTodoHeading: OrgCommand {
284 public init() {}
285 public var id: String { "org.heading.insert-todo" }
286 public var title: String { "Insert TODO Heading" }
287 public func applies(in context: EditContext) -> Bool { true }
288 public func run(in context: EditContext) -> CommandStep {
289 headingCommand(context) { buffer in
290 try buffer.insertHeading(respectContent: false, hidden: context.hidden)
291 let previous = buffer.saveExcursion { () -> String? in
292 try? buffer.forwardHeadingSameLevel(-1)
293 guard let match = buffer.lookingAt(buffer.todoLinePattern), let keyword = match[2] else { return nil }
294 return buffer.substring(keyword.lowerBound, keyword.upperBound)
295 }
296 let keyword = previous.flatMap { context.tree.settings.isDone($0) ? nil : $0 } ?? buffer.keywordsInOrder.first ?? "TODO"
297 buffer.forwardLine(0)
298 if let stars = buffer.lookingAt("\\*+ ") {
299 buffer.point = stars[0]!.upperBound
300 buffer.insert(keyword + " ")
301 }
302 }
303 }
304}
305
306// MARK: - Moving and levels
307
308/// M-up on a heading: `org-move-subtree-up`.
309public struct MoveSubtreeUp: OrgCommand {
310 public init() {}
311 public var id: String { "org.subtree.up" }
312 public var title: String { "Move Subtree Up" }
313 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
314 public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.moveSubtree(-1) } }
315}
316
317/// M-down on a heading: `org-move-subtree-down`.
318public struct MoveSubtreeDown: OrgCommand {
319 public init() {}
320 public var id: String { "org.subtree.down" }
321 public var title: String { "Move Subtree Down" }
322 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
323 public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.moveSubtree(1) } }
324}
325
326/// M-S-left on a heading: `org-promote-subtree`.
327public struct PromoteSubtree: OrgCommand {
328 public init() {}
329 public var id: String { "org.subtree.promote" }
330 public var title: String { "Promote Subtree" }
331 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
332 public func run(in context: EditContext) -> CommandStep {
333 headingCommand(context) { buffer in
334 try buffer.saveExcursion {
335 try buffer.backToHeading()
336 try buffer.mapTree { try buffer.changeLevel(by: -1) }
337 }
338 buffer.fixPositionAfterPromote()
339 }
340 }
341}
342
343/// M-S-right on a heading: `org-demote-subtree`.
344public struct DemoteSubtree: OrgCommand {
345 public init() {}
346 public var id: String { "org.subtree.demote" }
347 public var title: String { "Demote Subtree" }
348 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
349 public func run(in context: EditContext) -> CommandStep {
350 headingCommand(context) { buffer in
351 try buffer.saveExcursion {
352 try buffer.backToHeading()
353 try buffer.mapTree { try buffer.changeLevel(by: 1) }
354 }
355 buffer.fixPositionAfterPromote()
356 }
357 }
358}
359
360// MARK: - Motion
361
362/// C-c C-n: `org-next-visible-heading`.
363public struct NextHeading: OrgCommand {
364 public init() {}
365 public var id: String { "org.heading.next" }
366 public var title: String { "Next Heading" }
367 public func applies(in context: EditContext) -> Bool { true }
368 public func run(in context: EditContext) -> CommandStep {
369 headingCommand(context) { buffer in
370 buffer.endOfLine()
371 var found = buffer.nextHeadingStart(after: buffer.point)
372 while let start = found, context.hidden.contains(where: { $0.contains(start) }) {
373 found = buffer.nextHeadingStart(after: start)
374 }
375 buffer.point = found ?? buffer.length
376 }
377 }
378}
379
380/// C-c C-p: `org-previous-visible-heading`.
381public struct PreviousHeading: OrgCommand {
382 public init() {}
383 public var id: String { "org.heading.previous" }
384 public var title: String { "Previous Heading" }
385 public func applies(in context: EditContext) -> Bool { true }
386 public func run(in context: EditContext) -> CommandStep {
387 headingCommand(context) { buffer in
388 buffer.forwardLine(0)
389 var found = buffer.previousHeadingStart(before: buffer.point)
390 while let start = found, context.hidden.contains(where: { $0.contains(start) }) {
391 found = buffer.previousHeadingStart(before: start)
392 }
393 buffer.point = found ?? 0
394 }
395 }
396}
397
398/// C-c C-f: `org-forward-heading-same-level`.
399public struct ForwardSameLevel: OrgCommand {
400 public init() {}
401 public var id: String { "org.heading.forward-same-level" }
402 public var title: String { "Next Heading at Same Level" }
403 public func applies(in context: EditContext) -> Bool { true }
404 public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.forwardHeadingSameLevel(1) } }
405}
406
407/// C-c C-b: `org-backward-heading-same-level`.
408public struct BackwardSameLevel: OrgCommand {
409 public init() {}
410 public var id: String { "org.heading.backward-same-level" }
411 public var title: String { "Previous Heading at Same Level" }
412 public func applies(in context: EditContext) -> Bool { true }
413 public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.forwardHeadingSameLevel(-1) } }
414}
415
416/// C-c C-u: `outline-up-heading`.
417public struct UpHeading: OrgCommand {
418 public init() {}
419 public var id: String { "org.heading.up" }
420 public var title: String { "Up to Parent Heading" }
421 public func applies(in context: EditContext) -> Bool { true }
422 public func run(in context: EditContext) -> CommandStep {
423 headingCommand(context) { buffer in
424 do {
425 try buffer.backToHeading()
426 } catch {
427 throw EmacsBuffer.UserError(message: "before first heading")
428 }
429 let start = buffer.outlineLevel
430 guard start > 1 else { throw EmacsBuffer.UserError(message: "Already at top level of the outline") }
431 var level = start
432 while level >= start, !buffer.bobp {
433 buffer.outlinePreviousHeading()
434 level = buffer.outlineLevel
435 }
436 }
437 }
438}
Tests/OrgCoreTests/EmacsOracle.swift +2
@@ -44,6 +44,8 @@ enum EmacsOracle {
44 (with-temp-buffer 44 (with-temp-buffer
45 (insert (alist-get 'text c)) 45 (insert (alist-get 'text c))
46 (org-mode) 46 (org-mode)
47 ;; `#+STARTUP' may fold the buffer; cases run with everything visible.
48 (org-fold-show-all)
47 (goto-char (alist-get 'point c)) 49 (goto-char (alist-get 'point c))
48 ;; As an interactive call, not a repeat: some commands cycle differently on repeats. 50 ;; As an interactive call, not a repeat: some commands cycle differently on repeats.
49 (setq this-command 'orgstar-oracle last-command nil) 51 (setq this-command 'orgstar-oracle last-command nil)
Tests/OrgCoreTests/HeadingCommandTests.swift +32 −30
@@ -61,7 +61,7 @@ struct HeadingOracleTests {
61 61
62 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) 62 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
63 func headingCommandsMatchEmacs() throws { 63 func headingCommandsMatchEmacs() throws {
64 try compare(Self.variants.map { ($0, EmacsOracle.positions($0)) }) 64 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands)
65 } 65 }
66 66
67 /// Real headings: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every command on up to 400 heading 67 /// Real headings: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every command on up to 400 heading
@@ -86,40 +86,42 @@ struct HeadingOracleTests {
86 samples.append((sample, [parts.start, parts.titleStart, parts.contentEnd])) 86 samples.append((sample, [parts.start, parts.titleStart, parts.contentEnd]))
87 } 87 }
88 } 88 }
89 try compare(samples) 89 try compareWithEmacs(samples, Self.commands)
90 } 90 }
91}
91 92
92 func compare(_ inputs: [(text: String, carets: [Int])]) throws { 93/// Runs every command at every caret of every input here and in Emacs, and records each
93 try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip") 94/// difference in text, caret or failure.
94 var cases: [EmacsOracle.Case] = [] 95func compareWithEmacs(_ inputs: [(text: String, carets: [Int])], _ commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)]) throws {
95 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = [] 96 try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip")
96 for (text, carets) in inputs { 97 var cases: [EmacsOracle.Case] = []
97 for offset in carets { 98 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
98 let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil 99 for (text, carets) in inputs {
99 for (command, form, headingLineOnly) in Self.commands where onHeadingLine || !headingLineOnly { 100 for offset in carets {
100 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form)) 101 let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil
101 let result = runCommand(command, text, caret: offset) 102 for (command, form, headingLineOnly) in commands where onHeadingLine || !headingLineOnly {
102 ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil)) 103 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form))
103 } 104 let result = runCommand(command, text, caret: offset)
105 ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
104 } 106 }
105 } 107 }
106 let theirs = try EmacsOracle.run(cases) 108 }
107 var mismatches = 0 109 let theirs = try EmacsOracle.run(cases)
108 for (mine, emacs) in zip(ours, theirs) { 110 var mismatches = 0
109 let emacsFailed = !emacs.error.isEmpty 111 for (mine, emacs) in zip(ours, theirs) {
110 let emacsCaret = EmacsOracle.offset(emacs.point, in: emacs.text) 112 let emacsFailed = !emacs.error.isEmpty
111 let same = mine.failed == emacsFailed && (emacsFailed || (mine.text == emacs.text && mine.caret == emacsCaret)) 113 let emacsCaret = EmacsOracle.offset(emacs.point, in: emacs.text)
112 if !same { 114 let same = mine.failed == emacsFailed && (emacsFailed || (mine.text == emacs.text && mine.caret == emacsCaret))
113 mismatches += 1 115 if !same {
114 if mismatches <= 15 { 116 mismatches += 1
115 Issue.record(""" 117 if mismatches <= 15 {
116 \(mine.label) 118 Issue.record("""
117 ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)") 119 \(mine.label)
118 emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(emacs.text.debugDescription) @\(emacsCaret)") 120 ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")
119 """) 121 emacs: \(emacsFailed ? "failed: \(emacs.error)" : "\(emacs.text.debugDescription) @\(emacsCaret)")
120 } 122 """)
121 } 123 }
122 } 124 }
123 #expect(mismatches == 0, "\(mismatches) of \(ours.count) cases differ from Emacs")
124 } 125 }
126 #expect(mismatches == 0, "\(mismatches) of \(ours.count) cases differ from Emacs")
125} 127}
Tests/OrgCoreTests/StructureCommandTests.swift added +103
@@ -0,0 +1,103 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct StructureCommandTests {
6 @Test func insertHeading() {
7 #expect(runCommand(InsertHeading(), "* a\nbody\n", caret: 3).text == "* a\n* \nbody\n")
8 #expect(runCommand(InsertHeading(), "* ab\n", caret: 3).text == "* a\n* b\n")
9 #expect(runCommand(InsertHeadingAfterSubtree(), "* a\nbody\n** b\n* c\n", caret: 1).text == "* a\nbody\n** b\n* \n* c\n")
10 #expect(runCommand(InsertTodoHeading(), "* TODO a\n", caret: 8).text == "* TODO a\n* TODO \n")
11 }
12
13 @Test func moveAndLevels() {
14 #expect(runCommand(MoveSubtreeDown(), "* a\nx\n* b\ny\n", caret: 0).text == "* b\ny\n* a\nx\n")
15 #expect(runCommand(MoveSubtreeUp(), "* a\n* b\n", caret: 0).failure != nil)
16 #expect(runCommand(DemoteSubtree(), "* a\n** b\n* c\n", caret: 0).text == "** a\n*** b\n* c\n")
17 #expect(runCommand(PromoteSubtree(), "* a\n", caret: 0).failure != nil)
18 }
19
20 @Test func motion() {
21 let text = "intro\n* a\n** b\n** c\n* d\n"
22 #expect(runCommand(NextHeading(), text, caret: 0).caret == 6)
23 #expect(runCommand(ForwardSameLevel(), text, caret: 10).caret == 15)
24 #expect(runCommand(BackwardSameLevel(), text, caret: 16).caret == 10)
25 #expect(runCommand(UpHeading(), text, caret: 16).caret == 6)
26 #expect(runCommand(UpHeading(), text, caret: 6).failure != nil)
27 }
28
29 @Test func nextHeadingSkipsHiddenOnes() {
30 let text = "* a\n** b\n* c\n"
31 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [0..<0], hidden: [3..<8])
32 guard case .commit(let result) = NextHeading().run(in: context) else {
33 Issue.record("expected a commit")
34 return
35 }
36 #expect(result.selection == [9..<9])
37 }
38}
39
40struct StructureOracleTests {
41 static let variants = [
42 "* a\nbody\n* b\nmore\n",
43 "* a\n\n* b\n\n* c\n",
44 "intro\n* a\n** b\ntext\n** c\n* d\n",
45 "* TODO a :x:\n** DONE b\n* c :y:\n",
46 "* a\n** b\n*** c\n** d\n",
47 "* a\n* b",
48 "text\nmore\n",
49 "\n* a\n\nbody\n\n* b\n",
50 "* TODO [#A] title here :t:\nbody\n",
51 "* a 😀 b\n** c\n",
52 ]
53
54 static let commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)] = [
55 (InsertHeading(), "(org-insert-heading)", false),
56 (InsertHeadingAfterSubtree(), "(org-insert-heading-respect-content)", false),
57 (InsertTodoHeading(), "(org-insert-todo-heading nil)", false),
58 (MoveSubtreeUp(), "(org-move-subtree-up)", false),
59 (MoveSubtreeDown(), "(org-move-subtree-down)", false),
60 (PromoteSubtree(), "(org-promote-subtree)", false),
61 (DemoteSubtree(), "(org-demote-subtree)", false),
62 (NextHeading(), "(org-next-visible-heading 1)", false),
63 (PreviousHeading(), "(org-previous-visible-heading 1)", false),
64 (ForwardSameLevel(), "(org-forward-heading-same-level 1)", false),
65 (BackwardSameLevel(), "(org-backward-heading-same-level 1)", false),
66 (UpHeading(), "(outline-up-heading 1)", false),
67 ]
68
69 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
70 func structureCommandsMatchEmacs() throws {
71 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands)
72 }
73
74 /// Real files: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every structure command in files up to
75 /// 20 KB, with the caret at the start and end of up to eight heading lines per file and on
76 /// a body line, for up to 60 files.
77 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
78 func structureCommandsMatchEmacsOnACorpus() throws {
79 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
80 let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
81 .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
82 var samples: [(String, [Int])] = []
83 for file in files where samples.count < 60 {
84 guard let text = try? String(contentsOf: file, encoding: .utf8), text.utf16.count <= 20_000, !text.contains("\r") else { continue }
85 let ns = text as NSString
86 var carets: [Int] = []
87 var location = 0
88 while location < ns.length, carets.count < 17 {
89 let line = ns.lineRange(for: NSRange(location: location, length: 0))
90 let content = ns.substring(with: line)
91 if content.hasPrefix("* ") || content.hasPrefix("** ") || content.hasPrefix("*** ") {
92 carets.append(line.location)
93 carets.append(NSMaxRange(line) - (content.hasSuffix("\n") ? 1 : 0))
94 } else if carets.count == 2 {
95 carets.append(line.location)
96 }
97 location = NSMaxRange(line)
98 }
99 if !carets.isEmpty { samples.append((text, carets)) }
100 }
101 try compareWithEmacs(samples, Self.commands)
102 }
103}