Start work on gitignore parsing
This commit is contained in:
parent
b19b38f99e
commit
bde347457c
2 changed files with 255 additions and 0 deletions
174
tools/ignorefiles/gitignore.go
Normal file
174
tools/ignorefiles/gitignore.go
Normal file
|
|
@ -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
|
||||
}
|
||||
81
tools/ignorefiles/gitignore_test.go
Normal file
81
tools/ignorefiles/gitignore_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue