Sources/OrgPresentation/Presentation.swift
170 lines · 7349 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}
22
23public struct StyleRun: Sendable, Equatable {
24 public let range: Range<Int>
25 public let role: StyleRole
26}
27
28/// org-indent: how many columns a run of whole lines starts in.
29public struct IndentRun: Sendable, Equatable {
30 public let range: Range<Int>
31 public let firstLine: Int
32 /// Continuation lines of a wrapped paragraph.
33 public let wrapped: Int
34}
35
36public enum Presentation {
37 /// Style runs for nodes and tokens overlapping `range`, outer before inner, so applying
38 /// them in order lets inner roles win.
39 public static func styles(_ tree: OrgTree, in range: Range<Int>) -> [StyleRun] {
40 var runs: [StyleRun] = []
41 visit(tree.root, range, tree.settings, &runs)
42 return runs
43 }
44
45 private static func visit(_ node: SyntaxNode, _ range: Range<Int>, _ settings: OrgSettings, _ runs: inout [StyleRun]) {
46 if let role = role(of: node) { runs.append(StyleRun(range: node.range, role: role)) }
47 switch node.kind {
48 case .heading:
49 for token in node.tokens {
50 switch token.kind {
51 case .stars where token.range.count > 1:
52 runs.append(StyleRun(range: token.range.lowerBound..<(token.range.upperBound - 1), role: .leadingStars))
53 case .todoKeyword:
54 runs.append(StyleRun(range: token.range, role: settings.isDone(token.text) ? .done : .todo))
55 case .priority:
56 runs.append(StyleRun(range: token.range, role: .priority))
57 case .tags:
58 runs.append(StyleRun(range: token.range, role: .tags))
59 default:
60 break
61 }
62 }
63 case .link:
64 let described = node.children.contains { $0.kind == .linkDescription }
65 for token in node.tokens where token.kind == .marker || (described && token.kind == .linkPath) {
66 runs.append(StyleRun(range: token.range, role: .linkMarkup))
67 }
68 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code:
69 for token in node.tokens where token.kind == .marker {
70 runs.append(StyleRun(range: token.range, role: .markup))
71 }
72 case .block, .dynamicBlock:
73 let lines = node.tokens.filter { $0.kind == .text }
74 for token in [lines.first, lines.count > 1 ? lines.last : nil].compactMap({ $0 }) {
75 runs.append(StyleRun(range: token.range, role: .blockDelimiter))
76 }
77 default:
78 break
79 }
80 for child in node.children(overlapping: range) {
81 visit(child, range, settings, &runs)
82 }
83 }
84
85 static func role(of node: SyntaxNode) -> StyleRole? {
86 switch node.kind {
87 case .heading: .heading(level: node.tokens.first { $0.kind == .stars }?.text.count ?? 1)
88 case .bold: .bold
89 case .italic: .italic
90 case .underline: .underline
91 case .strikeThrough: .strikeThrough
92 case .verbatim: .verbatim
93 case .code: .code
94 case .link: .link
95 case .timestamp: .timestamp
96 case .footnoteReference, .footnoteDefinition: .footnote
97 case .statisticsCookie: .cookie
98 case .target: .target
99 case .macro: .macro
100 case .latexFragment: .latex
101 case .inlineSourceBlock: .inlineSource
102 case .comment: .comment
103 case .keyword, .affiliatedKeyword: .keyword
104 case .planning, .propertyDrawer, .drawer, .clock: .metadata
105 case .block, .dynamicBlock, .fixedWidth: .block
106 case .table: .table
107 case .horizontalRule: .rule
108 default: nil
109 }
110 }
111
112 /// Heading lines start at column 0, with wrapped title lines under the title; a section's
113 /// own body starts one column past its stars.
114 public static func indents(_ tree: OrgTree, in range: Range<Int>) -> [IndentRun] {
115 var runs: [IndentRun] = []
116 collectIndents(tree.root, range, &runs)
117 return runs
118 }
119
120 private static func collectIndents(_ node: SyntaxNode, _ range: Range<Int>, _ runs: inout [IndentRun]) {
121 for section in node.children(overlapping: range) where section.kind == .section {
122 guard let heading = section.firstChild(.heading) else { continue }
123 let level = heading.tokens.first { $0.kind == .stars }?.text.count ?? 1
124 runs.append(IndentRun(range: heading.range, firstLine: 0, wrapped: level + 1))
125 let bodyEnd = section.firstChild(.section)?.range.lowerBound ?? section.range.upperBound
126 if heading.range.upperBound < bodyEnd {
127 runs.append(IndentRun(range: heading.range.upperBound..<bodyEnd, firstLine: level + 1, wrapped: level + 1))
128 }
129 collectIndents(section, range, &runs)
130 }
131 }
132
133 /// Ranges to hide for folded headings, given by their start offsets: from the end of the
134 /// heading line to the end of its section. Sorted, without nested duplicates.
135 public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>) -> [Range<Int>] {
136 guard !folds.isEmpty else { return [] }
137 var ranges: [Range<Int>] = []
138 collectHidden(tree.root, folds, &ranges)
139 return ranges
140 }
141
142 private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ ranges: inout [Range<Int>]) {
143 for section in node.children where section.kind == .section {
144 guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue }
145 if folds.contains(heading.range.lowerBound) {
146 if heading.range.upperBound < section.range.upperBound {
147 ranges.append(heading.range.upperBound..<section.range.upperBound)
148 }
149 } else {
150 collectHidden(section, folds, &ranges)
151 }
152 }
153 }
154
155 /// Start offset of the heading whose line contains `offset`.
156 public static func heading(containing offset: Int, in tree: OrgTree) -> Int? {
157 var node = tree.root
158 while let child = node.child(containing: offset) {
159 if child.kind == .heading { return child.range.lowerBound }
160 guard child.kind == .section || child.kind == .zerothSection else { return nil }
161 node = child
162 }
163 return nil
164 }
165
166 /// Start offsets of every heading, for folding everything.
167 public static func headings(_ tree: OrgTree) -> [Int] {
168 tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound)
169 }
170}