krz/orgstar

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

Sources/OrgCore/Export/Citations.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgCore/Export/Citations.swift history · blame · raw

453 lines · 22516 bytes

  1import Foundation
  2
  3/// Citations as the `basic` processor (oc-basic.el, Org 9.8.7) exports them: author-year,
  4/// author, noauthor, text, nocite and numeric styles with the bare and caps variants, from
  5/// the BibTeX or CSL-JSON files `#+bibliography:` names, and the bibliography
  6/// `#+print_bibliography:` asks for. Note styles become footnotes. `#+cite_export:`
  7/// processors other than `basic` leave citations as they are written.
  8struct CiteProcessor {
  9    /// What a citation or bibliography entry exports as: text, Org markup (prefixes and
 10    /// suffixes) and an italic title.
 11    enum Piece: Equatable {
 12        case text(String)
 13        case org(String)
 14        case italic(String)
 15    }
 16
 17    struct Reference {
 18        var prefix: String?
 19        var key: String
 20        var suffix: String?
 21    }
 22
 23    struct Citation {
 24        var text: String
 25        var style: String?
 26        var variant: String?
 27        var prefix: String?
 28        var suffix: String?
 29        var references: [Reference]
 30    }
 31
 32    typealias Entry = [String: String]
 33
 34    /// Each citation's export by its text, in document order.
 35    private var outputs: [String: [[Piece]]] = [:]
 36    private(set) var bibliography: [[Piece]] = []
 37
 38    // MARK: - Reading
 39
 40    static let keyPattern = "@([-.:?!`'/*@+|(){}<>&_^$#%~\\p{L}\\p{N}_]+)"
 41
 42    /// `org-element-citation-parser`: style, the common prefix and suffix, and references.
 43    static func parse(_ text: String) -> Citation? {
 44        guard let m = text.firstMatch(of: #/^\[cite(?:/([^:/\]]*)(?:/([^:\]]*))?)?:(.*)\]$/#.dotMatchesNewlines()) else { return nil }
 45        let body = String(m.3)
 46        var parts = body.components(separatedBy: ";")
 47        let key = try! NSRegularExpression(pattern: keyPattern)
 48        func hasKey(_ s: String) -> Bool { key.firstMatch(in: s, range: NSRange(location: 0, length: (s as NSString).length)) != nil }
 49        guard let first = parts.firstIndex(where: hasKey), let last = parts.lastIndex(where: hasKey) else { return nil }
 50        let prefix = first > 0 ? parts[..<first].joined(separator: ";") : nil
 51        let suffix = last < parts.count - 1 ? parts[(last + 1)...].joined(separator: ";") : nil
 52        parts = Array(parts[first...last])
 53        var references: [Reference] = []
 54        for part in parts {
 55            let ns = part as NSString
 56            guard let k = key.firstMatch(in: part, range: NSRange(location: 0, length: ns.length)) else { continue }
 57            let before = ns.substring(to: k.range.location)
 58            let after = ns.substring(from: NSMaxRange(k.range))
 59            references.append(Reference(prefix: before.isEmpty ? nil : before, key: ns.substring(with: k.range(at: 1)), suffix: after.isEmpty ? nil : after))
 60        }
 61        func nonEmpty(_ s: String?) -> String? { s.flatMap { $0.isEmpty ? nil : $0 } }
 62        return Citation(text: text, style: m.1.map(String.init).flatMap { $0.isEmpty ? nil : $0 }, variant: m.2.map(String.init),
 63                        prefix: nonEmpty(prefix), suffix: nonEmpty(suffix), references: references)
 64    }
 65
 66    /// `#+cite_export:`: the processor, bibliography style and citation style.
 67    static func exportSettings(_ text: String) -> (processor: String, bibStyle: String?, citeStyle: String?)? {
 68        guard let line = text.matches(of: #/(?mi)^[ \t]*#\+cite_export:[ \t]*(.*?)[ \t]*$/#).last else { return nil }
 69        let words = line.1.split(whereSeparator: \.isWhitespace).map(String.init)
 70        guard let processor = words.first else { return nil }
 71        return (processor, words.count > 1 ? words[1] : nil, words.count > 2 ? words[2] : nil)
 72    }
 73
 74    static func isNocite(_ citation: Citation, defaultStyle: String?) -> Bool {
 75        let style = citation.style ?? defaultStyle.map { String($0.split(separator: "/").first ?? "") }
 76        return style == "nocite" || style == "n"
 77    }
 78
 79    /// The text with nocite citations removed, and the blanks before them, as
 80    /// `org-cite--set-previous-post-blank` leaves it.
 81    static func removingNocite(_ text: String) -> String {
 82        guard text.range(of: "[cite", options: .caseInsensitive) != nil else { return text }
 83        let defaultStyle = exportSettings(text)?.citeStyle
 84        let regex = try! NSRegularExpression(pattern: "[ \\t]*(\\[cite[/:][^\\]]*\\])")
 85        var out = text
 86        for m in regex.matches(in: text, range: NSRange(location: 0, length: (text as NSString).length)).reversed() {
 87            let ns = out as NSString
 88            guard let citation = parse(ns.substring(with: m.range(at: 1))), isNocite(citation, defaultStyle: defaultStyle) else { continue }
 89            // Blanks before a citation that starts its line are indentation.
 90            let lineStart = ns.lineRange(for: NSRange(location: m.range.location, length: 0)).location
 91            let blanksOnly = ns.substring(with: NSRange(location: lineStart, length: m.range(at: 1).location - lineStart)).allSatisfy { $0 == " " || $0 == "\t" }
 92            out = ns.replacingCharacters(in: blanksOnly ? m.range(at: 1) : m.range, with: "")
 93        }
 94        return out
 95    }
 96
 97    // MARK: - Bibliographies
 98
 99    /// `org-cite-basic--parse-bibtex`: entries with lower-case field names, `id` and `type`,
100    /// values without delimiters, `@string`s expanded, blanks collapsed.
101    static func parseBibTeX(_ text: String) -> [String: Entry] {
102        var entries: [String: Entry] = [:]
103        var strings: [String: String] = [:]
104        let chars = Array(text.unicodeScalars)
105        var i = 0
106        func skipSpace() { while i < chars.count, chars[i].properties.isWhitespace { i += 1 } }
107        func word() -> String {
108            var out = ""
109            while i < chars.count, !chars[i].properties.isWhitespace, !"{}(),=#\"".unicodeScalars.contains(chars[i]) {
110                out.unicodeScalars.append(chars[i])
111                i += 1
112            }
113            return out
114        }
115        func braced() -> String {
116            var depth = 0
117            var out = ""
118            while i < chars.count {
119                let c = chars[i]
120                i += 1
121                if c == "{" {
122                    depth += 1
123                    if depth == 1 { continue }
124                } else if c == "}" {
125                    depth -= 1
126                    if depth == 0 { break }
127                }
128                out.unicodeScalars.append(c)
129            }
130            return out
131        }
132        func quoted() -> String {
133            var depth = 0
134            var out = ""
135            i += 1
136            while i < chars.count {
137                let c = chars[i]
138                i += 1
139                if c == "{" { depth += 1 } else if c == "}" { depth -= 1 } else if c == "\"", depth == 0 { break }
140                out.unicodeScalars.append(c)
141            }
142            return out
143        }
144        func value() -> String {
145            var parts: [String] = []
146            while true {
147                skipSpace()
148                guard i < chars.count else { break }
149                if chars[i] == "{" {
150                    parts.append(braced())
151                } else if chars[i] == "\"" {
152                    parts.append(quoted())
153                } else {
154                    let w = word()
155                    parts.append(strings[w.lowercased()] ?? w)
156                }
157                skipSpace()
158                if i < chars.count, chars[i] == "#" { i += 1 } else { break }
159            }
160            return parts.joined().replacingOccurrences(of: "[ \\t\\n]+", with: " ", options: .regularExpression)
161        }
162        while i < chars.count {
163            guard chars[i] == "@" else { i += 1; continue }
164            i += 1
165            let type = word().lowercased()
166            skipSpace()
167            guard i < chars.count, chars[i] == "{" || chars[i] == "(" else { continue }
168            let close: Unicode.Scalar = chars[i] == "{" ? "}" : ")"
169            i += 1
170            if type == "comment" || type == "preamble" {
171                var depth = 1
172                while i < chars.count, depth > 0 {
173                    if chars[i] == "{" || chars[i] == "(" { depth += 1 } else if chars[i] == "}" || chars[i] == ")" { depth -= 1 }
174                    i += 1
175                }
176                continue
177            }
178            if type == "string" {
179                skipSpace()
180                let name = word().lowercased()
181                skipSpace()
182                if i < chars.count, chars[i] == "=" { i += 1 }
183                strings[name] = value()
184                skipSpace()
185                if i < chars.count, chars[i] == close { i += 1 }
186                continue
187            }
188            skipSpace()
189            var key = ""
190            while i < chars.count, chars[i] != ",", chars[i] != close, !chars[i].properties.isWhitespace {
191                key.unicodeScalars.append(chars[i])
192                i += 1
193            }
194            var entry: Entry = ["id": key, "type": type]
195            while i < chars.count {
196                skipSpace()
197                if i < chars.count, chars[i] == "," { i += 1; skipSpace() }
198                guard i < chars.count, chars[i] != close else { i += 1; break }
199                let field = word().lowercased()
200                skipSpace()
201                guard !field.isEmpty, i < chars.count, chars[i] == "=" else { i += 1; continue }
202                i += 1
203                entry[field] = value()
204            }
205            if entries[key] == nil { entries[key] = entry }
206        }
207        return entries
208    }
209
210    /// `org-cite-basic--parse-json`, for CSL-JSON: authors as "family given" joined by "and",
211    /// and the year from `issued`.
212    static func parseJSON(_ data: Data) -> [String: Entry] {
213        guard let items = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [:] }
214        var entries: [String: Entry] = [:]
215        for item in items {
216            guard let id = item["id"] as? String else { continue }
217            var entry: Entry = [:]
218            for (field, value) in item {
219                switch field {
220                case "author", "editor":
221                    let people = (value as? [[String: Any]]) ?? []
222                    entry[field] = people.map { ($0["family"] as? String ?? "") + " " + ($0["given"] as? String ?? "") }.joined(separator: " and ")
223                case "issued":
224                    let issued = value as? [String: Any] ?? [:]
225                    if let parts = issued["date-parts"] as? [[Any]], let year = parts.first?.first {
226                        entry["year"] = (year as? Int).map(String.init) ?? year as? String
227                    } else if let raw = (issued["literal"] ?? issued["raw"]) as? String, let m = raw.firstMatch(of: /[0-9]{4}/) {
228                        entry["year"] = String(m.0)
229                    }
230                default:
231                    if let s = value as? String { entry[field] = s } else if let n = value as? NSNumber { entry[field] = n.stringValue }
232                }
233            }
234            entries[id] = entry
235        }
236        return entries
237    }
238
239    // MARK: - Formatting
240
241    private var entries: [String: Entry] = [:]
242    private var yearCache: [(author: String?, year: String?, keys: [(key: String, suffix: String)])] = []
243
244    func author(_ key: String) -> String? { entries[key].flatMap { $0["author"] ?? $0["editor"] } }
245
246    /// `org-cite-basic--get-year`: the year, with a letter telling apart works by the same
247    /// authors in the same year.
248    mutating func year(_ key: String, noSuffix: Bool = false) -> String? {
249        let entry = entries[key]
250        var year = entry?["year"]
251        if year == nil, let date = entry?["date"], let m = date.firstMatch(of: /^([0-9]{4})(?:[^0-9]|$)/) { year = String(m.1) }
252        let author = self.author(key)
253        guard let index = yearCache.firstIndex(where: { $0.author == author && $0.year == year }) else {
254            yearCache.insert((author, year, [(key, "")]), at: 0)
255            return year
256        }
257        let suffix: String
258        if let known = yearCache[index].keys.first(where: { $0.key == key }) {
259            suffix = known.suffix
260        } else {
261            suffix = Self.suffix(yearCache[index].keys.count - 1)
262            yearCache[index].keys.insert((key, suffix), at: 0)
263        }
264        return noSuffix ? year : (year ?? "") + suffix
265    }
266
267    /// `org-cite-basic--number-to-suffix`.
268    static func suffix(_ number: Int) -> String {
269        var n = number
270        var result: [Int] = []
271        while true {
272            result.insert(n % 26, at: 0)
273            n /= 26
274            if n == 0 { break }
275            if n < 27 { result.insert(n - 1, at: 0); break }
276            if n == 27 { result.insert(contentsOf: [0, 0], at: 0); break }
277        }
278        return String(String.UnicodeScalarView(result.compactMap { Unicode.Scalar(97 + $0) }))
279    }
280
281    /// `org-cite-basic--field-less-p` with the author: `org-string<` ignoring case.
282    func authorLess(_ a: String, _ b: String) -> Bool {
283        (author(a) ?? "nil").lowercased() < (author(b) ?? "nil").lowercased()
284    }
285
286    /// `org-cite-basic-export-citation`. Nil for a style it doesn't do.
287    mutating func format(_ citation: Citation, keys: [String], defaultStyle: String?) -> [Piece]? {
288        let defaults = defaultStyle.map { $0.split(separator: "/", omittingEmptySubsequences: false).map(String.init) } ?? []
289        let style = citation.style ?? defaults.first
290        let variant = citation.variant.flatMap { $0.isEmpty ? nil : $0 } ?? (citation.style == nil && defaults.count > 1 ? defaults[1] : nil)
291        let bare = ["bare", "bare-caps", "b", "bc"].contains(variant ?? "")
292        let caps = ["caps", "bare-caps", "c", "bc"].contains(variant ?? "")
293        func org(_ s: String?) -> [Piece] { s.map { [.org($0)] } ?? [] }
294        func authorYear(_ cite: ([Piece], [Piece], [Piece]) -> [Piece], _ ref: (Reference, String, String) -> [Piece]) -> [Piece] {
295            var contents: [Piece] = []
296            for (n, reference) in citation.references.enumerated() {
297                if n > 0 { contents.append(.text(", ")) }
298                let author = self.author(reference.key) ?? "??"
299                let year = self.year(reference.key) ?? "????"
300                contents += ref(reference, author, year)
301            }
302            return cite(org(citation.prefix), contents, org(citation.suffix))
303        }
304        func capitalized(_ s: String) -> String { caps ? s.capitalized : s }
305        switch style {
306        case "author", "a":
307            let caps = ["caps", "c"].contains(variant ?? "")
308            return authorYear({ $0 + $1 + $2 }) { r, a, _ in org(r.prefix) + [.text(caps ? a.capitalized : a)] + org(r.suffix) }
309        case "noauthor", "na":
310            return authorYear({ (bare ? [] : [.text("(")]) + $0 + $1 + $2 + (bare ? [] : [.text(")")]) }) { r, _, y in org(r.prefix) + [.text(y)] + org(r.suffix) }
311        case "nocite", "n":
312            return []
313        case "text", "t", "note", "ft":
314            return authorYear({ $0 + $1 + $2 }) { r, a, y in
315                org(r.prefix) + [.text(capitalized(a) + (bare ? " " : " (") + y + (bare ? "" : ")"))] + org(r.suffix)
316            }
317        case "numeric", "nb":
318            let single = citation.references.count == 1 ? citation.references[0] : nil
319            let prefix = single.map { $0.prefix } ?? citation.prefix
320            let suffix = single.map { $0.suffix } ?? citation.suffix
321            let sorted = keys.sorted(by: authorLess)
322            let numbers = citation.references.compactMap { r in sorted.firstIndex(of: r.key).map { $0 + 1 } }.sorted()
323            return [.text("(")] + org(prefix) + [.text(Self.compact(numbers))] + org(suffix) + [.text(")")]
324        default:
325            return authorYear({ (bare ? [] : [.text("(")]) + $0 + $1 + $2 + (bare ? [] : [.text(")")]) }) { r, a, y in
326                org(r.prefix) + [.text(capitalized(a) + ", " + y)] + org(r.suffix)
327            }
328        }
329    }
330
331    /// `org-cite-basic--citation-numbers`: runs of three or more as `1-3`.
332    static func compact(_ numbers: [Int]) -> String {
333        guard var last = numbers.first else { return "" }
334        var result = [String(last)]
335        var rest = Array(numbers.dropFirst())
336        while !rest.isEmpty {
337            let current = rest.removeFirst()
338            let next = rest.first
339            if let next, current == last + 1, current == next - 1 {
340                if result.last != "-" { result.append("-") }
341            } else if result.last == "-" {
342                result.append(String(current))
343            } else {
344                result.append(", \(current)")
345            }
346            last = current
347        }
348        return result.joined()
349    }
350
351    /// `org-cite-basic--shorten-names`.
352    static func shortenNames(_ names: String) -> String {
353        names.components(separatedBy: " and ").map { name in
354            name.count == 1 ? "" : String(name.components(separatedBy: ", ")[0])
355        }.joined(separator: ", ")
356    }
357
358    /// `org-cite-basic--print-entry`. `org-cite-basic--print-bibtex-string` strips braces only
359    /// in the italic title, the one string inside an object.
360    mutating func entry(_ key: String, style: String?, keys: [String]) -> [Piece]? {
361        guard let entry = entries[key] else { return nil }
362        func clean(_ s: String?) -> String { (s ?? "").replacingOccurrences(of: "[{}]", with: "", options: .regularExpression) }
363        let author = self.author(key) ?? ""
364        let title = entry["title"]
365        let from = entry["publisher"] ?? entry["journal"] ?? entry["institution"] ?? entry["school"]
366        let fromText = from.map { ", " + $0 } ?? ""
367        switch style {
368        case "plain":
369            let year = self.year(key, noSuffix: true) ?? ""
370            return [.text(Self.shortenNames(author) + ". " + (title ?? "") + fromText + ", " + year + ".")]
371        case "numeric":
372            let n = (keys.sorted(by: authorLess).firstIndex(of: key) ?? 0) + 1
373            let year = self.year(key, noSuffix: true) ?? ""
374            return [.text("[\(n)] " + author + ", "), .italic(clean(title)), .text(fromText + ", " + year + ".")]
375        default:
376            let year = self.year(key) ?? ""
377            return [.text(author + " (" + year + "). "), .italic(clean(title)), .text(fromText + ".")]
378        }
379    }
380
381    // MARK: - Building
382
383    /// The citations of `text` (as written, nocite citations included) worked out in document
384    /// order, then the bibliography; nil when the export leaves citations alone.
385    init?(text: String, directory: URL?) {
386        guard text.range(of: "[cite", options: .caseInsensitive) != nil else { return nil }
387        let settings = Self.exportSettings(text)
388        if let settings, settings.processor != "basic" { return nil }
389        // Each `#+bibliography:` names one file.
390        for m in text.matches(of: #/(?mi)^[ \t]*#\+bibliography:[ \t]*(.*?)[ \t]*$/#) {
391            for name in [String(m.1)].map({ $0.count > 1 && $0.hasPrefix("\"") && $0.hasSuffix("\"") ? String($0.dropFirst().dropLast()) : $0 }) where !name.isEmpty {
392                let path = (name as NSString).expandingTildeInPath
393                let url = path.hasPrefix("/") ? URL(fileURLWithPath: path) : (directory ?? URL(fileURLWithPath: "/")).appendingPathComponent(path)
394                guard let data = try? Data(contentsOf: url) else { continue }
395                let parsed = url.pathExtension.lowercased() == "json" ? Self.parseJSON(data) : Self.parseBibTeX(String(decoding: data, as: UTF8.self))
396                for (key, entry) in parsed where entries[key] == nil { entries[key] = entry }
397            }
398        }
399        let tree = OrgParser.parse(text)
400        // Keywords (`#+TITLE`) aren't part of the document Org processes citations in.
401        var nodes: [SyntaxNode] = []
402        func collect(_ node: SyntaxNode) {
403            for child in node.children where child.kind != .keyword {
404                if child.kind == .citation { nodes.append(child) } else { collect(child) }
405            }
406        }
407        collect(tree.root)
408        let citations = nodes.compactMap { Self.parse($0.text) }
409        var keys: [String] = []
410        for citation in citations { for r in citation.references where !keys.contains(r.key) { keys.append(r.key) } }
411        for citation in citations {
412            guard let pieces = format(citation, keys: keys, defaultStyle: settings?.citeStyle) else { continue }
413            outputs[citation.text, default: []].append(pieces)
414        }
415        let style = settings?.bibStyle
416        // `sort` is stable: keys with the same author keep their order.
417        bibliography = keys.enumerated().sorted { a, b in
418            authorLess(a.element, b.element) || (!authorLess(b.element, a.element) && a.offset < b.offset)
419        }.compactMap { entry($0.element, style: style, keys: keys) }
420    }
421
422    /// Note citations (`note`, `ft`) as inline footnotes holding their text-style citation, as
423    /// `org-cite-wrap-citation` does, the blanks before them gone and punctuation right after
424    /// them moved in front (`org-cite-adjust-note`).
425    mutating func inliningNotes(_ text: String) -> String {
426        let defaultStyle = Self.exportSettings(text)?.citeStyle.map { String($0.split(separator: "/").first ?? "") }
427        let regex = try! NSRegularExpression(pattern: "[ \\t]*(\\[cite[/:][^\\]]*\\])([.,;:!?]*)")
428        var out = ""
429        var at = 0
430        let ns = text as NSString
431        for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
432            let written = ns.substring(with: m.range(at: 1))
433            guard let citation = Self.parse(written), ["note", "ft"].contains(citation.style ?? defaultStyle ?? ""),
434                  let pieces = next(written) else { continue }
435            let note = pieces.map { piece in
436                switch piece {
437                case .text(let s), .org(let s), .italic(let s): s
438                }
439            }.joined()
440            out += ns.substring(with: NSRange(location: at, length: m.range.location - at)) + ns.substring(with: m.range(at: 2)) + "[fn:: " + note + "]"
441            at = NSMaxRange(m.range)
442        }
443        return out + ns.substring(from: at)
444    }
445
446    /// The export of the next citation written as `text`; nil leaves it as written.
447    mutating func next(_ text: String) -> [Piece]? {
448        guard var queue = outputs[text], !queue.isEmpty else { return nil }
449        let first = queue.removeFirst()
450        outputs[text] = queue
451        return first
452    }
453}