krz/orgstar

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

Sources/OrgCore/Parser/SetupFile.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Sources/OrgCore/Parser/SetupFile.swift history · blame · raw

57 lines · 3463 bytes

 1import Foundation
 2
 3/// `#+SETUPFILE` (`org--collect-keywords-1`): each setup file's keywords count as if they were
 4/// in the file that names it, setup files named there included. Paths are relative to the
 5/// naming file's folder; URLs aren't fetched.
 6public enum SetupFile {
 7    /// The keyword lines the setup files `text` names bring, in order, as `#+KEY: value` lines.
 8    /// `read` gives a file's text, or nil when it can't be read. `path`, the naming file's,
 9    /// counts as read already, as `buffer-file-name` does in Org.
10    public static func keywordLines(in text: String, directory: String, path: String? = nil, read: (String) -> String?) -> String {
11        lines(in: text, directory: directory, read: read, own: false, seen: path.map { [($0 as NSString).standardizingPath] } ?? [])
12    }
13
14    private static func lines(in text: String, directory: String, read: (String) -> String?, own: Bool, seen: Set<String>) -> String {
15        guard own || text.range(of: "#+setupfile:", options: .caseInsensitive) != nil else { return "" }
16        var out = ""
17        for node in OrgParser.parse(text).root.descendants() where node.kind == .keyword {
18            let line = node.text.trimmingCharacters(in: .whitespacesAndNewlines)
19            guard let colon = line.firstIndex(of: ":") else { continue }
20            let key = line[line.index(line.startIndex, offsetBy: 2)..<colon].uppercased()
21            guard key == "SETUPFILE" else {
22                if own { out += line + "\n" }
23                continue
24            }
25            var value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
26            if value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") { value = String(value.dropFirst().dropLast()) }
27            guard !value.isEmpty, value.range(of: "^[a-zA-Z][a-zA-Z0-9+.-]*://", options: .regularExpression) == nil else { continue }
28            let expanded = (value as NSString).expandingTildeInPath
29            let path = ((expanded.hasPrefix("/") ? expanded : (directory as NSString).appendingPathComponent(expanded)) as NSString).standardizingPath
30            guard !seen.contains(path), let contents = read(path) else { continue }
31            out += lines(in: contents, directory: (path as NSString).deletingLastPathComponent, read: read, own: true, seen: seen.union([path]))
32        }
33        return out
34    }
35
36    /// `(key, value)` for each `#+KEY: value` line.
37    static func keywords(_ lines: String) -> [(key: String, value: Substring)] {
38        lines.split(separator: "\n").compactMap { line in
39            guard line.hasPrefix("#+"), let colon = line.firstIndex(of: ":") else { return nil }
40            return (line[line.index(line.startIndex, offsetBy: 2)..<colon].uppercased(), line[line.index(after: colon)...])
41        }
42    }
43}
44
45extension OrgSettings {
46    /// These settings with the keywords of the setup files `text` names, for a file at `path`.
47    public func resolvingSetupFiles(in text: String, path: String, read: (String) -> String?) -> OrgSettings {
48        var copy = self
49        copy.setupKeywords = SetupFile.keywordLines(in: text, directory: (path as NSString).deletingLastPathComponent, path: path, read: read)
50        return copy
51    }
52
53    /// A file's text readable as UTF-8, for `resolvingSetupFiles`.
54    public static func readFile(_ path: String) -> String? {
55        FileManager.default.contents(atPath: path).flatMap { String(data: $0, encoding: .utf8) }
56    }
57}