Inline objects !2

merged merged by cmc on 2026-10-04 23:31 UTC · krz/orgstar:phase1-inline-objects into main

10 files changed, +1889 −30

Layout: unified · split

Sources/OrgCore/Parser/Inline.swift added +376
@@ -0,0 +1,376 @@
1/// Characters allowed before an emphasis opener, besides whitespace and the start of the run.
2private let emphasisPre: Set<Character> = ["-", "(", "{", "'", "\""]
3
4/// Characters allowed after an emphasis closer, besides whitespace and the end of the run.
5private let emphasisPost: Set<Character> = ["-", ".", ",", ":", "!", "?", ";", "'", "\"", ")", "}", "\\", "["]
6
7private let emphasisKinds: [Character: SyntaxKind] = [
8 "*": .bold, "/": .italic, "_": .underline, "+": .strikeThrough, "=": .verbatim, "~": .code,
9]
10
11private let angleLinkSchemes: Set<String> = [
12 "http", "https", "mailto", "file", "id", "doi", "ftp", "news", "shell", "elisp", "info", "help", "attachment",
13]
14
15private let plainLinkPrefixes = ["https://", "http://", "mailto:", "file:"]
16
17/// "\r\n" is a single Character, so both forms count.
18func isNewline(_ c: Character) -> Bool {
19 c == "\n" || c == "\r\n"
20}
21
22enum InlineMatch {
23 case emphasis(SyntaxKind, open: Int, close: Int)
24 case link(path: Range<Int>, description: Range<Int>?, whole: Range<Int>)
25 case object(SyntaxKind, Range<Int>)
26
27 var end: Int {
28 switch self {
29 case .emphasis(_, _, let close): close + 1
30 case .link(_, _, let whole): whole.upperBound
31 case .object(_, let range): range.upperBound
32 }
33 }
34}
35
36/// Turns a run of text into text, newline and object tokens. Every character ends up in
37/// exactly one token.
38struct InlineScanner {
39 let chars: [Character]
40
41 func scan(_ range: Range<Int>, into b: inout GreenBuilder, inLink: Bool = false) {
42 var textStart = range.lowerBound
43 var i = range.lowerBound
44 while i < range.upperBound {
45 if let match = match(at: i, in: range, inLink: inLink) {
46 emitText(textStart..<i, into: &b)
47 emit(match, into: &b, inLink: inLink)
48 i = match.end
49 textStart = i
50 } else {
51 i += 1
52 }
53 }
54 emitText(textStart..<range.upperBound, into: &b)
55 }
56
57 func emitText(_ range: Range<Int>, into b: inout GreenBuilder) {
58 var start = range.lowerBound
59 for k in range where isNewline(chars[k]) {
60 if start < k { b.token(.text, string(start..<k)) }
61 b.token(.newline, String(chars[k]))
62 start = k + 1
63 }
64 if start < range.upperBound { b.token(.text, string(start..<range.upperBound)) }
65 }
66
67 func emit(_ match: InlineMatch, into b: inout GreenBuilder, inLink: Bool) {
68 switch match {
69 case .emphasis(let kind, let open, let close):
70 b.start(kind)
71 b.token(.marker, string(open..<(open + 1)))
72 if kind == .verbatim || kind == .code {
73 emitText((open + 1)..<close, into: &b)
74 } else {
75 scan((open + 1)..<close, into: &b, inLink: inLink)
76 }
77 b.token(.marker, string(close..<(close + 1)))
78 b.finish()
79 case .link(let path, let description, let whole):
80 b.start(.link)
81 b.token(.marker, string(whole.lowerBound..<path.lowerBound))
82 b.token(.linkPath, string(path))
83 if let description {
84 b.token(.marker, string(path.upperBound..<description.lowerBound))
85 b.start(.linkDescription)
86 scan(description, into: &b, inLink: true)
87 b.finish()
88 b.token(.marker, string(description.upperBound..<whole.upperBound))
89 } else {
90 b.token(.marker, string(path.upperBound..<whole.upperBound))
91 }
92 b.finish()
93 case .object(let kind, let range):
94 b.start(kind)
95 emitText(range, into: &b)
96 b.finish()
97 }
98 }
99
100 func string(_ range: Range<Int>) -> String {
101 String(chars[range])
102 }
103
104 func hasPrefix(_ s: String, at i: Int, _ limit: Int) -> Bool {
105 var j = i
106 for c in s {
107 guard j < limit, chars[j] == c else { return false }
108 j += 1
109 }
110 return true
111 }
112
113 // MARK: - Recognizers
114
115 func match(at i: Int, in range: Range<Int>, inLink: Bool) -> InlineMatch? {
116 let limit = range.upperBound
117 let previous: Character? = i > range.lowerBound ? chars[i - 1] : nil
118 let afterWord = previous.map { $0.isLetter || $0.isNumber } ?? false
119
120 switch chars[i] {
121 case "[":
122 if !inLink, let link = bracketLink(i, limit) { return link }
123 if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) }
124 if let end = scanTimestamp(chars, at: i, limit: limit)?.end { return .object(.timestamp, i..<end) }
125 if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) }
126 case "<":
127 if let end = scanTimestamp(chars, at: i, limit: limit)?.end { return .object(.timestamp, i..<end) }
128 if let end = target(i, limit) { return .object(.target, i..<end) }
129 if !inLink, let end = angleLink(i, limit) { return .object(.link, i..<end) }
130 case "{":
131 if let end = macro(i, limit) { return .object(.macro, i..<end) }
132 case "\\":
133 if let end = lineBreak(i, limit) { return .object(.lineBreak, i..<end) }
134 if let end = latexFragment(i, limit) { return .object(.latexFragment, i..<end) }
135 case "^":
136 if afterWord, let end = superscript(i, limit) { return .object(.superscript, i..<end) }
137 case "s":
138 if !afterWord, let end = inlineSourceBlock(i, limit) { return .object(.inlineSourceBlock, i..<end) }
139 default:
140 break
141 }
142
143 if !inLink, !afterWord, let end = plainLink(i, limit) { return .object(.link, i..<end) }
144
145 if let kind = emphasisKinds[chars[i]],
146 previous.map({ $0.isWhitespace || emphasisPre.contains($0) }) ?? true,
147 let close = emphasisClose(i, limit) {
148 return .emphasis(kind, open: i, close: close)
149 }
150 return nil
151 }
152
153 /// org's emphasis rules: the body neither starts nor ends with whitespace, spans at most
154 /// one line break, and the closer is followed by whitespace, punctuation or the end.
155 func emphasisClose(_ i: Int, _ limit: Int) -> Int? {
156 let marker = chars[i]
157 guard i + 1 < limit, !chars[i + 1].isWhitespace else { return nil }
158 var newlines = 0
159 var j = i + 1
160 while j < limit {
161 if isNewline(chars[j]) {
162 newlines += 1
163 if newlines > 1 { return nil }
164 } else if chars[j] == marker, j > i + 1, !chars[j - 1].isWhitespace {
165 if j + 1 == limit || chars[j + 1].isWhitespace || emphasisPost.contains(chars[j + 1]) { return j }
166 }
167 j += 1
168 }
169 return nil
170 }
171
172 /// `[[path]]` or `[[path][description]]`.
173 func bracketLink(_ i: Int, _ limit: Int) -> InlineMatch? {
174 guard i + 1 < limit, chars[i + 1] == "[" else { return nil }
175 var j = i + 2
176 while j < limit, chars[j] != "]" {
177 if chars[j] == "[" || isNewline(chars[j]) { return nil }
178 if chars[j] == "\\", j + 1 < limit { j += 1 }
179 j += 1
180 }
181 guard j > i + 2, j + 1 < limit else { return nil }
182 let path = (i + 2)..<j
183 if chars[j + 1] == "]" { return .link(path: path, description: nil, whole: i..<(j + 2)) }
184 guard chars[j + 1] == "[" else { return nil }
185 let descriptionStart = j + 2
186 var depth = 0
187 var k = descriptionStart
188 while k < limit {
189 if chars[k] == "[" {
190 depth += 1
191 } else if chars[k] == "]" {
192 if depth == 0 { break }
193 depth -= 1
194 }
195 k += 1
196 }
197 guard k > descriptionStart, k + 1 < limit, chars[k + 1] == "]" else { return nil }
198 return .link(path: path, description: descriptionStart..<k, whole: i..<(k + 2))
199 }
200
201 /// `[fn:label]`, `[fn:label:definition]` or `[fn::definition]`.
202 func footnoteReference(_ i: Int, _ limit: Int) -> Int? {
203 guard hasPrefix("[fn:", at: i, limit) else { return nil }
204 var j = i + 4
205 while j < limit, chars[j].isLetter || chars[j].isNumber || chars[j] == "_" || chars[j] == "-" { j += 1 }
206 guard j < limit else { return nil }
207 if chars[j] == "]" { return j > i + 4 ? j + 1 : nil }
208 guard chars[j] == ":" else { return nil }
209 var depth = 0
210 j += 1
211 while j < limit {
212 if chars[j] == "[" {
213 depth += 1
214 } else if chars[j] == "]" {
215 if depth == 0 { return j + 1 }
216 depth -= 1
217 }
218 j += 1
219 }
220 return nil
221 }
222
223 /// `[1/3]`, `[/]`, `[50%]` or `[%]`.
224 func statisticsCookie(_ i: Int, _ limit: Int) -> Int? {
225 var j = i + 1
226 while j < limit, chars[j].isASCII, chars[j].isNumber { j += 1 }
227 guard j < limit else { return nil }
228 if chars[j] == "%" {
229 j += 1
230 } else if chars[j] == "/" {
231 j += 1
232 while j < limit, chars[j].isASCII, chars[j].isNumber { j += 1 }
233 } else {
234 return nil
235 }
236 guard j < limit, chars[j] == "]" else { return nil }
237 return j + 1
238 }
239
240 /// `<<target>>`.
241 func target(_ i: Int, _ limit: Int) -> Int? {
242 guard hasPrefix("<<", at: i, limit), i + 2 < limit, chars[i + 2] != "<" else { return nil }
243 let start = i + 2
244 var j = start
245 while j < limit, chars[j] != ">" {
246 if chars[j] == "<" || isNewline(chars[j]) { return nil }
247 j += 1
248 }
249 guard j > start, j + 1 < limit, chars[j + 1] == ">",
250 !chars[start].isWhitespace, !chars[j - 1].isWhitespace else { return nil }
251 return j + 2
252 }
253
254 /// `<scheme:path>` for a known scheme.
255 func angleLink(_ i: Int, _ limit: Int) -> Int? {
256 var j = i + 1
257 while j < limit, chars[j].isLetter { j += 1 }
258 guard j < limit, chars[j] == ":", angleLinkSchemes.contains(string((i + 1)..<j).lowercased()) else { return nil }
259 j += 1
260 let bodyStart = j
261 while j < limit, chars[j] != ">" {
262 if chars[j] == "<" || isNewline(chars[j]) { return nil }
263 j += 1
264 }
265 guard j < limit, j > bodyStart else { return nil }
266 return j + 1
267 }
268
269 /// A bare URL. Trailing sentence punctuation stays outside the link.
270 func plainLink(_ i: Int, _ limit: Int) -> Int? {
271 guard "hmf".contains(chars[i]),
272 let prefix = plainLinkPrefixes.first(where: { hasPrefix($0, at: i, limit) }) else { return nil }
273 let bodyStart = i + prefix.count
274 var j = bodyStart
275 while j < limit, !chars[j].isWhitespace, !"()<>[]\"".contains(chars[j]) { j += 1 }
276 while j > bodyStart, ".,;:!?'".contains(chars[j - 1]) { j -= 1 }
277 return j > bodyStart ? j : nil
278 }
279
280 /// `{{{name}}}` or `{{{name(arguments)}}}`.
281 func macro(_ i: Int, _ limit: Int) -> Int? {
282 guard hasPrefix("{{{", at: i, limit) else { return nil }
283 var j = i + 3
284 guard j < limit, chars[j].isLetter else { return nil }
285 while j < limit, chars[j].isLetter || chars[j].isNumber || chars[j] == "-" || chars[j] == "_" { j += 1 }
286 if j < limit, chars[j] == "(" {
287 var k = j + 1
288 while k < limit, !hasPrefix(")}}}", at: k, limit) {
289 if isNewline(chars[k]) { return nil }
290 k += 1
291 }
292 return k < limit ? k + 4 : nil
293 }
294 return hasPrefix("}}}", at: j, limit) ? j + 3 : nil
295 }
296
297 /// `\\` at the end of a line, before optional trailing blanks. The newline stays outside.
298 func lineBreak(_ i: Int, _ limit: Int) -> Int? {
299 guard hasPrefix("\\\\", at: i, limit) else { return nil }
300 var j = i + 2
301 while j < limit, chars[j] == " " || chars[j] == "\t" { j += 1 }
302 guard j == limit || isNewline(chars[j]) else { return nil }
303 return j
304 }
305
306 /// `\(...\)` or `\[...\]`.
307 func latexFragment(_ i: Int, _ limit: Int) -> Int? {
308 guard i + 1 < limit else { return nil }
309 let closer: String
310 switch chars[i + 1] {
311 case "(": closer = "\\)"
312 case "[": closer = "\\]"
313 default: return nil
314 }
315 var j = i + 2
316 while j < limit {
317 if hasPrefix(closer, at: j, limit) { return j + 2 }
318 j += 1
319 }
320 return nil
321 }
322
323 /// `^word` or `^{group}` after a letter or digit.
324 func superscript(_ i: Int, _ limit: Int) -> Int? {
325 var j = i + 1
326 guard j < limit else { return nil }
327 if chars[j] == "{" {
328 j += 1
329 while j < limit, chars[j] != "}" {
330 if isNewline(chars[j]) { return nil }
331 j += 1
332 }
333 return j < limit ? j + 1 : nil
334 }
335 let start = j
336 while j < limit, chars[j].isLetter || chars[j].isNumber { j += 1 }
337 return j > start ? j : nil
338 }
339
340 /// `src_lang{body}` or `src_lang[headers]{body}`, on one line, with balanced braces.
341 func inlineSourceBlock(_ i: Int, _ limit: Int) -> Int? {
342 guard hasPrefix("src_", at: i, limit) else { return nil }
343 var j = i + 4
344 let languageStart = j
345 while j < limit, !chars[j].isWhitespace, chars[j] != "[", chars[j] != "{" { j += 1 }
346 guard j > languageStart, j < limit else { return nil }
347 if chars[j] == "[" {
348 while j < limit, chars[j] != "]" {
349 if isNewline(chars[j]) { return nil }
350 j += 1
351 }
352 guard j < limit else { return nil }
353 j += 1
354 }
355 guard j < limit, chars[j] == "{" else { return nil }
356 var depth = 0
357 while j < limit {
358 if isNewline(chars[j]) { return nil }
359 if chars[j] == "{" {
360 depth += 1
361 } else if chars[j] == "}" {
362 depth -= 1
363 if depth == 0 { return j + 1 }
364 }
365 j += 1
366 }
367 return nil
368 }
369}
370
371extension Parser {
372 mutating func inline(_ text: Substring) {
373 let chars = Array(text)
374 InlineScanner(chars: chars).scan(0..<chars.count, into: &builder)
375 }
376}
Sources/OrgCore/Parser/Lines.swift +1 −1
@@ -27,7 +27,7 @@ func splitRawLines(_ text: String) -> [RawLine] {
2727 }
2828 }
2929 if lineStart != scalars.endIndex {
30 lines.append(RawLine(content: text[lineStart...], ending: ""))
30 lines.append(RawLine(content: text[lineStart...], ending: text[text.endIndex...]))
3131 }
3232 return lines
3333}
Sources/OrgCore/Parser/Parser.swift +94 −21
@@ -77,10 +77,7 @@ struct Parser {
7777 headingLine(lines[i])
7878 i += 1
7979 if i < lines.count, info[i].cls == .planning {
80 builder.start(.planning)
81 line(i)
82 i += 1
83 builder.finish()
80 single(.planning)
8481 }
8582 if i < lines.count, case .drawerBegin(let name) = info[i].cls, name.uppercased() == "PROPERTIES",
8683 let end = blockEnds[i] {
@@ -173,13 +170,28 @@ struct Parser {
173170 }
174171 }
175172
173 /// Kinds whose single line holds inline objects (timestamps on planning and clock lines).
174 static let inlineLineKinds: Set<SyntaxKind> = [.planning, .clock]
175
176176 mutating func single(_ kind: SyntaxKind) {
177177 builder.start(kind)
178 line(i)
178 if Self.inlineLineKinds.contains(kind) {
179 let rest = whitespace(lines[i].content)
180 inline(rest)
181 builder.token(.newline, lines[i].ending)
182 } else {
183 line(i)
184 }
179185 i += 1
180186 builder.finish()
181187 }
182188
189 /// Source text from `start` through the end of line `last`, including its line ending.
190 func span(from start: Substring.Index, through last: Int) -> Substring {
191 let base = lines[last].ending.base
192 return base[start..<lines[last].ending.endIndex]
193 }
194
183195 mutating func consecutive(_ kind: SyntaxKind, limit: Int, floor: Int?, matching: (LineClass) -> Bool) {
184196 builder.start(kind)
185197 repeat {
@@ -192,7 +204,7 @@ struct Parser {
192204 mutating func table(limit: Int, floor: Int?) {
193205 builder.start(.table)
194206 while i < limit, info[i].cls == .tableRow, within(floor, i) {
195 single(.tableRow)
207 tableRow()
196208 }
197209 while i < limit, info[i].cls == .keyword(key: "TBLFM"), within(floor, i) {
198210 single(.tableFormula)
@@ -200,15 +212,47 @@ struct Parser {
200212 builder.finish()
201213 }
202214
215 /// A rule row (`|---+---|`) is one text token. Other rows alternate `|` markers and cells;
216 /// every pair of pipes gets a cell, even an empty one, so columns line up.
217 mutating func tableRow() {
218 builder.start(.tableRow)
219 let rest = whitespace(lines[i].content)
220 if rest.hasPrefix("|-") {
221 builder.token(.text, rest)
222 } else {
223 var cellStart = rest.startIndex
224 var index = rest.startIndex
225 while index < rest.endIndex {
226 if rest[index] == "|" {
227 if index > rest.startIndex { tableCell(rest[cellStart..<index]) }
228 builder.token(.marker, "|")
229 cellStart = rest.index(after: index)
230 }
231 index = rest.index(after: index)
232 }
233 if cellStart < rest.endIndex { tableCell(rest[cellStart...]) }
234 }
235 builder.token(.newline, lines[i].ending)
236 builder.finish()
237 i += 1
238 }
239
240 mutating func tableCell(_ text: Substring) {
241 builder.start(.tableCell)
242 inline(text)
243 builder.finish()
244 }
245
203246 mutating func footnoteDefinition(limit: Int) {
247 var end = i + 1
248 while end < limit, info[end].cls == .plain { end += 1 }
249 let content = lines[i].content
250 let close = content.firstIndex(of: "]")!
204251 builder.start(.footnoteDefinition)
205 line(i)
206 i += 1
207 while i < limit, info[i].cls == .plain {
208 line(i)
209 i += 1
210 }
252 builder.token(.marker, content[...close])
253 inline(span(from: content.index(after: close), through: end - 1))
211254 builder.finish()
255 i = end
212256 }
213257
214258 mutating func list(limit: Int, floor: Int?) {
@@ -224,8 +268,25 @@ struct Parser {
224268 /// inside the item when the item or list continues after it; two end the list.
225269 mutating func item(base: Int, limit: Int) {
226270 builder.start(.item)
227 line(i)
228 i += 1
271 var rest = whitespace(lines[i].content)
272 let bullet = rest.prefix { $0 != " " && $0 != "\t" }
273 builder.token(.bullet, bullet)
274 rest = whitespace(rest.dropFirst(bullet.count))
275 if let box = checkbox(rest) {
276 builder.token(.checkbox, box)
277 rest = whitespace(rest.dropFirst(box.count))
278 }
279 // The rest of the bullet line and its continuation lines are the item's first paragraph.
280 var end = i + 1
281 while end < limit, within(base, end), continuesParagraph(end) { end += 1 }
282 if rest.isEmpty, end == i + 1 {
283 builder.token(.newline, lines[i].ending)
284 } else {
285 builder.start(.paragraph)
286 inline(span(from: rest.startIndex, through: end - 1))
287 builder.finish()
288 }
289 i = end
229290 while i < limit, !isHeading(i) {
230291 if info[i].cls == .blank {
231292 var j = i
@@ -245,15 +306,23 @@ struct Parser {
245306 builder.finish()
246307 }
247308
309 /// `[ ]`, `[X]`, `[x]` or `[-]`, followed by whitespace or end of line.
310 func checkbox(_ s: Substring) -> Substring? {
311 guard s.count >= 3, s.first == "[", "Xx -".contains(s.dropFirst().first!),
312 s.dropFirst(2).first == "]" else { return nil }
313 let after = s.dropFirst(3)
314 guard after.isEmpty || after.first == " " || after.first == "\t" else { return nil }
315 return s.prefix(3)
316 }
317
318 /// The paragraph's lines are one inline run, so emphasis and links can cross a line break.
248319 mutating func paragraph(limit: Int, floor: Int?) {
320 var end = i + 1
321 while end < limit, within(floor, end), continuesParagraph(end) { end += 1 }
249322 builder.start(.paragraph)
250 line(i)
251 i += 1
252 while i < limit, within(floor, i), continuesParagraph(i) {
253 line(i)
254 i += 1
255 }
323 inline(span(from: lines[i].content.startIndex, through: end - 1))
256324 builder.finish()
325 i = end
257326 }
258327
259328 /// Lines that don't start an element of their own.
@@ -307,7 +376,11 @@ struct Parser {
307376 }
308377
309378 let parts = splitTags(rest)
310 builder.token(.title, parts.title)
379 if !parts.title.isEmpty {
380 builder.start(.title)
381 inline(parts.title)
382 builder.finish()
383 }
311384 builder.token(.whitespace, parts.gap)
312385 builder.token(.tags, parts.tags)
313386 builder.token(.whitespace, parts.trailing)
Sources/OrgCore/Syntax/SyntaxKind.swift +10 −4
@@ -1,12 +1,18 @@
11public enum SyntaxKind: String, Sendable {
22 // Tokens
33 case text, newline, whitespace
4 case stars, todoKeyword, priority, title, tags
4 case stars, todoKeyword, priority, tags
5 case marker, linkPath, bullet, checkbox
56
6 // Nodes
7 case document, zerothSection, section, heading
7 // Elements
8 case document, zerothSection, section, heading, title
89 case planning, propertyDrawer, nodeProperty, drawer, clock
9 case paragraph, plainList, item, table, tableRow, tableFormula
10 case paragraph, plainList, item, table, tableRow, tableCell, tableFormula
1011 case block, dynamicBlock, keyword, affiliatedKeyword
1112 case comment, fixedWidth, horizontalRule, footnoteDefinition
13
14 // Objects
15 case bold, italic, underline, strikeThrough, verbatim, code
16 case link, linkDescription, timestamp, footnoteReference, statisticsCookie
17 case target, macro, inlineSourceBlock, latexFragment, lineBreak, superscript
1218}
Sources/OrgCore/Timestamp.swift added +181
@@ -0,0 +1,181 @@
1public struct Timestamp: Sendable, Equatable {
2 public enum Unit: Character, Sendable {
3 case hour = "h", day = "d", week = "w", month = "m", year = "y"
4 }
5
6 public struct Interval: Sendable, Equatable {
7 public var value: Int
8 public var unit: Unit
9 }
10
11 public enum RepeaterKind: String, Sendable {
12 case cumulate = "+", catchUp = "++", restart = ".+"
13 }
14
15 public struct Repeater: Sendable, Equatable {
16 public var kind: RepeaterKind
17 public var interval: Interval
18 /// The habit deadline from `.+2d/3d`.
19 public var habitDeadline: Interval?
20 }
21
22 public struct Warning: Sendable, Equatable {
23 /// `--` warns only for the first occurrence of a repeated timestamp.
24 public var firstOccurrenceOnly: Bool
25 public var interval: Interval
26 }
27
28 public struct Point: Sendable, Equatable {
29 public var year: Int
30 public var month: Int
31 public var day: Int
32 public var hour: Int?
33 public var minute: Int?
34 }
35
36 public var active: Bool
37 public var start: Point
38 /// End of a same-day time range or of a `--` date range.
39 public var end: Point?
40 public var repeater: Repeater?
41 public var warning: Warning?
42
43 /// Parses exactly one timestamp or range, with nothing before or after it.
44 public static func parse(_ text: some StringProtocol) -> Timestamp? {
45 let chars = Array(text)
46 guard let result = scanTimestamp(chars, at: 0, limit: chars.count), result.end == chars.count else { return nil }
47 return result.stamp
48 }
49}
50
51/// A timestamp or `--` range starting at `start`, and the index after it.
52func scanTimestamp(_ chars: [Character], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
53 guard let first = scanSingleTimestamp(chars, at: start, limit: limit) else { return nil }
54 if first.stamp.end == nil, first.end + 2 < limit, chars[first.end] == "-", chars[first.end + 1] == "-",
55 let second = scanSingleTimestamp(chars, at: first.end + 2, limit: limit),
56 second.stamp.active == first.stamp.active, second.stamp.end == nil {
57 var range = first.stamp
58 range.end = second.stamp.start
59 return (range, second.end)
60 }
61 return first
62}
63
64/// `<YYYY-MM-DD DAY HH:MM-HH:MM REPEATER WARNING>`, or the same in `[...]` for inactive.
65private func scanSingleTimestamp(_ chars: [Character], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
66 guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil }
67 let active = chars[start] == "<"
68 let close: Character = active ? ">" : "]"
69 var j = start + 1
70
71 func number(_ minDigits: Int, _ maxDigits: Int) -> Int? {
72 var k = j
73 while k < limit, k - j < maxDigits, chars[k].isASCII, chars[k].isNumber { k += 1 }
74 guard k - j >= minDigits else { return nil }
75 let value = Int(String(chars[j..<k]))!
76 j = k
77 return value
78 }
79
80 func take(_ c: Character) -> Bool {
81 guard j < limit, chars[j] == c else { return false }
82 j += 1
83 return true
84 }
85
86 func interval() -> Timestamp.Interval? {
87 let before = j
88 guard let value = number(1, 9), j < limit, let unit = Timestamp.Unit(rawValue: chars[j]) else {
89 j = before
90 return nil
91 }
92 j += 1
93 return Timestamp.Interval(value: value, unit: unit)
94 }
95
96 func repeater() -> Timestamp.Repeater? {
97 let before = j
98 let kind: Timestamp.RepeaterKind
99 if j + 1 < limit, chars[j] == ".", chars[j + 1] == "+" {
100 kind = .restart
101 j += 2
102 } else if j + 1 < limit, chars[j] == "+", chars[j + 1] == "+" {
103 kind = .catchUp
104 j += 2
105 } else if take("+") {
106 kind = .cumulate
107 } else {
108 return nil
109 }
110 guard let value = interval() else {
111 j = before
112 return nil
113 }
114 var deadline: Timestamp.Interval?
115 if take("/") {
116 deadline = interval()
117 if deadline == nil {
118 j = before
119 return nil
120 }
121 }
122 return Timestamp.Repeater(kind: kind, interval: value, habitDeadline: deadline)
123 }
124
125 func warning() -> Timestamp.Warning? {
126 let before = j
127 guard take("-") else { return nil }
128 let firstOnly = take("-")
129 guard let value = interval() else {
130 j = before
131 return nil
132 }
133 return Timestamp.Warning(firstOccurrenceOnly: firstOnly, interval: value)
134 }
135
136 guard let year = number(4, 4), take("-"), let month = number(2, 2), take("-"), let day = number(2, 2) else {
137 return nil
138 }
139 var stamp = Timestamp(
140 active: active,
141 start: Timestamp.Point(year: year, month: month, day: day, hour: nil, minute: nil),
142 end: nil, repeater: nil, warning: nil
143 )
144
145 // Day name: anything but digits, whitespace, `+`, `-`, `]` and `>`, in any language.
146 if j < limit, chars[j] == " " {
147 var k = j + 1
148 while k < limit, !(chars[k].isNumber || chars[k].isWhitespace || "+-]>".contains(chars[k])) { k += 1 }
149 if k > j + 1 { j = k }
150 }
151
152 let beforeTime = j
153 if take(" "), let hour = number(1, 2), take(":"), let minute = number(2, 2) {
154 stamp.start.hour = hour
155 stamp.start.minute = minute
156 let beforeEnd = j
157 if take("-"), let endHour = number(1, 2), take(":"), let endMinute = number(2, 2) {
158 stamp.end = Timestamp.Point(year: year, month: month, day: day, hour: endHour, minute: endMinute)
159 } else {
160 j = beforeEnd
161 }
162 } else {
163 j = beforeTime
164 }
165
166 while j < limit, chars[j] == " " {
167 let beforeModifier = j
168 j += 1
169 if let value = repeater() {
170 stamp.repeater = value
171 } else if let value = warning() {
172 stamp.warning = value
173 } else {
174 j = beforeModifier
175 break
176 }
177 }
178
179 guard take(close) else { return nil }
180 return (stamp, j)
181}
Tests/OrgCoreTests/InlineTests.swift added +89
@@ -0,0 +1,89 @@
1import Testing
2@testable import OrgCore
3
4/// `kind:text` for each object directly inside the first paragraph.
5func objects(_ text: String) -> [String] {
6 let paragraph = OrgParser.parse(text).root.descendants().first { $0.kind == .paragraph }!
7 return paragraph.children.map { "\($0.kind.rawValue):\($0.text)" }
8}
9
10struct InlineTests {
11 @Test(arguments: [
12 ("*b* /i/ _u_ +s+ =v= ~c~", ["bold:*b*", "italic:/i/", "underline:_u_", "strikeThrough:+s+", "verbatim:=v=", "code:~c~"]),
13 ("a*b* (*c*) \"*d*\"", ["bold:*c*", "bold:*d*"]),
14 ("x *y * z", []),
15 ("*y*z", []),
16 ("*a\nb*", ["bold:*a\nb*"]),
17 ("*a\nb\nc*", []),
18 ("=*not bold*=", ["verbatim:=*not bold*="]),
19 ("[[https://a.b][the *site*]]", ["link:[[https://a.b][the *site*]]"]),
20 ("[[file:x.org]]", ["link:[[file:x.org]]"]),
21 ("<https://a.b/c>", ["link:<https://a.b/c>"]),
22 ("see https://a.b/c.", ["link:https://a.b/c"]),
23 ("<2026-10-04 Sun 10:00-11:30 +1w -2d>", ["timestamp:<2026-10-04 Sun 10:00-11:30 +1w -2d>"]),
24 ("[2026-10-04 Sun]--[2026-10-06 Tue]", ["timestamp:[2026-10-04 Sun]--[2026-10-06 Tue]"]),
25 ("a [fn:1] and [fn::inline [x] note]", ["footnoteReference:[fn:1]", "footnoteReference:[fn::inline [x] note]"]),
26 ("a [1/3] [50%]", ["statisticsCookie:[1/3]", "statisticsCookie:[50%]"]),
27 ("a <<target>> {{{m(x, y)}}}", ["target:<<target>>", "macro:{{{m(x, y)}}}"]),
28 ("\\(x^2\\) and src_sh[:results raw]{echo {a}}", ["latexFragment:\\(x^2\\)", "inlineSourceBlock:src_sh[:results raw]{echo {a}}"]),
29 ("N^2 and e^{i}", ["superscript:^2", "superscript:^{i}"]),
30 ("end\\\\\nnext", ["lineBreak:\\\\"]),
31 ])
32 func recognizes(text: String, expected: [String]) {
33 #expect(objects(text) == expected)
34 }
35
36 @Test func emphasisNests() {
37 let bold = OrgParser.parse("*bold /italic/ x*\n").root.descendants().first { $0.kind == .bold }!
38 #expect(bold.children.map(\.kind) == [.italic])
39 #expect(bold.tokens.first?.kind == .marker)
40 }
41
42 @Test func linkParts() {
43 let link = OrgParser.parse("[[id:abc][desc]]\n").root.descendants().first { $0.kind == .link }!
44 #expect(link.tokens.map(\.kind) == [.marker, .linkPath, .marker, .marker])
45 #expect(link.tokens.first { $0.kind == .linkPath }?.text == "id:abc")
46 #expect(link.children.map(\.kind) == [.linkDescription])
47 }
48
49 @Test func noLinksInsideLinkDescriptions() {
50 let link = OrgParser.parse("[[a][see https://b.c]]\n").root.descendants().first { $0.kind == .link }!
51 #expect(link.descendants().filter { $0.kind == .link }.count == 1)
52 }
53
54 @Test func headingTitlesHoldObjects() {
55 let title = OrgParser.parse("* TODO Read [[https://a.b][it]] [1/2]\n").root.descendants().first { $0.kind == .title }!
56 #expect(title.children.map(\.kind) == [.link, .statisticsCookie])
57 }
58
59 @Test func planningHoldsTimestamps() {
60 let planning = OrgParser.parse("* a\nDEADLINE: <2026-10-04 Sun -2d> SCHEDULED: <2026-10-01 Thu>\n").root
61 .descendants().first { $0.kind == .planning }!
62 #expect(planning.children.map(\.kind) == [.timestamp, .timestamp])
63 }
64
65 @Test func itemsSplitBulletAndCheckbox() {
66 let item = OrgParser.parse(" - [X] done *now*\n more\n").root.descendants().first { $0.kind == .item }!
67 #expect(item.tokens.map(\.kind) == [.whitespace, .bullet, .whitespace, .checkbox, .whitespace])
68 #expect(item.children.map(\.kind) == [.paragraph])
69 #expect(item.children[0].text == "done *now*\n more\n")
70 }
71
72 @Test func emptyItem() {
73 let item = OrgParser.parse("-\n").root.descendants().first { $0.kind == .item }!
74 #expect(item.tokens.map(\.kind) == [.bullet, .newline])
75 }
76
77 @Test func tableCells() {
78 let rows = OrgParser.parse("| *a* || b\n|---+---|\n").root.descendants().filter { $0.kind == .tableRow }
79 #expect(rows[0].children.map(\.text) == [" *a* ", "", " b"])
80 #expect(rows[0].children[0].children.map(\.kind) == [.bold])
81 #expect(rows[1].children.isEmpty)
82 }
83
84 @Test func footnoteDefinitionLabelIsNotAReference() {
85 let definition = OrgParser.parse("[fn:1] see [fn:2]\n").root.descendants().first { $0.kind == .footnoteDefinition }!
86 #expect(definition.tokens.first?.text == "[fn:1]")
87 #expect(definition.children.map(\.kind) == [.footnoteReference])
88 }
89}
Tests/OrgCoreTests/ParserSectionTests.swift +5 −4
@@ -17,7 +17,7 @@ struct ParserSectionTests {
1717 }
1818
1919 @Test func zerothSectionHoldsPreamble() {
20 #expect(nodeKinds("text\n* a\n") == [.document, .zerothSection, .paragraph, .section, .heading])
20 #expect(nodeKinds("text\n* a\n") == [.document, .zerothSection, .paragraph, .section, .heading, .title])
2121 }
2222
2323 @Test func sectionsNestByLevel() {
@@ -30,10 +30,11 @@ struct ParserSectionTests {
3030 }
3131
3232 @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])
33 let text = "** TODO [#A] Write the plan :work:urgent: \n"
34 let parts = tokens(of: .heading, in: text)
35 #expect(parts.map(\.kind) == [.stars, .whitespace, .todoKeyword, .whitespace, .priority, .whitespace, .whitespace, .tags, .whitespace, .newline])
3536 #expect(parts.first { $0.kind == .tags }?.text == ":work:urgent:")
36 #expect(parts.first { $0.kind == .title }?.text == "Write the plan")
37 #expect(OrgParser.parse(text).root.descendants().first { $0.kind == .title }?.text == "Write the plan")
3738 }
3839
3940 @Test func todoKeywordsComeFromSettings() {
Tests/OrgCoreTests/RoundTripTests.swift +4
@@ -20,6 +20,10 @@ let fragments = [
2020 ":LOGBOOK:", "CLOCK: [2026-10-04 Sun 10:00]", "SCHEDULED: <2026-10-04 Sun>", "- item", " - nested",
2121 "\t+ tab", "1. one", "| a | b |", "|---+---|", "#+TBLFM: $2=$1", "# comment", ": fixed", "-----",
2222 "[fn:1] note", "#+NAME: x", "plain text", "é", "😀", "e\u{301}", " ", "\t", "\n", "\n", "\r\n", "\r", "",
23 "*bold*", "/it/ ", "=v=", "~c~", "_u_", "+s+", "*", "/", "=", "[[https://a.b][d *b*]]", "[[x]]", "[[", "]]",
24 "<2026-10-04 Sun 10:00 +1w -2d>", "[2026-10-04]--[2026-10-05]", "<", ">", "[fn:2]", "[fn::in [x]]", "[1/3]",
25 "[50%]", "<<t>>", "{{{m(a)}}}", "\\(x\\)", "a\\\\", "x^2", "^{y}", "src_sh{echo}", "https://e.com.",
26 "<https://e.com>", "| *a* | b |", "||", "- [X] done", "+ [ ] todo", "-",
2327]
2428
2529func randomDocument(_ rng: inout SeededGenerator) -> String {
Tests/OrgCoreTests/TimestampTests.swift added +44
@@ -0,0 +1,44 @@
1import Testing
2@testable import OrgCore
3
4struct TimestampTests {
5 @Test func fullTimestamp() throws {
6 let stamp = try #require(Timestamp.parse("<2026-10-04 Sun 10:00-11:30 .+1w/2w --2d>"))
7 #expect(stamp.active)
8 #expect(stamp.start == Timestamp.Point(year: 2026, month: 10, day: 4, hour: 10, minute: 0))
9 #expect(stamp.end == Timestamp.Point(year: 2026, month: 10, day: 4, hour: 11, minute: 30))
10 #expect(stamp.repeater == Timestamp.Repeater(
11 kind: .restart,
12 interval: .init(value: 1, unit: .week),
13 habitDeadline: .init(value: 2, unit: .week)
14 ))
15 #expect(stamp.warning == Timestamp.Warning(firstOccurrenceOnly: true, interval: .init(value: 2, unit: .day)))
16 }
17
18 @Test func dateOnlyInactive() throws {
19 let stamp = try #require(Timestamp.parse("[2026-10-04]"))
20 #expect(!stamp.active)
21 #expect(stamp.start.hour == nil)
22 }
23
24 @Test func dateRange() throws {
25 let stamp = try #require(Timestamp.parse("[2026-10-04 Sun]--[2026-10-06 Tue]"))
26 #expect(stamp.end?.day == 6)
27 }
28
29 @Test func repeaterKinds() {
30 #expect(Timestamp.parse("<2026-10-04 +1d>")?.repeater?.kind == .cumulate)
31 #expect(Timestamp.parse("<2026-10-04 ++1m>")?.repeater?.kind == .catchUp)
32 #expect(Timestamp.parse("<2026-10-04 -3d +1y>")?.repeater?.interval == .init(value: 1, unit: .year))
33 }
34
35 @Test func oneDigitHourAndOtherLanguages() {
36 #expect(Timestamp.parse("<2026-10-04 9:05>")?.start.hour == 9)
37 #expect(Timestamp.parse("<2026-10-04 dim.>") != nil)
38 }
39
40 @Test(arguments: ["<2026-10-4>", "<2026-10-04 Sun", "[2026-10-04 Sun]x", "<2026-10-04 Sun +1x>", "<2026-10-04]", "2026-10-04"])
41 func rejects(text: String) {
42 #expect(Timestamp.parse(text) == nil)
43 }
44}
docs/plans/2026-10-04-inline-objects.md added +1085
@@ -0,0 +1,1085 @@
1# Inline Objects Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Parse org's inline objects into the lossless tree: emphasis, links, timestamps, footnote references, statistics cookies, targets, macros, inline source blocks, LaTeX fragments, line breaks and superscripts.
6
7**Architecture:** A character-level `InlineScanner` turns a run of text into text, newline and object nodes through the existing `GreenBuilder`. The parser feeds it whole paragraphs (so emphasis and links can cross one line break), heading titles, table cells, item paragraphs, footnote definitions, and planning and clock lines. `Timestamp` is both the recognizer and a public value type with repeaters and warnings.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Foundation only.
10
11**Spec:** `docs/design.md` ("OrgCore data model"); OrgSwift's `OrgInlineParser.swift` for the emphasis border rules.
12
13## Global Constraints
14
15- Every character of the input lands in exactly one token; tree text equals source text.
16- Emphasis follows org's defaults: opener preceded by start, whitespace or `-({'"`; closer followed by end, whitespace or `-.,:!?;'")}\[`; body neither starts nor ends with whitespace; at most one line break.
17- `OrgCore` imports Foundation only.
18
19## Out of scope
20
21Entities (`\alpha`), subscripts, `$...$` LaTeX, export snippets, citations, radio targets, inline footnote definitions as parsed content, and description-list terms. Each is recorded for a later plan.
22
23## File structure
24
25| File | Responsibility |
26| --- | --- |
27| `Sources/OrgCore/Timestamp.swift` | `Timestamp` value type and `scanTimestamp` recognizer |
28| `Sources/OrgCore/Parser/Inline.swift` | `InlineScanner` and `Parser.inline(_:)` |
29| `Sources/OrgCore/Syntax/SyntaxKind.swift` | New token and object kinds; `title` becomes a node |
30| `Sources/OrgCore/Parser/Parser.swift` | Paragraph spans, item bullets and checkboxes, table cells, footnote labels, inline planning and clock lines |
31| `Sources/OrgCore/Parser/Lines.swift` | Empty final line ending sliced from the source, so spans can end there |
32
33---
34
35### Task 1: Timestamps
36
37**Files:**
38- Create: `Sources/OrgCore/Timestamp.swift`
39- Test: `Tests/OrgCoreTests/TimestampTests.swift`
40
41**Interfaces:**
42- Produces: `Timestamp` (`active`, `start: Point`, `end: Point?`, `repeater: Repeater?`, `warning: Warning?`), `Timestamp.parse(_:) -> Timestamp?`; internal `scanTimestamp(_ chars: [Character], at: Int, limit: Int) -> (stamp: Timestamp, end: Int)?`.
43
44- [ ] **Step 1: Write the failing tests**
45
46```swift
47import Testing
48@testable import OrgCore
49
50struct TimestampTests {
51 @Test func fullTimestamp() throws {
52 let stamp = try #require(Timestamp.parse("<2026-10-04 Sun 10:00-11:30 .+1w/2w --2d>"))
53 #expect(stamp.active)
54 #expect(stamp.start == Timestamp.Point(year: 2026, month: 10, day: 4, hour: 10, minute: 0))
55 #expect(stamp.end == Timestamp.Point(year: 2026, month: 10, day: 4, hour: 11, minute: 30))
56 #expect(stamp.repeater == Timestamp.Repeater(
57 kind: .restart,
58 interval: .init(value: 1, unit: .week),
59 habitDeadline: .init(value: 2, unit: .week)
60 ))
61 #expect(stamp.warning == Timestamp.Warning(firstOccurrenceOnly: true, interval: .init(value: 2, unit: .day)))
62 }
63
64 @Test func dateOnlyInactive() throws {
65 let stamp = try #require(Timestamp.parse("[2026-10-04]"))
66 #expect(!stamp.active)
67 #expect(stamp.start.hour == nil)
68 }
69
70 @Test func dateRange() throws {
71 let stamp = try #require(Timestamp.parse("[2026-10-04 Sun]--[2026-10-06 Tue]"))
72 #expect(stamp.end?.day == 6)
73 }
74
75 @Test func repeaterKinds() {
76 #expect(Timestamp.parse("<2026-10-04 +1d>")?.repeater?.kind == .cumulate)
77 #expect(Timestamp.parse("<2026-10-04 ++1m>")?.repeater?.kind == .catchUp)
78 #expect(Timestamp.parse("<2026-10-04 -3d +1y>")?.repeater?.interval == .init(value: 1, unit: .year))
79 }
80
81 @Test func oneDigitHourAndOtherLanguages() {
82 #expect(Timestamp.parse("<2026-10-04 9:05>")?.start.hour == 9)
83 #expect(Timestamp.parse("<2026-10-04 dim.>") != nil)
84 }
85
86 @Test(arguments: ["<2026-10-4>", "<2026-10-04 Sun", "[2026-10-04 Sun]x", "<2026-10-04 Sun +1x>", "<2026-10-04]", "2026-10-04"])
87 func rejects(text: String) {
88 #expect(Timestamp.parse(text) == nil)
89 }
90}
91```
92
93- [ ] **Step 2: Run to verify failure**
94
95Run: `swift test --filter TimestampTests`
96Expected: build failure, `cannot find 'Timestamp' in scope`.
97
98- [ ] **Step 3: Implement**
99
100```swift
101public struct Timestamp: Sendable, Equatable {
102 public enum Unit: Character, Sendable {
103 case hour = "h", day = "d", week = "w", month = "m", year = "y"
104 }
105
106 public struct Interval: Sendable, Equatable {
107 public var value: Int
108 public var unit: Unit
109 }
110
111 public enum RepeaterKind: String, Sendable {
112 case cumulate = "+", catchUp = "++", restart = ".+"
113 }
114
115 public struct Repeater: Sendable, Equatable {
116 public var kind: RepeaterKind
117 public var interval: Interval
118 /// The habit deadline from `.+2d/3d`.
119 public var habitDeadline: Interval?
120 }
121
122 public struct Warning: Sendable, Equatable {
123 /// `--` warns only for the first occurrence of a repeated timestamp.
124 public var firstOccurrenceOnly: Bool
125 public var interval: Interval
126 }
127
128 public struct Point: Sendable, Equatable {
129 public var year: Int
130 public var month: Int
131 public var day: Int
132 public var hour: Int?
133 public var minute: Int?
134 }
135
136 public var active: Bool
137 public var start: Point
138 /// End of a same-day time range or of a `--` date range.
139 public var end: Point?
140 public var repeater: Repeater?
141 public var warning: Warning?
142
143 /// Parses exactly one timestamp or range, with nothing before or after it.
144 public static func parse(_ text: some StringProtocol) -> Timestamp? {
145 let chars = Array(text)
146 guard let result = scanTimestamp(chars, at: 0, limit: chars.count), result.end == chars.count else { return nil }
147 return result.stamp
148 }
149}
150
151/// A timestamp or `--` range starting at `start`, and the index after it.
152func scanTimestamp(_ chars: [Character], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
153 guard let first = scanSingleTimestamp(chars, at: start, limit: limit) else { return nil }
154 if first.stamp.end == nil, first.end + 2 < limit, chars[first.end] == "-", chars[first.end + 1] == "-",
155 let second = scanSingleTimestamp(chars, at: first.end + 2, limit: limit),
156 second.stamp.active == first.stamp.active, second.stamp.end == nil {
157 var range = first.stamp
158 range.end = second.stamp.start
159 return (range, second.end)
160 }
161 return first
162}
163
164/// `<YYYY-MM-DD DAY HH:MM-HH:MM REPEATER WARNING>`, or the same in `[...]` for inactive.
165private func scanSingleTimestamp(_ chars: [Character], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
166 guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil }
167 let active = chars[start] == "<"
168 let close: Character = active ? ">" : "]"
169 var j = start + 1
170
171 func number(_ minDigits: Int, _ maxDigits: Int) -> Int? {
172 var k = j
173 while k < limit, k - j < maxDigits, chars[k].isASCII, chars[k].isNumber { k += 1 }
174 guard k - j >= minDigits else { return nil }
175 let value = Int(String(chars[j..<k]))!
176 j = k
177 return value
178 }
179
180 func take(_ c: Character) -> Bool {
181 guard j < limit, chars[j] == c else { return false }
182 j += 1
183 return true
184 }
185
186 func interval() -> Timestamp.Interval? {
187 let before = j
188 guard let value = number(1, 9), j < limit, let unit = Timestamp.Unit(rawValue: chars[j]) else {
189 j = before
190 return nil
191 }
192 j += 1
193 return Timestamp.Interval(value: value, unit: unit)
194 }
195
196 func repeater() -> Timestamp.Repeater? {
197 let before = j
198 let kind: Timestamp.RepeaterKind
199 if j + 1 < limit, chars[j] == ".", chars[j + 1] == "+" {
200 kind = .restart
201 j += 2
202 } else if j + 1 < limit, chars[j] == "+", chars[j + 1] == "+" {
203 kind = .catchUp
204 j += 2
205 } else if take("+") {
206 kind = .cumulate
207 } else {
208 return nil
209 }
210 guard let value = interval() else {
211 j = before
212 return nil
213 }
214 var deadline: Timestamp.Interval?
215 if take("/") {
216 deadline = interval()
217 if deadline == nil {
218 j = before
219 return nil
220 }
221 }
222 return Timestamp.Repeater(kind: kind, interval: value, habitDeadline: deadline)
223 }
224
225 func warning() -> Timestamp.Warning? {
226 let before = j
227 guard take("-") else { return nil }
228 let firstOnly = take("-")
229 guard let value = interval() else {
230 j = before
231 return nil
232 }
233 return Timestamp.Warning(firstOccurrenceOnly: firstOnly, interval: value)
234 }
235
236 guard let year = number(4, 4), take("-"), let month = number(2, 2), take("-"), let day = number(2, 2) else {
237 return nil
238 }
239 var stamp = Timestamp(
240 active: active,
241 start: Timestamp.Point(year: year, month: month, day: day, hour: nil, minute: nil),
242 end: nil, repeater: nil, warning: nil
243 )
244
245 // Day name: anything but digits, whitespace, `+`, `-`, `]` and `>`, in any language.
246 if j < limit, chars[j] == " " {
247 var k = j + 1
248 while k < limit, !(chars[k].isNumber || chars[k].isWhitespace || "+-]>".contains(chars[k])) { k += 1 }
249 if k > j + 1 { j = k }
250 }
251
252 let beforeTime = j
253 if take(" "), let hour = number(1, 2), take(":"), let minute = number(2, 2) {
254 stamp.start.hour = hour
255 stamp.start.minute = minute
256 let beforeEnd = j
257 if take("-"), let endHour = number(1, 2), take(":"), let endMinute = number(2, 2) {
258 stamp.end = Timestamp.Point(year: year, month: month, day: day, hour: endHour, minute: endMinute)
259 } else {
260 j = beforeEnd
261 }
262 } else {
263 j = beforeTime
264 }
265
266 while j < limit, chars[j] == " " {
267 let beforeModifier = j
268 j += 1
269 if let value = repeater() {
270 stamp.repeater = value
271 } else if let value = warning() {
272 stamp.warning = value
273 } else {
274 j = beforeModifier
275 break
276 }
277 }
278
279 guard take(close) else { return nil }
280 return (stamp, j)
281}
282```
283
284- [ ] **Step 4: Run to verify pass**
285
286Run: `swift test --filter TimestampTests`
287Expected: all pass.
288
289- [ ] **Step 5: Commit**
290
291```bash
292git add Sources/OrgCore/Timestamp.swift Tests/OrgCoreTests/TimestampTests.swift
293git commit -m "Add timestamp recognizer and value type"
294```
295
296---
297
298### Task 2: Inline scanner and parser integration
299
300**Files:**
301- Create: `Sources/OrgCore/Parser/Inline.swift`
302- Modify: `Sources/OrgCore/Syntax/SyntaxKind.swift`, `Sources/OrgCore/Parser/Parser.swift`, `Sources/OrgCore/Parser/Lines.swift`
303- Test: `Tests/OrgCoreTests/InlineTests.swift`; update `Tests/OrgCoreTests/ParserSectionTests.swift` (title is a node)
304
305**Interfaces:**
306- Consumes: `scanTimestamp` (Task 1), `GreenBuilder`, `Parser`.
307- Produces: `InlineScanner(chars:)` with `scan(_:into:inLink:)`; `Parser.inline(_ text: Substring)`; `Parser.span(from:through:)`; node kinds `title`, `tableCell`, `bold`, `italic`, `underline`, `strikeThrough`, `verbatim`, `code`, `link`, `linkDescription`, `timestamp`, `footnoteReference`, `statisticsCookie`, `target`, `macro`, `inlineSourceBlock`, `latexFragment`, `lineBreak`, `superscript`; token kinds `marker`, `linkPath`, `bullet`, `checkbox`.
308
309- [ ] **Step 1: Write the failing tests**
310
311```swift
312import Testing
313@testable import OrgCore
314
315/// `kind:text` for each object directly inside the first paragraph.
316func objects(_ text: String) -> [String] {
317 let paragraph = OrgParser.parse(text).root.descendants().first { $0.kind == .paragraph }!
318 return paragraph.children.map { "\($0.kind.rawValue):\($0.text)" }
319}
320
321struct InlineTests {
322 @Test(arguments: [
323 ("*b* /i/ _u_ +s+ =v= ~c~", ["bold:*b*", "italic:/i/", "underline:_u_", "strikeThrough:+s+", "verbatim:=v=", "code:~c~"]),
324 ("a*b* (*c*) \"*d*\"", ["bold:*c*", "bold:*d*"]),
325 ("x *y * z", []),
326 ("*y*z", []),
327 ("*a\nb*", ["bold:*a\nb*"]),
328 ("*a\nb\nc*", []),
329 ("=*not bold*=", ["verbatim:=*not bold*="]),
330 ("[[https://a.b][the *site*]]", ["link:[[https://a.b][the *site*]]"]),
331 ("[[file:x.org]]", ["link:[[file:x.org]]"]),
332 ("<https://a.b/c>", ["link:<https://a.b/c>"]),
333 ("see https://a.b/c.", ["link:https://a.b/c"]),
334 ("<2026-10-04 Sun 10:00-11:30 +1w -2d>", ["timestamp:<2026-10-04 Sun 10:00-11:30 +1w -2d>"]),
335 ("[2026-10-04 Sun]--[2026-10-06 Tue]", ["timestamp:[2026-10-04 Sun]--[2026-10-06 Tue]"]),
336 ("a [fn:1] and [fn::inline [x] note]", ["footnoteReference:[fn:1]", "footnoteReference:[fn::inline [x] note]"]),
337 ("a [1/3] [50%]", ["statisticsCookie:[1/3]", "statisticsCookie:[50%]"]),
338 ("a <<target>> {{{m(x, y)}}}", ["target:<<target>>", "macro:{{{m(x, y)}}}"]),
339 ("\\(x^2\\) and src_sh[:results raw]{echo {a}}", ["latexFragment:\\(x^2\\)", "inlineSourceBlock:src_sh[:results raw]{echo {a}}"]),
340 ("N^2 and e^{i}", ["superscript:^2", "superscript:^{i}"]),
341 ("end\\\\\nnext", ["lineBreak:\\\\"]),
342 ])
343 func recognizes(text: String, expected: [String]) {
344 #expect(objects(text) == expected)
345 }
346
347 @Test func emphasisNests() {
348 let bold = OrgParser.parse("*bold /italic/ x*\n").root.descendants().first { $0.kind == .bold }!
349 #expect(bold.children.map(\.kind) == [.italic])
350 #expect(bold.tokens.first?.kind == .marker)
351 }
352
353 @Test func linkParts() {
354 let link = OrgParser.parse("[[id:abc][desc]]\n").root.descendants().first { $0.kind == .link }!
355 #expect(link.tokens.map(\.kind) == [.marker, .linkPath, .marker, .marker])
356 #expect(link.tokens.first { $0.kind == .linkPath }?.text == "id:abc")
357 #expect(link.children.map(\.kind) == [.linkDescription])
358 }
359
360 @Test func noLinksInsideLinkDescriptions() {
361 let link = OrgParser.parse("[[a][see https://b.c]]\n").root.descendants().first { $0.kind == .link }!
362 #expect(link.descendants().filter { $0.kind == .link }.count == 1)
363 }
364
365 @Test func headingTitlesHoldObjects() {
366 let title = OrgParser.parse("* TODO Read [[https://a.b][it]] [1/2]\n").root.descendants().first { $0.kind == .title }!
367 #expect(title.children.map(\.kind) == [.link, .statisticsCookie])
368 }
369
370 @Test func planningHoldsTimestamps() {
371 let planning = OrgParser.parse("* a\nDEADLINE: <2026-10-04 Sun -2d> SCHEDULED: <2026-10-01 Thu>\n").root
372 .descendants().first { $0.kind == .planning }!
373 #expect(planning.children.map(\.kind) == [.timestamp, .timestamp])
374 }
375
376 @Test func itemsSplitBulletAndCheckbox() {
377 let item = OrgParser.parse(" - [X] done *now*\n more\n").root.descendants().first { $0.kind == .item }!
378 #expect(item.tokens.map(\.kind) == [.whitespace, .bullet, .whitespace, .checkbox, .whitespace])
379 #expect(item.children.map(\.kind) == [.paragraph])
380 #expect(item.children[0].text == "done *now*\n more\n")
381 }
382
383 @Test func emptyItem() {
384 let item = OrgParser.parse("-\n").root.descendants().first { $0.kind == .item }!
385 #expect(item.tokens.map(\.kind) == [.bullet, .newline])
386 }
387
388 @Test func tableCells() {
389 let rows = OrgParser.parse("| *a* || b\n|---+---|\n").root.descendants().filter { $0.kind == .tableRow }
390 #expect(rows[0].children.map(\.text) == [" *a* ", "", " b"])
391 #expect(rows[0].children[0].children.map(\.kind) == [.bold])
392 #expect(rows[1].children.isEmpty)
393 }
394
395 @Test func footnoteDefinitionLabelIsNotAReference() {
396 let definition = OrgParser.parse("[fn:1] see [fn:2]\n").root.descendants().first { $0.kind == .footnoteDefinition }!
397 #expect(definition.tokens.first?.text == "[fn:1]")
398 #expect(definition.children.map(\.kind) == [.footnoteReference])
399 }
400}
401```
402
403Update the two `ParserSectionTests` expectations that assumed `title` was a token:
404
405```diff
406@@ -17,7 +17,7 @@ struct ParserSectionTests {
407 }
408
409 @Test func zerothSectionHoldsPreamble() {
410- #expect(nodeKinds("text\n* a\n") == [.document, .zerothSection, .paragraph, .section, .heading])
411+ #expect(nodeKinds("text\n* a\n") == [.document, .zerothSection, .paragraph, .section, .heading, .title])
412 }
413
414 @Test func sectionsNestByLevel() {
415@@ -30,10 +30,11 @@ struct ParserSectionTests {
416 }
417
418 @Test func headingTokens() {
419- let parts = tokens(of: .heading, in: "** TODO [#A] Write the plan :work:urgent: \n")
420- #expect(parts.map(\.kind) == [.stars, .whitespace, .todoKeyword, .whitespace, .priority, .whitespace, .title, .whitespace, .tags, .whitespace, .newline])
421+ let text = "** TODO [#A] Write the plan :work:urgent: \n"
422+ let parts = tokens(of: .heading, in: text)
423+ #expect(parts.map(\.kind) == [.stars, .whitespace, .todoKeyword, .whitespace, .priority, .whitespace, .whitespace, .tags, .whitespace, .newline])
424 #expect(parts.first { $0.kind == .tags }?.text == ":work:urgent:")
425- #expect(parts.first { $0.kind == .title }?.text == "Write the plan")
426+ #expect(OrgParser.parse(text).root.descendants().first { $0.kind == .title }?.text == "Write the plan")
427 }
428
429 @Test func todoKeywordsComeFromSettings() {
430```
431
432- [ ] **Step 2: Run to verify failure**
433
434Run: `swift test --filter InlineTests`
435Expected: build failure on the new `SyntaxKind` cases.
436
437- [ ] **Step 3: Replace `SyntaxKind.swift`**
438
439```swift
440public enum SyntaxKind: String, Sendable {
441 // Tokens
442 case text, newline, whitespace
443 case stars, todoKeyword, priority, tags
444 case marker, linkPath, bullet, checkbox
445
446 // Elements
447 case document, zerothSection, section, heading, title
448 case planning, propertyDrawer, nodeProperty, drawer, clock
449 case paragraph, plainList, item, table, tableRow, tableCell, tableFormula
450 case block, dynamicBlock, keyword, affiliatedKeyword
451 case comment, fixedWidth, horizontalRule, footnoteDefinition
452
453 // Objects
454 case bold, italic, underline, strikeThrough, verbatim, code
455 case link, linkDescription, timestamp, footnoteReference, statisticsCookie
456 case target, macro, inlineSourceBlock, latexFragment, lineBreak, superscript
457}
458```
459
460- [ ] **Step 4: Create `Inline.swift`**
461
462```swift
463/// Characters allowed before an emphasis opener, besides whitespace and the start of the run.
464private let emphasisPre: Set<Character> = ["-", "(", "{", "'", "\""]
465
466/// Characters allowed after an emphasis closer, besides whitespace and the end of the run.
467private let emphasisPost: Set<Character> = ["-", ".", ",", ":", "!", "?", ";", "'", "\"", ")", "}", "\\", "["]
468
469private let emphasisKinds: [Character: SyntaxKind] = [
470 "*": .bold, "/": .italic, "_": .underline, "+": .strikeThrough, "=": .verbatim, "~": .code,
471]
472
473private let angleLinkSchemes: Set<String> = [
474 "http", "https", "mailto", "file", "id", "doi", "ftp", "news", "shell", "elisp", "info", "help", "attachment",
475]
476
477private let plainLinkPrefixes = ["https://", "http://", "mailto:", "file:"]
478
479/// "\r\n" is a single Character, so both forms count.
480func isNewline(_ c: Character) -> Bool {
481 c == "\n" || c == "\r\n"
482}
483
484enum InlineMatch {
485 case emphasis(SyntaxKind, open: Int, close: Int)
486 case link(path: Range<Int>, description: Range<Int>?, whole: Range<Int>)
487 case object(SyntaxKind, Range<Int>)
488
489 var end: Int {
490 switch self {
491 case .emphasis(_, _, let close): close + 1
492 case .link(_, _, let whole): whole.upperBound
493 case .object(_, let range): range.upperBound
494 }
495 }
496}
497
498/// Turns a run of text into text, newline and object tokens. Every character ends up in
499/// exactly one token.
500struct InlineScanner {
501 let chars: [Character]
502
503 func scan(_ range: Range<Int>, into b: inout GreenBuilder, inLink: Bool = false) {
504 var textStart = range.lowerBound
505 var i = range.lowerBound
506 while i < range.upperBound {
507 if let match = match(at: i, in: range, inLink: inLink) {
508 emitText(textStart..<i, into: &b)
509 emit(match, into: &b, inLink: inLink)
510 i = match.end
511 textStart = i
512 } else {
513 i += 1
514 }
515 }
516 emitText(textStart..<range.upperBound, into: &b)
517 }
518
519 func emitText(_ range: Range<Int>, into b: inout GreenBuilder) {
520 var start = range.lowerBound
521 for k in range where isNewline(chars[k]) {
522 if start < k { b.token(.text, string(start..<k)) }
523 b.token(.newline, String(chars[k]))
524 start = k + 1
525 }
526 if start < range.upperBound { b.token(.text, string(start..<range.upperBound)) }
527 }
528
529 func emit(_ match: InlineMatch, into b: inout GreenBuilder, inLink: Bool) {
530 switch match {
531 case .emphasis(let kind, let open, let close):
532 b.start(kind)
533 b.token(.marker, string(open..<(open + 1)))
534 if kind == .verbatim || kind == .code {
535 emitText((open + 1)..<close, into: &b)
536 } else {
537 scan((open + 1)..<close, into: &b, inLink: inLink)
538 }
539 b.token(.marker, string(close..<(close + 1)))
540 b.finish()
541 case .link(let path, let description, let whole):
542 b.start(.link)
543 b.token(.marker, string(whole.lowerBound..<path.lowerBound))
544 b.token(.linkPath, string(path))
545 if let description {
546 b.token(.marker, string(path.upperBound..<description.lowerBound))
547 b.start(.linkDescription)
548 scan(description, into: &b, inLink: true)
549 b.finish()
550 b.token(.marker, string(description.upperBound..<whole.upperBound))
551 } else {
552 b.token(.marker, string(path.upperBound..<whole.upperBound))
553 }
554 b.finish()
555 case .object(let kind, let range):
556 b.start(kind)
557 emitText(range, into: &b)
558 b.finish()
559 }
560 }
561
562 func string(_ range: Range<Int>) -> String {
563 String(chars[range])
564 }
565
566 func hasPrefix(_ s: String, at i: Int, _ limit: Int) -> Bool {
567 var j = i
568 for c in s {
569 guard j < limit, chars[j] == c else { return false }
570 j += 1
571 }
572 return true
573 }
574
575 // MARK: - Recognizers
576
577 func match(at i: Int, in range: Range<Int>, inLink: Bool) -> InlineMatch? {
578 let limit = range.upperBound
579 let previous: Character? = i > range.lowerBound ? chars[i - 1] : nil
580 let afterWord = previous.map { $0.isLetter || $0.isNumber } ?? false
581
582 switch chars[i] {
583 case "[":
584 if !inLink, let link = bracketLink(i, limit) { return link }
585 if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) }
586 if let end = scanTimestamp(chars, at: i, limit: limit)?.end { return .object(.timestamp, i..<end) }
587 if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) }
588 case "<":
589 if let end = scanTimestamp(chars, at: i, limit: limit)?.end { return .object(.timestamp, i..<end) }
590 if let end = target(i, limit) { return .object(.target, i..<end) }
591 if !inLink, let end = angleLink(i, limit) { return .object(.link, i..<end) }
592 case "{":
593 if let end = macro(i, limit) { return .object(.macro, i..<end) }
594 case "\\":
595 if let end = lineBreak(i, limit) { return .object(.lineBreak, i..<end) }
596 if let end = latexFragment(i, limit) { return .object(.latexFragment, i..<end) }
597 case "^":
598 if afterWord, let end = superscript(i, limit) { return .object(.superscript, i..<end) }
599 case "s":
600 if !afterWord, let end = inlineSourceBlock(i, limit) { return .object(.inlineSourceBlock, i..<end) }
601 default:
602 break
603 }
604
605 if !inLink, !afterWord, let end = plainLink(i, limit) { return .object(.link, i..<end) }
606
607 if let kind = emphasisKinds[chars[i]],
608 previous.map({ $0.isWhitespace || emphasisPre.contains($0) }) ?? true,
609 let close = emphasisClose(i, limit) {
610 return .emphasis(kind, open: i, close: close)
611 }
612 return nil
613 }
614
615 /// org's emphasis rules: the body neither starts nor ends with whitespace, spans at most
616 /// one line break, and the closer is followed by whitespace, punctuation or the end.
617 func emphasisClose(_ i: Int, _ limit: Int) -> Int? {
618 let marker = chars[i]
619 guard i + 1 < limit, !chars[i + 1].isWhitespace else { return nil }
620 var newlines = 0
621 var j = i + 1
622 while j < limit {
623 if isNewline(chars[j]) {
624 newlines += 1
625 if newlines > 1 { return nil }
626 } else if chars[j] == marker, j > i + 1, !chars[j - 1].isWhitespace {
627 if j + 1 == limit || chars[j + 1].isWhitespace || emphasisPost.contains(chars[j + 1]) { return j }
628 }
629 j += 1
630 }
631 return nil
632 }
633
634 /// `[[path]]` or `[[path][description]]`.
635 func bracketLink(_ i: Int, _ limit: Int) -> InlineMatch? {
636 guard i + 1 < limit, chars[i + 1] == "[" else { return nil }
637 var j = i + 2
638 while j < limit, chars[j] != "]" {
639 if chars[j] == "[" || isNewline(chars[j]) { return nil }
640 if chars[j] == "\\", j + 1 < limit { j += 1 }
641 j += 1
642 }
643 guard j > i + 2, j + 1 < limit else { return nil }
644 let path = (i + 2)..<j
645 if chars[j + 1] == "]" { return .link(path: path, description: nil, whole: i..<(j + 2)) }
646 guard chars[j + 1] == "[" else { return nil }
647 let descriptionStart = j + 2
648 var depth = 0
649 var k = descriptionStart
650 while k < limit {
651 if chars[k] == "[" {
652 depth += 1
653 } else if chars[k] == "]" {
654 if depth == 0 { break }
655 depth -= 1
656 }
657 k += 1
658 }
659 guard k > descriptionStart, k + 1 < limit, chars[k + 1] == "]" else { return nil }
660 return .link(path: path, description: descriptionStart..<k, whole: i..<(k + 2))
661 }
662
663 /// `[fn:label]`, `[fn:label:definition]` or `[fn::definition]`.
664 func footnoteReference(_ i: Int, _ limit: Int) -> Int? {
665 guard hasPrefix("[fn:", at: i, limit) else { return nil }
666 var j = i + 4
667 while j < limit, chars[j].isLetter || chars[j].isNumber || chars[j] == "_" || chars[j] == "-" { j += 1 }
668 guard j < limit else { return nil }
669 if chars[j] == "]" { return j > i + 4 ? j + 1 : nil }
670 guard chars[j] == ":" else { return nil }
671 var depth = 0
672 j += 1
673 while j < limit {
674 if chars[j] == "[" {
675 depth += 1
676 } else if chars[j] == "]" {
677 if depth == 0 { return j + 1 }
678 depth -= 1
679 }
680 j += 1
681 }
682 return nil
683 }
684
685 /// `[1/3]`, `[/]`, `[50%]` or `[%]`.
686 func statisticsCookie(_ i: Int, _ limit: Int) -> Int? {
687 var j = i + 1
688 while j < limit, chars[j].isASCII, chars[j].isNumber { j += 1 }
689 guard j < limit else { return nil }
690 if chars[j] == "%" {
691 j += 1
692 } else if chars[j] == "/" {
693 j += 1
694 while j < limit, chars[j].isASCII, chars[j].isNumber { j += 1 }
695 } else {
696 return nil
697 }
698 guard j < limit, chars[j] == "]" else { return nil }
699 return j + 1
700 }
701
702 /// `<<target>>`.
703 func target(_ i: Int, _ limit: Int) -> Int? {
704 guard hasPrefix("<<", at: i, limit), i + 2 < limit, chars[i + 2] != "<" else { return nil }
705 let start = i + 2
706 var j = start
707 while j < limit, chars[j] != ">" {
708 if chars[j] == "<" || isNewline(chars[j]) { return nil }
709 j += 1
710 }
711 guard j > start, j + 1 < limit, chars[j + 1] == ">",
712 !chars[start].isWhitespace, !chars[j - 1].isWhitespace else { return nil }
713 return j + 2
714 }
715
716 /// `<scheme:path>` for a known scheme.
717 func angleLink(_ i: Int, _ limit: Int) -> Int? {
718 var j = i + 1
719 while j < limit, chars[j].isLetter { j += 1 }
720 guard j < limit, chars[j] == ":", angleLinkSchemes.contains(string((i + 1)..<j).lowercased()) else { return nil }
721 j += 1
722 let bodyStart = j
723 while j < limit, chars[j] != ">" {
724 if chars[j] == "<" || isNewline(chars[j]) { return nil }
725 j += 1
726 }
727 guard j < limit, j > bodyStart else { return nil }
728 return j + 1
729 }
730
731 /// A bare URL. Trailing sentence punctuation stays outside the link.
732 func plainLink(_ i: Int, _ limit: Int) -> Int? {
733 guard "hmf".contains(chars[i]),
734 let prefix = plainLinkPrefixes.first(where: { hasPrefix($0, at: i, limit) }) else { return nil }
735 let bodyStart = i + prefix.count
736 var j = bodyStart
737 while j < limit, !chars[j].isWhitespace, !"()<>[]\"".contains(chars[j]) { j += 1 }
738 while j > bodyStart, ".,;:!?'".contains(chars[j - 1]) { j -= 1 }
739 return j > bodyStart ? j : nil
740 }
741
742 /// `{{{name}}}` or `{{{name(arguments)}}}`.
743 func macro(_ i: Int, _ limit: Int) -> Int? {
744 guard hasPrefix("{{{", at: i, limit) else { return nil }
745 var j = i + 3
746 guard j < limit, chars[j].isLetter else { return nil }
747 while j < limit, chars[j].isLetter || chars[j].isNumber || chars[j] == "-" || chars[j] == "_" { j += 1 }
748 if j < limit, chars[j] == "(" {
749 var k = j + 1
750 while k < limit, !hasPrefix(")}}}", at: k, limit) {
751 if isNewline(chars[k]) { return nil }
752 k += 1
753 }
754 return k < limit ? k + 4 : nil
755 }
756 return hasPrefix("}}}", at: j, limit) ? j + 3 : nil
757 }
758
759 /// `\\` at the end of a line, before optional trailing blanks. The newline stays outside.
760 func lineBreak(_ i: Int, _ limit: Int) -> Int? {
761 guard hasPrefix("\\\\", at: i, limit) else { return nil }
762 var j = i + 2
763 while j < limit, chars[j] == " " || chars[j] == "\t" { j += 1 }
764 guard j == limit || isNewline(chars[j]) else { return nil }
765 return j
766 }
767
768 /// `\(...\)` or `\[...\]`.
769 func latexFragment(_ i: Int, _ limit: Int) -> Int? {
770 guard i + 1 < limit else { return nil }
771 let closer: String
772 switch chars[i + 1] {
773 case "(": closer = "\\)"
774 case "[": closer = "\\]"
775 default: return nil
776 }
777 var j = i + 2
778 while j < limit {
779 if hasPrefix(closer, at: j, limit) { return j + 2 }
780 j += 1
781 }
782 return nil
783 }
784
785 /// `^word` or `^{group}` after a letter or digit.
786 func superscript(_ i: Int, _ limit: Int) -> Int? {
787 var j = i + 1
788 guard j < limit else { return nil }
789 if chars[j] == "{" {
790 j += 1
791 while j < limit, chars[j] != "}" {
792 if isNewline(chars[j]) { return nil }
793 j += 1
794 }
795 return j < limit ? j + 1 : nil
796 }
797 let start = j
798 while j < limit, chars[j].isLetter || chars[j].isNumber { j += 1 }
799 return j > start ? j : nil
800 }
801
802 /// `src_lang{body}` or `src_lang[headers]{body}`, on one line, with balanced braces.
803 func inlineSourceBlock(_ i: Int, _ limit: Int) -> Int? {
804 guard hasPrefix("src_", at: i, limit) else { return nil }
805 var j = i + 4
806 let languageStart = j
807 while j < limit, !chars[j].isWhitespace, chars[j] != "[", chars[j] != "{" { j += 1 }
808 guard j > languageStart, j < limit else { return nil }
809 if chars[j] == "[" {
810 while j < limit, chars[j] != "]" {
811 if isNewline(chars[j]) { return nil }
812 j += 1
813 }
814 guard j < limit else { return nil }
815 j += 1
816 }
817 guard j < limit, chars[j] == "{" else { return nil }
818 var depth = 0
819 while j < limit {
820 if isNewline(chars[j]) { return nil }
821 if chars[j] == "{" {
822 depth += 1
823 } else if chars[j] == "}" {
824 depth -= 1
825 if depth == 0 { return j + 1 }
826 }
827 j += 1
828 }
829 return nil
830 }
831}
832
833extension Parser {
834 mutating func inline(_ text: Substring) {
835 let chars = Array(text)
836 InlineScanner(chars: chars).scan(0..<chars.count, into: &builder)
837 }
838}
839```
840
841- [ ] **Step 5: Wire the scanner into the parser**
842
843```diff
844diff --git a/Sources/OrgCore/Parser/Lines.swift b/Sources/OrgCore/Parser/Lines.swift
845index 32a778a..5e9a077 100644
846--- a/Sources/OrgCore/Parser/Lines.swift
847+++ b/Sources/OrgCore/Parser/Lines.swift
848@@ -27,7 +27,7 @@ func splitRawLines(_ text: String) -> [RawLine] {
849 }
850 }
851 if lineStart != scalars.endIndex {
852- lines.append(RawLine(content: text[lineStart...], ending: ""))
853+ lines.append(RawLine(content: text[lineStart...], ending: text[text.endIndex...]))
854 }
855 return lines
856 }
857diff --git a/Sources/OrgCore/Parser/Parser.swift b/Sources/OrgCore/Parser/Parser.swift
858index ac210ff..6460486 100644
859--- a/Sources/OrgCore/Parser/Parser.swift
860+++ b/Sources/OrgCore/Parser/Parser.swift
861@@ -77,10 +77,7 @@ struct Parser {
862 headingLine(lines[i])
863 i += 1
864 if i < lines.count, info[i].cls == .planning {
865- builder.start(.planning)
866- line(i)
867- i += 1
868- builder.finish()
869+ single(.planning)
870 }
871 if i < lines.count, case .drawerBegin(let name) = info[i].cls, name.uppercased() == "PROPERTIES",
872 let end = blockEnds[i] {
873@@ -173,13 +170,28 @@ struct Parser {
874 }
875 }
876
877+ /// Kinds whose single line holds inline objects (timestamps on planning and clock lines).
878+ static let inlineLineKinds: Set<SyntaxKind> = [.planning, .clock]
879+
880 mutating func single(_ kind: SyntaxKind) {
881 builder.start(kind)
882- line(i)
883+ if Self.inlineLineKinds.contains(kind) {
884+ let rest = whitespace(lines[i].content)
885+ inline(rest)
886+ builder.token(.newline, lines[i].ending)
887+ } else {
888+ line(i)
889+ }
890 i += 1
891 builder.finish()
892 }
893
894+ /// Source text from `start` through the end of line `last`, including its line ending.
895+ func span(from start: Substring.Index, through last: Int) -> Substring {
896+ let base = lines[last].ending.base
897+ return base[start..<lines[last].ending.endIndex]
898+ }
899+
900 mutating func consecutive(_ kind: SyntaxKind, limit: Int, floor: Int?, matching: (LineClass) -> Bool) {
901 builder.start(kind)
902 repeat {
903@@ -192,7 +204,7 @@ struct Parser {
904 mutating func table(limit: Int, floor: Int?) {
905 builder.start(.table)
906 while i < limit, info[i].cls == .tableRow, within(floor, i) {
907- single(.tableRow)
908+ tableRow()
909 }
910 while i < limit, info[i].cls == .keyword(key: "TBLFM"), within(floor, i) {
911 single(.tableFormula)
912@@ -200,15 +212,47 @@ struct Parser {
913 builder.finish()
914 }
915
916- mutating func footnoteDefinition(limit: Int) {
917- builder.start(.footnoteDefinition)
918- line(i)
919- i += 1
920- while i < limit, info[i].cls == .plain {
921- line(i)
922- i += 1
923+ /// A rule row (`|---+---|`) is one text token. Other rows alternate `|` markers and cells;
924+ /// every pair of pipes gets a cell, even an empty one, so columns line up.
925+ mutating func tableRow() {
926+ builder.start(.tableRow)
927+ let rest = whitespace(lines[i].content)
928+ if rest.hasPrefix("|-") {
929+ builder.token(.text, rest)
930+ } else {
931+ var cellStart = rest.startIndex
932+ var index = rest.startIndex
933+ while index < rest.endIndex {
934+ if rest[index] == "|" {
935+ if index > rest.startIndex { tableCell(rest[cellStart..<index]) }
936+ builder.token(.marker, "|")
937+ cellStart = rest.index(after: index)
938+ }
939+ index = rest.index(after: index)
940+ }
941+ if cellStart < rest.endIndex { tableCell(rest[cellStart...]) }
942 }
943+ builder.token(.newline, lines[i].ending)
944 builder.finish()
945+ i += 1
946+ }
947+
948+ mutating func tableCell(_ text: Substring) {
949+ builder.start(.tableCell)
950+ inline(text)
951+ builder.finish()
952+ }
953+
954+ mutating func footnoteDefinition(limit: Int) {
955+ var end = i + 1
956+ while end < limit, info[end].cls == .plain { end += 1 }
957+ let content = lines[i].content
958+ let close = content.firstIndex(of: "]")!
959+ builder.start(.footnoteDefinition)
960+ builder.token(.marker, content[...close])
961+ inline(span(from: content.index(after: close), through: end - 1))
962+ builder.finish()
963+ i = end
964 }
965
966 mutating func list(limit: Int, floor: Int?) {
967@@ -224,8 +268,25 @@ struct Parser {
968 /// inside the item when the item or list continues after it; two end the list.
969 mutating func item(base: Int, limit: Int) {
970 builder.start(.item)
971- line(i)
972- i += 1
973+ var rest = whitespace(lines[i].content)
974+ let bullet = rest.prefix { $0 != " " && $0 != "\t" }
975+ builder.token(.bullet, bullet)
976+ rest = whitespace(rest.dropFirst(bullet.count))
977+ if let box = checkbox(rest) {
978+ builder.token(.checkbox, box)
979+ rest = whitespace(rest.dropFirst(box.count))
980+ }
981+ // The rest of the bullet line and its continuation lines are the item's first paragraph.
982+ var end = i + 1
983+ while end < limit, within(base, end), continuesParagraph(end) { end += 1 }
984+ if rest.isEmpty, end == i + 1 {
985+ builder.token(.newline, lines[i].ending)
986+ } else {
987+ builder.start(.paragraph)
988+ inline(span(from: rest.startIndex, through: end - 1))
989+ builder.finish()
990+ }
991+ i = end
992 while i < limit, !isHeading(i) {
993 if info[i].cls == .blank {
994 var j = i
995@@ -245,15 +306,23 @@ struct Parser {
996 builder.finish()
997 }
998
999+ /// `[ ]`, `[X]`, `[x]` or `[-]`, followed by whitespace or end of line.
1000+ func checkbox(_ s: Substring) -> Substring? {
1001+ guard s.count >= 3, s.first == "[", "Xx -".contains(s.dropFirst().first!),
1002+ s.dropFirst(2).first == "]" else { return nil }
1003+ let after = s.dropFirst(3)
1004+ guard after.isEmpty || after.first == " " || after.first == "\t" else { return nil }
1005+ return s.prefix(3)
1006+ }
1007+
1008+ /// The paragraph's lines are one inline run, so emphasis and links can cross a line break.
1009 mutating func paragraph(limit: Int, floor: Int?) {
1010+ var end = i + 1
1011+ while end < limit, within(floor, end), continuesParagraph(end) { end += 1 }
1012 builder.start(.paragraph)
1013- line(i)
1014- i += 1
1015- while i < limit, within(floor, i), continuesParagraph(i) {
1016- line(i)
1017- i += 1
1018- }
1019+ inline(span(from: lines[i].content.startIndex, through: end - 1))
1020 builder.finish()
1021+ i = end
1022 }
1023
1024 /// Lines that don't start an element of their own.
1025@@ -307,7 +376,11 @@ struct Parser {
1026 }
1027
1028 let parts = splitTags(rest)
1029- builder.token(.title, parts.title)
1030+ if !parts.title.isEmpty {
1031+ builder.start(.title)
1032+ inline(parts.title)
1033+ builder.finish()
1034+ }
1035 builder.token(.whitespace, parts.gap)
1036 builder.token(.tags, parts.tags)
1037 builder.token(.whitespace, parts.trailing)
1038```
1039
1040- [ ] **Step 6: Run all tests**
1041
1042Run: `swift test`
1043Expected: all pass.
1044
1045- [ ] **Step 7: Commit**
1046
1047```bash
1048git add Sources Tests
1049git commit -m "Parse inline objects"
1050```
1051
1052---
1053
1054### Task 3: Fuzz the inline layer
1055
1056**Files:**
1057- Modify: `Tests/OrgCoreTests/RoundTripTests.swift` (fragment list)
1058
1059- [ ] **Step 1: Add inline fragments**
1060
1061```diff
1062@@ -20,6 +20,10 @@ let fragments = [
1063 ":LOGBOOK:", "CLOCK: [2026-10-04 Sun 10:00]", "SCHEDULED: <2026-10-04 Sun>", "- item", " - nested",
1064 "\t+ tab", "1. one", "| a | b |", "|---+---|", "#+TBLFM: $2=$1", "# comment", ": fixed", "-----",
1065 "[fn:1] note", "#+NAME: x", "plain text", "é", "😀", "e\u{301}", " ", "\t", "\n", "\n", "\r\n", "\r", "",
1066+ "*bold*", "/it/ ", "=v=", "~c~", "_u_", "+s+", "*", "/", "=", "[[https://a.b][d *b*]]", "[[x]]", "[[", "]]",
1067+ "<2026-10-04 Sun 10:00 +1w -2d>", "[2026-10-04]--[2026-10-05]", "<", ">", "[fn:2]", "[fn::in [x]]", "[1/3]",
1068+ "[50%]", "<<t>>", "{{{m(a)}}}", "\\(x\\)", "a\\\\", "x^2", "^{y}", "src_sh{echo}", "https://e.com.",
1069+ "<https://e.com>", "| *a* | b |", "||", "- [X] done", "+ [ ] todo", "-",
1070 ]
1071
1072 func randomDocument(_ rng: inout SeededGenerator) -> String {
1073```
1074
1075- [ ] **Step 2: Run the fuzz test and the private corpus**
1076
1077Run: `swift test` then `ORGSTAR_CORPUS=~/Documents/notes swift test --filter corpusRoundTrips`
1078Expected: all pass.
1079
1080- [ ] **Step 3: Commit**
1081
1082```bash
1083git add Tests
1084git commit -m "Fuzz inline objects"
1085```