Sources/OrgCore/Timestamp.swift
191 lines · 6502 bytes
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/// `<YYYY-MM-DD DAY HH:MM-HH:MM REPEATER WARNING>`, or the same in `[...]` for inactive.
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 }
67 let active = chars[start] == "<"
68 let close: Unicode.Scalar = 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, isASCIIDigit(chars[k]) { k += 1 }
74 guard k - j >= minDigits else { return nil }
75 var value = 0
76 for digit in chars[j..<k] { value = value * 10 + Int(digit.value - 48) }
77 j = k
78 return value
79 }
80
81 func take(_ c: Unicode.Scalar) -> Bool {
82 guard j < limit, chars[j] == c else { return false }
83 j += 1
84 return true
85 }
86
87 func interval() -> Timestamp.Interval? {
88 let before = j
89 guard let value = number(1, 9), j < limit, let unit = Timestamp.Unit(rawValue: Character(chars[j])) else {
90 j = before
91 return nil
92 }
93 j += 1
94 return Timestamp.Interval(value: value, unit: unit)
95 }
96
97 func repeater() -> Timestamp.Repeater? {
98 let before = j
99 let kind: Timestamp.RepeaterKind
100 if j + 1 < limit, chars[j] == ".", chars[j + 1] == "+" {
101 kind = .restart
102 j += 2
103 } else if j + 1 < limit, chars[j] == "+", chars[j + 1] == "+" {
104 kind = .catchUp
105 j += 2
106 } else if take("+") {
107 kind = .cumulate
108 } else {
109 return nil
110 }
111 guard let value = interval() else {
112 j = before
113 return nil
114 }
115 var deadline: Timestamp.Interval?
116 if take("/") {
117 deadline = interval()
118 if deadline == nil {
119 j = before
120 return nil
121 }
122 }
123 return Timestamp.Repeater(kind: kind, interval: value, habitDeadline: deadline)
124 }
125
126 func warning() -> Timestamp.Warning? {
127 let before = j
128 guard take("-") else { return nil }
129 let firstOnly = take("-")
130 guard let value = interval() else {
131 j = before
132 return nil
133 }
134 return Timestamp.Warning(firstOccurrenceOnly: firstOnly, interval: value)
135 }
136
137 guard let year = number(4, 4), take("-"), let month = number(2, 2), take("-"), let day = number(2, 2) else {
138 return nil
139 }
140 var stamp = Timestamp(
141 active: active,
142 start: Timestamp.Point(year: year, month: month, day: day, hour: nil, minute: nil),
143 end: nil, repeater: nil, warning: nil
144 )
145
146 // Day name: anything but digits, whitespace, `+`, `-`, `]` and `>`, in any language.
147 if j < limit, chars[j] == " " {
148 var k = j + 1
149 while k < limit, !(isDigit(chars[k]) || chars[k].properties.isWhitespace || "+-]>".unicodeScalars.contains(chars[k])) { k += 1 }
150 if k > j + 1 { j = k }
151 }
152
153 let beforeTime = j
154 if take(" "), let hour = number(1, 2), take(":"), let minute = number(2, 2) {
155 stamp.start.hour = hour
156 stamp.start.minute = minute
157 let beforeEnd = j
158 if take("-"), let endHour = number(1, 2), take(":"), let endMinute = number(2, 2) {
159 stamp.end = Timestamp.Point(year: year, month: month, day: day, hour: endHour, minute: endMinute)
160 } else {
161 j = beforeEnd
162 }
163 } else {
164 j = beforeTime
165 }
166
167 while j < limit, chars[j] == " " {
168 let beforeModifier = j
169 j += 1
170 if let value = repeater() {
171 stamp.repeater = value
172 } else if let value = warning() {
173 stamp.warning = value
174 } else {
175 j = beforeModifier
176 break
177 }
178 }
179
180 guard take(close) else { return nil }
181 return (stamp, j)
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}