krz/orgstar

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

Sources/OrgCore/Commands/Refile.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Sources/OrgCore/Commands/Refile.swift history · blame · raw

144 lines · 5871 bytes

  1import Foundation
  2
  3// Refiling, ported from org-refile.el and org.el (Org 9.8.7) with `org-log-refile` nil,
  4// `org-reverse-note-order` nil and `org-refile-keep` nil.
  5
  6extension EmacsBuffer {
  7    /// `org-copy-subtree` called from Lisp: the subtree at point, with the blank lines after it.
  8    func copySubtree() throws -> String {
  9        try saveExcursion {
 10            let begin = try backToHeading()
 11            skipForward(Self.whitespace)
 12            endOfSubtree(toHeading: true)
 13            return point > begin ? substring(begin, point) : ""
 14        }
 15    }
 16
 17    /// `org-map-region`.
 18    func mapRegion(_ begin: Int, _ end: Int, _ body: () throws -> Void) rethrows {
 19        try saveExcursion {
 20            let end = marker(end)
 21            defer { release(end) }
 22            point = begin
 23            if let heading = searchHeadingForward(), point < end.position {
 24                point = heading
 25                try body()
 26            }
 27            while true {
 28                outlineNextHeading()
 29                guard point < end.position, !eobp else { break }
 30                try body()
 31            }
 32        }
 33    }
 34
 35    /// `org-paste-subtree` with a numeric level; `limit` ends the buffer there, as narrowing does.
 36    func pasteSubtree(level: Int, tree: String, limit: Int? = nil) throws {
 37        let oldLevel = tree.range(of: "(?m)^\\*+ ", options: .regularExpression).map { tree[$0].count - 1 } ?? -1
 38        let shift = oldLevel == -1 || oldLevel == level ? 0 : level - oldLevel
 39        if !(bolp && atHeading) {
 40            endOfLine()
 41            point = min(nextHeadingStart(after: point) ?? length, limit ?? length)
 42            if !bolp { insert("\n") }
 43        }
 44        var begin = point
 45        insert(tree)
 46        if !tree.hasSuffix("\n") { insert("\n") }
 47        let end = marker(point)
 48        defer { release(end) }
 49        point = begin
 50        skipForward(Self.whitespace)
 51        begin = point
 52        var remaining = shift
 53        while remaining != 0 {
 54            try mapRegion(begin, end.position) { try changeLevel(by: remaining > 0 ? 1 : -1) }
 55            remaining += remaining > 0 ? -1 : 1
 56        }
 57        point = begin
 58    }
 59
 60    /// Inserts a refiled subtree as the last child of the heading at `target`, or at the end of
 61    /// the buffer as a top-level entry when `target` is nil.
 62    func refileInsert(_ tree: String, under target: Int?) throws {
 63        try saveExcursion {
 64            let level: Int
 65            if let target {
 66                point = target
 67                level = outlineLevel + 1
 68                let sibling = saveExcursion { nextSibling() }
 69                if let sibling { point = sibling } else { endOfSubtree(toHeading: true) }
 70            } else {
 71                level = 1
 72                point = length
 73            }
 74            if !bolp { insert("\n") }
 75            try pasteSubtree(level: level, tree: tree)
 76            alignTags(onLineAt: point)
 77        }
 78    }
 79
 80    /// Range of the subtree at point with the blank lines after it, as org-refile deletes it.
 81    func subtreeRange() throws -> Range<Int> {
 82        try saveExcursion {
 83            let begin = try backToHeading()
 84            let end = endOfSubtree(toHeading: true)
 85            return begin..<end
 86        }
 87    }
 88
 89    /// `org-refile` to a heading in this buffer, or to its end when `target` is nil.
 90    func refileWithinBuffer(to target: Int?) throws {
 91        let subtree = try subtreeRange()
 92        if let target, subtree.contains(target) {
 93            throw UserError(message: "Cannot refile to position inside the tree or region")
 94        }
 95        let tree = try copySubtree()
 96        let origin = marker(point, advances: true)
 97        defer { release(origin) }
 98        try refileInsert(tree, under: target)
 99        point = origin.position
100        let range = try subtreeRange()
101        deleteRegion(range.lowerBound, range.upperBound)
102    }
103}
104
105/// C-c C-w within one file: moves the subtree at the caret under the heading starting at the
106/// answer's offset, or to the end of the file for an empty answer.
107public struct RefileWithinFile: OrgCommand {
108    public init() {}
109    public var id: String { "org.refile.local" }
110    public var title: String { "Refile Within File" }
111    public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
112
113    public func run(in context: EditContext) -> CommandStep {
114        guard let answer = context.answers["refile"] else {
115            return .prompt(Prompt(key: "refile", message: "Refile to:"))
116        }
117        return commitBuffer(context) { try $0.refileWithinBuffer(to: Int(answer)) }
118    }
119}
120
121/// Refiling into another file: the edit to the source and the target's new text.
122public enum Refile {
123    public struct Result: Sendable, Equatable {
124        public let source: EditResult
125        public let target: TextEdit
126    }
127
128    public static func toOtherFile(source context: EditContext, target text: String, under heading: Int?) -> Result? {
129        let target = EmacsBuffer(text, point: 0, settings: context.tree.settings, options: context.options)
130        var tree = ""
131        let step = commitBuffer(context) { buffer in
132            tree = try buffer.copySubtree()
133            try target.refileInsert(tree, under: heading)
134            let range = try buffer.subtreeRange()
135            buffer.deleteRegion(range.lowerBound, range.upperBound)
136        }
137        guard case .commit(let source) = step else { return nil }
138        let edit = commitBuffer(EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [0..<0])) { buffer in
139            buffer.replace(0..<buffer.length, with: target.string)
140        }
141        guard case .commit(let result) = edit else { return nil }
142        return Result(source: source, target: result.edits.first ?? TextEdit(range: 0..<0, replacement: ""))
143    }
144}