krz/orgstar

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

Sources/OrgCore/Commands/ListCommands.swift

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

369 lines · 18089 bytes

  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}