| @@ -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 | } |