diff options
Diffstat (limited to 'modules/util')
-rw-r--r-- | modules/util/error.go | 68 | ||||
-rw-r--r-- | modules/util/filebuffer/file_backed_buffer.go | 35 | ||||
-rw-r--r-- | modules/util/filebuffer/file_backed_buffer_test.go | 3 | ||||
-rw-r--r-- | modules/util/legacy_test.go | 2 | ||||
-rw-r--r-- | modules/util/map.go | 13 | ||||
-rw-r--r-- | modules/util/map_test.go | 26 | ||||
-rw-r--r-- | modules/util/paginate_test.go | 14 | ||||
-rw-r--r-- | modules/util/path.go | 91 | ||||
-rw-r--r-- | modules/util/path_test.go | 20 | ||||
-rw-r--r-- | modules/util/remove.go | 6 | ||||
-rw-r--r-- | modules/util/rotatingfilewriter/writer_test.go | 2 | ||||
-rw-r--r-- | modules/util/runtime_test.go | 4 | ||||
-rw-r--r-- | modules/util/sec_to_time.go | 65 | ||||
-rw-r--r-- | modules/util/sec_to_time_test.go | 24 | ||||
-rw-r--r-- | modules/util/shellquote_test.go | 10 | ||||
-rw-r--r-- | modules/util/slice.go | 10 | ||||
-rw-r--r-- | modules/util/string.go | 38 | ||||
-rw-r--r-- | modules/util/string_test.go | 5 | ||||
-rw-r--r-- | modules/util/time_str.go | 2 | ||||
-rw-r--r-- | modules/util/truncate.go | 128 | ||||
-rw-r--r-- | modules/util/truncate_test.go | 143 | ||||
-rw-r--r-- | modules/util/util.go | 18 | ||||
-rw-r--r-- | modules/util/util_test.go | 17 |
23 files changed, 467 insertions, 277 deletions
diff --git a/modules/util/error.go b/modules/util/error.go index 0f3597147c..6b2721618e 100644 --- a/modules/util/error.go +++ b/modules/util/error.go @@ -10,56 +10,88 @@ import ( // Common Errors forming the base of our error system // -// Many Errors returned by Gitea can be tested against these errors -// using errors.Is. +// Many Errors returned by Gitea can be tested against these errors using "errors.Is". var ( - ErrInvalidArgument = errors.New("invalid argument") - ErrPermissionDenied = errors.New("permission denied") - ErrAlreadyExist = errors.New("resource already exists") - ErrNotExist = errors.New("resource does not exist") + ErrInvalidArgument = errors.New("invalid argument") // also implies HTTP 400 + ErrPermissionDenied = errors.New("permission denied") // also implies HTTP 403 + ErrNotExist = errors.New("resource does not exist") // also implies HTTP 404 + ErrAlreadyExist = errors.New("resource already exists") // also implies HTTP 409 + + // ErrUnprocessableContent implies HTTP 422, the syntax of the request content is correct, + // but the server is unable to process the contained instructions + ErrUnprocessableContent = errors.New("unprocessable content") ) -// SilentWrap provides a simple wrapper for a wrapped error where the wrapped error message plays no part in the error message +// errorWrapper provides a simple wrapper for a wrapped error where the wrapped error message plays no part in the error message // Especially useful for "untyped" errors created with "errors.New(…)" that can be classified as 'invalid argument', 'permission denied', 'exists already', or 'does not exist' -type SilentWrap struct { +type errorWrapper struct { Message string Err error } // Error returns the message -func (w SilentWrap) Error() string { +func (w errorWrapper) Error() string { return w.Message } // Unwrap returns the underlying error -func (w SilentWrap) Unwrap() error { +func (w errorWrapper) Unwrap() error { return w.Err } -// NewSilentWrapErrorf returns an error that formats as the given text but unwraps as the provided error -func NewSilentWrapErrorf(unwrap error, message string, args ...any) error { +type LocaleWrapper struct { + err error + TrKey string + TrArgs []any +} + +// Error returns the message +func (w LocaleWrapper) Error() string { + return w.err.Error() +} + +// Unwrap returns the underlying error +func (w LocaleWrapper) Unwrap() error { + return w.err +} + +// ErrorWrap returns an error that formats as the given text but unwraps as the provided error +func ErrorWrap(unwrap error, message string, args ...any) error { if len(args) == 0 { - return SilentWrap{Message: message, Err: unwrap} + return errorWrapper{Message: message, Err: unwrap} } - return SilentWrap{Message: fmt.Sprintf(message, args...), Err: unwrap} + return errorWrapper{Message: fmt.Sprintf(message, args...), Err: unwrap} } // NewInvalidArgumentErrorf returns an error that formats as the given text but unwraps as an ErrInvalidArgument func NewInvalidArgumentErrorf(message string, args ...any) error { - return NewSilentWrapErrorf(ErrInvalidArgument, message, args...) + return ErrorWrap(ErrInvalidArgument, message, args...) } // NewPermissionDeniedErrorf returns an error that formats as the given text but unwraps as an ErrPermissionDenied func NewPermissionDeniedErrorf(message string, args ...any) error { - return NewSilentWrapErrorf(ErrPermissionDenied, message, args...) + return ErrorWrap(ErrPermissionDenied, message, args...) } // NewAlreadyExistErrorf returns an error that formats as the given text but unwraps as an ErrAlreadyExist func NewAlreadyExistErrorf(message string, args ...any) error { - return NewSilentWrapErrorf(ErrAlreadyExist, message, args...) + return ErrorWrap(ErrAlreadyExist, message, args...) } // NewNotExistErrorf returns an error that formats as the given text but unwraps as an ErrNotExist func NewNotExistErrorf(message string, args ...any) error { - return NewSilentWrapErrorf(ErrNotExist, message, args...) + return ErrorWrap(ErrNotExist, message, args...) +} + +// ErrorWrapLocale wraps an err with a translation key and arguments +func ErrorWrapLocale(err error, trKey string, trArgs ...any) error { + return LocaleWrapper{err: err, TrKey: trKey, TrArgs: trArgs} +} + +func ErrorAsLocale(err error) *LocaleWrapper { + var e LocaleWrapper + if errors.As(err, &e) { + return &e + } + return nil } diff --git a/modules/util/filebuffer/file_backed_buffer.go b/modules/util/filebuffer/file_backed_buffer.go index 739543e297..0731ba30c8 100644 --- a/modules/util/filebuffer/file_backed_buffer.go +++ b/modules/util/filebuffer/file_backed_buffer.go @@ -7,16 +7,10 @@ import ( "bytes" "errors" "io" - "math" "os" ) -var ( - // ErrInvalidMemorySize occurs if the memory size is not in a valid range - ErrInvalidMemorySize = errors.New("Memory size must be greater 0 and lower math.MaxInt32") - // ErrWriteAfterRead occurs if Write is called after a read operation - ErrWriteAfterRead = errors.New("Write is unsupported after a read operation") -) +var ErrWriteAfterRead = errors.New("write is unsupported after a read operation") // occurs if Write is called after a read operation type readAtSeeker interface { io.ReadSeeker @@ -30,34 +24,17 @@ type FileBackedBuffer struct { maxMemorySize int64 size int64 buffer bytes.Buffer + tempDir string file *os.File reader readAtSeeker } // New creates a file backed buffer with a specific maximum memory size -func New(maxMemorySize int) (*FileBackedBuffer, error) { - if maxMemorySize < 0 || maxMemorySize > math.MaxInt32 { - return nil, ErrInvalidMemorySize - } - +func New(maxMemorySize int, tempDir string) *FileBackedBuffer { return &FileBackedBuffer{ maxMemorySize: int64(maxMemorySize), - }, nil -} - -// CreateFromReader creates a file backed buffer and copies the provided reader data into it. -func CreateFromReader(r io.Reader, maxMemorySize int) (*FileBackedBuffer, error) { - b, err := New(maxMemorySize) - if err != nil { - return nil, err + tempDir: tempDir, } - - _, err = io.Copy(b, r) - if err != nil { - return nil, err - } - - return b, nil } // Write implements io.Writer @@ -73,7 +50,7 @@ func (b *FileBackedBuffer) Write(p []byte) (int, error) { n, err = b.file.Write(p) } else { if b.size+int64(len(p)) > b.maxMemorySize { - b.file, err = os.CreateTemp("", "gitea-buffer-") + b.file, err = os.CreateTemp(b.tempDir, "gitea-buffer-") if err != nil { return 0, err } @@ -148,7 +125,7 @@ func (b *FileBackedBuffer) Seek(offset int64, whence int) (int64, error) { func (b *FileBackedBuffer) Close() error { if b.file != nil { err := b.file.Close() - os.Remove(b.file.Name()) + _ = os.Remove(b.file.Name()) b.file = nil return err } diff --git a/modules/util/filebuffer/file_backed_buffer_test.go b/modules/util/filebuffer/file_backed_buffer_test.go index 16d5a1965f..3f13c6ac7b 100644 --- a/modules/util/filebuffer/file_backed_buffer_test.go +++ b/modules/util/filebuffer/file_backed_buffer_test.go @@ -21,7 +21,8 @@ func TestFileBackedBuffer(t *testing.T) { } for _, c := range cases { - buf, err := CreateFromReader(strings.NewReader(c.Data), c.MaxMemorySize) + buf := New(c.MaxMemorySize, t.TempDir()) + _, err := io.Copy(buf, strings.NewReader(c.Data)) assert.NoError(t, err) assert.EqualValues(t, len(c.Data), buf.Size()) diff --git a/modules/util/legacy_test.go b/modules/util/legacy_test.go index e732094c29..565fb7f284 100644 --- a/modules/util/legacy_test.go +++ b/modules/util/legacy_test.go @@ -17,7 +17,7 @@ import ( func TestCopyFile(t *testing.T) { testContent := []byte("hello") - tmpDir := os.TempDir() + tmpDir := t.TempDir() now := time.Now() srcFile := fmt.Sprintf("%s/copy-test-%d-src.txt", tmpDir, now.UnixMicro()) dstFile := fmt.Sprintf("%s/copy-test-%d-dst.txt", tmpDir, now.UnixMicro()) diff --git a/modules/util/map.go b/modules/util/map.go new file mode 100644 index 0000000000..f307faad1f --- /dev/null +++ b/modules/util/map.go @@ -0,0 +1,13 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package util + +func GetMapValueOrDefault[T any](m map[string]any, key string, defaultValue T) T { + if value, ok := m[key]; ok { + if v, ok := value.(T); ok { + return v + } + } + return defaultValue +} diff --git a/modules/util/map_test.go b/modules/util/map_test.go new file mode 100644 index 0000000000..1a141cec88 --- /dev/null +++ b/modules/util/map_test.go @@ -0,0 +1,26 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetMapValueOrDefault(t *testing.T) { + testMap := map[string]any{ + "key1": "value1", + "key2": 42, + "key3": nil, + } + + assert.Equal(t, "value1", GetMapValueOrDefault(testMap, "key1", "default")) + assert.Equal(t, 42, GetMapValueOrDefault(testMap, "key2", 0)) + + assert.Equal(t, "default", GetMapValueOrDefault(testMap, "key4", "default")) + assert.Equal(t, 100, GetMapValueOrDefault(testMap, "key5", 100)) + + assert.Equal(t, "default", GetMapValueOrDefault(testMap, "key3", "default")) +} diff --git a/modules/util/paginate_test.go b/modules/util/paginate_test.go index 6e69dd19cc..3dc5095071 100644 --- a/modules/util/paginate_test.go +++ b/modules/util/paginate_test.go @@ -13,23 +13,23 @@ func TestPaginateSlice(t *testing.T) { stringSlice := []string{"a", "b", "c", "d", "e"} result, ok := PaginateSlice(stringSlice, 1, 2).([]string) assert.True(t, ok) - assert.EqualValues(t, []string{"a", "b"}, result) + assert.Equal(t, []string{"a", "b"}, result) result, ok = PaginateSlice(stringSlice, 100, 2).([]string) assert.True(t, ok) - assert.EqualValues(t, []string{}, result) + assert.Equal(t, []string{}, result) result, ok = PaginateSlice(stringSlice, 3, 2).([]string) assert.True(t, ok) - assert.EqualValues(t, []string{"e"}, result) + assert.Equal(t, []string{"e"}, result) result, ok = PaginateSlice(stringSlice, 1, 0).([]string) assert.True(t, ok) - assert.EqualValues(t, []string{"a", "b", "c", "d", "e"}, result) + assert.Equal(t, []string{"a", "b", "c", "d", "e"}, result) result, ok = PaginateSlice(stringSlice, 1, -1).([]string) assert.True(t, ok) - assert.EqualValues(t, []string{"a", "b", "c", "d", "e"}, result) + assert.Equal(t, []string{"a", "b", "c", "d", "e"}, result) type Test struct { Val int @@ -38,9 +38,9 @@ func TestPaginateSlice(t *testing.T) { testVar := []*Test{{Val: 2}, {Val: 3}, {Val: 4}} testVar, ok = PaginateSlice(testVar, 1, 50).([]*Test) assert.True(t, ok) - assert.EqualValues(t, []*Test{{Val: 2}, {Val: 3}, {Val: 4}}, testVar) + assert.Equal(t, []*Test{{Val: 2}, {Val: 3}, {Val: 4}}, testVar) testVar, ok = PaginateSlice(testVar, 2, 2).([]*Test) assert.True(t, ok) - assert.EqualValues(t, []*Test{{Val: 4}}, testVar) + assert.Equal(t, []*Test{{Val: 4}}, testVar) } diff --git a/modules/util/path.go b/modules/util/path.go index 1272f5af2e..0e56348978 100644 --- a/modules/util/path.go +++ b/modules/util/path.go @@ -36,9 +36,10 @@ func PathJoinRel(elem ...string) string { elems[i] = path.Clean("/" + e) } p := path.Join(elems...) - if p == "" { + switch p { + case "": return "" - } else if p == "/" { + case "/": return "." } return p[1:] @@ -140,81 +141,51 @@ func IsExist(path string) (bool, error) { return false, err } -func statDir(dirPath, recPath string, includeDir, isDirOnly, followSymlinks bool) ([]string, error) { - dir, err := os.Open(dirPath) +func listDirRecursively(result *[]string, fsDir, recordParentPath string, opts *ListDirOptions) error { + dir, err := os.Open(fsDir) if err != nil { - return nil, err + return err } defer dir.Close() fis, err := dir.Readdir(0) if err != nil { - return nil, err + return err } - statList := make([]string, 0) for _, fi := range fis { - if CommonSkip(fi.Name()) { + if opts.SkipCommonHiddenNames && IsCommonHiddenFileName(fi.Name()) { continue } - - relPath := path.Join(recPath, fi.Name()) - curPath := path.Join(dirPath, fi.Name()) + relPath := path.Join(recordParentPath, fi.Name()) + curPath := filepath.Join(fsDir, fi.Name()) if fi.IsDir() { - if includeDir { - statList = append(statList, relPath+"/") - } - s, err := statDir(curPath, relPath, includeDir, isDirOnly, followSymlinks) - if err != nil { - return nil, err - } - statList = append(statList, s...) - } else if !isDirOnly { - statList = append(statList, relPath) - } else if followSymlinks && fi.Mode()&os.ModeSymlink != 0 { - link, err := os.Readlink(curPath) - if err != nil { - return nil, err - } - - isDir, err := IsDir(link) - if err != nil { - return nil, err + if opts.IncludeDir { + *result = append(*result, relPath+"/") } - if isDir { - if includeDir { - statList = append(statList, relPath+"/") - } - s, err := statDir(curPath, relPath, includeDir, isDirOnly, followSymlinks) - if err != nil { - return nil, err - } - statList = append(statList, s...) + if err = listDirRecursively(result, curPath, relPath, opts); err != nil { + return err } + } else { + *result = append(*result, relPath) } } - return statList, nil + return nil } -// StatDir gathers information of given directory by depth-first. -// It returns slice of file list and includes subdirectories if enabled; -// it returns error and nil slice when error occurs in underlying functions, -// or given path is not a directory or does not exist. -// +type ListDirOptions struct { + IncludeDir bool // subdirectories are also included with suffix slash + SkipCommonHiddenNames bool +} + +// ListDirRecursively gathers information of given directory by depth-first. +// The paths are always in "dir/slash/file" format (not "\\" even in Windows) // Slice does not include given path itself. -// If subdirectories is enabled, they will have suffix '/'. -func StatDir(rootPath string, includeDir ...bool) ([]string, error) { - if isDir, err := IsDir(rootPath); err != nil { +func ListDirRecursively(rootDir string, opts *ListDirOptions) (res []string, err error) { + if err = listDirRecursively(&res, rootDir, "", opts); err != nil { return nil, err - } else if !isDir { - return nil, errors.New("not a directory or does not exist: " + rootPath) - } - - isIncludeDir := false - if len(includeDir) != 0 { - isIncludeDir = includeDir[0] } - return statDir(rootPath, "", isIncludeDir, false, false) + return res, nil } func isOSWindows() bool { @@ -265,8 +236,8 @@ func HomeDir() (home string, err error) { return home, nil } -// CommonSkip will check a provided name to see if it represents file or directory that should not be watched -func CommonSkip(name string) bool { +// IsCommonHiddenFileName will check a provided name to see if it represents file or directory that should not be watched +func IsCommonHiddenFileName(name string) bool { if name == "" { return true } @@ -275,9 +246,9 @@ func CommonSkip(name string) bool { case '.': return true case 't', 'T': - return name[1:] == "humbs.db" + return name[1:] == "humbs.db" // macOS case 'd', 'D': - return name[1:] == "esktop.ini" + return name[1:] == "esktop.ini" // Windows } return false diff --git a/modules/util/path_test.go b/modules/util/path_test.go index 6a38bf4ace..79c37e55f7 100644 --- a/modules/util/path_test.go +++ b/modules/util/path_test.go @@ -5,10 +5,12 @@ package util import ( "net/url" + "os" "runtime" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFileURLToPath(t *testing.T) { @@ -210,3 +212,21 @@ func TestCleanPath(t *testing.T) { assert.Equal(t, c.expected, FilePathJoinAbs(c.elems[0], c.elems[1:]...), "case: %v", c.elems) } } + +func TestListDirRecursively(t *testing.T) { + tmpDir := t.TempDir() + _ = os.WriteFile(tmpDir+"/.config", nil, 0o644) + _ = os.Mkdir(tmpDir+"/d1", 0o755) + _ = os.WriteFile(tmpDir+"/d1/f-d1", nil, 0o644) + _ = os.Mkdir(tmpDir+"/d1/s1", 0o755) + _ = os.WriteFile(tmpDir+"/d1/s1/f-d1s1", nil, 0o644) + _ = os.Mkdir(tmpDir+"/d2", 0o755) + + res, err := ListDirRecursively(tmpDir, &ListDirOptions{IncludeDir: true}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{".config", "d1/", "d1/f-d1", "d1/s1/", "d1/s1/f-d1s1", "d2/"}, res) + + res, err = ListDirRecursively(tmpDir, &ListDirOptions{SkipCommonHiddenNames: true}) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"d1/f-d1", "d1/s1/f-d1s1"}, res) +} diff --git a/modules/util/remove.go b/modules/util/remove.go index d1e38faf5f..3db0b5a796 100644 --- a/modules/util/remove.go +++ b/modules/util/remove.go @@ -15,7 +15,7 @@ const windowsSharingViolationError syscall.Errno = 32 // Remove removes the named file or (empty) directory with at most 5 attempts. func Remove(name string) error { var err error - for i := 0; i < 5; i++ { + for range 5 { err = os.Remove(name) if err == nil { break @@ -44,7 +44,7 @@ func Remove(name string) error { // RemoveAll removes the named file or (empty) directory with at most 5 attempts. func RemoveAll(name string) error { var err error - for i := 0; i < 5; i++ { + for range 5 { err = os.RemoveAll(name) if err == nil { break @@ -73,7 +73,7 @@ func RemoveAll(name string) error { // Rename renames (moves) oldpath to newpath with at most 5 attempts. func Rename(oldpath, newpath string) error { var err error - for i := 0; i < 5; i++ { + for i := range 5 { err = os.Rename(oldpath, newpath) if err == nil { break diff --git a/modules/util/rotatingfilewriter/writer_test.go b/modules/util/rotatingfilewriter/writer_test.go index 88392797b3..f6ea1d50ae 100644 --- a/modules/util/rotatingfilewriter/writer_test.go +++ b/modules/util/rotatingfilewriter/writer_test.go @@ -23,7 +23,7 @@ func TestCompressOldFile(t *testing.T) { ng, err := os.OpenFile(nonGzip, os.O_CREATE|os.O_WRONLY, 0o660) assert.NoError(t, err) - for i := 0; i < 999; i++ { + for range 999 { f.WriteString("This is a test file\n") ng.WriteString("This is a test file\n") } diff --git a/modules/util/runtime_test.go b/modules/util/runtime_test.go index 20f9063b0b..01dd034cea 100644 --- a/modules/util/runtime_test.go +++ b/modules/util/runtime_test.go @@ -18,14 +18,14 @@ func TestCallerFuncName(t *testing.T) { func BenchmarkCallerFuncName(b *testing.B) { // BenchmarkCaller/sprintf-12 12744829 95.49 ns/op b.Run("sprintf", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { _ = fmt.Sprintf("aaaaaaaaaaaaaaaa %s %s %s", "bbbbbbbbbbbbbbbbbbb", b.Name(), "ccccccccccccccccccccc") } }) // BenchmarkCaller/caller-12 10625133 113.6 ns/op // It is almost as fast as fmt.Sprintf b.Run("caller", func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { CallerFuncName(1) } }) diff --git a/modules/util/sec_to_time.go b/modules/util/sec_to_time.go index ad0fb1a68b..646f33c82a 100644 --- a/modules/util/sec_to_time.go +++ b/modules/util/sec_to_time.go @@ -8,61 +8,23 @@ import ( "strings" ) -// SecToTime converts an amount of seconds to a human-readable string. E.g. -// 66s -> 1 minute 6 seconds -// 52410s -> 14 hours 33 minutes -// 563418 -> 6 days 12 hours -// 1563418 -> 2 weeks 4 days -// 3937125s -> 1 month 2 weeks -// 45677465s -> 1 year 6 months -func SecToTime(durationVal any) string { - duration, _ := ToInt64(durationVal) +// SecToHours converts an amount of seconds to a human-readable hours string. +// This is stable for planning and managing timesheets. +// Here it only supports hours and minutes, because a work day could contain 6 or 7 or 8 hours. +// If the duration is less than 1 minute, it will be shown as seconds. +func SecToHours(durationVal any) string { + seconds, _ := ToInt64(durationVal) + hours := seconds / 3600 + minutes := (seconds / 60) % 60 formattedTime := "" - - // The following four variables are calculated by taking - // into account the previously calculated variables, this avoids - // pitfalls when using remainders. As that could lead to incorrect - // results when the calculated number equals the quotient number. - remainingDays := duration / (60 * 60 * 24) - years := remainingDays / 365 - remainingDays -= years * 365 - months := remainingDays * 12 / 365 - remainingDays -= months * 365 / 12 - weeks := remainingDays / 7 - remainingDays -= weeks * 7 - days := remainingDays - - // The following three variables are calculated without depending - // on the previous calculated variables. - hours := (duration / 3600) % 24 - minutes := (duration / 60) % 60 - seconds := duration % 60 - - // Extract only the relevant information of the time - // If the time is greater than a year, it makes no sense to display seconds. - switch { - case years > 0: - formattedTime = formatTime(years, "year", formattedTime) - formattedTime = formatTime(months, "month", formattedTime) - case months > 0: - formattedTime = formatTime(months, "month", formattedTime) - formattedTime = formatTime(weeks, "week", formattedTime) - case weeks > 0: - formattedTime = formatTime(weeks, "week", formattedTime) - formattedTime = formatTime(days, "day", formattedTime) - case days > 0: - formattedTime = formatTime(days, "day", formattedTime) - formattedTime = formatTime(hours, "hour", formattedTime) - case hours > 0: - formattedTime = formatTime(hours, "hour", formattedTime) - formattedTime = formatTime(minutes, "minute", formattedTime) - default: - formattedTime = formatTime(minutes, "minute", formattedTime) - formattedTime = formatTime(seconds, "second", formattedTime) - } + formattedTime = formatTime(hours, "hour", formattedTime) + formattedTime = formatTime(minutes, "minute", formattedTime) // The formatTime() function always appends a space at the end. This will be trimmed + if formattedTime == "" && seconds > 0 { + formattedTime = formatTime(seconds, "second", "") + } return strings.TrimRight(formattedTime, " ") } @@ -76,6 +38,5 @@ func formatTime(value int64, name, formattedTime string) string { } else if value > 1 { formattedTime = fmt.Sprintf("%s%d %ss ", formattedTime, value, name) } - return formattedTime } diff --git a/modules/util/sec_to_time_test.go b/modules/util/sec_to_time_test.go index 4d1213a52c..84e767c6e0 100644 --- a/modules/util/sec_to_time_test.go +++ b/modules/util/sec_to_time_test.go @@ -9,22 +9,20 @@ import ( "github.com/stretchr/testify/assert" ) -func TestSecToTime(t *testing.T) { +func TestSecToHours(t *testing.T) { second := int64(1) minute := 60 * second hour := 60 * minute day := 24 * hour - year := 365 * day - assert.Equal(t, "1 minute 6 seconds", SecToTime(minute+6*second)) - assert.Equal(t, "1 hour", SecToTime(hour)) - assert.Equal(t, "1 hour", SecToTime(hour+second)) - assert.Equal(t, "14 hours 33 minutes", SecToTime(14*hour+33*minute+30*second)) - assert.Equal(t, "6 days 12 hours", SecToTime(6*day+12*hour+30*minute+18*second)) - assert.Equal(t, "2 weeks 4 days", SecToTime((2*7+4)*day+2*hour+16*minute+58*second)) - assert.Equal(t, "4 weeks", SecToTime(4*7*day)) - assert.Equal(t, "4 weeks 1 day", SecToTime((4*7+1)*day)) - assert.Equal(t, "1 month 2 weeks", SecToTime((6*7+3)*day+13*hour+38*minute+45*second)) - assert.Equal(t, "11 months", SecToTime(year-25*day)) - assert.Equal(t, "1 year 5 months", SecToTime(year+163*day+10*hour+11*minute+5*second)) + assert.Equal(t, "1 minute", SecToHours(minute+6*second)) + assert.Equal(t, "1 hour", SecToHours(hour)) + assert.Equal(t, "1 hour", SecToHours(hour+second)) + assert.Equal(t, "14 hours 33 minutes", SecToHours(14*hour+33*minute+30*second)) + assert.Equal(t, "156 hours 30 minutes", SecToHours(6*day+12*hour+30*minute+18*second)) + assert.Equal(t, "98 hours 16 minutes", SecToHours(4*day+2*hour+16*minute+58*second)) + assert.Equal(t, "672 hours", SecToHours(4*7*day)) + assert.Equal(t, "1 second", SecToHours(1)) + assert.Equal(t, "2 seconds", SecToHours(2)) + assert.Empty(t, SecToHours(nil)) // old behavior, empty means no output } diff --git a/modules/util/shellquote_test.go b/modules/util/shellquote_test.go index 969998c592..4ef5ce6980 100644 --- a/modules/util/shellquote_test.go +++ b/modules/util/shellquote_test.go @@ -3,7 +3,11 @@ package util -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestShellEscape(t *testing.T) { tests := []struct { @@ -83,9 +87,7 @@ func TestShellEscape(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := ShellEscape(tt.toEscape); got != tt.want { - t.Errorf("ShellEscape(%q):\nGot: %s\nWanted: %s", tt.toEscape, got, tt.want) - } + assert.Equal(t, tt.want, ShellEscape(tt.toEscape)) }) } } diff --git a/modules/util/slice.go b/modules/util/slice.go index 9c878c24be..aaa729c1c9 100644 --- a/modules/util/slice.go +++ b/modules/util/slice.go @@ -12,8 +12,7 @@ import ( // SliceContainsString sequential searches if string exists in slice. func SliceContainsString(slice []string, target string, insensitive ...bool) bool { if len(insensitive) != 0 && insensitive[0] { - target = strings.ToLower(target) - return slices.ContainsFunc(slice, func(t string) bool { return strings.ToLower(t) == target }) + return slices.ContainsFunc(slice, func(t string) bool { return strings.EqualFold(t, target) }) } return slices.Contains(slice, target) @@ -71,3 +70,10 @@ func KeysOfMap[K comparable, V any](m map[K]V) []K { } return keys } + +func SliceNilAsEmpty[T any](a []T) []T { + if a == nil { + return []T{} + } + return a +} diff --git a/modules/util/string.go b/modules/util/string.go index cf50f591c6..b9b59df3ef 100644 --- a/modules/util/string.go +++ b/modules/util/string.go @@ -3,7 +3,10 @@ package util -import "unsafe" +import ( + "strings" + "unsafe" +) func isSnakeCaseUpper(c byte) bool { return 'A' <= c && c <= 'Z' @@ -95,3 +98,36 @@ func UnsafeBytesToString(b []byte) string { func UnsafeStringToBytes(s string) []byte { return unsafe.Slice(unsafe.StringData(s), len(s)) } + +// SplitTrimSpace splits the string at given separator and trims leading and trailing space +func SplitTrimSpace(input, sep string) []string { + input = strings.TrimSpace(input) + var stringList []string + for s := range strings.SplitSeq(input, sep) { + if s = strings.TrimSpace(s); s != "" { + stringList = append(stringList, s) + } + } + return stringList +} + +func asciiLower(b byte) byte { + if 'A' <= b && b <= 'Z' { + return b + ('a' - 'A') + } + return b +} + +// AsciiEqualFold is from Golang https://cs.opensource.google/go/go/+/refs/tags/go1.24.4:src/net/http/internal/ascii/print.go +// ASCII only. In most cases for protocols, we should only use this but not [strings.EqualFold] +func AsciiEqualFold(s, t string) bool { //nolint:revive // PascalCase + if len(s) != len(t) { + return false + } + for i := 0; i < len(s); i++ { + if asciiLower(s[i]) != asciiLower(t[i]) { + return false + } + } + return true +} diff --git a/modules/util/string_test.go b/modules/util/string_test.go index 0a4a8bbcfb..ff67b5c7d4 100644 --- a/modules/util/string_test.go +++ b/modules/util/string_test.go @@ -45,3 +45,8 @@ func TestToSnakeCase(t *testing.T) { assert.Equal(t, expected, ToSnakeCase(input)) } } + +func TestSplitTrimSpace(t *testing.T) { + assert.Equal(t, []string{"a", "b", "c"}, SplitTrimSpace("a\nb\nc", "\n")) + assert.Equal(t, []string{"a", "b"}, SplitTrimSpace("\r\na\n\r\nb\n\n", "\n")) +} diff --git a/modules/util/time_str.go b/modules/util/time_str.go index 0fccfe82cc..81b132c3db 100644 --- a/modules/util/time_str.go +++ b/modules/util/time_str.go @@ -59,7 +59,7 @@ func TimeEstimateParse(timeStr string) (int64, error) { unit := timeStr[match[4]:match[5]] found := false for _, u := range timeStrGlobalVars().units { - if strings.ToLower(unit) == u.name { + if strings.EqualFold(unit, u.name) { total += amount * u.num found = true break diff --git a/modules/util/truncate.go b/modules/util/truncate.go index f2edbdc673..52534d3cac 100644 --- a/modules/util/truncate.go +++ b/modules/util/truncate.go @@ -5,6 +5,7 @@ package util import ( "strings" + "unicode" "unicode/utf8" ) @@ -14,43 +15,118 @@ const ( asciiEllipsis = "..." ) -// SplitStringAtByteN splits a string at byte n accounting for rune boundaries. (Combining characters are not accounted for.) -func SplitStringAtByteN(input string, n int) (left, right string) { - if len(input) <= n { - return input, "" +func IsLikelyEllipsisLeftPart(s string) bool { + return strings.HasSuffix(s, utf8Ellipsis) || strings.HasSuffix(s, asciiEllipsis) +} + +func ellipsisDisplayGuessWidth(r rune) int { + // To make the truncated string as long as possible, + // CJK/emoji chars are considered as 2-ASCII width but not 3-4 bytes width. + // Here we only make the best guess (better than counting them in bytes), + // it's impossible to 100% correctly determine the width of a rune without a real font and render. + // + // ATTENTION: the guessed width can't be zero, more details in ellipsisDisplayString's comment + if r <= 255 { + return 1 } - if !utf8.ValidString(input) { - if n-3 < 0 { - return input, "" + switch { + case r == '\u3000': /* ideographic (CJK) characters, still use 2 */ + return 2 + case unicode.Is(unicode.M, r), /* (Mark) */ + unicode.Is(unicode.Cf, r), /* (Other, format) */ + unicode.Is(unicode.Cs, r), /* (Other, surrogate) */ + unicode.Is(unicode.Z /* (Space) */, r): + return 1 + default: + return 2 + } +} + +// EllipsisDisplayString returns a truncated short string for display purpose. +// The length is the approximate number of ASCII-width in the string (CJK/emoji are 2-ASCII width) +// It appends "…" or "..." at the end of truncated string. +// It guarantees the length of the returned runes doesn't exceed the limit. +func EllipsisDisplayString(str string, limit int) string { + s, _, _, _ := ellipsisDisplayString(str, limit, ellipsisDisplayGuessWidth) + return s +} + +// EllipsisDisplayStringX works like EllipsisDisplayString while it also returns the right part +func EllipsisDisplayStringX(str string, limit int) (left, right string) { + return ellipsisDisplayStringX(str, limit, ellipsisDisplayGuessWidth) +} + +func ellipsisDisplayStringX(str string, limit int, widthGuess func(rune) int) (left, right string) { + left, offset, truncated, encounterInvalid := ellipsisDisplayString(str, limit, widthGuess) + if truncated { + right = str[offset:] + r, _ := utf8.DecodeRune(UnsafeStringToBytes(right)) + encounterInvalid = encounterInvalid || r == utf8.RuneError + ellipsis := utf8Ellipsis + if encounterInvalid { + ellipsis = asciiEllipsis } - return input[:n-3] + asciiEllipsis, asciiEllipsis + input[n-3:] + right = ellipsis + right } + return left, right +} - end := 0 - for end <= n-3 { - _, size := utf8.DecodeRuneInString(input[end:]) - if end+size > n-3 { +func ellipsisDisplayString(str string, limit int, widthGuess func(rune) int) (res string, offset int, truncated, encounterInvalid bool) { + if len(str) <= limit { + return str, len(str), false, false + } + + // To future maintainers: this logic must guarantee that the length of the returned runes doesn't exceed the limit, + // because the returned string will also be used as database value. UTF-8 VARCHAR(10) could store 10 rune characters, + // So each rune must be countered as at least 1 width. + // Even if there are some special Unicode characters (zero-width, combining, etc.), they should NEVER be counted as zero. + pos, used := 0, 0 + for i, r := range str { + encounterInvalid = encounterInvalid || r == utf8.RuneError + pos = i + runeWidth := widthGuess(r) + if used+runeWidth+3 > limit { break } - end += size + used += runeWidth + offset += utf8.RuneLen(r) } - return input[:end] + utf8Ellipsis, utf8Ellipsis + input[end:] + // if the remaining are fewer than 3 runes, then maybe we could add them, no need to ellipse + if len(str)-pos <= 12 { + var nextCnt, nextWidth int + for _, r := range str[pos:] { + if nextCnt >= 4 { + break + } + nextWidth += widthGuess(r) + nextCnt++ + } + if nextCnt <= 3 && used+nextWidth <= limit { + return str, len(str), false, false + } + } + if limit < 3 { + // if the limit is so small, do not add ellipsis + return str[:offset], offset, true, false + } + ellipsis := utf8Ellipsis + if encounterInvalid { + ellipsis = asciiEllipsis + } + return str[:offset] + ellipsis, offset, true, encounterInvalid } -// SplitTrimSpace splits the string at given separator and trims leading and trailing space -func SplitTrimSpace(input, sep string) []string { - // Trim initial leading & trailing space - input = strings.TrimSpace(input) - // replace CRLF with LF - input = strings.ReplaceAll(input, "\r\n", "\n") +func EllipsisTruncateRunes(str string, limit int) (left, right string) { + return ellipsisDisplayStringX(str, limit, func(r rune) int { return 1 }) +} - var stringList []string - for _, s := range strings.Split(input, sep) { - // trim leading and trailing space - stringList = append(stringList, strings.TrimSpace(s)) +// TruncateRunes returns a truncated string with given rune limit, +// it returns input string if its rune length doesn't exceed the limit. +func TruncateRunes(str string, limit int) string { + if utf8.RuneCountInString(str) < limit { + return str } - - return stringList + return string([]rune(str)[:limit]) } diff --git a/modules/util/truncate_test.go b/modules/util/truncate_test.go index dfe1230fd4..6d71f38c0c 100644 --- a/modules/util/truncate_test.go +++ b/modules/util/truncate_test.go @@ -4,43 +4,128 @@ package util import ( + "fmt" + "strconv" + "strings" "testing" "github.com/stretchr/testify/assert" ) -func TestSplitString(t *testing.T) { - type testCase struct { - input string - n int - leftSub string - ellipsis string +func TestEllipsisGuessDisplayWidth(t *testing.T) { + cases := []struct { + r string + want int + }{ + {r: "a", want: 1}, + {r: "é", want: 1}, + {r: "测", want: 2}, + {r: "⚽", want: 2}, + {r: "☁️", want: 3}, // 2 runes, it has a mark + {r: "\u200B", want: 1}, // ZWSP + {r: "\u3000", want: 2}, // ideographic space } - - test := func(tc []*testCase, f func(input string, n int) (left, right string)) { - for _, c := range tc { - l, r := f(c.input, c.n) - if c.ellipsis != "" { - assert.Equal(t, c.leftSub+c.ellipsis, l, "test split %q at %d, expected leftSub: %q", c.input, c.n, c.leftSub) - assert.Equal(t, c.ellipsis+c.input[len(c.leftSub):], r, "test split %s at %d, expected rightSub: %q", c.input, c.n, c.input[len(c.leftSub):]) - } else { - assert.Equal(t, c.leftSub, l, "test split %q at %d, expected leftSub: %q", c.input, c.n, c.leftSub) - assert.Empty(t, r, "test split %q at %d, expected rightSub: %q", c.input, c.n, "") + for _, c := range cases { + t.Run(c.r, func(t *testing.T) { + w := 0 + for _, r := range c.r { + w += ellipsisDisplayGuessWidth(r) } - } + assert.Equal(t, c.want, w, "hex=% x", []byte(c.r)) + }) } +} + +func TestEllipsisString(t *testing.T) { + cases := []struct { + limit int + + input, left, right string + }{ + {limit: 0, input: "abcde", left: "", right: "…abcde"}, + {limit: 1, input: "abcde", left: "", right: "…abcde"}, + {limit: 2, input: "abcde", left: "", right: "…abcde"}, + {limit: 3, input: "abcde", left: "…", right: "…abcde"}, + {limit: 4, input: "abcde", left: "a…", right: "…bcde"}, + {limit: 5, input: "abcde", left: "abcde", right: ""}, + {limit: 6, input: "abcde", left: "abcde", right: ""}, + {limit: 7, input: "abcde", left: "abcde", right: ""}, - tc := []*testCase{ - {"abc123xyz", 0, "", utf8Ellipsis}, - {"abc123xyz", 1, "", utf8Ellipsis}, - {"abc123xyz", 4, "a", utf8Ellipsis}, - {"啊bc123xyz", 4, "", utf8Ellipsis}, - {"啊bc123xyz", 6, "啊", utf8Ellipsis}, - {"啊bc", 5, "啊bc", ""}, - {"啊bc", 6, "啊bc", ""}, - {"abc\xef\x03\xfe", 3, "", asciiEllipsis}, - {"abc\xef\x03\xfe", 4, "a", asciiEllipsis}, - {"\xef\x03", 1, "\xef\x03", ""}, + // a CJK char or emoji is considered as 2-ASCII width, the ellipsis is 3-ASCII width + {limit: 0, input: "测试文本", left: "", right: "…测试文本"}, + {limit: 1, input: "测试文本", left: "", right: "…测试文本"}, + {limit: 2, input: "测试文本", left: "", right: "…测试文本"}, + {limit: 3, input: "测试文本", left: "…", right: "…测试文本"}, + {limit: 4, input: "测试文本", left: "…", right: "…测试文本"}, + {limit: 5, input: "测试文本", left: "测…", right: "…试文本"}, + {limit: 6, input: "测试文本", left: "测…", right: "…试文本"}, + {limit: 7, input: "测试文本", left: "测试…", right: "…文本"}, + {limit: 8, input: "测试文本", left: "测试文本", right: ""}, + {limit: 9, input: "测试文本", left: "测试文本", right: ""}, + + {limit: 6, input: "测试abc", left: "测…", right: "…试abc"}, + {limit: 7, input: "测试abc", left: "测试abc", right: ""}, // exactly 7-width + {limit: 8, input: "测试abc", left: "测试abc", right: ""}, + + {limit: 7, input: "测abc试啊", left: "测ab…", right: "…c试啊"}, + {limit: 8, input: "测abc试啊", left: "测abc…", right: "…试啊"}, + {limit: 9, input: "测abc试啊", left: "测abc试啊", right: ""}, // exactly 9-width + {limit: 10, input: "测abc试啊", left: "测abc试啊", right: ""}, } - test(tc, SplitStringAtByteN) + for _, c := range cases { + t.Run(fmt.Sprintf("%s(%d)", c.input, c.limit), func(t *testing.T) { + left, right := EllipsisDisplayStringX(c.input, c.limit) + assert.Equal(t, c.left, left, "left") + assert.Equal(t, c.right, right, "right") + }) + } + + t.Run("LongInput", func(t *testing.T) { + left, right := EllipsisDisplayStringX(strings.Repeat("abc", 240), 90) + assert.Equal(t, strings.Repeat("abc", 29)+"…", left) + assert.Equal(t, "…"+strings.Repeat("abc", 211), right) + }) + + t.Run("InvalidUtf8", func(t *testing.T) { + invalidCases := []struct { + limit int + left, right string + }{ + {limit: 0, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"}, + {limit: 1, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"}, + {limit: 2, left: "", right: "...\xef\x03\xfe\xef\x03\xfe"}, + {limit: 3, left: "...", right: "...\xef\x03\xfe\xef\x03\xfe"}, + {limit: 4, left: "...", right: "...\xef\x03\xfe\xef\x03\xfe"}, + {limit: 5, left: "\xef\x03\xfe...", right: "...\xef\x03\xfe"}, + {limit: 6, left: "\xef\x03\xfe\xef\x03\xfe", right: ""}, + {limit: 7, left: "\xef\x03\xfe\xef\x03\xfe", right: ""}, + } + for _, c := range invalidCases { + t.Run(strconv.Itoa(c.limit), func(t *testing.T) { + left, right := EllipsisDisplayStringX("\xef\x03\xfe\xef\x03\xfe", c.limit) + assert.Equal(t, c.left, left, "left") + assert.Equal(t, c.right, right, "right") + }) + } + }) + + t.Run("IsLikelyEllipsisLeftPart", func(t *testing.T) { + assert.True(t, IsLikelyEllipsisLeftPart("abcde…")) + assert.True(t, IsLikelyEllipsisLeftPart("abcde...")) + }) +} + +func TestTruncateRunes(t *testing.T) { + assert.Empty(t, TruncateRunes("", 0)) + assert.Empty(t, TruncateRunes("", 1)) + + assert.Empty(t, TruncateRunes("ab", 0)) + assert.Equal(t, "a", TruncateRunes("ab", 1)) + assert.Equal(t, "ab", TruncateRunes("ab", 2)) + assert.Equal(t, "ab", TruncateRunes("ab", 3)) + + assert.Empty(t, TruncateRunes("测试", 0)) + assert.Equal(t, "测", TruncateRunes("测试", 1)) + assert.Equal(t, "测试", TruncateRunes("测试", 2)) + assert.Equal(t, "测试", TruncateRunes("测试", 3)) } diff --git a/modules/util/util.go b/modules/util/util.go index 1fb4cb21cb..dd8e073888 100644 --- a/modules/util/util.go +++ b/modules/util/util.go @@ -11,21 +11,10 @@ import ( "strconv" "strings" - "code.gitea.io/gitea/modules/optional" - "golang.org/x/text/cases" "golang.org/x/text/language" ) -// OptionalBoolParse get the corresponding optional.Option[bool] of a string using strconv.ParseBool -func OptionalBoolParse(s string) optional.Option[bool] { - v, e := strconv.ParseBool(s) - if e != nil { - return optional.None[bool]() - } - return optional.Some(v) -} - // IsEmptyString checks if the provided string is empty func IsEmptyString(s string) bool { return len(strings.TrimSpace(s)) == 0 @@ -230,6 +219,13 @@ func IfZero[T comparable](v, def T) T { return v } +func IfEmpty[T any](v, def []T) []T { + if len(v) == 0 { + return def + } + return v +} + // OptionalArg helps the "optional argument" in Golang: // // func foo(optArg ...int) { return OptionalArg(optArg) } diff --git a/modules/util/util_test.go b/modules/util/util_test.go index 52b05acc5b..fe4125cdb5 100644 --- a/modules/util/util_test.go +++ b/modules/util/util_test.go @@ -8,8 +8,6 @@ import ( "strings" "testing" - "code.gitea.io/gitea/modules/optional" - "github.com/stretchr/testify/assert" ) @@ -175,19 +173,6 @@ func Test_RandomBytes(t *testing.T) { assert.NotEqual(t, bytes3, bytes4) } -func TestOptionalBoolParse(t *testing.T) { - assert.Equal(t, optional.None[bool](), OptionalBoolParse("")) - assert.Equal(t, optional.None[bool](), OptionalBoolParse("x")) - - assert.Equal(t, optional.Some(false), OptionalBoolParse("0")) - assert.Equal(t, optional.Some(false), OptionalBoolParse("f")) - assert.Equal(t, optional.Some(false), OptionalBoolParse("False")) - - assert.Equal(t, optional.Some(true), OptionalBoolParse("1")) - assert.Equal(t, optional.Some(true), OptionalBoolParse("t")) - assert.Equal(t, optional.Some(true), OptionalBoolParse("True")) -} - // Test case for any function which accepts and returns a single string. type StringTest struct { in, out string @@ -215,7 +200,7 @@ func TestToUpperASCII(t *testing.T) { func BenchmarkToUpper(b *testing.B) { for _, tc := range upperTests { b.Run(tc.in, func(b *testing.B) { - for i := 0; i < b.N; i++ { + for b.Loop() { ToUpperASCII(tc.in) } }) |