Sources/OrgPresentation/Presentation.swift
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}