diff --git a/.travis.yml b/.travis.yml index 1c1b032..7345555 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: go sudo: false go: - - 1.7.x - - tip \ No newline at end of file + - 1.13.x + - tip diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e559461 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +bench: + go test -tags bench -benchmem -bench . + @git checkout go.mod + @rm go.sum diff --git a/README.md b/README.md index 81e90b4..f94f38e 100644 --- a/README.md +++ b/README.md @@ -88,40 +88,90 @@ Formats the time according to the pre-compiled pattern, and returns the result s | %z | the time zone offset from UTC | | %% | a '%' | +# EXTENSIONS / CUSTOM SPECIFICATIONS + +This library in general tries to be POSIX compliant, but sometimes you just need that +extra specification or two that is relatively widely used but is not included in the +POSIX specification. + +For example, POSIX does not specify how to print out milliseconds, +but popular implementations allow `%f` or `%L` to achieve this. + +For those instances, `strftime.Strftime` can be configured to use a custom set of +specifications: + +``` +ss := strftime.NewSpecificationSet() +ss.Set('L', ...) // provide implementation for `%L` + +// pass this new specification set to the strftime instance +p, err := strftime.New(`%L`, strftime.WithSpecificationSet(ss)) +p.Format(..., time.Now()) +``` + +The implementation must implement the `Appender` interface, which is + +``` +type Appender interface { + Append([]byte, time.Time) []byte +} +``` + +For commonly used extensions such as the millisecond example, we provide a default +implementation so the user can do one of the following: + +``` +// (1) Pass a speficication byte and the Appender +// This allows you to pass arbitrary Appenders +p, err := strftime.New( + `%L`, + strftime.WithSpecification('L', strftime.Milliseconds), +) + +// (2) Pass an option that knows to use strftime.Milliseconds +p, err := strftime.New( + `%L`, + strftime.WithMilliseconds('L'), +) +``` + +If a common specification is missing, please feel free to submit a PR +(but please be sure to be able to defend how "common" it is) + # PERFORMANCE / OTHER LIBRARIES The following benchmarks were run separately because some libraries were using cgo on specific platforms (notabley, the fastly version) ``` -// On my OS X 10.14.5, 2.3 GHz Intel Core i5, 16GB memory. -// go version go1.12.4rc1 darwin/amd64 +// On my OS X 10.14.6, 2.3 GHz Intel Core i5, 16GB memory. +// go version go1.13.4 darwin/amd64 hummingbird% go test -tags bench -benchmem -bench . -BenchmarkTebeka-4 500000 3894 ns/op 323 B/op 22 allocs/op -BenchmarkJehiah-4 1000000 1503 ns/op 256 B/op 17 allocs/op -BenchmarkFastly-4 3000000 549 ns/op 80 B/op 5 allocs/op -BenchmarkLestrrat-4 2000000 897 ns/op 240 B/op 3 allocs/op -BenchmarkLestrratCachedString-4 3000000 511 ns/op 128 B/op 2 allocs/op -BenchmarkLestrratCachedWriter-4 500000 2020 ns/op 192 B/op 3 allocs/op +BenchmarkTebeka-4 297471 3905 ns/op 257 B/op 20 allocs/op +BenchmarkJehiah-4 818444 1773 ns/op 256 B/op 17 allocs/op +BenchmarkFastly-4 2330794 550 ns/op 80 B/op 5 allocs/op +BenchmarkLestrrat-4 916365 1458 ns/op 80 B/op 2 allocs/op +BenchmarkLestrratCachedString-4 2527428 546 ns/op 128 B/op 2 allocs/op +BenchmarkLestrratCachedWriter-4 537422 2155 ns/op 192 B/op 3 allocs/op PASS -ok github.com/lestrrat-go/strftime 25.433s +ok github.com/lestrrat-go/strftime 25.618s ``` ``` -// (NOTE: This benchmark is outdated, and needs to be ru-run) -// On a host on Google Cloud Platform, machine-type: n1-standard-4 (vCPU x 4, memory: 15GB) -// Linux 3.16.0-4-amd64 #1 SMP Debian 3.16.36-1+deb8u2 (2016-10-19) x86_64 GNU/Linux -// go version go1.8rc1 linux/amd64 +// On a host on Google Cloud Platform, machine-type: f1-micro (vCPU x 1, memory: 0.6GB) +// (Yes, I was being skimpy) +// Linux 4.9.0-11-amd64 #1 SMP Debian 4.9.189-3+deb9u1 (2019-09-20) x86_64 GNU/Linux +// go version go1.13.4 linux/amd64 hummingbird% go test -tags bench -benchmem -bench . -BenchmarkTebeka-4 500000 3904 ns/op 288 B/op 21 allocs/op -BenchmarkJehiah-4 1000000 1665 ns/op 256 B/op 17 allocs/op -BenchmarkFastly-4 1000000 2134 ns/op 192 B/op 13 allocs/op -BenchmarkLestrrat-4 1000000 1327 ns/op 240 B/op 3 allocs/op -BenchmarkLestrratCachedString-4 3000000 498 ns/op 128 B/op 2 allocs/op -BenchmarkLestrratCachedWriter-4 1000000 3390 ns/op 192 B/op 3 allocs/op +BenchmarkTebeka 254997 4726 ns/op 256 B/op 20 allocs/op +BenchmarkJehiah 659289 1882 ns/op 256 B/op 17 allocs/op +BenchmarkFastly 389150 3044 ns/op 224 B/op 13 allocs/op +BenchmarkLestrrat 699069 1780 ns/op 80 B/op 2 allocs/op +BenchmarkLestrratCachedString 2081594 589 ns/op 128 B/op 2 allocs/op +BenchmarkLestrratCachedWriter 825763 1480 ns/op 192 B/op 3 allocs/op PASS -ok github.com/lestrrat-go/strftime 44.854s +ok github.com/lestrrat-go/strftime 11.355s ``` This library is much faster than other libraries *IF* you can reuse the format pattern. diff --git a/appenders.go b/appenders.go new file mode 100644 index 0000000..bad661b --- /dev/null +++ b/appenders.go @@ -0,0 +1,261 @@ +package strftime + +import ( + "strconv" + "strings" + "time" +) + +// These are all of the standard, POSIX compliant specifications. +// Extensions should be in extensions.go +var ( + fullWeekDayName = StdlibFormat("Monday") + abbrvWeekDayName = StdlibFormat("Mon") + fullMonthName = StdlibFormat("January") + abbrvMonthName = StdlibFormat("Jan") + centuryDecimal = AppendFunc(appendCentury) + timeAndDate = StdlibFormat("Mon Jan _2 15:04:05 2006") + mdy = StdlibFormat("01/02/06") + dayOfMonthZeroPad = StdlibFormat("02") + dayOfMonthSpacePad = StdlibFormat("_2") + ymd = StdlibFormat("2006-01-02") + twentyFourHourClockZeroPad = StdlibFormat("15") + twelveHourClockZeroPad = StdlibFormat("3") + dayOfYear = AppendFunc(appendDayOfYear) + twentyFourHourClockSpacePad = hourwblank(false) + twelveHourClockSpacePad = hourwblank(true) + minutesZeroPad = StdlibFormat("04") + monthNumberZeroPad = StdlibFormat("01") + newline = Verbatim("\n") + ampm = StdlibFormat("PM") + hm = StdlibFormat("15:04") + imsp = StdlibFormat("3:04:05 PM") + secondsNumberZeroPad = StdlibFormat("05") + hms = StdlibFormat("15:04:05") + tab = Verbatim("\t") + weekNumberSundayOrigin = weeknumberOffset(0) // week number of the year, Sunday first + weekdayMondayOrigin = weekday(1) + // monday as the first day, and 01 as the first value + weekNumberMondayOriginOneOrigin = AppendFunc(appendWeekNumber) + eby = StdlibFormat("_2-Jan-2006") + // monday as the first day, and 00 as the first value + weekNumberMondayOrigin = weeknumberOffset(1) // week number of the year, Monday first + weekdaySundayOrigin = weekday(0) + natReprTime = StdlibFormat("15:04:05") // national representation of the time XXX is this correct? + natReprDate = StdlibFormat("01/02/06") // national representation of the date XXX is this correct? + year = StdlibFormat("2006") // year with century + yearNoCentury = StdlibFormat("06") // year w/o century + timezone = StdlibFormat("MST") // time zone name + timezoneOffset = StdlibFormat("-0700") // time zone ofset from UTC + percent = Verbatim("%") +) + +// Appender is the interface that must be fulfilled by components that +// implement the translation of specifications to actual time value. +// +// The Append method takes the accumulated byte buffer, and the time to +// use to generate the textual representation. The resulting byte +// sequence must be returned by this method, normally by using the +// append() builtin function. +type Appender interface { + Append([]byte, time.Time) []byte +} + +// AppendFunc is an utility type to allow users to create a +// function-only version of an Appender +type AppendFunc func([]byte, time.Time) []byte + +func (af AppendFunc) Append(b []byte, t time.Time) []byte { + return af(b, t) +} + +type appenderList []Appender + +// does the time.Format thing +type stdlibFormat struct { + s string +} + +// StdlibFormat returns an Appender that simply goes through `time.Format()` +// For example, if you know you want to display the abbreviated month name for %b, +// you can create a StdlibFormat with the pattern `Jan` and register that +// for specification `b`: +// +// a := StdlibFormat(`Jan`) +// ss := NewSpecificationSet() +// ss.Set('b', a) // does %b -> abbreviated month name +func StdlibFormat(s string) Appender { + return &stdlibFormat{s: s} +} + +func (v stdlibFormat) Append(b []byte, t time.Time) []byte { + return t.AppendFormat(b, v.s) +} + +func (v stdlibFormat) str() string { + return v.s +} + +func (v stdlibFormat) canCombine() bool { + return true +} + +func (v stdlibFormat) combine(w combiner) Appender { + return StdlibFormat(v.s + w.str()) +} + +type verbatimw struct { + s string +} + +// Verbatim returns an Appender suitable for generating static text. +// For static text, this method is slightly favorable than creating +// your own appender, as adjacent verbatim blocks will be combined +// at compile time to produce more efficient Appenders +func Verbatim(s string) Appender { + return &verbatimw{s: s} +} + +func (v verbatimw) Append(b []byte, _ time.Time) []byte { + return append(b, v.s...) +} + +func (v verbatimw) canCombine() bool { + return canCombine(v.s) +} + +func (v verbatimw) combine(w combiner) Appender { + if _, ok := w.(*stdlibFormat); ok { + return StdlibFormat(v.s + w.str()) + } + return Verbatim(v.s + w.str()) +} + +func (v verbatimw) str() string { + return v.s +} + +// These words below, as well as any decimal character +var combineExclusion = []string{ + "Mon", + "Monday", + "Jan", + "January", + "MST", + "PM", + "pm", +} + +func canCombine(s string) bool { + if strings.ContainsAny(s, "0123456789") { + return false + } + for _, word := range combineExclusion { + if strings.Contains(s, word) { + return false + } + } + return true +} + +type combiner interface { + canCombine() bool + combine(combiner) Appender + str() string +} + +// this is container for the compiler to keep track of appenders, +// and combine them as we parse and compile the pattern +type combiningAppend struct { + list appenderList + prev Appender + prevCanCombine bool +} + +func (ca *combiningAppend) Append(w Appender) { + if ca.prevCanCombine { + if wc, ok := w.(combiner); ok && wc.canCombine() { + ca.prev = ca.prev.(combiner).combine(wc) + ca.list[len(ca.list)-1] = ca.prev + return + } + } + + ca.list = append(ca.list, w) + ca.prev = w + ca.prevCanCombine = false + if comb, ok := w.(combiner); ok { + if comb.canCombine() { + ca.prevCanCombine = true + } + } +} + +func appendCentury(b []byte, t time.Time) []byte { + n := t.Year() / 100 + if n < 10 { + b = append(b, '0') + } + return append(b, strconv.Itoa(n)...) +} + +type weekday int + +func (v weekday) Append(b []byte, t time.Time) []byte { + n := int(t.Weekday()) + if n < int(v) { + n += 7 + } + return append(b, byte(n+48)) +} + +type weeknumberOffset int + +func (v weeknumberOffset) Append(b []byte, t time.Time) []byte { + yd := t.YearDay() + offset := int(t.Weekday()) - int(v) + if offset < 0 { + offset += 7 + } + + if yd < offset { + return append(b, '0', '0') + } + + n := ((yd - offset) / 7) + 1 + if n < 10 { + b = append(b, '0') + } + return append(b, strconv.Itoa(n)...) +} + +func appendWeekNumber(b []byte, t time.Time) []byte { + _, n := t.ISOWeek() + if n < 10 { + b = append(b, '0') + } + return append(b, strconv.Itoa(n)...) +} + +func appendDayOfYear(b []byte, t time.Time) []byte { + n := t.YearDay() + if n < 10 { + b = append(b, '0', '0') + } else if n < 100 { + b = append(b, '0') + } + return append(b, strconv.Itoa(n)...) +} + +type hourwblank bool + +func (v hourwblank) Append(b []byte, t time.Time) []byte { + h := t.Hour() + if bool(v) && h > 12 { + h = h - 12 + } + if h < 10 { + b = append(b, ' ') + } + return append(b, strconv.Itoa(h)...) +} diff --git a/extension.go b/extension.go new file mode 100644 index 0000000..b0218b9 --- /dev/null +++ b/extension.go @@ -0,0 +1,31 @@ +package strftime + +import ( + "strconv" + "time" +) + +// NOTE: declare private variable and iniitalize once in init(), +// and leave the Milliseconds() function as returning static content. +// This way, `go doc -all` does not show the contents of the +// milliseconds function +var milliseconds Appender + +func init() { + milliseconds = AppendFunc(func(b []byte, t time.Time) []byte { + millisecond := int(t.Nanosecond()) / int(time.Millisecond) + if millisecond < 100 { + b = append(b, '0') + } + if millisecond < 10 { + b = append(b, '0') + } + return append(b, strconv.Itoa(millisecond)...) + }) +} + +// Milliseconds returns the Appender suitable for creating a zero-padded, +// 3-digit millisecond textual representation. +func Milliseconds() Appender { + return milliseconds +} diff --git a/internal_test.go b/internal_test.go index eaee1d5..b9b95eb 100644 --- a/internal_test.go +++ b/internal_test.go @@ -8,7 +8,11 @@ import ( func TestCombine(t *testing.T) { { - s, _ := New(`%A foo`) + s, err := New(`%A foo`) + if !assert.NoError(t, err, `New should succeed`) { + return + } + if !assert.Equal(t, 1, len(s.compiled), "there are 1 element") { return } diff --git a/options.go b/options.go new file mode 100644 index 0000000..8697a9b --- /dev/null +++ b/options.go @@ -0,0 +1,51 @@ +package strftime + +type Option interface { + Name() string + Value() interface{} +} + +type option struct { + name string + value interface{} +} + +func (o *option) Name() string { return o.name } +func (o *option) Value() interface{} { return o.value } + +const optSpecificationSet = `opt-specification-set` + +// WithSpecification allows you to specify a custom specification set +func WithSpecificationSet(ds SpecificationSet) Option { + return &option{ + name: optSpecificationSet, + value: ds, + } +} + +type optSpecificationPair struct { + name byte + appender Appender +} + +const optSpecification = `opt-specification` + +// WithSpecification allows you to create a new specification set on the fly, +// to be used only for that invocation. +func WithSpecification(b byte, a Appender) Option { + return &option{ + name: optSpecification, + value: &optSpecificationPair{ + name: b, + appender: a, + }, + } +} + +// WithMilliseconds is similar to WithSpecification, and specifies that +// the Strftime object should interpret the pattern `%b` (where b +// is the byte that you specify as the argument) +// as the zero-padded, 3 letter milliseconds of the time. +func WithMilliseconds(b byte) Option { + return WithSpecification(b, Milliseconds()) +} diff --git a/specifications.go b/specifications.go new file mode 100644 index 0000000..e31ef54 --- /dev/null +++ b/specifications.go @@ -0,0 +1,143 @@ +package strftime + +import ( + "sync" + + "github.com/pkg/errors" +) + +// because there is no such thing was a sync.RWLocker +type rwLocker interface { + RLock() + RUnlock() + sync.Locker +} + +// SpecificationSet is a container for patterns that Strftime uses. +// If you want a custom strftime, you can copy the default +// SpecificationSet and tweak it +type SpecificationSet interface { + Lookup(byte) (Appender, error) + Delete(byte) error + Set(byte, Appender) error +} + +type specificationSet struct { + mutable bool + lock rwLocker + store map[byte]Appender +} + +// The default specification set does not need any locking as it is never +// accessed from the outside, and is never mutated. +var defaultSpecificationSet SpecificationSet + +func init() { + defaultSpecificationSet = newImmutableSpecificationSet() +} + +func newImmutableSpecificationSet() SpecificationSet { + // Create a mutable one so that populateDefaultSpecifications work through + // its magic, then copy the associated map + // (NOTE: this is done this way because there used to be + // two struct types for specification set, united under an interface. + // it can now be removed, but we would need to change the entire + // populateDefaultSpecifications method, and I'm currently too lazy + // PRs welcome) + tmp := NewSpecificationSet() + + ss := &specificationSet{ + mutable: false, + lock: nil, // never used, so intentionally not initialized + store: tmp.(*specificationSet).store, + } + + return ss +} + +// NewSpecificationSet creates a specification set with the default specifications. +func NewSpecificationSet() SpecificationSet { + ds := &specificationSet{ + mutable: true, + lock: &sync.RWMutex{}, + store: make(map[byte]Appender), + } + populateDefaultSpecifications(ds) + + return ds +} + +func populateDefaultSpecifications(ds SpecificationSet) { + ds.Set('A', fullWeekDayName) + ds.Set('a', abbrvWeekDayName) + ds.Set('B', fullMonthName) + ds.Set('b', abbrvMonthName) + ds.Set('h', abbrvMonthName) + ds.Set('C', centuryDecimal) + ds.Set('c', timeAndDate) + ds.Set('D', mdy) + ds.Set('d', dayOfMonthZeroPad) + ds.Set('e', dayOfMonthSpacePad) + ds.Set('F', ymd) + ds.Set('H', twentyFourHourClockZeroPad) + ds.Set('I', twelveHourClockZeroPad) + ds.Set('j', dayOfYear) + ds.Set('k', twentyFourHourClockSpacePad) + ds.Set('l', twelveHourClockSpacePad) + ds.Set('M', minutesZeroPad) + ds.Set('m', monthNumberZeroPad) + ds.Set('n', newline) + ds.Set('p', ampm) + ds.Set('R', hm) + ds.Set('r', imsp) + ds.Set('S', secondsNumberZeroPad) + ds.Set('T', hms) + ds.Set('t', tab) + ds.Set('U', weekNumberSundayOrigin) + ds.Set('u', weekdayMondayOrigin) + ds.Set('V', weekNumberMondayOriginOneOrigin) + ds.Set('v', eby) + ds.Set('W', weekNumberMondayOrigin) + ds.Set('w', weekdaySundayOrigin) + ds.Set('X', natReprTime) + ds.Set('x', natReprDate) + ds.Set('Y', year) + ds.Set('y', yearNoCentury) + ds.Set('Z', timezone) + ds.Set('z', timezoneOffset) + ds.Set('%', percent) +} + +func (ds *specificationSet) Lookup(b byte) (Appender, error) { + if ds.mutable { + ds.lock.RLock() + defer ds.lock.RLock() + } + v, ok := ds.store[b] + if !ok { + return nil, errors.Errorf(`lookup failed: '%%%c' was not found in specification set`, b) + } + return v, nil +} + +func (ds *specificationSet) Delete(b byte) error { + if !ds.mutable { + return errors.New(`delete failed: this specification set is marked immutable`) + } + + ds.lock.Lock() + defer ds.lock.Unlock() + delete(ds.store, b) + return nil +} + +func (ds *specificationSet) Set(b byte, a Appender) error { + if !ds.mutable { + return errors.New(`set failed: this specification set is marked immutable`) + } + + ds.lock.Lock() + defer ds.lock.Unlock() + ds.store[b] = a + return nil +} diff --git a/strftime.go b/strftime.go index 6e88d09..379767f 100644 --- a/strftime.go +++ b/strftime.go @@ -3,164 +3,44 @@ package strftime import ( "io" "strings" + "sync" "time" "github.com/pkg/errors" ) -var ( - fullWeekDayName = timefmt("Monday") - abbrvWeekDayName = timefmt("Mon") - fullMonthName = timefmt("January") - abbrvMonthName = timefmt("Jan") - centuryDecimal = appenderFn(appendCentury) - timeAndDate = timefmt("Mon Jan _2 15:04:05 2006") - mdy = timefmt("01/02/06") - dayOfMonthZeroPad = timefmt("02") - dayOfMonthSpacePad = timefmt("_2") - ymd = timefmt("2006-01-02") - twentyFourHourClockZeroPad = timefmt("15") - twelveHourClockZeroPad = timefmt("3") - dayOfYear = appenderFn(appendDayOfYear) - twentyFourHourClockSpacePad = hourwblank(false) - twelveHourClockSpacePad = hourwblank(true) - minutesZeroPad = timefmt("04") - monthNumberZeroPad = timefmt("01") - newline = verbatim("\n") - ampm = timefmt("PM") - hm = timefmt("15:04") - imsp = timefmt("3:04:05 PM") - secondsNumberZeroPad = timefmt("05") - hms = timefmt("15:04:05") - tab = verbatim("\t") - weekNumberSundayOrigin = weeknumberOffset(0) // week number of the year, Sunday first - weekdayMondayOrigin = weekday(1) - // monday as the first day, and 01 as the first value - weekNumberMondayOriginOneOrigin = appenderFn(appendWeekNumber) - eby = timefmt("_2-Jan-2006") - // monday as the first day, and 00 as the first value - weekNumberMondayOrigin = weeknumberOffset(1) // week number of the year, Monday first - weekdaySundayOrigin = weekday(0) - natReprTime = timefmt("15:04:05") // national representation of the time XXX is this correct? - natReprDate = timefmt("01/02/06") // national representation of the date XXX is this correct? - year = timefmt("2006") // year with century - yearNoCentury = timefmt("06") // year w/o century - timezone = timefmt("MST") // time zone name - timezoneOffset = timefmt("-0700") // time zone ofset from UTC - percent = verbatim("%") -) +type compileHandler interface { + handle(Appender) +} -func lookupDirective(key byte) (appender, bool) { - switch key { - case 'A': - return fullWeekDayName, true - case 'a': - return abbrvWeekDayName, true - case 'B': - return fullMonthName, true - case 'b', 'h': - return abbrvMonthName, true - case 'C': - return centuryDecimal, true - case 'c': - return timeAndDate, true - case 'D': - return mdy, true - case 'd': - return dayOfMonthZeroPad, true - case 'e': - return dayOfMonthSpacePad, true - case 'F': - return ymd, true - case 'H': - return twentyFourHourClockZeroPad, true - case 'I': - return twelveHourClockZeroPad, true - case 'j': - return dayOfYear, true - case 'k': - return twentyFourHourClockSpacePad, true - case 'l': - return twelveHourClockSpacePad, true - case 'M': - return minutesZeroPad, true - case 'm': - return monthNumberZeroPad, true - case 'n': - return newline, true - case 'p': - return ampm, true - case 'R': - return hm, true - case 'r': - return imsp, true - case 'S': - return secondsNumberZeroPad, true - case 'T': - return hms, true - case 't': - return tab, true - case 'U': - return weekNumberSundayOrigin, true - case 'u': - return weekdayMondayOrigin, true - case 'V': - return weekNumberMondayOriginOneOrigin, true - case 'v': - return eby, true - case 'W': - return weekNumberMondayOrigin, true - case 'w': - return weekdaySundayOrigin, true - case 'X': - return natReprTime, true - case 'x': - return natReprDate, true - case 'Y': - return year, true - case 'y': - return yearNoCentury, true - case 'Z': - return timezone, true - case 'z': - return timezoneOffset, true - case '%': - return percent, true - } - return nil, false +// compile, and create an appender list +type appenderListBuilder struct { + list *combiningAppend } -type combiningAppend struct { - list appenderList - prev appender - prevCanCombine bool +func (alb *appenderListBuilder) handle(a Appender) { + alb.list.Append(a) } -func (ca *combiningAppend) Append(w appender) { - if ca.prevCanCombine { - if wc, ok := w.(combiner); ok && wc.canCombine() { - ca.prev = ca.prev.(combiner).combine(wc) - ca.list[len(ca.list)-1] = ca.prev - return - } - } +// compile, and execute the appenders on the fly +type appenderExecutor struct { + t time.Time + dst []byte +} - ca.list = append(ca.list, w) - ca.prev = w - ca.prevCanCombine = false - if comb, ok := w.(combiner); ok { - if comb.canCombine() { - ca.prevCanCombine = true - } - } +func (ae *appenderExecutor) handle(a Appender) { + ae.dst = a.Append(ae.dst, ae.t) } -func compile(wl *appenderList, p string) error { - var ca combiningAppend +func compile(handler compileHandler, p string, ds SpecificationSet) error { + // This is a really tight loop, so we don't even calls to + // Verbatim() to cuase extra stuff + var verbatim verbatimw for l := len(p); l > 0; l = len(p) { i := strings.IndexByte(p, '%') if i < 0 { - ca.Append(verbatim(p)) + verbatim.s = p + handler.handle(&verbatim) // this is silly, but I don't trust break keywords when there's a // possibility of this piece of code being rearranged p = p[l:] @@ -174,21 +54,63 @@ func compile(wl *appenderList, p string) error { // we already know that i < l - 1 // everything up to the i is verbatim if i > 0 { - ca.Append(verbatim(p[:i])) + verbatim.s = p[:i] + handler.handle(&verbatim) p = p[i:] } - directive, ok := lookupDirective(p[1]) - if !ok { - return errors.Errorf(`unknown time format specification '%c'`, p[1]) + specification, err := ds.Lookup(p[1]) + if err != nil { + return errors.Wrap(err, `pattern compilation failed`) } - ca.Append(directive) + + handler.handle(specification) p = p[2:] } + return nil +} - *wl = ca.list +func getSpecificationSetFor(options ...Option) SpecificationSet { + var ds SpecificationSet = defaultSpecificationSet + var extraSpecifications []*optSpecificationPair + for _, option := range options { + switch option.Name() { + case optSpecificationSet: + ds = option.Value().(SpecificationSet) + case optSpecification: + extraSpecifications = append(extraSpecifications, option.Value().(*optSpecificationPair)) + } + } - return nil + if len(extraSpecifications) > 0 { + // If ds is immutable, we're going to need to create a new + // one. oh what a waste! + if raw, ok := ds.(*specificationSet); ok && !raw.mutable { + ds = NewSpecificationSet() + } + for _, v := range extraSpecifications { + ds.Set(v.name, v.appender) + } + } + return ds +} + +var fmtAppendExecutorPool = sync.Pool{ + New: func() interface{} { + var h appenderExecutor + h.dst = make([]byte, 0, 32) + return &h + }, +} + +func getFmtAppendExecutor() *appenderExecutor { + return fmtAppendExecutorPool.Get().(*appenderExecutor) +} + +func releasdeFmtAppendExecutor(v *appenderExecutor) { + // TODO: should we discard the buffer if it's too long? + v.dst = v.dst[:0] + fmtAppendExecutorPool.Put(v) } // Format takes the format `s` and the time `t` to produce the @@ -198,41 +120,18 @@ func compile(wl *appenderList, p string) error { // If you know beforehand that you will be reusing the pattern // within your application, consider creating a `Strftime` object // and reusing it. -func Format(p string, t time.Time) (string, error) { - var dst []byte - // TODO: optimize for 64 byte strings - dst = make([]byte, 0, len(p)+10) - // Compile, but execute as we go - for l := len(p); l > 0; l = len(p) { - i := strings.IndexByte(p, '%') - if i < 0 { - dst = append(dst, p...) - // this is silly, but I don't trust break keywords when there's a - // possibility of this piece of code being rearranged - p = p[l:] - continue - } - if i == l-1 { - return "", errors.New(`stray % at the end of pattern`) - } - - // we found a '%'. we need the next byte to decide what to do next - // we already know that i < l - 1 - // everything up to the i is verbatim - if i > 0 { - dst = append(dst, p[:i]...) - p = p[i:] - } - - directive, ok := lookupDirective(p[1]) - if !ok { - return "", errors.Errorf(`unknown time format specification '%c'`, p[1]) - } - dst = directive.Append(dst, t) - p = p[2:] +func Format(p string, t time.Time, options ...Option) (string, error) { + // TODO: this may be premature optimization + ds := getSpecificationSetFor(options...) + h := getFmtAppendExecutor() + defer releasdeFmtAppendExecutor(h) + + h.t = t + if err := compile(h, p, ds); err != nil { + return "", errors.Wrap(err, `failed to compile format`) } - return string(dst), nil + return string(h.dst), nil } // Strftime is the object that represents a compiled strftime pattern @@ -243,14 +142,20 @@ type Strftime struct { // New creates a new Strftime object. If the compilation fails, then // an error is returned in the second argument. -func New(f string) (*Strftime, error) { - var wl appenderList - if err := compile(&wl, f); err != nil { +func New(p string, options ...Option) (*Strftime, error) { + // TODO: this may be premature optimization + ds := getSpecificationSetFor(options...) + + var h appenderListBuilder + h.list = &combiningAppend{} + + if err := compile(&h, p, ds); err != nil { return nil, errors.Wrap(err, `failed to compile format`) } + return &Strftime{ - pattern: f, - compiled: wl, + pattern: p, + compiled: h.list.list, }, nil } diff --git a/strftime_test.go b/strftime_test.go index 41f25be..2f9ed52 100644 --- a/strftime_test.go +++ b/strftime_test.go @@ -1,6 +1,7 @@ package strftime_test import ( + "fmt" "os" "testing" "time" @@ -10,7 +11,7 @@ import ( "github.com/stretchr/testify/assert" ) -var ref = time.Unix(1136239445, 0).UTC() +var ref = time.Unix(1136239445, 123456789).UTC() func TestExclusion(t *testing.T) { s, err := strftime.New("%p PM") @@ -146,3 +147,69 @@ func TestGHIssue5(t *testing.T) { return } } + +func TestGHPR7(t *testing.T) { + const expected = `123` + + p, _ := strftime.New(`%L`, strftime.WithMilliseconds('L')) + if !assert.Equal(t, expected, p.FormatString(ref), `patterns should match for custom specification`) { + return + } +} + +func Example_CustomSpecifications() { + { + // I want %L as milliseconds! + p, err := strftime.New(`%L`, strftime.WithMilliseconds('L')) + if err != nil { + fmt.Println(err) + return + } + p.Format(os.Stdout, ref) + os.Stdout.Write([]byte{'\n'}) + } + + { + // I want %f as milliseconds! + p, err := strftime.New(`%f`, strftime.WithMilliseconds('f')) + if err != nil { + fmt.Println(err) + return + } + p.Format(os.Stdout, ref) + os.Stdout.Write([]byte{'\n'}) + } + + { + // I want %X to print out my name! + a := strftime.Verbatim(`Daisuke Maki`) + p, err := strftime.New(`%X`, strftime.WithSpecification('X', a)) + if err != nil { + fmt.Println(err) + return + } + p.Format(os.Stdout, ref) + os.Stdout.Write([]byte{'\n'}) + } + + { + // I want a completely new specification set, and I want %X to print out my name! + a := strftime.Verbatim(`Daisuke Maki`) + + ds := strftime.NewSpecificationSet() + ds.Set('X', a) + p, err := strftime.New(`%X`, strftime.WithSpecificationSet(ds)) + if err != nil { + fmt.Println(err) + return + } + p.Format(os.Stdout, ref) + os.Stdout.Write([]byte{'\n'}) + } + + // OUTPUT: + // 123 + // 123 + // Daisuke Maki + // Daisuke Maki +} diff --git a/writer.go b/writer.go deleted file mode 100644 index 997675c..0000000 --- a/writer.go +++ /dev/null @@ -1,169 +0,0 @@ -package strftime - -import ( - "strconv" - "strings" - "time" -) - -type appender interface { - Append([]byte, time.Time) []byte -} - -type appenderFn func([]byte, time.Time) []byte - -func (af appenderFn) Append(b []byte, t time.Time) []byte { - return af(b, t) -} - -type appenderList []appender - -// does the time.Format thing -type timefmtw struct { - s string -} - -func timefmt(s string) *timefmtw { - return &timefmtw{s: s} -} - -func (v timefmtw) Append(b []byte, t time.Time) []byte { - return t.AppendFormat(b, v.s) -} - -func (v timefmtw) str() string { - return v.s -} - -func (v timefmtw) canCombine() bool { - return true -} - -func (v timefmtw) combine(w combiner) appender { - return timefmt(v.s + w.str()) -} - -type verbatimw struct { - s string -} - -func verbatim(s string) *verbatimw { - return &verbatimw{s: s} -} - -func (v verbatimw) Append(b []byte, _ time.Time) []byte { - return append(b, v.s...) -} - -func (v verbatimw) canCombine() bool { - return canCombine(v.s) -} - -func (v verbatimw) combine(w combiner) appender { - if _, ok := w.(*timefmtw); ok { - return timefmt(v.s + w.str()) - } - return verbatim(v.s + w.str()) -} - -func (v verbatimw) str() string { - return v.s -} - -// These words below, as well as any decimal character -var combineExclusion = []string{ - "Mon", - "Monday", - "Jan", - "January", - "MST", - "PM", - "pm", -} - -func canCombine(s string) bool { - if strings.ContainsAny(s, "0123456789") { - return false - } - for _, word := range combineExclusion { - if strings.Contains(s, word) { - return false - } - } - return true -} - -type combiner interface { - canCombine() bool - combine(combiner) appender - str() string -} - -func appendCentury(b []byte, t time.Time) []byte { - n := t.Year() / 100 - if n < 10 { - b = append(b, '0') - } - return append(b, strconv.Itoa(n)...) -} - -type weekday int - -func (v weekday) Append(b []byte, t time.Time) []byte { - n := int(t.Weekday()) - if n < int(v) { - n += 7 - } - return append(b, byte(n+48)) -} - -type weeknumberOffset int - -func (v weeknumberOffset) Append(b []byte, t time.Time) []byte { - yd := t.YearDay() - offset := int(t.Weekday()) - int(v) - if offset < 0 { - offset += 7 - } - - if yd < offset { - return append(b, '0', '0') - } - - n := ((yd - offset) / 7) + 1 - if n < 10 { - b = append(b, '0') - } - return append(b, strconv.Itoa(n)...) -} - -func appendWeekNumber(b []byte, t time.Time) []byte { - _, n := t.ISOWeek() - if n < 10 { - b = append(b, '0') - } - return append(b, strconv.Itoa(n)...) -} - -func appendDayOfYear(b []byte, t time.Time) []byte { - n := t.YearDay() - if n < 10 { - b = append(b, '0', '0') - } else if n < 100 { - b = append(b, '0') - } - return append(b, strconv.Itoa(n)...) -} - -type hourwblank bool - -func (v hourwblank) Append(b []byte, t time.Time) []byte { - h := t.Hour() - if bool(v) && h > 12 { - h = h - 12 - } - if h < 10 { - b = append(b, ' ') - } - return append(b, strconv.Itoa(h)...) -}