Split grep and rg into independent builtins

ober

20ca8afdd35aaa93554f71b334d94b4a70756031

diff --git a/jerboa-src/src/jsh/coreutils.ss b/jerboa-src/src/jsh/coreutils.ss
index 50805cf..7a080a3 100644
--- a/jerboa-src/src/jsh/coreutils.ss
+++ b/jerboa-src/src/jsh/coreutils.ss
@@ -268,8 +268,9 @@
 (define-coreutil-ffi ffi-tr "jsh_tr")
 (define-coreutil-ffi ffi-numfmt "jsh_numfmt")
 
-;; Search (ripgrep)
-(define-coreutil-ffi ffi-grep "jsh_grep")
+;; Search
+(define-coreutil-ffi ffi-grep "jsh_grep")   ; uu_grep — real GNU grep semantics
+(define-coreutil-ffi ffi-rg "jsh_rg")       ; ripgrep, untouched
 
 (define-coreutil-ffi ffi-id "jsh_id")
 (define-coreutil-ffi ffi-whoami "jsh_whoami")
@@ -387,8 +388,9 @@
      ("uniq"      . ,ffi-uniq)
      ("tr"        . ,ffi-tr)
      ("numfmt"    . ,ffi-numfmt)
-     ;; --- Search (ripgrep) ---
+     ;; --- Search ---
      ("grep"      . ,ffi-grep)
+     ("rg"        . ,ffi-rg)
      ;; --- Identity & system info ---
      ("id"        . ,ffi-id)
      ("whoami"    . ,ffi-whoami)
diff --git a/rust-coreutils/Cargo.lock b/rust-coreutils/Cargo.lock
index 87c45b6..7a79cb2 100644
--- a/rust-coreutils/Cargo.lock
+++ b/rust-coreutils/Cargo.lock
@@ -1444,6 +1444,7 @@ dependencies = [
  "uu_factor",
  "uu_fmt",
  "uu_fold",
+ "uu_grep",
  "uu_groups",
  "uu_head",
  "uu_hostid",
@@ -2999,6 +3000,19 @@ dependencies = [
 ]
 
 [[package]]
+name = "uu_grep"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "glob",
+ "memchr",
+ "onig",
+ "onig_sys",
+ "uucore",
+ "walkdir",
+]
+
+[[package]]
 name = "uu_groups"
 version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/rust-coreutils/Cargo.toml b/rust-coreutils/Cargo.toml
index 3356d59..c25b37a 100644
--- a/rust-coreutils/Cargo.toml
+++ b/rust-coreutils/Cargo.toml
@@ -31,7 +31,9 @@ uu_uniq = "0.7.0"
 uu_cut = "0.7.0"
 uu_paste = "0.7.0"
 uu_tr = "0.7.0"
-# grep: ripgrep embedded as library (not part of GNU coreutils)
+# grep: real GNU-compatible grep (uutils/grep, not part of GNU coreutils proper)
+uu_grep = { path = "uu-grep" }
+# rg: ripgrep embedded as library, exposed as its own separate builtin
 ripgrep-core = { path = "ripgrep-core" }
 uu_tee = "0.7.0"
 uu_basename = "0.7.0"
diff --git a/rust-coreutils/src/lib.rs b/rust-coreutils/src/lib.rs
index 76cc76c..b079106 100644
--- a/rust-coreutils/src/lib.rs
+++ b/rust-coreutils/src/lib.rs
@@ -126,64 +126,24 @@ coreutil!(jsh_uniq,     "uniq",     uu_uniq);
 coreutil!(jsh_tr,       "tr",       uu_tr);
 coreutil!(jsh_numfmt,   "numfmt",   uu_numfmt);
 
-// --- Search (ripgrep, not part of GNU coreutils) ---
+// --- Search ---
 //
