krz/orgstar

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

Sources/OrgCore/Commands/Fill.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgCore/Commands/Fill.swift history · blame · raw

484 lines · 22479 bytes

  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.
306    func fillRegionAsParagraph(_ fromOffset: Int, _ toOffset: Int, prefix fixedPrefix: String? = nil) {
307        point = min(fromOffset, toOffset)
308        var to = max(fromOffset, toOffset)
309        skipForward(Self.whitespace)
310        let fromPlusIndent = point
311        point = lineStart(point)
312        var from = max(point, fromOffset)
313        point = to
314        var oneLeft = false
315        while point > from, character(at: point - 1) == 10 {
316            if oneLeft {
317                deleteRegion(point - 1, point)
318            } else {
319                point -= 1
320                oneLeft = true
321            }
322        }
323        let toMarker = marker(point, advances: true)
324        defer { release(toMarker) }
325        point = fromPlusIndent
326        guard toMarker.position > point else { return }
327        var prefix = fixedPrefix
328        if prefix == nil || prefix == "" { prefix = contextPrefix(from: from, to: toMarker.position) }
329        point = lineStart(from)
330        // `fill-delete-prefix`.
331        let prefixPattern: String
332        if let prefix, !prefix.allSatisfy({ $0 == " " || $0 == "\t" }) {
333            prefixPattern = "[ \\t]*(" + NSRegularExpression.escapedPattern(for: prefix).replacingOccurrences(of: "[ \\t]+", with: "[ \\t]*", options: .regularExpression) + ")?[ \\t]*"
334        } else {
335            prefixPattern = "[ \\t]*"
336        }
337        point = from
338        forwardLine(1)
339        while point < toMarker.position {
340            if let match = looking(prefixPattern, setsMatch: false) { deleteRegion(point, match[0]!.upperBound) }
341            if forwardLine(1) != 0 { break }
342        }
343        point = from
344        if let match = looking(prefixPattern, setsMatch: false) { point = match[0]!.upperBound }
345        from = point
346        // `fill-delete-newlines`.
347        var at = from
348        while at < toMarker.position {
349            if character(at: at) == 10 { replace(at..<(at + 1), with: " ") }
350            at += 1
351        }
352        point = from
353        skipForward(Self.blanks)
354        canonicallySpace(point, toMarker.position)
355        point = toMarker.position
356        let end = point
357        skipBackward(Self.blanks)
358        if point < end { deleteRegion(point, end) }
359        point = from
360        while point < toMarker.position {
361            let lineBegin = point
362            moveToColumn(options.fillColumn)
363            var breaks = false
364            if point < toMarker.position, lineBegin < toMarker.position {
365                if currentColumn <= options.fillColumn { point += 1 }
366                moveToBreakPoint(lineBegin)
367                skipForward(Self.blanks)
368                breaks = point < toMarker.position
369            }
370            guard breaks else {
371                point = toMarker.position
372                break
373            }
374            // `fill-newline`.
375            skipBackward(Self.blanks)
376            insert("\n")
377            indentLineTo(0)
378            if let prefix, !prefix.isEmpty { insertBeforeMarkers(prefix) }
379        }
380        point = toMarker.position
381        if !eobp { point += 1 }
382    }
383
384    /// `org-fill-paragraph` without a region or justification.
385    func fillParagraph() {
386        saveExcursion {
387            if lookingAt("^[ \\t]*$") != nil || saveExcursion({ beginningOfLine(); return lookingAt("[ \\t]*$") != nil }) {
388                skipForward(Self.whitespace)
389            }
390            let eol = lineEnd(point)
391            guard let element = element(at: eol) else { return }
392            switch element.kind {
393            case .paragraph:
394                let begin = paragraphContentsBegin(element)
395                var end = contentsEnd(element)
396                guard lineEnd(point) >= begin else { return }
397                var cuts = [begin]
398                saveExcursion {
399                    point = begin
400                    while let match = searchForward("\\\\\\\\[ \\t]*\\n", bound: end, caseInsensitive: false) {
401                        cuts.insert(match[0]!.upperBound, at: 0)
402                    }
403                }
404                for cut in cuts where cut != end {
405                    fillRegionAsParagraph(cut, end)
406                    end = cut
407                }
408            case .comment:
409                let begin = postAffiliated(element)
410                let elementEnd = element.range.upperBound
411                guard point >= begin, point <= elementEnd else { return }
412                let from = saveExcursion { () -> Int in
413                    endOfLine()
414                    if searchBackward("^[ \\t]*#[ \\t]*$", bound: begin, caseInsensitive: false) != nil {
415                        forwardLine(1)
416                        return point
417                    }
418                    return begin
419                }
420                let to = saveExcursion { () -> Int in
421                    endOfLine()
422                    if searchForward("^[ \\t]*#[ \\t]*$", bound: elementEnd, caseInsensitive: false) != nil {
423                        return lineStart(point) - 1
424                    }
425                    point = elementEnd
426                    skipBackward(Self.whitespace)
427                    return lineEnd(point)
428                }
429                guard to > from else { return }
430                let prefix = saveExcursion { () -> String in
431                    beginningOfLine()
432                    let comment = looking("[ \\t]*#", setsMatch: false)!
433                    point = comment[0]!.upperBound
434                    let marker = substring(comment[0]!.lowerBound, comment[0]!.upperBound)
435                    if let rest = looking(Self.adaptiveFillPattern, setsMatch: false) {
436                        return marker + substring(rest[0]!.lowerBound, rest[0]!.upperBound)
437                    }
438                    return marker + " "
439                }
440                saveExcursion { fillRegionAsParagraph(from, to, prefix: prefix) }
441            case .block where element.blockType == "comment":
442                let begin = saveExcursion { () -> Int in
443                    point = element.range.lowerBound
444                    forwardLine(1)
445                    return point
446                }
447                let end = saveExcursion { () -> Int in
448                    point = element.range.upperBound
449                    skipBackward(Self.whitespace)
450                    return lineStart(point)
451                }
452                guard point >= begin, point <= end else { return }
453                let from = saveExcursion { () -> Int in
454                    endOfLine()
455                    if searchBackward("^[ \\t]*$", bound: begin, caseInsensitive: false) == nil { point = begin }
456                    return lineStart(point)
457                }
458                let to = saveExcursion { () -> Int in
459                    beginningOfLine()
460                    if searchForward("^[ \\t]*$", bound: end, caseInsensitive: false) == nil { point = end }
461                    return lineStart(point)
462                }
463                fillRegionAsParagraph(from, to)
464            default:
465                return
466            }
467        }
468    }
469
470    /// Where a paragraph's text starts: past affiliated keywords, and for a paragraph that
471    /// opens an item, past the bullet.
472    func paragraphContentsBegin(_ node: Element) -> Int {
473        max(postAffiliated(node), node.range.lowerBound)
474    }
475}
476
477/// M-q: `org-fill-paragraph`.
478public struct FillParagraph: OrgCommand {
479    public init() {}
480    public var id: String { "org.fill-paragraph" }
481    public var title: String { "Fill Paragraph" }
482    public func applies(in context: EditContext) -> Bool { true }
483    public func run(in context: EditContext) -> CommandStep { commitBuffer(context) { $0.fillParagraph() } }
484}