Sources/OrgCore/Commands/EmacsBuffer.swift
510 lines · 20083 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 /// Inserting at the marker moves it past the text, as `set-marker-insertion-type` t.
13 let advances: Bool
14 init(_ position: Int, advances: Bool = false) {
15 self.position = position
16 self.advances = advances
17 }
18 }
19
20 struct UserError: Error {
21 let message: String
22 }
23
24 /// The command needs an answer first.
25 struct Ask: Error {
26 let prompt: Prompt
27 }
28
29 private(set) var text: NSMutableString
30 var point: Int
31 private var markers: [Marker] = []
32 let settings: OrgSettings
33 private var visibleCache: [String: VisibleLine] = [:]
34 /// Groups of the last successful match, as Emacs's match data.
35 var lastMatch: [Range<Int>?] = []
36 /// The clock and calendar commands see.
37 var now = Date()
38 var calendar = Calendar.current
39 /// `org-last-inserted-timestamp`.
40 var lastInsertedTimestamp: String?
41 /// Shown after the command commits, as `message`.
42 var message: String?
43
44 let options: EditingOptions
45
46 init(_ text: String, point: Int, settings: OrgSettings = .default, options: EditingOptions = .org) {
47 self.text = NSMutableString(string: text)
48 self.point = point
49 self.settings = settings
50 self.options = options
51 unchangedPrefix = self.text.length
52 unchangedSuffix = self.text.length
53 }
54
55 /// Lengths of the start and end of the text no edit has touched, so the result's diff can
56 /// look only between them.
57 private(set) var unchangedPrefix: Int
58 private(set) var unchangedSuffix: Int
59
60 private func touched(_ range: Range<Int>) {
61 unchangedPrefix = min(unchangedPrefix, range.lowerBound)
62 unchangedSuffix = min(unchangedSuffix, length - range.upperBound)
63 }
64
65 /// The text as a `String` backed by an immutable `NSString`, copied once per change rather
66 /// than per regexp search; regexps read it without converting.
67 var string: String {
68 if let cachedString { return cachedString as String }
69 let copy = text.copy() as! NSString
70 cachedString = copy
71 return copy as String
72 }
73
74 private var cachedString: NSString?
75 var length: Int { text.length }
76
77 func character(at offset: Int) -> unichar? {
78 offset >= 0 && offset < length ? text.character(at: offset) : nil
79 }
80
81 static let newline = unichar(10)
82
83 // MARK: - Markers
84
85 func marker(_ position: Int, advances: Bool = false) -> Marker {
86 let marker = Marker(position, advances: advances)
87 markers.append(marker)
88 return marker
89 }
90
91 func release(_ marker: Marker) {
92 markers.removeAll { $0 === marker }
93 }
94
95 /// Runs `body` and puts point back where it was, following edits as a marker does.
96 @discardableResult
97 func saveExcursion<T>(_ body: () throws -> T) rethrows -> T {
98 let saved = marker(point)
99 defer {
100 point = saved.position
101 release(saved)
102 }
103 return try body()
104 }
105
106 // MARK: - Editing
107
108 /// `insert` at point.
109 func insert(_ string: String) {
110 let count = (string as NSString).length
111 guard count > 0 else { return }
112 touched(point..<point)
113 text.insert(string, at: point)
114 cachedString = nil
115 for marker in markers where marker.position > point || (marker.advances && marker.position == point) { marker.position += count }
116 point += count
117 }
118
119 /// `insert-before-markers` at point: markers at point move past the text too.
120 func insertBeforeMarkers(_ string: String) {
121 let count = (string as NSString).length
122 guard count > 0 else { return }
123 touched(point..<point)
124 text.insert(string, at: point)
125 cachedString = nil
126 for marker in markers where marker.position >= point { marker.position += count }
127 point += count
128 }
129
130 func deleteRegion(_ start: Int, _ end: Int) {
131 let (lower, upper) = (min(start, end), max(start, end))
132 guard upper > lower else { return }
133 touched(lower..<upper)
134 text.deleteCharacters(in: NSRange(lower..<upper))
135 cachedString = nil
136 func map(_ position: Int) -> Int {
137 position <= lower ? position : position >= upper ? position - (upper - lower) : lower
138 }
139 point = map(point)
140 for marker in markers { marker.position = map(marker.position) }
141 }
142
143 func substring(_ start: Int, _ end: Int) -> String {
144 text.substring(with: NSRange(min(start, end)..<max(start, end)))
145 }
146
147 /// `replace-match` over `range`: positions strictly inside go to its start, positions at
148 /// or after its end shift with the text.
149 func replace(_ range: Range<Int>, with replacement: String) {
150 let delta = (replacement as NSString).length - range.count
151 touched(range)
152 text.replaceCharacters(in: NSRange(range), with: replacement)
153 cachedString = nil
154 func map(_ position: Int) -> Int {
155 if position >= range.upperBound, position > range.lowerBound { return position + delta }
156 return position > range.lowerBound ? range.lowerBound : position
157 }
158 point = map(point)
159 for marker in markers { marker.position = map(marker.position) }
160 }
161
162 // MARK: - Motion
163
164 var bolp: Bool { point == 0 || character(at: point - 1) == Self.newline }
165 var eolp: Bool { point == length || character(at: point) == Self.newline }
166 var bobp: Bool { point == 0 }
167 var eobp: Bool { point == length }
168
169 func lineStart(_ offset: Int) -> Int {
170 var at = min(offset, length)
171 while at > 0, character(at: at - 1) != Self.newline { at -= 1 }
172 return at
173 }
174
175 func lineEnd(_ offset: Int) -> Int {
176 var at = max(offset, 0)
177 while at < length, character(at: at) != Self.newline { at += 1 }
178 return at
179 }
180
181 func beginningOfLine() { point = lineStart(point) }
182 func endOfLine() { point = lineEnd(point) }
183
184 /// `forward-line`: to the start of the line `n` lines away, stopping at the buffer ends.
185 @discardableResult
186 func forwardLine(_ n: Int) -> Int {
187 point = lineStart(point)
188 var remaining = n
189 while remaining > 0 {
190 let end = lineEnd(point)
191 if end == length {
192 point = length
193 return remaining
194 }
195 point = end + 1
196 remaining -= 1
197 }
198 while remaining < 0 {
199 if point == 0 { return remaining }
200 point = lineStart(point - 1)
201 remaining += 1
202 }
203 return 0
204 }
205
206 func skipForward(_ set: Set<unichar>) {
207 while let c = character(at: point), set.contains(c) { point += 1 }
208 }
209
210 func skipBackward(_ set: Set<unichar>) {
211 while let c = character(at: point - 1), set.contains(c) { point -= 1 }
212 }
213
214 static let blanks: Set<unichar> = [32, 9]
215 static let whitespace: Set<unichar> = [32, 9, 10, 13]
216
217 /// Display column of point, counting only what Emacs shows.
218 var currentColumn: Int {
219 let start = lineStart(point)
220 return visibleLine(start).column(at: point - start)
221 }
222
223 /// The display widths of the line starting at `start`, seen with three lines on either
224 /// side so that links spanning lines are whole. Cached by that text.
225 func visibleLine(_ start: Int) -> VisibleLine {
226 let (from, to) = saveExcursion { () -> (Int, Int) in
227 point = start
228 forwardLine(-3)
229 let from = point
230 point = start
231 forwardLine(3)
232 return (from, lineEnd(point))
233 }
234 let context = substring(from, to)
235 let range = (start - from)..<(lineEnd(start) - from)
236 let key = context + "\u{0}\(range.lowerBound)"
237 if let cached = visibleCache[key] { return cached }
238 let line = VisibleLine(context, line: range, options: options, settings: settings)
239 if visibleCache.count > 256 { visibleCache.removeAll() }
240 visibleCache[key] = line
241 return line
242 }
243
244 /// `move-to-column`: as far as `target` on this line, or the line's end. With `force`, a
245 /// tab spanning `target` becomes spaces (`indent-tabs-mode` nil) so point lands on it.
246 func moveToColumn(_ target: Int, force: Bool = false) {
247 let start = lineStart(point)
248 point = start + visibleLine(start).offset(ofColumn: target)
249 guard force, currentColumn > target, character(at: point - 1) == 9 else { return }
250 let tabEnd = currentColumn
251 point -= 1
252 let tabStart = currentColumn
253 deleteRegion(point, point + 1)
254 insert(String(repeating: " ", count: tabEnd - tabStart))
255 point -= tabEnd - target
256 }
257
258 // MARK: - Outline
259
260 /// Stars of a heading line starting at `lineStart`, or nil.
261 func headingLevel(atLine lineStart: Int) -> Int? {
262 var at = lineStart
263 while character(at: at) == 42 { at += 1 }
264 return at > lineStart && character(at: at) == 32 ? at - lineStart : nil
265 }
266
267 var atHeading: Bool { headingLevel(atLine: lineStart(point)) != nil }
268
269 /// Start of the first heading line after `offset`'s line, or nil.
270 func nextHeadingStart(after offset: Int) -> Int? {
271 var line = lineEnd(offset)
272 while line < length {
273 line += 1
274 if headingLevel(atLine: line) != nil { return line }
275 line = lineEnd(line)
276 }
277 return nil
278 }
279
280 /// Start of the last heading line before `offset`'s line, or nil.
281 func previousHeadingStart(before offset: Int) -> Int? {
282 var line = lineStart(offset)
283 while line > 0 {
284 line = lineStart(line - 1)
285 if headingLevel(atLine: line) != nil { return line }
286 }
287 return nil
288 }
289
290 /// `org-outline-level`: the level of the heading at or before point's line, or 0.
291 var outlineLevel: Int {
292 let start = lineStart(point)
293 if let level = headingLevel(atLine: start) { return level }
294 return previousHeadingStart(before: start).flatMap { headingLevel(atLine: $0) } ?? 0
295 }
296
297 var currentLevel: Int? { outlineLevel > 0 ? outlineLevel : nil }
298
299 var beforeFirstHeading: Bool { outlineLevel == 0 }
300
301 /// `org-back-to-heading`.
302 @discardableResult
303 func backToHeading() throws -> Int {
304 let start = lineStart(point)
305 if headingLevel(atLine: start) != nil {
306 point = start
307 } else if let previous = previousHeadingStart(before: start) {
308 point = previous
309 } else {
310 throw UserError(message: "Before first headline at position \(point + 1) in buffer")
311 }
312 return point
313 }
314
315 /// `outline-next-heading`: the next heading line, or the buffer end. True when found.
316 @discardableResult
317 func outlineNextHeading() -> Bool {
318 if bolp, !eobp { point += 1 }
319 // A heading line starting at point counts, as `re-search-forward "^\\*+ "` would.
320 if bolp, headingLevel(atLine: point) != nil { return true }
321 guard let next = nextHeadingStart(after: point) else {
322 point = length
323 return false
324 }
325 point = next
326 return true
327 }
328
329 /// `outline-previous-heading`: the previous heading line, or the buffer start.
330 func outlinePreviousHeading() {
331 // `re-search-backward` matches must end by point: a heading on this line counts once
332 // point is past its stars and space.
333 let start = lineStart(point)
334 if let level = headingLevel(atLine: start), point >= start + level + 1 {
335 point = start
336 return
337 }
338 point = previousHeadingStart(before: point) ?? 0
339 }
340
341 /// `org-end-of-subtree`.
342 @discardableResult
343 func endOfSubtree(toHeading: Bool = false) -> Int {
344 if beforeFirstHeading {
345 point = length
346 } else {
347 let start = try! backToHeading()
348 let level = headingLevel(atLine: start)!
349 var next = nextHeadingStart(after: start)
350 while let candidate = next, headingLevel(atLine: candidate)! > level {
351 next = nextHeadingStart(after: candidate)
352 }
353 point = next ?? length
354 }
355 if !toHeading, let c = character(at: point - 1), c == 10 || c == 13 {
356 point -= 1
357 skipBackward([10, 13, 9, 32])
358 }
359 return point
360 }
361
362 /// `org-get-next-sibling`.
363 func nextSibling() -> Int? {
364 let level = outlineLevel
365 outlineNextHeading()
366 while !eobp, outlineLevel > level { outlineNextHeading() }
367 return eobp || outlineLevel < level ? nil : point
368 }
369
370 /// `org-get-previous-sibling`.
371 func previousSibling() -> Int? {
372 let start = point
373 let level = outlineLevel
374 outlinePreviousHeading()
375 guard point != start, atHeading else { return nil }
376 while outlineLevel > level, !bobp { outlinePreviousHeading() }
377 return outlineLevel < level ? nil : point
378 }
379
380 /// `org--line-empty-p -1`.
381 var previousLineEmpty: Bool {
382 guard !bobp else { return false }
383 return saveExcursion {
384 forwardLine(-1)
385 skipForward(Self.blanks)
386 return eolp
387 }
388 }
389
390 // MARK: - Regular expressions
391
392 static let lookingOptions: NSRegularExpression.MatchingOptions = [.anchored, .withoutAnchoringBounds, .withTransparentBounds]
393 static let searchOptions: NSRegularExpression.MatchingOptions = [.withoutAnchoringBounds, .withTransparentBounds]
394 nonisolated(unsafe) private static var cache: [String: NSRegularExpression] = [:]
395 private static let cacheLock = NSLock()
396
397 /// A compiled Emacs-style pattern: `^` and `$` match at line boundaries, and a leading `^`
398 /// also matches at the end of a buffer that ends with a newline, as in Emacs.
399 static func regex(_ pattern: String, caseInsensitive: Bool) -> NSRegularExpression {
400 let key = (caseInsensitive ? "i" : "c") + pattern
401 return cacheLock.withLock {
402 if let cached = cache[key] { return cached }
403 let source = pattern.hasPrefix("^") ? "(?:(?<=\\n)|(?<![\\s\\S]))" + pattern.dropFirst() : pattern
404 let compiled = try! NSRegularExpression(pattern: source, options: caseInsensitive ? [.anchorsMatchLines, .caseInsensitive] : [.anchorsMatchLines])
405 cache[key] = compiled
406 return compiled
407 }
408 }
409
410 /// `looking-at`: the groups of `pattern` matched at point (`^` and `$` match at lines),
411 /// or nil. Unmatched groups are nil.
412 func lookingAt(_ pattern: String) -> [Range<Int>?]? {
413 let regex = Self.regex(pattern, caseInsensitive: false)
414 guard let match = regex.firstMatch(in: string, options: Self.lookingOptions, range: NSRange(point..<length)) else { return nil }
415 lastMatch = (0..<match.numberOfRanges).map { i in
416 let range = match.range(at: i)
417 return range.location == NSNotFound ? nil : range.location..<NSMaxRange(range)
418 }
419 return lastMatch
420 }
421
422 /// `org-complex-heading-regexp`: 1 stars, 2 keyword, 3 priority, 4 title, 5 tags.
423 var complexHeadingPattern: String {
424 "^(\\*+)(?: +(\(keywordAlternation)))?(?: +(\\[#(?:[A-Z]|[0-9]|[1-5][0-9]|6[0-4])\\]))?(?: +(.*?))??(?:[ \t]+(:[[:alnum:]_@#%:]+:))?[ \t]*$"
425 }
426
427 /// `org-todo-line-regexp`: 1 stars, 2 keyword, 3 title.
428 var todoLinePattern: String {
429 "^(\\*+)(?: +(\(keywordAlternation)))?(?: +(.*?))?[ \t]*$"
430 }
431
432 private var keywordAlternation: String {
433 keywordsInOrder.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|")
434 }
435
436 /// `org-todo-keywords-1`.
437 var keywordsInOrder: [String] {
438 settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) }
439 }
440
441 // MARK: - Tags
442
443 /// `org-align-tags` on the line at `offset`, moving point and markers on the line as the
444 /// tag alignment rules say.
445 func alignTags(onLineAt offset: Int) {
446 let start = lineStart(offset)
447 let end = lineEnd(offset)
448 let line = substring(start, end)
449 var plain = LineBuffer(line, caret: nil)
450 OrgCore.alignTags(&plain, options: options)
451 guard plain.string != line else { return }
452 func map(_ position: Int) -> Int {
453 guard position >= start, position <= end else {
454 return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position
455 }
456 var buffer = LineBuffer(line, caret: position - start)
457 OrgCore.alignTags(&buffer, options: options)
458 return start + buffer.caret!
459 }
460 let newPoint = map(point)
461 let newMarkers = markers.map { map($0.position) }
462 touched(start..<end)
463 text.replaceCharacters(in: NSRange(start..<end), with: plain.string)
464 cachedString = nil
465 point = newPoint
466 for (marker, position) in zip(markers, newMarkers) { marker.position = position }
467 }
468}
469
470/// Runs a buffer-editing body against a context and turns the result into one minimal edit
471/// and the new caret, with the buffer's message. A user error becomes `.failed`.
472func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
473 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
474 buffer.now = context.now
475 buffer.calendar = context.calendar
476 do {
477 try body(buffer)
478 } catch let error as EmacsBuffer.UserError {
479 return .failed(error.message)
480 } catch let ask as EmacsBuffer.Ask {
481 return .prompt(ask.prompt)
482 } catch {
483 return .failed("\(error)")
484 }
485 // Only the span between the untouched start and end can differ.
486 let oldText = context.text.utf16
487 let oldCount = oldText.count
488 let newCount = buffer.length
489 let lower = min(buffer.unchangedPrefix, oldCount, newCount)
490 let window = max(0, min(buffer.unchangedSuffix, oldCount - lower, newCount - lower))
491 let old = Array(oldText[oldText.index(oldText.startIndex, offsetBy: lower)..<oldText.index(oldText.startIndex, offsetBy: oldCount - window)])
492 var newBuffer = [unichar](repeating: 0, count: newCount - window - lower)
493 buffer.text.getCharacters(&newBuffer, range: NSRange(lower..<(newCount - window)))
494 let new = newBuffer
495 var prefix = 0
496 let shorter = min(old.count, new.count)
497 while prefix < shorter, old[prefix] == new[prefix] { prefix += 1 }
498 var suffix = 0
499 while suffix < shorter - prefix, old[old.count - 1 - suffix] == new[new.count - 1 - suffix] { suffix += 1 }
500 // Keep surrogate pairs whole.
501 while prefix > 0, prefix < old.count, UTF16.isTrailSurrogate(old[prefix]) { prefix -= 1 }
502 while suffix > 0, UTF16.isTrailSurrogate(old[old.count - suffix]) { suffix -= 1 }
503 let caret = buffer.point
504 let effects: [Effect] = buffer.message.map { [.message($0)] } ?? []
505 guard old.count != new.count || prefix != old.count else {
506 return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret], effects: effects))
507 }
508 let edit = TextEdit(range: (lower + prefix)..<(lower + old.count - suffix), replacement: String(decoding: new[prefix..<(new.count - suffix)], as: UTF16.self))
509 return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret], effects: effects))
510}