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 | /// One element starting at `i`. `floor` is the indent of the enclosing list item, if any: | 124 | /// 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. | 125 | /// non-blank lines at or left of it end the element. |
| 124 | mutating func element(limit: Int, floor: Int?) { | 126 | mutating func element(limit: Int, floor: Int?) { |
| 125 | if info[i].cls == .blank { | 127 | switch info[i].cls { |
| 128 | case .blank: | ||
| 126 | line(i) | 129 | line(i) |
| 127 | i += 1 | 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 | paragraph(limit: limit, floor: floor) | 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 | mutating func paragraph(limit: Int, floor: Int?) { | 248 | mutating func paragraph(limit: Int, floor: Int?) { |
| 134 | builder.start(.paragraph) | 249 | builder.start(.paragraph) |
| 135 | line(i) | 250 | line(i) |
| @@ -141,10 +256,15 @@ struct Parser { | |||
| 141 | builder.finish() | 256 | builder.finish() |
| 142 | } | 257 | } |
| 143 | 258 | ||
| 259 | /// Lines that don't start an element of their own. | ||
| 144 | func continuesParagraph(_ k: Int) -> Bool { | 260 | func continuesParagraph(_ k: Int) -> Bool { |
| 145 | switch info[k].cls { | 261 | switch info[k].cls { |
| 146 | case .blank, .heading: return false | 262 | case .plain, .planning, .blockEnd, .dynamicEnd, .drawerEnd: |
| 147 | default: return true | 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 | } | ||