1
0
mirror of https://github.com/mgechev/revive.git synced 2025-11-23 22:04:49 +02:00
Files
revive/rule/time_date.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
}