import Foundation // Heading-line commands, matched to Emacs 31.1 / Org 9.8.7 by the oracle tests. /// The heading whose line holds `offset`, for commands that act only on heading lines (org's /// M-left, M-right, S-up and S-down do something else elsewhere). func headingOnLine(at offset: Int, in tree: OrgTree) -> SyntaxNode? { guard let heading = entryHeading(at: offset, in: tree) else { return nil } let line = HeadingLine(heading) return offset >= line.start && offset <= line.contentEnd ? heading : nil } /// The heading of the entry holding `offset`, as `org-back-to-heading`; nil before the first /// heading. func entryHeading(at offset: Int, in tree: OrgTree) -> SyntaxNode? { let root = tree.root let position = offset >= root.range.upperBound ? max(0, root.range.upperBound - 1) : offset var node = root var heading: SyntaxNode? while let child = node.child(containing: position), child.kind == .section { heading = child.firstChild(.heading) node = child } return heading } /// The parts of a heading line, in offsets of the whole text. struct HeadingLine { let start: Int /// End of the line's content, before its line break. let contentEnd: Int let stars: Range let todo: SyntaxToken? let priority: SyntaxToken? let tags: SyntaxToken? /// Where the title begins: past the stars, keyword, cookie and the blanks after them. let titleStart: Int init(_ heading: SyntaxNode) { let tokens = heading.tokens start = heading.range.lowerBound contentEnd = tokens.first { $0.kind == .newline }?.range.lowerBound ?? heading.range.upperBound stars = tokens.first { $0.kind == .stars }?.range ?? start.., _ replacement: String) -> Int { text.replaceCharacters(in: NSRange(range), with: replacement) return (replacement as NSString).length - range.count } /// `replace-match`: a caret strictly inside moves to the start; at or after the end it /// shifts with the text. mutating func replace(_ range: Range, with replacement: String) { let delta = edit(range, replacement) guard let position = caret else { return } if position >= range.upperBound, position > range.lowerBound { caret = position + delta } else if position > range.lowerBound { caret = range.lowerBound } } /// Deleting `range`, then `insert-before-markers`: a caret anywhere from the start to the /// end of the range ends up after the new text. mutating func replaceBeforeMarkers(_ range: Range, with replacement: String) { let delta = edit(range, replacement) guard let position = caret else { return } if position > range.upperBound { caret = position + delta } else if position >= range.lowerBound { caret = range.lowerBound + (replacement as NSString).length } } /// `insert` under `save-excursion`: a caret at the insertion point stays before the text. mutating func insert(_ string: String, at position: Int) { let delta = edit(position.. position { self.caret = caret + delta } } /// `insert` at point: the caret moves past the text. mutating func insertAtCaret(_ string: String) { guard let position = caret else { return } let delta = edit(position.. Int { var column = 0 var offset = 0 for character in string { if column >= target { break } column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character) offset += String(character).utf16.count } return offset } } extension HeadingLine { /// Applies `edit` to the heading line in `context` and maps the caret: on the line it's /// placed by `edit`; after the line it shifts by the change in length. func commit(_ context: EditContext, _ edit: (inout LineBuffer) -> Void) -> CommandStep { let old = (context.text as NSString).substring(with: NSRange(start..= start && context.caret <= contentEnd var buffer = LineBuffer(old, caret: caretOnLine ? context.caret - start : nil) edit(&buffer) let new = buffer.string let delta = (new as NSString).length - (old as NSString).length let caret = buffer.caret.map { $0 + start } ?? (context.caret > contentEnd ? context.caret + delta : context.caret) guard new != old else { return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..) -> Range { (range.lowerBound - start)..<(range.upperBound - start) } } // MARK: - Tags /// `org--align-tags-here` with `org-tags-column` -77: tags end at column 77, or sit one blank /// after the title when it is too long. Nothing changes when they are already there. /// /// Commands align under `save-excursion`, where the caret is a marker: in the blanks before /// the tags it ends up where they start. Aligning tags directly (`preservingColumn`) keeps the /// caret's column instead, as org does for point. func alignTags(_ buffer: inout LineBuffer, tagsColumn: Int = -77, preservingColumn: Bool = false) { let line = buffer.string guard let match = line.range(of: "[ \\t]+(:[[:alnum:]_@#%]+)+:[ \\t]*$", options: .regularExpression) else { return } let matched = String(line[match]) let tags = matched.trimmingCharacters(in: .whitespaces) let blankStart = (String(line[..= 0 ? tagsColumn : abs(tagsColumn) - displayWidth(tags) let newColumn = max(target, prefixColumn + 1) guard newColumn != currentColumn else { return } let inBlanks = buffer.caret.map { $0 > blankStart && $0 <= tagsStart } ?? false let caretColumn: Int? = inBlanks && preservingColumn ? column(of: (line as NSString).substring(to: buffer.caret!)) : nil let old = buffer.caret buffer.text.replaceCharacters(in: NSRange(blankStart.. tagsStart { buffer.caret = old + delta } } /// Display column at the end of `text`, starting from column 0. func column(of text: String) -> Int { var column = 0 for character in text { column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character) } return column } // MARK: - TODO public struct TodoCycle: OrgCommand { public init() {} public var id: String { "org.todo.cycle" } public var title: String { "Cycle TODO State" } public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } /// No keyword, then each keyword of its sequence in order, then no keyword again. static func next(after current: String?, in settings: OrgSettings) -> String? { guard let current else { guard let first = settings.todoSequences.first else { return nil } return (first.active + first.done).first?.name } for sequence in settings.todoSequences { let names = (sequence.active + sequence.done).map(\.name) if let index = names.firstIndex(of: current) { return index + 1 < names.count ? names[index + 1] : nil } } return nil } /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags /// are aligned. public func run(in context: EditContext) -> CommandStep { guard let heading = entryHeading(at: context.caret, in: context.tree) else { return .failed("Before first headline") } let line = HeadingLine(heading) let next = Self.next(after: line.todo?.text, in: context.tree.settings) return line.commit(context) { buffer in let text = buffer.string as NSString let regionStart = line.stars.upperBound - line.start var regionEnd = regionStart while regionEnd < text.length, text.character(at: regionEnd) == 0x20 { regionEnd += 1 } if let todo = line.todo { regionEnd = line.local(todo.range).upperBound var blanks = regionEnd while blanks < text.length, text.character(at: blanks) == 0x20 { blanks += 1 } if blanks > regionEnd { regionEnd = blanks } else if text.substring(from: regionEnd).allSatisfy({ $0 == " " || $0 == "\t" }) { regionEnd = text.length } } buffer.replaceBeforeMarkers(regionStart.. CommandStep { guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") } let line = HeadingLine(heading) let priorities = context.tree.settings.priorities guard let highest = Self.value(priorities.highest), let lowest = Self.value(priorities.lowest) else { return .failed("Unsupported priority range") } let numeric = Int(priorities.highest) != nil let current = line.priority.flatMap { Self.value(String($0.text.dropFirst(2).dropLast())) } // From no cookie, the first press sets the default, as `org-priority-start-cycle-with-default`. var new: Int? if let current { new = up ? current - 1 : current + 1 if let value = new, value < highest || value > lowest { new = nil } } else { new = Self.value(priorities.default) } let cookie = new.map { "[#\(Self.label($0, numeric: numeric))]" } return line.commit(context) { buffer in if let existing = line.priority { let range = line.local(existing.range) if let cookie { buffer.replace((range.lowerBound + 2)..<(range.upperBound - 1), with: String(cookie.dropFirst(2).dropLast())) } else { // The cookie and one following space, as `org-priority-regexp` group 1. let text = buffer.string as NSString let space = range.upperBound < text.length && text.character(at: range.upperBound) == 0x20 ? 1 : 0 buffer.replace(range.lowerBound..<(range.upperBound + space), with: "") } } else if let cookie { if let todo = line.todo { buffer.insert(" " + cookie, at: line.local(todo.range).upperBound) } else { buffer.insert(cookie + " ", at: line.titleStart - line.start) } } alignTags(&buffer) } } /// A priority as a number: a letter's code point, or the number itself. static func value(_ text: String) -> Int? { if let number = Int(text) { return number } guard text.unicodeScalars.count == 1, let scalar = text.unicodeScalars.first else { return nil } return Int(scalar.value) } static func label(_ value: Int, numeric: Bool) -> String { numeric ? String(value) : String(Character(Unicode.Scalar(UInt32(value))!)) } } public struct PriorityUp: OrgCommand { public init() {} public var id: String { "org.priority.up" } public var title: String { "Raise Priority" } public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } public func run(in context: EditContext) -> CommandStep { PriorityChange(up: true).run(in: context) } } public struct PriorityDown: OrgCommand { public init() {} public var id: String { "org.priority.down" } public var title: String { "Lower Priority" } public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } public func run(in context: EditContext) -> CommandStep { PriorityChange(up: false).run(in: context) } } // MARK: - Promote and demote public struct PromoteHeading: OrgCommand { public init() {} public var id: String { "org.heading.promote" } public var title: String { "Promote Heading" } public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } public func run(in context: EditContext) -> CommandStep { guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") } let line = HeadingLine(heading) guard line.level > 1 else { return .failed("Cannot promote to level 0") } return line.commit(context) { buffer in buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ") alignTags(&buffer) fixPositionAfterPromote(&buffer, settings: context.tree.settings) } } } public struct DemoteHeading: OrgCommand { public init() {} public var id: String { "org.heading.demote" } public var title: String { "Demote Heading" } public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } public func run(in context: EditContext) -> CommandStep { guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") } let line = HeadingLine(heading) return line.commit(context) { buffer in buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ") alignTags(&buffer) fixPositionAfterPromote(&buffer, settings: context.tree.settings) } } } /// `org-fix-position-after-promote`: a caret right after the stars or the keyword steps over /// the following space, or adds one at the end of the line. func fixPositionAfterPromote(_ buffer: inout LineBuffer, settings: OrgSettings) { guard let caret = buffer.caret else { return } let tree = OrgParser.parse(buffer.string + "\n", defaults: settings) guard let heading = tree.root.firstChild(.section)?.firstChild(.heading) else { return } let line = HeadingLine(heading) let anchors = [line.stars.upperBound, line.todo?.range.upperBound].compactMap { $0 } guard anchors.contains(caret) else { return } if caret == buffer.length { buffer.insertAtCaret(" ") } else if buffer.text.character(at: caret) == 0x20 { buffer.caret = caret + 1 } }