Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ flate2 = "1.0"
quick-xml = "0.37"
which = "8"
automod = "1"
encoding_rs = "0.8"

[target.'cfg(unix)'.dependencies]
libc = "0.2"
Expand Down
99 changes: 77 additions & 22 deletions src/cmds/system/grep_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub fn run(
max_results: usize,
context_only: bool,
file_type: Option<&str>,
fixed: bool,
extra_args: &[String],
verbose: u8,
) -> Result<i32> {
Expand All @@ -25,27 +26,14 @@ pub fn run(
eprintln!("grep: '{}' in {}", pattern, path);
}

// Fix: convert BRE alternation \| → | for rg (which uses PCRE-style regex)
let rg_pattern = pattern.replace(r"\|", "|");

let mut rg_cmd = resolved_command("rg");
// --no-ignore-vcs: match grep -r behavior (don't skip .gitignore'd files).
// Without this, rg returns 0 matches for files in .gitignore, causing
// false negatives that make AI agents draw wrong conclusions.
// Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected.
rg_cmd.args(["-n", "--no-heading", "--no-ignore-vcs", &rg_pattern, path]);

if let Some(ft) = file_type {
rg_cmd.arg("--type").arg(ft);
}

for arg in extra_args {
// Fix: skip grep-ism -r flag (rg is recursive by default; rg -r means --replace)
if arg == "-r" || arg == "--recursive" {
continue;
}
rg_cmd.arg(arg);
}
rg_cmd.args(build_rg_args(
pattern,
path,
file_type,
fixed,
extra_args,
));

let result = exec_capture(&mut rg_cmd)
.or_else(|_| {
Expand All @@ -56,6 +44,13 @@ pub fn run(
})
.context("grep/rg failed")?;

if result.exit_code == 2 && !fixed && !result.stderr.trim().is_empty() {
let s = result.stderr.to_lowercase();
if s.contains("regex parse error") || s.contains("error parsing regex") {
eprintln!("rtk grep: regex parse error (hint: try `rtk grep --fixed ...`)");
}
}

// Passthrough output flags that produce output that is already small.
if has_format_flag(extra_args) {
print!("{}", result.stdout);
Expand Down Expand Up @@ -166,6 +161,50 @@ pub fn run(
Ok(exit_code)
}

fn build_rg_args(
pattern: &str,
path: &str,
file_type: Option<&str>,
fixed: bool,
extra_args: &[String],
) -> Vec<String> {
// Regex mode: convert BRE alternation \| → | for rg (which uses PCRE-style regex)
let rg_pattern = if fixed {
pattern.to_string()
} else {
pattern.replace(r"\|", "|")
};

// --no-ignore-vcs: match grep -r behavior (don't skip .gitignore'd files).
// Without this, rg returns 0 matches for files in .gitignore, causing
// false negatives that make AI agents draw wrong conclusions.
// Using --no-ignore-vcs (not --no-ignore) so .ignore/.rgignore are still respected.
let mut args = vec![
"-n".to_string(),
"--no-heading".to_string(),
"--no-ignore-vcs".to_string(),
];
if fixed {
args.push("-F".to_string());
}
if let Some(ft) = file_type {
args.push("--type".to_string());
args.push(ft.to_string());
}
args.push(rg_pattern);
args.push(path.to_string());

for arg in extra_args {
// Fix: skip grep-ism -r flag (rg is recursive by default; rg -r means --replace)
if arg == "-r" || arg == "--recursive" {
continue;
}
args.push(arg.clone());
}

args
}

fn has_format_flag(extra_args: &[String]) -> bool {
extra_args.iter().any(|arg| {
matches!(
Expand Down Expand Up @@ -294,8 +333,24 @@ mod tests {
#[test]
fn test_bre_alternation_translated() {
let pattern = r"fn foo\|pub.*bar";
let rg_pattern = pattern.replace(r"\|", "|");
assert_eq!(rg_pattern, "fn foo|pub.*bar");
let args = build_rg_args(pattern, ".", None, false, &[]);
assert!(args.iter().any(|a| a == "fn foo|pub.*bar"));
}

#[test]
fn test_fixed_grep_includes_dash_f_and_keeps_parens_literal() {
let pattern = "memcpy(szDummy";
let args = build_rg_args(pattern, ".", None, true, &[]);
assert!(args.iter().any(|a| a == "-F"));
assert!(args.iter().any(|a| a == pattern));
}

#[test]
fn test_fixed_grep_cpp_symbol_literal() {
let pattern = "AgcmUICharacter::OnAddModule";
let args = build_rg_args(pattern, ".", None, true, &[]);
assert!(args.iter().any(|a| a == "-F"));
assert!(args.iter().any(|a| a == pattern));
}

// Fix: -r flag (grep recursive) is stripped from extra_args (rg is recursive by default)
Expand Down
111 changes: 111 additions & 0 deletions src/cmds/system/patch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
use crate::core::text_encoding::{self, TextEncoding, UsedEncoding};
use anyhow::{anyhow, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};

pub struct PatchArgs<'a> {
pub file: &'a Path,
pub encoding: TextEncoding,
pub old: &'a str,
pub new: &'a str,
pub all: bool,
pub backup: bool,
}

pub fn run(args: PatchArgs<'_>, verbose: u8) -> Result<i32> {
if verbose > 0 {
eprintln!("rtk patch: {}", args.file.display());
}

let original = fs::read(args.file)
.with_context(|| format!("Failed to read file: {}", args.file.display()))?;

let decoded = text_encoding::decode_bytes(&original, args.encoding)
.with_context(|| format!("Failed to decode file: {}", args.file.display()))?;

if matches!(decoded.used, UsedEncoding::Utf16Le | UsedEncoding::Utf16Be) {
return Err(anyhow!(
"utf16 input is not supported by rtk patch (use a UTF-8/ANSI file)"
));
}

if decoded.used_fallback {
eprintln!(
"rtk patch: decoded {} as {}",
args.file.display(),
decoded.used.label()
);
}

let count = decoded.text.match_indices(args.old).count();
if count == 0 {
return Err(anyhow!("no matches for --replace in {}", args.file.display()));
}
if !args.all && count != 1 {
return Err(anyhow!(
"expected exactly 1 match for --replace (found {}); pass --all to replace all",
count
));
}

let replaced = if args.all {
decoded.text.replace(args.old, args.new)
} else {
decoded.text.replacen(args.old, args.new, 1)
};

let out = text_encoding::encode_text(&replaced, decoded.used)
.context("Failed to encode patched content")?;

if args.backup {
let backup_path = bak_path(args.file);
fs::write(&backup_path, &original).with_context(|| {
format!(
"Failed to write backup file: {}",
backup_path.display()
)
})?;
}

fs::write(args.file, out)
.with_context(|| format!("Failed to write file: {}", args.file.display()))?;

Ok(0)
}

fn bak_path(path: &Path) -> PathBuf {
let s = path.to_string_lossy();
PathBuf::from(format!("{}.bak", s))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::core::text_encoding::TextEncoding;
use tempfile::NamedTempFile;

#[test]
fn test_patch_preserves_non_utf8_bytes_latin1() -> Result<()> {
let f = NamedTempFile::new()?;
// Contains non-UTF8 bytes (0xFF, 0xFE) that must survive unchanged.
let original: Vec<u8> = b"AA OLD BB\n".iter().copied().chain([0xFF, 0xFE]).collect();
fs::write(f.path(), &original)?;

run(
PatchArgs {
file: f.path(),
encoding: TextEncoding::Latin1,
old: "OLD",
new: "NEW",
all: false,
backup: false,
},
0,
)?;

let out = fs::read(f.path())?;
assert!(out.starts_with(b"AA NEW BB\n"));
assert_eq!(&out[out.len() - 2..], &[0xFF, 0xFE]);
Ok(())
}
}
Loading