| @@ -0,0 +1,376 @@ |
| 1 | import 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). |
| 7 | func 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. |
| 15 | func 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. |
| 28 | struct 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. |
| 60 | struct 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 | /// Offset of display column `target` in the line, as `move-to-column`. |
| 116 | func offset(ofColumn target: Int) -> Int { |
| 117 | var column = 0 |
| 118 | var offset = 0 |
| 119 | for character in string { |
| 120 | if column >= target { break } |
| 121 | column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character) |
| 122 | offset += String(character).utf16.count |
| 123 | } |
| 124 | return offset |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | extension HeadingLine { |
| 129 | /// Applies `edit` to the heading line in `context` and maps the caret: on the line it's |
| 130 | /// placed by `edit`; after the line it shifts by the change in length. |
| 131 | func commit(_ context: EditContext, _ edit: (inout LineBuffer) -> Void) -> CommandStep { |
| 132 | let old = (context.text as NSString).substring(with: NSRange(start..<contentEnd)) |
| 133 | let caretOnLine = context.caret >= start && context.caret <= contentEnd |
| 134 | var buffer = LineBuffer(old, caret: caretOnLine ? context.caret - start : nil) |
| 135 | edit(&buffer) |
| 136 | let new = buffer.string |
| 137 | let delta = (new as NSString).length - (old as NSString).length |
| 138 | let caret = buffer.caret.map { $0 + start } ?? (context.caret > contentEnd ? context.caret + delta : context.caret) |
| 139 | guard new != old else { return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret])) } |
| 140 | return .commit(EditResult( |
| 141 | baseRevision: context.revision, |
| 142 | edits: [TextEdit(range: start..<contentEnd, replacement: new)], |
| 143 | selection: [caret..<caret] |
| 144 | )) |
| 145 | } |
| 146 | |
| 147 | /// Offsets relative to the line start. |
| 148 | func local(_ range: Range<Int>) -> Range<Int> { (range.lowerBound - start)..<(range.upperBound - start) } |
| 149 | } |
| 150 | |
| 151 | // MARK: - Tags |
| 152 | |
| 153 | /// `org--align-tags-here` with `org-tags-column` -77: tags end at column 77, or sit one blank |
| 154 | /// after the title when it is too long. Nothing changes when they are already there. |
| 155 | /// |
| 156 | /// Commands align under `save-excursion`, where the caret is a marker: in the blanks before |
| 157 | /// the tags it ends up where they start. Aligning tags directly (`preservingColumn`) keeps the |
| 158 | /// caret's column instead, as org does for point. |
| 159 | func alignTags(_ buffer: inout LineBuffer, tagsColumn: Int = -77, preservingColumn: Bool = false) { |
| 160 | let line = buffer.string |
| 161 | guard let match = line.range(of: "[ \\t]+(:[[:alnum:]_@#%]+)+:[ \\t]*$", options: .regularExpression) else { return } |
| 162 | let matched = String(line[match]) |
| 163 | let tags = matched.trimmingCharacters(in: .whitespaces) |
| 164 | let blankStart = (String(line[..<match.lowerBound]) as NSString).length |
| 165 | let tagsStart = blankStart + (matched as NSString).range(of: tags).location |
| 166 | let prefixColumn = column(of: String(line[..<match.lowerBound])) |
| 167 | let currentColumn = column(of: (line as NSString).substring(to: tagsStart)) |
| 168 | let target = tagsColumn >= 0 ? tagsColumn : abs(tagsColumn) - displayWidth(tags) |
| 169 | let newColumn = max(target, prefixColumn + 1) |
| 170 | guard newColumn != currentColumn else { return } |
| 171 | let inBlanks = buffer.caret.map { $0 > blankStart && $0 <= tagsStart } ?? false |
| 172 | let caretColumn: Int? = inBlanks && preservingColumn ? column(of: (line as NSString).substring(to: buffer.caret!)) : nil |
| 173 | let old = buffer.caret |
| 174 | buffer.text.replaceCharacters(in: NSRange(blankStart..<tagsStart), with: String(repeating: " ", count: newColumn - prefixColumn)) |
| 175 | let delta = (newColumn - prefixColumn) - (tagsStart - blankStart) |
| 176 | if let caretColumn { |
| 177 | buffer.caret = buffer.offset(ofColumn: caretColumn) |
| 178 | } else if inBlanks { |
| 179 | buffer.caret = blankStart |
| 180 | } else if let old, old > tagsStart { |
| 181 | buffer.caret = old + delta |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | /// Display column at the end of `text`, starting from column 0. |
| 186 | func column(of text: String) -> Int { |
| 187 | var column = 0 |
| 188 | for character in text { |
| 189 | column = character == "\t" ? (column / 8 + 1) * 8 : column + displayWidth(of: character) |
| 190 | } |
| 191 | return column |
| 192 | } |
| 193 | |
| 194 | // MARK: - TODO |
| 195 | |
| 196 | public struct TodoCycle: OrgCommand { |
| 197 | public init() {} |
| 198 | public var id: String { "org.todo.cycle" } |
| 199 | public var title: String { "Cycle TODO State" } |
| 200 | |
| 201 | public func applies(in context: EditContext) -> Bool { |
| 202 | entryHeading(at: context.caret, in: context.tree) != nil |
| 203 | } |
| 204 | |
| 205 | /// No keyword, then each keyword of its sequence in order, then no keyword again. |
| 206 | static func next(after current: String?, in settings: OrgSettings) -> String? { |
| 207 | guard let current else { |
| 208 | guard let first = settings.todoSequences.first else { return nil } |
| 209 | return (first.active + first.done).first?.name |
| 210 | } |
| 211 | for sequence in settings.todoSequences { |
| 212 | let names = (sequence.active + sequence.done).map(\.name) |
| 213 | if let index = names.firstIndex(of: current) { |
| 214 | return index + 1 < names.count ? names[index + 1] : nil |
| 215 | } |
| 216 | } |
| 217 | return nil |
| 218 | } |
| 219 | |
| 220 | /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are |
| 221 | /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags |
| 222 | /// are aligned. |
| 223 | public func run(in context: EditContext) -> CommandStep { |
| 224 | guard let heading = entryHeading(at: context.caret, in: context.tree) else { |
| 225 | return .failed("Before first headline") |
| 226 | } |
| 227 | let line = HeadingLine(heading) |
| 228 | let next = Self.next(after: line.todo?.text, in: context.tree.settings) |
| 229 | return line.commit(context) { buffer in |
| 230 | let text = buffer.string as NSString |
| 231 | let regionStart = line.stars.upperBound - line.start |
| 232 | var regionEnd = regionStart |
| 233 | while regionEnd < text.length, text.character(at: regionEnd) == 0x20 { regionEnd += 1 } |
| 234 | if let todo = line.todo { |
| 235 | regionEnd = line.local(todo.range).upperBound |
| 236 | var blanks = regionEnd |
| 237 | while blanks < text.length, text.character(at: blanks) == 0x20 { blanks += 1 } |
| 238 | if blanks > regionEnd { |
| 239 | regionEnd = blanks |
| 240 | } else if text.substring(from: regionEnd).allSatisfy({ $0 == " " || $0 == "\t" }) { |
| 241 | regionEnd = text.length |
| 242 | } |
| 243 | } |
| 244 | buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ") |
| 245 | alignTags(&buffer) |
| 246 | } |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // MARK: - Priority |
| 251 | |
| 252 | struct PriorityChange { |
| 253 | let up: Bool |
| 254 | |
| 255 | func run(in context: EditContext) -> CommandStep { |
| 256 | guard let heading = headingOnLine(at: context.caret, in: context.tree) else { |
| 257 | return .failed("Not on a heading") |
| 258 | } |
| 259 | let line = HeadingLine(heading) |
| 260 | let priorities = context.tree.settings.priorities |
| 261 | guard let highest = Self.value(priorities.highest), let lowest = Self.value(priorities.lowest) else { |
| 262 | return .failed("Unsupported priority range") |
| 263 | } |
| 264 | let numeric = Int(priorities.highest) != nil |
| 265 | let current = line.priority.flatMap { Self.value(String($0.text.dropFirst(2).dropLast())) } |
| 266 | // From no cookie, the first press sets the default, as `org-priority-start-cycle-with-default`. |
| 267 | var new: Int? |
| 268 | if let current { |
| 269 | new = up ? current - 1 : current + 1 |
| 270 | if let value = new, value < highest || value > lowest { new = nil } |
| 271 | } else { |
| 272 | new = Self.value(priorities.default) |
| 273 | } |
| 274 | let cookie = new.map { "[#\(Self.label($0, numeric: numeric))]" } |
| 275 | return line.commit(context) { buffer in |
| 276 | if let existing = line.priority { |
| 277 | let range = line.local(existing.range) |
| 278 | if let cookie { |
| 279 | buffer.replace((range.lowerBound + 2)..<(range.upperBound - 1), with: String(cookie.dropFirst(2).dropLast())) |
| 280 | } else { |
| 281 | // The cookie and one following space, as `org-priority-regexp` group 1. |
| 282 | let text = buffer.string as NSString |
| 283 | let space = range.upperBound < text.length && text.character(at: range.upperBound) == 0x20 ? 1 : 0 |
| 284 | buffer.replace(range.lowerBound..<(range.upperBound + space), with: "") |
| 285 | } |
| 286 | } else if let cookie { |
| 287 | if let todo = line.todo { |
| 288 | buffer.insert(" " + cookie, at: line.local(todo.range).upperBound) |
| 289 | } else { |
| 290 | buffer.insert(cookie + " ", at: line.titleStart - line.start) |
| 291 | } |
| 292 | } |
| 293 | alignTags(&buffer) |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | /// A priority as a number: a letter's code point, or the number itself. |
| 298 | static func value(_ text: String) -> Int? { |
| 299 | if let number = Int(text) { return number } |
| 300 | guard text.unicodeScalars.count == 1, let scalar = text.unicodeScalars.first else { return nil } |
| 301 | return Int(scalar.value) |
| 302 | } |
| 303 | |
| 304 | static func label(_ value: Int, numeric: Bool) -> String { |
| 305 | numeric ? String(value) : String(Character(Unicode.Scalar(UInt32(value))!)) |
| 306 | } |
| 307 | } |
| 308 | |
| 309 | public struct PriorityUp: OrgCommand { |
| 310 | public init() {} |
| 311 | public var id: String { "org.priority.up" } |
| 312 | public var title: String { "Raise Priority" } |
| 313 | public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } |
| 314 | public func run(in context: EditContext) -> CommandStep { PriorityChange(up: true).run(in: context) } |
| 315 | } |
| 316 | |
| 317 | public struct PriorityDown: OrgCommand { |
| 318 | public init() {} |
| 319 | public var id: String { "org.priority.down" } |
| 320 | public var title: String { "Lower Priority" } |
| 321 | public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } |
| 322 | public func run(in context: EditContext) -> CommandStep { PriorityChange(up: false).run(in: context) } |
| 323 | } |
| 324 | |
| 325 | // MARK: - Promote and demote |
| 326 | |
| 327 | public struct PromoteHeading: OrgCommand { |
| 328 | public init() {} |
| 329 | public var id: String { "org.heading.promote" } |
| 330 | public var title: String { "Promote Heading" } |
| 331 | public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } |
| 332 | |
| 333 | public func run(in context: EditContext) -> CommandStep { |
| 334 | guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") } |
| 335 | let line = HeadingLine(heading) |
| 336 | guard line.level > 1 else { return .failed("Cannot promote to level 0") } |
| 337 | return line.commit(context) { buffer in |
| 338 | buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ") |
| 339 | alignTags(&buffer) |
| 340 | fixPositionAfterPromote(&buffer, settings: context.tree.settings) |
| 341 | } |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | public struct DemoteHeading: OrgCommand { |
| 346 | public init() {} |
| 347 | public var id: String { "org.heading.demote" } |
| 348 | public var title: String { "Demote Heading" } |
| 349 | public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } |
| 350 | |
| 351 | public func run(in context: EditContext) -> CommandStep { |
| 352 | guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") } |
| 353 | let line = HeadingLine(heading) |
| 354 | return line.commit(context) { buffer in |
| 355 | buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ") |
| 356 | alignTags(&buffer) |
| 357 | fixPositionAfterPromote(&buffer, settings: context.tree.settings) |
| 358 | } |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | /// `org-fix-position-after-promote`: a caret right after the stars or the keyword steps over |
| 363 | /// the following space, or adds one at the end of the line. |
| 364 | func fixPositionAfterPromote(_ buffer: inout LineBuffer, settings: OrgSettings) { |
| 365 | guard let caret = buffer.caret else { return } |
| 366 | let tree = OrgParser.parse(buffer.string + "\n", defaults: settings) |
| 367 | guard let heading = tree.root.firstChild(.section)?.firstChild(.heading) else { return } |
| 368 | let line = HeadingLine(heading) |
| 369 | let anchors = [line.stars.upperBound, line.todo?.range.upperBound].compactMap { $0 } |
| 370 | guard anchors.contains(caret) else { return } |
| 371 | if caret == buffer.length { |
| 372 | buffer.insertAtCaret(" ") |
| 373 | } else if buffer.text.character(at: caret) == 0x20 { |
| 374 | buffer.caret = caret + 1 |
| 375 | } |
| 376 | } |