krz/orgstar

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

Commit c69f9a84b4

c69f9a84b4d822489605ef31b99d1f4c28419af3

parent: 6fb6b30dba

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-04 23:29 UTC

Add timestamp recognizer and value type

Layout: unified · split

Sources/OrgCore/Timestamp.swift added +181
@@ -0,0 +1,181 @@
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)
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: [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.
65private 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 @@
1import Testing
2@testable import OrgCore
3
4struct 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}