Author SHA1 Message Date
Sylvestre Ledru be51c04c08 grep: support GREP_COLORS 'mt' and warn on deprecated GREP_COLOR
Two color-handling gaps vs GNU: the 'mt' capability in GREP_COLORS (which
sets both the selected- and context-match colors) was ignored, and the
deprecated GREP_COLOR variable produced no warning. Handle 'mt', and emit
GNU's 'GREP_COLOR=... is deprecated; use GREP_COLORS=mt=...' warning when
color output is actually produced. Fixes the GNU testsuite 'color-colors'
test.
2026-05-30 18:55:55 +02:00
Sylvestre Ledru da32a63663 grep: map invalid back-reference errors to GNU's wording
A back-reference to a non-existent group, e.g. (.)\2, makes oniguruma
fail with 'invalid backref number/name'. GNU words this per engine:
'reference to non-existent subpattern' under -P (PCRE2) and 'Invalid
back reference' for basic/extended (gnulib regex). Translate
ONIGERR_INVALID_BACKREF accordingly (gnu_error_message now takes the
regex mode). Fixes the GNU testsuite 'pcre-wx-backref' test.

Also drop pipe_in() from the compile-error tests added in the previous
commits: those patterns are rejected before stdin is read, so feeding
input raced with the child exiting and intermittently panicked the test
harness with a broken pipe under parallel execution.
2026-05-30 18:02:32 +02:00
Sylvestre Ledru 9c21a7d2f0 grep: handle regex backtracking-limit instead of panicking
A pathological pattern such as -P '((a+)*)+$' makes oniguruma exceed its
match retry limit. The onig crate's search_with_encoding/match_with_encoding
panic on that error, so uu_grep aborted with a Rust panic. Switch to the
fallible *_with_param variants and propagate the error: match_line/is_match
now return io::Result, the failure is mapped to GNU's wording ('exceeded
PCRE's backtracking limit') and surfaces as a normal exit-code-2 diagnostic.
Fixes the GNU testsuite 'pcre-abort' test.
2026-05-30 17:55:31 +02:00
Sylvestre Ledru e767a30c1a grep: emit GNU's 'Invalid range end' for reversed bracket ranges
A reversed range like [b-a] makes oniguruma fail with 'empty range in
char class', which uu_grep wrapped as 'invalid pattern "[b-a]": ...'.
GNU grep instead prints the bare POSIX diagnostic 'Invalid range end'
and exits 2. Translate the oniguruma error code
(ONIGERR_EMPTY_RANGE_IN_CHAR_CLASS) to GNU's wording, leaving other
compile errors to fall back to oniguruma's text. Fixes the GNU testsuite
'reversed-range-endpoints' test.
2026-05-30 17:50:02 +02:00
Sylvestre Ledru 1a3e8a391d grep: reject confusing [:name:] bracket syntax like GNU
GNU grep flags a bracket expression of the form [:name:] (an almost
certain misspelling of [[:name:]]) with a dedicated diagnostic and exit
code 2, whereas oniguruma silently treats it as the character set
{':','n','a','m','e'}. Port GNU's colon_warning_state logic from
parse_bracket_exp (gnulib dfa.c) so basic/extended patterns produce the
same error. Fixes the GNU testsuite 'warn-char-classes' test.
2026-05-30 17:39:29 +02:00
13 changed files with 486 additions and 1747 deletions
+1 -18
View File
@@ -9,9 +9,6 @@ on:
branches:
- '*'
permissions:
contents: write # Publish grep instead of discarding
# End the current execution if there is a new changeset in the PR.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -50,21 +47,7 @@ jobs:
shell: bash
run: |
cd 'grep'
cargo build --release --config=profile.release.strip=true
tar -C target/release -cf - grep | zstd -19 -o ../grep-x86_64-unknown-linux-gnu.tar.zst
- name: Publish latest commit
uses: softprops/action-gh-release@v3
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
tag_name: latest-commit
body: |
commit: ${{ github.sha }}
draft: false
prerelease: true
files: |
grep-x86_64-unknown-linux-gnu.tar.zst
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
cargo build --release
- name: Run GNU grep testsuite
shell: bash
-37
View File
@@ -1,37 +0,0 @@
name: CodSpeed
on:
push:
branches:
- "main"
pull_request:
# `workflow_dispatch` allows CodSpeed to trigger backtest
# performance analysis in order to generate initial data.
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
codspeed:
name: Run benchmarks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust toolchain, cache and cargo-codspeed binary
uses: moonrepo/setup-rust@v0
with:
channel: stable
cache-target: release
bins: cargo-codspeed
- name: Build the benchmark target(s)
run: cargo codspeed build
- name: Run the benchmarks
uses: CodSpeedHQ/action@v4
with:
mode: simulation
run: cargo codspeed run
-55
View File
@@ -1,55 +0,0 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
exclude: ^tests/fixtures/
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: check-executables-have-shebangs
- id: check-json
exclude: '\.vscode/(cSpell|extensions)\.json' # cSpell.json and extensions.json use comments
- id: check-shebang-scripts-are-executable
exclude: '.+\.rs' # would be triggered by #![some_attribute]
- id: check-symlinks
- id: check-toml
- id: check-yaml
args: [ --allow-multiple-documents ]
- id: destroyed-symlinks
- id: end-of-file-fixer
- id: mixed-line-ending
args: [ --fix=lf ]
- id: trailing-whitespace
- repo: local
hooks:
- id: rust-linting
name: Rust linting
description: Run cargo fmt on files included in the commit.
entry: cargo +stable fmt --
pass_filenames: true
types: [file, rust]
language: system
- id: rust-clippy
name: Rust clippy
description: Run cargo clippy on files included in the commit.
entry: cargo +stable clippy --workspace --all-targets --all-features -- -D warnings
pass_filenames: false
types: [file, rust]
language: system
- id: cargo-lock-check
name: Cargo.lock sync check
description: Ensure Cargo.lock and fuzz/Cargo.lock are up-to-date.
entry: bash -c 'for dir in . fuzz; do if [ -d "$dir" ]; then ( cd "$dir" && cargo fetch --quiet ); fi; done'
pass_filenames: false
files: 'Cargo\.(toml|lock)$'
language: system
- id: cspell
name: Code spell checker (cspell)
description: Run cspell to check for spelling errors (if available).
entry: bash -c 'if command -v cspell >/dev/null 2>&1; then cspell --no-must-find-files -- "$@"; else echo "cspell not found, skipping spell check"; exit 0; fi' --
pass_filenames: true
language: system
ci:
skip: [rust-linting, rust-clippy, cargo-lock-check, cspell]
Generated
+11 -532
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -27,10 +27,5 @@ onig_sys = { version = "*", default-features = false }
uucore = "0.8.0"
walkdir = "2.5"
[[bench]]
name = "grep_bench"
harness = false
[dev-dependencies]
criterion = { version = "4.7.0", package = "codspeed-criterion-compat" }
uutests = "0.8.0"
-12
View File
@@ -4,19 +4,12 @@
[![dependency status](https://deps.rs/repo/github/uutils/grep/status.svg)](https://deps.rs/repo/github/uutils/grep)
[![CodeCov](https://codecov.io/gh/uutils/grep/branch/main/graph/badge.svg)](https://codecov.io/gh/uutils/grep)
[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/uutils/grep?utm_source=badge)
# Grep, now in Rust
A Rust implementation of [GNU Grep](https://www.gnu.org/software/grep/).
This project is an initial release and may contain bugs.
## Install
```shell
cargo install uu_grep
```
## Building
Download Rust at: https://rustup.rs/
@@ -36,15 +29,10 @@ cargo build --release
cargo test
```
## Pre-commit hooks
This project uses [pre-commit](https://pre-commit.com); run `pre-commit install` to enable the git hooks.
## Known Issues
* Does not take `LANG`, etc., into account for handling file encodings (non-UTF8 matches are treated as binary)
* No localization support yet
* Performances need to be improved
## Contributing
-128
View File
@@ -1,128 +0,0 @@
// This file is part of the uutils grep package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use criterion::{Criterion, black_box, criterion_group, criterion_main};
use std::ffi::OsString;
use std::path::Path;
/// Run grep end-to-end through the real `uumain` entry point. `args` are the
/// arguments after the program name (flags, pattern, paths). The exit status is
/// ignored — we only care about the work performed.
fn run(args: &[&str]) {
let mut argv: Vec<OsString> = Vec::with_capacity(args.len() + 1);
argv.push(OsString::from("grep"));
argv.extend(args.iter().map(OsString::from));
let _ = uu_grep::uumain(argv.into_iter());
}
/// Build a multi-megabyte log-like corpus plus a directory holding it alongside
/// a binary file. Every line contains `worker-<n>` and a `2024-…` timestamp; a
/// rare `RAREHIT` marker appears on a handful of lines (≈ every 10000th).
/// Returns `(dir, log_file)`.
fn build_corpus() -> (std::path::PathBuf, std::path::PathBuf) {
let mut content = String::new();
for i in 0..80_000u32 {
if i % 10_000 == 0 {
content.push_str(&format!(
"2024-01-15 10:30:{:02} RAREHIT worker-{i} special marker seen\n",
i % 60
));
} else if i % 100 == 0 {
content.push_str(&format!(
"2024-01-15 10:30:{:02} ERROR worker-{i} connection reset\n",
i % 60
));
} else {
content.push_str(&format!(
"2024-01-15 10:30:{:02} INFO worker-{i} request handled in {}ms\n",
i % 60,
i % 1000
));
}
}
assert!(content.len() > 4 * 1024 * 1024);
let dir = std::env::temp_dir().join(format!("uu_grep_bench_{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let log = dir.join("app.log");
std::fs::write(&log, &content).unwrap();
// A binary file (contains NUL) that also holds the marker, so `-I` has
// something to skip while recursing.
let mut binary = vec![0u8, 1, 2, 3];
binary.extend_from_slice(b"RAREHIT in binary blob");
binary.extend(std::iter::repeat_n(0u8, 4096));
std::fs::write(dir.join("data.bin"), &binary).unwrap();
(dir, log)
}
fn bench_e2e(c: &mut Criterion) {
let (dir, log) = build_corpus();
let file = log.to_str().unwrap();
let dir_str = dir.to_str().unwrap();
// Pure scanning throughput: `-q` with a pattern that never matches forces a
// full scan and produces no output. A literal (which a buffer-at-a-time
// searcher can accelerate) versus an extended-regex control (which cannot).
{
let mut group = c.benchmark_group("scan");
group.bench_function("literal_no_match", |b| {
b.iter(|| run(black_box(&["-q", "NONEXISTENT_TOKEN_XYZ", file])))
});
group.bench_function("regex_no_match", |b| {
b.iter(|| run(black_box(&["-q", "-E", "NON[0-9]EXISTENT_TOKEN", file])))
});
group.finish();
}
// Real invocation shapes from the `grep` tldr page, each scanning the whole
// corpus. The `RAREHIT` marker matches only a handful of lines, so output
// stays small while the full-file scan dominates.
{
let mut group = c.benchmark_group("usage");
// Search for a pattern within a file.
group.bench_function("search_pattern", |b| {
b.iter(|| run(black_box(&["RAREHIT", file])))
});
// Search for an exact string (-F).
group.bench_function("fixed_string", |b| {
b.iter(|| run(black_box(&["-F", "RAREHIT", file])))
});
// Recursive search ignoring binary files (-rI).
group.bench_function("recursive_no_binary", |b| {
b.iter(|| run(black_box(&["-rI", "RAREHIT", dir_str])))
});
// Print 3 lines of context (-C 3).
group.bench_function("context", |b| {
b.iter(|| run(black_box(&["-C", "3", "RAREHIT", file])))
});
// Filename + line number with forced color (-Hn --color=always).
group.bench_function("filename_lineno_color", |b| {
b.iter(|| run(black_box(&["-Hn", "--color=always", "RAREHIT", file])))
});
// Print only the matched text (-o).
group.bench_function("only_matching", |b| {
b.iter(|| run(black_box(&["-o", "RAREHIT", file])))
});
// Invert match (-v); `worker-` is on every line, so nothing is printed
// and this measures the full inverted scan.
group.bench_function("invert_match", |b| {
b.iter(|| run(black_box(&["-v", "worker-", file])))
});
// Extended regex, case-insensitive (-Ei).
group.bench_function("extended_icase", |b| {
b.iter(|| run(black_box(&["-Ei", "rarehit", file])))
});
group.finish();
}
let _ = std::fs::remove_dir_all(Path::new(dir_str));
}
criterion_group!(benches, bench_e2e);
criterion_main!(benches);
+71 -92
View File
@@ -3,12 +3,9 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
#[doc(hidden)]
pub mod context_buffer;
#[doc(hidden)]
pub mod line_buffer;
#[doc(hidden)]
pub mod matcher;
mod context_buffer;
mod line_buffer;
mod matcher;
mod output;
mod searcher;
@@ -22,9 +19,8 @@ use std::io::{IsTerminal as _, Read};
use std::path::Path;
use uucore::error::{FromIo, UResult, USimpleError};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[doc(hidden)]
pub enum RegexMode {
#[derive(Clone, Copy, PartialEq, Eq)]
enum RegexMode {
Fixed,
Basic,
Extended,
@@ -32,8 +28,7 @@ pub enum RegexMode {
}
#[derive(Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub enum BinaryMode {
enum BinaryMode {
Binary,
Text,
WithoutMatch,
@@ -47,84 +42,79 @@ enum ColorMode {
}
#[derive(Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub enum DirectoryMode {
enum DirectoryMode {
Read,
Skip,
Recurse,
}
#[derive(Clone, Copy, PartialEq, Eq)]
#[doc(hidden)]
pub enum DeviceMode {
enum DeviceMode {
Default,
Read,
Skip,
}
#[doc(hidden)]
pub struct ColorConfig<'a> {
pub matched_selected: &'a str,
pub matched_context: &'a str,
pub filename: &'a str,
pub line_number: &'a str,
pub byte_offset: &'a str,
pub separator: &'a str,
pub selected_line: &'a str,
pub context_line: &'a str,
struct ColorConfig<'a> {
matched_selected: &'a str,
matched_context: &'a str,
filename: &'a str,
line_number: &'a str,
byte_offset: &'a str,
separator: &'a str,
selected_line: &'a str,
context_line: &'a str,
pub reverse_video: bool,
pub no_erase: bool,
reverse_video: bool,
no_erase: bool,
}
#[doc(hidden)]
pub struct GlobSet {
struct GlobSet {
patterns: Vec<glob::Pattern>,
}
#[doc(hidden)]
pub struct Config<'a> {
struct Config<'a> {
// Searcher
pub directory_mode: DirectoryMode,
pub device_mode: DeviceMode,
pub follow_symlinks: bool,
pub include_globs: GlobSet,
pub exclude_globs: GlobSet,
pub exclude_dir_globs: GlobSet,
pub label: &'a str,
directory_mode: DirectoryMode,
device_mode: DeviceMode,
follow_symlinks: bool,
include_globs: GlobSet,
exclude_globs: GlobSet,
exclude_dir_globs: GlobSet,
label: &'a str,
#[cfg(windows)]
pub strip_cr: bool,
pub binary_mode: BinaryMode,
pub max_count: Option<u64>,
pub before_context: usize,
pub after_context: usize,
pub has_context: bool,
strip_cr: bool,
binary_mode: BinaryMode,
max_count: Option<u64>,
before_context: usize,
after_context: usize,
has_context: bool,
// Matcher
pub patterns: &'a [&'a str],
pub regex_mode: RegexMode,
pub ignore_case: bool,
pub invert_match: bool,
pub word_regexp: bool,
pub line_regexp: bool,
patterns: &'a [&'a str],
regex_mode: RegexMode,
ignore_case: bool,
invert_match: bool,
word_regexp: bool,
line_regexp: bool,
// Output
pub quiet: bool,
pub count: bool,
pub show_filename: bool,
pub files_with_matches: bool,
pub files_without_match: bool,
pub only_matching: bool,
pub byte_offset: bool,
pub line_number: bool,
pub initial_tab: bool,
pub null_separator: bool,
pub null_data: bool,
pub line_buffered: bool,
pub no_messages: bool,
pub group_separator: Option<&'a str>,
pub use_color: bool,
pub color_config: ColorConfig<'a>,
quiet: bool,
count: bool,
show_filename: bool,
files_with_matches: bool,
files_without_match: bool,
only_matching: bool,
byte_offset: bool,
line_number: bool,
initial_tab: bool,
null_separator: bool,
null_data: bool,
line_buffered: bool,
no_messages: bool,
group_separator: Option<&'a str>,
use_color: bool,
color_config: ColorConfig<'a>,
}
#[uucore::main(no_signals)]
@@ -255,14 +245,6 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
));
}
// GNU grep's PCRE backend (-P) supports only a single pattern.
if perl_regexp && patterns.len() > 1 {
return Err(USimpleError::new(
2,
"the -P option only supports a single pattern".to_string(),
));
}
// Decoded options into enums
let regex_mode = if fixed_strings {
RegexMode::Fixed
@@ -360,6 +342,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
ColorMode::Never => false,
ColorMode::Auto => std::io::stdout().is_terminal(),
};
// GREP_COLOR is deprecated in favour of GREP_COLORS' `mt` capability;
// GNU warns about it, but only when color output is actually produced.
if use_color && !grep_color.is_empty() {
eprintln!(
"grep: warning: GREP_COLOR='{grep_color}' is deprecated; use GREP_COLORS='mt={grep_color}'"
);
}
let color_config = ColorConfig::from_env(&grep_color, &grep_colors);
let config = Config {
@@ -715,16 +704,14 @@ pub fn uu_app() -> Command {
.short('L')
.long("files-without-match")
.help("print only names of FILEs with no selected lines")
.action(ArgAction::SetTrue)
.overrides_with("files_with_matches"),
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("files_with_matches")
.short('l')
.long("files-with-matches")
.help("print only names of FILEs with selected lines")
.action(ArgAction::SetTrue)
.overrides_with("files_without_match"),
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("count")
@@ -874,21 +861,8 @@ fn expand_num_shorthand(args: impl Iterator<Item = OsString>) -> Vec<OsString> {
out
}
impl Default for GlobSet {
fn default() -> Self {
Self::new()
}
}
impl GlobSet {
/// Create an empty GlobSet.
pub fn new() -> Self {
Self {
patterns: Vec::new(),
}
}
pub fn with_capacity(capacity: usize) -> Self {
fn with_capacity(capacity: usize) -> Self {
Self {
patterns: Vec::with_capacity(capacity),
}
@@ -936,6 +910,11 @@ impl<'a> ColorConfig<'a> {
for item in grep_colors.split(':') {
if let Some((key, value)) = item.split_once('=') {
match key {
// `mt` sets both the selected- and context-match colors.
"mt" => {
config.matched_selected = value;
config.matched_context = value;
}
"ms" => config.matched_selected = value,
"mc" => config.matched_context = value,
"fn" => config.filename = value,
+1 -190
View File
@@ -3,7 +3,7 @@
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use memchr::{memchr, memrchr};
use memchr::memchr;
use std::fs::File;
use std::io::{self, Read as _};
@@ -111,193 +111,4 @@ impl LineBuffer {
self.end += n;
}
}
/// Read the next run of *complete* lines as a single slice.
///
/// Returns `Ok(None)` at end of input. Otherwise returns `Ok(Some((chunk,
/// chunk_start)))`, where `chunk` spans one or more whole lines (each ending
/// in the terminator) and `chunk_start` is the absolute byte offset of the
/// first byte of the chunk. The only exception is a final line lacking a
/// terminator, which is returned on its own as the last chunk.
///
/// This hands back as much buffered data as ends on a line boundary, so a
/// caller can scan many lines with one pass instead of line by line.
pub fn read_chunk(&mut self, file: &mut File) -> io::Result<Option<(&[u8], u64)>> {
loop {
// Hand back everything up to and including the last terminator.
if self.end > self.beg
&& let Some(off) = memrchr(self.line_terminator, &self.buffer[self.beg..self.end])
{
let beg = self.beg;
let lim = self.beg + off + 1;
let chunk_start = self.next_line_start;
self.next_line_start += (lim - beg) as u64;
self.beg = lim;
self.scan = lim;
return Ok(Some((&self.buffer[beg..lim], chunk_start)));
}
// No whole line buffered. At EOF, flush any unterminated remainder.
if self.eof {
if self.beg == self.end {
return Ok(None);
}
let beg = self.beg;
let chunk_start = self.next_line_start;
self.next_line_start += (self.end - beg) as u64;
self.beg = self.end;
self.scan = self.end;
return Ok(Some((&self.buffer[beg..self.end], chunk_start)));
}
// Slide the partial tail to the front to maximize room for reading.
if self.beg > 0 {
self.buffer.copy_within(self.beg..self.end, 0);
self.end -= self.beg;
self.beg = 0;
self.scan = 0;
}
if self.end == self.buffer.len() {
// A single line is longer than the whole buffer; grow it.
self.buffer.resize(self.buffer.len() * 2, 0);
}
let n = loop {
match file.read(&mut self.buffer[self.end..]) {
Ok(n) => break n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
Err(e) => return Err(e),
}
};
if n == 0 {
self.eof = true;
} else {
self.end += n;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Seek as _, SeekFrom, Write as _};
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
/// A temp file pre-loaded with `content`, rewound to the start, and removed
/// from disk when dropped.
struct TempInput {
file: File,
path: std::path::PathBuf,
}
impl Drop for TempInput {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
fn temp_input(content: &[u8]) -> TempInput {
let mut path = std::env::temp_dir();
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
path.push(format!("uu_grep_lb_{}_{n}.tmp", std::process::id()));
let mut file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)
.unwrap();
file.write_all(content).unwrap();
file.seek(SeekFrom::Start(0)).unwrap();
TempInput { file, path }
}
/// Drain `read_chunk` into a list of (owned bytes, start offset) pairs.
fn chunks(term: u8, content: &[u8]) -> Vec<(Vec<u8>, u64)> {
let mut lb = LineBuffer::new(term);
let mut input = temp_input(content);
let mut out = Vec::new();
while let Some((chunk, start)) = lb.read_chunk(&mut input.file).unwrap() {
out.push((chunk.to_vec(), start));
}
out
}
#[test]
fn empty_input_yields_nothing() {
assert!(chunks(b'\n', b"").is_empty());
}
#[test]
fn whole_complete_lines_come_back_as_one_chunk() {
// Small input arrives in a single read, so everything up to the final
// terminator is one chunk starting at offset 0.
assert_eq!(
chunks(b'\n', b"a\nbb\nccc\n"),
vec![(b"a\nbb\nccc\n".to_vec(), 0)]
);
}
#[test]
fn unterminated_tail_is_a_final_chunk_with_its_own_offset() {
// "a\n" is the complete-line chunk; "bb" is flushed at EOF at offset 2.
assert_eq!(
chunks(b'\n', b"a\nbb"),
vec![(b"a\n".to_vec(), 0), (b"bb".to_vec(), 2)]
);
}
#[test]
fn input_without_any_terminator_is_one_chunk() {
assert_eq!(chunks(b'\n', b"abc"), vec![(b"abc".to_vec(), 0)]);
}
#[test]
fn honors_a_custom_terminator() {
assert_eq!(
chunks(b'\0', b"a\0bb\0c"),
vec![(b"a\0bb\0".to_vec(), 0), (b"c".to_vec(), 5)]
);
}
#[test]
fn reassembles_input_larger_than_the_buffer() {
// Force many reads and at least one chunk boundary mid-file.
let mut content = Vec::new();
for i in 0..50_000u32 {
content.extend_from_slice(format!("line number {i}\n").as_bytes());
}
assert!(content.len() > 128 * 1024);
let got = chunks(b'\n', &content);
assert!(got.len() > 1, "expected multiple chunks, got {}", got.len());
// Chunks must tile the input exactly, contiguously, each ending on a
// line boundary (the input ends with a terminator).
let mut expected_start = 0u64;
let mut joined = Vec::new();
for (bytes, start) in &got {
assert_eq!(*start, expected_start);
assert_eq!(*bytes.last().unwrap(), b'\n');
expected_start += bytes.len() as u64;
joined.extend_from_slice(bytes);
}
assert_eq!(joined, content);
}
#[test]
fn grows_to_hold_a_single_overlong_line() {
// One line far bigger than the initial 128 KiB buffer, then a short one.
let mut content = vec![b'x'; 300 * 1024];
content.push(b'\n');
content.extend_from_slice(b"tail\n");
let got = chunks(b'\n', &content);
let joined: Vec<u8> = got.iter().flat_map(|(b, _)| b.clone()).collect();
assert_eq!(joined, content);
assert_eq!(got[0].1, 0);
}
}
+271 -168
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -71,7 +71,6 @@ impl<'a> OutputWriter<'a> {
view.line_number,
view.byte_offset + start as u64,
b':',
false,
)?;
self.write_colored_bytes(
@@ -91,7 +90,6 @@ impl<'a> OutputWriter<'a> {
view.line_number,
view.byte_offset,
if view.is_match { b':' } else { b'-' },
view.line.is_empty(),
)?;
let mut last_end = 0;
@@ -127,7 +125,6 @@ impl<'a> OutputWriter<'a> {
line_number: u64,
byte_offset: u64,
sep_char: u8,
content_empty: bool,
) -> io::Result<()> {
if self.config.show_filename {
self.write_colored_fmt(
@@ -158,10 +155,7 @@ impl<'a> OutputWriter<'a> {
self.write_separator(sep_char)?;
}
// GNU grep aligns content with a tab under -T, but only when there is
// content to align: an empty line keeps just its prefix (no tab).
if self.config.initial_tab
&& !content_empty
&& (self.config.line_number || self.config.byte_offset || self.config.show_filename)
{
self.out.write_all(b"\t")?;
+5 -248
View File
@@ -8,8 +8,7 @@ use crate::line_buffer::LineBuffer;
use crate::matcher::Matcher;
use crate::output::OutputWriter;
use crate::{BinaryMode, Config, DeviceMode, DirectoryMode};
use memchr::memmem::Finder;
use memchr::{memchr, memchr_iter, memrchr};
use memchr::memchr;
use std::ffi::OsStr;
use std::fs::File;
use std::io;
@@ -118,12 +117,7 @@ impl<'a> Searcher<'a> {
.flush()
.map_err_context(|| "(standard output)".to_string())?;
// With -q, a match yields exit status 0 even if an error (e.g. a
// missing file) occurred earlier: GNU exits as soon as a line is
// selected, so the error never affects the status.
if self.config.quiet && self.any_match {
Ok(())
} else if self.had_error {
if self.had_error {
Err(ExitCode::new(2))
} else if self.any_match {
Ok(())
@@ -254,221 +248,12 @@ impl<'a> Searcher<'a> {
self.binary_notice_enabled && self.session_binary_detected && self.session_any_match()
}
/// Whether the current configuration can use the buffer-at-a-time fast
/// path. It applies only to pure-literal patterns and the simpler output
/// modes — anything needing match positions, context, inversion, or special
/// binary handling falls back to the line-at-a-time [`Self::session_run`].
fn eligible_for_fast_path(&self) -> bool {
// On Windows the line-at-a-time path strips a trailing CR before
// matching; the fast path mirrors that only for printed output, so a
// literal needle still behaves the same. Nothing else differs.
self.matcher.literal_searchers().is_some()
&& !self.config.invert_match
&& !self.config.word_regexp
&& !self.config.line_regexp
&& !self.config.only_matching
&& !self.config.use_color
// `has_context` also covers `-C 0`, which still emits `--` separators.
&& !self.config.has_context
&& !self.config.null_data
&& self.config.binary_mode != BinaryMode::WithoutMatch
}
/// Buffer-at-a-time driver for literal patterns. Instead of testing every
/// line, it scans whole chunks with a substring searcher and only locates
/// line boundaries around the matches it finds.
fn session_run_fast(
&mut self,
lb: &mut LineBuffer,
path: &Path,
reader: &mut File,
) -> io::Result<bool> {
lb.reset();
if self.config.quiet
|| self.config.files_with_matches
|| self.config.files_without_match
|| self.config.count
{
self.fast_locate(lb, path, reader)
} else {
self.fast_print(lb, path, reader)
}
}
/// Fast path for modes that only need to know *whether* / *how many* lines
/// match: `-c`, `-l`, `-L`, `-q`. No per-line rendering, so no line numbers,
/// byte offsets, or binary bookkeeping are required (the count of matching
/// lines is unaffected by binary detection, and `-l`/`-L`/`-q` list files
/// regardless).
fn fast_locate(
&mut self,
lb: &mut LineBuffer,
path: &Path,
reader: &mut File,
) -> io::Result<bool> {
let finders = self
.matcher
.literal_searchers()
.expect("eligibility guarantees literal searchers");
let max = self.config.max_count;
// Existence is enough for these three; only `-c` needs the full tally.
let stop_at_first =
self.config.quiet || self.config.files_with_matches || self.config.files_without_match;
let mut count: u64 = 0;
let mut matched = false;
'outer: while let Some((chunk, _)) = lb.read_chunk(reader)? {
let mut p = 0;
while p < chunk.len() {
let Some(rel) = leftmost_match(finders, &chunk[p..]) else {
break;
};
if max.is_some_and(|mx| count >= mx) {
break 'outer;
}
let (_, line_end) = line_bounds(chunk, p + rel);
count += 1;
matched = true;
if stop_at_first {
break 'outer;
}
// Each line counts once: resume past this line's terminator.
p = line_end + 1;
}
}
// `-l`/`-L` take precedence over `-c`, matching the line-at-a-time path.
if self.config.quiet {
// Exit status only.
} else if self.config.files_with_matches {
if matched {
self.writer.write_filename(path)?;
}
} else if self.config.files_without_match {
if !matched {
self.writer.write_filename(path)?;
}
} else if self.config.count {
self.writer.write_count(count, path)?;
}
Ok(matched)
}
/// Fast path that prints whole matching lines (optionally with `-n`, `-b`,
/// filename prefixes, `-m`). Binary files are detected per chunk and reported
/// with the usual notice instead of dumping their lines.
fn fast_print(
&mut self,
lb: &mut LineBuffer,
path: &Path,
reader: &mut File,
) -> io::Result<bool> {
let finders = self
.matcher
.literal_searchers()
.expect("eligibility guarantees literal searchers");
let max = self.config.max_count;
let want_lineno = self.config.line_number;
let detect_binary = self.config.binary_mode != BinaryMode::Text;
let notice_enabled = self.binary_notice_enabled;
let mut count: u64 = 0;
let mut matched = false;
let mut binary = false;
// Number of terminators in all previously consumed chunks (for `-n`).
let mut base_lines: u64 = 0;
'outer: while let Some((chunk, chunk_off)) = lb.read_chunk(reader)? {
let mut p = 0;
// NUL scanned up to here; terminators counted up to `nl_cursor`.
let mut nul_scanned = 0;
let mut nl_cursor = 0;
let mut nl_before = 0u64;
while p < chunk.len() {
let Some(rel) = leftmost_match(finders, &chunk[p..]) else {
break;
};
if max.is_some_and(|mx| count >= mx) {
break 'outer;
}
let (line_beg, line_end) = line_bounds(chunk, p + rel);
// A NUL anywhere up to this line marks the file binary, as does
// an invalid-UTF-8 matching line.
if detect_binary && !binary {
if memchr(0, &chunk[nul_scanned..line_end]).is_some() {
binary = true;
}
nul_scanned = line_end;
}
let line = &chunk[line_beg..line_end];
#[cfg(windows)]
let line = if self.config.strip_cr && line.last() == Some(&b'\r') {
&line[..line.len() - 1]
} else {
line
};
if detect_binary && !binary && std::str::from_utf8(line).is_err() {
binary = true;
}
if binary {
// First match in a binary file: stop and emit the notice
// once at the end instead of dumping the line.
matched = true;
break 'outer;
}
let line_number = if want_lineno {
nl_before += count_terminators(&chunk[nl_cursor..line_beg]);
nl_cursor = line_beg;
base_lines + nl_before + 1
} else {
0
};
self.writer.write_line(
&LineView {
line,
line_number,
byte_offset: chunk_off + line_beg as u64,
is_match: true,
match_positions: &[],
},
path,
)?;
count += 1;
matched = true;
p = line_end + 1;
}
// Carry NUL detection and the line tally across the chunk boundary.
if detect_binary && !binary && memchr(0, &chunk[nul_scanned..]).is_some() {
binary = true;
}
if want_lineno {
base_lines += nl_before + count_terminators(&chunk[nl_cursor..]);
}
}
if binary && notice_enabled && matched {
self.writer.report_binary_match(path);
}
Ok(matched)
}
fn session_run(
&mut self,
lb: &mut LineBuffer,
path: &Path,
reader: &mut File,
) -> io::Result<bool> {
if self.eligible_for_fast_path() {
return self.session_run_fast(lb, path, reader);
}
// Reset all session (per-file) state.
self.session_context_buf.clear();
self.session_match_count = 0;
@@ -496,7 +281,7 @@ impl<'a> Searcher<'a> {
return Ok(false);
}
if let Some(positions) = self.session_match_line(line) {
if let Some(positions) = self.session_match_line(line)? {
// TODO: GNU grep respects LANG. Here, I'm always checking for valid UTF-8.
if !self.session_mark_binary_if(|| std::str::from_utf8(line).is_err()) {
return Ok(false);
@@ -536,9 +321,9 @@ impl<'a> Searcher<'a> {
self.config.binary_mode != BinaryMode::WithoutMatch
}
fn session_match_line(&self, line: &[u8]) -> Option<Vec<(usize, usize)>> {
fn session_match_line(&self, line: &[u8]) -> io::Result<Option<Vec<(usize, usize)>>> {
if !self.session_can_match() {
None
Ok(None)
} else if self.session_needs_match_positions() {
self.matcher.match_line(line)
} else {
@@ -685,31 +470,3 @@ impl<'a> Searcher<'a> {
}
}
}
/// Offset of the earliest occurrence of any needle in `hay`, or `None`.
fn leftmost_match(finders: &[Finder<'static>], hay: &[u8]) -> Option<usize> {
let mut best: Option<usize> = None;
for finder in finders {
if let Some(pos) = finder.find(hay) {
best = Some(best.map_or(pos, |b| b.min(pos)));
if best == Some(0) {
break; // Can't start any earlier.
}
}
}
best
}
/// Count line terminators in `bytes`.
fn count_terminators(bytes: &[u8]) -> u64 {
memchr_iter(b'\n', bytes).count() as u64
}
/// Byte range `[start, end)` of the line containing `pos` in `buf`, excluding
/// the trailing terminator. `start` follows the previous terminator (or 0);
/// `end` is the next terminator (or end of buffer).
fn line_bounds(buf: &[u8], pos: usize) -> (usize, usize) {
let start = memrchr(b'\n', &buf[..pos]).map_or(0, |i| i + 1);
let end = memchr(b'\n', &buf[pos..]).map_or(buf.len(), |i| pos + i);
(start, end)
}
+126 -256
View File
@@ -127,43 +127,139 @@ fn ere_invalid_pattern_is_error() {
}
#[test]
fn quiet_match_overrides_file_error() {
// With -q, a match makes grep exit 0 even if an earlier file could not be
// opened. Without -q the missing file still yields exit 2, and -q with no
// match keeps the error status.
let (_s, mut c) = ucmd();
c.args(&["-q", "abc", "no-such-file", "-"])
.pipe_in("abcd\n")
.succeeds()
.no_output();
fn confusing_bracket_class_is_error() {
// GNU grep rejects the misspelled `[:name:]` form (meant to be
// `[[:name:]]`) with a dedicated diagnostic and exit code 2.
// No piped input: the pattern is rejected at compile time, before stdin is
// read, so feeding stdin would race with the child exiting (broken pipe).
for pattern in ["[:space:]", "[:digit:]", "[^:space:]", "x[:space:]y"] {
let (_s, mut c) = ucmd();
c.args(&[pattern])
.fails_with_code(2)
.stderr_is("grep: character class syntax is [[:space:]], not [:space:]\n");
}
// The same diagnostic applies in extended mode.
let (_s, mut c) = ucmd();
c.args(&["abc", "no-such-file", "-"])
.pipe_in("abcd\n")
.fails_with_code(2);
let (_s, mut c) = ucmd();
c.args(&["-q", "zzz", "no-such-file", "-"])
.pipe_in("abcd\n")
.fails_with_code(2);
c.args(&["-E", "[:space:]"])
.fails_with_code(2)
.stderr_is("grep: character class syntax is [[:space:]], not [:space:]\n");
}
#[test]
fn initial_tab_skips_empty_lines() {
// -T aligns content with a tab, but GNU omits the tab for an empty line
// (a whitespace-only line still gets one). -H forces the filename prefix
// on, so the tab is exercised.
let (s, mut c) = ucmd();
s.fixtures.write("in", "x\n\n");
c.args(&["-T", "-H", "^", "in"])
.succeeds()
.stdout_is("in:\tx\nin:\n");
fn lookalike_brackets_are_not_confusing() {
// Patterns that are NOT the confusing `[:name:]` form must compile
// normally (no diagnostic). A proper class, a colon set, a range, a
// trailing colon set, and `-F` literal text all stay valid.
for pattern in [
"[[:space:]]",
"[::]",
"[:space]",
"[:spac-e:]",
"[a:space:]",
] {
let (_s, mut c) = ucmd();
c.args(&[pattern])
.pipe_in("z\n")
.fails_with_code(1)
.no_output();
}
let (s, mut c) = ucmd();
s.fixtures.write("in", "x\n \n");
c.args(&["-T", "-H", "^", "in"])
// `\[` does not open a bracket expression.
let (_s, mut c) = ucmd();
c.args(&["\\[:space:]"])
.pipe_in("z\n")
.fails_with_code(1)
.no_output();
// `-F` treats the text literally, so no diagnostic.
let (_s, mut c) = ucmd();
c.args(&["-F", "[:space:]"])
.pipe_in("x\n")
.fails_with_code(1)
.no_output();
}
#[test]
fn reversed_range_uses_gnu_wording() {
// A range like `[b-a]` is an error; GNU prints the bare POSIX diagnostic
// "Invalid range end" (not oniguruma's phrasing) and exits 2.
// No piped input: the pattern is rejected before stdin is read.
for args in [&["[b-a]"][..], &["-E", "[b-a]"][..]] {
let (_s, mut c) = ucmd();
c.args(args)
.fails_with_code(2)
.stderr_is("grep: Invalid range end\n");
}
}
#[test]
fn pcre_backtracking_limit_does_not_abort() {
// A pathological PCRE pattern can exceed oniguruma's retry limit. GNU
// grep reports this and exits 2 (it must not crash); stdout stays empty.
let (_s, mut c) = ucmd();
c.args(&["-P", "((a+)*)+$"])
.pipe_in("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab\n")
.fails_with_code(2)
.stdout_is("")
.stderr_contains("backtracking limit");
}
#[test]
fn invalid_backreference_uses_gnu_wording() {
// A back-reference to a non-existent group is worded differently by GNU
// depending on the engine: PCRE (-P) vs gnulib regex (BRE/ERE).
// No piped input: these patterns are rejected before stdin is read.
let (_s, mut c) = ucmd();
c.args(&["-P", r"(.)\2"])
.fails_with_code(2)
.stderr_is("grep: reference to non-existent subpattern\n");
for args in [&["-E", r"(.)\2"][..], &[r"\(.\)\2"][..]] {
let (_s, mut c) = ucmd();
c.args(args)
.fails_with_code(2)
.stderr_is("grep: Invalid back reference\n");
}
// A valid back-reference with -Pw / -Px must still match.
for flag in ["-Pw", "-Px"] {
let (_s, mut c) = ucmd();
c.args(&[flag, r"(.)\1"])
.pipe_in("aa\n")
.succeeds()
.stdout_is("aa\n");
}
}
#[test]
fn grep_colors_mt_and_grep_color_deprecation() {
// GREP_COLORS `mt` sets the match color (both selected and context).
let (_s, mut c) = ucmd();
c.args(&["--color=always", "."])
.env("GREP_COLORS", "mt=36")
.pipe_in("x\n")
.succeeds()
.stdout_is("in:\tx\nin:\t \n");
.stdout_is("\u{1b}[36m\u{1b}[Kx\u{1b}[m\u{1b}[K\n");
// GREP_COLOR is deprecated: it still sets the match color, but emits a
// warning when color is actually produced.
let (_s, mut c) = ucmd();
c.args(&["--color=always", "."])
.env("GREP_COLOR", "36")
.pipe_in("x\n")
.succeeds()
.stdout_is("\u{1b}[36m\u{1b}[Kx\u{1b}[m\u{1b}[K\n")
.stderr_is("grep: warning: GREP_COLOR='36' is deprecated; use GREP_COLORS='mt=36'\n");
// No warning when color output is disabled.
let (_s, mut c) = ucmd();
c.args(&["--color=never", "."])
.env("GREP_COLOR", "36")
.pipe_in("x\n")
.succeeds()
.stdout_is("x\n")
.no_stderr();
}
#[test]
@@ -210,34 +306,6 @@ fn pcre_features() {
.stdout_only("42\n7\n");
}
#[test]
fn perl_regexp_rejects_multiple_patterns() {
// GNU grep's PCRE backend (-P) only supports a single pattern.
// Multiple -e patterns must produce exit 2 and the canonical error message.
// See: https://github.com/uutils/grep/issues/34
// Two separate -e flags.
let (_s, mut c) = ucmd();
c.args(&["-P", "-e", "foo", "-e", "bar"])
.pipe_in("foo\nbar\n")
.fails_with_code(2)
.stderr_contains("the -P option only supports a single pattern");
// A newline inside the pattern string is split into multiple patterns.
let (_s, mut c) = ucmd();
c.args(&["-P", "-e", "foo\nbar"])
.pipe_in("foo\nbar\n")
.fails_with_code(2)
.stderr_contains("the -P option only supports a single pattern");
// A single pattern with -P must still work normally.
let (_s, mut c) = ucmd();
c.args(&["-P", "-e", r"\d+"])
.pipe_in("abc\n42\n")
.succeeds()
.stdout_only("42\n");
}
#[test]
fn posix_character_classes() {
let (_s, mut c) = ucmd();
@@ -319,12 +387,6 @@ fn word_regexp() {
.pipe_in("foo bar\nfoobar\n")
.succeeds()
.stdout_only("foo bar\n");
let (_s, mut c) = ucmd();
c.args(&["-w", "$"])
.pipe_in("abc\n\nx\n")
.succeeds()
.stdout_only("\n");
}
#[test]
@@ -334,12 +396,6 @@ fn line_regexp() {
.pipe_in("foo bar\nfoo bar!\nx foo bar\n")
.succeeds()
.stdout_only("foo bar\n");
let (_s, mut c) = ucmd();
c.args(&["-x", "$"])
.pipe_in("abc\n\nx\n")
.succeeds()
.stdout_only("\n");
}
#[test]
@@ -540,25 +596,6 @@ fn files_with_and_without_matches() {
.stdout_only("many\n");
}
#[test]
fn files_with_and_without_matches_mutually_exclusive() {
// Test that -l and -L are mutually exclusive with last-one-wins semantics
let (scene, mut c) = ucmd();
scene.fixtures.write("file", "match\n");
// -l -L: last flag (-L) wins, so no output (file has match, -L excludes it)
c.args(&["-l", "-L", "match", "file"])
.succeeds()
.stdout_only("");
// -L -l: last flag (-l) wins, so filename is printed
let (scene, mut c) = ucmd();
scene.fixtures.write("file", "match\n");
c.args(&["-L", "-l", "match", "file"])
.succeeds()
.stdout_only("file\n");
}
#[test]
fn count_combined_with_listing_flags() {
let (scene, _) = ucmd();
@@ -1352,170 +1389,3 @@ fn repeated_options_are_accepted() {
.succeeds()
.stdout_only("a\nb\n");
}
#[test]
fn literal_buffer_path_prefixes_and_max() {
// Plain literals are served by the buffer-at-a-time engine; the line/byte
// prefixes and -m must still be byte-identical to the line-at-a-time path.
// -n and -b together: "lineno:byteoffset:line".
let (_s, mut c) = ucmd();
c.args(&["-nb", "foo"])
.pipe_in("foo\nbar\nfoobar\n")
.succeeds()
.stdout_only("1:0:foo\n3:8:foobar\n");
// A line matched more than once is still emitted once.
let (_s, mut c) = ucmd();
c.args(&["-c", "oo"])
.pipe_in("oooo\nbar\noo\n")
.succeeds()
.stdout_only("2\n");
// -m caps printed matches.
let (_s, mut c) = ucmd();
c.args(&["-m", "2", "x"])
.pipe_in("x\ny\nx\nz\nx\n")
.succeeds()
.stdout_only("x\nx\n");
// Final line without a trailing terminator still matches and is printed
// with an added newline.
let (_s, mut c) = ucmd();
c.args(&["foo"])
.pipe_in("bar\nfoo")
.succeeds()
.stdout_only("foo\n");
}
#[test]
fn literal_buffer_path_spans_many_chunks() {
// Build an input far larger than the read buffer so the buffer-at-a-time
// engine crosses several chunk boundaries, and check that line numbers and
// counts stay correct across them.
let mut input = String::new();
let mut expected_n = String::new();
let mut count = 0u32;
for i in 1..=100_000u32 {
if i % 7 == 0 {
input.push_str("needle\n");
expected_n.push_str(&format!("{i}:needle\n"));
count += 1;
} else {
input.push_str("some filler text\n");
}
}
assert!(input.len() > 512 * 1024, "input must exceed several chunks");
let (_s, mut c) = ucmd();
c.args(&["-c", "needle"])
.pipe_in(input.clone())
.succeeds()
.stdout_only(format!("{count}\n"));
let (_s, mut c) = ucmd();
c.args(&["-n", "needle"])
.pipe_in(input)
.succeeds()
.stdout_only(expected_n);
}
// Plain literals run on the buffer-at-a-time fast path, so the following tests
// use bracket-class patterns (non-literal) to keep the line-at-a-time engine's
// `-l` / `-L` / `-q` and binary-handling paths exercised too.
#[test]
fn slow_path_list_and_quiet_modes() {
let (scene, _) = ucmd();
scene.fixtures.write("hit", "yes\n");
scene.fixtures.write("miss", "no\n");
// -l: list matching files.
scene
.cmd(env!("CARGO_BIN_EXE_grep"))
.args(&["-l", "[y]es", "hit", "miss"])
.succeeds()
.stdout_is("hit\n");
// -L with a match in one file: only the non-matching file is listed.
scene
.cmd(env!("CARGO_BIN_EXE_grep"))
.args(&["-L", "[y]es", "hit", "miss"])
.succeeds()
.stdout_is("miss\n");
// -L with no match anywhere: both files listed, exit 1.
scene
.cmd(env!("CARGO_BIN_EXE_grep"))
.args(&["-L", "[z]z", "hit", "miss"])
.fails_with_code(1)
.stdout_is("hit\nmiss\n");
// -q stops at the first match (exit 0) or reports no match (exit 1).
scene
.cmd(env!("CARGO_BIN_EXE_grep"))
.args(&["-q", "[y]es", "hit"])
.succeeds()
.no_output();
scene
.cmd(env!("CARGO_BIN_EXE_grep"))
.args(&["-q", "[z]z", "hit"])
.fails_with_code(1)
.no_output();
}
#[test]
fn slow_path_binary_handling() {
let (scene, _) = ucmd();
// NOTE: avoid the name "nul" here — it's a reserved device name on Windows,
// so writing/reading it hits the null device instead of a real file.
scene.fixtures.write_bytes("nulbin", b"hit\0\n");
scene.fixtures.write_bytes("bad", b"a\x9d\n");
// Binary notice on the line-at-a-time engine (regex pattern).
scene
.cmd(env!("CARGO_BIN_EXE_grep"))
.args(&["[h]it", "nulbin"])
.succeeds()
.no_stdout()
.stderr_contains("binary file matches");
// -a forces text mode: the NUL line is printed verbatim.
scene
.cmd(env!("CARGO_BIN_EXE_grep"))
.args(&["-a", "[h]it", "nulbin"])
.succeeds()
.stdout_is_bytes(b"hit\0\n");
// --binary-files=without-match bails out on an invalid-UTF-8 match.
scene
.cmd(env!("CARGO_BIN_EXE_grep"))
.args(&["--binary-files=without-match", "[a]", "bad"])
.fails_with_code(1)
.no_output();
// A NUL after the matched line means binariness is discovered at EOF, so
// the line is printed first and the notice is emitted during finalization.
scene.fixtures.write_bytes("late", b"hit\nno\0\n");
scene
.cmd(env!("CARGO_BIN_EXE_grep"))
.args(&["[h]it", "late"])
.succeeds()
.stdout_is("hit\n")
.stderr_contains("binary file matches");
}
#[test]
fn fast_path_binary_detected_after_a_printed_line() {
// A NUL that appears only after the last match in the buffer marks the file
// binary on the fast path *after* an earlier match was already printed: the
// printed line stays and the trailing notice is still emitted.
let (scene, _) = ucmd();
scene.fixtures.write_bytes("b", b"hit\nno\0\n");
scene
.cmd(env!("CARGO_BIN_EXE_grep"))
.args(&["hit", "b"])
.succeeds()
.stdout_is("hit\n")
.stderr_contains("binary file matches");
}