krz/orgstar

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

Sources/OrgCore/Commands/Footnotes.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/OrgCore/Commands/Footnotes.swift history · blame · raw

521 lines · 24036 bytes

  1import Foundation
  2
  3// Footnotes (`org-footnote.el`, Org 9.8.7) with the defaults: `org-footnote-section`
  4// "Footnotes", `org-footnote-auto-label` t, `org-footnote-define-inline` and
  5// `org-footnote-auto-adjust` nil.
  6
  7extension EmacsBuffer {
  8    struct FootnoteReference: Equatable {
  9        /// Nil for anonymous references.
 10        var label: String?
 11        var begin: Int
 12        /// After the closing bracket.
 13        var end: Int
 14        /// An inline definition's text.
 15        var contents: Range<Int>?
 16        /// The label of the definition the reference sits in.
 17        var definition: String?
 18    }
 19
 20    struct FootnoteDefinition {
 21        var label: String
 22        var begin: Int
 23        var contentsBegin: Int?
 24        /// The line after its last non-blank line.
 25        var end: Int
 26    }
 27
 28    static let footnoteSection = "Footnotes"
 29
 30    private func footnoteTree() -> OrgTree { OrgParser.parse(string) }
 31
 32    /// Definitions in buffer order, ending as `org-element--footnote-separator` ends them.
 33    func footnoteDefinitions(_ tree: OrgTree? = nil) -> [FootnoteDefinition] {
 34        let tree = tree ?? footnoteTree()
 35        return tree.root.descendants().filter { $0.kind == .footnoteDefinition }.compactMap { node in
 36            saveExcursion {
 37                point = node.range.lowerBound
 38                guard let match = lookingAt("\\[fn:([-_\\w]+)\\]") else { return nil }
 39                let label = substring(match[1]!.lowerBound, match[1]!.upperBound)
 40                point = lineEnd(point)
 41                let separator = searchForward("^(?:\\*+ |\\[fn:[-_\\w]+\\]|([ \\t]*\\n){2,})", caseInsensitive: false)
 42                var raw = length
 43                if let separator {
 44                    raw = separator[0]!.lowerBound
 45                    if separator[1] != nil {
 46                        point = raw
 47                        skipForward([32, 13, 9, 10])
 48                        raw = eobp ? length : lineStart(point)
 49                    }
 50                }
 51                point = match[0]!.upperBound
 52                skipForward([32, 13, 9, 10])
 53                let contentsBegin: Int? = point >= raw ? nil : lineStart(point) == node.range.lowerBound ? point : lineStart(point)
 54                point = raw
 55                skipBackward([32, 13, 9, 10])
 56                forwardLine(1)
 57                return FootnoteDefinition(label: label, begin: node.range.lowerBound, contentsBegin: contentsBegin, end: point)
 58            }
 59        }
 60    }
 61
 62    /// References in buffer order.
 63    func footnoteReferences(_ tree: OrgTree? = nil) -> [FootnoteReference] {
 64        let tree = tree ?? footnoteTree()
 65        let definitions = footnoteDefinitions(tree)
 66        return tree.root.descendants().filter { $0.kind == .footnoteReference }.compactMap { node in
 67            saveExcursion {
 68                point = node.range.lowerBound
 69                guard let match = lookingAt("\\[fn:([-_\\w]*)(:)?") else { return nil }
 70                let label = match[1]!.isEmpty ? nil : substring(match[1]!.lowerBound, match[1]!.upperBound)
 71                let contents = match[2] != nil ? match[0]!.upperBound..<(node.range.upperBound - 1) : nil
 72                let holder = definitions.last { $0.begin <= node.range.lowerBound && node.range.lowerBound < $0.end }
 73                return FootnoteReference(label: label, begin: node.range.lowerBound, end: node.range.upperBound, contents: contents, definition: holder?.label)
 74            }
 75        }
 76    }
 77
 78    /// `org-footnote-at-reference-p`.
 79    func footnoteReferenceAtPoint() -> FootnoteReference? {
 80        footnoteReferences().first { $0.begin <= point && point < $0.end }
 81    }
 82
 83    /// `org-footnote-at-definition-p`: anywhere in a definition.
 84    func footnoteDefinitionAtPoint() -> FootnoteDefinition? {
 85        footnoteDefinitions().first { $0.begin <= point && point < max($0.end, $0.begin + 1) }
 86    }
 87
 88    /// `org-footnote-all-labels`.
 89    func footnoteLabels() -> [String] {
 90        let tree = footnoteTree()
 91        var all: [String] = []
 92        for label in footnoteReferences(tree).compactMap(\.label) + footnoteDefinitions(tree).map(\.label) where !all.contains(label) {
 93            all.append(label)
 94        }
 95        return all
 96    }
 97
 98    /// `org-footnote-unique-label`.
 99    func footnoteUniqueLabel(_ current: [String]) -> String {
100        var count = 1
101        while current.contains(String(count)) { count += 1 }
102        return String(count)
103    }
104
105    /// Where `org-footnote-get-definition` finds LABEL: its definition, or an inline reference.
106    func footnoteDefinitionStart(_ label: String) -> Int? {
107        let tree = footnoteTree()
108        let definition = footnoteDefinitions(tree).first { $0.label == label }?.begin
109        let inline = footnoteReferences(tree).first { $0.label == label && $0.contents != nil && $0.begin > 0 }?.begin
110        return [definition, inline].compactMap { $0 }.min()
111    }
112
113    /// `org-footnote--allow-reference-p` for a labeled reference.
114    func footnoteReferenceAllowed() -> Bool {
115        if bolp { return false }
116        let tree = footnoteTree()
117        var chain: [SyntaxNode] = []
118        var node = tree.root
119        while let child = node.child(containing: point) {
120            chain.append(child)
121            node = child
122        }
123        if chain.contains(where: { $0.kind == .heading }), let heading = chain.first(where: { $0.kind == .heading }),
124           lineStart(point) == heading.range.lowerBound {
125            guard let title = heading.firstChild(.title) else { return false }
126            let tags = heading.tokens.first { $0.kind == .tags }
127            return point >= title.range.lowerBound && tags.map { point < $0.range.lowerBound } ?? true
128        }
129        let objects: Set<SyntaxKind> = [.bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkDescription, .timestamp,
130                                        .footnoteReference, .statisticsCookie, .target, .macro, .inlineSourceBlock, .latexFragment, .lineBreak, .superscript, .subscript, .entity, .radioTarget,
131                                        .citation, .citationReference, .exportSnippet, .inlineBabelCall, .itemTag]
132        let containers: Set<SyntaxKind> = [.document, .zerothSection, .section, .plainList, .item, .drawer]
133        let element = chain.last { !objects.contains($0.kind) && !containers.contains($0.kind) }
134        switch element?.kind {
135        case nil, .paragraph, .tableRow, .tableCell:
136            break
137        case .footnoteDefinition:
138            let definition = footnoteDefinitions(tree).first { $0.begin == element!.range.lowerBound }
139            if let begin = definition?.contentsBegin, point >= begin { break }
140            return character(at: point).map { [32, 9, 10, 13].contains($0) } ?? false
141        case .block:
142            let block = element!
143            let opening = saveExcursion { () -> Bool in
144                point = block.range.lowerBound
145                return lookingAt("[ \\t]*#\\+begin_verse") != nil
146            }
147            guard opening, let wrapper = Wrappers.starting(at: block.range.lowerBound, in: tree) else { return false }
148            let contentsBegin = lineEnd(block.range.lowerBound) + 1
149            return point >= contentsBegin && point < wrapper.closeLine
150        default:
151            // Blank lines after an element.
152            let after = saveExcursion { () -> Int in
153                point = element!.range.upperBound
154                skipBackward([32, 13, 9, 10])
155                forwardLine(1)
156                return point
157            }
158            return point >= after
159        }
160        guard let object = chain.last(where: { objects.contains($0.kind) }) else { return true }
161        if point == object.range.lowerBound { return true }
162        if chain.contains(where: { $0.kind == .link }) { return false }
163        switch object.kind {
164        case .bold, .italic, .underline, .strikeThrough, .superscript, .subscript, .radioTarget:
165            return point > object.range.lowerBound && point <= object.range.upperBound - 1
166        default:
167            return false
168        }
169    }
170
171    /// `org-back-over-empty-lines`, returning the number of empty lines passed.
172    @discardableResult
173    func backOverEmptyLinesCount() -> Int {
174        let position = point
175        backOverEmptyLines()
176        return countLines(point, position)
177    }
178
179    /// `org-end-of-meta-data` with FULL t.
180    func endOfMetaDataFull() {
181        endOfMetaData()
182        guard !atHeading else { return }
183        let end = saveExcursion { () -> Int in outlineNextHeading(); return point }
184        while !eobp {
185            if lookingAt("[ \\t]*$|^[ \\t]*CLOCK:") != nil {
186                forwardLine(1)
187            } else if lookingAt("[ \\t]*:[-_\\w]+:[ \\t]*$") != nil {
188                guard searchForward("^[ \\t]*:END:[ \\t]*$", bound: end, caseInsensitive: true) != nil else { return }
189                forwardLine(1)
190            } else {
191                return
192            }
193        }
194    }
195
196    /// `org-footnote--clear-footnote-section`.
197    func clearFootnoteSection() {
198        point = 0
199        let pattern = "^\\*+ +" + NSRegularExpression.escapedPattern(for: Self.footnoteSection) + "[ \\t]*$"
200        while let match = searchForward(pattern, caseInsensitive: false) {
201            point = match[0]!.lowerBound
202            let end = endOfSubtree(toHeading: true)
203            deleteRegion(match[0]!.lowerBound, end)
204            point = match[0]!.lowerBound
205        }
206        point = length
207        skipBackward([32, 13, 9, 10])
208        if !bobp {
209            forwardLine(1)
210            if eolp { insert("\n") }
211        }
212        deleteRegion(point, length)
213        if saveExcursion({ backOverEmptyLinesCount() }) == 0 { insert("\n") }
214        insert("* " + Self.footnoteSection + "\n")
215    }
216
217    /// `org-footnote-create-definition`: the definition's start.
218    func createFootnoteDefinition(_ label: String) -> Int {
219        saveExcursion {
220            let pattern = "^\\*+[ \\t]+" + NSRegularExpression.escapedPattern(for: Self.footnoteSection) + "[ \\t]*$"
221            point = 0
222            if let match = searchForward(pattern, caseInsensitive: false) {
223                point = match[0]!.upperBound
224                endOfMetaDataFull()
225                if !bolp { insert("\n") }
226            } else {
227                clearFootnoteSection()
228            }
229            if backOverEmptyLinesCount() == 0 { insert("\n") }
230            insert("[fn:\(label)] \n")
231            forwardLine(-1)
232            return point
233        }
234    }
235
236    /// `org-footnote-goto-definition`.
237    func gotoFootnoteDefinition(_ label: String, at start: Int) {
238        point = start
239        if let match = lookingAt("\\[fn:" + NSRegularExpression.escapedPattern(for: label) + "[]:]") { point = match[0]!.upperBound }
240    }
241
242    /// `org-footnote-new`.
243    func newFootnote() throws {
244        let all = footnoteLabels()
245        let label = footnoteUniqueLabel(all)
246        guard footnoteReferenceAllowed() else { throw UserError(message: "Cannot insert a footnote here") }
247        insert("[fn:\(label)]")
248        gotoFootnoteDefinition(label, at: createFootnoteDefinition(label))
249    }
250
251    /// `org-footnote-goto-previous-reference`.
252    func gotoPreviousFootnoteReference(_ label: String) throws {
253        let references = footnoteReferences().filter { $0.label == label }
254        let opening = "[fn:\(label)".utf16.count + 1
255        guard let reference = references.last(where: { $0.begin + opening <= point }) ?? references.first(where: { $0.begin >= point }) else {
256            throw UserError(message: "Cannot find reference of footnote \"\(label)\"")
257        }
258        point = reference.begin
259    }
260
261    /// `org-footnote--set-label`, with point at the reference or definition.
262    func setFootnoteLabel(_ label: String) {
263        point += 4
264        if character(at: point) == 58 {
265            insert(label)
266        } else if let match = lookingAt("([-_\\w]+)") {
267            replace(match[1]!, with: label)
268            point = match[1]!.lowerBound + label.utf16.count
269        }
270    }
271
272    /// `org-footnote--collect-references`, in org's reading order: nested references follow
273    /// the definition they sit in.
274    func collectFootnoteReferences(anonymous: Bool = false) -> [FootnoteReference] {
275        let references = footnoteReferences().filter { anonymous || $0.label != nil }
276        var nested: [String: [String?]] = [:]
277        for reference in references { if let holder = reference.definition { nested[holder, default: []].append(reference.label) } }
278        var ordered: [FootnoteReference] = []
279        func add(_ reference: FootnoteReference, allowNested: Bool) {
280            guard allowNested || reference.definition == nil else { return }
281            ordered.append(reference)
282            for label in nested[reference.label ?? ""] ?? [] where reference.label != nil {
283                guard let r = references.last(where: { $0.label == label }), !ordered.contains(r) else { continue }
284                add(r, allowNested: true)
285            }
286        }
287        for reference in references { add(reference, allowNested: false) }
288        return ordered
289    }
290
291    /// `org-footnote--collect-definitions`: label and text, last first, as org's alist.
292    func collectFootnoteDefinitions(delete: Bool) -> [(label: String, text: String)] {
293        var seen: Set<String> = []
294        var found: [(label: String, text: String, range: Range<Int>)] = []
295        for definition in footnoteDefinitions() where !seen.contains(definition.label) {
296            seen.insert(definition.label)
297            let begin = saveExcursion { () -> Int in
298                point = definition.begin
299                skipBackward([32, 13, 9, 10])
300                if bobp { return point }
301                forwardLine(1)
302                return point
303            }
304            let text = substring(begin, definition.end).trimmingCharacters(in: CharacterSet(charactersIn: " \t\n\r"))
305            found.append((definition.label, text, begin..<definition.end))
306        }
307        if delete { for f in found.reversed() { deleteRegion(f.range.lowerBound, f.range.upperBound) } }
308        return found.reversed().map { ($0.label, $0.text) }
309    }
310
311    /// `org-footnote-renumber-fn:N`.
312    func renumberFootnotes() {
313        var c = 0
314        let references = collectFootnoteReferences().filter { $0.label!.range(of: "^[0-9]+$", options: .regularExpression) != nil }
315        var alist: [String: String] = [:]
316        for label in references.map({ $0.label! }) where alist[label] == nil {
317            c += 1
318            alist[label] = String(c)
319        }
320        let markers = references.map { marker($0.begin) }
321        for (reference, m) in zip(references, markers) {
322            point = m.position
323            setFootnoteLabel(alist[reference.label!]!)
324        }
325        markers.forEach(release)
326        point = 0
327        while let match = searchForward("^\\[fn:([0-9]+)\\]", caseInsensitive: false) {
328            let old = substring(match[1]!.lowerBound, match[1]!.upperBound)
329            let new = alist[old] ?? { c += 1; return String(c) }()
330            replace(match[1]!, with: new)
331            point = match[1]!.lowerBound + new.utf16.count + 1
332        }
333    }
334
335    /// `org-footnote-sort`.
336    func sortFootnotes() {
337        let references = collectFootnoteReferences()
338        let definitions = collectFootnoteDefinitions(delete: true)
339        clearFootnoteSection()
340        var inserted: [String] = []
341        for reference in references where !inserted.contains(reference.label!) && reference.contents == nil {
342            inserted.append(reference.label!)
343            insert("\n" + (definitions.first { $0.label == reference.label }?.text ?? "[fn:\(reference.label!)] DEFINITION NOT FOUND.") + "\n")
344        }
345        for definition in definitions where !inserted.contains(definition.label) {
346            insert("\n" + definition.text + "\n")
347        }
348    }
349
350    /// `org-footnote-normalize`.
351    func normalizeFootnotes() {
352        enum Key: Equatable {
353            case label(String)
354            case anonymous(Int)
355        }
356        var n = 0
357        var translations: [String: String] = [:]
358        var definitions: [(key: Key, text: String)] = []
359        let collected = collectFootnoteReferences(anonymous: true)
360        let markers = collected.map { marker($0.begin) }
361        var keys: [Key] = []
362        for (reference, m) in zip(collected, markers) {
363            let new: String
364            let key: Key
365            if let label = reference.label {
366                if let known = translations[label] {
367                    new = known
368                } else {
369                    n += 1
370                    new = String(n)
371                    translations[label] = new
372                }
373                key = .label(label)
374            } else {
375                n += 1
376                new = String(n)
377                key = .anonymous(n)
378            }
379            keys.append(key)
380            point = m.position
381            setFootnoteLabel(new)
382            if let contents = reference.contents {
383                let size = contents.count
384                let removed = substring(point, point + size + 1)
385                deleteRegion(point, point + size + 1)
386                let text = "[fn:\(new)] " + String(removed.dropFirst()).trimmingCharacters(in: CharacterSet(charactersIn: " \t\n\r"))
387                definitions.insert((key, text), at: 0)
388            }
389        }
390        markers.forEach(release)
391        definitions += collectFootnoteDefinitions(delete: true).map { (Key.label($0.label), $0.text) }
392        clearFootnoteSection()
393        var inserted: [Key] = []
394        for key in keys where !inserted.contains(key) {
395            inserted.append(key)
396            let stored = definitions.first { $0.key == key }?.text
397            let text: String
398            switch key {
399            case .anonymous(let number):
400                text = stored ?? "[fn:\(number)] DEFINITION NOT FOUND."
401            case .label(let label):
402                let new = translations[label]!
403                if let stored, let match = stored.firstMatch(of: /^\[fn:(.*?)\]/) {
404                    text = stored.replacingCharacters(in: match.1.startIndex..<match.1.endIndex, with: new)
405                } else {
406                    text = stored ?? "[fn:\(new)] DEFINITION NOT FOUND."
407                }
408            }
409            insert("\n" + text + "\n")
410        }
411        for definition in definitions where !inserted.contains(definition.key) {
412            n += 1
413            let number = n
414            let text = definition.text.replacingOccurrences(of: "(?m)^\\[fn:[-_\\w]+\\]", with: "[fn:\(number)]", options: .regularExpression)
415            insert("\n" + text + "\n")
416        }
417    }
418
419    /// `org-footnote-delete`: the message to show.
420    func deleteFootnote() throws -> String {
421        let label: String
422        if let reference = footnoteReferenceAtPoint() {
423            guard let l = reference.label else {
424                deleteRegion(reference.begin, reference.end)
425                return "Anonymous footnote removed"
426            }
427            label = l
428        } else if let definition = footnoteDefinitionAtPoint() {
429            label = definition.label
430        } else {
431            throw UserError(message: "Don't know which footnote to remove")
432        }
433        var references = 0
434        while let reference = footnoteReferences().first(where: { $0.label == label }) {
435            deleteRegion(reference.begin, reference.end)
436            references += 1
437        }
438        var definitions = 0
439        while let definition = footnoteDefinitions().first(where: { $0.label == label }) {
440            let begin = saveExcursion { () -> Int in
441                point = definition.begin
442                skipBackward([32, 13, 9, 10])
443                if bobp { return point }
444                forwardLine(1)
445                return point
446            }
447            deleteRegion(begin, definition.end)
448            definitions += 1
449        }
450        return "\(definitions) definition(s) of and \(references) reference(s) of footnote \(label) removed"
451    }
452}
453
454/// C-c C-x f: `org-footnote-action`. On a reference, goes to its definition; on a definition's
455/// label, back to a reference; elsewhere inserts a new footnote, or where one can't go, offers
456/// the footnote menu.
457public struct FootnoteAction: OrgCommand {
458    let special: Bool
459    public init(special: Bool = false) { self.special = special }
460    public var id: String { special ? "org.footnote.menu" : "org.footnote.action" }
461    public var title: String { special ? "Footnote Menu" : "Footnote Action" }
462    public func applies(in context: EditContext) -> Bool { true }
463
464    static let menu = FastSelection(items: [
465        .option(key: "s", name: "sort"), .option(key: "r", name: "renumber fn:N"), .option(key: "S", name: "renumber and sort"),
466        .option(key: "n", name: "normalize"), .option(key: "d", name: "delete"),
467    ], multiple: false)
468
469    public func run(in context: EditContext) -> CommandStep {
470        let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
471        if !special {
472            if let reference = buffer.footnoteReferenceAtPoint() {
473                guard let label = reference.label else { return move(context, to: reference.contents!.lowerBound) }
474                if let start = buffer.footnoteDefinitionStart(label) {
475                    buffer.gotoFootnoteDefinition(label, at: start)
476                    return move(context, to: buffer.point, message: Self.definitionMessage)
477                }
478                guard let answer = context.answers["create"] else {
479                    return .prompt(Prompt(key: "create", message: "No definition for \(label).  Create one?", choices: ["yes", "no"]))
480                }
481                guard answer == "yes" else { return .failed("Quit") }
482                return commitBuffer(context) { buffer in
483                    buffer.gotoFootnoteDefinition(label, at: buffer.createFootnoteDefinition(label))
484                }
485            }
486            if let definition = buffer.footnoteDefinitions().first(where: { $0.begin <= context.caret && context.caret < ($0.contentsBegin ?? $0.end) }) {
487                return commitBuffer(context) { try $0.gotoPreviousFootnoteReference(definition.label) }
488            }
489            if buffer.footnoteReferenceAllowed() { return commitBuffer(context) { try $0.newFootnote() } }
490        }
491        guard let key = context.answers["footnote"] else {
492            return .prompt(Prompt(key: "footnote", message: "Footnotes: [s]ort | [r]enumber fn:N | [S]=r+s | [n]ormalize | [d]elete", fast: Self.menu))
493        }
494        var message: String?
495        let step = commitBuffer(context) { buffer in
496            let caret = buffer.marker(buffer.point)
497            defer {
498                buffer.point = caret.position
499                buffer.release(caret)
500            }
501            switch key {
502            case "s": buffer.sortFootnotes()
503            case "r": buffer.renumberFootnotes()
504            case "S":
505                buffer.renumberFootnotes()
506                buffer.sortFootnotes()
507            case "n": buffer.normalizeFootnotes()
508            case "d": message = try buffer.deleteFootnote()
509            default: throw EmacsBuffer.UserError(message: "No such footnote command \(key)")
510            }
511        }
512        guard case .commit(let result) = step, let message else { return step }
513        return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection, effects: result.effects + [.message(message)]))
514    }
515
516    static let definitionMessage = "Edit definition and go back with `C-c C-c' or `C-c C-x f' on its label."
517
518    private func move(_ context: EditContext, to offset: Int, message: String? = nil) -> CommandStep {
519        .commit(EditResult(baseRevision: context.revision, edits: [], selection: [offset..<offset], effects: message.map { [.message($0)] } ?? []))
520    }
521}