Commit 73fb4b2c2c
Verified · cmc
Layout: unified · split
Sources/OrgCore/Parser/Settings.swift added +129
| @@ -0,0 +1,129 @@ | |||
| 1 | public 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 | |||
| 17 | public 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 | |||
| 31 | public 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 | |||
| 43 | public 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 | |||
| 66 | enum 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 @@ | |||
| 1 | import Testing | ||
| 2 | @testable import OrgCore | ||
| 3 | |||
| 4 | struct 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 | } | ||