use async_trait::async_trait;
use grep_regex::{RegexMatcher, RegexMatcherBuilder};
use grep_searcher::{BinaryDetection, Encoding, SearcherBuilder};
use regex::RegexBuilder;
use std::path::{Path, PathBuf};
use crate::ast::Value;
use crate::backend_walker_fs::BackendWalkerFs;
use crate::interpreter::{ExecResult, OutputData, OutputNode};
use crate::tools::builtin::grep_engine::{AccumulatorSink, ContextKind, SearchEvent};
use crate::tools::{ExecContext, ParamSchema, Tool, ToolArgs, ToolSchema, validate_against_schema};
use crate::validator::{IssueCode, ValidationIssue};
use crate::walker::{FileWalker, GlobPath, IncludeExclude, WalkOptions};
pub struct Grep;
#[async_trait]
impl Tool for Grep {
fn name(&self) -> &str {
"grep"
}
fn schema(&self) -> ToolSchema {
ToolSchema::new("grep", "Search for patterns in files or stdin")
.param(ParamSchema::required(
"pattern",
"string",
"Regular expression pattern to search for",
))
.param(ParamSchema::optional(
"path",
"string",
Value::Null,
"File to search (reads stdin if not provided)",
))
.param(ParamSchema::optional(
"ignore_case",
"bool",
Value::Bool(false),
"Case-insensitive matching (-i)",
).with_aliases(["-i"]))
.param(ParamSchema::optional(
"line_number",
"bool",
Value::Bool(false),
"Prefix output with line numbers (-n)",
).with_aliases(["-n"]))
.param(ParamSchema::optional(
"invert",
"bool",
Value::Bool(false),
"Select non-matching lines (-v)",
).with_aliases(["-v"]))
.param(ParamSchema::optional(
"count",
"bool",
Value::Bool(false),
"Only print count of matching lines (-c)",
).with_aliases(["-c"]))
.param(ParamSchema::optional(
"only_matching",
"bool",
Value::Bool(false),
"Print only the matched parts (-o)",
).with_aliases(["-o"]))
.param(ParamSchema::optional(
"after_context",
"int",
Value::Null,
"Print NUM lines after match (-A)",
).with_aliases(["-A"]))
.param(ParamSchema::optional(
"before_context",
"int",
Value::Null,
"Print NUM lines before match (-B)",
).with_aliases(["-B"]))
.param(ParamSchema::optional(
"context",
"int",
Value::Null,
"Print NUM lines before and after match (-C)",
).with_aliases(["-C"]))
.param(ParamSchema::optional(
"quiet",
"bool",
Value::Bool(false),
"Quiet mode, only return exit code (-q)",
).with_aliases(["-q"]))
.param(ParamSchema::optional(
"files_with_matches",
"bool",
Value::Bool(false),
"Print only filenames with matches (-l)",
).with_aliases(["-l"]))
.param(ParamSchema::optional(
"word_regexp",
"bool",
Value::Bool(false),
"Match whole words only (-w)",
).with_aliases(["-w"]))
.param(ParamSchema::optional(
"recursive",
"bool",
Value::Bool(false),
"Search directories recursively (-r)",
).with_aliases(["-r", "-R"]))
.param(ParamSchema::optional(
"include",
"string",
Value::Null,
"Include only files matching pattern (--include)",
).with_aliases(["--include"]))
.param(ParamSchema::optional(
"exclude",
"string",
Value::Null,
"Exclude files matching pattern (--exclude)",
).with_aliases(["--exclude"]))
.param(ParamSchema::optional(
"multiline",
"bool",
Value::Bool(false),
"Allow patterns to match across line boundaries (-U)",
).with_aliases(["-U", "--multiline"]))
.param(ParamSchema::optional(
"encoding",
"string",
Value::Null,
"Force a specific text encoding (e.g. utf-16, latin-1)",
).with_aliases(["--encoding"]))
.param(ParamSchema::optional(
"binary",
"string",
Value::String("quit".into()),
"Binary handling: quit (default), text, without-match",
).with_aliases(["--binary"]))
.example("Search for pattern in file", "grep pattern file.txt")
.example("Case-insensitive search", "grep -i ERROR log.txt")
.example("Show line numbers", "grep -n TODO *.rs")
.example("Extract matched text only", "grep -o 'https://[^\"]*' file.html")
.example("Context around matches", "grep -C 2 error log.txt")
.example("Recursive search", "grep -r TODO src/")
.example("With file filter", "grep -rn TODO . --include='*.rs'")
}
fn validate(&self, args: &ToolArgs) -> Vec<ValidationIssue> {
let mut issues = validate_against_schema(args, &self.schema());
if let Some(pattern) = args.get_string("pattern", 0) {
if !pattern.contains("<dynamic>")
&& let Err(e) = regex::Regex::new(&pattern) {
issues.push(ValidationIssue::error(
IssueCode::InvalidRegex,
format!("grep: invalid regex pattern: {}", e),
).with_suggestion("check regex syntax at https://docs.rs/regex"));
}
}
issues
}
async fn execute(&self, args: ToolArgs, ctx: &mut ExecContext) -> ExecResult {
let pattern = match args.get_string("pattern", 0) {
Some(p) => p,
None => return ExecResult::failure(1, "grep: missing pattern argument"),
};
let ignore_case = args.has_flag("ignore_case") || args.has_flag("i");
let line_number = args.has_flag("line_number") || args.has_flag("n");
let invert = args.has_flag("invert") || args.has_flag("v");
let count_only = args.has_flag("count") || args.has_flag("c");
let only_matching = args.has_flag("only_matching") || args.has_flag("o");
let quiet = args.has_flag("quiet") || args.has_flag("q");
let files_only = args.has_flag("files_with_matches") || args.has_flag("l");
let word_regexp = args.has_flag("word_regexp") || args.has_flag("w");
let recursive = args.has_flag("recursive") || args.has_flag("r") || args.has_flag("R");
let context = args.get("context", usize::MAX).and_then(|v| match v {
Value::Int(i) => Some(*i as usize),
Value::String(s) => s.parse().ok(),
_ => None,
});
let after_context = args
.get("after_context", usize::MAX)
.and_then(|v| match v {
Value::Int(i) => Some(*i as usize),
Value::String(s) => s.parse().ok(),
_ => None,
})
.or(context);
let before_context = args
.get("before_context", usize::MAX)
.and_then(|v| match v {
Value::Int(i) => Some(*i as usize),
Value::String(s) => s.parse().ok(),
_ => None,
})
.or(context);
let multiline = args.has_flag("multiline") || args.has_flag("U");
let encoding = args.get_string("encoding", usize::MAX);
let binary_mode = args
.get_string("binary", usize::MAX)
.unwrap_or_else(|| "quit".into());
let binary_detection = match binary_mode.as_str() {
"none" | "text" => BinaryDetection::none(),
"without-match" => BinaryDetection::convert(b'\x00'),
_ => BinaryDetection::quit(b'\x00'),
};
let final_pattern = if word_regexp {
format!(r"\b{}\b", pattern)
} else {
pattern
};
let regex = match RegexBuilder::new(&final_pattern)
.case_insensitive(ignore_case)
.multi_line(multiline)
.build()
{
Ok(r) => r,
Err(e) => return ExecResult::failure(1, format!("grep: invalid pattern: {}", e)),
};
let matcher = match RegexMatcherBuilder::new()
.case_insensitive(ignore_case)
.multi_line(multiline)
.build(&final_pattern)
{
Ok(m) => m,
Err(e) => return ExecResult::failure(1, format!("grep: invalid pattern: {}", e)),
};
let grep_opts = GrepOptions {
show_line_numbers: line_number,
invert,
only_matching,
before_context,
after_context,
show_filename: false, multiline,
encoding: encoding.clone(),
binary_detection,
};
if recursive {
let path = args
.get_string("path", 1)
.unwrap_or_else(|| ".".to_string());
let root = ctx.resolve_path(&path);
let mut filter = IncludeExclude::new();
if let Some(Value::String(inc)) = args.get("include", usize::MAX) {
filter.include(inc);
}
if let Some(Value::String(exc)) = args.get("exclude", usize::MAX) {
filter.exclude(exc);
}
let glob = if let Some(Value::String(inc)) = args.get("include", usize::MAX) {
GlobPath::new(&format!("**/{}", inc)).ok()
} else {
GlobPath::new("**/*").ok()
};
let options = WalkOptions {
max_depth: None,
entry_types: crate::walker::EntryTypes::files_only(),
respect_gitignore: ctx.ignore_config.auto_gitignore(),
include_hidden: false,
filter,
..WalkOptions::default()
};
let fs = BackendWalkerFs(ctx.backend.as_ref());
let mut walker = if let Some(g) = glob {
FileWalker::new(&fs, &root)
.with_pattern(g)
.with_options(options)
} else {
FileWalker::new(&fs, &root).with_options(options)
};
if let Some(ignore_filter) = ctx.build_ignore_filter(&root).await {
walker = walker.with_ignore(ignore_filter);
}
let files = match walker.collect().await {
Ok(f) => f,
Err(e) => return ExecResult::failure(1, format!("grep: {}", e)),
};
return self
.grep_multiple_files(ctx, &files, &root, &matcher, &grep_opts, quiet, files_only, count_only)
.await;
}
let can_stream = args.get_string("path", 1).is_none()
&& !count_only && !quiet && !files_only && !only_matching
&& before_context.is_none() && after_context.is_none()
&& ctx.pipe_stdin.is_some() && ctx.pipe_stdout.is_some();
if can_stream {
if let (Some(pipe_stdin), Some(pipe_stdout)) =
(ctx.pipe_stdin.take(), ctx.pipe_stdout.take())
{
return self.stream_grep(ctx, pipe_stdin, pipe_stdout, ®ex, invert, line_number).await;
}
}
let (bytes, filename) = match args.get_string("path", 1) {
Some(path) => {
let resolved = ctx.resolve_path(&path);
match ctx.backend.read(Path::new(&resolved), None).await {
Ok(data) => (data, Some(path)),
Err(e) => return ExecResult::failure(1, format!("grep: {}: {}", path, e)),
}
}
None => (
ctx.read_stdin_to_string()
.await
.unwrap_or_default()
.into_bytes(),
None,
),
};
let render = match grep_lines_structured(
&bytes,
&matcher,
&grep_opts,
filename.as_deref(),
) {
Ok(t) => t,
Err(e) => return ExecResult::failure(1, format!("grep: {e}")),
};
if quiet {
return if render.match_count > 0 {
ExecResult::success("")
} else {
ExecResult::from_output(1, "", "")
};
}
if files_only {
return if render.match_count > 0 {
if let Some(name) = filename {
ExecResult::with_output(OutputData::text(format!("{}\n", name)))
} else {
ExecResult::with_output(OutputData::text("-\n".to_string()))
}
} else {
ExecResult::from_output(1, "", "")
};
}
if count_only {
ExecResult::with_output(OutputData::text(format!("{}\n", render.match_count)))
} else if render.match_count == 0 {
ExecResult::from_output(1, render.text, "")
} else {
let headers = if grep_opts.show_line_numbers {
vec!["MATCH".to_string(), "LINE".to_string()]
} else {
vec!["MATCH".to_string()]
};
let output = OutputData::table(headers, render.nodes)
.with_rich_json(serde_json::Value::Array(render.rich));
ExecResult::with_output_and_text(output, render.text)
}
}
}
impl Grep {
async fn stream_grep(
&self,
_ctx: &mut ExecContext,
pipe_in: crate::scheduler::PipeReader,
mut pipe_out: crate::scheduler::PipeWriter,
regex: ®ex::Regex,
invert: bool,
show_line_numbers: bool,
) -> ExecResult {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let mut reader = BufReader::new(pipe_in);
let mut match_count = 0usize;
let mut line_num = 0usize;
let mut line_buf = String::new();
loop {
line_buf.clear();
match reader.read_line(&mut line_buf).await {
Ok(0) => break,
Ok(_) => {
line_num += 1;
let matches = regex.is_match(line_buf.trim_end_matches('\n'));
let should_output = if invert { !matches } else { matches };
if should_output {
match_count += 1;
let output = if show_line_numbers {
format!("{}:{}", line_num, line_buf)
} else {
line_buf.clone()
};
if pipe_out.write_all(output.as_bytes()).await.is_err() {
break;
}
if !output.ends_with('\n') && pipe_out.write_all(b"\n").await.is_err() {
break;
}
}
}
Err(_) => break,
}
}
drop(reader);
let _ = pipe_out.shutdown().await;
if match_count > 0 {
ExecResult::success("")
} else {
ExecResult::from_output(1, String::new(), String::new())
}
}
#[allow(clippy::too_many_arguments)]
async fn grep_multiple_files(
&self,
ctx: &mut ExecContext,
files: &[PathBuf],
root: &Path,
matcher: &RegexMatcher,
base_opts: &GrepOptions,
quiet: bool,
files_only: bool,
count_only: bool,
) -> ExecResult {
let mut total_output = String::new();
let mut total_nodes: Vec<OutputNode> = Vec::new();
let mut total_rich: Vec<serde_json::Value> = Vec::new();
let mut total_matches: usize = 0;
let mut files_with_matches = Vec::new();
let opts = GrepOptions {
show_filename: true,
..base_opts.clone()
};
for file_path in files {
let bytes = match ctx.backend.read(file_path, None).await {
Ok(data) => data,
Err(_) => continue,
};
let display_name = file_path
.strip_prefix(root)
.unwrap_or(file_path)
.to_string_lossy()
.to_string();
let render = match grep_lines_structured(&bytes, matcher, &opts, Some(&display_name)) {
Ok(t) => t,
Err(_) => continue,
};
if render.match_count > 0 {
total_matches += render.match_count;
files_with_matches.push(display_name.clone());
if !quiet && !files_only && !count_only {
total_output.push_str(&render.text);
total_nodes.extend(render.nodes);
total_rich.extend(render.rich);
}
}
}
if quiet {
return if total_matches > 0 {
ExecResult::success("")
} else {
ExecResult::from_output(1, "", "")
};
}
if files_only {
return if files_with_matches.is_empty() {
ExecResult::from_output(1, "", "")
} else {
ExecResult::with_output(OutputData::text(files_with_matches.join("\n") + "\n"))
};
}
if count_only {
ExecResult::with_output(OutputData::text(format!("{}\n", total_matches)))
} else if total_matches == 0 {
ExecResult::from_output(1, total_output, "")
} else {
let headers = if opts.show_line_numbers {
vec!["MATCH".to_string(), "FILE".to_string(), "LINE".to_string()]
} else {
vec!["MATCH".to_string(), "FILE".to_string()]
};
let output = OutputData::table(headers, total_nodes)
.with_rich_json(serde_json::Value::Array(total_rich));
ExecResult::with_output_and_text(output, total_output)
}
}
}
#[derive(Clone)]
struct GrepOptions {
show_line_numbers: bool,
invert: bool,
show_filename: bool,
only_matching: bool,
before_context: Option<usize>,
after_context: Option<usize>,
multiline: bool,
encoding: Option<String>,
binary_detection: BinaryDetection,
}
fn grep_lines_structured(
input: &[u8],
matcher: &RegexMatcher,
opts: &GrepOptions,
filename: Option<&str>,
) -> Result<RenderResult, String> {
let mut sb = SearcherBuilder::new();
sb.line_number(true)
.multi_line(opts.multiline)
.invert_match(opts.invert)
.binary_detection(opts.binary_detection.clone());
if let Some(before) = opts.before_context {
sb.before_context(before);
}
if let Some(after) = opts.after_context {
sb.after_context(after);
}
if let Some(enc_label) = opts.encoding.as_deref() {
match Encoding::new(enc_label) {
Ok(enc) => {
sb.encoding(Some(enc));
}
Err(e) => return Err(format!("invalid encoding '{enc_label}': {e}")),
}
}
let mut searcher = sb.build();
let mut sink = AccumulatorSink::new(matcher, None);
searcher
.search_slice(matcher, input, &mut sink)
.map_err(|e| e.to_string())?;
let events = sink.into_events();
Ok(render_events(&events, opts, filename))
}
struct RenderResult {
text: String,
nodes: Vec<OutputNode>,
rich: Vec<serde_json::Value>,
match_count: usize,
}
fn render_events(events: &[SearchEvent], opts: &GrepOptions, filename: Option<&str>) -> RenderResult {
let prefix = |line_num: u64, sep: char| -> String {
let mut p = String::new();
if opts.show_filename
&& let Some(f) = filename
{
p.push_str(f);
p.push(sep);
}
if opts.show_line_numbers {
p.push_str(&format!("{line_num}{sep}"));
}
p
};
let mut output = String::new();
let mut nodes: Vec<OutputNode> = Vec::new();
let mut rich: Vec<serde_json::Value> = Vec::new();
let mut match_count: usize = 0;
let mut emitted_any = false;
for event in events {
match event {
SearchEvent::Match(m) => {
let line_num = m.line_number.unwrap_or(0);
if opts.only_matching && !opts.invert && !m.submatches.is_empty() {
for sub in &m.submatches {
output.push_str(&prefix(line_num, ':'));
output.push_str(&sub.text);
output.push('\n');
let mut cells = Vec::new();
if opts.show_filename
&& let Some(f) = filename
{
cells.push(f.to_string());
}
if opts.show_line_numbers {
cells.push(line_num.to_string());
}
nodes.push(OutputNode::new(&sub.text).with_cells(cells));
}
} else {
output.push_str(&prefix(line_num, ':'));
output.push_str(&m.line_text);
output.push('\n');
let mut cells = Vec::new();
if opts.show_filename
&& let Some(f) = filename
{
cells.push(f.to_string());
}
if opts.show_line_numbers {
cells.push(line_num.to_string());
}
nodes.push(OutputNode::new(&m.line_text).with_cells(cells));
}
rich.push(match_record_to_json(m, filename));
match_count += 1;
emitted_any = true;
}
SearchEvent::Context(c) => {
let sep = match c.kind {
ContextKind::Before | ContextKind::After | ContextKind::Other => '-',
};
let line_num = c.line_number.unwrap_or(0);
output.push_str(&prefix(line_num, sep));
output.push_str(&c.line_text);
output.push('\n');
emitted_any = true;
}
SearchEvent::ContextBreak => {
if emitted_any {
output.push_str("--\n");
}
}
}
}
RenderResult {
text: output,
nodes,
rich,
match_count,
}
}
fn match_record_to_json(
m: &crate::tools::builtin::grep_engine::MatchRecord,
fallback_path: Option<&str>,
) -> serde_json::Value {
use serde_json::{Value, json};
let path = m
.path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.or_else(|| fallback_path.map(|s| s.to_string()));
let path_v = match path {
Some(p) => Value::String(p),
None => Value::Null,
};
let line_number_v = match m.line_number {
Some(n) => Value::Number(n.into()),
None => Value::Null,
};
let submatches: Vec<Value> = m
.submatches
.iter()
.map(|s| {
json!({
"text": s.text,
"start": s.start,
"end": s.end,
})
})
.collect();
json!({
"path": path_v,
"line_number": line_number_v,
"byte_offset": m.absolute_byte_offset,
"line_text": m.line_text,
"submatches": submatches,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::sync::Arc;
async fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("test.txt"), b"hello world\nHELLO WORLD\nfoo bar\nbaz")
.await
.unwrap();
mem.write(Path::new("lines.txt"), b"line one\nline two\nline three\nfour")
.await
.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_grep_file() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("hello".into()));
args.positional.push(Value::String("/test.txt".into()));
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(&*result.text_out(), "hello world\n");
}
#[tokio::test]
async fn test_grep_case_insensitive() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("hello".into()));
args.positional.push(Value::String("/test.txt".into()));
args.flags.insert("i".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("hello world"));
assert!(result.text_out().contains("HELLO WORLD"));
}
#[tokio::test]
async fn test_grep_line_numbers() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("line".into()));
args.positional.push(Value::String("/lines.txt".into()));
args.flags.insert("n".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("1:line one"));
assert!(result.text_out().contains("2:line two"));
assert!(result.text_out().contains("3:line three"));
}
#[tokio::test]
async fn test_grep_invert() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("line".into()));
args.positional.push(Value::String("/lines.txt".into()));
args.flags.insert("v".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(&*result.text_out(), "four\n");
}
#[tokio::test]
async fn test_grep_count() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("line".into()));
args.positional.push(Value::String("/lines.txt".into()));
args.flags.insert("c".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(&*result.text_out(), "3\n");
}
#[tokio::test]
async fn test_grep_no_match() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("xyz".into()));
args.positional.push(Value::String("/test.txt".into()));
let result = Grep.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.is_empty());
assert_eq!(result.code, 1);
}
#[tokio::test]
async fn test_grep_stdin() {
let mut ctx = make_ctx().await;
ctx.set_stdin("apple\nbanana\napricot\n".to_string());
let mut args = ToolArgs::new();
args.positional.push(Value::String("ap".into()));
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("apple"));
assert!(result.text_out().contains("apricot"));
assert!(!result.text_out().contains("banana"));
}
#[tokio::test]
async fn test_grep_regex() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("^line".into())); args.positional.push(Value::String("/lines.txt".into()));
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("line one"));
assert!(!result.text_out().contains("four")); }
#[tokio::test]
async fn test_grep_invalid_regex() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("[invalid".into()));
args.positional.push(Value::String("/test.txt".into()));
let result = Grep.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("invalid pattern"));
}
#[tokio::test]
async fn test_grep_missing_pattern() {
let mut ctx = make_ctx().await;
let result = Grep.execute(ToolArgs::new(), &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("pattern"));
}
#[tokio::test]
async fn test_grep_file_not_found() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("pattern".into()));
args.positional.push(Value::String("/nonexistent".into()));
let result = Grep.execute(args, &mut ctx).await;
assert!(!result.ok());
}
#[tokio::test]
async fn test_grep_only_matching() {
let mut ctx = make_ctx().await;
ctx.set_stdin("hello world hello\nfoo bar\n".to_string());
let mut args = ToolArgs::new();
args.positional.push(Value::String("hello".into()));
args.flags.insert("o".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
let text = result.text_out();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "hello");
assert_eq!(lines[1], "hello");
}
#[tokio::test]
async fn test_grep_quiet_match() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("hello".into()));
args.positional.push(Value::String("/test.txt".into()));
args.flags.insert("q".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().is_empty());
}
#[tokio::test]
async fn test_grep_quiet_no_match() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("xyz".into()));
args.positional.push(Value::String("/test.txt".into()));
args.flags.insert("q".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(!result.ok());
assert_eq!(result.code, 1);
}
#[tokio::test]
async fn test_grep_multiline_flag() {
let mut ctx = make_ctx().await;
ctx.set_stdin("foo line\nmiddle\nbar line\n".to_string());
let mut args = ToolArgs::new();
args.positional.push(Value::String("(?s)foo.*bar".into()));
args.flags.insert("U".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(
result.ok(),
"multiline grep failed: code={} err={}",
result.code,
result.err
);
assert!(
result.text_out().contains("foo line"),
"expected match crossing lines: {:?}",
result.text_out().to_string(),
);
}
#[tokio::test]
async fn test_grep_no_multiline_by_default() {
let mut ctx = make_ctx().await;
ctx.set_stdin("foo line\nmiddle\nbar line\n".to_string());
let mut args = ToolArgs::new();
args.positional.push(Value::String("(?s)foo.*bar".into()));
let result = Grep.execute(args, &mut ctx).await;
assert_eq!(result.code, 1);
}
#[tokio::test]
async fn test_grep_binary_quit_default() {
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::sync::Arc;
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
let mut bytes = b"foo\x00bar\n".to_vec();
bytes.extend_from_slice(b"second line foo\n");
mem.write(Path::new("bin.dat"), &bytes).await.unwrap();
vfs.mount("/", mem);
let mut ctx = ExecContext::new(Arc::new(vfs));
let mut args = ToolArgs::new();
args.positional.push(Value::String("foo".into()));
args.positional.push(Value::String("/bin.dat".into()));
let result = Grep.execute(args, &mut ctx).await;
assert!(
!result.text_out().contains("second line"),
"binary quit should suppress post-NUL output, got: {:?}",
result.text_out().to_string(),
);
}
#[tokio::test]
async fn test_grep_binary_text_searches_through() {
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::sync::Arc;
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
let mut bytes = b"foo\x00bar\n".to_vec();
bytes.extend_from_slice(b"after_null foo bar\n");
mem.write(Path::new("bin.dat"), &bytes).await.unwrap();
vfs.mount("/", mem);
let mut ctx = ExecContext::new(Arc::new(vfs));
let mut args = ToolArgs::new();
args.positional.push(Value::String("foo".into()));
args.positional.push(Value::String("/bin.dat".into()));
args.named
.insert("binary".to_string(), Value::String("text".into()));
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(
result.text_out().contains("after_null"),
"binary=text should find post-NUL match, got: {:?}",
result.text_out().to_string(),
);
}
#[tokio::test]
async fn test_grep_json_rich_schema() {
use kaish_types::output::{OutputFormat, apply_output_format};
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("hello".into()));
args.positional.push(Value::String("/test.txt".into()));
args.flags.insert("n".to_string());
let raw = Grep.execute(args, &mut ctx).await;
let result = apply_output_format(raw, OutputFormat::Json);
let parsed: serde_json::Value =
serde_json::from_str(&result.text_out()).expect("valid JSON");
let arr = parsed.as_array().expect("array");
assert!(!arr.is_empty(), "expected at least one match: {parsed:#?}");
let first = &arr[0];
for key in ["path", "line_number", "byte_offset", "line_text", "submatches"] {
assert!(
first.get(key).is_some(),
"missing key {key:?} in rich JSON: {first:#?}",
);
}
let subs = first
.get("submatches")
.and_then(|v| v.as_array())
.expect("submatches array");
assert!(!subs.is_empty(), "expected at least one submatch");
let first_sub = &subs[0];
assert!(first_sub.get("text").and_then(|v| v.as_str()).is_some());
assert!(first_sub.get("start").and_then(|v| v.as_u64()).is_some());
assert!(first_sub.get("end").and_then(|v| v.as_u64()).is_some());
}
#[tokio::test]
async fn test_grep_context_break_separator() {
let mut ctx = make_ctx().await;
ctx.set_stdin(
"match1\nbetween1\nbetween2\nbetween3\nbetween4\nmatch2\n".to_string(),
);
let mut args = ToolArgs::new();
args.positional.push(Value::String("match".into()));
args.named.insert("context".to_string(), Value::Int(1));
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
let out = result.text_out().to_string();
assert!(
out.contains("--\n"),
"expected context-break separator '--' in output, got:\n{out}",
);
}
#[tokio::test]
async fn test_grep_word_regexp() {
let mut ctx = make_ctx().await;
ctx.set_stdin("foobar\nfoo bar\nbarfoo\n".to_string());
let mut args = ToolArgs::new();
args.positional.push(Value::String("foo".into()));
args.flags.insert("w".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(&*result.text_out(), "foo bar\n");
}
#[tokio::test]
async fn test_grep_files_with_matches() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("hello".into()));
args.positional.push(Value::String("/test.txt".into()));
args.flags.insert("l".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(result.text_out().trim(), "/test.txt");
}
#[tokio::test]
async fn test_grep_context() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("two".into()));
args.positional.push(Value::String("/lines.txt".into()));
args.named.insert("context".to_string(), Value::Int(1));
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("line one"));
assert!(result.text_out().contains("line two"));
assert!(result.text_out().contains("line three"));
}
async fn make_recursive_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.mkdir(Path::new("src")).await.unwrap();
mem.mkdir(Path::new("src/lib")).await.unwrap();
mem.write(Path::new("src/main.rs"), b"fn main() {\n // TODO: implement\n}")
.await
.unwrap();
mem.write(Path::new("src/lib.rs"), b"// TODO: add modules\npub mod lib;")
.await
.unwrap();
mem.write(Path::new("src/lib/utils.rs"), b"pub fn util() {\n // helper function\n}")
.await
.unwrap();
mem.write(Path::new("README.md"), b"# Project\nTODO: write docs")
.await
.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_grep_recursive() {
let mut ctx = make_recursive_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("TODO".into()));
args.positional.push(Value::String("/".into()));
args.flags.insert("r".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("TODO"));
assert!(result.text_out().contains("main.rs"));
assert!(result.text_out().contains("lib.rs"));
assert!(result.text_out().contains("README.md"));
}
#[tokio::test]
async fn test_grep_recursive_with_line_numbers() {
let mut ctx = make_recursive_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("TODO".into()));
args.positional.push(Value::String("/src".into()));
args.flags.insert("r".to_string());
args.flags.insert("n".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains(":"));
}
#[tokio::test]
async fn test_grep_recursive_include() {
let mut ctx = make_recursive_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("TODO".into()));
args.positional.push(Value::String("/".into()));
args.flags.insert("r".to_string());
args.named
.insert("include".to_string(), Value::String("*.rs".into()));
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("main.rs") || result.text_out().contains("lib.rs"));
assert!(!result.text_out().contains("README.md"));
}
#[tokio::test]
async fn test_grep_recursive_files_only() {
let mut ctx = make_recursive_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("TODO".into()));
args.positional.push(Value::String("/".into()));
args.flags.insert("r".to_string());
args.flags.insert("l".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
let text = result.text_out();
let lines: Vec<&str> = text.lines().collect();
assert!(lines.len() >= 2); for line in &lines {
assert!(!line.contains("TODO"), "Output should only contain filenames");
}
}
#[tokio::test]
async fn test_grep_recursive_uppercase_r() {
let mut ctx = make_recursive_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("TODO".into()));
args.positional.push(Value::String("/src".into()));
args.flags.insert("R".to_string());
let result = Grep.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("TODO"));
assert!(result.text_out().contains("main.rs") || result.text_out().contains("lib.rs"));
}
}