krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

Commit fa46c3f157

fa46c3f1577091385a95ac5f5680409f09701004

parent: d91c9b5160

Unsigned

cmc <hello@cleberg.net> · 2026-09-30 05:08 UTC

Parser: a line starting with # that is not a comment is prose (!10)

`OrgParser.parse` looped forever on a line like `#12 fixed this`: `startsNewConstruct` treated every `#`-prefixed line as a new construct, no branch claimed it, and the paragraph loop broke before consuming it. Only `#+` keywords and comments (`#`, `# `) start a construct now, and a paragraph always takes its first line so the loop always advances. Froze the gitbay iOS app on an org comment. 3 new tests; 95 pass on macOS and iOS.

Layout: unified · split

Sources/OrgSwift/AST/OrgParser.swift +6 −2
@@ -138,7 +138,10 @@ public enum OrgParser {
138 while index < lines.count { 138 while index < lines.count {
139 let candidate = lines[index] 139 let candidate = lines[index]
140 let candidateTrimmed = candidate.trimmingCharacters(in: .whitespaces) 140 let candidateTrimmed = candidate.trimmingCharacters(in: .whitespaces)
141 if candidateTrimmed.isEmpty || startsNewConstruct(candidateTrimmed, raw: candidate) { break } 141 // The first line is always taken: nothing above claimed it, and
142 // leaving it would parse the same line forever.
143 if !paragraph.isEmpty,
144 candidateTrimmed.isEmpty || startsNewConstruct(candidateTrimmed, raw: candidate) { break }
142 paragraph.append(candidateTrimmed) 145 paragraph.append(candidateTrimmed)
143 index += 1 146 index += 1
144 } 147 }
@@ -153,7 +156,8 @@ public enum OrgParser {
153 156
154 /// Would this line begin a construct other than the paragraph currently being consumed? 157 /// Would this line begin a construct other than the paragraph currently being consumed?
155 private static func startsNewConstruct(_ trimmed: String, raw: String) -> Bool { 158 private static func startsNewConstruct(_ trimmed: String, raw: String) -> Bool {
156 if trimmed.hasPrefix("#+") || trimmed.hasPrefix("#") { return true } 159 // A keyword or a comment; `#12 fixed this` is prose.
160 if trimmed.hasPrefix("#+") || trimmed == "#" || trimmed.hasPrefix("# ") { return true }
157 if trimmed.firstMatch(of: /^\*{1,6}\s+/) != nil { return true } 161 if trimmed.firstMatch(of: /^\*{1,6}\s+/) != nil { return true }
158 if isOrgHorizontalRule(trimmed) { return true } 162 if isOrgHorizontalRule(trimmed) { return true }
159 if isTableLine(trimmed) { return true } 163 if isTableLine(trimmed) { return true }
Tests/OrgSwiftTests/OrgTreeTests.swift +22
@@ -9,6 +9,28 @@ import Testing
9/// than a second parser. Corpus conformance is gated separately, in `ConformanceTests`. 9/// than a second parser. Corpus conformance is gated separately, in `ConformanceTests`.
10struct ASTParseTests { 10struct ASTParseTests {
11 11
12 @Test(.timeLimit(.minutes(1)))
13 func aHashLineThatIsNotACommentIsAParagraph() {
14 let doc = OrgParser.parse("#12 fixed this\nsee #1")
15 guard case .paragraph(let objects) = doc.elements.first else {
16 Issue.record("expected a paragraph, got \(doc.elements)"); return
17 }
18 #expect(doc.elements.count == 1)
19 #expect(!objects.isEmpty)
20 }
21
22 @Test(.timeLimit(.minutes(1)))
23 func aHashLineEndsNoParagraph() {
24 let doc = OrgParser.parse("see\n#12 for context")
25 #expect(doc.elements.count == 1)
26 }
27
28 @Test(.timeLimit(.minutes(1)))
29 func aCommentStillEndsAParagraph() {
30 let doc = OrgParser.parse("one\n# a comment\ntwo")
31 #expect(doc.elements.count == 2)
32 }
33
12 @Test 34 @Test
13 func headingCarriesTodoPriorityAndTags() { 35 func headingCarriesTodoPriorityAndTags() {
14 let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:") 36 let doc = OrgParser.parse("* TODO [#A] Write the parser :work:rust:")