krz/orgstar

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

Sources/OrgCore/Export/ExportOptions.swift

c1295824f55c7bba681fca8338bf08167e110105
orgstar/Sources/OrgCore/Export/ExportOptions.swift history · blame · raw

308 lines · 15619 bytes

  1import Foundation
  2
  3/// What an export includes, from `#+OPTIONS` and the export keywords (ox.el, Org 9.8.7).
  4public struct ExportOptions: Sendable, Equatable {
  5    public enum Scripts: Sendable, Equatable {
  6        case on
  7        /// `^:{}`: only `_{…}` and `^{…}`.
  8        case braces
  9        case off
 10    }
 11
 12    /// `toc`: the table of contents' depth, nil for none.
 13    public var toc: Int?
 14    /// `num`: section numbers to this depth, nil for none.
 15    public var num: Int?
 16    public var tags = true
 17    /// `tags:not-in-toc`.
 18    public var tagsInTOC = true
 19    public var todo = true
 20    public var priority = false
 21    public var scripts = Scripts.on
 22    /// `H`: the deepest heading level the TOC and numbering count.
 23    public var headlineLevels = 3
 24    public var tex = true
 25    public var author = true
 26    public var date = true
 27    public var title = true
 28    public var excludeTags = ["noexport"]
 29    public var selectTags = ["export"]
 30    /// `#+HTML_HEAD` and `#+HTML_HEAD_EXTRA` lines, in order.
 31    public var htmlHead: [String] = []
 32
 33    /// `org-export-with-*` defaults.
 34    public static let org = ExportOptions(toc: 3, num: 3)
 35
 36    /// The shape the conformance corpus holds: no table of contents or numbers, priorities
 37    /// shown.
 38    public static let orgo: ExportOptions = {
 39        var options = ExportOptions(toc: nil, num: nil)
 40        options.priority = true
 41        return options
 42    }()
 43
 44    /// `defaults` with the document's `#+OPTIONS`, `#+EXCLUDE_TAGS`, `#+SELECT_TAGS` and
 45    /// `#+HTML_HEAD` applied.
 46    public static func parse(_ text: String, defaults: ExportOptions = .org) -> ExportOptions {
 47        var options = defaults
 48        var tocSet: String?
 49        var numSet: String?
 50        for m in text.matches(of: #/(?mi)^[ \t]*#\+(OPTIONS|EXCLUDE_TAGS|SELECT_TAGS|HTML_HEAD|HTML_HEAD_EXTRA):[ \t]*(.*?)[ \t]*$/#) {
 51            let value = String(m.2)
 52            switch m.1.uppercased() {
 53            case "EXCLUDE_TAGS": options.excludeTags = value.split(whereSeparator: \.isWhitespace).map(String.init)
 54            case "SELECT_TAGS": options.selectTags = value.split(whereSeparator: \.isWhitespace).map(String.init)
 55            case "HTML_HEAD", "HTML_HEAD_EXTRA": options.htmlHead.append(value)
 56            default:
 57                for item in value.matches(of: /(\S+?):(\S+)/) {
 58                    let key = String(item.1)
 59                    let v = String(item.2)
 60                    let on = v != "nil"
 61                    switch key {
 62                    case "toc": tocSet = v
 63                    case "num": numSet = v
 64                    case "tags":
 65                        options.tags = on
 66                        options.tagsInTOC = v != "not-in-toc"
 67                    case "todo": options.todo = on
 68                    case "pri": options.priority = on
 69                    case "^": options.scripts = v == "{}" ? .braces : on ? .on : .off
 70                    case "H": if let n = Int(v) { options.headlineLevels = n }
 71                    case "tex": options.tex = on
 72                    case "author": options.author = on
 73                    case "date": options.date = on
 74                    case "title": options.title = on
 75                    default: break
 76                    }
 77                }
 78            }
 79        }
 80        // `toc` and `num` as t (to H), nil or a depth.
 81        func depth(_ v: String?, _ current: Int?) -> Int? {
 82            guard let v else { return current.map { _ in options.headlineLevels } }
 83            if v == "nil" { return nil }
 84            return Int(v).map { min($0, options.headlineLevels) } ?? options.headlineLevels
 85        }
 86        options.toc = depth(tocSet, options.toc)
 87        options.num = depth(numSet, options.num)
 88        return options
 89    }
 90}
 91
 92/// What happens to the text before it's parsed for export: `#+INCLUDE` (ox.el's
 93/// `org-export-expand-include-keyword`) and macros (`org-macro-replace-all`).
 94public enum ExportPreprocess {
 95    /// `#+INCLUDE: "file" [example|export FORMAT|src LANG] [:lines "A-B"] [:minlevel N]`, with
 96    /// `file::*Heading` or `file::#custom-id` for one subtree. Relative to `directory`.
 97    public static func includes(_ text: String, directory: URL?, depth: Int = 0) -> String {
 98        guard depth < 8, text.range(of: "#+INCLUDE:", options: .caseInsensitive) != nil else { return text }
 99        var lines: [String] = []
100        for line in text.components(separatedBy: "\n") {
101            guard let m = line.firstMatch(of: #/(?i)^[ \t]*#\+INCLUDE:[ \t]+"([^"]+)"(.*)$/#) else {
102                lines.append(line)
103                continue
104            }
105            let spec = String(m.1)
106            let rest = String(m.2)
107            var file = spec
108            var target: String?
109            if let r = spec.range(of: "::") {
110                file = String(spec[..<r.lowerBound])
111                target = String(spec[r.upperBound...])
112            }
113            let url = (file as NSString).isAbsolutePath || directory == nil
114                ? URL(fileURLWithPath: (file as NSString).expandingTildeInPath)
115                : directory!.appendingPathComponent(file)
116            guard var included = try? String(contentsOf: url, encoding: .utf8) else {
117                lines.append("")
118                continue
119            }
120            if let target { included = subtree(included, target) ?? "" }
121            if let range = rest.firstMatch(of: /:lines[ \t]+"([0-9]*)-([0-9]*)"/) {
122                let all = included.components(separatedBy: "\n")
123                let low = max(1, Int(range.1) ?? 1)
124                let high = min(all.count, (Int(range.2).map { $0 - 1 }) ?? all.count)
125                included = low <= high ? all[(low - 1)..<high].joined(separator: "\n") : ""
126            }
127            let words = rest.replacingOccurrences(of: ":[a-z-]+[ \\t]+(\"[^\"]*\"|\\S+)", with: "", options: .regularExpression)
128                .split(whereSeparator: \.isWhitespace).map(String.init)
129            if let kind = words.first?.lowercased(), ["src", "example", "export", "quote", "verse", "center", "comment"].contains(kind) {
130                // The words after the kind, as written, up to the first option.
131                var parameter = rest.drop { $0 == " " || $0 == "\t" }.dropFirst(kind.count).drop { $0 == " " || $0 == "\t" }
132                if let option = parameter.firstIndex(of: ":") { parameter = parameter[..<option] }
133                let body = included.hasSuffix("\n") ? String(included.dropLast()) : included
134                let escaped = kind == "src" || kind == "example" ? BlockEdit.escape(body) : body
135                lines.append("#+BEGIN_\(kind)" + (parameter.isEmpty ? "" : " " + parameter))
136                lines.append(escaped)
137                lines.append("#+END_\(kind)")
138                continue
139            }
140            if let minLevel = rest.firstMatch(of: /:minlevel[ \t]+([0-9]+)/).flatMap({ Int($0.1) }) {
141                included = shift(included, to: minLevel)
142            }
143            let expanded = includes(included, directory: url.deletingLastPathComponent(), depth: depth + 1)
144            lines.append(expanded.hasSuffix("\n") ? String(expanded.dropLast()) : expanded)
145        }
146        return lines.joined(separator: "\n")
147    }
148
149    /// The subtree of `text` at `*Heading` or `#custom-id`.
150    static func subtree(_ text: String, _ target: String) -> String? {
151        let tree = OrgParser.parse(text)
152        let model = DocumentModel(tree: tree)
153        let found: Int?
154        if target.hasPrefix("#") {
155            let id = String(target.dropFirst())
156            found = model.headings.firstIndex { $0.properties.contains { $0.key.uppercased() == "CUSTOM_ID" && $0.value == id } }
157        } else {
158            let title = target.hasPrefix("*") ? String(target.dropFirst()) : target
159            found = model.headings.firstIndex { Links.displayFormat($0.title).trimmingCharacters(in: .whitespaces) == title || $0.title == title }
160        }
161        guard let index = found else { return nil }
162        let start = model.headings[index].headingRange.lowerBound
163        let end = model.headings[(index + 1)...].first { $0.level <= model.headings[index].level }?.headingRange.lowerBound ?? (text as NSString).length
164        return (text as NSString).substring(with: NSRange(location: start, length: end - start))
165    }
166
167    /// Headings moved so the shallowest is at `level`.
168    static func shift(_ text: String, to level: Int) -> String {
169        let levels = text.matches(of: #/(?m)^(\*+) /#).map { $0.1.count }
170        guard let shallowest = levels.min() else { return text }
171        let delta = level - shallowest
172        guard delta != 0 else { return text }
173        return text.components(separatedBy: "\n").map { line in
174            guard let m = line.firstMatch(of: #/^(\*+) /#) else { return line }
175            return String(repeating: "*", count: max(1, m.1.count + delta)) + line[m.1.endIndex...]
176        }.joined(separator: "\n")
177    }
178
179    /// `{{{name(arg, …)}}}` replaced: `#+MACRO` definitions with `$1`… and the built-in title,
180    /// author, date, email, input-file, keyword, property, n and time macros. Others become
181    /// empty, as Lisp ones do here.
182    /// `setup` is the setup files' keyword lines, whose definitions count too.
183    public static func macros(_ text: String, path: String?, now: Date = Date(), setup: String = "") -> String {
184        guard text.contains("{{{") else { return text }
185        var definitions: [String: String] = [:]
186        var keywords: [String: String] = [:]
187        for m in (setup + text).matches(of: #/(?mi)^[ \t]*#\+([A-Z_]+):[ \t]*(.*?)[ \t]*$/#) {
188            let key = m.1.uppercased()
189            let value = String(m.2)
190            if key == "MACRO", let d = value.firstMatch(of: /^(\S+)[ \t]*(.*)$/) {
191                definitions[d.1.lowercased()] = String(d.2)
192            } else if keywords[key] == nil {
193                keywords[key] = value
194            }
195        }
196        var counters: [String: Int] = [:]
197        var model: DocumentModel?
198        /// `org-entry-get` with `selective` inheritance at the heading holding `location`, or
199        /// at the entry `search` finds (`*Title`, `#custom-id` or a title).
200        func property(_ name: String, _ search: String?, at location: Int) -> String {
201            if model == nil { model = DocumentModel(tree: OrgParser.parse(text)) }
202            let headings = model!.headings
203            let index: Int?
204            if let search, !search.isEmpty {
205                if search.hasPrefix("#") {
206                    let id = String(search.dropFirst())
207                    index = headings.firstIndex { $0.properties.contains { $0.key.uppercased() == "CUSTOM_ID" && $0.value == id } }
208                } else {
209                    let title = search.hasPrefix("*") ? String(search.dropFirst()) : search
210                    index = headings.firstIndex { Links.displayFormat($0.title).trimmingCharacters(in: .whitespaces) == title || $0.title == title }
211                }
212            } else {
213                index = headings.lastIndex { $0.headingRange.lowerBound <= location }
214            }
215            guard let index else { return "" }
216            if name.uppercased() == "ITEM" { return headings[index].title }
217            return model!.property(name, of: index)?.value ?? ""
218        }
219        let regex = try! NSRegularExpression(pattern: "\\{\\{\\{([-A-Za-z0-9_]+)(?:\\((.*?)\\))?\\}\\}\\}")
220        var out = ""
221        var at = 0
222        let ns = text as NSString
223        for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
224            out += ns.substring(with: NSRange(location: at, length: m.range.location - at))
225            at = NSMaxRange(m.range)
226            // Not on #+MACRO lines themselves.
227            let lineStart = ns.lineRange(for: NSRange(location: m.range.location, length: 0)).location
228            if ns.substring(from: lineStart).range(of: "^[ \\t]*#\\+MACRO:", options: [.regularExpression, .caseInsensitive]) != nil {
229                out += ns.substring(with: m.range)
230                continue
231            }
232            let name = ns.substring(with: m.range(at: 1)).lowercased()
233            // Inline results stay for the exporter, which shows them as `:exports` says.
234            if name == "results", definitions[name] == nil {
235                out += ns.substring(with: m.range)
236                continue
237            }
238            let args = m.range(at: 2).location == NSNotFound ? [] : arguments(ns.substring(with: m.range(at: 2)))
239            let location = m.range.location
240            out += expand(name, args, definitions: definitions, keywords: keywords, counters: &counters, path: path, now: now) {
241                property($0, $1, at: location)
242            }
243        }
244        out += ns.substring(from: at)
245        return out
246    }
247
248    /// Macro arguments: split on commas, `\,` for a literal one, blanks around trimmed.
249    static func arguments(_ s: String) -> [String] {
250        var args: [String] = []
251        var current = ""
252        var escaped = false
253        for c in s {
254            if escaped {
255                current.append(c == "," ? "," : "\\")
256                if c != "," { current.append(c) }
257                escaped = false
258            } else if c == "\\" {
259                escaped = true
260            } else if c == "," {
261                args.append(current)
262                current = ""
263            } else {
264                current.append(c)
265            }
266        }
267        if escaped { current.append("\\") }
268        args.append(current)
269        return args.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
270    }
271
272    static func expand(_ name: String, _ args: [String], definitions: [String: String], keywords: [String: String],
273                       counters: inout [String: Int], path: String?, now: Date, property: (String, String?) -> String) -> String {
274        if let template = definitions[name] {
275            guard !template.hasPrefix("(eval") else { return "" }
276            var result = template
277            for (i, arg) in args.enumerated().reversed() { result = result.replacingOccurrences(of: "$\(i + 1)", with: arg) }
278            return result.replacingOccurrences(of: "\\$[0-9]+", with: "", options: .regularExpression)
279        }
280        switch name {
281        case "title", "author", "date", "email": return keywords[name.uppercased()] ?? ""
282        case "keyword": return args.first.flatMap { keywords[$0.uppercased()] } ?? ""
283        case "input-file": return path.map { ($0 as NSString).lastPathComponent } ?? ""
284        case "property": return args.first.map { property($0, args.count > 1 ? args[1] : nil) } ?? ""
285        case "n":
286            let counter = args.first ?? ""
287            let action = args.count > 1 ? args[1] : ""
288            if action == "-" { return String(counters[counter] ?? 0) }
289            let value = Int(action) ?? ((counters[counter] ?? 0) + 1)
290            counters[counter] = value
291            return String(value)
292        case "time":
293            guard let format = args.first else { return "" }
294            return strftime(format, now)
295        default: return ""
296        }
297    }
298
299    /// `format-time-string` for the common directives.
300    static func strftime(_ format: String, _ date: Date) -> String {
301        var t = time_t(date.timeIntervalSince1970)
302        var tm = tm()
303        localtime_r(&t, &tm)
304        var buffer = [Int8](repeating: 0, count: 256)
305        let count = Foundation.strftime(&buffer, buffer.count, format, &tm)
306        return String(decoding: buffer.prefix(count).map { UInt8(bitPattern: $0) }, as: UTF8.self)
307    }
308}