krz/orgstar

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

Sources/OrgCore/Commands/StructureCommands.swift

85c633a83a4744bef9dde8b52462f9c2a1c037e4
orgstar/Sources/OrgCore/Commands/StructureCommands.swift history · blame · raw

438 lines · 16877 bytes

  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}