-// Translate POSIX/GNU grep flags into ripgrep equivalents so common usage
-// works without surprises. Notable collisions:
-//   -E  (POSIX: extended regex)    → ripgrep -E means "specify encoding".
-//                                    ripgrep is ERE by default — drop the flag.
-//   -G  (POSIX: basic regex)       → ripgrep has no BRE mode; drop, runs as ERE.
-fn translate_grep_args(mut args: Vec<String>) -> Vec<String> {
-    let mut out = Vec::with_capacity(args.len());
-    let mut drain = args.drain(..);
-    // First element is the program name.
-    if let Some(prog) = drain.next() {
-        out.push(prog);
-    }
-    let mut after_double_dash = false;
-    for a in drain {
-        if after_double_dash {
-            out.push(a);
-            continue;
-        }
-        if a == "--" {
-            after_double_dash = true;
-            out.push(a);
-            continue;
-        }
-        if a == "-E" || a == "-G" {
-            // Drop: ripgrep is already ERE.
-            continue;
-        }
-        // Bundled short flags like -En, -Gv, -Ein etc.: strip E/G from the bundle.
-        if a.len() > 1 && a.starts_with('-') && !a.starts_with("--") {
-            let bytes = a.as_bytes();
-            let has_collision = bytes[1..].iter().any(|&b| b == b'E' || b == b'G');
-            if has_collision {
-                let mut cleaned = String::with_capacity(a.len());
-                cleaned.push('-');
-                for &b in &bytes[1..] {
-                    if b != b'E' && b != b'G' {
-                        cleaned.push(b as char);
-                    }
-                }
-                if cleaned.len() > 1 {
-                    out.push(cleaned);
-                }
-                continue;
-            }
-        }
-        out.push(a);
-    }
-    out
-}
+// grep and rg used to be the same builtin (grep aliased straight to
+// ripgrep), which silently broke GNU-style flag bundling: ripgrep
+// repurposes several GNU grep short flags for unrelated options — e.g.
+// -r/--replace takes a VALUE, so "-ri" got parsed as -r with value "i",
+// silently dropping -i and replacing every match with the literal "i".
+// Now they're separate builtins with no translation layer:
+//   grep -> uu_grep (vendored in ./uu-grep) — real GNU grep semantics.
+//   rg   -> ripgrep, untouched.
+coreutil!(jsh_grep, "grep", uu_grep);
 
 #[no_mangle]
