diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b9915..f756f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.3.20 + +- Skipped common build, dependency, cache, and VCS directories during grep unless the ignored directory is searched directly. + ## 0.3.19 - Reported session save, list, and load failures instead of silently dropping persistence errors. diff --git a/rv.toml b/rv.toml index e9f217e..762afc5 100644 --- a/rv.toml +++ b/rv.toml @@ -1,6 +1,6 @@ [package] name = "rook" -version = "0.3.19" +version = "0.3.20" edition = "v2" [dependencies] diff --git a/src/tools/search.rv b/src/tools/search.rv index c3e35ee..97ea5ad 100644 --- a/src/tools/search.rv +++ b/src/tools/search.rv @@ -6,7 +6,7 @@ import std/string import std/regex { compile, Regex } -import std/fs { walk, read, is_file, is_symlink, basename } +import std/fs { list_dir, read, is_file, is_dir, is_symlink, basename } import "./paths" { resolve_path } const MAX_MATCHES: Int = 50 @@ -47,57 +47,105 @@ fun _grep_with_regexes( has_glob: Bool, ) -> String { let files: List = [] - let ok = true - match walk(root) { - Ok(fs) -> { - files = fs - }, - Err(e) -> { - ok = false - }, - } + _collect_files(root, files, _has_ignored_segment(root)) let out = "" - if ok { - let count = 0 - let i = 0 - while i < files.len() && count < MAX_MATCHES { - let f = files[i] - if is_file(f) && !is_symlink(f) && (!has_glob || name_re.is_match(basename(f))) { - let content = "" - let rok = true - match read(f) { - Ok(s) -> { - content = s - }, - Err(e) -> { - rok = false - }, - } - if rok { - let lines = content.lines() - let ln = 0 - while ln < lines.len() && count < MAX_MATCHES { - if re.is_match(lines[ln]) { - out = out.concat("${f}:${ln + 1}: ${lines[ln]}\n") - count = count + 1 - } - ln = ln + 1 + let count = 0 + let i = 0 + while i < files.len() && count < MAX_MATCHES { + let f = files[i] + if !has_glob || name_re.is_match(basename(f)) { + let content = "" + let rok = true + match read(f) { + Ok(s) -> { + content = s + }, + Err(e) -> { + rok = false + }, + } + if rok { + let lines = content.lines() + let ln = 0 + while ln < lines.len() && count < MAX_MATCHES { + if re.is_match(lines[ln]) { + out = out.concat("${f}:${ln + 1}: ${lines[ln]}\n") + count = count + 1 } + ln = ln + 1 } } - i = i + 1 } - if count == 0 { - out = "no matches for '${pattern}' under ${root}" - } - } else { - out = "error: cannot search ${root}" + i = i + 1 + } + if count == 0 { + out = "no matches for '${pattern}' under ${root}" } name_re.free() return out } +fun _collect_files(path: String, out: List, include_ignored: Bool) { + if is_symlink(path) { + return + } + if is_file(path) { + out.push(path) + return + } + if !is_dir(path) { + return + } + let names: List = [] + match list_dir(path) { + Ok(ns) -> { + names = ns + }, + Err(e) -> { + return + }, + } + let i = 0 + while i < names.len() { + let name = names[i] + let full = "${path}/${name}" + if !is_symlink(full) { + if is_dir(full) { + if include_ignored || !_ignored(name) { + _collect_files(full, out, include_ignored) + } + } else if is_file(full) { + out.push(full) + } + } + i = i + 1 + } + return +} + +fun _has_ignored_segment(path: String) -> Bool { + let parts = path.split("/") + let i = 0 + while i < parts.len() { + if _ignored(parts[i]) { + return true + } + i = i + 1 + } + return false +} + +fun _ignored(name: String) -> Bool { + if name == "node_modules" || name == "target" || name == "dist" || name == "build" || name == "out" || name == "vendor" || name == "__pycache__" { + return true + } + if name.starts_with(".") { + return name != ".agents" && name != ".github" + } + return false +} + // Translates a shell-style glob (`*` any run, `?` one char, everything else // literal) into an anchored regex. An empty glob (no filter requested) // becomes `.*` so it matches any name without ever being consulted. diff --git a/src/tools/search_test.rv b/src/tools/search_test.rv index 2a3d9f9..6f53a9e 100644 --- a/src/tools/search_test.rv +++ b/src/tools/search_test.rv @@ -66,6 +66,50 @@ fun test_grep_glob_filters_files() { return } +fun test_grep_skips_noise_directories_by_default() { + create_dir_all("_rook_gr6/src") + create_dir_all("_rook_gr6/target/debug") + create_dir_all("_rook_gr6/node_modules/pkg") + create_dir_all("_rook_gr6/.git") + fs_write("_rook_gr6/src/main.rv", "needle in source") + fs_write("_rook_gr6/target/debug/app.txt", "needle in build output") + fs_write("_rook_gr6/node_modules/pkg/index.js", "needle in dependency") + fs_write("_rook_gr6/.git/config", "needle in metadata") + + let out = grep("needle", "_rook_gr6", "") + assert_true(out.contains("src/main.rv")) + assert_true(!out.contains("target")) + assert_true(!out.contains("node_modules")) + assert_true(!out.contains(".git")) + + remove_file("_rook_gr6/src/main.rv") + remove_file("_rook_gr6/target/debug/app.txt") + remove_file("_rook_gr6/node_modules/pkg/index.js") + remove_file("_rook_gr6/.git/config") + remove_dir("_rook_gr6/src") + remove_dir("_rook_gr6/target/debug") + remove_dir("_rook_gr6/target") + remove_dir("_rook_gr6/node_modules/pkg") + remove_dir("_rook_gr6/node_modules") + remove_dir("_rook_gr6/.git") + remove_dir("_rook_gr6") + return +} + +fun test_grep_can_search_explicit_ignored_directory() { + create_dir_all("_rook_gr7/target") + fs_write("_rook_gr7/target/out.txt", "needle in build output") + + let out = grep("needle", "_rook_gr7/target", "") + assert_true(out.contains("target/out.txt")) + assert_true(out.contains("needle in build output")) + + remove_file("_rook_gr7/target/out.txt") + remove_dir("_rook_gr7/target") + remove_dir("_rook_gr7") + return +} + fun test_grep_rejects_workspace_escape() { assert_true(grep("needle", "..", "").contains("workspace")) }