krz/orgstar

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

Commit cacf40a484

cacf40a4848d47d52b7f610db94db8c524613df5

parent: 2bd3e30944

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 05:08 UTC

Port the date prompt

Layout: unified · split

Sources/OrgCore/Time/ReadDate.swift added +297
@@ -0,0 +1,297 @@
1import Foundation
2
3/// Org's date prompt (`org-read-date-analyze`, Org 9.8.7) and the `parse-time-string` it uses,
4/// with `org-read-date-prefer-future` t. `now` and `defaultDate` stand in for the clock.
5public enum ReadDate {
6 public struct Answer: Sendable, Equatable {
7 public var year: Int, month: Int, day: Int, hour: Int, minute: Int
8 /// The answer included a time (`org-time-was-given`).
9 public var timeGiven: Bool
10 /// An end time such as `11:00`, from `10:00-11:00` (`org-end-time-was-given`).
11 public var endTime: String?
12
13 /// Normalized like `encode-time` (day 32 becomes the next month).
14 public func components(_ calendar: Calendar) -> DateComponents {
15 var components = DateComponents(year: year, month: month, day: 1, hour: 0, minute: 0)
16 let base = calendar.date(from: components)!
17 let date = calendar.date(byAdding: DateComponents(month: 0, day: day - 1, hour: hour, minute: minute), to: base)!
18 components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .weekday], from: date)
19 return components
20 }
21 }
22
23 static let weekdays: [(String, Int)] = [
24 ("sun", 0), ("mon", 1), ("tue", 2), ("wed", 3), ("thu", 4), ("fri", 5), ("sat", 6),
25 ("sunday", 0), ("monday", 1), ("tuesday", 2), ("wednesday", 3), ("thursday", 4), ("friday", 5), ("saturday", 6),
26 ]
27 static let months: [(String, Int)] = [
28 ("jan", 1), ("feb", 2), ("mar", 3), ("apr", 4), ("may", 5), ("jun", 6), ("jul", 7), ("aug", 8), ("sep", 9),
29 ("oct", 10), ("nov", 11), ("dec", 12), ("january", 1), ("february", 2), ("march", 3), ("april", 4), ("june", 6),
30 ("july", 7), ("august", 8), ("september", 9), ("october", 10), ("november", 11), ("december", 12),
31 ]
32
33 enum Token: Equatable {
34 case number(Int)
35 case word(String)
36 }
37
38 /// `parse-time-tokenize` on the downcased string.
39 static func tokenize(_ string: String) -> [Token] {
40 let chars = Array(string.lowercased().unicodeScalars)
41 func kind(_ c: Unicode.Scalar) -> Character? {
42 switch c {
43 case "a"..."z": "a"
44 case "0"..."9": "0"
45 case "+", "-", ":": Character(c)
46 default: nil
47 }
48 }
49 var tokens: [Token] = []
50 var index = 0
51 while index < chars.count {
52 while index < chars.count, kind(chars[index]) == nil { index += 1 }
53 guard index < chars.count else { break }
54 let start = index
55 var digits = kind(chars[index]) == "0"
56 index += 1
57 while index < chars.count, let k = kind(chars[index]) {
58 digits = digits && k == "0"
59 index += 1
60 }
61 let text = String(String.UnicodeScalarView(chars[start..<index]))
62 tokens.append(digits ? .number(Int(text) ?? 0) : .word(text))
63 }
64 return tokens
65 }
66
67 /// `parse-time-string`'s fallback: (sec, min, hour, day, month, year, weekday).
68 static func parseTime(_ string: String) -> [Int?] {
69 var time: [Int?] = Array(repeating: nil, count: 9)
70 func digits(_ text: String, _ range: Range<Int>) -> Int? {
71 let chars = Array(text)
72 guard range.upperBound <= chars.count else { return nil }
73 return Int(String(chars[range]))
74 }
75 for token in tokenize(string) {
76 if time[6] == nil, case .word(let w) = token, let day = weekdays.first(where: { $0.0 == w }) {
77 time[6] = day.1
78 } else if time[3] == nil, case .number(let n) = token, (1...31).contains(n) {
79 time[3] = n
80 } else if time[4] == nil, case .word(let w) = token, let month = months.first(where: { $0.0 == w }) {
81 time[4] = month.1
82 } else if time[5] == nil, case .number(let n) = token, n >= 100 {
83 time[5] = n
84 } else if time[2] == nil, case .word(let w) = token, w.count == 8, Array(w)[2] == ":", Array(w)[5] == ":" {
85 (time[2], time[1], time[0]) = (digits(w, 0..<2), digits(w, 3..<5), digits(w, 6..<8))
86 } else if time[5] == nil, case .word(let w) = token, w.count == 10, Array(w)[4] == "-", Array(w)[7] == "-" {
87 (time[5], time[4], time[3]) = (digits(w, 0..<4), digits(w, 5..<7), digits(w, 8..<10))
88 } else if time[2] == nil, case .word(let w) = token, w.count == 5, Array(w)[2] == ":" {
89 (time[2], time[1], time[0]) = (digits(w, 0..<2), digits(w, 3..<5), 0)
90 } else if time[2] == nil, case .word(let w) = token, w.count == 4, Array(w)[1] == ":" {
91 (time[2], time[1], time[0]) = (digits(w, 0..<1), digits(w, 2..<4), 0)
92 } else if time[2] == nil, case .word(let w) = token, w.count == 7, Array(w)[1] == ":" {
93 (time[2], time[1], time[0]) = (digits(w, 0..<1), digits(w, 2..<4), digits(w, 5..<7))
94 } else if time[5] == nil, case .number(let n) = token, (50...110).contains(n) {
95 time[5] = 1900 + n
96 } else if time[5] == nil, case .number(let n) = token, (0...49).contains(n) {
97 time[5] = 2000 + n
98 }
99 }
100 return time
101 }
102
103 /// `org-small-year-to-year`.
104 static func smallYear(_ year: Int, currentYear: Int) -> Int {
105 guard year < 100 else { return year }
106 let century = currentYear / 100
107 let offset = year - currentYear % 100
108 if offset > 30 { return (century - 1) * 100 + year }
109 if offset > -70 { return century * 100 + year }
110 return (century + 1) * 100 + year
111 }
112
113 /// Replaces the first match of `pattern` in `string`, with `$n` group references.
114 private static func replaceFirst(_ string: inout String, _ match: NSTextCheckingResult, _ template: String) {
115 let ns = string as NSString
116 var result = ""
117 var chars = Array(template)
118 var i = 0
119 while i < chars.count {
120 if chars[i] == "\\", i + 1 < chars.count, let n = chars[i + 1].wholeNumberValue {
121 let range = match.range(at: n)
122 if range.location != NSNotFound { result += ns.substring(with: range) }
123 i += 2
124 } else {
125 result.append(chars[i])
126 i += 1
127 }
128 }
129 chars = []
130 string = ns.replacingCharacters(in: match.range, with: result)
131 }
132
133 private static func firstMatch(_ pattern: String, _ string: String, caseInsensitive: Bool = false) -> NSTextCheckingResult? {
134 let regex = try! NSRegularExpression(pattern: pattern, options: caseInsensitive ? [.caseInsensitive] : [])
135 return regex.firstMatch(in: string, range: NSRange(location: 0, length: (string as NSString).length))
136 }
137
138 private static func group(_ match: NSTextCheckingResult, _ i: Int, _ string: String) -> String? {
139 let range = match.range(at: i)
140 return range.location == NSNotFound ? nil : (string as NSString).substring(with: range)
141 }
142
143 /// `org-read-date-get-relative`: (shift, unit, relative to the default).
144 static func relative(_ string: String, today: DateComponents, default def: DateComponents) -> (Int, String, Bool, NSTextCheckingResult)? {
145 let names = weekdays.map(\.0).joined(separator: "|")
146 guard let match = firstMatch("^[ \\t]*([-+]{0,2})([0-9]+)?([hdwmy]|(" + names + "))?([ \\t]|$)", string, caseInsensitive: true) else { return nil }
147 let sign = group(match, 1, string) ?? ""
148 guard !sign.isEmpty || match.range(at: 4).location != NSNotFound else { return nil }
149 let direction: Character = sign.last ?? "+"
150 let relativeToDefault = sign.count == 2
151 let n = group(match, 2, string).flatMap { Int($0) } ?? 1
152 let what = group(match, 3, string) ?? "d"
153 let date = relativeToDefault ? def : today
154 if let target = weekdays.first(where: { $0.0 == what.lowercased() })?.1 {
155 let weekday = (date.weekday ?? 1) - 1
156 var delta = (7 + target - weekday) % 7
157 if delta == 0 { delta = 7 }
158 if direction == "-" {
159 delta -= 7
160 if delta == 0 { delta = -7 }
161 }
162 if n > 1 { delta += (n - 1) * (direction == "-" ? -7 : 7) }
163 return (delta, "d", relativeToDefault, match)
164 }
165 return (n * (direction == "-" ? -1 : 1), what, relativeToDefault, match)
166 }
167
168 /// `org-read-date-analyze` with `org-read-date-prefer-future` t.
169 public static func analyze(_ input: String, default defaultDate: Date, now: Date, calendar: Calendar) -> Answer {
170 let units: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second, .weekday]
171 let def = calendar.dateComponents(units, from: defaultDate)
172 let today = calendar.dateComponents(units, from: now)
173 let currentYear = today.year!
174 var ans = input
175 if firstMatch("^[ \\t]*\\.[ \\t]*$", ans) != nil { ans = "+0" }
176 var delta: (Int, String, Bool)?
177 if let relative = relative(ans, today: today, default: def) {
178 delta = (relative.0, relative.1, relative.2)
179 ans = (ans as NSString).replacingCharacters(in: relative.3.range, with: "")
180 }
181 var isoYear: Int?, isoWeekday: Int?, isoWeek: Int?
182 if let match = firstMatch("\\b(?:([0-9]+)-)?[wW]([0-9]{1,2})(?:-([0-6]))?([ \\t]|$)", ans) {
183 isoYear = group(match, 1, ans).flatMap { Int($0) }.map { smallYear($0, currentYear: currentYear) }
184 isoWeekday = group(match, 3, ans).flatMap { Int($0) }
185 isoWeek = group(match, 2, ans).flatMap { Int($0) }
186 ans = (ans as NSString).replacingCharacters(in: match.range, with: "")
187 }
188 var killYear = false
189 if let match = firstMatch("^ *(([0-9]+)-)?([0-1]?[0-9])-([0-3]?[0-9])([^-0-9]|$)", ans) {
190 var year = group(match, 2, ans).flatMap { Int($0) } ?? { killYear = true; return currentYear }()
191 year = smallYear(year, currentYear: currentYear)
192 let month = Int(group(match, 3, ans)!)!, day = Int(group(match, 4, ans)!)!
193 replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day) + "\\5")
194 }
195 if let match = firstMatch("^ *(3[01]|0?[1-9]|[12][0-9])\\. ?(0?[1-9]|1[012])\\.( ?[1-9][0-9]{3})?", ans) {
196 let year = group(match, 3, ans).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } ?? { killYear = true; return currentYear }()
197 let day = Int(group(match, 1, ans)!)!, month = Int(group(match, 2, ans)!)!
198 replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day))
199 }
200 if let match = firstMatch("^ *(0?[1-9]|1[012])/(0?[1-9]|[12][0-9]|3[01])(/([0-9]+))?([^/0-9]|$)", ans) {
201 var year = group(match, 4, ans).flatMap { Int($0) } ?? { killYear = true; return currentYear }()
202 year = smallYear(year, currentYear: currentYear)
203 let month = Int(group(match, 1, ans)!)!, day = Int(group(match, 2, ans)!)!
204 replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day) + "\\5")
205 }
206 let plainTime = "(^|[^+])[012]?[0-9]:[0-9][0-9]([ \\t\\n]|$)"
207 for _ in 0..<2 where firstMatch(plainTime, ans) == nil {
208 guard let match = firstMatch("([012]?[0-9])(:([0-5][0-9]))?(am|AM|pm|PM)\\b", ans) else { break }
209 var hour = Int(group(match, 1, ans)!)!
210 let minute = group(match, 3, ans).flatMap { Int($0) } ?? 0
211 let pm = group(match, 4, ans)!.lowercased() == "pm"
212 if hour == 12, !pm { hour = 0 } else if pm, hour < 12 { hour += 12 }
213 ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d", hour, minute))
214 }
215 for _ in 0..<2 where firstMatch(plainTime, ans) == nil {
216 guard let match = firstMatch("(?:([012]?[0-9])?h([0-5][0-9]))|(?:([012]?[0-9])h([0-5][0-9])?)\\b", ans) else { break }
217 let hour = (group(match, 1, ans) ?? group(match, 3, ans)).flatMap { Int($0) } ?? 0
218 let minute = (group(match, 2, ans) ?? group(match, 4, ans)).flatMap { Int($0) } ?? 0
219 ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d", hour, minute))
220 }
221 if let match = firstMatch("([012]?[0-9]):([0-6][0-9])\\+([012]?[0-9])(:([0-5][0-9]))?", ans) {
222 let hour = Int(group(match, 1, ans)!)!, minute = Int(group(match, 2, ans)!)!
223 var h2 = hour + Int(group(match, 3, ans)!)!
224 var m2 = minute + (group(match, 5, ans).flatMap { Int($0) } ?? 0)
225 if m2 >= 60 { h2 += 1; m2 -= 60 }
226 ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d-%02d:%02d", hour, minute, h2, m2))
227 }
228 var timeGiven = false
229 var endTime: String?
230 let timeOfDay = "(\\b[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))\\b)(--?(\\b[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))\\b))?"
231 if let match = firstMatch(timeOfDay, ans), let end = group(match, 8, ans) {
232 endTime = end
233 let range = match.range(at: 7)
234 ans = (ans as NSString).replacingCharacters(in: range, with: "")
235 }
236 let tl = parseTime(ans)
237 var futurep = false
238 var day = tl[3] ?? def.day!
239 let month: Int
240 if let m = tl[4] {
241 month = m
242 } else if tl[3] != nil {
243 futurep = true
244 month = day < today.day! ? today.month! + 1 : today.month!
245 } else {
246 month = def.month!
247 }
248 var year: Int
249 if !killYear, let y = tl[5] {
250 year = y
251 } else if futurep {
252 year = (month > today.month! || day >= today.day!) ? today.year! : today.year! + 1
253 } else if tl[4] != nil {
254 futurep = true
255 if month > today.month! { year = today.year! } else if month < today.month! { year = today.year! + 1 } else if day < today.day! { year = today.year! + 1 } else { year = today.year! }
256 } else {
257 year = def.year!
258 }
259 var month2 = month
260 var hour = tl[2] ?? def.hour!
261 let minute = tl[1] ?? def.minute!
262 var wday = tl[6]
263 if let isoWeek {
264 let isoYearValue = isoYear ?? year
265 let weekday = isoWeekday ?? wday ?? 1
266 wday = nil
267 var iso = Calendar(identifier: .iso8601)
268 iso.timeZone = calendar.timeZone
269 let isoComponents = DateComponents(weekday: weekday == 0 ? 1 : weekday + 1, weekOfYear: isoWeek, yearForWeekOfYear: isoYearValue)
270 if let date = iso.date(from: isoComponents) {
271 let c = iso.dateComponents([.year, .month, .day], from: date)
272 (year, month2, day) = (c.year!, c.month!, c.day!)
273 }
274 } else if let (n, unit, relativeToDefault) = delta {
275 if !relativeToDefault { (day, month2, year) = (today.day!, today.month!, today.year!) }
276 switch unit.lowercased() {
277 case "h", "":
278 timeGiven = true
279 hour += n
280 case "d": day += n
281 case "w": day += 7 * n
282 case "m": month2 += n
283 case "y": year += n
284 default: break
285 }
286 } else if let target = wday, tl[3] == nil {
287 let base = calendar.date(from: DateComponents(year: year, month: month2, day: 1))!
288 let date = calendar.date(byAdding: .day, value: day - 1, to: base)!
289 let current = calendar.component(.weekday, from: date) - 1
290 if target != current { day += ((target - current + 7) % 7 + 7) % 7 }
291 }
292 if tl[2] != nil { timeGiven = true }
293 if year < 100 { year += 2000 }
294 _ = futurep
295 return Answer(year: year, month: month2, day: day, hour: hour, minute: minute, timeGiven: timeGiven, endTime: endTime)
296 }
297}
Tests/OrgCoreTests/ReadDateTests.swift added +73
@@ -0,0 +1,73 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Emacs Lisp that runs `body` with the clock stopped at `seconds` (UTC in the oracle).
6func frozenClock(_ seconds: Int, _ body: String) -> String {
7 """
8 (cl-letf* ((frozen (seconds-to-time \(seconds)))
9 (odt (symbol-function 'decode-time))
10 (oft (symbol-function 'format-time-string))
11 (otl (symbol-function 'time-less-p))
12 ((symbol-function 'current-time) (lambda () frozen))
13 ((symbol-function 'org-today) (lambda () (time-to-days frozen)))
14 ((symbol-function 'time-less-p) (lambda (a b) (funcall otl (or a frozen) (or b frozen))))
15 ((symbol-function 'decode-time) (lambda (&optional time zone form) (funcall odt (or time frozen) zone form)))
16 ((symbol-function 'format-time-string) (lambda (f &optional time zone) (funcall oft f (or time frozen) zone))))
17 \(body))
18 """
19}
20
21struct ReadDateTests {
22 /// Thu 2026-09-10 00:26 UTC.
23 static let now = Date(timeIntervalSince1970: 1_789_000_000)
24 static var utc: Calendar {
25 var calendar = Calendar(identifier: .gregorian)
26 calendar.timeZone = TimeZone(identifier: "UTC")!
27 return calendar
28 }
29
30 static func format(_ answer: ReadDate.Answer) -> String {
31 let c = answer.components(utc)
32 let date = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
33 return (answer.timeGiven ? date + String(format: " %02d:%02d", c.hour!, c.minute!) : date) + "|" + (answer.endTime ?? "nil")
34 }
35
36 static let inputs = [
37 "", ".", "+0", "+1", "+2d", "-3d", "+1w", "+2m", "-1y", "++3", "fri", "+fri", "-fri", "+2fri", "mon",
38 "2026-10-07", "2026-10-7", "10-7", "7.10.", "7.10.2027", "10/7", "10/7/27", "12/31",
39 "3", "15", "sep 15", "15 sep", "jan", "dec 25", "2027",
40 "10:00", "9:30", "10am", "3pm", "12am", "12:30pm", "10h", "10h30", "h45",
41 "10:00-11:30", "10:00+1:30", "fri 10:00", "2026-10-07 14:15", "+2d 9:00", "+3h",
42 "w42", "2027-w01", "w3-5", "tomorrow", "nonsense",
43 ]
44
45 @Test func examples() {
46 let answer = ReadDate.analyze("fri 10:00", default: Self.now, now: Self.now, calendar: Self.utc)
47 #expect(Self.format(answer) == "2026-09-11 10:00|nil")
48 #expect(Self.format(ReadDate.analyze("10:00-11:30", default: Self.now, now: Self.now, calendar: Self.utc)) == "2026-09-10 10:00|11:30")
49 }
50
51 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
52 func readDateMatchesEmacs() throws {
53 try #require(EmacsOracle.isAvailable)
54 let cases = Self.inputs.map { input in
55 EmacsOracle.Case(text: "", point: 1, form: frozenClock(1_789_000_000, """
56 (eval '(let ((org-time-was-given nil) (org-end-time-was-given nil))
57 (let ((d (org-read-date nil nil "\(input)")))
58 (insert d "|" (or org-end-time-was-given "nil"))))
59 nil)
60 """))
61 }
62 var mismatches = 0
63 for (input, emacs) in zip(Self.inputs, try EmacsOracle.run(cases)) {
64 let ours = Self.format(ReadDate.analyze(input, default: Self.now, now: Self.now, calendar: Self.utc))
65 let theirs = emacs.error.isEmpty ? emacs.text : "error: \(emacs.error)"
66 if ours != theirs {
67 mismatches += 1
68 Issue.record("\(input.debugDescription): ours \(ours), emacs \(theirs)")
69 }
70 }
71 #expect(mismatches == 0)
72 }
73}