krz/orgstar

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

Sources/OrgCore/Completion/OrgCompletion.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Sources/OrgCore/Completion/OrgCompletion.swift history · blame · raw

246 lines · 14197 bytes

  1import Foundation
  2
  3// Completion at point (Org 9.8.7's org-pcomplete): `org-thing-at-point` picks what the caret
  4// is on, and each context offers what its `pcomplete/org-mode/…` function does. Link types
  5// after `[[`, workspace tags and org-tempo's `<` templates are added.
  6
  7public struct Completion: Sendable, Equatable {
  8    public enum Kind: String, Sendable {
  9        case keyword, keywordValue, link, entity, tag, todo, heading, drawer, property, srcLanguage, srcHeader, clocktable, template
 10    }
 11
 12    public var kind: Kind
 13    /// The text the chosen candidate replaces: from the start of what's typed to the caret.
 14    public var range: Range<Int>
 15    /// Candidates starting with what's typed, sorted.
 16    public var candidates: [String]
 17
 18    /// How the list shows `candidate`: a template with the block it makes.
 19    public func label(_ candidate: String) -> String {
 20        guard kind == .template, let value = OrgCompletion.templateKeys.first(where: { "<" + $0.key == candidate })?.value else { return candidate }
 21        return candidate + "  " + value
 22    }
 23
 24    /// Text to insert for `candidate`, and where the caret goes in it.
 25    public func insertion(_ candidate: String) -> (text: String, caret: Int) {
 26        guard kind == .template, let expansion = OrgCompletion.templateExpansion(candidate) else { return (candidate, candidate.utf16.count) }
 27        return expansion
 28    }
 29}
 30
 31public enum OrgCompletion {
 32    public static func at(_ context: EditContext) -> Completion? {
 33        let ns = context.text as NSString
 34        let caret = min(context.caret, ns.length)
 35        let lineRange = ns.lineRange(for: NSRange(location: caret, length: 0))
 36        var contentsEnd = 0
 37        ns.getLineStart(nil, end: nil, contentsEnd: &contentsEnd, for: lineRange)
 38        let line = ns.substring(with: NSRange(location: lineRange.location, length: contentsEnd - lineRange.location))
 39        let start = lineRange.location
 40        let toHere = ns.substring(with: NSRange(location: start, length: caret - start))
 41        let afterBlank = ns.substring(with: NSRange(location: caret, length: contentsEnd - caret)).allSatisfy { $0 == " " || $0 == "\t" }
 42        /// `pcomplete`'s current argument: from the last blank or `[` on the line to the caret.
 43        let argStart: Int = {
 44            var p = caret
 45            while p > start, ![32, 9, 91].contains(ns.character(at: p - 1)) { p -= 1 }
 46            return p
 47        }()
 48        func make(_ kind: Completion.Kind, from: Int, _ candidates: [String]) -> Completion {
 49            let typed = ns.substring(with: NSRange(location: from, length: caret - from))
 50            var seen = Set<String>()
 51            let matching = candidates.filter { $0.hasPrefix(typed) && seen.insert($0).inserted }
 52            return Completion(kind: kind, range: from..<caret, candidates: matching.sorted { $0.localizedStandardCompare($1) == .orderedAscending })
 53        }
 54        func matches(_ pattern: String, _ s: String, caseInsensitive: Bool = true) -> NSTextCheckingResult? {
 55            let regex = try! NSRegularExpression(pattern: pattern, options: caseInsensitive ? [.caseInsensitive] : [])
 56            return regex.firstMatch(in: s, range: NSRange(location: 0, length: (s as NSString).length))
 57        }
 58        /// Arguments before the current one on the line.
 59        func arguments() -> [String] {
 60            ns.substring(with: NSRange(location: start, length: argStart - start)).split { " \t[".contains($0) }.map(String.init)
 61        }
 62
 63        // Templates (org-tempo): `<` and a key after only blanks.
 64        if let m = matches("^[ \\t]*<[A-Za-z]*$", toHere, caseInsensitive: false) {
 65            // The expansion replaces the indentation too.
 66            var completion = make(.template, from: start + (toHere as NSString).range(of: "<").location, templateKeys.map { "<" + $0.key })
 67            completion.range = start..<(start + m.range.length)
 68            return completion
 69        }
 70        if matches("^[ \\t]*#\\+BEGIN: clocktable[ \\t]", line) != nil {
 71            return make(.clocktable, from: argStart, clocktableOptions)
 72        }
 73        if matches("^[ \\t]*#\\+BEGIN_SRC[ \\t]", line) != nil {
 74            return arguments().count == 1 ? make(.srcLanguage, from: argStart, languages) : make(.srcHeader, from: argStart, srcHeaders)
 75        }
 76        if let m = matches("^[ \\t]*#\\+(\\S+):", line) {
 77            let keyword = (line as NSString).substring(with: m.range(at: 1)).lowercased()
 78            let arguments = arguments()
 79            // The keyword's own text, or (as pcomplete would) nothing past the value.
 80            guard argStart >= start + m.range.upperBound else { return nil }
 81            let values: [String]
 82            switch keyword {
 83            case "startup":
 84                values = startupOptions.filter { !(arguments.contains("hidestars") && $0 == "showstars") }
 85            case "options":
 86                values = exportOptions
 87            case _ where arguments.count > 1:
 88                return nil
 89            case "date":
 90                let formatter = DateFormatter()
 91                formatter.locale = Locale(identifier: "en_US_POSIX")
 92                formatter.calendar = context.calendar
 93                formatter.timeZone = context.calendar.timeZone
 94                formatter.dateFormat = "yyyy-MM-dd EEE"
 95                values = ["<" + formatter.string(from: context.now) + ">"]
 96            case "exclude_tags": values = ["noexport"]
 97            case "select_tags": values = ["export"]
 98            case "language": values = ["en"]
 99            case "priorities":
100                let p = context.tree.settings.priorities
101                values = ["\(p.highest) \(p.lowest) \(p.default)"]
102            default:
103                return nil
104            }
105            return make(.keywordValue, from: argStart, values)
106        }
107        if matches("^[ \\t]*#\\+[a-zA-Z_]*$", line) != nil, afterBlank {
108            let from = start + NSMaxRange((line as NSString).range(of: "#+"))
109            return make(.keyword, from: from, caseDouble(fileOptions))
110        }
111        // Link abbreviations and types after `[[`.
112        do {
113            var p = caret
114            while p > start, let ch = UnicodeScalar(ns.character(at: p - 1)), CharacterSet(charactersIn: "-_").union(.alphanumerics).contains(ch), ch.isASCII { p -= 1 }
115            if p - 2 >= start, ns.character(at: p - 1) == 91, ns.character(at: p - 2) == 91 {
116                let abbreviations = Links.abbreviations(in: context.tree.settings.setupKeywords + context.text).keys.map { $0 + ":" }
117                return make(.link, from: p, abbreviations + linkTypes.map { $0 + ":" })
118            }
119        }
120        // Entities after `\`.
121        do {
122            var p = caret
123            while p > start, (48...57).contains(ns.character(at: p - 1)) { p -= 1 }
124            while p > start, let ch = UnicodeScalar(ns.character(at: p - 1)), ch.isASCII, CharacterSet.letters.contains(ch) { p -= 1 }
125            if p > start, ns.character(at: p - 1) == 92 {
126                return make(.entity, from: p, Array(OrgEntities.display.keys))
127            }
128        }
129        // Tags at the end of a heading.
130        if let m = matches("^\\*+ (?:.+? )?(:)((?::|[\\p{L}\\p{N}_@#%]+)+)?[ \\t]*$", line, caseInsensitive: false) {
131            let colon = start + m.range(at: 1).location + 1
132            let group = m.range(at: 2)
133            let inGroup = group.location != NSNotFound && caret >= start + group.location && caret <= start + group.location + group.length
134            if inGroup || caret == colon {
135                let typed = ns.substring(with: NSRange(location: colon, length: caret - colon))
136                let from = colon + ((typed as NSString).range(of: ":", options: .backwards).toOptional.map { NSMaxRange($0) } ?? 0)
137                let heading = DocumentModel(tree: context.tree).headings.last { $0.headingRange.lowerBound <= caret }
138                let own = Set(heading?.localTags ?? [])
139                let alist = FastSelection.tagTable(context.tree.settings.setupKeywords + context.text)?.compactMap { item -> String? in
140                    if case .option(_, let name) = item { return name }
141                    return nil
142                } ?? []
143                let pool = alist.isEmpty ? SetTags.bufferTags(context) + context.knownTags() : alist
144                return make(.tag, from: from, pool.filter { !own.contains($0) }.map { $0 + ":" })
145            }
146        }
147        // TODO keywords on an empty heading.
148        if matches("^\\*+ +\\S*$", toHere, caseInsensitive: false) != nil, afterBlank {
149            let keywords = context.tree.settings.todoSequences.flatMap { $0.active + $0.done }.map(\.name)
150            return make(.todo, from: argStart, keywords)
151        }
152        // Headings after `[[*`.
153        if let star = (toHere as NSString).range(of: "*", options: .backwards).toOptional, star.location >= 2,
154           (toHere as NSString).substring(with: NSRange(location: star.location - 2, length: 2)) == "[[" {
155            let titles = DocumentModel(tree: context.tree).headings.map { Links.normalizedTitle($0.title) }
156            return make(.heading, from: start + star.location + 1, titles)
157        }
158        // Property or drawer names.
159        if matches("^[ \\t]*:\\S*$", toHere, caseInsensitive: false) != nil, afterBlank {
160            let from = start + NSMaxRange((toHere as NSString).range(of: ":"))
161            if let entry = propertyEntry(context, lineStart: start) {
162                return make(.property, from: from, caseDouble(propertyKeys(context).filter { !entry.contains($0) }.map { $0 + ": " }))
163            }
164            return make(.drawer, from: from, caseDouble((["PROPERTIES"] + drawerNames(context.tree)).map { $0 + ":" }))
165        }
166        return nil
167    }
168
169    // MARK: - Sources
170
171    /// Upper and lower case of each, as `org-pcomplete-case-double`.
172    static func caseDouble(_ list: [String]) -> [String] { list.flatMap { [$0.uppercased(), $0.lowercased()] } }
173
174    /// When the line is in a heading's property drawer, the properties the entry already has.
175    static func propertyEntry(_ context: EditContext, lineStart: Int) -> Set<String>? {
176        guard let heading = DocumentModel(tree: context.tree).headings.last(where: { $0.headingRange.lowerBound < lineStart }),
177              let drawer = context.tree.root.descendants().first(where: {
178                  $0.kind == .propertyDrawer && $0.range.lowerBound > heading.headingRange.lowerBound && $0.range.contains(lineStart)
179              }) else { return nil }
180        let ns = context.text as NSString
181        // Only on the line after the last property, as `org-pcomplete` checks.
182        var p = drawer.range.lowerBound
183        p = NSMaxRange(ns.lineRange(for: NSRange(location: p, length: 0)))
184        while p < drawer.range.upperBound {
185            let line = ns.substring(with: ns.lineRange(for: NSRange(location: p, length: 0)))
186            guard line.range(of: "^[ \\t]*:\\S+:", options: .regularExpression) != nil, line.range(of: "^[ \\t]*:END:", options: [.regularExpression, .caseInsensitive]) == nil else { break }
187            p = NSMaxRange(ns.lineRange(for: NSRange(location: p, length: 0)))
188        }
189        guard p == lineStart else { return nil }
190        return Set(heading.properties.map { $0.key.uppercased() } + ["CATEGORY"])
191    }
192
193    /// `org-buffer-property-keys` with defaults and column keys.
194    static func propertyKeys(_ context: EditContext) -> [String] {
195        var keys: [String] = []
196        for heading in DocumentModel(tree: context.tree).headings {
197            for property in heading.properties {
198                keys.append(property.key)
199                if property.key.uppercased().hasSuffix("_ALL") { keys.append(String(property.key.dropLast(4))) }
200            }
201        }
202        let ns = context.text as NSString
203        let columnLines = try! NSRegularExpression(pattern: "^[ \\t]*#\\+COLUMNS:(.*)$|^[ \\t]*:COLUMNS:(.*)$", options: [.anchorsMatchLines, .caseInsensitive])
204        let columns = columnLines.matches(in: context.text, range: NSRange(location: 0, length: ns.length)).map {
205            ns.substring(with: $0.range(at: 1).location != NSNotFound ? $0.range(at: 1) : $0.range(at: 2))
206        }
207        for spec in columns {
208            for m in try! NSRegularExpression(pattern: "%[0-9]*([-\\w]+)").matches(in: spec, range: NSRange(location: 0, length: (spec as NSString).length)) {
209                keys.append((spec as NSString).substring(with: m.range(at: 1)))
210            }
211        }
212        return keys + defaultProperties + ["EFFORT"]
213    }
214
215    static func drawerNames(_ tree: OrgTree) -> [String] {
216        let ns = tree.text as NSString
217        return tree.root.descendants().filter { $0.kind == .drawer }.compactMap { node in
218            let first = ns.substring(with: ns.lineRange(for: NSRange(location: node.range.lowerBound, length: 0)))
219            return first.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: ":"))
220        }
221    }
222
223    static let templateKeys: [(key: String, value: String)] = [
224        ("a", "export ascii"), ("c", "center"), ("C", "comment"), ("e", "example"), ("E", "export"), ("h", "export html"),
225        ("l", "export latex"), ("q", "quote"), ("s", "src"), ("v", "verse"),
226        ("L", "latex"), ("H", "html"), ("A", "ascii"), ("i", "index"),
227    ]
228
229    /// org-tempo's expansion of `<key`: a block, or for keywords a `#+key: ` line.
230    static func templateExpansion(_ candidate: String) -> (text: String, caret: Int)? {
231        let key = String(candidate.dropFirst())
232        guard let index = templateKeys.firstIndex(where: { $0.key == key }) else { return nil }
233        let value = templateKeys[index].value
234        if index >= 10 {
235            let text = "#+\(value): "
236            return (text, text.utf16.count)
237        }
238        let name = value.split(separator: " ").first.map(String.init) ?? value
239        let begin = "#+begin_\(value)" + (["src", "export"].contains(value) ? " " : "")
240        return (begin + "\n#+end_\(name)", begin.utf16.count)
241    }
242}
243
244private extension NSRange {
245    var toOptional: NSRange? { location == NSNotFound ? nil : self }
246}