diff --git a/cmds/timestamps/ast.go b/cmds/timestamps/ast.go index 43679fb..7c2c1b3 100644 --- a/cmds/timestamps/ast.go +++ b/cmds/timestamps/ast.go @@ -2,7 +2,6 @@ package main import ( "errors" - "strings" "time" ) @@ -10,51 +9,66 @@ type TimeComponent struct { TS *string `parser:"@Time"` } -func (t TimeComponent) Time() (time.Time, error) { - if strings.HasSuffix(*t.TS, "Z") { - return time.ParseInLocation("15:04:05Z", *t.TS, time.UTC) - } - return time.ParseInLocation("15:04:05", *t.TS, time.Local) +func (t TimeComponent) Time(loc *time.Location) (time.Time, error) { + return time.ParseInLocation("15:04:05", *t.TS, loc) } func (t TimeComponent) RequiresRefreshing() bool { return false } +type TimeInZoneComponent struct { + TS *string `parser:"@Time"` + InUTC bool `parser:"@Z?"` +} + +func (t TimeInZoneComponent) Time() (time.Time, error) { + loc := time.Local + if t.InUTC { + loc = time.UTC + } + + return time.ParseInLocation("15:04:05", *t.TS, loc) +} + +func (t TimeInZoneComponent) RequiresRefreshing() bool { + return false +} + type DateWithTimeComponents struct { Date *string `parser:"@Date"` - MaybeTime *TimeComponent `parser:" @@?"` + MaybeTime *TimeComponent `parser:"(T? @@)?"` + InUTC bool `parser:"(@Z)?"` } func (d DateWithTimeComponents) Time() (time.Time, error) { - t, err := time.Parse("2006-01-02", *d.Date) + loc := time.Local + if d.InUTC { + loc = time.UTC + } + + t, err := time.ParseInLocation("2006-01-02", *d.Date, loc) if err != nil { return time.Time{}, err } if d.MaybeTime != nil { - tt, err := d.MaybeTime.Time() + tt, err := d.MaybeTime.Time(loc) if err != nil { return time.Time{}, err } - t = t.Add(tt.Sub(tt.Truncate(24 * time.Hour))) + t = time.Date(t.Year(), t.Month(), t.Day(), tt.Hour(), tt.Minute(), tt.Second(), 0, loc) } return t, nil } type Timestamp struct { - Full *string `parser:"@Timestamp"` - DT *DateWithTimeComponents `parser:"| @@"` - TimeToday *TimeComponent `parser:" | @@"` + DT *DateWithTimeComponents `parser:"@@"` + TimeToday *TimeInZoneComponent `parser:" | @@"` Int *int `parser:" | @Int"` } func (t Timestamp) Time() (time.Time, error) { switch { - case t.Full != nil: - if strings.HasSuffix(*t.Full, "Z") { - return time.ParseInLocation("2006-01-02T15:04:05Z", *t.Full, time.UTC) - } - return time.ParseInLocation("2006-01-02T15:04:05", *t.Full, time.Local) case t.DT != nil: return t.DT.Time() case t.TimeToday != nil: diff --git a/cmds/timestamps/input.go b/cmds/timestamps/input.go index a569805..4460318 100644 --- a/cmds/timestamps/input.go +++ b/cmds/timestamps/input.go @@ -8,17 +8,19 @@ import ( ) var basicLexer = lexer.MustSimple([]lexer.SimpleRule{ - {"Identifier", `[a-zA-Z_][a-zA-Z0-9_]*`}, + {"Identifier", `[a-z][a-zA-Z0-9_]*`}, {"Date", `\d{4}-\d{2}-\d{2}`}, - {"Timestamp", `\d{4}-\d{2}-\d{2}T?\d{2}:\d{2}:\d{2}Z?`}, - {"Time", `\d{2}:\d{2}:\d{2}Z?`}, + {"Time", `\d{2}:\d{2}:\d{2}`}, {"Int", `\d+`}, + {"T", `T`}, + {"Z", `Z`}, {"Whitespace", `[ ]+`}, }) var parser = participle.MustBuild[Atom]( participle.Lexer(basicLexer), participle.Elide("Whitespace"), + participle.UseLookahead(2), ) func Parse(expr string) (TimeResult, error) { diff --git a/cmds/timestamps/input_test.go b/cmds/timestamps/input_test.go new file mode 100644 index 0000000..b23fe49 --- /dev/null +++ b/cmds/timestamps/input_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "testing" + "time" +) + +func TestParse(t *testing.T) { + // Reference time: 2025-11-19T11:07:13Z + // Unix: 1763550433 + refTime := time.Date(2025, 11, 19, 11, 7, 13, 0, time.UTC) + + tests := []struct { + input string + expected func() time.Time + desc string + }{ + { + input: "1763550433", + expected: func() time.Time { + return refTime + }, + desc: "Unix timestamp", + }, + { + input: "2025-11-19T11:07:13Z", + expected: func() time.Time { + return refTime + }, + desc: "ISO 8601 UTC", + }, + { + input: "2025-11-19T11:07:13", + expected: func() time.Time { + return time.Date(2025, 11, 19, 11, 7, 13, 0, time.Local) + }, + desc: "ISO 8601 Local", + }, + { + input: "2025-11-19 11:07:13Z", + expected: func() time.Time { + return refTime + }, + desc: "Date Space Time Z", + }, + { + input: "2025-11-19", + expected: func() time.Time { + return time.Date(2025, 11, 19, 0, 0, 0, 0, time.Local) + }, + desc: "Date only Local", + }, + { + input: "2025-11-19Z", + expected: func() time.Time { + return time.Date(2025, 11, 19, 0, 0, 0, 0, time.UTC) + }, + desc: "Date only UTC", + }, + { + input: "11:07:13", + expected: func() time.Time { + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day(), 11, 7, 13, 0, time.Local) + }, + desc: "Time only Local", + }, + { + input: "11:07:13Z", + expected: func() time.Time { + now := time.Now().In(time.UTC) + return time.Date(now.Year(), now.Month(), now.Day(), 11, 7, 13, 0, time.UTC) + }, + desc: "Time only UTC", + }, + } + + for _, tc := range tests { + t.Run(tc.desc, func(t *testing.T) { + res, err := Parse(tc.input) + if err != nil { + t.Fatalf("Parse(%q) error: %v", tc.input, err) + } + got, err := res.Time() + if err != nil { + t.Fatalf("res.Time() error: %v", err) + } + + want := tc.expected() + + // For "Time only" tests, there might be a slight race condition if the day changes between test execution and expected value generation. + // But practically it's fine. + + if !got.Truncate(time.Second).Equal(want) { + t.Errorf("Parse(%q) = %v, want %v", tc.input, got, want) + } + }) + } + + t.Run("now", func(t *testing.T) { + res, err := Parse("now") + if err != nil { + t.Fatalf("Parse('now') error: %v", err) + } + got, err := res.Time() + if err != nil { + t.Fatalf("res.Time() error: %v", err) + } + if time.Since(got) > time.Second { + t.Errorf("Parse('now') = %v, want close to now", got) + } + }) +} diff --git a/site/index.html b/site/index.html index f0756cc..c71b5cc 100644 --- a/site/index.html +++ b/site/index.html @@ -1,22 +1,21 @@ + - - - - Tools - + + + + Tools + + -
+
-

Tools

-

Collection of online tools

-
-
+

Tools

+

Collection of online tools

+ +
+ \ No newline at end of file