From a7b15320af897b58c6d393c8604aa112ad6aa8c2 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 30 May 2026 18:06:14 +0200 Subject: [PATCH 01/15] 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. --- src/output.rs | 6 ++++++ tests/test_grep.rs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/output.rs b/src/output.rs index 30cdc5f..21343ab 100644 --- a/src/output.rs +++ b/src/output.rs @@ -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")?; diff --git a/tests/test_grep.rs b/tests/test_grep.rs index 5e487ea..b1648e6 100644 --- a/tests/test_grep.rs +++ b/tests/test_grep.rs @@ -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. From 2a6a3aba7ec61640743a0070cc287e7247491623 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sat, 30 May 2026 19:25:27 +0200 Subject: [PATCH 02/15] Add note about performance improvements needed --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 7a71b65..8531b38 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ cargo test * 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 From 079619ee448b6dcef612c6af80aa581d57c97b1a Mon Sep 17 00:00:00 2001 From: "codspeed-hq[bot]" <117304815+codspeed-hq[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 07:59:22 +0000 Subject: [PATCH 03/15] Add CodSpeed performance benchmarks --- .github/workflows/codspeed.yml | 37 +++ Cargo.lock | 543 ++++++++++++++++++++++++++++++++- Cargo.toml | 5 + README.md | 1 + benches/grep_bench.rs | 288 +++++++++++++++++ src/lib.rs | 129 ++++---- 6 files changed, 936 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/codspeed.yml create mode 100644 benches/grep_bench.rs diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml new file mode 100644 index 0000000..59aaa9f --- /dev/null +++ b/.github/workflows/codspeed.yml @@ -0,0 +1,37 @@ +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 diff --git a/Cargo.lock b/Cargo.lock index 4d21e03..caaf560 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -67,12 +73,39 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.62" @@ -106,6 +139,33 @@ dependencies = [ "rand_core", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.6.1" @@ -134,12 +194,80 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "codspeed" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57af92d1db7f6871b7e82c79cd87f2501db66f36b0eab924be6ea83dd6b2f3f3" +dependencies = [ + "anyhow", + "cc", + "colored", + "getrandom 0.2.17", + "glob", + "libc", + "nix", + "serde", + "serde_json", + "statrs", +] + +[[package]] +name = "codspeed-criterion-compat" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d31ae2e9ab23c29fa13bdfa06d012524176f5c0f4e25ec262cd829d947ebc5e" +dependencies = [ + "clap", + "codspeed", + "codspeed-criterion-compat-walltime", + "colored", + "regex", +] + +[[package]] +name = "codspeed-criterion-compat-walltime" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc8605e40bab5114dcb0f76268e18880082b5798dec10757b5b58d2c3bbc7a1c" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "codspeed", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -149,6 +277,47 @@ dependencies = [ "libc", ] +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "ctor" version = "0.8.0" @@ -218,6 +387,12 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "equivalent" version = "1.0.2" @@ -297,6 +472,41 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -317,6 +527,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -338,6 +559,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "id-arena" version = "2.3.0" @@ -375,12 +602,32 @@ dependencies = [ "unic-langid", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -428,6 +675,24 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -476,6 +741,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -519,6 +793,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "os_display" version = "0.1.4" @@ -528,12 +808,46 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -606,7 +920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", - "getrandom", + "getrandom 0.4.2", "rand_core", ] @@ -616,6 +930,26 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "regex" version = "1.12.3" @@ -673,6 +1007,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "same-file" version = "1.0.6" @@ -743,6 +1083,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -759,6 +1105,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "strsim" version = "0.11.1" @@ -783,7 +1139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -863,6 +1219,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "type-map" version = "0.5.1" @@ -919,6 +1285,7 @@ name = "uu_grep" version = "0.1.0" dependencies = [ "clap", + "codspeed-criterion-compat", "glob", "memchr", "onig", @@ -990,6 +1357,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -1008,6 +1381,51 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1042,6 +1460,16 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wild" version = "2.2.1" @@ -1066,13 +1494,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1084,6 +1521,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1091,58 +1544,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -1253,6 +1754,26 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 019577a..7170309 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,5 +27,10 @@ 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" diff --git a/README.md b/README.md index 8531b38..6f08387 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![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 diff --git a/benches/grep_bench.rs b/benches/grep_bench.rs new file mode 100644 index 0000000..3232f08 --- /dev/null +++ b/benches/grep_bench.rs @@ -0,0 +1,288 @@ +// 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 uu_grep::matcher::Matcher; +use uu_grep::{BinaryMode, ColorConfig, Config, DeviceMode, DirectoryMode, GlobSet, RegexMode}; + +fn make_config<'a>( + patterns: &'a [&'a str], + regex_mode: RegexMode, + ignore_case: bool, + invert_match: bool, + word_regexp: bool, +) -> Config<'a> { + Config { + directory_mode: DirectoryMode::Read, + device_mode: DeviceMode::Default, + follow_symlinks: false, + include_globs: GlobSet::new(), + exclude_globs: GlobSet::new(), + exclude_dir_globs: GlobSet::new(), + label: "(standard input)", + #[cfg(windows)] + strip_cr: false, + binary_mode: BinaryMode::Binary, + max_count: None, + before_context: 0, + after_context: 0, + has_context: false, + patterns, + regex_mode, + ignore_case, + invert_match, + word_regexp, + line_regexp: false, + quiet: true, + count: false, + show_filename: false, + files_with_matches: false, + files_without_match: false, + only_matching: false, + byte_offset: false, + line_number: false, + initial_tab: false, + null_separator: false, + null_data: false, + line_buffered: false, + no_messages: true, + group_separator: None, + use_color: false, + color_config: ColorConfig { + matched_selected: "", + matched_context: "", + filename: "", + line_number: "", + byte_offset: "", + separator: "", + selected_line: "", + context_line: "", + reverse_video: false, + no_erase: false, + }, + } +} + +fn bench_compile(c: &mut Criterion) { + let mut group = c.benchmark_group("compile"); + + group.bench_function("fixed_string", |b| { + b.iter(|| { + let patterns: &[&str] = &["hello world"]; + let config = make_config(patterns, RegexMode::Fixed, false, false, false); + let matcher = Matcher::compile(black_box(&config)).unwrap(); + let _ = black_box(&matcher); + }) + }); + + group.bench_function("basic_regex", |b| { + b.iter(|| { + let patterns: &[&str] = &[r"[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}"]; + let config = make_config(patterns, RegexMode::Basic, false, false, false); + let matcher = Matcher::compile(black_box(&config)).unwrap(); + let _ = black_box(&matcher); + }) + }); + + group.bench_function("extended_regex", |b| { + b.iter(|| { + let patterns: &[&str] = &[r"[0-9]{4}-[0-9]{2}-[0-9]{2}"]; + let config = make_config(patterns, RegexMode::Extended, false, false, false); + let matcher = Matcher::compile(black_box(&config)).unwrap(); + let _ = black_box(&matcher); + }) + }); + + group.bench_function("perl_regex", |b| { + b.iter(|| { + let patterns: &[&str] = &[r"\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}"]; + let config = make_config(patterns, RegexMode::Perl, false, false, false); + let matcher = Matcher::compile(black_box(&config)).unwrap(); + let _ = black_box(&matcher); + }) + }); + + group.bench_function("multiple_patterns", |b| { + b.iter(|| { + let patterns: &[&str] = &["error", "warning", "critical", "fatal", "panic"]; + let config = make_config(patterns, RegexMode::Fixed, false, false, false); + let matcher = Matcher::compile(black_box(&config)).unwrap(); + let _ = black_box(&matcher); + }) + }); + + group.finish(); +} + +fn bench_match(c: &mut Criterion) { + let mut group = c.benchmark_group("match"); + + // Fixed string match - hit + { + let patterns: &[&str] = &["ERROR"]; + let config = make_config(patterns, RegexMode::Fixed, false, false, false); + let matcher = Matcher::compile(&config).unwrap(); + let line = b"2024-01-15 10:30:45 ERROR: Connection timeout on server-42"; + + group.bench_function("fixed_string_hit", |b| { + b.iter(|| black_box(matcher.match_line(black_box(line)))) + }); + } + + // Fixed string match - miss + { + let patterns: &[&str] = &["CRITICAL"]; + let config = make_config(patterns, RegexMode::Fixed, false, false, false); + let matcher = Matcher::compile(&config).unwrap(); + let line = b"2024-01-15 10:30:45 INFO: Server started successfully"; + + group.bench_function("fixed_string_miss", |b| { + b.iter(|| black_box(matcher.match_line(black_box(line)))) + }); + } + + // Extended regex match + { + let patterns: &[&str] = &[r"[0-9]{4}-[0-9]{2}-[0-9]{2}"]; + let config = make_config(patterns, RegexMode::Extended, false, false, false); + let matcher = Matcher::compile(&config).unwrap(); + let line = b"2024-01-15 10:30:45 ERROR: Connection timeout"; + + group.bench_function("extended_regex_hit", |b| { + b.iter(|| black_box(matcher.match_line(black_box(line)))) + }); + } + + // Case-insensitive match + { + let patterns: &[&str] = &["error"]; + let config = make_config(patterns, RegexMode::Fixed, true, false, false); + let matcher = Matcher::compile(&config).unwrap(); + let line = b"2024-01-15 10:30:45 ERROR: Connection timeout"; + + group.bench_function("case_insensitive_hit", |b| { + b.iter(|| black_box(matcher.match_line(black_box(line)))) + }); + } + + // Inverted match + { + let patterns: &[&str] = &["ERROR"]; + let config = make_config(patterns, RegexMode::Fixed, false, true, false); + let matcher = Matcher::compile(&config).unwrap(); + let line = b"2024-01-15 10:30:45 INFO: Server started successfully"; + + group.bench_function("inverted_match", |b| { + b.iter(|| black_box(matcher.match_line(black_box(line)))) + }); + } + + // Word boundary match + { + let patterns: &[&str] = &["error"]; + let config = make_config(patterns, RegexMode::Fixed, true, false, true); + let matcher = Matcher::compile(&config).unwrap(); + let line = b"2024-01-15 10:30:45 error: Connection timeout"; + + group.bench_function("word_boundary_hit", |b| { + b.iter(|| black_box(matcher.match_line(black_box(line)))) + }); + } + + // Multiple patterns + { + let patterns: &[&str] = &["error", "warning", "critical", "fatal", "panic"]; + let config = make_config(patterns, RegexMode::Fixed, true, false, false); + let matcher = Matcher::compile(&config).unwrap(); + let line = b"2024-01-15 10:30:45 WARNING: High memory usage detected on node-7"; + + group.bench_function("multi_pattern_hit", |b| { + b.iter(|| black_box(matcher.match_line(black_box(line)))) + }); + } + + // Long line + { + let patterns: &[&str] = &["needle"]; + let config = make_config(patterns, RegexMode::Fixed, false, false, false); + let matcher = Matcher::compile(&config).unwrap(); + let mut long_line = "a".repeat(5000); + long_line.push_str("needle"); + long_line.push_str(&"b".repeat(5000)); + let long_line_bytes = long_line.into_bytes(); + + group.bench_function("long_line_hit", |b| { + b.iter(|| black_box(matcher.match_line(black_box(&long_line_bytes)))) + }); + } + + group.finish(); +} + +fn bench_throughput(c: &mut Criterion) { + let mut group = c.benchmark_group("throughput"); + + // Simulate processing many lines (like searching a log file) + let lines: Vec> = (0..1000) + .map(|i| { + if i % 50 == 0 { + format!( + "2024-01-15 10:30:{:02} ERROR: Connection timeout on server-{}", + i % 60, + i + ) + .into_bytes() + } else { + format!( + "2024-01-15 10:30:{:02} INFO: Request processed in {}ms", + i % 60, + i * 3 + ) + .into_bytes() + } + }) + .collect(); + + { + let patterns: &[&str] = &["ERROR"]; + let config = make_config(patterns, RegexMode::Fixed, false, false, false); + let matcher = Matcher::compile(&config).unwrap(); + + group.bench_function("scan_1000_lines_fixed", |b| { + b.iter(|| { + let mut matches = 0u64; + for line in &lines { + if matcher.match_line(black_box(line)).is_some() { + matches += 1; + } + } + black_box(matches) + }) + }); + } + + { + let patterns: &[&str] = &[r"[0-9]+ *ms"]; + let config = make_config(patterns, RegexMode::Extended, false, false, false); + let matcher = Matcher::compile(&config).unwrap(); + + group.bench_function("scan_1000_lines_regex", |b| { + b.iter(|| { + let mut matches = 0u64; + for line in &lines { + if matcher.match_line(black_box(line)).is_some() { + matches += 1; + } + } + black_box(matches) + }) + }); + } + + group.finish(); +} + +criterion_group!(benches, bench_compile, bench_match, bench_throughput); +criterion_main!(benches); diff --git a/src/lib.rs b/src/lib.rs index b915ae0..894faa8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,9 +3,12 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -mod context_buffer; -mod line_buffer; -mod matcher; +#[doc(hidden)] +pub mod context_buffer; +#[doc(hidden)] +pub mod line_buffer; +#[doc(hidden)] +pub mod matcher; mod output; mod searcher; @@ -20,7 +23,8 @@ use std::path::Path; use uucore::error::{FromIo, UResult, USimpleError}; #[derive(Clone, Copy, PartialEq, Eq)] -enum RegexMode { +#[doc(hidden)] +pub enum RegexMode { Fixed, Basic, Extended, @@ -28,7 +32,8 @@ enum RegexMode { } #[derive(Clone, Copy, PartialEq, Eq)] -enum BinaryMode { +#[doc(hidden)] +pub enum BinaryMode { Binary, Text, WithoutMatch, @@ -42,79 +47,84 @@ enum ColorMode { } #[derive(Clone, Copy, PartialEq, Eq)] -enum DirectoryMode { +#[doc(hidden)] +pub enum DirectoryMode { Read, Skip, Recurse, } #[derive(Clone, Copy, PartialEq, Eq)] -enum DeviceMode { +#[doc(hidden)] +pub enum DeviceMode { Default, Read, Skip, } -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, +#[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, - reverse_video: bool, - no_erase: bool, + pub reverse_video: bool, + pub no_erase: bool, } -struct GlobSet { +#[doc(hidden)] +pub struct GlobSet { patterns: Vec, } -struct Config<'a> { +#[doc(hidden)] +pub struct Config<'a> { // Searcher - directory_mode: DirectoryMode, - device_mode: DeviceMode, - follow_symlinks: bool, - include_globs: GlobSet, - exclude_globs: GlobSet, - exclude_dir_globs: GlobSet, - label: &'a str, + 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, #[cfg(windows)] - strip_cr: bool, - binary_mode: BinaryMode, - max_count: Option, - before_context: usize, - after_context: usize, - has_context: bool, + pub strip_cr: bool, + pub binary_mode: BinaryMode, + pub max_count: Option, + pub before_context: usize, + pub after_context: usize, + pub has_context: bool, // Matcher - patterns: &'a [&'a str], - regex_mode: RegexMode, - ignore_case: bool, - invert_match: bool, - word_regexp: bool, - line_regexp: bool, + 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, // Output - 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>, + 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>, } #[uucore::main(no_signals)] @@ -855,7 +865,14 @@ fn expand_num_shorthand(args: impl Iterator) -> Vec { } impl GlobSet { - fn with_capacity(capacity: usize) -> Self { + /// Create an empty GlobSet. + pub fn new() -> Self { + Self { + patterns: Vec::new(), + } + } + + pub fn with_capacity(capacity: usize) -> Self { Self { patterns: Vec::with_capacity(capacity), } From c8dfef656345d2e6e3a7f88d6d638dc38d2ad69f Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Fri, 29 May 2026 11:08:47 +0200 Subject: [PATCH 04/15] Add pre-commit configuration --- .pre-commit-config.yaml | 55 +++++++++++++++++++++++++++++++++++++++++ README.md | 4 +++ 2 files changed, 59 insertions(+) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..950598f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,55 @@ +# 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] diff --git a/README.md b/README.md index 6f08387..524e6d7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,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) From 96762f26ca83479522bc9f36cd477fa7e3d5809e Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 31 May 2026 10:18:32 +0200 Subject: [PATCH 05/15] Add Default impl for GlobSet to satisfy clippy --- src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 894faa8..df2b56a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -864,6 +864,12 @@ fn expand_num_shorthand(args: impl Iterator) -> Vec { out } +impl Default for GlobSet { + fn default() -> Self { + Self::new() + } +} + impl GlobSet { /// Create an empty GlobSet. pub fn new() -> Self { From b46a86d48a552f64e48dd1d04b5ef89ba85d801b Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 31 May 2026 10:41:52 +0200 Subject: [PATCH 06/15] bench: end-to-end search throughput via uumain The existing match/throughput benches call Matcher::match_line on pre-split lines, so they only measure matching in isolation and cannot observe how the searcher feeds data to the matcher. Add a 'search' group that drives the whole pipeline through uumain over a multi-MB file: a literal pattern (which a buffer-at-a-time searcher can speed up) and an extended-regex control (which it cannot). Uses -q with a non-matching pattern for a silent full-file scan. --- benches/grep_bench.rs | 70 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/benches/grep_bench.rs b/benches/grep_bench.rs index 3232f08..a87ab50 100644 --- a/benches/grep_bench.rs +++ b/benches/grep_bench.rs @@ -284,5 +284,73 @@ fn bench_throughput(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, bench_compile, bench_match, bench_throughput); +/// End-to-end search throughput, driven through the real `uumain` entry point +/// so the whole pipeline (input buffering, searcher, output) is exercised. +/// +/// `bench_match` / `bench_throughput` call `Matcher::match_line` on pre-split +/// lines, which measures matching in isolation. They cannot see a change to how +/// the *searcher* feeds data to the matcher (e.g. scanning whole buffers instead +/// of testing one line at a time), because they never run the searcher. These +/// cases do: a literal pattern (which a buffer-at-a-time engine can accelerate) +/// and an extended-regex control (which cannot), over a multi-megabyte file. +fn bench_search(c: &mut Criterion) { + use std::ffi::OsString; + + // A log-like file large enough to cross many internal read buffers. + let mut content = String::new(); + for i in 0..80_000u32 { + 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 mut path = std::env::temp_dir(); + path.push(format!("uu_grep_bench_{}.log", std::process::id())); + std::fs::write(&path, &content).unwrap(); + let path_arg = path.clone().into_os_string(); + + // `-q` with a pattern that never matches forces a full scan of the file and + // produces no output, so the timing reflects pure scanning throughput. + let run = |extra_flag: Option<&str>, pattern: &str| { + let mut args: Vec = vec![OsString::from("grep"), OsString::from("-q")]; + if let Some(flag) = extra_flag { + args.push(OsString::from(flag)); + } + args.push(OsString::from(pattern)); + args.push(path_arg.clone()); + // No match => Err(exit code 1); we only care about the work, not status. + let _ = uu_grep::uumain(args.into_iter()); + }; + + let mut group = c.benchmark_group("search"); + + group.bench_function("scan_literal_no_match", |b| { + b.iter(|| run(None, black_box("NONEXISTENT_TOKEN_XYZ"))) + }); + + group.bench_function("scan_regex_no_match", |b| { + b.iter(|| run(Some("-E"), black_box("NON[0-9]EXISTENT_TOKEN"))) + }); + + group.finish(); + let _ = std::fs::remove_file(&path); +} + +criterion_group!( + benches, + bench_compile, + bench_match, + bench_throughput, + bench_search +); criterion_main!(benches); From b5816820edfdf7b74319b951bfbb87ad211905d6 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 31 May 2026 11:05:28 +0200 Subject: [PATCH 07/15] bench: end-to-end search throughput only Replace the matcher micro-benchmarks with a single end-to-end 'search' group driven through uumain over a multi-megabyte file: a literal pattern (which a buffer-at-a-time searcher can accelerate) and an extended-regex control (which cannot). Matching pre-split lines in isolation cannot reveal how the searcher feeds data to the matcher; this does. --- benches/grep_bench.rs | 298 +----------------------------------------- 1 file changed, 6 insertions(+), 292 deletions(-) diff --git a/benches/grep_bench.rs b/benches/grep_bench.rs index a87ab50..42c7597 100644 --- a/benches/grep_bench.rs +++ b/benches/grep_bench.rs @@ -4,295 +4,15 @@ // file that was distributed with this source code. use criterion::{Criterion, black_box, criterion_group, criterion_main}; -use uu_grep::matcher::Matcher; -use uu_grep::{BinaryMode, ColorConfig, Config, DeviceMode, DirectoryMode, GlobSet, RegexMode}; - -fn make_config<'a>( - patterns: &'a [&'a str], - regex_mode: RegexMode, - ignore_case: bool, - invert_match: bool, - word_regexp: bool, -) -> Config<'a> { - Config { - directory_mode: DirectoryMode::Read, - device_mode: DeviceMode::Default, - follow_symlinks: false, - include_globs: GlobSet::new(), - exclude_globs: GlobSet::new(), - exclude_dir_globs: GlobSet::new(), - label: "(standard input)", - #[cfg(windows)] - strip_cr: false, - binary_mode: BinaryMode::Binary, - max_count: None, - before_context: 0, - after_context: 0, - has_context: false, - patterns, - regex_mode, - ignore_case, - invert_match, - word_regexp, - line_regexp: false, - quiet: true, - count: false, - show_filename: false, - files_with_matches: false, - files_without_match: false, - only_matching: false, - byte_offset: false, - line_number: false, - initial_tab: false, - null_separator: false, - null_data: false, - line_buffered: false, - no_messages: true, - group_separator: None, - use_color: false, - color_config: ColorConfig { - matched_selected: "", - matched_context: "", - filename: "", - line_number: "", - byte_offset: "", - separator: "", - selected_line: "", - context_line: "", - reverse_video: false, - no_erase: false, - }, - } -} - -fn bench_compile(c: &mut Criterion) { - let mut group = c.benchmark_group("compile"); - - group.bench_function("fixed_string", |b| { - b.iter(|| { - let patterns: &[&str] = &["hello world"]; - let config = make_config(patterns, RegexMode::Fixed, false, false, false); - let matcher = Matcher::compile(black_box(&config)).unwrap(); - let _ = black_box(&matcher); - }) - }); - - group.bench_function("basic_regex", |b| { - b.iter(|| { - let patterns: &[&str] = &[r"[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}"]; - let config = make_config(patterns, RegexMode::Basic, false, false, false); - let matcher = Matcher::compile(black_box(&config)).unwrap(); - let _ = black_box(&matcher); - }) - }); - - group.bench_function("extended_regex", |b| { - b.iter(|| { - let patterns: &[&str] = &[r"[0-9]{4}-[0-9]{2}-[0-9]{2}"]; - let config = make_config(patterns, RegexMode::Extended, false, false, false); - let matcher = Matcher::compile(black_box(&config)).unwrap(); - let _ = black_box(&matcher); - }) - }); - - group.bench_function("perl_regex", |b| { - b.iter(|| { - let patterns: &[&str] = &[r"\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}"]; - let config = make_config(patterns, RegexMode::Perl, false, false, false); - let matcher = Matcher::compile(black_box(&config)).unwrap(); - let _ = black_box(&matcher); - }) - }); - - group.bench_function("multiple_patterns", |b| { - b.iter(|| { - let patterns: &[&str] = &["error", "warning", "critical", "fatal", "panic"]; - let config = make_config(patterns, RegexMode::Fixed, false, false, false); - let matcher = Matcher::compile(black_box(&config)).unwrap(); - let _ = black_box(&matcher); - }) - }); - - group.finish(); -} - -fn bench_match(c: &mut Criterion) { - let mut group = c.benchmark_group("match"); - - // Fixed string match - hit - { - let patterns: &[&str] = &["ERROR"]; - let config = make_config(patterns, RegexMode::Fixed, false, false, false); - let matcher = Matcher::compile(&config).unwrap(); - let line = b"2024-01-15 10:30:45 ERROR: Connection timeout on server-42"; - - group.bench_function("fixed_string_hit", |b| { - b.iter(|| black_box(matcher.match_line(black_box(line)))) - }); - } - - // Fixed string match - miss - { - let patterns: &[&str] = &["CRITICAL"]; - let config = make_config(patterns, RegexMode::Fixed, false, false, false); - let matcher = Matcher::compile(&config).unwrap(); - let line = b"2024-01-15 10:30:45 INFO: Server started successfully"; - - group.bench_function("fixed_string_miss", |b| { - b.iter(|| black_box(matcher.match_line(black_box(line)))) - }); - } - - // Extended regex match - { - let patterns: &[&str] = &[r"[0-9]{4}-[0-9]{2}-[0-9]{2}"]; - let config = make_config(patterns, RegexMode::Extended, false, false, false); - let matcher = Matcher::compile(&config).unwrap(); - let line = b"2024-01-15 10:30:45 ERROR: Connection timeout"; - - group.bench_function("extended_regex_hit", |b| { - b.iter(|| black_box(matcher.match_line(black_box(line)))) - }); - } - - // Case-insensitive match - { - let patterns: &[&str] = &["error"]; - let config = make_config(patterns, RegexMode::Fixed, true, false, false); - let matcher = Matcher::compile(&config).unwrap(); - let line = b"2024-01-15 10:30:45 ERROR: Connection timeout"; - - group.bench_function("case_insensitive_hit", |b| { - b.iter(|| black_box(matcher.match_line(black_box(line)))) - }); - } - - // Inverted match - { - let patterns: &[&str] = &["ERROR"]; - let config = make_config(patterns, RegexMode::Fixed, false, true, false); - let matcher = Matcher::compile(&config).unwrap(); - let line = b"2024-01-15 10:30:45 INFO: Server started successfully"; - - group.bench_function("inverted_match", |b| { - b.iter(|| black_box(matcher.match_line(black_box(line)))) - }); - } - - // Word boundary match - { - let patterns: &[&str] = &["error"]; - let config = make_config(patterns, RegexMode::Fixed, true, false, true); - let matcher = Matcher::compile(&config).unwrap(); - let line = b"2024-01-15 10:30:45 error: Connection timeout"; - - group.bench_function("word_boundary_hit", |b| { - b.iter(|| black_box(matcher.match_line(black_box(line)))) - }); - } - - // Multiple patterns - { - let patterns: &[&str] = &["error", "warning", "critical", "fatal", "panic"]; - let config = make_config(patterns, RegexMode::Fixed, true, false, false); - let matcher = Matcher::compile(&config).unwrap(); - let line = b"2024-01-15 10:30:45 WARNING: High memory usage detected on node-7"; - - group.bench_function("multi_pattern_hit", |b| { - b.iter(|| black_box(matcher.match_line(black_box(line)))) - }); - } - - // Long line - { - let patterns: &[&str] = &["needle"]; - let config = make_config(patterns, RegexMode::Fixed, false, false, false); - let matcher = Matcher::compile(&config).unwrap(); - let mut long_line = "a".repeat(5000); - long_line.push_str("needle"); - long_line.push_str(&"b".repeat(5000)); - let long_line_bytes = long_line.into_bytes(); - - group.bench_function("long_line_hit", |b| { - b.iter(|| black_box(matcher.match_line(black_box(&long_line_bytes)))) - }); - } - - group.finish(); -} - -fn bench_throughput(c: &mut Criterion) { - let mut group = c.benchmark_group("throughput"); - - // Simulate processing many lines (like searching a log file) - let lines: Vec> = (0..1000) - .map(|i| { - if i % 50 == 0 { - format!( - "2024-01-15 10:30:{:02} ERROR: Connection timeout on server-{}", - i % 60, - i - ) - .into_bytes() - } else { - format!( - "2024-01-15 10:30:{:02} INFO: Request processed in {}ms", - i % 60, - i * 3 - ) - .into_bytes() - } - }) - .collect(); - - { - let patterns: &[&str] = &["ERROR"]; - let config = make_config(patterns, RegexMode::Fixed, false, false, false); - let matcher = Matcher::compile(&config).unwrap(); - - group.bench_function("scan_1000_lines_fixed", |b| { - b.iter(|| { - let mut matches = 0u64; - for line in &lines { - if matcher.match_line(black_box(line)).is_some() { - matches += 1; - } - } - black_box(matches) - }) - }); - } - - { - let patterns: &[&str] = &[r"[0-9]+ *ms"]; - let config = make_config(patterns, RegexMode::Extended, false, false, false); - let matcher = Matcher::compile(&config).unwrap(); - - group.bench_function("scan_1000_lines_regex", |b| { - b.iter(|| { - let mut matches = 0u64; - for line in &lines { - if matcher.match_line(black_box(line)).is_some() { - matches += 1; - } - } - black_box(matches) - }) - }); - } - - group.finish(); -} /// End-to-end search throughput, driven through the real `uumain` entry point /// so the whole pipeline (input buffering, searcher, output) is exercised. /// -/// `bench_match` / `bench_throughput` call `Matcher::match_line` on pre-split -/// lines, which measures matching in isolation. They cannot see a change to how -/// the *searcher* feeds data to the matcher (e.g. scanning whole buffers instead -/// of testing one line at a time), because they never run the searcher. These -/// cases do: a literal pattern (which a buffer-at-a-time engine can accelerate) -/// and an extended-regex control (which cannot), over a multi-megabyte file. +/// Matching a pattern against an already-split line in isolation cannot reveal +/// how the *searcher* feeds data to the matcher (e.g. scanning whole buffers +/// instead of testing one line at a time). These cases do: a literal pattern +/// (which a buffer-at-a-time engine can accelerate) and an extended-regex +/// control (which cannot), over a multi-megabyte file. fn bench_search(c: &mut Criterion) { use std::ffi::OsString; @@ -346,11 +66,5 @@ fn bench_search(c: &mut Criterion) { let _ = std::fs::remove_file(&path); } -criterion_group!( - benches, - bench_compile, - bench_match, - bench_throughput, - bench_search -); +criterion_group!(benches, bench_search); criterion_main!(benches); From 6e6db248f1a350e45fee4d1c1cf078e2c9c587e3 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 31 May 2026 11:10:11 +0200 Subject: [PATCH 08/15] bench: add e2e benchmarks for the grep tldr invocations Cover the real-world grep usage shapes from the tldr page end-to-end through uumain over a shared multi-MB corpus (plus a directory with a binary file for -rI): search pattern, -F fixed string, -rI recursive ignoring binary, -C 3 context, -Hn --color=always, -o only-matching, -v invert, -Ei extended + ignore-case. Kept alongside the pure-scan throughput benches (literal vs regex, no match). A rare marker keeps matched output small so the full-file scan dominates the timing. --- benches/grep_bench.rs | 140 +++++++++++++++++++++++++++++------------- 1 file changed, 99 insertions(+), 41 deletions(-) diff --git a/benches/grep_bench.rs b/benches/grep_bench.rs index 42c7597..2f1792a 100644 --- a/benches/grep_bench.rs +++ b/benches/grep_bench.rs @@ -4,22 +4,32 @@ // 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; -/// End-to-end search throughput, driven through the real `uumain` entry point -/// so the whole pipeline (input buffering, searcher, output) is exercised. -/// -/// Matching a pattern against an already-split line in isolation cannot reveal -/// how the *searcher* feeds data to the matcher (e.g. scanning whole buffers -/// instead of testing one line at a time). These cases do: a literal pattern -/// (which a buffer-at-a-time engine can accelerate) and an extended-regex -/// control (which cannot), over a multi-megabyte file. -fn bench_search(c: &mut Criterion) { - use std::ffi::OsString; +/// 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 = 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()); +} - // A log-like file large enough to cross many internal read buffers. +/// Build a multi-megabyte log-like corpus plus a directory holding it alongside +/// a binary file. Every line contains `worker-` 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 % 100 == 0 { + 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 @@ -34,37 +44,85 @@ fn bench_search(c: &mut Criterion) { } assert!(content.len() > 4 * 1024 * 1024); - let mut path = std::env::temp_dir(); - path.push(format!("uu_grep_bench_{}.log", std::process::id())); - std::fs::write(&path, &content).unwrap(); - let path_arg = path.clone().into_os_string(); + 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(); - // `-q` with a pattern that never matches forces a full scan of the file and - // produces no output, so the timing reflects pure scanning throughput. - let run = |extra_flag: Option<&str>, pattern: &str| { - let mut args: Vec = vec![OsString::from("grep"), OsString::from("-q")]; - if let Some(flag) = extra_flag { - args.push(OsString::from(flag)); - } - args.push(OsString::from(pattern)); - args.push(path_arg.clone()); - // No match => Err(exit code 1); we only care about the work, not status. - let _ = uu_grep::uumain(args.into_iter()); - }; + // 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(); - let mut group = c.benchmark_group("search"); - - group.bench_function("scan_literal_no_match", |b| { - b.iter(|| run(None, black_box("NONEXISTENT_TOKEN_XYZ"))) - }); - - group.bench_function("scan_regex_no_match", |b| { - b.iter(|| run(Some("-E"), black_box("NON[0-9]EXISTENT_TOKEN"))) - }); - - group.finish(); - let _ = std::fs::remove_file(&path); + (dir, log) } -criterion_group!(benches, bench_search); +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); From bc416c6d8b0e363e4bcb3f3b0fefaf0855bb4885 Mon Sep 17 00:00:00 2001 From: oech3 <79379754+oech3@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:55:55 +0900 Subject: [PATCH 09/15] GnuTests: publish binary from main --- .github/workflows/GnuTests.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/GnuTests.yml b/.github/workflows/GnuTests.yml index 5f5913e..63989bd 100644 --- a/.github/workflows/GnuTests.yml +++ b/.github/workflows/GnuTests.yml @@ -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 From 4e6823a8b39564069fe2ab8a80a8a85aecb1815a Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Wed, 3 Jun 2026 17:56:23 +0200 Subject: [PATCH 10/15] Add installation section to README (#29) --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 524e6d7..9dff846 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,12 @@ 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/ From f4798cb6d04917e2da7f8d2fd5a454ad21196841 Mon Sep 17 00:00:00 2001 From: rifatx Date: Thu, 4 Jun 2026 23:14:12 +0300 Subject: [PATCH 11/15] fix -l and -L to be mutually exclusive so that last one wins (#30) --- src/lib.rs | 6 ++++-- tests/test_grep.rs | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index df2b56a..0f2b63a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -707,14 +707,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") diff --git a/tests/test_grep.rs b/tests/test_grep.rs index 5e487ea..d061918 100644 --- a/tests/test_grep.rs +++ b/tests/test_grep.rs @@ -460,6 +460,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(); From 28186e9ec3867c70cbdaa6988fbb9e24b95a2195 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 31 May 2026 10:06:09 +0200 Subject: [PATCH 12/15] 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). --- src/lib.rs | 2 +- src/line_buffer.rs | 191 +++++++++++++++++++++++++++++++++++- src/matcher.rs | 99 ++++++++++++++++++- src/searcher.rs | 240 ++++++++++++++++++++++++++++++++++++++++++++- tests/test_grep.rs | 67 +++++++++++++ 5 files changed, 595 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 0f2b63a..e34bd0a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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, diff --git a/src/line_buffer.rs b/src/line_buffer.rs index 54e7057..51ee4da 100644 --- a/src/line_buffer.rs +++ b/src/line_buffer.rs @@ -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> { + 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, 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 = got.iter().flat_map(|(b, _)| b.clone()).collect(); + assert_eq!(joined, content); + assert_eq!(got[0].1, 0); + } } diff --git a/src/matcher.rs b/src/matcher.rs index d9cf846..604259c 100644 --- a/src/matcher.rs +++ b/src/matcher.rs @@ -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, + /// 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>>, } impl<'a> Matcher<'a> { @@ -22,7 +29,32 @@ 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>> = 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. @@ -194,6 +226,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> { + 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, @@ -289,3 +340,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> { + 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); + } +} diff --git a/src/searcher.rs b/src/searcher.rs index c63c826..5da5936 100644 --- a/src/searcher.rs +++ b/src/searcher.rs @@ -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 { + 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 { + 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 { + 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 { + 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; @@ -470,3 +680,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 { + let mut best: Option = 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) +} diff --git a/tests/test_grep.rs b/tests/test_grep.rs index d061918..87bc292 100644 --- a/tests/test_grep.rs +++ b/tests/test_grep.rs @@ -1272,3 +1272,70 @@ 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); +} From 56d774f576bd8e3a04027d555b9cdfc471cb923f Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 31 May 2026 11:41:16 +0200 Subject: [PATCH 13/15] 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. --- tests/test_grep.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/tests/test_grep.rs b/tests/test_grep.rs index 87bc292..560dc12 100644 --- a/tests/test_grep.rs +++ b/tests/test_grep.rs @@ -1339,3 +1339,103 @@ fn literal_buffer_path_spans_many_chunks() { .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"); +} From e3d80f59e29eaea8defae0fa7493da5bbfbef21c Mon Sep 17 00:00:00 2001 From: Kanishk Sachan Date: Fri, 5 Jun 2026 01:25:22 +0100 Subject: [PATCH 14/15] 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 --- src/lib.rs | 8 ++++++++ tests/test_grep.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 0f2b63a..af06840 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 diff --git a/tests/test_grep.rs b/tests/test_grep.rs index d061918..e9665ee 100644 --- a/tests/test_grep.rs +++ b/tests/test_grep.rs @@ -170,6 +170,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(); From ad7595ffe64a88a7422a8100a79f0ffe7ada871e Mon Sep 17 00:00:00 2001 From: Wondr Date: Fri, 5 Jun 2026 04:39:22 +0100 Subject: [PATCH 15/15] grep: select zero-width matches under -w and -x --- src/matcher.rs | 17 ++++++++++------- tests/test_grep.rs | 12 ++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/matcher.rs b/src/matcher.rs index d9cf846..49f2a27 100644 --- a/src/matcher.rs +++ b/src/matcher.rs @@ -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> { 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; } diff --git a/tests/test_grep.rs b/tests/test_grep.rs index d061918..ef53858 100644 --- a/tests/test_grep.rs +++ b/tests/test_grep.rs @@ -251,6 +251,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 +266,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]