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 @@
1/// Characters allowed before an emphasis opener, besides whitespace and the start of the run. 1/// 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> = ["-", "(", "{", "'", "\""]
3 3
4/// Characters allowed after an emphasis closer, besides whitespace and the end of the run. 4/// Characters allowed after an emphasis closer, besides whitespace and the end of the run.
5private let emphasisPost: Set<Character> = ["-", ".", ",", ":", "!", "?", ";", "'", "\"", ")", "}", "\\", "["] 5private let emphasisPost: Set<Unicode.Scalar> = ["-", ".", ",", ":", "!", "?", ";", "'", "\"", ")", "}", "\\", "["]
6
7private let emphasisKinds: [Character: SyntaxKind] = [
8 "*": .bold, "/": .italic, "_": .underline, "+": .strikeThrough, "=": .verbatim, "~": .code,
9]
10 6
11private let angleLinkSchemes: Set<String> = [ 7private let angleLinkSchemes: Set<String> = [
12 "http", "https", "mailto", "file", "id", "doi", "ftp", "news", "shell", "elisp", "info", "help", "attachment", 8 "http", "https", "mailto", "file", "id", "doi", "ftp", "news", "shell", "elisp", "info", "help", "attachment",
13] 9]
14 10
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}
16 28
17/// "\r\n" is a single Character, so both forms count. 29func isSpace(_ c: Unicode.Scalar) -> Bool {
18func isNewline(_ c: Character) -> Bool { 30 c.isASCII ? (c == " " || c == "\t" || c == "\n" || c == "\r" || c == "\u{0B}" || c == "\u{0C}") : c.properties.isWhitespace
19 c == "\n" || c == "\r\n" 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
20} 39}
21 40
22enum InlineMatch { 41enum InlineMatch {
@@ -33,16 +52,38 @@ enum InlineMatch {
33 } 52 }
34} 53}
35 54
36/// Turns a run of text into text, newline and object tokens. Every character ends up in 55/// Turns a run of text into text, newline and object tokens. Every scalar ends up in exactly
37/// exactly one token. 56/// one token. Works on unicode scalars: every recognizer starts on an ASCII character, so
57/// grapheme clusters never need to be formed.
38struct InlineScanner { 58struct 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 }
40 81
41 func scan(_ range: Range<Int>, into b: inout GreenBuilder, inLink: Bool = false) { 82 func scan(_ range: Range<Int>, into b: inout GreenBuilder, inLink: Bool = false) {
42 var textStart = range.lowerBound 83 var textStart = range.lowerBound
43 var i = range.lowerBound 84 var i = range.lowerBound
44 while i < range.upperBound { 85 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) {
46 emitText(textStart..<i, into: &b) 87 emitText(textStart..<i, into: &b)
47 emit(match, into: &b, inLink: inLink) 88 emit(match, into: &b, inLink: inLink)
48 i = match.end 89 i = match.end
@@ -54,12 +95,26 @@ struct InlineScanner {
54 emitText(textStart..<range.upperBound, into: &b) 95 emitText(textStart..<range.upperBound, into: &b)
55 } 96 }
56 97
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.
57 func emitText(_ range: Range<Int>, into b: inout GreenBuilder) { 107 func emitText(_ range: Range<Int>, into b: inout GreenBuilder) {
58 var start = range.lowerBound 108 var start = range.lowerBound
59 for k in range where isNewline(chars[k]) { 109 var k = range.lowerBound
60 if start < k { b.token(.text, string(start..<k)) } 110 while k < range.upperBound {
61 b.token(.newline, String(chars[k])) 111 if chars[k] == "\n" {
62 start = k + 1 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
63 } 118 }
64 if start < range.upperBound { b.token(.text, string(start..<range.upperBound)) } 119 if start < range.upperBound { b.token(.text, string(start..<range.upperBound)) }
65 } 120 }
@@ -98,24 +153,25 @@ struct InlineScanner {
98 } 153 }
99 154
100 func string(_ range: Range<Int>) -> String { 155 func string(_ range: Range<Int>) -> String {
101 String(chars[range]) 156 String(source[starts[range.lowerBound]..<starts[range.upperBound]])
102 } 157 }
103 158
104 func hasPrefix(_ s: String, at i: Int, _ limit: Int) -> Bool { 159 func hasPrefix(_ s: [Unicode.Scalar], at i: Int, _ limit: Int) -> Bool {
105 var j = i 160 guard i + s.count <= limit else { return false }
106 for c in s { 161 for (offset, c) in s.enumerated() where chars[i + offset] != c { return false }
107 guard j < limit, chars[j] == c else { return false }
108 j += 1
109 }
110 return true 162 return true
111 } 163 }
112 164
165 func hasPrefix(_ s: String, at i: Int, _ limit: Int) -> Bool {
166 hasPrefix(Array(s.unicodeScalars), at: i, limit)
167 }
168
113 // MARK: - Recognizers 169 // MARK: - Recognizers
114 170
115 func match(at i: Int, in range: Range<Int>, inLink: Bool) -> InlineMatch? { 171 func match(at i: Int, in range: Range<Int>, inLink: Bool) -> InlineMatch? {
116 let limit = range.upperBound 172 let limit = range.upperBound
117 let previous: Character? = i > range.lowerBound ? chars[i - 1] : nil 173 let previous: Unicode.Scalar? = i > range.lowerBound ? chars[i - 1] : nil
118 let afterWord = previous.map { $0.isLetter || $0.isNumber } ?? false 174 let afterWord = previous.map(isWordScalar) ?? false
119 175
120 switch chars[i] { 176 switch chars[i] {
121 case "[": 177 case "[":
@@ -136,14 +192,14 @@ struct InlineScanner {
136 if afterWord, let end = superscript(i, limit) { return .object(.superscript, i..<end) } 192 if afterWord, let end = superscript(i, limit) { return .object(.superscript, i..<end) }
137 case "s": 193 case "s":
138 if !afterWord, let end = inlineSourceBlock(i, limit) { return .object(.inlineSourceBlock, i..<end) } 194 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) }
139 default: 197 default:
140 break 198 break
141 } 199 }
142 200
143 if !inLink, !afterWord, let end = plainLink(i, limit) { return .object(.link, i..<end) } 201 if let kind = emphasisKind(chars[i]),
144 202 previous.map({ isSpace($0) || emphasisPre.contains($0) }) ?? true,
145 if let kind = emphasisKinds[chars[i]],
146 previous.map({ $0.isWhitespace || emphasisPre.contains($0) }) ?? true,
147 let close = emphasisClose(i, limit) { 203 let close = emphasisClose(i, limit) {
148 return .emphasis(kind, open: i, close: close) 204 return .emphasis(kind, open: i, close: close)
149 } 205 }
@@ -154,15 +210,15 @@ struct InlineScanner {
154 /// one line break, and the closer is followed by whitespace, punctuation or the end. 210 /// one line break, and the closer is followed by whitespace, punctuation or the end.
155 func emphasisClose(_ i: Int, _ limit: Int) -> Int? { 211 func emphasisClose(_ i: Int, _ limit: Int) -> Int? {
156 let marker = chars[i] 212 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 }
158 var newlines = 0 214 var newlines = 0
159 var j = i + 1 215 var j = i + 1
160 while j < limit { 216 while j < limit {
161 if isNewline(chars[j]) { 217 if isNewline(chars[j]) {
162 newlines += 1 218 newlines += 1
163 if newlines > 1 { return nil } 219 if newlines > 1 { return nil }
164 } else if chars[j] == marker, j > i + 1, !chars[j - 1].isWhitespace { 220 } else if chars[j] == marker, j > i + 1, !isSpace(chars[j - 1]) {
165 if j + 1 == limit || chars[j + 1].isWhitespace || emphasisPost.contains(chars[j + 1]) { return j } 221 if j + 1 == limit || isSpace(chars[j + 1]) || emphasisPost.contains(chars[j + 1]) { return j }
166 } 222 }
167 j += 1 223 j += 1
168 } 224 }
@@ -202,7 +258,7 @@ struct InlineScanner {
202 func footnoteReference(_ i: Int, _ limit: Int) -> Int? { 258 func footnoteReference(_ i: Int, _ limit: Int) -> Int? {
203 guard hasPrefix("[fn:", at: i, limit) else { return nil } 259 guard hasPrefix("[fn:", at: i, limit) else { return nil }
204 var j = i + 4 260 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 }
206 guard j < limit else { return nil } 262 guard j < limit else { return nil }
207 if chars[j] == "]" { return j > i + 4 ? j + 1 : nil } 263 if chars[j] == "]" { return j > i + 4 ? j + 1 : nil }
208 guard chars[j] == ":" else { return nil } 264 guard chars[j] == ":" else { return nil }
@@ -223,13 +279,13 @@ struct InlineScanner {
223 /// `[1/3]`, `[/]`, `[50%]` or `[%]`. 279 /// `[1/3]`, `[/]`, `[50%]` or `[%]`.
224 func statisticsCookie(_ i: Int, _ limit: Int) -> Int? { 280 func statisticsCookie(_ i: Int, _ limit: Int) -> Int? {
225 var j = i + 1 281 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 }
227 guard j < limit else { return nil } 283 guard j < limit else { return nil }
228 if chars[j] == "%" { 284 if chars[j] == "%" {
229 j += 1 285 j += 1
230 } else if chars[j] == "/" { 286 } else if chars[j] == "/" {
231 j += 1 287 j += 1
232 while j < limit, chars[j].isASCII, chars[j].isNumber { j += 1 } 288 while j < limit, isASCIIDigit(chars[j]) { j += 1 }
233 } else { 289 } else {
234 return nil 290 return nil
235 } 291 }
@@ -247,14 +303,14 @@ struct InlineScanner {
247 j += 1 303 j += 1
248 } 304 }
249 guard j > start, j + 1 < limit, chars[j + 1] == ">", 305 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 }
251 return j + 2 307 return j + 2
252 } 308 }
253 309
254 /// `<scheme:path>` for a known scheme. 310 /// `<scheme:path>` for a known scheme.
255 func angleLink(_ i: Int, _ limit: Int) -> Int? { 311 func angleLink(_ i: Int, _ limit: Int) -> Int? {
256 var j = i + 1 312 var j = i + 1
257 while j < limit, chars[j].isLetter { j += 1 } 313 while j < limit, chars[j].properties.isAlphabetic { j += 1 }
258 guard j < limit, chars[j] == ":", angleLinkSchemes.contains(string((i + 1)..<j).lowercased()) else { return nil } 314 guard j < limit, chars[j] == ":", angleLinkSchemes.contains(string((i + 1)..<j).lowercased()) else { return nil }
259 j += 1 315 j += 1
260 let bodyStart = j 316 let bodyStart = j
@@ -268,12 +324,11 @@ struct InlineScanner {
268 324
269 /// A bare URL. Trailing sentence punctuation stays outside the link. 325 /// A bare URL. Trailing sentence punctuation stays outside the link.
270 func plainLink(_ i: Int, _ limit: Int) -> Int? { 326 func plainLink(_ i: Int, _ limit: Int) -> Int? {
271 guard "hmf".contains(chars[i]), 327 guard let prefix = plainLinkPrefixes.first(where: { hasPrefix($0, at: i, limit) }) else { return nil }
272 let prefix = plainLinkPrefixes.first(where: { hasPrefix($0, at: i, limit) }) else { return nil }
273 let bodyStart = i + prefix.count 328 let bodyStart = i + prefix.count
274 var j = bodyStart 329 var j = bodyStart
275 while j < limit, !chars[j].isWhitespace, !"()<>[]\"".contains(chars[j]) { j += 1 } 330 while j < limit, !isSpace(chars[j]), !"()<>[]\"".unicodeScalars.contains(chars[j]) { j += 1 }
276 while j > bodyStart, ".,;:!?'".contains(chars[j - 1]) { j -= 1 } 331 while j > bodyStart, ".,;:!?'".unicodeScalars.contains(chars[j - 1]) { j -= 1 }
277 return j > bodyStart ? j : nil 332 return j > bodyStart ? j : nil
278 } 333 }
279 334
@@ -281,8 +336,8 @@ struct InlineScanner {
281 func macro(_ i: Int, _ limit: Int) -> Int? { 336 func macro(_ i: Int, _ limit: Int) -> Int? {
282 guard hasPrefix("{{{", at: i, limit) else { return nil } 337 guard hasPrefix("{{{", at: i, limit) else { return nil }
283 var j = i + 3 338 var j = i + 3
284 guard j < limit, chars[j].isLetter else { return nil } 339 guard j < limit, chars[j].properties.isAlphabetic else { return nil }
285 while j < limit, chars[j].isLetter || chars[j].isNumber || chars[j] == "-" || chars[j] == "_" { j += 1 } 340 while j < limit, isWordScalar(chars[j]) || chars[j] == "-" || chars[j] == "_" { j += 1 }
286 if j < limit, chars[j] == "(" { 341 if j < limit, chars[j] == "(" {
287 var k = j + 1 342 var k = j + 1
288 while k < limit, !hasPrefix(")}}}", at: k, limit) { 343 while k < limit, !hasPrefix(")}}}", at: k, limit) {
@@ -294,11 +349,12 @@ struct InlineScanner {
294 return hasPrefix("}}}", at: j, limit) ? j + 3 : nil 349 return hasPrefix("}}}", at: j, limit) ? j + 3 : nil
295 } 350 }
296 351
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.
298 func lineBreak(_ i: Int, _ limit: Int) -> Int? { 353 func lineBreak(_ i: Int, _ limit: Int) -> Int? {
299 guard hasPrefix("\\\\", at: i, limit) else { return nil } 354 guard hasPrefix("\\\\", at: i, limit) else { return nil }
300 var j = i + 2 355 var j = i + 2
301 while j < limit, chars[j] == " " || chars[j] == "\t" { j += 1 } 356 while j < limit, chars[j] == " " || chars[j] == "\t" { j += 1 }
357 if j + 1 < limit, chars[j] == "\r", chars[j + 1] == "\n" { return j }
302 guard j == limit || isNewline(chars[j]) else { return nil } 358 guard j == limit || isNewline(chars[j]) else { return nil }
303 return j 359 return j
304 } 360 }
@@ -312,9 +368,10 @@ struct InlineScanner {
312 case "[": closer = "\\]" 368 case "[": closer = "\\]"
313 default: return nil 369 default: return nil
314 } 370 }
371 let closing = Array(closer.unicodeScalars)
315 var j = i + 2 372 var j = i + 2
316 while j < limit { 373 while j < limit {
317 if hasPrefix(closer, at: j, limit) { return j + 2 } 374 if hasPrefix(closing, at: j, limit) { return j + 2 }
318 j += 1 375 j += 1
319 } 376 }
320 return nil 377 return nil
@@ -333,7 +390,7 @@ struct InlineScanner {
333 return j < limit ? j + 1 : nil 390 return j < limit ? j + 1 : nil
334 } 391 }
335 let start = j 392 let start = j
336 while j < limit, chars[j].isLetter || chars[j].isNumber { j += 1 } 393 while j < limit, isWordScalar(chars[j]) { j += 1 }
337 return j > start ? j : nil 394 return j > start ? j : nil
338 } 395 }
339 396
@@ -342,7 +399,7 @@ struct InlineScanner {
342 guard hasPrefix("src_", at: i, limit) else { return nil } 399 guard hasPrefix("src_", at: i, limit) else { return nil }
343 var j = i + 4 400 var j = i + 4
344 let languageStart = j 401 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 }
346 guard j > languageStart, j < limit else { return nil } 403 guard j > languageStart, j < limit else { return nil }
347 if chars[j] == "[" { 404 if chars[j] == "[" {
348 while j < limit, chars[j] != "]" { 405 while j < limit, chars[j] != "]" {
@@ -370,7 +427,7 @@ struct InlineScanner {
370 427
371extension Parser { 428extension Parser {
372 mutating func inline(_ text: Substring) { 429 mutating func inline(_ text: Substring) {
373 let chars = Array(text) 430 let scanner = InlineScanner(text)
374 InlineScanner(chars: chars).scan(0..<chars.count, into: &builder) 431 scanner.scan(0..<scanner.chars.count, into: &builder)
375 } 432 }
376} 433}
Sources/OrgCore/Timestamp.swift +19 −9
@@ -42,14 +42,14 @@ public struct Timestamp: Sendable, Equatable {
42 42
43 /// Parses exactly one timestamp or range, with nothing before or after it. 43 /// Parses exactly one timestamp or range, with nothing before or after it.
44 public static func parse(_ text: some StringProtocol) -> Timestamp? { 44 public static func parse(_ text: some StringProtocol) -> Timestamp? {
45 let chars = Array(text) 45 let chars = Array(text.unicodeScalars)
46 guard let result = scanTimestamp(chars, at: 0, limit: chars.count), result.end == chars.count else { return nil } 46 guard let result = scanTimestamp(chars, at: 0, limit: chars.count), result.end == chars.count else { return nil }
47 return result.stamp 47 return result.stamp
48 } 48 }
49} 49}
50 50
51/// A timestamp or `--` range starting at `start`, and the index after it. 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)? { 52func scanTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
53 guard let first = scanSingleTimestamp(chars, at: start, limit: limit) else { return nil } 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] == "-", 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), 55 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
62} 62}
63 63
64/// `<YYYY-MM-DD DAY HH:MM-HH:MM REPEATER WARNING>`, or the same in `[...]` for inactive. 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)? { 65private func scanSingleTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
66 guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil } 66 guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil }
67 let active = chars[start] == "<" 67 let active = chars[start] == "<"
68 let close: Character = active ? ">" : "]" 68 let close: Unicode.Scalar = active ? ">" : "]"
69 var j = start + 1 69 var j = start + 1
70 70
71 func number(_ minDigits: Int, _ maxDigits: Int) -> Int? { 71 func number(_ minDigits: Int, _ maxDigits: Int) -> Int? {
72 var k = j 72 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 }
74 guard k - j >= minDigits else { return nil } 74 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) }
76 j = k 77 j = k
77 return value 78 return value
78 } 79 }
79 80
80 func take(_ c: Character) -> Bool { 81 func take(_ c: Unicode.Scalar) -> Bool {
81 guard j < limit, chars[j] == c else { return false } 82 guard j < limit, chars[j] == c else { return false }
82 j += 1 83 j += 1
83 return true 84 return true
@@ -85,7 +86,7 @@ private func scanSingleTimestamp(_ chars: [Character], at start: Int, limit: Int
85 86
86 func interval() -> Timestamp.Interval? { 87 func interval() -> Timestamp.Interval? {
87 let before = j 88 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 {
89 j = before 90 j = before
90 return nil 91 return nil
91 } 92 }
@@ -145,7 +146,7 @@ private func scanSingleTimestamp(_ chars: [Character], at start: Int, limit: Int
145 // Day name: anything but digits, whitespace, `+`, `-`, `]` and `>`, in any language. 146 // Day name: anything but digits, whitespace, `+`, `-`, `]` and `>`, in any language.
146 if j < limit, chars[j] == " " { 147 if j < limit, chars[j] == " " {
147 var k = j + 1 148 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 }
149 if k > j + 1 { j = k } 150 if k > j + 1 { j = k }
150 } 151 }
151 152
@@ -179,3 +180,12 @@ private func scanSingleTimestamp(_ chars: [Character], at start: Int, limit: Int
179 guard take(close) else { return nil } 180 guard take(close) else { return nil }
180 return (stamp, j) 181 return (stamp, j)
181} 182}
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}