import Foundation /// A whole-text buffer with Emacs's point and marker rules, for porting org commands that /// edit several lines. Offsets are UTF-16, as everywhere in `OrgCore`. /// /// Inserting at point moves point past the text; a marker at the insertion point stays before /// it unless the insertion is `insertBeforeMarkers`. Deleting a range moves positions inside /// it to its start. `saveExcursion` restores point from a marker, as Emacs does. final class EmacsBuffer { final class Marker { var position: Int init(_ position: Int) { self.position = position } } struct UserError: Error { let message: String } private(set) var text: NSMutableString var point: Int private var markers: [Marker] = [] let settings: OrgSettings init(_ text: String, point: Int, settings: OrgSettings = .default) { self.text = NSMutableString(string: text) self.point = point self.settings = settings } var string: String { text as String } var length: Int { text.length } func character(at offset: Int) -> unichar? { offset >= 0 && offset < length ? text.character(at: offset) : nil } static let newline = unichar(10) // MARK: - Markers func marker(_ position: Int) -> Marker { let marker = Marker(position) markers.append(marker) return marker } func release(_ marker: Marker) { markers.removeAll { $0 === marker } } /// Runs `body` and puts point back where it was, following edits as a marker does. @discardableResult func saveExcursion(_ body: () throws -> T) rethrows -> T { let saved = marker(point) defer { point = saved.position release(saved) } return try body() } // MARK: - Editing /// `insert` at point. func insert(_ string: String) { let count = (string as NSString).length guard count > 0 else { return } text.insert(string, at: point) for marker in markers where marker.position > point { marker.position += count } point += count } /// `insert-before-markers` at point: markers at point move past the text too. func insertBeforeMarkers(_ string: String) { let count = (string as NSString).length guard count > 0 else { return } text.insert(string, at: point) for marker in markers where marker.position >= point { marker.position += count } point += count } func deleteRegion(_ start: Int, _ end: Int) { let (lower, upper) = (min(start, end), max(start, end)) guard upper > lower else { return } text.deleteCharacters(in: NSRange(lower.. Int { position <= lower ? position : position >= upper ? position - (upper - lower) : lower } point = map(point) for marker in markers { marker.position = map(marker.position) } } func substring(_ start: Int, _ end: Int) -> String { text.substring(with: NSRange(min(start, end).., with replacement: String) { let delta = (replacement as NSString).length - range.count text.replaceCharacters(in: NSRange(range), with: replacement) func map(_ position: Int) -> Int { if position >= range.upperBound, position > range.lowerBound { return position + delta } return position > range.lowerBound ? range.lowerBound : position } point = map(point) for marker in markers { marker.position = map(marker.position) } } // MARK: - Motion var bolp: Bool { point == 0 || character(at: point - 1) == Self.newline } var eolp: Bool { point == length || character(at: point) == Self.newline } var bobp: Bool { point == 0 } var eobp: Bool { point == length } func lineStart(_ offset: Int) -> Int { var at = min(offset, length) while at > 0, character(at: at - 1) != Self.newline { at -= 1 } return at } func lineEnd(_ offset: Int) -> Int { var at = max(offset, 0) while at < length, character(at: at) != Self.newline { at += 1 } return at } func beginningOfLine() { point = lineStart(point) } func endOfLine() { point = lineEnd(point) } /// `forward-line`: to the start of the line `n` lines away, stopping at the buffer ends. @discardableResult func forwardLine(_ n: Int) -> Int { point = lineStart(point) var remaining = n while remaining > 0 { let end = lineEnd(point) if end == length { point = length return remaining } point = end + 1 remaining -= 1 } while remaining < 0 { if point == 0 { return remaining } point = lineStart(point - 1) remaining += 1 } return 0 } func skipForward(_ set: Set) { while let c = character(at: point), set.contains(c) { point += 1 } } func skipBackward(_ set: Set) { while let c = character(at: point - 1), set.contains(c) { point -= 1 } } static let blanks: Set = [32, 9] static let whitespace: Set = [32, 9, 10, 13] var currentColumn: Int { column(of: substring(lineStart(point), point)) } /// `move-to-column`: as far as `target` on this line, or the line's end. func moveToColumn(_ target: Int) { let start = lineStart(point) let line = LineBuffer(substring(start, lineEnd(point)), caret: nil) point = start + line.offset(ofColumn: target) } // MARK: - Outline /// Stars of a heading line starting at `lineStart`, or nil. func headingLevel(atLine lineStart: Int) -> Int? { var at = lineStart while character(at: at) == 42 { at += 1 } return at > lineStart && character(at: at) == 32 ? at - lineStart : nil } var atHeading: Bool { headingLevel(atLine: lineStart(point)) != nil } /// Start of the first heading line after `offset`'s line, or nil. func nextHeadingStart(after offset: Int) -> Int? { var line = lineEnd(offset) while line < length { line += 1 if headingLevel(atLine: line) != nil { return line } line = lineEnd(line) } return nil } /// Start of the last heading line before `offset`'s line, or nil. func previousHeadingStart(before offset: Int) -> Int? { var line = lineStart(offset) while line > 0 { line = lineStart(line - 1) if headingLevel(atLine: line) != nil { return line } } return nil } /// `org-outline-level`: the level of the heading at or before point's line, or 0. var outlineLevel: Int { let start = lineStart(point) if let level = headingLevel(atLine: start) { return level } return previousHeadingStart(before: start).flatMap { headingLevel(atLine: $0) } ?? 0 } var currentLevel: Int? { outlineLevel > 0 ? outlineLevel : nil } var beforeFirstHeading: Bool { outlineLevel == 0 } /// `org-back-to-heading`. @discardableResult func backToHeading() throws -> Int { let start = lineStart(point) if headingLevel(atLine: start) != nil { point = start } else if let previous = previousHeadingStart(before: start) { point = previous } else { throw UserError(message: "Before first headline at position \(point + 1) in buffer") } return point } /// `outline-next-heading`: the next heading line, or the buffer end. True when found. @discardableResult func outlineNextHeading() -> Bool { if bolp, !eobp { point += 1 } // A heading line starting at point counts, as `re-search-forward "^\\*+ "` would. if bolp, headingLevel(atLine: point) != nil { return true } guard let next = nextHeadingStart(after: point) else { point = length return false } point = next return true } /// `outline-previous-heading`: the previous heading line, or the buffer start. func outlinePreviousHeading() { // `re-search-backward` matches must end by point: a heading on this line counts once // point is past its stars and space. let start = lineStart(point) if let level = headingLevel(atLine: start), point >= start + level + 1 { point = start return } point = previousHeadingStart(before: point) ?? 0 } /// `org-end-of-subtree`. @discardableResult func endOfSubtree(toHeading: Bool = false) -> Int { if beforeFirstHeading { point = length } else { let start = try! backToHeading() let level = headingLevel(atLine: start)! var next = nextHeadingStart(after: start) while let candidate = next, headingLevel(atLine: candidate)! > level { next = nextHeadingStart(after: candidate) } point = next ?? length } if !toHeading, let c = character(at: point - 1), c == 10 || c == 13 { point -= 1 skipBackward([10, 13, 9, 32]) } return point } /// `org-get-next-sibling`. func nextSibling() -> Int? { let level = outlineLevel outlineNextHeading() while !eobp, outlineLevel > level { outlineNextHeading() } return eobp || outlineLevel < level ? nil : point } /// `org-get-previous-sibling`. func previousSibling() -> Int? { let start = point let level = outlineLevel outlinePreviousHeading() guard point != start, atHeading else { return nil } while outlineLevel > level, !bobp { outlinePreviousHeading() } return outlineLevel < level ? nil : point } /// `org--line-empty-p -1`. var previousLineEmpty: Bool { guard !bobp else { return false } return saveExcursion { forwardLine(-1) skipForward(Self.blanks) return eolp } } // MARK: - Regular expressions /// `looking-at`: the groups of `pattern` matched at point (`^` and `$` match at lines), /// or nil. Unmatched groups are nil. func lookingAt(_ pattern: String) -> [Range?]? { let regex = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) guard let match = regex.firstMatch(in: string, options: [.anchored], range: NSRange(point.. Int { guard position >= start, position <= end else { return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position } var buffer = LineBuffer(line, caret: position - start) OrgCore.alignTags(&buffer) return start + buffer.caret! } let newPoint = map(point) let newMarkers = markers.map { map($0.position) } text.replaceCharacters(in: NSRange(start.. Void) -> CommandStep { let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) do { try body(buffer) } catch let error as EmacsBuffer.UserError { return .failed(error.message) } catch { return .failed("\(error)") } let old = context.text as NSString let new = buffer.text var prefix = 0 let shorter = min(old.length, new.length) while prefix < shorter, old.character(at: prefix) == new.character(at: prefix) { prefix += 1 } var suffix = 0 while suffix < shorter - prefix, old.character(at: old.length - 1 - suffix) == new.character(at: new.length - 1 - suffix) { suffix += 1 } // Keep surrogate pairs whole. while prefix > 0, prefix < old.length, UTF16.isTrailSurrogate(old.character(at: prefix)) { prefix -= 1 } while suffix > 0, UTF16.isTrailSurrogate(old.character(at: old.length - suffix)) { suffix -= 1 } let caret = buffer.point guard old.length != new.length || prefix != old.length else { return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..