mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat: Added Go style date format configuration.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
import "core:time/datetime"
|
||||
|
||||
Date_Components :: struct {
|
||||
year: int,
|
||||
month: int,
|
||||
day: int,
|
||||
hour: int,
|
||||
minute: int,
|
||||
second: int,
|
||||
}
|
||||
|
||||
// TODO: Use some kind of scanner interface
|
||||
parse_iso_date :: proc(iso: string) -> (c: Date_Components, ok: bool) {
|
||||
if len(iso) < 10 {
|
||||
return {}, false
|
||||
}
|
||||
|
||||
c.year = parse_2_digits(iso, 0) * 100 + parse_2_digits(iso, 2)
|
||||
c.month = parse_2_digits(iso, 5)
|
||||
c.day = parse_2_digits(iso, 8)
|
||||
|
||||
if c.month < 1 || c.month > 12 {
|
||||
return {}, false
|
||||
}
|
||||
if c.day < 1 || c.day > 31 {
|
||||
return {}, false
|
||||
}
|
||||
|
||||
if len(iso) >= 19 && (iso[10] == 'T' || iso[10] == 't') {
|
||||
c.hour = parse_2_digits(iso, 11)
|
||||
c.minute = parse_2_digits(iso, 14)
|
||||
c.second = parse_2_digits(iso, 17)
|
||||
}
|
||||
|
||||
return c, true
|
||||
}
|
||||
|
||||
parse_2_digits :: proc(s: string, offset: int) -> int {
|
||||
if offset + 1 >= len(s) {
|
||||
return 0
|
||||
}
|
||||
return (int(s[offset]) - 0x30) * 10 + (int(s[offset + 1]) - 0x30)
|
||||
}
|
||||
|
||||
format_date :: proc(
|
||||
dt: Date_Components,
|
||||
fmt: string,
|
||||
allocator := context.temp_allocator,
|
||||
) -> string {
|
||||
b: strings.Builder
|
||||
strings.builder_init(&b, allocator)
|
||||
|
||||
for i := 0; i < len(fmt); {
|
||||
matched := match_token(&b, dt, fmt[i:])
|
||||
if matched > 0 {
|
||||
i += matched
|
||||
} else {
|
||||
strings.write_byte(&b, fmt[i])
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
log.debugf("formatted date: '%s'", b.buf)
|
||||
return strings.to_string(b)
|
||||
}
|
||||
|
||||
match_token :: proc(b: ^strings.Builder, dt: Date_Components, s: string) -> int {
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"January",
|
||||
) {strings.write_string(b, fmt.tprintf("%s", time.Month(dt.month))); return 7}
|
||||
if strings.has_prefix(s, "Monday") {emit_weekday(b, dt, full = true); return 6}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"2006",
|
||||
) {strings.write_string(b, fmt.tprintf("%04d", dt.year)); return 4}
|
||||
if strings.has_prefix(s, "MST") {strings.write_string(b, "UTC"); return 3}
|
||||
if strings.has_prefix(s, "Jan") {emit_month_abbr(b, dt); return 3}
|
||||
if strings.has_prefix(s, "Mon") {emit_weekday(b, dt, full = false); return 3}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"06",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.year % 100)); return 2}
|
||||
if strings.has_prefix(s, "02") {strings.write_string(b, fmt.tprintf("%02d", dt.day)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"15",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.hour)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"04",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.minute)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"05",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.second)); return 2}
|
||||
if strings.has_prefix(
|
||||
s,
|
||||
"01",
|
||||
) {strings.write_string(b, fmt.tprintf("%02d", dt.month)); return 2}
|
||||
if strings.has_prefix(s, "03") {emit_hour_12(b, dt, pad = true); return 2}
|
||||
if strings.has_prefix(s, "PM") {emit_am_pm(b, dt); return 2}
|
||||
if strings.has_prefix(s, "pm") {emit_am_pm_lower(b, dt); return 2}
|
||||
if len(s) >= 1 {
|
||||
switch s[0] {
|
||||
case '2':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.day)); return 1
|
||||
case '1':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.month)); return 1
|
||||
case '4':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.minute)); return 1
|
||||
case '5':
|
||||
strings.write_string(b, fmt.tprintf("%d", dt.second)); return 1
|
||||
case '3':
|
||||
emit_hour_12(b, dt, pad = false); return 1
|
||||
case:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
emit_month_abbr :: proc(b: ^strings.Builder, dt: Date_Components) {
|
||||
name := fmt.tprintf("%s", time.Month(dt.month))
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
|
||||
emit_weekday :: proc(b: ^strings.Builder, dt: Date_Components, full: bool) {
|
||||
date := datetime.Date {
|
||||
year = i64(dt.year),
|
||||
month = i8(dt.month),
|
||||
day = i8(dt.day),
|
||||
}
|
||||
ordinal, err := datetime.date_to_ordinal(date)
|
||||
if err != .None {
|
||||
strings.write_string(b, "???")
|
||||
return
|
||||
}
|
||||
weekday := datetime.day_of_week(ordinal)
|
||||
name := fmt.tprintf("%s", weekday)
|
||||
if full {
|
||||
strings.write_string(b, name)
|
||||
} else {
|
||||
strings.write_string(b, name[:3 if len(name) >= 3 else len(name)])
|
||||
}
|
||||
}
|
||||
|
||||
emit_hour_12 :: proc(b: ^strings.Builder, dt: Date_Components, pad: bool) {
|
||||
h12 := dt.hour % 12
|
||||
if h12 == 0 {h12 = 12}
|
||||
format := "%02d" if pad else "%d"
|
||||
|
||||
fmt.sbprintf(b, format, h12)
|
||||
}
|
||||
|
||||
emit_am_pm :: proc(b: ^strings.Builder, dt: Date_Components) {
|
||||
strings.write_string(b, "PM" if dt.hour >= 12 else "AM")
|
||||
}
|
||||
|
||||
emit_am_pm_lower :: proc(b: ^strings.Builder, dt: Date_Components) {
|
||||
strings.write_string(b, "pm" if dt.hour >= 12 else "am")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
#+test
|
||||
package mustache
|
||||
|
||||
import "core:testing"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parse_iso_date
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_parse_iso_date_extracts_time :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15T08:49:54-04:00")
|
||||
testing.expect(t, ok, "should parse")
|
||||
testing.expect_value(t, c.year, 2026)
|
||||
testing.expect_value(t, c.month, 3)
|
||||
testing.expect_value(t, c.day, 15)
|
||||
testing.expect_value(t, c.hour, 8)
|
||||
testing.expect_value(t, c.minute, 49)
|
||||
testing.expect_value(t, c.second, 54)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_iso_date_lowercase_t :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15t08:49:54Z")
|
||||
testing.expect(t, ok, "should parse lowercase t separator")
|
||||
testing.expect_value(t, c.hour, 8)
|
||||
testing.expect_value(t, c.minute, 49)
|
||||
testing.expect_value(t, c.second, 54)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_iso_date_date_only_zero_time :: proc(t: ^testing.T) {
|
||||
c, ok := parse_iso_date("2026-03-15")
|
||||
testing.expect(t, ok, "should parse date-only")
|
||||
testing.expect_value(t, c.hour, 0)
|
||||
testing.expect_value(t, c.minute, 0)
|
||||
testing.expect_value(t, c.second, 0)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_iso_date_invalid_day_errors :: proc(t: ^testing.T) {
|
||||
_, ok := parse_iso_date("2026-03-32")
|
||||
testing.expect(t, !ok, "day > 31 should fail")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_iso_date_too_short_errors :: proc(t: ^testing.T) {
|
||||
_, ok := parse_iso_date("2026-03")
|
||||
testing.expect(t, !ok, "input shorter than 10 chars should fail")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// format_date / match_token
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@(test)
|
||||
test_format_date_weekday_full :: proc(t: ^testing.T) {
|
||||
// 2026-01-01 is a Thursday.
|
||||
dt := Date_Components{year = 2026, month = 1, day = 1}
|
||||
result := format_date(dt, "Monday")
|
||||
testing.expect_value(t, result, "Thursday")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_weekday_abbr :: proc(t: ^testing.T) {
|
||||
dt := Date_Components{year = 2026, month = 1, day = 1}
|
||||
result := format_date(dt, "Mon")
|
||||
testing.expect_value(t, result, "Thu")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_month_full_name :: proc(t: ^testing.T) {
|
||||
dt := Date_Components{year = 2026, month = 3, day = 15}
|
||||
result := format_date(dt, "January")
|
||||
testing.expect_value(t, result, "March")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_two_digit_year :: proc(t: ^testing.T) {
|
||||
dt := Date_Components{year = 2026, month = 3, day = 15}
|
||||
result := format_date(dt, "06")
|
||||
testing.expect_value(t, result, "26")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_hour24_padded :: proc(t: ^testing.T) {
|
||||
midnight := Date_Components{year = 2026, month = 1, day = 1, hour = 0}
|
||||
testing.expect_value(t, format_date(midnight, "15"), "00")
|
||||
|
||||
afternoon := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
|
||||
testing.expect_value(t, format_date(afternoon, "15"), "13")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_hour12_padded_am_pm_boundaries :: proc(t: ^testing.T) {
|
||||
cases := [4]struct {
|
||||
hour: int,
|
||||
expected: string,
|
||||
}{{0, "12 AM"}, {12, "12 PM"}, {13, "01 PM"}, {23, "11 PM"}}
|
||||
|
||||
for &c in cases {
|
||||
dt := Date_Components{year = 2026, month = 1, day = 1, hour = c.hour}
|
||||
result := format_date(dt, "03 PM")
|
||||
testing.expect_value(t, result, c.expected)
|
||||
}
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_hour12_unpadded :: proc(t: ^testing.T) {
|
||||
one_am := Date_Components{year = 2026, month = 1, day = 1, hour = 1}
|
||||
testing.expect_value(t, format_date(one_am, "3"), "1")
|
||||
|
||||
one_pm := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
|
||||
testing.expect_value(t, format_date(one_pm, "3"), "1")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_am_pm_lowercase :: proc(t: ^testing.T) {
|
||||
afternoon := Date_Components{year = 2026, month = 1, day = 1, hour = 13}
|
||||
testing.expect_value(t, format_date(afternoon, "pm"), "pm")
|
||||
|
||||
morning := Date_Components{year = 2026, month = 1, day = 1, hour = 9}
|
||||
testing.expect_value(t, format_date(morning, "pm"), "am")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_minute_second_padding :: proc(t: ^testing.T) {
|
||||
dt := Date_Components{year = 2026, month = 1, day = 1, minute = 4, second = 5}
|
||||
testing.expect_value(t, format_date(dt, "04:05"), "04:05")
|
||||
testing.expect_value(t, format_date(dt, "4:5"), "4:5")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_month_day_numeric_padding :: proc(t: ^testing.T) {
|
||||
dt := Date_Components{year = 2026, month = 3, day = 5}
|
||||
testing.expect_value(t, format_date(dt, "01"), "03")
|
||||
testing.expect_value(t, format_date(dt, "1"), "3")
|
||||
testing.expect_value(t, format_date(dt, "02"), "05")
|
||||
testing.expect_value(t, format_date(dt, "2"), "5")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_mst_always_utc :: proc(t: ^testing.T) {
|
||||
// Date_Components carries no offset yet, so MST is a hardcoded
|
||||
// placeholder until real timezone support lands.
|
||||
dt := Date_Components{year = 2026, month = 1, day = 1, hour = 12}
|
||||
result := format_date(dt, "MST")
|
||||
testing.expect_value(t, result, "UTC")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_literal_passthrough :: proc(t: ^testing.T) {
|
||||
dt := Date_Components{year = 2026, month = 1, day = 1}
|
||||
result := format_date(dt, "Year: 2006!")
|
||||
testing.expect_value(t, result, "Year: 2026!")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_date_combined_go_reference_layout :: proc(t: ^testing.T) {
|
||||
// 2023-10-15 is a Sunday.
|
||||
dt := Date_Components{year = 2023, month = 10, day = 15, hour = 13, minute = 18, second = 50}
|
||||
result := format_date(dt, "Mon Jan 2 15:04:05 MST 2006")
|
||||
testing.expect_value(t, result, "Sun Oct 15 13:18:50 UTC 2023")
|
||||
}
|
||||
+61
-41
@@ -1,6 +1,7 @@
|
||||
package mustache
|
||||
|
||||
import "core:fmt"
|
||||
import "core:log"
|
||||
import "core:reflect"
|
||||
import "core:strings"
|
||||
import "core:time"
|
||||
@@ -12,6 +13,8 @@ MAX_PIPES :: 8
|
||||
// No filter accepts more than 2 args.
|
||||
MAX_PIPE_ARGS :: 2
|
||||
|
||||
DEFAULT_DATE_FORMAT :: "2 Jan 2006"
|
||||
|
||||
Pipe_Filter :: struct {
|
||||
op: string,
|
||||
args: [dynamic; MAX_PIPE_ARGS]string,
|
||||
@@ -111,6 +114,7 @@ apply_pipeline :: proc(
|
||||
current = value
|
||||
for &filter in filters {
|
||||
current = apply_filter(current, &filter, pos, ctx) or_return
|
||||
log.debugf("applied: filter=%v before=%s after=%s pos=%d", filter, value, current, pos)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -128,11 +132,42 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
} else {
|
||||
return apply_format(str, filter.args[:], pos)
|
||||
}
|
||||
|
||||
date_format: string
|
||||
|
||||
if len(filter.args) > 0 {
|
||||
date_format = filter.args[0]
|
||||
} else {
|
||||
date_format_raw := resolve_name("date_format", ctx)
|
||||
if date_format_raw == nil {
|
||||
return value, Error_Body {
|
||||
msg = "Unable to determine date format",
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
} else {
|
||||
valid: bool
|
||||
date_format, valid = reflect.as_string(date_format_raw)
|
||||
if !valid {
|
||||
return value, Error_Body {
|
||||
msg = "date format is not a string",
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
str2, err := apply_format(str, filter.args[:], pos, date_format)
|
||||
if err != nil {
|
||||
return value, err
|
||||
} else {
|
||||
return any{new_clone(str2, context.temp_allocator), typeid_of(string)}, nil
|
||||
}
|
||||
|
||||
case:
|
||||
return nil, Error_Body{
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("unknown pipe op '%s'", filter.op),
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
@@ -140,51 +175,40 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
|
||||
}
|
||||
}
|
||||
|
||||
// apply_format formats an ISO 8601 date string as a display string
|
||||
// (e.g. "2026-03-15T08:49:54-04:00" → "15 Mar 2026"). Invalid input
|
||||
// (empty, too-short, or unparseable) returns a Data-kind `Error`. Templates
|
||||
// that need to skip dateless pages should gate with a section:
|
||||
// {{#date}}<time datetime="{{.}}">{{. | format}}</time>{{/date}}
|
||||
// The section's truthiness check catches empty before the filter runs.
|
||||
//
|
||||
// Currently ignores args; planned to accept Go reference-date format
|
||||
// strings in the future.
|
||||
//
|
||||
// Accepts any of these ISO 8601 forms (date prefix is invariant):
|
||||
// 2023-10-15T13:18:50-07:00
|
||||
// 2023-10-15T13:18:50-0700
|
||||
// 2023-10-15T13:18:50Z
|
||||
// 2023-10-15T13:18:50
|
||||
// 2023-10-15
|
||||
apply_format :: proc(iso: string, args: []string, pos: int) -> (result: any, err: Error) {
|
||||
if len(iso) < 10 {
|
||||
return nil, Error_Body{
|
||||
msg = "format may only be used on dates",
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
apply_format :: proc(
|
||||
iso: string,
|
||||
args: []string,
|
||||
pos: int,
|
||||
date_format: string,
|
||||
) -> (
|
||||
result: string,
|
||||
err: Error,
|
||||
) {
|
||||
fmt_str := date_format
|
||||
if fmt_str == "" {
|
||||
log.errorf(
|
||||
"format pipe used but no date format configured (set date.format in thor.json) Default will be used",
|
||||
)
|
||||
fmt_str = DEFAULT_DATE_FORMAT
|
||||
}
|
||||
|
||||
year := iso[:4]
|
||||
month_num := (int(iso[5]) - 0x30) * 10 + (int(iso[6]) - 0x30)
|
||||
day_num := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30)
|
||||
|
||||
if month_num < 1 || month_num > 12 {
|
||||
return nil, Error_Body{
|
||||
components, ok := parse_iso_date(iso)
|
||||
if !ok {
|
||||
return "", Error_Body {
|
||||
msg = fmt.tprintf("invalid date: \"%s\"", iso),
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
}
|
||||
|
||||
month := fmt.tprintf("%s", time.Month(month_num))[:3]
|
||||
return fmt.tprintf("%d %s %s", day_num, month, year), nil
|
||||
log.debugf("date: '%s' format: '%s'", iso, date_format)
|
||||
return format_date(components, fmt_str), nil
|
||||
}
|
||||
|
||||
// Groups preserve first-appearance order from the input list.
|
||||
apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, err: Error) {
|
||||
if len(args) != 1 {
|
||||
return nil, Error_Body{
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("group_by expects 1 argument, got %d", len(args)),
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
@@ -194,11 +218,7 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er
|
||||
|
||||
elem_info, count, data := list_info(value)
|
||||
if elem_info == nil {
|
||||
return nil, Error_Body{
|
||||
msg = "group_by expects a list",
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
}
|
||||
return nil, Error_Body{msg = "group_by expects a list", pos = pos, kind = .Data}
|
||||
}
|
||||
|
||||
groups := make([dynamic]Group, 0, 8, context.temp_allocator)
|
||||
@@ -219,7 +239,7 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er
|
||||
}
|
||||
key_str := any_to_string(key_val)
|
||||
if len(key_str) == 0 {
|
||||
return nil, Error_Body{
|
||||
return nil, Error_Body {
|
||||
msg = fmt.tprintf("group_by: field '%s' is empty", field),
|
||||
pos = pos,
|
||||
kind = .Data,
|
||||
|
||||
@@ -358,6 +358,28 @@ test_format_inside_section_skips_when_empty :: proc(t: ^testing.T) {
|
||||
testing.expect_value(t, result, "[]")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_context_date_format_weekday :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "2026-01-01T00:00:00Z",
|
||||
date_format = "Monday, January 2, 2006",
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "Thursday, January 1, 2026")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_context_date_format_time_of_day :: proc(t: ^testing.T) {
|
||||
data := Format_Data {
|
||||
date = "2026-01-01T15:30:00Z",
|
||||
date_format = "3:04 PM",
|
||||
}
|
||||
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
|
||||
result, _ := render(tpl, data, {}, context.temp_allocator)
|
||||
testing.expect_value(t, result, "3:30 PM")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_format_handles_all_iso8601_variants :: proc(t: ^testing.T) {
|
||||
cases := [5]struct {
|
||||
|
||||
Reference in New Issue
Block a user