Author SHA1 Message Date
oech3andGitHub e8b93e94cb uufuzz: simplify (#59) 2026-06-10 12:43:17 +02:00
oech3andGitHub 0326b43cb2 uufuzz: simplify pipe() code (#58) 2026-06-09 23:21:59 +02:00
WondrandGitHub e925ff4ac8 grep: honor GNU buffer anchors (#56) 2026-06-05 17:33:23 +02:00
Leonard Hecker f2f86ef572 grep: Fix slow_path_binary_handling test 2026-06-05 17:04:50 +02:00
Leonard Hecker 265a562c26 grep: Simplify session_finalize 2026-06-05 17:04:29 +02:00
WondrandGitHub bc61aece73 grep: cover zero-width EOF only-matching behavior (#44) 2026-06-05 16:52:30 +02:00
WondrandGitHub 55a7b0180b grep: suppress EOF output in quiet mode (#41) 2026-06-05 16:48:36 +02:00
WondrandGitHub d556159952 grep: let dot match newline in null-data mode (#45) 2026-06-05 16:25:04 +02:00
WondrandGitHub 7c79cb4e3a grep: keep invalid UTF-8 text under -I (#49) 2026-06-05 16:06:49 +02:00
Sylvestre Ledru abcdd7e84d docs: add playground section to README with URL example 2026-06-05 13:34:16 +02:00
Sylvestre LedruandGitHub d28bf769a1 Merge pull request #7 from uutils/add-differential-fuzzer
fuzz: add differential fuzzer against GNU grep
2026-06-05 08:29:30 +02:00
Sylvestre LedruandGitHub b4980df814 Merge pull request #16 from uutils/literal-fast-path
perf: buffer-at-a-time search for literal patterns
2026-06-05 08:28:04 +02:00
Sylvestre LedruandGitHub e9825e3503 Merge pull request #12 from uutils/grep-initial-tab-empty-line
grep: don't emit the -T alignment tab on empty lines
2026-06-05 08:27:43 +02:00
Sylvestre LedruandGitHub f5d5f6c063 Merge pull request #40 from koopatroopa787/issue-34-perl-single-pattern
fix: reject multiple patterns when -P/--perl-regexp is used
2026-06-05 08:25:18 +02:00
Sylvestre LedruandGitHub 337b7c704b Merge pull request #52 from wondr-wclabs/codex/empty-match-word-line
grep: select zero-width matches under -w and -x
2026-06-05 08:23:03 +02:00
Wondr ad7595ffe6 grep: select zero-width matches under -w and -x 2026-06-05 04:39:22 +01:00
Kanishk Sachan e3d80f59e2 fix: reject multiple patterns when -P/--perl-regexp is used
GNU grep's PCRE backend supports only a single pattern. Supplying
multiple patterns via repeated -e flags, or a pattern string that
contains a literal newline, must exit 2 with the message:

    the -P option only supports a single pattern

Add the validation immediately after patterns are collected, before
regex-mode selection. Add a test covering:
  - two separate -e flags with -P
  - a newline-embedded pattern string with -P
  - single -e with -P still works normally

Closes #34
2026-06-05 01:25:22 +01:00
Sylvestre Ledru 56d774f576 test: cover slow-path modes that literal tests no longer reach
The buffer-at-a-time fast path now serves the literal patterns that the
existing -l/-L/-q and binary tests used, leaving the line-at-a-time
engine's equivalents uncovered. Add bracket-class (non-literal) tests
for -l/-L/-q and binary handling (notice, -a text, without-match bail,
and the finalize-time notice), plus a fast-path test for a NUL that is
only discovered after a line was already printed.

No dead code was found: the remaining uncovered lines are writer I/O
error-propagation arms and pre-existing filesystem error handlers.
2026-06-04 22:24:27 +02:00
Sylvestre Ledru 28186e9ec3 perf: buffer-at-a-time search for literal patterns
Literal searches were ~50-70x slower than GNU grep because every line
paid per-line costs (terminator scan, NUL scan, dispatch) even when a
buffer held no match. Add a buffer-at-a-time driver that scans whole
chunks with a substring searcher and only locates line boundaries
around the matches it finds; a chunk with no match costs a single
vectorized sweep and no per-line work.

The driver activates only for plain ASCII literal patterns (case
sensitive, no metacharacters) in the simpler output modes: -c, -l, -L,
-q, and plain line printing with -n/-b/filename/-m. Anything needing
match positions, context, inversion, color, or special binary handling
falls back to the unchanged line-at-a-time path. Output stays
byte-identical to that path, including binary/invalid-UTF-8 behavior.

- line_buffer: read_chunk() yields the largest span of complete lines.
- matcher: expose per-pattern memmem searchers when every pattern is a
  plain literal (plain_literal()).
- searcher: eligible_for_fast_path(), fast_locate(), fast_print().

All scanning rides on the memchr crate (SIMD memchr/memrchr/memmem).
Unit tests for read_chunk and plain_literal; integration tests for
prefixes, -m, and multi-chunk line-number correctness.

Benchmarks (31 MB corpus) vs prior release:
  -F (no match):  232ms -> 15ms  (15.9x; now faster than GNU)
  -c literal:     229ms -> 15ms  (15.2x)
  plain print:    248ms -> 18ms  (13.5x)
Regex and -i paths are unchanged (still the line-at-a-time engine).
2026-06-04 22:24:27 +02:00
rifatxandGitHub f4798cb6d0 fix -l and -L to be mutually exclusive so that last one wins (#30) 2026-06-04 22:14:12 +02:00
Sylvestre LedruandGitHub 4e6823a8b3 Add installation section to README (#29) 2026-06-03 17:56:23 +02:00
Sylvestre LedruandGitHub b0700b1d78 Merge pull request #21 from oech3/pub
GnuTests: publish binary from main
2026-06-02 21:04:50 +02:00
oech3 bc416c6d8b GnuTests: publish binary from main 2026-06-03 00:05:24 +09:00
Sylvestre Ledru a7b15320af grep: don't emit the -T alignment tab on empty lines
With -T, grep pads the prefix with a tab so line content lands on a tab
stop. GNU omits that tab when the line has no content: an empty line
prints just its prefix (a whitespace-only line still gets the tab).
uu_grep always wrote the tab, so empty matched lines gained a spurious
trailing tab. Gate the tab on non-empty content. Fixes the GNU testsuite
'initial-tab' test.
2026-05-30 19:13:52 +02:00
9 changed files with 966 additions and 134 deletions
+18 -1
View File
@@ -9,6 +9,9 @@ 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 }}
@@ -47,7 +50,21 @@ jobs:
shell: bash
run: |
cd 'grep'
cargo build --release
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 }}
- name: Run GNU grep testsuite
shell: bash
+17
View File
@@ -11,6 +11,23 @@
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
```
## 🚀 Try it online
You can try `grep` directly in your browser on the [uutils playground](https://uutils.github.io/playground/).
Arguments (and a full command) can be passed through the URL via the `cmd` query parameter, for example:
```shell
printf '🚀 rocket\n🛰️ satellite\n🌙 moon\n⭐ star\n' | grep 🌙
```
[Run it in the playground](https://uutils.github.io/playground/?cmd=printf%20%27%F0%9F%9A%80%20rocket%5Cn%F0%9F%9B%B0%EF%B8%8F%20satellite%5Cn%F0%9F%8C%99%20moon%5Cn%E2%AD%90%20star%5Cn%27%20%7C%20grep%20%F0%9F%8C%99)
## Building
Download Rust at: https://rustup.rs/
+32 -110
View File
@@ -11,12 +11,11 @@ use rand::RngExt;
use rand::prelude::IndexedRandom;
use rustix::io::dup;
use rustix::io::read;
use rustix::pipe::pipe;
use rustix::stdio::{dup2_stderr, dup2_stdin, dup2_stdout};
use std::env::temp_dir;
use std::ffi::OsString;
use std::fs::File;
use std::io::{Seek, SeekFrom, Write};
use std::io::{Seek, SeekFrom, Write, pipe};
use std::process::{Command, Stdio};
use std::sync::atomic::Ordering;
use std::sync::{Once, atomic::AtomicBool};
@@ -69,58 +68,18 @@ where
F: FnOnce(std::vec::IntoIter<OsString>) -> i32 + Send + 'static,
{
// Duplicate the stdout and stderr file descriptors to restore later
let original_stdout_fd_owned = match dup(std::io::stdout()) {
Ok(fd) => fd,
Err(_) => {
return CommandResult {
stdout: "".to_string(),
stderr: "Failed to duplicate STDOUT_FILENO".to_string(),
exit_code: -1,
};
}
};
let original_stderr_fd_owned = match dup(std::io::stderr()) {
Ok(fd) => fd,
Err(_) => {
return CommandResult {
stdout: "".to_string(),
stderr: "Failed to duplicate STDERR_FILENO".to_string(),
exit_code: -1,
};
}
};
let original_stdout_fd_owned =
dup(std::io::stdout()).expect("Failed to duplicate STDOUT_FILENO");
let original_stderr_fd_owned =
dup(std::io::stderr()).expect("Failed to duplicate STDERR_FILENO");
println!("Running test {:?}", &args[0..]);
let (read_pipe_stdout, write_pipe_stdout) = match pipe() {
Ok(fds) => fds,
Err(_) => {
return CommandResult {
stdout: "".to_string(),
stderr: "Failed to create pipes".to_string(),
exit_code: -1,
};
}
};
let (read_pipe_stderr, write_pipe_stderr) = match pipe() {
Ok(fds) => fds,
Err(_) => {
return CommandResult {
stdout: "".to_string(),
stderr: "Failed to create pipes".to_string(),
exit_code: -1,
};
}
};
let (read_pipe_stdout, write_pipe_stdout) = pipe().expect("Failed to create pipes");
let (read_pipe_stderr, write_pipe_stderr) = pipe().expect("Failed to create pipes");
// Redirect stdout and stderr to their respective pipes
if dup2_stdout(&write_pipe_stdout).is_err() || dup2_stderr(&write_pipe_stderr).is_err() {
return CommandResult {
stdout: "".to_string(),
stderr: "Failed to redirect STDOUT_FILENO or STDERR_FILENO".to_string(),
exit_code: -1,
};
}
dup2_stdout(&write_pipe_stdout).expect("Failed to redirect STDOUT_FILENO");
dup2_stderr(&write_pipe_stderr).expect("Failed to redirect STDERR_FILENO");
// Handle stdin redirection if needed
let original_stdin_fd_owned = if let Some(input_str) = pipe_input {
@@ -130,25 +89,10 @@ where
input_file.seek(SeekFrom::Start(0)).unwrap();
// Redirect stdin to read from the in-memory file
let stdin_fd = match dup(std::io::stdin()) {
Ok(fd) => fd,
Err(_) => {
return CommandResult {
stdout: "".to_string(),
stderr: "Failed to duplicate STDIN".to_string(),
exit_code: -1,
};
}
};
let stdin_fd = dup(std::io::stdin()).expect("Failed to duplicate STDIN");
// Redirect stdin to read from the in-memory file
if dup2_stdin(&input_file).is_err() {
return CommandResult {
stdout: "".to_string(),
stderr: "Failed to set up stdin redirection".to_string(),
exit_code: -1,
};
}
dup2_stdin(&input_file).expect("Failed to set up stdin redirection");
Some(stdin_fd)
} else {
@@ -176,14 +120,8 @@ where
});
// Restore the original stdin if it was modified
if let Some(fd) = original_stdin_fd_owned
&& dup2_stdin(&fd).is_err()
{
return CommandResult {
stdout: "".to_string(),
stderr: "Failed to restore the original STDIN".to_string(),
exit_code: -1,
};
if let Some(fd) = original_stdin_fd_owned {
dup2_stdin(&fd).expect("Failed to restore the original STDIN");
}
CommandResult {
@@ -226,18 +164,14 @@ pub fn run_gnu_cmd(
check_gnu: bool,
pipe_input: Option<&str>,
) -> Result<CommandResult, CommandResult> {
if check_gnu {
match is_gnu_cmd(cmd_path) {
Ok(_) => {} // if the check passes, do nothing
Err(e) => {
// Convert the io::Error into the function's error type
return Err(CommandResult {
stdout: String::new(),
stderr: e.to_string(),
exit_code: -1,
});
}
}
// if the check passes, do nothing
if check_gnu && let Err(e) = is_gnu_cmd(cmd_path) {
// Convert the io::Error into the function's error type
return Err(CommandResult {
stdout: String::new(),
stderr: e.to_string(),
exit_code: -1,
});
}
let mut command = Command::new(cmd_path);
@@ -505,12 +439,9 @@ mod tests {
let result = run_gnu_cmd("echo", &args, false, None);
// Should succeed (echo --version might not be standard but echo should exist)
match result {
Ok(_) => {} // Command succeeded
Err(err_result) => {
// Command failed but at least ran
assert_ne!(err_result.exit_code, -1); // -1 would indicate the command couldn't be found
}
if let Err(e) = result {
// Command failed but at least ran
assert_ne!(e.exit_code, -1); // -1 would indicate the command couldn't be found
}
}
@@ -519,29 +450,20 @@ mod tests {
let args: Vec<OsString> = vec![];
let pipe_input = "hello world";
let result = run_gnu_cmd("cat", &args, false, Some(pipe_input));
match result {
Ok(cmd_result) => {
assert_eq!(cmd_result.stdout.trim(), "hello world");
}
Err(_) => {
// cat might not be available in test environment, that's ok
}
// cat might not be available in test environment, that's ok
if let Ok(cmd_result) = result {
assert_eq!(cmd_result.stdout.trim(), "hello world");
}
}
#[test]
fn test_generate_random_file() {
let result = generate_random_file();
match result {
Ok(file_path) => {
assert!(!file_path.is_empty());
// Clean up - try to remove the file
let _ = std::fs::remove_file(&file_path);
}
Err(_) => {
// File creation might fail due to permissions, that's acceptable for this test
}
// File creation might fail due to permissions, that's acceptable for this test
if let Ok(path) = result {
assert!(!path.is_empty());
// Clean up - try to remove the file
let _ = std::fs::remove_file(&path);
}
}
}
+13 -3
View File
@@ -22,7 +22,7 @@ use std::io::{IsTerminal as _, Read};
use std::path::Path;
use uucore::error::{FromIo, UResult, USimpleError};
#[derive(Clone, Copy, PartialEq, Eq)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[doc(hidden)]
pub enum RegexMode {
Fixed,
@@ -255,6 +255,14 @@ 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
@@ -707,14 +715,16 @@ pub fn uu_app() -> Command {
.short('L')
.long("files-without-match")
.help("print only names of FILEs with no selected lines")
.action(ArgAction::SetTrue),
.action(ArgAction::SetTrue)
.overrides_with("files_with_matches"),
)
.arg(
Arg::new("files_with_matches")
.short('l')
.long("files-with-matches")
.help("print only names of FILEs with selected lines")
.action(ArgAction::SetTrue),
.action(ArgAction::SetTrue)
.overrides_with("files_without_match"),
)
.arg(
Arg::new("count")
+190 -1
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;
use memchr::{memchr, memrchr};
use std::fs::File;
use std::io::{self, Read as _};
@@ -111,4 +111,193 @@ 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);
}
}
+118 -8
View File
@@ -4,6 +4,7 @@
// file that was distributed with this source code.
use crate::{Config, RegexMode};
use memchr::memmem;
use onig::{
EncodedBytes, Regex, RegexOptions, Region, SearchOptions, Syntax, SyntaxBehavior,
SyntaxOperator,
@@ -14,6 +15,12 @@ use uucore::error::{UResult, USimpleError};
pub struct Matcher<'a> {
config: &'a Config<'a>,
patterns: Vec<CompiledPattern>,
/// One substring searcher per pattern, present only when *every* pattern is
/// a plain literal that a raw byte search resolves exactly (see
/// [`plain_literal`]). When set, a caller can decide a line matches by
/// looking for any of these needles, bypassing the regex engine entirely.
/// `None` as soon as a single pattern needs real regex evaluation.
literal_searchers: Option<Vec<memmem::Finder<'static>>>,
}
impl<'a> Matcher<'a> {
@@ -22,19 +29,41 @@ impl<'a> Matcher<'a> {
for raw in config.patterns {
patterns.push(CompiledPattern::compile(raw, config)?);
}
Ok(Self { config, patterns })
// If we can reduce the whole pattern set to literal needles, keep a
// searcher for each so the driver can take a bulk substring-scan path.
let needles: Option<Vec<Vec<u8>>> = config
.patterns
.iter()
.map(|p| plain_literal(p, config.ignore_case, config.regex_mode))
.collect();
let literal_searchers = needles.filter(|n| !n.is_empty()).map(|n| {
n.iter()
.map(|w| memmem::Finder::new(w).into_owned())
.collect()
});
Ok(Self {
config,
patterns,
literal_searchers,
})
}
/// Per-pattern substring searchers, present only when the pattern set is a
/// pure set of literals (no regex needed). Used by the searcher to scan a
/// whole buffer at once instead of testing line by line.
pub fn literal_searchers(&self) -> Option<&[memmem::Finder<'static>]> {
self.literal_searchers.as_deref()
}
/// Decide whether `line` matches and return the positions to highlight.
pub fn match_line(&self, line: &[u8]) -> Option<Vec<(usize, usize)>> {
let mut any_seen = false;
let mut any_selected = false;
let positions: Vec<_> = MatchIter::new(&self.patterns, line)
.filter(|&(start, end)| {
any_seen = true;
// Drop zero-length matches from the output.
if start == end {
return false;
}
// Drop matches that don't span the whole line if `-x` was requested.
if self.config.line_regexp && !(start == 0 && end == line.len()) {
return false;
@@ -43,13 +72,19 @@ impl<'a> Matcher<'a> {
if self.config.word_regexp && !Self::is_word_match(line, start, end) {
return false;
}
any_selected = true;
// Drop zero-length matches from the output.
if start == end {
return false;
}
true
})
.collect();
let raw_matched = if self.config.line_regexp || self.config.word_regexp {
// -w / -x are authoritative once positions are filtered.
!positions.is_empty()
// -w / -x are authoritative once matches are filtered. Zero-length
// matches can select a line even though there is no span to output.
any_selected
} else {
any_seen
};
@@ -174,7 +209,7 @@ struct Cursor<'a> {
impl Cursor<'_> {
fn refill(&mut self) {
if self.offset >= self.line.len() {
if self.offset > self.line.len() {
self.pending = None;
return;
}
@@ -194,6 +229,25 @@ impl Cursor<'_> {
}
}
/// Return the literal bytes of `pattern` when a raw byte-for-byte substring
/// search is *exactly* equivalent to matching it, otherwise `None`.
///
/// We accept only ASCII, case-sensitive needles. That keeps the byte search in
/// agreement with the regex engine on every possible input, including bytes that
/// are not valid UTF-8: an ASCII byte can never be part of a multi-byte sequence,
/// so its presence is unambiguous. In the regex modes we also require that no
/// byte could ever act as a metacharacter; under `-F` the text is literal as-is.
fn plain_literal(pattern: &str, ignore_case: bool, mode: RegexMode) -> Option<Vec<u8>> {
if ignore_case || pattern.is_empty() || !pattern.is_ascii() {
return None;
}
// Every byte that carries special meaning in any of our regex syntaxes.
// A needle without these reads the same as a literal in Basic/Extended/Perl.
const SPECIAL: &[u8] = b".*[]^$\\+?{}()|";
let plain = mode == RegexMode::Fixed || !pattern.bytes().any(|b| SPECIAL.contains(&b));
plain.then(|| pattern.as_bytes().to_vec())
}
struct CompiledPattern {
/// Default semantics. It's decently fast and used for searching.
leftmost: Regex,
@@ -215,6 +269,10 @@ impl CompiledPattern {
// GNU grep supports `{,n}` as an alias for `{0,n}`.
syntax.enable_behavior(SyntaxBehavior::SYNTAX_BEHAVIOR_ALLOW_INTERVAL_LOW_ABBREV);
}
if matches!(config.regex_mode, RegexMode::Basic | RegexMode::Extended) {
// GNU grep supports \` and \' as buffer anchors in BRE and ERE.
syntax.enable_operators(SyntaxOperator::SYNTAX_OPERATOR_ESC_GNU_BUF_ANCHOR);
}
if config.regex_mode == RegexMode::Perl {
// GNU grep supports `(?P<name>...)`.
// Unfortunately, the onig crate defines the OP2 flag without the
@@ -231,6 +289,12 @@ impl CompiledPattern {
if config.ignore_case {
options |= RegexOptions::REGEX_OPTION_IGNORECASE;
}
// In GNU grep's Basic/Extended modes, `-z` makes newline ordinary data
// for `.`, but PCRE keeps its existing non-DOTALL behavior. The GNU
// `pcre-context` test documents this as current behavior until PCRE2.
if config.null_data && matches!(config.regex_mode, RegexMode::Basic | RegexMode::Extended) {
options |= RegexOptions::REGEX_OPTION_MULTILINE;
}
fn compile_with(pattern: &str, syntax: &Syntax, options: RegexOptions) -> UResult<Regex> {
Regex::with_options_and_encoding(pattern, options, syntax).map_err(|err| {
@@ -289,3 +353,49 @@ impl CompiledPattern {
.is_some()
}
}
#[cfg(test)]
mod tests {
use super::plain_literal;
use crate::RegexMode;
fn lit(p: &str, ic: bool, mode: RegexMode) -> Option<Vec<u8>> {
plain_literal(p, ic, mode)
}
#[test]
fn fixed_mode_takes_any_ascii_verbatim() {
// Under -F every byte is literal, even regex metacharacters.
assert_eq!(lit("abc", false, RegexMode::Fixed), Some(b"abc".to_vec()));
assert_eq!(lit("a.*b", false, RegexMode::Fixed), Some(b"a.*b".to_vec()));
assert_eq!(lit("a+b", false, RegexMode::Fixed), Some(b"a+b".to_vec()));
}
#[test]
fn regex_modes_accept_metacharacter_free_literals() {
for mode in [RegexMode::Basic, RegexMode::Extended, RegexMode::Perl] {
assert_eq!(lit("ing", false, mode), Some(b"ing".to_vec()));
assert_eq!(lit("Hello123", false, mode), Some(b"Hello123".to_vec()));
}
}
#[test]
fn regex_modes_reject_anything_with_a_metacharacter() {
for mode in [RegexMode::Basic, RegexMode::Extended, RegexMode::Perl] {
for p in [
"a.b", "a*", "[ab]", "^a", "a$", "a\\b", "a+", "a?", "(a)", "a|b", "a{2}",
] {
assert_eq!(lit(p, false, mode), None, "pattern {p:?} in {mode:?}");
}
}
}
#[test]
fn rejects_empty_case_insensitive_and_non_ascii() {
assert_eq!(lit("", false, RegexMode::Fixed), None);
assert_eq!(lit("abc", true, RegexMode::Fixed), None); // -i
assert_eq!(lit("abc", true, RegexMode::Basic), None);
assert_eq!(lit("café", false, RegexMode::Fixed), None); // non-ASCII
assert_eq!(lit("naïve", false, RegexMode::Basic), None);
}
}
+6
View File
@@ -71,6 +71,7 @@ impl<'a> OutputWriter<'a> {
view.line_number,
view.byte_offset + start as u64,
b':',
false,
)?;
self.write_colored_bytes(
@@ -90,6 +91,7 @@ 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;
@@ -125,6 +127,7 @@ 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(
@@ -155,7 +158,10 @@ 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")?;
+255 -11
View File
@@ -8,7 +8,8 @@ use crate::line_buffer::LineBuffer;
use crate::matcher::Matcher;
use crate::output::OutputWriter;
use crate::{BinaryMode, Config, DeviceMode, DirectoryMode};
use memchr::memchr;
use memchr::memmem::Finder;
use memchr::{memchr, memchr_iter, memrchr};
use std::ffi::OsStr;
use std::fs::File;
use std::io;
@@ -248,12 +249,221 @@ 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;
@@ -283,7 +493,9 @@ impl<'a> Searcher<'a> {
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()) {
if self.config.binary_mode != BinaryMode::WithoutMatch
&& !self.session_mark_binary_if(|| std::str::from_utf8(line).is_err())
{
return Ok(false);
}
@@ -431,15 +643,19 @@ impl<'a> Searcher<'a> {
/// End-of-file bookkeeping: count / `-L` / binary notice.
fn session_finalize(&mut self, path: &Path) -> io::Result<bool> {
if self.config.count && !self.config.files_with_matches && !self.config.files_without_match
{
self.writer.write_count(self.session_match_count, path)?;
}
if self.config.files_without_match && !self.session_any_match() {
self.writer.write_filename(path)?;
}
if self.session_should_emit_binary_notice() {
self.writer.report_binary_match(path);
if !self.config.quiet {
if self.config.count
&& !self.config.files_with_matches
&& !self.config.files_without_match
{
self.writer.write_count(self.session_match_count, path)?;
}
if self.config.files_without_match && !self.session_any_match() {
self.writer.write_filename(path)?;
}
if self.session_should_emit_binary_notice() {
self.writer.report_binary_match(path);
}
}
Ok(self.session_any_match())
}
@@ -470,3 +686,31 @@ 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)
}
+317
View File
@@ -85,6 +85,21 @@ fn bre_gnu_extensions() {
.stdout_only("*foo\n**foo\n");
}
#[test]
fn gnu_buffer_anchors() {
let (_s, mut c) = ucmd();
c.args(&[r"\`c\|r\'"])
.pipe_in("cat\nscat\ntar\ndog\n")
.succeeds()
.stdout_only("cat\ntar\n");
let (_s, mut c) = ucmd();
c.args(&["-E", r"\`c|r\'"])
.pipe_in("cat\nscat\ntar\ndog\n")
.succeeds()
.stdout_only("cat\ntar\n");
}
#[test]
fn ere_metacharacters() {
let cases: &[(&[&str], &str, &str)] = &[
@@ -126,6 +141,24 @@ fn ere_invalid_pattern_is_error() {
.stderr_contains("invalid pattern");
}
#[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");
let (s, mut c) = ucmd();
s.fixtures.write("in", "x\n \n");
c.args(&["-T", "-H", "^", "in"])
.succeeds()
.stdout_is("in:\tx\nin:\t \n");
}
#[test]
fn fixed_string_is_literal() {
// Metacharacters are not interpreted.
@@ -170,6 +203,34 @@ 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();
@@ -251,6 +312,12 @@ 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]
@@ -260,6 +327,12 @@ 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]
@@ -460,6 +533,25 @@ 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();
@@ -510,6 +602,23 @@ fn only_matching() {
.succeeds()
.stdout_only("Hello\nhELLO\nHELLO\n");
// Zero-width matches do not print in -o mode, but still mark the line as
// matched. This matters for -v because matched lines must not be selected.
let (_s, mut c) = ucmd();
c.args(&["-o", "$"]).pipe_in("\n").succeeds().no_output();
let (_s, mut c) = ucmd();
c.args(&["-o", "-v", "$"])
.pipe_in("a\n\nb\n")
.fails_with_code(1)
.no_output();
let (_s, mut c) = ucmd();
c.args(&["-o", "-v", "x*"])
.pipe_in("a\n\nb\n")
.fails_with_code(1)
.no_output();
// After a match ends, ^ must not re-match at that position.
let (_s, mut c) = ucmd();
c.args(&["-o", "^hello*"])
@@ -530,6 +639,20 @@ fn quiet_modes() {
.pipe_in("a\n")
.fails_with_code(1)
.no_output();
// Quiet mode suppresses EOF bookkeeping output from -c and -L even when
// the no-match path reaches finalization.
let (_s, mut c) = ucmd();
c.args(&["-q", "-c", "z"])
.pipe_in("a\n")
.fails_with_code(1)
.no_output();
let (_s, mut c) = ucmd();
c.args(&["-q", "-L", "z"])
.pipe_in("a\n")
.fails_with_code(1)
.no_output();
}
#[test]
@@ -862,6 +985,7 @@ fn binary_files_text_forces_text_mode() {
fn binary_files_without_match_skips() {
let (scene, _) = ucmd();
scene.fixtures.write_bytes("b", b"hit\0more\n");
scene.fixtures.write_bytes("invalid", b"a\x9db\n");
let mut c = scene.cmd(env!("CARGO_BIN_EXE_grep"));
c.args(&["-I", "hit", "b"]).fails_with_code(1).no_output();
@@ -870,6 +994,18 @@ fn binary_files_without_match_skips() {
c.args(&["--binary-files=without-match", "hit", "b"])
.fails_with_code(1)
.no_output();
let mut c = scene.cmd(env!("CARGO_BIN_EXE_grep"));
c.args(&["-I", "a", "invalid"])
.succeeds()
.stdout_is_bytes(b"a\x9db\n")
.no_stderr();
let mut c = scene.cmd(env!("CARGO_BIN_EXE_grep"));
c.args(&["--binary-files=without-match", "a", "invalid"])
.succeeds()
.stdout_is_bytes(b"a\x9db\n")
.no_stderr();
}
fn build_tree(scene: &TestScenario) {
@@ -1174,6 +1310,27 @@ fn null_data_mode_records() {
.succeeds()
.stdout_is_bytes(b"hello\0");
// With NUL-delimited records, newline is ordinary data and `.` matches it.
let (_s, mut c) = ucmd();
c.args(&["-z", "-o", "."])
.pipe_in(&b"a\nb"[..])
.succeeds()
.stdout_is_bytes(b"a\0\n\0b\0");
// GNU grep's PCRE path currently does not let `.*` consume the extra
// newline here under -z; this mirrors the GNU pcre-context test.
let (_s, mut c) = ucmd();
c.args(&["-P", "-z", "-o", r"(?<=\n\n\n).*"])
.pipe_in(
&b"NUL preceded by 0 empty lines.\0\
\nNUL preceded by 1 empty line.\0\
\n\nNUL preceded by 2 empty lines.\0\
\n\n\nNUL preceded by 3 empty lines.\0\
\n\n\n\nNUL preceded by 4 empty lines.\0\n"[..],
)
.succeeds()
.stdout_is_bytes(b"NUL preceded by 3 empty lines.\0NUL preceded by 4 empty lines.\0");
// Counting works under -z.
let (_s, mut c) = ucmd();
c.args(&["-z", "-c", "hello"])
@@ -1253,3 +1410,163 @@ 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");
// 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");
}