Sources/OrgCore/Commands/Refile.swift
144 lines · 5871 bytes
12 symbols in this file
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}