krz/orgstar

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

Sources/OrgCore/Links/LinkStore.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgCore/Links/LinkStore.swift history · blame · raw

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}