From 4ddafd807d76faa8ea5e0f9655dd92d50e5075b6 Mon Sep 17 00:00:00 2001 From: isink17 <39876158+isink17@users.noreply.github.com> Date: Mon, 11 May 2026 21:54:27 +0200 Subject: [PATCH] Fixed: - grep fixed-mode logic clarified and commented - GNU grep fallback now honors fixed mode with -F - rg extra args are inserted before pattern/path - patch wording changed from byte-preserving to encoding-aware best-effort roundtrip - auto encoding no longer guesses cp949; cp949 is explicit via --encoding cp949 - README updated with grep/read/patch usage, safety notes, and encoding behavior --- Cargo.lock | 12 ++- Cargo.toml | 1 + src/cmds/system/grep_cmd.rs | 99 ++++++++++++++----- src/cmds/system/patch.rs | 111 +++++++++++++++++++++ src/cmds/system/read.rs | 188 +++++++++++++++++++++++++---------- src/core/mod.rs | 1 + src/core/text_encoding.rs | 189 ++++++++++++++++++++++++++++++++++++ src/main.rs | 129 +++++++++++++++++++++--- 8 files changed, 645 insertions(+), 85 deletions(-) create mode 100644 src/cmds/system/patch.rs create mode 100644 src/core/text_encoding.rs diff --git a/Cargo.lock b/Cargo.lock index b7796e6d8b..74cbf5a39b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,15 @@ dependencies = [ "syn", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_home" version = "0.1.0" @@ -892,7 +901,7 @@ dependencies = [ [[package]] name = "rtk" -version = "0.36.0" +version = "0.34.3" dependencies = [ "anyhow", "automod", @@ -900,6 +909,7 @@ dependencies = [ "clap", "colored", "dirs", + "encoding_rs", "flate2", "getrandom 0.4.2", "ignore", diff --git a/Cargo.toml b/Cargo.toml index 726a01709c..b081012b9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 6a33cf3a44..a11f388eba 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -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 { @@ -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(|_| { @@ -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); @@ -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 { + // 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!( @@ -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) diff --git a/src/cmds/system/patch.rs b/src/cmds/system/patch.rs new file mode 100644 index 0000000000..447e0eba48 --- /dev/null +++ b/src/cmds/system/patch.rs @@ -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 { + 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 = 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(()) + } +} diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 3a8406c86e..f8cd040224 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -2,6 +2,7 @@ use crate::cmds::cpp::msbuild_cmd; use crate::core::filter::{self, FilterLevel, Language}; +use crate::core::text_encoding::{self, TextEncoding}; use crate::core::tracking; use anyhow::{Context, Result}; use lazy_static::lazy_static; @@ -21,7 +22,9 @@ pub fn run( level: FilterLevel, max_lines: Option, tail_lines: Option, + line_range: Option<(usize, usize)>, line_numbers: bool, + encoding: TextEncoding, verbose: u8, ) -> Result<()> { let timer = tracking::TimedExecution::start(); @@ -31,7 +34,14 @@ pub fn run( } // Read file content (handles UTF-16 LE/BE BOM — MSBuild logs on Windows) - let content = read_file_text(file)?; + let (content, used_encoding, used_fallback) = read_file_text(file, encoding)?; + if used_fallback { + eprintln!( + "rtk read: decoded {} as {}", + file.display(), + used_encoding.label() + ); + } // Auto-detect MSBuild log files and route through the msbuild filter. // Without this, `rtk read msbuild.log` (after `msbuild *> file.log`) would @@ -89,10 +99,13 @@ pub fn run( ); } - filtered = apply_line_window(&filtered, max_lines, tail_lines, &lang); + filtered = apply_line_window(&filtered, max_lines, tail_lines, line_range, &lang); let rtk_output = if line_numbers { - format_with_line_numbers(&filtered) + match line_range { + Some((start, _end)) => format_with_line_numbers_offset(&filtered, start), + None => format_with_line_numbers(&filtered), + } } else { filtered.clone() }; @@ -110,7 +123,9 @@ pub fn run_stdin( level: FilterLevel, max_lines: Option, tail_lines: Option, + line_range: Option<(usize, usize)>, line_numbers: bool, + _encoding: TextEncoding, verbose: u8, ) -> Result<()> { use std::io::{self, Read as IoRead}; @@ -153,10 +168,13 @@ pub fn run_stdin( ); } - filtered = apply_line_window(&filtered, max_lines, tail_lines, &lang); + filtered = apply_line_window(&filtered, max_lines, tail_lines, line_range, &lang); let rtk_output = if line_numbers { - format_with_line_numbers(&filtered) + match line_range { + Some((start, _end)) => format_with_line_numbers_offset(&filtered, start), + None => format_with_line_numbers(&filtered), + } } else { filtered.clone() }; @@ -219,43 +237,15 @@ fn maybe_apply_msbuild_filter(content: &str) -> Option { Some(out) } -fn read_file_text(path: &Path) -> Result { +fn read_file_text( + path: &Path, + encoding: TextEncoding, +) -> Result<(String, text_encoding::UsedEncoding, bool)> { let bytes = fs::read(path) .with_context(|| format!("Failed to read file: {}", path.display()))?; - decode_bytes(&bytes) - .with_context(|| format!("Failed to decode file: {}", path.display())) -} - -/// Decode raw bytes to UTF-8, detecting UTF-16 LE/BE and UTF-8 BOMs. -/// MSBuild log files on Windows are UTF-16 LE (BOM: FF FE) — without this -/// detection the read filter crashed with "stream did not contain valid UTF-8". -fn decode_bytes(bytes: &[u8]) -> Result { - if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { - return Ok(decode_utf16(&bytes[2..], true)); - } - if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF { - return Ok(decode_utf16(&bytes[2..], false)); - } - let payload = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { - &bytes[3..] - } else { - bytes - }; - String::from_utf8(payload.to_vec()).context("stream did not contain valid UTF-8") -} - -fn decode_utf16(bytes: &[u8], little_endian: bool) -> String { - let units: Vec = bytes - .chunks_exact(2) - .map(|c| { - if little_endian { - u16::from_le_bytes([c[0], c[1]]) - } else { - u16::from_be_bytes([c[0], c[1]]) - } - }) - .collect(); - String::from_utf16_lossy(&units) + let decoded = text_encoding::decode_bytes(&bytes, encoding) + .with_context(|| format!("Failed to decode file: {}", path.display()))?; + Ok((decoded.text, decoded.used, decoded.used_fallback)) } fn format_with_line_numbers(content: &str) -> String { @@ -268,12 +258,46 @@ fn format_with_line_numbers(content: &str) -> String { out } +fn format_with_line_numbers_offset(content: &str, start_line: usize) -> String { + let lines: Vec<&str> = content.lines().collect(); + let max_line_num = start_line.saturating_add(lines.len()).saturating_sub(1); + let width = max_line_num.to_string().len().max(1); + let mut out = String::new(); + for (i, line) in lines.iter().enumerate() { + out.push_str(&format!( + "{:>width$} │ {}\n", + start_line + i, + line, + width = width + )); + } + out +} + fn apply_line_window( content: &str, max_lines: Option, tail_lines: Option, + line_range: Option<(usize, usize)>, lang: &Language, ) -> String { + if let Some((start, end)) = line_range { + if start == 0 || end == 0 || end < start { + return String::new(); + } + let lines: Vec<&str> = content.lines().collect(); + let start_idx = start.saturating_sub(1).min(lines.len()); + let end_idx = end.min(lines.len()); + if end_idx <= start_idx { + return String::new(); + } + let mut result = lines[start_idx..end_idx].join("\n"); + if content.ends_with('\n') { + result.push('\n'); + } + return result; + } + if let Some(tail) = tail_lines { if tail == 0 { return String::new(); @@ -312,10 +336,40 @@ fn main() {{ )?; // Just verify it doesn't panic - run(file.path(), FilterLevel::Minimal, None, None, false, 0)?; + run( + file.path(), + FilterLevel::Minimal, + None, + None, + None, + false, + TextEncoding::Auto, + 0, + )?; Ok(()) } + #[test] + fn test_read_auto_fallback_cp949() -> Result<()> { + let mut file = NamedTempFile::new()?; + let (bytes, _, _) = encoding_rs::EUC_KR.encode("안녕\n"); + file.write_all(&bytes)?; + + let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?; + assert!(used_fallback); + assert_eq!(used, text_encoding::UsedEncoding::Cp949); + assert!(txt.contains("안녕")); + Ok(()) + } + + #[test] + fn test_apply_line_window_range() { + let lang = Language::Unknown; + let s = "a\nb\nc\nd\n"; + let out = apply_line_window(s, None, None, Some((2, 3)), &lang); + assert_eq!(out, "b\nc\n"); + } + #[test] fn test_stdin_support_signature() { // Test that run_stdin has correct signature and compiles @@ -411,25 +465,45 @@ fn main() {{ fn test_decode_utf16_le_bom() { // "abc" in UTF-16 LE with BOM let bytes: &[u8] = &[0xFF, 0xFE, b'a', 0, b'b', 0, b'c', 0]; - assert_eq!(decode_bytes(bytes).unwrap(), "abc"); + assert_eq!( + text_encoding::decode_bytes(bytes, TextEncoding::Auto) + .unwrap() + .text, + "abc" + ); } #[test] fn test_decode_utf16_be_bom() { // "abc" in UTF-16 BE with BOM let bytes: &[u8] = &[0xFE, 0xFF, 0, b'a', 0, b'b', 0, b'c']; - assert_eq!(decode_bytes(bytes).unwrap(), "abc"); + assert_eq!( + text_encoding::decode_bytes(bytes, TextEncoding::Auto) + .unwrap() + .text, + "abc" + ); } #[test] fn test_decode_utf8_bom_stripped() { let bytes: &[u8] = &[0xEF, 0xBB, 0xBF, b'h', b'i']; - assert_eq!(decode_bytes(bytes).unwrap(), "hi"); + assert_eq!( + text_encoding::decode_bytes(bytes, TextEncoding::Auto) + .unwrap() + .text, + "hi" + ); } #[test] fn test_decode_plain_utf8() { - assert_eq!(decode_bytes(b"plain text").unwrap(), "plain text"); + assert_eq!( + text_encoding::decode_bytes(b"plain text", TextEncoding::Auto) + .unwrap() + .text, + "plain text" + ); } #[test] @@ -440,7 +514,12 @@ fn main() {{ for c in line.encode_utf16() { bytes.extend_from_slice(&c.to_le_bytes()); } - assert_eq!(decode_bytes(&bytes).unwrap(), line); + assert_eq!( + text_encoding::decode_bytes(&bytes, TextEncoding::Auto) + .unwrap() + .text, + line + ); } #[test] @@ -451,28 +530,37 @@ fn main() {{ for c in "Build succeeded.\n".encode_utf16() { file.write_all(&c.to_le_bytes())?; } - run(file.path(), FilterLevel::Minimal, None, None, false, 0)?; + run( + file.path(), + FilterLevel::Minimal, + None, + None, + None, + false, + TextEncoding::Auto, + 0, + )?; Ok(()) } #[test] fn test_apply_line_window_tail_lines() { let input = "a\nb\nc\nd\n"; - let output = apply_line_window(input, None, Some(2), &Language::Unknown); + let output = apply_line_window(input, None, Some(2), None, &Language::Unknown); assert_eq!(output, "c\nd\n"); } #[test] fn test_apply_line_window_tail_lines_no_trailing_newline() { let input = "a\nb\nc\nd"; - let output = apply_line_window(input, None, Some(2), &Language::Unknown); + let output = apply_line_window(input, None, Some(2), None, &Language::Unknown); assert_eq!(output, "c\nd"); } #[test] fn test_apply_line_window_max_lines_still_works() { let input = "a\nb\nc\nd\n"; - let output = apply_line_window(input, Some(2), None, &Language::Unknown); + let output = apply_line_window(input, Some(2), None, None, &Language::Unknown); assert!(output.starts_with("a\n")); assert!(output.contains("more lines")); } diff --git a/src/core/mod.rs b/src/core/mod.rs index 01317e9425..a463d9c2d1 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -9,6 +9,7 @@ pub mod stream; pub mod tee; pub mod telemetry; pub mod telemetry_cmd; +pub mod text_encoding; pub mod toml_filter; pub mod tracking; pub mod utils; diff --git a/src/core/text_encoding.rs b/src/core/text_encoding.rs new file mode 100644 index 0000000000..822554f78e --- /dev/null +++ b/src/core/text_encoding.rs @@ -0,0 +1,189 @@ +use anyhow::{anyhow, Context, Result}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum TextEncoding { + Auto, + Utf8, + Cp949, + Latin1, + #[value(name = "windows-1252")] + Windows1252, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsedEncoding { + Utf8, + Utf16Le, + Utf16Be, + Cp949, + Latin1, + Windows1252, +} + +impl UsedEncoding { + pub fn label(self) -> &'static str { + match self { + UsedEncoding::Utf8 => "utf8", + UsedEncoding::Utf16Le => "utf16-le", + UsedEncoding::Utf16Be => "utf16-be", + UsedEncoding::Cp949 => "cp949", + UsedEncoding::Latin1 => "latin1", + UsedEncoding::Windows1252 => "windows-1252", + } + } +} + +pub struct DecodedText { + pub text: String, + pub used: UsedEncoding, + /// True when `--encoding auto` selected a non-UTF8 fallback. + pub used_fallback: bool, +} + +pub fn decode_bytes(bytes: &[u8], requested: TextEncoding) -> Result { + // Always honor UTF-16 BOMs first (MSBuild logs on Windows). + if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { + return Ok(DecodedText { + text: decode_utf16(&bytes[2..], true), + used: UsedEncoding::Utf16Le, + used_fallback: false, + }); + } + if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF { + return Ok(DecodedText { + text: decode_utf16(&bytes[2..], false), + used: UsedEncoding::Utf16Be, + used_fallback: false, + }); + } + + let (payload, had_utf8_bom) = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) { + (&bytes[3..], true) + } else { + (bytes, false) + }; + + match requested { + TextEncoding::Auto => { + if let Ok(s) = std::str::from_utf8(payload) { + return Ok(DecodedText { + text: s.to_string(), + used: UsedEncoding::Utf8, + used_fallback: false, + }); + } + + // Windows-ish fallbacks first (common for legacy C++ source / logs). + // + // Note: WINDOWS-1252 decoding is permissive for all bytes, so try a + // stricter multibyte encoding first when explicitly supported. + for enc in [TextEncoding::Cp949, TextEncoding::Windows1252] { + if let Ok(dt) = decode_bytes(payload, enc) { + return Ok(DecodedText { + text: if had_utf8_bom { + // Should not happen (UTF-8 BOM implies UTF-8), but keep behavior explicit. + dt.text + } else { + dt.text + }, + used: dt.used, + used_fallback: true, + }); + } + } + + // Last resort: byte-safe 1:1 mapping. + let dt = decode_bytes(payload, TextEncoding::Latin1)?; + Ok(DecodedText { + text: dt.text, + used: dt.used, + used_fallback: true, + }) + } + TextEncoding::Utf8 => Ok(DecodedText { + text: String::from_utf8(payload.to_vec()).context("stream did not contain valid UTF-8")?, + used: UsedEncoding::Utf8, + used_fallback: false, + }), + TextEncoding::Windows1252 => { + let (cow, _, had_errors) = encoding_rs::WINDOWS_1252.decode(payload); + if had_errors { + return Err(anyhow!("invalid bytes for windows-1252")); + } + Ok(DecodedText { + text: cow.into_owned(), + used: UsedEncoding::Windows1252, + used_fallback: false, + }) + } + TextEncoding::Cp949 => { + let (cow, _, had_errors) = encoding_rs::EUC_KR.decode(payload); + if had_errors { + return Err(anyhow!("invalid bytes for cp949")); + } + Ok(DecodedText { + text: cow.into_owned(), + used: UsedEncoding::Cp949, + used_fallback: false, + }) + } + TextEncoding::Latin1 => { + let text: String = payload.iter().map(|b| *b as char).collect(); + Ok(DecodedText { + text, + used: UsedEncoding::Latin1, + used_fallback: false, + }) + } + } +} + +pub fn encode_text(text: &str, encoding: UsedEncoding) -> Result> { + match encoding { + UsedEncoding::Utf8 => Ok(text.as_bytes().to_vec()), + UsedEncoding::Utf16Le => { + // Keep it simple: patch currently does not target UTF-16 paths. + Err(anyhow!("encoding utf16-le output is not supported")) + } + UsedEncoding::Utf16Be => Err(anyhow!("encoding utf16-be output is not supported")), + UsedEncoding::Windows1252 => { + let (cow, _, had_errors) = encoding_rs::WINDOWS_1252.encode(text); + if had_errors { + return Err(anyhow!("text not representable in windows-1252")); + } + Ok(cow.into_owned()) + } + UsedEncoding::Cp949 => { + let (cow, _, had_errors) = encoding_rs::EUC_KR.encode(text); + if had_errors { + return Err(anyhow!("text not representable in cp949")); + } + Ok(cow.into_owned()) + } + UsedEncoding::Latin1 => { + let mut out = Vec::with_capacity(text.len()); + for ch in text.chars() { + let u = ch as u32; + if u > 0xFF { + return Err(anyhow!("text not representable in latin1")); + } + out.push(u as u8); + } + Ok(out) + } + } +} + +fn decode_utf16(bytes: &[u8], little_endian: bool) -> String { + let units: Vec = bytes + .chunks_exact(2) + .map(|c| { + if little_endian { + u16::from_le_bytes([c[0], c[1]]) + } else { + u16::from_be_bytes([c[0], c[1]]) + } + }) + .collect(); + String::from_utf16_lossy(&units) +} diff --git a/src/main.rs b/src/main.rs index 4c2bff49af..ccc0514bfa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,7 +22,7 @@ use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; use cmds::rust::{cargo_cmd, runner}; use cmds::system::{ deps, env_cmd, find_cmd, format_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, - read, summary, tree, wc_cmd, + patch, read, summary, tree, wc_cmd, }; use anyhow::{Context, Result}; @@ -102,9 +102,37 @@ enum Commands { /// Keep only last N lines #[arg(long, conflicts_with = "max_lines")] tail_lines: Option, + /// Read only an inclusive line range (START:END, 1-based) + #[arg(long, conflicts_with_all = ["max_lines", "tail_lines"])] + lines: Option, /// Show line numbers #[arg(short = 'n', long)] line_numbers: bool, + /// Input encoding for file decoding + #[arg(long, value_enum, default_value = "auto")] + encoding: core::text_encoding::TextEncoding, + }, + + /// Encoding-aware file patch helper (best-effort roundtrip) + Patch { + /// File to patch + #[arg(long)] + file: PathBuf, + /// Input/output encoding + #[arg(long, value_enum, default_value = "auto")] + encoding: core::text_encoding::TextEncoding, + /// Replace exactly one match by default + #[arg(long = "replace")] + old: String, + /// Replacement string + #[arg(long = "with")] + new: String, + /// Replace all matches + #[arg(long)] + all: bool, + /// Write `.bak` before patching + #[arg(long)] + backup: bool, }, /// Generate 2-line technical summary (heuristic-based) @@ -298,6 +326,7 @@ enum Commands { }, /// Compact grep - strips whitespace, truncates, groups by file + #[command(alias = "fgrep")] Grep { /// Pattern to search pattern: String, @@ -319,6 +348,12 @@ enum Commands { /// Show line numbers (always on, accepted for grep/rg compatibility) #[arg(short = 'n', long)] line_numbers: bool, + /// Treat pattern as a literal string (fixed) + #[arg(long, conflicts_with = "regex")] + fixed: bool, + /// Treat pattern as a regular expression + #[arg(long, conflicts_with = "fixed")] + regex: bool, /// Extra ripgrep arguments (e.g., -i, -A 3, -w, --glob) #[arg(trailing_var_arg = true, allow_hyphen_values = true)] extra_args: Vec, @@ -1432,6 +1467,25 @@ fn validate_pnpm_filters(filters: &[String], command: &PnpmCommands) -> Option std::result::Result<(usize, usize), String> { + let (start_s, end_s) = spec + .split_once(':') + .ok_or_else(|| "expected START:END".to_string())?; + let start: usize = start_s + .parse() + .map_err(|_| "START must be a positive integer".to_string())?; + let end: usize = end_s + .parse() + .map_err(|_| "END must be a positive integer".to_string())?; + if start == 0 || end == 0 { + return Err("START and END must be >= 1".to_string()); + } + if end < start { + return Err("END must be >= START".to_string()); + } + Ok((start, end)) +} + fn main() { let code = match run_cli() { Ok(code) => code, @@ -1481,10 +1535,23 @@ fn run_cli() -> Result { level, max_lines, tail_lines, + lines, line_numbers, + encoding, } => { let mut had_error = false; let mut stdin_seen = false; + let line_range = match lines.as_deref() { + None => Ok(None), + Some(spec) => parse_line_range(spec).map(Some), + }; + let line_range = match line_range { + Ok(v) => v, + Err(e) => { + eprintln!("rtk read: invalid --lines '{}': {}", lines.unwrap_or_default(), e); + return Ok(2); + } + }; for file in &files { let result = if file == Path::new("-") { if stdin_seen { @@ -1492,14 +1559,24 @@ fn run_cli() -> Result { continue; } stdin_seen = true; - read::run_stdin(level, max_lines, tail_lines, line_numbers, cli.verbose) + read::run_stdin( + level, + max_lines, + tail_lines, + line_range, + line_numbers, + encoding, + cli.verbose, + ) } else { read::run( file, level, max_lines, tail_lines, + line_range, line_numbers, + encoding, cli.verbose, ) }; @@ -1515,6 +1592,25 @@ fn run_cli() -> Result { } } + Commands::Patch { + file, + encoding, + old, + new, + all, + backup, + } => patch::run( + patch::PatchArgs { + file: &file, + encoding, + old: &old, + new: &new, + all, + backup, + }, + cli.verbose, + )?, + Commands::Smart { file, model, @@ -1837,17 +1933,25 @@ fn run_cli() -> Result { context_only, file_type, line_numbers: _, // no-op: line numbers always enabled in grep_cmd::run + fixed, + regex, extra_args, - } => grep_cmd::run( - &pattern, - &path, - max_len, - max, - context_only, - file_type.as_deref(), - &extra_args, - cli.verbose, - )?, + } => { + // Default to fixed/literal search for agent safety; --regex opts into regex mode. + // --fixed is accepted as an explicit/no-op compatibility flag. + let fixed_mode = fixed || !regex; + grep_cmd::run( + &pattern, + &path, + max_len, + max, + context_only, + file_type.as_deref(), + fixed_mode, + &extra_args, + cli.verbose, + )? + } Commands::Init { global, @@ -2544,6 +2648,7 @@ fn is_operational_command(cmd: &Commands) -> bool { Commands::Ls { .. } | Commands::Tree { .. } | Commands::Read { .. } + | Commands::Patch { .. } | Commands::Smart { .. } | Commands::Git { .. } | Commands::Gh { .. }