krz/orgstar

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

Sources/OrgCore/Parser/Settings.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/OrgCore/Parser/Settings.swift history · blame · raw

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}