krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 39a494d592

39a494d5929e3396ee493b23eee15e6252b1c7e9

parent: 42db54c4d8

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-04 23:17 UTC

Parse block-level elements

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 @@
1import Testing
2@testable import OrgCore
3
4func 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
10struct 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}