Sources/OrgCore/Export/Citations.swift
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}