Repository: mgechev/revive (go)

Issue:
report time.Date used with leading zeroes (so the octal notation)
**Is your feature request related to a problem? Please describe.**

[time.Date](https://pkg.go.dev/time#Date) is often used with leading zeroes

```go
time.Date(2023, 01, 02, 03, 04, 05, 06, time.UTC)
```

[This pattern is pretty common](https://github.com/search?q=language%3Ago%20%2Ftime.Date%5C(%5B0-9%5D%2B%2C%20%5B%5E%5C)%5D*0o%3F%5B0-7%5D%2F&type=code) as it replicates the natural way a human would write dates.

But the leading zeros that seem OK, forces the value to be octal literals.

This would be the way to define the date,

```go
time.Date(2023, 1, 2, 3, 4, 5, 6, time.UTC)
```

It doesn't seem to be a real problem, but the following code cannot compile
```go
time.Date(2023, 01, 02, 03, 08, 00, 00, time.UTC)
```

Here someone wanted to use 08:00:00 UTC time, but 08 is not a valid octal.

So some PR requires to change from octal notation to decimal one to be able to use 08 and 09

There is fortunately no conversion issues for number under 7 (octal range is 0-7)

Also, there is a real concern when [gofumpt](https://github.com/mvdan/gofumpt) faces octal notation, it will transform it to this.

```go
time.Date(2023, 0o1, 0o2, 0o3, 0o4, 0o5, 0o6, time.UTC)
```
[
This pattern is not that common](https://github.com/search?q=language%3Ago%20%2Ftime.Date%5C(%5B0-9%5D%2B%2C%20%5B%5E%5C)%5D*0o%5B0-7%5D%2F&type=code), but pretty ugly.

**Describe the solution you'd like**

A new rule to detect this.

**Describe alternatives you've considered**

- updating the code of gofumpt, it's strange to add exception for this in [gofumpt code that is very simple](https://github.com/mvdan/gofumpt/blob/72ae882b16759122e10c954ce3ebb807b6d7365e/format/format.go#L685-L692)
- adding exception to go-critic [octalLiteral](https://go-critic.com/overview.html#octalliteral), the code is very simple, and the linter is not aware of how an octal is used, so it would require a lot of code change to check if we are in time.Date method


**Additional context**

I faced this when:
- I tried to use the time 08:00:00 and wrote `time.Date(2023, 01, 02, 03, 08, 00, 00, time.UTC)`
- gofumpt reformated some files


Pull request:
time-date: add rule to check for time.Date usage
This commit introduces a new rule to check for the usage of [`time.Date`](https://pkg.go.dev/time#Date)

The rule is added to report the usage of `time.Date` with non-decimal literals

Here the leading zeros that seem OK, forces the value to be octal literals.

```go
time.Date(2023, 01, 02, 03, 04, 05, 06, time.UTC)
```

gofumpt formats the code like this when it encounters leading zeroes.

```go
time.Date(2023, 0o1, 0o2, 0o3, 0o4, 0o5, 0o6, time.UTC)
```

The rule also reports anything that is not a decimal literal. Please take a look at the testdata file.

Closes #1326


Patch:
diff --git a/README.md b/README.md
index c6dccbb..b55119b 100644
--- a/README.md
+++ b/README.md
@@ -530,6 +530,7 @@ List of all available rules. The rules ported from `golint` are left unchanged a
 | [`string-of-int`](./RULES_DESCRIPTIONS.md#string-of-int)          |  n/a   | Warns on suspicious casts from int to string            |    no    |  yes   |
 | [`struct-tag`](./RULES_DESCRIPTIONS.md#struct-tag)          |  []string   | Checks common struct tags like `json`, `xml`, `yaml`               |    no    |  no   |
 | [`superfluous-else`](./RULES_DESCRIPTIONS.md#superfluous-else)    |  []string   | Prevents redundant else statements (extends [`indent-error-flow`](./RULES_DESCRIPTIONS.md#indent-error-flow)) |    no    |  no   |
+| [`time-date`](./RULES_DESCRIPTIONS.md#time-date)         |  n/a   | Reports bad usage of `time.Date`.                 |   no    |  yes  |
 | [`time-equal`](./RULES_DESCRIPTIONS.md#time-equal)         |  n/a   | Suggests to use `time.Time.Equal` instead of `==` and `!=` for equality check time.                 |   no    |  yes  |
 | [`time-naming`](./RULES_DESCRIPTIONS.md#time-naming)         |  n/a   | Conventions around the naming of time variables.                 |   yes    |  yes  |
 | [`unchecked-type-assertions`](./RULES_DESCRIPTIONS.md#unchecked-type-assertions)         |  n/a   | Disallows type assertions without checking the result.                 |   no    |  yes  |
diff --git a/RULES_DESCRIPTIONS.md b/RULES_DESCRIPTIONS.md
index 2ac0c1f..183b080 100644
--- a/RULES_DESCRIPTIONS.md
+++ b/RULES_DESCRIPTIONS.md
@@ -70,6 +70,7 @@ List of all available rules.
   - [string-of-int](#string-of-int)
   - [struct-tag](#struct-tag)
   - [superfluous-else](#superfluous-else)
+  - [time-date](#time-date)
   - [time-equal](#time-equal)
   - [time-naming](#time-naming)
   - [unchecked-type-assertion](#unchecked-type-assertion)
@@ -1013,6 +1014,55 @@ Examples:
   arguments = ["preserve-scope"]
 ```
 
+## time-date
+
+_Description_: Reports bad usage of `time.Date`.
+
+_Configuration_: N/A
+
+_Example_:
+
+Here the leading zeros are defining integers with octal notation
+
+```go
+var (
+  // here we can imagine zeroes were used for padding purpose
+  a = time.Date(2023, 1, 2, 3, 4, 0, 00000000, time.UTC) // 00000000 is octal and equals 0 in decimal
+  b = time.Date(2023, 1, 2, 3, 4, 0, 00000006, time.UTC) // 00000006 is octal and equals 6 in decimal
+  c = time.Date(2023, 1, 2, 3, 4, 0, 00123456, time.UTC) // 00123456 is octal and equals 42798 in decimal
+)
+```
+
+But here, what was expected 123456 or 42798 ? It's a source of bugs.
+
+So the rule will report this
+
+> octal notation with padding zeroes found: choose between 123456 and 42798 (decimal value of 123456 octal value)
+
+This rule also reports strange notations used with time.Date.
+
+Example:
+
+```go
+var _ = time.Date(
+		0x7e7,    // hexadecimal notation: use 2023 instead of 0x7e7/
+		0b1,      // binary notation: use 1 instead of 0b1/
+		0x_2,     // hexadecimal notation: use 2 instead of 0x_2/
+		1_3,      // alternative notation: use 13 instead of 1_3/
+		1e1,      // exponential notation: use 10 instead of 1e1/
+		0.,       // float literal: use 0 instead of 0./
+		0x1.Fp+6, // float literal: use 124 instead of 0x1.Fp+6/
+		time.UTC)
+```
+
+All these are considered to be an uncommon usage of time.Date, are reported with a 0.8 confidence.
+
+Note: even if 00, 01, 02, 03, 04, 05, 06, 07 are octal numbers, they can be considered as valid, and reported with 0.5 confidence.
+
+```go
+var _ = time.Date(2023, 01, 02, 03, 04, 00, 0, time.UTC)
+```
+
 ## time-equal
 
 _Description_: This rule warns when using `==` and `!=` for equality check `time.Time` and suggest to `time.time.Equal` method,
diff --git a/config/config.go b/config/config.go
index 34340b7..0f9ee9e 100644
--- a/config/config.go
+++ b/config/config.go
@@ -83,6 +83,7 @@ var allRules = append([]lint.Rule{
 	&rule.UselessBreak{},
 	&rule.UncheckedTypeAssertionRule{},
 	&rule.TimeEqualRule{},
+	&rule.TimeDateRule{},
 	&rule.BannedCharsRule{},
 	&rule.OptimizeOperandsOrderRule{},
 	&rule.UseAnyRule{},
diff --git a/rule/time_date.go b/rule/time_date.go
new file mode 100644
index 0000000..c8ba841
--- /dev/null
+++ b/rule/time_date.go
@@ -0,0 +1,237 @@
+package rule
+
+import (
+	"errors"
+	"fmt"
+	"go/ast"
+	"go/token"
+	"strconv"
+	"strings"
+
+	"github.com/mgechev/revive/lint"
+	"github.com/mgechev/revive/logging"
+)
+
+// TimeDateRule lints the way time.Date is used.
+type TimeDateRule struct{}
+
+// Apply applies the rule to given file.
+func (*TimeDateRule) Apply(file *lint.File, _ lint.Arguments) []lint.Failure {
+	var failures []lint.Failure
+
+	onFailure := func(failure lint.Failure) {
+		failures = append(failures, failure)
+	}
+
+	w := &lintTimeDate{file, onFailure}
+
+	ast.Walk(w, file.AST)
+	return failures
+}
+
+// Name returns the rule name.
+func (*TimeDateRule) Name() string {
+	return "time-date"
+}
+
+type lintTimeDate struct {
+	file      *lint.File
+	onFailure func(lint.Failure)
+}
+
+var (
+	// timeDateArgumentNames are the names of the arguments of time.Date
+	timeDateArgumentNames = []string{
+		"year",
+		"month",
+		"day",
+		"hour",
+		"minute",
+		"second",
+		"nanosecond",
+		"timezone",
+	}
+
+	// timeDateArity is the number of arguments of time.Date
+	timeDateArity = len(timeDateArgumentNames)
+)
+
+func (w lintTimeDate) Visit(n ast.Node) ast.Visitor {
+	ce, ok := n.(*ast.CallExpr)
+	if !ok || len(ce.Args) != timeDateArity {
+		return w
+	}
+	if !isPkgDot(ce.Fun, "time", "Date") {
+		return w
+	}
+
+	// The last argument is a timezone, there is no need to check it, also it has a different type
+	for pos, arg := range ce.Args[:timeDateArity-1] {
+		bl, ok := arg.(*ast.BasicLit)
+		if !ok {
+			continue
+		}
+
+		replacedValue, err := parseDecimalInteger(bl)
+		if err == nil {
+			continue
+		}
+
+		if errors.Is(err, errParsedInvalid) {
+			// This is not supposed to happen, let's be defensive
+			// log the error, but continue
+
+			logger, errLogger := logging.GetLogger()
+			if errLogger != nil {
+				// This is not supposed to happen, discard both errors
+				continue
+			}
+			logger.With(
+				"value", bl.Value,
+				"kind", bl.Kind,
+				"error", err.Error(),
+			).Error("failed to parse time.Date argument")
+
+			continue
+		}
+
+		confidence := 0.8 // default confidence
+		errMessage := err.Error()
+		instructions := fmt.Sprintf("use %s instead of %s", replacedValue, bl.Value)
+		switch {
+		case errors.Is(err, errParsedOctalWithZero):
+			// people can use 00, 01, 02, 03, 04, 05, 06, and 07 if they want.
+			confidence = 0.5
+
+		case errors.Is(err, errParsedOctalWithPaddingZeroes):
+			// This is a clear mistake.
+			// example with 000123456 (octal) is about 123456 or 42798 ?
+			confidence = 1
+
+			strippedValue := strings.TrimLeft(bl.Value, "0")
+			if strippedValue == "" {
+				// avoid issue with 00000000
+				strippedValue = "0"
+			}
+
+			if strippedValue != replacedValue {
+				instructions = fmt.Sprintf(
+					"choose between %s and %s (decimal value of %s octal value)",
+					strippedValue, replacedValue, strippedValue,
+				)
+			}
+		}
+
+		w.onFailure(lint.Failure{
+			Category:   "time",
+			Node:       bl,
+			Confidence: confidence,
+			Failure: fmt.Sprintf(
+				"use decimal digits for time.Date %s argument: %s found: %s",
+				timeDateArgumentNames[pos], errMessage, instructions),
+		})
+	}
+
+	return w
+}
+
+var (
+	errParsedOctal                  = errors.New("octal notation")
+	errParsedOctalWithZero          = errors.New("octal notation with leading zero")
+	errParsedOctalWithPaddingZeroes = errors.New("octal notation with padding zeroes")
+	errParsedHexadecimal            = errors.New("hexadecimal notation")
+	errParseBinary                  = errors.New("binary notation")
+	errParsedFloat                  = errors.New("float literal")
+	errParsedExponential            = errors.New("exponential notation")
+	errParsedAlternative            = errors.New("alternative notation")
+	errParsedInvalid                = errors.New("invalid notation")
+)
+
+func parseDecimalInteger(bl *ast.BasicLit) (string, error) {
+	currentValue := strings.ToLower(bl.Value)
+
+	switch currentValue {
+	case "0":
+		// skip 0 as it is a valid value for all the arguments
+		return bl.Value, nil
+	case "00", "01", "02", "03", "04", "05", "06", "07":
+		// people can use 00, 01, 02, 03, 04, 05, 06, 07, if they want
+		return bl.Value[1:], errParsedOctalWithZero
+	}
+
+	switch bl.Kind {
+	case token.FLOAT:
+		// someone used a float literal, while they should have used an integer literal.
+		parsedValue, err := strconv.ParseFloat(currentValue, 64)
+		if err != nil {
+			// This is not supposed to happen
+			return bl.Value, fmt.Errorf(
+				"%w: %s: %w",
+				errParsedInvalid,
+				"failed to parse number as float",
+				err,
+			)
+		}
+
+		// this will convert back the number to a string
+		cleanedValue := strconv.FormatFloat(parsedValue, 'f', -1, 64)
+		if strings.Contains(currentValue, "e") {
+			return cleanedValue, errParsedExponential
+		}
+
+		return cleanedValue, errParsedFloat
+
+	case token.INT:
+		// we expect this format
+
+	default:
+		// This is not supposed to happen
+		return bl.Value, fmt.Errorf(
+			"%w: %s",
+			errParsedInvalid,
+			"unexpected kind of literal",
+		)
+	}
+
+	// Parse the number with base=0 that allows to accept all number formats and base
+	parsedValue, err := strconv.ParseInt(currentValue, 0, 64)
+	if err != nil {
+		// This is not supposed to happen
+		return bl.Value, fmt.Errorf(
+			"%w: %s: %w",
+			errParsedInvalid,
+			"failed to parse number as integer",
+			err,
+		)
+	}
+
+	cleanedValue := strconv.FormatInt(parsedValue, 10)
+
+	// Let's figure out the notation to return an error
+	switch {
+	case strings.HasPrefix(currentValue, "0b"):
+		return cleanedValue, errParseBinary
+	case strings.HasPrefix(currentValue, "0x"):
+		return cleanedValue, errParsedHexadecimal
+	case strings.HasPrefix(currentValue, "0"):
+		// this matches both "0" and "0o" octal notation.
+
+		if strings.HasPrefix(currentValue, "00") {
+			// 00123456 (octal) is about 123456 or 42798 ?
+			return cleanedValue, errParsedOctalWithPaddingZeroes
+		}
+
+		// 0006 is a valid octal notation, but we can use 6 instead.
+		return cleanedValue, errParsedOctal
+	}
+
+	// Convert back the number to a string, and compare it with the original one
+	formattedValue := strconv.FormatInt(parsedValue, 10)
+	if formattedValue != currentValue {
+		// This can catch some edge cases like: 1_0 ...
+		return cleanedValue, errParsedAlternative
+	}
+
+	// The number is a decimal integer, we can use it as is.
+	return bl.Value, nil
+}


Test patch:
diff --git a/test/time_date_test.go b/test/time_date_test.go
new file mode 100644
index 0000000..6cb98db
--- /dev/null
+++ b/test/time_date_test.go
@@ -0,0 +1,11 @@
+package test
+
+import (
+	"testing"
+
+	"github.com/mgechev/revive/rule"
+)
+
+func TestTimeDate(t *testing.T) {
+	testRule(t, "time_date_decimal_literal", &rule.TimeDateRule{})
+}
diff --git a/testdata/time_date_decimal_literal.go b/testdata/time_date_decimal_literal.go
new file mode 100644
index 0000000..d2b9526
--- /dev/null
+++ b/testdata/time_date_decimal_literal.go
@@ -0,0 +1,108 @@
+package pkg
+
+import "time"
+
+// these should match and be reported as invalid usage of time.Date
+// the rule should suggest to use a decimal number
+var (
+	// All these examples refers to the same date
+	// 2023-01-02 03:04:05.000000006 +0000 UTC
+
+	// this is an invalid usage
+	// people are using this format because a date is easier to read
+	// but here it leads to use octal numbers0
+	_ = time.Date(
+		2023,
+		01,        // MATCH /use decimal digits for time.Date month argument: octal notation with leading zero found: use 1 instead of 01/
+		02,        // MATCH /use decimal digits for time.Date day argument: octal notation with leading zero found: use 2 instead of 02/
+		03,        // MATCH /use decimal digits for time.Date hour argument: octal notation with leading zero found: use 3 instead of 03/
+		04,        // MATCH /use decimal digits for time.Date minute argument: octal notation with leading zero found: use 4 instead of 04/
+		05,        // MATCH /use decimal digits for time.Date second argument: octal notation with leading zero found: use 5 instead of 05/
+		000000006, // MATCH /use decimal digits for time.Date nanosecond argument: octal notation with padding zeroes found: use 6 instead of 000000006/
+		time.UTC)
+
+	// the following one could have been written by someone who is not aware of the issue
+	// Please note, there are multiple issues on the same line
+	//
+	// use special syntax to match multiple issues being reported on the same line
+	// MATCH:34 /use decimal digits for time.Date month argument: octal notation with leading zero found: use 1 instead of 01/
+	// MATCH:34 /use decimal digits for time.Date day argument: octal notation with leading zero found: use 2 instead of 02/
+	// MATCH:34 /use decimal digits for time.Date hour argument: octal notation with leading zero found: use 3 instead of 03/
+	// MATCH:34 /use decimal digits for time.Date minute argument: octal notation with leading zero found: use 4 instead of 04/
+	// MATCH:34 /use decimal digits for time.Date second argument: octal notation with leading zero found: use 5 instead of 05/
+	// MATCH:34 /use decimal digits for time.Date nanosecond argument: octal notation with padding zeroes found: use 6 instead of 000000006/
+	_ = time.Date(2023, 01, 02, 03, 04, 05, 000000006, time.UTC)
+)
+
+// gofumpt formats legacy non-decimal notation to new one non-decimal notation
+// it transforms 01, 02, 03, 04, 05, 06, and 07 to 0o1, 0o2, 0o3, 0o4, 0o5, 0o6, and 0o7
+// but here with time.Date it doesn't make sense. This is the main reason why the rule was created.
+var (
+	_ = time.Date(2023, 0o1, 2, 3, 4, 5, 6, time.UTC) // MATCH /use decimal digits for time.Date month argument: octal notation found: use 1 instead of 0o1/
+	_ = time.Date(2023, 1, 0o2, 3, 4, 5, 6, time.UTC) // MATCH /use decimal digits for time.Date day argument: octal notation found: use 2 instead of 0o2/
+	_ = time.Date(2023, 1, 2, 0o3, 4, 5, 6, time.UTC) // MATCH /use decimal digits for time.Date hour argument: octal notation found: use 3 instead of 0o3/
+	_ = time.Date(2023, 1, 2, 3, 0o4, 5, 6, time.UTC) // MATCH /use decimal digits for time.Date minute argument: octal notation found: use 4 instead of 0o4/
+	_ = time.Date(2023, 1, 2, 3, 4, 0o5, 6, time.UTC) // MATCH /use decimal digits for time.Date second argument: octal notation found: use 5 instead of 0o5/
+	_ = time.Date(2023, 1, 2, 3, 4, 5, 0o6, time.UTC) // MATCH /use decimal digits for time.Date nanosecond argument: octal notation found: use 6 instead of 0o6/
+)
+
+// padding with zeroes can lead to errors
+var (
+	_ = time.Date(2023, 1, 2, 3, 4, 5, 00, time.UTC) // MATCH /use decimal digits for time.Date nanosecond argument: octal notation with leading zero found: use 0 instead of 00/
+	_ = time.Date(2023, 1, 2, 3, 4, 5, 01, time.UTC) // MATCH /use decimal digits for time.Date nanosecond argument: octal notation with leading zero found: use 1 instead of 01/
+
+	_ = time.Date(2023, 1, 2, 3, 4, 5, 00000000, time.UTC) // MATCH /use decimal digits for time.Date nanosecond argument: octal notation with padding zeroes found: use 0 instead of 00000000/
+	_ = time.Date(2023, 1, 2, 3, 4, 5, 00000006, time.UTC) // MATCH /use decimal digits for time.Date nanosecond argument: octal notation with padding zeroes found: use 6 instead of 00000006/
+	_ = time.Date(2023, 1, 2, 3, 4, 5, 00123456, time.UTC) // MATCH /use decimal digits for time.Date nanosecond argument: octal notation with padding zeroes found: choose between 123456 and 42798 (decimal value of 123456 octal value)/
+)
+
+// hypothetical examples based on other number notations
+// https://go.dev/ref/spec#Integer_literals
+// these should match and be reported as invalid usage of time.Date
+var (
+	_ = time.Date(
+		0x7e7,    // MATCH /use decimal digits for time.Date year argument: hexadecimal notation found: use 2023 instead of 0x7e7/
+		0b1,      // MATCH /use decimal digits for time.Date month argument: binary notation found: use 1 instead of 0b1/
+		0x_2,     // MATCH /use decimal digits for time.Date day argument: hexadecimal notation found: use 2 instead of 0x_2/
+		1_3,      // MATCH /use decimal digits for time.Date hour argument: alternative notation found: use 13 instead of 1_3/
+		1e1,      // MATCH /use decimal digits for time.Date minute argument: exponential notation found: use 10 instead of 1e1/
+		0.,       // MATCH /use decimal digits for time.Date second argument: float literal found: use 0 instead of 0./
+		0x1.Fp+6, // MATCH /use decimal digits for time.Date nanosecond argument: float literal found: use 124 instead of 0x1.Fp+6/
+		time.UTC)
+)
+
+// these should never match
+var (
+	_ = time.Date(2023, 1, 2, 3, 4, 5, 1234567, time.UTC)
+	_ = time.Date(2023, time.January, 2, 3, 4, 5, 1234567, time.UTC)
+	_ = time.Date(2023, 10, 10, 10, 10, 10, 100, time.UTC)
+	_ = time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC)
+	_ = time.Date(2023, 1, 2, 3, 4, 5, 6, time.UTC)
+)
+
+// uncommon notations
+// these notations are supported by Go, the rule should not report it
+// it's out of the scope of the rule
+var (
+	// negative values can be used
+	// Go will compute 2022-10-28 20:55:54.999999994 +0000 UTC
+	_ = time.Date(2023, -1, -2, -3, -4, -5, -6, time.UTC)
+
+	// value out of the expected scale for a field, are OK.
+	// Go will compute 2024-02-02 04:11:10 +0000 UTC
+	_ = time.Date(2023, 13, 32, 27, 70, 70, 0, time.UTC)
+
+	// using gigantic nanoseconds to move into the future
+	// Go will compute 2054-09-10 04:50:45 +0000 UTC
+	_ = time.Date(2023, 1, 2, 3, 4, 5, 1000000000000000000, time.UTC)
+)
+
+// Common user errors with dates
+// it's out of the scope of the rule
+var (
+	// there is no February 29th in 2023, Go will compute 2023-03-01 03:04:05 +0000 UTC
+	_ = time.Date(2023, 2, 29, 3, 4, 5, 0, time.UTC)
+
+	// June has only 30 days, Go will compute 2023-07-01 03:04:05 +0000 UTC
+	_ = time.Date(2023, 6, 31, 3, 4, 5, 0, time.UTC)
+)