Sources/OrgCore/Links/LinkStore.swift
328 lines · 17990 bytes
21 symbols in this file
1import Foundation
2
3/// An entry of `org-stored-links`.
4public struct StoredLink: Sendable, Equatable, Codable {
5 public let link: String
6 public let description: String?
7
8 public init(link: String, description: String?) {
9 self.link = link
10 self.description = description
11 }
12
13 /// `org-link--add-to-stored-links`: puts `link` first in `links` and says what happened.
14 public static func add(_ link: StoredLink, to links: inout [StoredLink]) -> String {
15 if links.first == link { return "This link has already been stored" }
16 let moved = links.contains(link)
17 links.removeAll { $0 == link }
18 links.insert(link, at: 0)
19 return (moved ? "Link moved to front: " : "Stored: ") + (link.description ?? link.link)
20 }
21}
22
23extension Links {
24 /// `org-store-link` in an org file, with `org-link-context-for-files` t: a file link to
25 /// `path` with a search string for the target, named element, heading or line at the
26 /// caret (the selection when there is one). `path` is as `abbreviate-file-name` gives it.
27 public static func store(in context: EditContext, path: String) -> StoredLink {
28 let link = "file:" + path
29 guard let target = targetAtCaret(context) else {
30 guard let (search, description, _) = preciseTarget(context) else { return StoredLink(link: link, description: nil) }
31 return StoredLink(link: link + "::" + search, description: description.map(displayFormat))
32 }
33 return StoredLink(link: link + "::" + target, description: nil)
34 }
35
36 /// `org-id-store-link` after `org-id-get-create`: an `id:` link to the entry at the caret,
37 /// described by its title, or before the first heading by `#+TITLE` or the file name.
38 public static func storeID(in context: EditContext, fileName: String) -> StoredLink? {
39 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
40 buffer.backToHeadingOrPointMin()
41 guard let id = buffer.entryID() else { return nil }
42 let link = "id:" + id
43 // With `org-id-link-use-context`, a target after the entry's start narrows the link.
44 if let (search, description, position) = preciseTarget(context), position > buffer.point {
45 return StoredLink(link: link + "::" + search, description: description)
46 }
47 if buffer.beforeFirstHeading { return StoredLink(link: link, description: buffer.keyword("TITLE") ?? fileName) }
48 guard let match = buffer.lookingAt(buffer.complexHeadingPattern), let all = match[0] else { return StoredLink(link: link, description: link) }
49 let title = match[4] ?? all
50 return StoredLink(link: link, description: buffer.substring(title.lowerBound, title.upperBound))
51 }
52
53 /// A `<<target>>` on the caret's line that the caret touches, as `org-in-regexp` finds it.
54 static func targetAtCaret(_ context: EditContext) -> String? {
55 let ns = context.text as NSString
56 let line = ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0))
57 var end = NSMaxRange(line)
58 if end > line.location, ns.character(at: end - 1) == 10 { end -= 1 }
59 let regex = try! NSRegularExpression(pattern: "[^<]<<([^<>]+)>>[^>]")
60 var from = line.location
61 while from < end, let m = regex.firstMatch(in: context.text, range: NSRange(from..<end)), m.range.location <= context.caret {
62 if NSMaxRange(m.range) >= context.caret { return ns.substring(with: m.range(at: 1)) }
63 from = m.range.location + 1
64 }
65 return nil
66 }
67
68 /// `org-link-precise-link-target`: the search string and description for the caret, and
69 /// where the search matches.
70 static func preciseTarget(_ context: EditContext) -> (String, String?, Int)? {
71 let ns = context.text as NSString
72 let result: (String, String?, Int)
73 if let range = context.selection.first, !range.isEmpty {
74 result = (normalize(ns.substring(with: NSRange(range)), context: true), nil, range.lowerBound)
75 } else if let (name, start) = namedElement(at: context.caret, in: context.tree) {
76 result = (name, name, start)
77 } else if let heading = DocumentModel(tree: context.tree).headings.last(where: { $0.headingRange.lowerBound <= context.caret }) {
78 let title = normalizedTitle(heading.title)
79 let customID = heading.properties.last { $0.key.uppercased() == "CUSTOM_ID" }?.value
80 result = (customID.map { "#" + $0 } ?? "*" + title, title, heading.headingRange.lowerBound)
81 } else {
82 let line = ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0))
83 result = (normalize(ns.substring(with: line).trimmingCharacters(in: .newlines), context: true), nil, line.location)
84 }
85 return result.0.contains(where: { !$0.isWhitespace }) ? result : nil
86 }
87
88 /// The `#+NAME` of the element at `offset`, and where the element starts (its first
89 /// affiliated keyword).
90 static func namedElement(at offset: Int, in tree: OrgTree) -> (String, Int)? {
91 let containers: Set<SyntaxKind> = [.document, .zerothSection, .section, .plainList, .item, .drawer, .footnoteDefinition]
92 var node = tree.root
93 while true {
94 let children = node.children
95 guard let index = children.firstIndex(where: { $0.range.contains(offset) || ($0.range.upperBound == offset && offset == tree.root.range.upperBound) }) else { return nil }
96 var element = index
97 while element < children.count, children[element].kind == .affiliatedKeyword { element += 1 }
98 if element > index, element == children.count || children[element - 1].range.upperBound != children[element].range.lowerBound { return nil }
99 if element == index, containers.contains(children[index].kind) {
100 node = children[index]
101 continue
102 }
103 var first = element
104 while first > 0, children[first - 1].kind == .affiliatedKeyword, children[first - 1].range.upperBound == children[first].range.lowerBound { first -= 1 }
105 for keyword in children[first..<element].reversed() {
106 if let range = keyword.text.range(of: "^[ \\t]*#\\+NAME:[ \\t]*(.*?)[ \\t]*\\n?$", options: [.regularExpression, .caseInsensitive]) {
107 let value = keyword.text[range].replacingOccurrences(of: "^[ \\t]*#\\+[Nn][Aa][Mm][Ee]:[ \\t]*|[ \\t]*\\n?$", with: "", options: .regularExpression)
108 if !value.isEmpty { return (value, children[first].range.lowerBound) }
109 }
110 }
111 return nil
112 }
113 }
114
115 /// `org-link--normalize-string`: statistics cookies and runs of blanks become one space;
116 /// with `context`, enclosing parentheses and leading `#` and `*` go too.
117 static func normalize(_ s: String, context: Bool) -> String {
118 var t = s.replacingOccurrences(of: "\\[[0-9]*(%|/[0-9]*)\\]", with: " ", options: .regularExpression)
119 t = t.replacingOccurrences(of: "[ \\t]+", with: " ", options: .regularExpression)
120 t = t.trimmingCharacters(in: .whitespacesAndNewlines)
121 guard context else { return t }
122 while true {
123 if t.hasPrefix("("), t.hasSuffix(")"), t.count >= 2 {
124 t = String(t.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines)
125 } else if let range = t.range(of: "^[#*]+[ \\t]*", options: .regularExpression) {
126 t.removeSubrange(range)
127 } else {
128 return t
129 }
130 }
131 }
132
133 /// `org-link-display-format`: bracket links become their descriptions.
134 static func displayFormat(_ s: String) -> String {
135 let regex = try! NSRegularExpression(pattern: "\\[\\[((?:[^\\]\\[\\\\]|\\\\(?:\\\\\\\\)*[\\]\\[]|\\\\+[^\\]\\[])+)\\](?:\\[((?:.|\\n)+?)\\])?\\]")
136 let ns = s as NSString
137 var out = s
138 for m in regex.matches(in: s, range: NSRange(location: 0, length: ns.length)).reversed() {
139 let group = m.range(at: 2).location != NSNotFound ? m.range(at: 2) : m.range(at: 1)
140 out = (out as NSString).replacingCharacters(in: m.range, with: ns.substring(with: group))
141 }
142 return out
143 }
144
145 /// `org-link-escape`.
146 static func escape(_ link: String) -> String {
147 let regex = try! NSRegularExpression(pattern: "(\\\\*)([\\[\\]]|$)")
148 let ns = link as NSString
149 var out = link
150 for m in regex.matches(in: link, range: NSRange(location: 0, length: ns.length)).reversed() {
151 let run = ns.substring(with: m.range(at: 1))
152 out = (out as NSString).replacingCharacters(in: m.range(at: 1), with: run + run + (m.range(at: 2).length > 0 ? "\\" : ""))
153 }
154 return out
155 }
156
157 /// `org-link-make-string`.
158 static func makeString(_ link: String, _ description: String?) -> String {
159 let zeroWidthSpace = "\u{200B}"
160 let description = description.flatMap { d -> String? in
161 guard d.contains(where: { !$0.isWhitespace }) else { return nil }
162 return d.trimmingCharacters(in: .whitespacesAndNewlines)
163 .replacingOccurrences(of: "\\]$", with: "]" + zeroWidthSpace, options: .regularExpression)
164 .replacingOccurrences(of: "\\]\\]", with: "]" + zeroWidthSpace + "]", options: .regularExpression)
165 }
166 return "[[" + escape(link) + "]" + (description.map { "[" + $0 + "]" } ?? "") + "]"
167 }
168
169 /// `expand-file-name` of `path` in `directory`, with `~` for `home`.
170 static func expand(_ path: String, directory: String?, home: String) -> String {
171 var p = path
172 if p == "~" || p.hasPrefix("~/") { p = home + p.dropFirst() }
173 if !p.hasPrefix("/"), let directory { p = (directory as NSString).appendingPathComponent(p) }
174 return (p as NSString).standardizingPath
175 }
176
177 /// `abbreviate-file-name`: `~` for the home folder.
178 public static func abbreviate(_ path: String, home: String) -> String {
179 if path == home { return "~" }
180 return path.hasPrefix(home + "/") ? "~" + path.dropFirst(home.count) : path
181 }
182
183 /// `org-link-make-string-for-buffer` before its description prompt: the link a buffer
184 /// visiting `file` gets, and the description to offer.
185 static func forBuffer(_ link: String, description: String?, file: String?, home: String) -> (link: String, description: String?) {
186 var link = link
187 var description = description
188 if link.hasPrefix("<"), link.hasSuffix(">"), typesPattern.firstMatch(in: String(link.dropFirst()), range: NSRange(location: 0, length: (link as NSString).length - 1)) != nil {
189 link = String(link.dropFirst().dropLast())
190 }
191 let directory = file.map { ($0 as NSString).deletingLastPathComponent }
192 if let file, let range = link.range(of: "^file:(.+?)::", options: .regularExpression) {
193 let path = String(link[range].dropFirst(5).dropLast(2))
194 if expand(path, directory: directory, home: home) == expand(file, directory: nil, home: home) {
195 link = String(link[range.upperBound...])
196 }
197 }
198 if let prefix = ["file:", "docview:"].first(where: link.hasPrefix) {
199 var rest = String(link.dropFirst(prefix.count))
200 var search: String?
201 if let range = rest.range(of: "::(.*)$", options: .regularExpression) {
202 search = String(rest[range].dropFirst(2))
203 rest.removeSubrange(range)
204 }
205 let original = rest.contains(where: { !$0.isWhitespace }) ? rest : file ?? ""
206 let expanded = expand(original, directory: directory, home: home)
207 let base = directory.map { expand($0, directory: nil, home: home) + "/" }
208 let path = base.map(expanded.hasPrefix) == true ? String(expanded.dropFirst(base!.count)) : abbreviate(expanded, home: home)
209 link = prefix + path + (search.map { "::" + $0 } ?? "")
210 if description == original { description = path }
211 }
212 return (link, description)
213 }
214}
215
216/// `org-insert-link` (C-c C-l): asks for a link, offering stored links and link types, and a
217/// description; on a link, edits it.
218public struct InsertLink: OrgCommand {
219 /// `org-stored-links`, most recent first.
220 public let stored: [StoredLink]
221 /// The file the buffer visits.
222 public let file: String?
223 public let home: String
224
225 public init(stored: [StoredLink] = [], file: String? = nil, home: String = NSHomeDirectory()) {
226 self.stored = stored
227 self.file = file
228 self.home = home
229 }
230
231 public var id: String { "org.link.insert" }
232 public var title: String { "Insert Link" }
233 public func applies(in context: EditContext) -> Bool { true }
234
235 public func run(in context: EditContext) -> CommandStep {
236 let ns = context.text as NSString
237 var remove: Range<Int>?
238 var description: String?
239 if let range = context.selection.first, !range.isEmpty {
240 remove = range
241 description = ns.substring(with: NSRange(range))
242 }
243 var link: String
244 if let found = Links.link(at: context.caret, text: context.text, tree: context.tree) {
245 let whole = ns.substring(with: NSRange(found.range))
246 remove = found.range
247 let initial: String
248 if whole.hasPrefix("[[") {
249 let inside = whole.dropFirst(2).dropLast(1)
250 let split = inside.range(of: "][")
251 initial = Links.unescape(String(split.map { inside[..<$0.lowerBound] } ?? inside.dropLast()))
252 description = split.map { String(inside[$0.upperBound...].dropLast()) }
253 } else {
254 initial = whole.hasPrefix("<") && whole.hasSuffix(">") ? String(whole.dropFirst().dropLast()) : whole
255 }
256 guard let answer = context.answers["link"] else { return .prompt(Prompt(key: "link", message: "Link:", initial: initial)) }
257 link = answer
258 } else {
259 let prefixes = Array(Links.abbreviations(in: context.tree.settings.setupKeywords + context.text).keys).sorted() + Links.types
260 guard let answer = context.answers["link"] else {
261 let message = stored.first.map { "Insert link (default \($0.link)):" } ?? "Insert link:"
262 let choices = stored.map(\.link) + stored.compactMap(\.description) + prefixes.map { $0 + ":" }
263 return .prompt(Prompt(key: "link", message: message, choices: choices, requireMatch: false))
264 }
265 link = answer.isEmpty ? stored.first?.link ?? "" : answer
266 guard link.contains(where: { !$0.isWhitespace }) else { return .failed("No link selected") }
267 if let byDescription = stored.last(where: { $0.description == link }) { link = byDescription.link }
268 let type = link.hasSuffix(":") ? String(link.dropLast()) : link
269 if prefixes.contains(type) {
270 guard let special = context.answers["special"] else {
271 return .prompt(Prompt(key: "special", message: "Link (no completion support):", initial: type + ":"))
272 }
273 link = special
274 }
275 description = description ?? stored.first { $0.link == link }?.description
276 }
277 let used = link
278 let prepared = Links.forBuffer(link, description: description, file: file, home: home)
279 guard let typed = context.answers["description"] else {
280 return .prompt(Prompt(key: "description", message: "Description:", initial: prepared.description ?? ""))
281 }
282 let text = Links.makeString(prepared.link, typed)
283 let at = remove ?? context.caret..<context.caret
284 let end = at.lowerBound + (text as NSString).length
285 let effects: [Effect] = stored.contains { $0.link == used } ? [.storedLinkUsed(used)] : []
286 return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: at, replacement: text)], selection: [end..<end], effects: effects))
287 }
288}
289
290/// `org-id-get-create`: gives the entry an `ID` property unless it has one.
291public struct CreateID: OrgCommand {
292 let newID: @Sendable () -> String
293
294 public init(newID: @escaping @Sendable () -> String = { UUID().uuidString.lowercased() }) {
295 self.newID = newID
296 }
297
298 public var id: String { "org.id.create" }
299 public var title: String { "Create ID" }
300 public func applies(in context: EditContext) -> Bool { true }
301 public func run(in context: EditContext) -> CommandStep {
302 commitBuffer(context) { buffer in
303 buffer.saveExcursion {
304 buffer.backToHeadingOrPointMin()
305 if buffer.entryID() == nil { buffer.entryPut("ID", newID()) }
306 }
307 }
308 }
309}
310
311extension EmacsBuffer {
312 /// The entry's own `ID` when it has non-blank one, from point at its heading (or the
313 /// start of the file).
314 func entryID() -> String? {
315 saveExcursion {
316 if beforeFirstHeading {
317 while bolp, lookingAt("^[ \\t]*#[ \\t]") != nil || lookingAt("^[ \\t]*#$") != nil { if forwardLine(1) != 0 { break } }
318 } else {
319 forwardLine(1)
320 if lookingAt(planningPattern) != nil { forwardLine(1) }
321 }
322 guard let drawer = looking(Self.propertyDrawerPattern, caseInsensitive: true) else { return nil }
323 let body = substring(drawer[0]!.lowerBound, drawer[0]!.upperBound)
324 guard let range = body.range(of: "(?m)^[ \\t]*:ID:[ \\t]+(\\S.*?)[ \\t]*$", options: [.regularExpression, .caseInsensitive]) else { return nil }
325 return body[range].replacingOccurrences(of: "^[ \\t]*:[Ii][Dd]:[ \\t]+|[ \\t]+$", with: "", options: .regularExpression)
326 }
327 }
328}