| @@ -0,0 +1,228 @@ |
| |
1 | public enum OrgParser { |
| |
2 | public static func parse(_ text: String, defaults: OrgSettings = .default) -> OrgTree { |
| |
3 | var parser = Parser(text: text, defaults: defaults) |
| |
4 | return parser.run() |
| |
5 | } |
| |
6 | } |
| |
7 | |
| |
8 | struct Parser { |
| |
9 | let lines: [RawLine] |
| |
10 | let info: [ClassifiedLine] |
| |
11 | /// Begin line → end line, for blocks, dynamic blocks and drawers that are closed before the |
| |
12 | /// next heading. |
| |
13 | let blockEnds: [Int: Int] |
| |
14 | let settings: OrgSettings |
| |
15 | var builder = GreenBuilder() |
| |
16 | var i = 0 |
| |
17 | |
| |
18 | init(text: String, defaults: OrgSettings) { |
| |
19 | lines = splitRawLines(text) |
| |
20 | info = lines.map { classifyLine($0.content) } |
| |
21 | blockEnds = Parser.matchEnds(info) |
| |
22 | settings = SettingsScanner.scan(lines: lines, info: info, blockEnds: blockEnds, defaults: defaults) |
| |
23 | } |
| |
24 | |
| |
25 | static func matchEnds(_ info: [ClassifiedLine]) -> [Int: Int] { |
| |
26 | var ends: [Int: Int] = [:] |
| |
27 | var k = 0 |
| |
28 | while k < info.count { |
| |
29 | let isEnd: ((LineClass) -> Bool)? |
| |
30 | switch info[k].cls { |
| |
31 | case .blockBegin(let name): isEnd = { $0 == .blockEnd(name: name) } |
| |
32 | case .dynamicBegin: isEnd = { $0 == .dynamicEnd } |
| |
33 | case .drawerBegin: isEnd = { $0 == .drawerEnd } |
| |
34 | default: isEnd = nil |
| |
35 | } |
| |
36 | if let isEnd { |
| |
37 | var j = k + 1 |
| |
38 | while j < info.count { |
| |
39 | if case .heading = info[j].cls { break } |
| |
40 | if isEnd(info[j].cls) { ends[k] = j; break } |
| |
41 | j += 1 |
| |
42 | } |
| |
43 | // Block contents are verbatim, so nothing inside starts another element. |
| |
44 | if let end = ends[k], !isDrawer(info[k].cls) { k = end } |
| |
45 | } |
| |
46 | k += 1 |
| |
47 | } |
| |
48 | return ends |
| |
49 | } |
| |
50 | |
| |
51 | static func isDrawer(_ cls: LineClass) -> Bool { |
| |
52 | if case .drawerBegin = cls { return true } |
| |
53 | return false |
| |
54 | } |
| |
55 | |
| |
56 | mutating func run() -> OrgTree { |
| |
57 | builder.start(.document) |
| |
58 | if !lines.isEmpty, !isHeading(0) { |
| |
59 | builder.start(.zerothSection) |
| |
60 | parseContent(limit: lines.count) |
| |
61 | builder.finish() |
| |
62 | } |
| |
63 | while i < lines.count, case .heading(let level) = info[i].cls { |
| |
64 | parseSection(level: level) |
| |
65 | } |
| |
66 | builder.finish() |
| |
67 | return OrgTree(green: builder.build(), settings: settings) |
| |
68 | } |
| |
69 | |
| |
70 | func isHeading(_ k: Int) -> Bool { |
| |
71 | if case .heading = info[k].cls { return true } |
| |
72 | return false |
| |
73 | } |
| |
74 | |
| |
75 | mutating func parseSection(level: Int) { |
| |
76 | builder.start(.section) |
| |
77 | headingLine(lines[i]) |
| |
78 | i += 1 |
| |
79 | if i < lines.count, info[i].cls == .planning { |
| |
80 | builder.start(.planning) |
| |
81 | line(i) |
| |
82 | i += 1 |
| |
83 | builder.finish() |
| |
84 | } |
| |
85 | if i < lines.count, case .drawerBegin(let name) = info[i].cls, name.uppercased() == "PROPERTIES", |
| |
86 | let end = blockEnds[i] { |
| |
87 | propertyDrawer(end: end) |
| |
88 | } |
| |
89 | parseContent(limit: lines.count) |
| |
90 | while i < lines.count, case .heading(let child) = info[i].cls, child > level { |
| |
91 | parseSection(level: child) |
| |
92 | } |
| |
93 | builder.finish() |
| |
94 | } |
| |
95 | |
| |
96 | mutating func propertyDrawer(end: Int) { |
| |
97 | builder.start(.propertyDrawer) |
| |
98 | line(i) |
| |
99 | i += 1 |
| |
100 | while i < end { |
| |
101 | if info[i].cls == .blank { |
| |
102 | line(i) |
| |
103 | } else { |
| |
104 | builder.start(.nodeProperty) |
| |
105 | line(i) |
| |
106 | builder.finish() |
| |
107 | } |
| |
108 | i += 1 |
| |
109 | } |
| |
110 | line(i) |
| |
111 | i += 1 |
| |
112 | builder.finish() |
| |
113 | } |
| |
114 | |
| |
115 | /// Elements until `limit` or the next heading. |
| |
116 | mutating func parseContent(limit: Int) { |
| |
117 | while i < limit, !isHeading(i) { |
| |
118 | element(limit: limit, floor: nil) |
| |
119 | } |
| |
120 | } |
| |
121 | |
| |
122 | /// One element starting at `i`. `floor` is the indent of the enclosing list item, if any: |
| |
123 | /// non-blank lines at or left of it end the element. |
| |
124 | mutating func element(limit: Int, floor: Int?) { |
| |
125 | if info[i].cls == .blank { |
| |
126 | line(i) |
| |
127 | i += 1 |
| |
128 | } else { |
| |
129 | paragraph(limit: limit, floor: floor) |
| |
130 | } |
| |
131 | } |
| |
132 | |
| |
133 | mutating func paragraph(limit: Int, floor: Int?) { |
| |
134 | builder.start(.paragraph) |
| |
135 | line(i) |
| |
136 | i += 1 |
| |
137 | while i < limit, within(floor, i), continuesParagraph(i) { |
| |
138 | line(i) |
| |
139 | i += 1 |
| |
140 | } |
| |
141 | builder.finish() |
| |
142 | } |
| |
143 | |
| |
144 | func continuesParagraph(_ k: Int) -> Bool { |
| |
145 | switch info[k].cls { |
| |
146 | case .blank, .heading: return false |
| |
147 | default: return true |
| |
148 | } |
| |
149 | } |
| |
150 | |
| |
151 | func within(_ floor: Int?, _ k: Int) -> Bool { |
| |
152 | guard let floor else { return true } |
| |
153 | return info[k].indent > floor |
| |
154 | } |
| |
155 | |
| |
156 | // MARK: - Tokens |
| |
157 | |
| |
158 | /// A whole line as leading whitespace, content and line ending. |
| |
159 | mutating func line(_ k: Int) { |
| |
160 | let rest = whitespace(lines[k].content) |
| |
161 | builder.token(.text, rest) |
| |
162 | builder.token(.newline, lines[k].ending) |
| |
163 | } |
| |
164 | |
| |
165 | mutating func whitespace(_ s: Substring) -> Substring { |
| |
166 | let ws = s.prefix { $0 == " " || $0 == "\t" } |
| |
167 | builder.token(.whitespace, ws) |
| |
168 | return s.dropFirst(ws.count) |
| |
169 | } |
| |
170 | |
| |
171 | mutating func headingLine(_ raw: RawLine) { |
| |
172 | builder.start(.heading) |
| |
173 | var rest = raw.content |
| |
174 | let stars = rest.prefix { $0 == "*" } |
| |
175 | builder.token(.stars, stars) |
| |
176 | rest = whitespace(rest.dropFirst(stars.count)) |
| |
177 | |
| |
178 | let word = rest.prefix { $0 != " " && $0 != "\t" } |
| |
179 | if !word.isEmpty, settings.todoKeywordNames.contains(String(word)) { |
| |
180 | builder.token(.todoKeyword, word) |
| |
181 | rest = whitespace(rest.dropFirst(word.count)) |
| |
182 | } |
| |
183 | |
| |
184 | if let cookie = priorityCookie(rest) { |
| |
185 | builder.token(.priority, cookie) |
| |
186 | rest = whitespace(rest.dropFirst(cookie.count)) |
| |
187 | } |
| |
188 | |
| |
189 | let parts = splitTags(rest) |
| |
190 | builder.token(.title, parts.title) |
| |
191 | builder.token(.whitespace, parts.gap) |
| |
192 | builder.token(.tags, parts.tags) |
| |
193 | builder.token(.whitespace, parts.trailing) |
| |
194 | builder.token(.newline, raw.ending) |
| |
195 | builder.finish() |
| |
196 | } |
| |
197 | |
| |
198 | /// `[#A]` or `[#10]`, followed by whitespace or end of line. |
| |
199 | func priorityCookie(_ s: Substring) -> Substring? { |
| |
200 | guard s.hasPrefix("[#"), let close = s.firstIndex(of: "]") else { return nil } |
| |
201 | let value = s[s.index(s.startIndex, offsetBy: 2)..<close] |
| |
202 | let valid = (value.count == 1 && value.first!.isLetter && value.first!.isUppercase) |
| |
203 | || (!value.isEmpty && value.allSatisfy { $0.isASCII && $0.isNumber }) |
| |
204 | guard valid else { return nil } |
| |
205 | let after = s[s.index(after: close)...] |
| |
206 | guard after.isEmpty || after.first == " " || after.first == "\t" else { return nil } |
| |
207 | return s[...close] |
| |
208 | } |
| |
209 | |
| |
210 | func splitTags(_ s: Substring) -> (title: Substring, gap: Substring, tags: Substring, trailing: Substring) { |
| |
211 | let trimmed = s.trimmingTrailingWhitespace |
| |
212 | let trailing = s[trimmed.endIndex...] |
| |
213 | let none = (title: trimmed, gap: Substring(), tags: Substring(), trailing: trailing) |
| |
214 | guard trimmed.last == ":" else { return none } |
| |
215 | let tagStart = trimmed.lastIndex { $0 == " " || $0 == "\t" }.map { trimmed.index(after: $0) } ?? trimmed.startIndex |
| |
216 | let tags = trimmed[tagStart...] |
| |
217 | guard tags.count >= 3, tags.first == ":", isTagString(tags) else { return none } |
| |
218 | let before = trimmed[..<tagStart] |
| |
219 | let title = before.trimmingTrailingWhitespace |
| |
220 | return (title, before[title.endIndex...], tags, trailing) |
| |
221 | } |
| |
222 | |
| |
223 | func isTagString(_ tags: Substring) -> Bool { |
| |
224 | tags.dropFirst().dropLast().split(separator: ":", omittingEmptySubsequences: false).allSatisfy { tag in |
| |
225 | !tag.isEmpty && tag.allSatisfy { $0.isLetter || $0.isNumber || "_@#%".contains($0) } |
| |
226 | } |
| |
227 | } |
| |
228 | } |