diff --git a/TODOS.md b/TODOS.md index 05a9f87..7acfb0e 100644 --- a/TODOS.md +++ b/TODOS.md @@ -13,6 +13,14 @@ - [ ] Not sure whether to use temp allocator or site_allocator in opengraph.odin. - [ ] Not sure whether to use temp allocator or site_allocator in `site_load_content`. - [ ] Might not need to allocate in `strip_html_tags` +- [ ] Fix `apply_filter`'s `format` case (`mustache/pipes.odin`) boxing `apply_format`'s + `string` result into `any` via bare `return`, which materializes a hidden + header temp in `apply_filter`'s own stack frame. Dangling once the frame + returns; caused the `-o:speed` segfault in `write_value`. Fix: box explicitly + with `any{new_clone(formatted, context.temp_allocator), typeid_of(string)}`. +- [ ] Same pattern in `apply_group_by` (`mustache/pipes.odin`): `return groups, nil` + boxes a freshly-built `[dynamic]Group` as bare `any` — same latent + stack-temp UB, hasn't crashed yet but should get the same treatment. ## Markdown - [ ] Add overloads for every extension - accept ^strings.Builder. @@ -20,13 +28,20 @@ - [ ] Add heading ids as a default on extension. - [ ] Add opt-in deflist support. - [ ] Decide if lambdas actually provide any value. -- [ ] configure date format as a partial + +## Dates +- [ ] Accept "strings" +- [ ] Accept keys +- [ ] handle timezones +- [ ] display an error when no part of the date appears in the output. +- [x] use `date.format` as the default format. ## General - [ ] Integrity hash - Allows users to verify their output didn't change after upgrading to a new version - [ ] Content-hash fingerprinting for CSS and JS cache busting - [ ] Avoid `json.Value` / `json.Object` where possible. +- [ ] Create a json schema file for `thor.json`. - [ ] make `parse` an overload of `parse_text/parse_inline` and `parse_file`, or something. - [ ] Add page params - [ ] We must remove all mention of `posts` from the odin code. @@ -46,6 +61,16 @@ - [x] Block-override source-template tracking: warnings inside overrides point at the override's source file, not the parent template - [ ] Partial invocation stack in diagnostics: when an error fires inside a partial, show "invoked from" chain through `{{> name}}` calls. Currently warnings inside partials point at the partial (correct file) but don't show the invocation site. - [ ] Could be better error message when missing a closing (or opening) brace + - [ ] Error message doesn't show position of faulty pipe name correctly. + ```bash + [ERROR] --- [138:render_template()] unknown pipe op 'formats' + --> /home/spencer/github.com/sbrow.github.io/layouts/home.html:1:1 + | +1 | {{ +``` - [ ] Block attributes on code fences (`{ #ex-1 }`) — hello-world.md - [ ] include-code shortcode (`{{< include-code ... >}}`) — i-ported-fd-to-odin - [ ] follow symlinks in `scan_content`? diff --git a/bench/date/check.odin b/bench/date/check.odin new file mode 100644 index 0000000..8e825e9 --- /dev/null +++ b/bench/date/check.odin @@ -0,0 +1,130 @@ +package main + +import "base:runtime" +import "common" +import "core:fmt" +import "core:log" +import "core:os" +import "core:strings" +import "core:time" +import "inlined" +import "original" + +DATES :: #load(#directory + os.Path_Separator_String + "dates.txt") +FORMATS :: #load(#directory + os.Path_Separator_String + "formats.txt") + +ITERATIONS :: 1_000 + +dates: []common.Date_Components +formats: []string + +formatter :: #type proc( + date: common.Date_Components, + format: string, + allocator: runtime.Allocator, +) -> string + +benchmark :: #type proc( + options: ^time.Benchmark_Options, + allocator: runtime.Allocator, +) -> ( + err: time.Benchmark_Error, +) + +Version :: struct { + name: string, + bench: benchmark, +} + +init :: proc() { + raw_dates := strings.split_lines(string(DATES)) + raw_dates = raw_dates[:len(raw_dates) - 1] + formats = strings.split_lines(string(FORMATS)) + + dates = make([]common.Date_Components, len(raw_dates)) + + assert(to_parsed(raw_dates, &dates)) +} + +to_parsed :: proc(raw_dates: []string, dates: ^[]common.Date_Components) -> bool { + for date, i in raw_dates { + // log.debugf("parsing '%s'", date) + dates[i] = common.parse_iso_date(date) or_return + } + + return true +} + +main :: proc() { + logger_opts: log.Options = + (log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path}) + console_logger := log.create_console_logger(.Info, logger_opts) + context.logger = console_logger + defer log.destroy_console_logger(console_logger) + + init() + + versions := [?]Version { + {"original", to_benchmark(original.format_date)}, + {"inlined", to_benchmark(inlined.format_date)}, + } + + fmt.printfln( + "%-10s %14s %10s %14s %12s %8s", + "version", + "total", + "calls", + "calls/s", + "time/call", + "MB/s", + ) + + for version in versions { + defer free_all(context.temp_allocator) + + b: time.Benchmark_Options + b.bench = version.bench + + if err := time.benchmark(&b, context.temp_allocator); err != nil { + fmt.panicf("%v", err) + } + + // fmt.printfln("%v", b) + per_call := time.Duration(i64(b.duration) / i64(b.count)) + fmt.printfln( + "%-10s %14v % 10d % 14.0f %12v % 8.2f", + version.name, + b.duration, + b.count, + b.rounds_per_second, + per_call, + b.megabytes_per_second, + ) + } +} + +to_benchmark :: proc($f: formatter) -> benchmark { + return( + proc( + opts: ^time.Benchmark_Options, + allocator: runtime.Allocator, + ) -> ( + err: time.Benchmark_Error, + ) { + for _ in 0 ..< ITERATIONS { + for date in dates { + for format in formats { + f(date, format, allocator) + opts.count += 1 + opts.processed += size_of(date) + size_of(format) + } + } + + opts.rounds += 1 + } + + return err + } \ + ) +} + diff --git a/bench/date/common/types.odin b/bench/date/common/types.odin new file mode 100644 index 0000000..46abd1f --- /dev/null +++ b/bench/date/common/types.odin @@ -0,0 +1,44 @@ +package common + +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) +} + diff --git a/bench/date/dates.txt b/bench/date/dates.txt new file mode 100644 index 0000000..2c110bb --- /dev/null +++ b/bench/date/dates.txt @@ -0,0 +1,59 @@ +2024-01-05 +2024-02-14 +2024-03-08 +2024-04-22 +2024-05-01 +2024-06-19 +2024-07-04 +2024-08-30 +2024-09-11 +2024-10-31 +2024-11-27 +2024-12-25 +2023-01-15T00:00:00-05:00 +2023-02-14T09:30:15+01:00 +2023-03-08T14:45:22-08:00 +2023-04-22T23:59:59+05:30 +2023-05-01T06:05:09-04:00 +2023-06-19T18:20:33+09:00 +2023-07-04T03:04:05-07:00 +2023-08-30T11:11:11+00:00 +2023-09-11T15:04:05-04:00 +2023-10-31T20:15:30+02:00 +2023-11-27T07:07:07-06:00 +2023-12-25T12:30:45+03:00 +2025-01-15T00:15:00-0500 +2025-02-14T09:30:15+0100 +2025-03-08T14:45:22-0800 +2025-04-22T23:59:59+0530 +2025-05-01T06:05:09-0400 +2025-06-19T18:20:33+0900 +2025-07-04T03:04:05-0700 +2025-08-30T11:11:11+0000 +2025-09-11T15:04:05-0400 +2025-10-31T20:15:30+0200 +2025-11-27T07:07:07-0600 +2025-12-25T12:30:45+0300 +2022-01-01T00:00:00Z +2022-02-14T01:30:00Z +2022-03-08T11:59:59Z +2022-04-22T12:00:00Z +2022-05-01T12:00:01Z +2022-06-19T13:15:00Z +2022-07-04T15:04:05Z +2022-08-30T18:45:30Z +2022-09-11T20:20:20Z +2022-10-31T22:10:10Z +2022-11-27T23:59:59Z +2022-12-25T09:09:09Z +2021-03-14t09:26:53 +2021-07-20t23:00:00 +2021-11-05t00:00:00 +2020-06-15T15:04:05 +2020-12-31T23:59:59 +2020-01-01T00:00:00 +2024-02-29T12:00:00Z +2000-02-29T00:00:00Z +1999-12-31T23:59:59Z +2026-01-01T00:00:00Z +2026-07-23T14:56:07-04:00 diff --git a/bench/date/formats.txt b/bench/date/formats.txt new file mode 100644 index 0000000..e351b03 --- /dev/null +++ b/bench/date/formats.txt @@ -0,0 +1,46 @@ +2006 +06 +January +Jan +Monday +Mon +01 +02 +03 +04 +05 +15 +1 +2 +3 +4 +5 +PM +pm +MST +2006-01-02 +2006-01-02 15:04:05 +2 Jan 2006 +Jan 2, 2006 +January 2, 2006 +Monday, January 2, 2006 +Mon Jan 2 2006 +Mon Jan 02 2006 +Mon, 02 Jan 2006 15:04:05 MST +01/02/2006 +02/01/2006 +2006/01/02 +1/2/06 +1/2/06 3:04PM +1/2/2006 +3:04:05 PM +3:04 pm +15:04:05 +15:04 +15:04:05 MST +January 2006 +Jan 06 +2 January 2006 +Monday 2 Jan 2006 at 15:04 +06-01-02 +3:4:5 diff --git a/bench/date/inlined/format.odin b/bench/date/inlined/format.odin new file mode 100644 index 0000000..381cc1d --- /dev/null +++ b/bench/date/inlined/format.odin @@ -0,0 +1,126 @@ +package inlined + +import "../common" +import "core:fmt" +import "core:log" +import "core:strings" +import "core:time" +import "core:time/datetime" + +format_date :: proc( + dt: common.Date_Components, + fmt: string, + allocator := context.temp_allocator, +) -> string { + b: strings.Builder + strings.builder_init_len(&b, len(fmt), 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: common.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") { + strings.write_string(b, "PM" if dt.hour >= 12 else "AM") + return 2 + } + if strings.has_prefix(s, "pm") { + strings.write_string(b, "pm" if dt.hour >= 12 else "am") + 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: common.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: common.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: common.Date_Components, pad: bool) { + h12 := dt.hour % 12 + if h12 == 0 {h12 = 12} + format := "%02d" if pad else "%d" + + fmt.sbprintf(b, format, h12) +} + diff --git a/bench/date/inplace/format.odin b/bench/date/inplace/format.odin new file mode 100644 index 0000000..5297365 --- /dev/null +++ b/bench/date/inplace/format.odin @@ -0,0 +1,133 @@ +package inplace + +import "../common" +import "core:bytes" +import "core:fmt" +import "core:mem" +import "core:strings" +import "core:time" +import "core:time/datetime" + +format_date :: proc( + dt: common.Date_Components, + fmt: string, + allocator := context.temp_allocator, +) -> string { + b := make([]byte, len(fmt), context.temp_allocator) + x := transmute([]byte)(fmt) + mem.copy_non_overlapping(&b[0], &x, len(fmt)) + + + return string(b) + // 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) +} + +FULL_MONTH: string : "January" + +match_token :: proc(s: []byte, dt: common.Date_Components) { + if bytes.equal(s[:len(FULL_MONTH)], transmute([]u8)FULL_MONTH) { + mo := fmt.tprintf("%s", time.Month(dt.month)) + mem.copy_non_overlapping(&s[0], &(transmute([]u8)mo)[0], len(mo)) + } + /* + 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") { + strings.write_string(b, "PM" if dt.hour >= 12 else "AM") + return 2 + } + if strings.has_prefix(s, "pm") { + strings.write_string(b, "pm" if dt.hour >= 12 else "am") + 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 + } + } + */ +} + +emit_month_abbr :: proc(b: ^strings.Builder, dt: common.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: common.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: common.Date_Components, pad: bool) { + h12 := dt.hour % 12 + if h12 == 0 {h12 = 12} + format := "%02d" if pad else "%d" + + fmt.sbprintf(b, format, h12) +} + diff --git a/bench/date/original/format.odin b/bench/date/original/format.odin new file mode 100644 index 0000000..d9868dd --- /dev/null +++ b/bench/date/original/format.odin @@ -0,0 +1,128 @@ +package original + +import "../common" +import "core:fmt" +import "core:log" +import "core:strings" +import "core:time" +import "core:time/datetime" + +format_date :: proc( + dt: common.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: common.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: common.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: common.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: common.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: common.Date_Components) { + strings.write_string(b, "PM" if dt.hour >= 12 else "AM") +} + +emit_am_pm_lower :: proc(b: ^strings.Builder, dt: common.Date_Components) { + strings.write_string(b, "pm" if dt.hour >= 12 else "am") +} + diff --git a/bench/date/parse.odin b/bench/date/parse.odin new file mode 100644 index 0000000..43727f6 --- /dev/null +++ b/bench/date/parse.odin @@ -0,0 +1,44 @@ +package main + +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) +} + diff --git a/bench/date/returned/format.odin b/bench/date/returned/format.odin new file mode 100644 index 0000000..4442cc0 --- /dev/null +++ b/bench/date/returned/format.odin @@ -0,0 +1,127 @@ +package returned + +import "../common" +import "core:fmt" +import "core:log" +import "core:strings" +import "core:time" +import "core:time/datetime" + +format_date :: proc( + dt: common.Date_Components, + fmt: string, + allocator := context.temp_allocator, +) -> string { + b: strings.Builder + // strings.builder_init_len(&b, len(fmt), allocator) + 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: common.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") { + strings.write_string(b, "PM" if dt.hour >= 12 else "AM") + return 2 + } + if strings.has_prefix(s, "pm") { + strings.write_string(b, "pm" if dt.hour >= 12 else "am") + 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: common.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: common.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: common.Date_Components, pad: bool) { + h12 := dt.hour % 12 + if h12 == 0 {h12 = 12} + format := "%02d" if pad else "%d" + + fmt.sbprintf(b, format, h12) +} + diff --git a/flake.nix b/flake.nix index c3c2a16..ebbcd3a 100644 --- a/flake.nix +++ b/flake.nix @@ -149,6 +149,8 @@ cmark tree-sitter + gdb + # IDE unstable.helix typescript-language-server diff --git a/main.odin b/main.odin index b833548..1a5b087 100644 --- a/main.odin +++ b/main.odin @@ -1,11 +1,9 @@ package main import "base:runtime" -import "core:fmt" import "core:log" import "core:os" import "core:prof/spall" -import "core:strings" import "core:sync" import "core:time" @@ -36,7 +34,7 @@ main :: proc() { logger_opts: log.Options = (log.Default_Console_Logger_Opts - log.Full_Timestamp_Opts - {.Short_File_Path}) - console_logger := log.create_console_logger(.Debug, logger_opts) + console_logger := log.create_console_logger(.Info, logger_opts) context.logger = console_logger defer log.destroy_console_logger(console_logger) diff --git a/mustache/format.odin b/mustache/format.odin new file mode 100644 index 0000000..8c0773d --- /dev/null +++ b/mustache/format.odin @@ -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") +} + diff --git a/mustache/format_test.odin b/mustache/format_test.odin new file mode 100644 index 0000000..0c57fb2 --- /dev/null +++ b/mustache/format_test.odin @@ -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") +} diff --git a/mustache/pipes.odin b/mustache/pipes.odin index 9f708cd..a68975c 100644 --- a/mustache/pipes.odin +++ b/mustache/pipes.odin @@ -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}}{{/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, diff --git a/mustache/pipes_test.odin b/mustache/pipes_test.odin index ff01a66..5caf1ef 100644 --- a/mustache/pipes_test.odin +++ b/mustache/pipes_test.odin @@ -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}}", "", 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}}", "", 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 {