package ignorefiles import ( "fmt" "io" "io/fs" "os" "path/filepath" "slices" "strings" "github.com/kovidgoyal/kitty/tools/utils" ) var _ = fmt.Print type GitPattern struct { 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) Len() int { return len(g.patterns) } 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 = pat.line_number pattern = pat.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 } 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 parts[pos] { case "**": for pos+1 < len(parts) && parts[pos+1] == "**" { pos++ } if pos == last { return true } pos++ 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 } ans.pattern = line // 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 } func get_global_gitconfig_excludesfile() (ans string) { return _get_global_gitconfig_excludesfile(utils.Expanduser("~/.gitconfig")) } func _get_global_gitconfig_excludesfile(home_gitconfig string) (ans string) { cfhome := os.Getenv("XDG_CONFIG_HOME") if cfhome == "" { cfhome = utils.Expanduser("~/.config") } for _, candidate := range []string{"/etc/gitconfig", filepath.Join(cfhome, "git", "config"), home_gitconfig} { if data, err := os.ReadFile(candidate); err == nil { s := utils.NewLineScanner(utils.UnsafeBytesToString(data)) in_core := false for s.Scan() { line := strings.TrimSpace(s.Text()) if in_core { if strings.HasPrefix(line, "[") { in_core = line == "[core]" continue } if k, rest, found := strings.Cut(line, "="); found && strings.ToLower(strings.TrimSpace(k)) == `excludesfile` { ans = strings.TrimSpace(rest) ans = utils.Expanduser(ans) if !filepath.IsAbs(ans) { if a, err := filepath.Abs(ans); err != nil { ans = a } } } } else if strings.ToLower(line) == "[core]" { in_core = true } } } } if ans == "" { ans = filepath.Join(cfhome, "git", "ignore") } return } func get_global_gitignore() (ans IgnoreFile) { excludesfile := get_global_gitconfig_excludesfile() if data, err := os.ReadFile(excludesfile); err == nil { q := NewGitignore() if q.LoadString(utils.UnsafeBytesToString(data)) == nil { ans = q } } return }