Sources/OrgCore/Completion/OrgCompletion.swift
246 lines · 14197 bytes
14 symbols in this file
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}