Skip to content

Commit fd3b781

Browse files
committed
[STEP-2135] Migrate pathutil filters and sortable path to v2
Adds the v1 pathutil filter and sort helpers to v2: - FilterFS walker over fs.FS with FilterFunc(pth, fs.DirEntry) - Factories: Base, Extension, Regexp, Component, ComponentWithExtension, IsDirectory, InDirectory, SkipDirectoryName, DirectoryContainsFile, FileContains - v1-compat adapters FilterPaths([]string) and ListEntries(dir) so existing callers migrate by import-path rename only - Sortable path helpers: SortablePath, NewSortablePath, BySortablePathComponents, SortPathsByComponents, ListPathInDirSortedByComponents
1 parent 24c384d commit fd3b781

6 files changed

Lines changed: 652 additions & 0 deletions

File tree

pathutil/path_filter.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package pathutil
2+
3+
import (
4+
"errors"
5+
"io/fs"
6+
"path"
7+
"regexp"
8+
"slices"
9+
"strings"
10+
)
11+
12+
// FilterFunc decides whether an entry produced by an fs.FS walk should be
13+
// kept. The path is slash-separated and rooted at the fs root ("." for the
14+
// root itself). Returning fs.SkipDir as the error skips the current
15+
// directory's subtree.
16+
type FilterFunc func(pth string, d fs.DirEntry) (bool, error)
17+
18+
// FilterFS walks fsys recursively and returns every path for which all
19+
// filters return true. Paths are slash-separated and relative to fsys.
20+
func FilterFS(fsys fs.FS, filters ...FilterFunc) ([]string, error) {
21+
var filtered []string
22+
23+
err := fs.WalkDir(fsys, ".", func(pth string, d fs.DirEntry, walkErr error) error {
24+
if walkErr != nil {
25+
return walkErr
26+
}
27+
28+
for _, filter := range filters {
29+
matches, err := filter(pth, d)
30+
if err != nil {
31+
return err
32+
}
33+
if !matches {
34+
return nil
35+
}
36+
}
37+
38+
filtered = append(filtered, pth)
39+
return nil
40+
})
41+
if err != nil {
42+
return nil, err
43+
}
44+
return filtered, nil
45+
}
46+
47+
// BaseFilter matches entries whose base name equals base (case-insensitive).
48+
// When allowed is false the match is inverted.
49+
func BaseFilter(base string, allowed bool) FilterFunc {
50+
return func(pth string, _ fs.DirEntry) (bool, error) {
51+
return allowed == strings.EqualFold(path.Base(pth), base), nil
52+
}
53+
}
54+
55+
// ExtensionFilter matches entries whose extension equals ext
56+
// (case-insensitive, leading dot included, e.g. ".txt").
57+
func ExtensionFilter(ext string, allowed bool) FilterFunc {
58+
return func(pth string, _ fs.DirEntry) (bool, error) {
59+
return allowed == strings.EqualFold(path.Ext(pth), ext), nil
60+
}
61+
}
62+
63+
// RegexpFilter matches entries whose path contains a match for pattern.
64+
// The pattern is compiled once when the filter is constructed.
65+
func RegexpFilter(pattern string, allowed bool) FilterFunc {
66+
re := regexp.MustCompile(pattern)
67+
return func(pth string, _ fs.DirEntry) (bool, error) {
68+
return allowed == (re.FindString(pth) != ""), nil
69+
}
70+
}
71+
72+
// ComponentFilter matches entries whose path contains component as one of
73+
// its slash-separated components.
74+
func ComponentFilter(component string, allowed bool) FilterFunc {
75+
return func(pth string, _ fs.DirEntry) (bool, error) {
76+
found := slices.Contains(strings.Split(pth, "/"), component)
77+
return allowed == found, nil
78+
}
79+
}
80+
81+
// ComponentWithExtensionFilter matches entries whose path has at least one
82+
// component with the given extension.
83+
func ComponentWithExtensionFilter(ext string, allowed bool) FilterFunc {
84+
return func(pth string, _ fs.DirEntry) (bool, error) {
85+
found := slices.ContainsFunc(strings.Split(pth, "/"), func(c string) bool {
86+
return path.Ext(c) == ext
87+
})
88+
return allowed == found, nil
89+
}
90+
}
91+
92+
// IsDirectoryFilter matches entries based on whether they are directories.
93+
func IsDirectoryFilter(allowed bool) FilterFunc {
94+
return func(_ string, d fs.DirEntry) (bool, error) {
95+
if d == nil {
96+
return false, errors.New("no directory entry available")
97+
}
98+
return allowed == d.IsDir(), nil
99+
}
100+
}
101+
102+
// InDirectoryFilter matches entries whose direct parent directory equals dir.
103+
func InDirectoryFilter(dir string, allowed bool) FilterFunc {
104+
return func(pth string, _ fs.DirEntry) (bool, error) {
105+
return allowed == (path.Dir(pth) == dir), nil
106+
}
107+
}
108+
109+
// SkipDirectoryNameFilter keeps every non-directory entry. When it encounters
110+
// a directory whose base name matches dirName (case-insensitive) it returns
111+
// fs.SkipDir so the whole subtree is skipped.
112+
func SkipDirectoryNameFilter(dirName string) FilterFunc {
113+
return func(pth string, d fs.DirEntry) (bool, error) {
114+
if d == nil || !d.IsDir() {
115+
return true, nil
116+
}
117+
if strings.EqualFold(path.Base(pth), dirName) {
118+
return false, fs.SkipDir
119+
}
120+
return true, nil
121+
}
122+
}
123+
124+
// DirectoryContainsFileFilter matches directories that contain a regular
125+
// file named fileName. fsys is used to stat the candidate path.
126+
func DirectoryContainsFileFilter(fsys fs.FS, fileName string) FilterFunc {
127+
return func(pth string, d fs.DirEntry) (bool, error) {
128+
if d == nil || !d.IsDir() {
129+
return false, nil
130+
}
131+
info, err := fs.Stat(fsys, path.Join(pth, fileName))
132+
if err != nil {
133+
if errors.Is(err, fs.ErrNotExist) {
134+
return false, nil
135+
}
136+
return false, err
137+
}
138+
return !info.IsDir(), nil
139+
}
140+
}
141+
142+
// FileContainsFilter matches regular files whose contents include content.
143+
// Directories never match.
144+
func FileContainsFilter(fsys fs.FS, content string) FilterFunc {
145+
return func(pth string, d fs.DirEntry) (bool, error) {
146+
if d != nil && d.IsDir() {
147+
return false, nil
148+
}
149+
data, err := fs.ReadFile(fsys, pth)
150+
if err != nil {
151+
return false, err
152+
}
153+
return strings.Contains(string(data), content), nil
154+
}
155+
}

