krz/orgstar

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

Sources/OrgCore/Links/Links.swift

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

320 lines · 17077 bytes

  1import Foundation
  2
  3/// A link as `org-element-link-parser` reads it.
  4public struct OrgLink: Sendable, Equatable {
  5    /// `https`, `file`, `id`, `custom-id`, `coderef`, `fuzzy`, or another registered type.
  6    public let type: String
  7    public let path: String
  8    /// What follows `::` in a file link.
  9    public let searchOption: String?
 10    /// The link as written, unescaped and with abbreviations expanded.
 11    public let raw: String
 12    /// Where an `attachment` link starts in its file, whose entry holds the attachments.
 13    public var position: Int?
 14
 15    public init(type: String, path: String, searchOption: String? = nil, raw: String, position: Int? = nil) {
 16        self.type = type
 17        self.path = path
 18        self.searchOption = searchOption
 19        self.raw = raw
 20        self.position = position
 21    }
 22}
 23
 24public enum Links {
 25    /// `org-link-parameters` in Emacs with Org's default modules, and org-attach's.
 26    static let types = [
 27        "attachment", "id", "eww", "rmail", "mhe", "irc", "info", "gnus", "docview", "bibtex", "bbdb", "w3m", "doi", "file+sys", "file+emacs",
 28        "shell", "news", "mailto", "https", "http", "ftp", "shortdoc", "help", "file", "elisp",
 29    ]
 30
 31    /// `org-link-types-re`: a registered type and its colon, longest names first.
 32    static let typesPattern = try! NSRegularExpression(
 33        pattern: "^(" + types.sorted { $0.count > $1.count }.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "|") + "):"
 34    )
 35
 36    /// `org-link-unescape`: halves each run of backslashes before a bracket or the end.
 37    static func unescape(_ s: String) -> String {
 38        let regex = try! NSRegularExpression(pattern: "(\\\\+)(?=$|[\\[\\]])")
 39        let ns = s as NSString
 40        var out = s
 41        for m in regex.matches(in: s, range: NSRange(location: 0, length: ns.length)).reversed() {
 42            let run = m.range(at: 1)
 43            out = (out as NSString).replacingCharacters(in: run, with: String(repeating: "\\", count: run.length / 2))
 44        }
 45        return out
 46    }
 47
 48    /// `#+LINK: key template` lines of a file.
 49    public static func abbreviations(in text: String) -> [String: String] {
 50        var result: [String: String] = [:]
 51        let regex = try! NSRegularExpression(pattern: "^[ \\t]*#\\+LINK:[ \\t]+(\\S+)[ \\t]+(.*?)[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive])
 52        let ns = text as NSString
 53        for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) where result[ns.substring(with: m.range(at: 1))] == nil {
 54            result[ns.substring(with: m.range(at: 1))] = ns.substring(with: m.range(at: 2))
 55        }
 56        return result
 57    }
 58
 59    /// `org-link-expand-abbrev` for templates with `%s`, `%h`, or a plain prefix.
 60    static func expandAbbreviation(_ link: String, _ abbreviations: [String: String]) -> String {
 61        let regex = try! NSRegularExpression(pattern: "^([^:]*)(::?(.*))?$")
 62        let ns = link as NSString
 63        guard let m = regex.firstMatch(in: link, range: NSRange(location: 0, length: ns.length)),
 64              let template = abbreviations[ns.substring(with: m.range(at: 1))] else { return link }
 65        let tag = m.range(at: 2).location == NSNotFound ? "" : ns.substring(with: m.range(at: 3))
 66        if template.contains("%s") {
 67            return template.replacingOccurrences(of: "%s", with: tag, options: [], range: template.range(of: "%s"))
 68        }
 69        if template.contains("%h") {
 70            let hex = tag.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+?#"))) ?? tag
 71            return template.replacingOccurrences(of: "%h", with: hex, options: [], range: template.range(of: "%h"))
 72        }
 73        return template + tag
 74    }
 75
 76    /// A bracket link's inside (`[[` to `]]` or `][`), as `org-element-link-parser` reads it.
 77    public static func parse(bracket inside: String, abbreviations: [String: String] = [:]) -> OrgLink {
 78        let joined = inside.replacingOccurrences(of: "[ \\t]*\\n[ \\t]*", with: " ", options: .regularExpression)
 79        let raw = expandAbbreviation(unescape(joined), abbreviations)
 80        let ns = raw as NSString
 81        var type: String
 82        var path: String
 83        if raw.hasPrefix("/") || raw.hasPrefix("~") || raw.hasPrefix("./") || raw.hasPrefix("../") {
 84            type = "file"
 85            path = raw
 86        } else if let m = typesPattern.firstMatch(in: raw, range: NSRange(location: 0, length: ns.length)) {
 87            type = ns.substring(with: m.range(at: 1))
 88            path = ns.substring(from: NSMaxRange(m.range))
 89        } else if raw.hasPrefix("("), raw.hasSuffix(")"), raw.count >= 2 {
 90            type = "coderef"
 91            path = String(raw.dropFirst().dropLast())
 92        } else if raw.hasPrefix("#") {
 93            type = "custom-id"
 94            path = String(raw.dropFirst())
 95        } else {
 96            type = "fuzzy"
 97            path = raw
 98        }
 99        return fileParts(type: type, path: path, raw: raw)
100    }
101
102    /// A plain or angle link: `type:path` as written.
103    public static func parse(plain: String) -> OrgLink {
104        let ns = plain as NSString
105        guard let m = typesPattern.firstMatch(in: plain, range: NSRange(location: 0, length: ns.length)) else {
106            return OrgLink(type: "fuzzy", path: plain, raw: plain)
107        }
108        let path = ns.substring(from: NSMaxRange(m.range)).replacingOccurrences(of: "[ \\t]*\\n[ \\t]*", with: "", options: .regularExpression)
109        return fileParts(type: ns.substring(with: m.range(at: 1)), path: path, raw: plain)
110    }
111
112    /// `file` and `file+app` links keep their `::search` apart.
113    static func fileParts(type: String, path: String, raw: String) -> OrgLink {
114        guard type == "file" || type.hasPrefix("file+") else { return OrgLink(type: type, path: path, raw: raw) }
115        var path = path
116        var search: String?
117        if let range = path.range(of: "::(.*)$", options: .regularExpression) {
118            search = String(path[range].dropFirst(2))
119            path.removeSubrange(range)
120        }
121        path = path.replacingOccurrences(of: "^///*([A-Za-z]:)?/", with: "$1/", options: .regularExpression)
122        return OrgLink(type: "file", path: path, searchOption: search, raw: raw)
123    }
124
125    /// The link at `offset`: the link node holding it, read with the file's abbreviations.
126    public static func link(at offset: Int, text: String, tree: OrgTree) -> (link: OrgLink, range: Range<Int>)? {
127        var found: SyntaxNode?
128        func walk(_ node: SyntaxNode) {
129            for child in node.children where child.range.lowerBound <= offset && offset < child.range.upperBound
130                || (child.range.upperBound == offset && child.kind == .link) {
131                if child.kind == .link { found = child }
132                walk(child)
133            }
134        }
135        walk(tree.root)
136        guard let node = found else { return nil }
137        let whole = node.text
138        var link: OrgLink
139        if whole.hasPrefix("[["), let path = node.tokens.first(where: { $0.kind == .linkPath }) {
140            link = parse(bracket: path.text, abbreviations: abbreviations(in: tree.settings.setupKeywords + text))
141        } else if whole.hasPrefix("<") && whole.hasSuffix(">") {
142            link = parse(plain: String(whole.dropFirst().dropLast()))
143        } else if tree.settings.radioTargets.contains(where: { words($0) == words(whole) }) {
144            link = OrgLink(type: "radio", path: whole, raw: whole)
145        } else {
146            link = parse(plain: whole)
147        }
148        if link.type == "attachment" { link.position = node.range.lowerBound }
149        return (link, node.range)
150    }
151
152    /// Words without case, as radio links match their targets.
153    static func words(_ s: String) -> [String] { s.lowercased().split(whereSeparator: \.isWhitespace).map(String.init) }
154
155    /// `org-link--search-radio-target`: the `<<<target>>>` a radio link's text names.
156    public static func searchRadio(_ target: String, tree: OrgTree) -> SearchResult {
157        var found: Int?
158        func walk(_ node: SyntaxNode) {
159            for child in node.children where found == nil {
160                if child.kind == .radioTarget, words(String(child.text.dropFirst(3).dropLast(3))) == words(target) {
161                    found = child.range.lowerBound
162                }
163                walk(child)
164            }
165        }
166        walk(tree.root)
167        return found.map { .found($0) } ?? .failed("No match for radio target: \(target)")
168    }
169
170    /// The file `org-attach-follow` opens for an `attachment` link, with org-attach's
171    /// defaults: in the entry's DIR property (or the older ATTACH_DIR), else in the folder for
172    /// its ID under `data/` that exists (`org-attach-id-to-path-function-list`), both relative
173    /// to the file's folder. Without an existing folder the file is relative to the file's
174    /// folder, as `org-attach-expand` has it. Nil without a folder to start from.
175    public static func attachmentFile(_ link: OrgLink, tree: OrgTree, path: String?, isDirectory: (String) -> Bool) -> OrgLink? {
176        let model = DocumentModel(tree: tree)
177        let offset = link.position ?? 0
178        let heading = model.headings.lastIndex { $0.sectionRange.contains(offset) }
179        let folder = path.map { ($0 as NSString).deletingLastPathComponent }
180        func absolute(_ p: String, in folder: String?) -> String? {
181            let expanded = (p as NSString).expandingTildeInPath
182            if expanded.hasPrefix("/") { return (expanded as NSString).standardizingPath }
183            return folder.map { (($0 as NSString).appendingPathComponent(expanded) as NSString).standardizingPath }
184        }
185        var candidates: [String] = []
186        if let dir = model.property("DIR", of: heading) ?? model.property("ATTACH_DIR", of: heading) {
187            candidates = [dir.value]
188        } else if let id = model.property("ID", of: heading)?.value, let first = id.first {
189            let chars = Array(id)
190            if chars.count > 2 { candidates.append("data/" + String(chars[..<2]) + "/" + String(chars[2...])) }
191            if chars.count > 6 { candidates.append("data/" + String(chars[..<6]) + "/" + String(chars[6...])) }
192            candidates.append("data/__/\(first)/\(id)")
193        }
194        let dir = candidates.lazy.compactMap { absolute($0, in: folder) }.first(where: isDirectory) ?? folder
195        var file = link.path
196        var search: String?
197        if let range = file.range(of: "::") {
198            search = String(file[range.upperBound...])
199            file = String(file[..<range.lowerBound])
200        }
201        return absolute(file, in: dir).map { OrgLink(type: "file", path: $0, searchOption: search, raw: link.raw) }
202    }
203
204    // MARK: - Searching a file
205
206    public enum SearchResult: Sendable, Equatable {
207        case found(Int)
208        case failed(String)
209    }
210
211    /// `org-link-search` in an org file: a `#custom-id`, a `<<target>>`, a `#+NAME:`, or a
212    /// heading with these words (`*` asks for a heading only).
213    public static func search(_ s: String, text: String, tree: OrgTree) -> SearchResult {
214        guard s.contains(where: { !$0.isWhitespace }) else { return .failed("Invalid search string \"\(s)\"") }
215        let ns = text as NSString
216        let all = NSRange(location: 0, length: ns.length)
217        let normalized = s.replacingOccurrences(of: "\\n[ \\t]*", with: " ", options: .regularExpression)
218        let starred = normalized.hasPrefix("*")
219        let words = (starred ? String(s.dropFirst()) : s).split(whereSeparator: \.isWhitespace).map(String.init)
220        let model = DocumentModel(tree: tree)
221
222        if normalized.hasPrefix("#") {
223            let id = String(normalized.dropFirst())
224            for heading in model.headings where heading.properties.contains(where: { $0.key.uppercased() == "CUSTOM_ID" && $0.value == id }) {
225                return .found(heading.headingRange.lowerBound)
226            }
227            return .failed("No match for custom ID: \(id)")
228        }
229        if normalized.hasPrefix("("), normalized.hasSuffix(")") {
230            return .failed("Coderef links aren't supported")
231        }
232        if normalized.count >= 2, normalized.hasPrefix("/"), normalized.hasSuffix("/") {
233            return .failed("Regexp links aren't supported")
234        }
235        let quoted = words.map(NSRegularExpression.escapedPattern(for:))
236        if !starred {
237            // A dedicated target, not part of a radio target.
238            let target = try! NSRegularExpression(pattern: "(?<!<)<<" + quoted.joined(separator: "(?:[ \\t\\n]+)") + ">>(?!>)", options: .caseInsensitive)
239            if let m = target.firstMatch(in: text, range: all) { return .found(m.range.location) }
240            let name = try! NSRegularExpression(pattern: "^[ \\t]*#\\+NAME: +" + quoted.joined(separator: "[ \\t]+") + "[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive])
241            if let m = name.firstMatch(in: text, range: all) { return .found(ns.lineRange(for: m.range).location) }
242        }
243        let upper = words.map { $0.uppercased() }
244        for heading in model.headings {
245            let title = normalizedTitle(heading.title)
246            if title.split(whereSeparator: \.isWhitespace).map({ $0.uppercased() }) == upper {
247                return .found(heading.headingRange.lowerBound)
248            }
249        }
250        return .failed("No match for fuzzy expression: \(normalized)")
251    }
252
253    /// `org-link--normalize-string` of a heading title: statistics cookies and runs of space go.
254    static func normalizedTitle(_ title: String) -> String {
255        var t = title
256        if t == "COMMENT" { t = "" } else if t.hasPrefix("COMMENT ") { t = String(t.dropFirst(8)) }
257        t = t.replacingOccurrences(of: "\\[[0-9]*(%|/[0-9]*)\\]", with: " ", options: .regularExpression)
258        t = t.replacingOccurrences(of: "[ \\t]+", with: " ", options: .regularExpression)
259        return t.trimmingCharacters(in: .whitespaces)
260    }
261}
262
263/// `org-open-at-point` on a link or a timestamp (C-c C-o).
264public struct OpenLinkAtPoint: OrgCommand {
265    public init() {}
266    public var id: String { "org.link.open" }
267    public var title: String { "Open Link" }
268    public func applies(in context: EditContext) -> Bool {
269        Links.link(at: context.caret, text: context.text, tree: context.tree) != nil
270            || Links.timestampDays(at: context.caret, text: context.text) != nil
271    }
272    public func run(in context: EditContext) -> CommandStep {
273        if let found = Links.link(at: context.caret, text: context.text, tree: context.tree) {
274            return .external(.openLink(found.link))
275        }
276        if let days = Links.timestampDays(at: context.caret, text: context.text) {
277            return .external(.agenda(start: days.start, days: days.count))
278        }
279        return .failed("No link to follow here")
280    }
281}
282
283extension Links {
284    /// `org-at-date-range-p` with INACTIVE-OK: the range starting at one of the two brackets
285    /// back from `offset` that reaches it.
286    static func dateRange(at offset: Int, text ns: NSString) -> [Range<Int>?]? {
287        let stamp = "[\\[<]([0-9]{4}-[0-9]{2}-[0-9]{2}(?: .*?)?)[\\]>]"
288        let regex = try! NSRegularExpression(pattern: stamp + "--?-?" + stamp)
289        func char(_ i: Int) -> unichar? { i >= 0 && i < ns.length ? ns.character(at: i) : nil }
290        let brackets: Set<unichar> = [91, 60]
291        var p = offset
292        for _ in 0..<2 {
293            while let c = char(p - 1), !brackets.contains(c), c != 10, c != 13 { p -= 1 }
294            while let c = char(p - 1), brackets.contains(c) { p -= 1 }
295            if let m = regex.firstMatch(in: ns as String, options: .anchored, range: NSRange(location: p, length: ns.length - p)),
296               NSMaxRange(m.range) >= offset {
297                return (0..<m.numberOfRanges).map { i in m.range(at: i).location == NSNotFound ? nil : m.range(at: i).location..<NSMaxRange(m.range(at: i)) }
298            }
299        }
300        return nil
301    }
302
303    /// `org-follow-timestamp-link`: the agenda's first day and length for the date range or
304    /// timestamp at `offset`. Point just past a timestamp isn't on it.
305    public static func timestampDays(at offset: Int, text: String) -> (start: Int, count: Int)? {
306        let buffer = EmacsBuffer(text, point: offset)
307        func day(_ match: [Range<Int>?], _ group: Int) -> Int? {
308            guard let range = match[group] else { return nil }
309            let parts = buffer.substring(range.lowerBound, range.upperBound).prefix(10).split(separator: "-").compactMap { Int($0) }
310            return parts.count == 3 ? Days.absolute(year: parts[0], month: parts[1], day: parts[2]) : nil
311        }
312        if let range = dateRange(at: offset, text: text as NSString), let first = day(range, 1), let last = day(range, 2) {
313            return (first, last - first + 1)
314        }
315        if let (part, match) = buffer.atTimestamp(), part != .after, let first = day(match, 1) {
316            return (first, 1)
317        }
318        return nil
319    }
320}