Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
.idea
.env
.mcp.json
go.work.sum
11 changes: 0 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,6 @@ Golang support for string formats defined by JSON Schema and OpenAPI.

## Announcements

* **2025-12-19** : new community chat on discord
* a new discord community channel is available to be notified of changes and support users
* our venerable Slack channel remains open, and will be eventually discontinued on **2026-03-31**

You may join the discord community by clicking the invite link on the discord badge (also above). [![Discord Channel][discord-badge]][discord-url]

Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url]

* **2026-03-07** : v0.26.0 **dropped dependency to the mongodb driver**
* mongodb users can still use this package without any change
* however, we have frozen the back-compatible support for mongodb driver at v2.5.0
Expand Down Expand Up @@ -214,9 +206,6 @@ Maintainers can cut a new release by either:
[doc-url]: https://goswagger.io/go-openapi
[godoc-badge]: https://pkg.go.dev/badge/github.com/go-openapi/strfmt
[godoc-url]: http://pkg.go.dev/github.com/go-openapi/strfmt
[slack-logo]: https://a.slack-edge.com/e6a93c1/img/icons/favicon-32.png
[slack-badge]: https://img.shields.io/badge/slack-blue?link=https%3A%2F%2Fgoswagger.slack.com%2Farchives%2FC04R30YM
[slack-url]: https://goswagger.slack.com/archives/C04R30YMU
[discord-badge]: https://img.shields.io/discord/1446918742398341256?logo=discord&label=discord&color=blue
[discord-url]: https://discord.gg/FfnFYaC3k5

Expand Down
322 changes: 254 additions & 68 deletions duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,9 @@
"database/sql/driver"
"encoding/json"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"time"
"unicode"
)

