Sources/OrgCore/Commands/HeadingCommands.swift
467 lines · 22610 bytes
41 symbols in this file
headingOnLineentryHeadingHeadingLineLineBuffereditreplacereplaceBeforeMarkersinsertinsertAtCaretcommitlocalalignTagsTodoCycleappliesnextruncycleTodoShiftappliesrunPriorityChangerunwritevaluelabelSetPriorityappliesrunPriorityUpappliesrunPriorityDownappliesrunPromoteHeadingappliesrunDemoteHeadingappliesrunfixPositionAfterPromote
1import Foundation
2
3// Heading-line commands, matched to Emacs 31.1 / Org 9.8.7 by the oracle tests.
4
5/// The heading whose line holds `offset`, for commands that act only on heading lines (org's
6/// M-left, M-right, S-up and S-down do something else elsewhere).
7func headingOnLine(at offset: Int, in tree: OrgTree) -> SyntaxNode? {
8 guard let heading = entryHeading(at: offset, in: tree) else { return nil }
9 let line = HeadingLine(heading)
10 return offset >= line.start && offset <= line.contentEnd ? heading : nil
11}
12
13/// The heading of the entry holding `offset`, as `org-back-to-heading`; nil before the first
14/// heading.
15func entryHeading(at offset: Int, in tree: OrgTree) -> SyntaxNode? {
16 let root = tree.root
17 let position = offset >= root.range.upperBound ? max(0, root.range.upperBound - 1) : offset
18 var node = root
19 var heading: SyntaxNode?
20 while let child = node.child(containing: position), child.kind == .section {
21 heading = child.firstChild(.heading)
22 node = child
23 }
24 return heading
25}
26
27/// The parts of a heading line, in offsets of the whole text.
28struct HeadingLine {
29 let start: Int
30 /// End of the line's content, before its line break.
31 let contentEnd: Int
32 let stars: Range<Int>
33 let todo: SyntaxToken?
34 let priority: SyntaxToken?
35 let tags: SyntaxToken?
36 /// Where the title begins: past the stars, keyword, cookie and the blanks after them.
37 let titleStart: Int
38
39 init(_ heading: SyntaxNode) {
40 let tokens = heading.tokens
41 start = heading.range.lowerBound
42 contentEnd = tokens.first { $0.kind == .newline }?.range.lowerBound ?? heading.range.upperBound
43 stars = tokens.first { $0.kind == .stars }?.range ?? start..<start
44 todo = tokens.first { $0.kind == .todoKeyword }
45 priority = tokens.first { $0.kind == .priority }
46 tags = tokens.first { $0.kind == .tags }
47 var at = start
48 for child in heading.green.children {
49 guard case .token(let token) = child, [.stars, .whitespace, .todoKeyword, .priority].contains(token.kind) else { break }
50 at += token.length
51 }
52 titleStart = min(at, contentEnd)
53 }
54
55 var level: Int { stars.count }
56}
57
58/// One line of text being edited, with a caret that moves the way Emacs moves point and
59/// markers for each kind of edit, so commands land the caret where Emacs does.
60struct LineBuffer {
61 var text: NSMutableString
62 /// Relative to the line start; nil when the caret is elsewhere in the document.
63 var caret: Int?
64
65 init(_ line: String, caret: Int?) {
66 text = NSMutableString(string: line)
67 self.caret = caret
68 }
69
70 var string: String { text as String }
71 var length: Int { text.length }
72
73 private mutating func edit(_ range: Range<Int>, _ replacement: String) -> Int {
74 text.replaceCharacters(in: NSRange(range), with: replacement)
75 return (replacement as NSString).length - range.count
76 }
77
78 /// `replace-match`: a caret strictly inside moves to the start; at or after the end it
79 /// shifts with the text.
80 mutating func replace(_ range: Range<Int>, with replacement: String) {
81 let delta = edit(range, replacement)
82 guard let position = caret else { return }
83 if position >= range.upperBound, position > range.lowerBound {
84 caret = position + delta
85 } else if position > range.lowerBound {
86 caret = range.lowerBound
87 }
88 }
89
90 /// Deleting `range`, then `insert-before-markers`: a caret anywhere from the start to the
91 /// end of the range ends up after the new text.
92 mutating func replaceBeforeMarkers(_ range: Range<Int>, with replacement: String) {
93 let delta = edit(range, replacement)
94 guard let position = caret else { return }
95 if position > range.upperBound {
96 caret = position + delta
97 } else if position >= range.lowerBound {
98 caret = range.lowerBound + (replacement as NSString).length
99 }
100 }
101
102 /// `insert` under `save-excursion`: a caret at the insertion point stays before the text.
103 mutating func insert(_ string: String, at position: Int) {
104 let delta = edit(position..<position, string)
105 if let caret, caret > position { self.caret = caret + delta }
106 }
107
108 /// `insert` at point: the caret moves past the text.
109 mutating func insertAtCaret(_ string: String) {
110 guard let position = caret else { return }
111 let delta = edit(position..<position, string)
112 caret = position + delta
113 }
114}
115
116extension HeadingLine {
117 /// Applies `edit` to the heading line in `context` and maps the caret: on the line it's
118 /// placed by `edit`; after the line it shifts by the change in length.
119 func commit(_ context: EditContext, _ edit: (inout LineBuffer) -> Void) -> CommandStep {
120 let old = (context.text as NSString).substring(with: NSRange(start..<contentEnd))
121 let caretOnLine = context.caret >= start && context.caret <= contentEnd
122 var buffer = LineBuffer(old, caret: caretOnLine ? context.caret - start : nil)
123 edit(&buffer)
124 let new = buffer.string
125 let delta = (new as NSString).length - (old as NSString).length
126 let caret = buffer.caret.map { $0 + start } ?? (context.caret > contentEnd ? context.caret + delta : context.caret)
127 guard new != old else { return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret])) }
128 return .commit(EditResult(
129 baseRevision: context.revision,
130 edits: [TextEdit(range: start..<contentEnd, replacement: new)],
131 selection: [caret..<caret]
132 ))
133 }
134
135 /// Offsets relative to the line start.
136 func local(_ range: Range<Int>) -> Range<Int> { (range.lowerBound - start)..<(range.upperBound - start) }
137}
138
139// MARK: - Tags
140
141/// `org--align-tags-here` with `org-tags-column` -77: tags end at column 77, or sit one blank
142/// after the title when it is too long. Nothing changes when they are already there.
143///
144/// Commands align under `save-excursion`, where the caret is a marker: in the blanks before
145/// the tags it ends up where they start. Aligning tags directly (`preservingColumn`) keeps the
146/// caret's column instead, as org does for point.
147func alignTags(_ buffer: inout LineBuffer, options: EditingOptions = .org, preservingColumn: Bool = false) {
148 let line = buffer.string
149 guard let match = line.range(of: "[ \\t]+(:[[:alnum:]_@#%]+)+:[ \\t]*$", options: .regularExpression) else { return }
150 let matched = String(line[match])
151 let tags = matched.trimmingCharacters(in: .whitespaces)
152 let blankStart = (String(line[..<match.lowerBound]) as NSString).length
153 let tagsStart = blankStart + (matched as NSString).range(of: tags).location
154 // Columns as Emacs displays the line: link markup and, with the options, emphasis markers
155 // don't count.
156 let visible = VisibleLine(line, options: options)
157 let prefixColumn = visible.column(at: blankStart)
158 let currentColumn = visible.column(at: tagsStart)
159 let tagsColumn = options.tagsColumn
160 let target = tagsColumn >= 0 ? tagsColumn : abs(tagsColumn) - displayWidth(tags)
161 let newColumn = max(target, prefixColumn + 1)
162 guard newColumn != currentColumn else { return }
163 let inBlanks = buffer.caret.map { $0 > blankStart && $0 <= tagsStart } ?? false
164 let caretColumn: Int? = inBlanks && preservingColumn ? visible.column(at: buffer.caret!) : nil
165 let old = buffer.caret
166 buffer.text.replaceCharacters(in: NSRange(blankStart..<tagsStart), with: String(repeating: " ", count: newColumn - prefixColumn))
167 let delta = (newColumn - prefixColumn) - (tagsStart - blankStart)
168 if let caretColumn {
169 buffer.caret = VisibleLine(buffer.string, options: options).offset(ofColumn: caretColumn)
170 } else if inBlanks {
171 buffer.caret = blankStart
172 } else if let old, old > tagsStart {
173 buffer.caret = old + delta
174 }
175}
176
177
178// MARK: - TODO
179
180public struct TodoCycle: OrgCommand {
181 public init() {}
182 public var id: String { "org.todo.cycle" }
183 public var title: String { "Cycle TODO State" }
184
185 public func applies(in context: EditContext) -> Bool {
186 entryHeading(at: context.caret, in: context.tree) != nil
187 }
188
189 /// No keyword, then each keyword of its sequence in order, then no keyword again. A
190 /// `#+TYP_TODO` keyword goes to the sequence's first done keyword unless `repeated` (the
191 /// previous command was also this one, `this-command` eq `last-command` in `org-todo`).
192 static func next(after current: String?, in settings: OrgSettings, repeated: Bool) -> String? {
193 guard let current else {
194 guard let first = settings.todoSequences.first else { return nil }
195 return (first.active + first.done).first?.name
196 }
197 for sequence in settings.todoSequences {
198 let names = (sequence.active + sequence.done).map(\.name)
199 if let index = names.firstIndex(of: current) {
200 guard index + 1 < names.count else { return nil }
201 return sequence.kind == .type && !repeated ? sequence.done.first?.name : names[index + 1]
202 }
203 }
204 return nil
205 }
206
207 /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are
208 /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags
209 /// are aligned.
210 /// `org-todo`, then the parent's TODO statistics, and the repeat of a repeating entry
211 /// that became done.
212 public func run(in context: EditContext) -> CommandStep {
213 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
214 let previous = buffer.todoKeyword
215 if context.options.fastTodoSelection, entryHeading(at: context.caret, in: context.tree) != nil,
216 let fast = FastSelection.todo(context.tree.settings) {
217 guard let answer = context.answers["todo"] else {
218 return .prompt(Prompt(key: "todo", message: "TODO state: [a-z..]:Set [SPC]:clear", fast: fast))
219 }
220 guard let key = answer.first, let keyword = fast.keyword(for: key, current: previous) else { return .failed("Quit") }
221 return SetTodo(keyword: keyword).run(in: context)
222 }
223 let next = Self.next(after: previous, in: context.tree.settings, repeated: context.lastCommand == id)
224 if let message = buffer.todoBlockedMessage(from: previous, to: next) { return .failed(message) }
225 return followUp(context, cycle(in: context)) { buffer in
226 try buffer.saveExcursion {
227 buffer.updateParentTodoStatistics()
228 try buffer.afterTodoChange(previous: previous, answers: context.answers)
229 }
230 }
231 }
232
233 private func cycle(in context: EditContext) -> CommandStep {
234 guard let heading = entryHeading(at: context.caret, in: context.tree) else {
235 return .failed("Before first headline")
236 }
237 let line = HeadingLine(heading)
238 let next = Self.next(after: line.todo?.text, in: context.tree.settings, repeated: context.lastCommand == id)
239 return line.commit(context) { buffer in
240 let text = buffer.string as NSString
241 let regionStart = line.stars.upperBound - line.start
242 var regionEnd = regionStart
243 while regionEnd < text.length, text.character(at: regionEnd) == 0x20 { regionEnd += 1 }
244 if let todo = line.todo {
245 regionEnd = line.local(todo.range).upperBound
246 var blanks = regionEnd
247 while blanks < text.length, text.character(at: blanks) == 0x20 { blanks += 1 }
248 if blanks > regionEnd {
249 regionEnd = blanks
250 } else if text.substring(from: regionEnd).allSatisfy({ $0 == " " || $0 == "\t" }) {
251 regionEnd = text.length
252 }
253 }
254 buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ")
255 alignTags(&buffer, options: context.options)
256 }
257 }
258}
259
260/// S-right and S-left on a heading: `org-todo` with `right` or `left`, through every keyword
261/// of every sequence.
262public struct TodoShift: OrgCommand {
263 public let right: Bool
264 public init(right: Bool) { self.right = right }
265 public var id: String { right ? "org.todo.next" : "org.todo.previous" }
266 public var title: String { right ? "Next TODO Keyword" : "Previous TODO Keyword" }
267 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
268 public func run(in context: EditContext) -> CommandStep {
269 let keywords = context.tree.settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) }
270 let current = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).todoKeyword
271 let index = current.flatMap(keywords.firstIndex(of:))
272 let next: String? = if right {
273 index.map { $0 + 1 < keywords.count ? keywords[$0 + 1] : nil } ?? keywords.first
274 } else {
275 index.map { $0 > 0 ? keywords[$0 - 1] : nil } ?? keywords.last
276 }
277 return SetTodo(keyword: next).run(in: context)
278 }
279}
280
281// MARK: - Priority
282
283struct PriorityChange {
284 let up: Bool
285
286 func run(in context: EditContext) -> CommandStep {
287 guard let heading = headingOnLine(at: context.caret, in: context.tree) else {
288 return .failed("Not on a heading")
289 }
290 let line = HeadingLine(heading)
291 let priorities = context.tree.settings.priorities
292 guard let highest = Self.value(priorities.highest), let lowest = Self.value(priorities.lowest) else {
293 return .failed("Unsupported priority range")
294 }
295 let numeric = Int(priorities.highest) != nil
296 let current = line.priority.flatMap { Self.value(String($0.text.dropFirst(2).dropLast())) }
297 // From no cookie, the first press sets the default, as `org-priority-start-cycle-with-default`.
298 var new: Int?
299 if let current {
300 new = up ? current - 1 : current + 1
301 if let value = new, value < highest || value > lowest { new = nil }
302 } else {
303 new = Self.value(priorities.default)
304 }
305 return Self.write(new.map { "[#\(Self.label($0, numeric: numeric))]" }, line: line, context: context)
306 }
307
308 /// Puts `cookie` on the heading, or with nil removes it.
309 static func write(_ cookie: String?, line: HeadingLine, context: EditContext) -> CommandStep {
310 line.commit(context) { buffer in
311 if let existing = line.priority {
312 let range = line.local(existing.range)
313 if let cookie {
314 buffer.replace((range.lowerBound + 2)..<(range.upperBound - 1), with: String(cookie.dropFirst(2).dropLast()))
315 } else {
316 // The cookie and one following space, as `org-priority-regexp` group 1.
317 let text = buffer.string as NSString
318 let space = range.upperBound < text.length && text.character(at: range.upperBound) == 0x20 ? 1 : 0
319 buffer.replace(range.lowerBound..<(range.upperBound + space), with: "")
320 }
321 } else if let cookie {
322 if let todo = line.todo {
323 buffer.insert(" " + cookie, at: line.local(todo.range).upperBound)
324 } else {
325 buffer.insert(cookie + " ", at: line.titleStart - line.start)
326 }
327 }
328 alignTags(&buffer, options: context.options)
329 }
330 }
331
332 /// A priority as a number: a letter's code point, or the number itself.
333 static func value(_ text: String) -> Int? {
334 if let number = Int(text) { return number }
335 guard text.unicodeScalars.count == 1, let scalar = text.unicodeScalars.first else { return nil }
336 return Int(scalar.value)
337 }
338
339 static func label(_ value: Int, numeric: Bool) -> String {
340 numeric ? String(value) : String(Character(Unicode.Scalar(UInt32(value))!))
341 }
342}
343
344/// C-c ,: `org-priority` with `set`, asking for the priority (SPC removes it); with `value`,
345/// that priority, or with " " none, as the speed keys 1, 2, 3 and 0 do.
346public struct SetPriority: OrgCommand {
347 let value: Character?
348 public init(value: Character? = nil) { self.value = value }
349 public var id: String {
350 switch value {
351 case nil: "org.priority.set"
352 case " ": "org.priority.remove"
353 case let v?: "org.priority.set-\(v.lowercased())"
354 }
355 }
356 public var title: String {
357 switch value {
358 case nil: "Set Priority…"
359 case " ": "Remove Priority"
360 case let v?: "Set Priority \(v)"
361 }
362 }
363 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
364
365 public func run(in context: EditContext) -> CommandStep {
366 guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
367 let priorities = context.tree.settings.priorities
368 let numeric = Int(priorities.lowest) != nil
369 let answer: String
370 if let value {
371 answer = String(value)
372 } else if let given = context.answers["priority"] {
373 answer = given
374 } else {
375 let message = "Priority \(priorities.highest)-\(priorities.lowest), SPC to remove:"
376 if numeric, (Int(priorities.lowest) ?? 0) >= 10 { return .prompt(Prompt(key: "priority", message: message)) }
377 guard let high = PriorityChange.value(priorities.highest), let low = PriorityChange.value(priorities.lowest), high <= low else {
378 return .failed("Unsupported priority range")
379 }
380 let items: [FastSelection.Item] = (high...low).map { v in
381 let label = PriorityChange.label(v, numeric: numeric)
382 return .option(key: Character(label.lowercased()), name: label)
383 } + [.option(key: " ", name: "remove")]
384 return .prompt(Prompt(key: "priority", message: message, fast: FastSelection(items: items, multiple: false)))
385 }
386 let line = HeadingLine(heading)
387 if answer == " " {
388 guard line.priority != nil else { return .failed("No priority cookie found in line") }
389 return PriorityChange.write(nil, line: line, context: context)
390 }
391 let new = numeric ? Int(answer.trimmingCharacters(in: .whitespaces)) : PriorityChange.value(answer.uppercased())
392 guard let new, let high = PriorityChange.value(priorities.highest), let low = PriorityChange.value(priorities.lowest), new >= high, new <= low else {
393 return .failed(numeric ? "Priority must be a number between `\(priorities.highest)' and `\(priorities.lowest)'"
394 : "Priority must be between `\(priorities.highest)' and `\(priorities.lowest)'")
395 }
396 return PriorityChange.write("[#\(PriorityChange.label(new, numeric: numeric))]", line: line, context: context)
397 }
398}
399
400public struct PriorityUp: OrgCommand {
401 public init() {}
402 public var id: String { "org.priority.up" }
403 public var title: String { "Raise Priority" }
404 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
405 public func run(in context: EditContext) -> CommandStep { PriorityChange(up: true).run(in: context) }
406}
407
408public struct PriorityDown: OrgCommand {
409 public init() {}
410 public var id: String { "org.priority.down" }
411 public var title: String { "Lower Priority" }
412 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
413 public func run(in context: EditContext) -> CommandStep { PriorityChange(up: false).run(in: context) }
414}
415
416// MARK: - Promote and demote
417
418public struct PromoteHeading: OrgCommand {
419 public init() {}
420 public var id: String { "org.heading.promote" }
421 public var title: String { "Promote Heading" }
422 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
423
424 public func run(in context: EditContext) -> CommandStep {
425 guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
426 let line = HeadingLine(heading)
427 guard line.level > 1 else { return .failed("Cannot promote to level 0") }
428 return line.commit(context) { buffer in
429 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ")
430 alignTags(&buffer, options: context.options)
431 fixPositionAfterPromote(&buffer, settings: context.tree.settings)
432 }
433 }
434}
435
436public struct DemoteHeading: OrgCommand {
437 public init() {}
438 public var id: String { "org.heading.demote" }
439 public var title: String { "Demote Heading" }
440 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
441
442 public func run(in context: EditContext) -> CommandStep {
443 guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
444 let line = HeadingLine(heading)
445 return line.commit(context) { buffer in
446 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ")
447 alignTags(&buffer, options: context.options)
448 fixPositionAfterPromote(&buffer, settings: context.tree.settings)
449 }
450 }
451}
452
453/// `org-fix-position-after-promote`: a caret right after the stars or the keyword steps over
454/// the following space, or adds one at the end of the line.
455func fixPositionAfterPromote(_ buffer: inout LineBuffer, settings: OrgSettings) {
456 guard let caret = buffer.caret else { return }
457 let tree = OrgParser.parse(buffer.string + "\n", defaults: settings)
458 guard let heading = tree.root.firstChild(.section)?.firstChild(.heading) else { return }
459 let line = HeadingLine(heading)
460 let anchors = [line.stars.upperBound, line.todo?.range.upperBound].compactMap { $0 }
461 guard anchors.contains(caret) else { return }
462 if caret == buffer.length {
463 buffer.insertAtCaret(" ")
464 } else if buffer.text.character(at: caret) == 0x20 {
465 buffer.caret = caret + 1
466 }
467}