Sources/OrgCore/Commands/Statistics.swift
137 lines · 6640 bytes
6 symbols in this file
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}