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

Also drop pipe_in() from the compile-error tests added in the previous
commits: those patterns are rejected before stdin is read, so feeding
input raced with the child exiting and intermittently panicked the test
harness with a broken pipe under parallel execution.
2026-05-30 18:02:32 +02:00
Sylvestre Ledru 9c21a7d2f0 grep: handle regex backtracking-limit instead of panicking
A pathological pattern such as -P '((a+)*)+$' makes oniguruma exceed its
match retry limit. The onig crate's search_with_encoding/match_with_encoding
panic on that error, so uu_grep aborted with a Rust panic. Switch to the
fallible *_with_param variants and propagate the error: match_line/is_match
now return io::Result, the failure is mapped to GNU's wording ('exceeded
PCRE's backtracking limit') and surfaces as a normal exit-code-2 diagnostic.
Fixes the GNU testsuite 'pcre-abort' test.
2026-05-30 17:55:31 +02:00
Sylvestre Ledru e767a30c1a grep: emit GNU's 'Invalid range end' for reversed bracket ranges
A reversed range like [b-a] makes oniguruma fail with 'empty range in
char class', which uu_grep wrapped as 'invalid pattern "[b-a]": ...'.
GNU grep instead prints the bare POSIX diagnostic 'Invalid range end'
and exits 2. Translate the oniguruma error code
(ONIGERR_EMPTY_RANGE_IN_CHAR_CLASS) to GNU's wording, leaving other
compile errors to fall back to oniguruma's text. Fixes the GNU testsuite
'reversed-range-endpoints' test.
2026-05-30 17:50:02 +02:00
Sylvestre Ledru 1a3e8a391d grep: reject confusing [:name:] bracket syntax like GNU
GNU grep flags a bracket expression of the form [:name:] (an almost
certain misspelling of [[:name:]]) with a dedicated diagnostic and exit
code 2, whereas oniguruma silently treats it as the character set
{':','n','a','m','e'}. Port GNU's colon_warning_state logic from
parse_bracket_exp (gnulib dfa.c) so basic/extended patterns produce the
same error. Fixes the GNU testsuite 'warn-char-classes' test.
2026-05-30 17:39:29 +02:00
Sylvestre LedruandGitHub 98e6bb6f53 Merge pull request #10 from uutils/improv-cov
Improve the code coverage
2026-05-30 14:25:50 +02:00
Sylvestre Ledru da0ada8a37 test: use platform-specific expected message for nonexistent-file error 2026-05-30 11:33:47 +02:00
Sylvestre Ledru fcf46d8f56 test: cover -D skip on an explicit special-file argument 2026-05-30 11:28:04 +02:00
Sylvestre Ledru 55cb643545 test: cover strip_dot_prefix for implicit-cwd recursive search 2026-05-30 11:28:04 +02:00
Sylvestre Ledru 89aec4a45a test: cover -T line-number width on the file (not stdin) path 2026-05-30 11:28:03 +02:00
Sylvestre Ledru 6c5b7dc9e3 test: cover -L listing when the pattern matches one file 2026-05-30 11:28:03 +02:00
Sylvestre LedruandGitHub ff918e4a63 grep: strip trailing (os error N) from file error messages to match GNU (#6) 2026-05-29 22:24:20 +02:00
Sylvestre LedruandGitHub c50c0458cb fix: accept repeated options like GNU grep (#2)
GNU grep tolerates options given more than once (boolean flags are
idempotent, value options take the last occurrence), but clap rejected
them with "cannot be used multiple times" (exit 2). Set
args_override_self(true) so clap replaces rather than errors; args with
ArgAction::Append (-e/-f/--include/--exclude) still accumulate.

Found by the differential fuzzer (e.g. `grep -e .* -E -n -o -n -x`).

Add a regression test covering repeated booleans, repeated value options
(last wins), and that -e still accumulates patterns.
2026-05-29 22:21:27 +02:00
Sylvestre LedruandGitHub f7813500c9 Merge pull request #8 from uutils/gnu-test
run the gnu testsuite in the ci
2026-05-29 21:43:41 +02:00
Leonard Hecker 05d167b8d5 Fix support for Python-style named backreferences 2026-05-29 21:12:19 +02:00
Sylvestre LedruandGitHub 41b92b3cc1 Merge pull request #5 from uutils/fix-coverage-profraw-path
ci: use absolute LLVM_PROFILE_FILE path to fix 0% coverage
2026-05-29 20:46:10 +02:00
Sylvestre Ledru e885f66523 ci: post GNU grep testsuite comparison as a PR comment
Add a GnuComment workflow that runs after GnuTests completes on a pull
request, downloads the 'comment' artifact (PR number + comparison text), and
posts it as a PR comment. Mirrors ../sed's GnuComment workflow.
2026-05-29 18:43:11 +02:00
Sylvestre Ledru 10bfa405dc ci: add GnuTests workflow running the GNU grep testsuite
Add a GnuTests workflow that, on push and PR, fetches the GNU grep release
tarball, builds the Rust grep binary, runs util/run-gnu-testsuite.sh, and
uploads the JSON results. An aggregate job compares the run against the
reference summary from the default branch (util/compare_test_results.py,
borrowed from ../sed) and fails only on new, non-intermittent regressions;
known-flaky tests are listed in .github/workflows/ignore-intermittent.txt.
2026-05-29 18:43:11 +02:00
Sylvestre Ledru f0ae449d11 tests: run the GNU grep testsuite against uu_grep
Add util/fetch-gnu.sh (downloads the GNU grep 3.12 release tarball from
ftp.gnu.org) and util/run-gnu-testsuite.sh, which reuses the gnulib test
framework shipped in the tarball (tests/init.sh + init.cfg) and injects the
Rust grep binary via PATH, replicating tests/Makefile.am's TESTS_ENVIRONMENT.
Each test is classified by its gnulib exit code (0=PASS, 77=SKIP, else FAIL)
and results are emitted as JSON.

Modelled on ../sed (lightweight PATH-injection runner) and ../coreutils
(release-tarball fetch). Current baseline: 61 pass / 39 fail / 28 skip of 128
tests -- the failures quantify the remaining GNU-compatibility gap.
2026-05-29 18:43:11 +02:00
Sylvestre Ledru 79db36edbe add license headers 2026-05-29 10:12:38 +02:00
14 changed files with 1426 additions and 81 deletions
+83
View File
@@ -0,0 +1,83 @@
name: GnuComment
# Post the GNU grep testsuite comparison (produced by the GnuTests workflow)
# as a comment on the pull request.
on:
workflow_run:
workflows: ["GnuTests"]
types:
- completed
permissions: {}
jobs:
post-comment:
permissions:
actions: read # to list workflow run artifacts
pull-requests: write # to comment on the pr
runs-on: ubuntu-latest
if: >
github.event.workflow_run.event == 'pull_request'
steps:
- name: 'Download artifact'
uses: actions/github-script@v7
with:
script: |
// List all artifacts from GnuTests
var artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
// Download the "comment" artifact, which contains a PR number (NR) and result.txt
var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
return artifact.name == "comment"
})[0];
if (!matchArtifact) {
console.log('No comment artifact found');
return;
}
var download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
var fs = require('fs');
fs.writeFileSync('${{ github.workspace }}/comment.zip', Buffer.from(download.data));
- run: unzip comment.zip || echo "Failed to unzip comment artifact"
- name: 'Comment on PR'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
var fs = require('fs');
// Check if files exist
if (!fs.existsSync('./NR')) {
console.log('No NR file found, skipping comment');
return;
}
if (!fs.existsSync('./result.txt')) {
console.log('No result.txt file found, skipping comment');
return;
}
var issue_number = Number(fs.readFileSync('./NR'));
var content = fs.readFileSync('./result.txt');
if (content.toString().trim().length > 7) { // 7 because we have backquote + \n
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue_number,
body: 'GNU grep testsuite comparison:\n```\n' + content + '```'
});
} else {
console.log('Comment content too short, skipping');
}
+180
View File
@@ -0,0 +1,180 @@
name: GnuTests
# Run the upstream GNU grep testsuite against the Rust grep implementation to
# track and guard byte-for-byte compatibility. See util/run-gnu-testsuite.sh.
on:
pull_request:
push:
branches:
- '*'
# End the current execution if there is a new changeset in the PR.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEST_FULL_SUMMARY_FILE: 'grep-gnu-full-result.json'
jobs:
native:
name: Run GNU grep testsuite
runs-on: ubuntu-24.04
steps:
- name: Checkout code (grep)
uses: actions/checkout@v4
with:
path: 'grep'
persist-credentials: false
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: "./grep -> target"
- name: Fetch GNU grep testsuite
shell: bash
run: |
## Download and extract the upstream GNU grep release tarball
mkdir -p gnu.grep
cd gnu.grep
bash ../grep/util/fetch-gnu.sh
- name: Build Rust grep binary
shell: bash
run: |
cd 'grep'
cargo build --release
- name: Run GNU grep testsuite
shell: bash
run: |
cd 'grep'
export GNU_GREP_DIR="../gnu.grep"
./util/run-gnu-testsuite.sh --json-output "${{ env.TEST_FULL_SUMMARY_FILE }}" || true
- name: Upload full json results
uses: actions/upload-artifact@v4
with:
name: grep-gnu-full-result
path: grep/${{ env.TEST_FULL_SUMMARY_FILE }}
if-no-files-found: warn
aggregate:
needs: [native]
permissions:
actions: read
contents: read
pull-requests: read
name: Aggregate GNU test results
runs-on: ubuntu-24.04
steps:
- name: Initialize workflow variables
id: vars
shell: bash
run: |
## VARs setup
outputs() { step_id="${{ github.action }}"; for var in "$@" ; do echo steps.${step_id}.outputs.${var}="${!var}"; echo "${var}=${!var}" >> $GITHUB_OUTPUT; done; }
TEST_SUMMARY_FILE='grep-gnu-result.json'
outputs TEST_SUMMARY_FILE
- name: Checkout code (grep)
uses: actions/checkout@v4
with:
path: 'grep'
persist-credentials: false
- name: Retrieve reference artifacts
uses: dawidd6/action-download-artifact@v6
continue-on-error: true
with:
workflow: GnuTests.yml
branch: "${{ env.DEFAULT_BRANCH }}"
workflow_conclusion: completed
path: "reference"
if_no_artifact_found: warn
- name: Download full json results
uses: actions/download-artifact@v4
with:
name: grep-gnu-full-result
path: results
- name: Extract/summarize testing info
id: summary
shell: bash
run: |
## Extract/summarize testing info
outputs() { step_id="${{ github.action }}"; for var in "$@" ; do echo steps.${step_id}.outputs.${var}="${!var}"; echo "${var}=${!var}" >> $GITHUB_OUTPUT; done; }
RESULT_FILE="results/${{ env.TEST_FULL_SUMMARY_FILE }}"
if [[ ! -f "$RESULT_FILE" ]]; then
echo "::error ::Result file $RESULT_FILE not found"
find results -type f || true
exit 1
fi
TOTAL=$(jq -r '.summary.total // 0' "$RESULT_FILE")
PASS=$(jq -r '.summary.passed // 0' "$RESULT_FILE")
FAIL=$(jq -r '.summary.failed // 0' "$RESULT_FILE")
SKIP=$(jq -r '.summary.skipped // 0' "$RESULT_FILE")
output="GNU grep tests summary = TOTAL: $TOTAL / PASS: $PASS / FAIL: $FAIL / SKIP: $SKIP"
echo "${output}"
if [[ "$FAIL" -gt 0 ]]; then
echo "::warning ::${output}"
fi
outputs TOTAL PASS FAIL SKIP
- name: Compare test failures VS reference
shell: bash
run: |
## Compare current results against the reference summary from the default branch
REF_SUMMARY_FILE='reference/grep-gnu-full-result/${{ env.TEST_FULL_SUMMARY_FILE }}'
CURRENT_SUMMARY_FILE="results/${{ env.TEST_FULL_SUMMARY_FILE }}"
IGNORE_INTERMITTENT="grep/.github/workflows/ignore-intermittent.txt"
# Set up comment directory for the GnuComment workflow.
COMMENT_DIR="reference/comment"
mkdir -p ${COMMENT_DIR}
echo ${{ github.event.number }} > ${COMMENT_DIR}/NR
COMMENT_LOG="${COMMENT_DIR}/result.txt"
: > "${COMMENT_LOG}"
COMPARISON_RESULT=0
if test -f "${REF_SUMMARY_FILE}"; then
python3 grep/util/compare_test_results.py \
--ignore-file "${IGNORE_INTERMITTENT}" \
--output "${COMMENT_LOG}" \
"${CURRENT_SUMMARY_FILE}" "${REF_SUMMARY_FILE}" || COMPARISON_RESULT=$?
else
echo "::warning ::Skipping test comparison; no prior reference summary at '${REF_SUMMARY_FILE}'."
fi
if [ ${COMPARISON_RESULT} -eq 1 ]; then
echo "::error ::Found new non-intermittent test failures"
UPLOAD_EXIT=1
else
echo "::notice ::No new test failures detected"
UPLOAD_EXIT=0
fi
echo "UPLOAD_EXIT=${UPLOAD_EXIT}" >> $GITHUB_ENV
- name: Upload comparison log (for GnuComment workflow)
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: comment
path: reference/comment/
- name: Report test results
if: success() || failure()
shell: bash
run: |
echo "::notice ::GNU grep testsuite: TOTAL ${{ steps.summary.outputs.TOTAL }} / PASS ${{ steps.summary.outputs.PASS }} / FAIL ${{ steps.summary.outputs.FAIL }} / SKIP ${{ steps.summary.outputs.SKIP }}"
# Fail the job if the comparison found new non-intermittent regressions.
exit "${UPLOAD_EXIT:-0}"
@@ -0,0 +1,7 @@
# List of intermittent test names to ignore in result comparisons
# Format: one test name per line, lines starting with # are comments
#
# Add test names that are known to be flaky or environment-dependent
# Example:
# basic_substitution
# line_address_test
+5
View File
@@ -1,3 +1,8 @@
// 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.
pub struct LineView<'a> {
/// Line content (without the terminator).
pub line: &'a [u8],
+21
View File
@@ -1,3 +1,8 @@
// 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.
mod context_buffer;
mod line_buffer;
mod matcher;
@@ -337,6 +342,13 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
ColorMode::Never => false,
ColorMode::Auto => std::io::stdout().is_terminal(),
};
// GREP_COLOR is deprecated in favour of GREP_COLORS' `mt` capability;
// GNU warns about it, but only when color output is actually produced.
if use_color && !grep_color.is_empty() {
eprintln!(
"grep: warning: GREP_COLOR='{grep_color}' is deprecated; use GREP_COLORS='mt={grep_color}'"
);
}
let color_config = ColorConfig::from_env(&grep_color, &grep_colors);
let config = Config {
@@ -416,6 +428,10 @@ pub fn uu_app() -> Command {
.about("Search for PATTERNS in each FILE.")
.disable_help_flag(true)
.disable_version_flag(true)
// GNU grep accepts repeated options (booleans are idempotent, value
// options take the last); make clap replace rather than error. Args
// with ArgAction::Append (e.g. -e/-f/--include) still accumulate.
.args_override_self(true)
.after_help(
"When FILE is '-', read standard input. If no FILE is given, read standard \
input, but with -r, recursively search the working directory instead. With \
@@ -894,6 +910,11 @@ impl<'a> ColorConfig<'a> {
for item in grep_colors.split(':') {
if let Some((key, value)) = item.split_once('=') {
match key {
// `mt` sets both the selected- and context-match colors.
"mt" => {
config.matched_selected = value;
config.matched_context = value;
}
"ms" => config.matched_selected = value,
"mc" => config.matched_context = value,
"fn" => config.filename = value,
+5
View File
@@ -1,3 +1,8 @@
// 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 memchr::memchr;
use std::fs::File;
use std::io::{self, Read as _};
+5
View File
@@ -1 +1,6 @@
// 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.
uucore::bin!(uu_grep);
+300 -77
View File
@@ -1,6 +1,18 @@
// 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 crate::{Config, RegexMode};
use onig::{EncodedBytes, Regex, RegexOptions, Region, SearchOptions, Syntax, SyntaxBehavior};
use onig_sys::{OnigEncCtype_ONIGENC_CTYPE_WORD, OnigEncodingUTF8};
use onig::{
EncodedBytes, Error, MatchParam, Regex, RegexOptions, Region, SearchOptions, Syntax,
SyntaxBehavior, SyntaxOperator,
};
use onig_sys::{
ONIGERR_EMPTY_RANGE_IN_CHAR_CLASS, ONIGERR_INVALID_BACKREF, ONIGERR_RETRY_LIMIT_IN_MATCH_OVER,
ONIGERR_RETRY_LIMIT_IN_SEARCH_OVER, OnigEncCtype_ONIGENC_CTYPE_WORD, OnigEncodingUTF8,
};
use std::io;
use uucore::error::{UResult, USimpleError};
pub struct Matcher<'a> {
@@ -18,26 +30,30 @@ impl<'a> Matcher<'a> {
}
/// Decide whether `line` matches and return the positions to highlight.
pub fn match_line(&self, line: &[u8]) -> Option<Vec<(usize, usize)>> {
///
/// Returns an error if the regex engine bails out (e.g. it exceeds its
/// backtracking retry limit on a pathological pattern); the caller turns
/// that into a GNU-style diagnostic and exit code 2 rather than aborting.
pub fn match_line(&self, line: &[u8]) -> io::Result<Option<Vec<(usize, usize)>>> {
let mut any_seen = 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;
}
// Drop matches that aren't word matches if `-w` was requested.
if self.config.word_regexp && !Self::is_word_match(line, start, end) {
return false;
}
true
})
.collect();
let mut positions = Vec::new();
let mut iter = MatchIter::new(&self.patterns, line).map_err(match_error)?;
while let Some((start, end)) = iter.next_match().map_err(match_error)? {
any_seen = true;
// Drop zero-length matches from the output.
if start == end {
continue;
}
// Drop matches that don't span the whole line if `-x` was requested.
if self.config.line_regexp && !(start == 0 && end == line.len()) {
continue;
}
// Drop matches that aren't word matches if `-w` was requested.
if self.config.word_regexp && !Self::is_word_match(line, start, end) {
continue;
}
positions.push((start, end));
}
let raw_matched = if self.config.line_regexp || self.config.word_regexp {
// -w / -x are authoritative once positions are filtered.
@@ -46,23 +62,25 @@ impl<'a> Matcher<'a> {
any_seen
};
if raw_matched != self.config.invert_match {
Some(positions)
} else {
None
}
Ok((raw_matched != self.config.invert_match).then_some(positions))
}
/// Cheap match check that doesn't enumerate positions.
pub fn is_match(&self, line: &[u8]) -> Option<Vec<(usize, usize)>> {
pub fn is_match(&self, line: &[u8]) -> io::Result<Option<Vec<(usize, usize)>>> {
// `-w` / `-x` need positions to filter, so we fall back to `match_line`.
let matched = if self.config.line_regexp || self.config.word_regexp {
self.match_line(line).is_some()
self.match_line(line)?.is_some()
} else {
let raw_matched = self.patterns.iter().any(|p| p.is_match(line));
let mut raw_matched = false;
for p in &self.patterns {
if p.is_match(line).map_err(match_error)? {
raw_matched = true;
break;
}
}
raw_matched != self.config.invert_match
};
matched.then(Vec::new)
Ok(matched.then(Vec::new))
}
/// Word-boundary check `-w`.
@@ -104,35 +122,31 @@ struct MatchIter<'a> {
}
impl<'a> MatchIter<'a> {
fn new(patterns: &'a [CompiledPattern], line: &'a [u8]) -> Self {
Self {
cursors: patterns
.iter()
.map(|pattern| {
let mut c = Cursor {
pattern,
line,
offset: 0,
pending: None,
};
c.refill();
c
})
.collect(),
last_end: 0,
fn new(patterns: &'a [CompiledPattern], line: &'a [u8]) -> Result<Self, Error> {
let mut cursors = Vec::with_capacity(patterns.len());
for pattern in patterns {
let mut c = Cursor {
pattern,
line,
offset: 0,
pending: None,
};
c.refill()?;
cursors.push(c);
}
Ok(Self {
cursors,
last_end: 0,
})
}
}
impl<'a> Iterator for MatchIter<'a> {
type Item = (usize, usize);
fn next(&mut self) -> Option<Self::Item> {
/// Yield the next match across all patterns, or `None` when exhausted.
fn next_match(&mut self) -> Result<Option<(usize, usize)>, Error> {
// Discard stale pendings that fall before the last emit.
for cursor in &mut self.cursors {
if matches!(cursor.pending, Some((s, _)) if s < self.last_end) {
cursor.offset = self.last_end;
cursor.refill();
cursor.refill()?;
}
}
@@ -145,12 +159,15 @@ impl<'a> Iterator for MatchIter<'a> {
.enumerate()
.filter_map(|(i, c)| c.pending.map(|p| (i, p)))
.min_by_key(|&(_, (s, e))| (s, std::cmp::Reverse(e)))
.map(|(i, _)| i)?;
.map(|(i, _)| i);
let Some(best_idx) = best_idx else {
return Ok(None);
};
let (start, end) = self.cursors[best_idx].pending.unwrap();
self.cursors[best_idx].refill();
self.cursors[best_idx].refill()?;
self.last_end = end;
Some((start, end))
Ok(Some((start, end)))
}
}
@@ -165,24 +182,25 @@ struct Cursor<'a> {
}
impl Cursor<'_> {
fn refill(&mut self) {
fn refill(&mut self) -> Result<(), Error> {
if self.offset >= self.line.len() {
self.pending = None;
return;
return Ok(());
}
let Some((start, leftmost_end)) = self.pattern.search_leftmost(self.line, self.offset)
let Some((start, leftmost_end)) = self.pattern.search_leftmost(self.line, self.offset)?
else {
self.pending = None;
return;
return Ok(());
};
let end = self
.pattern
.longest_end_at(self.line, start)
.longest_end_at(self.line, start)?
.unwrap_or(leftmost_end);
// Advance the next search past the match we just found.
// Zero-length matches need a +1 nudge to avoid spinning forever.
self.offset = end.max(start + 1);
self.pending = Some((start, end));
Ok(())
}
}
@@ -197,32 +215,62 @@ struct CompiledPattern {
impl CompiledPattern {
fn compile(pattern: &str, config: &Config) -> UResult<Self> {
// GNU grep rejects the confusing `[:name:]` bracket form (a misspelled
// `[[:name:]]`) in basic/extended modes; oniguruma accepts it silently.
if matches!(config.regex_mode, RegexMode::Basic | RegexMode::Extended) {
check_confusing_bracket(pattern)?;
}
let mut syntax = *match config.regex_mode {
RegexMode::Fixed => Syntax::asis(),
RegexMode::Basic => Syntax::grep(),
RegexMode::Extended => Syntax::gnu_regex(),
RegexMode::Perl => Syntax::perl(),
RegexMode::Perl => Syntax::perl_ng(),
};
if !matches!(config.regex_mode, RegexMode::Fixed) {
if config.regex_mode != RegexMode::Fixed {
// GNU grep supports `{,n}` as an alias for `{0,n}`.
syntax.enable_behavior(SyntaxBehavior::SYNTAX_BEHAVIOR_ALLOW_INTERVAL_LOW_ABBREV);
}
if config.regex_mode == RegexMode::Perl {
// GNU grep supports `(?P<name>...)`.
// Unfortunately, the onig crate defines the OP2 flag without the
// necessary <<32 bit shift, so we need to hotpatch that here.
const _: () =
assert!(SyntaxOperator::SYNTAX_OPERATOR_QMARK_CAPITAL_P_NAME.bits() == 0x80000000);
const FIXED: SyntaxOperator = SyntaxOperator::from_bits_retain(
SyntaxOperator::SYNTAX_OPERATOR_QMARK_CAPITAL_P_NAME.bits() << 32,
);
syntax.enable_operators(FIXED);
}
let mut options = RegexOptions::REGEX_OPTION_NONE;
if config.ignore_case {
options |= RegexOptions::REGEX_OPTION_IGNORECASE;
}
fn compile_with(pattern: &str, syntax: &Syntax, options: RegexOptions) -> UResult<Regex> {
let mode = config.regex_mode;
fn compile_with(
pattern: &str,
syntax: &Syntax,
options: RegexOptions,
mode: RegexMode,
) -> UResult<Regex> {
Regex::with_options_and_encoding(pattern, options, syntax).map_err(|err| {
USimpleError::new(2, format!("invalid pattern \"{pattern}\": {err}"))
// Prefer GNU grep's wording for the errors it has a dedicated
// message for; fall back to oniguruma's text otherwise.
match gnu_error_message(err.code(), mode) {
Some(msg) => USimpleError::new(2, msg.to_string()),
None => USimpleError::new(2, format!("invalid pattern \"{pattern}\": {err}")),
}
})
}
let leftmost = compile_with(pattern, &syntax, options)?;
let leftmost = compile_with(pattern, &syntax, options, mode)?;
let longest_anchored = compile_with(
pattern,
&syntax,
options | RegexOptions::REGEX_OPTION_FIND_LONGEST,
mode,
)?;
Ok(Self {
leftmost,
@@ -231,41 +279,216 @@ impl CompiledPattern {
}
/// Find the leftmost match starting at or after `offset`.
fn search_leftmost(&self, line: &[u8], offset: usize) -> Option<(usize, usize)> {
fn search_leftmost(&self, line: &[u8], offset: usize) -> Result<Option<(usize, usize)>, Error> {
let mut region = Region::new();
self.leftmost.search_with_encoding(
let found = self.leftmost.search_with_param(
EncodedBytes::from_parts(line, &raw mut OnigEncodingUTF8),
offset,
line.len(),
SearchOptions::SEARCH_OPTION_NONE,
Some(&mut region),
MatchParam::default(),
)?;
region.pos(0)
Ok(found.and_then(|_| region.pos(0)))
}
/// Given a known leftmost start `start`, return the longest extent
/// of a match anchored exactly there = POSIX leftmost-longest end.
fn longest_end_at(&self, line: &[u8], start: usize) -> Option<usize> {
fn longest_end_at(&self, line: &[u8], start: usize) -> Result<Option<usize>, Error> {
let mut region = Region::new();
self.longest_anchored.match_with_encoding(
self.longest_anchored.match_with_param(
EncodedBytes::from_parts(line, &raw mut OnigEncodingUTF8),
start,
SearchOptions::SEARCH_OPTION_NONE,
Some(&mut region),
);
region.pos(0).map(|(_, end)| end)
MatchParam::default(),
)?;
Ok(region.pos(0).map(|(_, end)| end))
}
/// True if any match exists in `line` (including zero-length).
fn is_match(&self, line: &[u8]) -> bool {
self.leftmost
.search_with_encoding(
fn is_match(&self, line: &[u8]) -> Result<bool, Error> {
Ok(self
.leftmost
.search_with_param(
EncodedBytes::from_parts(line, &raw mut OnigEncodingUTF8),
0,
line.len(),
SearchOptions::SEARCH_OPTION_NONE,
None,
)
.is_some()
MatchParam::default(),
)?
.is_some())
}
}
/// Convert a regex-engine match-time error into an I/O error carrying GNU
/// grep's wording. The only error we expect in practice is the backtracking
/// retry limit being exceeded on a pathological pattern; GNU reports this as
/// `exceeded PCRE's backtracking limit` and exits 2 instead of aborting.
fn match_error(err: Error) -> io::Error {
let message = if matches!(
err.code(),
ONIGERR_RETRY_LIMIT_IN_MATCH_OVER | ONIGERR_RETRY_LIMIT_IN_SEARCH_OVER
) {
"exceeded PCRE's backtracking limit".to_string()
} else {
err.description().to_string()
};
io::Error::other(message)
}
/// Map an oniguruma compile-error code to GNU grep's wording for the same
/// condition, when one exists. GNU emits a bare POSIX-style diagnostic (e.g.
/// `Invalid range end`) rather than oniguruma's phrasing, so translating keeps
/// us byte-compatible. Returns `None` for errors with no GNU equivalent, where
/// the caller falls back to oniguruma's own message.
fn gnu_error_message(code: i32, mode: RegexMode) -> Option<&'static str> {
match code {
// e.g. `[b-a]`: a range whose end precedes its start.
ONIGERR_EMPTY_RANGE_IN_CHAR_CLASS => Some("Invalid range end"),
// e.g. `(.)\2`: a back-reference to a group that does not exist. GNU
// (via PCRE2) and gnulib's regex word this differently.
ONIGERR_INVALID_BACKREF if mode == RegexMode::Perl => {
Some("reference to non-existent subpattern")
}
ONIGERR_INVALID_BACKREF => Some("Invalid back reference"),
_ => None,
}
}
/// Reject the confusing `[:name:]` bracket form the way GNU grep does.
///
/// A bracket expression like `[:space:]` is almost always a misspelled
/// `[[:space:]]`; GNU grep flags it with a dedicated diagnostic and exits 2,
/// whereas oniguruma silently treats it as the set `{':','s','p',…}`. This
/// scans the pattern for that form and returns the same error.
fn check_confusing_bracket(pattern: &str) -> UResult<()> {
let bytes = pattern.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
// Outside a bracket a backslash escapes the next character, so
// `\[` does not open a bracket expression.
b'\\' => i += 2,
b'[' => {
i += 1;
if bracket_warns(bytes, &mut i) {
return Err(USimpleError::new(
2,
"character class syntax is [[:space:]], not [:space:]".to_string(),
));
}
}
_ => i += 1,
}
}
Ok(())
}
/// Consume a single bracket expression starting just past its opening `[` and
/// report whether GNU grep's colon warning fires for it.
///
/// This is a faithful port of the `colon_warning_state` logic in GNU grep's
/// `parse_bracket_exp` (gnulib `dfa.c`). The state is a bitmask:
/// bit 0 — first character is a colon
/// bit 1 — last character is a colon
/// bit 2 — includes some other (non-colon) character
/// bit 3 — includes a range, char/equivalence class, or collating element
/// The warning fires exactly when the state ends equal to `7` (bits 0–2 set,
/// bit 3 clear). On the way it advances `i` past the closing `]`.
fn bracket_warns(bytes: &[u8], i: &mut usize) -> bool {
fn fetch(bytes: &[u8], i: &mut usize) -> Option<u8> {
let b = bytes.get(*i).copied();
if b.is_some() {
*i += 1;
}
b
}
let Some(first) = fetch(bytes, i) else {
return false;
};
let mut c = first;
if c == b'^' {
match fetch(bytes, i) {
Some(x) => c = x,
None => return false,
}
}
let mut state: u8 = u8::from(c == b':');
'scan: loop {
state &= !2;
let mut c1: Option<u8> = None;
if c == b'[' {
let Some(nc1) = fetch(bytes, i) else {
return false;
};
// `[:`, `[.` and `[=` introduce a class / collating / equivalence
// element; consume it whole and mark bit 3.
if nc1 == b':' || nc1 == b'.' || nc1 == b'=' {
loop {
match fetch(bytes, i) {
None => break,
Some(cc) if cc == nc1 && bytes.get(*i).copied() == Some(b']') => break,
Some(_) => {}
}
}
if fetch(bytes, i).is_none() {
return false; // consumes the `]`
}
state |= 8;
match fetch(bytes, i) {
Some(b']') => break 'scan,
Some(x) => {
c = x;
continue 'scan;
}
None => return false,
}
}
// Otherwise `[` is an ordinary character; `nc1` is the lookahead.
c1 = Some(nc1);
}
if c1.is_none() {
c1 = fetch(bytes, i);
}
if c1 == Some(b'-') {
let Some(mut c2) = fetch(bytes, i) else {
return false;
};
if c2 == b'[' && bytes.get(*i).copied() == Some(b'.') {
c2 = b']';
}
if c2 == b']' {
// `[x-]`: the hyphen is a literal; put the `]` back so the
// loop terminator sees it next.
*i -= 1;
} else {
state |= 8;
match fetch(bytes, i) {
Some(b']') => break 'scan,
Some(x) => {
c = x;
continue 'scan;
}
None => return false,
}
}
}
state |= if c == b':' { 2 } else { 4 };
match c1 {
Some(b']') => break 'scan,
Some(x) => c = x,
None => return false,
}
}
state == 7
}
+12 -1
View File
@@ -1,8 +1,14 @@
// 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 crate::Config;
use crate::context_buffer::LineView;
use std::ffi::OsStr;
use std::io::{self, BufWriter, StdoutLock, Write};
use std::path::Path;
use uucore::error::strip_errno;
#[cfg(target_pointer_width = "64")]
const BUF_SIZE: usize = 128 * 1024;
@@ -190,7 +196,12 @@ impl<'a> OutputWriter<'a> {
/// Write an IO error to stderr.
pub fn report_io_error(&self, label: &OsStr, err: &io::Error) {
if !self.config.no_messages && !self.config.quiet {
eprintln!("grep: {label}: {err}", label = label.to_string_lossy());
// Strip the trailing " (os error XX)" so the message matches GNU grep.
eprintln!(
"grep: {label}: {err}",
label = label.to_string_lossy(),
err = strip_errno(err)
);
}
}
+8 -3
View File
@@ -1,3 +1,8 @@
// 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 crate::context_buffer::{ContextBuffer, LineView};
use crate::line_buffer::LineBuffer;
use crate::matcher::Matcher;
@@ -276,7 +281,7 @@ impl<'a> Searcher<'a> {
return Ok(false);
}
if let Some(positions) = self.session_match_line(line) {
if let Some(positions) = self.session_match_line(line)? {
// TODO: GNU grep respects LANG. Here, I'm always checking for valid UTF-8.
if !self.session_mark_binary_if(|| std::str::from_utf8(line).is_err()) {
return Ok(false);
@@ -316,9 +321,9 @@ impl<'a> Searcher<'a> {
self.config.binary_mode != BinaryMode::WithoutMatch
}
fn session_match_line(&self, line: &[u8]) -> Option<Vec<(usize, usize)>> {
fn session_match_line(&self, line: &[u8]) -> io::Result<Option<Vec<(usize, usize)>>> {
if !self.session_can_match() {
None
Ok(None)
} else if self.session_needs_match_positions() {
self.matcher.match_line(line)
} else {
+251
View File
@@ -126,6 +126,142 @@ fn ere_invalid_pattern_is_error() {
.stderr_contains("invalid pattern");
}
#[test]
fn confusing_bracket_class_is_error() {
// GNU grep rejects the misspelled `[:name:]` form (meant to be
// `[[:name:]]`) with a dedicated diagnostic and exit code 2.
// No piped input: the pattern is rejected at compile time, before stdin is
// read, so feeding stdin would race with the child exiting (broken pipe).
for pattern in ["[:space:]", "[:digit:]", "[^:space:]", "x[:space:]y"] {
let (_s, mut c) = ucmd();
c.args(&[pattern])
.fails_with_code(2)
.stderr_is("grep: character class syntax is [[:space:]], not [:space:]\n");
}
// The same diagnostic applies in extended mode.
let (_s, mut c) = ucmd();
c.args(&["-E", "[:space:]"])
.fails_with_code(2)
.stderr_is("grep: character class syntax is [[:space:]], not [:space:]\n");
}
#[test]
fn lookalike_brackets_are_not_confusing() {
// Patterns that are NOT the confusing `[:name:]` form must compile
// normally (no diagnostic). A proper class, a colon set, a range, a
// trailing colon set, and `-F` literal text all stay valid.
for pattern in [
"[[:space:]]",
"[::]",
"[:space]",
"[:spac-e:]",
"[a:space:]",
] {
let (_s, mut c) = ucmd();
c.args(&[pattern])
.pipe_in("z\n")
.fails_with_code(1)
.no_output();
}
// `\[` does not open a bracket expression.
let (_s, mut c) = ucmd();
c.args(&["\\[:space:]"])
.pipe_in("z\n")
.fails_with_code(1)
.no_output();
// `-F` treats the text literally, so no diagnostic.
let (_s, mut c) = ucmd();
c.args(&["-F", "[:space:]"])
.pipe_in("x\n")
.fails_with_code(1)
.no_output();
}
#[test]
fn reversed_range_uses_gnu_wording() {
// A range like `[b-a]` is an error; GNU prints the bare POSIX diagnostic
// "Invalid range end" (not oniguruma's phrasing) and exits 2.
// No piped input: the pattern is rejected before stdin is read.
for args in [&["[b-a]"][..], &["-E", "[b-a]"][..]] {
let (_s, mut c) = ucmd();
c.args(args)
.fails_with_code(2)
.stderr_is("grep: Invalid range end\n");
}
}
#[test]
fn pcre_backtracking_limit_does_not_abort() {
// A pathological PCRE pattern can exceed oniguruma's retry limit. GNU
// grep reports this and exits 2 (it must not crash); stdout stays empty.
let (_s, mut c) = ucmd();
c.args(&["-P", "((a+)*)+$"])
.pipe_in("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab\n")
.fails_with_code(2)
.stdout_is("")
.stderr_contains("backtracking limit");
}
#[test]
fn invalid_backreference_uses_gnu_wording() {
// A back-reference to a non-existent group is worded differently by GNU
// depending on the engine: PCRE (-P) vs gnulib regex (BRE/ERE).
// No piped input: these patterns are rejected before stdin is read.
let (_s, mut c) = ucmd();
c.args(&["-P", r"(.)\2"])
.fails_with_code(2)
.stderr_is("grep: reference to non-existent subpattern\n");
for args in [&["-E", r"(.)\2"][..], &[r"\(.\)\2"][..]] {
let (_s, mut c) = ucmd();
c.args(args)
.fails_with_code(2)
.stderr_is("grep: Invalid back reference\n");
}
// A valid back-reference with -Pw / -Px must still match.
for flag in ["-Pw", "-Px"] {
let (_s, mut c) = ucmd();
c.args(&[flag, r"(.)\1"])
.pipe_in("aa\n")
.succeeds()
.stdout_is("aa\n");
}
}
#[test]
fn grep_colors_mt_and_grep_color_deprecation() {
// GREP_COLORS `mt` sets the match color (both selected and context).
let (_s, mut c) = ucmd();
c.args(&["--color=always", "."])
.env("GREP_COLORS", "mt=36")
.pipe_in("x\n")
.succeeds()
.stdout_is("\u{1b}[36m\u{1b}[Kx\u{1b}[m\u{1b}[K\n");
// GREP_COLOR is deprecated: it still sets the match color, but emits a
// warning when color is actually produced.
let (_s, mut c) = ucmd();
c.args(&["--color=always", "."])
.env("GREP_COLOR", "36")
.pipe_in("x\n")
.succeeds()
.stdout_is("\u{1b}[36m\u{1b}[Kx\u{1b}[m\u{1b}[K\n")
.stderr_is("grep: warning: GREP_COLOR='36' is deprecated; use GREP_COLORS='mt=36'\n");
// No warning when color output is disabled.
let (_s, mut c) = ucmd();
c.args(&["--color=never", "."])
.env("GREP_COLOR", "36")
.pipe_in("x\n")
.succeeds()
.stdout_is("x\n")
.no_stderr();
}
#[test]
fn fixed_string_is_literal() {
// Metacharacters are not interpreted.
@@ -440,6 +576,17 @@ fn files_with_and_without_matches() {
.fails_with_code(1)
.stdout_is("hit\nmiss\n");
// -L with a pattern that DOES match in one file: the matching file is
// excluded from the listing, so only the non-matching file is printed.
// This exercises the early-return in `session_handle_match` taken when
// `files_without_match` is set and a match is found (src/searcher.rs).
let (scene, mut c) = ucmd();
scene.fixtures.write("hit", "yes\n");
scene.fixtures.write("miss", "no\n");
c.args(&["-L", "yes", "hit", "miss"])
.succeeds()
.stdout_is("miss\n");
// -l early-exits after the first match. Verify it doesn't print twice.
// when the file has many.
let (scene, mut c) = ucmd();
@@ -615,6 +762,15 @@ fn line_number_and_byte_offset_prefixes() {
.pipe_in("x\n")
.succeeds()
.stdout_contains("1:\tx\n");
// -T against a real file (not stdin): the line-number field width is
// derived from the file size, which only happens on the `File` path in
// `process_file` (src/searcher.rs), not the stdin path.
let (scene, mut c) = ucmd();
scene.fixtures.write("f", "x\n");
c.args(&["-T", "-n", "x", "f"])
.succeeds()
.stdout_contains("1:\tx\n");
}
#[test]
@@ -883,6 +1039,25 @@ fn recursive_no_file_defaults_to_cwd_not_stdin() {
.stdout_contains("a.txt");
}
#[test]
fn recursive_implicit_cwd_strips_dot_prefix() {
// `-r` with no path argument searches the implicit ".", so reported paths
// come back as "./only.txt" (or ".\\only.txt" on Windows). GNU strips that
// leading prefix; `strip_dot_prefix` in src/searcher.rs must do the same.
// A single file keeps the output deterministic and lets us assert the exact
// line, which `stdout_contains` in `recursive_no_file_defaults_to_cwd_not_stdin`
// cannot (it would also pass with a leaked "./" prefix).
let (scene, _) = ucmd();
scene.fixtures.mkdir_all("flat");
scene.fixtures.write("flat/only.txt", "grep me\n");
let mut c = scene.cmd(env!("CARGO_BIN_EXE_grep"));
c.current_dir(scene.fixtures.plus("flat"))
.args(&["-r", "grep"])
.succeeds()
.stdout_is("only.txt:grep me\n");
}
#[test]
fn recursive_with_include_exclude() {
let (scene, _) = ucmd();
@@ -1022,6 +1197,30 @@ fn recursive_skips_fifos_by_default() {
.stdout_does_not_contain("fifo");
}
#[cfg(unix)]
#[test]
fn device_skip_on_explicit_special_file_arg() {
use std::process::Command;
// A special file (FIFO) named *directly* as an argument, not via recursion.
// With `-D skip` it must be dropped without reading (reading would block
// forever, so the test returning at all proves it was skipped). This covers
// the top-level special-file branch in `process_path` and `is_special_file`
// (src/searcher.rs), distinct from the recursive FIFO path.
let (scene, _) = ucmd();
let fifo_path = scene.fixtures.plus("fifo");
let status = Command::new("mkfifo")
.arg(&fifo_path)
.status()
.expect("mkfifo failed");
assert!(status.success(), "could not create FIFO");
let mut c = scene.cmd(env!("CARGO_BIN_EXE_grep"));
c.args(&["-D", "skip", "grep", "fifo"])
.fails_with_code(1)
.no_output();
}
#[test]
fn nonexistent_file_is_error() {
let (_s, mut c) = ucmd();
@@ -1030,6 +1229,23 @@ fn nonexistent_file_is_error() {
.stderr_contains("does-not-exist");
}
#[test]
fn nonexistent_file_error_has_no_os_error_suffix() {
// GNU prints "grep: <file>: No such file or directory" with no
// " (os error 2)" suffix; strip_errno keeps us byte-compatible. The
// underlying OS message text differs on Windows, but in both cases the
// trailing " (os error N)" must be absent.
#[cfg(not(windows))]
let expected = "grep: does-not-exist: No such file or directory\n";
#[cfg(windows)]
let expected = "grep: does-not-exist: The system cannot find the file specified.\n";
let (_s, mut c) = ucmd();
c.args(&["x", "does-not-exist"])
.fails_with_code(2)
.stderr_is(expected);
}
#[test]
fn dash_argument_means_stdin() {
let (_s, mut c) = ucmd();
@@ -1138,3 +1354,38 @@ fn help_and_version() {
.succeeds()
.stdout_contains(env!("CARGO_PKG_VERSION"));
}
#[test]
fn repeated_options_are_accepted() {
// GNU grep tolerates options given more than once: boolean flags are
// idempotent and value options take the last occurrence. clap would
// otherwise error with "cannot be used multiple times".
// Repeated boolean flags are a no-op (not an error).
let (_s, mut c) = ucmd();
c.args(&["-n", "-n", "a"])
.pipe_in("abc\n")
.succeeds()
.stdout_only("1:abc\n");
// Mixed repeated booleans behave like a single occurrence.
let (_s, mut c) = ucmd();
c.args(&["-i", "-i", "abc"])
.pipe_in("ABC\n")
.succeeds()
.stdout_only("ABC\n");
// Repeated value options take the last value (here: -m 1 wins).
let (_s, mut c) = ucmd();
c.args(&["-m", "5", "-m", "1", "x"])
.pipe_in("x\nx\nx\n")
.succeeds()
.stdout_only("x\n");
// -e (ArgAction::Append) must still accumulate every pattern.
let (_s, mut c) = ucmd();
c.args(&["-e", "a", "-e", "b"])
.pipe_in("a\nb\nc\n")
.succeeds()
.stdout_only("a\nb\n");
}
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Compare the current GNU test results to the last results gathered from the main branch to
highlight if a PR is making the results better/worse.
Don't exit with error code if all failing tests are in the ignore-intermittent.txt list.
"""
import json
import sys
import argparse
from pathlib import Path
def load_ignore_list(ignore_file):
"""Load list of intermittent test names to ignore from file."""
ignore_set = set()
if ignore_file and Path(ignore_file).exists():
with open(ignore_file, "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
ignore_set.add(line)
return ignore_set
def extract_test_results(json_data):
"""Extract test results from JSON data."""
if not json_data or "summary" not in json_data:
return {"total": 0, "passed": 0, "failed": 0, "skipped": 0}, []
summary = json_data["summary"]
tests = json_data.get("tests", [])
# Extract failed test names
failed_tests = []
for test in tests:
if test.get("status") == "FAIL":
failed_tests.append(test.get("name", "unknown"))
return summary, failed_tests
def compare_results(current_file, reference_file, ignore_file=None, output_file=None):
"""Compare current results with reference results."""
# Load ignore list
ignore_set = load_ignore_list(ignore_file)
# Load JSON files
try:
with open(current_file, "r") as f:
current_data = json.load(f)
current_summary, current_failed = extract_test_results(current_data)
except Exception as e:
print(f"Error loading current results: {e}")
return 1
try:
with open(reference_file, "r") as f:
reference_data = json.load(f)
reference_summary, reference_failed = extract_test_results(reference_data)
except Exception as e:
print(f"Error loading reference results: {e}")
return 1
# Calculate differences
pass_diff = int(current_summary.get("passed", 0)) - int(
reference_summary.get("passed", 0)
)
fail_diff = int(current_summary.get("failed", 0)) - int(
reference_summary.get("failed", 0)
)
total_diff = int(current_summary.get("total", 0)) - int(
reference_summary.get("total", 0)
)
# Find new failures and improvements
current_failed_set = set(current_failed)
reference_failed_set = set(reference_failed)
new_failures = current_failed_set - reference_failed_set
improvements = reference_failed_set - current_failed_set
# Filter out intermittent failures
non_intermittent_new_failures = new_failures - ignore_set
# Check if results are identical (no changes)
no_changes = (
pass_diff == 0
and fail_diff == 0
and total_diff == 0
and not new_failures
and not improvements
)
# If no changes, write empty output to prevent comment posting
if no_changes:
with open(output_file, "w") as f:
f.write("")
return 0
# Prepare output message
output_lines = []
# Show current vs reference numbers for debugging
output_lines.append("Test results comparison:")
output_lines.append(
f" Current: TOTAL: {current_summary.get('total', 0)} / PASSED: {current_summary.get('passed', 0)} / FAILED: {current_summary.get('failed', 0)} / SKIPPED: {current_summary.get('skipped', 0)}"
)
output_lines.append(
f" Reference: TOTAL: {reference_summary.get('total', 0)} / PASSED: {reference_summary.get('passed', 0)} / FAILED: {reference_summary.get('failed', 0)} / SKIPPED: {reference_summary.get('skipped', 0)}"
)
output_lines.append("")
# Summary of changes
if pass_diff != 0 or fail_diff != 0 or total_diff != 0:
output_lines.append("Changes from main branch:")
output_lines.append(f" TOTAL: {total_diff:+d}")
output_lines.append(f" PASSED: {pass_diff:+d}")
output_lines.append(f" FAILED: {fail_diff:+d}")
output_lines.append("")
# New failures
if new_failures:
output_lines.append(f"New test failures ({len(new_failures)}):")
for test in sorted(new_failures):
if test in ignore_set:
output_lines.append(f" - {test} (intermittent)")
else:
output_lines.append(f" - {test}")
output_lines.append("")
# Improvements
if improvements:
output_lines.append(f"Test improvements ({len(improvements)}):")
for test in sorted(improvements):
output_lines.append(f" + {test}")
output_lines.append("")
# Write output
output_text = "\n".join(output_lines)
if output_file:
with open(output_file, "w") as f:
f.write(output_text)
else:
print(output_text)
# Return appropriate exit code
if non_intermittent_new_failures:
print(
f"ERROR: Found {len(non_intermittent_new_failures)} new non-intermittent test failures"
)
return 1
return 0
def main():
parser = argparse.ArgumentParser(description="Compare GNU test results")
parser.add_argument("current", help="Current test results JSON file")
parser.add_argument("reference", help="Reference test results JSON file")
parser.add_argument(
"--ignore-file", help="File containing intermittent test names to ignore"
)
parser.add_argument("--output", help="Output file for comparison results")
args = parser.parse_args()
return compare_results(args.current, args.reference, args.ignore_file, args.output)
if __name__ == "__main__":
sys.exit(main())
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash -e
# 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.
#
# Download and extract the upstream GNU grep release tarball into the current
# directory. Run it from an (empty) directory that will hold the GNU grep tree,
# e.g.:
#
# mkdir -p ../gnu.grep && (cd ../gnu.grep && bash ../grep/util/fetch-gnu.sh)
#
# The extracted tree ships a ready-to-use gnulib test framework under tests/
# (init.sh + init.cfg + the extensionless test scripts), which
# util/run-gnu-testsuite.sh drives against the Rust grep binary.
ver="3.12"
curl -L "https://ftp.gnu.org/gnu/grep/grep-${ver}.tar.xz" | tar --strip-components=1 -xJf -
+359
View File
@@ -0,0 +1,359 @@
#!/bin/bash
# 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.
#
# Run the upstream GNU grep testsuite against the Rust grep implementation.
#
# Unlike GNU coreutils, we do *not* build GNU grep here. Instead we reuse the
# gnulib test framework (tests/init.sh + tests/init.cfg) shipped in the GNU grep
# release tarball and inject our Rust `grep` binary via PATH, replicating the
# environment that tests/Makefile.am's TESTS_ENVIRONMENT would normally set up.
# Each test is classified by its gnulib exit code: 0 = PASS, 77 = SKIP, anything
# else = FAIL (timeouts and framework failures count as FAIL).
#
# Get the GNU grep sources with:
# mkdir -p ../gnu.grep && (cd ../gnu.grep && bash ../grep/util/fetch-gnu.sh)
#
# Usage: ./util/run-gnu-testsuite.sh [options]
#
# Options:
# -h, --help Show this help message
# -v, --verbose Show diagnostics for failing/skipped tests
# -q, --quiet Only print failures and the final summary
# --json-output FILE Write results to FILE as JSON
#
# Environment variables:
# GNU_GREP_DIR Path to the extracted GNU grep source tree
# (default: ../gnu.grep)
# RUN_EXPENSIVE_TESTS Set to "yes" to run expensive tests (default: no)
# PER_TEST_TIMEOUT Per-test timeout in seconds (default: 30)
# Don't exit on failure since test failures are expected.
set -o pipefail
# Configuration
RUST_GREP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GNU_GREP_DIR="${GNU_GREP_DIR:-${RUST_GREP_DIR}/../gnu.grep}"
GNU_TESTS_DIR=""
VERBOSE=false
QUIET=false
JSON_OUTPUT_FILE=""
PER_TEST_TIMEOUT="${PER_TEST_TIMEOUT:-30}"
DETAILED_RESULTS=()
# Statistics
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
SKIPPED_TESTS=0
usage() {
echo "Usage: $0 [options]"
echo
echo "Options:"
echo " -h, --help Show this help message"
echo " -v, --verbose Show diagnostics for failing/skipped tests"
echo " -q, --quiet Only print failures and the final summary"
echo " --json-output FILE Write results to FILE as JSON"
echo
echo "Environment variables:"
echo " GNU_GREP_DIR Path to the extracted GNU grep source tree"
echo " (default: ../gnu.grep)"
echo " RUN_EXPENSIVE_TESTS Set to 'yes' to run expensive tests"
echo " PER_TEST_TIMEOUT Per-test timeout in seconds (default: 30)"
echo
echo "Setup:"
echo " mkdir -p ../gnu.grep && (cd ../gnu.grep && bash ../grep/util/fetch-gnu.sh)"
}
log_info() { [[ "$QUIET" != "true" ]] && echo "[INFO] $1"; return 0; }
log_success() { [[ "$QUIET" != "true" ]] && echo "[PASS] $1"; return 0; }
log_skip() { [[ "$QUIET" != "true" ]] && echo "[SKIP] $1"; return 0; }
log_warning() { echo "[WARN] $1"; }
log_error() { echo "[FAIL] $1"; }
# Generate JSON output (schema shared with ../sed so compare_test_results.py works).
generate_json_output() {
cd "$RUST_GREP_DIR" || return
local timestamp
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local rust_version
rust_version=$(cargo metadata --no-deps --format-version 1 2>/dev/null | jq -r '.packages[0].version // "unknown"')
local tests_json="[]"
if [[ ${#DETAILED_RESULTS[@]} -gt 0 ]]; then
local temp_file
temp_file=$(mktemp)
printf "%s\n" "${DETAILED_RESULTS[@]}" > "$temp_file"
tests_json=$(jq -s '.' < "$temp_file" 2>/dev/null) || tests_json="[]"
rm -f "$temp_file"
fi
jq -n \
--arg timestamp "$timestamp" \
--argjson total "$TOTAL_TESTS" \
--argjson passed "$PASSED_TESTS" \
--argjson failed "$FAILED_TESTS" \
--argjson skipped "$SKIPPED_TESTS" \
--argjson duration "$duration" \
--arg rust_version "$rust_version" \
--arg gnu_testsuite_dir "$GNU_TESTS_DIR" \
--argjson tests "$tests_json" \
'{
timestamp: $timestamp,
summary: {
total: $total,
passed: $passed,
failed: $failed,
skipped: $skipped,
duration_seconds: $duration
},
environment: {
rust_grep_version: $rust_version,
gnu_testsuite_dir: $gnu_testsuite_dir
},
tests: $tests
}' > "$JSON_OUTPUT_FILE"
log_info "JSON results written to: $JSON_OUTPUT_FILE"
}
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help) usage; exit 0 ;;
-v|--verbose) VERBOSE=true; shift ;;
-q|--quiet) QUIET=true; shift ;;
--json-output) JSON_OUTPUT_FILE="$2"; shift 2 ;;
*) echo "Unknown argument: $1"; usage; exit 1 ;;
esac
done
# Validate environment
if [[ -d "$GNU_GREP_DIR" ]]; then
GNU_GREP_DIR="$(cd "$GNU_GREP_DIR" && pwd)"
GNU_TESTS_DIR="$GNU_GREP_DIR/tests"
fi
if [[ ! -f "$GNU_TESTS_DIR/init.sh" ]]; then
log_error "GNU grep testsuite not found at: $GNU_GREP_DIR"
log_error "Fetch it with:"
log_error " mkdir -p ${RUST_GREP_DIR}/../gnu.grep && (cd ${RUST_GREP_DIR}/../gnu.grep && bash ${RUST_GREP_DIR}/util/fetch-gnu.sh)"
exit 1
fi
if [[ ! -f "$RUST_GREP_DIR/Cargo.toml" ]]; then
log_error "Not in a Rust project directory: $RUST_GREP_DIR"
exit 1
fi
# Build the Rust grep implementation
log_info "Building Rust grep implementation..."
cd "$RUST_GREP_DIR" || exit 1
if ! cargo build --release --quiet; then
log_error "Failed to build Rust grep implementation"
exit 1
fi
RUST_GREP_BIN="$RUST_GREP_DIR/target/release/grep"
if [[ ! -x "$RUST_GREP_BIN" ]]; then
log_error "Built grep binary not found at: $RUST_GREP_BIN"
exit 1
fi
log_info "Using Rust grep binary: $RUST_GREP_BIN"
# Create a temporary work tree that mimics a GNU grep build directory.
TEST_WORK_DIR=$(mktemp -d)
trap 'rm -rf "$TEST_WORK_DIR"' EXIT
log_info "Test working directory: $TEST_WORK_DIR"
# A fake $abs_top_builddir whose src/ holds the binaries the tests expect.
BUILD_DIR="$TEST_WORK_DIR/build"
BIN_DIR="$BUILD_DIR/src"
mkdir -p "$BIN_DIR"
# grep, plus the egrep/fgrep wrappers a handful of tests rely on.
cat > "$BIN_DIR/grep" <<WRAPPER_EOF
#!/bin/sh
exec "$RUST_GREP_BIN" "\$@"
WRAPPER_EOF
cat > "$BIN_DIR/egrep" <<WRAPPER_EOF
#!/bin/sh
exec "$RUST_GREP_BIN" -E "\$@"
WRAPPER_EOF
cat > "$BIN_DIR/fgrep" <<WRAPPER_EOF
#!/bin/sh
exec "$RUST_GREP_BIN" -F "\$@"
WRAPPER_EOF
chmod +x "$BIN_DIR/grep" "$BIN_DIR/egrep" "$BIN_DIR/fgrep"
# Empty config.h: tests that probe it for build-time features just skip.
: > "$BUILD_DIR/config.h"
# get-mb-cur-max is a tiny standalone helper used by the locale require_ checks.
if [[ -f "$GNU_TESTS_DIR/get-mb-cur-max.c" ]]; then
if cc -I"$BUILD_DIR" -o "$BIN_DIR/get-mb-cur-max" "$GNU_TESTS_DIR/get-mb-cur-max.c" 2>/dev/null; then
log_info "Built get-mb-cur-max helper"
else
log_warning "Could not build get-mb-cur-max; multibyte/locale tests may skip"
fi
fi
# Replicate the PCRE_WORKS probe from tests/Makefile.am's TESTS_ENVIRONMENT.
PCRE_WORKS=0
if err=$(echo . | "$BIN_DIR/grep" -Pq . 2>&1); then
[[ -z "$err" ]] && PCRE_WORKS=1
fi
log_info "PCRE_WORKS=$PCRE_WORKS"
GREP_VERSION=$(basename "$GNU_GREP_DIR" | sed 's/^grep-//')
[[ "$GREP_VERSION" == "$(basename "$GNU_GREP_DIR")" ]] && GREP_VERSION="unknown"
HOST_TRIPLET="$(uname -m)-pc-linux-gnu"
# Record a test result (for JSON output)
record_result() {
if [[ -n "$JSON_OUTPUT_FILE" ]]; then
DETAILED_RESULTS+=("$(jq -n \
--arg name "$1" --arg status "$2" --arg error "$3" \
'{name: $name, status: $status, error: $error}')")
fi
}
# Run a single GNU testsuite script with the Rust grep on PATH.
run_gnu_test() {
local test_script="$1"
local test_name
test_name=$(basename "$test_script")
TOTAL_TESTS=$((TOTAL_TESTS + 1))
local test_output_file="$TEST_WORK_DIR/test_output_$$"
local test_exit_code=0
# When not the process-group leader (e.g. in CI), GNU timeout falls back to
# "foreground" mode and SIGTERMs the whole group on timeout. Shield the
# parent script so a single hung test doesn't take the run down.
trap '' TERM
(
cd "$TEST_WORK_DIR" || exit 99
# init.cfg refuses to run if these are set.
unset GREP_COLOR GREP_COLORS TERM CDPATH
export PATH="$BIN_DIR:$PATH"
export srcdir="$GNU_TESTS_DIR" abs_srcdir="$GNU_TESTS_DIR"
export abs_top_srcdir="$GNU_GREP_DIR" top_srcdir="$GNU_GREP_DIR"
export abs_top_builddir="$BUILD_DIR"
export CONFIG_HEADER="$BUILD_DIR/config.h"
export built_programs="grep egrep fgrep"
export AWK=awk PERL=perl SHELL=/bin/sh MAKE=make CC=cc
export LC_ALL=C MALLOC_PERTURB_=87
export VERSION="$GREP_VERSION" PACKAGE_VERSION="$GREP_VERSION"
export host_triplet="$HOST_TRIPLET"
export PCRE_WORKS="$PCRE_WORKS"
export GREP_TEST_NAME="$test_name"
export RUN_EXPENSIVE_TESTS="${RUN_EXPENSIVE_TESTS:-no}"
# fd 9 is the framework's stderr (init.cfg's stderr_fileno_=9).
if [[ "$test_name" == *.pl ]]; then
exec timeout --kill-after=5 "$PER_TEST_TIMEOUT" \
perl -w -I"$GNU_TESTS_DIR" -MCoreutils -MCuSkip "$test_script" 9>&2
else
exec timeout --kill-after=5 "$PER_TEST_TIMEOUT" \
/bin/sh "$test_script" 9>&2
fi
) </dev/null >"$test_output_file" 2>&1
test_exit_code=$?
trap - TERM
# Strip NUL bytes: some tests (e.g. z-anchor-newline) emit binary output,
# which would otherwise trigger a "ignored null byte" warning from $(...).
local test_output=""
[[ -f "$test_output_file" ]] && test_output=$(tr -d '\0' < "$test_output_file")
rm -f "$test_output_file"
# 124 = GNU timeout, 125 = uutils timeout, >=128 = killed by signal.
if [[ $test_exit_code -eq 124 || $test_exit_code -eq 125 || $test_exit_code -ge 128 ]]; then
log_error "$test_name (timeout)"
FAILED_TESTS=$((FAILED_TESTS + 1))
record_result "$test_name" "FAIL" "Test timed out after ${PER_TEST_TIMEOUT}s"
return
fi
case $test_exit_code in
0)
log_success "$test_name"
PASSED_TESTS=$((PASSED_TESTS + 1))
record_result "$test_name" "PASS" ""
;;
77)
log_skip "$test_name"
SKIPPED_TESTS=$((SKIPPED_TESTS + 1))
[[ "$VERBOSE" == "true" ]] && echo "$test_output" | head -3 | sed 's/^/ | /'
record_result "$test_name" "SKIP" "$test_output"
;;
*)
log_error "$test_name (exit $test_exit_code)"
FAILED_TESTS=$((FAILED_TESTS + 1))
[[ "$VERBOSE" == "true" ]] && echo "$test_output" | head -10 | sed 's/^/ | /'
record_result "$test_name" "FAIL" "Exit code: $test_exit_code"
;;
esac
}
# Discover the canonical test list from tests/Makefile.am's TESTS variable.
collect_tests() {
awk '
/^TESTS *\+?=/ { collect=1; sub(/^TESTS *\+?=/, "") }
collect {
line=$0
cont=sub(/\\[ \t]*$/, "", line)
n=split(line, a, /[ \t]+/)
for (i=1; i<=n; i++) if (a[i] != "") print a[i]
if (!cont) collect=0
}
' "$GNU_TESTS_DIR/Makefile.am"
}
log_info "Discovering tests from $GNU_TESTS_DIR/Makefile.am"
mapfile -t TEST_LIST < <(collect_tests | sort -u)
log_info "Found ${#TEST_LIST[@]} tests"
log_info "Starting test execution..."
start_time=$(date +%s)
for t in "${TEST_LIST[@]}"; do
[[ -z "$t" ]] && continue
test_path="$GNU_TESTS_DIR/$t"
[[ -f "$test_path" ]] || { log_warning "Listed test not found: $t"; continue; }
run_gnu_test "$test_path"
done
end_time=$(date +%s)
duration=$((end_time - start_time))
# Print summary
echo
echo "========================================="
echo "GNU grep testsuite results"
echo "========================================="
echo "Total tests: $TOTAL_TESTS"
echo "Passed: $PASSED_TESTS"
echo "Failed: $FAILED_TESTS"
echo "Skipped: $SKIPPED_TESTS"
echo "Duration: ${duration}s"
if [[ -n "$JSON_OUTPUT_FILE" ]]; then
generate_json_output
fi
if [[ $((PASSED_TESTS + FAILED_TESTS)) -gt 0 ]]; then
pass_rate=$(( (PASSED_TESTS * 100) / (PASSED_TESTS + FAILED_TESTS) ))
echo "Pass rate: ${pass_rate}%"
fi
# Mirror the script's exit convention to ../sed: nonzero if anything failed.
[[ $FAILED_TESTS -eq 0 ]] && exit 0 || exit 1