krz/orgstar

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

Commit 9d407be245

9d407be2459dbbff3b620a87d93eecea2d22afec

parent: b84ac9f2e2

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 02:34 UTC

Scan inline objects over unicode scalars

Layout: unified · split

Sources/OrgCore/Parser/Inline.swift +108 −51
@@ -1,22 +1,41 @@
11/// Characters allowed before an emphasis opener, besides whitespace and the start of the run.
2private let emphasisPre: Set<Character> = ["-", "(", "{", "'", "\""]
2private let emphasisPre: Set<Unicode.Scalar> = ["-", "(", "{", "'", "\""]
33
44/// 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]
5private let emphasisPost: Set<Unicode.Scalar> = ["-", ".", ",", ":", "!", "?", ";", "'", "\"", ")", "}", "\\", "["]
106
117private let angleLinkSchemes: Set<String> = [
128 "http", "https", "mailto", "file", "id", "doi", "ftp", "news", "shell", "elisp", "info", "help", "attachment",
139]
1410
15private let plainLinkPrefixes = ["https://", "http://", "mailto:", "file:"]
11private let plainLinkPrefixes: [[Unicode.Scalar]] = ["https://", "http://", "mailto:", "file:"].map { Array($0.unicodeScalars) }
12
13private func emphasisKind(_ c: Unicode.Scalar) -> SyntaxKind? {
14 switch c {
15 case "*": .bold
16 case "/": .italic
17 case "_": .underline
18 case "+": .strikeThrough
19 case "=": .verbatim
20 case "~": .code
21 default: nil
22 }
23}
24
25func isNewline(_ c: Unicode.Scalar) -> Bool {
26 c == "\n"
27}
1628
17/// "\r\n" is a single Character, so both forms count.
18func isNewline(_ c: Character) -> Bool {
19 c == "\n" || c == "\r\n"
29func isSpace(_ c: Unicode.Scalar) -> Bool {
30 c.isASCII ? (c == " " || c == "\t" || c == "\n" || c == "\r" || c == "\u{0B}" || c == "\u{0C}") : c.properties.isWhitespace
31}
32
33func isWordScalar(_ c: Unicode.Scalar) -> Bool {
34 if c.isASCII {
35 let v = c.value
36 return (v >= 48 && v <= 57) || (v >= 65 && v <= 90) || (v >= 97 && v <= 122)
37 }
38 return c.properties.isAlphabetic || c.properties.numericType != nil
2039}
2140
2241enum InlineMatch {
@@ -33,16 +52,38 @@ enum InlineMatch {
3352 }
3453}
3554
36/// Turns a run of text into text, newline and object tokens. Every character ends up in
37/// exactly one token.
55/// Turns a run of text into text, newline and object tokens. Every scalar ends up in exactly
56/// one token. Works on unicode scalars: every recognizer starts on an ASCII character, so
57/// grapheme clusters never need to be formed.
3858struct InlineScanner {
39 let chars: [Character]
59 let chars: [Unicode.Scalar]
60 /// Where each scalar starts in `source`, plus its end, so token text is sliced from the
61 /// source instead of rebuilt scalar by scalar.
62 let source: Substring.UnicodeScalarView
63 let starts: [String.Index]
64
65 init(_ text: Substring) {
66 source = text.unicodeScalars
67 var chars: [Unicode.Scalar] = []
68 var starts: [String.Index] = []
69 chars.reserveCapacity(text.utf8.count)
70 starts.reserveCapacity(text.utf8.count + 1)
71 var index = source.startIndex
72 while index < source.endIndex {
73 chars.append(source[index])
74 starts.append(index)
75 index = source.index(after: index)
76 }
77 starts.append(source.endIndex)
78 self.chars = chars
79 self.starts = starts
80 }
4081
4182 func scan(_ range: Range<Int>, into b: inout GreenBuilder, inLink: Bool = false) {
4283 var textStart = range.lowerBound
4384 var i = range.lowerBound
4485 while i < range.upperBound {
45 if let match = match(at: i, in: range, inLink: inLink) {
86 if Self.mayStartObject(chars[i]), let match = match(at: i, in: range, inLink: inLink) {
4687 emitText(textStart..<i, into: &b)
4788 emit(match, into: &b, inLink: inLink)
4889 i = match.end
@@ -54,12 +95,26 @@ struct InlineScanner {
5495 emitText(textStart..<range.upperBound, into: &b)
5596 }
5697
98 /// The first characters any recognizer accepts.
99 static func mayStartObject(_ c: Unicode.Scalar) -> Bool {
100 switch c {
101 case "[", "<", "{", "\\", "^", "s", "h", "m", "f", "*", "/", "_", "+", "=", "~": true
102 default: false
103 }
104 }
105
106 /// Text with each line break as its own newline token; "\r\n" stays one token.
57107 func emitText(_ range: Range<Int>, into b: inout GreenBuilder) {
58108 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
109 var k = range.lowerBound
110 while k < range.upperBound {
111 if chars[k] == "\n" {
112 let breakStart = k > start && chars[k - 1] == "\r" ? k - 1 : k
113 if start < breakStart { b.token(.text, string(start..<breakStart)) }
114 b.token(.newline, string(breakStart..<(k + 1)))
115 start = k + 1
116 }
117 k += 1
63118 }
64119 if start < range.upperBound { b.token(.text, string(start..<range.upperBound)) }
65120 }
@@ -98,24 +153,25 @@ struct InlineScanner {
98153 }
99154
100155 func string(_ range: Range<Int>) -> String {
101 String(chars[range])
156 String(source[starts[range.lowerBound]..<starts[range.upperBound]])
102157 }
103158
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 }
159 func hasPrefix(_ s: [Unicode.Scalar], at i: Int, _ limit: Int) -> Bool {
160 guard i + s.count <= limit else { return false }
161 for (offset, c) in s.enumerated() where chars[i + offset] != c { return false }
110162 return true
111163 }
112164
165 func hasPrefix(_ s: String, at i: Int, _ limit: Int) -> Bool {
166 hasPrefix(Array(s.unicodeScalars), at: i, limit)
167 }
168
113169 // MARK: - Recognizers
114170
115171 func match(at i: Int, in range: Range<Int>, inLink: Bool) -> InlineMatch? {
116172 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
173 let previous: Unicode.Scalar? = i > range.lowerBound ? chars[i - 1] : nil
174 let afterWord = previous.map(isWordScalar) ?? false
119175
120176 switch chars[i] {
121177 case "[":
@@ -136,14 +192,14 @@ struct InlineScanner {
136192 if afterWord, let end = superscript(i, limit) { return .object(.superscript, i..<end) }
137193 case "s":
138194 if !afterWord, let end = inlineSourceBlock(i, limit) { return .object(.inlineSourceBlock, i..<end) }
195 case "h", "m", "f":
196 if !inLink, !afterWord, let end = plainLink(i, limit) { return .object(.link, i..<end) }
139197 default:
140198 break
141199 }
142200
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,
201 if let kind = emphasisKind(chars[i]),
202 previous.map({ isSpace($0) || emphasisPre.contains($0) }) ?? true,
147203 let close = emphasisClose(i, limit) {
148204 return .emphasis(kind, open: i, close: close)
149205 }
@@ -154,15 +210,15 @@ struct InlineScanner {
154210 /// one line break, and the closer is followed by whitespace, punctuation or the end.
155211 func emphasisClose(_ i: Int, _ limit: Int) -> Int? {
156212 let marker = chars[i]
157 guard i + 1 < limit, !chars[i + 1].isWhitespace else { return nil }
213 guard i + 1 < limit, !isSpace(chars[i + 1]) else { return nil }
158214 var newlines = 0
159215 var j = i + 1
160216 while j < limit {
161217 if isNewline(chars[j]) {
162218 newlines += 1
163219 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 }
220 } else if chars[j] == marker, j > i + 1, !isSpace(chars[j - 1]) {
221 if j + 1 == limit || isSpace(chars[j + 1]) || emphasisPost.contains(chars[j + 1]) { return j }
166222 }
167223 j += 1
168224 }
@@ -202,7 +258,7 @@ struct InlineScanner {
202258 func footnoteReference(_ i: Int, _ limit: Int) -> Int? {
203259 guard hasPrefix("[fn:", at: i, limit) else { return nil }
204260 var j = i + 4
205 while j < limit, chars[j].isLetter || chars[j].isNumber || chars[j] == "_" || chars[j] == "-" { j += 1 }
261 while j < limit, isWordScalar(chars[j]) || chars[j] == "_" || chars[j] == "-" { j += 1 }
206262 guard j < limit else { return nil }
207263 if chars[j] == "]" { return j > i + 4 ? j + 1 : nil }
208264 guard chars[j] == ":" else { return nil }
@@ -223,13 +279,13 @@ struct InlineScanner {
223279 /// `[1/3]`, `[/]`, `[50%]` or `[%]`.
224280 func statisticsCookie(_ i: Int, _ limit: Int) -> Int? {
225281 var j = i + 1
226 while j < limit, chars[j].isASCII, chars[j].isNumber { j += 1 }
282 while j < limit, isASCIIDigit(chars[j]) { j += 1 }
227283 guard j < limit else { return nil }
228284 if chars[j] == "%" {
229285 j += 1
230286 } else if chars[j] == "/" {
231287 j += 1
232 while j < limit, chars[j].isASCII, chars[j].isNumber { j += 1 }
288 while j < limit, isASCIIDigit(chars[j]) { j += 1 }
233289 } else {
234290 return nil
235291 }
@@ -247,14 +303,14 @@ struct InlineScanner {
247303 j += 1
248304 }
249305 guard j > start, j + 1 < limit, chars[j + 1] == ">",
250 !chars[start].isWhitespace, !chars[j - 1].isWhitespace else { return nil }
306 !isSpace(chars[start]), !isSpace(chars[j - 1]) else { return nil }
251307 return j + 2
252308 }
253309
254310 /// `<scheme:path>` for a known scheme.
255311 func angleLink(_ i: Int, _ limit: Int) -> Int? {
256312 var j = i + 1
257 while j < limit, chars[j].isLetter { j += 1 }
313 while j < limit, chars[j].properties.isAlphabetic { j += 1 }
258314 guard j < limit, chars[j] == ":", angleLinkSchemes.contains(string((i + 1)..<j).lowercased()) else { return nil }
259315 j += 1
260316 let bodyStart = j
@@ -268,12 +324,11 @@ struct InlineScanner {
268324
269325 /// A bare URL. Trailing sentence punctuation stays outside the link.
270326 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 }
327 guard let prefix = plainLinkPrefixes.first(where: { hasPrefix($0, at: i, limit) }) else { return nil }
273328 let bodyStart = i + prefix.count
274329 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 }
330 while j < limit, !isSpace(chars[j]), !"()<>[]\"".unicodeScalars.contains(chars[j]) { j += 1 }
331 while j > bodyStart, ".,;:!?'".unicodeScalars.contains(chars[j - 1]) { j -= 1 }
277332 return j > bodyStart ? j : nil
278333 }
279334
@@ -281,8 +336,8 @@ struct InlineScanner {
281336 func macro(_ i: Int, _ limit: Int) -> Int? {
282337 guard hasPrefix("{{{", at: i, limit) else { return nil }
283338 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 }
339 guard j < limit, chars[j].properties.isAlphabetic else { return nil }
340 while j < limit, isWordScalar(chars[j]) || chars[j] == "-" || chars[j] == "_" { j += 1 }
286341 if j < limit, chars[j] == "(" {
287342 var k = j + 1
288343 while k < limit, !hasPrefix(")}}}", at: k, limit) {
@@ -294,11 +349,12 @@ struct InlineScanner {
294349 return hasPrefix("}}}", at: j, limit) ? j + 3 : nil
295350 }
296351
297 /// `\\` at the end of a line, before optional trailing blanks. The newline stays outside.
352 /// `\\` at the end of a line, before optional trailing blanks. The line break stays outside.
298353 func lineBreak(_ i: Int, _ limit: Int) -> Int? {
299354 guard hasPrefix("\\\\", at: i, limit) else { return nil }
300355 var j = i + 2
301356 while j < limit, chars[j] == " " || chars[j] == "\t" { j += 1 }
357 if j + 1 < limit, chars[j] == "\r", chars[j + 1] == "\n" { return j }
302358 guard j == limit || isNewline(chars[j]) else { return nil }
303359 return j
304360 }
@@ -312,9 +368,10 @@ struct InlineScanner {
312368 case "[": closer = "\\]"
313369 default: return nil
314370 }
371 let closing = Array(closer.unicodeScalars)
315372 var j = i + 2
316373 while j < limit {
317 if hasPrefix(closer, at: j, limit) { return j + 2 }
374 if hasPrefix(closing, at: j, limit) { return j + 2 }
318375 j += 1
319376 }
320377 return nil
@@ -333,7 +390,7 @@ struct InlineScanner {
333390 return j < limit ? j + 1 : nil
334391 }
335392 let start = j
336 while j < limit, chars[j].isLetter || chars[j].isNumber { j += 1 }
393 while j < limit, isWordScalar(chars[j]) { j += 1 }
337394 return j > start ? j : nil
338395 }
339396
@@ -342,7 +399,7 @@ struct InlineScanner {
342399 guard hasPrefix("src_", at: i, limit) else { return nil }
343400 var j = i + 4
344401 let languageStart = j
345 while j < limit, !chars[j].isWhitespace, chars[j] != "[", chars[j] != "{" { j += 1 }
402 while j < limit, !isSpace(chars[j]), chars[j] != "[", chars[j] != "{" { j += 1 }
346403 guard j > languageStart, j < limit else { return nil }
347404 if chars[j] == "[" {
348405 while j < limit, chars[j] != "]" {
@@ -370,7 +427,7 @@ struct InlineScanner {
370427
371428extension Parser {
372429 mutating func inline(_ text: Substring) {
373 let chars = Array(text)
374 InlineScanner(chars: chars).scan(0..<chars.count, into: &builder)
430 let scanner = InlineScanner(text)
431 scanner.scan(0..<scanner.chars.count, into: &builder)
375432 }
376433}
Sources/OrgCore/Timestamp.swift +19 −9
@@ -42,14 +42,14 @@ public struct Timestamp: Sendable, Equatable {
4242
4343 /// Parses exactly one timestamp or range, with nothing before or after it.
4444 public static func parse(_ text: some StringProtocol) -> Timestamp? {
45 let chars = Array(text)
45 let chars = Array(text.unicodeScalars)
4646 guard let result = scanTimestamp(chars, at: 0, limit: chars.count), result.end == chars.count else { return nil }
4747 return result.stamp
4848 }
4949}
5050
5151/// 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)? {
52func scanTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
5353 guard let first = scanSingleTimestamp(chars, at: start, limit: limit) else { return nil }
5454 if first.stamp.end == nil, first.end + 2 < limit, chars[first.end] == "-", chars[first.end + 1] == "-",
5555 let second = scanSingleTimestamp(chars, at: first.end + 2, limit: limit),
@@ -62,22 +62,23 @@ func scanTimestamp(_ chars: [Character], at start: Int, limit: Int) -> (stamp: T
6262}
6363
6464/// `<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)? {
65private func scanSingleTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
6666 guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil }
6767 let active = chars[start] == "<"
68 let close: Character = active ? ">" : "]"
68 let close: Unicode.Scalar = active ? ">" : "]"
6969 var j = start + 1
7070
7171 func number(_ minDigits: Int, _ maxDigits: Int) -> Int? {
7272 var k = j
73 while k < limit, k - j < maxDigits, chars[k].isASCII, chars[k].isNumber { k += 1 }
73 while k < limit, k - j < maxDigits, isASCIIDigit(chars[k]) { k += 1 }
7474 guard k - j >= minDigits else { return nil }
75 let value = Int(String(chars[j..<k]))!
75 var value = 0
76 for digit in chars[j..<k] { value = value * 10 + Int(digit.value - 48) }
7677 j = k
7778 return value
7879 }
7980
80 func take(_ c: Character) -> Bool {
81 func take(_ c: Unicode.Scalar) -> Bool {
8182 guard j < limit, chars[j] == c else { return false }
8283 j += 1
8384 return true
@@ -85,7 +86,7 @@ private func scanSingleTimestamp(_ chars: [Character], at start: Int, limit: Int
8586
8687 func interval() -> Timestamp.Interval? {
8788 let before = j
88 guard let value = number(1, 9), j < limit, let unit = Timestamp.Unit(rawValue: chars[j]) else {
89 guard let value = number(1, 9), j < limit, let unit = Timestamp.Unit(rawValue: Character(chars[j])) else {
8990 j = before
9091 return nil
9192 }
@@ -145,7 +146,7 @@ private func scanSingleTimestamp(_ chars: [Character], at start: Int, limit: Int
145146 // Day name: anything but digits, whitespace, `+`, `-`, `]` and `>`, in any language.
146147 if j < limit, chars[j] == " " {
147148 var k = j + 1
148 while k < limit, !(chars[k].isNumber || chars[k].isWhitespace || "+-]>".contains(chars[k])) { k += 1 }
149 while k < limit, !(isDigit(chars[k]) || chars[k].properties.isWhitespace || "+-]>".unicodeScalars.contains(chars[k])) { k += 1 }
149150 if k > j + 1 { j = k }
150151 }
151152
@@ -179,3 +180,12 @@ private func scanSingleTimestamp(_ chars: [Character], at start: Int, limit: Int
179180 guard take(close) else { return nil }
180181 return (stamp, j)
181182}
183
184func isASCIIDigit(_ c: Unicode.Scalar) -> Bool {
185 c.value >= 48 && c.value <= 57
186}
187
188/// Any numeric character, as `Character.isNumber` would see it in a day name.
189func isDigit(_ c: Unicode.Scalar) -> Bool {
190 c.properties.numericType != nil
191}