Sources/OrgCore/Commands/ListCommands.swift
369 lines · 18089 bytes
39 symbols in this file
insertListItemindentItemmoveItemtoggleCheckboxupdateCheckboxCountcountBoxescontainerRangebodyRangeentryPropertyfixTagsOnTheFlylistCommandInsertItemappliesrunInsertCheckboxItemappliesrunatItemLineIndentItemappliesrunOutdentItemappliesrunIndentItemTreeappliesrunOutdentItemTreeappliesrunMoveItemUpappliesrunMoveItemDownappliesrunToggleCheckboxappliesrun
1import Foundation
2
3// Plain list commands from org-list.el (Org 9.8.7) on `EmacsBuffer`, matched to Emacs by the
4// oracle tests. The ORDERED property, radio lists and timer items are not handled.
5
6extension EmacsBuffer {
7 /// `org-insert-item`. False when point isn't in an item.
8 func insertListItem(checkbox: Bool) -> Bool {
9 guard let start = inItem() else { return false }
10 let pos = point
11 var structure = saveExcursion { point = start; return listStruct() }
12 let prevs = structure.prevs
13 let description = structure.listType(start, prevs) == .descriptive ? " :: " : nil
14 structure = insertItem(at: pos, structure, prevs, checkbox: checkbox, afterBullet: description, splitLine: options.metaReturnMaySplitLine)
15 writeStruct(&structure, structure.parents)
16 if checkbox { updateCheckboxCount() }
17 forwardLine(0)
18 let match = looking(Self.fullItemPattern)!
19 if let tag = match[4], substring(match[1]!.lowerBound, match[1]!.upperBound).contains(where: { $0 == "." || $0 == ")" }) {
20 point = tag.lowerBound
21 } else {
22 point = match[0]!.upperBound
23 }
24 if description != nil { point -= 1 }
25 return true
26 }
27
28 /// `org-list-indent-item-generic` without a region.
29 func indentItem(_ direction: Int, subtree: Bool) throws {
30 guard atItem else { throw UserError(message: "Not at an item") }
31 var structure = listStruct()
32 try saveExcursion {
33 let top = structure.top
34 let parents = structure.parents
35 let prevs = structure.prevs
36 let line = lineStart(point)
37 let special = top == line
38 if special, !subtree { throw UserError(message: "At first item: use S-M-<left/right> to move the whole list") }
39 let begin = line
40 let end = special ? structure.bottom : !subtree ? line + 1 : structure.end(line)
41 if special {
42 let offset = direction < 0 ? -1 : 1
43 let topInd = structure.ind(begin)
44 let old = structure
45 guard topInd + offset >= 0 else { throw UserError(message: "Cannot outdent beyond margin") }
46 if topInd + offset == 0, structure.bullet(begin).contains("*") {
47 structure.set(begin) { $0.bullet = bulletString("-") }
48 }
49 for i in structure.indices { structure[i].ind += offset }
50 fixBullets(&structure, prevs)
51 applyStruct(structure, old)
52 } else if direction < 0,
53 (!subtree && structure.hasChild(begin) != nil)
54 || structure.hasChild(structure.last { $0.pos < end }!.pos) != nil {
55 throw UserError(message: "Cannot outdent an item without its children")
56 } else {
57 let old = structure
58 let newParents = direction < 0
59 ? try structOutdent(begin, end, structure, parents)
60 : try structIndent(begin, end, structure, parents, prevs)
61 writeStruct(&structure, newParents, old: old)
62 updateCheckboxCount()
63 }
64 }
65 }
66
67 /// `org-move-item-down` and `org-move-item-up`.
68 func moveItem(down: Bool) throws {
69 guard atItem else { throw UserError(message: "Not at an item") }
70 let column = currentColumn
71 let item = lineStart(point)
72 var structure = listStruct()
73 let prevs = structure.prevs
74 if down {
75 guard let next = structure.nextItem(item, prevs) else { throw UserError(message: "Cannot move this item further down") }
76 structure = swapItems(item, next, structure)
77 point = structure.nextItem(item, structure.prevs)!
78 } else {
79 guard let previous = structure.previousItem(item, prevs) else { throw UserError(message: "Cannot move this item further up") }
80 structure = swapItems(previous, item, structure)
81 }
82 writeStruct(&structure, structure.parents)
83 moveToColumn(column)
84 }
85
86 /// `org-toggle-checkbox` on the item at point.
87 func toggleCheckbox() throws {
88 guard atItem else { throw UserError(message: "Not at an item or heading, and no active region") }
89 saveExcursion {
90 let lower = lineStart(point)
91 let upper = marker(lineEnd(point))
92 defer { release(upper) }
93 point = lower
94 let current = looking(itemPattern).flatMap { match -> String? in
95 let box = saveExcursion { () -> [Range<Int>?]? in
96 point = match[0]!.upperBound
97 if let counter = looking(options.listAllowAlphabetical ? "(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)" : "(?:\\[@(?:start:)?[0-9]+\\][ \\t]*)") { point = counter[0]!.upperBound }
98 return looking("(\\[[- X]\\])[ \\t]+")
99 }
100 return box.map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
101 }
102 let reference = current == "[X]" ? "[ ]" : "[X]"
103 point = lower
104 while point < upper.position, searchForward(itemBeginningPattern, bound: upper.position, move: true) != nil {
105 var structure = listStruct()
106 let old = structure
107 let parents = structure.parents
108 let bottom = marker(structure.bottom)
109 for i in structure.indices where structure[i].pos >= lower && structure[i].pos <= upper.position {
110 if structure[i].checkbox != nil { structure[i].checkbox = reference }
111 }
112 fixCheckboxes(&structure, parents)
113 point = bottom.position
114 release(bottom)
115 applyStruct(structure, old)
116 }
117 }
118 updateCheckboxCount()
119 }
120
121 // MARK: - Statistics
122
123 /// `org-update-checkbox-count` for the current section.
124 func updateCheckboxCount() {
125 saveExcursion {
126 let end = saveExcursion { () -> Int in outlineNextHeading(); return point }
127 if beforeFirstHeading { point = 0 } else { _ = try? backToHeading() }
128 let sectionStart = point
129 let section = OrgParser.parse(substring(sectionStart, end))
130 let cookies = section.root.descendants().filter { $0.kind == .statisticsCookie }.map { node in
131 (range: (node.range.lowerBound + sectionStart)..<(node.range.upperBound + sectionStart), node: node)
132 }
133 let cookieData = entryProperty("COOKIE_DATA", sectionStart: sectionStart, end: end) ?? ""
134 let recursive = cookieData.range(of: "\\brecursive\\b", options: .regularExpression) != nil
135 var found: [(begin: Int, end: Int, percent: Bool, checked: Int, total: Int)] = []
136 var cache: [Int: (Int, Int)] = [:]
137 let cookiePattern = "((\\[[0-9]*%\\])|(\\[[0-9]*/[0-9]*\\]))"
138 while let match = searchForward(cookiePattern, bound: end) {
139 let last = match[0]!.upperBound - 1
140 guard let cookie = cookies.first(where: { $0.range.contains(last) }), cookieData.range(of: "\\btodo\\b", options: .regularExpression) == nil else { continue }
141 let container = containerRange(of: cookie.node, sectionStart: sectionStart)
142 let begin = container?.contents.lowerBound ?? saveExcursion { () -> Int in
143 point = match[0]!.lowerBound
144 outlinePreviousHeading()
145 return point
146 }
147 let count: (Int, Int)
148 if let cached = cache[begin] {
149 count = cached
150 } else {
151 let limit = container?.contents.upperBound ?? end
152 var structures: [ListStruct] = []
153 saveExcursion {
154 point = begin
155 let boxPattern = "^[ \\t]*([-+*]|([0-9]+|[A-Za-z])[.)])[ \\t]+(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)?(\\[[- X]\\])"
156 while searchForward(boxPattern, bound: limit) != nil {
157 guard atItem else { continue }
158 let structure = listStruct()
159 structures.append(structure)
160 point = min(max(structure.bottom, point), limit)
161 }
162 }
163 count = countBoxes(item: container?.item, structures, recursive: recursive)
164 cache[begin] = count
165 }
166 found.insert((match[1]!.lowerBound, match[1]!.upperBound, match[2] != nil, count.0, count.1), at: 0)
167 }
168 for cookie in found {
169 point = cookie.begin
170 let text = cookie.percent ? "[\(Int((100.0 * Double(cookie.checked) / Double(max(1, cookie.total))).rounded(.down)))%]" : "[\(cookie.checked)/\(cookie.total)]"
171 insert(text)
172 deleteRegion(point, point + (cookie.end - cookie.begin))
173 fixTagsOnTheFly()
174 }
175 }
176 }
177
178 /// Checked and total boxes, as `count-boxes` in `org-update-checkbox-count`.
179 private func countBoxes(item: Int?, _ structures: [ListStruct], recursive: Bool) -> (Int, Int) {
180 var on = 0, all = 0
181 for structure in structures {
182 let items: [Int]
183 if recursive, let item {
184 items = structure.subtree(item)
185 } else if recursive {
186 items = structure.map(\.pos)
187 } else if let item {
188 items = structure.children(item, structure.parents)
189 } else {
190 items = structure.allItems(structure.top, structure.prevs)
191 }
192 let boxes = items.compactMap { structure.checkbox($0) }
193 all += boxes.count
194 on += boxes.filter { $0 == "[X]" }.count
195 }
196 return (on, all)
197 }
198
199 /// The innermost container org counts a statistics cookie over: its contents range, and its
200 /// start when it is an item.
201 private func containerRange(of cookie: SyntaxNode, sectionStart: Int) -> (contents: Range<Int>, item: Int?)? {
202 var node = cookie.parent
203 while let current = node {
204 let start = current.range.lowerBound + sectionStart
205 let end = current.range.upperBound + sectionStart
206 switch current.kind {
207 case .item:
208 let item = start
209 let structure = saveExcursion { point = item; return listStruct() }
210 let itemEnd = structure.item(item)?.end ?? end
211 let contentsStart = saveExcursion { () -> Int? in
212 point = item
213 let match = looking(Self.fullItemPattern)!
214 let bullet = substring(match[1]!.lowerBound, match[1]!.upperBound)
215 point = match[4] != nil && bullet.contains(where: { $0 == "." || $0 == ")" }) ? match[4]!.lowerBound : match[0]!.upperBound
216 while point < itemEnd, let c = character(at: point), Self.whitespace.contains(c) { point += 1 }
217 if point == itemEnd { return nil }
218 return bolp ? lineStart(point) : point
219 }
220 guard let contentsStart else { return (item..<item, item) }
221 let contentsEnd = saveExcursion { () -> Int in
222 point = itemEnd
223 skipBackward(Self.whitespace)
224 return min(lineEnd(point) + 1, length)
225 }
226 return (contentsStart..<contentsEnd, item)
227 case .drawer, .dynamicBlock:
228 return (bodyRange(start, end), nil)
229 case .block:
230 let firstLine = substring(start, lineEnd(start)).lowercased()
231 let name = firstLine.range(of: "#\\+begin_(\\S+)", options: .regularExpression).map { String(firstLine[$0].dropFirst(8)) } ?? ""
232 if !["src", "example", "export", "comment"].contains(name) { return (bodyRange(start, end), nil) }
233 default:
234 break
235 }
236 node = current.parent
237 }
238 return nil
239 }
240
241 /// Lines between a container's first and last line.
242 private func bodyRange(_ start: Int, _ end: Int) -> Range<Int> {
243 let contentsStart = lineEnd(start) + 1
244 var last = end
245 if character(at: last - 1) == Self.newline { last -= 1 }
246 let closing = saveExcursion { () -> Int in
247 point = last
248 skipBackward(Self.whitespace)
249 return lineStart(point)
250 }
251 return contentsStart..<max(contentsStart, closing)
252 }
253
254 /// A property of the entry from its property drawer, without inheritance.
255 private func entryProperty(_ name: String, sectionStart: Int, end: Int) -> String? {
256 let text = substring(sectionStart, end)
257 let pattern = "^[ \\t]*:" + NSRegularExpression.escapedPattern(for: name) + ":[ \\t]+(.*?)[ \\t]*$"
258 guard let range = text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) else { return nil }
259 return String(text[range]).replacingOccurrences(of: "^[ \\t]*:[^:]+:[ \\t]+", with: "", options: .regularExpression)
260 }
261
262 /// `org-fix-tags-on-the-fly`.
263 private func fixTagsOnTheFly() {
264 let line = saveExcursion { () -> [Range<Int>?]? in
265 beginningOfLine()
266 return looking(SetTags.tagLine)
267 }
268 guard let tags = line?[1], point < tags.lowerBound else { return }
269 alignTags(onLineAt: point)
270 }
271}
272
273private func listCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
274 commitBuffer(context, body)
275}
276
277/// M-RET in a list: `org-insert-item`.
278public struct InsertItem: OrgCommand {
279 public init() {}
280 public var id: String { "org.item.insert" }
281 public var title: String { "Insert Item" }
282 public func applies(in context: EditContext) -> Bool { KeyContext.holds("item", in: context) }
283 public func run(in context: EditContext) -> CommandStep {
284 listCommand(context) { buffer in
285 guard buffer.insertListItem(checkbox: false) else { throw EmacsBuffer.UserError(message: "Not in an item") }
286 }
287 }
288}
289
290/// M-S-RET in a list: `org-insert-item` with a checkbox.
291public struct InsertCheckboxItem: OrgCommand {
292 public init() {}
293 public var id: String { "org.item.insert-checkbox" }
294 public var title: String { "Insert Checkbox Item" }
295 public func applies(in context: EditContext) -> Bool { KeyContext.holds("item", in: context) }
296 public func run(in context: EditContext) -> CommandStep {
297 listCommand(context) { buffer in
298 guard buffer.insertListItem(checkbox: true) else { throw EmacsBuffer.UserError(message: "Not in an item") }
299 }
300 }
301}
302
303/// An item command that needs point on an item's first line.
304private func atItemLine(_ context: EditContext) -> Bool {
305 EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options).atItem
306}
307
308/// M-right on an item: `org-indent-item`.
309public struct IndentItem: OrgCommand {
310 public init() {}
311 public var id: String { "org.item.indent" }
312 public var title: String { "Indent Item" }
313 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
314 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.indentItem(1, subtree: false) } }
315}
316
317/// M-left on an item: `org-outdent-item`.
318public struct OutdentItem: OrgCommand {
319 public init() {}
320 public var id: String { "org.item.outdent" }
321 public var title: String { "Outdent Item" }
322 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
323 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.indentItem(-1, subtree: false) } }
324}
325
326/// M-S-right on an item: `org-indent-item-tree`.
327public struct IndentItemTree: OrgCommand {
328 public init() {}
329 public var id: String { "org.item.indent-tree" }
330 public var title: String { "Indent Item and Children" }
331 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
332 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.indentItem(1, subtree: true) } }
333}
334
335/// M-S-left on an item: `org-outdent-item-tree`.
336public struct OutdentItemTree: OrgCommand {
337 public init() {}
338 public var id: String { "org.item.outdent-tree" }
339 public var title: String { "Outdent Item and Children" }
340 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
341 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.indentItem(-1, subtree: true) } }
342}
343
344/// M-up on an item: `org-move-item-up`.
345public struct MoveItemUp: OrgCommand {
346 public init() {}
347 public var id: String { "org.item.up" }
348 public var title: String { "Move Item Up" }
349 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
350 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.moveItem(down: false) } }
351}
352
353/// M-down on an item: `org-move-item-down`.
354public struct MoveItemDown: OrgCommand {
355 public init() {}
356 public var id: String { "org.item.down" }
357 public var title: String { "Move Item Down" }
358 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
359 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.moveItem(down: true) } }
360}
361
362/// C-c C-c on an item: `org-toggle-checkbox`.
363public struct ToggleCheckbox: OrgCommand {
364 public init() {}
365 public var id: String { "org.checkbox.toggle" }
366 public var title: String { "Toggle Checkbox" }
367 public func applies(in context: EditContext) -> Bool { atItemLine(context) }
368 public func run(in context: EditContext) -> CommandStep { listCommand(context) { try $0.toggleCheckbox() } }
369}