krz/orgstar

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

Sources/OrgCore/Links/LinkStore.swift

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

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}