krz/orgstar

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

Sources/OrgPresentation/Presentation.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgPresentation/Presentation.swift history · blame · raw

233 lines · 10897 bytes

  1import OrgCore
  2
  3/// What a range of text is, for styling. Platform adapters map roles to attributes.
  4public enum StyleRole: Hashable, Sendable {
  5    /// A whole heading line.
  6    case heading(level: Int)
  7    /// All stars of a heading but the last, hidden as in `org-hide-leading-stars`.
  8    case leadingStars
  9    case todo, done, priority, tags
 10    case bold, italic, underline, strikeThrough, verbatim, code
 11    /// Emphasis markers.
 12    case markup
 13    case link
 14    /// Brackets and the target of a described link.
 15    case linkMarkup
 16    case timestamp, footnote, cookie, target, macro, latex, inlineSource
 17    case comment, keyword
 18    /// Planning lines, drawers, property drawers, clock lines.
 19    case metadata
 20    case block, blockDelimiter, table, rule
 21    /// Code in a src block, by what the language's grammar says it is.
 22    case syntax(SyntaxCategory)
 23}
 24
 25/// Kinds of code token, from tree-sitter highlight capture names.
 26public enum SyntaxCategory: String, Hashable, Sendable, CaseIterable {
 27    case keyword, string, comment, function, type, number, constant, variable, property, `operator`
 28    case punctuation, attribute, tag, escape, label
 29
 30    /// The category for a capture name such as `keyword.control` or `string.special`.
 31    public init?(capture name: String) {
 32        let head = name.split(separator: ".").first.map(String.init) ?? name
 33        switch head {
 34        case "keyword", "conditional", "repeat", "include", "exception", "storageclass": self = .keyword
 35        case "string", "character": self = name.hasPrefix("string.escape") ? .escape : .string
 36        case "comment": self = .comment
 37        case "function", "method": self = .function
 38        case "type", "constructor", "module", "namespace": self = .type
 39        case "number", "float": self = .number
 40        case "constant", "boolean": self = .constant
 41        case "variable", "parameter": self = .variable
 42        case "property", "field": self = .property
 43        case "operator": self = .operator
 44        case "punctuation": self = .punctuation
 45        case "attribute", "annotation", "decorator": self = .attribute
 46        case "tag": self = .tag
 47        case "escape": self = .escape
 48        case "label": self = .label
 49        default: return nil
 50        }
 51    }
 52}
 53
 54/// Highlights source code; the platform supplies one (tree-sitter on macOS and iOS).
 55public protocol CodeHighlighter: Sendable {
 56    /// Categories for ranges of `code`, in UTF-16 offsets; later runs win over earlier ones.
 57    /// Empty for languages it doesn't know.
 58    func highlights(language: String, code: String) -> [(range: Range<Int>, category: SyntaxCategory)]
 59}
 60
 61public struct StyleRun: Sendable, Equatable {
 62    public let range: Range<Int>
 63    public let role: StyleRole
 64}
 65
 66/// org-indent: how many columns a run of whole lines starts in.
 67public struct IndentRun: Sendable, Equatable {
 68    public let range: Range<Int>
 69    public let firstLine: Int
 70    /// Continuation lines of a wrapped paragraph.
 71    public let wrapped: Int
 72}
 73
 74public enum Presentation {
 75    /// Style runs for nodes and tokens overlapping `range`, outer before inner, so applying
 76    /// them in order lets inner roles win.
 77    public static func styles(_ tree: OrgTree, in range: Range<Int>, highlighter: (any CodeHighlighter)? = nil) -> [StyleRun] {
 78        var runs: [StyleRun] = []
 79        visit(tree.root, range, tree.settings, highlighter, &runs)
 80        return runs
 81    }
 82
 83    /// Runs for the code inside a src block, from `highlighter`.
 84    static func sourceRuns(_ node: SyntaxNode, _ highlighter: any CodeHighlighter) -> [StyleRun] {
 85        let lines = node.tokens.filter { $0.kind == .text }
 86        guard lines.count >= 2, let first = lines.first, let last = lines.last,
 87              let match = first.text.firstMatch(of: /^[ \t]*#\+(?i:begin_src)[ \t]+(\S+)/) else { return [] }
 88        let language = String(match.1)
 89        let start = first.range.upperBound + 1
 90        let end = last.range.lowerBound
 91        guard end > start else { return [] }
 92        let utf16 = node.text.utf16
 93        let lower = utf16.index(utf16.startIndex, offsetBy: start - node.range.lowerBound)
 94        let upper = utf16.index(utf16.startIndex, offsetBy: end - node.range.lowerBound)
 95        let code = String(node.text[lower..<upper])
 96        return highlighter.highlights(language: language, code: code).map {
 97            StyleRun(range: ($0.range.lowerBound + start)..<($0.range.upperBound + start), role: .syntax($0.category))
 98        }
 99    }
100
101    private static func visit(_ node: SyntaxNode, _ range: Range<Int>, _ settings: OrgSettings, _ highlighter: (any CodeHighlighter)?, _ runs: inout [StyleRun]) {
102        if let role = role(of: node) { runs.append(StyleRun(range: node.range, role: role)) }
103        switch node.kind {
104        case .heading:
105            for token in node.tokens {
106                switch token.kind {
107                case .stars where token.range.count > 1:
108                    runs.append(StyleRun(range: token.range.lowerBound..<(token.range.upperBound - 1), role: .leadingStars))
109                case .todoKeyword:
110                    runs.append(StyleRun(range: token.range, role: settings.isDone(token.text) ? .done : .todo))
111                case .priority:
112                    runs.append(StyleRun(range: token.range, role: .priority))
113                case .tags:
114                    runs.append(StyleRun(range: token.range, role: .tags))
115                default:
116                    break
117                }
118            }
119        case .link:
120            let described = node.children.contains { $0.kind == .linkDescription }
121            for token in node.tokens where token.kind == .marker || (described && token.kind == .linkPath) {
122                runs.append(StyleRun(range: token.range, role: .linkMarkup))
123            }
124        case .bold, .italic, .underline, .strikeThrough, .verbatim, .code:
125            for token in node.tokens where token.kind == .marker {
126                runs.append(StyleRun(range: token.range, role: .markup))
127            }
128        case .block, .dynamicBlock:
129            let lines = node.tokens.filter { $0.kind == .text }
130            for token in [lines.first, lines.count > 1 ? lines.last : nil].compactMap({ $0 }) {
131                runs.append(StyleRun(range: token.range, role: .blockDelimiter))
132            }
133            if let highlighter { runs += sourceRuns(node, highlighter) }
134        default:
135            break
136        }
137        for child in node.children(overlapping: range) {
138            visit(child, range, settings, highlighter, &runs)
139        }
140    }
141
142    static func role(of node: SyntaxNode) -> StyleRole? {
143        switch node.kind {
144        case .heading: .heading(level: node.tokens.first { $0.kind == .stars }?.text.count ?? 1)
145        case .bold: .bold
146        case .italic: .italic
147        case .underline: .underline
148        case .strikeThrough: .strikeThrough
149        case .verbatim: .verbatim
150        case .code: .code
151        case .link: .link
152        case .timestamp: .timestamp
153        case .footnoteReference, .footnoteDefinition: .footnote
154        case .statisticsCookie: .cookie
155        case .target: .target
156        case .macro: .macro
157        case .latexFragment: .latex
158        case .inlineSourceBlock: .inlineSource
159        case .comment: .comment
160        case .keyword, .affiliatedKeyword: .keyword
161        case .planning, .propertyDrawer, .drawer, .clock: .metadata
162        case .block, .dynamicBlock, .fixedWidth: .block
163        case .table: .table
164        case .horizontalRule: .rule
165        default: nil
166        }
167    }
168
169    /// Heading lines start at column 0, with wrapped title lines under the title; a section's
170    /// own body starts one column past its stars.
171    public static func indents(_ tree: OrgTree, in range: Range<Int>) -> [IndentRun] {
172        var runs: [IndentRun] = []
173        collectIndents(tree.root, range, &runs)
174        return runs
175    }
176
177    private static func collectIndents(_ node: SyntaxNode, _ range: Range<Int>, _ runs: inout [IndentRun]) {
178        for section in node.children(overlapping: range) where section.kind == .section {
179            guard let heading = section.firstChild(.heading) else { continue }
180            let level = heading.tokens.first { $0.kind == .stars }?.text.count ?? 1
181            runs.append(IndentRun(range: heading.range, firstLine: 0, wrapped: level + 1))
182            let bodyEnd = section.firstChild(.section)?.range.lowerBound ?? section.range.upperBound
183            if heading.range.upperBound < bodyEnd {
184                runs.append(IndentRun(range: heading.range.upperBound..<bodyEnd, firstLine: level + 1, wrapped: level + 1))
185            }
186            collectIndents(section, range, &runs)
187        }
188    }
189
190    /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its
191    /// section; one in `bodyFolds` hides only its own body, up to its first child heading.
192    /// Sorted, without nested duplicates.
193    public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>, bodyFolds: Set<Int> = []) -> [Range<Int>] {
194        guard !folds.isEmpty || !bodyFolds.isEmpty else { return [] }
195        var ranges: [Range<Int>] = []
196        collectHidden(tree.root, folds, bodyFolds, &ranges)
197        return ranges
198    }
199
200    private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ bodyFolds: Set<Int>, _ ranges: inout [Range<Int>]) {
201        for section in node.children where section.kind == .section {
202            guard let heading = section.firstChild(.heading) else { continue }
203            let start = heading.range.lowerBound
204            if folds.contains(start) {
205                if heading.range.upperBound < section.range.upperBound {
206                    ranges.append(heading.range.upperBound..<section.range.upperBound)
207                }
208                continue
209            }
210            if bodyFolds.contains(start) {
211                let bodyEnd = section.firstChild(.section)?.range.lowerBound ?? section.range.upperBound
212                if heading.range.upperBound < bodyEnd { ranges.append(heading.range.upperBound..<bodyEnd) }
213            }
214            collectHidden(section, folds, bodyFolds, &ranges)
215        }
216    }
217
218    /// Start offset of the heading whose line contains `offset`.
219    public static func heading(containing offset: Int, in tree: OrgTree) -> Int? {
220        var node = tree.root
221        while let child = node.child(containing: offset) {
222            if child.kind == .heading { return child.range.lowerBound }
223            guard child.kind == .section || child.kind == .zerothSection else { return nil }
224            node = child
225        }
226        return nil
227    }
228
229    /// Start offsets of every heading, for folding everything.
230    public static func headings(_ tree: OrgTree) -> [Int] {
231        tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound)
232    }
233}