Skip to content

Commit 3aaff8f

Browse files
committed
feat: add parse_date() built-in function
Parses date strings into the same time map returned by now() and parse_epoch(), closing the gap where users had to shell out to platform-specific tools like 'date -j -f'. Two modes of operation: - Auto-detect: tries common unambiguous formats (ISO 8601, RFC 3339, space-separated datetimes) in order - Explicit format: uses readable tokens (YYYY, MM, DD, HH, mm, ss) converted to Go layouts under the hood Timezone handling mirrors parse_epoch: strings without tz info are interpreted in the target tz (local by default); strings with tz info are parsed then converted to the output tz. The token convention follows the widely-adopted Java/JS DateTimeFormatter pattern rather than Go's reference-time system or strftime, since readability is a core Rad principle and these tokens are self-documenting.
1 parent fda6b2e commit 3aaff8f

9 files changed

Lines changed: 432 additions & 0 deletions

File tree

core/error_docs/20044.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# RAD20044: Failed to Parse Date
2+
3+
A string passed to `parse_date` could not be interpreted as a valid date.
4+
5+
## Example
6+
7+
```rad
8+
parse_date("not-a-date") # Error: unrecognized format
9+
parse_date("2026-03-22") # OK: ISO date
10+
parse_date("2026-03-22T14:30:00Z") # OK: ISO datetime with timezone
11+
parse_date("22/03/2026", format="DD/MM/YYYY") # OK: custom format
12+
```
13+
14+
## Auto-Detected Formats
15+
16+
When no `format` is specified, `parse_date` tries these formats:
17+
18+
- `YYYY-MM-DDTHH:mm:ssZ` or `...+HH:MM` (RFC 3339, with optional fractional seconds)
19+
- `YYYY-MM-DD HH:mm:ss+HH:MM` (space-separated with timezone offset, with optional fractional seconds)
20+
- `YYYY-MM-DDTHH:mm:ss` (ISO datetime, with optional fractional seconds)
21+
- `YYYY-MM-DD HH:mm:ss` (space-separated, with optional fractional seconds)
22+
- `YYYY-MM-DD` (date only)
23+
24+
## Format Tokens
25+
26+
When using the `format` parameter, these tokens are available:
27+
28+
| Token | Meaning | Example |
29+
|--------|-------------------|---------|
30+
| `YYYY` | 4-digit year | `2026` |
31+
| `MM` | 2-digit month | `03` |
32+
| `DD` | 2-digit day | `22` |
33+
| `HH` | 2-digit hour (24h)| `14` |
34+
| `mm` | 2-digit minute | `30` |
35+
| `ss` | 2-digit second | `00` |
36+
37+
Note: `MM` (uppercase) is month, `mm` (lowercase) is minute. Mixing these
38+
up will produce wrong results or parse errors.
39+
40+
Format tokens are replaced wherever they appear in the format string,
41+
including inside other text. Use format strings that contain only tokens
42+
and separator characters (e.g. `DD/MM/YYYY`, `YYYY-MM-DD HH:mm:ss`).
43+
44+
## How to Fix
45+
46+
- Check that your date string matches one of the auto-detected formats, or provide an explicit `format`
47+
- Ensure the format tokens match the structure of your date string
48+
- Use `catch` to handle the error if the input is dynamic

