krz/orgstar

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

Sources/OrgCore/Timestamp.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgCore/Timestamp.swift history · blame · raw

222 lines · 7854 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/// 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}