krz/orgstar

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

Sources/OrgCore/Commands/Fill.swift

593 lines · 27451 bytes

26 symbols in this file
  1import Foundation
  2
  3// M-q: `org-fill-paragraph` (Org 9.8.7) over Emacs's `fill-region-as-paragraph` (fill.el), for
  4// paragraphs, comments and comment blocks, with `sentence-end-double-space` nil,
  5// `colon-double-space` nil, `left-margin` 0 and left justification.
  6
  7extension EmacsBuffer {
  8    /// `org-element-paragraph-separate`, which org uses as `paragraph-start`.
  9    var paragraphStartPattern: String {
 10        let letters = options.listAllowAlphabetical ? "|[A-Za-z]" : ""
 11        return "(?:\\*+ |\\[fn:[-_\\w]+\\]|%%\\(|[ \\t]*(?:$|\\||\\+(?:-+\\+)+[ \\t]*$|#(?: |$|\\+(?:BEGIN_\\S+|\\S+(?:\\[.*\\])?:[ \\t]*))|:(?: |$|[-_\\w]+:[ \\t]*$)|-{5,}[ \\t]*$|\\\\begin\\{([A-Za-z0-9*]+)\\}|(?:[-+*]|(?:[0-9]+\(letters))[.)])(?:[ \\t]|$)))"
 12    }
 13
 14    static let adaptiveFillPattern = "[-–!|#%;>*·•‣⁃◦ \\t]*"
 15    static let sentenceEndPattern = "([.?!…‽][\\]\"'”’)}»›]*($|[\\t \\u00A0])|[。.?!]+)[ \\u00A0\\t\\n]*"
 16
 17    /// An element as `org-element-at-point` sees it, with offsets in the buffer.
 18    struct Element {
 19        let kind: SyntaxKind
 20        let range: Range<Int>
 21        /// The enclosing element, for paragraphs in items.
 22        let parentKind: SyntaxKind?
 23        let parentStart: Int?
 24        /// For blocks: the word after `#+begin_`, lowercased.
 25        let blockType: String?
 26        /// For plain lists: the first item's start.
 27        let firstItem: Int?
 28    }
 29
 30    static let elementKinds: Set<SyntaxKind> = [.paragraph, .comment, .block, .table, .tableRow, .item, .plainList, .heading,
 31                                                .keyword, .fixedWidth, .drawer, .propertyDrawer, .planning, .dynamicBlock,
 32                                                .footnoteDefinition, .horizontalRule, .clock, .nodeProperty]
 33
 34    /// The innermost element (not object) holding `offset`, as `org-element-at-point`. Greater
 35    /// blocks (quote, center, special) are parsed into their elements.
 36    func element(at offset: Int) -> Element? {
 37        // Elements never cross a heading, so the section around `offset` is enough to parse.
 38        let lineBegin = lineStart(offset)
 39        let from = headingLevel(atLine: lineBegin) != nil ? lineBegin : (previousHeadingStart(before: lineBegin) ?? 0)
 40        let to = nextHeadingStart(after: lineBegin) ?? length
 41        return element(at: offset, in: substring(from, to), base: from)
 42    }
 43
 44    private func element(at offset: Int, in text: String, base: Int) -> Element? {
 45        let tree = OrgParser.parse(text, defaults: settings)
 46        let local = offset - base
 47        var node = tree.root
 48        var found: SyntaxNode?
 49        while true {
 50            let child = node.child(containing: local) ?? (local == node.range.upperBound ? node.children.last : nil)
 51            guard let child, Self.elementKinds.contains(child.kind) || child.kind == .section || child.kind == .zerothSection else { break }
 52            if Self.elementKinds.contains(child.kind) { found = child }
 53            if [.heading, .paragraph, .comment, .tableRow, .block].contains(child.kind) { break }
 54            node = child
 55        }
 56        guard let found else { return nil }
 57        let range = (found.range.lowerBound + base)..<(found.range.upperBound + base)
 58        var blockType: String?
 59        if found.kind == .block {
 60            let first = found.text.prefix { $0 != "\n" }.lowercased()
 61            blockType = first.firstMatch(of: /#\+begin_(\S+)/).map { String($0.1) }
 62            if let type = blockType, !["src", "example", "export", "comment", "verse"].contains(type) {
 63                let contentsStart = lineEnd(range.lowerBound) + 1
 64                let closing = saveExcursion { () -> Int in
 65                    point = range.upperBound
 66                    skipBackward(Self.whitespace)
 67                    return lineStart(point)
 68                }
 69                if offset >= contentsStart, offset < closing, contentsStart < closing {
 70                    return element(at: offset, in: substring(contentsStart, closing), base: contentsStart)
 71                }
 72            }
 73        }
 74        let parent = found.parent.flatMap { Self.elementKinds.contains($0.kind) ? $0 : nil }
 75        return Element(
 76            kind: found.kind, range: range, parentKind: parent?.kind, parentStart: parent.map { $0.range.lowerBound + base },
 77            blockType: blockType, firstItem: found.children.first { $0.kind == .item }.map { $0.range.lowerBound + base }
 78        )
 79    }
 80
 81    /// Where an element's contents end: after its last non-blank line.
 82    func contentsEnd(_ node: Element) -> Int {
 83        saveExcursion {
 84            point = node.range.upperBound
 85            skipBackward(Self.whitespace)
 86            return min(lineEnd(point) + 1, length)
 87        }
 88    }
 89
 90    /// `org-list-item-body-column`.
 91    func itemBodyColumn(_ item: Int) -> Int {
 92        saveExcursion {
 93            point = item
 94            let match = looking("[ \\t]*(\\S+)", setsMatch: false)!
 95            point = match[1]!.upperBound
 96            return currentColumn + 1
 97        }
 98    }
 99
100    /// `org-adaptive-fill-function` at point's line.
101    func orgAdaptiveFillPrefix() -> String? {
102        saveExcursion {
103            guard !atHeading else { return nil }
104            let lineBegin = lineStart(point)
105            guard let element = element(at: lineBegin) else { return nil }
106            switch element.kind {
107            case .comment:
108                let indent = substring(lineBegin, lineBegin + (looking("[ \\t]*", setsMatch: false).map { $0[0]!.count } ?? 0))
109                return indent + "# "
110            case .item, .plainList:
111                let item = element.kind == .item ? element.range.lowerBound : (element.firstItem ?? element.range.lowerBound)
112                return String(repeating: " ", count: itemBodyColumn(item))
113            case .block where element.blockType == "comment":
114                let begin = lineEnd(element.range.lowerBound) + 1
115                let end = saveExcursion { () -> Int in
116                    point = element.range.upperBound
117                    skipBackward(Self.whitespace)
118                    return lineStart(point)
119                }
120                guard lineBegin >= begin, lineBegin < end else { return nil }
121                point = lineBegin
122                return looking("[ \\t]+", setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) } ?? ""
123            case .paragraph:
124                point = lineBegin
125                if element.parentKind == .item, let parent = element.parentStart {
126                    return String(repeating: " ", count: itemBodyColumn(parent))
127                }
128                if let prefix = contextPrefix(from: postAffiliated(element), to: element.range.upperBound, paragraphOnly: true) {
129                    return prefix
130                }
131                return looking("[ \\t]+", setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) } ?? ""
132            default:
133                return nil
134            }
135        }
136    }
137
138    /// Start of an element after its affiliated keywords.
139    func postAffiliated(_ node: Element) -> Int {
140        saveExcursion {
141            point = node.range.lowerBound
142            while looking("[ \\t]*#\\+(?:CAPTION|DATA|HEADERS?|LABEL|NAME|PLOT|RESNAME|RESULTS?|SOURCE|SRCNAME|TBLNAME|ATTR_[-_\\w]+)(?:\\[.*\\])?:", setsMatch: false) != nil {
143                if forwardLine(1) != 0 { break }
144            }
145            return point
146        }
147    }
148
149    /// `fill-match-adaptive-prefix`, with org's function or, in paragraphs, the regexp after
150    /// any markup at the line start.
151    func matchAdaptivePrefix(paragraphOnly: Bool) -> String? {
152        let prefix: String?
153        if paragraphOnly {
154            let start = point
155            prefix = saveExcursion {
156                skipLineStartMarkup()
157                return looking(Self.adaptiveFillPattern, setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) }
158            }
159            point = start
160        } else {
161            prefix = orgAdaptiveFillPrefix() ?? looking(Self.adaptiveFillPattern, setsMatch: false).map { substring($0[0]!.lowerBound, $0[0]!.upperBound) }
162        }
163        guard let prefix else { return nil }
164        return (prefix as NSString).length >= options.fillColumn ? nil : prefix
165    }
166
167    /// Moves past the opening markup of an object that starts at point, as
168    /// `org-adaptive-fill-paragraph-function` does.
169    private func skipLineStartMarkup() {
170        let line = substring(point, lineEnd(point)) + "\n"
171        let tree = OrgParser.parse(line, defaults: settings)
172        var node: SyntaxNode? = tree.root
173        while let current = node, let child = current.child(containing: 0), child.range.lowerBound == 0 {
174            if [.bold, .italic, .underline, .strikeThrough].contains(child.kind) {
175                point += 1
176                return
177            }
178            if child.kind == .link, let description = child.firstChild(.linkDescription) {
179                point += description.range.lowerBound
180                return
181            }
182            node = child
183        }
184    }
185
186    /// `fill-context-prefix`.
187    func contextPrefix(from: Int, to: Int, paragraphOnly: Bool = false) -> String? {
188        saveExcursion {
189            point = from
190            if eolp { forwardLine(1) }
191            point = lineStart(point)
192            var first = matchAdaptivePrefix(paragraphOnly: paragraphOnly)
193            forwardLine(1)
194            if point < to {
195                point = lineStart(point)
196                let second: String? = looking(paragraphStartPattern, setsMatch: false) != nil ? nil : matchAdaptivePrefix(paragraphOnly: paragraphOnly)
197                if let second {
198                    if first == nil { first = "" }
199                    var pattern = "^"
200                    var rest = Substring(second)
201                    while let match = rest.firstMatch(of: /^[ \t]*([^ \t]+)/) {
202                        pattern += ".*" + NSRegularExpression.escapedPattern(for: String(match.1))
203                        rest = rest[match.range.upperBound...]
204                    }
205                    if first!.range(of: pattern, options: .regularExpression) != nil { return second }
206                    let common = zip(first!, second).prefix { $0 == $1 }.count
207                    return common == 0 ? nil : String(first!.prefix(common))
208                }
209            }
210            guard let firstPrefix = first else { return nil }
211            let keep = firstPrefix.allSatisfy({ $0 == " " || $0 == "\t" }) || firstPrefix.range(of: "^\\s*#( |$)", options: .regularExpression) != nil
212            let result = keep ? firstPrefix : String(repeating: " ", count: displayWidth(firstPrefix))
213            let probe = result + "a"
214            let regex = Self.regex(paragraphStartPattern, caseInsensitive: false)
215            if let match = regex.firstMatch(in: probe, options: [.anchored], range: NSRange(location: 0, length: (probe as NSString).length)), match.range.location == 0 {
216                return nil
217            }
218            return result
219        }
220    }
221
222    /// `fill-nobreak-p` with org's predicates.
223    func fillNoBreak() -> Bool {
224        if bolp { return false }
225        let startsParagraph = saveExcursion { () -> Bool in
226            skipForward(Self.blanks)
227            return !eolp && looking(paragraphStartPattern, setsMatch: false) != nil
228        }
229        if startsParagraph { return true }
230        let lineBreak = saveExcursion { () -> Bool in
231            skipBackward(Self.blanks)
232            skipBackward([92])
233            return looking("\\\\\\\\($|[^\\\\])", setsMatch: false) != nil
234        }
235        if lineBreak { return true }
236        if looking("[ \\t]*\\{\\{\\{n(?:\\([^\\n)]*\\))?\\}\\}\\}[.)](?:$| )", setsMatch: false) != nil { return true }
237        let saved = lastMatch
238        defer { lastMatch = saved }
239        if atTimestamp() != nil, looking("[\\[<](" + Self.timestampCore + ")[^\\]>\\n]*[\\]>]", setsMatch: false) == nil { return true }
240        return false
241    }
242
243    /// `fill-move-to-break-point` for text without CJK characters.
244    func moveToBreakPoint(_ lineBegin: Int) {
245        if lineBegin > point { point = lineBegin }
246        while true {
247            guard let match = searchBackward("[ \\t]", bound: lineBegin, caseInsensitive: false) else {
248                point = lineBegin
249                break
250            }
251            point = match[0]!.lowerBound + 1
252            if fillNoBreak() {
253                skipBackward(Self.blanks, bound: lineBegin)
254            } else {
255                break
256            }
257        }
258        skipBackward(Self.blanks)
259        if lineBegin >= point {
260            let end = lineEnd(point)
261            var first = true
262            point = lineBegin
263            while point < end, first || fillNoBreak() {
264                skipForward(Self.blanks)
265                while point < length, let c = character(at: point), c != 32, c != 10, c != 9 { point += 1 }
266                first = false
267            }
268        }
269    }
270
271    /// `skip-chars-backward` stopping at `bound`.
272    func skipBackward(_ set: Set<unichar>, bound: Int) {
273        while point > bound, let c = character(at: point - 1), set.contains(c) { point -= 1 }
274    }
275
276    /// `canonically-space-region` with single spaces after sentences.
277    func canonicallySpace(_ begin: Int, _ endOffset: Int) {
278        saveExcursion {
279            let end = marker(endOffset, advances: true)
280            defer { release(end) }
281            for offset in begin..<end.position where character(at: offset) == 9 { replace(offset..<(offset + 1), with: " ") }
282            point = begin
283            let pattern = "(" + Self.sentenceEndPattern + ") *|  +"
284            while point < end.position, let match = searchForward(pattern, bound: end.position, caseInsensitive: false) {
285                let matchEnd = match[0]!.upperBound
286                let start: Int
287                if let sentence = match[1], matchEnd > sentence.upperBound {
288                    start = sentence.upperBound
289                } else if match[1] != nil {
290                    let lastNonSpace = saveExcursion { () -> Int in point = matchEnd; skipBackward([32]); return point }
291                    start = min(matchEnd, lastNonSpace + 1)
292                } else {
293                    let before = saveExcursion { () -> unichar? in
294                        point = match[0]!.lowerBound
295                        skipBackward(Set(" ]})\"'".utf16))
296                        return character(at: point - 1)
297                    }
298                    start = match[0]!.lowerBound + (before == 10 ? 0 : 1)
299                }
300                if start < matchEnd { deleteRegion(start, matchEnd) }
301            }
302        }
303    }
304
305    /// `fill-region-as-paragraph` with left justification and adaptive fill. With `nosqueeze`,
306    /// spaces between words and at the end stay.
307    func fillRegionAsParagraph(_ fromOffset: Int, _ toOffset: Int, prefix fixedPrefix: String? = nil, nosqueeze: Bool = false) {
308        point = min(fromOffset, toOffset)
309        let to = max(fromOffset, toOffset)
310        skipForward(Self.whitespace)
311        let fromPlusIndent = point
312        point = lineStart(point)
313        var from = max(point, fromOffset)
314        point = to
315        var oneLeft = false
316        while point > from, character(at: point - 1) == 10 {
317            if oneLeft {
318                deleteRegion(point - 1, point)
319            } else {
320                point -= 1
321                oneLeft = true
322            }
323        }
324        let toMarker = marker(point, advances: true)
325        defer { release(toMarker) }
326        point = fromPlusIndent
327        guard toMarker.position > point else { return }
328        var prefix = fixedPrefix
329        if prefix == nil || prefix == "" { prefix = contextPrefix(from: from, to: toMarker.position) }
330        point = lineStart(from)
331        // `fill-delete-prefix`.
332        let prefixPattern: String
333        if let prefix, !prefix.allSatisfy({ $0 == " " || $0 == "\t" }) {
334            prefixPattern = "[ \\t]*(" + NSRegularExpression.escapedPattern(for: prefix).replacingOccurrences(of: "[ \\t]+", with: "[ \\t]*", options: .regularExpression) + ")?[ \\t]*"
335        } else {
336            prefixPattern = "[ \\t]*"
337        }
338        point = from
339        forwardLine(1)
340        while point < toMarker.position {
341            if let match = looking(prefixPattern, setsMatch: false) { deleteRegion(point, match[0]!.upperBound) }
342            if forwardLine(1) != 0 { break }
343        }
344        point = from
345        if let match = looking(prefixPattern, setsMatch: false) { point = match[0]!.upperBound }
346        from = point
347        // `fill-delete-newlines`.
348        var at = from
349        while at < toMarker.position {
350            if character(at: at) == 10 { replace(at..<(at + 1), with: " ") }
351            at += 1
352        }
353        if !nosqueeze {
354            point = from
355            skipForward(Self.blanks)
356            canonicallySpace(point, toMarker.position)
357            point = toMarker.position
358            let end = point
359            skipBackward(Self.blanks)
360            if point < end { deleteRegion(point, end) }
361        }
362        point = from
363        while point < toMarker.position {
364            let lineBegin = point
365            moveToColumn(options.fillColumn)
366            var breaks = false
367            if point < toMarker.position, lineBegin < toMarker.position {
368                if currentColumn <= options.fillColumn { point += 1 }
369                moveToBreakPoint(lineBegin)
370                skipForward(Self.blanks)
371                breaks = point < toMarker.position
372            }
373            guard breaks else {
374                point = toMarker.position
375                break
376            }
377            // `fill-newline`.
378            skipBackward(Self.blanks)
379            insert("\n")
380            indentLineTo(0)
381            if let prefix, !prefix.isEmpty { insertBeforeMarkers(prefix) }
382        }
383        point = toMarker.position
384        if !eobp { point += 1 }
385    }
386
387    /// `org-fill-paragraph` without a region or justification.
388    func fillParagraph() {
389        saveExcursion {
390            if lookingAt("^[ \\t]*$") != nil || saveExcursion({ beginningOfLine(); return lookingAt("[ \\t]*$") != nil }) {
391                skipForward(Self.whitespace)
392            }
393            let eol = lineEnd(point)
394            guard let element = element(at: eol) else { return }
395            switch element.kind {
396            case .paragraph:
397                let begin = paragraphContentsBegin(element)
398                var end = contentsEnd(element)
399                guard lineEnd(point) >= begin else { return }
400                var cuts = [begin]
401                saveExcursion {
402                    point = begin
403                    while let match = searchForward("\\\\\\\\[ \\t]*\\n", bound: end, caseInsensitive: false) {
404                        cuts.insert(match[0]!.upperBound, at: 0)
405                    }
406                }
407                for cut in cuts where cut != end {
408                    fillRegionAsParagraph(cut, end)
409                    end = cut
410                }
411            case .comment:
412                let begin = postAffiliated(element)
413                let elementEnd = element.range.upperBound
414                guard point >= begin, point <= elementEnd else { return }
415                let from = saveExcursion { () -> Int in
416                    endOfLine()
417                    if searchBackward("^[ \\t]*#[ \\t]*$", bound: begin, caseInsensitive: false) != nil {
418                        forwardLine(1)
419                        return point
420                    }
421                    return begin
422                }
423                let to = saveExcursion { () -> Int in
424                    endOfLine()
425                    if searchForward("^[ \\t]*#[ \\t]*$", bound: elementEnd, caseInsensitive: false) != nil {
426                        return lineStart(point) - 1
427                    }
428                    point = elementEnd
429                    skipBackward(Self.whitespace)
430                    return lineEnd(point)
431                }
432                guard to > from else { return }
433                let prefix = saveExcursion { () -> String in
434                    beginningOfLine()
435                    let comment = looking("[ \\t]*#", setsMatch: false)!
436                    point = comment[0]!.upperBound
437                    let marker = substring(comment[0]!.lowerBound, comment[0]!.upperBound)
438                    if let rest = looking(Self.adaptiveFillPattern, setsMatch: false) {
439                        return marker + substring(rest[0]!.lowerBound, rest[0]!.upperBound)
440                    }
441                    return marker + " "
442                }
443                saveExcursion { fillRegionAsParagraph(from, to, prefix: prefix) }
444            case .block where element.blockType == "comment":
445                let begin = saveExcursion { () -> Int in
446                    point = element.range.lowerBound
447                    forwardLine(1)
448                    return point
449                }
450                let end = saveExcursion { () -> Int in
451                    point = element.range.upperBound
452                    skipBackward(Self.whitespace)
453                    return lineStart(point)
454                }
455                guard point >= begin, point <= end else { return }
456                let from = saveExcursion { () -> Int in
457                    endOfLine()
458                    if searchBackward("^[ \\t]*$", bound: begin, caseInsensitive: false) == nil { point = begin }
459                    return lineStart(point)
460                }
461                let to = saveExcursion { () -> Int in
462                    beginningOfLine()
463                    if searchForward("^[ \\t]*$", bound: end, caseInsensitive: false) == nil { point = end }
464                    return lineStart(point)
465                }
466                fillRegionAsParagraph(from, to)
467            default:
468                return
469            }
470        }
471    }
472
473    /// `org-fill-paragraph` with an active region: from the region's end back to `start`, M-q at
474    /// the end of each paragraph's text, moving back a paragraph at a time.
475    func fillParagraphs(_ start: Int, _ end: Int) {
476        saveExcursion {
477            point = end
478            skipBackward(Self.whitespace)
479            while point > start {
480                let before = point
481                fillParagraph()
482                point = paragraphBackwardStop()
483                skipBackward(Self.whitespace)
484                if point >= before { break }
485            }
486        }
487    }
488
489    /// Where `org--backward-paragraph-once` goes from the end of a line's text.
490    private func paragraphBackwardStop() -> Int {
491        guard let element = element(at: point) else { return lineStart(point) }
492        switch element.kind {
493        case .block where ["comment", "example", "export", "src", "verse"].contains(element.blockType ?? ""):
494            // Between empty lines in the block.
495            let contentsStart = lineEnd(element.range.lowerBound) + 1
496            guard point > contentsStart else { return element.range.lowerBound }
497            let contentsEnd = saveExcursion { () -> Int in
498                point = element.range.upperBound
499                skipBackward(Self.whitespace)
500                return lineStart(point)
501            }
502            if point > contentsEnd { return contentsEnd }
503            return saveExcursion {
504                skipBackward(Self.whitespace, bound: element.range.lowerBound)
505                return searchBackward("^[ \t]*\n", bound: contentsStart, caseInsensitive: false)?[0]!.lowerBound ?? contentsStart
506            }
507        case .block, .drawer, .propertyDrawer, .dynamicBlock:
508            // On the closing line: inside, to the contents' end.
509            return lineStart(point) > element.range.lowerBound ? lineStart(point) : element.range.lowerBound
510        default:
511            return lineStart(element.range.lowerBound)
512        }
513    }
514
515    /// `fill-region` over whole lines: each paragraph's part of `from..<to` filled as a
516    /// paragraph. Other elements stay.
517    func fillRegion(_ from: Int, _ to: Int, nosqueeze: Bool = false) {
518        var spans: [Range<Int>] = []
519        var line = lineStart(from)
520        while line < to, line < length {
521            var next = lineEnd(line) + 1
522            let blank = substring(line, lineEnd(line)).allSatisfy { $0 == " " || $0 == "\t" }
523            if !blank, let element = element(at: lineEnd(line)) {
524                if element.kind == .paragraph {
525                    let span = max(paragraphContentsBegin(element), from)..<min(contentsEnd(element), to)
526                    if !span.isEmpty { spans.append(span) }
527                }
528                next = max(next, lineStart(min(length, max(element.range.lowerBound, contentsEnd(element)))))
529                if next <= line { next = lineEnd(line) + 1 }
530            }
531            line = next
532        }
533        // Back to front, so the spans before stay where they are.
534        for span in spans.reversed() { fillRegionAsParagraph(span.lowerBound, span.upperBound, nosqueeze: nosqueeze) }
535    }
536
537    /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that
538    /// opens an item, past the bullet.
539    func paragraphContentsBegin(_ node: Element) -> Int {
540        max(postAffiliated(node), node.range.lowerBound)
541    }
542}
543
544/// M-q: `org-fill-paragraph`. With a selection, as with an active region, every paragraph
545/// in it; `point` is where the caret is in it, and stays.
546public struct FillParagraph: OrgCommand {
547    public let point: Int?
548
549    public init(point: Int? = nil) { self.point = point }
550    public var id: String { "org.fill-paragraph" }
551    public var title: String { "Fill Paragraph" }
552    public func applies(in context: EditContext) -> Bool { true }
553    public func run(in context: EditContext) -> CommandStep {
554        commitBuffer(context) { buffer in
555            if let selection = context.selection.first, !selection.isEmpty {
556                if let point { buffer.point = point }
557                buffer.fillParagraphs(selection.lowerBound, selection.upperBound)
558            } else {
559                buffer.fillParagraph()
560            }
561        }
562    }
563}
564
565/// evil's `gq` and `gw` operators: the paragraphs in `range`'s lines filled, as `fill-region`.
566/// With `keep` (`gw`) the caret stays there; without (`gq`) it goes to the first non-blank
567/// of the range's last line. Spaces are not squeezed, as Doom's `+evil--no-squeeze-on-fill-a`
568/// has it.
569public struct FillRegion: OrgCommand {
570    public let range: Range<Int>
571    public let keep: Int?
572
573    public init(range: Range<Int>, keep: Int?) {
574        self.range = range
575        self.keep = keep
576    }
577
578    public var id: String { "org.fill-region" }
579    public var title: String { "Fill Region" }
580    public func applies(in context: EditContext) -> Bool { true }
581    public func run(in context: EditContext) -> CommandStep {
582        commitBuffer(context) { buffer in
583            let target = buffer.marker(keep ?? max(range.lowerBound, range.upperBound - 1))
584            defer { buffer.release(target) }
585            buffer.fillRegion(range.lowerBound, range.upperBound, nosqueeze: true)
586            buffer.point = target.position
587            if keep == nil {
588                buffer.beginningOfLine()
589                buffer.skipForward(EmacsBuffer.blanks)
590            }
591        }
592    }
593}