From c346735c7445a7db6331d3c38f7fc10fc10df1eb Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 8 Jul 2025 12:03:58 +0530 Subject: [PATCH] Finish up gitignore implementation --- tools/ignorefiles/api.go | 25 ++++++++ tools/ignorefiles/gitignore.go | 98 ++++++++++++++++++++++++++--- tools/ignorefiles/gitignore_test.go | 44 +++++++++++++ 3 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 tools/ignorefiles/api.go diff --git a/tools/ignorefiles/api.go b/tools/ignorefiles/api.go new file mode 100644 index 000000000..d91a6038a --- /dev/null +++ b/tools/ignorefiles/api.go @@ -0,0 +1,25 @@ +package ignorefiles + +import ( + "fmt" + "io" + "io/fs" +) + +var _ = fmt.Print + +type IgnoreFile interface { + LoadString(string) error + LoadBytes([]byte) error + LoadLines(...string) error + LoadFile(io.Reader) error + LoadPath(string) error + + // relpath is the path relative to the directory containing the ignorefile. + // When is_ignored is true, linenum_of_matching_rule will be the line + // number of the rule causing relpath to be ignored and pattern is the + // textual representation of the matching pattern. + IsIgnored(relpath string, ftype fs.FileMode) (is_ignored bool, linenum_of_matching_rule int, pattern string) +} + +func NewGitignore() IgnoreFile { return &Gitignore{index_of_last_negated_rule: -1} } diff --git a/tools/ignorefiles/gitignore.go b/tools/ignorefiles/gitignore.go index 13d8b9910..85af292b0 100644 --- a/tools/ignorefiles/gitignore.go +++ b/tools/ignorefiles/gitignore.go @@ -2,29 +2,112 @@ package ignorefiles import ( "fmt" + "io" "io/fs" "os" "path/filepath" "slices" "strings" + + "github.com/kovidgoyal/kitty/tools/utils" ) var _ = fmt.Print type GitPattern struct { - only_dirs bool - negated bool - parts []string - matcher func(path string) bool + line_number int + only_dirs bool + negated bool + pattern string + parts []string + matcher func(path string) bool +} + +type Gitignore struct { + patterns []GitPattern + index_of_last_negated_rule int + line_number_offset int +} + +func (g Gitignore) IsIgnored(relpath string, ftype os.FileMode) (is_ignored bool, linenum_of_matching_rule int, pattern string) { + if os.PathSeparator != '/' { + relpath = strings.ReplaceAll(relpath, string(os.PathSeparator), "/") + } + linenum_of_matching_rule = -1 + for i, pat := range g.patterns { + if is_ignored { + if i > g.index_of_last_negated_rule { + break + } + if pat.negated && pat.Match(relpath, ftype) { + is_ignored = false + linenum_of_matching_rule = -1 + pattern = "" + } + } else { + if !pat.negated && pat.Match(relpath, ftype) { + is_ignored = true + linenum_of_matching_rule = pat.line_number + pattern = pat.pattern + } + } + } + return +} + +func (g *Gitignore) load_line(line string, line_number int) { + if p, skipped_line := CompileGitIgnoreLine(line); !skipped_line { + p.line_number = g.line_number_offset + line_number + g.patterns = append(g.patterns, p) + if p.negated { + g.index_of_last_negated_rule = len(g.patterns) - 1 + } + } +} + +func (g *Gitignore) LoadLines(lines ...string) error { + for i, line := range lines { + g.load_line(line, i) + } + g.line_number_offset += len(lines) + return nil +} + +func (g *Gitignore) LoadString(text string) error { + s := utils.NewLineScanner(text) + lnum := 0 + for s.Scan() { + g.load_line(s.Text(), lnum) + lnum++ + } + g.line_number_offset += lnum + return nil +} + +func (g *Gitignore) LoadBytes(text []byte) error { + return g.LoadString(string(text)) +} + +func (g *Gitignore) LoadPath(path string) error { + if data, err := os.ReadFile(path); err == nil { + return g.LoadString(utils.UnsafeBytesToString(data)) + } else { + return err + } +} + +func (g *Gitignore) LoadFile(f io.Reader) error { + if data, err := io.ReadAll(f); err == nil { + return g.LoadString(utils.UnsafeBytesToString(data)) + } else { + return err + } } 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) } @@ -122,6 +205,7 @@ func CompileGitIgnoreLine(line string) (ans GitPattern, skipped_line bool) { skipped_line = true return } + ans.pattern = line // Handle negated (accept) patterns if line[0] == '!' { diff --git a/tools/ignorefiles/gitignore_test.go b/tools/ignorefiles/gitignore_test.go index 8276a4c56..b5a03944b 100644 --- a/tools/ignorefiles/gitignore_test.go +++ b/tools/ignorefiles/gitignore_test.go @@ -98,4 +98,48 @@ func TestGitignore(t *testing.T) { } } } + for text, tests := range map[string]map[string]bool{ + ``: {"foo": false}, + ` +# exclude everything except directory foo/bar +/* +!/foo +/foo/* +!/foo/bar`: { + "a": true, "foo": false, "foo/x": true, "foo/bar": false, "foo/bar/": false, + }, + ` +**/foo +bar `: { + "foo": true, "baz/foo": true, "bar": true, "baz/bar": true, "a": false, + }, + `/*.c`: {"a.c": true, "b/a.c": false}, + ` +**/external/**/*.json +**/external/**/.*ignore +**/external/foobar/*.css`: { + "external/foobar/angular.foo.css": true, "external/barfoo/.gitignore": true, "external/barfoo/.bower.json": true, + }, + "abc/def\r\nxyz": {"abc/def": true, "a/xyz": true}, + `/**/foo`: {"foo": true, "foo/": true, "a/b/foo": true, "fooo": false, "ofoo": false}, + "/.js": {".js": true, ".js/": true, ".js/a": true, ".jsa": false}, + "*.js": {".js": true, ".js/": true, ".js/a": true, "a.js/a": true, "a.js/a.js": true, ".jsa": false, "a.jsa": false}, + "foo/**/": {"foo/": false, "foo": false, "foo/abc/": true, "foo/a/b/c/": true, "foo/a": false}, + "foo/**/*.bar": {"foo/": false, "abc.bar": false, "foo/abc.bar": true, "foo/a.bar/": true, "foo/x/y/z.bar": true}, + `\#abc`: {"abc": false, "#abc": true}, + "abc\n!abc/x": {"abc": true, "abc/x": false, "abc/y": true}, + `abc/*`: {"abc": false, "abc/": false, "abc/x": true}, + } { + p := NewGitignore() + if err := p.LoadString(text); err != nil { + t.Fatal(err) + } + for tpath, expected := range tests { + path := strings.TrimRight(tpath, "/") + ftype := utils.IfElse(len(path) < len(tpath), fs.ModeDir, 0) + if actual, _, _ := p.IsIgnored(path, ftype); actual != expected { + t.Fatalf("ignored: %v != %v for path: %#v and ignorefile:\n%s", expected, actual, tpath, text) + } + } + } }