diff --git a/tools/ignorefiles/gitignore.go b/tools/ignorefiles/gitignore.go new file mode 100644 index 000000000..98aa10443 --- /dev/null +++ b/tools/ignorefiles/gitignore.go @@ -0,0 +1,174 @@ +package ignorefiles + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" +) + +var _ = fmt.Print + +type GitPattern struct { + only_dirs bool + negated bool + parts []string + matcher func(path string) bool +} + +func (p GitPattern) Match(path string, ftype fs.FileMode) bool { + if p.only_dirs && ftype&fs.ModeDir == 0 { + return false + } + if os.PathSeparator != '/' { + path = strings.ReplaceAll(path, string(os.PathSeparator), "/") + } + return p.matcher(path) +} + +func anchored_single_match(path string, pattern string) bool { + name, _, _ := strings.Cut(path, "/") + matches, err := filepath.Match(pattern, name) + return err == nil && matches +} + +func unanchored_single_match(path string, pattern string) bool { + for path != "" { + var name string + name, path, _ = strings.Cut(path, "/") + matches, err := filepath.Match(pattern, name) + if err != nil { + return false + } + if matches { + return true + } + } + return false +} + +func anchored_simple_match(path string, parts []string) bool { + for ; path != "" && len(parts) > 0; parts = parts[1:] { + var name string + name, path, _ = strings.Cut(path, "/") + if matches, err := filepath.Match(parts[0], name); err != nil || !matches { + return false + } + } + return path == "" && len(parts) == 0 +} + +func anchored_full_match(path string, parts []string) bool { + pos, last := 0, len(parts)-1 + for pos <= last && path != "" { + var name string + name, path, _ = strings.Cut(path, "/") + switch name { + case "**": + for pos+1 < len(parts) && parts[pos+1] == "**" { + pos++ + } + if pos == last { + return true + } + for { + matches, err := filepath.Match(parts[pos], name) + if err != nil { + return false + } + if matches { + return anchored_full_match(path, parts[pos+1:]) + } + if path == "" { + return false + } + name, path, _ = strings.Cut(path, "/") + } + default: + if matches, err := filepath.Match(parts[pos], name); err != nil || !matches { + return false + } + pos++ + } + } + return path == "" && pos > last +} + +// Parse a line from a .gitignore file, see man gitignore for the syntax +func CompileGitIgnoreLine(line string) (ans GitPattern, skipped_line bool) { + // Strip comments + if strings.HasPrefix(line, `#`) { + skipped_line = true + return + } + + // Trim OS-specific carriage returns. + line = strings.TrimRight(line, "\r") + + // Trim trailing spaces unless backslash escaped + for strings.HasSuffix(line, " ") { + if strings.HasSuffix(line, `\ `) { + line = line[:len(line)-2] + " " + break + } + line = line[:len(line)-1] + } + + // Empty lines are ignored + if line == "" { + skipped_line = true + return + } + + // Handle negated (accept) patterns + if line[0] == '!' { + line = line[1:] + ans.negated = true + } + + // Handle leading slash used to escape leading # or ! + if line[0] == '\\' && len(line) > 1 && (line[1] == '#' || line[1] == '!') { + line = line[1:] + } + if strings.HasSuffix(line, "/") { + ans.only_dirs = true + line = strings.TrimRight(line, "/") + if line == "" { + skipped_line = true + return + } + } + starts_with_slash := strings.HasPrefix(line, "/") + if starts_with_slash { + line = strings.TrimLeft(line, "/") + } + ans.parts = strings.Split(line, "/") + if slices.Contains(ans.parts, "") { + ans.parts = slices.DeleteFunc(ans.parts, func(x string) bool { return x == "" }) + } + if len(ans.parts) == 0 { + skipped_line = true + return + } + if len(ans.parts) == 1 { + pattern := ans.parts[0] + if pattern == "**" { + ans.matcher = func(string) bool { return true } + } else { + if starts_with_slash { + ans.matcher = func(path string) bool { return anchored_single_match(path, pattern) } + } else { + ans.matcher = func(path string) bool { return unanchored_single_match(path, pattern) } + } + } + } else { + if slices.Contains(ans.parts, "**") { + ans.matcher = func(path string) bool { return anchored_full_match(path, ans.parts) } + } else { + ans.matcher = func(path string) bool { return anchored_simple_match(path, ans.parts) } + } + } + return +} diff --git a/tools/ignorefiles/gitignore_test.go b/tools/ignorefiles/gitignore_test.go new file mode 100644 index 000000000..202791852 --- /dev/null +++ b/tools/ignorefiles/gitignore_test.go @@ -0,0 +1,81 @@ +package ignorefiles + +import ( + "fmt" + "io/fs" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/kovidgoyal/kitty/tools/utils" +) + +var _ = fmt.Print + +func TestGitignore(t *testing.T) { + for line, expected := range map[string]struct { + skipped, negated, only_dirs bool + parts []string + }{ + "": {skipped: true}, + " ": {skipped: true}, + " ": {skipped: true}, + "/": {skipped: true}, + "//": {skipped: true}, + "# abc": {skipped: true}, + `\!moose \ `: {parts: []string{`!moose `}}, + `\#m\oose `: {parts: []string{`#m\oose`}}, + } { + p, skipped := CompileGitIgnoreLine(line) + if skipped != expected.skipped { + t.Fatalf("skipped: %v != %v for line: %s", expected.skipped, skipped, line) + } + if !skipped { + if p.negated != expected.negated { + t.Fatalf("negated: %v != %v for line: %s", expected.negated, p.negated, line) + } + if p.only_dirs != expected.only_dirs { + t.Fatalf("only_dirs: %v != %v for line: %s", expected.only_dirs, p.only_dirs, line) + } + if diff := cmp.Diff(expected.parts, p.parts); diff != "" { + t.Fatalf("parts not equal for line: %s\n%s", line, diff) + } + } + } + type ptest struct { + path string + expected bool + } + for _, x := range []struct { + line string + tests []ptest + }{ + {"foo", []ptest{ + {"foo", true}, {"x/foo", true}, {"foo/x", true}, + }}, + {"/foo", []ptest{ + {"foo", true}, {"x/foo", false}, + }}, + {"doc/frotz/", []ptest{ + {"doc/frotz/", true}, {"a/doc/frotz/", false}, {"doc/frotz", false}, + }}, + {"frotz/", []ptest{ + {"frotz/", true}, {"a/doc/frotz/", true}, {"doc/frotz", false}, {"frotz/", true}, + }}, + {"foo.*", []ptest{ + {"foo.txt", true}, {"foo", false}, {"a/foo.x", true}, {"foo.", true}, + }}, + } { + p, skipped := CompileGitIgnoreLine(x.line) + if skipped { + t.Fatalf("Unexpectedly failed to compile: %#v", x.line) + } + for _, test := range x.tests { + path := strings.TrimRight(test.path, "/") + ftype := utils.IfElse(len(path) < len(test.path), fs.ModeDir, 0) + if actual := p.Match(path, ftype); actual != test.expected { + t.Fatalf("matched: %v != %v for pattern: %#v and path: %#v", test.expected, actual, x.line, test.path) + } + } + } +}