krz/orgstar

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

Sources/OrgCore/Semantic/DocumentModel.swift

296 lines · 14217 bytes

17 symbols in this file
  1import Foundation
  2
  3/// Typed facts read from one parsed document. Built once per tree; commands and the index read
  4/// this rather than raw text.
  5public struct DocumentModel: Sendable {
  6    public let settings: SemanticSettings
  7    public let orgSettings: OrgSettings
  8    public private(set) var headings: [HeadingInfo] = []
  9    public private(set) var fileTags: [String] = []
 10    /// `#+PROPERTY:` lines in order.
 11    public private(set) var fileProperties: [Property] = []
 12    public private(set) var tables: [TableModel] = []
 13    public private(set) var srcBlocks: [SrcBlockInfo] = []
 14    /// Link targets before the first heading, as `HeadingInfo.links` has a heading's.
 15    public private(set) var links: [String] = []
 16
 17    /// Always inherited, whatever the inheritance setting.
 18    static let alwaysInherited: Set<String> = ["CATEGORY", "ARCHIVE", "COLUMNS", "LOGGING"]
 19    /// Identify one heading, so never inherited.
 20    static let neverInherited: Set<String> = ["ID", "CUSTOM_ID"]
 21
 22    private typealias PendingBlock = (node: SyntaxNode, heading: Int?, affiliated: [SyntaxNode])
 23
 24    public init(tree: OrgTree, settings: SemanticSettings = .default) {
 25        self.settings = settings
 26        self.orgSettings = tree.settings
 27        for (key, value) in SetupFile.keywords(tree.settings.setupKeywords) { fileKeyword(key, value.trimmingCharacters(in: .whitespaces)) }
 28        var pending: [PendingBlock] = []
 29        walk(tree.root, heading: nil, pending: &pending)
 30        // Header arguments depend on file and heading properties, so blocks resolve last.
 31        srcBlocks = pending.map { srcBlock($0.node, heading: $0.heading, affiliated: $0.affiliated) }
 32    }
 33
 34    // MARK: - Queries
 35
 36    /// Ancestor indexes of a heading, outermost first.
 37    public func ancestors(of index: Int) -> [Int] {
 38        var result: [Int] = []
 39        var current = headings[index].parent
 40        while let parent = current {
 41            result.insert(parent, at: 0)
 42            current = headings[parent].parent
 43        }
 44        return result
 45    }
 46
 47    /// Titles from the outermost ancestor down to the heading itself.
 48    public func outlinePath(of index: Int) -> [String] {
 49        (ancestors(of: index) + [index]).map { headings[$0].title }
 50    }
 51
 52    public func inherits(_ key: String) -> Bool {
 53        let key = key.uppercased()
 54        if Self.neverInherited.contains(key) { return false }
 55        if Self.alwaysInherited.contains(key) || key.hasPrefix("HEADER-ARGS") { return true }
 56        switch settings.propertyInheritance {
 57        case .none: return false
 58        case .all: return true
 59        case .only(let keys): return keys.contains(key)
 60        }
 61    }
 62
 63    /// The value of `key` for a heading, or for the file when `index` is nil. Inherited keys
 64    /// start from `#+PROPERTY` and walk down the ancestors; `KEY+` lines append with a space.
 65    public func property(_ key: String, of index: Int?) -> Resolved<String>? {
 66        let key = key.uppercased()
 67        var layers: [(entries: [Property], source: ValueSource)] = []
 68        if let index {
 69            if inherits(key) {
 70                layers.append((fileProperties, .file))
 71                layers += ancestors(of: index).map { (headings[$0].properties, .heading($0)) }
 72            }
 73            layers.append((headings[index].properties, .heading(index)))
 74        } else {
 75            layers.append((fileProperties, .file))
 76        }
 77        var result: Resolved<String>?
 78        for layer in layers {
 79            for entry in layer.entries where entry.key.uppercased() == key {
 80                if entry.additive, let current = result {
 81                    result = Resolved(current.value + " " + entry.value, layer.source)
 82                } else {
 83                    result = Resolved(entry.value, layer.source)
 84                }
 85            }
 86        }
 87        return result
 88    }
 89
 90    /// Inherited tags first (file tags, then ancestors outermost first), then the heading's own.
 91    /// A tag the heading has itself counts as its own.
 92    public func tags(of index: Int) -> [Resolved<String>] {
 93        let local = headings[index].localTags
 94        var inherited: [Resolved<String>] = []
 95        if settings.tagInheritance {
 96            let layers = [(fileTags, ValueSource.file)] + ancestors(of: index).map { (headings[$0].localTags, ValueSource.heading($0)) }
 97            for (tags, source) in layers {
 98                for tag in tags where !settings.tagsExcludedFromInheritance.contains(tag)
 99                    && !local.contains(tag) && !inherited.contains(where: { $0.value == tag }) {
100                    inherited.append(Resolved(tag, source))
101                }
102            }
103        }
104        var own: [Resolved<String>] = []
105        for tag in local where !own.contains(where: { $0.value == tag }) {
106            own.append(Resolved(tag, .heading(index)))
107        }
108        return inherited + own
109    }
110
111    // MARK: - Building
112
113    private mutating func walk(_ node: SyntaxNode, heading: Int?, pending: inout [PendingBlock]) {
114        var previous: SyntaxNode?
115        var affiliated: [SyntaxNode] = []
116        for child in node.children {
117            // Blank lines between nodes detach affiliated keywords.
118            if let previous, previous.range.upperBound != child.range.lowerBound { affiliated = [] }
119            switch child.kind {
120            case .section:
121                section(child, parent: heading, pending: &pending)
122            case .heading, .affiliatedKeyword:
123                break
124            case .planning:
125                if let heading { planning(child, heading) }
126            case .propertyDrawer:
127                let properties = child.children.filter { $0.kind == .nodeProperty }.compactMap { Property(line: $0.text) }
128                // The file's own drawer comes after its #+PROPERTY lines, as org reads them.
129                if let heading { headings[heading].properties += properties } else { fileProperties += properties }
130            case .clock:
131                if let heading, let clock = clock(child) { headings[heading].clocks.append(clock) }
132            case .timestamp:
133                if let heading, let stamp = Timestamp.parse(child.text) { headings[heading].timestamps.append(stamp) }
134            case .link:
135                if let heading { headings[heading].links.append(linkTarget(child)) } else { links.append(linkTarget(child)) }
136            case .keyword:
137                fileKeyword(child)
138            case .table:
139                tables.append(TableModel(node: child, heading: heading))
140                walk(child, heading: heading, pending: &pending)
141            case .block:
142                if Self.isSrcBlock(child) { pending.append((child, heading, affiliated)) }
143            default:
144                walk(child, heading: heading, pending: &pending)
145            }
146            affiliated = child.kind == .affiliatedKeyword ? affiliated + [child] : []
147            previous = child
148        }
149    }
150
151    private mutating func section(_ node: SyntaxNode, parent: Int?, pending: inout [PendingBlock]) {
152        guard let headingNode = node.children.first(where: { $0.kind == .heading }) else { return }
153        let index = headings.count
154        let tokens = headingNode.tokens
155        let todo = tokens.first { $0.kind == .todoKeyword }?.text
156        let title = headingNode.children.first { $0.kind == .title }
157        headings.append(HeadingInfo(
158            sectionRange: node.range,
159            headingRange: headingNode.range,
160            level: tokens.first { $0.kind == .stars }?.text.count ?? 0,
161            parent: parent,
162            todo: todo,
163            isDone: todo.map(orgSettings.isDone) ?? false,
164            priority: tokens.first { $0.kind == .priority }.map { String($0.text.dropFirst(2).dropLast()) },
165            title: title?.text ?? "",
166            localTags: tokens.first { $0.kind == .tags }.map { $0.text.split(separator: ":").map(String.init) } ?? [],
167            properties: [], scheduled: nil, deadline: nil, closed: nil, clocks: [], timestamps: [], links: []
168        ))
169        if let title { walk(title, heading: index, pending: &pending) }
170        walk(node, heading: index, pending: &pending)
171    }
172
173    /// Each timestamp belongs to the keyword just before it.
174    private mutating func planning(_ node: SyntaxNode, _ heading: Int) {
175        let text = node.text
176        for stampNode in node.children where stampNode.kind == .timestamp {
177            guard let stamp = Timestamp.parse(stampNode.text) else { continue }
178            let end = String.Index(utf16Offset: stampNode.offset - node.offset, in: text)
179            let before = text[..<end].trimmingCharacters(in: .whitespaces)
180            if before.hasSuffix("SCHEDULED:") {
181                headings[heading].scheduled = stamp
182            } else if before.hasSuffix("DEADLINE:") {
183                headings[heading].deadline = stamp
184            } else if before.hasSuffix("CLOSED:") {
185                headings[heading].closed = stamp
186            }
187        }
188    }
189
190    private func clock(_ node: SyntaxNode) -> Clock? {
191        guard let stampNode = node.children.first(where: { $0.kind == .timestamp }),
192              let stamp = Timestamp.parse(stampNode.text) else { return nil }
193        var minutes: Int?
194        if let arrow = node.text.range(of: "=>") {
195            let parts = node.text[arrow.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines).split(separator: ":")
196            if parts.count == 2, let hours = Int(parts[0]), let mins = Int(parts[1]) { minutes = hours * 60 + mins }
197        }
198        return Clock(start: stamp.start, end: stamp.end, minutes: minutes)
199    }
200
201    private func linkTarget(_ node: SyntaxNode) -> String {
202        if let path = node.tokens.first(where: { $0.kind == .linkPath }) { return path.text }
203        let text = node.text
204        if text.hasPrefix("<"), text.hasSuffix(">") { return String(text.dropFirst().dropLast()) }
205        return text
206    }
207
208    private mutating func fileKeyword(_ node: SyntaxNode) {
209        let text = node.text.trimmingCharacters(in: .whitespacesAndNewlines)
210        guard let colon = text.firstIndex(of: ":") else { return }
211        fileKeyword(text[text.index(text.startIndex, offsetBy: 2)..<colon].uppercased(), text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces))
212    }
213
214    private mutating func fileKeyword(_ key: String, _ value: String) {
215        switch key {
216        case "FILETAGS":
217            fileTags += value.split(whereSeparator: { $0 == ":" || $0.isWhitespace }).map(String.init)
218        case "PROPERTY":
219            let parts = value.split(maxSplits: 1, whereSeparator: \.isWhitespace)
220            guard var name = parts.first.map(String.init) else { return }
221            let additive = name.hasSuffix("+")
222            if additive { name.removeLast() }
223            fileProperties.append(Property(key: name, value: parts.count > 1 ? String(parts[1]) : "", additive: additive))
224        default:
225            break
226        }
227    }
228
229    // MARK: - Source blocks
230
231    static func isSrcBlock(_ node: SyntaxNode) -> Bool {
232        node.text.drop { $0 == " " || $0 == "\t" }.lowercased().hasPrefix("#+begin_src")
233    }
234
235    private func srcBlock(_ node: SyntaxNode, heading: Int?, affiliated: [SyntaxNode]) -> SrcBlockInfo {
236        let raw = splitRawLines(node.text)
237        let beginLine = raw[0].content.drop { $0 == " " || $0 == "\t" }.dropFirst("#+begin_src".count)
238        let words = beginLine.split(whereSeparator: \.isWhitespace)
239        var language: String?
240        var switches: [String] = []
241        var rest = words[...]
242        if let first = rest.first, !first.hasPrefix("-"), !first.hasPrefix("+"), !first.hasPrefix(":") {
243            language = String(first)
244            rest = rest.dropFirst()
245        }
246        while let word = rest.first, !word.hasPrefix(":") {
247            switches.append(String(word))
248            rest = rest.dropFirst()
249        }
250        let body = raw.dropFirst().dropLast().map { String($0.content) + String($0.ending) }.joined()
251
252        var name: String?
253        var headerLines: [String] = []
254        for keyword in affiliated {
255            let text = keyword.text.trimmingCharacters(in: .whitespacesAndNewlines)
256            let value = SettingsScanner.keywordValue(text[...]).trimmingCharacters(in: .whitespaces)
257            if text.uppercased().hasPrefix("#+NAME:") { name = value }
258            if text.uppercased().hasPrefix("#+HEADER:") { headerLines.append(value) }
259        }
260
261        var arguments = HeaderArguments.defaults.mapValues { Resolved($0, .defaults) }
262        var keys = ["header-args"]
263        if let language { keys.append("header-args:\(language)") }
264        for key in keys {
265            for pair in headerArgumentLayers(key, heading: heading) {
266                arguments[pair.key] = Resolved(pair.value, pair.source)
267            }
268        }
269        for line in headerLines + [rest.joined(separator: " ")] {
270            for pair in HeaderArguments.parse(line) {
271                arguments[pair.key] = Resolved(pair.value, .element)
272            }
273        }
274        return SrcBlockInfo(
275            range: node.range, heading: heading, name: name, language: language,
276            switches: switches, body: body, headerArguments: arguments
277        )
278    }
279
280    /// `header-args` pairs from `#+PROPERTY` down to the heading. As in org, a plain property
281    /// replaces everything above it; `header-args+` adds to it.
282    private func headerArgumentLayers(_ key: String, heading: Int?) -> [(key: String, value: String, source: ValueSource)] {
283        var layers: [(entries: [Property], source: ValueSource)] = [(fileProperties, .file)]
284        if let heading {
285            layers += (ancestors(of: heading) + [heading]).map { (headings[$0].properties, .heading($0)) }
286        }
287        var pairs: [(key: String, value: String, source: ValueSource)] = []
288        for layer in layers {
289            for entry in layer.entries where entry.key.lowercased() == key {
290                let parsed = HeaderArguments.parse(entry.value).map { (key: $0.key, value: $0.value, source: layer.source) }
291                pairs = entry.additive ? pairs + parsed : parsed
292            }
293        }
294        return pairs
295    }
296}