krz/orgstar

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

Commit 73fb4b2c2c

73fb4b2c2c0ea0b4bd2f4911ede4567b2674ce1f

parent: 5fe7e78dfd

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-04 23:16 UTC

Add in-buffer TODO and priority settings

Layout: unified · split

Sources/OrgCore/Parser/Settings.swift added +129
@@ -0,0 +1,129 @@
1public struct TodoKeyword: Sendable, Hashable {
2 public var name: String
3 public var fastKey: Character?
4 /// Logging flag when entering the state (`!` or `@`), from `NAME(k!/@)`.
5 public var logOnEnter: String?
6 /// Logging flag when leaving the state.
7 public var logOnLeave: String?
8
9 public init(name: String, fastKey: Character? = nil, logOnEnter: String? = nil, logOnLeave: String? = nil) {
10 self.name = name
11 self.fastKey = fastKey
12 self.logOnEnter = logOnEnter
13 self.logOnLeave = logOnLeave
14 }
15}
16
17public struct TodoSequence: Sendable, Equatable {
18 public enum Kind: Sendable, Equatable { case sequence, type }
19
20 public var kind: Kind
21 public var active: [TodoKeyword]
22 public var done: [TodoKeyword]
23
24 public init(kind: Kind, active: [TodoKeyword], done: [TodoKeyword]) {
25 self.kind = kind
26 self.active = active
27 self.done = done
28 }
29}
30
31public struct Priorities: Sendable, Equatable {
32 public var highest: String
33 public var lowest: String
34 public var `default`: String
35
36 public init(highest: String, lowest: String, default: String) {
37 self.highest = highest
38 self.lowest = lowest
39 self.default = `default`
40 }
41}
42
43public struct OrgSettings: Sendable, Equatable {
44 public var todoSequences: [TodoSequence]
45 public var priorities: Priorities
46
47 public init(todoSequences: [TodoSequence], priorities: Priorities) {
48 self.todoSequences = todoSequences
49 self.priorities = priorities
50 }
51
52 public static let `default` = OrgSettings(
53 todoSequences: [TodoSequence(kind: .sequence, active: [TodoKeyword(name: "TODO")], done: [TodoKeyword(name: "DONE")])],
54 priorities: Priorities(highest: "A", lowest: "C", default: "B")
55 )
56
57 public var todoKeywordNames: Set<String> {
58 Set(todoSequences.flatMap { ($0.active + $0.done).map(\.name) })
59 }
60
61 public func isDone(_ name: String) -> Bool {
62 todoSequences.contains { $0.done.contains { $0.name == name } }
63 }
64}
65
66enum SettingsScanner {
67 /// Reads `#+TODO`, `#+SEQ_TODO`, `#+TYP_TODO` and `#+PRIORITIES` outside blocks. Any TODO
68 /// line replaces the default sequences, as in org.
69 static func scan(lines: [RawLine], info: [ClassifiedLine], blockEnds: [Int: Int], defaults: OrgSettings) -> OrgSettings {
70 var sequences: [TodoSequence] = []
71 var priorities = defaults.priorities
72 var k = 0
73 while k < lines.count {
74 switch info[k].cls {
75 case .blockBegin, .dynamicBegin:
76 if let end = blockEnds[k] { k = end }
77 case .keyword(let key):
78 let value = keywordValue(lines[k].content)
79 switch key {
80 case "TODO", "SEQ_TODO":
81 if let s = todoSequence(value, kind: .sequence) { sequences.append(s) }
82 case "TYP_TODO":
83 if let s = todoSequence(value, kind: .type) { sequences.append(s) }
84 case "PRIORITIES":
85 let words = value.split(whereSeparator: \.isWhitespace)
86 if words.count == 3 {
87 priorities = Priorities(highest: String(words[0]), lowest: String(words[1]), default: String(words[2]))
88 }
89 default:
90 break
91 }
92 default:
93 break
94 }
95 k += 1
96 }
97 return OrgSettings(todoSequences: sequences.isEmpty ? defaults.todoSequences : sequences, priorities: priorities)
98 }
99
100 static func keywordValue(_ line: Substring) -> Substring {
101 guard let colon = line.firstIndex(of: ":") else { return "" }
102 return line[line.index(after: colon)...]
103 }
104
105 static func todoSequence(_ value: Substring, kind: TodoSequence.Kind) -> TodoSequence? {
106 let words = value.split(whereSeparator: \.isWhitespace)
107 guard !words.isEmpty else { return nil }
108 if let bar = words.firstIndex(of: "|") {
109 return TodoSequence(kind: kind, active: words[..<bar].map(todoKeyword), done: words[(bar + 1)...].map(todoKeyword))
110 }
111 return TodoSequence(kind: kind, active: words.dropLast().map(todoKeyword), done: [todoKeyword(words.last!)])
112 }
113
114 /// `NAME`, or `NAME(spec)` where spec is an optional fast key followed by `enter/leave`
115 /// logging flags.
116 static func todoKeyword(_ word: Substring) -> TodoKeyword {
117 guard let open = word.firstIndex(of: "("), word.last == ")" else { return TodoKeyword(name: String(word)) }
118 var spec = word[word.index(after: open)..<word.index(before: word.endIndex)]
119 var fastKey: Character?
120 if let first = spec.first, first != "!", first != "@", first != "/" {
121 fastKey = first
122 spec = spec.dropFirst()
123 }
124 let parts = spec.split(separator: "/", omittingEmptySubsequences: false)
125 let enter = parts.first.flatMap { $0.isEmpty ? nil : String($0) }
126 let leave = parts.count > 1 && !parts[1].isEmpty ? String(parts[1]) : nil
127 return TodoKeyword(name: String(word[..<open]), fastKey: fastKey, logOnEnter: enter, logOnLeave: leave)
128 }
129}
Tests/OrgCoreTests/SettingsTests.swift added +48
@@ -0,0 +1,48 @@
1import Testing
2@testable import OrgCore
3
4struct SettingsTests {
5 func scan(_ text: String, blockEnds: [Int: Int] = [:]) -> OrgSettings {
6 let lines = splitRawLines(text)
7 let info = lines.map { classifyLine($0.content) }
8 return SettingsScanner.scan(lines: lines, info: info, blockEnds: blockEnds, defaults: .default)
9 }
10
11 @Test func defaultsWithoutKeywords() {
12 let settings = scan("* TODO a\n")
13 #expect(settings.todoKeywordNames == ["TODO", "DONE"])
14 #expect(settings.isDone("DONE"))
15 }
16
17 @Test func fileKeywordsReplaceDefaults() {
18 let settings = scan("#+TODO: NEXT(n) WAIT(w@/!) | DONE(d!) CANCELED(c@)\n")
19 #expect(settings.todoKeywordNames == ["NEXT", "WAIT", "DONE", "CANCELED"])
20 let sequence = settings.todoSequences[0]
21 #expect(sequence.active.map(\.name) == ["NEXT", "WAIT"])
22 #expect(sequence.done.map(\.name) == ["DONE", "CANCELED"])
23 #expect(sequence.active[1] == TodoKeyword(name: "WAIT", fastKey: "w", logOnEnter: "@", logOnLeave: "!"))
24 #expect(sequence.done[0] == TodoKeyword(name: "DONE", fastKey: "d", logOnEnter: "!", logOnLeave: nil))
25 }
26
27 @Test func lastWordIsDoneWithoutSeparator() {
28 let settings = scan("#+SEQ_TODO: A B C\n")
29 #expect(settings.todoSequences[0].active.map(\.name) == ["A", "B"])
30 #expect(settings.todoSequences[0].done.map(\.name) == ["C"])
31 }
32
33 @Test func severalLinesMakeSeveralSequences() {
34 let settings = scan("#+TODO: A | B\n#+TYP_TODO: X | Y\n")
35 #expect(settings.todoSequences.count == 2)
36 #expect(settings.todoSequences[1].kind == .type)
37 }
38
39 @Test func keywordsInsideBlocksAreIgnored() {
40 let text = "#+begin_example\n#+TODO: X | Y\n#+end_example\n"
41 #expect(scan(text, blockEnds: [0: 2]).todoKeywordNames == ["TODO", "DONE"])
42 }
43
44 @Test func priorities() {
45 #expect(scan("#+PRIORITIES: 1 10 5\n").priorities == Priorities(highest: "1", lowest: "10", default: "5"))
46 #expect(scan("").priorities == Priorities(highest: "A", lowest: "C", default: "B"))
47 }
48}