use async_trait::async_trait;
use regex::Regex;
use super::search_common::{build_search_regex, collect_files_recursive};
use super::{Builtin, Context, read_text_file, resolve_path};
use crate::error::{Error, Result};
use crate::interpreter::ExecResult;
pub struct Rg;
struct RgOptions {
pattern: String,
paths: Vec<String>,
ignore_case: bool,
line_numbers: bool,
count_only: bool,
files_with_matches: bool,
invert_match: bool,
word_boundary: bool,
fixed_strings: bool,
max_count: Option<usize>,
no_filename: bool,
}
impl RgOptions {
fn parse(args: &[String]) -> Result<Self> {
let mut opts = RgOptions {
pattern: String::new(),
paths: Vec::new(),
ignore_case: false,
line_numbers: false, count_only: false,
files_with_matches: false,
invert_match: false,
word_boundary: false,
fixed_strings: false,
max_count: None,
no_filename: false,
};
let mut positional = Vec::new();
let mut p = super::arg_parser::ArgParser::new(args);
while !p.is_done() {
if let Ok(Some(val)) = p.flag_value("-m", "rg") {
opts.max_count = Some(
val.parse()
.map_err(|_| Error::Execution(format!("rg: invalid -m value: {val}")))?,
);
} else if p.flag("--no-filename") {
opts.no_filename = true;
} else if p.flag("--no-line-number") {
opts.line_numbers = false;
} else if p.flag("--line-number") {
opts.line_numbers = true;
} else if p.flag("--color") {
} else if p.current().is_some_and(|s| s.starts_with("--color=")) {
p.advance();
} else if p.is_flag() {
let arg = p.current().expect("is_flag guarantees Some");
if arg.starts_with("--") {
p.advance();
continue;
}
let chars: Vec<char> = arg[1..].chars().collect();
p.advance();
for (j, &c) in chars.iter().enumerate() {
match c {
'i' => opts.ignore_case = true,
'n' => opts.line_numbers = true,
'N' => opts.line_numbers = false,
'c' => opts.count_only = true,
'l' => opts.files_with_matches = true,
'v' => opts.invert_match = true,
'w' => opts.word_boundary = true,
'F' => opts.fixed_strings = true,
'm' => {
let rest: String = chars[j + 1..].iter().collect();
let num_str = if !rest.is_empty() {
rest
} else {
match p.positional() {
Some(v) => v.to_string(),
None => {
return Err(Error::Execution(
"rg: -m requires an argument".to_string(),
));
}
}
};
opts.max_count = Some(num_str.parse().map_err(|_| {
Error::Execution(format!("rg: invalid -m value: {num_str}"))
})?);
break;
}
_ => {} }
}
} else if let Some(arg) = p.positional() {
positional.push(arg.to_string());
}
}
if positional.is_empty() {
return Err(Error::Execution("rg: missing pattern".to_string()));
}
opts.pattern = positional.remove(0);
opts.paths = positional;
Ok(opts)
}
fn build_regex(&self) -> Result<Regex> {
build_search_regex(
&self.pattern,
self.fixed_strings,
self.word_boundary,
self.ignore_case,
"rg",
)
}
}
#[async_trait]
impl Builtin for Rg {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: rg [OPTIONS] PATTERN [PATH...]\nRecursively search for a pattern.\n\n -i\tcase insensitive\n -n\tshow line numbers\n -N, --no-line-number\tsuppress line numbers\n -c\tcount matches\n -l\tfiles with matches\n -v\tinvert match\n -w\tword boundary\n -F\tfixed strings (literal)\n -m NUM\tmax count per file\n --no-filename\tsuppress filename\n --color MODE\tcolor output (no-op)\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("rg (bashkit) 0.1"),
) {
return Ok(r);
}
let opts = RgOptions::parse(ctx.args)?;
let regex = opts.build_regex()?;
let inputs: Vec<(String, String)> = if opts.paths.is_empty() {
if let Some(stdin) = ctx.stdin {
vec![("(stdin)".to_string(), stdin.to_string())]
} else {
let files = collect_files_recursive(&ctx.fs, std::slice::from_ref(ctx.cwd)).await;
let mut inputs = Vec::new();
for path in files {
if let Ok(content) = ctx.fs.read_file(&path).await {
let text = String::from_utf8_lossy(&content).into_owned();
inputs.push((path.to_string_lossy().into_owned(), text));
}
}
inputs
}
} else {
let mut inputs = Vec::new();
for p in &opts.paths {
let path = resolve_path(ctx.cwd, p);
if let Ok(meta) = ctx.fs.stat(&path).await
&& meta.file_type.is_dir()
{
let files = collect_files_recursive(&ctx.fs, std::slice::from_ref(&path)).await;
for fpath in files {
if let Ok(content) = ctx.fs.read_file(&fpath).await {
let text = String::from_utf8_lossy(&content).into_owned();
inputs.push((fpath.to_string_lossy().into_owned(), text));
}
}
continue;
}
let text = match read_text_file(&*ctx.fs, &path, "rg").await {
Ok(t) => t,
Err(e) => return Ok(e),
};
inputs.push((p.clone(), text));
}
inputs
};
let show_filename = if opts.no_filename {
false
} else {
inputs.len() > 1
};
let mut output = String::new();
let mut any_match = false;
for (filename, content) in &inputs {
let mut match_count = 0usize;
for (line_idx, line) in content.lines().enumerate() {
let matched = regex.is_match(line);
let matched = if opts.invert_match { !matched } else { matched };
if !matched {
continue;
}
match_count += 1;
any_match = true;
if let Some(max) = opts.max_count
&& match_count > max
{
break;
}
if opts.files_with_matches || opts.count_only {
continue;
}
if show_filename {
output.push_str(filename);
output.push(':');
}
if opts.line_numbers {
output.push_str(&(line_idx + 1).to_string());
output.push(':');
}
output.push_str(line);
output.push('\n');
}
if opts.files_with_matches && match_count > 0 {
output.push_str(filename);
output.push('\n');
}
if opts.count_only {
if show_filename {
output.push_str(filename);
output.push(':');
}
output.push_str(&match_count.to_string());
output.push('\n');
}
}
if any_match {
Ok(ExecResult::ok(output))
} else {
Ok(ExecResult::with_code(String::new(), 1))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::{FileSystem, InMemoryFs};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
async fn run_rg(args: &[&str], stdin: Option<&str>, files: &[(&str, &[u8])]) -> ExecResult {
let fs = Arc::new(InMemoryFs::new());
for (path, content) in files {
let p = Path::new(path);
if let Some(parent) = p.parent()
&& parent != Path::new("/")
{
let fs_trait: &dyn FileSystem = &*fs;
let _ = fs_trait.mkdir(parent, true).await;
}
let fs_trait: &dyn FileSystem = &*fs;
fs_trait.write_file(p, content).await.unwrap();
}
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let env = HashMap::new();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let fs_dyn = fs as Arc<dyn FileSystem>;
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs: fs_dyn,
stdin,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
Rg.execute(ctx).await.unwrap()
}
#[tokio::test]
async fn test_rg_basic_match() {
let result = run_rg(
&["hello", "/test.txt"],
None,
&[("/test.txt", b"hello world\ngoodbye\nhello again\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello world"));
assert!(result.stdout.contains("hello again"));
assert!(!result.stdout.contains("goodbye"));
}
#[tokio::test]
async fn test_rg_no_match() {
let result = run_rg(
&["missing", "/test.txt"],
None,
&[("/test.txt", b"hello world\n")],
)
.await;
assert_eq!(result.exit_code, 1);
assert!(result.stdout.is_empty());
}
#[tokio::test]
async fn test_rg_case_insensitive() {
let result = run_rg(
&["-i", "HELLO", "/test.txt"],
None,
&[("/test.txt", b"Hello World\nhello world\nHELLO\n")],
)
.await;
assert_eq!(result.exit_code, 0);
let lines: Vec<&str> = result.stdout.trim().lines().collect();
assert_eq!(lines.len(), 3);
}
#[tokio::test]
async fn test_rg_count() {
let result = run_rg(
&["-c", "hello", "/test.txt"],
None,
&[("/test.txt", b"hello\nworld\nhello again\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.trim().ends_with('2'));
}
#[tokio::test]
async fn test_rg_files_with_matches() {
let result = run_rg(
&["-l", "hello", "/a.txt", "/b.txt"],
None,
&[("/a.txt", b"hello\n"), ("/b.txt", b"world\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("/a.txt"));
assert!(!result.stdout.contains("/b.txt"));
}
#[tokio::test]
async fn test_rg_invert_match() {
let result = run_rg(
&["-v", "hello", "/test.txt"],
None,
&[("/test.txt", b"hello\nworld\nfoo\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("world"));
assert!(result.stdout.contains("foo"));
assert!(!result.stdout.contains("hello"));
}
#[tokio::test]
async fn test_rg_fixed_strings() {
let result = run_rg(
&["-F", "a.b", "/test.txt"],
None,
&[("/test.txt", b"a.b matches\naxb no match\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("a.b matches"));
assert!(!result.stdout.contains("axb"));
}
#[tokio::test]
async fn test_rg_word_boundary() {
let result = run_rg(
&["-w", "cat", "/test.txt"],
None,
&[("/test.txt", b"the cat sat\ncatch this\nmy cat\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("the cat sat"));
assert!(result.stdout.contains("my cat"));
assert!(!result.stdout.contains("catch"));
}
#[tokio::test]
async fn test_rg_max_count() {
let result = run_rg(
&["-m", "1", "hello", "/test.txt"],
None,
&[("/test.txt", b"hello one\nhello two\nhello three\n")],
)
.await;
assert_eq!(result.exit_code, 0);
let lines: Vec<&str> = result.stdout.trim().lines().collect();
assert_eq!(lines.len(), 1);
}
#[tokio::test]
async fn test_rg_recursive_directory() {
let result = run_rg(
&["needle", "/dir"],
None,
&[
("/dir/a.txt", b"has needle here\n"),
("/dir/sub/b.txt", b"no match\n"),
("/dir/sub/c.txt", b"another needle\n"),
],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("needle"));
assert!(result.stdout.contains("a.txt"));
assert!(result.stdout.contains("c.txt"));
}
#[tokio::test]
async fn test_rg_stdin() {
let result = run_rg(&["world"], Some("hello\nworld\nfoo\n"), &[]).await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("world"));
}
#[tokio::test]
async fn test_rg_missing_pattern() {
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
let args: Vec<String> = vec![];
let env = HashMap::new();
let mut variables = HashMap::new();
let mut cwd = PathBuf::from("/");
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin: None,
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
let result = Rg.execute(ctx).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_rg_file_not_found() {
let result = run_rg(&["pattern", "/nonexistent"], None, &[]).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("rg:"));
}
#[tokio::test]
async fn test_rg_no_filename_flag() {
let result = run_rg(
&["--no-filename", "hello", "/a.txt", "/b.txt"],
None,
&[("/a.txt", b"hello\n"), ("/b.txt", b"hello there\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(!result.stdout.contains("/a.txt"));
assert!(!result.stdout.contains("/b.txt"));
}
#[tokio::test]
async fn test_rg_no_line_numbers_default() {
let result = run_rg(
&["world", "/test.txt"],
None,
&[("/test.txt", b"hello\nworld\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "world");
assert!(!result.stdout.contains("2:"));
}
#[tokio::test]
async fn test_rg_line_numbers_explicit() {
let result = run_rg(
&["-n", "world", "/test.txt"],
None,
&[("/test.txt", b"hello\nworld\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("2:world"));
}
#[tokio::test]
async fn test_rg_no_line_number_flag_short() {
let result = run_rg(
&["-N", "world", "/test.txt"],
None,
&[("/test.txt", b"hello\nworld\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "world");
}
#[tokio::test]
async fn test_rg_no_line_number_flag_long() {
let result = run_rg(
&["--no-line-number", "world", "/test.txt"],
None,
&[("/test.txt", b"hello\nworld\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "world");
}
#[tokio::test]
async fn test_rg_line_number_long_flag() {
let result = run_rg(
&["--line-number", "world", "/test.txt"],
None,
&[("/test.txt", b"hello\nworld\n")],
)
.await;
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("2:world"));
}
#[tokio::test]
async fn test_rg_stdin_no_line_numbers() {
let result = run_rg(&["hello"], Some("hello world\n"), &[]).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout.trim(), "hello world");
assert!(!result.stdout.contains("1:"));
}
}