krz/orgstar

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

Sources/OrgCore/Timestamp.swift

10c33661279a1fb0e8d3dd51509db9637778769e
orgstar/Sources/OrgCore/Timestamp.swift history · blame · raw

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}