Sources/OrgCore/Commands/TodoDependencies.swift
157 lines · 7423 bytes
11 symbols in this file
1import Foundation
2
3// TODO dependencies (Org 9.8.7): `org-block-todo-from-children-or-siblings-or-parent` with
4// `org-enforce-todo-dependencies`, `org-block-todo-from-checkboxes` with
5// `org-enforce-todo-checkbox-dependencies`, `org-entry-blocked-p` and
6// `org-toggle-ordered-property` with `org-track-ordered-property-with-tag` nil.
7
8extension EmacsBuffer {
9 /// `org-not-done-heading-regexp`.
10 var notDoneHeadingPattern: String? {
11 let names = settings.todoSequences.flatMap { $0.active.map(\.name) }
12 guard !names.isEmpty else { return nil }
13 return "^(\\*+)(?: +(" + names.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|") + "))(?: +(.*?))?[ \\t]*$"
14 }
15
16 /// `org-get-heading` for the heading at point.
17 func orgGetHeading() -> String {
18 saveExcursion {
19 _ = try? backToHeading()
20 guard let match = lookingAt(complexHeadingPattern) else { return "" }
21 let parts = [match[2], match[3]].map { $0.map { substring($0.lowerBound, $0.upperBound) } }
22 + [match[4].map { substring($0.lowerBound, $0.upperBound) } ?? "", match[5].map { substring($0.lowerBound, $0.upperBound) }]
23 return parts.compactMap { $0 }.joined(separator: " ")
24 }
25 }
26
27 /// Whether the heading at `heading` has a not-done keyword.
28 private func isNotDone(at heading: Int) -> Bool {
29 saveExcursion {
30 point = heading
31 return todoKeyword.map { keywordsInOrder.contains($0) && !settings.isDone($0) } ?? false
32 }
33 }
34
35 /// `ORDERED` on the entry at `heading`, as `org-not-nil` of `org-entry-get`.
36 private func isOrdered(_ heading: Int) -> Bool {
37 localProperty("ORDERED", heading: heading).map { $0 != "nil" } ?? false
38 }
39
40 /// `org-block-todo-from-children-or-siblings-or-parent` for the entry at point becoming
41 /// done: the heading that blocks it, or nil.
42 func hierarchyBlocker() -> String? {
43 saveExcursion {
44 guard let start = try? backToHeading() else { return nil }
45 let level = headingLevel(atLine: start)!
46 outlineNextHeading()
47 while !eobp, outlineLevel > level {
48 if isNotDone(at: point) { return orgGetHeading() }
49 outlineNextHeading()
50 }
51 guard let notDone = notDoneHeadingPattern else { return nil }
52 point = start
53 var pos = start
54 guard upHeadingSafe() != nil else { return nil }
55 var parent = point
56 if isOrdered(parent) {
57 forwardLine(1)
58 if let match = searchForward(notDone, bound: pos, caseInsensitive: false) { return substring(match[0]!.lowerBound, match[0]!.upperBound) }
59 }
60 // An ancestor blocked by its own ordered siblings blocks its subtree.
61 while true {
62 point = parent
63 guard lookingAt(notDone) != nil else { return nil }
64 pos = point
65 guard upHeadingSafe() != nil else { return nil }
66 parent = point
67 if isOrdered(parent) {
68 forwardLine(1)
69 if searchForward(notDone, bound: pos, caseInsensitive: false) != nil { return orgGetHeading() }
70 }
71 }
72 }
73 }
74
75 /// `org-block-todo-from-checkboxes`: an unchecked or partly checked box in the entry's
76 /// own text, outside blocks lists can't be in.
77 func hasOpenCheckbox() -> Bool {
78 saveExcursion {
79 guard let start = try? backToHeading() else { return false }
80 outlineNextHeading()
81 let end = point
82 point = start
83 let pattern = itemBeginningPattern + "(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)?\\[[- ]\\]"
84 while searchForward(pattern, bound: end, caseInsensitive: false) != nil {
85 if !inBlock(Self.forbiddenBlocks) { return true }
86 }
87 return false
88 }
89 }
90
91 /// `org-in-block-p`: point is in a block of one of the types, between the headings around it.
92 func inBlock(_ names: [String]) -> Bool {
93 saveExcursion {
94 let pos = point
95 let up = saveExcursion { () -> Int in outlinePreviousHeading(); return point }
96 let down = saveExcursion { () -> Int in outlineNextHeading(); return point }
97 for name in names {
98 let begin = "^[ \\t]*#\\+begin_" + NSRegularExpression.escapedPattern(for: name)
99 let end = "^[ \\t]*#\\+end_" + NSRegularExpression.escapedPattern(for: name)
100 point = pos
101 guard let open = searchBackward(begin, bound: up) else { continue }
102 let beg = open[0]!.lowerBound
103 point = open[0]!.upperBound
104 guard let close = searchForward(end, bound: down), close[0]!.upperBound > pos else { continue }
105 point = close[0]!.lowerBound
106 if searchBackward(begin, bound: beg + 1) == nil { return true }
107 }
108 return false
109 }
110 }
111
112 /// `org-blocker-hook` as `org-todo` runs it for a change from `from` to `to`, with the
113 /// checkbox hook first, as `org-mode` adds them: the message refusing the change, or nil.
114 func todoBlockedMessage(from: String?, to: String?) -> String? {
115 let checkboxes = options.enforceTodoCheckboxDependencies
116 let dependencies = options.enforceTodoDependencies
117 guard checkboxes || dependencies, let to, settings.isDone(to), !(from.map(settings.isDone) ?? false) else { return nil }
118 let heading = saveExcursion { try? backToHeading() }
119 guard let heading, localProperty("NOBLOCKING", heading: heading).map({ $0 == "nil" }) ?? true else { return nil }
120 let reason: String
121 if checkboxes, hasOpenCheckbox() {
122 reason = "contained checkboxes"
123 } else if dependencies, let blocker = hierarchyBlocker() {
124 reason = "\"\(blocker)\""
125 } else {
126 return nil
127 }
128 return "TODO state change from \(from ?? "nil") to \(to) blocked (by \(reason))"
129 }
130
131 /// Throws the refusal of a change from `from` to `to`, if it is blocked.
132 func checkTodoBlocking(from: String?, to: String?) throws {
133 if let message = todoBlockedMessage(from: from, to: to) { throw UserError(message: message) }
134 }
135}
136
137/// C-c C-x o: `org-toggle-ordered-property`.
138public struct ToggleOrderedProperty: OrgCommand {
139 public init() {}
140 public var id: String { "org.property.toggle-ordered" }
141 public var title: String { "Toggle ORDERED Property" }
142 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
143 public func run(in context: EditContext) -> CommandStep {
144 commitBuffer(context) { buffer in
145 try buffer.saveExcursion {
146 let heading = try buffer.backToHeading()
147 if buffer.localProperty("ORDERED", heading: heading).map({ $0 != "nil" }) ?? false {
148 buffer.entryDelete("ORDERED")
149 buffer.message = "Subtasks can be completed in arbitrary order"
150 } else {
151 buffer.entryPut("ORDERED", "t")
152 buffer.message = "Subtasks must be completed in sequence"
153 }
154 }
155 }
156 }
157}