fileutil.go 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. package fsext
  2. import (
  3. "fmt"
  4. "os"
  5. "path/filepath"
  6. "sort"
  7. "strings"
  8. "time"
  9. "github.com/bmatcuk/doublestar/v4"
  10. "github.com/charlievieth/fastwalk"
  11. ignore "github.com/sabhiram/go-gitignore"
  12. )
  13. type FileInfo struct {
  14. Path string
  15. ModTime time.Time
  16. }
  17. func SkipHidden(path string) bool {
  18. // Check for hidden files (starting with a dot)
  19. base := filepath.Base(path)
  20. if base != "." && strings.HasPrefix(base, ".") {
  21. return true
  22. }
  23. commonIgnoredDirs := map[string]bool{
  24. ".crush": true,
  25. "node_modules": true,
  26. "vendor": true,
  27. "dist": true,
  28. "build": true,
  29. "target": true,
  30. ".git": true,
  31. ".idea": true,
  32. ".vscode": true,
  33. "__pycache__": true,
  34. "bin": true,
  35. "obj": true,
  36. "out": true,
  37. "coverage": true,
  38. "logs": true,
  39. "generated": true,
  40. "bower_components": true,
  41. "jspm_packages": true,
  42. }
  43. parts := strings.SplitSeq(path, string(os.PathSeparator))
  44. for part := range parts {
  45. if commonIgnoredDirs[part] {
  46. return true
  47. }
  48. }
  49. return false
  50. }
  51. // FastGlobWalker provides gitignore-aware file walking with fastwalk
  52. type FastGlobWalker struct {
  53. gitignore *ignore.GitIgnore
  54. crushignore *ignore.GitIgnore
  55. rootPath string
  56. }
  57. func NewFastGlobWalker(searchPath string) *FastGlobWalker {
  58. walker := &FastGlobWalker{
  59. rootPath: searchPath,
  60. }
  61. // Load gitignore if it exists
  62. gitignorePath := filepath.Join(searchPath, ".gitignore")
  63. if _, err := os.Stat(gitignorePath); err == nil {
  64. if gi, err := ignore.CompileIgnoreFile(gitignorePath); err == nil {
  65. walker.gitignore = gi
  66. }
  67. }
  68. // Load crushignore if it exists
  69. crushignorePath := filepath.Join(searchPath, ".crushignore")
  70. if _, err := os.Stat(crushignorePath); err == nil {
  71. if ci, err := ignore.CompileIgnoreFile(crushignorePath); err == nil {
  72. walker.crushignore = ci
  73. }
  74. }
  75. return walker
  76. }
  77. // ShouldSkip checks if a path should be skipped based on gitignore, crushignore, and hidden file rules
  78. func (w *FastGlobWalker) ShouldSkip(path string) bool {
  79. if SkipHidden(path) {
  80. return true
  81. }
  82. relPath, err := filepath.Rel(w.rootPath, path)
  83. if err != nil {
  84. return false
  85. }
  86. if w.gitignore != nil {
  87. if w.gitignore.MatchesPath(relPath) {
  88. return true
  89. }
  90. }
  91. if w.crushignore != nil {
  92. if w.crushignore.MatchesPath(relPath) {
  93. return true
  94. }
  95. }
  96. return false
  97. }
  98. func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, error) {
  99. walker := NewFastGlobWalker(searchPath)
  100. var matches []FileInfo
  101. conf := fastwalk.Config{
  102. Follow: true,
  103. // Use forward slashes when running a Windows binary under WSL or MSYS
  104. ToSlash: fastwalk.DefaultToSlash(),
  105. Sort: fastwalk.SortFilesFirst,
  106. }
  107. err := fastwalk.Walk(&conf, searchPath, func(path string, d os.DirEntry, err error) error {
  108. if err != nil {
  109. return nil // Skip files we can't access
  110. }
  111. if d.IsDir() {
  112. if walker.ShouldSkip(path) {
  113. return filepath.SkipDir
  114. }
  115. return nil
  116. }
  117. if walker.ShouldSkip(path) {
  118. return nil
  119. }
  120. // Check if path matches the pattern
  121. relPath, err := filepath.Rel(searchPath, path)
  122. if err != nil {
  123. relPath = path
  124. }
  125. matched, err := doublestar.Match(pattern, relPath)
  126. if err != nil || !matched {
  127. return nil
  128. }
  129. info, err := d.Info()
  130. if err != nil {
  131. return nil
  132. }
  133. matches = append(matches, FileInfo{Path: path, ModTime: info.ModTime()})
  134. if limit > 0 && len(matches) >= limit*2 {
  135. return filepath.SkipAll
  136. }
  137. return nil
  138. })
  139. if err != nil {
  140. return nil, false, fmt.Errorf("fastwalk error: %w", err)
  141. }
  142. sort.Slice(matches, func(i, j int) bool {
  143. return matches[i].ModTime.After(matches[j].ModTime)
  144. })
  145. truncated := false
  146. if limit > 0 && len(matches) > limit {
  147. matches = matches[:limit]
  148. truncated = true
  149. }
  150. results := make([]string, len(matches))
  151. for i, m := range matches {
  152. results[i] = m.Path
  153. }
  154. return results, truncated, nil
  155. }
  156. func PrettyPath(path string) string {
  157. // replace home directory with ~
  158. homeDir, err := os.UserHomeDir()
  159. if err == nil {
  160. path = strings.ReplaceAll(path, homeDir, "~")
  161. }
  162. return path
  163. }
  164. func DirTrim(pwd string, lim int) string {
  165. var (
  166. out string
  167. sep = string(filepath.Separator)
  168. )
  169. dirs := strings.Split(pwd, sep)
  170. if lim > len(dirs)-1 || lim <= 0 {
  171. return pwd
  172. }
  173. for i := len(dirs) - 1; i > 0; i-- {
  174. out = sep + out
  175. if i == len(dirs)-1 {
  176. out = dirs[i]
  177. } else if i >= len(dirs)-lim {
  178. out = string(dirs[i][0]) + out
  179. } else {
  180. out = "..." + out
  181. break
  182. }
  183. }
  184. out = filepath.Join("~", out)
  185. return out
  186. }
  187. // PathOrPrefix returns the prefix if the path starts with it, or falls back to
  188. // the path otherwise.
  189. func PathOrPrefix(path, prefix string) string {
  190. if HasPrefix(path, prefix) {
  191. return prefix
  192. }
  193. return path
  194. }
  195. // HasPrefix checks if the given path starts with the specified prefix.
  196. // Uses filepath.Rel to determine if path is within prefix.
  197. func HasPrefix(path, prefix string) bool {
  198. rel, err := filepath.Rel(prefix, path)
  199. if err != nil {
  200. return false
  201. }
  202. // If path is within prefix, Rel will not return a path starting with ".."
  203. return !strings.HasPrefix(rel, "..")
  204. }