krz/orgstar

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

Sources/OrgCore/Commands/HeadingCommands.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Sources/OrgCore/Commands/HeadingCommands.swift history · blame · raw

464 lines · 22341 bytes

  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 previous = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).todoKeyword
214        if context.options.fastTodoSelection, entryHeading(at: context.caret, in: context.tree) != nil,
215           let fast = FastSelection.todo(context.tree.settings) {
216            guard let answer = context.answers["todo"] else {
217                return .prompt(Prompt(key: "todo", message: "TODO state: [a-z..]:Set [SPC]:clear", fast: fast))
218            }
219            guard let key = answer.first, let keyword = fast.keyword(for: key, current: previous) else { return .failed("Quit") }
220            return SetTodo(keyword: keyword).run(in: context)
221        }
222        return followUp(context, cycle(in: context)) { buffer in
223            try buffer.saveExcursion {
224                buffer.updateParentTodoStatistics()
225                try buffer.afterTodoChange(previous: previous, answers: context.answers)
226            }
227        }
228    }
229
230    private func cycle(in context: EditContext) -> CommandStep {
231        guard let heading = entryHeading(at: context.caret, in: context.tree) else {
232            return .failed("Before first headline")
233        }
234        let line = HeadingLine(heading)
235        let next = Self.next(after: line.todo?.text, in: context.tree.settings, repeated: context.lastCommand == id)
236        return line.commit(context) { buffer in
237            let text = buffer.string as NSString
238            let regionStart = line.stars.upperBound - line.start
239            var regionEnd = regionStart
240            while regionEnd < text.length, text.character(at: regionEnd) == 0x20 { regionEnd += 1 }
241            if let todo = line.todo {
242                regionEnd = line.local(todo.range).upperBound
243                var blanks = regionEnd
244                while blanks < text.length, text.character(at: blanks) == 0x20 { blanks += 1 }
245                if blanks > regionEnd {
246                    regionEnd = blanks
247                } else if text.substring(from: regionEnd).allSatisfy({ $0 == " " || $0 == "\t" }) {
248                    regionEnd = text.length
249                }
250            }
251            buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ")
252            alignTags(&buffer, options: context.options)
253        }
254    }
255}
256
257/// S-right and S-left on a heading: `org-todo` with `right` or `left`, through every keyword
258/// of every sequence.
259public struct TodoShift: OrgCommand {
260    public let right: Bool
261    public init(right: Bool) { self.right = right }
262    public var id: String { right ? "org.todo.next" : "org.todo.previous" }
263    public var title: String { right ? "Next TODO Keyword" : "Previous TODO Keyword" }
264    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
265    public func run(in context: EditContext) -> CommandStep {
266        let keywords = context.tree.settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) }
267        let current = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).todoKeyword
268        let index = current.flatMap(keywords.firstIndex(of:))
269        let next: String? = if right {
270            index.map { $0 + 1 < keywords.count ? keywords[$0 + 1] : nil } ?? keywords.first
271        } else {
272            index.map { $0 > 0 ? keywords[$0 - 1] : nil } ?? keywords.last
273        }
274        return SetTodo(keyword: next).run(in: context)
275    }
276}
277
278// MARK: - Priority
279
280struct PriorityChange {
281    let up: Bool
282
283    func run(in context: EditContext) -> CommandStep {
284        guard let heading = headingOnLine(at: context.caret, in: context.tree) else {
285            return .failed("Not on a heading")
286        }
287        let line = HeadingLine(heading)
288        let priorities = context.tree.settings.priorities
289        guard let highest = Self.value(priorities.highest), let lowest = Self.value(priorities.lowest) else {
290            return .failed("Unsupported priority range")
291        }
292        let numeric = Int(priorities.highest) != nil
293        let current = line.priority.flatMap { Self.value(String($0.text.dropFirst(2).dropLast())) }
294        // From no cookie, the first press sets the default, as `org-priority-start-cycle-with-default`.
295        var new: Int?
296        if let current {
297            new = up ? current - 1 : current + 1
298            if let value = new, value < highest || value > lowest { new = nil }
299        } else {
300            new = Self.value(priorities.default)
301        }
302        return Self.write(new.map { "[#\(Self.label($0, numeric: numeric))]" }, line: line, context: context)
303    }
304
305    /// Puts `cookie` on the heading, or with nil removes it.
306    static func write(_ cookie: String?, line: HeadingLine, context: EditContext) -> CommandStep {
307        line.commit(context) { buffer in
308            if let existing = line.priority {
309                let range = line.local(existing.range)
310                if let cookie {
311                    buffer.replace((range.lowerBound + 2)..<(range.upperBound - 1), with: String(cookie.dropFirst(2).dropLast()))
312                } else {
313                    // The cookie and one following space, as `org-priority-regexp` group 1.
314                    let text = buffer.string as NSString
315                    let space = range.upperBound < text.length && text.character(at: range.upperBound) == 0x20 ? 1 : 0
316                    buffer.replace(range.lowerBound..<(range.upperBound + space), with: "")
317                }
318            } else if let cookie {
319                if let todo = line.todo {
320                    buffer.insert(" " + cookie, at: line.local(todo.range).upperBound)
321                } else {
322                    buffer.insert(cookie + " ", at: line.titleStart - line.start)
323                }
324            }
325            alignTags(&buffer, options: context.options)
326        }
327    }
328
329    /// A priority as a number: a letter's code point, or the number itself.
330    static func value(_ text: String) -> Int? {
331        if let number = Int(text) { return number }
332        guard text.unicodeScalars.count == 1, let scalar = text.unicodeScalars.first else { return nil }
333        return Int(scalar.value)
334    }
335
336    static func label(_ value: Int, numeric: Bool) -> String {
337        numeric ? String(value) : String(Character(Unicode.Scalar(UInt32(value))!))
338    }
339}
340
341/// C-c ,: `org-priority` with `set`, asking for the priority (SPC removes it); with `value`,
342/// that priority, or with " " none, as the speed keys 1, 2, 3 and 0 do.
343public struct SetPriority: OrgCommand {
344    let value: Character?
345    public init(value: Character? = nil) { self.value = value }
346    public var id: String {
347        switch value {
348        case nil: "org.priority.set"
349        case " ": "org.priority.remove"
350        case let v?: "org.priority.set-\(v.lowercased())"
351        }
352    }
353    public var title: String {
354        switch value {
355        case nil: "Set Priority…"
356        case " ": "Remove Priority"
357        case let v?: "Set Priority \(v)"
358        }
359    }
360    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
361
362    public func run(in context: EditContext) -> CommandStep {
363        guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
364        let priorities = context.tree.settings.priorities
365        let numeric = Int(priorities.lowest) != nil
366        let answer: String
367        if let value {
368            answer = String(value)
369        } else if let given = context.answers["priority"] {
370            answer = given
371        } else {
372            let message = "Priority \(priorities.highest)-\(priorities.lowest), SPC to remove:"
373            if numeric, (Int(priorities.lowest) ?? 0) >= 10 { return .prompt(Prompt(key: "priority", message: message)) }
374            guard let high = PriorityChange.value(priorities.highest), let low = PriorityChange.value(priorities.lowest), high <= low else {
375                return .failed("Unsupported priority range")
376            }
377            let items: [FastSelection.Item] = (high...low).map { v in
378                let label = PriorityChange.label(v, numeric: numeric)
379                return .option(key: Character(label.lowercased()), name: label)
380            } + [.option(key: " ", name: "remove")]
381            return .prompt(Prompt(key: "priority", message: message, fast: FastSelection(items: items, multiple: false)))
382        }
383        let line = HeadingLine(heading)
384        if answer == " " {
385            guard line.priority != nil else { return .failed("No priority cookie found in line") }
386            return PriorityChange.write(nil, line: line, context: context)
387        }
388        let new = numeric ? Int(answer.trimmingCharacters(in: .whitespaces)) : PriorityChange.value(answer.uppercased())
389        guard let new, let high = PriorityChange.value(priorities.highest), let low = PriorityChange.value(priorities.lowest), new >= high, new <= low else {
390            return .failed(numeric ? "Priority must be a number between `\(priorities.highest)' and `\(priorities.lowest)'"
391                : "Priority must be between `\(priorities.highest)' and `\(priorities.lowest)'")
392        }
393        return PriorityChange.write("[#\(PriorityChange.label(new, numeric: numeric))]", line: line, context: context)
394    }
395}
396
397public struct PriorityUp: OrgCommand {
398    public init() {}
399    public var id: String { "org.priority.up" }
400    public var title: String { "Raise Priority" }
401    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
402    public func run(in context: EditContext) -> CommandStep { PriorityChange(up: true).run(in: context) }
403}
404
405public struct PriorityDown: OrgCommand {
406    public init() {}
407    public var id: String { "org.priority.down" }
408    public var title: String { "Lower Priority" }
409    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
410    public func run(in context: EditContext) -> CommandStep { PriorityChange(up: false).run(in: context) }
411}
412
413// MARK: - Promote and demote
414
415public struct PromoteHeading: OrgCommand {
416    public init() {}
417    public var id: String { "org.heading.promote" }
418    public var title: String { "Promote Heading" }
419    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
420
421    public func run(in context: EditContext) -> CommandStep {
422        guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
423        let line = HeadingLine(heading)
424        guard line.level > 1 else { return .failed("Cannot promote to level 0") }
425        return line.commit(context) { buffer in
426            buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ")
427            alignTags(&buffer, options: context.options)
428            fixPositionAfterPromote(&buffer, settings: context.tree.settings)
429        }
430    }
431}
432
433public struct DemoteHeading: OrgCommand {
434    public init() {}
435    public var id: String { "org.heading.demote" }
436    public var title: String { "Demote Heading" }
437    public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
438
439    public func run(in context: EditContext) -> CommandStep {
440        guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
441        let line = HeadingLine(heading)
442        return line.commit(context) { buffer in
443            buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ")
444            alignTags(&buffer, options: context.options)
445            fixPositionAfterPromote(&buffer, settings: context.tree.settings)
446        }
447    }
448}
449
450/// `org-fix-position-after-promote`: a caret right after the stars or the keyword steps over
451/// the following space, or adds one at the end of the line.
452func fixPositionAfterPromote(_ buffer: inout LineBuffer, settings: OrgSettings) {
453    guard let caret = buffer.caret else { return }
454    let tree = OrgParser.parse(buffer.string + "\n", defaults: settings)
455    guard let heading = tree.root.firstChild(.section)?.firstChild(.heading) else { return }
456    let line = HeadingLine(heading)
457    let anchors = [line.stars.upperBound, line.todo?.range.upperBound].compactMap { $0 }
458    guard anchors.contains(caret) else { return }
459    if caret == buffer.length {
460        buffer.insertAtCaret(" ")
461    } else if buffer.text.character(at: caret) == 0x20 {
462        buffer.caret = caret + 1
463    }
464}