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