Files
oh-my-pi/crates/pi-builtins/src/grep.rs
T
can1357 12591dbde3 refactor: restructured streaming output with configurable buffering
- Introduced `StreamWriter` with configurable block and line buffering policies across builtins.
- Updated pipeline stages and compound commands to execute concurrently as tasks.
- Replaced Linux splice and local stream wrapping with generic `Write` streams and explicit flushing.
- Added regression and streaming smoke tests to verify concurrent output and prevent deadlocks.
2026-08-20 03:14:09 +02:00

1739 lines
48 KiB
Rust

//! `grep` builtin implemented on top of the ripgrep libraries.
//!
//! Matching uses `grep-regex`/`grep-searcher`; recursive walks use `pi-walker`.
use std::{
borrow::Cow,
ffi::{OsStr, OsString},
fs::File,
io::{self, Read, Write},
path::{Path, PathBuf},
};
use brush_core::{ShellExtensions, builtins::Registration};
use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser, ValueEnum, parser::ValueSource};
use globset::{Glob, GlobMatcher};
use grep_matcher::{LineTerminator, Matcher};
use grep_pcre2::{RegexMatcher as PcreMatcher, RegexMatcherBuilder as PcreMatcherBuilder};
use grep_regex::{RegexMatcher, RegexMatcherBuilder};
use grep_searcher::{
BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkFinish, SinkMatch,
};
use crate::host::{Host, Utility, util};
/// PCRE2 JIT toggle: `OMP_PCRE2_JIT=1` forces JIT on, `0`/`false` forces it
/// off. Unset, JIT stays on everywhere except macOS, where PCRE2's SLJIT
/// executable allocator can fault while compiling patterns (issue #7399).
pub(crate) fn pcre2_jit_enabled(host: &Host) -> bool {
match host.var("OMP_PCRE2_JIT") {
Some(value) if !value.is_empty() => value != "0" && !value.eq_ignore_ascii_case("false"),
_ => !cfg!(target_os = "macos"),
}
}
#[derive(Parser, Debug)]
#[command(
name = "grep",
version = concat!("grep (pi-uu-grep) ", env!("CARGO_PKG_VERSION")),
about = "Search for PATTERN in each FILE or standard input.",
disable_help_flag = true,
disable_version_flag = true,
args_override_self = true
)]
struct GrepArgs {
/// Use PATTERN for matching (may be repeated; all patterns are OR-ed).
#[arg(short = 'e', long = "regexp", value_name = "PATTERN")]
patterns: Vec<String>,
/// Read patterns from FILE, one per line.
#[arg(short = 'f', long = "file", value_name = "FILE")]
pattern_files: Vec<OsString>,
/// Interpret PATTERN as a strict extended regular expression.
#[arg(short = 'E', long = "extended-regexp")]
extended: bool,
/// Interpret PATTERN using the default basic-compatible mode.
#[arg(short = 'G', long = "basic-regexp")]
basic: bool,
/// Interpret PATTERN as a fixed string.
#[arg(short = 'F', long = "fixed-strings")]
fixed: bool,
/// Interpret PATTERN as a Perl-compatible regular expression.
#[arg(short = 'P', long = "perl-regexp")]
perl: bool,
/// Ignore case distinctions in patterns and data.
#[arg(short = 'i', short_alias = 'y', long = "ignore-case")]
ignore_case: bool,
/// Restore case-sensitive matching after an earlier -i.
#[arg(long = "no-ignore-case")]
no_ignore_case: bool,
/// Select non-matching lines.
#[arg(short = 'v', long = "invert-match")]
invert: bool,
/// Match only whole words.
#[arg(short = 'w', long = "word-regexp")]
word: bool,
/// Match only whole lines.
#[arg(short = 'x', long = "line-regexp")]
line_regexp: bool,
/// Print only a count of selected lines per FILE.
#[arg(short = 'c', long = "count")]
count: bool,
/// Print only the names of FILEs with at least one selected line.
#[arg(short = 'l', long = "files-with-matches")]
files_with_matches: bool,
/// Print only the names of FILEs with no selected lines.
#[arg(short = 'L', long = "files-without-match")]
files_without_match: bool,
/// Stop after NUM selected lines in each input.
#[arg(short = 'm', long = "max-count", value_name = "NUM", allow_hyphen_values = true)]
max_count: Option<i64>,
/// Print only the matched non-empty parts of selected lines.
#[arg(short = 'o', long = "only-matching")]
only_matching: bool,
/// Quiet; suppress normal output and stop after the first selected line.
#[arg(short = 'q', long = "quiet", visible_alias = "silent")]
quiet: bool,
/// Suppress error messages about nonexistent or unreadable files.
#[arg(short = 's', long = "no-messages")]
no_messages: bool,
/// Prefix output with the zero-based byte offset.
#[arg(short = 'b', long = "byte-offset")]
byte_offset: bool,
/// Always print the file name with output lines.
#[arg(short = 'H', long = "with-filename")]
with_filename: bool,
/// Never print the file name with output lines.
#[arg(short = 'h', long = "no-filename")]
no_filename: bool,
/// Use LABEL as the displayed name for standard input.
#[arg(long = "label", value_name = "LABEL")]
label: Option<OsString>,
/// Prefix each output line with its one-based line number.
#[arg(short = 'n', long = "line-number")]
line_number: bool,
/// Align line content on a tab stop after output prefixes.
#[arg(short = 'T', long = "initial-tab")]
initial_tab: bool,
/// Write NUL instead of the separator following a file name.
#[arg(short = 'Z', long = "null")]
null_paths: bool,
/// Print NUM lines of trailing context after selected lines.
#[arg(short = 'A', long = "after-context", value_name = "NUM")]
after_context: Option<usize>,
/// Print NUM lines of leading context before selected lines.
#[arg(short = 'B', long = "before-context", value_name = "NUM")]
before_context: Option<usize>,
/// Print NUM lines of leading and trailing context.
#[arg(short = 'C', long = "context", value_name = "NUM")]
context: Option<usize>,
/// Print STRING between non-adjacent groups of context lines.
#[arg(long = "group-separator", value_name = "STRING")]
group_separator: Option<String>,
/// Do not print a separator between context groups.
#[arg(long = "no-group-separator")]
no_group_separator: bool,
/// Process binary input as text.
#[arg(short = 'a', long = "text")]
text: bool,
/// Treat binary input as having no selected lines.
#[arg(short = 'I')]
binary_without_match: bool,
/// Choose how binary input is searched.
#[arg(long = "binary-files", value_name = "TYPE")]
binary_files: Option<BinaryFiles>,
/// Choose how device, FIFO, and socket operands are handled.
#[arg(short = 'D', long = "devices", value_name = "ACTION")]
devices: Option<DeviceAction>,
/// Choose how directory operands are handled.
#[arg(short = 'd', long = "directories", value_name = "ACTION")]
directories: Option<DirectoryAction>,
/// Search files matching GLOB.
#[arg(long = "include", value_name = "GLOB")]
include: Vec<String>,
/// Skip files matching GLOB.
#[arg(long = "exclude", value_name = "GLOB")]
exclude: Vec<String>,
/// Read file exclusion globs from FILE.
#[arg(long = "exclude-from", value_name = "FILE")]
exclude_from: Vec<OsString>,
/// Skip directories matching GLOB during recursive searches.
#[arg(long = "exclude-dir", value_name = "GLOB")]
exclude_dir: Vec<String>,
/// Search directories matching GLOB during recursive searches.
#[arg(long = "include-dir", value_name = "GLOB")]
include_dir: Vec<String>,
/// Recursively search each directory operand.
#[arg(short = 'r', long = "recursive")]
recursive: bool,
/// Recursively search and follow every symbolic link.
#[arg(short = 'R', long = "dereference-recursive")]
dereference_recursive: bool,
/// Follow symbolic links named as command-line operands.
#[arg(short = 'O')]
follow_command_line: bool,
/// Do not follow symbolic links during recursive searches.
#[arg(short = 'p')]
no_follow: bool,
/// Follow every symbolic link during recursive searches.
#[arg(short = 'S')]
follow_all: bool,
/// Flush standard output after each output record.
#[arg(long = "line-buffered")]
line_buffered: bool,
/// Use binary I/O where the platform distinguishes it.
#[arg(short = 'U', long = "binary")]
binary_io: bool,
/// Treat NUL rather than newline as the input and output record delimiter.
#[arg(short = 'z', long = "null-data")]
null_data: bool,
/// Request memory-mapped input where supported.
#[allow(dead_code, reason = "accepted BSD grep compatibility option")]
#[arg(long = "mmap")]
mmap: bool,
/// Accepted compatibility option with no effect.
#[allow(dead_code, reason = "accepted GNU grep compatibility option")]
#[arg(short = 'u')]
unix_byte_offsets: bool,
/// Print a help message.
#[allow(dead_code, reason = "clap consumes help before options are inspected")]
#[arg(long = "help", action = clap::ArgAction::Help)]
help: Option<bool>,
/// Print version information.
#[allow(dead_code, reason = "clap consumes version before options are inspected")]
#[arg(short = 'V', long = "version", action = clap::ArgAction::Version)]
version: Option<bool>,
/// Accept color configuration without injecting ANSI into redirected output.
#[allow(dead_code, reason = "color is intentionally disabled for builtin output")]
#[arg(
long = "color",
alias = "colour",
value_name = "WHEN",
num_args = 0..=1,
require_equals = true,
default_missing_value = "auto",
)]
color: Option<String>,
/// PATTERN followed by FILEs (PATTERN is omitted with -e or -f).
#[arg(value_name = "ARGS")]
args: Vec<OsString>,
}
/// Parsed GNU `grep` invocation, including option occurrence order.
pub(crate) struct Grep {
cli: GrepArgs,
matches: ArgMatches,
}
impl CommandFactory for Grep {
fn command() -> clap::Command { GrepArgs::command() }
fn command_for_update() -> clap::Command { GrepArgs::command_for_update() }
}
impl FromArgMatches for Grep {
fn from_arg_matches(matches: &ArgMatches) -> Result<Self, clap::Error> {
Ok(Self { cli: GrepArgs::from_arg_matches(matches)?, matches: matches.clone() })
}
fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), clap::Error> {
self.cli.update_from_arg_matches(matches)?;
self.matches = matches.clone();
Ok(())
}
}
impl Parser for Grep {}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum BinaryFiles {
Binary,
Text,
WithoutMatch,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum DeviceAction {
Read,
Skip,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum DirectoryAction {
Read,
Skip,
Recurse,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MatchMode {
Default,
Extended,
Fixed,
Perl,
}
/// Resolved, flag-free options shared with the search [`Sink`].
struct Options {
line_number: bool,
byte_offset: bool,
count: bool,
files_with_matches: bool,
files_without_match: bool,
only_matching: bool,
before: usize,
after: usize,
no_messages: bool,
quiet: bool,
prefix_filename: bool,
initial_tab: bool,
null_paths: bool,
record_terminator: u8,
group_separator: Option<Vec<u8>>,
line_buffered: bool,
binary_files: BinaryFiles,
}
enum CompiledMatcher {
Rust(RegexMatcher),
Pcre(PcreMatcher),
}
struct PathRule {
include: bool,
matcher: GlobMatcher,
}
struct RuleSpec {
index: usize,
include: bool,
pattern: String,
}
#[derive(Default)]
struct PathRules {
files: Vec<PathRule>,
dirs: Vec<PathRule>,
}
impl PathRules {
fn allows_file(&self, path: &Path) -> bool {
Self::allows(&self.files, path)
}
fn allows_dir(&self, path: &Path) -> bool {
Self::allows(&self.dirs, path)
}
fn allows(rules: &[PathRule], path: &Path) -> bool {
let mut allowed = rules.first().is_none_or(|first| !first.include);
for rule in rules {
if path_suffix_matches(&rule.matcher, path) {
allowed = rule.include;
}
}
allowed
}
}
fn path_suffix_matches(matcher: &GlobMatcher, path: &Path) -> bool {
let mut components = path.components();
loop {
let suffix = components.as_path();
if suffix.as_os_str().is_empty() {
return false;
}
if matcher.is_match(suffix) {
return true;
}
if components.next().is_none() {
return false;
}
}
}
fn last_index(matches: &ArgMatches, id: &str) -> Option<usize> {
if matches.value_source(id) != Some(ValueSource::CommandLine) {
return None;
}
matches.indices_of(id).and_then(|indices| indices.max())
}
fn choose_latest<T>(selected: &mut (usize, T), index: Option<usize>, value: T) {
if let Some(index) = index
&& index >= selected.0
{
*selected = (index, value);
}
}
fn resolve_match_mode(matches: &ArgMatches) -> MatchMode {
let mut selected = (0, MatchMode::Default);
choose_latest(&mut selected, last_index(matches, "basic"), MatchMode::Default);
choose_latest(&mut selected, last_index(matches, "extended"), MatchMode::Extended);
choose_latest(&mut selected, last_index(matches, "fixed"), MatchMode::Fixed);
choose_latest(&mut selected, last_index(matches, "perl"), MatchMode::Perl);
selected.1
}
fn resolve_ignore_case(matches: &ArgMatches) -> bool {
let mut selected = (0, false);
choose_latest(&mut selected, last_index(matches, "ignore_case"), true);
choose_latest(&mut selected, last_index(matches, "no_ignore_case"), false);
selected.1
}
fn resolve_filename_prefix(matches: &ArgMatches) -> Option<bool> {
let mut selected = (0, None);
choose_latest(&mut selected, last_index(matches, "with_filename"), Some(true));
choose_latest(&mut selected, last_index(matches, "no_filename"), Some(false));
selected.1
}
fn resolve_file_list_modes(matches: &ArgMatches) -> (bool, bool) {
let mut selected = (0, None);
choose_latest(&mut selected, last_index(matches, "files_with_matches"), Some(true));
choose_latest(&mut selected, last_index(matches, "files_without_match"), Some(false));
match selected.1 {
Some(true) => (true, false),
Some(false) => (false, true),
None => (false, false),
}
}
fn resolve_context(cli: &GrepArgs, matches: &ArgMatches) -> (usize, usize) {
let mut events = Vec::with_capacity(3);
if let (Some(index), Some(value)) = (last_index(matches, "after_context"), cli.after_context) {
events.push((index, false, value));
}
if let (Some(index), Some(value)) = (last_index(matches, "before_context"), cli.before_context) {
events.push((index, true, value));
}
if let (Some(index), Some(value)) = (last_index(matches, "context"), cli.context) {
events.push((index, false, value));
events.push((index, true, value));
}
events.sort_unstable_by_key(|event| event.0);
let mut before = 0;
let mut after = 0;
for (_, is_before, value) in events {
if is_before {
before = value;
} else {
after = value;
}
}
(before, after)
}
fn resolve_group_separator(cli: &GrepArgs, matches: &ArgMatches) -> Option<Vec<u8>> {
let mut selected = (0, Some(b"--".to_vec()));
if let Some(separator) = &cli.group_separator {
choose_latest(
&mut selected,
last_index(matches, "group_separator"),
Some(separator.as_bytes().to_vec()),
);
}
choose_latest(&mut selected, last_index(matches, "no_group_separator"), None);
selected.1
}
fn resolve_directory_action(cli: &GrepArgs, matches: &ArgMatches) -> DirectoryAction {
let mut selected = (0, DirectoryAction::Read);
choose_latest(&mut selected, last_index(matches, "recursive"), DirectoryAction::Recurse);
choose_latest(
&mut selected,
last_index(matches, "dereference_recursive"),
DirectoryAction::Recurse,
);
if let Some(action) = cli.directories {
choose_latest(&mut selected, last_index(matches, "directories"), action);
}
selected.1
}
fn resolve_follow_links(cli: &GrepArgs, matches: &ArgMatches) -> pi_walker::FollowLinks {
let mut selected = (0, pi_walker::FollowLinks::Roots);
choose_latest(&mut selected, last_index(matches, "recursive"), pi_walker::FollowLinks::Roots);
choose_latest(
&mut selected,
last_index(matches, "dereference_recursive"),
pi_walker::FollowLinks::Always,
);
if cli.directories == Some(DirectoryAction::Recurse) {
choose_latest(
&mut selected,
last_index(matches, "directories"),
pi_walker::FollowLinks::Roots,
);
}
choose_latest(
&mut selected,
last_index(matches, "follow_command_line"),
pi_walker::FollowLinks::Roots,
);
choose_latest(&mut selected, last_index(matches, "no_follow"), pi_walker::FollowLinks::Never);
choose_latest(&mut selected, last_index(matches, "follow_all"), pi_walker::FollowLinks::Always);
selected.1
}
fn resolve_binary_files(cli: &GrepArgs, matches: &ArgMatches) -> BinaryFiles {
// Preserve the builtin's historical byte-transparent default. Explicit
// GNU/BSD binary controls opt into detection.
let mut selected = (0, BinaryFiles::Text);
choose_latest(&mut selected, last_index(matches, "text"), BinaryFiles::Text);
choose_latest(
&mut selected,
last_index(matches, "binary_without_match"),
BinaryFiles::WithoutMatch,
);
if let Some(mode) = cli.binary_files {
choose_latest(&mut selected, last_index(matches, "binary_files"), mode);
}
choose_latest(&mut selected, last_index(matches, "binary_io"), BinaryFiles::Binary);
selected.1
}
fn resolve_max_count(cli: &GrepArgs) -> Result<Option<u64>, String> {
match cli.max_count {
None | Some(-1) => Ok(None),
Some(value) if value >= 0 => u64::try_from(value)
.map(Some)
.map_err(|_| format!("invalid max count: {value}")),
Some(value) => Err(format!("invalid max count: {value}")),
}
}
fn option_takes_next_value(arg: &str) -> bool {
matches!(arg, "-e" | "-f" | "-m" | "-A" | "-B" | "-C" | "-D" | "-d"
| "--regexp" | "--file" | "--max-count" | "--after-context" | "--before-context"
| "--context" | "--label" | "--group-separator" | "--binary-files" | "--devices"
| "--directories" | "--include" | "--exclude" | "--exclude-from" | "--exclude-dir"
| "--include-dir")
}
fn normalize_context_args(argv: Vec<OsString>) -> Vec<OsString> {
let mut normalized = Vec::with_capacity(argv.len());
let mut literal = false;
let mut value_pending = false;
for (index, arg) in argv.into_iter().enumerate() {
if index == 0 || literal || value_pending {
value_pending = false;
normalized.push(arg);
continue;
}
let Some(text) = arg.to_str() else { normalized.push(arg); continue };
if text == "--" { literal = true; normalized.push(arg); continue }
if let Some(digits) = text.strip_prefix('-')
&& !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
{
normalized.push(OsString::from(format!("--context={digits}")));
continue;
}
value_pending = option_takes_next_value(text);
normalized.push(arg);
}
normalized
}
/// Escape regular-expression meta-characters so a pattern is matched literally,
/// mirroring `regex::escape` (used to implement `-F`/`--fixed-strings`).
fn escape_literal(pat: &str) -> String {
const META: &[char] =
&['\\', '.', '+', '*', '?', '(', ')', '|', '[', ']', '{', '}', '^', '$', '#', '&', '-', '~'];
let mut out = String::with_capacity(pat.len());
for ch in pat.chars() {
if META.contains(&ch) {
out.push('\\');
}
out.push(ch);
}
out
}
/// Translate GNU BRE `\|` alternation into the syntax accepted by
/// `grep-regex`, without rewriting escaped pipes inside character classes.
fn normalize_basic_alternation(pattern: &str) -> Cow<'_, str> {
let bytes = pattern.as_bytes();
let mut output = None;
let mut copied = 0;
let mut index = 0;
let mut in_class = false;
while index < bytes.len() {
if bytes[index] == b'\\' {
let run_start = index;
while index < bytes.len() && bytes[index] == b'\\' {
index += 1;
}
let slash_count = index - run_start;
if !in_class && slash_count % 2 == 1 && index < bytes.len() && bytes[index] == b'|' {
let normalized = output.get_or_insert_with(|| String::with_capacity(pattern.len()));
normalized.push_str(&pattern[copied..index - 1]);
normalized.push('|');
copied = index + 1;
index += 1;
continue;
}
if slash_count % 2 == 1 && index < bytes.len() {
index += 1;
}
continue;
}
match bytes[index] {
b'[' if !in_class => in_class = true,
b']' if in_class => in_class = false,
_ => {},
}
index += 1;
}
if let Some(mut normalized) = output {
normalized.push_str(&pattern[copied..]);
Cow::Owned(normalized)
} else {
Cow::Borrowed(pattern)
}
}
fn build_default_matcher<P: AsRef<str>>(
builder: &RegexMatcherBuilder,
patterns: &[P],
) -> Result<RegexMatcher, String> {
let error = match builder.build_many(patterns) {
Ok(matcher) => return Ok(matcher),
Err(error) => error,
};
let sanitized: Vec<String> = patterns
.iter()
.map(|pattern| {
let pattern = pattern.as_ref();
if builder.build(pattern).is_ok() {
pattern.to_owned()
} else {
escape_literal(pattern)
}
})
.collect();
builder
.build_many(&sanitized)
.map_err(|_| error.to_string())
}
/// Compile all patterns using the last-selected matcher mode.
fn build_matcher(
host: &Host,
patterns: &[String],
cli: &GrepArgs,
mode: MatchMode,
ignore_case: bool,
) -> Result<CompiledMatcher, String> {
if mode == MatchMode::Perl {
let mut builder = PcreMatcherBuilder::new();
builder
.caseless(ignore_case)
.word(cli.word && !cli.line_regexp)
.whole_line(cli.line_regexp)
.utf(true)
.ucp(true)
.jit_if_available(pcre2_jit_enabled(host));
return builder
.build_many(patterns)
.map(CompiledMatcher::Pcre)
.map_err(|error| error.to_string());
}
let mut builder = RegexMatcherBuilder::new();
builder
.case_insensitive(ignore_case)
.word(cli.word && !cli.line_regexp)
.whole_line(cli.line_regexp);
if cli.null_data {
builder.line_terminator(Some(b'\0'));
}
if mode == MatchMode::Fixed {
let escaped: Vec<String> = patterns
.iter()
.map(|pattern| escape_literal(pattern))
.collect();
return builder
.build_many(&escaped)
.map(CompiledMatcher::Rust)
.map_err(|error| error.to_string());
}
if mode == MatchMode::Default {
let normalized: Vec<_> = patterns
.iter()
.map(|pattern| normalize_basic_alternation(pattern))
.collect();
return build_default_matcher(&builder, &normalized).map(CompiledMatcher::Rust);
}
builder
.build_many(patterns)
.map(CompiledMatcher::Rust)
.map_err(|error| error.to_string())
}
/// A search sink that renders GNU-compatible records and tracks selection.
struct GrepSink<'a, M: Matcher, W: Write> {
out: &'a mut W,
matcher: &'a M,
display: &'a [u8],
opts: &'a Options,
match_count: u64,
any_match: bool,
binary: bool,
}
impl<M: Matcher, W: Write> GrepSink<'_, M, W> {
fn flush_record(&mut self) -> io::Result<()> {
if self.opts.line_buffered {
self.out.flush()?;
}
Ok(())
}
fn write_prefix(
&mut self,
line_number: Option<u64>,
byte_offset: u64,
separator: u8,
) -> io::Result<()> {
let mut has_prefix = false;
if self.opts.prefix_filename {
self.out.write_all(self.display)?;
if self.opts.null_paths {
self.out.write_all(b"\0")?;
} else {
self.out.write_all(&[separator])?;
}
has_prefix = true;
}
if self.opts.line_number
&& let Some(number) = line_number
{
write!(self.out, "{number}")?;
self.out.write_all(&[separator])?;
has_prefix = true;
}
if self.opts.byte_offset {
write!(self.out, "{byte_offset}")?;
self.out.write_all(&[separator])?;
has_prefix = true;
}
if self.opts.initial_tab && has_prefix {
self.out.write_all(b"\t")?;
}
Ok(())
}
fn write_record(&mut self, record: &[u8]) -> io::Result<()> {
self.out.write_all(record)?;
if record.last().copied() != Some(self.opts.record_terminator) {
self.out.write_all(&[self.opts.record_terminator])?;
}
self.flush_record()
}
fn write_path_record(&mut self) -> io::Result<()> {
self.out.write_all(self.display)?;
let terminator = if self.opts.null_paths {
b'\0'
} else {
self.opts.record_terminator
};
self.out.write_all(&[terminator])?;
self.flush_record()
}
fn print_only_matching(
&mut self,
line: &[u8],
line_number: Option<u64>,
line_offset: u64,
) -> io::Result<()> {
let mut at = 0usize;
while at <= line.len() {
let Some(found) = self
.matcher
.find_at(line, at)
.map_err(|error| io::Error::other(error.to_string()))?
else {
break;
};
if found.is_empty() {
at = found.end() + 1;
continue;
}
let match_offset = line_offset.saturating_add(
u64::try_from(found.start()).map_err(|error| io::Error::other(error.to_string()))?,
);
self.write_prefix(line_number, match_offset, b':')?;
self.write_record(&line[found.start()..found.end()])?;
at = found.end();
}
Ok(())
}
fn normal_output_is_suppressed(&self) -> bool {
self.opts.count
|| self.opts.files_with_matches
|| self.opts.files_without_match
|| self.opts.quiet
}
fn binary_summary(&self) -> bool {
self.binary
&& self.opts.binary_files == BinaryFiles::Binary
&& !self.normal_output_is_suppressed()
}
}
impl<M: Matcher, W: Write> Sink for GrepSink<'_, M, W> {
type Error = io::Error;
fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, io::Error> {
if self.binary && self.opts.binary_files == BinaryFiles::WithoutMatch {
return Ok(false);
}
self.any_match = true;
self.match_count += 1;
if self.opts.quiet
|| self.opts.files_with_matches
|| self.opts.files_without_match
|| self.binary_summary()
{
return Ok(false);
}
if self.opts.count {
return Ok(true);
}
if self.opts.only_matching {
self.print_only_matching(mat.bytes(), mat.line_number(), mat.absolute_byte_offset())?;
} else {
self.write_prefix(mat.line_number(), mat.absolute_byte_offset(), b':')?;
self.write_record(mat.bytes())?;
}
Ok(true)
}
fn context(&mut self, _searcher: &Searcher, ctx: &SinkContext<'_>) -> Result<bool, io::Error> {
if self.normal_output_is_suppressed() || self.opts.only_matching || self.binary_summary() {
return Ok(true);
}
self.write_prefix(ctx.line_number(), ctx.absolute_byte_offset(), b'-')?;
self.write_record(ctx.bytes())?;
Ok(true)
}
fn context_break(&mut self, _searcher: &Searcher) -> Result<bool, io::Error> {
if !self.normal_output_is_suppressed()
&& !self.opts.only_matching
&& !self.binary_summary()
&& let Some(separator) = &self.opts.group_separator
{
self.out.write_all(separator)?;
self.out.write_all(&[self.opts.record_terminator])?;
self.flush_record()?;
}
Ok(true)
}
fn binary_data(
&mut self,
_searcher: &Searcher,
_binary_byte_offset: u64,
) -> Result<bool, io::Error> {
self.binary = true;
if self.opts.binary_files == BinaryFiles::WithoutMatch {
self.any_match = false;
self.match_count = 0;
return Ok(false);
}
Ok(true)
}
fn finish(&mut self, _searcher: &Searcher, _: &SinkFinish) -> Result<(), io::Error> {
if self.opts.quiet {
return Ok(());
}
if self.binary_summary() && self.any_match {
self.out.write_all(b"Binary file ")?;
self.out.write_all(self.display)?;
self.out.write_all(b" matches")?;
self.out.write_all(&[self.opts.record_terminator])?;
return self.flush_record();
}
if self.opts.files_with_matches {
if self.any_match {
self.write_path_record()?;
}
} else if self.opts.files_without_match {
if !self.any_match {
self.write_path_record()?;
}
} else if self.opts.count {
if self.opts.prefix_filename {
self.out.write_all(self.display)?;
if self.opts.null_paths {
self.out.write_all(b"\0")?;
} else {
self.out.write_all(b":")?;
}
}
write!(self.out, "{}", self.match_count)?;
self.out.write_all(&[self.opts.record_terminator])?;
self.flush_record()?;
}
Ok(())
}
}
/// Search one input and return whether it contained a selected record.
fn process_reader<M: Matcher, R: Read, W: Write>(
matcher: &M,
searcher: &mut Searcher,
reader: R,
display: &[u8],
opts: &Options,
out: &mut W,
) -> io::Result<bool> {
let mut sink =
GrepSink { out, matcher, display, opts, match_count: 0, any_match: false, binary: false };
searcher.search_reader(matcher, reader, &mut sink)?;
Ok(sink.any_match)
}
fn display_path_for_operand(operand: &OsStr, resolved: &Path, path: &Path) -> PathBuf {
let rel = path.strip_prefix(resolved).unwrap_or(path);
if rel.as_os_str().is_empty() {
PathBuf::from(operand)
} else {
Path::new(operand).join(rel)
}
}
#[allow(clippy::too_many_arguments)]
fn search_file_path<M: Matcher, W: Write>(
host: &mut Host,
operand: &OsStr,
resolved: &Path,
path: &Path,
matcher: &M,
searcher: &mut Searcher,
opts: &Options,
out: &mut W,
had_error: &mut bool,
) -> bool {
let display_path = display_path_for_operand(operand, resolved, path);
match File::open(path) {
Ok(file) => {
let display = display_path.as_os_str().as_encoded_bytes();
match process_reader(matcher, searcher, file, display, opts, out) {
Ok(matched) => matched,
Err(error) => {
*had_error = true;
if !opts.no_messages {
let _ = writeln!(
host.stderr,
"grep: {}: {error}",
display_path.to_string_lossy()
);
}
false
},
}
},
Err(error) => {
*had_error = true;
if !opts.no_messages {
let _ = writeln!(
host.stderr,
"grep: {}: {error}",
display_path.to_string_lossy()
);
}
false
},
}
}
fn grep_walk_request(root: &Path, follow_links: pi_walker::FollowLinks) -> pi_walker::WalkRequest {
pi_walker::WalkRequest::new(root)
.hidden(true)
.gitignore(false)
.skip_git(false)
.skip_node_modules(false)
.follow_links(follow_links)
.detail(pi_walker::WalkDetail::Minimal)
.order(pi_walker::WalkOrder::Unordered)
.emit_root(true)
.depth(0, usize::MAX)
.visit_order(pi_walker::VisitOrder::PreOrder)
.directory_errors(pi_walker::DirectoryErrorMode::Visit)
.same_file_system(false)
.cache(false)
.filter(pi_walker::WalkFilter::all())
}
/// Recursively search a directory operand while pruning excluded directories.
#[allow(clippy::too_many_arguments)]
fn search_dir<M: Matcher, W: Write>(
host: &mut Host,
operand: &OsStr,
resolved: &Path,
matcher: &M,
searcher: &mut Searcher,
opts: &Options,
rules: &PathRules,
follow_links: pi_walker::FollowLinks,
out: &mut W,
had_error: &mut bool,
) -> bool {
let request = grep_walk_request(resolved, follow_links);
let mut any = false;
let had_error_state = std::cell::Cell::new(*had_error);
let cancel = host.cancel_flag();
let mut walk_err = host.stderr_clone();
let walk = request.for_each_entry_with_heartbeat(
|| {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
Err(io::Error::from(io::ErrorKind::Interrupted))
} else {
Ok::<(), io::Error>(())
}
},
|entry: pi_walker::EntryMeta<'_>| {
if opts.quiet && any {
return Ok(pi_walker::WalkDecision::Stop);
}
if entry.file_type == pi_walker::FileType::Dir {
if entry.depth > 0 && !rules.allows_dir(Path::new(entry.relative_path)) {
return Ok(pi_walker::WalkDecision::SkipDescend);
}
return Ok(pi_walker::WalkDecision::Include);
}
if entry.file_type != pi_walker::FileType::File
|| !rules.allows_file(Path::new(entry.relative_path))
{
return Ok(pi_walker::WalkDecision::Skip);
}
let mut entry_had_error = had_error_state.get();
let matched = search_file_path(
host,
operand,
resolved,
entry.absolute_path.as_ref(),
matcher,
searcher,
opts,
out,
&mut entry_had_error,
);
had_error_state.set(entry_had_error);
any |= matched;
if opts.quiet && any {
Ok(pi_walker::WalkDecision::Stop)
} else {
Ok(pi_walker::WalkDecision::Include)
}
},
|error: pi_walker::DirectoryError<'_>| {
had_error_state.set(true);
if !opts.no_messages {
let display_path = display_path_for_operand(operand, resolved, error.path);
let _ = writeln!(
walk_err,
"grep: {}: {}",
display_path.to_string_lossy(),
error.error
);
}
Ok(pi_walker::WalkDecision::Include)
},
);
*had_error |= had_error_state.get();
match walk {
Ok(pi_walker::WalkStatus::Complete | pi_walker::WalkStatus::Stopped) => any,
Err(pi_walker::WalkError::Interrupted(_)) if host.is_cancelled() => {
// The shell wrapper owns the user-visible cancellation status.
*had_error = true;
any
},
Err(pi_walker::WalkError::Interrupted(error)) => {
*had_error = true;
if !opts.no_messages {
let _ = writeln!(host.stderr, "grep: {error}");
}
any
},
Err(pi_walker::WalkError::InvalidData { path, message }) => {
*had_error = true;
if !opts.no_messages {
let display_path = display_path_for_operand(operand, resolved, &path);
let _ = writeln!(
host.stderr,
"grep: {}: {message}",
display_path.to_string_lossy()
);
}
any
},
}
}
fn read_auxiliary_file(host: &mut Host, path: &OsStr) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
let result = if path == OsStr::new("-") {
host.stdin.read_to_end(&mut bytes)
} else {
File::open(host.resolve(path)).and_then(|mut file| file.read_to_end(&mut bytes))
};
result
.map(|_| bytes)
.map_err(|error| format!("{}: {error}", path.to_string_lossy()))
}
fn pattern_file_lines(bytes: &[u8]) -> Vec<String> {
if bytes.is_empty() {
return Vec::new();
}
String::from_utf8_lossy(bytes)
.split_terminator('\n')
.map(str::to_owned)
.collect()
}
fn resolve_patterns(host: &mut Host, cli: &GrepArgs) -> Result<(Vec<String>, Vec<OsString>), String> {
let has_explicit_patterns = !cli.patterns.is_empty() || !cli.pattern_files.is_empty();
let mut patterns = Vec::new();
let mut files = Vec::new();
if has_explicit_patterns {
for pattern in &cli.patterns {
patterns.extend(pattern.split('\n').map(str::to_owned));
}
for path in &cli.pattern_files {
patterns.extend(pattern_file_lines(&read_auxiliary_file(host, path)?));
}
files.clone_from(&cli.args);
return Ok((patterns, files));
}
let mut args = cli.args.iter();
let Some(pattern) = args.next() else {
return Err("no pattern given\nUsage: grep [OPTION]... PATTERN [FILE]...".to_owned());
};
patterns.extend(pattern.to_string_lossy().split('\n').map(str::to_owned));
files.extend(args.cloned());
Ok((patterns, files))
}
fn collect_rule_specs(
host: &mut Host,
cli: &GrepArgs,
matches: &ArgMatches,
) -> Result<(Vec<RuleSpec>, Vec<RuleSpec>), String> {
let mut files = Vec::new();
if let Some(indices) = matches.indices_of("include") {
for (index, pattern) in indices.zip(&cli.include) {
files.push(RuleSpec { index, include: true, pattern: pattern.clone() });
}
}
if let Some(indices) = matches.indices_of("exclude") {
for (index, pattern) in indices.zip(&cli.exclude) {
files.push(RuleSpec { index, include: false, pattern: pattern.clone() });
}
}
if let Some(indices) = matches.indices_of("exclude_from") {
for (index, path) in indices.zip(&cli.exclude_from) {
for pattern in pattern_file_lines(&read_auxiliary_file(host, path)?) {
files.push(RuleSpec { index, include: false, pattern });
}
}
}
let mut dirs = Vec::new();
if let Some(indices) = matches.indices_of("include_dir") {
for (index, pattern) in indices.zip(&cli.include_dir) {
dirs.push(RuleSpec { index, include: true, pattern: pattern.clone() });
}
}
if let Some(indices) = matches.indices_of("exclude_dir") {
for (index, pattern) in indices.zip(&cli.exclude_dir) {
dirs.push(RuleSpec { index, include: false, pattern: pattern.clone() });
}
}
Ok((files, dirs))
}
fn compile_rules(mut specs: Vec<RuleSpec>) -> Result<Vec<PathRule>, String> {
specs.sort_by_key(|spec| spec.index);
specs
.into_iter()
.map(|spec| {
Glob::new(&spec.pattern)
.map(|glob| PathRule { include: spec.include, matcher: glob.compile_matcher() })
.map_err(|error| format!("{}: {error}", spec.pattern))
})
.collect()
}
fn build_path_rules(host: &mut Host, cli: &GrepArgs, matches: &ArgMatches) -> Result<PathRules, String> {
let (files, dirs) = collect_rule_specs(host, cli, matches)?;
Ok(PathRules { files: compile_rules(files)?, dirs: compile_rules(dirs)? })
}
fn build_searcher(cli: &GrepArgs, opts: &Options, max_count: Option<u64>) -> Searcher {
let binary_detection = if cli.null_data || opts.binary_files == BinaryFiles::Text {
BinaryDetection::none()
} else if opts.binary_files == BinaryFiles::WithoutMatch {
BinaryDetection::quit(b'\0')
} else {
BinaryDetection::convert(b'\0')
};
let mut builder = SearcherBuilder::new();
builder
.line_number(opts.line_number)
.before_context(opts.before)
.after_context(opts.after)
.invert_match(cli.invert)
.binary_detection(binary_detection)
.max_matches(max_count);
if cli.null_data {
builder.line_terminator(LineTerminator::byte(b'\0'));
}
builder.build()
}
#[allow(clippy::too_many_arguments)]
fn execute_search<M: Matcher>(
host: &mut Host,
cli: &GrepArgs,
matcher: &M,
files: &[OsString],
directory_action: DirectoryAction,
follow_links: pi_walker::FollowLinks,
rules: &PathRules,
opts: &Options,
max_count: Option<u64>,
) -> i32 {
let mut searcher = build_searcher(cli, opts, max_count);
let mut out = host.stdout_writer();
let mut any_match = false;
let mut had_error = false;
let mut processed_operand = false;
for operand in files {
if opts.quiet && any_match {
break;
}
if processed_operand && host.is_cancelled() {
had_error = true;
break;
}
processed_operand = true;
if operand == OsStr::new("-") {
let display = cli
.label
.as_deref()
.unwrap_or_else(|| OsStr::new("(standard input)"))
.as_encoded_bytes();
match process_reader(
matcher,
&mut searcher,
&mut host.stdin,
display,
opts,
&mut out,
) {
Ok(matched) => any_match |= matched,
Err(error) => {
had_error = true;
if !opts.no_messages {
let _ = writeln!(host.stderr, "grep: (standard input): {error}");
}
},
}
if host.is_cancelled() {
had_error = true;
break;
}
continue;
}
let resolved = host.resolve(operand);
match std::fs::metadata(&resolved) {
Ok(metadata) if metadata.is_dir() => match directory_action {
DirectoryAction::Recurse => {
if rules.allows_dir(Path::new(operand))
&& search_dir(
host,
operand.as_os_str(),
&resolved,
matcher,
&mut searcher,
opts,
rules,
follow_links,
&mut out,
&mut had_error,
) {
any_match = true;
}
},
DirectoryAction::Skip => {},
DirectoryAction::Read => {
had_error = true;
let _ = writeln!(
host.stderr,
"grep: {}: Is a directory",
operand.to_string_lossy()
);
},
},
Ok(metadata) => {
if cli.devices == Some(DeviceAction::Skip) && !metadata.is_file() {
continue;
}
if !rules.allows_file(Path::new(operand)) {
continue;
}
if search_file_path(
host,
operand.as_os_str(),
&resolved,
&resolved,
matcher,
&mut searcher,
opts,
&mut out,
&mut had_error,
) {
any_match = true;
}
},
Err(error) => {
had_error = true;
if !opts.no_messages {
let _ =
writeln!(host.stderr, "grep: {}: {error}", operand.to_string_lossy());
}
},
}
if host.is_cancelled() {
had_error = true;
break;
}
}
let _ = out.flush();
if opts.quiet {
if any_match {
0
} else if had_error {
2
} else {
1
}
} else if had_error {
2
} else if any_match {
0
} else {
1
}
}
impl Utility for Grep {
const NAME: &'static str = "grep";
const USAGE_ERROR: u8 = 2;
fn rewrite_argv(argv: Vec<OsString>) -> Result<Vec<OsString>, String> {
Ok(normalize_context_args(argv))
}
fn run(self, host: &mut Host) -> i32 {
let cli = self.cli;
let matches = self.matches;
let (mut patterns, mut files) = match resolve_patterns(host, &cli) {
Ok(resolved) => resolved,
Err(error) => {
let _ = writeln!(host.stderr, "grep: {error}");
return 2;
},
};
let directory_action = resolve_directory_action(&cli, &matches);
if files.is_empty() {
files.push(OsString::from(if directory_action == DirectoryAction::Recurse {
"."
} else {
"-"
}));
}
let max_count = match resolve_max_count(&cli) {
Ok(max_count) => max_count,
Err(error) => {
let _ = writeln!(host.stderr, "grep: {error}");
return 2;
},
};
let rules = match build_path_rules(host, &cli, &matches) {
Ok(rules) => rules,
Err(error) => {
let _ = writeln!(host.stderr, "grep: {error}");
return 2;
},
};
let matcher = match build_matcher(
host,
&patterns,
&cli,
resolve_match_mode(&matches),
resolve_ignore_case(&matches),
) {
Ok(matcher) => matcher,
Err(error) => {
let _ = writeln!(host.stderr, "grep: {error}");
return 2;
},
};
patterns.clear();
let (files_with_matches, files_without_match) = resolve_file_list_modes(&matches);
let suppress_context =
cli.count || files_with_matches || files_without_match || cli.quiet || cli.only_matching;
let (before, after) = if suppress_context {
(0, 0)
} else {
resolve_context(&cli, &matches)
};
let prefix_filename = resolve_filename_prefix(&matches)
.unwrap_or(directory_action == DirectoryAction::Recurse || files.len() > 1);
let opts = Options {
line_number: cli.line_number,
byte_offset: cli.byte_offset,
count: cli.count,
files_with_matches,
files_without_match,
only_matching: cli.only_matching,
before,
after,
no_messages: cli.no_messages,
quiet: cli.quiet,
prefix_filename,
initial_tab: cli.initial_tab,
null_paths: cli.null_paths,
record_terminator: if cli.null_data { b'\0' } else { b'\n' },
group_separator: resolve_group_separator(&cli, &matches),
line_buffered: cli.line_buffered,
binary_files: resolve_binary_files(&cli, &matches),
};
let follow_links = resolve_follow_links(&cli, &matches);
match matcher {
CompiledMatcher::Rust(matcher) => execute_search(
host,
&cli,
&matcher,
&files,
directory_action,
follow_links,
&rules,
&opts,
max_count,
),
CompiledMatcher::Pcre(matcher) => execute_search(
host,
&cli,
&matcher,
&files,
directory_action,
follow_links,
&rules,
&opts,
max_count,
),
}
}
}
/// Creates the GNU `grep` builtin registration.
pub(crate) fn grep_builtin<SE: ShellExtensions>() -> Registration<SE> {
util::<Grep, SE>()
}
#[cfg(test)]
mod tests {
use std::{
io::{self, Read, Write},
sync::Arc,
};
use parking_lot::Mutex;
use super::*;
use brush_core::openfiles;
use crate::host::{Host, run_caught, run_util};
struct SnapshottingStdin {
pos: usize,
snapped: bool,
stdout: Arc<Mutex<Option<Arc<Mutex<Vec<u8>>>>>>,
snapshot: Arc<Mutex<Vec<u8>>>,
}
const SNAPSHOT_INPUT: &[u8] = b"hit\nmiss\n";
impl Read for SnapshottingStdin {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.pos < SNAPSHOT_INPUT.len() {
let n = buf.len().min(SNAPSHOT_INPUT.len() - self.pos);
buf[..n].copy_from_slice(&SNAPSHOT_INPUT[self.pos..self.pos + n]);
self.pos += n;
return Ok(n);
}
// Input exhausted: grep is back asking for more. Whatever it has
// already flushed to stdout is what a live consumer would see now.
if !self.snapped {
let stdout = self.stdout.lock().clone().expect("stdout buffer is initialized");
*self.snapshot.lock() = stdout.lock().clone();
self.snapped = true;
}
Ok(0)
}
}
impl Write for SnapshottingStdin {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl openfiles::Stream for SnapshottingStdin {
fn clone_box(&self) -> Box<dyn openfiles::Stream> {
Box::new(Self {
pos: self.pos,
snapped: self.snapped,
stdout: Arc::clone(&self.stdout),
snapshot: Arc::clone(&self.snapshot),
})
}
#[cfg(unix)]
fn try_clone_to_owned(&self) -> Result<std::os::fd::OwnedFd, brush_core::Error> {
Err(brush_core::error::ErrorKind::CannotConvertToNativeFd.into())
}
#[cfg(unix)]
fn try_borrow_as_fd(&self) -> Result<std::os::fd::BorrowedFd<'_>, brush_core::Error> {
Err(brush_core::error::ErrorKind::CannotConvertToNativeFd.into())
}
}
fn run(args: &[&str], stdin: &str) -> (i32, String, String) {
let (code, capture) = run_util::<Grep>(args, stdin, "/");
(code, capture.out(), capture.err())
}
#[test]
fn stdin_matches_are_visible_before_eof() {
let stdout = Arc::new(Mutex::new(None));
let snapshot = Arc::new(Mutex::new(Vec::new()));
let stdin = Box::new(SnapshottingStdin {
pos: 0,
snapped: false,
stdout: Arc::clone(&stdout),
snapshot: Arc::clone(&snapshot),
});
let (mut host, capture) = Host::for_test_with_stdin("grep", stdin, "/");
*stdout.lock() = Some(capture.stdout_buffer());
let parsed = Grep::try_parse_from(["grep", "hit", "-"]).unwrap();
assert_eq!(run_caught(parsed, &mut host), 0, "{}", capture.err());
// A regression re-buffering grep's output makes matches invisible until EOF.
assert_eq!(snapshot.lock().as_slice(), b"hit\n");
}
#[test]
fn max_count_and_no_match_statuses_are_gnu_compatible() {
let (code, out, err) = run(&["-m1", "hit"], "hit\nmiss\nhit\n");
assert_eq!(code, 0, "{err}");
assert_eq!(out, "hit\n");
let (code, out, _) = run(&["absent"], "hit\n");
assert_eq!(code, 1);
assert!(out.is_empty());
}
#[test]
fn pattern_file_is_resolved_against_shell_cwd() {
let tree = tempfile::tempdir().unwrap();
std::fs::write(tree.path().join("patterns"), "alpha\nbeta\n").unwrap();
std::fs::write(tree.path().join("haystack"), "alpha\ngamma\nbeta\n").unwrap();
let (code, capture) = run_util::<Grep>(&["-f", "patterns", "haystack"], "", tree.path());
assert_eq!(code, 0, "{}", capture.err());
assert_eq!(capture.out(), "alpha\nbeta\n");
}
#[test]
fn perl_mode_supports_lookbehind() {
let (code, out, err) = run(&["-P", "(?<=foo)bar"], "foobar\nbar\n");
assert_eq!(code, 0, "{err}");
assert_eq!(out, "foobar\n");
}
#[test]
fn compact_numeric_context_is_rewritten_before_clap() {
let input = "a\nhit\nb\ngap\nc\nhit\nd\n";
let (code, out, err) = run(&["-1", "--group-separator=@", "hit"], input);
assert_eq!(code, 0, "{err}");
assert_eq!(out, "a\nhit\nb\n@\nc\nhit\nd\n");
}
#[test]
fn recursive_rules_filter_walk_and_preserve_relative_names() {
let tree = tempfile::tempdir().unwrap();
std::fs::write(tree.path().join("keep.rs"), "hit\n").unwrap();
std::fs::write(tree.path().join("drop.txt"), "hit\n").unwrap();
std::fs::create_dir(tree.path().join("vendor")).unwrap();
std::fs::write(tree.path().join("vendor/hidden.rs"), "hit\n").unwrap();
let (code, capture) = run_util::<Grep>(&["-r", "--include=*.rs", "--exclude-dir=vendor", "hit", "."], "", tree.path());
assert_eq!(code, 0, "{}", capture.err());
assert!(capture.out().contains("keep.rs:hit"));
assert!(!capture.out().contains("drop.txt"));
assert!(!capture.out().contains("hidden.rs"));
}
#[test]
fn quiet_match_wins_over_later_error() {
let tree = tempfile::tempdir().unwrap();
std::fs::write(tree.path().join("hit"), "needle\n").unwrap();
let (code, capture) = run_util::<Grep>(&["-q", "needle", "hit", "missing"], "", tree.path());
assert_eq!(code, 0);
assert!(capture.out().is_empty());
assert!(capture.err().is_empty());
}
#[test]
fn recursive_walk_observes_cancellation() {
let tree = tempfile::tempdir().unwrap();
std::fs::create_dir(tree.path().join("root")).unwrap();
std::fs::write(tree.path().join("root/file"), "hit\n").unwrap();
let parsed = Grep::try_parse_from(["grep", "-r", "hit", "root"]).unwrap();
let (mut host, capture) = Host::for_test("grep", Vec::new(), tree.path());
host.cancel_for_test();
assert_eq!(parsed.run(&mut host), 2);
assert!(capture.out().is_empty());
assert!(capture.err().is_empty());
}
#[test]
fn byte_offsets_labels_and_nul_separators_are_rendered() {
let (code, out, err) = run(&["-bn", "hit"], "no\nhit\n");
assert_eq!(code, 0, "{err}");
assert_eq!(out, "2:3:hit\n");
let (code, out, err) = run(&["--label=pipe", "-HZ", "hit"], "hit\n");
assert_eq!(code, 0, "{err}");
assert_eq!(out.as_bytes(), b"pipe\0hit\n");
}
#[test]
fn basic_mode_falls_back_per_pattern_but_extended_mode_is_strict() {
let (code, out, err) = run(&["-A", "1", "fail)"], "ok\n(1 fail)\nnext\n");
assert_eq!(code, 0, "{err}");
assert_eq!(out, "(1 fail)\nnext\n");
let (code, _, err) = run(&["-E", "fail)"], "fail)\n");
assert_eq!(code, 2);
assert!(err.contains("grep:"));
let (code, out, err) =
run(&["-e", "fo+", "-e", "bar)", "-h"], "foooo\nbar)\nbaz\n");
assert_eq!(code, 0, "{err}");
assert_eq!(out, "foooo\nbar)\n");
}
#[test]
fn gnu_basic_alternation_and_color_aliases_are_supported() {
let input = "\"tools.xdev\": {}\n\"tools.toolbox\": {}\n\"tools.other\": {}\n";
let (code, out, err) = run(&["-c", r"tools.xdev\|tools.toolbox"], input);
assert_eq!(code, 0, "{err}");
assert_eq!(out, "2\n");
for color in ["--color=auto", "--color=always", "--color=never", "--color"] {
let (code, out, err) = run(&[color, "foo"], "foo\nbar\n");
assert_eq!(code, 0, "{err}");
assert_eq!(out, "foo\n");
}
}
#[test]
fn version_is_reported_on_stdout() {
let (code, out, err) = run(&["--version"], "");
assert_eq!(code, 0);
assert!(err.is_empty());
assert!(out.contains("grep") && out.contains("pi-uu-grep"));
}
}