func init() { //nolint:gochecknoinits // registers duration format in the default registry
Expand All @@ -22,34 +20,62 @@
const (
hoursInDay = 24
daysInWeek = 7
nanos = uint64(time.Nanosecond)
micros = uint64(time.Microsecond)
millis = uint64(time.Millisecond)
seconds = uint64(time.Second)
minutes = uint64(time.Minute)
hours = uint64(time.Hour)
days = uint64(hoursInDay * time.Hour)
weeks = uint64(hoursInDay * daysInWeek * time.Hour)
maxUint64 = uint64(1 << 63)
)

// timeMultiplier holds all supported aliases for duration units, including their plural form.
//
//nolint:gochecknoglobals // package-level lookup tables for duration parsing
var (
timeUnits = [][]string{
{"ns", "nano"},
{"us", "µs", "micro"},
{"ms", "milli"},
{"s", "sec"},
{"m", "min"},
{"h", "hr", "hour"},
{"d", "day"},
{"w", "wk", "week"},
}

timeMultiplier = map[string]time.Duration{
"ns": time.Nanosecond,
"us": time.Microsecond,
"ms": time.Millisecond,
"s": time.Second,
"m": time.Minute,
"h": time.Hour,
"d": hoursInDay * time.Hour,
"w": hoursInDay * daysInWeek * time.Hour,
}

durationMatcher = regexp.MustCompile(`^(((?:-\s?)?\d+)(\.\d+)?\s*([A-Za-zµ]+))`)
)
var timeMultiplier = map[string]uint64{
"ns": nanos,
"nano": nanos,
"nanosecond": nanos,
"nanoseconds": nanos,
"nanos": nanos,
"us": micros,
"µs": micros, // U+00B5 = micro symbol
"μs": micros, // U+03BC = Greek letter mu
"micro": micros,
"micros": micros,
"microsecond": micros,
"microseconds": micros,
"ms": millis,
"milli": millis,
"millis": millis,
"millisecond": millis,
"milliseconds": millis,
"s": seconds,
"sec": seconds,
"secs": seconds,
"second": seconds,
"seconds": seconds,
"m": minutes,
"min": minutes,
"mins": minutes,
"minute": minutes,
"minutes": minutes,
"h": hours,
"hr": hours,
"hrs": hours,
"hour": hours,
"hours": hours,
"d": days,
"day": days,
"days": days,
"w": weeks,
"wk": weeks,
"wks": weeks,
"week": weeks,
"weeks": weeks,
}

// IsDuration returns true if the provided string is a valid duration.
func IsDuration(str string) bool {
Expand Down Expand Up @@ -80,65 +106,158 @@
return nil
}

// ParseDuration parses a duration from a string, compatible with scala duration syntax.
func ParseDuration(cand string) (time.Duration, error) {
if dur, err := time.ParseDuration(cand); err == nil {
return dur, nil
// ParseDuration parses a duration from a string
//
// It is similar to [time.ParseDuration] but support additional units like days and weeks,
// additional abreviations for units and is more tolerant on the presence of blank spaces.
//
// A duration may be negative or fractional.
//
// # Differences with [time.ParseDuration]
//
// - more supported units and aliases (see below)
// - sign followed by blank space is tolerated
// - tolerates blanks between duration and unit (e.g. "300 ms")
//
// # Supported units
//
// Units may be specified using aliases or a plural form.
//
// - "ns", "nano", "nanosecond", "nanoseconds", "nanos"
// - "us", "µs" (U+00B5 = micro symbol), "μs" (U+03BC = Greek letter mu), "micro", "micros", "microsecond", "microseconds"
// - "ms", "milli", "millis", "millisecond", "milliseconds"
// - "s", "sec", "secs", "second", "seconds"
// - "m", "min", "mins", "minute", "minutes"
// - "h", "hr", "hrs", "hour", "hours"
// - "d", "day", "days"
// - "w", "wk", "wks", "week", "weeks"
//
// NOTE: inspired by scala duration syntax.
//
// # Examples
//
// "300ms", "-1.5h", "2h45m",
// ".5 week",
// "2 minutes 45 seconds".
//
//nolint:gocognit,gocyclo,cyclop // complexity is only slightly above the usual level, may be tolerated as it mimicks the stdlib.
func ParseDuration(s string) (time.Duration, error) {
// NOTE: this code is largely inspired by the standard library.
orig := s
var d uint64
neg := false

// Consume [-+]?
if s != "" {
c := s[0]
if c == '-' || c == '+' {
neg = c == '-'
s = s[1:]
}
}

var dur time.Duration
ok := false
const expectGroups = 4
for _, match := range durationMatcher.FindAllStringSubmatch(cand, -1) {
if len(match) < expectGroups {
continue
// Consume space
s = strings.TrimLeftFunc(s, unicode.IsSpace)

// Special case: if all that is left is "0", this is zero.
if s == "0" {
return 0, nil
}

if s == "" {
return 0, parseDurationError(orig, "empty duration")
}

for s != "" {
var (
v, f uint64 // integers before, after decimal point
scale float64 = 1 // value = v + f/scale
)
s = strings.TrimLeftFunc(s, unicode.IsSpace)

// The next character must be 0-9.]
if s[0] != '.' && ('0' > s[0] || s[0] > '9') {
return 0, parseDurationError(orig, fmt.Sprintf("expected a numerical value, but got %q", s[0]))
}

// Consume integer part [0-9]*
pl := len(s)
var ok bool
v, s, ok = leadingInt(s)
if !ok {
return 0, parseDurationError(orig, "expected a leading integer part")
}
pre := pl != len(s) // whether we consumed anything before a period

// remove possible leading - and spaces
value, negative := strings.CutPrefix(match[2], "-")
// Consume fractional part (\.[0-9]*)?
post := false
if s != "" && s[0] == '.' {
s = s[1:]
pl := len(s)
f, scale, s = leadingFraction(s)
post = pl != len(s)
}

// if the duration contains a decimal separator determine a divising factor
const neutral = 1.0
divisor := neutral
decimal, hasDecimal := strings.CutPrefix(match[3], ".")
if hasDecimal {
divisor = math.Pow10(len(decimal))
value += decimal // consider the value as an integer: will change units later on
if !pre && !post {
// no digits (e.g. ".s" or "-.s")
return 0, parseDurationError(orig, "expected digits")
}

// if the string is a valid duration, parse it
factor, err := strconv.Atoi(strings.TrimSpace(value)) // converts string to int
if err != nil {
return 0, err
// Consume space.
s = strings.TrimLeftFunc(s, unicode.IsSpace)

// Consume unit.
i := 0
for ; i < len(s); i++ {
c := s[i]
if c == '.' || '0' <= c && c <= '9' || unicode.IsSpace(rune(c)) {
break
}
}

if negative {
factor = -factor
if i == 0 {
return 0, parseDurationError(orig, "missing unit in duration")
}

unit := strings.ToLower(strings.TrimSpace(match[4]))
u := s[:i]
s = s[i:]
unit, ok := timeMultiplier[u]
if !ok {
return 0, parseDurationError(orig, fmt.Sprintf("unknown unit %q in duration", u))
}

for _, variants := range timeUnits {
last := len(variants) - 1
multiplier := timeMultiplier[variants[0]]
if v > maxUint64/unit {
// overflow
return 0, parseDurationError(orig, "numerical overflow")
}

for i, variant := range variants {
if (last == i && strings.HasPrefix(unit, variant)) || strings.EqualFold(variant, unit) {
ok = true
if divisor != neutral {
multiplier = time.Duration(float64(multiplier) / divisor) // convert to duration only after having reduced the scale
}
dur += (time.Duration(factor) * multiplier)
}
v *= unit
if f > 0 {
// float64 is needed to be nanosecond accurate for fractions of hours.
// v >= 0 && (f*unit/scale) <= 3.6e+12 (ns/h, h is the largest unit)
v += uint64(float64(f) * (float64(unit) / scale))
if v > maxUint64 {
// overflow
return 0, parseDurationError(orig, "numerical overflow")
}
}

d += v
if d > maxUint64 {
return 0, parseDurationError(orig, "numerical overflow")
}
}

if ok {
return dur, nil
if neg {
return -time.Duration(d), nil
}
return 0, fmt.Errorf("unable to parse %s as duration: %w", cand, ErrFormat)

if d > maxUint64-1 {
return 0, parseDurationError(orig, "numerical overflow")
}

return time.Duration(d), nil
}

Check notice on line 260 in duration.go

View check run for this annotation

codefactor.io / CodeFactor

duration.go#L144-L260

Complex Method

// Scan reads a Duration value from database driver type.
func (d *Duration) Scan(raw any) error {
Expand Down Expand Up @@ -204,3 +323,70 @@
d.DeepCopyInto(out)
return out
}

func parseDurationError(s, msg string) error {
if msg == "" {
return fmt.Errorf("invalid duration: %s: %w", s, ErrFormat)
}

return fmt.Errorf("invalid duration: %s: %s: %w", s, msg, ErrFormat)
}

// leadingInt consumes the leading [0-9]* from s.
func leadingInt[bytes []byte | string](s bytes) (x uint64, rem bytes, ok bool) { //nolint:ireturn // false positive
i := 0
for ; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}

if x > maxUint64/10 { // overflow
return 0, rem, false
}

x = x*10 + uint64(c) - '0'
if x > maxUint64 { // overflow
return 0, rem, false
}
}

return x, s[i:], true
}

// leadingFraction consumes the leading [0-9]* from s.
// //
// It is used only for fractions, so it does not return an error on overflow,
// it just stops accumulating precision.
func leadingFraction(s string) (x uint64, scale float64, rem string) {
i := 0
scale = 1
overflow := false
for ; i < len(s); i++ {
c := s[i]
if c < '0' || c > '9' {
break
}

if overflow {
continue
}

if x > (maxUint64-1)/10 {
// It's possible for overflow to give a positive number, so take care.
overflow = true
continue
}

y := x*10 + uint64(c) - '0'
if y > maxUint64 {
overflow = true
continue
}

x = y
scale *= 10
}

return x, scale, s[i:]
}
Loading
Loading