| @@ -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 | } |