krz/orgstar

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

Sources/OrgCore/Commands/StructureCommands.swift

176fc78498d9ba962fa963bbeca0dd433d9e0bab
orgstar/Sources/OrgCore/Commands/StructureCommands.swift history · blame · raw

441 lines · 17118 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 options.metaReturnMaySplitLine,
 78                      let match = saveExcursion({ () -> [Range<Int>?]? in beginningOfLine(); return lookingAt(complexHeadingPattern) }),
 79                      let title = match[4], title.lowerBound <= point, point <= title.upperBound {
 80                let split = substring(point, title.upperBound)
 81                deleteRegion(point, title.upperBound)
 82                if let blanks = lookingAt("[ \\t]*$") {
 83                    deleteRegion(blanks[0]!.lowerBound, blanks[0]!.upperBound)
 84                } else {
 85                    alignTags(onLineAt: point)
 86                }
 87                endOfLine()
 88                if blank { insert("\n") }
 89                insert("\n" + stars + " ")
 90                addBlankAfter(blank)
 91                if split.contains(where: { !$0.isWhitespace }) { insert(split) }
 92            } else {
 93                endOfLine()
 94                if blank { insert("\n") }
 95                insert("\n" + stars + " ")
 96                addBlankAfter(blank)
 97            }
 98        } else if bolp {
 99            insert(stars + " ")
100            if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
101            addBlankAfter(blank)
102        } else {
103            if !options.metaReturnMaySplitLine { endOfLine() }
104            insert("\n" + stars + " ")
105            if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
106            addBlankAfter(blank)
107        }
108    }
109
110    /// `org-forward-heading-same-level` over all headings, visible or not.
111    func forwardHeadingSameLevel(_ count: Int) throws {
112        let backward = count < 0
113        if beforeFirstHeading {
114            if backward { point = 0 } else { outlineNextHeading() }
115            return
116        }
117        try backToHeading()
118        if !backward { endOfLine() }
119        let level = currentLevel ?? 0
120        var remaining = abs(count)
121        var result = point
122        while remaining > 0, let found = backward ? searchHeadingBackward() : searchHeadingForward() {
123            let l = headingLevel(atLine: found)!
124            if l < level {
125                remaining = 0
126            } else if l == level {
127                remaining -= 1
128                result = point
129            }
130        }
131        point = result
132        forwardLine(0)
133    }
134
135    /// `re-search-forward "^\\*+ " nil 'move`: point to the match end, or the buffer end.
136    /// Returns the heading's line start.
137    func searchHeadingForward() -> Int? {
138        var line = bolp ? point : lineEnd(point) + 1
139        while line <= length {
140            if let level = headingLevel(atLine: line) {
141                point = line + level + 1
142                return line
143            }
144            line = lineEnd(line) + 1
145        }
146        point = length
147        return nil
148    }
149
150    /// `re-search-backward "^\\*+ " nil 'move`: point to the match start, or the buffer start.
151    /// The match must end by point.
152    func searchHeadingBackward() -> Int? {
153        var line = lineStart(point)
154        if let level = headingLevel(atLine: line), line + level + 1 <= point {
155            point = line
156            return line
157        }
158        while line > 0 {
159            line = lineStart(line - 1)
160            if headingLevel(atLine: line) != nil {
161                point = line
162                return line
163            }
164        }
165        point = 0
166        return nil
167    }
168
169    /// `org-promote` or `org-demote` on the heading at point.
170    func changeLevel(by delta: Int) throws {
171        try saveExcursion {
172            try backToHeading()
173            let level = headingLevel(atLine: point)!
174            guard level + delta >= 1 else { throw UserError(message: "Cannot promote to level 0.  UNDO to recover if necessary") }
175            replace(point..<(point + level + 1), with: String(repeating: "*", count: level + delta) + " ")
176            alignTags(onLineAt: point)
177        }
178    }
179
180    /// `org-fix-position-after-promote`.
181    func fixPositionAfterPromote() {
182        let position = point
183        let match = saveExcursion { () -> [Range<Int>?]? in
184            forwardLine(0)
185            return lookingAt(todoLinePattern)
186        }
187        guard let match, match[1]?.upperBound == position || match[2]?.upperBound == position else { return }
188        if eobp || eolp {
189            insert(" ")
190        } else if character(at: point) == 32 {
191            point += 1
192        }
193    }
194
195    /// `org-map-tree` over the subtree at point.
196    func mapTree(_ body: () throws -> Void) throws {
197        try backToHeading()
198        let level = outlineLevel
199        try saveExcursion {
200            try body()
201            while true {
202                outlineNextHeading()
203                guard outlineLevel > level, !eobp else { break }
204                try body()
205            }
206        }
207    }
208
209    /// `org-back-over-empty-lines` with blank lines before headings on (`auto`).
210    func backOverEmptyLines() {
211        let position = point
212        skipBackward(Self.whitespace)
213        forwardLine(1)
214        point = min(point, position)
215    }
216
217    /// `org-move-subtree-down`; negative counts move up.
218    func moveSubtree(_ count: Int) throws {
219        let column = currentColumn
220        try backToHeading()
221        let begin = point
222        endOfSubtree(toHeading: true)
223        if !eobp { point -= 1 }
224        outlineNextHeading()
225        let end = point
226        point = begin
227        for _ in 0..<abs(count) {
228            let found = count > 0 ? nextSibling() : previousSibling()
229            guard found != nil, atHeading, bolp else {
230                point = begin
231                throw UserError(message: "Cannot move past superior level or buffer limit")
232            }
233        }
234        if count > 0 {
235            endOfSubtree(toHeading: true)
236            saveExcursion {
237                backOverEmptyLines()
238                if !bolp { insert("\n") }
239            }
240        }
241        let insertion = marker(point)
242        defer { release(insertion) }
243        let subtree = substring(begin, end)
244        deleteRegion(begin, end)
245        if !bolp, character(at: point) == Self.newline { point += 1 }
246        let at = point
247        insertBeforeMarkers(subtree)
248        insertion.position = at
249        if !bolp { insert("\n") }
250        point = insertion.position
251        skipForward(Self.whitespace)
252        moveToColumn(column)
253    }
254}
255
256private func headingCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
257    commitBuffer(context, body)
258}
259
260// MARK: - Inserting
261
262/// M-RET on a heading or text: `org-insert-heading`.
263public struct InsertHeading: OrgCommand {
264    public init() {}
265    public var id: String { "org.heading.insert" }
266    public var title: String { "Insert Heading" }
267    public func applies(in context: EditContext) -> Bool { true }
268    public func run(in context: EditContext) -> CommandStep {
269        headingCommand(context) { try $0.insertHeading(respectContent: context.options.insertHeadingRespectContent, hidden: context.hidden) }
270    }
271}
272
273/// C-RET: `org-insert-heading-respect-content`.
274public struct InsertHeadingAfterSubtree: OrgCommand {
275    public init() {}
276    public var id: String { "org.heading.insert-after-subtree" }
277    public var title: String { "Insert Heading After Subtree" }
278    public func applies(in context: EditContext) -> Bool { true }
279    public func run(in context: EditContext) -> CommandStep {
280        headingCommand(context) { try $0.insertHeading(respectContent: true, hidden: context.hidden) }
281    }
282}
283
284/// M-S-RET on a heading or text: `org-insert-todo-heading`.
285public struct InsertTodoHeading: OrgCommand {
286    public init() {}
287    public var id: String { "org.heading.insert-todo" }
288    public var title: String { "Insert TODO Heading" }
289    public func applies(in context: EditContext) -> Bool { true }
290    public func run(in context: EditContext) -> CommandStep {
291        headingCommand(context) { buffer in
292            try buffer.insertHeading(respectContent: context.options.insertHeadingRespectContent, hidden: context.hidden)
293            let previous = buffer.saveExcursion { () -> String? in
294                try? buffer.forwardHeadingSameLevel(-1)
295                guard let match = buffer.lookingAt(buffer.todoLinePattern), let keyword = match[2] else { return nil }
296                return buffer.substring(keyword.lowerBound, keyword.upperBound)
297            }
298            let keyword = previous.flatMap { context.tree.settings.isDone($0) ? nil : $0 } ?? buffer.keywordsInOrder.first ?? "TODO"
299            buffer.forwardLine(0)
300            if let stars = buffer.lookingAt("\\*+ ") {
301                buffer.point = stars[0]!.upperBound
302                buffer.insert(keyword + " ")
303            }
304            buffer.updateParentTodoStatistics()
305        }
306    }
307}
308
309// MARK: - Moving and levels
310
311/// M-up on a heading: `org-move-subtree-up`.
312public struct MoveSubtreeUp: OrgCommand {
313    public init() {}
314    public var id: String { "org.subtree.up" }
315    public var title: String { "Move Subtree Up" }
316    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
317    public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.moveSubtree(-1) } }
318}
319
320/// M-down on a heading: `org-move-subtree-down`.
321public struct MoveSubtreeDown: OrgCommand {
322    public init() {}
323    public var id: String { "org.subtree.down" }
324    public var title: String { "Move Subtree Down" }
325    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
326    public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.moveSubtree(1) } }
327}
328
329/// M-S-left on a heading: `org-promote-subtree`.
330public struct PromoteSubtree: OrgCommand {
331    public init() {}
332    public var id: String { "org.subtree.promote" }
333    public var title: String { "Promote Subtree" }
334    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
335    public func run(in context: EditContext) -> CommandStep {
336        headingCommand(context) { buffer in
337            try buffer.saveExcursion {
338                try buffer.backToHeading()
339                try buffer.mapTree { try buffer.changeLevel(by: -1) }
340            }
341            buffer.fixPositionAfterPromote()
342        }
343    }
344}
345
346/// M-S-right on a heading: `org-demote-subtree`.
347public struct DemoteSubtree: OrgCommand {
348    public init() {}
349    public var id: String { "org.subtree.demote" }
350    public var title: String { "Demote Subtree" }
351    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
352    public func run(in context: EditContext) -> CommandStep {
353        headingCommand(context) { buffer in
354            try buffer.saveExcursion {
355                try buffer.backToHeading()
356                try buffer.mapTree { try buffer.changeLevel(by: 1) }
357            }
358            buffer.fixPositionAfterPromote()
359        }
360    }
361}
362
363// MARK: - Motion
364
365/// C-c C-n: `org-next-visible-heading`.
366public struct NextHeading: OrgCommand {
367    public init() {}
368    public var id: String { "org.heading.next" }
369    public var title: String { "Next Heading" }
370    public func applies(in context: EditContext) -> Bool { true }
371    public func run(in context: EditContext) -> CommandStep {
372        headingCommand(context) { buffer in
373            buffer.endOfLine()
374            var found = buffer.nextHeadingStart(after: buffer.point)
375            while let start = found, context.hidden.contains(where: { $0.contains(start) }) {
376                found = buffer.nextHeadingStart(after: start)
377            }
378            buffer.point = found ?? buffer.length
379        }
380    }
381}
382
383/// C-c C-p: `org-previous-visible-heading`.
384public struct PreviousHeading: OrgCommand {
385    public init() {}
386    public var id: String { "org.heading.previous" }
387    public var title: String { "Previous Heading" }
388    public func applies(in context: EditContext) -> Bool { true }
389    public func run(in context: EditContext) -> CommandStep {
390        headingCommand(context) { buffer in
391            buffer.forwardLine(0)
392            var found = buffer.previousHeadingStart(before: buffer.point)
393            while let start = found, context.hidden.contains(where: { $0.contains(start) }) {
394                found = buffer.previousHeadingStart(before: start)
395            }
396            buffer.point = found ?? 0
397        }
398    }
399}
400
401/// C-c C-f: `org-forward-heading-same-level`.
402public struct ForwardSameLevel: OrgCommand {
403    public init() {}
404    public var id: String { "org.heading.forward-same-level" }
405    public var title: String { "Next Heading at Same Level" }
406    public func applies(in context: EditContext) -> Bool { true }
407    public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.forwardHeadingSameLevel(1) } }
408}
409
410/// C-c C-b: `org-backward-heading-same-level`.
411public struct BackwardSameLevel: OrgCommand {
412    public init() {}
413    public var id: String { "org.heading.backward-same-level" }
414    public var title: String { "Previous Heading at Same Level" }
415    public func applies(in context: EditContext) -> Bool { true }
416    public func run(in context: EditContext) -> CommandStep { headingCommand(context) { try $0.forwardHeadingSameLevel(-1) } }
417}
418
419/// C-c C-u: `outline-up-heading`.
420public struct UpHeading: OrgCommand {
421    public init() {}
422    public var id: String { "org.heading.up" }
423    public var title: String { "Up to Parent Heading" }
424    public func applies(in context: EditContext) -> Bool { true }
425    public func run(in context: EditContext) -> CommandStep {
426        headingCommand(context) { buffer in
427            do {
428                try buffer.backToHeading()
429            } catch {
430                throw EmacsBuffer.UserError(message: "before first heading")
431            }
432            let start = buffer.outlineLevel
433            guard start > 1 else { throw EmacsBuffer.UserError(message: "Already at top level of the outline") }
434            var level = start
435            while level >= start, !buffer.bobp {
436                buffer.outlinePreviousHeading()
437                level = buffer.outlineLevel
438            }
439        }
440    }
441}