Commit c69f9a84b4
Verified · cmc
Layout: unified · split
Sources/OrgCore/Timestamp.swift added +181
| @@ -0,0 +1,181 @@ | |||
| 1 | public 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) | ||
| 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. | ||
| 52 | func scanTimestamp(_ chars: [Character], 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. | ||
| 65 | private func scanSingleTimestamp(_ chars: [Character], 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: Character = 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, chars[k].isASCII, chars[k].isNumber { k += 1 } | ||
| 74 | guard k - j >= minDigits else { return nil } | ||
| 75 | let value = Int(String(chars[j..<k]))! | ||
| 76 | j = k | ||
| 77 | return value | ||
| 78 | } | ||
| 79 | |||
| 80 | func take(_ c: Character) -> Bool { | ||
| 81 | guard j < limit, chars[j] == c else { return false } | ||
| 82 | j += 1 | ||
| 83 | return true | ||
| 84 | } | ||
| 85 | |||
| 86 | func interval() -> Timestamp.Interval? { | ||
| 87 | let before = j | ||
| 88 | guard let value = number(1, 9), j < limit, let unit = Timestamp.Unit(rawValue: chars[j]) else { | ||
| 89 | j = before | ||
| 90 | return nil | ||
| 91 | } | ||
| 92 | j += 1 | ||
| 93 | return Timestamp.Interval(value: value, unit: unit) | ||
| 94 | } | ||
| 95 | |||
| 96 | func repeater() -> Timestamp.Repeater? { | ||
| 97 | let before = j | ||
| 98 | let kind: Timestamp.RepeaterKind | ||
| 99 | if j + 1 < limit, chars[j] == ".", chars[j + 1] == "+" { | ||
| 100 | kind = .restart | ||
| 101 | j += 2 | ||
| 102 | } else if j + 1 < limit, chars[j] == "+", chars[j + 1] == "+" { | ||
| 103 | kind = .catchUp | ||
| 104 | j += 2 | ||
| 105 | } else if take("+") { | ||
| 106 | kind = .cumulate | ||
| 107 | } else { | ||
| 108 | return nil | ||
| 109 | } | ||
| 110 | guard let value = interval() else { | ||
| 111 | j = before | ||
| 112 | return nil | ||
| 113 | } | ||
| 114 | var deadline: Timestamp.Interval? | ||
| 115 | if take("/") { | ||
| 116 | deadline = interval() | ||
| 117 | if deadline == nil { | ||
| 118 | j = before | ||
| 119 | return nil | ||
| 120 | } | ||
| 121 | } | ||
| 122 | return Timestamp.Repeater(kind: kind, interval: value, habitDeadline: deadline) | ||
| 123 | } | ||
| 124 | |||
| 125 | func warning() -> Timestamp.Warning? { | ||
| 126 | let before = j | ||
| 127 | guard take("-") else { return nil } | ||
| 128 | let firstOnly = take("-") | ||
| 129 | guard let value = interval() else { | ||
| 130 | j = before | ||
| 131 | return nil | ||
| 132 | } | ||
| 133 | return Timestamp.Warning(firstOccurrenceOnly: firstOnly, interval: value) | ||
| 134 | } | ||
| 135 | |||
| 136 | guard let year = number(4, 4), take("-"), let month = number(2, 2), take("-"), let day = number(2, 2) else { | ||
| 137 | return nil | ||
| 138 | } | ||
| 139 | var stamp = Timestamp( | ||
| 140 | active: active, | ||
| 141 | start: Timestamp.Point(year: year, month: month, day: day, hour: nil, minute: nil), | ||
| 142 | end: nil, repeater: nil, warning: nil | ||
| 143 | ) | ||
| 144 | |||
| 145 | // Day name: anything but digits, whitespace, `+`, `-`, `]` and `>`, in any language. | ||
| 146 | if j < limit, chars[j] == " " { | ||
| 147 | var k = j + 1 | ||
| 148 | while k < limit, !(chars[k].isNumber || chars[k].isWhitespace || "+-]>".contains(chars[k])) { k += 1 } | ||
| 149 | if k > j + 1 { j = k } | ||
| 150 | } | ||
| 151 | |||
| 152 | let beforeTime = j | ||
| 153 | if take(" "), let hour = number(1, 2), take(":"), let minute = number(2, 2) { | ||
| 154 | stamp.start.hour = hour | ||
| 155 | stamp.start.minute = minute | ||
| 156 | let beforeEnd = j | ||
| 157 | if take("-"), let endHour = number(1, 2), take(":"), let endMinute = number(2, 2) { | ||
| 158 | stamp.end = Timestamp.Point(year: year, month: month, day: day, hour: endHour, minute: endMinute) | ||
| 159 | } else { | ||
| 160 | j = beforeEnd | ||
| 161 | } | ||
| 162 | } else { | ||
| 163 | j = beforeTime | ||
| 164 | } | ||
| 165 | |||
| 166 | while j < limit, chars[j] == " " { | ||
| 167 | let beforeModifier = j | ||
| 168 | j += 1 | ||
| 169 | if let value = repeater() { | ||
| 170 | stamp.repeater = value | ||
| 171 | } else if let value = warning() { | ||
| 172 | stamp.warning = value | ||
| 173 | } else { | ||
| 174 | j = beforeModifier | ||
| 175 | break | ||
| 176 | } | ||
| 177 | } | ||
| 178 | |||
| 179 | guard take(close) else { return nil } | ||
| 180 | return (stamp, j) | ||
| 181 | } | ||
Tests/OrgCoreTests/TimestampTests.swift added +44
| @@ -0,0 +1,44 @@ | |||
| 1 | import Testing | ||
| 2 | @testable import OrgCore | ||
| 3 | |||
| 4 | struct TimestampTests { | ||
| 5 | @Test func fullTimestamp() throws { | ||
| 6 | let stamp = try #require(Timestamp.parse("<2026-10-04 Sun 10:00-11:30 .+1w/2w --2d>")) | ||
| 7 | #expect(stamp.active) | ||
| 8 | #expect(stamp.start == Timestamp.Point(year: 2026, month: 10, day: 4, hour: 10, minute: 0)) | ||
| 9 | #expect(stamp.end == Timestamp.Point(year: 2026, month: 10, day: 4, hour: 11, minute: 30)) | ||
| 10 | #expect(stamp.repeater == Timestamp.Repeater( | ||
| 11 | kind: .restart, | ||
| 12 | interval: .init(value: 1, unit: .week), | ||
| 13 | habitDeadline: .init(value: 2, unit: .week) | ||
| 14 | )) | ||
| 15 | #expect(stamp.warning == Timestamp.Warning(firstOccurrenceOnly: true, interval: .init(value: 2, unit: .day))) | ||
| 16 | } | ||
| 17 | |||
| 18 | @Test func dateOnlyInactive() throws { | ||
| 19 | let stamp = try #require(Timestamp.parse("[2026-10-04]")) | ||
| 20 | #expect(!stamp.active) | ||
| 21 | #expect(stamp.start.hour == nil) | ||
| 22 | } | ||
| 23 | |||
| 24 | @Test func dateRange() throws { | ||
| 25 | let stamp = try #require(Timestamp.parse("[2026-10-04 Sun]--[2026-10-06 Tue]")) | ||
| 26 | #expect(stamp.end?.day == 6) | ||
| 27 | } | ||
| 28 | |||
| 29 | @Test func repeaterKinds() { | ||
| 30 | #expect(Timestamp.parse("<2026-10-04 +1d>")?.repeater?.kind == .cumulate) | ||
| 31 | #expect(Timestamp.parse("<2026-10-04 ++1m>")?.repeater?.kind == .catchUp) | ||
| 32 | #expect(Timestamp.parse("<2026-10-04 -3d +1y>")?.repeater?.interval == .init(value: 1, unit: .year)) | ||
| 33 | } | ||
| 34 | |||
| 35 | @Test func oneDigitHourAndOtherLanguages() { | ||
| 36 | #expect(Timestamp.parse("<2026-10-04 9:05>")?.start.hour == 9) | ||
| 37 | #expect(Timestamp.parse("<2026-10-04 dim.>") != nil) | ||
| 38 | } | ||
| 39 | |||
| 40 | @Test(arguments: ["<2026-10-4>", "<2026-10-04 Sun", "[2026-10-04 Sun]x", "<2026-10-04 Sun +1x>", "<2026-10-04]", "2026-10-04"]) | ||
| 41 | func rejects(text: String) { | ||
| 42 | #expect(Timestamp.parse(text) == nil) | ||
| 43 | } | ||
| 44 | } | ||