Author SHA1 Message Date
Sylvestre LedruandGitHub 0b62b44dd3 Revert "perf: buffer-at-a-time search for literal patterns" 2026-06-05 16:10:10 +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
7 changed files with 156 additions and 11 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/
+12 -2
View File
@@ -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")
+10 -7
View File
@@ -28,13 +28,10 @@ impl<'a> Matcher<'a> {
/// 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 +40,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 +177,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;
}
+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")?;
+3 -1
View File
@@ -283,7 +283,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);
}
+90
View File
@@ -126,6 +126,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 +188,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 +297,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 +312,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 +518,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();
@@ -862,6 +939,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 +948,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) {