krz/orgstar

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

Sources/OrgCore/Commands/Statistics.swift

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

137 lines · 6640 bytes

  1import Foundation
  2
  3// TODO statistics cookies (`org-update-parent-todo-statistics`, Org 9.8.7) with
  4// `org-provide-todo-statistics` t and `org-hierarchical-todo-statistics` t.
  5
  6extension EmacsBuffer {
  7    /// A property of the entry at `heading` from its property drawer, or nil.
  8    func localProperty(_ name: String, heading: Int) -> String? {
  9        saveExcursion {
 10            point = heading
 11            forwardLine(1)
 12            if lookingAt(planningPattern) != nil { forwardLine(1) }
 13            guard lookingAt("^[ \\t]*:PROPERTIES:[ \\t]*$") != nil else { return nil }
 14            forwardLine(1)
 15            while !eobp, lookingAt("^[ \\t]*:END:[ \\t]*$") == nil {
 16                if let match = lookingAt("^[ \\t]*:([^:\\s]+):(?:[ \\t]+(.*?))?[ \\t]*$"),
 17                   substring(match[1]!.lowerBound, match[1]!.upperBound).caseInsensitiveCompare(name) == .orderedSame {
 18                    return match[2].map { substring($0.lowerBound, $0.upperBound) } ?? ""
 19                }
 20                if forwardLine(1) != 0 { break }
 21            }
 22            return nil
 23        }
 24    }
 25
 26    var planningPattern: String { "^[ \\t]*(?:SCHEDULED|DEADLINE|CLOSED):" }
 27
 28    /// A property with inheritance: the entry, then its ancestors. Returns the value and the
 29    /// heading it came from.
 30    func inheritedProperty(_ name: String) -> (value: String, from: Int)? {
 31        saveExcursion {
 32            guard (try? backToHeading()) != nil else { return nil }
 33            while true {
 34                if let value = localProperty(name, heading: point) { return (value, point) }
 35                let level = outlineLevel
 36                guard level > 1, let parent = saveExcursion({ () -> Int? in
 37                    while let previous = previousHeadingStart(before: point) {
 38                        point = previous
 39                        if headingLevel(atLine: previous)! < level { return previous }
 40                    }
 41                    return nil
 42                }) else { return nil }
 43                point = parent
 44            }
 45        }
 46    }
 47
 48    /// `org-up-heading-safe`: the parent heading's level, with point on it, or nil.
 49    @discardableResult
 50    func upHeadingSafe() -> Int? {
 51        guard let start = try? backToHeading() else { return nil }
 52        let level = headingLevel(atLine: start)!
 53        var at = start
 54        while let previous = previousHeadingStart(before: at) {
 55            at = previous
 56            if let parentLevel = headingLevel(atLine: previous), parentLevel < level {
 57                point = previous
 58                return parentLevel
 59            }
 60        }
 61        point = start
 62        return nil
 63    }
 64
 65    /// `org-update-parent-todo-statistics` for the heading at point.
 66    func updateParentTodoStatistics() {
 67        let property = saveExcursion { () -> (value: String, from: Int)? in
 68            upHeadingSafe()
 69            return inheritedProperty("COOKIE_DATA")
 70        }
 71        let recursive = property.map { $0.value.range(of: "\\brecursive\\b", options: .regularExpression) != nil } ?? false
 72        let limit = property?.from ?? 0
 73        var first = true
 74        saveExcursion {
 75            beginningOfLine()
 76            let toggleLevel = outlineLevel
 77            while let level = upHeadingSafe(), recursive || first, point >= limit {
 78                first = false
 79                let data = (localProperty("COOKIE_DATA", heading: point) ?? "").lowercased()
 80                if data.range(of: "\\bcheckbox\\b", options: .regularExpression) != nil { return }
 81                let headingStart = point
 82                while let match = searchForward("((\\[[0-9]*%\\])|(\\[[0-9]*/[0-9]*\\]))", bound: lineEnd(point)) {
 83                    let cookieStart = match[0]!.lowerBound
 84                    let isPercent = match[2] != nil
 85                    guard isStatisticsCookie(at: cookieStart, headingStart: headingStart) else { continue }
 86                    var all = 0, done = 0
 87                    let after = point
 88                    point = headingStart
 89                    guard outlineNextHeading() else { return }
 90                    while let heading = lookingAt(complexHeadingPattern), let l1 = heading[1].map({ $0.count }), l1 > level {
 91                        let keyword = (recursive || l1 == toggleLevel) ? heading[2].map { substring($0.lowerBound, $0.upperBound) } : nil
 92                        if let keyword {
 93                            all += 1
 94                            if settings.isDone(keyword) { done += 1 }
 95                        }
 96                        outlineNextHeading()
 97                    }
 98                    point = after
 99                    let new = isPercent ? "[\(Int((100.0 * Double(done) / Double(max(1, all))).rounded(.down)))%]" : "[\(done)/\(all)]"
100                    if new != substring(cookieStart, match[0]!.upperBound) {
101                        let length = match[0]!.upperBound - cookieStart
102                        point = cookieStart
103                        insert(new)
104                        deleteRegion(point, point + length)
105                        let tags = saveExcursion { () -> [Range<Int>?]? in
106                            beginningOfLine()
107                            return lookingAt(SetTags.tagLine)
108                        }
109                        if let tagsRange = tags?[1], point < tagsRange.lowerBound { alignTags(onLineAt: point) }
110                    }
111                }
112                point = headingStart
113            }
114        }
115    }
116
117    /// Whether the cookie text at `offset` on a heading line parses as a statistics cookie.
118    private func isStatisticsCookie(at offset: Int, headingStart: Int) -> Bool {
119        let line = substring(headingStart, lineEnd(headingStart)) + "\n"
120        let tree = OrgParser.parse(line, defaults: settings)
121        return tree.root.descendants().contains { $0.kind == .statisticsCookie && $0.range.contains(offset - headingStart) }
122    }
123}
124
125/// Applies a command's result to a buffer, runs `then` on it, and commits the combined change:
126/// for commands org follows with more edits, such as statistics updates.
127func followUp(_ context: EditContext, _ step: CommandStep, _ then: (EmacsBuffer) throws -> Void) -> CommandStep {
128    guard case .commit(let result) = step, !result.edits.isEmpty else { return step }
129    let caret = result.selection?.first?.lowerBound ?? context.caret
130    return commitBuffer(context) { buffer in
131        for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
132            buffer.replace(edit.range, with: edit.replacement)
133        }
134        buffer.point = caret
135        try then(buffer)
136    }
137}