Commit 39a494d592
Verified · cmc
Layout: unified · split
Sources/OrgCore/Parser/Parser.swift +124 −4
| @@ -119,17 +119,132 @@ struct Parser { | ||
| 119 | 119 | } |
| 120 | 120 | } |
| 121 | 121 | |
| 122 | static let affiliatedKeys: Set<String> = ["NAME", "CAPTION", "RESULTS", "HEADER", "PLOT"] | |
| 123 | ||
| 122 | 124 | /// One element starting at `i`. `floor` is the indent of the enclosing list item, if any: |
| 123 | 125 | /// non-blank lines at or left of it end the element. |
| 124 | 126 | mutating func element(limit: Int, floor: Int?) { |
| 125 | if info[i].cls == .blank { | |
| 127 | switch info[i].cls { | |
| 128 | case .blank: | |
| 126 | 129 | line(i) |
| 127 | 130 | i += 1 |
| 128 | } else { | |
| 131 | case .blockBegin, .dynamicBegin: | |
| 132 | if let end = blockEnds[i], end < limit { | |
| 133 | builder.start(info[i].cls == .dynamicBegin ? .dynamicBlock : .block) | |
| 134 | while i <= end { | |
| 135 | line(i) | |
| 136 | i += 1 | |
| 137 | } | |
| 138 | builder.finish() | |
| 139 | } else { | |
| 140 | paragraph(limit: limit, floor: floor) | |
| 141 | } | |
| 142 | case .drawerBegin: | |
| 143 | if let end = blockEnds[i], end < limit { | |
| 144 | builder.start(.drawer) | |
| 145 | line(i) | |
| 146 | i += 1 | |
| 147 | parseContent(limit: end) | |
| 148 | line(i) | |
| 149 | i += 1 | |
| 150 | builder.finish() | |
| 151 | } else { | |
| 152 | paragraph(limit: limit, floor: floor) | |
| 153 | } | |
| 154 | case .keyword(let key): | |
| 155 | let affiliated = Self.affiliatedKeys.contains(key) || key.hasPrefix("ATTR_") | |
| 156 | single(affiliated ? .affiliatedKeyword : .keyword) | |
| 157 | case .comment: | |
| 158 | consecutive(.comment, limit: limit, floor: floor) { $0 == .comment } | |
| 159 | case .fixedWidth: | |
| 160 | consecutive(.fixedWidth, limit: limit, floor: floor) { $0 == .fixedWidth } | |
| 161 | case .horizontalRule: | |
| 162 | single(.horizontalRule) | |
| 163 | case .clock: | |
| 164 | single(.clock) | |
| 165 | case .tableRow: | |
| 166 | table(limit: limit, floor: floor) | |
| 167 | case .footnoteDefinition: | |
| 168 | footnoteDefinition(limit: limit) | |
| 169 | case .listItem: | |
| 170 | list(limit: limit, floor: floor) | |
| 171 | default: | |
| 129 | 172 | paragraph(limit: limit, floor: floor) |
| 130 | 173 | } |
| 131 | 174 | } |
| 132 | 175 | |
| 176 | mutating func single(_ kind: SyntaxKind) { | |
| 177 | builder.start(kind) | |
| 178 | line(i) | |
| 179 | i += 1 | |
| 180 | builder.finish() | |
| 181 | } | |
| 182 | ||
| 183 | mutating func consecutive(_ kind: SyntaxKind, limit: Int, floor: Int?, matching: (LineClass) -> Bool) { | |
| 184 | builder.start(kind) | |
| 185 | repeat { | |
| 186 | line(i) | |
| 187 | i += 1 | |
| 188 | } while i < limit && matching(info[i].cls) && within(floor, i) | |
| 189 | builder.finish() | |
| 190 | } | |
| 191 | ||
| 192 | mutating func table(limit: Int, floor: Int?) { | |
| 193 | builder.start(.table) | |
| 194 | while i < limit, info[i].cls == .tableRow, within(floor, i) { | |
| 195 | single(.tableRow) | |
| 196 | } | |
| 197 | while i < limit, info[i].cls == .keyword(key: "TBLFM"), within(floor, i) { | |
| 198 | single(.tableFormula) | |
| 199 | } | |
| 200 | builder.finish() | |
| 201 | } | |
| 202 | ||
| 203 | mutating func footnoteDefinition(limit: Int) { | |
| 204 | builder.start(.footnoteDefinition) | |
| 205 | line(i) | |
| 206 | i += 1 | |
| 207 | while i < limit, info[i].cls == .plain { | |
| 208 | line(i) | |
| 209 | i += 1 | |
| 210 | } | |
| 211 | builder.finish() | |
| 212 | } | |
| 213 | ||
| 214 | mutating func list(limit: Int, floor: Int?) { | |
| 215 | let base = info[i].indent | |
| 216 | builder.start(.plainList) | |
| 217 | while i < limit, info[i].cls == .listItem, info[i].indent == base, within(floor, i) { | |
| 218 | item(base: base, limit: limit) | |
| 219 | } | |
| 220 | builder.finish() | |
| 221 | } | |
| 222 | ||
| 223 | /// An item's first line, then everything indented past its bullet. One blank line stays | |
| 224 | /// inside the item when the item or list continues after it; two end the list. | |
| 225 | mutating func item(base: Int, limit: Int) { | |
| 226 | builder.start(.item) | |
| 227 | line(i) | |
| 228 | i += 1 | |
| 229 | while i < limit, !isHeading(i) { | |
| 230 | if info[i].cls == .blank { | |
| 231 | var j = i | |
| 232 | while j < limit, info[j].cls == .blank { j += 1 } | |
| 233 | guard j - i < 2, j < limit else { break } | |
| 234 | let continuesItem = info[j].indent > base | |
| 235 | let nextSibling = info[j].cls == .listItem && info[j].indent == base | |
| 236 | guard continuesItem || nextSibling else { break } | |
| 237 | line(i) | |
| 238 | i += 1 | |
| 239 | if nextSibling { break } | |
| 240 | continue | |
| 241 | } | |
| 242 | guard info[i].indent > base else { break } | |
| 243 | element(limit: limit, floor: base) | |
| 244 | } | |
| 245 | builder.finish() | |
| 246 | } | |
| 247 | ||
| 133 | 248 | mutating func paragraph(limit: Int, floor: Int?) { |
| 134 | 249 | builder.start(.paragraph) |
| 135 | 250 | line(i) |
| @@ -141,10 +256,15 @@ struct Parser { | ||
| 141 | 256 | builder.finish() |
| 142 | 257 | } |
| 143 | 258 | |
| 259 | /// Lines that don't start an element of their own. | |
| 144 | 260 | func continuesParagraph(_ k: Int) -> Bool { |
| 145 | 261 | switch info[k].cls { |
| 146 | case .blank, .heading: return false | |
| 147 | default: return true | |
| 262 | case .plain, .planning, .blockEnd, .dynamicEnd, .drawerEnd: | |
| 263 | return true | |
| 264 | case .blockBegin, .dynamicBegin, .drawerBegin: | |
| 265 | return blockEnds[k] == nil | |
| 266 | default: | |
| 267 | return false | |
| 148 | 268 | } |
| 149 | 269 | } |
| 150 | 270 | |
Tests/OrgCoreTests/ParserElementTests.swift added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | import Testing | |
| 2 | @testable import OrgCore | |
| 3 | ||
| 4 | func childKinds(_ text: String) -> [SyntaxKind] { | |
| 5 | let root = OrgParser.parse(text).root | |
| 6 | let container = root.children.first { $0.kind == .zerothSection || $0.kind == .section }! | |
| 7 | return container.children.map(\.kind) | |
| 8 | } | |
| 9 | ||
| 10 | struct ParserElementTests { | |
| 11 | @Test func planningAndPropertiesFollowHeading() { | |
| 12 | let text = "* a\nSCHEDULED: <2026-10-04 Sun>\n:PROPERTIES:\n:ID: x\n:END:\nbody\n" | |
| 13 | #expect(childKinds(text) == [.heading, .planning, .propertyDrawer, .paragraph]) | |
| 14 | } | |
| 15 | ||
| 16 | @Test func planningElsewhereIsText() { | |
| 17 | #expect(childKinds("SCHEDULED: <2026-10-04 Sun>\n") == [.paragraph]) | |
| 18 | } | |
| 19 | ||
| 20 | @Test func blocks() { | |
| 21 | #expect(childKinds("#+begin_src sh\n,* escaped\n:END:\n#+end_src\nafter\n") == [.block, .paragraph]) | |
| 22 | #expect(childKinds("#+BEGIN: clocktable\n#+END:\n") == [.dynamicBlock]) | |
| 23 | } | |
| 24 | ||
| 25 | @Test func headingsEndBlocks() { | |
| 26 | #expect(childKinds("#+begin_src sh\n* heading\n#+end_src\n") == [.paragraph]) | |
| 27 | } | |
| 28 | ||
| 29 | @Test func unclosedBlockIsParagraph() { | |
| 30 | #expect(childKinds("#+begin_src sh\necho\n") == [.paragraph]) | |
| 31 | } | |
| 32 | ||
| 33 | @Test func drawersHoldElements() { | |
| 34 | let root = OrgParser.parse(":LOGBOOK:\nCLOCK: [2026-10-04 Sun 10:00]\n:END:\n").root | |
| 35 | let drawer = root.children[0].children[0] | |
| 36 | #expect(drawer.kind == .drawer) | |
| 37 | #expect(drawer.children.map(\.kind) == [.clock]) | |
| 38 | } | |
| 39 | ||
| 40 | @Test func keywords() { | |
| 41 | #expect(childKinds("#+TITLE: x\n#+NAME: t\n#+ATTR_HTML: :width 10\n") == [.keyword, .affiliatedKeyword, .affiliatedKeyword]) | |
| 42 | } | |
| 43 | ||
| 44 | @Test func commentsAndFixedWidthGroup() { | |
| 45 | #expect(childKinds("# a\n# b\n: c\n: d\n-----\n") == [.comment, .fixedWidth, .horizontalRule]) | |
| 46 | } | |
| 47 | ||
| 48 | @Test func tableWithFormulas() { | |
| 49 | let root = OrgParser.parse("| a |\n|---|\n| 1 |\n#+TBLFM: $1=2\n#+TBLFM: $1=3\n").root | |
| 50 | let table = root.children[0].children[0] | |
| 51 | #expect(table.kind == .table) | |
| 52 | #expect(table.children.map(\.kind) == [.tableRow, .tableRow, .tableRow, .tableFormula, .tableFormula]) | |
| 53 | } | |
| 54 | ||
| 55 | @Test func footnoteDefinition() { | |
| 56 | #expect(childKinds("[fn:1] note\ncontinued\n\nafter\n") == [.footnoteDefinition, .paragraph]) | |
| 57 | } | |
| 58 | ||
| 59 | @Test func listsNestByIndent() { | |
| 60 | let text = "- a\n more\n - b\n- c\n\nafter\n" | |
| 61 | let root = OrgParser.parse(text).root | |
| 62 | let list = root.children[0].children[0] | |
| 63 | #expect(list.kind == .plainList) | |
| 64 | #expect(list.children.map(\.kind) == [.item, .item]) | |
| 65 | #expect(list.children[0].children.map(\.kind) == [.paragraph, .plainList]) | |
| 66 | #expect(childKinds(text) == [.plainList, .paragraph]) | |
| 67 | } | |
| 68 | ||
| 69 | @Test func twoBlankLinesEndAList() { | |
| 70 | #expect(childKinds("- a\n\n\n- b\n") == [.plainList, .plainList]) | |
| 71 | #expect(childKinds("- a\n\n- b\n") == [.plainList]) | |
| 72 | } | |
| 73 | ||
| 74 | @Test func paragraphStopsAtElementStart() { | |
| 75 | #expect(childKinds("text\n| a |\n") == [.paragraph, .table]) | |
| 76 | #expect(childKinds("text\n#+begin_quote\nq\n#+end_quote\n") == [.paragraph, .block]) | |
| 77 | #expect(childKinds("text\n#+begin_quote\nq\n") == [.paragraph]) | |
| 78 | } | |
| 79 | } | |