krz/orgstar

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

Commit 42db54c4d8

42db54c4d8919b7b859dc4262642caf151a1ff73

parent: 73fb4b2c2c

Verified · cmc

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

Parse document, sections and headings

Layout: unified · split

Sources/OrgCore/Parser/Parser.swift added +228
@@ -0,0 +1,228 @@
1public 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
8struct 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}
Sources/OrgCore/Syntax/SyntaxNode.swift +8
@@ -49,3 +49,11 @@ public struct SyntaxToken: Sendable, Equatable {
4949 public let text: String
5050 public let range: Range<Int>
5151}
52
53public struct OrgTree: Sendable {
54 public let green: GreenNode
55 public let settings: OrgSettings
56
57 public var root: SyntaxNode { SyntaxNode(green: green, offset: 0, parent: nil) }
58 public var text: String { green.text }
59}
Tests/OrgCoreTests/ParserSectionTests.swift added +66
@@ -0,0 +1,66 @@
1import Testing
2@testable import OrgCore
3
4func nodeKinds(_ text: String) -> [SyntaxKind] {
5 OrgParser.parse(text).root.descendants().map(\.kind)
6}
7
8func tokens(of kind: SyntaxKind, in text: String) -> [SyntaxToken] {
9 OrgParser.parse(text).root.descendants().filter { $0.kind == kind }.flatMap(\.tokens)
10}
11
12struct ParserSectionTests {
13 @Test func emptyDocument() {
14 let tree = OrgParser.parse("")
15 #expect(tree.text == "")
16 #expect(nodeKinds("") == [.document])
17 }
18
19 @Test func zerothSectionHoldsPreamble() {
20 #expect(nodeKinds("text\n* a\n") == [.document, .zerothSection, .paragraph, .section, .heading])
21 }
22
23 @Test func sectionsNestByLevel() {
24 let text = "* a\n** b\n*** c\n** d\n* e\n"
25 let root = OrgParser.parse(text).root
26 let top = root.children
27 #expect(top.map(\.kind) == [.section, .section])
28 #expect(top[0].children.map(\.kind) == [.heading, .section, .section])
29 #expect(top[0].children[1].children.map(\.kind) == [.heading, .section])
30 }
31
32 @Test func headingTokens() {
33 let parts = tokens(of: .heading, in: "** TODO [#A] Write the plan :work:urgent: \n")
34 #expect(parts.map(\.kind) == [.stars, .whitespace, .todoKeyword, .whitespace, .priority, .whitespace, .title, .whitespace, .tags, .whitespace, .newline])
35 #expect(parts.first { $0.kind == .tags }?.text == ":work:urgent:")
36 #expect(parts.first { $0.kind == .title }?.text == "Write the plan")
37 }
38
39 @Test func todoKeywordsComeFromSettings() {
40 let text = "#+TODO: NEXT | DONE\n* NEXT a\n* TODO b\n"
41 let todo = tokens(of: .heading, in: text).filter { $0.kind == .todoKeyword }.map(\.text)
42 #expect(todo == ["NEXT"])
43 }
44
45 @Test func priorityNeedsValidValueAndSpace() {
46 #expect(tokens(of: .heading, in: "* [#B] x\n").contains { $0.kind == .priority })
47 #expect(tokens(of: .heading, in: "* [#10] x\n").contains { $0.kind == .priority })
48 #expect(!tokens(of: .heading, in: "* [#AB] x\n").contains { $0.kind == .priority })
49 #expect(!tokens(of: .heading, in: "* [#A]x\n").contains { $0.kind == .priority })
50 }
51
52 @Test func tagsNeedValidCharacters() {
53 #expect(tokens(of: .heading, in: "* a :b@c_1:\n").contains { $0.kind == .tags })
54 #expect(!tokens(of: .heading, in: "* a :b c:\n").contains { $0.kind == .tags })
55 #expect(!tokens(of: .heading, in: "* a :b:c\n").contains { $0.kind == .tags })
56 }
57
58 @Test func headingWithoutNewlineAtEnd() {
59 #expect(OrgParser.parse("* a").text == "* a")
60 }
61
62 @Test(arguments: ["* a\n", "*\n", "* TODO\n", "text\r\n* a\r\n** b\r\n", "\n\n* a\n\n"])
63 func roundTrip(text: String) {
64 #expect(OrgParser.parse(text).text == text)
65 }
66}