Files
findr/gitignore.odin
2026-06-17 11:45:33 -04:00

192 lines
3.7 KiB
Odin

package findr
import "core:fmt"
import "core:strings"
import "core:text/regex"
// FIXME: Use a const bit_set[0..<128; u128] here when we start doing optimizations
is_regex_meta :: proc(c: u8) -> bool {
switch c {
case '.', '+', '(', ')', '{', '}', '^', '$', '|', '#':
return true
}
return false
}
glob_to_regex :: proc(pattern: string, anchored: bool) -> string {
// TODO: Attempt to pre-allocate the string builder when we start doing optimizations
sb: strings.Builder
strings.builder_init(&sb)
defer strings.builder_destroy(&sb)
if anchored {
fmt.sbprintf(&sb, "^")
} else {
fmt.sbprintf(&sb, "(^|/)")
}
i := 0
for i < len(pattern) {
c := pattern[i]
if c == '*' {
if i + 1 < len(pattern) && pattern[i + 1] == '*' {
prev_slash := i == 0 || pattern[i - 1] == '/'
at_end := i + 2 >= len(pattern)
next_slash := !at_end && pattern[i + 2] == '/'
if prev_slash && (next_slash || at_end) {
if next_slash {
i += 3
fmt.sbprintf(&sb, "(.*/)?")
} else {
i += 2
fmt.sbprintf(&sb, ".*")
}
} else {
fmt.sbprintf(&sb, "[^/]*")
i += 2
}
} else {
fmt.sbprintf(&sb, "[^/]*")
i += 1
}
} else if c == '?' {
fmt.sbprintf(&sb, "[^/]")
i += 1
} else if c == '[' {
append(&sb.buf, '[')
i += 1
if i < len(pattern) && pattern[i] == '!' {
append(&sb.buf, '^')
i += 1
}
if i < len(pattern) && pattern[i] == ']' {
append(&sb.buf, ']')
i += 1
}
for i < len(pattern) && pattern[i] != ']' {
append(&sb.buf, pattern[i])
i += 1
}
if i < len(pattern) {
append(&sb.buf, ']')
i += 1
}
} else if c == '\\' {
i += 1
if i < len(pattern) {
if is_regex_meta(pattern[i]) {
append(&sb.buf, '\\')
}
append(&sb.buf, pattern[i])
i += 1
}
} else if is_regex_meta(c) {
append(&sb.buf, '\\')
append(&sb.buf, c)
i += 1
} else {
append(&sb.buf, c)
i += 1
}
}
fmt.sbprintf(&sb, "(/.*)?$")
s := strings.to_string(sb)
result, _ := strings.clone(s)
return result
}
Rule :: struct {
regex: regex.Regular_Expression,
negated: bool,
dir_only: bool,
}
Gitignore :: struct {
rules: [dynamic]Rule,
}
parse :: proc(content: string) -> Gitignore {
gi: Gitignore
gi.rules = make([dynamic]Rule)
remaining := content
for {
line, ok := strings.split_lines_iterator(&remaining)
if !ok do break
s := strings.trim_space(line)
if len(s) == 0 do continue
if s[0] == '#' do continue
negated := false
if s[0] == '!' {
negated = true
s = s[1:]
}
if len(s) > 0 && s[0] == '\\' {
if len(s) > 1 && (s[1] == '#' || s[1] == '!') {
s = s[1:]
}
}
dir_only := false
if len(s) > 0 && s[len(s) - 1] == '/' {
dir_only = true
s = s[:len(s) - 1]
}
anchored := false
if len(s) > 0 && s[0] == '/' {
anchored = true
s = s[1:]
}
if len(s) == 0 do continue
regex_str := glob_to_regex(s, anchored)
re, err := regex.create(regex_str, {regex.Flag.No_Capture})
delete(regex_str)
if err != nil do continue
append(&gi.rules, Rule{regex = re, negated = negated, dir_only = dir_only})
}
return gi
}
Match :: enum {
None,
Ignored,
Unignored,
}
check_match :: proc(gi: ^Gitignore, path: string, is_dir: bool) -> Match {
result := Match.None
for rule in gi.rules {
if rule.dir_only && !is_dir do continue
cap, ok := regex.match(rule.regex, path)
regex.destroy(cap)
if ok {
result = rule.negated ? .Unignored : .Ignored
}
}
return result
}
is_ignored :: proc(gi: ^Gitignore, path: string, is_dir: bool) -> bool {
return check_match(gi, path, is_dir) == .Ignored
}
destroy :: proc(gi: ^Gitignore) {
for rule in gi.rules {
regex.destroy(rule.regex)
}
delete(gi.rules)
}