Sources/OrgCore/Commands/EmacsBuffer.swift
392 lines · 14608 bytes
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}