-pub unsafe extern "C" fn jsh_grep(
+pub unsafe extern "C" fn jsh_rg(
     argc: libc::c_int,
     argv: *const *const libc::c_char,
 ) -> libc::c_int {
-    let args = translate_grep_args(args_from_c("grep", argc, argv));
+    let args = args_from_c("rg", argc, argv);
     let os_args: Vec<std::ffi::OsString> = args.into_iter().map(|s| s.into()).collect();
     ripgrep_core::rg_main(os_args) as libc::c_int
 }
diff --git a/rust-coreutils/uu-grep/Cargo.toml b/rust-coreutils/uu-grep/Cargo.toml
new file mode 100644
index 0000000..7a54244
--- /dev/null
+++ b/rust-coreutils/uu-grep/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "uu_grep"
+description = "A Rust implementation of GNU Grep"
+repository = "https://github.com/uutils/grep"
+edition = "2024"
+rust-version = "1.88.0"
+version = "0.1.0"
+license = "MIT"
+homepage = "https://github.com/uutils/grep"
+keywords = ["grep", "uutils", "cross-platform", "cli", "utility"]
+categories = ["command-line-utilities"]
+
+[lib]
+name = "uu_grep"
+path = "src/lib.rs"
+
+[dependencies]
+clap = { version = "4.5", features = ["wrap_help", "cargo", "color"] }
+glob = "0.3.1"
+memchr = "2.7.2"
+onig = { version = "~6.5.1", default-features = false }
+onig_sys = { version = "*", default-features = false }
+# Pinned to match the project's local uucore-patch (0.7.0); upstream uu_grep
+# targets uucore 0.8.0. See ../uucore-patch/Cargo.toml.
+uucore = "0.7.0"
+walkdir = "2.5"
diff --git a/rust-coreutils/uu-grep/LICENSE b/rust-coreutils/uu-grep/LICENSE
new file mode 100644
index 0000000..21bd444
--- /dev/null
+++ b/rust-coreutils/uu-grep/LICENSE
@@ -0,0 +1,18 @@
+Copyright (c) uutils developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/rust-coreutils/uu-grep/src/context_buffer.rs b/rust-coreutils/uu-grep/src/context_buffer.rs
new file mode 100644
index 0000000..76ea5db
--- /dev/null
+++ b/rust-coreutils/uu-grep/src/context_buffer.rs
@@ -0,0 +1,107 @@
+// 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],
+    /// 1-based line number.
+    pub line_number: u64,
+    /// Byte offset of this line in the input stream.
+    pub byte_offset: u64,
+    /// Whether this line matched (vs. being a context line).
+    pub is_match: bool,
+    /// Match positions within the line (start, end).
+    /// Empty for context lines or for matching lines we don't need to highlight.
+    pub match_positions: &'a [(usize, usize)],
+}
+
+#[derive(Clone)]
+pub struct BufferedLine {
+    pub line: Vec<u8>,
+    pub line_number: u64,
+    pub byte_offset: u64,
+}
+
+impl BufferedLine {
+    pub fn view(&self) -> LineView<'_> {
+        LineView {
+            line: &self.line,
+            line_number: self.line_number,
+            byte_offset: self.byte_offset,
+            is_match: false,
+            match_positions: &[],
+        }
+    }
+}
+
+/// A fixed-capacity ring buffer of context lines.
+///
+/// TODO: Ideally, this would be integrated into `LineBuffer`, which can then
+/// provide a more optimized `ContextBuffer` when `mmap()` is available.
+pub struct ContextBuffer {
+    slots: Vec<BufferedLine>,
+    head: usize,
+    len: usize,
+}
+
+impl ContextBuffer {
+    pub fn new(capacity: usize) -> Self {
+        let len = if capacity == 0 {
+            0
+        } else {
+            capacity.next_power_of_two()
+        };
+        Self {
+            slots: vec![
+                BufferedLine {
+                    line: Vec::new(),
+                    line_number: 0,
+                    byte_offset: 0,
+                };
+                len
+            ],
+            head: 0,
+            len: 0,
+        }
+    }
+
+    pub fn clear(&mut self) {
+        self.head = 0;
+        self.len = 0;
+    }
+
+    pub fn push(&mut self, line: &[u8], line_number: u64, byte_offset: u64) {
+        debug_assert!(
+            !self.slots.is_empty(),
+            "push on zero-capacity ContextBuffer"
+        );
+
+        let mask = self.slots.len() - 1;
+        let slot = &mut self.slots[self.head & mask];
+
+        slot.line.clear();
+        if slot.line.capacity() / 2 > line.len() {
+            slot.line.shrink_to(line.len());
+        }
+        slot.line.extend_from_slice(line);
+        slot.line_number = line_number;
+        slot.byte_offset = byte_offset;
+
+        self.head = self.head.wrapping_add(1);
+        self.len = (self.len + 1).min(self.slots.len());
+    }
+
+    pub fn drain_iter(&mut self) -> impl Iterator<Item = &BufferedLine> {
+        let len = self.len;
+        let slots = &self.slots[..];
+        let mask = self.slots.len().wrapping_sub(1);
+        let start = self.head.wrapping_sub(len);
+
+        self.head = 0;
+        self.len = 0;
+
+        (0..len).map(move |i| &slots[start.wrapping_add(i) & mask])
+    }
+}
diff --git a/rust-coreutils/uu-grep/src/lib.rs b/rust-coreutils/uu-grep/src/lib.rs
new file mode 100644
index 0000000..fbf25c3
--- /dev/null
+++ b/rust-coreutils/uu-grep/src/lib.rs
@@ -0,0 +1,999 @@
+// 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.
+
+#[doc(hidden)]
+pub mod context_buffer;
+#[doc(hidden)]
+pub mod line_buffer;
+#[doc(hidden)]
+pub mod matcher;
+mod output;
+mod searcher;
+
+use crate::line_buffer::LineBuffer;
+use crate::matcher::Matcher;
+use crate::output::OutputWriter;
+use crate::searcher::Searcher;
+use clap::{Arg, ArgAction, Command};
+use std::ffi::{OsStr, OsString};
+use std::io::{IsTerminal as _, Read};
+use std::path::Path;
+use uucore::error::{ExitCode, FromIo, UResult, USimpleError};
+use uucore::show_warning;
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+#[doc(hidden)]
+pub enum RegexMode {
+    Fixed,
+    Basic,
+    Extended,
+    Perl,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+#[doc(hidden)]
+pub enum BinaryMode {
+    Binary,
+    Text,
+    WithoutMatch,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum ColorMode {
+    Always,
+    Never,
+    Auto,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+#[doc(hidden)]
+pub enum DirectoryMode {
+    Read,
+    Skip,
+    Recurse,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+#[doc(hidden)]
+pub enum DeviceMode {
+    Default,
+    Read,
+    Skip,
+}
+
+#[doc(hidden)]
+pub struct ColorConfig<'a> {
+    pub matched_selected: &'a str,
+    pub matched_context: &'a str,
+    pub filename: &'a str,
+    pub line_number: &'a str,
+    pub byte_offset: &'a str,
+    pub separator: &'a str,
+    pub selected_line: &'a str,
+    pub context_line: &'a str,
+
+    pub reverse_video: bool,
+    pub no_erase: bool,
+}
+
+#[doc(hidden)]
+pub struct GlobSet {
+    patterns: Vec<glob::Pattern>,
+}
+
+#[doc(hidden)]
+pub struct Config<'a> {
+    // Searcher
+    pub directory_mode: DirectoryMode,
+    pub device_mode: DeviceMode,
+    pub follow_symlinks: bool,
+    pub include_globs: GlobSet,
+    pub exclude_globs: GlobSet,
+    pub exclude_dir_globs: GlobSet,
+    pub label: &'a str,
+    #[cfg(windows)]
+    pub strip_cr: bool,
+    pub binary_mode: BinaryMode,
+    pub max_count: Option<u64>,
+    pub before_context: usize,
+    pub after_context: usize,
+    pub has_context: bool,
+
+    // Matcher
+    pub patterns: &'a [&'a str],
+    pub regex_mode: RegexMode,
+    pub ignore_case: bool,
+    pub invert_match: bool,
+    pub word_regexp: bool,
+    pub line_regexp: bool,
+
+    // Output
+    pub quiet: bool,
+    pub count: bool,
+    pub show_filename: bool,
+    pub files_with_matches: bool,
+    pub files_without_match: bool,
+    pub only_matching: bool,
+    pub byte_offset: bool,
+    pub line_number: bool,
+    pub initial_tab: bool,
+    pub null_separator: bool,
+    pub null_data: bool,
+    pub line_buffered: bool,
+    pub no_messages: bool,
+    pub group_separator: Option<&'a str>,
+    pub use_color: bool,
+    pub color_config: ColorConfig<'a>,
+}
+
+#[uucore::main(no_signals)]
+pub fn uumain(args: impl uucore::Args) -> UResult<()> {
+    let args = expand_num_shorthand(args);
+    let matches = uucore::clap_localization::handle_clap_result_with_exit_code(uu_app(), args, 2)?;
+
+    let grep_color = std::env::var("GREP_COLOR").unwrap_or_default();
+    let grep_colors = std::env::var("GREP_COLORS").unwrap_or_default();
+
+    let patterns_or_files: Vec<_> = matches
+        .get_many::<OsString>("patterns_or_files")
+        .map_or(Default::default(), |v| v.collect());
+    let extended_regexp = matches.get_flag("extended_regexp");
+    let fixed_strings = matches.get_flag("fixed_strings");
+    let basic_regexp = matches.get_flag("basic_regexp");
+    let perl_regexp = matches.get_flag("perl_regexp");
+    let regexp = matches.get_many::<String>("regexp").unwrap_or_default();
+    let file_pattern = matches
+        .get_many::<String>("file_pattern")
+        .unwrap_or_default();
+    let ignore_case = matches.get_flag("ignore_case");
+    let word_regexp = matches.get_flag("word_regexp");
+    let line_regexp = matches.get_flag("line_regexp");
+    let null_data = matches.get_flag("null_data");
+    let no_messages = matches.get_flag("no_messages");
+    let invert_match = matches.get_flag("invert_match");
+    let max_count = matches.get_one::<u64>("max_count").copied();
+    let byte_offset = matches.get_flag("byte_offset");
+    let line_number = matches.get_flag("line_number");
+    let line_buffered = matches.get_flag("line_buffered");
+    let with_filename = matches.get_flag("with_filename");
+    let no_filename = matches.get_flag("no_filename");
+    let label = matches
+        .get_one::<String>("label")
+        .map_or("(standard input)", |s| s.as_str());
+    let only_matching = matches.get_flag("only_matching");
+    let quiet = matches.get_flag("quiet");
+    let binary_files = matches
+        .get_one::<String>("binary_files")
+        .map(String::as_str);
+    let text = matches.get_flag("text");
+    let skip_binary = matches.get_flag("skip_binary");
+    let directories = matches.get_one::<String>("directories").map(String::as_str);
+    let devices = matches.get_one::<String>("devices").map(String::as_str);
+    let recursive = matches.get_flag("recursive");
+    let dereference_recursive = matches.get_flag("dereference_recursive");
+    let include = matches.get_many::<String>("include").unwrap_or_default();
+    let exclude = matches.get_many::<String>("exclude").unwrap_or_default();
+    let exclude_from = matches
+        .get_many::<String>("exclude_from")
+        .unwrap_or_default();
+    let exclude_dir = matches
+        .get_many::<String>("exclude_dir")
+        .unwrap_or_default();
+    let files_without_match = matches.get_flag("files_without_match");
+    let files_with_matches = matches.get_flag("files_with_matches");
+    let count = matches.get_flag("count");
+    let initial_tab = matches.get_flag("initial_tab");
+    let null = matches.get_flag("null");
+    let before_context = matches.get_one::<usize>("before_context").copied();
+    let after_context = matches.get_one::<usize>("after_context").copied();
+    let context = matches.get_one::<usize>("context").copied();
+    let group_separator = matches
+        .get_one::<String>("group_separator")
+        .map_or("--", |s| s.as_str());
+    let no_group_separator = matches.get_flag("no_group_separator");
+    let color = matches
+        .get_one::<String>("color")
+        .map_or("", |s| s.as_str());
+    #[cfg(windows)]
+    let binary = matches.get_flag("binary");
+
+    let matcher_mode_count = [extended_regexp, fixed_strings, basic_regexp, perl_regexp]
+        .into_iter()
+        .filter(|matched| *matched)
+        .count();
+    if matcher_mode_count > 1 {
+        return Err(USimpleError::new(
+            2,
+            "conflicting matchers specified".to_string(),
+        ));
+    }
+
+    // With -e/-f given, ALL positionals are files.
+    let has_explicit_patterns = regexp.len() != 0 || file_pattern.len() != 0;
+    let (positional_pattern, file_args) = if has_explicit_patterns {
+        (None, &patterns_or_files[..])
+    } else {
+        patterns_or_files
+            .split_first()
+            .map_or((None, &[][..]), |(p, rest)| (Some(*p), rest))
+    };
+
+    // An empty pattern set is a usage error only when no explicit pattern source was
+    // given (`-e` / `-f`). An empty `-f` file is legitimate and simply matches nothing.
+    let mut pattern_strings = Vec::new();
+    let mut patterns = Vec::new();
+    {
+        for expr in regexp {
+            for line in expr.split('\n') {
+                patterns.push(line);
+            }
+        }
+
+        for path in file_pattern {
+            let contents = if *path == "-" {
+                let mut buf = String::new();
+                std::io::stdin()
+                    .read_to_string(&mut buf)
+                    .map_err_context(|| "(standard input)".to_string())?;
+                buf
+            } else {
+                std::fs::read_to_string(path).map_err_context(|| path.to_string())?
+            };
+            pattern_strings.push(contents);
+        }
+        for contents in &pattern_strings {
+            if !contents.is_empty() {
+                let body = contents.strip_suffix('\n').unwrap_or(contents);
+                for line in body.split('\n') {
+                    patterns.push(line);
+                }
+            }
+        }
+
+        if let Some(pos) = positional_pattern {
+            let pat = pos
+                .to_str()
+                .ok_or_else(|| USimpleError::new(2, "pattern must be valid UTF-8".to_string()))?;
+            for line in pat.split('\n') {
+                patterns.push(line);
+            }
+        }
+    }
+    if patterns.is_empty() && !has_explicit_patterns {
+        return Err(USimpleError::new(
+            2,
+            "no PATTERN specified. Try 'grep --help' for more information.".to_string(),
+        ));
+    }
+
+    // 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
+    } else if extended_regexp {
+        RegexMode::Extended
+    } else if perl_regexp {
+        RegexMode::Perl
+    } else {
+        RegexMode::Basic
+    };
+    let directory_mode = if recursive || dereference_recursive {
+        DirectoryMode::Recurse
+    } else {
+        match directories {
+            Some("skip") => DirectoryMode::Skip,
+            Some("recurse") => DirectoryMode::Recurse,
+            _ => DirectoryMode::Read,
+        }
+    };
+    let binary_mode = if text {
+        BinaryMode::Text
+    } else if skip_binary {
+        BinaryMode::WithoutMatch
+    } else {
+        match binary_files {
+            Some("text") => BinaryMode::Text,
+            Some("without-match") => BinaryMode::WithoutMatch,
+            _ => BinaryMode::Binary,
+        }
+    };
+    let device_mode = match devices {
+        Some("read") => DeviceMode::Read,
+        Some("skip") => DeviceMode::Skip,
+        _ => DeviceMode::Default,
+    };
+    let color = match color {
+        "always" => ColorMode::Always,
+        "never" => ColorMode::Never,
+        _ => ColorMode::Auto,
+    };
+    let (before_context, after_context, has_context) = {
+        // "-o" overrides any context arguments
+        if only_matching {
+            (0, 0, false)
+        } else {
+            let fallback = context.unwrap_or(0);
+            let before = before_context.unwrap_or(fallback);
+            let after = after_context.unwrap_or(fallback);
+            let has = context.is_some() || before_context.is_some() || after_context.is_some();
+
+            (before, after, has)
+        }
+    };
+    let include_globs = {
+        let mut patterns = GlobSet::with_capacity(include.len());
+        for pattern in include {
+            patterns.add(pattern)?;
+        }
+        patterns
+    };
+    let exclude_globs = {
+        let mut patterns = GlobSet::with_capacity(exclude.len());
+        for pattern in exclude {
+            patterns.add(pattern)?;
+        }
+        for path in exclude_from {
+            let contents = std::fs::read_to_string(path).map_err_context(|| path.to_string())?;
+            for line in contents.lines() {
+                let trimmed = line.trim();
+                if !trimmed.is_empty() {
+                    patterns.add(trimmed)?;
+                }
+            }
+        }
+        patterns
+    };
+    let exclude_dir_globs = {
+        let mut patterns = GlobSet::with_capacity(exclude_dir.len());
+        for pattern in exclude_dir {
+            patterns.add(pattern)?;
+        }
+        patterns
+    };
+    let show_filename = if with_filename {
+        true
+    } else if no_filename {
+        false
+    } else {
+        match file_args {
+            [] => directory_mode == DirectoryMode::Recurse,
+            [one] if one.to_str() != Some("-") => {
+                directory_mode == DirectoryMode::Recurse && Path::new(one).is_dir()
+            }
+            [_] => false,
+            _ => true,
+        }
+    };
+    let group_separator = (!no_group_separator).then_some(group_separator);
+    let use_color = match color {
+        ColorMode::Always => true,
+        ColorMode::Never => false,
+        ColorMode::Auto => std::io::stdout().is_terminal(),
+    };
+    // GNU grep treats GREP_COLOR as deprecated: when it is set and color output
+    // is active, warn and point users at the GREP_COLORS 'mt' capability.
+    if use_color && !grep_color.is_empty() {
+        show_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 {
+        // Searcher
+        directory_mode,
+        device_mode,
+        follow_symlinks: dereference_recursive,
+        include_globs,
+        exclude_globs,
+        exclude_dir_globs,
+        label,
+        #[cfg(windows)]
+        strip_cr: !binary,
+        binary_mode,
+        max_count,
+        before_context,
+        after_context,
+        has_context,
+
+        // Matcher
+        patterns: &patterns,
+        regex_mode,
+        ignore_case,
+        invert_match,
+        word_regexp,
+        line_regexp,
+
+        // Output
+        quiet,
+        count,
+        show_filename,
+        files_with_matches,
+        files_without_match,
+        only_matching,
+        byte_offset,
+        line_number,
+        initial_tab,
+        null_separator: null,
+        null_data,
+        line_buffered,
+        no_messages,
+        group_separator,
+        use_color,
+        color_config,
+    };
+
+    let matcher = Matcher::compile(&config)?;
+
+    // grep with -m 0 should not open any file
+    if config.max_count == Some(0) {
+        return Err(ExitCode::new(1));
+    }
+
+    // An empty pattern matches every line; with `-v`, GNU grep selects no lines
+    // and exits as "no match" without reading any input files.
+    if invert_match && patterns.iter().any(|pattern| pattern.is_empty()) {
+        return Err(ExitCode::new(1));
+    }
+
+    let writer = OutputWriter::new(&config);
+    let mut searcher = Searcher::new(&config, matcher, writer);
+    let mut lb = LineBuffer::new(if config.null_data { b'\0' } else { b'\n' });
+
+    if file_args.is_empty() {
+        if directory_mode != DirectoryMode::Recurse {
+            _ = searcher.process_stdin(&mut lb);
+        } else {
+            _ = searcher.process_implicit_cwd(&mut lb);
+        }
+    } else {
+        for f in file_args {
+            let cf = if f.to_str() == Some("-") {
+                searcher.process_stdin(&mut lb)
+            } else {
+                searcher.process_path(&mut lb, Path::new(f))
+            };
+            if cf.is_break() {
+                break;
+            }
+        }
+    }
+
+    searcher.finish()
+}
+
+pub fn uu_app() -> Command {
+    Command::new("grep")
+        .version(env!("CARGO_PKG_VERSION"))
+        .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 \
+             fewer than two FILEs, assume -h.  Exit status is 0 if any line is selected, \
+             1 otherwise; if any error occurs and -q is not given, the exit status is 2.",
+        )
+        .arg(
+            Arg::new("patterns_or_files")
+                .help("Pattern (if no -e/-f) followed by files to search")
+                .index(1)
+                .num_args(0..)
+                .value_parser(clap::value_parser!(OsString)),
+        )
+        .arg(
+            Arg::new("extended_regexp")
+                .short('E')
+                .long("extended-regexp")
+                .help("PATTERNS are extended regular expressions")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("fixed_strings")
+                .short('F')
+                .long("fixed-strings")
+                .help("PATTERNS are strings")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("basic_regexp")
+                .short('G')
+                .long("basic-regexp")
+                .help("PATTERNS are basic regular expressions")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("perl_regexp")
+                .short('P')
+                .long("perl-regexp")
+                .help("PATTERNS are Perl regular expressions")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("regexp")
+                .short('e')
+                .long("regexp")
+                .value_name("PATTERNS")
+                .help("use PATTERNS for matching")
+                .action(ArgAction::Append)
+                .allow_hyphen_values(true),
+        )
+        .arg(
+            Arg::new("file_pattern")
+                .short('f')
+                .long("file")
+                .value_name("FILE")
+                .help("take PATTERNS from FILE")
+                .action(ArgAction::Append),
+        )
+        .arg(
+            Arg::new("ignore_case")
+                .short('i')
+                .long("ignore-case")
+                .short_alias('y')
+                .help("ignore case distinctions in patterns and data")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("no_ignore_case")
+                .long("no-ignore-case")
+                .help("do not ignore case distinctions (default)")
+                .action(ArgAction::SetTrue)
+                .overrides_with("ignore_case"),
+        )
+        .arg(
+            Arg::new("word_regexp")
+                .short('w')
+                .long("word-regexp")
+                .help("match only whole words")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("line_regexp")
+                .short('x')
+                .long("line-regexp")
+                .help("match only whole lines")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("null_data")
+                .short('z')
+                .long("null-data")
+                .help("a data line ends in 0 byte, not newline")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("no_messages")
+                .short('s')
+                .long("no-messages")
+                .help("suppress error messages")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("invert_match")
+                .short('v')
+                .long("invert-match")
+                .help("select non-matching lines")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("version")
+                .short('V')
+                .long("version")
+                .help("display version information and exit")
+                .action(ArgAction::Version),
+        )
+        .arg(
+            Arg::new("help")
+                .long("help")
+                .help("display this help text and exit")
+                .action(ArgAction::Help),
+        )
+        .arg(
+            Arg::new("max_count")
+                .short('m')
+                .long("max-count")
+                .value_name("NUM")
+                .help("stop after NUM selected lines")
+                .value_parser(clap::value_parser!(u64)),
+        )
+        .arg(
+            Arg::new("byte_offset")
+                .short('b')
+                .long("byte-offset")
+                .help("print the byte offset with output lines")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("line_number")
+                .short('n')
+                .long("line-number")
+                .help("print line number with output lines")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("line_buffered")
+                .long("line-buffered")
+                .help("flush output on every line")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("with_filename")
+                .short('H')
+                .long("with-filename")
+                .help("print file name with output lines")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("no_filename")
+                .short('h')
+                .long("no-filename")
+                .help("suppress the file name prefix on output")
+                .action(ArgAction::SetTrue)
+                .overrides_with("with_filename"),
+        )
+        .arg(
+            Arg::new("label")
+                .long("label")
+                .value_name("LABEL")
+                .help("use LABEL as the standard input file name prefix"),
+        )
+        .arg(
+            Arg::new("only_matching")
+                .short('o')
+                .long("only-matching")
+                .help("show only nonempty parts of lines that match")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("quiet")
+                .short('q')
+                .long("quiet")
+                .alias("silent")
+                .help("suppress all normal output")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("binary_files")
+                .long("binary-files")
+                .value_name("TYPE")
+                .help("assume that binary files are TYPE; TYPE is 'binary', 'text', or 'without-match'")
+                .value_parser(["binary", "text", "without-match"]),
+        )
+        .arg(
+            Arg::new("text")
+                .short('a')
+                .long("text")
+                .help("equivalent to --binary-files=text")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("skip_binary")
+                .short('I')
+                .help("equivalent to --binary-files=without-match")
+                .action(ArgAction::SetTrue),
+        )
+        .arg(
+            Arg::new("directories")
+                .short('d')
+                .long("directories")
+                .value_name("ACTION")
+                .help("how to handle directories; ACTION is 'read', 'recurse', or 'skip'")
+                .value_parser(["read", "skip", "recurse"]),
+        )
+        .arg(
+            Arg::new("devices")
+                .short('D')