From 05ef638d51063d8e42bde15a1e7c4a588c45fdd1 Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Wed, 17 Jun 2026 10:58:01 -0400 Subject: [PATCH] wip: "full" finder --- findr_test.odin | 63 ++++++++++++++++++++++++++++++++++++++++++ gitignore.odin | 21 ++++++++------ gitignore_test.odin | 26 ++++++++++++++++++ walker.odin | 67 +++++++++++++++++++++++++++++---------------- 4 files changed, 144 insertions(+), 33 deletions(-) diff --git a/findr_test.odin b/findr_test.odin index 74fc11a..15aa34d 100644 --- a/findr_test.odin +++ b/findr_test.odin @@ -1,6 +1,8 @@ package findr import "core:os" +import "core:strings" +import "core:sys/linux" import "core:testing" // ============================================================================ @@ -364,3 +366,64 @@ test_no_hidden_skips_dotfiles :: proc(t: ^testing.T) { {"repo/secrets.env"}, ) } + +// ============================================================================ +// Special file type tests (SOCK, FIFO, CHR, BLK parity with fd) +// ============================================================================ + +@(test) +test_fifo_emitted :: proc(t: ^testing.T) { + env := create_test_env() + defer destroy_test_env(&env) + + create_git_repo(env, "repo") + create_file(env, "repo/.gitignore", "*.env\n") + + fifo_path := join_path(env.temp_dir, "repo/test.fifo") + defer delete(fifo_path) + cpath := strings.clone_to_cstring(fifo_path) + defer delete(cpath) + linux.mknod(cpath, linux.S_IFIFO | linux.Mode{.IRUSR, .IWUSR}, 0) + + assert_output(t, env, nil, + {include_hidden = true, ignore_mode = .All}, + {"repo/", "repo/.gitignore", "repo/test.fifo"}, + ) +} + +// ============================================================================ +// .ignore file support tests (fd respects .ignore in addition to .gitignore) +// ============================================================================ + +@(test) +test_ignore_file_respected :: proc(t: ^testing.T) { + env := create_test_env() + defer destroy_test_env(&env) + + create_git_repo(env, "repo") + create_file(env, "repo/.ignore", "*.tmp\n") + create_file(env, "repo/file.tmp") + create_file(env, "repo/file.txt") + + assert_output(t, env, nil, + {include_hidden = true, ignore_mode = .Respected}, + {"repo/", "repo/.ignore", "repo/file.txt"}, + ) +} + +@(test) +test_ignore_overrides_gitignore :: proc(t: ^testing.T) { + env := create_test_env() + defer destroy_test_env(&env) + + create_git_repo(env, "repo") + create_file(env, "repo/.gitignore", "*.log\n") + create_file(env, "repo/.ignore", "important.log\n") + create_file(env, "repo/debug.log") + create_file(env, "repo/important.log") + + assert_output(t, env, nil, + {include_hidden = true, ignore_mode = .Respected}, + {"repo/", "repo/.gitignore", "repo/.ignore"}, + ) +} diff --git a/gitignore.odin b/gitignore.odin index 6af5536..3c3c98e 100644 --- a/gitignore.odin +++ b/gitignore.odin @@ -4,15 +4,17 @@ 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 '.', '+', '(', ')', '{', '}', '^', '$', '|': + 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) @@ -98,8 +100,8 @@ glob_to_regex :: proc(pattern: string, anchored: bool) -> string { } Rule :: struct { - regex: regex.Regular_Expression, - negated: bool, + regex: regex.Regular_Expression, + negated: bool, dir_only: bool, } @@ -151,17 +153,17 @@ parse :: proc(content: string) -> Gitignore { delete(regex_str) if err != nil do continue - append(&gi.rules, Rule{ - regex = re, - negated = negated, - dir_only = dir_only, - }) + append(&gi.rules, Rule{regex = re, negated = negated, dir_only = dir_only}) } return gi } -Match :: enum { None, Ignored, Unignored } +Match :: enum { + None, + Ignored, + Unignored, +} check_match :: proc(gi: ^Gitignore, path: string, is_dir: bool) -> Match { result := Match.None @@ -186,3 +188,4 @@ destroy :: proc(gi: ^Gitignore) { } delete(gi.rules) } + diff --git a/gitignore_test.odin b/gitignore_test.odin index db36aa4..3db97d5 100644 --- a/gitignore_test.odin +++ b/gitignore_test.odin @@ -79,6 +79,20 @@ test_glob_backslash_escape :: proc(t: ^testing.T) { testing.expect_value(t, result, "(^|/)!foo(/.*)?$") } +@(test) +test_glob_hash_escaped :: proc(t: ^testing.T) { + result := glob_to_regex("#foo", false) + defer delete(result) + testing.expect_value(t, result, "(^|/)\\#foo(/.*)?$") +} + +@(test) +test_glob_hash_in_pattern :: proc(t: ^testing.T) { + result := glob_to_regex("#*#", false) + defer delete(result) + testing.expect_value(t, result, "(^|/)\\#[^/]*\\#(/.*)?$") +} + @(test) test_glob_empty :: proc(t: ^testing.T) { result := glob_to_regex("", false) @@ -176,3 +190,15 @@ test_is_ignored_globstar :: proc(t: ^testing.T) { testing.expect_value(t, is_ignored(&gi, "foo/bar/cache", false), true) } +@(test) +test_is_ignored_hash_pattern :: proc(t: ^testing.T) { + gi := parse("\\#*\\#\n") + defer destroy(&gi) + + testing.expect_value(t, is_ignored(&gi, "#foo#", false), true) + testing.expect_value(t, is_ignored(&gi, "#test#", false), true) + testing.expect_value(t, is_ignored(&gi, "AUTHORS", false), false) + testing.expect_value(t, is_ignored(&gi, "build.zig", false), false) + testing.expect_value(t, is_ignored(&gi, "ChangeLog", false), false) +} + diff --git a/walker.odin b/walker.odin index fdddfd5..4ad7129 100644 --- a/walker.odin +++ b/walker.odin @@ -181,22 +181,20 @@ process_dir :: proc(pool: ^WalkerPool, item: WorkItem) { rel = "" } - if has_git || gi_ctx != nil { - gi := load_gitignore(dir_path) - if gi != nil { - new_ctx := new(GIContext) - new_ctx.gi = gi - if len(rel) > 0 { - new_ctx.base_rel, _ = strings.clone(rel) - } - new_ctx.parent = gi_ctx - - sync.mutex_lock(&pool.contexts_lock) - append(&pool.all_contexts, new_ctx) - sync.mutex_unlock(&pool.contexts_lock) - - gi_ctx = new_ctx + gi := load_ignore_patterns(dir_path, has_git || gi_ctx != nil) + if gi != nil { + new_ctx := new(GIContext) + new_ctx.gi = gi + if len(rel) > 0 { + new_ctx.base_rel, _ = strings.clone(rel) } + new_ctx.parent = gi_ctx + + sync.mutex_lock(&pool.contexts_lock) + append(&pool.all_contexts, new_ctx) + sync.mutex_unlock(&pool.contexts_lock) + + gi_ctx = new_ctx } rel_buf: [4096]u8 @@ -205,7 +203,7 @@ process_dir :: proc(pool: ^WalkerPool, item: WorkItem) { if entry.name == ".git" do continue is_dir := entry.type == .DIR - is_regular := entry.type == .REG || entry.type == .UNKNOWN || entry.type == .LNK + is_nondir := entry.type != .DIR if pool.exclude_gi != nil && is_ignored(pool.exclude_gi, entry.name, is_dir) { continue @@ -241,7 +239,7 @@ process_dir :: proc(pool: ^WalkerPool, item: WorkItem) { child_path := join_path(dir_path, entry.name) push_work(pool, WorkItem{path = child_path, rel = child_rel, gi_ctx = gi_ctx}) } - } else if is_regular { + } else if is_nondir { if should_emit && matches_pattern(pool, entry.name) { full_path := join_path(dir_path, entry.name) sync.mutex_lock(&pool.results_mutex) @@ -345,16 +343,37 @@ free_entries :: proc(entries: ^[dynamic]RawEntry) { delete(entries^) } -load_gitignore :: proc(dir_path: string) -> ^Gitignore { - gi_path := join_path(dir_path, ".gitignore") - defer delete(gi_path) +load_ignore_patterns :: proc(dir_path: string, in_repo: bool) -> ^Gitignore { + has_patterns := false + sb: strings.Builder + strings.builder_init(&sb) + defer strings.builder_destroy(&sb) - data, err := os.read_entire_file_from_path(gi_path, context.allocator) - if err != .NONE do return nil + if in_repo { + gi_path := join_path(dir_path, ".gitignore") + data, err := os.read_entire_file_from_path(gi_path, context.allocator) + delete(gi_path) + if err == .NONE { + fmt.sbprintf(&sb, "%s", string(data)) + delete(data) + has_patterns = true + } + } + ig_path := join_path(dir_path, ".ignore") + idata, ierr := os.read_entire_file_from_path(ig_path, context.allocator) + delete(ig_path) + if ierr == .NONE { + fmt.sbprintf(&sb, "%s", string(idata)) + delete(idata) + has_patterns = true + } + + if !has_patterns do return nil + + content := strings.to_string(sb) gi := new(Gitignore) - gi^ = parse(string(data)) - delete(data) + gi^ = parse(content) return gi }