core/func_parse_date.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package core
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"time"
7+
8+
"github.com/amterp/rad/rts/rl"
9+
)
10+
11+
// Ordered longest-first so e.g. YYYY is replaced before a hypothetical YY.
12+
var dateFormatTokens = []struct {
13+
token string
14+
goLayout string
15+
}{
16+
{"YYYY", "2006"},
17+
{"MM", "01"},
18+
{"DD", "02"},
19+
{"HH", "15"},
20+
{"mm", "04"},
21+
{"ss", "05"},
22+
}
23+
24+
// Auto-detect formats tried in order. More specific formats come first so we
25+
// don't accidentally match a datetime string as just a date. hasTz marks
26+
// formats that capture timezone from the input string.
27+
//
28+
// Go's .999999999 layout gracefully accepts absent fractional seconds, so
29+
// each entry covers both fractional and non-fractional variants.
30+
var autoDetectFormats = []struct {
31+
layout string
32+
hasTz bool
33+
}{
34+
{time.RFC3339Nano, true}, // 2006-01-02T15:04:05[.nnn]Z or +HH:MM
35+
{"2006-01-02 15:04:05.999999999Z07:00", true}, // space separator with tz offset
36+
{"2006-01-02T15:04:05.999999999", false}, // T separator, no tz
37+
{"2006-01-02 15:04:05.999999999", false}, // space separator, no tz
38+
{"2006-01-02", false}, // date only
39+
}
40+
41+
func convertFormatToGoLayout(format string) string {
42+
result := format
43+
for _, tok := range dateFormatTokens {
44+
result = strings.ReplaceAll(result, tok.token, tok.goLayout)
45+
}
46+
return result
47+
}
48+
49+
var FuncParseDate = BuiltInFunc{
50+
Name: FUNC_PARSE_DATE,
51+
Execute: func(f FuncInvocation) RadValue {
52+
dateStr := f.GetStr("_date").Plain()
53+
formatArg := f.GetArg("format")
54+
tz := f.GetStr("tz").Plain()
55+
56+
// Resolve output timezone
57+
var location *time.Location
58+
if tz == "local" {
59+
location = RClock.Local()
60+
} else {
61+
var err error
62+
location, err = time.LoadLocation(tz)
63+
if err != nil {
64+
return f.ReturnErrf(rl.ErrInvalidTimeZone, "Invalid time zone '%s'", tz)
65+
}
66+
}
67+
68+
if dateStr == "" {
69+
return f.Return(NewErrorStrf("Cannot parse an empty date string").SetCode(rl.ErrParseDate))
70+
}
71+
72+
var parsedTime time.Time
73+
74+
if !formatArg.IsNull() {
75+
// Explicit format: convert tokens to Go layout, parse in target tz
76+
format := formatArg.RequireStr(f.i, f.callNode).Plain()
77+
if format == "" {
78+
return f.Return(NewErrorStrf("Cannot parse date with an empty format string").SetCode(rl.ErrParseDate))
79+
}
80+
goLayout := convertFormatToGoLayout(format)
81+
82+
t, err := time.ParseInLocation(goLayout, dateStr, location)
83+
if err != nil {
84+
errMsg := fmt.Sprintf("Failed to parse date %q with format %q", dateStr, format)
85+
return f.Return(NewErrorStrf(errMsg).SetCode(rl.ErrParseDate))
86+
}
87+
parsedTime = t
88+
} else {
89+
// Auto-detect: try known unambiguous formats
90+
var parsed bool
91+
for _, af := range autoDetectFormats {
92+
if af.hasTz {
93+
t, err := time.Parse(af.layout, dateStr)
94+
if err == nil {
95+
parsedTime = t.In(location)
96+
parsed = true
97+
break
98+
}
99+
} else {
100+
t, err := time.ParseInLocation(af.layout, dateStr, location)
101+
if err == nil {
102+
parsedTime = t
103+
parsed = true
104+
break
105+
}
106+
}
107+
}
108+
109+
if !parsed {
110+
errMsg := fmt.Sprintf(
111+
"Failed to parse date %q. Supported formats: YYYY-MM-DD, YYYY-MM-DDTHH:mm:ss, "+
112+
"YYYY-MM-DD HH:mm:ss (with optional timezone offset and fractional seconds). "+
113+
"Use 'format' to specify a custom format, e.g. parse_date(%q, format=\"DD/MM/YYYY\").",
114+
dateStr, dateStr,
115+
)
116+
return f.Return(NewErrorStrf(errMsg).SetCode(rl.ErrParseDate))
117+
}
118+
}
119+
120+
return f.Return(NewTimeMap(parsedTime))
121+
},
122+
}

