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::plan::{self, Plan};
use crate::tools::shell::scan::CdScan;
use crate::tools::shell::scan::strip_heredoc_bodies;
use crate::util::UnwrapPoison;
use crate::util::is_word_char;
use super::{REFUSAL_FRAME, SHELL_PLATFORM, ShellPlatform};
pub(super) mod windows;
mod parity;
pub(crate) use self::parity::{invalidate_verdict_for, refresh_if_environment_changed};
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;
#[cfg_attr(unix, expect(dead_code, reason = "the unix close path is SIGPIPE"))]
const BROKEN_PIPE_EXIT: i32 = 128 + 13;
pub(super) const STALE_BINARY_LOCK_MSG: &str = "Another instance of mahbot is already running";
pub(super) const ENGINE_REFUSAL_MARKER: &str = "__mahbot_grep_engine_unserved__";
const STREAM_SIZE_MARKER: &str = "__mahbot_stream_bytes__";
const MAX_SPEC_JSON: usize = 64 * 1024;
const MAX_SPEC_FILE: usize = 4 * 1024 * 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, Copy, PartialEq, Eq)]
enum StdoutDest {
Capture,
Tail,
File,
}
impl StdoutDest {
fn of(spec: &EngineSpec, fd1_is_pipe: bool) -> Self {
if !fd1_is_pipe {
StdoutDest::File
} else if spec.piped {
StdoutDest::Tail
} else {
StdoutDest::Capture
}
}
fn limit(self) -> Option<usize> {
match self {
StdoutDest::Capture => Some(OUTPUT_CAP),
StdoutDest::Tail | StdoutDest::File => None,
}
}
}
#[cfg(unix)]
fn stdout_is_pipe() -> bool {
let mut st = std::mem::MaybeUninit::<libc::stat>::uninit();
if unsafe { libc::fstat(libc::STDOUT_FILENO, st.as_mut_ptr()) } != 0 {
return true;
}
let mode = unsafe { st.assume_init() }.st_mode;
mode & libc::S_IFMT == libc::S_IFIFO
}
#[cfg(windows)]
fn stdout_is_pipe() -> bool {
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_PIPE, FILE_TYPE_UNKNOWN, GetFileType};
use windows_sys::Win32::System::Console::{GetStdHandle, STD_OUTPUT_HANDLE};
let kind = unsafe { GetFileType(GetStdHandle(STD_OUTPUT_HANDLE)) };
kind == FILE_TYPE_PIPE || kind == FILE_TYPE_UNKNOWN
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Operand {
display: String,
resolved: String,
trailing_slash: bool,
}
#[derive(Debug)]
pub(super) struct GrepOutcome {
pub served: bool,
pub reason: String,
pub recursive: bool,
pub piped: bool,
pub operand_count: usize,
pub flags: String,
}
#[derive(Debug)]
pub(super) struct GrepServe {
pub rewritten: Option<String>,
pub outcomes: Vec<GrepOutcome>,
pub spec_files: Vec<PathBuf>,
pub plan: Option<Plan>,
pub refusal: Option<String>,
}
pub(super) fn unserved_failure(reason: &str, remedy: Option<&str>) -> String {
let message = format!(
"{REFUSAL_FRAME}{reason}. The search did NOT run — this is not an empty \
match set."
);
match remedy {
Some(remedy) => format!("{message} {remedy}"),
None => message,
}
}
pub(super) struct GrepTelemetryShape {
pub recursive: bool,
pub piped: bool,
pub operand_count: usize,
pub flags: String,
pub grep_count: usize,
pub served_count: usize,
pub skipped_count: usize,
}
impl GrepServe {
fn not_rewritten(outcomes: Vec<GrepOutcome>, refusal: Option<String>) -> Self {
Self {
rewritten: None,
outcomes,
spec_files: Vec::new(),
plan: None,
refusal,
}
}
pub(super) fn telemetry_shape(&self, applied: bool) -> GrepTelemetryShape {
let grep_count = self.outcomes.len();
let served_count = if applied {
self.outcomes.iter().filter(|o| o.served).count()
} else {
0
};
GrepTelemetryShape {
recursive: self.outcomes.iter().any(|o| o.recursive),
piped: self.outcomes.iter().any(|o| o.piped),
operand_count: self.outcomes.iter().map(|o| o.operand_count).sum(),
flags: self
.outcomes
.iter()
.map(|o| o.flags.as_str())
.filter(|s| !s.is_empty())
.fold(String::new(), |mut acc, s| {
if !acc.is_empty() {
acc.push('|');
}
acc.push_str(s);
acc
}),
grep_count,
served_count,
skipped_count: grep_count - served_count,
}
}
}
pub(super) fn try_serve_command(command: &str, workspace_root: &Path) -> GrepServe {
serve_command(
command,
workspace_root,
pinned_home().as_deref(),
SHELL_PLATFORM,
engine_available,
)
}
fn refusal(platform: ShellPlatform, cause: Option<String>) -> Option<String> {
if platform == ShellPlatform::Windows {
cause
} else {
None
}
}
fn demoted(platform: ShellPlatform, mut outcomes: Vec<GrepOutcome>, reason: &str) -> GrepServe {
all_not_served(&mut outcomes, reason);
let refusal = refusal(platform, Some(reason.to_string()));
GrepServe::not_rewritten(outcomes, refusal)
}
fn serve_command(
command: &str,
workspace_root: &Path,
home: Option<&Path>,
platform: ShellPlatform,
engine_ready: impl Fn() -> bool,
) -> GrepServe {
let windows = platform == ShellPlatform::Windows;
let home = match home {
Some(home) => home,
None if windows => Path::new(""),
None => return GrepServe::not_rewritten(Vec::new(), None),
};
let allow_single = windows;
let Analyzed {
specs,
shapes,
segments,
outcomes,
root_cwd,
unserved,
} = match analyze_command(command, workspace_root, home, platform, allow_single) {
Ok(analyzed) => analyzed,
Err(fail) => {
let AnalyzeFailure {
reason,
mut outcomes,
unserved,
} = fail;
let cause = unserved.or_else(|| {
(windows
&& !matches!(reason, Fallback::NoGrep)
&& unreadable_line_search(command, platform))
.then(|| reason.to_string())
});
if outcomes.is_empty() {
return GrepServe::not_rewritten(Vec::new(), refusal(platform, cause));
}
tracing::debug!(command = command, %reason, "grep engine: fallback");
if outcomes.iter().any(|o| o.served) {
all_not_served(&mut outcomes, &reason.to_string());
}
return GrepServe::not_rewritten(outcomes, refusal(platform, cause));
}
};
if let Some(cause) = unserved {
return demoted(platform, outcomes, &cause);
}
if !engine_ready() {
return demoted(platform, outcomes, "engine unavailable");
}
let jsons: Vec<String> = specs.iter().map(spec_json).collect();
let limit = match platform {
ShellPlatform::Unix => MAX_SPEC_JSON,
ShellPlatform::Windows => MAX_SPEC_FILE,
};
if jsons.iter().any(|json| json.len() > limit) {
return demoted(platform, outcomes, "spec exceeds payload limit");
}
let rendered = std::env::current_exe()
.map(|exe| exe.to_string_lossy().into_owned())
.map_err(|e| Fallback::Handoff(format!("no own executable path: {e}")))
.and_then(|exe| join_rewritten(&segments, &specs, &jsons, platform, &exe, &root_cwd));
let Joined {
text: rewritten,
spec_files,
plan,
} = match rendered {
Ok(rendered) => rendered,
Err(reason) => {
tracing::debug!(command = command, %reason, "grep engine: fallback");
return demoted(platform, outcomes, &reason.to_string());
}
};
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"
);
}
}
GrepServe {
rewritten: Some(rewritten),
outcomes,
spec_files,
plan,
refusal: None,
}
}
fn skipped_grep_outcome(reason: String, seg: &str, piped: bool) -> GrepOutcome {
let (recursive, operand_count, flags) = lightweight_scan(seg);
GrepOutcome {
served: false,
reason,
recursive,
piped,
operand_count,
flags,
}
}
const FLAG_ORDER: &[char] = &[
'n', 'i', 'v', 'w', 'x', 'a', 'h', 'H', 's', 'r', 'o', 'z', 'c', 'l', 'm', 'C', 'A', 'B',
];
fn lightweight_scan(command: &str) -> (bool, usize, String) {
let mut recursive = false;
let mut operand_count = 0usize;
let mut flags: std::collections::BTreeSet<char> = std::collections::BTreeSet::new();
let mut saw_pattern = false;
for (i, word) in command.split_whitespace().enumerate() {
if i == 0 {
continue; }
if word.starts_with("--") {
continue; }
if let Some(cluster) = word.strip_prefix('-') {
if cluster.is_empty() {
if saw_pattern {
operand_count += 1;
} else {
saw_pattern = true;
}
continue;
}
for c in cluster.chars().filter(char::is_ascii_alphabetic) {
if c == 'r' || c == 'R' {
recursive = true;
}
flags.insert(if c == 'R' { 'r' } else { c });
}
continue;
}
if saw_pattern {
operand_count += 1;
} else {
saw_pattern = true;
}
}
let flag_str = FLAG_ORDER
.iter()
.filter(|c| flags.contains(c))
.copied()
.collect();
(recursive, operand_count, flag_str)
}
#[cfg(feature = "grep-engine-e2e")]
fn served_rewrite(command: &str, workspace_root: &Path, home: &Path) -> Option<String> {
serve_command(
command,
workspace_root,
Some(home),
ShellPlatform::Unix,
|| true,
)
.rewritten
}
#[cfg(feature = "grep-engine-e2e")]
#[doc(hidden)]
#[must_use]
pub fn grep_engine_rewrite_for_test(
command: &str,
workspace_root: &Path,
home: &Path,
) -> Option<String> {
served_rewrite(command, workspace_root, home)
}
#[cfg(feature = "grep-engine-e2e")]
#[doc(hidden)]
pub fn grep_engine_establish_parity_for_harness() {
parity::publish_all_for_harness();
}
#[cfg_attr(
not(any(all(test, target_os = "macos"), feature = "grep-engine-e2e")),
expect(
dead_code,
reason = "used only by the macOS-gated parity matrix and the e2e harness"
)
)]
fn spec_uses_parallel_walk(spec: &EngineSpec) -> bool {
spec.flags.r
&& spec
.operands
.iter()
.any(|op| std::fs::metadata(&op.resolved).is_ok_and(|m| m.is_dir()))
}
#[cfg(feature = "grep-engine-e2e")]
#[doc(hidden)]
#[must_use]
pub fn served_spec_walks_directory(command: &str, workspace_root: &Path, home: &Path) -> bool {
let Ok(analyzed) = analyze_command(command, workspace_root, home, ShellPlatform::Unix, false)
else {
return false;
};
analyzed.specs.iter().any(spec_uses_parallel_walk)
}
#[cfg(feature = "grep-engine-e2e")]
#[doc(hidden)]
#[must_use]
pub fn grep_sorted_lines(bytes: &[u8]) -> Vec<&[u8]> {
let mut v: Vec<&[u8]> = bytes
.split(|&b| b == b'\n' || b == b'\0')
.filter(|l| !l.is_empty())
.collect();
v.sort();
v
}
fn pinned_home() -> Option<PathBuf> {
directories::UserDirs::new().map(|d| d.home_dir().to_path_buf())
}
fn spec_json(spec: &EngineSpec) -> String {
serde_json::to_string(spec).expect("spec serializes")
}
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, line_end) = locate_line(rest, b'\n', pos);
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<()> = std::sync::OnceLock::new();
fn engine_available() -> bool {
if ENGINE_AVAILABLE.get().is_some() {
return true;
}
let available = probe_engine();
if available {
let _ = ENGINE_AVAILABLE.set(());
}
available
}
fn probe_engine() -> bool {
let Some(exe) = std::env::current_exe().ok() else {
return false;
};
let mut probe = std::process::Command::new(exe);
probe
.arg(ENGINE_VERB)
.arg("--probe")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
probe.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
}
probe.status().is_ok_and(|s| s.success())
}
struct ServedMember {
text: String,
argv: Option<Vec<String>>,
}
fn render_served(
json: &str,
redirects: &[String],
platform: ShellPlatform,
exe: &str,
files: &mut Vec<PathBuf>,
) -> Result<ServedMember, Fallback> {
let mut fragment = String::new();
let mut argv = None;
match platform {
ShellPlatform::Unix => {
fragment.push_str(&shell_quote(exe));
fragment.push(' ');
fragment.push_str(ENGINE_VERB);
fragment.push(' ');
fragment.push_str(&shell_quote(json));
}
ShellPlatform::Windows => {
let quoted_exe = windows::cmd_quote(exe).map_err(Fallback::Handoff)?;
let path = windows::spec_file_path();
let quoted_path =
windows::cmd_quote(&path.to_string_lossy()).map_err(Fallback::Handoff)?;
files.push(path.clone());
windows::write_spec_file(&path, json).map_err(Fallback::Handoff)?;
fragment.push_str("ed_exe);
fragment.push(' ');
fragment.push_str(ENGINE_VERB);
fragment.push(' ');
fragment.push_str(windows::SPEC_FILE_FLAG);
fragment.push(' ');
fragment.push_str("ed_path);
argv = Some(vec![
ENGINE_VERB.to_string(),
windows::SPEC_FILE_FLAG.to_string(),
path.to_string_lossy().into_owned(),
]);
}
}
if !redirects.is_empty() {
fragment.push(' ');
fragment.push_str(&redirects.join(" "));
}
Ok(ServedMember {
text: fragment,
argv,
})
}
#[derive(Debug)]
pub(super) enum Fallback {
NoGrep,
NestedGrep,
Heredoc,
StdinMode,
UnsupportedFlag(String),
MissingOptionValue,
EmptyAlternation,
Pattern(String),
CompileFailure(String),
UnresolvableOperand(String),
UnexpandableGlob,
ProcessSubstitution,
SingleFile,
NotEstablished(&'static str),
CdUntrackable,
StdinOperands,
StdinRecursive,
SegmentEmpty,
EnvironmentChanged,
CmdSyntax(String),
Expansion(String),
Handoff(String),
PlanRefusal(String),
}
impl From<plan::Refusal> for Fallback {
fn from(refusal: plan::Refusal) -> Self {
Fallback::PlanRefusal(refusal.to_string())
}
}
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::ProcessSubstitution => write!(f, "process substitution"),
Fallback::SingleFile => write!(f, "single file"),
Fallback::NotEstablished(cap) => {
write!(f, "locale parity not established ({cap})")
}
Fallback::CdUntrackable => write!(f, "cd untrackable"),
Fallback::EnvironmentChanged => write!(f, "the line changes the environment first"),
Fallback::StdinOperands => write!(f, "stdin with operands"),
Fallback::StdinRecursive => write!(f, "stdin with -r"),
Fallback::SegmentEmpty => write!(f, "empty command or pipeline member"),
Fallback::CmdSyntax(s) => write!(f, "cmd.exe spelling the engine cannot read: {s}"),
Fallback::Expansion(s) => write!(f, "two `%` in {s}"),
Fallback::Handoff(s) => write!(f, "hand-off refused: {s}"),
Fallback::PlanRefusal(s) => write!(f, "{s}"),
}
}
}
#[derive(Debug)]
struct AnalyzeFailure {
reason: Fallback,
outcomes: Vec<GrepOutcome>,
unserved: Option<String>,
}
impl From<Fallback> for AnalyzeFailure {
fn from(reason: Fallback) -> Self {
AnalyzeFailure {
reason,
outcomes: Vec::new(),
unserved: None,
}
}
}
fn all_not_served(outcomes: &mut [GrepOutcome], reason: &str) {
for o in outcomes {
o.served = false;
o.reason = reason.to_string();
}
}
enum OutSegment {
Verbatim(String),
Served { spec: usize, redirects: Vec<String> },
}
struct Analyzed {
specs: Vec<EngineSpec>,
shapes: Vec<(usize, String, bool)>,
segments: Vec<(OutSegment, String)>,
outcomes: Vec<GrepOutcome>,
root_cwd: PathBuf,
unserved: Option<String>,
}
fn note_unserved(platform: ShellPlatform, unserved: &mut Option<String>, reason: &str) {
if platform == ShellPlatform::Windows && unserved.is_none() {
*unserved = Some(reason.to_string());
}
}
#[expect(clippy::too_many_lines)] fn analyze_command(
command: &str,
workspace_root: &Path,
home: &Path,
platform: ShellPlatform,
allow_single: bool,
) -> Result<Analyzed, AnalyzeFailure> {
if platform == ShellPlatform::Windows && windows::unquoted_semicolon(command) {
return Err(Fallback::CmdSyntax("unquoted `;`".into()).into());
}
let stripped = strip_heredoc_bodies(command);
if stripped != command {
return Err(Fallback::Heredoc.into());
}
let segments = split_segments(&stripped, platform)?;
if segments.is_empty() {
return Err(Fallback::SegmentEmpty.into());
}
if !segments.iter().any(|(seg, _)| {
let verb = first_word(seg);
is_grep_verb(verb, platform)
|| segment_contains_grep(seg, &list_key(verb, platform), platform)
}) {
return Err(Fallback::NoGrep.into());
}
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 root_cwd = canonical_or_lexical(workspace_root, platform);
let mut cwd = root_cwd.clone();
let mut rewritten: Vec<(OutSegment, 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];
let mut outcomes: Vec<GrepOutcome> = Vec::new();
let mut first_skip_reason: Option<Fallback> = None;
let mut unserved: Option<String> = None;
let mut group_depth = 0isize;
let mut env_may_have_changed = false;
for (idx, (seg, conn)) in segments.iter().enumerate() {
let in_group = group_depth > 0;
let delta = group_delta(seg);
if tail_preserved[idx] {
if grep_family(first_word(seg), platform).is_some() {
note_unserved(platform, &mut unserved, &Fallback::NestedGrep.to_string());
}
rewritten.push((OutSegment::Verbatim(seg.clone()), conn.clone()));
group_depth = (group_depth + delta).max(0);
continue;
}
let verb = first_word(seg);
let key = list_key(verb, platform);
let env_changed = env_may_have_changed;
if !matches!(conn.as_str(), "|" | "|&") && changes_the_environment(seg, platform) {
env_may_have_changed = true;
}
if is_cd_segment(verb, platform) {
if pstart[idx] != pend[idx] {
return Err(AnalyzeFailure {
reason: Fallback::CdUntrackable,
outcomes,
unserved,
});
}
let new_cwd = match resolve_cd(seg, &cwd, home, platform) {
Ok(c) => c,
Err(reason) => {
return Err(AnalyzeFailure {
reason,
outcomes,
unserved,
});
}
};
cwd = new_cwd;
rewritten.push((OutSegment::Verbatim(seg.clone()), conn.clone()));
group_depth = (group_depth + delta).max(0);
continue;
}
if let Some(family) = grep_family(verb, platform) {
let ctx = PipelineCtx {
piped: pstart[idx] != pend[idx],
stdin_fed: pstart[idx] != idx,
marker_ok: !exec_redirects_stderr,
env_changed,
};
if in_group {
if ctx.piped {
tail_preserved[idx + 1..=pend[idx]].fill(true);
}
if first_skip_reason.is_none() {
first_skip_reason = Some(Fallback::NestedGrep);
}
note_unserved(platform, &mut unserved, &Fallback::NestedGrep.to_string());
outcomes.push(skipped_grep_outcome(
Fallback::NestedGrep.to_string(),
seg,
ctx.piped,
));
rewritten.push((OutSegment::Verbatim(seg.clone()), conn.clone()));
group_depth = (group_depth + delta).max(0);
continue;
}
match serve_one_grep(seg, family, &cwd, home, platform, allow_single, ctx) {
Ok((spec, redirects)) => {
if ctx.piped {
tail_preserved[idx + 1..=pend[idx]].fill(true);
let verbs: Vec<String> = segments[pstart[idx]..=pend[idx]]
.iter()
.map(|(s, _)| {
let key = list_key(first_word(s), platform);
if is_grep_verb(&key, platform) {
"grep".to_string()
} else {
key
}
})
.collect();
shapes.push((verbs.len(), verbs.join("|"), spec.stdin));
}
outcomes.push(GrepOutcome {
served: true,
reason: String::new(),
recursive: spec.flags.r,
piped: ctx.piped,
operand_count: spec.operands.len(),
flags: flags_surface(&spec.flags),
});
rewritten.push((
OutSegment::Served {
spec: specs.len(),
redirects,
},
conn.clone(),
));
specs.push(spec);
}
Err(e) => {
let reason = e.to_string();
if ctx.piped {
tail_preserved[idx + 1..=pend[idx]].fill(true);
}
if first_skip_reason.is_none() {
first_skip_reason = Some(e);
}
note_unserved(platform, &mut unserved, &reason);
outcomes.push(skipped_grep_outcome(reason, seg, ctx.piped));
rewritten.push((OutSegment::Verbatim(seg.clone()), conn.clone()));
}
}
group_depth = (group_depth + delta).max(0);
continue;
}
let compound = is_compound_segment(seg, platform);
let carries_grep = segment_contains_grep(seg, &key, platform);
if compound || carries_grep {
if carries_grep {
if first_skip_reason.is_none() {
first_skip_reason = Some(Fallback::NestedGrep);
}
if !SEARCH_OWNING_VERBS.contains(&key.as_str()) {
note_unserved(platform, &mut unserved, &Fallback::NestedGrep.to_string());
}
outcomes.push(skipped_grep_outcome(
Fallback::NestedGrep.to_string(),
seg,
pstart[idx] != pend[idx],
));
}
rewritten.push((OutSegment::Verbatim(seg.clone()), conn.clone()));
group_depth = (group_depth + delta).max(0);
continue;
}
if key == "exec" && (seg.contains("2>") || seg.contains("&>")) {
exec_redirects_stderr = true;
}
rewritten.push((OutSegment::Verbatim(seg.clone()), conn.clone()));
group_depth = (group_depth + delta).max(0);
}
if specs.is_empty() {
return Err(AnalyzeFailure {
reason: first_skip_reason.unwrap_or(Fallback::NoGrep),
outcomes,
unserved,
});
}
Ok(Analyzed {
specs,
shapes,
segments: rewritten,
outcomes,
root_cwd,
unserved,
})
}
struct Joined {
text: String,
spec_files: Vec<PathBuf>,
plan: Option<Plan>,
}
fn join_rewritten(
segments: &[(OutSegment, String)],
specs: &[EngineSpec],
jsons: &[String],
platform: ShellPlatform,
exe: &str,
root_cwd: &Path,
) -> Result<Joined, Fallback> {
let mut out = String::new();
let mut files = Vec::new();
let mut members: Vec<(plan::Member, String)> = Vec::new();
for (i, (seg, conn)) in segments.iter().enumerate() {
if i > 0 {
out.push(' ');
}
match seg {
OutSegment::Verbatim(text) => {
out.push_str(text);
members.push((plan::Member::Shell { text: text.clone() }, conn.clone()));
}
OutSegment::Served { spec, redirects } => {
match render_served(&jsons[*spec], redirects, platform, exe, &mut files) {
Ok(rendered) => {
out.push_str(&rendered.text);
if let Some(argv) = rendered.argv {
members.push((
plan::Member::Own {
argv,
redirects: redirects.clone(),
cwd: PathBuf::from(&specs[*spec].cwd),
},
conn.clone(),
));
}
}
Err(reason) => {
discard_spec_files(&files);
return Err(reason);
}
}
}
}
if i + 1 < segments.len() {
out.push(' ');
out.push_str(conn);
} else if conn == "&" {
out.push(' ');
out.push_str(conn);
}
}
let plan = match platform {
ShellPlatform::Windows => {
match plan::build(&members, Path::new(exe), root_cwd) {
Ok(plan) => Some(plan),
Err(refusal) => {
discard_spec_files(&files);
return Err(Fallback::from(refusal));
}
}
}
ShellPlatform::Unix => None,
};
Ok(Joined {
text: out,
spec_files: files,
plan,
})
}
pub(super) fn discard_spec_files(files: &[PathBuf]) {
for path in files {
let _ = fs::remove_file(path);
}
}
fn split_segments(
command: &str,
platform: ShellPlatform,
) -> Result<Vec<(String, String)>, Fallback> {
match platform {
ShellPlatform::Unix => {
super::segment_command(command, super::SegmentMode::Grep).ok_or(Fallback::SegmentEmpty)
}
ShellPlatform::Windows => windows::segment_command(command)
.ok_or_else(|| Fallback::CmdSyntax("the whole line".into())),
}
}
fn group_delta(segment: &str) -> isize {
let mut delta = 0isize;
let mut in_single = false;
let mut in_double = false;
let mut chars = segment.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' && !in_single {
chars.next();
continue;
}
if !super::check_outside_quotes(c, &mut in_single, &mut in_double) {
continue;
}
match c {
'(' | '{' => delta += 1,
')' | '}' => delta -= 1,
_ => {}
}
}
delta
}
fn first_word(segment: &str) -> &str {
segment.split_whitespace().next().unwrap_or("")
}
fn grep_family(verb: &str, platform: ShellPlatform) -> Option<&'static str> {
match platform {
ShellPlatform::Unix => match verb {
"grep" => Some("grep"),
"egrep" => Some("egrep"),
"fgrep" => Some("fgrep"),
_ => None,
},
ShellPlatform::Windows => windows::grep_verb(verb),
}
}
fn is_grep_verb(verb: &str, platform: ShellPlatform) -> bool {
grep_family(verb, platform).is_some()
}
fn list_key(verb: &str, platform: ShellPlatform) -> String {
match platform {
ShellPlatform::Unix => verb.to_string(),
ShellPlatform::Windows => windows::verb_key(verb).unwrap_or_default(),
}
}
pub(super) fn is_cd_segment(verb: &str, platform: ShellPlatform) -> bool {
match platform {
ShellPlatform::Unix => verb == "cd",
ShellPlatform::Windows => matches!(
list_key(verb, platform).as_str(),
"cd" | "chdir" | "popd" | "pushd"
),
}
}
#[must_use]
pub(super) fn is_cd_spelling(word: &str, platform: ShellPlatform) -> bool {
if platform != ShellPlatform::Windows {
return false;
}
let word = word.strip_prefix('@').unwrap_or(word);
let name = word.split(['\\', '/', '.']).next().unwrap_or("");
matches!(
name.to_ascii_lowercase().as_str(),
"cd" | "chdir" | "popd" | "pushd"
)
}
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",
"cmd",
"powershell",
"pwsh",
"find",
"git",
"docker",
"kubectl",
"podman",
];
const COMPOUND_KEYWORDS: &[&str] = &[
"if", "then", "else", "elif", "fi", "for", "while", "until", "do", "done", "case", "esac",
"select",
];
const ENV_MUTATING_BUILTINS: &[&str] = &["export", "unset", "eval", "source", "."];
fn changes_the_environment(seg: &str, platform: ShellPlatform) -> bool {
if platform == ShellPlatform::Windows {
return false;
}
let mut words = seg
.split_whitespace()
.map(|word| crate::tools::shell::scan::strip_quoted_word(word).trim_start_matches('\\'));
let first = words.next().unwrap_or_default();
let second = words.next();
let command_position =
super::is_env_assignment(first) || ENV_MUTATING_BUILTINS.contains(&first);
let body_position = is_compound_segment(seg, platform)
&& second.is_some_and(|word| {
super::is_env_assignment(word) || ENV_MUTATING_BUILTINS.contains(&word)
});
command_position || body_position
}
fn is_compound_segment(segment: &str, platform: ShellPlatform) -> bool {
let trimmed = segment.trim_start();
match trimmed.chars().next() {
Some('(' | '{') => true,
Some(c) if is_word_char(c) => {
COMPOUND_KEYWORDS.contains(&list_key(first_word(trimmed), platform).as_str())
}
_ => false,
}
}
fn segment_contains_grep(segment: &str, verb: &str, platform: ShellPlatform) -> bool {
let suspicious = GREP_INTRODUCERS.contains(&verb) || verb.contains('=');
if !suspicious {
return false;
}
match platform {
ShellPlatform::Windows => {
windows::tokenize(segment).is_some_and(|words| words_nest_grep(&words))
}
ShellPlatform::Unix => segment
.split_whitespace()
.any(|w| is_grep_verb(crate::tools::shell::scan::strip_quoted_word(w), platform)),
}
}
fn words_nest_grep(words: &[GrepWord]) -> bool {
words.iter().any(|w| {
is_grep_verb(&w.value, ShellPlatform::Windows)
|| (w.value != w.raw
&& windows::tokenize(&w.value).is_some_and(|inner| words_nest_grep(&inner)))
})
}
const SEARCH_OWNING_VERBS: &[&str] = &["git", "docker", "kubectl", "podman", "ssh"];
fn unreadable_line_search(command: &str, platform: ShellPlatform) -> bool {
command.split(['&', '|', ';', '\n']).any(|fragment| {
let fragment = fragment.trim_start_matches(['(', '{', ' ', '\t']);
grep_family(first_word(fragment), platform).is_some()
})
}
pub(super) fn resolve_cd(
segment: &str,
cwd: &Path,
home: &Path,
platform: ShellPlatform,
) -> Result<PathBuf, Fallback> {
if platform == ShellPlatform::Windows {
let words = windows::tokenize(segment).ok_or(Fallback::CdUntrackable)?;
let Some((verb, rest)) = words.split_first() else {
return Err(Fallback::CdUntrackable);
};
let verb = list_key(&verb.raw, platform);
let rest: Vec<&str> = rest.iter().map(|w| w.raw.as_str()).collect();
if verb == "popd" {
return Err(Fallback::CdUntrackable);
}
let (target, next) = match windows::cd_scan(&rest) {
CdScan::Target(target, next) => (target, next),
CdScan::Bare => {
if verb == "pushd" {
return Err(Fallback::CdUntrackable);
}
return Ok(cwd.to_path_buf());
}
CdScan::BadOption => return Err(Fallback::CdUntrackable),
};
if rest.get(next).is_some() {
return Err(Fallback::CdUntrackable);
}
if windows::has_percent_expansion(target) {
return Err(Fallback::CdUntrackable);
}
if windows::is_drive_relative(target) {
return Err(Fallback::CdUntrackable);
}
return Ok(canonical_or_lexical(
&absolute_or_cwd(cwd, target, platform),
platform,
));
}
let words: Vec<&str> = segment.split_whitespace().collect();
let (target, next) = match super::scan::cd_target_after_options(&words, 1) {
CdScan::Target(target, next) => (target, next),
CdScan::Bare => return Ok(canonical_or_lexical(home, platform)),
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 {
absolute_or_cwd(cwd, target, platform)
};
Ok(canonical_or_lexical(&resolved, platform))
}
fn absolute_or_cwd(cwd: &Path, word: &str, platform: ShellPlatform) -> PathBuf {
let absolute = match platform {
ShellPlatform::Unix => word.starts_with('/'),
ShellPlatform::Windows => windows::is_absolute_word(word),
};
if absolute {
PathBuf::from(word)
} else {
cwd.join(word)
}
}
pub(super) fn canonical_or_lexical(p: &Path, platform: ShellPlatform) -> PathBuf {
let canonical = fs::canonicalize(p)
.or_else(|_| std::path::absolute(p))
.unwrap_or_else(|_| p.to_path_buf());
match platform {
ShellPlatform::Unix => canonical,
ShellPlatform::Windows => crate::util::strip_verbatim_prefix(&canonical),
}
}
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
}
fn context_active(&self) -> bool {
!self.count_mode() && (self.before > 0 || self.after > 0)
}
}
fn flags_surface(flags: &GrepFlags) -> String {
let mut s = String::new();
if flags.n {
s.push('n');
}
if flags.i {
s.push('i');
}
if flags.v {
s.push('v');
}
if flags.w {
s.push('w');
}
if flags.x {
s.push('x');
}
if flags.a {
s.push('a');
}
if flags.h {
s.push('h');
}
if flags.H {
s.push('H');
}
if flags.s {
s.push('s');
}
if flags.r {
s.push('r');
}
if flags.o {
s.push('o');
}
if flags.null {
s.push('z');
}
if flags.c {
s.push('c');
}
if flags.l {
s.push('l');
}
if flags.m.is_some() {
s.push('m');
}
if flags.before > 0 && flags.after > 0 {
s.push('C');
} else {
if flags.before > 0 {
s.push('B');
}
if flags.after > 0 {
s.push('A');
}
}
s
}
#[derive(Clone)]
pub(super) struct GrepWord {
pub(super) value: String,
pub(super) raw: String,
pub(super) redirect: bool,
pub(super) needs_target: bool,
}
fn opens_redirect(token: &str) -> bool {
let rest = token.trim_start_matches(|c: char| c.is_ascii_digit());
if rest.len() == token.len() {
token.starts_with(['<', '>']) || token.starts_with("&>")
} else {
rest.starts_with(['<', '>'])
}
}
fn grep_tokenize(segment: &str, platform: ShellPlatform) -> Result<Vec<GrepWord>, Fallback> {
if platform == ShellPlatform::Windows {
let words = windows::tokenize(segment)
.ok_or_else(|| Fallback::CmdSyntax("unbalanced quotes".into()))?;
if let Some(word) = words
.iter()
.find(|w| !w.redirect && windows::has_glued_redirect(&w.raw))
{
return Err(Fallback::CmdSyntax(format!("redirect in `{}`", word.raw)));
}
return Ok(words);
}
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, platform)?;
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;
}
if c == '<' || c == '>' {
if chars.peek() == Some(&'(') {
return Err(Fallback::ProcessSubstitution);
}
let all_digits = current.bytes().all(|b| b.is_ascii_digit());
if !all_digits && !opens_redirect(¤t) {
flush(&mut current, &mut out)?;
}
current.push(c);
continue;
}
if c == '&' && chars.peek() == Some(&'>') {
if !opens_redirect(¤t) {
flush(&mut current, &mut out)?;
}
current.push(c);
current.push(chars.next().expect("peeked `>`"));
continue;
}
}
current.push(c);
}
flush(&mut current, &mut out)?;
Ok(out)
}
fn unquote_word(raw: &str, platform: ShellPlatform) -> Result<String, Fallback> {
if platform == ShellPlatform::Windows {
return Ok(windows::unquote_word(raw));
}
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>,
patterns: 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,
platform: ShellPlatform,
) -> 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;
}
if platform == ShellPlatform::Windows
&& let Some(word) = argv.iter().find(|w| windows::has_percent_expansion(&w.raw))
{
return Err(Fallback::Expansion(word.raw.clone()));
}
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; }
if flags.v && flags.context_active() {
return Err(Fallback::UnsupportedFlag("-v+context".into()));
}
if flags.o && flags.context_active() {
return Err(Fallback::UnsupportedFlag("-o+context".into()));
}
if flags.o && flags.m.is_some() && !flags.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
&& !flags.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,
patterns: raw_patterns,
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 bsd_escaped_class_reject(c: char) -> Option<&'static str> {
match c {
'<' | '>' => Some(r"\< \>"),
's' | 'S' | 'w' | 'W' | 'd' | 'D' => Some(r"\s\w\d"),
_ => None,
}
}
fn check_ere_safe(pattern: &str) -> Result<(), Fallback> {
let mut escaped = false;
for c in pattern.chars() {
if escaped {
if let Some(what) = bsd_escaped_class_reject(c) {
return Err(Fallback::Pattern(what.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())),
esc if let Some(what) = bsd_escaped_class_reject(esc) => {
return Err(Fallback::Pattern(what.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)
.line_terminator(Some(b'\n'));
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> {
self.inner.line_terminator()
}
fn non_matching_bytes(&self) -> Option<&grep_matcher::ByteSet> {
self.inner.non_matching_bytes()
}
fn find_candidate_line(
&self,
haystack: &[u8],
) -> Result<Option<grep_matcher::LineMatchKind>, Self::Error> {
let Some(kind) = self.inner.find_candidate_line(haystack)? else {
return Ok(None);
};
if !self.validate_utf8 {
return Ok(Some(kind));
}
match kind {
grep_matcher::LineMatchKind::Candidate(i) => {
Ok(Some(grep_matcher::LineMatchKind::Candidate(i)))
}
grep_matcher::LineMatchKind::Confirmed(i) => {
let (start, end) = locate_line(haystack, b'\n', i);
if std::str::from_utf8(&haystack[start..end]).is_ok() {
return Ok(Some(grep_matcher::LineMatchKind::Confirmed(i)));
}
let mut pos = end;
loop {
let Some(kind) = self.inner.find_candidate_line(&haystack[pos..])? else {
return Ok(None);
};
match kind {
grep_matcher::LineMatchKind::Candidate(j) => {
return Ok(Some(grep_matcher::LineMatchKind::Candidate(pos + j)));
}
grep_matcher::LineMatchKind::Confirmed(j) => {
let (start, end) = locate_line(haystack, b'\n', pos + j);
if std::str::from_utf8(&haystack[start..end]).is_ok() {
return Ok(Some(grep_matcher::LineMatchKind::Confirmed(pos + j)));
}
pos = end;
}
}
}
}
}
}
}
fn locate_line(bytes: &[u8], term: u8, pos: usize) -> (usize, usize) {
let start = bytes[..pos]
.iter()
.rposition(|&b| b == term)
.map_or(0, |i| i + 1);
let end = bytes[pos..]
.iter()
.position(|&b| b == term)
.map_or(bytes.len(), |i| pos + i + 1);
(start, end)
}
#[expect(
clippy::struct_excessive_bools,
reason = "independent facts about one member, each read on its own"
)]
#[derive(Clone, Copy)]
struct PipelineCtx {
piped: bool,
stdin_fed: bool,
marker_ok: bool,
env_changed: bool,
}
fn serve_one_grep(
segment: &str,
verb: &str,
cwd: &Path,
home: &Path,
platform: ShellPlatform,
allow_single: bool,
ctx: PipelineCtx,
) -> Result<(EngineSpec, Vec<String>), Fallback> {
let mut words = grep_tokenize(segment, platform)?;
if words
.first()
.is_some_and(|w| grep_family(&w.value, platform) == Some(verb))
{
words.remove(0);
} else {
return Err(Fallback::NestedGrep);
}
let parsed = parse_grep_words(&words, verb, platform)?;
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, platform)? == "-" {
return Err(Fallback::StdinMode);
}
let expanded = resolve_operand(tok, cwd, home, platform)?;
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);
}
if ctx.env_changed {
return Err(Fallback::EnvironmentChanged);
}
if let Err(cap) = parity::serve_allowed(&parity::needs(&parsed, platform), platform) {
return Err(Fallback::NotEstablished(cap));
}
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)),
};
Ok((spec, parsed.redirects))
}
fn resolve_operand(
tok: &str,
cwd: &Path,
home: &Path,
platform: ShellPlatform,
) -> Result<Vec<Operand>, Fallback> {
if platform == ShellPlatform::Unix {
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, platform) {
let value = unquote_word(tok, platform)?;
let pattern = if platform == ShellPlatform::Unix && tok.starts_with("~/") {
home.join(&value[2..]).to_string_lossy().into_owned()
} else {
value
};
let matches = expand_glob(&pattern, cwd, platform)?;
if matches.is_empty() {
return Err(Fallback::UnexpandableGlob);
}
return Ok(matches
.iter()
.map(|m| {
let abs = absolute_or_cwd(cwd, m, platform);
operand_from_path(m, &abs, false)
})
.collect());
}
if platform == ShellPlatform::Unix
&& let Some(rest) = tok.strip_prefix("~/")
{
let expanded = home.join(unquote_word(rest, platform)?);
return Ok(vec![operand_from_path(
&expanded.to_string_lossy(),
&expanded,
false,
)]);
}
let value = unquote_word(tok, platform)?;
let trailing_slash = match platform {
ShellPlatform::Unix => value.ends_with('/'),
ShellPlatform::Windows => value.ends_with(['\\', '/']),
};
let abs = absolute_or_cwd(cwd, &value, platform);
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, platform: ShellPlatform) -> bool {
if platform == ShellPlatform::Windows {
return windows::has_unquoted_glob(tok);
}
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,
platform: ShellPlatform,
) -> Result<Vec<String>, Fallback> {
if platform == ShellPlatform::Windows {
if pattern.ends_with(['\\', '/']) {
return Err(Fallback::UnexpandableGlob);
}
let (prefix, comps) =
windows::split_components(pattern).ok_or(Fallback::UnexpandableGlob)?;
let base = if prefix.is_empty() {
cwd.to_path_buf()
} else {
PathBuf::from(&prefix)
};
let comps: Vec<&str> = comps.iter().map(String::as_str).collect();
let mut results = Vec::new();
glob_walk(&base, &prefix, &comps, &mut results, platform);
results.sort();
return Ok(results);
}
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, platform);
results.sort();
Ok(results)
}
fn glob_walk(
dir: &Path,
display: &str,
comps: &[&str],
results: &mut Vec<String>,
platform: ShellPlatform,
) {
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, true, platform) {
continue;
}
let child_display = join_display(display, &name, platform);
if rest.is_empty() {
results.push(child_display);
} else if entry.path().is_dir() {
glob_walk(&entry.path(), &child_display, rest, results, platform);
}
}
} else {
let next = dir.join(first);
let child_display = join_display(display, first, platform);
if rest.is_empty() {
if next.exists() {
results.push(child_display);
}
} else if next.is_dir() {
glob_walk(&next, &child_display, rest, results, platform);
}
}
}
fn join_display(prefix: &str, name: &str, platform: ShellPlatform) -> String {
match platform {
ShellPlatform::Windows => windows::join_display(prefix, name),
ShellPlatform::Unix => {
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;
}
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let json = match read_spec(args) {
Ok(json) => json,
Err(message) => return engine_failed(Some(&message)),
};
let spec: EngineSpec = match serde_json::from_str(&json) {
Ok(s) => s,
Err(e) => return engine_failed(Some(&format!("grep: engine: bad spec: {e}"))),
};
if spec.version != PROTOCOL_VERSION {
return cannot_serve(&spec, "spec version mismatch");
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| serve(&spec)));
match result {
Ok(Ok(code)) if code != ENGINE_FAILED_EXIT => code,
Ok(Err(reason)) => cannot_serve(&spec, reason),
Ok(Ok(_)) | Err(_) => engine_failed(None),
}
}
fn engine_failed(detail: Option<&str>) -> i32 {
if SHELL_PLATFORM == ShellPlatform::Windows {
eprintln!("{ENGINE_REFUSAL_MARKER}");
}
if let Some(detail) = detail {
eprintln!("{detail}");
}
ENGINE_FAILED_EXIT
}
fn read_spec(args: &[String]) -> Result<String, String> {
match args {
[flag, path, ..] if flag == windows::SPEC_FILE_FLAG => {
fs::read_to_string(path).map_err(|e| format!("grep: engine: spec file {path}: {e}"))
}
[json, ..] => Ok(json.clone()),
[] => Err("grep: engine: missing spec".to_string()),
}
}
fn cannot_serve(spec: &EngineSpec, reason: &str) -> i32 {
#[cfg(unix)]
{
let _ = reason;
exec_grep(&spec.fallback)
}
#[cfg(not(unix))]
{
engine_failed(Some(&format!("{}: engine: {reason}", spec.verb)))
}
}
#[cfg(unix)]
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, &'static str> {
let actual_cwd = std::env::current_dir().map_err(|_| "working directory unavailable")?;
let same_cwd = match SHELL_PLATFORM {
ShellPlatform::Unix => actual_cwd == Path::new(&spec.cwd),
ShellPlatform::Windows => windows::same_spelling(
&canonical_or_lexical(&actual_cwd, ShellPlatform::Windows),
Path::new(&spec.cwd),
),
};
if !same_cwd {
return Err("working directory diverged from the analyzed command");
}
let matcher = build_matcher(&spec.patterns, spec.mode, &spec.flags)
.map_err(|_| "pattern could not be compiled")?;
let mut out = Output::new(
OutputSink::Stdout(io::BufWriter::with_capacity(16 * 1024, io::stdout())),
StdoutDest::of(spec, stdout_is_pipe()),
);
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("engine panicked before producing output")
}
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));
if spec.flags.context_active() {
sb.before_context(spec.flags.before)
.after_context(spec.flags.after);
}
if let Some(m) = spec.flags.m {
sb.max_matches(Some(m));
}
(sb.build(), spec.flags.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 dyn LineOut,
) -> (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,
block_pending: true,
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 dyn LineOut,
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, SHELL_PLATFORM) {
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, SHELL_PLATFORM) {
return OperandResult::NoMatch;
}
return search_file(path, display, spec, matcher, out, show_prefix);
}
}
}
if lmeta.is_dir() {
if dir_excluded(display, &spec.exclude_dir, SHELL_PLATFORM) {
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, SHELL_PLATFORM) {
return OperandResult::NoMatch;
}
search_file(path, display, spec, matcher, out, show_prefix)
}
fn file_allowed_by_filters(display: &str, spec: &EngineSpec, platform: ShellPlatform) -> bool {
let base = display_basename(display, platform);
let mut last: Option<bool> = None;
for (include, pat) in &spec.filters {
if fnmatch(pat, display, platform) || fnmatch(pat, base, platform) {
last = Some(*include);
}
}
match last {
Some(include) => include,
None => !spec.filters.iter().any(|(include, _)| *include),
}
}
fn dir_excluded(display: &str, exclude_dir: &[String], platform: ShellPlatform) -> bool {
let base = display_basename(display, platform);
exclude_dir
.iter()
.any(|pat| fnmatch(pat, base, platform) || fnmatch(pat, display, platform))
}
fn display_basename(display: &str, platform: ShellPlatform) -> &str {
match platform {
ShellPlatform::Unix => display.rsplit('/').next().unwrap_or(display),
ShellPlatform::Windows => display.rsplit(['/', '\\']).next().unwrap_or(display),
}
}
fn fnmatch_flags(pattern: &str, s: &str, period: bool, platform: ShellPlatform) -> bool {
if platform == ShellPlatform::Windows {
return windows::fnmatch(pattern, s, period);
}
#[cfg(unix)]
{
let Ok(p) = std::ffi::CString::new(pattern) else {
return false;
};
let Ok(n) = std::ffi::CString::new(s) else {
return false;
};
let flags = if period { libc::FNM_PERIOD } else { 0 };
unsafe { libc::fnmatch(p.as_ptr(), n.as_ptr(), flags) == 0 }
}
#[cfg(not(unix))]
{
false
}
}
fn fnmatch(pattern: &str, s: &str, platform: ShellPlatform) -> bool {
fnmatch_flags(pattern, s, false, platform)
}
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 dest = out.dest;
let mut builder = ignore::WalkBuilder::new(&root_abs);
builder.filter_entry({
let root_display = root_display.clone();
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(),
SHELL_PLATFORM,
);
!dir_excluded(&display, &exclude_dir, SHELL_PLATFORM)
} else {
true
}
}
});
let shared_out = std::sync::Mutex::new(&mut *out);
let shared_result = std::sync::Mutex::new(OperandResult::NoMatch);
let root_abs = &root_abs;
let root_display = &root_display;
let out_ref = &shared_out;
let result_ref = &shared_result;
let visit = |entry: Result<ignore::DirEntry, ignore::Error>, sink: &mut dyn LineOut| {
match entry {
Ok(e) => {
let ft = e.file_type();
if ft.is_some_and(|t| t.is_symlink() || t.is_dir()) {
return OperandResult::NoMatch; }
let display = traversal_display(root_display, root_abs, e.path(), SHELL_PLATFORM);
if !file_allowed_by_filters(&display, spec, SHELL_PLATFORM) {
return OperandResult::NoMatch;
}
search_file(e.path(), &display, spec, matcher, sink, show_prefix)
}
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, SHELL_PLATFORM),
);
emit_error(spec, sink, &display, &message);
OperandResult::Error
}
}
};
if dest == StdoutDest::File {
builder.build_parallel().run(|| {
let mut local = ChunkedOut::new(out_ref);
Box::new(move |entry| {
let r = visit(entry, &mut local);
local.hand_off(); let mut gr = result_ref.lock().unwrap_poison();
*gr = (*gr).max(r);
ignore::WalkState::Continue
})
});
} else {
builder.build_parallel().run(|| {
let mut local = PerFileOut::new(dest);
Box::new(move |entry| {
let r = visit(entry, &mut local);
let (buf, err, block_pending) = local.take_stdio();
if !buf.is_empty() || !err.is_empty() {
merge_worker_output(
&mut out_ref.lock().unwrap_poison(),
&buf,
&err,
block_pending,
);
}
let mut gr = result_ref.lock().unwrap_poison();
*gr = (*gr).max(r);
ignore::WalkState::Continue
})
});
}
shared_result.into_inner().unwrap_poison()
}
fn merge_worker_output(guard: &mut Output, buf: &[u8], err: &[u8], block_pending: bool) {
if block_pending {
guard.file_block();
}
guard.write_bytes(buf);
for chunk in err.chunks(4096) {
guard.write_err(&String::from_utf8_lossy(chunk));
}
guard.flush();
}
const WALK_CHUNK: usize = 64 * 1024;
struct ChunkedOut<'a> {
shared: &'a std::sync::Mutex<&'a mut Output>,
buf: Vec<u8>,
err: Vec<u8>,
block_pending: bool,
}
impl<'a> ChunkedOut<'a> {
fn new(shared: &'a std::sync::Mutex<&'a mut Output>) -> Self {
ChunkedOut {
shared,
buf: Vec::new(),
err: Vec::new(),
block_pending: false,
}
}
fn hand_off(&mut self) {
if self.buf.is_empty() && self.err.is_empty() {
return;
}
let block_pending = std::mem::take(&mut self.block_pending);
merge_worker_output(
&mut self.shared.lock().unwrap_poison(),
&self.buf,
&self.err,
block_pending,
);
self.buf.clear();
self.err.clear();
}
}
impl LineOut for ChunkedOut<'_> {
fn write_bytes(&mut self, b: &[u8]) {
self.buf.extend_from_slice(b);
}
fn write_err(&mut self, s: &str) {
self.err.extend_from_slice(s.as_bytes());
}
fn flush(&mut self) {
self.hand_off_if_full();
}
fn hand_off_if_full(&mut self) {
if self.buf.len() >= WALK_CHUNK {
self.hand_off();
}
}
fn file_block(&mut self) {
self.block_pending = true;
}
}
struct PerFileOut {
out: Output,
block_pending: bool,
}
impl PerFileOut {
fn new(dest: StdoutDest) -> Self {
PerFileOut {
out: Output::new(OutputSink::Buffer(Vec::new()), dest),
block_pending: false,
}
}
fn take_stdio(&mut self) -> (Vec<u8>, Vec<u8>, bool) {
let block_pending = std::mem::take(&mut self.block_pending);
let (buf, err) = self.out.take_stdio();
(buf, err, block_pending)
}
}
impl LineOut for PerFileOut {
fn write_bytes(&mut self, b: &[u8]) {
self.out.write_bytes(b);
}
fn write_err(&mut self, s: &str) {
self.out.write_err(s);
}
fn flush(&mut self) {
self.out.flush();
}
fn file_block(&mut self) {
self.block_pending = true;
}
}
fn traversal_display(
root_display: &str,
root_abs: &Path,
entry: &Path,
platform: ShellPlatform,
) -> String {
let rel = entry.strip_prefix(root_abs).unwrap_or(entry);
let rel = rel.to_string_lossy();
if rel.is_empty() {
return root_display.to_string();
}
match platform {
ShellPlatform::Unix => format!("{}/{}", root_display.trim_end_matches('/'), rel),
ShellPlatform::Windows => windows::traversal_display(root_display, &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 dyn LineOut,
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 dyn LineOut, display: &str, message: &str) {
if !spec.flags.s {
out.write_err(&format!(
"{}: {display}: {}\n",
spec.verb,
strip_os_error(message)
));
}
}
const BLOCK_SEPARATOR: &[u8] = b"--\n";
#[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,
block_pending: bool,
count: u64,
matcher: &'a SearchMatcher,
out: &'a mut dyn LineOut,
}
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;
self.open_block();
let content = trim_line_terminator(mat.bytes(), SHELL_PLATFORM);
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_bytes(b"\n");
self.out.hand_off_if_full();
}
} else {
self.write_prefix(mat.line_number(), false);
self.out.write_bytes(content);
self.out.write_bytes(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); }
self.open_block();
let content = trim_line_terminator(ctx.bytes(), SHELL_PLATFORM);
self.write_prefix(ctx.line_number(), true);
self.out.write_bytes(content);
self.out.write_bytes(b"\n");
self.out.flush();
Ok(true)
}
fn context_break(&mut self, _searcher: &grep_searcher::Searcher) -> Result<bool, io::Error> {
self.out.write_bytes(BLOCK_SEPARATOR);
Ok(true)
}
}
impl GrepSink<'_> {
fn open_block(&mut self) {
if std::mem::take(&mut self.block_pending) && self.spec.flags.context_active() {
self.out.file_block();
}
}
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_bytes(&[sep]);
}
if let Some(n) = lineno {
self.out.write_bytes(n.to_string().as_bytes());
self.out.write_bytes(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_bytes(b":");
}
self.out.write_bytes(self.count.to_string().as_bytes());
self.out.write_bytes(b"\n");
}
if self.spec.flags.l && self.selected_any {
self.out.write_bytes(self.display.as_bytes());
self.out
.write_bytes(if self.spec.flags.null { b"\0" } else { b"\n" });
}
self.out.flush();
}
}
fn trim_line_terminator(b: &[u8], platform: ShellPlatform) -> &[u8] {
match platform {
ShellPlatform::Unix => b.strip_suffix(b"\n").unwrap_or(b),
ShellPlatform::Windows => b
.strip_suffix(b"\r\n")
.or_else(|| b.strip_suffix(b"\n"))
.unwrap_or(b),
}
}
struct Output {
sink: OutputSink,
err: Vec<u8>,
written: usize,
dest: StdoutDest,
block_printed: bool,
}
enum OutputSink {
Stdout(io::BufWriter<io::Stdout>),
Buffer(Vec<u8>),
}
trait LineOut {
fn write_bytes(&mut self, b: &[u8]);
fn write_err(&mut self, s: &str);
fn flush(&mut self);
fn hand_off_if_full(&mut self) {}
fn file_block(&mut self);
}
impl LineOut for Output {
fn write_bytes(&mut self, b: &[u8]) {
let take = match self.dest.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) => exit_on_broken_pipe(&w.write_all(&b[..take])),
OutputSink::Buffer(v) => v.extend_from_slice(&b[..take]),
}
self.written += take;
}
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 self.dest == StdoutDest::File {
return;
}
if let OutputSink::Stdout(w) = &mut self.sink {
exit_on_broken_pipe(&w.flush());
}
}
fn file_block(&mut self) {
if std::mem::replace(&mut self.block_printed, true) {
self.write_bytes(BLOCK_SEPARATOR);
}
}
}
fn exit_on_broken_pipe(result: &io::Result<()>) {
#[cfg(not(windows))]
let _ = result;
#[cfg(windows)]
if let Err(err) = result
&& err.kind() == io::ErrorKind::BrokenPipe
{
std::process::exit(BROKEN_PIPE_EXIT);
}
}
impl Output {
fn new(sink: OutputSink, dest: StdoutDest) -> Self {
Output {
sink,
err: Vec::new(),
written: 0,
dest,
block_printed: false,
}
}
fn take_stdio(&mut self) -> (Vec<u8>, Vec<u8>) {
self.written = 0;
match &mut self.sink {
OutputSink::Buffer(v) => (std::mem::take(v), std::mem::take(&mut self.err)),
OutputSink::Stdout(_) => (Vec::new(), std::mem::take(&mut self.err)),
}
}
fn finish(self) {
if let OutputSink::Stdout(mut w) = self.sink {
exit_on_broken_pipe(&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::test_support::{engine_run, engine_run_with_stdin};
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::create_dir_all(ws.join("walkctx")).unwrap();
let walk_block = "needle\nfiller\nfiller\nfiller\nfiller\n".repeat(1000);
fs::write(ws.join("walkctx/one.txt"), &walk_block).unwrap();
fs::write(ws.join("walkctx/two.txt"), &walk_block).unwrap();
fs::write(ws.join("walkctx/small.txt"), "x\nneedle\nx\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 fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
super::test_support::open_the_gate();
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);
(tmp, ws, home)
}
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, ShellPlatform::Unix).expect("segments");
let gidx = segments
.iter()
.position(|(s, _)| {
let v = first_word(s);
is_grep_verb(v, ShellPlatform::Unix)
|| segment_contains_grep(s, v, ShellPlatform::Unix)
})
.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 rejoin_from(segments: &[(String, String)], start: usize) -> String {
let mut out = format!(" {}", segments[start].1);
for (seg, conn) in &segments[start + 1..] {
out.push(' ');
out.push_str(seg);
if !conn.is_empty() {
out.push(' ');
out.push_str(conn);
}
}
out
}
fn pipeline_tail(command: &str) -> Option<String> {
let segments = split_segments(command, ShellPlatform::Unix).ok()?;
let first = segments
.iter()
.position(|(_, c)| matches!(c.as_str(), "|" | "|&"))?;
Some(rejoin_from(&segments, first))
}
type JoinedAnalyzeOutput = (
Vec<EngineSpec>,
Vec<(usize, String, bool)>,
String,
Vec<GrepOutcome>,
);
fn analyze_joined(
command: &str,
ws: &Path,
home: &Path,
allow_single: bool,
) -> Result<JoinedAnalyzeOutput, AnalyzeFailure> {
let analyzed = analyze_command(command, ws, home, ShellPlatform::Unix, allow_single)?;
let jsons: Vec<String> = analyzed.specs.iter().map(spec_json).collect();
let joined = join_rewritten(
&analyzed.segments,
&analyzed.specs,
&jsons,
ShellPlatform::Unix,
"/mahbot",
&analyzed.root_cwd,
)
.expect("the unix join never refuses");
Ok((
analyzed.specs,
analyzed.shapes,
joined.text,
analyzed.outcomes,
))
}
fn assert_parity(command: &str, ws: &Path, home: &Path) {
let (specs, _, rewritten, _) = analyze_joined(command, ws, home, true)
.unwrap_or_else(|e| panic!("{command}: expected servable, got {}", e.reason));
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 parallel = spec_uses_parallel_walk(&specs[0]);
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)
};
if parallel {
assert_eq!(
sorted_lines(&eout),
sorted_lines(&rout),
"stdout mismatch (parallel-walk ordering) for {command}"
);
assert_eq!(
sorted_lines(&eerr),
sorted_lines(&rerr),
"stderr mismatch (parallel-walk ordering) for {command}"
);
} else {
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 sorted_lines(bytes: &[u8]) -> Vec<&[u8]> {
let mut v: Vec<&[u8]> = bytes
.split(|&b| b == b'\n' || b == b'\0')
.filter(|l| !l.is_empty())
.collect();
v.sort();
v
}
fn grep_tail(command: &str) -> Option<String> {
let segments = split_segments(command, ShellPlatform::Unix).ok()?;
let gidx = segments.iter().position(|(s, _)| {
let v = first_word(s);
is_grep_verb(v, ShellPlatform::Unix) || segment_contains_grep(s, v, ShellPlatform::Unix)
})?;
if gidx + 1 >= segments.len() {
return Some(String::new());
}
Some(rejoin_from(&segments, gidx))
}
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_redirect_parity(command: &str, target: &str, ws: &Path, home: &Path) {
let (specs, _, rewritten, _) = analyze_joined(command, ws, home, false)
.unwrap_or_else(|e| panic!("{command}: expected servable, got {}", e.reason));
assert_eq!(specs.len(), 1, "{command}: expected one grep member");
assert!(
rewritten.ends_with(target),
"{command}: the redirect rides the rewrite verbatim: {rewritten}"
);
let (eout, eerr, ecode) = engine_run(&specs[0]);
let path = ws.join(target);
let _ = fs::remove_file(&path);
let (rout, rerr, rcode) = real_run_shell(command, ws, home);
assert!(
rout.is_empty(),
"{command}: the original's stdout went to {target}"
);
let written = fs::read(&path)
.unwrap_or_else(|e| panic!("{command}: the original's shell wrote {target}: {e}"));
assert!(
eout.len() < OUTPUT_CAP && written.len() < OUTPUT_CAP,
"{command}: a result at or above OUTPUT_CAP is pinned by the e2e redirect \
lane in benches/grep_engine_e2e.rs, not this capped in-process harness"
);
if spec_uses_parallel_walk(&specs[0]) {
assert_eq!(
sorted_lines(&eout),
sorted_lines(&written),
"{command}: the engine's stream must be what the original wrote to {target}"
);
} else {
assert_eq!(
eout, written,
"{command}: the engine's stream must be what the original wrote to {target}"
);
}
assert_eq!(eerr, rerr, "{command}: stderr");
assert_eq!(ecode, rcode, "{command}: exit");
}
fn assert_falls_back(command: &str, ws: &Path, home: &Path) {
assert!(
analyze_joined(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_joined(command, ws, home, false)
.err()
.unwrap_or_else(|| panic!("{command}: expected fallback, got served"));
assert_eq!(err.reason.to_string(), reason, "{command}");
}
#[test]
#[expect(clippy::too_many_lines)] fn differential_parity_matrix() {
let (_tmp, ws, home) = fixture();
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 foo a.txt b.txt",
"grep -n -A1 b a.txt",
"grep -C1 foo a.txt b.txt", "grep -h -C1 foo a.txt b.txt", "grep -n -A1 -B1 'b\\|h' ctx.txt a.txt",
"grep -C0 foo a.txt b.txt", "grep -C1 needle sub/s.txt bindir/bin1.dat bindir/bin4.dat", "grep -C1 needle bindir/bin1.dat sub/s.txt", "grep -C1 needle sub/s.txt bindir/bin1.dat walkctx/small.txt", "grep -rn -C1 x plain",
"grep -rn -C1 'x1\\|x3' plain",
"grep -rn -C1 needle walkctx",
"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 glued_operator_and_background_parity() {
let (_tmp, ws, home) = fixture();
for (command, target) in [
(
"grep -n x a.txt b.txt>redirect-glued.txt",
"redirect-glued.txt",
),
(
"grep -n x a.txt b.txt>>redirect-glued-append.txt",
"redirect-glued-append.txt",
),
(
"grep -n x a.txt b.txt&>redirect-glued-both.txt",
"redirect-glued-both.txt",
),
(
"grep -n x a.txt b.txt > redirect-closed.txt",
"redirect-closed.txt",
),
(
"grep -rn x plain>redirect-glued-walk.txt",
"redirect-glued-walk.txt",
),
] {
assert_redirect_parity(command, target, &ws, &home);
}
for (command, tail) in [
("grep -rn x plain & wait", "& wait"),
("grep -rn x plain & wait; echo AFTER", "& wait ; echo AFTER"),
] {
let (specs, _, rewritten, _) = analyze_joined(command, &ws, &home, false)
.unwrap_or_else(|e| panic!("{command}: expected servable, got {}", e.reason));
assert_eq!(specs.len(), 1, "{command}: the search is served");
assert!(
rewritten.ends_with(tail),
"{command}: the tail after `&` survives verbatim: {rewritten}"
);
}
assert_parity("grep -rn x plain & wait", &ws, &home);
assert_parity("grep -rn x plain # note & echo TAIL-RAN", &ws, &home);
}
#[test]
fn stdin_m_early_stop_stops_consuming() {
let (_tmp, ws, home) = fixture();
let (specs, _, _, _) =
analyze_joined("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, ws, home) = fixture();
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", "( 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", "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 | 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 per_segment_skip_with_served_sibling() {
let (_tmp, ws, home) = fixture();
let (specs, _, rewritten, outcomes) =
analyze_joined("grep x a.txt; grep -rn needle sub", &ws, &home, false)
.expect("chain with a servable sibling served");
assert_eq!(specs.len(), 1, "only the recursive grep is served");
assert!(
rewritten.starts_with("grep x a.txt ;"),
"first grep kept verbatim: {rewritten}"
);
assert!(
rewritten.contains("__grep-engine"),
"rewritten has engine verb: {rewritten}"
);
assert_eq!(outcomes.len(), 2, "one skip + one serve recorded");
assert!(!outcomes[0].served && outcomes[0].reason == "single file");
assert!(outcomes[1].served && outcomes[1].reason.is_empty());
let (specs, _, rewritten, outcomes) = analyze_joined(
"grep -rn needle sub; if grep x a.txt; then echo hi; fi",
&ws,
&home,
false,
)
.expect("compound sibling does not poison");
assert_eq!(specs.len(), 1, "only the recursive grep is served");
assert!(
rewritten.contains("__grep-engine"),
"recursive grep served: {rewritten}"
);
assert!(
rewritten.ends_with("if grep x a.txt ; then echo hi ; fi"),
"compound kept verbatim: {rewritten}"
);
assert_eq!(
outcomes.len(),
2,
"serve + the compound segment containing grep; bare `then`/`fi` carry no grep"
);
assert!(outcomes[0].served && outcomes[0].reason.is_empty());
assert!(!outcomes[1].served && outcomes[1].reason == "nested grep");
}
#[test]
fn substitution_escape_resegments() {
let (_tmp, ws, home) = fixture();
analyze_joined("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, ws, home) = fixture();
let (specs, _, _, _) =
analyze_joined("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_joined("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, ws, home) = fixture();
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, ShellPlatform::Unix).expect("tokenize");
if words.first().is_some_and(|w| w.value == "grep") {
words.remove(0);
}
let parsed = parse_grep_words(&words, "grep", ShellPlatform::Unix).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, ShellPlatform::Unix).expect("bare cd"),
canonical_or_lexical(&home, ShellPlatform::Unix)
);
assert_eq!(
resolve_cd("cd -P", &ws, &home, ShellPlatform::Unix).expect("flag-only cd"),
canonical_or_lexical(&home, ShellPlatform::Unix)
);
for bad in ["cd -e sub", "cd -Pe sub", "cd sub extra", "cd -- -P extra"] {
assert!(
matches!(
resolve_cd(bad, &ws, &home, ShellPlatform::Unix),
Err(Fallback::CdUntrackable)
),
"{bad}: expected CdUntrackable"
);
}
}
}
#[cfg(all(test, unix))]
mod windows_fnmatch_parity {
use super::*;
fn host_fnmatch(pattern: &str, name: &str, period: bool) -> bool {
let p = std::ffi::CString::new(pattern).expect("pattern has no NUL");
let n = std::ffi::CString::new(name).expect("name has no NUL");
let flags = if period { libc::FNM_PERIOD } else { 0 };
unsafe { libc::fnmatch(p.as_ptr(), n.as_ptr(), flags) == 0 }
}
#[test]
fn windows_matcher_agrees_with_the_host() {
let patterns = [
"*", "?", "a*", "*a", "a?c", "*.txt", "*.*", "[abc]", "[abc]*", "[!abc]", "[^a]",
"[a-z]*", "a[b-d]e", "[a-]", "[]a]", r"\*", r"a\?c", ".*", ".x", "*[!.]*", "*x*",
"[abc", "a[", "[z-a]",
];
let names = [
"", "a", "ab", "abc", "a.txt", "ab.txt", ".a", ".a.txt", ".hidden", ".", "..", "*",
"?", "\\", "a*c", "a?c", "a-b", "]", "a]", "ace", "axe", ".txt", "a/b", "abc ",
];
for pattern in patterns {
for name in names {
for period in [false, true] {
assert_eq!(
windows::fnmatch(pattern, name, period),
host_fnmatch(pattern, name, period),
"fnmatch({pattern:?}, {name:?}, period={period})"
);
}
}
}
}
}
#[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"]),
("grep x a.txt # c & echo A", &["grep x a.txt # c & echo A"]),
];
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", "")]),
(
"grep x a.txt # c & echo A\nls -la",
&[("grep x a.txt", "\n"), ("ls -la", "")],
),
];
for (input, expected) in grep_ok {
let got = split_segments(input, ShellPlatform::Unix).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", ShellPlatform::Unix).is_err(),
"grep: ;; outside case"
);
}
}
#[cfg(test)]
mod platform_string_pins {
use super::*;
#[test]
fn unix_display_paths_and_filters_read_only_slash() {
assert_eq!(
traversal_display(
"/ws/src",
Path::new("/ws/src"),
Path::new("/ws/src/main.rs"),
ShellPlatform::Unix
),
"/ws/src/main.rs"
);
assert_eq!(
traversal_display(
"",
Path::new("/ws"),
Path::new("/ws/main.rs"),
ShellPlatform::Unix
),
"/main.rs"
);
assert_eq!(
traversal_display(
r"/ws\dir",
Path::new("/ws/dir"),
Path::new("/ws/dir/a.rs"),
ShellPlatform::Unix
),
r"/ws\dir/a.rs"
);
assert_eq!(display_basename("a/b/c.rs", ShellPlatform::Unix), "c.rs");
assert_eq!(display_basename(r"a\b", ShellPlatform::Unix), r"a\b");
assert_eq!(display_basename(r"a\b", ShellPlatform::Windows), "b");
assert_eq!(display_basename("a/b", ShellPlatform::Windows), "b");
}
#[test]
fn windows_strips_the_carriage_return_only_with_its_newline() {
assert_eq!(
trim_line_terminator(b"line\r\n", ShellPlatform::Windows),
b"line"
);
assert_eq!(
trim_line_terminator(b"line\n", ShellPlatform::Windows),
b"line"
);
assert_eq!(
trim_line_terminator(b"line\r", ShellPlatform::Windows),
b"line\r"
);
assert_eq!(
trim_line_terminator(b"line\r", ShellPlatform::Unix),
b"line\r"
);
}
#[test]
fn canonical_or_lexical_strips_the_windows_verbatim_prefix() {
let tmp = tempfile::tempdir().expect("tempdir");
let canonical = canonical_or_lexical(tmp.path(), ShellPlatform::Windows);
assert!(
!canonical.to_string_lossy().starts_with(r"\\?\"),
"{canonical:?}"
);
let missing = tmp.path().join("no-such-dir");
assert!(
canonical_or_lexical(&missing, ShellPlatform::Windows)
.to_string_lossy()
.contains("no-such-dir")
);
}
}
#[cfg(test)]
mod handoff_pins {
use super::super::SpecFiles;
use super::*;
#[test]
fn windows_hand_off_quotes_the_executable_and_refuses_only_what_cmd_rewrites() {
let spec = render_only_json();
let mut files = Vec::new();
let ServedMember { text, argv } = render_served(
&spec,
&[],
ShellPlatform::Windows,
r"C:\Program Files (x86)\MahBot\mahbot.exe",
&mut files,
)
.expect("a programme path cmd.exe reads literally inside quotes");
assert!(
text.starts_with(r#""C:\Program Files (x86)\MahBot\mahbot.exe" "#),
"{text}"
);
assert!(text.contains(windows::SPEC_FILE_FLAG), "{text}");
assert_eq!(
argv.as_deref(),
Some(
[
ENGINE_VERB.to_string(),
windows::SPEC_FILE_FLAG.to_string(),
files[0].to_string_lossy().into_owned(),
]
.as_slice()
),
"the plan's argv names the scratch file the fragment does"
);
assert_eq!(files.len(), 1, "the spec rides a scratch file");
assert!(files[0].exists(), "{:?}", files[0]);
let file = files[0].clone();
drop(SpecFiles(files));
assert!(!file.exists(), "the call's guard removes it: {file:?}");
for bad in [r"C:\Users\a%b\mahbot.exe", r"C:\Users\a!b\mahbot.exe"] {
let err = render_served(&spec, &[], ShellPlatform::Windows, bad, &mut Vec::new())
.err()
.expect("a path cmd.exe rewrites even inside quotes");
assert!(err.to_string().contains("hand-off refused"), "{err}");
}
}
#[test]
fn unix_hand_off_keeps_the_argv_transport() {
let mut files = Vec::new();
let member = render_served(
&render_only_json(),
&["2>&1".into()],
ShellPlatform::Unix,
"/opt/it's here/mahbot",
&mut files,
)
.expect("unix hand-off");
assert!(member.argv.is_none(), "unix builds no own-image argv");
let text = member.text;
assert!(files.is_empty(), "no scratch file on unix");
assert!(text.starts_with("'/opt/it'\\''s here/mahbot' "), "{text}");
assert!(text.contains(ENGINE_VERB), "{text}");
assert!(text.ends_with(" 2>&1"), "redirects stay verbatim: {text}");
}
#[test]
fn the_spec_file_flag_reads_the_scratch_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("spec.json");
fs::write(&path, r#"{"version":1}"#).expect("spec file");
let file_args = |path: &Path| {
vec![
windows::SPEC_FILE_FLAG.to_string(),
path.to_string_lossy().into_owned(),
"grep".to_string(),
]
};
assert_eq!(
read_spec(&file_args(&path)).expect("the flag reads the file"),
r#"{"version":1}"#
);
assert!(
read_spec(&file_args(&tmp.path().join("no-such-spec.json"))).is_err(),
"an unreadable spec file is an error, never a parse of the flag"
);
assert_eq!(
read_spec(&[r#"{"version":2}"#.to_string()]).expect("the argv arm passes it through"),
r#"{"version":2}"#
);
}
}
#[cfg(test)]
mod writer_pins {
use super::*;
fn member_stdout(shared: &std::sync::Mutex<&mut Output>) -> Vec<u8> {
let guard = shared.lock().unwrap_poison();
let OutputSink::Buffer(bytes) = &guard.sink else {
unreachable!("buffered sink")
};
bytes.clone()
}
#[test]
fn chunked_walk_sink_hands_off_whole_records_at_record_and_entry_ends() {
let mut member = Output::new(OutputSink::Buffer(Vec::new()), StdoutDest::File);
let shared = std::sync::Mutex::new(&mut member);
let mut out = ChunkedOut::new(&shared);
let line = b"bigdir/file.txt:12345:needle\n";
out.write_bytes(b"path:0\n");
out.write_bytes(b"path\0");
out.flush();
assert!(
member_stdout(&shared).is_empty(),
"a sub-chunk buffer waits for the entry end, not a record end"
);
out.hand_off();
assert_eq!(member_stdout(&shared), b"path:0\npath\0");
let mut expected = member_stdout(&shared);
for _ in 0..(2 * WALK_CHUNK / line.len()) {
out.write_bytes(line);
out.flush();
assert!(
out.buf.len() < WALK_CHUNK + line.len(),
"a worker hands off as it fills instead of holding the result"
);
expected.extend_from_slice(line);
}
out.hand_off();
assert!(out.buf.is_empty());
assert_eq!(member_stdout(&shared), expected);
let before = expected.len();
let filler = vec![b'x'; WALK_CHUNK + 1];
let tail = b":needle\n";
out.write_bytes(&filler);
assert_eq!(
member_stdout(&shared).len(),
before,
"a chunk filled mid-record waits for the record's end"
);
out.write_bytes(tail);
out.flush();
assert!(out.buf.is_empty());
assert_eq!(
member_stdout(&shared).len(),
before + filler.len() + tail.len()
);
let start = member_stdout(&shared).len();
let group = 8 * WALK_CHUNK / line.len();
for _ in 0..group {
out.write_bytes(line);
out.hand_off_if_full();
assert!(
out.buf.len() < WALK_CHUNK + line.len(),
"a hand-off inside a record run keeps the worker at the chunk"
);
}
assert_eq!(
member_stdout(&shared).len() + out.buf.len(),
start + group * line.len(),
"a run's records are handed over as they fill, none lost"
);
out.hand_off();
assert_eq!(member_stdout(&shared).len(), start + group * line.len());
}
#[test]
fn the_merge_point_owns_the_between_files_separator() {
let mut member = Output::new(OutputSink::Buffer(Vec::new()), StdoutDest::File);
let shared = std::sync::Mutex::new(&mut member);
let mut out = ChunkedOut::new(&shared);
out.file_block();
out.write_bytes(b"one:needle\n");
out.hand_off();
assert_eq!(member_stdout(&shared), b"one:needle\n");
out.file_block();
out.write_bytes(b"two:needle\n");
out.hand_off();
assert_eq!(member_stdout(&shared), b"one:needle\n--\ntwo:needle\n");
out.write_bytes(b"two:more\n");
out.hand_off();
assert_eq!(
member_stdout(&shared),
b"one:needle\n--\ntwo:needle\ntwo:more\n"
);
out.write_err("grep: gone.txt: No such file or directory\n");
out.hand_off();
assert_eq!(
member_stdout(&shared),
b"one:needle\n--\ntwo:needle\ntwo:more\n"
);
}
#[test]
fn the_destination_decides_whether_the_result_is_cut() {
const LINES: usize = 4000;
let (_tmp, ws, _home) = serve_fixture();
let line = "needle ".to_string() + &"x".repeat(90) + "\n";
let huge = ws.join("huge.txt");
fs::write(&huge, line.repeat(LINES)).expect("huge fixture");
let other = ws.join("nomatch.txt");
fs::write(&other, "nothing here\n").expect("second operand");
let operand = |name: &str, path: &Path| Operand {
display: name.to_string(),
resolved: path.to_string_lossy().into_owned(),
trailing_slash: false,
};
let spec = EngineSpec {
version: PROTOCOL_VERSION,
verb: "grep".into(),
mode: MatchMode::Basic,
flags: GrepFlags {
h: true,
..GrepFlags::default()
},
filters: Vec::new(),
exclude_dir: Vec::new(),
patterns: vec!["needle".into()],
operands: vec![operand("huge.txt", &huge), operand("nomatch.txt", &other)],
cwd: ws.to_string_lossy().into_owned(),
fallback: Vec::new(),
piped: false,
stdin: false,
report_stream_bytes: false,
};
let as_pipeline_member = EngineSpec {
piped: true,
..spec.clone()
};
let matcher = build_matcher(&spec.patterns, spec.mode, &spec.flags).expect("matcher");
let run = |dest: StdoutDest| {
let mut out = Output::new(OutputSink::Buffer(Vec::new()), dest);
let consumed = std::cell::Cell::new(false);
let code = serve_into(&spec, &matcher, &mut out, io::Cursor::new(&[]), &consumed);
let Output {
sink: OutputSink::Buffer(bytes),
..
} = out
else {
unreachable!("buffered sink")
};
(bytes, code)
};
assert_eq!(StdoutDest::of(&spec, true), StdoutDest::Capture);
assert_eq!(StdoutDest::of(&as_pipeline_member, true), StdoutDest::Tail);
assert_eq!(StdoutDest::of(&spec, false), StdoutDest::File);
assert_eq!(StdoutDest::of(&as_pipeline_member, false), StdoutDest::File);
let (captured, code) = run(StdoutDest::Capture);
assert_eq!(
captured.len(),
OUTPUT_CAP,
"the captured result stops at the cap"
);
assert_eq!(code, 0);
let (written, code) = run(StdoutDest::File);
assert_eq!(
written.len(),
line.len() * LINES,
"a redirected member's file takes every match, none cut"
);
assert_eq!(code, 0);
}
}
#[cfg(test)]
use self::test_support::{render_only_json, serve_fixture, served, spec_cwd};
#[cfg(test)]
mod test_support {
use super::*;
pub(super) fn open_the_gate() {
parity::publish_for_test((true, true, true));
}
pub(super) fn serve_fixture() -> (tempfile::TempDir, PathBuf, PathBuf) {
open_the_gate();
let tmp = tempfile::tempdir().expect("tempdir");
let ws = tmp.path().join("ws");
let home = tmp.path().join("home");
fs::create_dir_all(ws.join("src")).expect("ws/src");
fs::create_dir_all(&home).expect("home");
fs::write(ws.join("f.txt"), "needle\n").expect("f.txt");
fs::write(ws.join("src/main.rs"), "fn needle() {}\n").expect("main.rs");
(tmp, ws, home)
}
pub(super) fn served(
command: &str,
ws: &Path,
home: &Path,
platform: ShellPlatform,
) -> (String, Vec<PathBuf>) {
let mut serve = serve_command(command, ws, Some(home), platform, || true);
assert!(
serve.refusal.is_none(),
"{command}: served, not refused: {:?}",
serve.refusal
);
let rewritten = serve
.rewritten
.take()
.unwrap_or_else(|| panic!("{command}: expected a rewrite"));
(rewritten, serve.spec_files)
}
#[cfg(unix)]
pub(super) fn engine_run(spec: &EngineSpec) -> (Vec<u8>, Vec<u8>, i32) {
let (out, err, code, _) = engine_run_with_stdin(spec, &[]);
(out, err, code)
}
#[cfg(unix)]
pub(super) 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 dest = StdoutDest::of(spec, true);
let mut out = Output::new(OutputSink::Buffer(Vec::new()), dest);
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)
}
pub(super) fn render_only_json() -> String {
spec_json(&EngineSpec {
version: PROTOCOL_VERSION,
verb: "grep".into(),
mode: MatchMode::Basic,
flags: GrepFlags::default(),
filters: Vec::new(),
exclude_dir: Vec::new(),
patterns: vec!["needle".into()],
operands: vec![Operand {
display: ".".into(),
resolved: "/ws".into(),
trailing_slash: false,
}],
cwd: "/ws".into(),
fallback: vec!["grep".into()],
piped: false,
stdin: false,
report_stream_bytes: false,
})
}
pub(super) fn spec_cwd(files: &[PathBuf]) -> String {
let path = files.first().expect("the rewrite scratched a spec file");
let json = fs::read_to_string(path).expect("spec file readable");
let spec: EngineSpec = serde_json::from_str(&json).expect("spec parses");
spec.cwd
}
}
#[cfg(test)]
mod read_only_serve_pins {
use super::super::SpecFiles;
use super::super::readonly::{CheckContext, check_command};
use super::*;
fn served_and_guard_accepted(
command: &str,
ws: &Path,
home: &Path,
platform: ShellPlatform,
) -> (String, Vec<PathBuf>) {
let (rewritten, files) = served(command, ws, home, platform);
assert!(rewritten.contains(ENGINE_VERB), "{rewritten}");
let ctx = CheckContext::for_platform(ws, platform);
assert!(
check_command(&rewritten, &ctx).is_ok(),
"read-only guard rejected the {platform:?} engine rewrite: {rewritten}"
);
(rewritten, files)
}
#[test]
fn generated_rewrite_passes_the_read_only_guard() {
let (_tmp, ws, home) = serve_fixture();
let (rewritten, files) =
served_and_guard_accepted("grep -rn needle .", &ws, &home, ShellPlatform::Unix);
assert!(
rewritten.starts_with('\''),
"rewrite quotes the executable path: {rewritten}"
);
assert!(files.is_empty(), "unix serves the spec by argv");
for (command, tail) in [
("grep -rn needle . & echo done", "& echo done"),
("grep -rn needle . &", " &"),
("grep -rn needle<f.txt .", "<f.txt"),
] {
let (rewritten, files) =
served_and_guard_accepted(command, &ws, &home, ShellPlatform::Unix);
assert!(
rewritten.ends_with(tail),
"{command}: the operator rides the rewrite: {rewritten}"
);
drop(SpecFiles(files));
}
let (rewritten, files) = served_and_guard_accepted(
"grep -n needle src/main.rs",
&ws,
&home,
ShellPlatform::Windows,
);
assert_eq!(files.len(), 1, "one spec file per served member");
assert!(rewritten.contains(windows::SPEC_FILE_FLAG), "{rewritten}");
let json = fs::read_to_string(&files[0]).expect("spec file readable");
let spec: serde_json::Value = serde_json::from_str(&json).expect("spec parses as JSON");
assert_eq!(spec["verb"], "grep");
let file = files[0].clone();
drop(SpecFiles(files));
assert!(
!file.exists(),
"the shell call's guard removes it: {file:?}"
);
let (rewritten, files) = served_and_guard_accepted(
"grep -n a f.txt && grep -n b g.txt",
&ws,
&home,
ShellPlatform::Windows,
);
assert_eq!(files.len(), 2, "one spec file per served member");
assert_eq!(
rewritten.matches(windows::SPEC_FILE_FLAG).count(),
2,
"both members hand their spec over by file: {rewritten}"
);
drop(SpecFiles(files.clone()));
for path in &files {
assert!(
!path.exists(),
"the guard removes every spec file: {path:?}"
);
}
}
#[test]
fn single_file_members_are_served_on_windows_only() {
let (_tmp, ws, home) = serve_fixture();
let command = "grep -n needle src/main.rs";
let windows = serve_command(command, &ws, Some(&home), ShellPlatform::Windows, || true);
assert!(
windows.rewritten.is_some(),
"single-file serving on Windows"
);
drop(SpecFiles(windows.spec_files));
let unix = serve_command(command, &ws, Some(&home), ShellPlatform::Unix, || true);
assert!(unix.rewritten.is_none(), "the unix perf gate stays");
assert_eq!(unix.outcomes.len(), 1);
assert_eq!(unix.outcomes[0].reason, "single file");
}
#[test]
fn the_shapes_a_read_only_windows_role_issues_are_served() {
let (_tmp, ws, home) = serve_fixture();
for command in [
"grep -rn needle .",
"cd src && grep -n needle main.rs",
"grep -rn --include=*.rs needle .",
"grep -rn needle *",
"grep \".\" src/main.rs",
] {
let (rewritten, files) =
served_and_guard_accepted(command, &ws, &home, ShellPlatform::Windows);
assert!(rewritten.contains(ENGINE_VERB), "{command}: {rewritten}");
drop(SpecFiles(files));
}
}
#[test]
fn windows_tracks_pushd_and_chdir_like_cd() {
let (_tmp, ws, home) = serve_fixture();
for command in [
"pushd src && grep -rn needle .",
"chdir src && grep -rn needle .",
"CD src && grep -rn needle .",
r#""cd" src && grep -rn needle ."#,
] {
let (rewritten, files) =
served_and_guard_accepted(command, &ws, &home, ShellPlatform::Windows);
assert!(rewritten.contains(ENGINE_VERB), "{command}: {rewritten}");
let cwd = spec_cwd(&files);
assert!(
cwd.ends_with("src"),
"{command}: tracked cwd must be inside src: {cwd}"
);
drop(SpecFiles(files));
}
}
#[test]
fn windows_tracks_a_quoted_cd_target() {
let (_tmp, ws, home) = serve_fixture();
fs::create_dir_all(ws.join("my dir")).expect("my dir");
let (rewritten, files) = served_and_guard_accepted(
"cd \"my dir\" && grep -rn needle .",
&ws,
&home,
ShellPlatform::Windows,
);
assert!(rewritten.contains(ENGINE_VERB), "{rewritten}");
let cwd = spec_cwd(&files);
assert!(
cwd.ends_with("my dir"),
"tracked cwd must be inside `my dir`: {cwd}"
);
drop(SpecFiles(files));
}
#[test]
fn the_shell_hands_over_a_line_whose_break_is_already_an_ampersand() {
let (_tmp, ws, home) = serve_fixture();
let (rewritten, files) = served_and_guard_accepted(
"cd src&grep -rn needle .",
&ws,
&home,
ShellPlatform::Windows,
);
assert!(
!rewritten.contains('\n'),
"newline in the rewrite: {rewritten}"
);
assert!(
rewritten.contains(" & "),
"members joined with `&`: {rewritten}"
);
let cwd = spec_cwd(&files);
assert!(
cwd.ends_with("src"),
"tracked cwd must be inside src: {cwd}"
);
drop(SpecFiles(files));
}
#[test]
fn the_guard_accepts_a_windows_installed_executable_spelling() {
let (_tmp, ws, _home) = serve_fixture();
let mut files = Vec::new();
let fragment = render_served(
&render_only_json(),
&[],
ShellPlatform::Windows,
r"C:\Program Files (x86)\MahBot\mahbot.exe",
&mut files,
)
.expect("the install spelling is a quotable argument")
.text;
let ctx = CheckContext::for_platform(&ws, ShellPlatform::Windows);
let verdict = check_command(&fragment, &ctx);
drop(SpecFiles(files));
assert!(
verdict.is_ok(),
"read-only guard rejected the installed-executable rewrite {fragment}: {verdict:?}"
);
}
}
#[cfg(test)]
mod refusal_pins {
use super::super::SpecFiles;
use super::*;
fn refused(command: &str, ws: &Path, home: &Path) -> String {
let mut serve = serve_command(command, ws, Some(home), ShellPlatform::Windows, || true);
assert!(
serve.rewritten.is_none(),
"{command}: expected no rewrite (got {:?})",
serve.rewritten
);
let cause = serve
.refusal
.take()
.unwrap_or_else(|| panic!("{command}: expected a refusal"));
let message = unserved_failure(&cause, None);
assert!(!cause.is_empty(), "{command}: a cause is named");
assert!(
message.contains("did NOT run"),
"{command}: the message must say the search did not run: {message}"
);
assert!(
message.contains(&cause),
"{command}: the message must name the cause {cause:?}: {message}"
);
assert!(
serve.spec_files.is_empty(),
"{command}: a refused command scratches no spec file"
);
cause
}
#[test]
fn the_engine_probe_is_asked_only_when_there_is_something_to_serve() {
let (_tmp, ws, home) = serve_fixture();
let asked = std::cell::Cell::new(0usize);
let probe = || {
asked.set(asked.get() + 1);
true
};
let no_search = serve_command("echo hi", &ws, Some(&home), ShellPlatform::Unix, probe);
assert!(no_search.rewritten.is_none());
assert_eq!(asked.get(), 0, "a command with no search must not probe");
let servable = serve_command("grep -rn x .", &ws, Some(&home), ShellPlatform::Unix, probe);
assert_eq!(asked.get(), 1, "a servable member asks exactly once");
assert!(servable.rewritten.is_some());
let unavailable = serve_command(
"grep -rn x .",
&ws,
Some(&home),
ShellPlatform::Unix,
|| false,
);
assert!(unavailable.rewritten.is_none());
assert_eq!(unavailable.outcomes.len(), 1, "the member is analysed");
assert!(!unavailable.outcomes[0].served);
assert_eq!(unavailable.outcomes[0].reason, "engine unavailable");
}
#[test]
fn a_line_that_exports_an_environment_first_is_not_served() {
let (_tmp, ws, home) = serve_fixture();
for command in [
"export LC_ALL=C; grep -rn needle .",
"export LC_ALL=C && grep -rn needle .",
"unset LANG; grep -rn needle .",
"eval \"export LC_ALL=C\"; grep -rn needle .",
"source /dev/null; grep -rn needle .",
"\"export\" LC_ALL=C; grep -rn needle .",
"\\export LC_ALL=C; grep -rn needle .",
"(export LC_ALL=C); grep -rn needle .",
"LC_ALL=C; grep -rn needle .",
"PATH=/opt/bin:$PATH; grep -rn needle .",
"LC_ALL=C export FOO=1; grep -rn needle .",
"if true; then export LC_ALL=C; fi; grep -rn needle .",
] {
let serve = serve_command(command, &ws, Some(&home), ShellPlatform::Unix, || true);
assert!(serve.rewritten.is_none(), "{command}: expected no serve");
assert_eq!(serve.outcomes.len(), 1, "{command}: the member is analysed");
assert!(
!serve.outcomes[0].served,
"{command}: the member falls back"
);
assert_eq!(
serve.outcomes[0].reason,
Fallback::EnvironmentChanged.to_string(),
"{command}: the cause is named"
);
}
for command in [
"grep -rn needle . ; export LC_ALL=C",
"cd . && grep -rn needle .",
"echo hi; grep -rn needle .",
"echo KEY=value; grep -rn needle .",
"grep -rn KEY=value .",
"export LC_ALL=C | grep needle",
"LC_ALL=C echo hi | grep needle",
] {
let serve = serve_command(command, &ws, Some(&home), ShellPlatform::Unix, || true);
assert!(
serve.rewritten.is_some(),
"{command}: expected to be served, got {:?}",
serve.refusal
);
}
let serve = serve_command(
"export LC_ALL=C && grep -rn needle .",
&ws,
Some(&home),
ShellPlatform::Windows,
|| true,
);
assert!(
serve.refusal.is_none(),
"windows must still serve: {:?}",
serve.refusal
);
}
#[test]
fn windows_serves_a_line_that_runs_the_own_image_beside_a_search() {
let (_tmp, ws, home) = serve_fixture();
let exe = std::env::current_exe().expect("this process's own path");
let image = exe.display().to_string();
let mut files = Vec::new();
for command in [
format!("{image} debug --db board && grep -rn needle ."),
format!("grep -rn needle . && {image} --version"),
format!("{image} -V | grep needle"),
format!("echo hi && {image} -V && grep -rn needle ."),
] {
let mut serve =
serve_command(&command, &ws, Some(&home), ShellPlatform::Windows, || true);
assert!(
serve.refusal.is_none(),
"{command}: must be served, not refused: {:?}",
serve.refusal
);
assert!(serve.rewritten.is_some(), "{command}: a rewrite");
files.append(&mut serve.spec_files);
let plan = serve.plan.take().expect("a served rewrite has a plan");
let own = plan
.steps
.iter()
.filter(|step| matches!(step.run, plan::Run::Own { .. }))
.count();
assert!(own >= 1, "{command}: the image's call is an own step");
}
drop(SpecFiles(files));
}
#[test]
fn windows_refuses_every_version_of_an_unserved_search() {
let (_tmp, ws, home) = serve_fixture();
let rows: &[(&str, &str)] = &[
("grep -P x f.txt", "unsupported flag -P"),
("echo a; grep x f.txt", "unquoted `;`"),
("grep ^fn src/main.rs", "the whole line"),
("xargs grep x f.txt", "nested grep"),
("cd /x && grep x f.txt", "cd untrackable"),
("popd && grep -rn x .", "cd untrackable"),
("pushd && grep -rn x .", "cd untrackable"),
("(grep x f.txt)", "the whole line"),
("grep -rn needle . && xargs grep x f.txt", "nested grep"),
("grep -rn needle . | grep -v skip", "nested grep"),
("cmd /c grep -rn x .", "nested grep"),
("CMD /C GREP -rn x .", "nested grep"),
("cmd.exe /c grep -rn x .", "nested grep"),
(r#"cmd /c "grep -rn x .""#, "nested grep"),
("powershell -c \"git log | grep x\"", "nested grep"),
("grep -n x>out.txt f.txt", "redirect in `x>out.txt`"),
("grep -rn x>log .", "redirect in `x>log`"),
("grep a>&echo done", "redirect in `a>&echo`"),
("grep -n %TEMP% f.txt", "two `%` in %TEMP%"),
("grep -n x --include=%x% f.txt", "two `%` in --include=%x%"),
("grep -n 100%% f.txt", "two `%` in 100%%"),
];
for (command, cause) in rows {
let reason = refused(command, &ws, &home);
assert!(
reason.contains(cause),
"{command}: reason {reason:?} lacks {cause:?}"
);
}
let exe = std::env::current_exe().expect("this process's own path");
let name = exe
.file_name()
.expect("an executable has a file name")
.to_string_lossy()
.to_string();
for (command, cause) in [
(
format!("start {} -V && grep -rn needle .", exe.display()),
"runs `mahbot` itself",
),
(
format!(r".\{name} -V && grep -rn needle ."),
"names `mahbot`'s file name",
),
] {
let reason = refused(&command, &ws, &home);
assert!(
reason.contains(cause),
"{command}: reason {reason:?} lacks {cause:?}"
);
}
}
#[test]
fn windows_serves_the_lone_percent_and_bang_searches() {
let (_tmp, ws, home) = serve_fixture();
for command in [
r#"grep -rn "!=" src/main.rs"#,
r#"grep -n "50%" src/main.rs"#,
r#"grep -rn "!important" ."#,
] {
let (_rewritten, files) = served(command, &ws, &home, ShellPlatform::Windows);
drop(SpecFiles(files));
}
}
#[test]
fn windows_serves_the_readable_redirect_shapes() {
let (_tmp, ws, home) = serve_fixture();
for command in [
"grep -n needle f.txt > out.txt",
"grep -n needle f.txt >> out.txt",
"grep -n needle f.txt 2> err.txt",
"grep -n needle f.txt < f.txt",
"grep -n needle f.txt 2>&1",
"grep -n needle f.txt 2>&1 | head -2",
r#"grep -n "a>b" f.txt"#,
"grep -n needle f.txt >out.txt",
"grep -n needle f.txt >>log.txt",
"grep -n needle f.txt 2>err.txt",
"grep -n needle f.txt 2>>log.txt",
"grep -n needle f.txt <in.txt",
"grep -n needle f.txt 1>0",
] {
let (rewritten, files) = served(command, &ws, &home, ShellPlatform::Windows);
assert!(rewritten.contains(ENGINE_VERB), "{command}: {rewritten}");
drop(SpecFiles(files));
}
}
#[test]
fn windows_refuses_a_redirect_the_runner_cannot_apply() {
let (_tmp, ws, home) = serve_fixture();
let rows: &[(&str, &str)] = &[
("grep -n needle f.txt >&2", "`>&2`"),
("grep -n needle f.txt 1>&2", "`1>&2`"),
("grep -n needle f.txt 3>&1", "`3>&1`"),
("grep -n needle f.txt 10> out.txt", "`10>`"),
("grep -n needle f.txt 10>out.txt", "`10>out.txt`"),
("grep -n needle f.txt <>f.txt", "`<>f.txt`"),
("grep -n needle f.txt >& out.txt", "`>&`"),
("grep -n needle f.txt 2>>&1", "`2>>&1`"),
("grep -n needle f.txt >", "its target word is missing"),
("grep -n needle f.txt > %TMPOUT%", "`%TMPOUT%`"),
("grep -n needle f.txt > C:out.txt", "`C:out.txt`"),
];
for (command, cause) in rows {
let reason = refused(command, &ws, &home);
assert!(
reason.contains(cause),
"{command}: reason {reason:?} lacks {cause:?}"
);
assert!(
reason.contains("cannot apply the redirection"),
"{command}: the cause explains the refusal: {reason:?}"
);
}
}
#[test]
fn windows_refuses_a_cd_the_runner_cannot_follow() {
let (_tmp, ws, home) = serve_fixture();
let rows: &[(&str, &str)] = &[
("cd.. && grep -rn needle .", "`cd` member"),
("if exist x cd sub && grep -rn needle .", "keyword form"),
("cd C:ws && grep -rn needle .", "cd untrackable"),
];
for (command, cause) in rows {
let reason = refused(command, &ws, &home);
assert!(
reason.contains(cause),
"{command}: reason {reason:?} lacks {cause:?}"
);
}
}
#[test]
fn windows_does_not_refuse_a_grep_word_in_argument_position() {
let (_tmp, ws, home) = serve_fixture();
for command in ["echo hi & echo (grep is a tool)", "echo (grep x f.txt)"] {
let serve = serve_command(command, &ws, Some(&home), ShellPlatform::Windows, || true);
assert!(
serve.refusal.is_none(),
"{command}: must not be refused: {:?}",
serve.refusal
);
assert!(serve.rewritten.is_none(), "{command}: nothing to serve");
}
}
#[test]
fn windows_serves_the_quoted_caret_spelling() {
let (_tmp, ws, home) = serve_fixture();
let mut serve = serve_command(
"grep \"^fn \" src/main.rs",
&ws,
Some(&home),
ShellPlatform::Windows,
|| true,
);
assert!(serve.refusal.is_none(), "{:?}", serve.refusal);
assert!(serve.rewritten.is_some(), "a serveable search");
drop(SpecFiles(std::mem::take(&mut serve.spec_files)));
}
#[test]
fn windows_leaves_every_exempt_shape_alone() {
let (_tmp, ws, home) = serve_fixture();
for command in [
"echo hi",
"git commit -m \"grep fix\"",
"echo grep",
r".\grep -n x f.txt",
r"C:\tools\grep.exe -rn x .",
"GIT GREP x",
"{grep x f.txt",
"grep>x f.txt",
"foo grep x",
r"find . -exec grep x {} \;",
] {
let serve = serve_command(command, &ws, Some(&home), ShellPlatform::Windows, || true);
assert!(
serve.refusal.is_none(),
"{command}: must not be refused: {:?}",
serve.refusal
);
assert!(serve.rewritten.is_none(), "{command}: nothing to serve");
}
let serve = serve_command(
"git grep x",
&ws,
Some(&home),
ShellPlatform::Windows,
|| true,
);
assert!(serve.refusal.is_none(), "{:?}", serve.refusal);
assert!(serve.rewritten.is_none());
assert_eq!(serve.outcomes.len(), 1, "recorded as a skipped member");
assert!(!serve.outcomes[0].served);
let serve = serve_command(
"ssh host grep -rn x /srv",
&ws,
Some(&home),
ShellPlatform::Windows,
|| true,
);
assert!(serve.refusal.is_none(), "{:?}", serve.refusal);
assert!(serve.rewritten.is_none());
assert_eq!(serve.outcomes.len(), 1, "recorded as a skipped member");
}
#[test]
fn unix_keeps_the_fallback_and_never_refuses() {
let (_tmp, ws, home) = serve_fixture();
let rows = [
"grep -P x f.txt",
"echo a; grep x f.txt",
"grep ^fn src/main.rs",
"xargs grep x f.txt",
"cd /x && grep x f.txt",
"(grep x f.txt)",
"git grep x",
];
for command in rows {
let serve = serve_command(command, &ws, Some(&home), ShellPlatform::Unix, || true);
assert!(
serve.refusal.is_none(),
"{command}: unix keeps the fallback: {:?}",
serve.refusal
);
assert!(
serve.rewritten.is_none(),
"{command}: nothing to serve (got {:?})",
serve.rewritten
);
}
}
#[test]
fn windows_serves_without_a_home() {
let (_tmp, ws, _home) = serve_fixture();
let mut windows = serve_command(
"cd src && grep -n needle main.rs",
&ws,
None,
ShellPlatform::Windows,
|| true,
);
assert!(windows.refusal.is_none(), "{:?}", windows.refusal);
let rewritten = windows.rewritten.take().expect("served with no home");
assert!(rewritten.contains(ENGINE_VERB), "{rewritten}");
let cwd = spec_cwd(&windows.spec_files);
assert!(cwd.ends_with("src"), "tracked cwd: {cwd}");
drop(SpecFiles(windows.spec_files));
let unix = serve_command("grep -rn needle .", &ws, None, ShellPlatform::Unix, || true);
assert!(unix.rewritten.is_none(), "unix demotes without a home");
assert!(unix.refusal.is_none(), "and refuses nothing there");
}
#[test]
fn unix_records_a_wrapped_interpreter_as_a_skip_without_refusing() {
let (_tmp, ws, home) = serve_fixture();
let serve = serve_command(
"cmd /c grep x f.txt",
&ws,
Some(&home),
ShellPlatform::Unix,
|| true,
);
assert!(serve.refusal.is_none(), "{:?}", serve.refusal);
assert!(serve.rewritten.is_none());
assert_eq!(serve.outcomes.len(), 1);
assert!(!serve.outcomes[0].served);
assert_eq!(serve.outcomes[0].reason, "nested grep");
}
#[test]
fn unix_still_serves_a_sibling_of_a_rejected_member() {
let (_tmp, ws, home) = serve_fixture();
let mut serve = serve_command(
"grep -rn needle . && xargs grep x f.txt",
&ws,
Some(&home),
ShellPlatform::Unix,
|| true,
);
assert!(serve.refusal.is_none(), "{:?}", serve.refusal);
let rewritten = serve.rewritten.take().expect("the sibling is served");
assert!(rewritten.contains(ENGINE_VERB), "{rewritten}");
assert!(rewritten.contains("xargs grep x f.txt"), "{rewritten}");
}
#[test]
fn unix_serves_the_percent_and_bang_searches() {
let (_tmp, ws, home) = serve_fixture();
for command in [
"grep -rn %TEMP% .",
"grep -rn x --include=%x% .",
"grep -rn \"a!b\" .",
] {
let mut serve = serve_command(command, &ws, Some(&home), ShellPlatform::Unix, || true);
assert!(serve.refusal.is_none(), "{command}: {:?}", serve.refusal);
assert!(
serve.rewritten.is_some(),
"{command}: served on unix (got {:?})",
serve.rewritten
);
drop(SpecFiles(std::mem::take(&mut serve.spec_files)));
}
}
}
#[cfg(test)]
mod plan_pins {
use super::super::SpecFiles;
use super::super::plan::{Plan, Run};
use super::*;
fn served_with_plan(command: &str, ws: &Path, home: &Path) -> (String, Plan, Vec<PathBuf>) {
let mut serve = serve_command(command, ws, Some(home), ShellPlatform::Windows, || true);
assert!(
serve.refusal.is_none(),
"{command}: served, not refused: {:?}",
serve.refusal
);
let rewritten = serve
.rewritten
.take()
.unwrap_or_else(|| panic!("{command}: expected a rewrite"));
let plan = serve
.plan
.take()
.unwrap_or_else(|| panic!("{command}: a produced Windows rewrite carries a plan"));
(rewritten, plan, serve.spec_files)
}
enum Planned {
Shell(String),
Own(Vec<String>, Vec<String>),
}
fn planned_members(plan: &Plan) -> Vec<(Planned, String)> {
let mut members = Vec::new();
for (index, step) in plan.steps.iter().enumerate() {
let after = plan
.steps
.get(index + 1)
.map_or("", |next| next.join.spelling());
match &step.run {
Run::Shell { text } => {
let fold = windows::segment_command(text).expect("cmd.exe reads a fold");
let last = fold.len() - 1;
for (at, (member, connector)) in fold.into_iter().enumerate() {
members.push((
Planned::Shell(member),
if at == last {
after.to_string()
} else {
connector
},
));
}
}
Run::Own { args, redirects } => members.push((
Planned::Own(args.clone(), redirects.clone()),
after.to_string(),
)),
}
}
members
}
#[test]
fn the_plan_rejoins_into_the_rewritten_line() {
let (_tmp, ws, home) = serve_fixture();
let exe = std::env::current_exe().expect("this process's own path");
let name = exe
.file_name()
.expect("an executable has a file name")
.to_string_lossy()
.to_string();
let bare_call = format!("{name} -V && grep -rn needle .");
let prefixed_call = format!("@{name} -V && grep -rn needle .");
let mut shapes: Vec<&str> = vec![
"grep -rn needle .",
"grep -rn needle . | head -3",
"cd src && grep -rn needle .",
"cd src && grep -rn needle . | head -3",
"grep needle f.txt && grep needle src/main.rs",
"grep -n needle f.txt > out.txt",
"grep -n needle f.txt >> out.txt",
"grep -n needle f.txt 2>&1",
"grep -n needle f.txt 2>&1 | head -2",
"type note.txt && grep -n needle f.txt",
];
shapes.push(&bare_call);
shapes.push(&prefixed_call);
for command in shapes {
let (rewritten, plan, files) = served_with_plan(command, &ws, &home);
let written = windows::segment_command(&rewritten).expect("cmd.exe reads the rewrite");
let planned = planned_members(&plan);
assert_eq!(
written.len(),
planned.len(),
"{command}: the plan and the rewrite must have the same members \
(rewrite: {rewritten})"
);
let plan_exe = plan.exe.to_string_lossy();
for (at, ((written_text, written_conn), (member, connector))) in
written.iter().zip(&planned).enumerate()
{
assert_eq!(written_conn, connector, "{command}: connector before {at}");
match member {
Planned::Shell(text) => assert_eq!(
written_text, text,
"{command}: the fold's member {at} must be verbatim"
),
Planned::Own(args, redirects) => {
let delivered: Vec<String> = windows::tokenize(written_text)
.expect("cmd.exe reads the member")
.iter()
.map(|word| windows::unquote_word(&word.raw))
.collect();
let expected: Vec<String> = std::iter::once(plan_exe.to_string())
.chain(args.iter().cloned())
.chain(redirects.iter().map(|token| windows::unquote_word(token)))
.collect();
if delivered != expected {
assert_eq!(delivered.len(), expected.len(), "{command}: member {at}");
assert_eq!(delivered[1..], expected[1..], "{command}: member {at}");
let named = delivered[0]
.trim_start_matches(['@', '('])
.trim_end_matches(')');
assert_eq!(
std::path::Path::new(named).file_name(),
std::path::Path::new(&expected[0]).file_name(),
"{command}: member {at} names another file: {written_text}"
);
}
}
}
}
drop(SpecFiles(files));
}
}
#[test]
fn the_plan_carries_the_tracked_cwd_of_every_member() {
let (_tmp, ws, home) = serve_fixture();
let root = canonical_or_lexical(&ws, ShellPlatform::Windows);
let src = root.join("src");
let cases: Vec<(&str, Vec<PathBuf>)> = vec![
("grep -rn needle .", vec![root.clone()]),
(
"cd src && grep -rn needle .",
vec![root.clone(), src.clone()],
),
(
"cd src && grep -rn needle . | head -3",
vec![root.clone(), src.clone(), src.clone()],
),
(
"grep -rn needle . && cd src && echo after",
vec![root.clone(), root.clone()],
),
(
"type note.txt && grep -n needle f.txt",
vec![root.clone(), root.clone()],
),
(
"grep needle f.txt && grep needle src/main.rs",
vec![root.clone(), root.clone()],
),
];
for (command, expected) in cases {
let (_rewritten, plan, files) = served_with_plan(command, &ws, &home);
let steps: Vec<&Path> = plan.steps.iter().map(|step| step.cwd.as_path()).collect();
assert_eq!(
steps,
expected.iter().map(PathBuf::as_path).collect::<Vec<_>>(),
"{command}"
);
drop(SpecFiles(files));
}
}
#[test]
fn a_demoted_command_carries_no_plan() {
let (_tmp, ws, home) = serve_fixture();
for command in ["grep -rn needle .", "grep -P needle f.txt"] {
let serve = serve_command(command, &ws, Some(&home), ShellPlatform::Windows, || false);
assert!(
serve.plan.is_none(),
"{command}: a demotion carries no plan"
);
assert!(serve.rewritten.is_none(), "{command}");
}
let unix = serve_command(
"grep -rn needle .",
&ws,
Some(&home),
ShellPlatform::Unix,
|| true,
);
assert!(unix.plan.is_none());
assert!(unix.rewritten.is_some());
}
}
#[cfg(all(test, unix))]
mod unix_operator_pins {
use super::test_support::engine_run;
use super::*;
use std::process::Command;
fn reading(command: &str, ws: &Path, home: &Path) -> String {
let analyzed = analyze_command(command, ws, home, ShellPlatform::Unix, false)
.unwrap_or_else(|e| panic!("{command}: expected servable, got {}", e.reason));
assert_eq!(analyzed.specs.len(), 1, "{command}: one served member");
let OutSegment::Served { redirects, .. } = &analyzed.segments[0].0 else {
panic!("{command}: expected a served member");
};
let operands: Vec<&str> = analyzed.specs[0]
.operands
.iter()
.map(|o| o.display.as_str())
.collect();
format!(
"search={} redirect={} operands={}",
analyzed.specs[0].patterns.join(" "),
redirects.join(" "),
operands.join(" ")
)
}
#[test]
fn the_comment_takes_the_rest_of_its_line_out_of_the_command() {
let (tmp, ws, home) = serve_fixture();
let rows: &[(&str, &str)] = &[
(
"grep -rn needle . # note & echo TAIL-RAN",
"search=needle redirect= operands=.",
),
(
"grep -rn needle . # don't",
"search=needle redirect= operands=.",
),
("grep -rn a#b .", "search=a#b redirect= operands=."),
("grep -rn 'a#b' .", "search=a#b redirect= operands=."),
(r"grep -rn a\#b .", "search=a#b redirect= operands=."),
];
for (command, expected) in rows {
assert_eq!(reading(command, &ws, &home), *expected, "{command}");
}
let engine = engine_stub(tmp.path());
let rows: &[(&str, &str, i32, bool)] = &[
(
"grep -rn needle . # note & echo TAIL-RAN",
"ENGINE-OUT\n",
ENGINE_EXIT,
false,
),
(
"grep -rn needle . & wait; echo TAIL-RAN",
"ENGINE-OUT\nTAIL-RAN\n",
0,
true,
),
(
"grep -rn needle . # note & echo TAIL-RAN\nls f.txt",
"ENGINE-OUT\nf.txt\n",
0,
false,
),
];
for (command, stdout, status, tail_kept) in rows {
let analyzed = analyze_command(command, &ws, &home, ShellPlatform::Unix, false)
.unwrap_or_else(|e| panic!("{command}: expected servable, got {}", e.reason));
let jsons: Vec<String> = analyzed.specs.iter().map(spec_json).collect();
let rewritten = join_rewritten(
&analyzed.segments,
&analyzed.specs,
&jsons,
ShellPlatform::Unix,
&engine,
&analyzed.root_cwd,
)
.expect("the unix join never refuses")
.text;
assert_eq!(
rewritten.contains("TAIL-RAN"),
*tail_kept,
"{command}: the comment takes the tail with it ({rewritten})"
);
let out = Command::new("/bin/sh")
.arg("-c")
.arg(&rewritten)
.current_dir(&ws)
.output()
.expect("sh runs");
assert_eq!(
String::from_utf8_lossy(&out.stdout),
*stdout,
"{command}: stdout (rewrite: {rewritten})"
);
assert_eq!(
out.status.code(),
Some(*status),
"{command}: status (rewrite: {rewritten})"
);
}
}
#[test]
fn the_glued_operator_family_is_read_the_way_sh_reads_it() {
let (_tmp, ws, home) = serve_fixture();
let rows: &[(&str, &str)] = &[
(
"grep -rn needle > out.txt .",
"search=needle redirect=> out.txt operands=.",
),
(
"grep -rn needle 2> out.txt .",
"search=needle redirect=2> out.txt operands=.",
),
(
"grep -rn needle>out.txt .",
"search=needle redirect=>out.txt operands=.",
),
(
"grep -rn needle>>out.txt .",
"search=needle redirect=>>out.txt operands=.",
),
(
"grep -rn needle<f.txt .",
"search=needle redirect=<f.txt operands=.",
),
(
"grep -rn needle>&1 .",
"search=needle redirect=>&1 operands=.",
),
(
"grep -rn needle . <&2",
"search=needle redirect=<&2 operands=.",
),
(
"grep -rn 2<&1 needle .",
"search=needle redirect=2<&1 operands=.",
),
(
"grep -rn needle&>out.txt .",
"search=needle redirect=&>out.txt operands=.",
),
(
"grep -rn needle&>>out.txt .",
"search=needle redirect=&>>out.txt operands=.",
),
(
"grep -rn 2>out.txt needle .",
"search=needle redirect=2>out.txt operands=.",
),
(
"grep -rn 10>out.txt needle .",
"search=needle redirect=10>out.txt operands=.",
),
(
"grep -rn needle2>out.txt .",
"search=needle2 redirect=>out.txt operands=.",
),
("grep -rn 'a>b' .", "search=a>b redirect= operands=."),
("grep -rn \"x>log\" .", "search=x>log redirect= operands=."),
(r"grep -rn a\>b .", "search=a>b redirect= operands=."),
];
for (command, expected) in rows {
assert_eq!(reading(command, &ws, &home), *expected, "{command}");
}
}
#[test]
fn the_engine_searches_the_text_the_separated_spelling_searches() {
let (_tmp, ws, home) = serve_fixture();
let expected = "f.txt:needle\nsrc/main.rs:fn needle() {}\n";
for command in [
"grep needle f.txt src/main.rs",
"grep needle>out.txt f.txt src/main.rs",
"grep needle>>out.txt f.txt src/main.rs",
"grep needle<f.txt f.txt src/main.rs",
"grep needle&>out.txt f.txt src/main.rs",
"grep 2>out.txt needle f.txt src/main.rs",
] {
let analyzed = analyze_command(command, &ws, &home, ShellPlatform::Unix, false)
.unwrap_or_else(|e| panic!("{command}: expected servable, got {}", e.reason));
let (out, err, code) = engine_run(&analyzed.specs[0]);
assert_eq!(code, 0, "{command}: exit");
assert_eq!(
String::from_utf8_lossy(&out),
expected,
"{command}: searched text"
);
assert!(err.is_empty(), "{command}: stderr: {err:?}");
}
}
const ENGINE_OUT: &str = "ENGINE-OUT";
const ENGINE_ERR: &str = "ENGINE-ERR";
const ENGINE_EXIT: i32 = 7;
fn engine_stub(dir: &Path) -> String {
let path = dir.join("mahbot-engine-stub");
fs::write(
&path,
format!(
"#!/bin/sh\nprintf '%s\\n' {ENGINE_OUT}\nprintf '%s\\n' {ENGINE_ERR} >&2\nexit {ENGINE_EXIT}\n"
),
)
.expect("stub written");
let mut perms = fs::metadata(&path).expect("stub").permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
fs::set_permissions(&path, perms).expect("stub executable");
path.to_string_lossy().into_owned()
}
#[test]
fn the_shell_performs_the_write_and_keeps_the_backgrounded_tail() {
let (tmp, ws, home) = serve_fixture();
let engine = engine_stub(tmp.path());
let rows: &[(&str, &str, &str, &str)] = &[
("grep -rn needle>out.txt .", "out.txt", "ENGINE-OUT\n", ""),
("grep -rn needle>>out.txt .", "out.txt", "ENGINE-OUT\n", ""),
(
"grep -rn 2>err.txt needle .",
"err.txt",
"ENGINE-ERR\n",
"ENGINE-OUT\n",
),
(
"grep -rn needle>out.txt . & wait; echo AFTER",
"out.txt",
"ENGINE-OUT\n",
"AFTER\n",
),
];
for (command, target, in_target, stdout) in rows {
let _ = fs::remove_file(ws.join(target));
let analyzed = analyze_command(command, &ws, &home, ShellPlatform::Unix, false)
.unwrap_or_else(|e| panic!("{command}: expected servable, got {}", e.reason));
let jsons: Vec<String> = analyzed.specs.iter().map(spec_json).collect();
let rewritten = join_rewritten(
&analyzed.segments,
&analyzed.specs,
&jsons,
ShellPlatform::Unix,
&engine,
&analyzed.root_cwd,
)
.expect("the unix join never refuses")
.text;
let out = Command::new("/bin/sh")
.arg("-c")
.arg(&rewritten)
.current_dir(&ws)
.output()
.expect("sh runs");
assert_eq!(
fs::read_to_string(ws.join(target)).unwrap_or_default(),
*in_target,
"{command}: `sh` writes the member's stream into {target} (rewrite: {rewritten})"
);
assert_eq!(
String::from_utf8_lossy(&out.stdout),
*stdout,
"{command}: stdout (rewrite: {rewritten})"
);
}
}
#[test]
fn a_trailing_ampersand_backgrounds_the_served_member() {
let (tmp, ws, home) = serve_fixture();
let engine = engine_stub(tmp.path());
let command = "grep -rn needle . &";
let analyzed = analyze_command(command, &ws, &home, ShellPlatform::Unix, false)
.unwrap_or_else(|e| panic!("{command}: expected servable, got {}", e.reason));
let jsons: Vec<String> = analyzed.specs.iter().map(spec_json).collect();
let rewritten = join_rewritten(
&analyzed.segments,
&analyzed.specs,
&jsons,
ShellPlatform::Unix,
&engine,
&analyzed.root_cwd,
)
.expect("the unix join never refuses")
.text;
assert!(
rewritten.ends_with(" &"),
"the `&` rides the rewrite: {rewritten}"
);
let out = Command::new("/bin/sh")
.arg("-c")
.arg(&rewritten)
.current_dir(&ws)
.output()
.expect("sh runs");
assert_eq!(
out.status.code(),
Some(0),
"the shell's status, not the backgrounded member's: {rewritten}"
);
}
#[test]
fn process_substitution_is_never_a_redirect() {
let (_tmp, ws, home) = serve_fixture();
for command in [
"grep -rn needle <(cat f.txt) .",
"grep -rn needle . >(cat f.txt)",
] {
let serve = serve_command(command, &ws, Some(&home), ShellPlatform::Unix, || true);
assert!(serve.refusal.is_none(), "{command}: unix never refuses");
assert!(
serve.rewritten.is_none(),
"{command}: nothing to serve (got {:?})",
serve.rewritten
);
assert_eq!(serve.outcomes.len(), 1, "{command}: one analyzed member");
assert_eq!(
serve.outcomes[0].reason, "process substitution",
"{command}"
);
}
}
}