diff --git a/Cargo.lock b/Cargo.lock
index 6d3627ed..d2650c4c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6618,6 +6618,7 @@ dependencies = [
"wasmtime",
"wat",
"workdir",
+ "workspace-api",
"yoi-plugin-pdk",
]
diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs
index 53999081..fb2cd54f 100644
--- a/crates/fs-operation/src/lib.rs
+++ b/crates/fs-operation/src/lib.rs
@@ -157,6 +157,22 @@ mod tests {
}
}
+ fn grep_request(path: &str, pattern: &str) -> GrepRequest {
+ GrepRequest {
+ pattern: pattern.to_string(),
+ path: FsPath::new(path).unwrap(),
+ glob: None,
+ file_type: None,
+ case_insensitive: false,
+ before_context: 0,
+ after_context: 0,
+ multiline: false,
+ output_mode: GrepOutputMode::Content,
+ limit: 10,
+ offset: 0,
+ }
+ }
+
#[test]
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
assert!(FsPath::new("src/lib.rs").is_ok());
@@ -280,6 +296,261 @@ mod tests {
assert!(!grep.output.contains("c.txt"));
}
+ #[test]
+ fn grep_accepts_a_direct_file_without_searching_siblings() {
+ let temp = tempfile::tempdir().unwrap();
+ let selected = temp.path().join("selected.txt");
+ std::fs::write(&selected, "before\nneedle selected\nafter\n").unwrap();
+ std::fs::write(temp.path().join("sibling.txt"), "needle sibling\n").unwrap();
+ let root = temp.path().canonicalize().unwrap();
+ let readable = RootAccess(root.clone());
+
+ let mut request = grep_request("selected.txt", "needle");
+ request.before_context = 1;
+ request.after_context = 1;
+ let direct = run_grep(&root, selected, request, &readable).unwrap();
+
+ assert_eq!(direct.match_count, 1);
+ assert_eq!(direct.matched_files, 1);
+ assert_eq!(
+ direct.output,
+ concat!(
+ "selected.txt\n",
+ " 1 │ before\n",
+ " > 2 │ needle selected\n",
+ " 3 │ after\n",
+ )
+ );
+ assert!(!direct.output.contains("sibling"));
+
+ let directory = run_grep(
+ &root,
+ root.clone(),
+ GrepRequest {
+ pattern: "needle".to_string(),
+ path: FsPath::root(),
+ glob: None,
+ file_type: None,
+ case_insensitive: false,
+ before_context: 0,
+ after_context: 0,
+ multiline: false,
+ output_mode: GrepOutputMode::Content,
+ limit: 10,
+ offset: 0,
+ },
+ &readable,
+ )
+ .unwrap();
+ assert_eq!(directory.match_count, 2);
+ assert_eq!(directory.matched_files, 2);
+ }
+
+ #[test]
+ fn grep_direct_file_applies_glob_and_type_filters_for_every_output_mode() {
+ let temp = tempfile::tempdir().unwrap();
+ let nested = temp.path().join("nested");
+ std::fs::create_dir(&nested).unwrap();
+ let selected = nested.join("selected.rs");
+ std::fs::write(&selected, "needle one\nneedle two\n").unwrap();
+ let root = temp.path().canonicalize().unwrap();
+ let readable = RootAccess(root.clone());
+
+ for mode in [
+ GrepOutputMode::Content,
+ GrepOutputMode::FilesWithMatches,
+ GrepOutputMode::Count,
+ ] {
+ for (glob, file_type) in [(Some("other/*.rs"), None), (None, Some("python"))] {
+ let mut request = grep_request("nested/selected.rs", "needle");
+ request.output_mode = mode;
+ request.glob = glob.map(str::to_string);
+ request.file_type = file_type.map(str::to_string);
+
+ let excluded = run_grep(&root, selected.clone(), request, &readable).unwrap();
+ assert_eq!(excluded.output, "", "mode {mode:?}");
+ assert_eq!(excluded.match_count, 0, "mode {mode:?}");
+ assert_eq!(excluded.matched_files, 0, "mode {mode:?}");
+ assert!(!excluded.truncated, "mode {mode:?}");
+ }
+
+ let mut request = grep_request("nested/selected.rs", "needle");
+ request.output_mode = mode;
+ request.glob = Some("nested/*.rs".to_string());
+ request.file_type = Some("rust".to_string());
+ let matched = run_grep(&root, selected.clone(), request, &readable).unwrap();
+
+ match mode {
+ GrepOutputMode::Content => {
+ assert_eq!(matched.match_count, 2);
+ assert_eq!(matched.matched_files, 1);
+ assert!(matched.output.starts_with("nested/selected.rs\n"));
+ assert!(matched.output.contains("> 1 │ needle one"));
+ assert!(matched.output.contains("> 2 │ needle two"));
+ }
+ GrepOutputMode::FilesWithMatches => {
+ assert_eq!(matched.match_count, 1);
+ assert_eq!(matched.matched_files, 1);
+ assert_eq!(matched.output, "nested/selected.rs\n");
+ }
+ GrepOutputMode::Count => {
+ assert_eq!(matched.match_count, 2);
+ assert_eq!(matched.matched_files, 1);
+ assert_eq!(matched.output, "nested/selected.rs:2\n");
+ }
+ }
+ assert!(!matched.truncated, "mode {mode:?}");
+ }
+ }
+
+ #[test]
+ fn grep_direct_file_preserves_explicit_hidden_and_gitignored_behavior() {
+ let temp = tempfile::tempdir().unwrap();
+ let hidden = temp.path().join(".hidden.rs");
+ let ignored = temp.path().join("ignored.rs");
+ std::fs::write(&hidden, "needle hidden\n").unwrap();
+ std::fs::write(&ignored, "needle ignored\n").unwrap();
+ std::fs::write(temp.path().join(".gitignore"), "ignored.rs\n").unwrap();
+ let root = temp.path().canonicalize().unwrap();
+ let readable = RootAccess(root.clone());
+
+ for (path, expected) in [
+ (".hidden.rs", "needle hidden"),
+ ("ignored.rs", "needle ignored"),
+ ] {
+ let result = run_grep(
+ &root,
+ root.join(path),
+ grep_request(path, "needle"),
+ &readable,
+ )
+ .unwrap();
+ assert_eq!(result.match_count, 1, "path {path}");
+ assert!(result.output.contains(expected), "path {path}");
+ }
+ }
+
+ #[test]
+ fn grep_direct_file_preserves_case_multiline_and_bounds() {
+ let temp = tempfile::tempdir().unwrap();
+ let selected = temp.path().join("selected.txt");
+ std::fs::write(&selected, "NEEDLE first\nstart\nfinish\nneedle last\n").unwrap();
+ let root = temp.path().canonicalize().unwrap();
+ let readable = RootAccess(root.clone());
+
+ let mut case_request = grep_request("selected.txt", "needle");
+ case_request.case_insensitive = true;
+ case_request.offset = 1;
+ case_request.limit = 1;
+ let bounded = run_grep(&root, selected.clone(), case_request, &readable).unwrap();
+ assert_eq!(bounded.match_count, 1);
+ assert!(!bounded.output.contains("NEEDLE first"));
+ assert!(bounded.output.contains("needle last"));
+ assert!(bounded.truncated);
+
+ let mut multiline_request = grep_request("selected.txt", "start\\nfinish");
+ multiline_request.multiline = true;
+ let multiline = run_grep(&root, selected, multiline_request, &readable).unwrap();
+ assert_eq!(multiline.match_count, 1);
+ assert!(multiline.output.contains("start\nfinish"));
+ }
+
+ #[test]
+ fn grep_returns_not_found_for_a_missing_direct_path() {
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path().canonicalize().unwrap();
+ let missing = root.join("missing.txt");
+ let readable = RootAccess(root.clone());
+
+ let error = run_grep(
+ &root,
+ missing.clone(),
+ grep_request("missing.txt", "needle"),
+ &readable,
+ )
+ .unwrap_err();
+
+ assert!(matches!(error, FsError::NotFound(path) if path == missing));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn grep_keeps_direct_symlink_directory_and_broken_path_guards() {
+ use std::os::unix::fs::symlink;
+
+ let temp = tempfile::tempdir().unwrap();
+ let root = temp.path().canonicalize().unwrap();
+ let readable = RootAccess(root.clone());
+ std::fs::create_dir(root.join("target-dir")).unwrap();
+ std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
+ symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
+ symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
+ symlink(root.join("missing-target"), root.join("broken-link")).unwrap();
+
+ let request = |path: &str| grep_request(path, "needle");
+
+ let file_result = run_grep(
+ &root,
+ root.join("file-link.rs"),
+ request("file-link.rs"),
+ &readable,
+ )
+ .unwrap();
+ assert_eq!(file_result.match_count, 1);
+ assert!(file_result.output.starts_with("file-link.rs\n"));
+
+ let directory_error = run_grep(
+ &root,
+ root.join("directory-link"),
+ request("directory-link"),
+ &readable,
+ )
+ .unwrap_err();
+ assert!(matches!(
+ directory_error,
+ FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. }
+ if path == root.join("directory-link")
+ ));
+
+ let broken_error = run_grep(
+ &root,
+ root.join("broken-link"),
+ request("broken-link"),
+ &readable,
+ )
+ .unwrap_err();
+ assert!(matches!(
+ broken_error,
+ FsError::BrokenSymlink { path, .. } if path == root.join("broken-link")
+ ));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn grep_rejects_a_direct_special_file_as_invalid_argument() {
+ use std::os::unix::net::UnixListener;
+
+ let temp = tempfile::tempdir().unwrap();
+ let socket = temp.path().join("grep.sock");
+ let _listener = UnixListener::bind(&socket).unwrap();
+ let root = temp.path().canonicalize().unwrap();
+ let readable = RootAccess(root.clone());
+
+ let error = run_grep(
+ &root,
+ socket,
+ grep_request("grep.sock", "needle"),
+ &readable,
+ )
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ FsError::InvalidArgument(message)
+ if message.contains("must be a regular file or directory")
+ ));
+ }
+
#[test]
fn grep_content_groups_lines_by_file_and_marks_matches() {
let temp = tempfile::tempdir().unwrap();
diff --git a/crates/fs-operation/src/search.rs b/crates/fs-operation/src/search.rs
index 54420f6f..a6d9d336 100644
--- a/crates/fs-operation/src/search.rs
+++ b/crates/fs-operation/src/search.rs
@@ -7,8 +7,8 @@ use grep_regex::RegexMatcherBuilder;
use grep_searcher::sinks::UTF8 as UTF8Sink;
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
use ignore::WalkBuilder;
-use ignore::overrides::OverrideBuilder;
-use ignore::types::TypesBuilder;
+use ignore::overrides::{Override, OverrideBuilder};
+use ignore::types::{Types, TypesBuilder};
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
@@ -126,6 +126,38 @@ fn logical_display(root: &Path, path: &Path) -> String {
const DEFAULT_HEAD_LIMIT: usize = 250;
+fn build_overrides(base: &Path, glob: Option<&str>) -> Result