Sources/OrgCore/Parser/Settings.swift
170 lines · 7414 bytes
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 /// `<<<radio target>>>` texts in the file, longest first: their words elsewhere are links.
47 public var radioTargets: [String] = []
48 /// `org-list-allow-alphabetical`: `a.` and `A)` are bullets.
49 public var listAllowAlphabetical = false
50 /// Keyword lines from the file's `#+SETUPFILE`s, as `#+KEY: value` lines: they count as
51 /// the file's own, before them.
52 public var setupKeywords = ""
53 /// Words of the `#+STARTUP` keyword lines outside blocks, setup files' first.
54 public var startup: [String] = []
55
56 public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) {
57 self.todoSequences = todoSequences
58 self.priorities = priorities
59 self.radioTargets = radioTargets
60 }
61
62 public static let `default` = OrgSettings(
63 todoSequences: [TodoSequence(kind: .sequence, active: [TodoKeyword(name: "TODO")], done: [TodoKeyword(name: "DONE")])],
64 priorities: Priorities(highest: "A", lowest: "C", default: "B")
65 )
66
67 public var todoKeywordNames: Set<String> {
68 Set(todoSequences.flatMap { ($0.active + $0.done).map(\.name) })
69 }
70
71 /// These settings with TODO keywords from `#+TODO:` syntax (`TODO NEXT | DONE`), one
72 /// sequence per line; nil when there are no keywords.
73 public func withTodoKeywords(_ lines: String) -> OrgSettings? {
74 let sequences = lines.split(separator: "\n").compactMap { SettingsScanner.todoSequence($0, kind: .sequence) }
75 guard !sequences.isEmpty else { return nil }
76 var copy = self
77 copy.todoSequences = sequences
78 return copy
79 }
80
81 public func isDone(_ name: String) -> Bool {
82 todoSequences.contains { $0.done.contains { $0.name == name } }
83 }
84}
85
86enum SettingsScanner {
87 /// Reads `#+TODO`, `#+SEQ_TODO`, `#+TYP_TODO`, `#+PRIORITIES` and `#+STARTUP` outside blocks. Any TODO
88 /// line replaces the default sequences; as in org, `TYP_TODO` sequences come first, then
89 /// `TODO`, then `SEQ_TODO`.
90 static func scan(lines: [RawLine], info: [ClassifiedLine], blockEnds: [Int: Int], defaults: OrgSettings) -> OrgSettings {
91 var types: [TodoSequence] = [], todos: [TodoSequence] = [], seqs: [TodoSequence] = []
92 var priorities = defaults.priorities
93 var startup: [String] = []
94 func apply(_ key: String, _ value: Substring) {
95 switch key {
96 case "TODO":
97 if let s = todoSequence(value, kind: .sequence) { todos.append(s) }
98 case "SEQ_TODO":
99 if let s = todoSequence(value, kind: .sequence) { seqs.append(s) }
100 case "TYP_TODO":
101 if let s = todoSequence(value, kind: .type) { types.append(s) }
102 case "STARTUP":
103 startup += value.split(whereSeparator: \.isWhitespace).map(String.init)
104 case "PRIORITIES":
105 let words = value.split(whereSeparator: \.isWhitespace)
106 if words.count == 3 {
107 priorities = Priorities(highest: String(words[0]), lowest: String(words[1]), default: String(words[2]))
108 }
109 default:
110 break
111 }
112 }
113 for (key, value) in SetupFile.keywords(defaults.setupKeywords) { apply(key, value) }
114 var k = 0
115 while k < lines.count {
116 switch info[k].cls {
117 case .blockBegin, .dynamicBegin:
118 if let end = blockEnds[k] { k = end }
119 case .keyword(let key):
120 apply(key, keywordValue(lines[k].content))
121 default:
122 break
123 }
124 k += 1
125 }
126 let sequences = types + todos + seqs
127 var radio: [String] = []
128 for line in lines where line.content.contains("<<<") {
129 for m in String(line.content).matches(of: /<<<([^<>\n\r \t](?:[^<>\n\r]*[^<>\n\r \t])?)>>>/) where !radio.contains(String(m.1)) {
130 radio.append(String(m.1))
131 }
132 }
133 var settings = OrgSettings(todoSequences: sequences.isEmpty ? defaults.todoSequences : sequences, priorities: priorities,
134 radioTargets: radio.sorted { $0.count > $1.count })
135 settings.listAllowAlphabetical = defaults.listAllowAlphabetical
136 settings.setupKeywords = defaults.setupKeywords
137 settings.startup = startup
138 return settings
139 }
140
141 static func keywordValue(_ line: Substring) -> Substring {
142 guard let colon = line.firstIndex(of: ":") else { return "" }
143 return line[line.index(after: colon)...]
144 }
145
146 static func todoSequence(_ value: Substring, kind: TodoSequence.Kind) -> TodoSequence? {
147 let words = value.split(whereSeparator: \.isWhitespace)
148 guard !words.isEmpty else { return nil }
149 if let bar = words.firstIndex(of: "|") {
150 return TodoSequence(kind: kind, active: words[..<bar].map(todoKeyword), done: words[(bar + 1)...].map(todoKeyword))
151 }
152 return TodoSequence(kind: kind, active: words.dropLast().map(todoKeyword), done: [todoKeyword(words.last!)])
153 }
154
155 /// `NAME`, or `NAME(spec)` where spec is an optional fast key followed by `enter/leave`
156 /// logging flags.
157 static func todoKeyword(_ word: Substring) -> TodoKeyword {
158 guard let open = word.firstIndex(of: "("), word.last == ")" else { return TodoKeyword(name: String(word)) }
159 var spec = word[word.index(after: open)..<word.index(before: word.endIndex)]
160 var fastKey: Character?
161 if let first = spec.first, first != "!", first != "@", first != "/" {
162 fastKey = first
163 spec = spec.dropFirst()
164 }
165 let parts = spec.split(separator: "/", omittingEmptySubsequences: false)
166 let enter = parts.first.flatMap { $0.isEmpty ? nil : String($0) }
167 let leave = parts.count > 1 && !parts[1].isEmpty ? String(parts[1]) : nil
168 return TodoKeyword(name: String(word[..<open]), fastKey: fastKey, logOnEnter: enter, logOnLeave: leave)
169 }
170}