pathutil/path_filter_compat.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package pathutil
2+
3+
import (
4+
"errors"
5+
"io/fs"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
// FilterPaths applies filters to an explicit list of OS paths and returns
11+
// the ones that pass every filter, preserving input order. Each path is
12+
// stat-ed to build an fs.DirEntry for the filter callbacks; a missing path
13+
// surfaces the stat error.
14+
//
15+
// This adapter preserves the v1 go-utils pathutil.FilterPaths signature so
16+
// callers can migrate by import-path rename only. Prefer FilterFS in new code.
17+
func FilterPaths(paths []string, filters ...FilterFunc) ([]string, error) {
18+
var filtered []string
19+
for _, pth := range paths {
20+
info, err := os.Lstat(pth)
21+
if err != nil {
22+
return nil, err
23+
}
24+
d := fs.FileInfoToDirEntry(info)
25+
26+
keep, err := runFilters(pth, d, filters)
27+
if err != nil {
28+
return nil, err
29+
}
30+
if keep {
31+
filtered = append(filtered, pth)
32+
}
33+
}
34+
return filtered, nil
35+
}
36+
37+
// ListEntries lists the immediate children of dir, applies filters, and
38+
// returns the matching entries as absolute paths. It is non-recursive.
39+
//
40+
// This adapter preserves the v1 go-utils pathutil.ListEntries signature so
41+
// callers can migrate by import-path rename only. Prefer FilterFS on an
42+
// fs.FS in new code.
43+
func ListEntries(dir string, filters ...FilterFunc) ([]string, error) {
44+
absDir, err := filepath.Abs(dir)
45+
if err != nil {
46+
return nil, err
47+
}
48+
entries, err := os.ReadDir(absDir)
49+
if err != nil {
50+
return nil, err
51+
}
52+
53+
var filtered []string
54+
for _, entry := range entries {
55+
pth := filepath.Join(absDir, entry.Name())
56+
keep, err := runFilters(pth, entry, filters)
57+
if err != nil {
58+
return nil, err
59+
}
60+
if keep {
61+
filtered = append(filtered, pth)
62+
}
63+
}
64+
return filtered, nil
65+
}
66+
67+
// runFilters evaluates filters in order. fs.SkipDir from a filter is
68+
// treated as "exclude this entry" rather than a walk directive, because the
69+
// compat adapters do not walk a tree.
70+
func runFilters(pth string, d fs.DirEntry, filters []FilterFunc) (bool, error) {
71+
for _, filter := range filters {
72+
matches, err := filter(pth, d)
73+
if err != nil {
74+
if errors.Is(err, fs.SkipDir) {
75+
return false, nil
76+
}
77+
return false, err
78+
}
79+
if !matches {
80+
return false, nil
81+
}
82+
}
83+
return true, nil
84+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package pathutil
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func writeTempTree(t *testing.T) string {
12+
t.Helper()
13+
14+
root := t.TempDir()
15+
16+
files := map[string]string{
17+
"README.md": "# readme",
18+
"main.go": "package main",
19+
"main_test.go": "package main",
20+
"sub/note.txt": "todo",
21+
"sub/nested/deep.txt": "x",
22+
"vendor/pkg/lib.go": "package pkg",
23+
}
24+
for rel, content := range files {
25+
full := filepath.Join(root, rel)
26+
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
27+
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
28+
}
29+
return root
30+
}
31+
32+
func TestFilterPaths(t *testing.T) {
33+
root := writeTempTree(t)
34+
paths := []string{
35+
filepath.Join(root, "README.md"),
36+
filepath.Join(root, "main.go"),
37+
filepath.Join(root, "main_test.go"),
38+
filepath.Join(root, "sub"),
39+
}
40+
41+
got, err := FilterPaths(paths, ExtensionFilter(".go", true), IsDirectoryFilter(false))
42+
require.NoError(t, err)
43+
require.ElementsMatch(t, []string{
44+
filepath.Join(root, "main.go"),
45+
filepath.Join(root, "main_test.go"),
46+
}, got)
47+
}
48+
49+
func TestFilterPaths_missingPathErrors(t *testing.T) {
50+
_, err := FilterPaths([]string{"/does/not/exist/ever"}, IsDirectoryFilter(false))
51+
require.Error(t, err)
52+
}
53+
54+
func TestFilterPaths_empty(t *testing.T) {
55+
got, err := FilterPaths(nil, ExtensionFilter(".go", true))
56+
require.NoError(t, err)
57+
require.Empty(t, got)
58+
}
59+
60+
func TestListEntries(t *testing.T) {
61+
root := writeTempTree(t)
62+
63+
got, err := ListEntries(root, IsDirectoryFilter(false))
64+
require.NoError(t, err)
65+
require.ElementsMatch(t, []string{
66+
filepath.Join(root, "README.md"),
67+
filepath.Join(root, "main.go"),
68+
filepath.Join(root, "main_test.go"),
69+
}, got, "ListEntries must be non-recursive")
70+
}
71+
72+
func TestListEntries_onlyDirs(t *testing.T) {
73+
root := writeTempTree(t)
74+
75+
got, err := ListEntries(root, IsDirectoryFilter(true))
76+
require.NoError(t, err)
77+
require.ElementsMatch(t, []string{
78+
filepath.Join(root, "sub"),
79+
filepath.Join(root, "vendor"),
80+
}, got)
81+
}

0 commit comments

Comments
 (0)