krz/orgstar

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

Sources/OrgCore/Commands/TodoDependencies.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/OrgCore/Commands/TodoDependencies.swift history · blame · raw

157 lines · 7423 bytes

  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}