Sources/OrgCore/Links/Links.swift
320 lines · 17077 bytes
25 symbols in this file
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}