diff --git a/internal/paths/glob.go b/internal/paths/glob.go index 05ed633..1de1b94 100644 --- a/internal/paths/glob.go +++ b/internal/paths/glob.go @@ -4,22 +4,37 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" ) -var dirGlobOperator = string(filepath.Separator) + "**" + string(filepath.Separator) +var dirGlobOperator = "**" + +// dirGlobOperatorUseValid returns false if '**' occurs, but '/**/' doesn't and pattern does not start with '**/'. +func dirGlobOperatorUseValid(pattern string) bool { + containsOperator := strings.Contains(pattern, dirGlobOperator) + operatorIsOwnPathSegment := strings.Contains(pattern, string(filepath.Separator)+dirGlobOperator+string(filepath.Separator)) + startsWithOperator := strings.HasPrefix(pattern, dirGlobOperator+string(filepath.Separator)) + + return !containsOperator || (operatorIsOwnPathSegment || startsWithOperator) +} // Glob supports * and ** globbing according to https://phraseapp.com/docs/developers/cli/configuration/#globbing func Glob(pattern string) (matches []string, err error) { pattern = filepath.Clean(pattern) + pattern = escape(pattern) if strings.Count(pattern, dirGlobOperator) > 1 { return nil, fmt.Errorf("invalid pattern '%s': the ** globbing operator may only be used once in a pattern", pattern) } + if !dirGlobOperatorUseValid(pattern) { + return nil, fmt.Errorf("invalid pattern '%s': the ** globbing operator may only be used as path segment on its own, i.e. …/**/… or **/…", pattern) + } + if strings.Contains(pattern, dirGlobOperator) { parts := strings.Split(pattern, dirGlobOperator) - basePattern, endPattern := parts[0], parts[1] + basePattern, endPattern := filepath.Clean(parts[0]), filepath.Clean(parts[1]) baseCandidates, err := filepath.Glob(basePattern) if err != nil { @@ -53,6 +68,16 @@ func Glob(pattern string) (matches []string, err error) { return matches, nil } +// escape escapes characters which filepath.Glob would otherwise handle in a special way (except on Windows...) +func escape(s string) string { + if runtime.GOOS == "windows" { + return s + } + + s = strings.Replace(s, "?", "\\?", -1) + return strings.Replace(s, "[", "\\[", -1) +} + func filter(candidates []string, f func(os.FileInfo) bool) []string { matches := []string{} for _, candidate := range candidates { diff --git a/internal/paths/glob_test.go b/internal/paths/glob_test.go index 4e99273..9ef62bb 100644 --- a/internal/paths/glob_test.go +++ b/internal/paths/glob_test.go @@ -9,12 +9,6 @@ import ( ) func TestGlob(t *testing.T) { - base, err := ioutil.TempDir("", "test-glob_") - defer os.RemoveAll(base) - if err != nil { - t.Error(err) - } - directories := []string{ "foo/bar/baz/asd", "foo/bar/xyz/asd", @@ -28,21 +22,6 @@ func TestGlob(t *testing.T) { "nanana", } - for _, dir := range directories { - err := os.MkdirAll(filepath.Join(base, dir), 0755) - defer os.RemoveAll(filepath.Join(base, dir)) - if err != nil { - t.Error(err) - } - - for _, file := range files { - _, err := os.Create(filepath.Join(base, dir, file)) - if err != nil { - t.Error(err) - } - } - } - tests := map[string][]string{ "**/*.mp3": {}, "foo/*/baz/**/asd/*.yml": {"foo/bar/baz/asd/en.yml", "foo/bar/baz/xyz/asd/en.yml"}, @@ -66,6 +45,80 @@ func TestGlob(t *testing.T) { }, } + testGlob(directories, files, tests, t) +} + +func TestGlob_specialCharacters(t *testing.T) { + directories := []string{ + "locales", + "foo?bar", + "bar[a-z]", + "bla/*", + } + + files := []string{ + "en.yml", + "?", + "_[^x]_.yml", + } + + tests := map[string][]string{ + "**/*.yml": { + "locales/en.yml", + "locales/_[^x]_.yml", + "foo?bar/en.yml", + "foo?bar/_[^x]_.yml", + "bar[a-z]/en.yml", + "bar[a-z]/_[^x]_.yml", + "bla/*/en.yml", + "bla/*/_[^x]_.yml", + }, + "foo?bar/*": { + "foo?bar/en.yml", + "foo?bar/?", + "foo?bar/_[^x]_.yml", + }, + "bar[a*/_[^x*]_.yml": { + "bar[a-z]/_[^x]_.yml", + }, + "bla/\\*/*": { + "bla/*/en.yml", + "bla/*/?", + "bla/*/_[^x]_.yml", + }, + "**/?": { + "locales/?", + "foo?bar/?", + "bar[a-z]/?", + "bla/*/?", + }, + } + + testGlob(directories, files, tests, t) +} + +func testGlob(directories, files []string, tests map[string][]string, t *testing.T) { + base, err := ioutil.TempDir("", "test-glob_") + defer os.RemoveAll(base) + if err != nil { + t.Error(err) + } + + for _, dir := range directories { + err := os.MkdirAll(filepath.Join(base, dir), 0755) + defer os.RemoveAll(filepath.Join(base, dir)) + if err != nil { + t.Error(err) + } + + for _, file := range files { + _, err := os.Create(filepath.Join(base, dir, file)) + if err != nil { + t.Error(err) + } + } + } + for pattern, expected := range tests { matches, err := Glob(filepath.Join(base, pattern)) if err != nil {