Sources/OrgApp/LinkFollow.swift
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}