From 635193f38104fe65d25f94e371b523bbce167cd2 Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 8 Feb 2026 16:10:41 +1100 Subject: [PATCH] initial commit --- README.md | 361 ++++++++++++++++++++++++++ TIME_FORMAT_RESEARCH.md | 538 +++++++++++++++++++++++++++++++++++++++ builder.go | 278 ++++++++++++++++++++ builder_test.go | 346 +++++++++++++++++++++++++ cmd/demo/demo.go | 57 +++++ constants.go | 264 +++++++++++++++++++ converter.go | 143 +++++++++++ converter_test.go | 302 ++++++++++++++++++++++ doc.go | 86 +++++++ examples/example_test.go | 189 ++++++++++++++ format.go | 143 +++++++++++ format_test.go | 480 ++++++++++++++++++++++++++++++++++ go.mod | 3 + 13 files changed, 3190 insertions(+) create mode 100644 README.md create mode 100644 TIME_FORMAT_RESEARCH.md create mode 100644 builder.go create mode 100644 builder_test.go create mode 100644 cmd/demo/demo.go create mode 100644 constants.go create mode 100644 converter.go create mode 100644 converter_test.go create mode 100644 doc.go create mode 100644 examples/example_test.go create mode 100644 format.go create mode 100644 format_test.go create mode 100644 go.mod diff --git a/README.md b/README.md new file mode 100644 index 0000000..c98f4cf --- /dev/null +++ b/README.md @@ -0,0 +1,361 @@ +# timefmt + +[![Go Reference](https://pkg.go.dev/badge/git.haelnorr.com/h/timefmt.svg)](https://pkg.go.dev/git.haelnorr.com/h/timefmt) +[![Go Report Card](https://goreportcard.com/badge/git.haelnorr.com/h/timefmt)](https://goreportcard.com/report/git.haelnorr.com/h/timefmt) + +A Go library for building and converting time format strings with human-readable descriptions. `timefmt` provides a fluent builder API for constructing time formats and can convert between Go's reference time format, LDML tokens, and plain English descriptions. + +## Features + +- 🏗️ **Builder Pattern**: Fluent, chainable API for constructing time formats +- 🔄 **Multi-Format Output**: Convert to Go format, LDML tokens, or English descriptions +- 📦 **Pre-built Formats**: Common formats like ISO8601, RFC3339, etc. ready to use +- 🔍 **Format Parser**: Parse existing Go time format strings +- 📝 **Type-Safe**: No string concatenation errors +- ✅ **Well-Tested**: Comprehensive test coverage + +## Installation + +```bash +go get git.haelnorr.com/h/timefmt +``` + +## Quick Start + +```go +package main + +import ( + "fmt" + "time" + "git.haelnorr.com/h/timefmt" +) + +func main() { + // Build a format using the fluent API + format := timefmt.NewBuilder(). + Year4().Dash(). + MonthNumeric2().Dash(). + DayNumeric2().Space(). + Hour24().Colon(). + Minute().Colon(). + Second(). + Build() + + // Get different representations + fmt.Println(format.GoFormat()) // "2006-01-02 15:04:05" + fmt.Println(format.LDML()) // "yyyy-MM-dd HH:mm:ss" + fmt.Println(format.Description()) // "Year (4-digit), dash, Month (2-digit), ..." + + // Use it to format times + now := time.Now() + fmt.Println(format.Format(now)) // "2026-02-08 15:04:05" +} +``` + +## Usage + +### Building Formats + +The builder provides methods for all time components: + +```go +format := timefmt.NewBuilder(). + WeekdayFull().Comma(). // "Monday, " + MonthFull().Space(). // "January " + DayNumeric().Comma(). // "8, " + Year4().Space(). // "2006 " + Literal("at").Space(). // "at " + Hour12().Colon(). // "3:" + Minute().Space(). // "04 " + AMPM(). // "PM" + Build() + +// Outputs: "Monday, January 8, 2006 at 3:04 PM" +``` + +### Pre-built Formats + +Common formats are available as constants: + +```go +// ISO 8601 / RFC 3339 +fmt.Println(timefmt.ISO8601.GoFormat()) // "2006-01-02T15:04:05Z07:00" +fmt.Println(timefmt.RFC3339Nano.GoFormat()) // "2006-01-02T15:04:05.999999999Z07:00" + +// Date and time +fmt.Println(timefmt.DateOnly.GoFormat()) // "2006-01-02" +fmt.Println(timefmt.TimeOnly.GoFormat()) // "15:04:05" +fmt.Println(timefmt.DateTime.GoFormat()) // "2006-01-02 15:04:05" + +// Regional formats +fmt.Println(timefmt.DateUS.GoFormat()) // "01/02/2006" +fmt.Println(timefmt.DateEU.GoFormat()) // "02/01/2006" + +// Kitchen and stamps +fmt.Println(timefmt.Kitchen.GoFormat()) // "3:04 PM" +fmt.Println(timefmt.StampMilli.GoFormat()) // "Jan _2 15:04:05.000" +``` + +Available pre-built formats: +- `ISO8601`, `RFC3339`, `RFC3339Nano` +- `DateOnly`, `TimeOnly`, `DateTime`, `DateTimeWithMillis` +- `DateUS`, `DateEU`, `DateTimeUS`, `DateTimeEU` +- `Kitchen`, `Stamp`, `StampMilli`, `StampMicro`, `StampNano` + +### Parsing Existing Go Formats + +Convert Go time format strings to get LDML and descriptions: + +```go +format, err := timefmt.ParseGoFormat("02/01/2006") +if err != nil { + log.Fatal(err) +} + +fmt.Println(format.GoFormat()) // "02/01/2006" +fmt.Println(format.LDML()) // "dd/MM/yyyy" +fmt.Println(format.Description()) // "Day (2-digit), slash, Month (2-digit), slash, Year (4-digit)" +``` + +### Multiple Output Formats + +Every `Format` can output in three ways: + +```go +format := timefmt.NewBuilder(). + Year4().Dash().MonthNumeric2().Dash().DayNumeric2(). + Build() + +// Go reference time format (for time.Format/Parse) +goFmt := format.GoFormat() // "2006-01-02" + +// LDML tokens (Unicode standard, used by ICU, Java, Swift, etc.) +ldml := format.LDML() // "yyyy-MM-dd" + +// Human-readable English description +desc := format.Description() // "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)" +``` + +### Format and Parse Times + +Use the Format to actually format and parse times: + +```go +format := timefmt.DateTime + +// Format a time +now := time.Now() +formatted := format.Format(now) // "2026-02-08 15:04:05" + +// Parse a time string +parsed, err := format.Parse("2026-02-08 15:04:05") +if err != nil { + log.Fatal(err) +} + +// Parse with location +loc, _ := time.LoadLocation("America/New_York") +parsed, err = format.ParseInLocation("2026-02-08 15:04:05", loc) +``` + +### Get Example Output + +See what a format looks like with example data: + +```go +format := timefmt.NewBuilder(). + MonthShort().Space().DayNumeric().Comma().Year4(). + Build() + +example := format.Example() // "Feb 8, 2026" (using reference time) +``` + +## Builder API Reference + +### Year Methods +- `Year4()` - 4-digit year (2006) +- `Year2()` - 2-digit year (06) + +### Month Methods +- `MonthNumeric()` - Numeric month without leading zero (1-12) +- `MonthNumeric2()` - 2-digit month with leading zero (01-12) +- `MonthShort()` - Abbreviated month name (Jan, Feb, ...) +- `MonthFull()` - Full month name (January, February, ...) + +### Day Methods +- `DayNumeric()` - Numeric day without leading zero (1-31) +- `DayNumeric2()` - 2-digit day with leading zero (01-31) +- `DaySpacePadded()` - Space-padded day ( 1-31) +- `DayOfYear()` - 3-digit day of year (001-365) +- `DayOfYearSpacePadded()` - Space-padded day of year ( 1-365) + +### Weekday Methods +- `WeekdayShort()` - Abbreviated weekday (Mon, Tue, ...) +- `WeekdayFull()` - Full weekday (Monday, Tuesday, ...) + +### Hour Methods +- `Hour24()` - 24-hour format with leading zero (00-23) +- `Hour12()` - 12-hour format without leading zero (1-12) +- `Hour12Padded()` - 12-hour format with leading zero (01-12) + +### Minute Methods +- `Minute()` - 2-digit minute with leading zero (00-59) +- `MinuteUnpadded()` - Minute without leading zero (0-59) + +### Second Methods +- `Second()` - 2-digit second with leading zero (00-59) +- `SecondUnpadded()` - Second without leading zero (0-59) + +### Subsecond Methods +- `Millisecond()` - 3-digit milliseconds (.000) +- `MillisecondTrim()` - Milliseconds with trailing zeros removed (.999) +- `Microsecond()` - 6-digit microseconds (.000000) +- `MicrosecondTrim()` - Microseconds with trailing zeros removed (.999999) +- `Nanosecond()` - 9-digit nanoseconds (.000000000) +- `NanosecondTrim()` - Nanoseconds with trailing zeros removed (.999999999) + +### AM/PM Methods +- `AMPM()` - AM/PM in uppercase +- `AMPMLower()` - am/pm in lowercase + +### Timezone Methods +- `TimezoneOffset()` - Timezone offset as ±HHMM (e.g., -0700) +- `TimezoneOffsetColon()` - Timezone offset as ±HH:MM (e.g., -07:00) +- `TimezoneOffsetHourOnly()` - Timezone offset hours only ±HH (e.g., -07) +- `TimezoneOffsetSeconds()` - Timezone offset with seconds ±HHMMSS (e.g., -070000) +- `TimezoneOffsetColonSeconds()` - Timezone offset with seconds ±HH:MM:SS (e.g., -07:00:00) +- `TimezoneISO8601()` - ISO 8601 timezone (Z or ±HHMM) +- `TimezoneISO8601Colon()` - ISO 8601 timezone with colon (Z or ±HH:MM) +- `TimezoneName()` - Timezone abbreviation (MST, PST, etc.) + +### Literal Methods +- `Literal(s string)` - Add custom literal text +- `Dash()` - Add "-" +- `Slash()` - Add "/" +- `Colon()` - Add ":" +- `Space()` - Add " " +- `T()` - Add "T" (for ISO 8601) +- `Comma()` - Add ", " +- `Period()` - Add "." + +### Building +- `Build()` - Finalize and return the Format + +## Fragment Constants + +All format components are also available as `Fragment` constants for advanced use: + +```go +type Fragment struct { + GoFormat string // Go's reference format + LDML string // LDML token + Description string // Human-readable description +} + +// Example fragments +timefmt.Year4Digit // Fragment{GoFormat: "2006", LDML: "yyyy", Description: "Year (4-digit)"} +timefmt.MonthNumeric2 // Fragment{GoFormat: "01", LDML: "MM", Description: "Month (2-digit)"} +timefmt.Hour24 // Fragment{GoFormat: "15", LDML: "HH", Description: "Hour (24-hour, 2-digit)"} +``` + +You can use fragments directly with `NewFormat()`: + +```go +format := timefmt.NewFormat( + timefmt.Year4Digit, + "-", + timefmt.MonthNumeric2, + "-", + timefmt.DayNumeric2, +) +``` + +## Examples + +### User-Facing Format Display + +Perfect for showing users what format is expected: + +```go +format := timefmt.ParseGoFormat(userConfigFormat) + +// Show LDML tokens (familiar to developers) +fmt.Printf("Format: %s\n", format.LDML()) +// Output: "Format: yyyy-MM-dd HH:mm:ss" + +// Show description (clear for everyone) +fmt.Printf("Expected format: %s\n", format.Description()) +// Output: "Expected format: Year (4-digit), dash, Month (2-digit), ..." + +// Show example +fmt.Printf("Example: %s\n", format.Example()) +// Output: "Example: 2026-02-08 15:04:05" +``` + +### Configuration Documentation + +Generate documentation for time format configuration: + +```go +formats := map[string]*timefmt.Format{ + "log_timestamp": timefmt.DateTime, + "api_response": timefmt.RFC3339, + "display_date": timefmt.DateUS, +} + +for name, format := range formats { + fmt.Printf("%s:\n", name) + fmt.Printf(" Go format: %s\n", format.GoFormat()) + fmt.Printf(" LDML: %s\n", format.LDML()) + fmt.Printf(" Description: %s\n", format.Description()) + fmt.Printf(" Example: %s\n\n", format.Example()) +} +``` + +### Form Validation Messages + +```go +format := timefmt.DateOnly + +err := fmt.Errorf( + "Invalid date format. Expected format: %s (example: %s)", + format.Description(), + format.Example(), +) +// Error: Invalid date format. Expected format: Year (4-digit), dash, Month (2-digit), dash, Day (2-digit) (example: 2026-02-08) +``` + +## LDML Token Compatibility + +The LDML output follows the [Unicode LDML standard](https://unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table), making it compatible with: + +- **ICU** (International Components for Unicode) +- **Java** (`SimpleDateFormat`, `DateTimeFormatter`) +- **Swift** (`DateFormatter`) +- **Moment.js** and **Day.js** +- **date-fns** +- Most modern date/time libraries + +## Why timefmt? + +Go's time formatting using reference times is unique and powerful, but: + +1. **Not immediately intuitive** - New developers need to memorize the reference time +2. **Hard to document** - "2006-01-02" doesn't clearly communicate "YYYY-MM-DD" +3. **No validation helpers** - No easy way to show users what format is expected + +`timefmt` solves these problems by providing: + +- A type-safe builder that prevents format errors +- Automatic conversion to widely-understood LDML tokens +- Plain English descriptions for end users +- Pre-built formats for common use cases + +## License + +MIT License - see LICENSE file for details + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. diff --git a/TIME_FORMAT_RESEARCH.md b/TIME_FORMAT_RESEARCH.md new file mode 100644 index 0000000..5d070e7 --- /dev/null +++ b/TIME_FORMAT_RESEARCH.md @@ -0,0 +1,538 @@ +# Time Format Patterns - Cross-System Comparison + +## Overview + +This document provides a comprehensive comparison of time format patterns across different programming languages and systems. This research will help inform the design of a widely-recognized, human-readable time format. + +--- + +## 1. Unicode LDML (Locale Data Markup Language) + +**Used by:** ICU, Java, Swift, JavaScript (Intl), and many internationalization libraries + +### Key Characteristics +- Single letters represent minimal/numeric forms +- Repeated letters increase width or change format type +- Case-sensitive (different meanings for upper/lower case) +- Canonical order matters for skeletons + +### Format Tokens + +| Element | Token | Examples | Notes | +|---------|-------|----------|-------| +| **Year** | +| 4-digit year | `yyyy` or `y` | 2024, 0070 | `y` adapts to context | +| 2-digit year | `yy` | 24, 70 | 00-99 | +| **Month** | +| Numeric, no zero | `M` | 1, 12 | 1-12 | +| Numeric, zero-padded | `MM` | 01, 12 | 01-12 | +| Abbreviated name | `MMM` | Jan, Dec | Locale-specific | +| Full name | `MMMM` | January, December | Locale-specific | +| Stand-alone abbrev | `LLL` | Jan, Dec | Nominative form | +| Stand-alone full | `LLLL` | January, December | Nominative form | +| **Day of Month** | +| Numeric, no zero | `d` | 1, 31 | 1-31 | +| Numeric, zero-padded | `dd` | 01, 31 | 01-31 | +| **Weekday** | +| Abbreviated | `EEE` | Mon, Fri | Locale-specific | +| Full name | `EEEE` | Monday, Friday | Locale-specific | +| Narrow | `EEEEE` | M, F | Single character | +| Short | `EEEEEE` | Mo, Fr | Between abbrev & narrow | +| **Hour (12-hour)** | +| No zero (1-12) | `h` | 1, 12 | Requires AM/PM | +| Zero-padded (01-12) | `hh` | 01, 12 | Requires AM/PM | +| **Hour (24-hour)** | +| No zero (0-23) | `H` | 0, 23 | | +| Zero-padded (00-23) | `HH` | 00, 23 | | +| **Minute** | +| No zero | `m` | 0, 59 | 0-59 | +| Zero-padded | `mm` | 00, 59 | 00-59 | +| **Second** | +| No zero | `s` | 0, 59 | 0-59 | +| Zero-padded | `ss` | 00, 59 | 00-59 | +| **Fractional Seconds** | +| Milliseconds | `SSS` | 000, 999 | Always 3 digits | +| Variable precision | `S` to `SSSSSSSSS` | 0 to 9 digits | Fractional precision | +| **AM/PM** | +| Period | `a` | AM, PM | Locale-specific | +| Period, narrow | `aaaaa` | a, p | Single character | +| Day period (flexible) | `b` | at night, in the morning | Locale-specific | +| Day period (specific) | `B` | noon, midnight | Locale-specific | +| **Timezone** | +| ISO offset | `Z` to `ZZZZZ` | -0800, -08:00, Z | Various formats | +| Localized GMT | `O` to `OOOO` | GMT-8, GMT-08:00 | | +| Generic non-location | `v` | PT | Short form | +| Generic non-location | `vvvv` | Pacific Time | Long form | +| Specific non-location | `z` | PST | Short form | +| Specific non-location | `zzzz` | Pacific Standard Time | Long form | + +### Common Pattern Examples +- `yyyy-MM-dd` → 2024-02-08 +- `MMM d, yyyy` → Feb 8, 2024 +- `EEEE, MMMM d, yyyy` → Thursday, February 8, 2024 +- `h:mm a` → 3:45 PM +- `HH:mm:ss` → 15:45:30 +- `yyyy-MM-dd'T'HH:mm:ss` → 2024-02-08T15:45:30 + +--- + +## 2. strftime (C, Python, Ruby, PHP, etc.) + +**Used by:** C, C++, Python, Ruby, PHP, Perl, Unix/Linux systems + +### Key Characteristics +- All format codes start with `%` +- Single character codes (case-sensitive) +- Platform-dependent for some codes +- Locale-aware variants with `E` and `O` modifiers + +### Format Codes + +| Element | Code | Examples | Notes | +|---------|------|----------|-------| +| **Year** | +| 4-digit year | `%Y` | 2024, 0070 | | +| 2-digit year | `%y` | 24, 70 | 00-99 | +| Century | `%C` | 20 | First 2 digits | +| **Month** | +| Numeric, zero-padded | `%m` | 01, 12 | 01-12 | +| Abbreviated name | `%b` or `%h` | Jan, Dec | Locale-specific | +| Full name | `%B` | January, December | Locale-specific | +| **Day of Month** | +| Zero-padded | `%d` | 01, 31 | 01-31 | +| Space-padded | `%e` | " 1", "31" | 1-31 with space | +| **Weekday** | +| Abbreviated | `%a` | Mon, Fri | Locale-specific | +| Full name | `%A` | Monday, Friday | Locale-specific | +| Numeric (0-6) | `%w` | 0, 6 | Sunday = 0 | +| Numeric (1-7) | `%u` | 1, 7 | Monday = 1 | +| **Hour (12-hour)** | +| Zero-padded | `%I` | 01, 12 | 01-12, requires AM/PM | +| **Hour (24-hour)** | +| Zero-padded | `%H` | 00, 23 | 00-23 | +| Space-padded | `%k` | " 0", "23" | 0-23 with space | +| **Minute** | +| Zero-padded | `%M` | 00, 59 | 00-59 | +| **Second** | +| Zero-padded | `%S` | 00, 59 | 00-59 | +| **Fractional Seconds** | +| Microseconds | `%f` | 000000, 999999 | Python-specific, 6 digits | +| **AM/PM** | +| Period | `%p` | AM, PM | Locale-specific | +| Period lowercase | `%P` | am, pm | GNU extension | +| **Timezone** | +| Name or abbreviation | `%Z` | PST, EST | Platform-dependent | +| Offset | `%z` | -0800, +0530 | ±HHMM format | +| **Composite Formats** | +| Date (MM/DD/YY) | `%D` | 02/08/24 | Equivalent to %m/%d/%y | +| ISO date | `%F` | 2024-02-08 | Equivalent to %Y-%m-%d | +| 12-hour time | `%r` | 03:45:30 PM | Locale-specific | +| 24-hour time (HH:MM) | `%R` | 15:45 | Equivalent to %H:%M | +| Time with seconds | `%T` | 15:45:30 | Equivalent to %H:%M:%S | +| Date and time | `%c` | Thu Feb 8 15:45:30 2024 | Locale-specific | + +### Common Pattern Examples +- `%Y-%m-%d` → 2024-02-08 +- `%b %d, %Y` → Feb 08, 2024 +- `%A, %B %d, %Y` → Thursday, February 08, 2024 +- `%I:%M %p` → 03:45 PM +- `%H:%M:%S` → 15:45:30 +- `%Y-%m-%dT%H:%M:%S` → 2024-02-08T15:45:30 + +--- + +## 3. Moment.js / Day.js (JavaScript) + +**Used by:** Moment.js, Day.js (legacy JavaScript libraries) + +### Key Characteristics +- No special prefix character +- Case-sensitive +- Repetition changes format +- Inspired by PHP date() function + +### Format Tokens + +| Element | Token | Examples | Notes | +|---------|-------|----------|-------| +| **Year** | +| 4-digit year | `YYYY` | 2024, 0070 | | +| 2-digit year | `YY` | 24, 70 | 00-99 | +| **Month** | +| Numeric, no zero | `M` | 1, 12 | 1-12 | +| Numeric, zero-padded | `MM` | 01, 12 | 01-12 | +| Abbreviated name | `MMM` | Jan, Dec | | +| Full name | `MMMM` | January, December | | +| **Day of Month** | +| Numeric, no zero | `D` | 1, 31 | 1-31 | +| Numeric, zero-padded | `DD` | 01, 31 | 01-31 | +| Ordinal | `Do` | 1st, 31st | With suffix | +| **Weekday** | +| Abbreviated | `ddd` | Mon, Fri | | +| Full name | `dddd` | Monday, Friday | | +| Min (2 chars) | `dd` | Mo, Fr | | +| Numeric (0-6) | `d` | 0, 6 | Sunday = 0 | +| **Hour (12-hour)** | +| No zero (1-12) | `h` | 1, 12 | Requires A or a | +| Zero-padded (01-12) | `hh` | 01, 12 | Requires A or a | +| **Hour (24-hour)** | +| No zero (0-23) | `H` | 0, 23 | | +| Zero-padded (00-23) | `HH` | 00, 23 | | +| **Minute** | +| No zero | `m` | 0, 59 | 0-59 | +| Zero-padded | `mm` | 00, 59 | 00-59 | +| **Second** | +| No zero | `s` | 0, 59 | 0-59 | +| Zero-padded | `ss` | 00, 59 | 00-59 | +| **Fractional Seconds** | +| 1-3 digits | `S`, `SS`, `SSS` | 0, 00, 000 | Tenths, hundredths, ms | +| Up to 9 digits | `SSSS` to `SSSSSSSSS` | Variable | Extended precision | +| **AM/PM** | +| Lowercase | `a` | am, pm | | +| Uppercase | `A` | AM, PM | | +| **Timezone** | +| Offset | `Z` | -08:00, +05:30 | With colon | +| Compact offset | `ZZ` | -0800, +0530 | Without colon | + +### Common Pattern Examples +- `YYYY-MM-DD` → 2024-02-08 +- `MMM D, YYYY` → Feb 8, 2024 +- `dddd, MMMM D, YYYY` → Thursday, February 8, 2024 +- `h:mm A` → 3:45 PM +- `HH:mm:ss` → 15:45:30 +- `YYYY-MM-DDTHH:mm:ss` → 2024-02-08T15:45:30 + +--- + +## 4. date-fns (Modern JavaScript) + +**Used by:** date-fns library + +### Key Characteristics +- Based on Unicode LDML tokens +- More standardized than Moment.js +- Case-sensitive +- Uses escape sequences for literals + +### Format Tokens + +*date-fns uses Unicode LDML tokens (see Section 1) with some differences:* + +| Element | Token | Examples | Notes | +|---------|-------|----------|-------| +| **Year** | +| Extended year | `uuuu` | 2024, -0001 | Recommended over yyyy | +| Calendar year | `yyyy` | 2024 | Use uuuu instead | +| 2-digit year | `yy` or `uu` | 24 | | +| **Month** | | | Same as LDML | +| **Day** | +| Day of month | `d`, `do`, `dd` | 1, 1st, 01 | | +| Day of year | `D`, `Do`, `DD`, `DDD` | 1, 1st, 01, 001 | | +| **Week** | +| Local week | `w`, `wo`, `ww` | 1, 1st, 01 | | +| ISO week | `I`, `Io`, `II` | 1, 1st, 01 | | +| **Weekday** | +| Short | `eee`, `eeeee` | Mon, M | Locale day | +| Long | `eeee`, `eeeeee` | Monday, Mo | Locale day | +| ISO | `i`, `io`, `iii`, `iiii`, `iiiii`, `iiiiii` | 1, 1st, Mon, Monday, M, Mo | | +| **Hour, Minute, Second** | | | Same as LDML | +| **AM/PM** | | | Same as LDML | +| **Timezone** | | | Extended LDML support | + +### Common Pattern Examples +- `yyyy-MM-dd` → 2024-02-08 +- `MMM d, yyyy` → Feb 8, 2024 +- `EEEE, MMMM d, yyyy` → Thursday, February 8, 2024 +- `h:mm a` → 3:45 PM +- `HH:mm:ss` → 15:45:30 + +--- + +## 5. .NET DateTime Formats + +**Used by:** C#, F#, VB.NET, .NET platform + +### Key Characteristics +- Single character codes (case-sensitive) +- Can use multiple characters for wider format +- Custom format strings combine individual specifiers +- Standard format strings (single character) produce locale-specific output + +### Format Specifiers + +| Element | Specifier | Examples | Notes | +|---------|-----------|----------|-------| +| **Year** | +| 1-2 digit year | `y` | 8, 24 | Minimum digits | +| 2-digit year | `yy` | 08, 24 | 00-99 | +| 3-digit year | `yyy` | 008, 024 | Minimum 3 digits | +| 4-digit year | `yyyy` | 0008, 2024 | | +| 5-digit year | `yyyyy` | 00008, 02024 | | +| **Month** | +| Numeric, no zero | `M` | 1, 12 | 1-12 | +| Numeric, zero-padded | `MM` | 01, 12 | 01-12 | +| Abbreviated name | `MMM` | Jan, Dec | Locale-specific | +| Full name | `MMMM` | January, December | Locale-specific | +| **Day of Month** | +| Numeric, no zero | `d` | 1, 31 | 1-31 | +| Numeric, zero-padded | `dd` | 01, 31 | 01-31 | +| **Weekday** | +| Abbreviated | `ddd` | Mon, Fri | Locale-specific | +| Full name | `dddd` | Monday, Friday | Locale-specific | +| **Hour (12-hour)** | +| No zero (1-12) | `h` | 1, 12 | Requires tt or t | +| Zero-padded (01-12) | `hh` | 01, 12 | Requires tt or t | +| **Hour (24-hour)** | +| No zero (0-23) | `H` | 0, 23 | | +| Zero-padded (00-23) | `HH` | 00, 23 | | +| **Minute** | +| No zero | `m` | 0, 59 | 0-59 | +| Zero-padded | `mm` | 00, 59 | 00-59 | +| **Second** | +| No zero | `s` | 0, 59 | 0-59 | +| Zero-padded | `ss` | 00, 59 | 00-59 | +| **Fractional Seconds** | +| Tenths | `f` | 0-9 | Always shown | +| Hundredths | `ff` | 00-99 | Always shown | +| Milliseconds | `fff` | 000-999 | Always shown | +| Optional tenths | `F` | 0-9 or nothing | Not shown if zero | +| Optional hundredths | `FF` | 00-99 or nothing | Not shown if zero | +| Optional milliseconds | `FFF` | 000-999 or nothing | Not shown if zero | +| Up to 7 digits | `fffffff`, `FFFFFFF` | Nanosecond precision | | +| **AM/PM** | +| First char | `t` | A, P | Locale-specific | +| Full designator | `tt` | AM, PM | Locale-specific | +| **Timezone** | +| Hours offset | `z` | -8, +5 | No leading zero | +| Hours offset padded | `zz` | -08, +05 | With leading zero | +| Full offset | `zzz` | -08:00, +05:30 | Hours and minutes | +| **Other** | +| Era | `g` or `gg` | A.D. | Period/era | +| Time separator | `:` | : | Locale-specific | +| Date separator | `/` | / | Locale-specific | + +### Standard Format Strings +- `d` - Short date pattern (MM/dd/yyyy) +- `D` - Long date pattern (dddd, MMMM dd, yyyy) +- `t` - Short time pattern (h:mm tt) +- `T` - Long time pattern (h:mm:ss tt) +- `f` - Full date/time short (dddd, MMMM dd, yyyy h:mm tt) +- `F` - Full date/time long (dddd, MMMM dd, yyyy h:mm:ss tt) +- `g` - General short (M/d/yyyy h:mm tt) +- `G` - General long (M/d/yyyy h:mm:ss tt) +- `o` - ISO 8601 (yyyy-MM-ddTHH:mm:ss.fffffffK) +- `s` - Sortable (yyyy-MM-ddTHH:mm:ss) + +### Common Pattern Examples +- `yyyy-MM-dd` → 2024-02-08 +- `MMM d, yyyy` → Feb 8, 2024 +- `dddd, MMMM d, yyyy` → Thursday, February 8, 2024 +- `h:mm tt` → 3:45 PM +- `HH:mm:ss` → 15:45:30 +- `yyyy-MM-ddTHH:mm:ss` → 2024-02-08T15:45:30 + +--- + +## 6. ISO 8601 Standard + +**International Standard for date and time representation** + +### Key Characteristics +- Designed for unambiguous machine-readable formats +- Always uses Gregorian calendar +- Year-month-day order (largest to smallest units) +- Uses T as date/time separator +- Uses Z to denote UTC + +### Standard Representations + +| Format | Pattern | Example | Notes | +|--------|---------|---------|-------| +| **Date** | +| Calendar date | `YYYY-MM-DD` | 2024-02-08 | Extended format | +| Calendar date | `YYYYMMDD` | 20240208 | Basic format | +| Week date | `YYYY-Www-D` | 2024-W06-4 | Week 6, day 4 | +| Week date | `YYYYWwwD` | 2024W064 | Basic format | +| Ordinal date | `YYYY-DDD` | 2024-039 | Day 39 of year | +| Ordinal date | `YYYYDDD` | 2024039 | Basic format | +| Year and month | `YYYY-MM` | 2024-02 | | +| Year only | `YYYY` | 2024 | | +| **Time** | +| Hours and minutes | `hh:mm` | 15:45 | Extended format | +| Hours and minutes | `hhmm` | 1545 | Basic format | +| Hours, min, sec | `hh:mm:ss` | 15:45:30 | Extended format | +| Hours, min, sec | `hhmmss` | 154530 | Basic format | +| With fractional sec | `hh:mm:ss.sss` | 15:45:30.123 | Variable precision | +| **DateTime** | +| Combined | `YYYY-MM-DDThh:mm:ss` | 2024-02-08T15:45:30 | T separator | +| Combined basic | `YYYYMMDDThhmmss` | 20240208T154530 | No separators | +| **Timezone** | +| UTC indicator | `Z` | 2024-02-08T15:45:30Z | Zulu time | +| Offset | `±hh:mm` | 2024-02-08T15:45:30-08:00 | Extended format | +| Offset | `±hhmm` | 2024-02-08T15:45:30-0800 | Basic format | +| Offset | `±hh` | 2024-02-08T15:45:30-08 | Hours only | + +### Common ISO 8601 Examples +- `2024-02-08` (Date only) +- `15:45:30` (Time only) +- `2024-02-08T15:45:30` (Local datetime) +- `2024-02-08T15:45:30Z` (UTC datetime) +- `2024-02-08T15:45:30-08:00` (With timezone offset) +- `2024-W06-4` (Week date: year 2024, week 6, Thursday) + +--- + +## Cross-System Comparison Table + +### Year Formats + +| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 | +|--------|------|----------|--------|----------|------|----------| +| 4-digit | `yyyy` | `%Y` | `YYYY` | `yyyy` | `yyyy` | `YYYY` | +| 2-digit | `yy` | `%y` | `YY` | `yy` | `yy` | `YY` | + +### Month Formats + +| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 | +|--------|------|----------|--------|----------|------|----------| +| Numeric no zero | `M` | - | `M` | `M` | `M` | - | +| Numeric zero-pad | `MM` | `%m` | `MM` | `MM` | `MM` | `MM` | +| Abbreviated name | `MMM` | `%b` | `MMM` | `MMM` | `MMM` | - | +| Full name | `MMMM` | `%B` | `MMMM` | `MMMM` | `MMMM` | - | + +### Day Formats + +| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 | +|--------|------|----------|--------|----------|------|----------| +| Numeric no zero | `d` | - | `D` | `d` | `d` | - | +| Numeric zero-pad | `dd` | `%d` | `DD` | `dd` | `dd` | `DD` | +| Abbreviated weekday | `EEE` | `%a` | `ddd` | `eee` | `ddd` | - | +| Full weekday | `EEEE` | `%A` | `dddd` | `eeee` | `dddd` | - | + +### Hour Formats (12-hour) + +| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 | +|--------|------|----------|--------|----------|------|----------| +| No zero (1-12) | `h` | - | `h` | `h` | `h` | - | +| Zero-pad (01-12) | `hh` | `%I` | `hh` | `hh` | `hh` | - | + +### Hour Formats (24-hour) + +| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 | +|--------|------|----------|--------|----------|------|----------| +| No zero (0-23) | `H` | - | `H` | `H` | `H` | - | +| Zero-pad (00-23) | `HH` | `%H` | `HH` | `HH` | `HH` | `hh` | + +### Minute/Second Formats + +| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 | +|--------|------|----------|--------|----------|------|----------| +| Minutes no zero | `m` | - | `m` | `m` | `m` | - | +| Minutes zero-pad | `mm` | `%M` | `mm` | `mm` | `mm` | `mm` | +| Seconds no zero | `s` | - | `s` | `s` | `s` | - | +| Seconds zero-pad | `ss` | `%S` | `ss` | `ss` | `ss` | `ss` | + +### AM/PM Indicators + +| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 | +|--------|------|----------|--------|----------|------|----------| +| Uppercase | `a` | `%p` | `A` | `a` | `tt` | N/A | +| Lowercase | `aaaaa` | `%P`* | `a` | `aaa` | - | N/A | +| First char only | - | - | - | - | `t` | N/A | + +*GNU extension, not POSIX standard + +### Timezone Formats + +| Format | LDML | strftime | Moment | date-fns | .NET | ISO 8601 | +|--------|------|----------|--------|----------|------|----------| +| Offset with colon | `ZZZZZ` or `XXXXX` | - | `Z` | `XXX` | `zzz` | `±hh:mm` | +| Offset no colon | `ZZZ` or `XX` | `%z` | `ZZ` | `XX` | - | `±hhmm` | +| UTC indicator | - | - | - | `X` | - | `Z` | +| Named timezone | `z`, `zzzz` | `%Z` | - | - | - | N/A | + +--- + +## Recommendations for Human-Readable Format + +Based on this research, here are recommendations for a widely-recognized human-readable format: + +### Most Universal Patterns + +1. **Date Only - ISO 8601 style (MOST UNIVERSAL)** + - Pattern: `YYYY-MM-DD` + - Example: `2024-02-08` + - Recognized by: ALL systems + - Pros: Unambiguous, sortable, internationally recognized + - Cons: Less readable for some English speakers + +2. **Date Only - Text month** + - Pattern: `MMM DD, YYYY` or `DD MMM YYYY` + - Example: `Feb 08, 2024` or `08 Feb 2024` + - Recognized by: All major systems (with slight variations) + - Pros: Very readable, avoids MM/DD vs DD/MM confusion + - Cons: Not sortable, locale-dependent + +3. **Time Only - 24-hour (MOST UNIVERSAL)** + - Pattern: `HH:mm:ss` + - Example: `15:45:30` + - Recognized by: ALL systems + - Pros: Unambiguous, no AM/PM needed + - Cons: Some users prefer 12-hour format + +4. **Time Only - 12-hour** + - Pattern: `hh:mm:ss AM` or `h:mm:ss a` + - Example: `03:45:30 PM` + - Recognized by: All major systems (with variations) + - Pros: Familiar to many users + - Cons: Requires AM/PM designator + +5. **Full DateTime - ISO 8601 (MOST UNIVERSAL)** + - Pattern: `YYYY-MM-DDTHH:mm:ss` + - Example: `2024-02-08T15:45:30` + - Recognized by: ALL systems + - Pros: Unambiguous, sortable, international standard + - Cons: T separator less readable + +6. **Full DateTime - Human-readable** + - Pattern: `MMMM DD, YYYY HH:mm:ss` or `DD MMMM YYYY HH:mm:ss` + - Example: `February 08, 2024 15:45:30` + - Recognized by: All major systems (with slight variations) + - Pros: Very readable + - Cons: Verbose, locale-dependent month names + +### Token Compatibility Summary + +**Highest Compatibility (work in 5-6 systems):** +- `yyyy`/`YYYY` - 4-digit year +- `MM` - 2-digit month (zero-padded) +- `dd`/`DD` - 2-digit day (zero-padded) +- `HH` - 24-hour time (zero-padded) +- `mm` - minutes (zero-padded) +- `ss` - seconds (zero-padded) + +**High Compatibility (work in 4-5 systems):** +- `MMM` - Abbreviated month name +- `MMMM` - Full month name +- `hh` - 12-hour time (zero-padded) +- `a`/`A`/`tt` - AM/PM (varies by system) + +**Moderate Compatibility:** +- `EEE`/`ddd` - Abbreviated weekday +- `EEEE`/`dddd` - Full weekday +- Single-letter tokens (no zero padding) - variable support + +### Recommended Format for timefmt Project + +For maximum compatibility and readability, consider: + +1. **For machine-readable output**: `YYYY-MM-DD HH:mm:ss` (ISO 8601 without T) +2. **For human-readable output**: `MMM DD, YYYY HH:mm:ss` (e.g., "Feb 08, 2024 15:45:30") +3. **For compact output**: `YYYY-MM-DD HH:mm` (omit seconds if not needed) + +These formats: +- Avoid MM/DD vs DD/MM confusion +- Don't require AM/PM logic +- Use widely recognized token patterns +- Are unambiguous across cultures +- Balance readability with precision diff --git a/builder.go b/builder.go new file mode 100644 index 0000000..79de993 --- /dev/null +++ b/builder.go @@ -0,0 +1,278 @@ +package timefmt + +// Builder provides a fluent interface for constructing Format instances. +// It uses a mutable pattern where each method modifies the builder and returns it for chaining. +type Builder struct { + format *Format +} + +// NewBuilder creates a new format builder with an empty format. +func NewBuilder() *Builder { + return &Builder{ + format: &Format{ + fragments: make([]interface{}, 0), + }, + } +} + +// Year4 adds a 4-digit year (e.g., 2026) to the format. +func (b *Builder) Year4() *Builder { + b.format.fragments = append(b.format.fragments, Year4Digit) + return b +} + +// Year2 adds a 2-digit year (e.g., 26) to the format. +func (b *Builder) Year2() *Builder { + b.format.fragments = append(b.format.fragments, Year2Digit) + return b +} + +// MonthNumeric adds a numeric month without leading zero (1-12) to the format. +func (b *Builder) MonthNumeric() *Builder { + b.format.fragments = append(b.format.fragments, MonthNumeric) + return b +} + +// MonthNumeric2 adds a 2-digit month with leading zero (01-12) to the format. +func (b *Builder) MonthNumeric2() *Builder { + b.format.fragments = append(b.format.fragments, MonthNumeric2) + return b +} + +// MonthShort adds an abbreviated month name (Jan, Feb, Mar, etc.) to the format. +func (b *Builder) MonthShort() *Builder { + b.format.fragments = append(b.format.fragments, MonthShort) + return b +} + +// MonthFull adds a full month name (January, February, March, etc.) to the format. +func (b *Builder) MonthFull() *Builder { + b.format.fragments = append(b.format.fragments, MonthFull) + return b +} + +// DayNumeric adds a numeric day without leading zero (1-31) to the format. +func (b *Builder) DayNumeric() *Builder { + b.format.fragments = append(b.format.fragments, DayNumeric) + return b +} + +// DayNumeric2 adds a 2-digit day with leading zero (01-31) to the format. +func (b *Builder) DayNumeric2() *Builder { + b.format.fragments = append(b.format.fragments, DayNumeric2) + return b +} + +// DaySpacePadded adds a space-padded day ( 1-31) to the format. +func (b *Builder) DaySpacePadded() *Builder { + b.format.fragments = append(b.format.fragments, DaySpacePadded) + return b +} + +// DayOfYear adds a 3-digit day of year (001-365) to the format. +func (b *Builder) DayOfYear() *Builder { + b.format.fragments = append(b.format.fragments, DayOfYearNumeric) + return b +} + +// DayOfYearSpacePadded adds a space-padded day of year ( 1-365) to the format. +func (b *Builder) DayOfYearSpacePadded() *Builder { + b.format.fragments = append(b.format.fragments, DayOfYearSpacePadded) + return b +} + +// WeekdayShort adds an abbreviated weekday name (Mon, Tue, Wed, etc.) to the format. +func (b *Builder) WeekdayShort() *Builder { + b.format.fragments = append(b.format.fragments, WeekdayShort) + return b +} + +// WeekdayFull adds a full weekday name (Monday, Tuesday, Wednesday, etc.) to the format. +func (b *Builder) WeekdayFull() *Builder { + b.format.fragments = append(b.format.fragments, WeekdayFull) + return b +} + +// Hour24 adds a 24-hour format with leading zero (00-23) to the format. +func (b *Builder) Hour24() *Builder { + b.format.fragments = append(b.format.fragments, Hour24) + return b +} + +// Hour12 adds a 12-hour format without leading zero (1-12) to the format. +func (b *Builder) Hour12() *Builder { + b.format.fragments = append(b.format.fragments, Hour12) + return b +} + +// Hour12Padded adds a 12-hour format with leading zero (01-12) to the format. +func (b *Builder) Hour12Padded() *Builder { + b.format.fragments = append(b.format.fragments, Hour12Padded) + return b +} + +// Minute adds a 2-digit minute with leading zero (00-59) to the format. +func (b *Builder) Minute() *Builder { + b.format.fragments = append(b.format.fragments, Minute) + return b +} + +// MinuteUnpadded adds a minute without leading zero (0-59) to the format. +func (b *Builder) MinuteUnpadded() *Builder { + b.format.fragments = append(b.format.fragments, MinuteUnpadded) + return b +} + +// Second adds a 2-digit second with leading zero (00-59) to the format. +func (b *Builder) Second() *Builder { + b.format.fragments = append(b.format.fragments, Second) + return b +} + +// SecondUnpadded adds a second without leading zero (0-59) to the format. +func (b *Builder) SecondUnpadded() *Builder { + b.format.fragments = append(b.format.fragments, SecondUnpadded) + return b +} + +// Millisecond adds milliseconds as 3 digits (.000) to the format. +func (b *Builder) Millisecond() *Builder { + b.format.fragments = append(b.format.fragments, Millisecond) + return b +} + +// MillisecondTrim adds milliseconds with trailing zeros removed (.999) to the format. +func (b *Builder) MillisecondTrim() *Builder { + b.format.fragments = append(b.format.fragments, MillisecondTrim) + return b +} + +// Microsecond adds microseconds as 6 digits (.000000) to the format. +func (b *Builder) Microsecond() *Builder { + b.format.fragments = append(b.format.fragments, Microsecond) + return b +} + +// MicrosecondTrim adds microseconds with trailing zeros removed (.999999) to the format. +func (b *Builder) MicrosecondTrim() *Builder { + b.format.fragments = append(b.format.fragments, MicrosecondTrim) + return b +} + +// Nanosecond adds nanoseconds as 9 digits (.000000000) to the format. +func (b *Builder) Nanosecond() *Builder { + b.format.fragments = append(b.format.fragments, Nanosecond) + return b +} + +// NanosecondTrim adds nanoseconds with trailing zeros removed (.999999999) to the format. +func (b *Builder) NanosecondTrim() *Builder { + b.format.fragments = append(b.format.fragments, NanosecondTrim) + return b +} + +// AMPM adds an AM/PM marker in uppercase to the format. +func (b *Builder) AMPM() *Builder { + b.format.fragments = append(b.format.fragments, AMPM) + return b +} + +// AMPMLower adds an am/pm marker in lowercase to the format. +func (b *Builder) AMPMLower() *Builder { + b.format.fragments = append(b.format.fragments, AMPMLower) + return b +} + +// TimezoneOffset adds a timezone offset as ±HHMM (e.g., -0700) to the format. +func (b *Builder) TimezoneOffset() *Builder { + b.format.fragments = append(b.format.fragments, TimezoneOffset) + return b +} + +// TimezoneOffsetColon adds a timezone offset as ±HH:MM (e.g., -07:00) to the format. +func (b *Builder) TimezoneOffsetColon() *Builder { + b.format.fragments = append(b.format.fragments, TimezoneOffsetColon) + return b +} + +// TimezoneOffsetHourOnly adds a timezone offset hours only as ±HH (e.g., -07) to the format. +func (b *Builder) TimezoneOffsetHourOnly() *Builder { + b.format.fragments = append(b.format.fragments, TimezoneOffsetHourOnly) + return b +} + +// TimezoneOffsetSeconds adds a timezone offset with seconds as ±HHMMSS (e.g., -070000) to the format. +func (b *Builder) TimezoneOffsetSeconds() *Builder { + b.format.fragments = append(b.format.fragments, TimezoneOffsetSeconds) + return b +} + +// TimezoneOffsetColonSeconds adds a timezone offset with seconds as ±HH:MM:SS (e.g., -07:00:00) to the format. +func (b *Builder) TimezoneOffsetColonSeconds() *Builder { + b.format.fragments = append(b.format.fragments, TimezoneOffsetColonSeconds) + return b +} + +// TimezoneISO8601 adds an ISO 8601 timezone with Z for UTC (e.g., Z or -0700) to the format. +func (b *Builder) TimezoneISO8601() *Builder { + b.format.fragments = append(b.format.fragments, TimezoneISO8601) + return b +} + +// TimezoneISO8601Colon adds an ISO 8601 timezone with colon (e.g., Z or -07:00) to the format. +func (b *Builder) TimezoneISO8601Colon() *Builder { + b.format.fragments = append(b.format.fragments, TimezoneISO8601Colon) + return b +} + +// TimezoneName adds a timezone abbreviation (e.g., MST, PST) to the format. +func (b *Builder) TimezoneName() *Builder { + b.format.fragments = append(b.format.fragments, TimezoneName) + return b +} + +// Literal adds arbitrary literal text to the format. +func (b *Builder) Literal(s string) *Builder { + b.format.fragments = append(b.format.fragments, s) + return b +} + +// Dash adds a dash (-) to the format. +func (b *Builder) Dash() *Builder { + return b.Literal("-") +} + +// Slash adds a slash (/) to the format. +func (b *Builder) Slash() *Builder { + return b.Literal("/") +} + +// Colon adds a colon (:) to the format. +func (b *Builder) Colon() *Builder { + return b.Literal(":") +} + +// Space adds a space ( ) to the format. +func (b *Builder) Space() *Builder { + return b.Literal(" ") +} + +// T adds a literal 'T' to the format (commonly used in ISO 8601). +func (b *Builder) T() *Builder { + return b.Literal("T") +} + +// Comma adds a comma and space (, ) to the format. +func (b *Builder) Comma() *Builder { + return b.Literal(", ") +} + +// Period adds a period (.) to the format. +func (b *Builder) Period() *Builder { + return b.Literal(".") +} + +// Build finalizes the builder and returns the constructed Format. +func (b *Builder) Build() *Format { + return b.format +} diff --git a/builder_test.go b/builder_test.go new file mode 100644 index 0000000..3eb2c06 --- /dev/null +++ b/builder_test.go @@ -0,0 +1,346 @@ +package timefmt + +import ( + "testing" + "time" +) + +func TestBuilder_BasicFormats(t *testing.T) { + tests := []struct { + name string + builder func() *Builder + wantGo string + wantLDML string + }{ + { + name: "ISO 8601 date", + builder: func() *Builder { + return NewBuilder(). + Year4().Dash().MonthNumeric2().Dash().DayNumeric2() + }, + wantGo: "2006-01-02", + wantLDML: "yyyy-MM-dd", + }, + { + name: "24-hour time", + builder: func() *Builder { + return NewBuilder(). + Hour24().Colon().Minute().Colon().Second() + }, + wantGo: "15:04:05", + wantLDML: "HH:mm:ss", + }, + { + name: "Full datetime", + builder: func() *Builder { + return NewBuilder(). + Year4().Dash().MonthNumeric2().Dash().DayNumeric2(). + Space(). + Hour24().Colon().Minute().Colon().Second() + }, + wantGo: "2006-01-02 15:04:05", + wantLDML: "yyyy-MM-dd HH:mm:ss", + }, + { + name: "12-hour with AM/PM", + builder: func() *Builder { + return NewBuilder(). + Hour12().Colon().Minute().Space().AMPM() + }, + wantGo: "3:04 PM", + wantLDML: "h:mm a", + }, + { + name: "US date format", + builder: func() *Builder { + return NewBuilder(). + MonthNumeric2().Slash().DayNumeric2().Slash().Year4() + }, + wantGo: "01/02/2006", + wantLDML: "MM/dd/yyyy", + }, + { + name: "European date format", + builder: func() *Builder { + return NewBuilder(). + DayNumeric2().Slash().MonthNumeric2().Slash().Year4() + }, + wantGo: "02/01/2006", + wantLDML: "dd/MM/yyyy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + format := tt.builder().Build() + + gotGo := format.GoFormat() + if gotGo != tt.wantGo { + t.Errorf("GoFormat() = %q, want %q", gotGo, tt.wantGo) + } + + gotLDML := format.LDML() + if gotLDML != tt.wantLDML { + t.Errorf("LDML() = %q, want %q", gotLDML, tt.wantLDML) + } + }) + } +} + +func TestBuilder_ChainableMethods(t *testing.T) { + // Test that all methods return *Builder for chaining + builder := NewBuilder() + + result := builder. + Year4().Year2(). + MonthNumeric().MonthNumeric2().MonthShort().MonthFull(). + DayNumeric().DayNumeric2().DaySpacePadded().DayOfYear(). + WeekdayShort().WeekdayFull(). + Hour24().Hour12().Hour12Padded(). + Minute().MinuteUnpadded(). + Second().SecondUnpadded(). + Millisecond().Microsecond().Nanosecond(). + AMPM().AMPMLower(). + TimezoneOffset().TimezoneOffsetColon().TimezoneName(). + Literal("test").Dash().Slash().Colon().Space().T().Comma().Period() + + if result == nil { + t.Error("Builder methods should return *Builder for chaining") + } + + // Verify it's the same builder instance (mutable pattern) + if result != builder { + t.Error("Builder methods should return the same builder instance") + } +} + +func TestBuilder_LiteralShortcuts(t *testing.T) { + tests := []struct { + name string + builder func() *Builder + want string + }{ + { + name: "Dash", + builder: func() *Builder { return NewBuilder().Dash() }, + want: "-", + }, + { + name: "Slash", + builder: func() *Builder { return NewBuilder().Slash() }, + want: "/", + }, + { + name: "Colon", + builder: func() *Builder { return NewBuilder().Colon() }, + want: ":", + }, + { + name: "Space", + builder: func() *Builder { return NewBuilder().Space() }, + want: " ", + }, + { + name: "T", + builder: func() *Builder { return NewBuilder().T() }, + want: "T", + }, + { + name: "Comma", + builder: func() *Builder { return NewBuilder().Comma() }, + want: ", ", + }, + { + name: "Period", + builder: func() *Builder { return NewBuilder().Period() }, + want: ".", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + format := tt.builder().Build() + got := format.GoFormat() + if got != tt.want { + t.Errorf("GoFormat() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuilder_ComplexFormat(t *testing.T) { + // Build a complex format with multiple components + format := NewBuilder(). + WeekdayFull().Comma(). + MonthFull().Space().DayNumeric().Comma().Year4(). + Space().Literal("at").Space(). + Hour12().Colon().Minute().Space().AMPM(). + Space().Literal("(").TimezoneName().Literal(")"). + Build() + + wantGo := "Monday, January 2, 2006 at 3:04 PM (MST)" + gotGo := format.GoFormat() + if gotGo != wantGo { + t.Errorf("GoFormat() = %q, want %q", gotGo, wantGo) + } + + // Test that it actually formats correctly + testTime := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.FixedZone("MST", -7*3600)) + formatted := format.Format(testTime) + expected := "Sunday, February 8, 2026 at 3:04 PM (MST)" + if formatted != expected { + t.Errorf("Format() = %q, want %q", formatted, expected) + } +} + +func TestPrebuiltFormats(t *testing.T) { + tests := []struct { + name string + format *Format + wantGo string + }{ + { + name: "ISO8601", + format: ISO8601, + wantGo: "2006-01-02T15:04:05Z07:00", + }, + { + name: "RFC3339", + format: RFC3339, + wantGo: "2006-01-02T15:04:05Z07:00", + }, + { + name: "RFC3339Nano", + format: RFC3339Nano, + wantGo: "2006-01-02T15:04:05.999999999Z07:00", + }, + { + name: "DateOnly", + format: DateOnly, + wantGo: "2006-01-02", + }, + { + name: "TimeOnly", + format: TimeOnly, + wantGo: "15:04:05", + }, + { + name: "DateTime", + format: DateTime, + wantGo: "2006-01-02 15:04:05", + }, + { + name: "DateTimeWithMillis", + format: DateTimeWithMillis, + wantGo: "2006-01-02 15:04:05.000", + }, + { + name: "DateUS", + format: DateUS, + wantGo: "01/02/2006", + }, + { + name: "DateEU", + format: DateEU, + wantGo: "02/01/2006", + }, + { + name: "DateTimeUS", + format: DateTimeUS, + wantGo: "01/02/2006 3:04:05 PM", + }, + { + name: "DateTimeEU", + format: DateTimeEU, + wantGo: "02/01/2006 15:04:05", + }, + { + name: "Kitchen", + format: Kitchen, + wantGo: "3:04 PM", + }, + { + name: "Stamp", + format: Stamp, + wantGo: "Jan _2 15:04:05", + }, + { + name: "StampMilli", + format: StampMilli, + wantGo: "Jan _2 15:04:05.000", + }, + { + name: "StampMicro", + format: StampMicro, + wantGo: "Jan _2 15:04:05.000000", + }, + { + name: "StampNano", + format: StampNano, + wantGo: "Jan _2 15:04:05.000000000", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.format == nil { + t.Fatal("Format is nil") + } + + gotGo := tt.format.GoFormat() + if gotGo != tt.wantGo { + t.Errorf("GoFormat() = %q, want %q", gotGo, tt.wantGo) + } + + // Verify LDML and Description don't panic + _ = tt.format.LDML() + _ = tt.format.Description() + _ = tt.format.Example() + }) + } +} + +func TestPrebuiltFormats_Formatting(t *testing.T) { + testTime := time.Date(2026, time.February, 8, 15, 4, 5, 123456789, time.UTC) + + tests := []struct { + name string + format *Format + want string + }{ + { + name: "DateOnly", + format: DateOnly, + want: "2026-02-08", + }, + { + name: "TimeOnly", + format: TimeOnly, + want: "15:04:05", + }, + { + name: "DateTime", + format: DateTime, + want: "2026-02-08 15:04:05", + }, + { + name: "DateUS", + format: DateUS, + want: "02/08/2026", + }, + { + name: "DateEU", + format: DateEU, + want: "08/02/2026", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.format.Format(testTime) + if got != tt.want { + t.Errorf("Format() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/cmd/demo/demo.go b/cmd/demo/demo.go new file mode 100644 index 0000000..292c2c4 --- /dev/null +++ b/cmd/demo/demo.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "time" + "git.haelnorr.com/h/timefmt" +) + +func main() { + fmt.Println("=== timefmt Demo ===\n") + + // 1. Building a custom format + fmt.Println("1. Building a custom format:") + format := timefmt.NewBuilder(). + Year4().Dash(). + MonthNumeric2().Dash(). + DayNumeric2().Space(). + Hour24().Colon(). + Minute().Colon(). + Second(). + Build() + + fmt.Printf(" Go format: %s\n", format.GoFormat()) + fmt.Printf(" LDML: %s\n", format.LDML()) + fmt.Printf(" Description: %s\n\n", format.Description()) + + // 2. Using pre-built formats + fmt.Println("2. Pre-built formats:") + now := time.Now() + fmt.Printf(" ISO8601: %s\n", timefmt.ISO8601.Format(now)) + fmt.Printf(" DateTime: %s\n", timefmt.DateTime.Format(now)) + fmt.Printf(" DateUS: %s\n", timefmt.DateUS.Format(now)) + fmt.Printf(" Kitchen: %s\n\n", timefmt.Kitchen.Format(now)) + + // 3. Parsing existing Go formats + fmt.Println("3. Parsing an existing Go format:") + parsed, _ := timefmt.ParseGoFormat("02/01/2006") + fmt.Printf(" Input: 02/01/2006\n") + fmt.Printf(" LDML: %s\n", parsed.LDML()) + fmt.Printf(" Description: %s\n\n", parsed.Description()) + + // 4. Complex format example + fmt.Println("4. Complex format with custom text:") + complex := timefmt.NewBuilder(). + WeekdayFull().Comma(). + MonthFull().Space(). + DayNumeric().Comma(). + Year4().Space(). + Literal("at").Space(). + Hour12().Colon(). + Minute().Space(). + AMPM(). + Build() + + fmt.Printf(" Formatted: %s\n", complex.Format(now)) + fmt.Printf(" LDML: %s\n", complex.LDML()) +} diff --git a/constants.go b/constants.go new file mode 100644 index 0000000..4119415 --- /dev/null +++ b/constants.go @@ -0,0 +1,264 @@ +package timefmt + +// Fragment represents a single component of a time format. +// It maps between Go's reference time format, LDML tokens, and human-readable descriptions. +type Fragment struct { + // GoFormat is Go's reference time format (e.g., "2006" for 4-digit year) + GoFormat string + // LDML is the Unicode LDML token (e.g., "yyyy" for 4-digit year) + LDML string + // Description is a human-readable English description (e.g., "Year (4-digit)") + Description string +} + +// Year fragments represent different year formats +var ( + // Year4Digit represents a 4-digit year (e.g., 2026) + Year4Digit = Fragment{GoFormat: "2006", LDML: "yyyy", Description: "Year (4-digit)"} + // Year2Digit represents a 2-digit year (e.g., 26) + Year2Digit = Fragment{GoFormat: "06", LDML: "yy", Description: "Year (2-digit)"} +) + +// Month fragments represent different month formats +var ( + // MonthNumeric represents a numeric month without leading zero (1-12) + MonthNumeric = Fragment{GoFormat: "1", LDML: "M", Description: "Month (numeric)"} + // MonthNumeric2 represents a 2-digit month with leading zero (01-12) + MonthNumeric2 = Fragment{GoFormat: "01", LDML: "MM", Description: "Month (2-digit)"} + // MonthShort represents an abbreviated month name (Jan, Feb, Mar, etc.) + MonthShort = Fragment{GoFormat: "Jan", LDML: "MMM", Description: "Month (abbreviated)"} + // MonthFull represents a full month name (January, February, March, etc.) + MonthFull = Fragment{GoFormat: "January", LDML: "MMMM", Description: "Month (full name)"} +) + +// Day fragments represent different day-of-month formats +var ( + // DayNumeric represents a numeric day without leading zero (1-31) + DayNumeric = Fragment{GoFormat: "2", LDML: "d", Description: "Day (numeric)"} + // DayNumeric2 represents a 2-digit day with leading zero (01-31) + DayNumeric2 = Fragment{GoFormat: "02", LDML: "dd", Description: "Day (2-digit)"} + // DaySpacePadded represents a space-padded day ( 1-31) + DaySpacePadded = Fragment{GoFormat: "_2", LDML: "d", Description: "Day (space-padded)"} + // DayOfYearNumeric represents the day of year as a 3-digit number (001-365) + DayOfYearNumeric = Fragment{GoFormat: "002", LDML: "DDD", Description: "Day of year (3-digit)"} + // DayOfYearSpacePadded represents the day of year as a space-padded number ( 1-365) + DayOfYearSpacePadded = Fragment{GoFormat: "__2", LDML: "DDD", Description: "Day of year (space-padded)"} +) + +// Weekday fragments represent different weekday formats +var ( + // WeekdayShort represents an abbreviated weekday name (Mon, Tue, Wed, etc.) + WeekdayShort = Fragment{GoFormat: "Mon", LDML: "EEE", Description: "Weekday (abbreviated)"} + // WeekdayFull represents a full weekday name (Monday, Tuesday, Wednesday, etc.) + WeekdayFull = Fragment{GoFormat: "Monday", LDML: "EEEE", Description: "Weekday (full name)"} +) + +// Hour fragments represent different hour formats +var ( + // Hour24 represents 24-hour format with leading zero (00-23) + Hour24 = Fragment{GoFormat: "15", LDML: "HH", Description: "Hour (24-hour, 2-digit)"} + // Hour12 represents 12-hour format without leading zero (1-12) + Hour12 = Fragment{GoFormat: "3", LDML: "h", Description: "Hour (12-hour)"} + // Hour12Padded represents 12-hour format with leading zero (01-12) + Hour12Padded = Fragment{GoFormat: "03", LDML: "hh", Description: "Hour (12-hour, 2-digit)"} +) + +// Minute fragments represent different minute formats +var ( + // Minute represents minutes with leading zero (00-59) + Minute = Fragment{GoFormat: "04", LDML: "mm", Description: "Minute (2-digit)"} + // MinuteUnpadded represents minutes without leading zero (0-59) + MinuteUnpadded = Fragment{GoFormat: "4", LDML: "m", Description: "Minute"} +) + +// Second fragments represent different second formats +var ( + // Second represents seconds with leading zero (00-59) + Second = Fragment{GoFormat: "05", LDML: "ss", Description: "Second (2-digit)"} + // SecondUnpadded represents seconds without leading zero (0-59) + SecondUnpadded = Fragment{GoFormat: "5", LDML: "s", Description: "Second"} +) + +// Subsecond fragments represent fractional seconds +var ( + // Millisecond represents milliseconds as 3 digits (.000) + Millisecond = Fragment{GoFormat: ".000", LDML: ".SSS", Description: "Millisecond (3-digit)"} + // MillisecondTrim represents milliseconds with trailing zeros removed (.999) + MillisecondTrim = Fragment{GoFormat: ".999", LDML: ".SSS", Description: "Millisecond (trim zeros)"} + // Microsecond represents microseconds as 6 digits (.000000) + Microsecond = Fragment{GoFormat: ".000000", LDML: ".SSSSSS", Description: "Microsecond (6-digit)"} + // MicrosecondTrim represents microseconds with trailing zeros removed (.999999) + MicrosecondTrim = Fragment{GoFormat: ".999999", LDML: ".SSSSSS", Description: "Microsecond (trim zeros)"} + // Nanosecond represents nanoseconds as 9 digits (.000000000) + Nanosecond = Fragment{GoFormat: ".000000000", LDML: ".SSSSSSSSS", Description: "Nanosecond (9-digit)"} + // NanosecondTrim represents nanoseconds with trailing zeros removed (.999999999) + NanosecondTrim = Fragment{GoFormat: ".999999999", LDML: ".SSSSSSSSS", Description: "Nanosecond (trim zeros)"} +) + +// AMPM fragments represent AM/PM markers +var ( + // AMPM represents AM/PM in uppercase + AMPM = Fragment{GoFormat: "PM", LDML: "a", Description: "AM/PM (uppercase)"} + // AMPMLower represents am/pm in lowercase + AMPMLower = Fragment{GoFormat: "pm", LDML: "a", Description: "AM/PM (lowercase)"} +) + +// Timezone fragments represent different timezone formats +var ( + // TimezoneOffset represents timezone offset as ±HHMM (e.g., -0700) + TimezoneOffset = Fragment{GoFormat: "-0700", LDML: "ZZZ", Description: "Timezone offset (±HHMM)"} + // TimezoneOffsetColon represents timezone offset as ±HH:MM (e.g., -07:00) + TimezoneOffsetColon = Fragment{GoFormat: "-07:00", LDML: "ZZZZZ", Description: "Timezone offset (±HH:MM)"} + // TimezoneOffsetHourOnly represents timezone offset hours only as ±HH (e.g., -07) + TimezoneOffsetHourOnly = Fragment{GoFormat: "-07", LDML: "ZZ", Description: "Timezone offset (±HH)"} + // TimezoneOffsetSeconds represents timezone offset with seconds as ±HHMMSS (e.g., -070000) + TimezoneOffsetSeconds = Fragment{GoFormat: "-070000", LDML: "ZZZZ", Description: "Timezone offset (±HHMMSS)"} + // TimezoneOffsetColonSeconds represents timezone offset with seconds as ±HH:MM:SS (e.g., -07:00:00) + TimezoneOffsetColonSeconds = Fragment{GoFormat: "-07:00:00", LDML: "ZZZZZ", Description: "Timezone offset (±HH:MM:SS)"} + // TimezoneISO8601 represents ISO 8601 timezone with Z for UTC (e.g., Z or -0700) + TimezoneISO8601 = Fragment{GoFormat: "Z0700", LDML: "ZZZ", Description: "ISO 8601 timezone (Z or ±HHMM)"} + // TimezoneISO8601Colon represents ISO 8601 timezone with colon (e.g., Z or -07:00) + TimezoneISO8601Colon = Fragment{GoFormat: "Z07:00", LDML: "ZZZZZ", Description: "ISO 8601 timezone (Z or ±HH:MM)"} + // TimezoneName represents timezone abbreviation (e.g., MST, PST) + TimezoneName = Fragment{GoFormat: "MST", LDML: "zzz", Description: "Timezone abbreviation"} +) + +// Pre-built common formats for convenience. +// These are initialized in init() to avoid initialization cycles. +var ( + // ISO8601 represents the ISO 8601 datetime format: 2006-01-02T15:04:05Z07:00 + ISO8601 *Format + // RFC3339 represents the RFC 3339 datetime format (same as ISO8601): 2006-01-02T15:04:05Z07:00 + RFC3339 *Format + // RFC3339Nano represents the RFC 3339 datetime format with nanoseconds: 2006-01-02T15:04:05.999999999Z07:00 + RFC3339Nano *Format + // DateOnly represents a date-only format: 2006-01-02 + DateOnly *Format + // TimeOnly represents a time-only format: 15:04:05 + TimeOnly *Format + // DateTime represents a simple datetime format: 2006-01-02 15:04:05 + DateTime *Format + // DateTimeWithMillis represents datetime with milliseconds: 2006-01-02 15:04:05.000 + DateTimeWithMillis *Format + // DateUS represents US date format: 01/02/2006 + DateUS *Format + // DateEU represents European date format: 02/01/2006 + DateEU *Format + // DateTimeUS represents US datetime format: 01/02/2006 3:04:05 PM + DateTimeUS *Format + // DateTimeEU represents European datetime format: 02/01/2006 15:04:05 + DateTimeEU *Format + // Kitchen represents kitchen time format: 3:04 PM + Kitchen *Format + // Stamp represents a timestamp format: Jan _2 15:04:05 + Stamp *Format + // StampMilli represents a timestamp with milliseconds: Jan _2 15:04:05.000 + StampMilli *Format + // StampMicro represents a timestamp with microseconds: Jan _2 15:04:05.000000 + StampMicro *Format + // StampNano represents a timestamp with nanoseconds: Jan _2 15:04:05.000000000 + StampNano *Format +) + +func init() { + // ISO 8601 / RFC 3339: 2006-01-02T15:04:05Z07:00 + ISO8601 = NewBuilder(). + Year4().Dash().MonthNumeric2().Dash().DayNumeric2(). + T(). + Hour24().Colon().Minute().Colon().Second(). + TimezoneISO8601Colon(). + Build() + RFC3339 = ISO8601 // RFC 3339 is the same as ISO 8601 + + // RFC 3339 with nanoseconds: 2006-01-02T15:04:05.999999999Z07:00 + RFC3339Nano = NewBuilder(). + Year4().Dash().MonthNumeric2().Dash().DayNumeric2(). + T(). + Hour24().Colon().Minute().Colon().Second(). + NanosecondTrim(). + TimezoneISO8601Colon(). + Build() + + // Date only: 2006-01-02 + DateOnly = NewBuilder(). + Year4().Dash().MonthNumeric2().Dash().DayNumeric2(). + Build() + + // Time only: 15:04:05 + TimeOnly = NewBuilder(). + Hour24().Colon().Minute().Colon().Second(). + Build() + + // DateTime: 2006-01-02 15:04:05 + DateTime = NewBuilder(). + Year4().Dash().MonthNumeric2().Dash().DayNumeric2(). + Space(). + Hour24().Colon().Minute().Colon().Second(). + Build() + + // DateTime with milliseconds: 2006-01-02 15:04:05.000 + DateTimeWithMillis = NewBuilder(). + Year4().Dash().MonthNumeric2().Dash().DayNumeric2(). + Space(). + Hour24().Colon().Minute().Colon().Second(). + Millisecond(). + Build() + + // US date: 01/02/2006 + DateUS = NewBuilder(). + MonthNumeric2().Slash().DayNumeric2().Slash().Year4(). + Build() + + // European date: 02/01/2006 + DateEU = NewBuilder(). + DayNumeric2().Slash().MonthNumeric2().Slash().Year4(). + Build() + + // US datetime: 01/02/2006 3:04:05 PM + DateTimeUS = NewBuilder(). + MonthNumeric2().Slash().DayNumeric2().Slash().Year4(). + Space(). + Hour12().Colon().Minute().Colon().Second(). + Space().AMPM(). + Build() + + // European datetime: 02/01/2006 15:04:05 + DateTimeEU = NewBuilder(). + DayNumeric2().Slash().MonthNumeric2().Slash().Year4(). + Space(). + Hour24().Colon().Minute().Colon().Second(). + Build() + + // Kitchen: 3:04 PM + Kitchen = NewBuilder(). + Hour12().Colon().Minute(). + Space().AMPM(). + Build() + + // Stamp: Jan _2 15:04:05 + Stamp = NewBuilder(). + MonthShort().Space().DaySpacePadded().Space(). + Hour24().Colon().Minute().Colon().Second(). + Build() + + // Stamp with milliseconds: Jan _2 15:04:05.000 + StampMilli = NewBuilder(). + MonthShort().Space().DaySpacePadded().Space(). + Hour24().Colon().Minute().Colon().Second(). + Millisecond(). + Build() + + // Stamp with microseconds: Jan _2 15:04:05.000000 + StampMicro = NewBuilder(). + MonthShort().Space().DaySpacePadded().Space(). + Hour24().Colon().Minute().Colon().Second(). + Microsecond(). + Build() + + // Stamp with nanoseconds: Jan _2 15:04:05.000000000 + StampNano = NewBuilder(). + MonthShort().Space().DaySpacePadded().Space(). + Hour24().Colon().Minute().Colon().Second(). + Nanosecond(). + Build() +} diff --git a/converter.go b/converter.go new file mode 100644 index 0000000..810b51b --- /dev/null +++ b/converter.go @@ -0,0 +1,143 @@ +package timefmt + +import ( + "fmt" + "strings" +) + +// ParseGoFormat parses a Go time format string and returns a Format. +// It analyzes the Go reference time format (e.g., "2006-01-02 15:04:05") +// and converts it into a Format with the appropriate fragments. +// +// Example: +// +// format, err := ParseGoFormat("2006-01-02 15:04:05") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(format.LDML()) // "yyyy-MM-dd HH:mm:ss" +// fmt.Println(format.Description()) // Full English description +func ParseGoFormat(goFormat string) (*Format, error) { + if goFormat == "" { + return nil, fmt.Errorf("empty format string") + } + + // Define token mappings in order of precedence to avoid partial matches + // CRITICAL: Patterns that could conflict must be ordered carefully! + // - Longer patterns before shorter + // - More specific patterns before general + // - Unique identifiers (like "15" for hour, "04" for minute) before ambiguous ones + tokens := []struct { + goToken string + fragment Fragment + }{ + // Subseconds (must come before other number patterns due to decimal point) + {".000000000", Nanosecond}, + {".999999999", NanosecondTrim}, + {".000000", Microsecond}, + {".999999", MicrosecondTrim}, + {".000", Millisecond}, + {".999", MillisecondTrim}, + + // Timezone (longer patterns first) + {"-07:00:00", TimezoneOffsetColonSeconds}, + {"-070000", TimezoneOffsetSeconds}, + {"Z07:00", TimezoneISO8601Colon}, + {"Z0700", TimezoneISO8601}, + {"-07:00", TimezoneOffsetColon}, + {"-0700", TimezoneOffset}, + {"-07", TimezoneOffsetHourOnly}, + {"MST", TimezoneName}, + + // Year (must come before month numbers) + {"2006", Year4Digit}, + {"06", Year2Digit}, + + // Month names (before numeric months to avoid conflicts) + {"January", MonthFull}, + {"Jan", MonthShort}, + + // Weekday names (before numeric days) + {"Monday", WeekdayFull}, + {"Mon", WeekdayShort}, + + // Day of year (before regular days, longer patterns first) + {"__2", DayOfYearSpacePadded}, + {"002", DayOfYearNumeric}, + + // Time components (MUST come before month/day numbers to avoid conflicts!) + // "15" is unique to 24-hour format, "04" is unique to minutes, "05" to seconds + {"15", Hour24}, // Must come before "1" (month) and "5" (second) + {"04", Minute}, // Must come before "4" (minute unpadded) + {"05", Second}, // Must come before "5" (second unpadded) + {"03", Hour12Padded}, // Must come before "3" (hour) + + // Month and day numbers (after time components!) + {"01", MonthNumeric2}, // Padded month + {"02", DayNumeric2}, // Padded day + {"_2", DaySpacePadded}, // Space-padded day + + // Single digit patterns (LAST to avoid premature matching!) + {"1", MonthNumeric}, + {"2", DayNumeric}, + {"3", Hour12}, + {"4", MinuteUnpadded}, + {"5", SecondUnpadded}, + + // AM/PM + {"PM", AMPM}, + {"pm", AMPMLower}, + } + + var fragments []interface{} + i := 0 + + for i < len(goFormat) { + matched := false + + // Try to match tokens (longest first) + for _, token := range tokens { + if strings.HasPrefix(goFormat[i:], token.goToken) { + fragments = append(fragments, token.fragment) + i += len(token.goToken) + matched = true + break + } + } + + if !matched { + // This is a literal character + // Collect consecutive literal characters + literalStart := i + i++ + // Continue collecting until we hit a token + for i < len(goFormat) { + foundToken := false + for _, token := range tokens { + if strings.HasPrefix(goFormat[i:], token.goToken) { + foundToken = true + break + } + } + if foundToken { + break + } + i++ + } + literal := goFormat[literalStart:i] + fragments = append(fragments, literal) + } + } + + return &Format{fragments: fragments}, nil +} + +// MustParseGoFormat is like ParseGoFormat but panics on error. +// It's useful for initialization of package-level variables. +func MustParseGoFormat(goFormat string) *Format { + format, err := ParseGoFormat(goFormat) + if err != nil { + panic(fmt.Sprintf("MustParseGoFormat: %v", err)) + } + return format +} diff --git a/converter_test.go b/converter_test.go new file mode 100644 index 0000000..db78460 --- /dev/null +++ b/converter_test.go @@ -0,0 +1,302 @@ +package timefmt + +import ( + "testing" + "time" +) + +func TestParseGoFormat(t *testing.T) { + tests := []struct { + name string + goFormat string + wantLDML string + wantDesc string + shouldError bool + }{ + { + name: "ISO 8601 date", + goFormat: "2006-01-02", + wantLDML: "yyyy-MM-dd", + wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)", + }, + { + name: "24-hour time", + goFormat: "15:04:05", + wantLDML: "HH:mm:ss", + wantDesc: "Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)", + }, + { + name: "Full datetime", + goFormat: "2006-01-02 15:04:05", + wantLDML: "yyyy-MM-dd HH:mm:ss", + wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)", + }, + { + name: "12-hour with AM/PM", + goFormat: "3:04 PM", + wantLDML: "h:mm a", + wantDesc: "Hour (12-hour), colon, Minute (2-digit), space, AM/PM (uppercase)", + }, + { + name: "US date format", + goFormat: "01/02/2006", + wantLDML: "MM/dd/yyyy", + wantDesc: "Month (2-digit), slash, Day (2-digit), slash, Year (4-digit)", + }, + { + name: "European date format", + goFormat: "02/01/2006", + wantLDML: "dd/MM/yyyy", + wantDesc: "Day (2-digit), slash, Month (2-digit), slash, Year (4-digit)", + }, + { + name: "RFC3339", + goFormat: "2006-01-02T15:04:05Z07:00", + wantLDML: "yyyy-MM-dd'T'HH:mm:ssZZZZZ", + wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), literal 'T', Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), ISO 8601 timezone (Z or ±HH:MM)", + }, + { + name: "With milliseconds", + goFormat: "2006-01-02 15:04:05.000", + wantLDML: "yyyy-MM-dd HH:mm:ss.SSS", + wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), Millisecond (3-digit)", + }, + { + name: "With nanoseconds", + goFormat: "2006-01-02 15:04:05.000000000", + wantLDML: "yyyy-MM-dd HH:mm:ss.SSSSSSSSS", + wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), Nanosecond (9-digit)", + }, + { + name: "Month names", + goFormat: "January 2, 2006", + wantLDML: "MMMM d, yyyy", + wantDesc: "Month (full name), space, Day (numeric), comma-space, Year (4-digit)", + }, + { + name: "Abbreviated month and weekday", + goFormat: "Mon, Jan 2 2006", + wantLDML: "EEE, MMM d yyyy", + wantDesc: "Weekday (abbreviated), comma-space, Month (abbreviated), space, Day (numeric), space, Year (4-digit)", + }, + { + name: "Full weekday and month", + goFormat: "Monday, January 2, 2006", + wantLDML: "EEEE, MMMM d, yyyy", + wantDesc: "Weekday (full name), comma-space, Month (full name), space, Day (numeric), comma-space, Year (4-digit)", + }, + { + name: "With timezone name", + goFormat: "2006-01-02 15:04:05 MST", + wantLDML: "yyyy-MM-dd HH:mm:ss zzz", + wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), space, Timezone abbreviation", + }, + { + name: "With timezone offset", + goFormat: "2006-01-02 15:04:05 -0700", + wantLDML: "yyyy-MM-dd HH:mm:ss ZZZ", + wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), space, Timezone offset (±HHMM)", + }, + { + name: "Kitchen time", + goFormat: "3:04PM", + wantLDML: "h:mma", + wantDesc: "Hour (12-hour), colon, Minute (2-digit), AM/PM (uppercase)", + }, + { + name: "With literal text", + goFormat: "2006-01-02 at 15:04", + wantLDML: "yyyy-MM-dd' at 'HH:mm", + wantDesc: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), literal ' at ', Hour (24-hour, 2-digit), colon, Minute (2-digit)", + }, + { + name: "Space-padded day", + goFormat: "Jan _2 15:04:05", + wantLDML: "MMM d HH:mm:ss", + wantDesc: "Month (abbreviated), space, Day (space-padded), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)", + }, + { + name: "Empty string", + goFormat: "", + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + format, err := ParseGoFormat(tt.goFormat) + + if tt.shouldError { + if err == nil { + t.Error("Expected error but got none") + } + return + } + + if err != nil { + t.Fatalf("ParseGoFormat() error = %v", err) + } + + // Verify the format can reproduce the original Go format + gotGoFormat := format.GoFormat() + if gotGoFormat != tt.goFormat { + t.Errorf("GoFormat() = %q, want %q", gotGoFormat, tt.goFormat) + } + + // Verify LDML conversion + gotLDML := format.LDML() + if gotLDML != tt.wantLDML { + t.Errorf("LDML() = %q, want %q", gotLDML, tt.wantLDML) + } + + // Verify description + gotDesc := format.Description() + if gotDesc != tt.wantDesc { + t.Errorf("Description() = %q, want %q", gotDesc, tt.wantDesc) + } + }) + } +} + +func TestParseGoFormat_StdlibFormats(t *testing.T) { + // Test that we can parse all the standard library time format constants + tests := []struct { + name string + goFormat string + }{ + {"time.ANSIC", "Mon Jan _2 15:04:05 2006"}, + {"time.UnixDate", "Mon Jan _2 15:04:05 MST 2006"}, + {"time.RubyDate", "Mon Jan 02 15:04:05 -0700 2006"}, + {"time.RFC822", "02 Jan 06 15:04 MST"}, + {"time.RFC822Z", "02 Jan 06 15:04 -0700"}, + {"time.RFC850", "Monday, 02-Jan-06 15:04:05 MST"}, + {"time.RFC1123", "Mon, 02 Jan 2006 15:04:05 MST"}, + {"time.RFC1123Z", "Mon, 02 Jan 2006 15:04:05 -0700"}, + {"time.RFC3339", "2006-01-02T15:04:05Z07:00"}, + {"time.RFC3339Nano", "2006-01-02T15:04:05.999999999Z07:00"}, + {"time.Kitchen", "3:04PM"}, + {"time.Stamp", "Jan _2 15:04:05"}, + {"time.StampMilli", "Jan _2 15:04:05.000"}, + {"time.StampMicro", "Jan _2 15:04:05.000000"}, + {"time.StampNano", "Jan _2 15:04:05.000000000"}, + {"time.DateTime", "2006-01-02 15:04:05"}, + {"time.DateOnly", "2006-01-02"}, + {"time.TimeOnly", "15:04:05"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + format, err := ParseGoFormat(tt.goFormat) + if err != nil { + t.Fatalf("ParseGoFormat() error = %v", err) + } + + // Verify roundtrip + gotGoFormat := format.GoFormat() + if gotGoFormat != tt.goFormat { + t.Errorf("GoFormat() = %q, want %q", gotGoFormat, tt.goFormat) + } + + // Verify it can actually format a time + testTime := time.Date(2026, time.February, 8, 15, 4, 5, 123456789, time.FixedZone("MST", -7*3600)) + formatted := format.Format(testTime) + if formatted == "" { + t.Error("Format() returned empty string") + } + + // Verify LDML and Description don't panic + _ = format.LDML() + _ = format.Description() + }) + } +} + +func TestParseGoFormat_RoundTrip(t *testing.T) { + // Test that parsing a format and converting back to Go format is lossless + formats := []string{ + "2006-01-02", + "15:04:05", + "2006-01-02 15:04:05", + "01/02/2006 3:04:05 PM", + "Monday, January 2, 2006", + "Jan _2 15:04:05.000", + "2006-01-02T15:04:05Z07:00", + "02 Jan 06 15:04 MST", + } + + for _, original := range formats { + t.Run(original, func(t *testing.T) { + format, err := ParseGoFormat(original) + if err != nil { + t.Fatalf("ParseGoFormat() error = %v", err) + } + + result := format.GoFormat() + if result != original { + t.Errorf("Round trip failed: got %q, want %q", result, original) + } + }) + } +} + +func TestMustParseGoFormat(t *testing.T) { + t.Run("Valid format", func(t *testing.T) { + // Should not panic + format := MustParseGoFormat("2006-01-02") + if format == nil { + t.Error("MustParseGoFormat returned nil") + } + }) + + t.Run("Invalid format panics", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("MustParseGoFormat should panic on empty string") + } + }() + MustParseGoFormat("") + }) +} + +func TestParseGoFormat_EdgeCases(t *testing.T) { + tests := []struct { + name string + goFormat string + wantGo string + }{ + { + name: "Consecutive literals", + goFormat: "2006-01-02T15:04:05", + wantGo: "2006-01-02T15:04:05", + }, + { + name: "Just literals", + goFormat: "Hello, World!", + wantGo: "Hello, World!", + }, + { + name: "Mixed tokens and literals", + goFormat: "Year: 2006, Month: 01", + wantGo: "Year: 2006, Month: 01", + }, + { + name: "Special characters", + goFormat: "2006/01/02 @ 15:04:05", + wantGo: "2006/01/02 @ 15:04:05", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + format, err := ParseGoFormat(tt.goFormat) + if err != nil { + t.Fatalf("ParseGoFormat() error = %v", err) + } + + gotGo := format.GoFormat() + if gotGo != tt.wantGo { + t.Errorf("GoFormat() = %q, want %q", gotGo, tt.wantGo) + } + }) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..574063b --- /dev/null +++ b/doc.go @@ -0,0 +1,86 @@ +// Package timefmt provides tools for building and converting time format strings +// with human-readable descriptions. +// +// It offers a fluent builder API for constructing time formats and can convert +// between Go's reference time format, LDML (Unicode Locale Data Markup Language) +// tokens, and plain English descriptions. +// +// # Basic Usage +// +// Build a time format using the fluent builder: +// +// format := timefmt.NewBuilder(). +// Year4().Dash(). +// MonthNumeric2().Dash(). +// DayNumeric2(). +// Build() +// +// fmt.Println(format.GoFormat()) // "2006-01-02" +// fmt.Println(format.LDML()) // "yyyy-MM-dd" +// fmt.Println(format.Description()) // "Year (4-digit), dash, ..." +// +// # Pre-built Formats +// +// Common formats are available as package-level constants: +// +// now := time.Now() +// fmt.Println(timefmt.ISO8601.Format(now)) +// fmt.Println(timefmt.DateTime.Format(now)) +// fmt.Println(timefmt.DateUS.Format(now)) +// +// # Parsing Existing Formats +// +// Parse an existing Go time format string to get human-readable descriptions: +// +// format, err := timefmt.ParseGoFormat("02/01/2006 15:04:05") +// if err != nil { +// log.Fatal(err) +// } +// +// fmt.Println(format.LDML()) // "dd/MM/yyyy HH:mm:ss" +// fmt.Println(format.Description()) // Full English description +// +// # Multiple Output Formats +// +// Every Format provides three representations: +// +// - GoFormat(): Go's reference time format (e.g., "2006-01-02") +// - LDML(): Unicode LDML tokens (e.g., "yyyy-MM-dd") +// - Description(): Plain English description +// +// This makes it easy to document time formats for both developers and end users. +// +// # Use Cases +// +// Display format information to users: +// +// format := timefmt.DateOnly +// fmt.Printf("Expected format: %s\n", format.Description()) +// fmt.Printf("Example: %s\n", format.Example()) +// +// Validate user input with helpful error messages: +// +// if _, err := format.Parse(userInput); err != nil { +// return fmt.Errorf("invalid date. Expected: %s (example: %s)", +// format.Description(), format.Example()) +// } +// +// Generate documentation for configuration: +// +// for name, fmt := range configFormats { +// fmt.Printf("%s: %s (LDML: %s)\n", +// name, fmt.Description(), fmt.LDML()) +// } +// +// # LDML Compatibility +// +// The LDML output follows the Unicode LDML standard, making it compatible with: +// +// - ICU (International Components for Unicode) +// - Java (SimpleDateFormat, DateTimeFormatter) +// - Swift (DateFormatter) +// - JavaScript (Moment.js, Day.js, date-fns) +// - Most modern date/time libraries +// +// This allows seamless integration with systems that use LDML for time formatting. +package timefmt diff --git a/examples/example_test.go b/examples/example_test.go new file mode 100644 index 0000000..40f3bff --- /dev/null +++ b/examples/example_test.go @@ -0,0 +1,189 @@ +package examples + +import ( + "fmt" + "log" + "time" + + "git.haelnorr.com/h/timefmt" +) + +// Example demonstrating basic usage of the builder +func Example_basic() { + format := timefmt.NewBuilder(). + Year4().Dash(). + MonthNumeric2().Dash(). + DayNumeric2(). + Build() + + fmt.Println("Go format:", format.GoFormat()) + fmt.Println("LDML:", format.LDML()) + fmt.Println("Description:", format.Description()) + + // Output: + // Go format: 2006-01-02 + // LDML: yyyy-MM-dd + // Description: Year (4-digit), dash, Month (2-digit), dash, Day (2-digit) +} + +// Example showing how to use pre-built formats +func Example_prebuilt() { + now := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.UTC) + + fmt.Println("ISO8601:", timefmt.ISO8601.Format(now)) + fmt.Println("DateOnly:", timefmt.DateOnly.Format(now)) + fmt.Println("DateTime:", timefmt.DateTime.Format(now)) + fmt.Println("DateUS:", timefmt.DateUS.Format(now)) + fmt.Println("Kitchen:", timefmt.Kitchen.Format(now)) + + // Output: + // ISO8601: 2026-02-08T15:04:05Z + // DateOnly: 2026-02-08 + // DateTime: 2026-02-08 15:04:05 + // DateUS: 02/08/2026 + // Kitchen: 3:04 PM +} + +// Example parsing an existing Go format string +func Example_parsing() { + format, err := timefmt.ParseGoFormat("02/01/2006 15:04:05") + if err != nil { + log.Fatal(err) + } + + fmt.Println("LDML:", format.LDML()) + fmt.Println("Description:", format.Description()) + + // Output: + // LDML: dd/MM/yyyy HH:mm:ss + // Description: Day (2-digit), slash, Month (2-digit), slash, Year (4-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit) +} + +// Example creating a complex custom format +func Example_complex() { + format := timefmt.NewBuilder(). + WeekdayFull().Comma(). + MonthFull().Space(). + DayNumeric().Comma(). + Year4().Space(). + Literal("at").Space(). + Hour12().Colon(). + Minute().Space(). + AMPM(). + Build() + + testTime := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.UTC) + + fmt.Println("Formatted:", format.Format(testTime)) + fmt.Println("LDML:", format.LDML()) + + // Output: + // Formatted: Sunday, February 8, 2026 at 3:04 PM + // LDML: EEEE, MMMM d, yyyy 'at' h:mm a +} + +// Example showing format conversion for user documentation +func Example_documentation() { + // Imagine this comes from user configuration + userFormat := "2006-01-02 15:04:05 MST" + + format, err := timefmt.ParseGoFormat(userFormat) + if err != nil { + log.Fatal(err) + } + + // Display to user + fmt.Println("Your timestamp format:") + fmt.Printf(" Format: %s\n", format.LDML()) + fmt.Printf(" Example: %s\n", format.Example()) + fmt.Println() + fmt.Println("Detailed breakdown:") + fmt.Printf(" %s\n", format.Description()) + + // Output: + // Your timestamp format: + // Format: yyyy-MM-dd HH:mm:ss zzz + // Example: 2026-02-08 15:04:05 MST + // + // Detailed breakdown: + // Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit), space, Timezone abbreviation +} + +// Example using format fragments directly +func Example_fragments() { + // Create a format using fragments directly + format := timefmt.NewFormat( + timefmt.Year4Digit, + "/", + timefmt.MonthNumeric2, + "/", + timefmt.DayNumeric2, + ) + + fmt.Println("Go format:", format.GoFormat()) + fmt.Println("LDML:", format.LDML()) + + // Output: + // Go format: 2006/01/02 + // LDML: yyyy/MM/dd +} + +// Example for form validation error messages +func Example_validation() { + format := timefmt.DateOnly + + // Simulated user input validation + userInput := "02-08-2026" // Wrong format + _, err := format.Parse(userInput) + + if err != nil { + fmt.Printf("Error: Invalid date format\n") + fmt.Printf("Expected: %s\n", format.Description()) + fmt.Printf("Example: %s\n", format.Example()) + } + + // Output: + // Error: Invalid date format + // Expected: Year (4-digit), dash, Month (2-digit), dash, Day (2-digit) + // Example: 2026-02-08 +} + +// Example showing timezone handling +func Example_timezone() { + format := timefmt.NewBuilder(). + Year4().Dash().MonthNumeric2().Dash().DayNumeric2(). + Space(). + Hour24().Colon().Minute().Colon().Second(). + Space(). + TimezoneOffsetColon(). + Build() + + loc := time.FixedZone("EST", -5*3600) + t := time.Date(2026, time.February, 8, 15, 4, 5, 0, loc) + + fmt.Println("Formatted:", format.Format(t)) + fmt.Println("LDML:", format.LDML()) + + // Output: + // Formatted: 2026-02-08 15:04:05 -05:00 + // LDML: yyyy-MM-dd HH:mm:ss ZZZZZ +} + +// Example demonstrating millisecond precision +func Example_subseconds() { + format := timefmt.NewBuilder(). + Hour24().Colon(). + Minute().Colon(). + Second(). + Millisecond(). + Build() + + t := time.Date(2026, time.February, 8, 15, 4, 5, 123456789, time.UTC) + + fmt.Println("Formatted:", format.Format(t)) + fmt.Println("Go format:", format.GoFormat()) + + // Output: + // Formatted: 15:04:05.123 + // Go format: 15:04:05.000 +} diff --git a/format.go b/format.go new file mode 100644 index 0000000..a2e4a32 --- /dev/null +++ b/format.go @@ -0,0 +1,143 @@ +package timefmt + +import ( + "strings" + "time" +) + +// Format represents a time format pattern composed of fragments and literals. +// It can convert between Go's reference time format, LDML tokens, and human-readable descriptions. +type Format struct { + fragments []interface{} // Can be Fragment or string (for literals) +} + +// NewFormat creates a new Format from a slice of fragments and literals. +// Each element can be either a Fragment or a string literal. +func NewFormat(fragments ...interface{}) *Format { + return &Format{fragments: fragments} +} + +// GoFormat returns the Go reference time format string. +// Example: "2006-01-02 15:04:05" +func (f *Format) GoFormat() string { + var sb strings.Builder + for _, frag := range f.fragments { + switch v := frag.(type) { + case Fragment: + sb.WriteString(v.GoFormat) + case string: + sb.WriteString(v) + } + } + return sb.String() +} + +// LDML returns the LDML-style human-readable format string. +// Example: "yyyy-MM-dd HH:mm:ss" +func (f *Format) LDML() string { + var sb strings.Builder + for _, frag := range f.fragments { + switch v := frag.(type) { + case Fragment: + sb.WriteString(v.LDML) + case string: + // Escape literals that might conflict with LDML tokens + // by wrapping them in single quotes if they contain letters + if needsEscaping(v) { + sb.WriteRune('\'') + sb.WriteString(v) + sb.WriteRune('\'') + } else { + sb.WriteString(v) + } + } + } + return sb.String() +} + +// Description returns a full English description of the format. +// Example: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), space, Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)" +func (f *Format) Description() string { + var parts []string + for _, frag := range f.fragments { + switch v := frag.(type) { + case Fragment: + parts = append(parts, v.Description) + case string: + parts = append(parts, describeLiteral(v)) + } + } + return strings.Join(parts, ", ") +} + +// Format formats a time.Time using this format pattern. +// It uses Go's time.Format internally. +func (f *Format) Format(t time.Time) string { + return t.Format(f.GoFormat()) +} + +// Parse parses a time string using this format pattern. +// It uses Go's time.Parse internally. +func (f *Format) Parse(value string) (time.Time, error) { + return time.Parse(f.GoFormat(), value) +} + +// ParseInLocation parses a time string using this format pattern in the specified location. +// It uses Go's time.ParseInLocation internally. +func (f *Format) ParseInLocation(value string, loc *time.Location) (time.Time, error) { + return time.ParseInLocation(f.GoFormat(), value, loc) +} + +// Example returns an example of what this format looks like using a reference time. +// The reference time used is: February 8, 2026 at 15:04:05.999999999 in UTC-7 +func (f *Format) Example() string { + // Use a specific reference time that shows all components clearly + referenceTime := time.Date(2026, time.February, 8, 15, 4, 5, 999999999, time.FixedZone("MST", -7*3600)) + return f.Format(referenceTime) +} + +// Fragments returns a copy of the format's fragments slice. +// This is useful for inspecting the format's structure. +func (f *Format) Fragments() []interface{} { + result := make([]interface{}, len(f.fragments)) + copy(result, f.fragments) + return result +} + +// needsEscaping returns true if a literal string contains letters that might +// be confused with LDML format tokens and should be escaped with quotes. +func needsEscaping(s string) bool { + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { + return true + } + } + return false +} + +// describeLiteral converts a literal string into a human-readable description. +func describeLiteral(s string) string { + switch s { + case "-": + return "dash" + case "/": + return "slash" + case ":": + return "colon" + case " ": + return "space" + case ".": + return "period" + case ",": + return "comma" + case ", ": + return "comma-space" + case "T": + return "literal 'T'" + case "Z": + return "literal 'Z'" + default: + // For other literals, describe as "literal 'X'" + return "literal '" + s + "'" + } +} diff --git a/format_test.go b/format_test.go new file mode 100644 index 0000000..ab1967d --- /dev/null +++ b/format_test.go @@ -0,0 +1,480 @@ +package timefmt + +import ( + "testing" + "time" +) + +func TestFormat_GoFormat(t *testing.T) { + tests := []struct { + name string + format *Format + want string + }{ + { + name: "ISO 8601 date", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + ), + want: "2006-01-02", + }, + { + name: "24-hour time", + format: NewFormat( + Hour24, + ":", + Minute, + ":", + Second, + ), + want: "15:04:05", + }, + { + name: "Full datetime", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + " ", + Hour24, + ":", + Minute, + ":", + Second, + ), + want: "2006-01-02 15:04:05", + }, + { + name: "12-hour with AM/PM", + format: NewFormat( + Hour12, + ":", + Minute, + " ", + AMPM, + ), + want: "3:04 PM", + }, + { + name: "Full date with weekday and month name", + format: NewFormat( + WeekdayFull, + ", ", + MonthFull, + " ", + DayNumeric, + ", ", + Year4Digit, + ), + want: "Monday, January 2, 2006", + }, + { + name: "ISO 8601 with timezone", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + "T", + Hour24, + ":", + Minute, + ":", + Second, + TimezoneISO8601Colon, + ), + want: "2006-01-02T15:04:05Z07:00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.format.GoFormat() + if got != tt.want { + t.Errorf("GoFormat() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormat_LDML(t *testing.T) { + tests := []struct { + name string + format *Format + want string + }{ + { + name: "ISO 8601 date", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + ), + want: "yyyy-MM-dd", + }, + { + name: "24-hour time", + format: NewFormat( + Hour24, + ":", + Minute, + ":", + Second, + ), + want: "HH:mm:ss", + }, + { + name: "Full datetime", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + " ", + Hour24, + ":", + Minute, + ":", + Second, + ), + want: "yyyy-MM-dd HH:mm:ss", + }, + { + name: "12-hour with AM/PM", + format: NewFormat( + Hour12, + ":", + Minute, + " ", + AMPM, + ), + want: "h:mm a", + }, + { + name: "ISO 8601 with T literal", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + "T", + Hour24, + ":", + Minute, + ":", + Second, + ), + want: "yyyy-MM-dd'T'HH:mm:ss", + }, + { + name: "Month name (abbreviated)", + format: NewFormat( + MonthShort, + " ", + DayNumeric2, + ", ", + Year4Digit, + ), + want: "MMM dd, yyyy", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.format.LDML() + if got != tt.want { + t.Errorf("LDML() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormat_Description(t *testing.T) { + tests := []struct { + name string + format *Format + want string + }{ + { + name: "ISO 8601 date", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + ), + want: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit)", + }, + { + name: "24-hour time", + format: NewFormat( + Hour24, + ":", + Minute, + ":", + Second, + ), + want: "Hour (24-hour, 2-digit), colon, Minute (2-digit), colon, Second (2-digit)", + }, + { + name: "12-hour with AM/PM", + format: NewFormat( + Hour12, + ":", + Minute, + " ", + AMPM, + ), + want: "Hour (12-hour), colon, Minute (2-digit), space, AM/PM (uppercase)", + }, + { + name: "Date with literal T", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + "T", + Hour24, + ), + want: "Year (4-digit), dash, Month (2-digit), dash, Day (2-digit), literal 'T', Hour (24-hour, 2-digit)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.format.Description() + if got != tt.want { + t.Errorf("Description() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormat_Format(t *testing.T) { + // Use a specific test time + testTime := time.Date(2026, time.February, 8, 15, 4, 5, 0, time.UTC) + + tests := []struct { + name string + format *Format + want string + }{ + { + name: "ISO 8601 date", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + ), + want: "2026-02-08", + }, + { + name: "24-hour time", + format: NewFormat( + Hour24, + ":", + Minute, + ":", + Second, + ), + want: "15:04:05", + }, + { + name: "Full datetime", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + " ", + Hour24, + ":", + Minute, + ":", + Second, + ), + want: "2026-02-08 15:04:05", + }, + { + name: "Month name", + format: NewFormat( + MonthFull, + " ", + DayNumeric, + ", ", + Year4Digit, + ), + want: "February 8, 2026", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.format.Format(testTime) + if got != tt.want { + t.Errorf("Format() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFormat_Parse(t *testing.T) { + tests := []struct { + name string + format *Format + input string + wantYear int + wantMonth time.Month + wantDay int + wantHour int + wantMin int + wantSec int + }{ + { + name: "ISO 8601 date", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + ), + input: "2026-02-08", + wantYear: 2026, + wantMonth: time.February, + wantDay: 8, + }, + { + name: "Full datetime", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + " ", + Hour24, + ":", + Minute, + ":", + Second, + ), + input: "2026-02-08 15:04:05", + wantYear: 2026, + wantMonth: time.February, + wantDay: 8, + wantHour: 15, + wantMin: 4, + wantSec: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.format.Parse(tt.input) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + if got.Year() != tt.wantYear { + t.Errorf("Parse() year = %v, want %v", got.Year(), tt.wantYear) + } + if got.Month() != tt.wantMonth { + t.Errorf("Parse() month = %v, want %v", got.Month(), tt.wantMonth) + } + if got.Day() != tt.wantDay { + t.Errorf("Parse() day = %v, want %v", got.Day(), tt.wantDay) + } + if got.Hour() != tt.wantHour { + t.Errorf("Parse() hour = %v, want %v", got.Hour(), tt.wantHour) + } + if got.Minute() != tt.wantMin { + t.Errorf("Parse() minute = %v, want %v", got.Minute(), tt.wantMin) + } + if got.Second() != tt.wantSec { + t.Errorf("Parse() second = %v, want %v", got.Second(), tt.wantSec) + } + }) + } +} + +func TestFormat_Example(t *testing.T) { + tests := []struct { + name string + format *Format + // We'll just check that Example() returns a non-empty string + // The exact output depends on the reference time used + }{ + { + name: "ISO 8601 date", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + ), + }, + { + name: "Full datetime", + format: NewFormat( + Year4Digit, + "-", + MonthNumeric2, + "-", + DayNumeric2, + " ", + Hour24, + ":", + Minute, + ":", + Second, + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.format.Example() + if got == "" { + t.Errorf("Example() returned empty string") + } + }) + } +} + +func TestFormat_Fragments(t *testing.T) { + format := NewFormat( + Year4Digit, + "-", + MonthNumeric2, + ) + + fragments := format.Fragments() + if len(fragments) != 3 { + t.Errorf("Fragments() returned %d fragments, want 3", len(fragments)) + } + + // Verify it's a copy (modifying it doesn't affect the original) + fragments[0] = "modified" + originalFragments := format.Fragments() + if len(originalFragments) != 3 { + t.Errorf("Original format was modified") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..56558b5 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.haelnorr.com/h/timefmt + +go 1.25.6