core/funcs.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const (
4545
FUNC_SORT = "sort"
4646
FUNC_NOW = "now"
4747
FUNC_PARSE_EPOCH = "parse_epoch"
48+
FUNC_PARSE_DATE = "parse_date"
4849
FUNC_TYPE_OF = "type_of"
4950
FUNC_JOIN = "join"
5051
FUNC_UPPER = "upper"
@@ -303,6 +304,7 @@ func init() {
303304
FuncSleep,
304305
FuncParseDuration,
305306
FuncConvertDuration,
307+
FuncParseDate,
306308
FuncSeedRandom,
307309
FuncRand,
308310
FuncRandInt,

core/testing/snapshots/functions/time.snap

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,5 +197,185 @@ error[RAD20008]: parse_epoch unit "milliseconds" is no longer valid
197197
= info: rad explain RAD20008
198198

199199

200+
### EXIT ###
201+
1
202+
### TITLE ###
203+
Func ParseDateIsoDate
204+
### INPUT ###
205+
a = parse_date("2026-03-22")
206+
print(a)
207+
### STDOUT ###
208+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 0, "minute": 0, "second": 0, "time": "00:00:00", "epoch": { "seconds": 1774137600, "millis": 1774137600000, "nanos": 1774137600000000000 } }
209+
210+
### TITLE ###
211+
Func ParseDateIsoDatetime
212+
### INPUT ###
213+
a = parse_date("2026-03-22T14:30:00")
214+
print(a)
215+
### STDOUT ###
216+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 14, "minute": 30, "second": 0, "time": "14:30:00", "epoch": { "seconds": 1774189800, "millis": 1774189800000, "nanos": 1774189800000000000 } }
217+
218+
### TITLE ###
219+
Func ParseDateIsoDatetimeZ
220+
### INPUT ###
221+
a = parse_date("2026-03-22T14:30:00Z")
222+
print(a)
223+
### STDOUT ###
224+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 14, "minute": 30, "second": 0, "time": "14:30:00", "epoch": { "seconds": 1774189800, "millis": 1774189800000, "nanos": 1774189800000000000 } }
225+
226+
### TITLE ###
227+
Func ParseDateIsoDatetimeOffset
228+
### INPUT ###
229+
a = parse_date("2026-03-22T14:30:00+05:00")
230+
print(a)
231+
### STDOUT ###
232+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 9, "minute": 30, "second": 0, "time": "09:30:00", "epoch": { "seconds": 1774171800, "millis": 1774171800000, "nanos": 1774171800000000000 } }
233+
234+
### TITLE ###
235+
Func ParseDateSpaceDatetime
236+
### INPUT ###
237+
a = parse_date("2026-03-22 14:30:00")
238+
print(a)
239+
### STDOUT ###
240+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 14, "minute": 30, "second": 0, "time": "14:30:00", "epoch": { "seconds": 1774189800, "millis": 1774189800000, "nanos": 1774189800000000000 } }
241+
242+
### TITLE ###
243+
Func ParseDateFractionalSeconds
244+
### INPUT ###
245+
a = parse_date("2026-03-22T14:30:00.123Z")
246+
print(a)
247+
### STDOUT ###
248+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 14, "minute": 30, "second": 0, "time": "14:30:00", "epoch": { "seconds": 1774189800, "millis": 1774189800123, "nanos": 1774189800123000000 } }
249+
250+
### TITLE ###
251+
Func ParseDateWithFormat
252+
### INPUT ###
253+
a = parse_date("22/03/2026", format="DD/MM/YYYY")
254+
print(a)
255+
### STDOUT ###
256+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 0, "minute": 0, "second": 0, "time": "00:00:00", "epoch": { "seconds": 1774137600, "millis": 1774137600000, "nanos": 1774137600000000000 } }
257+
258+
### TITLE ###
259+
Func ParseDateWithFormatDatetime
260+
### INPUT ###
261+
a = parse_date("22.03.2026 14:30", format="DD.MM.YYYY HH:mm")
262+
print(a)
263+
### STDOUT ###
264+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 14, "minute": 30, "second": 0, "time": "14:30:00", "epoch": { "seconds": 1774189800, "millis": 1774189800000, "nanos": 1774189800000000000 } }
265+
266+
### TITLE ###
267+
Func ParseDateWithTimezone
268+
### INPUT ###
269+
a = parse_date("2026-03-22T14:30:00Z", tz="America/Chicago")
270+
print(a)
271+
### STDOUT ###
272+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 9, "minute": 30, "second": 0, "time": "09:30:00", "epoch": { "seconds": 1774189800, "millis": 1774189800000, "nanos": 1774189800000000000 } }
273+
274+
### TITLE ###
275+
Func ParseDateErrorInvalidString
276+
### INPUT ###
277+
a = parse_date("not-a-date") catch:
278+
print(a)
279+
a = parse_date("not-a-date")
280+
### STDOUT ###
281+
Failed to parse date "not-a-date". Supported formats: YYYY-MM-DD, YYYY-MM-DDTHH:mm:ss, YYYY-MM-DD HH:mm:ss (with optional timezone offset and fractional seconds). Use 'format' to specify a custom format, e.g. parse_date("not-a-date", format="DD/MM/YYYY").
282+
283+
### STDERR ###
284+
error[RAD20044]: Failed to parse date "not-a-date". Supported formats: YYYY-MM-DD, YYYY-MM-DDTHH:mm:ss, YYYY-MM-DD HH:mm:ss (with optional timezone offset and fractional seconds). Use 'format' to specify a custom format, e.g. parse_date("not-a-date", format="DD/MM/YYYY").
285+
--> <script>:3:5
286+
|
287+
2 | print(a)
288+
3 | a = parse_date("not-a-date")
289+
| ^^^^^^^^^^^^^^^^^^^^^^^^
290+
|
291+
= info: rad explain RAD20044
292+
293+
294+
### EXIT ###
295+
1
296+
### TITLE ###
297+
Func ParseDateSpaceDatetimeWithTzOffset
298+
### INPUT ###
299+
a = parse_date("2026-03-22 14:30:00+05:00")
300+
print(a)
301+
### STDOUT ###
302+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 9, "minute": 30, "second": 0, "time": "09:30:00", "epoch": { "seconds": 1774171800, "millis": 1774171800000, "nanos": 1774171800000000000 } }
303+
304+
### TITLE ###
305+
Func ParseDateSpaceDatetimeFractionalSeconds
306+
### INPUT ###
307+
a = parse_date("2026-03-22 14:30:00.456")
308+
print(a)
309+
### STDOUT ###
310+
{ "date": "2026-03-22", "year": 2026, "month": 3, "day": 22, "hour": 14, "minute": 30, "second": 0, "time": "14:30:00", "epoch": { "seconds": 1774189800, "millis": 1774189800456, "nanos": 1774189800456000000 } }
311+
312+
### TITLE ###
313+
Func ParseDateNoTzStringWithExplicitTz
314+
### INPUT ###
315+
a = parse_date("2026-03-22T14:30:00", tz="UTC")
316+
print(a.epoch.seconds)
317+
### STDOUT ###
318+
1774189800
319+
320+
### TITLE ###
321+
Func ParseDateCustomFormatWithTimezone
322+
### INPUT ###
323+
a = parse_date("22/03/2026 14:30", format="DD/MM/YYYY HH:mm", tz="UTC")
324+
print(a.epoch.seconds)
325+
### STDOUT ###
326+
1774189800
327+
328+
### TITLE ###
329+
Func ParseDateErrorInvalidFormat
330+
### INPUT ###
331+
a = parse_date("2026-03-22", format="DD/MM/YYYY") catch:
332+
print(a)
333+
### STDOUT ###
334+
Failed to parse date "2026-03-22" with format "DD/MM/YYYY"
335+
336+
### TITLE ###
337+
Func ParseDateErrorFormatGarbageInput
338+
### INPUT ###
339+
a = parse_date("not-a-date", format="DD/MM/YYYY") catch:
340+
print(a)
341+
### STDOUT ###
342+
Failed to parse date "not-a-date" with format "DD/MM/YYYY"
343+
344+
### TITLE ###
345+
Func ParseDateErrorEmptyString
346+
### INPUT ###
347+
a = parse_date("") catch:
348+
print(a)
349+
### STDOUT ###
350+
Cannot parse an empty date string
351+
352+
### TITLE ###
353+
Func ParseDateErrorEmptyFormat
354+
### INPUT ###
355+
a = parse_date("2026-03-22", format="") catch:
356+
print(a)
357+
### STDOUT ###
358+
Cannot parse date with an empty format string
359+
360+
### TITLE ###
361+
Func ParseDateErrorInvalidTimezone
362+
### INPUT ###
363+
a = parse_date("2026-03-22", tz="bad") catch:
364+
print(a)
365+
a = parse_date("2026-03-22", tz="another bad one")
366+
### STDOUT ###
367+
Invalid time zone 'bad'
368+
369+
### STDERR ###
370+
error[RAD20009]: Invalid time zone 'another bad one'
371+
--> <script>:3:5
372+
|
373+
2 | print(a)
374+
3 | a = parse_date("2026-03-22", tz="another bad one")
375+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
376+
|
377+
= info: rad explain RAD20009
378+
379+
200380
### EXIT ###
201381
1

0 commit comments

Comments
 (0)