mirror of
https://github.com/mgechev/revive.git
synced 2025-11-23 22:04:49 +02:00
483 lines
13 KiB
Go
483 lines
13 KiB
Go
package rule
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/token"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mgechev/revive/internal/astutils"
|
|
"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)
|
|
}
|
|
|
|
// timeDateArgument is a type for the arguments of time.Date function.
|
|
type timeDateArgument string
|
|
|
|
const (
|
|
timeDateArgYear timeDateArgument = "year"
|
|
timeDateArgMonth timeDateArgument = "month"
|
|
timeDateArgDay timeDateArgument = "day"
|
|
timeDateArgHour timeDateArgument = "hour"
|
|
timeDateArgMinute timeDateArgument = "minute"
|
|
timeDateArgSecond timeDateArgument = "second"
|
|
timeDateArgNanosecond timeDateArgument = "nanosecond"
|
|
timeDateArgTimezone timeDateArgument = "timezone"
|
|
)
|
|
|
|
var (
|
|
// timeDateArgumentNames are the names of the arguments of time.Date.
|
|
timeDateArgumentNames = []timeDateArgument{
|
|
timeDateArgYear,
|
|
timeDateArgMonth,
|
|
timeDateArgDay,
|
|
timeDateArgHour,
|
|
timeDateArgMinute,
|
|
timeDateArgSecond,
|
|
timeDateArgNanosecond,
|
|
timeDateArgTimezone,
|
|
}
|
|
|
|
// timeDateArity is the number of arguments of time.Date.
|
|
timeDateArity = len(timeDateArgumentNames)
|
|
)
|
|
|
|
var timeDateArgumentBoundaries = map[timeDateArgument][2]int64{
|
|
// year is not validated
|
|
timeDateArgMonth: {1, 12},
|
|
timeDateArgDay: {1, 31}, // there is a special check for this field, this is just a fallback
|
|
timeDateArgHour: {0, 23},
|
|
timeDateArgMinute: {0, 59},
|
|
timeDateArgSecond: {0, 60}, // 60 is for leap second
|
|
timeDateArgNanosecond: {0, 1e9 - 1}, // 1e9 is not allowed, as it means 1 second
|
|
}
|
|
|
|
type timeDateMonthYear struct {
|
|
year, month int64
|
|
}
|
|
|
|
func (w *lintTimeDate) Visit(n ast.Node) ast.Visitor {
|
|
ce, ok := n.(*ast.CallExpr)
|
|
if !ok || len(ce.Args) != timeDateArity {
|
|
return w
|
|
}
|
|
if !astutils.IsPkgDotName(ce.Fun, "time", "Date") {
|
|
return w
|
|
}
|
|
|
|
// The last argument is a timezone, check it
|
|
tzArg := ce.Args[timeDateArity-1]
|
|
if astutils.IsIdent(tzArg, "nil") {
|
|
w.onFailure(lint.Failure{
|
|
Category: "time",
|
|
Node: tzArg,
|
|
Confidence: 1,
|
|
Failure: "time.Date timezone argument cannot be nil, it would panic on runtime",
|
|
})
|
|
}
|
|
|
|
var parsedDate timeDateMonthYear
|
|
// All the other arguments should be decimal integers.
|
|
for pos, arg := range ce.Args[:timeDateArity-1] {
|
|
fieldName := timeDateArgumentNames[pos]
|
|
|
|
bl, ok := w.checkArgSign(arg, fieldName)
|
|
if !ok {
|
|
// either it is not a basic literal
|
|
// or it is a unary expression with a sign that was reported as a failure
|
|
continue
|
|
}
|
|
|
|
parsedValue, err := parseDecimalInteger(bl)
|
|
if err == nil {
|
|
if fieldName == timeDateArgYear {
|
|
// store the year value for further checks with day
|
|
parsedDate.year = parsedValue
|
|
|
|
// no checks for year, as it can be any value
|
|
// a year can be negative, zero, or positive
|
|
continue
|
|
}
|
|
|
|
boundaries, ok := timeDateArgumentBoundaries[fieldName]
|
|
if !ok {
|
|
// no boundaries for this field, skip it
|
|
continue
|
|
}
|
|
minValue, maxValue := boundaries[0], boundaries[1]
|
|
|
|
switch fieldName {
|
|
case timeDateArgMonth:
|
|
parsedDate.month = parsedValue
|
|
|
|
if parsedValue == 0 {
|
|
// Special case: month is 0.
|
|
// Go treats it as January, but we still report it as a failure.
|
|
w.onFailure(lint.Failure{
|
|
Category: "time",
|
|
Node: arg,
|
|
Confidence: 1,
|
|
Failure: "time.Date month argument should not be zero",
|
|
})
|
|
continue
|
|
}
|
|
|
|
case timeDateArgDay:
|
|
|
|
switch {
|
|
case parsedValue == 0:
|
|
// Special case: day is 0.
|
|
// Go treats it as the first day of the month, but we still report it as a failure.
|
|
w.onFailure(lint.Failure{
|
|
Category: "time",
|
|
Node: arg,
|
|
Confidence: 1,
|
|
Failure: "time.Date day argument should not be zero",
|
|
})
|
|
continue
|
|
|
|
// the month is valid, check the day
|
|
case parsedDate.month >= 1 && parsedDate.month <= 12:
|
|
month := time.Month(parsedDate.month)
|
|
|
|
maxValue = w.daysInMonth(parsedDate.year, month)
|
|
|
|
monthName := month.String()
|
|
if month == time.February {
|
|
// because of leap years, we need to provide the year in the error message
|
|
monthName += " " + strconv.FormatInt(parsedDate.year, 10)
|
|
}
|
|
|
|
if parsedValue > maxValue {
|
|
// we can provide a more detailed error message
|
|
w.onFailure(lint.Failure{
|
|
Category: "time",
|
|
Node: arg,
|
|
Confidence: 0.8,
|
|
Failure: fmt.Sprintf(
|
|
"time.Date day argument is %d, but %s has only %d days",
|
|
parsedValue, monthName, maxValue,
|
|
),
|
|
})
|
|
continue
|
|
}
|
|
|
|
// We know, the month is >12, let's try to detect possible day and month swap in arguments.
|
|
// for example: time.Date(2023, 31, 6, 0, 0, 0, 0, time.UTC)
|
|
case parsedDate.month > 12 && parsedDate.month <= 31 && parsedValue <= 12:
|
|
|
|
// Invert the month and day values
|
|
realMonth, realDay := parsedValue, parsedDate.month
|
|
|
|
// Check if the real month is valid.
|
|
if realDay <= w.daysInMonth(parsedDate.year, time.Month(realMonth)) {
|
|
w.onFailure(lint.Failure{
|
|
Category: "time",
|
|
Node: arg,
|
|
Confidence: 0.5,
|
|
Failure: fmt.Sprintf(
|
|
"time.Date month and day arguments appear to be swapped: %d-%02d-%02d vs %d-%02d-%02d",
|
|
parsedDate.year, realMonth, realDay,
|
|
parsedDate.year, parsedDate.month, parsedValue,
|
|
),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if parsedValue < minValue || parsedValue > maxValue {
|
|
w.onFailure(lint.Failure{
|
|
Category: "time",
|
|
Node: arg,
|
|
Confidence: 0.8,
|
|
Failure: fmt.Sprintf(
|
|
"time.Date %s argument should be between %d and %d: %s",
|
|
fieldName, minValue, maxValue, astutils.GoFmt(arg),
|
|
),
|
|
})
|
|
}
|
|
|
|
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()
|
|
replacedValue := strconv.FormatInt(parsedValue, 10)
|
|
instructions := fmt.Sprintf("use %s instead of %s", replacedValue, astutils.GoFmt(arg))
|
|
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",
|
|
fieldName, errMessage, instructions),
|
|
})
|
|
}
|
|
|
|
return w
|
|
}
|
|
|
|
func (w *lintTimeDate) checkArgSign(arg ast.Node, fieldName timeDateArgument) (*ast.BasicLit, bool) {
|
|
if bl, ok := arg.(*ast.BasicLit); ok {
|
|
// it is an unsigned basic literal
|
|
// we can use it as is
|
|
return bl, true
|
|
}
|
|
|
|
// We can have an unary expression like -1, -a, +a, +1...
|
|
node, ok := arg.(*ast.UnaryExpr)
|
|
if !ok {
|
|
// Any other expression is not supported.
|
|
// It could be something like this:
|
|
// time.Date(2023, 2 * a, 3 + b, 4, 5, 6, 7, time.UTC)
|
|
return nil, false
|
|
}
|
|
|
|
// But we expect the unary expression to be followed by a basic literal
|
|
bl, ok := node.X.(*ast.BasicLit)
|
|
if !ok {
|
|
// This is not a basic literal, it could be an identifier, a function call, etc.
|
|
// -a
|
|
// ^b
|
|
// -foo()
|
|
//
|
|
// It's out of scope of this rule.
|
|
return nil, false
|
|
}
|
|
// So now, we have an unary expression like -500, +2023, -0x1234 ...
|
|
|
|
if fieldName == timeDateArgYear && node.Op == token.SUB {
|
|
// The year can be negative, like referring to BC years.
|
|
// We can return it as is, without reporting a failure
|
|
return bl, true
|
|
}
|
|
|
|
switch node.Op {
|
|
case token.SUB:
|
|
// This is a negative number, it is supported, but it's uncommon.
|
|
w.onFailure(lint.Failure{
|
|
Category: "time",
|
|
Node: arg,
|
|
Confidence: 0.5,
|
|
Failure: fmt.Sprintf(
|
|
"time.Date %s argument is negative: %s",
|
|
fieldName, astutils.GoFmt(arg),
|
|
),
|
|
})
|
|
case token.ADD:
|
|
// There is a positive sign, but it is not necessary to have a positive sign
|
|
w.onFailure(lint.Failure{
|
|
Category: "time",
|
|
Node: arg,
|
|
Confidence: 0.8,
|
|
Failure: fmt.Sprintf(
|
|
"time.Date %s argument contains a useless plus sign: %s",
|
|
fieldName, astutils.GoFmt(arg),
|
|
),
|
|
})
|
|
default:
|
|
// Other unary expressions are not supported.
|
|
//
|
|
// It could be something like this:
|
|
// ^1, ^0x1234
|
|
// but these are unlikely to be used with time.Date
|
|
// We ignore them, to avoid false positives.
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// isLeapYear checks if the year is a leap year.
|
|
// This is used to check if the date is valid according to Go implementation.
|
|
func (*lintTimeDate) isLeapYear(year int64) bool {
|
|
// We cannot use the classic formula of
|
|
// year%4 == 0 && (year%100 != 0 || year%400 == 0)
|
|
// because we want to ensure what time.Date will compute
|
|
|
|
return time.Date(int(year), 2, 29, 0, 0, 0, 0, time.UTC).Format("01-02") == "02-29"
|
|
}
|
|
|
|
func (w *lintTimeDate) daysInMonth(year int64, month time.Month) int64 {
|
|
switch month {
|
|
case time.April, time.June, time.September, time.November:
|
|
return 30
|
|
case time.February:
|
|
if w.isLeapYear(year) {
|
|
return 29
|
|
}
|
|
return 28
|
|
}
|
|
|
|
return 31
|
|
}
|
|
|
|
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) (int64, error) {
|
|
currentValue := strings.ToLower(bl.Value)
|
|
|
|
if currentValue == "0" {
|
|
// skip 0 as it is a valid value for all the arguments
|
|
return 0, nil
|
|
}
|
|
|
|
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 0, fmt.Errorf(
|
|
"%w: %s: %w",
|
|
errParsedInvalid,
|
|
"failed to parse number as float",
|
|
err,
|
|
)
|
|
}
|
|
|
|
// this will convert back the number to a string
|
|
if strings.Contains(currentValue, "e") {
|
|
return int64(parsedValue), errParsedExponential
|
|
}
|
|
|
|
return int64(parsedValue), errParsedFloat
|
|
|
|
case token.INT:
|
|
// we expect this format
|
|
|
|
default:
|
|
// This is not supposed to happen
|
|
return 0, 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 0, fmt.Errorf(
|
|
"%w: %s: %w",
|
|
errParsedInvalid,
|
|
"failed to parse number as integer",
|
|
err,
|
|
)
|
|
}
|
|
|
|
// Let's figure out the notation to return an error
|
|
switch {
|
|
case strings.HasPrefix(currentValue, "0b"):
|
|
return parsedValue, errParseBinary
|
|
case strings.HasPrefix(currentValue, "0x"):
|
|
return parsedValue, errParsedHexadecimal
|
|
case strings.HasPrefix(currentValue, "0"):
|
|
// this matches both "0" and "0o" octal notation.
|
|
|
|
switch currentValue {
|
|
// people can use 00, 01, 02, 03, 04, 05, 06, 07, if they want
|
|
case "00", "01", "02", "03", "04", "05", "06", "07":
|
|
return parsedValue, errParsedOctalWithZero
|
|
}
|
|
|
|
if strings.HasPrefix(currentValue, "00") {
|
|
// 00123456 (octal) is about 123456 or 42798 ?
|
|
return parsedValue, errParsedOctalWithPaddingZeroes
|
|
}
|
|
|
|
return parsedValue, 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 parsedValue, errParsedAlternative
|
|
}
|
|
|
|
return parsedValue, nil
|
|
}
|