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 | } | |