krz/orgstar

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

Sources/OrgApp/LinkFollow.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgApp/LinkFollow.swift history · blame · raw

105 lines · 4787 bytes

  1import Foundation
  2import OrgCore
  3
  4/// What following a link comes to.
  5public enum LinkFollow: Equatable, Sendable {
  6    /// Shown in Orgstar: a buffer opened or a heading revealed.
  7    case shown
  8    /// For the system to open: a web or mail address, or a file Orgstar doesn't edit.
  9    case openExternally(URL)
 10    case message(String)
 11}
 12
 13extension DocumentSession {
 14    /// Follows a link as `org-link-open` does: files open as buffers (with a `::` search),
 15    /// `id:` links through the index, internal and radio links in the current file,
 16    /// `attachment:` links in their entry's attachment folder, and web and mail links go to
 17    /// their apps.
 18    public func follow(_ link: OrgLink, workspace: WorkspaceModel?, isText: (String) -> Bool) -> LinkFollow {
 19        switch link.type {
 20        case "http", "https", "ftp", "mailto", "news":
 21            return Self.url(link.raw)
 22        case "doi":
 23            return Self.url("https://doi.org/" + link.path)
 24        case "file":
 25            return followFile(link, isText: isText)
 26        case "id":
 27            guard let location = try? workspace?.index.headings(withID: link.path).first else {
 28                return .message("No heading has the ID \(link.path)")
 29            }
 30            show(location)
 31            return .shown
 32        case "custom-id":
 33            return searchCurrent("#" + link.path)
 34        case "fuzzy":
 35            return searchCurrent(link.path)
 36        case "radio":
 37            guard let document else { return .message("No file is open") }
 38            return shown(Links.searchRadio(link.path, tree: document.tree))
 39        case "attachment":
 40            guard let tree = document?.tree,
 41                  let file = Links.attachmentFile(link, tree: tree, path: path, isDirectory: Self.isDirectory) else {
 42                return .message("Need absolute `org-attach-id-dir' to attach in buffers without filename")
 43            }
 44            return followFile(file, isText: isText)
 45        case "coderef":
 46            return .message("Coderef links aren't supported")
 47        case "shell", "elisp":
 48            return .message("Orgstar doesn't run \(link.type) links")
 49        default:
 50            return .message("Orgstar can't open \(link.type) links")
 51        }
 52    }
 53
 54    static func url(_ string: String) -> LinkFollow {
 55        let allowed = CharacterSet.urlFragmentAllowed.union(.urlQueryAllowed).union(CharacterSet(charactersIn: "#%"))
 56        guard let url = URL(string: string.addingPercentEncoding(withAllowedCharacters: allowed) ?? string) else {
 57            return .message("Not a link Orgstar can open: \(string)")
 58        }
 59        return .openExternally(url)
 60    }
 61
 62    /// A file link's path against the open file's folder, as Org resolves relative links.
 63    public static func resolve(_ path: String, from current: String?) -> String {
 64        let expanded = (path as NSString).expandingTildeInPath
 65        if expanded.hasPrefix("/") { return (expanded as NSString).standardizingPath }
 66        let folder = current.map { ($0 as NSString).deletingLastPathComponent } ?? NSHomeDirectory()
 67        return ((folder as NSString).appendingPathComponent(expanded) as NSString).standardizingPath
 68    }
 69
 70    private func followFile(_ link: OrgLink, isText: (String) -> Bool) -> LinkFollow {
 71        let target = link.path.isEmpty ? (path ?? "") : Self.resolve(link.path, from: path)
 72        guard FileManager.default.fileExists(atPath: target) else { return .message("No file \(target)") }
 73        guard Self.isOrgPath(target) || isText(target) else { return .openExternally(URL(fileURLWithPath: target)) }
 74        open(target)
 75        guard let option = link.searchOption, !option.isEmpty else { return .shown }
 76        if let line = Int(option), let text = document?.text {
 77            let ns = text as NSString
 78            var offset = 0
 79            for _ in 1..<max(1, line) where offset < ns.length { offset = NSMaxRange(ns.lineRange(for: NSRange(location: offset, length: 0))) }
 80            reveal(offset)
 81            return .shown
 82        }
 83        return searchCurrent(option)
 84    }
 85
 86    static func isDirectory(_ path: String) -> Bool {
 87        var directory: ObjCBool = false
 88        return FileManager.default.fileExists(atPath: path, isDirectory: &directory) && directory.boolValue
 89    }
 90
 91    private func searchCurrent(_ s: String) -> LinkFollow {
 92        guard let document else { return .message("No file is open") }
 93        return shown(Links.search(s, text: document.text, tree: document.tree))
 94    }
 95
 96    private func shown(_ result: Links.SearchResult) -> LinkFollow {
 97        switch result {
 98        case .found(let offset):
 99            reveal(offset)
100            return .shown
101        case .failed(let message):
102            return .message(message)
103        }
104    }
105}