use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use grep_matcher::Matcher;
use serde::{Deserialize, Serialize};
use crate::tools::path::shell_quote;
use crate::tools::shell::SHELL_PIPE_READ_CAP;
use crate::tools::shell::scan::strip_heredoc_bodies;
use crate::util::is_word_char;
const ENGINE_VERB: &str = "__grep-engine";
const PROTOCOL_VERSION: u32 = 5;
const BINARY_WINDOW: usize = 32 * 1024;
const OUTPUT_CAP: usize = SHELL_PIPE_READ_CAP;
pub(super) const ENGINE_FAILED_EXIT: i32 = 3;
pub(super) const STALE_BINARY_LOCK_MSG: &str = "Another instance of mahbot is already running";
pub(super) const STREAM_SIZE_MARKER: &str = "__mahbot_stream_bytes__";
const MAX_SPEC_JSON: usize = 64 * 1024;
const HEAP_LIMIT: usize = 512 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct EngineSpec {
version: u32,
verb: String,
mode: MatchMode,
flags: GrepFlags,
filters: Vec<(bool, String)>,
exclude_dir: Vec<String>,
patterns: Vec<String>,
operands: Vec<Operand>,
cwd: String,
fallback: Vec<String>,
#[serde(default)] piped: bool,
#[serde(default)]
stdin: bool,
#[serde(default)]
report_stream_bytes: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Operand {
display: String,
resolved: String,
trailing_slash: bool,
}
pub(super) fn try_serve_command(command: &str, workspace_root: &Path) -> Option<String> {
let home = pinned_home()?;
let (specs, shapes, rewritten) = match analyze_command(command, workspace_root, &home, false) {
Ok(v) => v,
Err(reason) => {
if matches!(
reason,
Fallback::SegmentEmpty
| Fallback::StdinOperands
| Fallback::StdinRecursive
| Fallback::NestedGrep
| Fallback::StdinMode
) && command.split_whitespace().any(is_grep_verb)
{
tracing::info!(command = command, %reason, "grep engine: fallback");
} else {
tracing::debug!(command = command, %reason, "grep engine: fallback");
}
return None;
}
};
if !engine_available() {
return None;
}
for spec in &specs {
if !spec_json_ok(spec) {
return None;
}
}
if shapes.is_empty() {
tracing::debug!(
command = command,
greps = specs.len(),
"grep engine: served"
);
} else {
for (members, shape, stdin) in shapes {
tracing::debug!(
command = command,
members,
shape = %shape,
stdin,
"grep engine: served"
);
}
}
Some(rewritten)
}
#[cfg(feature = "grep-engine-e2e")]
#[doc(hidden)]
#[must_use]
pub fn try_serve_command_for_test(
command: &str,
workspace_root: &Path,
home: &Path,
) -> Option<String> {
let (specs, _, rewritten) = analyze_command(command, workspace_root, home, false).ok()?;
for spec in &specs {
if !spec_json_ok(spec) {
return None;
}
}
Some(rewritten)
}
fn pinned_home() -> Option<PathBuf> {
directories::UserDirs::new().map(|d| d.home_dir().to_path_buf())
}
fn spec_json_ok(spec: &EngineSpec) -> bool {
serde_json::to_string(spec).is_ok_and(|j| j.len() <= MAX_SPEC_JSON)
}
pub(super) fn strip_stream_size_marker(stderr: &mut Vec<u8>) -> Option<u64> {
if !stderr
.windows(STREAM_SIZE_MARKER.len())
.any(|w| w == STREAM_SIZE_MARKER.as_bytes())
{
return None;
}
let mut last = None;
let mut cleaned = Vec::with_capacity(stderr.len());
let mut rest = &stderr[..];
while let Some(pos) = rest
.windows(STREAM_SIZE_MARKER.len())
.position(|w| w == STREAM_SIZE_MARKER.as_bytes())
{
let line_start = rest[..pos]
.iter()
.rposition(|&b| b == b'\n')
.map_or(0, |p| p + 1);
let line_end = rest[pos..]
.iter()
.position(|&b| b == b'\n')
.map_or(rest.len(), |p| pos + p + 1);
cleaned.extend_from_slice(&rest[..line_start]);
if let Some(v) = std::str::from_utf8(&rest[pos + STREAM_SIZE_MARKER.len()..line_end])
.ok()
.and_then(|s| {
s.trim()
.strip_prefix(':')
.and_then(|s| s.trim().parse::<u64>().ok())
})
{
last = Some(v);
}
rest = &rest[line_end..];
}
cleaned.extend_from_slice(rest);
*stderr = cleaned;
last
}
static ENGINE_AVAILABLE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
fn engine_available() -> bool {
*ENGINE_AVAILABLE.get_or_init(|| {
let Some(exe) = std::env::current_exe().ok() else {
return false;
};
std::process::Command::new(exe)
.arg(ENGINE_VERB)
.arg("--probe")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
})
}
fn build_rewritten(spec: &EngineSpec) -> String {
let json = serde_json::to_string(spec).expect("spec serializes");
let exe = std::env::current_exe().expect("current exe resolved");
format!(
"{} {} {}",
shell_quote(&exe.to_string_lossy()),
ENGINE_VERB,
shell_quote(&json)
)
}
#[derive(Debug)]
enum Fallback {
NoGrep,
NestedGrep,
Heredoc,
StdinMode,
UnsupportedFlag(String),
MissingOptionValue,
EmptyAlternation,
Pattern(String),
CompileFailure(String),
UnresolvableOperand(String),
UnexpandableGlob,
SingleFile,
CdUntrackable,
StdinOperands,
StdinRecursive,
SegmentEmpty,
}
impl std::fmt::Display for Fallback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Fallback::NoGrep => write!(f, "no grep"),
Fallback::NestedGrep => write!(f, "nested grep"),
Fallback::Heredoc => write!(f, "heredoc present"),
Fallback::StdinMode => write!(f, "stdin mode"),
Fallback::UnsupportedFlag(s) => write!(f, "unsupported flag {s}"),
Fallback::MissingOptionValue => write!(f, "missing option value"),
Fallback::EmptyAlternation => write!(f, "empty alternation"),
Fallback::Pattern(s) => write!(f, "pattern: {s}"),
Fallback::CompileFailure(s) => write!(f, "compile failure: {s}"),
Fallback::UnresolvableOperand(s) => write!(f, "unresolvable operand: {s}"),
Fallback::UnexpandableGlob => write!(f, "unexpandable glob"),
Fallback::SingleFile => write!(f, "single file"),
Fallback::CdUntrackable => write!(f, "cd untrackable"),
Fallback::StdinOperands => write!(f, "stdin with operands"),
Fallback::StdinRecursive => write!(f, "stdin with -r"),
Fallback::SegmentEmpty => write!(f, "empty command or pipeline member"),
}
}
}
type AnalyzeOutput = (Vec<EngineSpec>, Vec<(usize, String, bool)>, String);
fn analyze_command(
command: &str,
workspace_root: &Path,
home: &Path,
allow_single: bool,
) -> Result<AnalyzeOutput, Fallback> {
let stripped = strip_heredoc_bodies(command);
if stripped != command {
return Err(Fallback::Heredoc);
}
let segments = split_segments(&stripped)?;
if segments.is_empty() {
return Err(Fallback::SegmentEmpty);
}
if !segments.iter().any(|(seg, _)| {
let verb = first_word(seg);
is_grep_verb(verb) || segment_contains_grep(seg, verb)
}) {
return Err(Fallback::NoGrep);
}
let n = segments.len();
let mut pstart = vec![0usize; n];
let mut pend = vec![0usize; n];
let mut i = 0;
while i < n {
let mut j = i;
while j + 1 < n && matches!(segments[j].1.as_str(), "|" | "|&") {
j += 1;
}
for k in i..=j {
pstart[k] = i;
pend[k] = j;
}
i = j + 1;
}
let mut cwd = canonical_or_lexical(workspace_root);
let mut rewritten: Vec<(String, String)> = Vec::new();
let mut specs: Vec<EngineSpec> = Vec::new();
let mut shapes: Vec<(usize, String, bool)> = Vec::new();
let mut exec_redirects_stderr = false;
let mut tail_preserved = vec![false; n];
for (idx, (seg, conn)) in segments.iter().enumerate() {
if tail_preserved[idx] {
rewritten.push((seg.clone(), conn.clone()));
continue;
}
let verb = first_word(seg);
if is_cd_segment(verb) {
if pstart[idx] != pend[idx] {
return Err(Fallback::CdUntrackable);
}
cwd = resolve_cd(seg, &cwd, home)?;
rewritten.push((seg.clone(), conn.clone()));
continue;
}
if is_grep_verb(verb) {
let ctx = PipelineCtx {
piped: pstart[idx] != pend[idx],
stdin_fed: pstart[idx] != idx,
marker_ok: !exec_redirects_stderr,
};
match serve_one_grep(seg, verb, &cwd, home, allow_single, ctx) {
Ok((spec, rewritten_seg)) => {
if ctx.piped {
tail_preserved[idx + 1..=pend[idx]].fill(true);
let verbs: Vec<&str> = segments[pstart[idx]..=pend[idx]]
.iter()
.map(|(s, _)| {
let v = first_word(s);
if is_grep_verb(v) { "grep" } else { v }
})
.collect();
shapes.push((verbs.len(), verbs.join("|"), spec.stdin));
}
rewritten.push((rewritten_seg, conn.clone()));
specs.push(spec);
}
Err(e) => return Err(e),
}
continue;
}
if is_compound_segment(seg) || segment_contains_grep(seg, verb) {
return Err(Fallback::NestedGrep);
}
if verb == "exec" && (seg.contains("2>") || seg.contains("&>")) {
exec_redirects_stderr = true;
}
rewritten.push((seg.clone(), conn.clone()));
}
Ok((specs, shapes, join_rewritten(&rewritten)))
}
fn join_rewritten(segments: &[(String, String)]) -> String {
let mut out = String::new();
for (i, (seg, conn)) in segments.iter().enumerate() {
if i > 0 {
out.push(' ');
}
out.push_str(seg);
if i + 1 < segments.len() {
out.push(' ');
out.push_str(conn);
}
}
out
}
fn split_segments(command: &str) -> Result<Vec<(String, String)>, Fallback> {
super::segment_command(command, super::SegmentMode::Grep).ok_or(Fallback::SegmentEmpty)
}
fn first_word(segment: &str) -> &str {
segment.split_whitespace().next().unwrap_or("")
}
fn is_grep_verb(verb: &str) -> bool {
matches!(verb, "grep" | "egrep" | "fgrep")
}
fn is_cd_segment(verb: &str) -> bool {
verb == "cd"
}
const GREP_INTRODUCERS: &[&str] = &[
"if", "while", "until", "case", "for", "select", "then", "else", "elif", "do", "!", "time",
"command", "builtin", "exec", "eval", "sudo", "env", "nice", "nohup", "xargs", "ssh", "sh",
"bash", "zsh", "ksh", "dash", "csh", "tcsh", "fish", "find", "git", "docker", "kubectl",
"podman",
];
const COMPOUND_KEYWORDS: &[&str] = &[
"if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac",
"select",
];
fn is_compound_segment(segment: &str) -> bool {
let trimmed = segment.trim_start();
match trimmed.chars().next() {
Some('(' | '{') => true,
Some(c) if is_word_char(c) => COMPOUND_KEYWORDS.contains(&first_word(trimmed)),
_ => false,
}
}
fn segment_contains_grep(segment: &str, verb: &str) -> bool {
let suspicious = GREP_INTRODUCERS.contains(&verb) || verb.contains('=');
if !suspicious {
return false;
}
segment.split_whitespace().any(|w| {
let bare = crate::tools::shell::scan::strip_quoted_word(w);
is_grep_verb(bare)
})
}
fn resolve_cd(segment: &str, cwd: &Path, home: &Path) -> Result<PathBuf, Fallback> {
let words: Vec<&str> = segment.split_whitespace().collect();
let (target, next) = match super::scan::cd_target_after_options(&words, 1) {
super::scan::CdScan::Target(target, next) => (target, next),
super::scan::CdScan::Bare => return Ok(canonical_or_lexical(home)),
super::scan::CdScan::BadOption => return Err(Fallback::CdUntrackable),
};
if words.get(next).is_some() {
return Err(Fallback::CdUntrackable);
}
if has_expansion(target) {
return Err(Fallback::CdUntrackable);
}
let resolved = if target == "-" {
return Err(Fallback::CdUntrackable);
} else if let Some(rest) = target.strip_prefix("~/") {
home.join(rest)
} else if target == "~" {
home.to_path_buf()
} else if target.starts_with('/') {
PathBuf::from(target)
} else {
cwd.join(target)
};
Ok(canonical_or_lexical(&resolved))
}
fn canonical_or_lexical(p: &Path) -> PathBuf {
fs::canonicalize(p)
.or_else(|_| std::path::absolute(p))
.unwrap_or_else(|_| p.to_path_buf())
}
fn has_expansion(word: &str) -> bool {
let mut in_single = false;
let mut escaped = false;
for c in word.chars() {
if escaped {
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if c == '\'' {
in_single = !in_single;
continue;
}
if !in_single && (c == '$' || c == '`') {
return true;
}
}
false
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
enum MatchMode {
Basic,
Extended,
Fixed,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
#[expect(non_snake_case, clippy::struct_excessive_bools)] struct GrepFlags {
n: bool,
i: bool,
v: bool,
w: bool,
x: bool,
a: bool,
h: bool,
H: bool,
s: bool,
r: bool,
o: bool,
null: bool,
c: bool,
l: bool,
m: Option<u64>,
before: usize,
after: usize,
}
impl GrepFlags {
fn count_mode(&self) -> bool {
self.c || self.l
}
}
#[derive(Clone)]
struct GrepWord {
value: String,
raw: String,
redirect: bool,
needs_target: bool,
}
fn grep_tokenize(segment: &str) -> Result<Vec<GrepWord>, Fallback> {
let mut out = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
let mut chars = segment.chars().peekable();
let flush = |current: &mut String, out: &mut Vec<GrepWord>| -> Result<(), Fallback> {
if current.is_empty() {
return Ok(());
}
let raw = std::mem::take(current);
let value = unquote_word(&raw)?;
let (redirect, needs_target) = match super::scan::classify_shell_token(&raw) {
super::scan::TokenKind::Regular => (false, false),
super::scan::TokenKind::Redirect { needs_target } => (true, needs_target),
};
out.push(GrepWord {
value,
raw,
redirect,
needs_target,
});
Ok(())
};
while let Some(c) = chars.next() {
if c == '\\' && !in_single {
match chars.next() {
Some('\n') => continue,
Some(next) => {
current.push('\\');
current.push(next);
}
None => current.push('\\'),
}
continue;
}
if super::check_outside_quotes(c, &mut in_single, &mut in_double) {
if super::consume_substitution(c, &mut chars, &mut current) {
continue;
}
if c.is_whitespace() {
flush(&mut current, &mut out)?;
continue;
}
}
current.push(c);
}
flush(&mut current, &mut out)?;
Ok(out)
}
fn unquote_word(raw: &str) -> Result<String, Fallback> {
if has_expansion(raw) {
return Err(Fallback::UnresolvableOperand(raw.to_string()));
}
let mut out = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\'' => {
for c2 in chars.by_ref() {
if c2 == '\'' {
break;
}
out.push(c2);
}
}
'"' => {
for c2 in chars.by_ref() {
if c2 == '"' {
break;
}
out.push(c2);
}
}
'\\' => {
if let Some(next) = chars.next() {
out.push(next);
} else {
out.push('\\');
}
}
other => out.push(other),
}
}
Ok(out)
}
fn redirect_merges_stderr(r: &str) -> bool {
r.contains("2>") || r.contains("&>")
}
struct ParsedGrep {
mode: MatchMode,
flags: GrepFlags,
filters: Vec<(bool, String)>,
exclude_dir: Vec<String>,
engine_patterns: Vec<String>,
operand_tokens: Vec<String>,
redirects: Vec<String>,
fallback_prefix: Vec<String>,
}
#[expect(clippy::too_many_lines)] fn parse_grep_words(words: &[GrepWord], verb: &str) -> Result<ParsedGrep, Fallback> {
let mut argv: Vec<GrepWord> = Vec::new();
let mut redirects: Vec<String> = Vec::new();
let mut i = 0;
while i < words.len() {
if words[i].redirect {
redirects.push(words[i].raw.clone());
if words[i].needs_target && i + 1 < words.len() {
redirects.push(words[i + 1].raw.clone());
i += 1;
}
} else {
argv.push(words[i].clone());
}
i += 1;
}
let mut mode = match verb {
"egrep" => MatchMode::Extended,
"fgrep" => MatchMode::Fixed,
_ => MatchMode::Basic,
};
let mut flags = GrepFlags::default();
let mut filters = Vec::new();
let mut exclude_dir = Vec::new();
let mut e_patterns: Vec<String> = Vec::new();
let mut positional: Option<String> = None;
let mut operand_tokens: Vec<String> = Vec::new();
let mut options_ended = false;
let mut i = 0;
while i < argv.len() {
let tok = &argv[i];
if !options_ended && tok.value == "--" {
options_ended = true;
i += 1;
continue;
}
if !options_ended && tok.value.starts_with('-') && tok.value.len() > 1 {
if let Some(rest) = tok.value.strip_prefix("--") {
if rest == "null" {
flags.null = true;
} else if rest == "count" {
flags.c = true;
} else if rest == "files-with-matches" {
flags.l = true;
} else if let Some(v) = rest.strip_prefix("include=") {
filters.push((true, v.to_string()));
} else if let Some(v) = rest.strip_prefix("exclude=") {
filters.push((false, v.to_string()));
} else if let Some(v) = rest.strip_prefix("exclude-dir=") {
exclude_dir.push(v.to_string());
} else if rest == "include" || rest == "exclude" || rest == "exclude-dir" {
i += 1;
let v = argv
.get(i)
.map(|w| w.value.clone())
.ok_or(Fallback::MissingOptionValue)?;
match rest {
"include" => filters.push((true, v.clone())),
"exclude" => filters.push((false, v.clone())),
_ => exclude_dir.push(v.clone()),
}
} else {
return Err(Fallback::UnsupportedFlag(format!("--{rest}")));
}
} else {
let chars: Vec<char> = tok.value[1..].chars().collect();
let mut j = 0;
while j < chars.len() {
match chars[j] {
'n' => flags.n = true,
'i' | 'y' => flags.i = true,
'v' => flags.v = true,
'w' => flags.w = true,
'x' => flags.x = true,
'a' => flags.a = true,
'h' => flags.h = true,
'H' => flags.H = true,
's' => flags.s = true,
'r' | 'R' => flags.r = true,
'o' => flags.o = true,
'c' => flags.c = true,
'l' => flags.l = true,
'u' => {} 'G' => mode = MatchMode::Basic,
'E' => mode = MatchMode::Extended,
'F' => mode = MatchMode::Fixed,
'e' | 'm' | 'A' | 'B' | 'C' => {
let rest: String = chars[j + 1..].iter().collect();
let value = if rest.is_empty() {
i += 1;
argv.get(i)
.map(|w| w.value.clone())
.ok_or(Fallback::MissingOptionValue)?
} else {
rest
};
match chars[j] {
'e' => e_patterns.push(value),
'm' => {
let n: u64 = value
.parse()
.map_err(|_| Fallback::UnsupportedFlag("-m".into()))?;
if n == 0 {
return Err(Fallback::UnsupportedFlag("-m0".into()));
}
flags.m = Some(n);
}
'A' => {
flags.after = value
.parse()
.map_err(|_| Fallback::UnsupportedFlag("-A".into()))?;
}
'B' => {
flags.before = value
.parse()
.map_err(|_| Fallback::UnsupportedFlag("-B".into()))?;
}
'C' => {
let n: usize = value
.parse()
.map_err(|_| Fallback::UnsupportedFlag("-C".into()))?;
flags.before = n;
flags.after = n;
}
_ => unreachable!(),
}
break; }
other => {
return Err(Fallback::UnsupportedFlag(format!("-{other}")));
}
}
j += 1;
}
}
i += 1;
} else if positional.is_none() && e_patterns.is_empty() {
positional = Some(tok.value.clone());
i += 1;
} else {
operand_tokens.push(tok.raw.clone());
i += 1;
}
}
if flags.v {
flags.o = false; }
let count_mode = flags.count_mode();
if flags.v && (flags.before > 0 || flags.after > 0) && !count_mode {
return Err(Fallback::UnsupportedFlag("-v+context".into()));
}
if flags.o && (flags.before > 0 || flags.after > 0) && !count_mode {
return Err(Fallback::UnsupportedFlag("-o+context".into()));
}
if flags.o && flags.m.is_some() && !count_mode {
return Err(Fallback::UnsupportedFlag("-m+-o".into()));
}
let mut raw_patterns: Vec<String> = Vec::new();
for p in positional.into_iter().chain(e_patterns) {
raw_patterns.extend(p.split('\n').map(str::to_string));
}
if raw_patterns.is_empty() {
return Err(Fallback::Pattern("no pattern".into()));
}
if raw_patterns.len() > 1 && raw_patterns.iter().any(String::is_empty) {
return Err(Fallback::EmptyAlternation);
}
let mut engine_patterns = Vec::with_capacity(raw_patterns.len());
for p in &raw_patterns {
engine_patterns.push(translate_pattern(p, mode)?);
}
if flags.w {
for p in &engine_patterns {
if !word_safe(p) {
return Err(Fallback::Pattern("-w edge".into()));
}
}
}
if flags.o
&& !count_mode
&& mode != MatchMode::Fixed
&& engine_patterns.iter().any(|p| has_alternation(p))
{
return Err(Fallback::UnsupportedFlag("-o+alternation".into()));
}
let matcher = build_matcher(&engine_patterns, mode, &flags)
.map_err(|e| Fallback::CompileFailure(e.to_string()))?;
let matches_empty = matcher
.is_match(b"")
.map_err(|e| Fallback::CompileFailure(e.to_string()))?;
if matches_empty && !(raw_patterns.len() == 1 && raw_patterns[0].is_empty()) {
return Err(Fallback::EmptyAlternation);
}
drop(matcher);
let mut fallback_prefix = vec![verb.to_string()];
match (verb, mode) {
("grep" | "fgrep", MatchMode::Extended) => fallback_prefix.push("-E".into()),
("grep" | "egrep", MatchMode::Fixed) => fallback_prefix.push("-F".into()),
("egrep" | "fgrep", MatchMode::Basic) => fallback_prefix.push("-G".into()),
_ => {}
}
push_flag(&mut fallback_prefix, flags.n, "-n");
push_flag(&mut fallback_prefix, flags.i, "-i");
push_flag(&mut fallback_prefix, flags.v, "-v");
push_flag(&mut fallback_prefix, flags.w, "-w");
push_flag(&mut fallback_prefix, flags.x, "-x");
push_flag(&mut fallback_prefix, flags.a, "-a");
push_flag(&mut fallback_prefix, flags.h, "-h");
push_flag(&mut fallback_prefix, flags.H, "-H");
push_flag(&mut fallback_prefix, flags.s, "-s");
push_flag(&mut fallback_prefix, flags.r, "-r");
push_flag(&mut fallback_prefix, flags.o, "-o");
push_flag(&mut fallback_prefix, flags.c, "-c");
push_flag(&mut fallback_prefix, flags.l, "-l");
if flags.null {
fallback_prefix.push("--null".into());
}
if let Some(m) = flags.m {
fallback_prefix.push(format!("-m{m}"));
}
if flags.before > 0 {
fallback_prefix.push(format!("-B{}", flags.before));
}
if flags.after > 0 {
fallback_prefix.push(format!("-A{}", flags.after));
}
for (include, p) in &filters {
fallback_prefix.push(format!(
"--{}{p}",
if *include { "include=" } else { "exclude=" }
));
}
for p in &exclude_dir {
fallback_prefix.push(format!("--exclude-dir={p}"));
}
for p in &raw_patterns {
fallback_prefix.push("-e".into());
fallback_prefix.push(p.clone());
}
Ok(ParsedGrep {
mode,
flags,
filters,
exclude_dir,
engine_patterns,
operand_tokens,
redirects,
fallback_prefix,
})
}
fn push_flag(argv: &mut Vec<String>, on: bool, flag: &str) {
if on {
argv.push(flag.to_string());
}
}
fn translate_pattern(pattern: &str, mode: MatchMode) -> Result<String, Fallback> {
match mode {
MatchMode::Fixed => Ok(pattern.to_string()),
MatchMode::Extended => {
check_ere_safe(pattern)?;
Ok(pattern.to_string())
}
MatchMode::Basic => translate_bre(pattern),
}
}
fn check_ere_safe(pattern: &str) -> Result<(), Fallback> {
let mut escaped = false;
for c in pattern.chars() {
if escaped {
match c {
'<' | '>' => return Err(Fallback::Pattern(r"\< \>".into())),
's' | 'S' | 'w' | 'W' | 'd' | 'D' => {
return Err(Fallback::Pattern(r"\s\w\d".into()));
}
_ => {}
}
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
}
}
if pattern.contains("[[:<:]]") || pattern.contains("[[:>:]]") {
return Err(Fallback::Pattern("word-boundary class".into()));
}
Ok(())
}
fn translate_bre(pattern: &str) -> Result<String, Fallback> {
let mut out = String::with_capacity(pattern.len());
let mut chars = pattern.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
let Some(esc) = chars.next() else {
return Err(Fallback::Pattern("trailing backslash".into()));
};
match esc {
'|' | '(' | ')' | '+' | '?' => out.push(esc),
'{' => {
let mut body = String::new();
loop {
match chars.next() {
Some('\\') if chars.peek() == Some(&'}') => {
chars.next();
break;
}
Some(d @ '0'..='9') => body.push(d),
Some(',') => body.push(','),
Some(_) => {
return Err(Fallback::Pattern("bad interval".into()));
}
None => return Err(Fallback::Pattern("unclosed interval".into())),
}
}
out.push('{');
out.push_str(&body);
out.push('}');
}
'}' => return Err(Fallback::Pattern(r"lone \}".into())),
'b' => out.push_str("\\b"),
'1'..='9' => return Err(Fallback::Pattern("backreference".into())),
'<' | '>' => return Err(Fallback::Pattern(r"\< \>".into())),
's' | 'S' | 'w' | 'W' | 'd' | 'D' => {
return Err(Fallback::Pattern(r"\s\w\d".into()));
}
'.' | '*' | '\\' | '[' | ']' | '$' | '^' => {
out.push('\\');
out.push(esc);
}
other => return Err(Fallback::Pattern(format!(r"unknown escape \{other}"))),
}
} else {
match c {
'|' | '(' | ')' | '+' | '?' | '{' | '}' => {
out.push('\\');
out.push(c);
}
_ => out.push(c),
}
}
}
Ok(out)
}
fn has_alternation(s: &str) -> bool {
alternation_at(s, false)
}
fn has_top_level_alternation(s: &str) -> bool {
alternation_at(s, true)
}
fn alternation_at(s: &str, top_level_only: bool) -> bool {
let mut depth = 0usize;
let mut in_class = false;
let mut escaped = false;
for c in s.chars() {
if escaped {
escaped = false;
continue;
}
if c == '\\' {
escaped = true;
continue;
}
if in_class {
if c == ']' {
in_class = false;
}
continue;
}
match c {
'[' => in_class = true,
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
'|' if !top_level_only || depth == 0 => return true,
_ => {}
}
}
false
}
fn word_safe(pattern: &str) -> bool {
let Some(first) = pattern.chars().next() else {
return false;
};
let Some(last) = pattern.chars().last() else {
return false;
};
if !is_word_char(first) || !is_word_char(last) {
return false;
}
!has_top_level_alternation(pattern)
}
fn build_matcher(
patterns: &[String],
mode: MatchMode,
flags: &GrepFlags,
) -> Result<grep_regex::RegexMatcher, grep_regex::Error> {
let mut b = grep_regex::RegexMatcherBuilder::new();
b.case_insensitive(flags.i)
.word(flags.w)
.whole_line(flags.x)
.unicode(true);
if mode == MatchMode::Fixed {
b.fixed_strings(true);
}
b.build_many(patterns)
}
#[derive(Clone)]
struct SearchMatcher {
inner: grep_regex::RegexMatcher,
validate_utf8: bool,
}
impl grep_matcher::Matcher for SearchMatcher {
type Captures = <grep_regex::RegexMatcher as grep_matcher::Matcher>::Captures;
type Error = <grep_regex::RegexMatcher as grep_matcher::Matcher>::Error;
fn find_at(
&self,
haystack: &[u8],
at: usize,
) -> Result<Option<grep_matcher::Match>, Self::Error> {
if self.validate_utf8 && std::str::from_utf8(haystack).is_err() {
return Ok(None);
}
self.inner.find_at(haystack, at)
}
fn new_captures(&self) -> Result<Self::Captures, Self::Error> {
self.inner.new_captures()
}
fn line_terminator(&self) -> Option<grep_matcher::LineTerminator> {
None }
fn non_matching_bytes(&self) -> Option<&grep_matcher::ByteSet> {
None }
}
#[derive(Clone, Copy)]
struct PipelineCtx {
piped: bool,
stdin_fed: bool,
marker_ok: bool,
}
fn serve_one_grep(
segment: &str,
verb: &str,
cwd: &Path,
home: &Path,
allow_single: bool,
ctx: PipelineCtx,
) -> Result<(EngineSpec, String), Fallback> {
let mut words = grep_tokenize(segment)?;
if words.first().is_some_and(|w| w.value == verb) {
words.remove(0);
} else {
return Err(Fallback::NestedGrep);
}
let parsed = parse_grep_words(&words, verb)?;
let mut operands: Vec<Operand> = Vec::new();
if parsed.operand_tokens.is_empty() {
if parsed.flags.r {
if ctx.stdin_fed {
return Err(Fallback::StdinRecursive);
}
operands.push(Operand {
display: ".".into(),
resolved: cwd.to_string_lossy().into_owned(),
trailing_slash: false,
});
} else if ctx.stdin_fed {
} else {
return Err(Fallback::StdinMode);
}
} else {
if ctx.stdin_fed {
return Err(Fallback::StdinOperands);
}
for tok in &parsed.operand_tokens {
if unquote_word(tok)? == "-" {
return Err(Fallback::StdinMode);
}
let expanded = resolve_operand(tok, cwd, home)?;
if expanded.is_empty() {
return Err(Fallback::UnexpandableGlob);
}
operands.extend(expanded);
}
}
let mut dir_count = 0usize;
for op in &operands {
if fs::metadata(&op.resolved).is_ok_and(|m| m.is_dir()) {
dir_count += 1;
}
}
let serve = ctx.stdin_fed
|| allow_single
|| (parsed.flags.r && (dir_count > 0 || operands.len() != 1))
|| operands.len() >= 2;
if !serve {
return Err(Fallback::SingleFile);
}
let mut fallback = parsed.fallback_prefix.clone();
if operands.iter().any(|op| op.display.starts_with('-')) {
fallback.push("--".into());
}
for op in &operands {
fallback.push(op.display.clone());
}
let spec = EngineSpec {
version: PROTOCOL_VERSION,
verb: verb.to_string(),
mode: parsed.mode,
flags: parsed.flags,
filters: parsed.filters,
exclude_dir: parsed.exclude_dir,
patterns: parsed.engine_patterns,
operands,
cwd: cwd.to_string_lossy().into_owned(),
fallback,
piped: ctx.piped,
stdin: ctx.stdin_fed,
report_stream_bytes: ctx.stdin_fed
&& ctx.marker_ok
&& !parsed.redirects.iter().any(|r| redirect_merges_stderr(r)),
};
let mut rewritten = build_rewritten(&spec);
if !parsed.redirects.is_empty() {
rewritten.push(' ');
rewritten.push_str(&parsed.redirects.join(" "));
}
Ok((spec, rewritten))
}
fn resolve_operand(tok: &str, cwd: &Path, home: &Path) -> Result<Vec<Operand>, Fallback> {
if tok == "~" {
return Ok(vec![operand_from_path(
&home.to_string_lossy(),
home,
false,
)]);
}
if tok.starts_with('~') && !tok.starts_with("~/") {
return Err(Fallback::UnresolvableOperand(tok.to_string()));
}
if has_unquoted_glob(tok) {
let value = unquote_word(tok)?;
let pattern = if tok.starts_with("~/") {
home.join(&value[2..]).to_string_lossy().into_owned()
} else {
value
};
let matches = expand_glob(&pattern, cwd)?;
if matches.is_empty() {
return Err(Fallback::UnexpandableGlob);
}
return Ok(matches
.iter()
.map(|m| {
let abs = if m.starts_with('/') {
PathBuf::from(m)
} else {
cwd.join(m)
};
operand_from_path(m, &abs, false)
})
.collect());
}
if let Some(rest) = tok.strip_prefix("~/") {
let expanded = home.join(unquote_word(rest)?);
return Ok(vec![operand_from_path(
&expanded.to_string_lossy(),
&expanded,
false,
)]);
}
let value = unquote_word(tok)?;
let trailing_slash = value.ends_with('/');
let abs = if value.starts_with('/') {
PathBuf::from(&value)
} else {
cwd.join(&value)
};
Ok(vec![operand_from_path(&value, &abs, trailing_slash)])
}
fn operand_from_path(display: &str, resolved: &Path, trailing_slash: bool) -> Operand {
Operand {
display: display.to_string(),
resolved: resolved.to_string_lossy().into_owned(),
trailing_slash,
}
}
fn has_unquoted_glob(tok: &str) -> bool {
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
for c in tok.chars() {
if super::track_char_context(c, &mut in_single, &mut in_double, &mut escaped)
&& matches!(c, '*' | '?' | '[')
{
return true;
}
}
false
}
fn expand_glob(pattern: &str, cwd: &Path) -> Result<Vec<String>, Fallback> {
if pattern.ends_with('/') {
return Err(Fallback::UnexpandableGlob);
}
let (base, display_prefix, comps) = if let Some(rest) = pattern.strip_prefix('/') {
(PathBuf::from("/"), "/", rest.split('/').collect::<Vec<_>>())
} else {
(
cwd.to_path_buf(),
"",
pattern.split('/').collect::<Vec<_>>(),
)
};
let mut results = Vec::new();
glob_walk(&base, display_prefix, &comps, &mut results);
results.sort();
Ok(results)
}
fn glob_walk(dir: &Path, display: &str, comps: &[&str], results: &mut Vec<String>) {
let Some((first, rest)) = comps.split_first() else {
return;
};
if first.contains(['*', '?', '[']) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if !fnmatch_flags(first, &name, libc::FNM_PERIOD) {
continue;
}
let child_display = join_display(display, &name);
if rest.is_empty() {
results.push(child_display);
} else if entry.path().is_dir() {
glob_walk(&entry.path(), &child_display, rest, results);
}
}
} else {
let next = dir.join(first);
let child_display = join_display(display, first);
if rest.is_empty() {
if next.exists() {
results.push(child_display);
}
} else if next.is_dir() {
glob_walk(&next, &child_display, rest, results);
}
}
}
fn join_display(prefix: &str, name: &str) -> String {
if prefix.is_empty() {
name.to_string()
} else if prefix == "/" {
format!("/{name}")
} else {
format!("{prefix}/{name}")
}
}
pub fn run_engine(args: &[String]) -> i32 {
if args.first().map(String::as_str) == Some("--probe") {
return 0;
}
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let Some(json) = args.first() else {
eprintln!("grep: engine: missing spec");
return ENGINE_FAILED_EXIT;
};
let spec: EngineSpec = match serde_json::from_str(json) {
Ok(s) => s,
Err(e) => {
eprintln!("grep: engine: bad spec: {e}");
return ENGINE_FAILED_EXIT;
}
};
if spec.version != PROTOCOL_VERSION {
return exec_grep(&spec.fallback);
}
let fallback = spec.fallback.clone();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| serve(&spec)));
match result {
Ok(Ok(code)) => code,
Ok(Err(())) => exec_grep(&fallback),
Err(_) => ENGINE_FAILED_EXIT,
}
}
fn exec_grep(argv: &[String]) -> i32 {
use std::os::unix::process::CommandExt;
let Some(first) = argv.first() else {
return ENGINE_FAILED_EXIT;
};
let err = std::process::Command::new(first).args(&argv[1..]).exec();
eprintln!("grep: failed to exec {first}: {err}");
ENGINE_FAILED_EXIT
}
fn serve(spec: &EngineSpec) -> Result<i32, ()> {
let actual_cwd = std::env::current_dir().map_err(|_| ())?;
if actual_cwd != Path::new(&spec.cwd) {
return Err(());
}
let matcher = build_matcher(&spec.patterns, spec.mode, &spec.flags).map_err(|_| ())?;
let mut out = Output::new(
OutputSink::Stdout(io::BufWriter::with_capacity(16 * 1024, io::stdout())),
if spec.piped { None } else { Some(OUTPUT_CAP) },
);
let stdin_consumed = std::cell::Cell::new(false);
let stdin = io::stdin();
let code = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
serve_into(spec, &matcher, &mut out, stdin.lock(), &stdin_consumed)
}));
match code {
Ok(code) => {
out.finish();
Ok(code)
}
Err(_) if out.written == 0 && !stdin_consumed.get() => Err(()),
Err(_) => {
out.finish();
Ok(ENGINE_FAILED_EXIT)
}
}
}
fn serve_into<R: io::Read>(
spec: &EngineSpec,
matcher: &grep_regex::RegexMatcher,
out: &mut Output,
stdin: R,
stdin_consumed: &std::cell::Cell<bool>,
) -> i32 {
if spec.stdin {
return serve_stdin(spec, matcher, out, stdin, stdin_consumed);
}
let show_prefix = !spec.flags.h && (spec.flags.H || spec.flags.r || spec.operands.len() > 1);
let mut result = OperandResult::NoMatch;
for op in &spec.operands {
result = result.max(process_operand(op, spec, matcher, out, show_prefix));
}
match result {
OperandResult::NoMatch => 1,
OperandResult::Match => 0,
OperandResult::Error => 2,
}
}
struct CountingReader<R> {
inner: R,
count: u64,
}
impl<R: io::Read> CountingReader<R> {
fn new(inner: R) -> Self {
CountingReader { inner, count: 0 }
}
fn count(&self) -> u64 {
self.count
}
}
impl<R: io::Read> io::Read for CountingReader<R> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = self.inner.read(buf)?;
self.count += n as u64;
Ok(n)
}
}
fn read_binary_head<R: io::Read>(src: &mut R) -> io::Result<(Vec<u8>, bool)> {
let mut head = vec![0u8; BINARY_WINDOW];
let n = src.read(&mut head)?;
head.truncate(n);
let binary = head.contains(&0);
Ok((head, binary))
}
fn build_searcher(spec: &EngineSpec) -> (grep_searcher::Searcher, bool) {
let mut sb = grep_searcher::SearcherBuilder::new();
sb.line_number(spec.flags.n)
.invert_match(spec.flags.v)
.binary_detection(grep_searcher::BinaryDetection::none())
.heap_limit(Some(HEAP_LIMIT));
let count_mode = spec.flags.count_mode();
if !count_mode && spec.flags.before > 0 {
sb.before_context(spec.flags.before);
}
if !count_mode && spec.flags.after > 0 {
sb.after_context(spec.flags.after);
}
if let Some(m) = spec.flags.m {
sb.max_matches(Some(m));
}
(sb.build(), count_mode)
}
fn search_stream<R: io::Read>(
spec: &EngineSpec,
matcher: &grep_regex::RegexMatcher,
display: &str,
show_prefix: bool,
binary: bool,
input: &mut R,
out: &mut Output,
) -> (Result<(), io::Error>, bool) {
let search_matcher = SearchMatcher {
inner: matcher.clone(),
validate_utf8: !binary,
};
let (mut searcher, count_mode) = build_searcher(spec);
let matcher_for_search = search_matcher.clone();
let (result, selected_any) = {
let mut sink = GrepSink {
spec,
display,
show_prefix,
binary: binary && !spec.flags.a,
message_emitted: false,
selected_any: false,
count: 0,
matcher: &search_matcher,
out,
};
let result = searcher.search_reader(matcher_for_search, input, &mut sink);
if count_mode {
sink.finish_count();
}
(result, sink.selected_any)
};
(result, selected_any)
}
fn serve_stdin<R: io::Read>(
spec: &EngineSpec,
matcher: &grep_regex::RegexMatcher,
out: &mut Output,
mut stdin: R,
stdin_consumed: &std::cell::Cell<bool>,
) -> i32 {
let (head, binary) = match read_binary_head(&mut stdin) {
Ok(v) => v,
Err(e) => {
emit_error(spec, out, "(standard input)", &e.to_string());
return 2;
}
};
stdin_consumed.set(true);
let mut input = CountingReader::new(io::Cursor::new(&head).chain(stdin));
let (search, selected_any) = search_stream(
spec,
matcher,
"(standard input)",
spec.flags.H && !spec.flags.h,
binary,
&mut input,
out,
);
if spec.report_stream_bytes {
out.write_err(&format!(
"{}: {STREAM_SIZE_MARKER}: {}\n",
spec.verb,
input.count()
));
}
if let Err(e) = search {
emit_error(spec, out, "(standard input)", &e.to_string());
return 2;
}
i32::from(!selected_any)
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum OperandResult {
NoMatch,
Match,
Error,
}
fn process_operand(
op: &Operand,
spec: &EngineSpec,
matcher: &grep_regex::RegexMatcher,
out: &mut Output,
show_prefix: bool,
) -> OperandResult {
let path = Path::new(&op.resolved);
let display = &op.display;
let lmeta = match fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) => {
emit_error(spec, out, display, &e.to_string());
return OperandResult::Error;
}
};
if lmeta.file_type().is_symlink() {
if spec.flags.r && !op.trailing_slash {
return OperandResult::NoMatch; }
match fs::metadata(path) {
Err(e) => {
emit_error(spec, out, display, &e.to_string());
return OperandResult::Error;
}
Ok(meta) => {
if meta.is_dir() {
if !spec.flags.r {
emit_error(spec, out, display, "Is a directory");
return OperandResult::Error;
}
if dir_excluded(display, &spec.exclude_dir) {
return OperandResult::NoMatch;
}
return walk_dir(op, spec, matcher, out, show_prefix);
}
if op.trailing_slash {
emit_error(spec, out, display, "Not a directory");
return OperandResult::Error;
}
if !file_allowed_by_filters(display, spec) {
return OperandResult::NoMatch;
}
return search_file(path, display, spec, matcher, out, show_prefix);
}
}
}
if lmeta.is_dir() {
if dir_excluded(display, &spec.exclude_dir) {
return OperandResult::NoMatch;
}
if !spec.flags.r {
emit_error(spec, out, display, "Is a directory");
return OperandResult::Error;
}
return walk_dir(op, spec, matcher, out, show_prefix);
}
if op.trailing_slash {
emit_error(spec, out, display, "Not a directory");
return OperandResult::Error;
}
if !file_allowed_by_filters(display, spec) {
return OperandResult::NoMatch;
}
search_file(path, display, spec, matcher, out, show_prefix)
}
fn file_allowed_by_filters(display: &str, spec: &EngineSpec) -> bool {
let base = display.rsplit('/').next().unwrap_or(display);
let mut last: Option<bool> = None;
for (include, pat) in &spec.filters {
if fnmatch(pat, display) || fnmatch(pat, base) {
last = Some(*include);
}
}
match last {
Some(include) => include,
None => !spec.filters.iter().any(|(include, _)| *include),
}
}
fn dir_excluded(display: &str, exclude_dir: &[String]) -> bool {
let base = display.rsplit('/').next().unwrap_or(display);
exclude_dir
.iter()
.any(|pat| fnmatch(pat, base) || fnmatch(pat, display))
}
fn fnmatch_flags(pattern: &str, s: &str, flags: i32) -> bool {
let Ok(p) = std::ffi::CString::new(pattern) else {
return false;
};
let Ok(n) = std::ffi::CString::new(s) else {
return false;
};
unsafe { libc::fnmatch(p.as_ptr(), n.as_ptr(), flags) == 0 }
}
fn fnmatch(pattern: &str, s: &str) -> bool {
fnmatch_flags(pattern, s, 0)
}
fn walk_dir(
op: &Operand,
spec: &EngineSpec,
matcher: &grep_regex::RegexMatcher,
out: &mut Output,
show_prefix: bool,
) -> OperandResult {
let root_abs = PathBuf::from(&op.resolved);
let root_display = op.display.clone();
let exclude_dir = spec.exclude_dir.clone();
let root_for_filter = root_abs.clone();
let mut result = OperandResult::NoMatch;
let mut builder = ignore::WalkBuilder::new(&root_abs);
builder.filter_entry(move |entry| {
if entry.file_type().is_some_and(|t| t.is_dir()) && entry.depth() > 0 {
let display = traversal_display(&root_display, &root_for_filter, entry.path());
!dir_excluded(&display, &exclude_dir)
} else {
true
}
});
for entry in builder.build() {
let entry = match entry {
Ok(e) => e,
Err(e) => {
let (path, message) = walk_error_info(&e);
let display = path.as_deref().map_or_else(
|| op.display.clone(),
|p| traversal_display(&op.display, &root_abs, p),
);
emit_error(spec, out, &display, &message);
result = OperandResult::Error;
continue;
}
};
let ft = entry.file_type();
if ft.is_some_and(|t| t.is_symlink() || t.is_dir()) {
continue; }
let display = traversal_display(&op.display, &root_abs, entry.path());
if !file_allowed_by_filters(&display, spec) {
continue;
}
let r = search_file(entry.path(), &display, spec, matcher, out, show_prefix);
result = result.max(r);
}
result
}
fn traversal_display(root_display: &str, root_abs: &Path, entry: &Path) -> String {
let rel = entry.strip_prefix(root_abs).unwrap_or(entry);
let rel = rel.to_string_lossy();
if rel.is_empty() {
root_display.to_string()
} else {
format!("{}/{}", root_display.trim_end_matches('/'), rel)
}
}
fn walk_error_info(e: &ignore::Error) -> (Option<PathBuf>, String) {
if let ignore::Error::WithPath { path, err } = e {
(Some(path.clone()), normalize_io_message(err))
} else {
(None, normalize_io_message(e))
}
}
fn strip_os_error(msg: &str) -> &str {
match msg.split_once(" (os error ") {
Some((head, tail)) if tail.ends_with(')') => head,
_ => msg,
}
}
fn normalize_io_message(e: &ignore::Error) -> String {
let msg = e
.io_error()
.map_or_else(|| e.to_string(), ToString::to_string);
strip_os_error(&msg).to_string()
}
fn search_file(
path: &Path,
display: &str,
spec: &EngineSpec,
matcher: &grep_regex::RegexMatcher,
out: &mut Output,
show_prefix: bool,
) -> OperandResult {
let mut file = match fs::File::open(path) {
Ok(f) => f,
Err(e) => {
emit_error(spec, out, display, &e.to_string());
return OperandResult::Error;
}
};
let (head, binary) = match read_binary_head(&mut file) {
Ok(v) => v,
Err(e) => {
emit_error(spec, out, display, &e.to_string());
return OperandResult::Error;
}
};
let mut input = io::Cursor::new(&head).chain(file);
let (search, selected_any) =
search_stream(spec, matcher, display, show_prefix, binary, &mut input, out);
if let Err(e) = search {
emit_error(spec, out, display, &e.to_string());
return OperandResult::Error;
}
if selected_any {
OperandResult::Match
} else {
OperandResult::NoMatch
}
}
fn emit_error(spec: &EngineSpec, out: &mut Output, display: &str, message: &str) {
if !spec.flags.s {
out.write_err(&format!(
"{}: {display}: {}\n",
spec.verb,
strip_os_error(message)
));
}
}
#[expect(clippy::struct_excessive_bools)] struct GrepSink<'a> {
spec: &'a EngineSpec,
display: &'a str,
show_prefix: bool,
binary: bool,
message_emitted: bool,
selected_any: bool,
count: u64,
matcher: &'a SearchMatcher,
out: &'a mut Output,
}
impl grep_searcher::Sink for GrepSink<'_> {
type Error = io::Error;
fn matched(
&mut self,
_searcher: &grep_searcher::Searcher,
mat: &grep_searcher::SinkMatch<'_>,
) -> Result<bool, io::Error> {
if self.spec.flags.count_mode() {
self.selected_any = true;
self.count += 1;
return Ok(!self.spec.flags.l);
}
if self.binary {
if !self.message_emitted {
self.message_emitted = true;
self.out
.write_bytes(format!("Binary file {} matches\n", self.display).as_bytes());
}
self.selected_any = true;
return Ok(false); }
self.selected_any = true;
let content = trim_line_terminator(mat.bytes());
if self.spec.flags.o {
let mut matches = Vec::new();
let _ = self.matcher.find_iter(content, |m| {
if !m.is_empty() {
matches.push((m.start(), m.end()));
}
true
});
for (start, end) in matches {
self.write_prefix(mat.line_number(), false);
self.out.write_bytes(&content[start..end]);
self.out.write_byte(b'\n');
}
} else {
self.write_prefix(mat.line_number(), false);
self.out.write_bytes(content);
self.out.write_byte(b'\n');
}
self.out.flush();
Ok(true)
}
fn context(
&mut self,
_searcher: &grep_searcher::Searcher,
ctx: &grep_searcher::SinkContext<'_>,
) -> Result<bool, io::Error> {
if self.binary {
return Ok(true); }
let content = trim_line_terminator(ctx.bytes());
self.write_prefix(ctx.line_number(), true);
self.out.write_bytes(content);
self.out.write_byte(b'\n');
self.out.flush();
Ok(true)
}
fn context_break(&mut self, _searcher: &grep_searcher::Searcher) -> Result<bool, io::Error> {
self.out.write_bytes(b"--\n");
Ok(true)
}
}
impl GrepSink<'_> {
fn write_prefix(&mut self, lineno: Option<u64>, is_context: bool) {
if self.show_prefix {
self.out.write_bytes(self.display.as_bytes());
let sep = if self.spec.flags.null {
b'\0'
} else if is_context {
b'-'
} else {
b':'
};
self.out.write_byte(sep);
}
if let Some(n) = lineno {
self.out.write_bytes(n.to_string().as_bytes());
self.out.write_byte(if is_context { b'-' } else { b':' });
}
}
fn finish_count(&mut self) {
if self.spec.flags.c {
if self.show_prefix {
self.out.write_bytes(self.display.as_bytes());
self.out.write_byte(b':');
}
self.out.write_bytes(self.count.to_string().as_bytes());
self.out.write_byte(b'\n');
}
if self.spec.flags.l && self.selected_any {
self.out.write_bytes(self.display.as_bytes());
self.out
.write_byte(if self.spec.flags.null { 0 } else { b'\n' });
}
self.out.flush();
}
}
fn trim_line_terminator(b: &[u8]) -> &[u8] {
b.strip_suffix(b"\n").unwrap_or(b)
}
struct Output {
sink: OutputSink,
err: Vec<u8>,
written: usize,
limit: Option<usize>,
}
enum OutputSink {
Stdout(io::BufWriter<io::Stdout>),
#[cfg_attr(not(all(test, target_os = "macos")), allow(dead_code))]
Buffer(Vec<u8>),
}
impl Output {
fn new(sink: OutputSink, limit: Option<usize>) -> Self {
Output {
sink,
err: Vec::new(),
written: 0,
limit,
}
}
fn write_bytes(&mut self, b: &[u8]) {
let take = match self.limit {
Some(limit) if self.written < limit => (limit - self.written).min(b.len()),
Some(_) => return,
None => b.len(),
};
match &mut self.sink {
OutputSink::Stdout(w) => {
let _ = w.write_all(&b[..take]);
}
OutputSink::Buffer(v) => v.extend_from_slice(&b[..take]),
}
self.written += take;
}
fn write_byte(&mut self, b: u8) {
self.write_bytes(&[b]);
}
fn write_err(&mut self, s: &str) {
if self.err.len() < 64 * 1024 {
self.err.extend_from_slice(s.as_bytes());
}
}
fn flush(&mut self) {
if let OutputSink::Stdout(w) = &mut self.sink {
let _ = w.flush();
}
}
fn finish(self) {
if let OutputSink::Stdout(mut w) = self.sink {
let _ = w.flush();
}
let stderr = io::stderr();
let mut lock = stderr.lock();
let _ = lock.write_all(&self.err);
let _ = lock.flush();
}
}
#[cfg(all(test, target_os = "macos"))]
mod parity_tests {
use super::*;
use std::process::Command;
fn build_fixture(ws: &Path, home: &Path) {
fs::write(ws.join("a.txt"), "foo\nbar\nfoo\nbaz\n").unwrap();
fs::write(ws.join("b.txt"), "qux\nfoo\n").unwrap();
fs::write(ws.join("c.txt"), "apple\n").unwrap();
fs::write(ws.join("m.txt"), "a\nb\na\nb\na\n").unwrap();
fs::write(ws.join("ctx.txt"), "a\nb\nc\nd\ne\nf\ng\nh\ni\n").unwrap();
fs::write(ws.join("w1.txt"), "foo.bar\nfoo x\n").unwrap();
fs::write(ws.join("x1.txt"), "foo\n^foo\n").unwrap();
fs::write(ws.join("z2.txt"), "ab\n\ncd\n").unwrap();
fs::write(ws.join("uni.txt"), "café\nCAFÉ\n").unwrap();
fs::write(ws.join("uni2.txt"), "straße\nstrasse\n").unwrap();
fs::write(ws.join("inv.txt"), b"ok\nbad\xffline\nok2\n").unwrap();
fs::write(ws.join("br.txt"), "foo\nbar\nfoobar\nfooXbar\n").unwrap();
fs::write(ws.join("br2.txt"), "a.b\naab\n").unwrap();
fs::write(ws.join("dash.txt"), "-x1\n").unwrap();
fs::write(ws.join("-x1"), "x\n").unwrap();
fs::write(ws.join("pipe.txt"), "a|b\nx\na|b\n").unwrap();
fs::write(ws.join("xx.txt"), "x1\nx2\n").unwrap();
fs::create_dir_all(ws.join("plain/d1")).unwrap();
fs::write(ws.join("plain/a.txt"), "x1\n").unwrap();
fs::write(ws.join("plain/d1/b.txt"), "x2\n").unwrap();
fs::write(ws.join("plain/d1/c.txt"), "x3\n").unwrap();
fs::write(ws.join("plain/e.txt"), "x5\n").unwrap();
fs::create_dir_all(ws.join("plain/d2")).unwrap();
fs::write(ws.join("plain/d2/d.txt"), "x4\n").unwrap();
fs::create_dir_all(ws.join("ign/.git")).unwrap();
fs::write(ws.join("ign/visible.txt"), "x1\n").unwrap();
fs::write(ws.join("ign/.hidden.txt"), "x2\n").unwrap();
fs::write(ws.join("ign/.gitignore"), "*.log\n").unwrap();
fs::write(ws.join("ign/skip.log"), "x3\n").unwrap();
fs::create_dir_all(ws.join("bindir")).unwrap();
fs::write(ws.join("bindir/bin1.dat"), b"hello\x00world\nneedle\n").unwrap();
fs::write(ws.join("bindir/bin2.dat"), b"needle\x00world\n").unwrap();
fs::write(ws.join("bindir/bin3.dat"), b"hello\x00world\n").unwrap();
fs::write(ws.join("bindir/bin4.dat"), b"needle\nhello\x00world\n").unwrap();
fs::write(ws.join("bindir/bin5.dat"), b"\xff\x00needle\n").unwrap();
fs::write(ws.join("bindir/bin6.dat"), b"\xff\x00other\n").unwrap();
std::os::unix::fs::symlink("a.txt", ws.join("filelink")).unwrap();
std::os::unix::fs::symlink("plain", ws.join("dirlink")).unwrap();
std::os::unix::fs::symlink("plain", ws.join("plain/dirlink2")).unwrap();
std::os::unix::fs::symlink("missing", ws.join("broken")).unwrap();
fs::create_dir_all(home.join("htree/sub")).unwrap();
fs::write(home.join("htree/f1.txt"), "needle\n").unwrap();
fs::write(home.join("htree/sub/f2.txt"), "needle\n").unwrap();
fs::create_dir_all(ws.join("sub")).unwrap();
fs::write(ws.join("sub/s.txt"), "needle\n").unwrap();
fs::write(ws.join("~"), "needle\n").unwrap();
fs::write(ws.join("*.txt"), "needle\n").unwrap();
fs::write(ws.join("a\"b.txt"), "needle\n").unwrap();
fs::create_dir_all(ws.join("sub/~")).unwrap();
fs::write(ws.join("sub/~/q.txt"), "needle\n").unwrap();
}
fn engine_run(spec: &EngineSpec) -> (Vec<u8>, Vec<u8>, i32) {
let (out, err, code, _) = engine_run_with_stdin(spec, &[]);
(out, err, code)
}
fn engine_run_with_stdin(
spec: &EngineSpec,
stdin: &[u8],
) -> (Vec<u8>, Vec<u8>, i32, Option<u64>) {
let matcher =
build_matcher(&spec.patterns, spec.mode, &spec.flags).expect("matcher builds");
let mut out = Output::new(
OutputSink::Buffer(Vec::new()),
if spec.piped { None } else { Some(OUTPUT_CAP) },
);
let consumed = std::cell::Cell::new(false);
let code = serve_into(spec, &matcher, &mut out, io::Cursor::new(stdin), &consumed);
let Output {
sink: OutputSink::Buffer(buf),
mut err,
..
} = out
else {
unreachable!("buffered sink")
};
let stream_bytes = strip_stream_size_marker(&mut err);
(buf, err, code, stream_bytes)
}
fn real_run_shell(command: &str, cwd: &Path, home: &Path) -> (Vec<u8>, Vec<u8>, i32) {
let out = Command::new("/bin/sh")
.arg("-c")
.arg(command)
.current_dir(cwd)
.env("LC_ALL", "C.UTF-8")
.env("HOME", home)
.output()
.expect("system grep runs");
(
out.stdout,
out.stderr,
out.status.code().expect("grep exits normally"),
)
}
fn real_run_fallback(spec: &EngineSpec) -> (Vec<u8>, Vec<u8>, i32) {
real_run_fallback_with_stdin(spec, &[])
}
fn real_run_fallback_with_stdin(spec: &EngineSpec, stdin: &[u8]) -> (Vec<u8>, Vec<u8>, i32) {
let bin = match spec.verb.as_str() {
"egrep" => "/usr/bin/egrep",
"fgrep" => "/usr/bin/fgrep",
_ => "/usr/bin/grep",
};
let mut child = Command::new(bin)
.args(&spec.fallback[1..])
.current_dir(&spec.cwd)
.env("LC_ALL", "C.UTF-8")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("system grep spawns");
let mut stdin_pipe = child.stdin.take().expect("stdin piped");
let _ = stdin_pipe.write_all(stdin);
drop(stdin_pipe);
let out = child.wait_with_output().expect("system grep runs");
(
out.stdout,
out.stderr,
out.status.code().expect("grep exits normally"),
)
}
fn producer_command(command: &str) -> String {
let segments = split_segments(command).expect("segments");
let gidx = segments
.iter()
.position(|(s, _)| {
let v = first_word(s);
is_grep_verb(v) || segment_contains_grep(s, v)
})
.expect("grep member");
let mut start = gidx;
while start > 0
&& matches!(
segments[start - 1].1.as_str(),
"|" | "|&" | "&&" | "||" | ";" | "\n"
)
{
start -= 1;
}
let mut out = String::new();
for (i, (seg, conn)) in segments[start..gidx].iter().enumerate() {
if i > 0 {
out.push(' ');
}
out.push_str(seg);
if i + 1 < gidx - start {
out.push(' ');
out.push_str(conn);
}
}
out
}
fn pipeline_tail(command: &str) -> Option<String> {
let segments = split_segments(command).ok()?;
let first = segments
.iter()
.position(|(_, c)| matches!(c.as_str(), "|" | "|&"))?;
let mut out = format!(" {}", segments[first].1);
for (seg, conn) in &segments[first + 1..] {
out.push(' ');
out.push_str(seg);
if !conn.is_empty() {
out.push(' ');
out.push_str(conn);
}
}
Some(out)
}
fn assert_parity(command: &str, ws: &Path, home: &Path) {
let (specs, _, rewritten) = analyze_command(command, ws, home, true)
.unwrap_or_else(|e| panic!("{command}: expected servable, got {e}"));
assert_eq!(specs.len(), 1, "{command}: expected one grep member");
if specs[0].stdin {
assert_stdin_parity(command, &specs[0], &rewritten, ws, home);
return;
}
let (eout, eerr, ecode) = engine_run(&specs[0]);
let piped = command.split_whitespace().any(|w| w == "|" || w == "|&");
let (rout, rerr, rcode) = if piped {
let tail = pipeline_tail(command).unwrap_or_default();
assert!(
!tail.is_empty() && rewritten.ends_with(&tail),
"{command}: pipeline tail not preserved verbatim (rewritten: {rewritten})"
);
real_run_fallback(&specs[0])
} else {
real_run_shell(command, ws, home)
};
assert_eq!(eout, rout, "stdout mismatch for {command}");
assert_eq!(eerr, rerr, "stderr mismatch for {command}");
assert_eq!(ecode, rcode, "exit mismatch for {command}");
}
fn grep_tail(command: &str) -> Option<String> {
let segments = split_segments(command).ok()?;
let gidx = segments.iter().position(|(s, _)| {
let v = first_word(s);
is_grep_verb(v) || segment_contains_grep(s, v)
})?;
if gidx + 1 >= segments.len() {
return Some(String::new());
}
let mut out = format!(" {}", segments[gidx].1);
for (seg, conn) in &segments[gidx + 1..] {
out.push(' ');
out.push_str(seg);
if !conn.is_empty() {
out.push(' ');
out.push_str(conn);
}
}
Some(out)
}
fn assert_stdin_parity(
command: &str,
spec: &EngineSpec,
rewritten: &str,
ws: &Path,
home: &Path,
) {
let piped = command.split_whitespace().any(|w| w == "|" || w == "|&");
assert!(piped, "{command}: stdin serve requires a pipeline");
let producer = producer_command(command);
assert!(
!producer.is_empty() && rewritten.starts_with(&producer),
"{command}: producer not preserved verbatim (rewritten: {rewritten})"
);
let tail = grep_tail(command).unwrap_or_default();
assert!(
tail.is_empty() || rewritten.ends_with(&tail),
"{command}: pipeline tail not preserved verbatim (rewritten: {rewritten})"
);
let (stream, _, _) = real_run_shell(&producer, ws, home);
let (eout, eerr, ecode, stream_bytes) = engine_run_with_stdin(spec, &stream);
let (rout, rerr, rcode) = real_run_fallback_with_stdin(spec, &stream);
if spec.report_stream_bytes {
match stream_bytes {
Some(n) if stream.len() <= BINARY_WINDOW => {
assert_eq!(n, stream.len() as u64, "stream-size marker for {command}");
}
Some(n) => assert!(
n > 0 && n <= stream.len() as u64,
"stream-size marker out of range for {command}"
),
None => panic!("stream-size marker missing for {command}"),
}
} else {
assert_eq!(stream_bytes, None, "marker suppressed for {command}");
}
assert_eq!(eout, rout, "stdout mismatch for {command}");
assert_eq!(eerr, rerr, "stderr mismatch for {command}");
assert_eq!(ecode, rcode, "exit mismatch for {command}");
}
fn assert_falls_back(command: &str, ws: &Path, home: &Path) {
assert!(
analyze_command(command, ws, home, false).is_err(),
"{command}: expected fallback, got served"
);
}
fn assert_falls_back_reason(command: &str, ws: &Path, home: &Path, reason: &str) {
let err = analyze_command(command, ws, home, false)
.err()
.unwrap_or_else(|| panic!("{command}: expected fallback, got served"));
assert_eq!(err.to_string(), reason, "{command}");
}
#[test]
#[expect(clippy::too_many_lines)] fn differential_parity_matrix() {
let tmp = tempfile::tempdir().expect("tempdir");
let ws = tmp.path().join("ws");
let home = tmp.path().join("home");
fs::create_dir_all(&ws).expect("ws");
fs::create_dir_all(&home).expect("home");
build_fixture(&ws, &home);
let rows: &[&str] = &[
"grep -n 'foo\\|bar' a.txt",
"grep -n '\\(foo\\)\\?bar' br.txt",
"grep -n 'fo\\+o' br.txt",
"grep -n 'fo\\?o' br.txt",
"grep -n 'fo\\{2,3\\}' br.txt",
"grep -n 'foo|bar' br.txt",
"grep -n 'a(b' br2.txt",
"grep -n 'a+b' br2.txt",
"grep -n 'a.b' br2.txt",
"grep -n 'a\\.b' br2.txt",
"grep -n 'x1$' plain/a.txt",
"grep -n '^x' plain/a.txt",
"grep -n 'cat\\b' br.txt",
"grep -n '[[:digit:]]' br.txt",
"grep -n 'fo\\{2,\\}' br.txt",
"egrep -n 'foo|bar' br.txt",
"egrep -n '(foo)+' br.txt",
"egrep -n 'a{2}' br2.txt",
"fgrep -n 'a.b' br2.txt",
"fgrep -n 'foo' a.txt",
"grep -n x a.txt b.txt",
"grep -H x a.txt",
"grep -h x a.txt b.txt",
"grep -r x plain",
"grep -rn x plain",
"grep -rn x plain/e.txt plain/d1",
"grep -rn --null x plain",
"grep -Hn --null x a.txt b.txt",
"grep -o x a.txt b.txt",
"grep -on o a.txt",
"grep -oH x a.txt",
"grep -o '' z2.txt a.txt",
"fgrep -o 'a|b' pipe.txt a.txt", "grep -n -A1 -B1 d ctx.txt",
"grep -A1 -B1 'd\\|h' ctx.txt",
"grep -A1 -B1 d a.txt b.txt",
"grep -n -A1 b a.txt",
"grep -v foo a.txt",
"grep -v -o foo a.txt",
"grep -vn foo a.txt b.txt",
"grep -v '' z2.txt a.txt", "grep -i 'café' uni.txt",
"grep -i 'straße' uni2.txt",
"grep -i 'STRASSE' uni2.txt",
"grep 'ok' inv.txt",
"grep -v 'ok' inv.txt",
"grep -E 'b.d' inv.txt",
"grep -i 'BAD' inv.txt",
"grep -F 'bad' inv.txt",
"grep -w foo a.txt",
"grep -w café uni.txt",
"grep -x foo x1.txt",
"grep -x '^foo' x1.txt",
"grep -x '' z2.txt",
"grep -x -e foo -e bar a.txt",
"grep -iw Foo a.txt",
"grep -m2 a m.txt",
"grep -m2 -v a m.txt",
"grep -m2 a m.txt c.txt",
"grep -m1 -A1 a m.txt",
"grep -m1 -B1 b m.txt",
"grep -c foo a.txt",
"grep -c foo a.txt b.txt",
"grep -c zzz a.txt b.txt", "grep -ch foo a.txt b.txt", "grep -cH foo a.txt", "grep -cn foo a.txt b.txt", "grep -c -A1 foo a.txt b.txt", "grep -cv -A1 foo a.txt b.txt", "grep -co foo a.txt b.txt", "grep -co -m2 foo a.txt b.txt", "grep -co 'foo\\|bar' a.txt b.txt", "grep -ci FOO a.txt b.txt",
"grep -cw foo a.txt b.txt",
"grep -cx foo x1.txt a.txt",
"grep -c -e foo -e bar a.txt b.txt",
"egrep -c 'foo|bar' a.txt b.txt",
"fgrep -c 'a.b' br2.txt a.txt",
"grep -c -m2 a m.txt c.txt", "grep -c -m10 foo a.txt b.txt", "grep -c -m2 -v a m.txt",
"grep -cv foo a.txt b.txt",
"grep -cvo foo a.txt b.txt", "grep -c '' z2.txt a.txt", "grep -c foo inv.txt", "grep -c bad inv.txt",
"grep -cv foo inv.txt",
"grep -cr x plain",
"grep -chr x plain", "grep -crx x1 plain/a.txt plain/e.txt",
"grep -cr x plain missing.txt", "grep -l foo a.txt",
"grep -l foo a.txt b.txt c.txt",
"grep -l zzz a.txt b.txt", "grep -lh foo a.txt b.txt", "grep -lH foo a.txt",
"grep -ln foo a.txt b.txt",
"grep -l -B1 foo a.txt b.txt", "grep -lw foo a.txt b.txt",
"grep -lv a m.txt b.txt",
"grep -lx foo x1.txt a.txt",
"grep -l -e foo -e bar a.txt b.txt",
"grep -lr x plain",
"grep -cl foo a.txt",
"grep -cl foo a.txt b.txt c.txt",
"grep -cl zzz a.txt b.txt", "grep -clH foo a.txt",
"grep -clv a m.txt b.txt",
"grep -cl -m5 a m.txt", "grep -cla needle bindir/bin1.dat bindir/bin2.dat bindir/bin3.dat",
"grep -cl -e foo -e bar a.txt b.txt",
"grep -clr x plain",
"grep -clh --null foo a.txt b.txt",
"grep -l --null foo a.txt b.txt",
"grep -c --null foo a.txt b.txt",
"grep -cl --null foo a.txt b.txt",
"grep -lr --null x plain",
"grep -cr --null x plain",
"grep -clr --null x plain",
"grep x missing.txt a.txt",
"grep -s x missing.txt a.txt",
"grep x plain a.txt",
"grep x xx.txt/ a.txt",
"grep -rn x plain missing.txt",
"grep -rn needle bindir",
"grep -r needle bindir",
"grep -rn -a needle bindir/bin1.dat bindir/bin4.dat",
"grep -rn -v needle bindir",
"grep -rn -o needle bindir",
"grep -c needle bindir/bin1.dat bindir/bin2.dat bindir/bin3.dat",
"grep -l needle bindir/bin1.dat bindir/bin2.dat bindir/bin3.dat",
"grep -cl needle bindir/bin1.dat bindir/bin2.dat bindir/bin3.dat",
"grep -cv needle bindir/bin1.dat bindir/bin2.dat bindir/bin3.dat",
"grep -clv --null needle bindir/bin1.dat bindir/bin2.dat bindir/bin3.dat",
"grep -ca needle bindir/bin1.dat bindir/bin2.dat bindir/bin3.dat",
"grep -cr needle bindir",
"grep -lr needle bindir",
"grep -clr needle bindir",
"grep -cs x missing.txt a.txt", "grep -rn needle bindir/bin5.dat bindir/bin4.dat",
"grep -a needle bindir/bin5.dat bindir/bin4.dat",
"grep -rn needle bindir/bin5.dat bindir/bin6.dat",
"grep -r x plain/dirlink",
"grep -r x plain/dirlink/",
"grep -r foo filelink",
"grep foo filelink a.txt",
"grep -r x plain/dirlink2",
"grep -r --exclude='c.txt' x plain",
"grep -r --exclude='plain/d1/*.txt' x plain",
"grep -r --exclude-dir='d1' x plain",
"grep -r --include='*.txt' x plain",
"grep -r --include='*.txt' --exclude='c.txt' x plain",
"grep -r --exclude='c.txt' --include='*.txt' x plain",
"grep --exclude='b.txt' x a.txt b.txt",
"grep --include='a.txt' x a.txt b.txt",
"grep -r --include='plain/d1/*' --exclude='*.txt' x plain",
"grep -r --exclude='*.txt' --include='plain/d1/*' x plain",
"cd sub && grep -r needle .",
"cd sub && grep -rn needle .",
"grep -rn needle ~/htree",
"grep -rn needle ~/htree/sub",
"grep -rn needle sub",
"grep -rn needle plain/*.txt",
"grep -rn needle plain/d1/*.txt",
"grep -rn needle ~/htree/*.txt",
"grep -n -- x a.txt",
"grep x -n a.txt",
"grep -e x a.txt b.txt",
"grep -e x -e y a.txt b.txt",
"grep -y x a.txt",
"grep -u -n x a.txt",
"grep -r x -n plain",
"grep -rn -- -x dash.txt",
"grep -e x -- -x1", "grep -rn x plain | head -5",
"grep -rn x plain | head",
"cat a.txt | grep foo",
"cat a.txt | grep -n foo | head -2",
"cat a.txt | grep -c foo",
"cat a.txt | grep -l foo",
"cat a.txt | grep -H foo",
"cat a.txt | grep -m1 foo",
"cat a.txt | grep -l --null foo",
"cat a.txt | grep -o foo | head -3",
"cat a.txt | grep -i FOO",
"cat a.txt | grep -v bar",
"cat a.txt | grep -cl foo",
"cat ctx.txt | grep -A1 -B1 d",
"cat a.txt | egrep 'foo|bar'",
"cat a.txt | fgrep foo",
"cat a.txt | grep foo | grep oo | head -3", "seq 1 100 | grep 5 | head -5",
"seq 1 100000 | grep -m1 5", "cat bindir/bin1.dat | grep needle", "echo hi | grep x", "cd sub && cat s.txt | grep needle", "cd sub && echo hi && cat s.txt | grep needle", "false || cat a.txt | grep foo", "cat a.txt | grep foo 2>&1 | wc -l", "grep -rn needle '~'",
"grep -rn needle '*.txt'",
"grep -rn needle \\*.txt",
"grep -rn needle 'a\"b.txt'",
"grep -rn needle '~/htree'", "grep -rn needle '~user'", "grep -rn needle ~/\"htree\"", "grep -rn x plain | wc -l",
"grep -rn x plain | tail -2",
"grep -rn x plain | sort",
"grep -rn x plain missing.txt 2>&1 | wc -l",
"grep -rn x plain | grep -v 'plain/d1' | head -5", "grep foo a.txt | grep oo", "grep foo a.txt | xargs grep oo", "grep foo a.txt | head -1 | wc -l", "grep -rn needle 'sub/~'/*.txt",
];
let mut failures = Vec::new();
for row in rows {
let result = std::panic::catch_unwind(|| assert_parity(row, &ws, &home));
if let Err(payload) = result {
let msg = payload
.downcast_ref::<String>()
.cloned()
.or_else(|| payload.downcast_ref::<&str>().map(ToString::to_string))
.unwrap_or_else(|| "unknown panic".into());
failures.push(format!("{row}: {msg}"));
}
}
assert!(
failures.is_empty(),
"parity failures:\n {}",
failures.join("\n ")
);
}
#[test]
fn stdin_m_early_stop_stops_consuming() {
let tmp = tempfile::tempdir().expect("tempdir");
let ws = tmp.path().join("ws");
let home = tmp.path().join("home");
fs::create_dir_all(&ws).expect("ws");
fs::create_dir_all(&home).expect("home");
build_fixture(&ws, &home);
let (specs, _, _) =
analyze_command("seq 1 100000 | grep -m1 5", &ws, &home, true).expect("servable");
let (stream, _, _) = real_run_shell("seq 1 100000", &ws, &home);
assert!(stream.len() > BINARY_WINDOW, "fixture stream too small");
let (_, _, _, stream_bytes) = engine_run_with_stdin(&specs[0], &stream);
let n = stream_bytes.expect("marker emitted");
assert!(
n > 0 && n < stream.len() as u64,
"consumed {n} of {}: expected early stop",
stream.len()
);
}
#[test]
fn fallback_triggers() {
let tmp = tempfile::tempdir().expect("tempdir");
let ws = tmp.path().join("ws");
let home = tmp.path().join("home");
fs::create_dir_all(&ws).expect("ws");
fs::create_dir_all(&home).expect("home");
build_fixture(&ws, &home);
let rows: &[&str] = &[
"grep x", "grep x -", "grep -q x a.txt", "grep -L x a.txt", "grep -P x a.txt", "grep -z x a.txt", "grep -U x a.txt", "grep -S -r x plain", "grep -d skip x plain", "grep -b x a.txt", "grep -f pat.txt a.txt", "grep -m0 x a.txt", "grep -o -m2 x a.txt", "grep -o -A1 x a.txt", "grep -v -A1 x a.txt", "grep -w 'foo.' a.txt", "grep -w '' a.txt", "grep '\\(foo\\)\\1' br.txt", "grep 'a\\{,2\\}' br.txt", "grep 'a\\s' a.txt", "grep '\\<foo\\>' br.txt", "grep -e '' -e foo a.txt", "grep $VAR a.txt b.txt", "grep -o 'err\\|error' a.txt b.txt", "grep -o 'x\\(a\\|ab\\)' br.txt a.txt", "grep x *.rs", "grep x a.txt", "grep -c x a.txt", "grep -l x a.txt", "grep -r x plain/e.txt", "grep -rn needle '~'", "cat <<EOF\nfoo\nEOF\ngrep x a.txt b.txt", "git grep x", "if grep x a.txt; then echo hi; fi", "for x in a; do grep x a.txt; done", "case $x in a) grep x a.txt;; esac", "( grep x a.txt b.txt )", "{ grep x a.txt; }", "(cd sub && grep x a.txt b.txt)", "xargs grep x", "sh -c 'grep x a.txt'", "cd $HOME && grep x a.txt", "cd - && grep x a.txt", "grep x a.txt | head -1 | wc -l", "grep x a.txt | grep y", "grep x a.txt | xargs grep y", "grep x a.txt | | wc -l", "grep x a.txt |", "! grep x a.txt", "sudo grep x a.txt", "grep -w 'foo\\|bar' a.txt b.txt", "echo $(echo \\) ; grep x a.txt", "echo hello && echo world", "cat a.txt | grep foo b.txt", "cat a.txt | grep foo -", "printf 'x' | grep -r foo", "cat a.txt | sudo grep foo", "cat a.txt | xargs grep foo", ];
for row in rows {
assert_falls_back(row, &ws, &home);
}
assert_falls_back_reason(
"case $x in a) grep x a.txt;; esac",
&ws,
&home,
"nested grep",
);
assert_falls_back_reason(
"grep x a.txt | | wc -l",
&ws,
&home,
"empty command or pipeline member",
);
assert_falls_back_reason("cat f | head", &ws, &home, "no grep");
assert_falls_back_reason(
"cat a.txt | grep foo b.txt",
&ws,
&home,
"stdin with operands",
);
assert_falls_back_reason("cat a.txt | grep foo -", &ws, &home, "stdin with operands");
assert_falls_back_reason("printf 'x' | grep -r foo", &ws, &home, "stdin with -r");
assert_falls_back_reason("grep x a.txt | head -1 | wc -l", &ws, &home, "single file");
assert_falls_back_reason("grep x a.txt | grep y", &ws, &home, "single file");
assert_falls_back_reason("grep x a.txt | xargs grep y", &ws, &home, "single file");
}
#[test]
fn substitution_escape_resegments() {
let tmp = tempfile::tempdir().expect("tempdir");
let ws = tmp.path().join("ws");
let home = tmp.path().join("home");
fs::create_dir_all(&ws).expect("ws");
fs::create_dir_all(&home).expect("home");
build_fixture(&ws, &home);
analyze_command("echo `a\\`b` ; grep x a.txt b.txt", &ws, &home, true)
.expect("escaped backtick: trailing grep is a separate served member");
}
#[test]
fn exclusion_delta_is_rg_default() {
let tmp = tempfile::tempdir().expect("tempdir");
let ws = tmp.path().join("ws");
let home = tmp.path().join("home");
fs::create_dir_all(&ws).expect("ws");
fs::create_dir_all(&home).expect("home");
build_fixture(&ws, &home);
let (specs, _, _) =
analyze_command("grep -r x ign", &ws, &home, true).expect("ign walk servable");
let (eout, _, ecode) = engine_run(&specs[0]);
let text = String::from_utf8_lossy(&eout).to_string();
assert_eq!(ecode, 0);
assert!(text.contains("ign/visible.txt"), "visible searched: {text}");
assert!(!text.contains(".hidden.txt"), "hidden skipped: {text}");
assert!(!text.contains("skip.log"), "gitignored skipped: {text}");
let (specs, _, _) =
analyze_command("grep -r x ign/.hidden.txt ign/skip.log", &ws, &home, true)
.expect("explicit operands servable");
let (eout, _, ecode) = engine_run(&specs[0]);
assert_eq!(ecode, 0);
let text = String::from_utf8_lossy(&eout).to_string();
assert!(text.contains("ign/.hidden.txt"), "explicit hidden: {text}");
assert!(text.contains("ign/skip.log"), "explicit gitignored: {text}");
}
#[test]
fn engine_falls_back_on_untranslatable_patterns() {
let tmp = tempfile::tempdir().expect("tempdir");
let ws = tmp.path().join("ws");
let home = tmp.path().join("home");
fs::create_dir_all(&ws).expect("ws");
fs::create_dir_all(&home).expect("home");
build_fixture(&ws, &home);
let rows: &[&str] = &[
"grep '\\(' a.txt",
"grep -E 'a(' a.txt",
"grep 'a\\}' a.txt",
"grep 'a{' a.txt", ];
for row in &rows[..3] {
assert_falls_back(row, &ws, &home);
}
assert_parity(rows[3], &ws, &home);
}
}
#[cfg(test)]
mod redirect_token_pins {
use super::*;
fn redirects_of(segment: &str) -> (Vec<String>, Vec<String>) {
let mut words = grep_tokenize(segment).expect("tokenize");
if words.first().is_some_and(|w| w.value == "grep") {
words.remove(0);
}
let parsed = parse_grep_words(&words, "grep").expect("parse");
(parsed.redirects, parsed.operand_tokens)
}
#[test]
fn divergent_redirect_shapes_consume_targets_like_the_guard() {
let (redirects, operands) = redirects_of("grep foo a.txt 10> out.txt");
assert_eq!(redirects, ["10>", "out.txt"], "10> must consume its target");
assert_eq!(operands, ["a.txt"]);
let (redirects, operands) = redirects_of("grep foo a.txt 1>0 out.txt");
assert_eq!(redirects, ["1>0"], "1>0 is self-contained");
assert_eq!(operands, ["a.txt", "out.txt"], "out.txt stays an operand");
}
}
#[cfg(test)]
mod cd_scan_pins {
use super::super::scan::CdScan;
use super::*;
fn scan(segment: &str) -> CdScan<'_> {
let words: Vec<&str> = segment.split_whitespace().collect();
super::super::scan::cd_target_after_options(&words, 0)
}
#[test]
fn cd_option_grammar_stays_shared_and_fail_closed() {
let rows: &[(&str, CdScan<'static>)] = &[
("sub", CdScan::Target("sub", 1)),
("-P /tmp", CdScan::Target("/tmp", 2)),
("-L /tmp", CdScan::Target("/tmp", 2)),
("-PL /tmp", CdScan::Target("/tmp", 2)),
("-- -P", CdScan::Target("-P", 2)), ("--", CdScan::Bare),
("-P", CdScan::Bare), ("", CdScan::Bare), ("-", CdScan::Target("-", 1)), ("-e /tmp", CdScan::BadOption), ("-Pe /tmp", CdScan::BadOption),
("\"-P\"", CdScan::Target("-P", 1)), ("\"/tmp\"", CdScan::Target("/tmp", 1)), ];
for (input, expected) in rows {
assert_eq!(&scan(input), expected, "cd {input}");
}
let tmp = tempfile::tempdir().expect("tempdir");
let ws = tmp.path().join("ws");
let home = tmp.path().join("home");
fs::create_dir_all(&ws).expect("ws");
fs::create_dir_all(&home).expect("home");
assert_eq!(
resolve_cd("cd", &ws, &home).expect("bare cd"),
canonical_or_lexical(&home)
);
assert_eq!(
resolve_cd("cd -P", &ws, &home).expect("flag-only cd"),
canonical_or_lexical(&home)
);
for bad in ["cd -e sub", "cd -Pe sub", "cd sub extra", "cd -- -P extra"] {
assert!(
matches!(resolve_cd(bad, &ws, &home), Err(Fallback::CdUntrackable)),
"{bad}: expected CdUntrackable"
);
}
}
}
#[cfg(test)]
mod segmenter_pins {
use super::super::extract_command_segments;
use super::*;
#[test]
fn divergent_policies_stay_pinned() {
let profile_rows: &[(&str, &[&str])] = &[
("echo \\a", &["echo a"]),
("a |& b", &["a", "& b"]),
("echo a ;; echo b", &["echo a", "echo b"]),
];
for (input, expected) in profile_rows {
let segs = extract_command_segments(input);
let got: Vec<&str> = segs.iter().map(String::as_str).collect();
assert_eq!(got, *expected, "profile: {input:?}");
}
let grep_ok: &[(&str, &[(&str, &str)])] = &[
("grep \\*.txt a.txt", &[("grep \\*.txt a.txt", "")]),
("a |& grep x a.txt", &[("a", "|&"), ("grep x a.txt", "")]),
];
for (input, expected) in grep_ok {
let got = split_segments(input).expect("expected segments");
let got: Vec<(&str, &str)> =
got.iter().map(|(s, c)| (s.as_str(), c.as_str())).collect();
assert_eq!(got.as_slice(), *expected, "grep: {input:?}");
}
assert!(
split_segments("echo a ;; echo b").is_err(),
"grep: ;; outside case"
);
}
}