Sources/OrgCore/Timestamp.swift
222 lines · 7854 bytes
19 symbols in this file
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.unicodeScalars)
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: [Unicode.Scalar], 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/// The end of the timestamp object at `start`. Org reads anything matching
65/// `org-ts-regexp-both` as one, `<2026-10-07 Wed 9am>` included, so a timestamp `parse` rejects
66/// is still an object.
67func timestampEnd(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> Int? {
68 if let strict = scanTimestamp(chars, at: start, limit: limit) { return strict.end }
69 guard let first = lenientTimestampEnd(chars, at: start, limit: limit) else { return nil }
70 if first + 2 < limit, chars[first] == "-", chars[first + 1] == "-",
71 let second = lenientTimestampEnd(chars, at: first + 2, limit: limit) {
72 return second
73 }
74 return first
75}
76
77/// `[[<]YYYY-MM-DD( .*?)?[]>]`.
78private func lenientTimestampEnd(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> Int? {
79 guard start + 11 < limit, chars[start] == "<" || chars[start] == "[" else { return nil }
80 for (k, c) in "dddd-dd-dd".unicodeScalars.enumerated() {
81 let actual = chars[start + 1 + k]
82 guard c == "d" ? isASCIIDigit(actual) : actual == c else { return nil }
83 }
84 var j = start + 11
85 if chars[j] == "]" || chars[j] == ">" { return j + 1 }
86 guard chars[j] == " " else { return nil }
87 j += 1
88 while j < limit, chars[j] != "\n" {
89 if chars[j] == "]" || chars[j] == ">" { return j + 1 }
90 j += 1
91 }
92 return nil
93}
94
95/// `<YYYY-MM-DD DAY HH:MM-HH:MM REPEATER WARNING>`, or the same in `[...]` for inactive.
96private func scanSingleTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
97 guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil }
98 let active = chars[start] == "<"
99 let close: Unicode.Scalar = active ? ">" : "]"
100 var j = start + 1
101
102 func number(_ minDigits: Int, _ maxDigits: Int) -> Int? {
103 var k = j
104 while k < limit, k - j < maxDigits, isASCIIDigit(chars[k]) { k += 1 }
105 guard k - j >= minDigits else { return nil }
106 var value = 0
107 for digit in chars[j..<k] { value = value * 10 + Int(digit.value - 48) }
108 j = k
109 return value
110 }
111
112 func take(_ c: Unicode.Scalar) -> Bool {
113 guard j < limit, chars[j] == c else { return false }
114 j += 1
115 return true
116 }
117
118 func interval() -> Timestamp.Interval? {
119 let before = j
120 guard let value = number(1, 9), j < limit, let unit = Timestamp.Unit(rawValue: Character(chars[j])) else {
121 j = before
122 return nil
123 }
124 j += 1
125 return Timestamp.Interval(value: value, unit: unit)
126 }
127
128 func repeater() -> Timestamp.Repeater? {
129 let before = j
130 let kind: Timestamp.RepeaterKind
131 if j + 1 < limit, chars[j] == ".", chars[j + 1] == "+" {
132 kind = .restart
133 j += 2
134 } else if j + 1 < limit, chars[j] == "+", chars[j + 1] == "+" {
135 kind = .catchUp
136 j += 2
137 } else if take("+") {
138 kind = .cumulate
139 } else {
140 return nil
141 }
142 guard let value = interval() else {
143 j = before
144 return nil
145 }
146 var deadline: Timestamp.Interval?
147 if take("/") {
148 deadline = interval()
149 if deadline == nil {
150 j = before
151 return nil
152 }
153 }
154 return Timestamp.Repeater(kind: kind, interval: value, habitDeadline: deadline)
155 }
156
157 func warning() -> Timestamp.Warning? {
158 let before = j
159 guard take("-") else { return nil }
160 let firstOnly = take("-")
161 guard let value = interval() else {
162 j = before
163 return nil
164 }
165 return Timestamp.Warning(firstOccurrenceOnly: firstOnly, interval: value)
166 }
167
168 guard let year = number(4, 4), take("-"), let month = number(2, 2), take("-"), let day = number(2, 2) else {
169 return nil
170 }
171 var stamp = Timestamp(
172 active: active,
173 start: Timestamp.Point(year: year, month: month, day: day, hour: nil, minute: nil),
174 end: nil, repeater: nil, warning: nil
175 )
176
177 // Day name: anything but digits, whitespace, `+`, `-`, `]` and `>`, in any language.
178 if j < limit, chars[j] == " " {
179 var k = j + 1
180 while k < limit, !(isDigit(chars[k]) || chars[k].properties.isWhitespace || "+-]>".unicodeScalars.contains(chars[k])) { k += 1 }
181 if k > j + 1 { j = k }
182 }
183
184 let beforeTime = j
185 if take(" "), let hour = number(1, 2), take(":"), let minute = number(2, 2) {
186 stamp.start.hour = hour
187 stamp.start.minute = minute
188 let beforeEnd = j
189 if take("-"), let endHour = number(1, 2), take(":"), let endMinute = number(2, 2) {
190 stamp.end = Timestamp.Point(year: year, month: month, day: day, hour: endHour, minute: endMinute)
191 } else {
192 j = beforeEnd
193 }
194 } else {
195 j = beforeTime
196 }
197
198 while j < limit, chars[j] == " " {
199 let beforeModifier = j
200 j += 1
201 if let value = repeater() {
202 stamp.repeater = value
203 } else if let value = warning() {
204 stamp.warning = value
205 } else {
206 j = beforeModifier
207 break
208 }
209 }
210
211 guard take(close) else { return nil }
212 return (stamp, j)
213}
214
215func isASCIIDigit(_ c: Unicode.Scalar) -> Bool {
216 c.value >= 48 && c.value <= 57
217}
218
219/// Any numeric character, as `Character.isNumber` would see it in a day name.
220func isDigit(_ c: Unicode.Scalar) -> Bool {
221 c.properties.numericType != nil
222}