diff --git a/Cargo.lock b/Cargo.lock index 8773a0899..e26e2fa51 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" @@ -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 15d76d88b..a11782a95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,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/README.md b/README.md index d91e21b24..2ada83d6d 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,7 @@ 中文日本語한국어 • - Espanol • - Português + Espanol

--- @@ -120,6 +119,36 @@ git status # Automatically rewritten to rtk git status Hook-based agents rewrite Bash commands (e.g., `git status` -> `rtk git status`) before execution. Plugin-based agents, including Hermes, use their plugin API to rewrite commands before execution. The agent receives compact output without needing to call `rtk` explicitly. +## Grep (agent-friendly) + +```bash +rtk grep "Foo" . --files-only +rtk grep "Foo" . --count-by-file +rtk grep "Foo" . --top-files 10 +rtk grep "Foo" . --agent-safe +rtk grep "Foo" . --agent-safe --max-per-file 30 +rtk grep "Foo" . --json +rtk grep "Foo" . --agent-safe --json +rtk grep "Foo" . --all --full-lines + +# Opt-in preset for agents (grep only in this slice): +RTK_AGENT_SAFE=1 rtk grep "Foo" . +``` + +Notes: +- `--files-only`: locator mode (paths only) +- `--count-by-file`: counts per file +- `--top-files N`: ranked file summary (top N files) +- `--agent-safe`: caps match spam + adds summary/hints (flags override env/config) +- `--json`: machine-readable JSON only (no human text) +- `--all`: disables match caps +- `--full-lines`: disables line clipping + +PowerShell: +```powershell +$env:RTK_AGENT_SAFE="1"; rtk grep "Foo" src +``` + **Important:** the hook only runs on Bash tool calls. Claude Code built-in tools like `Read`, `Grep`, and `Glob` do not pass through the Bash hook, so they are not auto-rewritten. To get RTK's compact output for those workflows, use shell commands (`cat`/`head`/`tail`, `rg`/`grep`, `find`) or call `rtk read`, `rtk grep`, or `rtk find` directly. ## How It Works @@ -147,9 +176,15 @@ Four strategies applied per command type: rtk ls . # Token-optimized directory tree rtk read file.rs # Smart file reading rtk read file.rs -l aggressive # Signatures only (strips bodies) +rtk read file.rs --lines 430:540 # Inclusive line range (1-based) rtk smart file.rs # 2-line heuristic code summary rtk find "*.rs" . # Compact find results -rtk grep "pattern" . # Grouped search results +rtk grep "pattern" . # Grouped search results (legacy defaults) +rtk grep "Foo" . --files-only # Unique matching file paths +rtk grep "Foo" . --count-by-file # Counts per file +rtk grep "Foo" . --agent-safe # Token-safe preset (caps + clipping + summary) +rtk grep "Foo" . --agent-safe --max-per-file 30 +rtk grep "Foo" . --all --full-lines # Legacy full output (uncapped + unclipped) rtk diff file1 file2 # Condensed diff ``` @@ -209,6 +244,26 @@ rtk bundle install # Ruby gems (strip Using lines) rtk prisma generate # Schema generation (no ASCII art) ``` +### C++ / CMake / MSBuild +```bash +rtk cmake --build # Build errors only (-85%) +rtk cmake -B # Configure: errors + key settings only (-70%) +rtk make [-j] # Make errors only (-80%) +rtk ninja [-C ] # Ninja build errors only (-80%) +rtk ctest [--test-dir dir] # Failed tests only (-85%) +rtk msbuild [sln] [flags] # MSVC/LNK errors only, no project noise (-80%) +rtk codegraph index # Summary only, no per-file progress (-90%) +``` + +### PowerShell +```powershell +Select-String -Path f -Pattern p # auto-rewritten -> rtk grep +Get-Content # auto-rewritten -> rtk read +GC # auto-rewritten -> rtk read +rtk read --lines 430:540 # Prefer over Get-Content line-range loops +Remove-Item -Force # -> ok +``` + ### AWS ```bash rtk aws sts get-caller-identity # One-line identity @@ -238,6 +293,7 @@ rtk json config.json # Structure without values rtk deps # Dependencies summary rtk env -f AWS # Filtered env vars rtk log app.log # Deduplicated logs +rtk log ERRORLOG.TXT --events 20 # Tail key error/assert events (deduped) rtk curl # Truncate + save full output rtk wget # Download, strip progress bars rtk summary # Heuristic summary @@ -314,6 +370,22 @@ rtk init --show # Verify installation After install, **restart Claude Code**. +### Commands Rewritten + +Selected entries (full set covered by the hook): + +| Raw Command | Rewritten To | +|---|---| +| `cmake --build / cmake -B` | `rtk cmake ...` | +| `ctest` | `rtk ctest` | +| `make` (non-lifecycle targets) | `rtk make` | +| `ninja` | `rtk ninja` | +| `msbuild` | `rtk msbuild` | +| `codegraph index/update/search/...` | `rtk codegraph ...` | +| `Select-String -Path f -Pattern p` | `rtk grep p f` | +| `Get-Content / GC` | `rtk read` | +| `Remove-Item` | `rtk remove-item` | + ## Windows RTK works on Windows with some limitations. The auto-rewrite hook (`rtk-rewrite.sh`) requires a Unix shell, so on native Windows RTK falls back to **CLAUDE.md injection mode** — your AI assistant receives RTK instructions but commands are not rewritten automatically. diff --git a/src/cmds/cpp/cmake_cmd.rs b/src/cmds/cpp/cmake_cmd.rs new file mode 100644 index 000000000..c45adaf61 --- /dev/null +++ b/src/cmds/cpp/cmake_cmd.rs @@ -0,0 +1,278 @@ +//! Filters cmake build/configure output — keep diagnostics, drop progress noise. + +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::resolved_command; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + // GCC/Clang diagnostic: file.cpp:line:col: error|warning|note: message + static ref GCC_DIAG_RE: Regex = + Regex::new(r"^[^:\s].*:\d+:\d+:\s+(?:error|warning|note|fatal error):").unwrap(); + // make[N]: *** error + static ref MAKE_ERR_RE: Regex = Regex::new(r"^make(\[\d+\])?:\s+\*\*\*").unwrap(); + // [ N%] Building CXX object ... or [ N%] Linking ... + static ref CMAKE_PROGRESS_RE: Regex = + Regex::new(r"^\[\s*\d+%\]\s+(Building|Linking|Built target|Generating|Built)").unwrap(); + // ninja-style progress: [N/M] Building ... + static ref NINJA_PROGRESS_RE: Regex = + Regex::new(r"^\[\d+/\d+\]\s+(Building|Linking|Generating)").unwrap(); + // CMake configure noise lines + static ref CMAKE_PROBE_RE: Regex = Regex::new( + r"^-- (Check for working|Detecting|Looking for|Found|Performing Test|Checking|Could NOT find)" + ) + .unwrap(); +} + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("cmake"); + for a in args { + cmd.arg(a); + } + + if verbose > 0 { + eprintln!("Running: cmake {}", args.join(" ")); + } + + let is_build = args.iter().any(|a| a == "--build"); + let args_owned = args.to_vec(); + runner::run_filtered( + cmd, + "cmake", + &args.join(" "), + move |raw| { + if is_build { + filter_build(raw, &args_owned) + } else { + filter_configure(raw) + } + }, + RunOptions::with_tee("cmake"), + ) +} + +fn filter_build(raw: &str, args: &[String]) -> String { + let mut out = Vec::new(); + let mut diag_context = 0usize; + let mut emitted_diag = false; + let mut file_count = 0usize; + + for line in raw.lines() { + if CMAKE_PROGRESS_RE.is_match(line) || NINJA_PROGRESS_RE.is_match(line) { + file_count += 1; + continue; + } + if line.contains("Entering directory") || line.contains("Leaving directory") { + continue; + } + + if GCC_DIAG_RE.is_match(line) { + out.push(line.to_string()); + diag_context = 3; + emitted_diag = true; + continue; + } + if MAKE_ERR_RE.is_match(line) { + out.push(line.to_string()); + emitted_diag = true; + diag_context = 0; + continue; + } + if line.contains("error:") || line.contains("undefined reference") { + out.push(line.to_string()); + emitted_diag = true; + diag_context = 2; + continue; + } + if diag_context > 0 { + // Source context lines (typical clang/gcc): ' 42 | code' + // ' | ^~~~' + let trimmed = line.trim_start(); + if trimmed.is_empty() + || trimmed.starts_with('|') + || line.starts_with(' ') + || line.starts_with('\t') + || trimmed.chars().take_while(|c| c.is_ascii_digit()).count() > 0 + { + out.push(line.to_string()); + diag_context -= 1; + continue; + } + diag_context = 0; + } + } + + if !emitted_diag { + let target = args + .iter() + .position(|a| a == "--target") + .and_then(|i| args.get(i + 1)) + .map(String::as_str) + .unwrap_or_else(|| { + args.iter() + .position(|a| a == "--build") + .and_then(|i| args.get(i + 1)) + .map(|s| s.trim_start_matches("./")) + .unwrap_or("") + }); + if target.is_empty() { + return format!("cmake: ok ({} files)", file_count); + } + return format!("cmake: ok {} ({} files)", target, file_count); + } + + out.join("\n") +} + +fn filter_configure(raw: &str) -> String { + let mut out = Vec::new(); + let mut had_error = false; + + for line in raw.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if trimmed.starts_with("CMake Error") || trimmed.starts_with("CMake Warning") { + out.push(line.to_string()); + had_error = trimmed.starts_with("CMake Error") || had_error; + continue; + } + if line.starts_with("ERROR") || line.contains("error:") { + out.push(line.to_string()); + had_error = true; + continue; + } + if let Some(rest) = line.strip_prefix("-- ") { + if CMAKE_PROBE_RE.is_match(line) { + continue; + } + // Keep notable lines: Configuring done, Build files written, Build type, Install prefix, etc. + if rest.starts_with("Configuring done") + || rest.starts_with("Generating done") + || rest.starts_with("Build files have been written") + || rest.starts_with("Build type") + || rest.starts_with("Install prefix") + || rest.starts_with("The C compiler identification") + || rest.starts_with("The CXX compiler identification") + || rest.starts_with("Configuring incomplete") + { + out.push(line.to_string()); + } + continue; + } + } + + if out.is_empty() { + let _ = had_error; + return "cmake: configure ok".to_string(); + } + out.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(s: &str) -> usize { + s.split_whitespace().count() + } + + #[test] + fn test_build_success_summary() { + let raw = "[ 10%] Building CXX object CMakeFiles/myapp.dir/main.cpp.o\n\ + [ 50%] Building CXX object CMakeFiles/myapp.dir/util.cpp.o\n\ + [100%] Linking CXX executable myapp\n\ + [100%] Built target myapp\n"; + let args = vec!["--build".to_string(), "build".to_string()]; + let out = filter_build(raw, &args); + assert!(out.starts_with("cmake: ok")); + assert!(out.contains("4 files")); + } + + #[test] + fn test_build_failure_keeps_diag() { + let raw = "[ 50%] Building CXX object CMakeFiles/x.dir/main.cpp.o\n\ + /tmp/main.cpp:3:5: error: 'foo' was not declared in this scope\n\ + 3 | foo();\n\ + | ^~~\n\ + make[2]: *** [CMakeFiles/x.dir/main.cpp.o] Error 1\n\ + make[1]: *** [CMakeFiles/x.dir/all] Error 2\n"; + let args = vec!["--build".to_string(), "build".to_string()]; + let out = filter_build(raw, &args); + assert!(out.contains("error: 'foo'")); + assert!(out.contains("make[2]: ***")); + assert!(!out.contains("Building CXX")); + } + + #[test] + fn test_configure_strips_probes() { + let raw = "-- The C compiler identification is GNU 13\n\ + -- Detecting C compiler ABI info\n\ + -- Detecting C compiler ABI info - done\n\ + -- Check for working C compiler: /usr/bin/cc\n\ + -- Looking for sys/types.h\n\ + -- Looking for sys/types.h - found\n\ + -- Configuring done\n\ + -- Generating done\n\ + -- Build files have been written to: /tmp/build\n"; + let out = filter_configure(raw); + assert!(out.contains("Configuring done")); + assert!(out.contains("Build files have been written")); + assert!(!out.contains("Detecting")); + assert!(!out.contains("Looking for")); + } + + #[test] + fn test_configure_keeps_errors() { + let raw = "-- Configuring incomplete, errors occurred!\n\ + CMake Error at CMakeLists.txt:5 (find_package):\n\ + Could not find FooBar.\n"; + let out = filter_configure(raw); + assert!(out.contains("CMake Error")); + assert!(out.contains("Configuring incomplete")); + } + + #[test] + fn test_fixture_build_success() { + let raw = include_str!("../../../tests/fixtures/cpp/cmake_build_success.txt"); + let args = vec!["--build".to_string(), "build".to_string()]; + let out = filter_build(raw, &args); + assert!(out.starts_with("cmake: ok")); + let savings = + 100.0 - (count_tokens(&out) as f64 / count_tokens(raw) as f64 * 100.0); + assert!(savings >= 60.0, "expected >=60%, got {:.1}%", savings); + } + + #[test] + fn test_fixture_build_failure() { + let raw = include_str!("../../../tests/fixtures/cpp/cmake_build_failure.txt"); + let args = vec!["--build".to_string(), "build".to_string()]; + let out = filter_build(raw, &args); + assert!(out.contains("error:")); + assert!(out.contains("make[2]: ***")); + assert!(!out.contains("Building CXX object")); + } + + #[test] + fn test_fixture_configure() { + let raw = include_str!("../../../tests/fixtures/cpp/cmake_configure.txt"); + let out = filter_configure(raw); + assert!(out.contains("Configuring done")); + assert!(!out.contains("Detecting")); + assert!(!out.contains("Looking for")); + } + + #[test] + fn test_savings_build_success() { + let raw = (0..50) + .map(|i| format!("[{:>3}%] Building CXX object CMakeFiles/lib.dir/file{}.cpp.o", i * 2, i)) + .collect::>() + .join("\n"); + let args = vec!["--build".to_string(), "build".to_string()]; + let out = filter_build(&raw, &args); + let savings = 100.0 - (count_tokens(&out) as f64 / count_tokens(&raw) as f64 * 100.0); + assert!(savings >= 60.0, "expected >=60%, got {:.1}%", savings); + } +} diff --git a/src/cmds/cpp/codegraph_cmd.rs b/src/cmds/cpp/codegraph_cmd.rs new file mode 100644 index 000000000..d2c9c3907 --- /dev/null +++ b/src/cmds/cpp/codegraph_cmd.rs @@ -0,0 +1,289 @@ +//! Filters codegraph CLI output — strips per-file progress, truncates result lists. +//! +//! IMPORTANT: `codegraph affected-tests` is NEVER truncated — its output is consumed +//! by CI scripts. The MCP server (`codegraph serve`) is never routed here. + +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::resolved_command; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; + +const RESULT_LIMIT: usize = 20; + +lazy_static! { + static ref PARSING_RE: Regex = Regex::new(r"^Parsing\s+\S+\s+ok\s+\(\d+\s+symbols?\)").unwrap(); + static ref UPDATE_FILE_RE: Regex = + Regex::new(r"^(Updating|Reparsing|Removing)\s+\S+").unwrap(); + static ref STATS_LINE_RE: Regex = + Regex::new(r"^\s*(Files|Symbols|Edges|Errors|Languages|Repos|Indexed)\s*:").unwrap(); + static ref ERROR_RE: Regex = Regex::new(r"(?i)\b(error|failed)\b").unwrap(); +} + +pub fn run_index(args: &[String], verbose: u8) -> Result { + run_index_like("index", args, verbose) +} + +pub fn run_update(args: &[String], verbose: u8) -> Result { + run_index_like("update", args, verbose) +} + +fn run_index_like(sub: &'static str, args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("codegraph"); + cmd.arg(sub); + for a in args { + cmd.arg(a); + } + if verbose > 0 { + eprintln!("Running: codegraph {} {}", sub, args.join(" ")); + } + runner::run_filtered( + cmd, + &format!("codegraph {}", sub), + &args.join(" "), + move |raw| filter_index(raw, sub), + RunOptions::with_tee("codegraph"), + ) +} + +pub fn run_stats(args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("codegraph"); + cmd.arg("stats"); + for a in args { + cmd.arg(a); + } + if verbose > 0 { + eprintln!("Running: codegraph stats {}", args.join(" ")); + } + runner::run_filtered( + cmd, + "codegraph stats", + &args.join(" "), + filter_stats, + RunOptions::default(), + ) +} + +pub fn run_search_like(sub: &'static str, args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("codegraph"); + cmd.arg(sub); + for a in args { + cmd.arg(a); + } + if verbose > 0 { + eprintln!("Running: codegraph {} {}", sub, args.join(" ")); + } + runner::run_filtered( + cmd, + &format!("codegraph {}", sub), + &args.join(" "), + |raw| filter_results(raw, RESULT_LIMIT), + RunOptions::default(), + ) +} + +pub fn run_affected_tests(args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("codegraph"); + cmd.arg("affected-tests"); + for a in args { + cmd.arg(a); + } + if verbose > 0 { + eprintln!("Running: codegraph affected-tests {}", args.join(" ")); + } + // Pass-through completely unchanged — output is consumed by CI scripts. + runner::run_filtered( + cmd, + "codegraph affected-tests", + &args.join(" "), + |raw| raw.trim_end().to_string(), + RunOptions::default(), + ) +} + +fn filter_index(raw: &str, sub: &str) -> String { + let mut errors = Vec::new(); + let mut summary = SummaryStats::default(); + + for line in raw.lines() { + let trimmed = line.trim(); + if PARSING_RE.is_match(trimmed) || UPDATE_FILE_RE.is_match(trimmed) { + continue; + } + if let Some(rest) = trimmed.strip_prefix("Files:") { + summary.files = parse_first_num(rest); + } else if let Some(rest) = trimmed.strip_prefix("Files changed:") { + summary.files = parse_first_num(rest); + summary.has_changed = true; + } else if let Some(rest) = trimmed.strip_prefix("Symbols:") { + summary.symbols = parse_first_num(rest); + } else if let Some(rest) = trimmed.strip_prefix("New symbols:") { + summary.new_symbols = parse_first_num(rest); + } else if let Some(rest) = trimmed.strip_prefix("Edges:") { + summary.edges = parse_first_num(rest); + } else if let Some(rest) = trimmed.strip_prefix("Errors:") { + summary.errors = parse_first_num(rest); + } else if let Some(rest) = trimmed.strip_prefix("Time:") { + summary.time = rest.trim().to_string(); + } else if trimmed.contains("error:") || trimmed.starts_with("Error") { + errors.push(trimmed.to_string()); + } + } + + let mut out = String::new(); + if sub == "update" { + out.push_str(&format!( + "codegraph update: {} files changed", + summary.files.unwrap_or(0) + )); + if let Some(n) = summary.new_symbols { + out.push_str(&format!(" +{} symbols", n)); + } + } else { + out.push_str(&format!( + "codegraph index: {} files", + summary.files.unwrap_or(0) + )); + if let Some(n) = summary.symbols { + out.push_str(&format!(" {} symbols", n)); + } + if let Some(n) = summary.edges { + out.push_str(&format!(" {} edges", n)); + } + } + if !summary.time.is_empty() { + out.push_str(&format!(" {}", summary.time)); + } + if let Some(n) = summary.errors { + if n > 0 { + out.push_str(&format!("\nErrors: {}", n)); + for e in errors.iter().take(20) { + out.push('\n'); + out.push_str(e); + } + } + } + out +} + +#[derive(Default)] +struct SummaryStats { + files: Option, + symbols: Option, + new_symbols: Option, + edges: Option, + errors: Option, + time: String, + #[allow(dead_code)] + has_changed: bool, +} + +fn parse_first_num(s: &str) -> Option { + s.split(|c: char| !c.is_ascii_digit()) + .find(|t| !t.is_empty()) + .and_then(|t| t.parse().ok()) +} + +fn filter_stats(raw: &str) -> String { + let mut out = Vec::new(); + for line in raw.lines() { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + continue; + } + let stripped = trimmed.trim_start(); + if stripped.chars().all(|c| matches!(c, '-' | '=' | '*' | '+')) { + continue; + } + out.push(trimmed.to_string()); + } + out.join("\n") +} + +fn filter_results(raw: &str, limit: usize) -> String { + let mut lines: Vec<&str> = raw + .lines() + .filter(|l| !l.trim().is_empty()) + .collect(); + let total = lines.len(); + if total <= limit { + return raw.trim_end().to_string(); + } + lines.truncate(limit); + let mut out = lines.join("\n"); + out.push_str(&format!( + "\n(+{} more — run codegraph search directly for full results)", + total - limit + )); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_index_strips_parsing_lines() { + let raw = "Parsing src/foo.rs ok (12 symbols)\n\ + Parsing src/bar.rs ok (8 symbols)\n\ + Parsing src/baz.rs ok (3 symbols)\n\ + Files: 3\n\ + Symbols: 23\n\ + Edges: 47\n\ + Errors: 0\n\ + Time: 0.4s\n"; + let out = filter_index(raw, "index"); + assert!(out.starts_with("codegraph index:")); + assert!(out.contains("3 files")); + assert!(out.contains("23 symbols")); + assert!(!out.contains("Parsing")); + } + + #[test] + fn test_search_truncates() { + let raw = (1..=50) + .map(|i| format!("result_{}: foo at file{}.rs:10", i, i)) + .collect::>() + .join("\n"); + let out = filter_results(&raw, 20); + assert!(out.contains("result_1:")); + assert!(out.contains("result_20:")); + assert!(!out.contains("result_21:")); + assert!(out.contains("(+30 more")); + } + + #[test] + fn test_search_no_truncate_under_limit() { + let raw = "a\nb\nc"; + assert_eq!(filter_results(raw, 20), "a\nb\nc"); + } + + #[test] + fn test_fixture_index_verbose() { + let raw = include_str!("../../../tests/fixtures/cpp/codegraph_index_verbose.txt"); + let out = filter_index(raw, "index"); + assert!(out.starts_with("codegraph index:")); + assert!(out.contains("21 files")); + assert!(out.contains("771 symbols")); + assert!(out.contains("2412 edges")); + assert!(!out.contains("Parsing src/")); + } + + #[test] + fn test_fixture_search_results_truncate() { + let raw = include_str!("../../../tests/fixtures/cpp/codegraph_search_results.txt"); + let out = filter_results(raw, RESULT_LIMIT); + assert!(out.contains("(+30 more")); + assert!(out.contains("parse_expr at src/parser.rs:42")); + } + + #[test] + fn test_stats_strips_separators() { + let raw = "==========\nFiles: 47\n----------\nSymbols: 2841\n"; + let out = filter_stats(raw); + assert!(out.contains("Files: 47")); + assert!(out.contains("Symbols: 2841")); + assert!(!out.contains("====")); + assert!(!out.contains("----")); + } +} diff --git a/src/cmds/cpp/ctest_cmd.rs b/src/cmds/cpp/ctest_cmd.rs new file mode 100644 index 000000000..30f8ecf21 --- /dev/null +++ b/src/cmds/cpp/ctest_cmd.rs @@ -0,0 +1,361 @@ +//! Filters ctest output — keep GoogleTest failures and final summaries. + +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::resolved_command; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("ctest"); + for a in args { + cmd.arg(a); + } + if verbose > 0 { + eprintln!("Running: ctest {}", args.join(" ")); + } + runner::run_filtered( + cmd, + "ctest", + &args.join(" "), + filter_ctest_output, + RunOptions::with_tee("ctest"), + ) +} + +pub(crate) fn filter_ctest_output(output: &str) -> String { + let mut out: Vec = Vec::new(); + let mut current_test: Option = None; + let mut emitting_failure_block = false; + let mut emitted_test_header_for_block = false; + + for line in output.lines() { + let trimmed = line.trim_end(); + let t = trimmed.trim(); + let m = normalize_ctest_verbose_prefix(t); + + if t.is_empty() { + if emitting_failure_block { + out.push(String::new()); + } + continue; + } + + if let Some(name) = parse_gtest_run(m) { + current_test = Some(name); + emitting_failure_block = false; + emitted_test_header_for_block = false; + continue; + } + + if is_gtest_ok(m) { + emitting_failure_block = false; + emitted_test_header_for_block = false; + current_test = None; + continue; + } + + if is_gtest_failure_header(m) { + if !emitted_test_header_for_block { + if let Some(name) = current_test.as_deref() { + out.push(name.to_string()); + } + emitted_test_header_for_block = true; + } + emitting_failure_block = true; + out.push(trimmed.to_string()); + continue; + } + + if is_gtest_failed_summary_line(m) { + emitting_failure_block = false; + emitted_test_header_for_block = false; + out.push(trimmed.to_string()); + continue; + } + + if is_ctest_summary_line(m) { + emitting_failure_block = false; + emitted_test_header_for_block = false; + out.push(trimmed.to_string()); + continue; + } + + if is_important_non_gtest_line(m) { + out.push(trimmed.to_string()); + continue; + } + + if emitting_failure_block { + if is_gtest_failed_test_line(m) { + out.push(trimmed.to_string()); + emitting_failure_block = false; + emitted_test_header_for_block = false; + current_test = None; + continue; + } + + if is_noisy_separator(m) { + continue; + } + + out.push(trimmed.to_string()); + } + } + + compact_lines(out).join("\n") +} + +fn normalize_ctest_verbose_prefix(line: &str) -> &str { + let s = line.trim_start(); + let mut i = 0; + for (idx, ch) in s.char_indices() { + if ch.is_ascii_digit() { + i = idx + ch.len_utf8(); + continue; + } + i = idx; + break; + } + if i == 0 { + return line; + } + let rest = &s[i..]; + if !rest.starts_with(':') { + return line; + } + let rest = &rest[1..]; + let rest = rest.strip_prefix(' ').unwrap_or(rest); + if rest.is_empty() { + line + } else { + rest + } +} + +fn parse_gtest_run(line: &str) -> Option { + if !line.starts_with("[ RUN") { + return None; + } + let idx = line.find("]")?; + let rest = line[idx + 1..].trim(); + if rest.is_empty() { + None + } else { + Some(rest.to_string()) + } +} + +fn is_gtest_ok(line: &str) -> bool { + line.starts_with("[ OK ]") +} + +fn is_gtest_failure_header(line: &str) -> bool { + line.ends_with(": Failure") +} + +fn is_gtest_failed_test_line(line: &str) -> bool { + line.starts_with("[ FAILED ]") && line.contains('(') +} + +fn is_gtest_failed_summary_line(line: &str) -> bool { + if line.starts_with("[ FAILED ]") { + return true; + } + if line.starts_with("[ PASSED ]") && line.contains("tests") { + return true; + } + false +} + +fn is_ctest_summary_line(line: &str) -> bool { + let l = line.to_lowercase(); + l.contains("tests failed") + || l.contains("% tests passed") + || l.contains("the following tests failed") + || l.contains("errors while running ctest") + || l.contains("ctest error") +} + +fn is_important_non_gtest_line(line: &str) -> bool { + let l = line.to_lowercase(); + l.contains("addresssanitizer") + || l.contains("runtime error") + || l.contains("undefined behavior") + || l.contains("segmentation fault") + || l.contains("abort") + || l.starts_with("error:") + || l.contains("fatal error") + || l.contains("cmake error") + || l.contains("clang: error") + || l.contains("gcc: error") + || l.contains("ld: error") +} + +fn is_noisy_separator(line: &str) -> bool { + line.chars().all(|c| c == '=' || c == '-' || c == '─' || c == '═') +} + +fn compact_lines(mut lines: Vec) -> Vec { + while lines.first().is_some_and(|l| l.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|l| l.trim().is_empty()) { + lines.pop(); + } + + let mut out: Vec = Vec::with_capacity(lines.len()); + let mut last_blank = false; + for l in lines.drain(..) { + let blank = l.trim().is_empty(); + if blank { + if last_blank { + continue; + } + last_blank = true; + out.push(String::new()); + } else { + last_blank = false; + out.push(l); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gtest_all_passed_compacts() { + let input = r#" +[==========] Running 2 tests from 1 test suite. +[ RUN ] Foo.Pass +[ OK ] Foo.Pass (1 ms) +[ RUN ] Foo.Pass2 +[ OK ] Foo.Pass2 (1 ms) +[==========] 2 tests from 1 test suite ran. (2 ms total) +[ PASSED ] 2 tests. +"#; + let out = filter_ctest_output(input); + assert!(!out.contains("Foo.Pass")); + assert!(!out.contains("[ RUN")); + assert!(out.contains("[ PASSED ] 2 tests.")); + } + + #[test] + fn gtest_one_failed_keeps_block_and_summary() { + let input = r#" +[==========] Running 3 tests from 1 test suite. +[ RUN ] Foo.Pass +[ OK ] Foo.Pass (1 ms) +[ RUN ] Foo.Fail +/path/foo_test.cc:42: Failure +Expected equality of these values: + actual + expected +[ FAILED ] Foo.Fail (0 ms) +[ RUN ] Foo.Pass2 +[ OK ] Foo.Pass2 (1 ms) +[==========] 3 tests from 1 test suite ran. +[ PASSED ] 2 tests. +[ FAILED ] 1 test, listed below: +[ FAILED ] Foo.Fail +"#; + let out = filter_ctest_output(input); + assert!(out.contains("Foo.Fail")); + assert!(out.contains("/path/foo_test.cc:42: Failure")); + assert!(out.contains("Expected equality of these values:")); + assert!(out.contains("[ FAILED ] 1 test, listed below:")); + assert!(out.contains("[ FAILED ] Foo.Fail")); + assert!(!out.contains("Foo.Pass")); + assert!(!out.contains("Foo.Pass2")); + } + + #[test] + fn gtest_multiple_failed_keeps_both_blocks() { + let input = r#" +[ RUN ] A.Fail +/p/a.cc:1: Failure +boom +[ FAILED ] A.Fail (0 ms) +[ RUN ] B.Fail +/p/b.cc:2: Failure +kaboom +[ FAILED ] B.Fail (0 ms) +[ FAILED ] 2 tests, listed below: +[ FAILED ] A.Fail +[ FAILED ] B.Fail +"#; + let out = filter_ctest_output(input); + assert!(out.contains("A.Fail")); + assert!(out.contains("/p/a.cc:1: Failure")); + assert!(out.contains("B.Fail")); + assert!(out.contains("/p/b.cc:2: Failure")); + assert!(out.contains("[ FAILED ] A.Fail")); + assert!(out.contains("[ FAILED ] B.Fail")); + } + + #[test] + fn gtest_parameterized_names_survive() { + let input = r#" +[ RUN ] FooTest/0.DoesThing +/p/t.cc:3: Failure +nope +[ FAILED ] FooTest/0.DoesThing (0 ms) +[ RUN ] FooSuite/BarTest.DoesThing/1 +/p/u.cc:4: Failure +nope2 +[ FAILED ] FooSuite/BarTest.DoesThing/1 (0 ms) +[ FAILED ] 2 tests, listed below: +[ FAILED ] FooTest/0.DoesThing +[ FAILED ] FooSuite/BarTest.DoesThing/1 +"#; + let out = filter_ctest_output(input); + assert!(out.contains("FooTest/0.DoesThing")); + assert!(out.contains("FooSuite/BarTest.DoesThing/1")); + } + + #[test] + fn keeps_non_gtest_important_lines() { + let input = r#" +[ RUN ] Foo.Fail +/p/t.cc:3: Failure +nope +AddressSanitizer: heap-use-after-free +[ FAILED ] Foo.Fail (0 ms) +"#; + let out = filter_ctest_output(input); + assert!(out.contains("AddressSanitizer: heap-use-after-free")); + assert!(out.contains("/p/t.cc:3: Failure")); + } + + #[test] + fn ctest_verbose_prefix_gtest_failure_survives_and_pass_noise_drops() { + let input = r#" +1: [ RUN ] Foo.Pass +1: [ OK ] Foo.Pass (1 ms) +1: [ RUN ] Foo.Fail +1: /path/foo_test.cc:42: Failure +1: Expected equality of these values: +1: actual +1: expected +1: [ FAILED ] Foo.Fail (0 ms) +1: [ RUN ] Foo.Pass2 +1: [ OK ] Foo.Pass2 (1 ms) +1: [ FAILED ] 1 test, listed below: +1: [ FAILED ] Foo.Fail +"#; + let out = filter_ctest_output(input); + assert!(out.contains("Foo.Fail")); + assert!(out.contains("1: /path/foo_test.cc:42: Failure")); + assert!(out.contains("1: Expected equality of these values:")); + assert!(out.contains("1: [ FAILED ] Foo.Fail")); + assert!(!out.contains("Foo.Pass")); + assert!(!out.contains("Foo.Pass2")); + } + + #[test] + fn ctest_verbose_prefix_normalizer_requires_colon() { + assert_eq!(normalize_ctest_verbose_prefix("123 tests failed"), "123 tests failed"); + assert_eq!(normalize_ctest_verbose_prefix("404 error happened"), "404 error happened"); + } +} diff --git a/src/cmds/cpp/make_cmd.rs b/src/cmds/cpp/make_cmd.rs new file mode 100644 index 000000000..b884c627c --- /dev/null +++ b/src/cmds/cpp/make_cmd.rs @@ -0,0 +1,141 @@ +//! Filters make/ninja output — strips per-file noise, surfaces compiler diagnostics. + +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::resolved_command; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + static ref GCC_DIAG_RE: Regex = + Regex::new(r"^[^:\s].*:\d+:\d+:\s+(?:error|warning|note|fatal error):").unwrap(); + static ref MAKE_ERR_RE: Regex = Regex::new(r"^make(\[\d+\])?:\s+\*\*\*").unwrap(); + static ref NINJA_PROGRESS_RE: Regex = + Regex::new(r"^\[\d+/\d+\]\s+(Building|Linking|Generating|Compiling)").unwrap(); + static ref MAKE_BUILD_LINE_RE: Regex = + Regex::new(r"^(?:cc|gcc|g\+\+|clang|clang\+\+|c\+\+|ld|ar)\b").unwrap(); +} + +pub fn run_make(args: &[String], verbose: u8) -> Result { + run_inner("make", args, verbose) +} + +pub fn run_ninja(args: &[String], verbose: u8) -> Result { + run_inner("ninja", args, verbose) +} + +fn run_inner(tool: &'static str, args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command(tool); + for a in args { + cmd.arg(a); + } + if verbose > 0 { + eprintln!("Running: {} {}", tool, args.join(" ")); + } + runner::run_filtered( + cmd, + tool, + &args.join(" "), + move |raw| filter_output(raw, tool), + RunOptions::with_tee("make"), + ) +} + +fn filter_output(raw: &str, tool: &str) -> String { + let mut out = Vec::new(); + let mut diag_context = 0usize; + let mut emitted_diag = false; + + for line in raw.lines() { + if NINJA_PROGRESS_RE.is_match(line) { + continue; + } + if line.contains("Entering directory") || line.contains("Leaving directory") { + continue; + } + if MAKE_BUILD_LINE_RE.is_match(line) { + continue; + } + + if GCC_DIAG_RE.is_match(line) { + out.push(line.to_string()); + diag_context = 3; + emitted_diag = true; + continue; + } + if MAKE_ERR_RE.is_match(line) { + out.push(line.to_string()); + emitted_diag = true; + diag_context = 0; + continue; + } + if line.contains("undefined reference") { + out.push(line.to_string()); + emitted_diag = true; + continue; + } + if diag_context > 0 { + let trimmed = line.trim_start(); + if trimmed.is_empty() + || trimmed.starts_with('|') + || line.starts_with(' ') + || line.starts_with('\t') + { + out.push(line.to_string()); + diag_context -= 1; + continue; + } + diag_context = 0; + } + } + + if !emitted_diag { + return format!("{}: ok", tool); + } + out.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_make_success() { + let raw = "make: Entering directory '/tmp/x'\n\ + cc -c main.c -o main.o\n\ + cc -o myapp main.o\n\ + make: Leaving directory '/tmp/x'\n"; + assert_eq!(filter_output(raw, "make"), "make: ok"); + } + + #[test] + fn test_make_failure_keeps_diag() { + let raw = "cc -c main.c -o main.o\n\ + main.c:5:1: error: expected ';' before 'return'\n\ + 5 | return 0\n\ + | ^\n\ + make[1]: *** [Makefile:10: main.o] Error 1\n\ + make: *** [all] Error 2\n"; + let out = filter_output(raw, "make"); + assert!(out.contains("error:")); + assert!(out.contains("make[1]: ***")); + assert!(!out.contains("cc -c main.c")); + } + + #[test] + fn test_fixture_make_failure() { + let raw = include_str!("../../../tests/fixtures/cpp/make_failure.txt"); + let out = filter_output(raw, "make"); + assert!(out.contains("error:")); + assert!(out.contains("make[1]: ***") || out.contains("make: ***")); + assert!(!out.contains("cc -Wall")); + } + + #[test] + fn test_ninja_progress_stripped() { + let raw = "[1/3] Building CXX object x.o\n\ + [2/3] Building CXX object y.o\n\ + [3/3] Linking myapp\n"; + assert_eq!(filter_output(raw, "ninja"), "ninja: ok"); + } +} diff --git a/src/cmds/cpp/mod.rs b/src/cmds/cpp/mod.rs new file mode 100644 index 000000000..a37ca7466 --- /dev/null +++ b/src/cmds/cpp/mod.rs @@ -0,0 +1 @@ +automod::dir!(pub "src/cmds/cpp"); diff --git a/src/cmds/cpp/msbuild_cmd.rs b/src/cmds/cpp/msbuild_cmd.rs new file mode 100644 index 000000000..c8c64e1b6 --- /dev/null +++ b/src/cmds/cpp/msbuild_cmd.rs @@ -0,0 +1,711 @@ +//! Filters MSBuild output — keeps cl/link diagnostics, drops project/task noise. +//! +//! Captures stdout AND stderr (default for run_filtered) so linker errors from +//! `link.exe` (which writes to stderr) survive into the filter input. + +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::resolved_command; +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; +use std::collections::HashSet; + +lazy_static! { + // Compiler: file(line): error|warning C1234: message [project.vcxproj] + static ref MSVC_COMPILER_RE: Regex = + Regex::new(r"^(.+)\((\d+)\): (error|warning|fatal error) (C\d+): (.+?)(?:\s+\[.+\])?$") + .unwrap(); + // Linker: module : error|fatal error LNK1234: message + static ref MSVC_LINKER_RE: Regex = + Regex::new(r"^(.+) : (error|fatal error) (LNK\d+): (.+)$").unwrap(); + // Linker tool (no file prefix): "LINK : fatal error LNK1104: ..." + static ref MSVC_LINK_TOOL_RE: Regex = + Regex::new(r"^(?i:LINK)\s*: (warning|error|fatal error) (LNK\d+): (.+)$").unwrap(); + // Resource compiler: file.rc(line): error|fatal error RC1234: message [project.vcxproj] + static ref RC_DIAG_RE: Regex = + Regex::new(r"^(.+)\((\d+)\): (warning|error|fatal error) (RC\d+): (.+?)(?:\s+\[.+\])?$") + .unwrap(); + // MSBuild-style diagnostics: path.vcxproj(123,5): error MSB3073: ... + static ref MSBUILD_DIAG_RE: Regex = Regex::new( + r"^(.+?)\((\d+)(?:,(\d+))?\): (warning|error|fatal error) ((?:MSB|PRJ|CVT|LNK|RC|C)\d+): (.+)$" + ) + .unwrap(); + static ref MSB3073_RE: Regex = Regex::new(r"(?i)\b(MSB3073|MSB3721)\b").unwrap(); + static ref EXIT_CODE_RE: Regex = Regex::new(r"(?i)\bexited with code\s+(\d+)\b").unwrap(); + static ref PROJECT_ON_NODE_RE: Regex = + Regex::new(r#"^Project \"(.+?)\" on node \d+ \((.+?) target\(s\)\)\."#).unwrap(); + static ref DONE_BUILDING_RE: Regex = + Regex::new(r#"^Done Building Project \"(.+?)\" \(.+\) -- (FAILED|SUCCESSFUL)\."#) + .unwrap(); + // Build FAILED. or Build succeeded. + static ref BUILD_RESULT_RE: Regex = Regex::new(r"^Build (FAILED|succeeded)\.").unwrap(); + // " N Error(s)" or " N Warning(s)" + static ref ERR_WARN_COUNT_RE: Regex = Regex::new(r"^\s+\d+\s+(Error|Warning)\(s\)").unwrap(); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Severity { + Error, + Warning, +} + +#[derive(Debug, Clone)] +struct MsbuildDiag { + idx: usize, + severity: Severity, + code: String, + file: Option, + line: Option, + message: String, + project: Option, + raw: String, +} + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut adjusted: Vec = Vec::with_capacity(args.len() + 1); + let mut found_link_only = false; + for a in args { + // Always rewrite /t:Link-only to /t:Build to capture both compile + link output + let lower = a.to_ascii_lowercase(); + if lower == "/t:link" || lower == "-t:link" { + adjusted.push("/t:Build".to_string()); + found_link_only = true; + } else { + adjusted.push(a.clone()); + } + } + if verbose > 0 && found_link_only { + eprintln!("rtk msbuild: rewrote /t:Link → /t:Build to capture linker output"); + } + + let mut cmd = resolved_command("msbuild"); + for a in &adjusted { + cmd.arg(a); + } + if verbose > 0 { + eprintln!("Running: msbuild {}", adjusted.join(" ")); + } + + let args_owned = adjusted.clone(); + runner::run_filtered( + cmd, + "msbuild", + &adjusted.join(" "), + move |raw| filter_output(raw, &args_owned), + RunOptions::with_tee("msbuild"), + ) +} + +pub(crate) fn filter_output(raw: &str, args: &[String]) -> String { + let mut diags: Vec = Vec::new(); + let mut summary: Vec = Vec::new(); + let mut build_result: Option = None; + let mut succeeded = false; + let mut current_project: Option = None; + let mut failed_projects: Vec = Vec::new(); + + let lines: Vec<&str> = raw.lines().collect(); + for (idx, line) in lines.iter().enumerate() { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + continue; + } + + if let Some(caps) = PROJECT_ON_NODE_RE.captures(trimmed) { + current_project = Some(caps[1].to_string()); + continue; + } + if let Some(caps) = DONE_BUILDING_RE.captures(trimmed) { + let proj = caps[1].to_string(); + let status = &caps[2]; + if status.eq_ignore_ascii_case("FAILED") && !failed_projects.contains(&proj) { + failed_projects.push(proj.clone()); + } + current_project = Some(proj); + continue; + } + + if let Some(diag) = parse_diag_line(trimmed, idx, current_project.as_deref()) { + diags.push(diag); + continue; + } + if let Some(caps) = BUILD_RESULT_RE.captures(trimmed) { + build_result = Some(trimmed.to_string()); + succeeded = &caps[1] == "succeeded"; + continue; + } + if ERR_WARN_COUNT_RE.is_match(trimmed) { + summary.push(trimmed.to_string()); + continue; + } + } + + let has_errors = diags.iter().any(|d| d.severity == Severity::Error); + + if !has_errors && build_result.is_none() && diags.is_empty() { + // Empty / redirected output + let target = configuration_summary(args); + return format!( + "msbuild: no output captured \u{2014} rerun with /t:Build to capture linker output\n\ + [target: {}]", + target + ); + } + + if succeeded && !has_errors { + let target = configuration_summary(args); + return format!("msbuild: ok {}", target); + } + + let mut out = String::new(); + + if let Some(first_error) = first_real_error(&diags) { + let target = configuration_summary(args); + out.push_str("FIRST_ERROR\n"); + if !target.is_empty() { + out.push_str(&format!(" target: {}\n", target)); + } + if let Some(p) = first_error.project.as_deref() { + out.push_str(&format!(" project: {}\n", p)); + } + if let Some(f) = first_error.file.as_deref() { + out.push_str(&format!(" file: {}\n", f)); + } + if let Some(ln) = first_error.line { + out.push_str(&format!(" line: {}\n", ln)); + } + out.push_str(&format!(" code: {}\n", first_error.code)); + out.push_str(&format!(" message: {}\n", first_error.message)); + + let ctx = extract_context(&lines, first_error.idx, 3, 5); + if !ctx.prev.is_empty() || !ctx.next.is_empty() { + out.push_str(" context:\n"); + for l in ctx.prev { + out.push_str(&format!(" - {}\n", l)); + } + for l in ctx.next { + out.push_str(&format!(" + {}\n", l)); + } + } + out.push('\n'); + } + + if !failed_projects.is_empty() { + out.push_str("FAILED_PROJECTS\n"); + for p in &failed_projects { + out.push_str(&format!(" - {}\n", p)); + } + out.push('\n'); + } + + out.push_str("DIAGNOSTICS\n"); + for d in dedup_diags(&diags) + .into_iter() + .filter(|d| d.severity == Severity::Error) + { + out.push_str(&d.raw); + out.push('\n'); + } + if !succeeded { + for d in dedup_diags(&diags) + .into_iter() + .filter(|d| d.severity == Severity::Warning) + { + out.push_str(&d.raw); + out.push('\n'); + } + } + if let Some(br) = build_result { + out.push_str(&br); + out.push('\n'); + } + for s in &summary { + out.push_str(s); + out.push('\n'); + } + out.trim_end().to_string() +} + +fn parse_diag_line(line: &str, idx: usize, current_project: Option<&str>) -> Option { + // Extract the project path from trailing "[...vcxproj]" when present. + let project_from_suffix = line + .rfind('[') + .and_then(|i| line[i..].strip_prefix('[')) + .and_then(|rest| rest.strip_suffix(']')) + .map(|s| s.trim().to_string()); + let project = project_from_suffix.or_else(|| current_project.map(str::to_string)); + + if let Some(caps) = MSVC_COMPILER_RE.captures(line) { + let file = caps.get(1)?.as_str().to_string(); + let lnum: usize = caps.get(2)?.as_str().parse().ok()?; + let kind = caps.get(3)?.as_str(); + let code = caps.get(4)?.as_str().to_string(); + let msg = caps.get(5)?.as_str().to_string(); + let severity = if kind.eq_ignore_ascii_case("warning") { + Severity::Warning + } else { + Severity::Error + }; + let raw = format!("{}({}): {} {}: {}", file, lnum, kind, code, msg); + return Some(MsbuildDiag { + idx, + severity, + code, + file: Some(file), + line: Some(lnum), + message: msg, + project, + raw, + }); + } + + if let Some(caps) = RC_DIAG_RE.captures(line) { + let file = caps.get(1)?.as_str().to_string(); + let lnum: usize = caps.get(2)?.as_str().parse().ok()?; + let kind = caps.get(3)?.as_str(); + let code = caps.get(4)?.as_str().to_string(); + let msg = caps.get(5)?.as_str().to_string(); + let severity = if kind.eq_ignore_ascii_case("warning") { + Severity::Warning + } else { + Severity::Error + }; + return Some(MsbuildDiag { + idx, + severity, + code, + file: Some(file), + line: Some(lnum), + message: msg, + project, + raw: line.to_string(), + }); + } + + if let Some(caps) = MSBUILD_DIAG_RE.captures(line) { + let file = caps.get(1)?.as_str().to_string(); + let lnum: usize = caps.get(2)?.as_str().parse().ok()?; + let kind = caps.get(4)?.as_str(); + let code = caps.get(5)?.as_str().to_string(); + let mut msg = caps.get(6)?.as_str().to_string(); + if MSB3073_RE.is_match(&code) { + let exit_code = EXIT_CODE_RE + .captures(&msg) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()); + if let Some(cmd) = extract_msb3073_command(&msg) { + msg = cmd; + } + if let Some(n) = exit_code { + msg = format!("{} (exit code {})", msg, n); + } + } + let severity = if kind.eq_ignore_ascii_case("warning") { + Severity::Warning + } else { + Severity::Error + }; + return Some(MsbuildDiag { + idx, + severity, + code, + file: Some(file), + line: Some(lnum), + message: msg, + project, + raw: line.to_string(), + }); + } + + if let Some(caps) = MSVC_LINKER_RE.captures(line) { + let kind = caps.get(2)?.as_str(); + let code = caps.get(3)?.as_str().to_string(); + let msg = caps.get(4)?.as_str().to_string(); + let severity = if kind.eq_ignore_ascii_case("warning") { + Severity::Warning + } else { + Severity::Error + }; + return Some(MsbuildDiag { + idx, + severity, + code, + file: None, + line: None, + message: msg, + project, + raw: line.to_string(), + }); + } + + if let Some(caps) = MSVC_LINK_TOOL_RE.captures(line) { + let kind = caps.get(1)?.as_str(); + let code = caps.get(2)?.as_str().to_string(); + let msg = caps.get(3)?.as_str().to_string(); + let severity = if kind.eq_ignore_ascii_case("warning") { + Severity::Warning + } else { + Severity::Error + }; + return Some(MsbuildDiag { + idx, + severity, + code, + file: None, + line: None, + message: msg, + project, + raw: line.to_string(), + }); + } + + if MSB3073_RE.is_match(line) { + let code = MSB3073_RE + .captures(line) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_else(|| "MSB3073".to_string()); + + let mut msg = line.to_string(); + let exit_code = EXIT_CODE_RE + .captures(&msg) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()); + if let Some(cmd) = extract_msb3073_command(line) { + msg = cmd; + } + if let Some(n) = exit_code { + msg = format!("{} (exit code {})", msg, n); + } + + return Some(MsbuildDiag { + idx, + severity: Severity::Error, + code, + file: None, + line: None, + message: msg.clone(), + project, + raw: line.to_string(), + }); + } + + None +} + +fn extract_msb3073_command(msg: &str) -> Option { + // Expected shape: + // The command "...." exited with code N. + // Command body may contain escaped quotes: \"C:\path with spaces\" + let start = msg.find("The command \"")? + "The command \"".len(); + let rest = &msg[start..]; + let mut out = String::new(); + let mut escape = false; + for (i, ch) in rest.char_indices() { + if escape { + out.push(ch); + escape = false; + continue; + } + + if ch == '\\' { + // Only treat backslash as an escape marker when it escapes a quote or a backslash. + // Otherwise it's a real Windows path separator. + let next = rest[i + ch.len_utf8()..].chars().next(); + if matches!(next, Some('"') | Some('\\')) { + escape = true; + } else { + out.push(ch); + } + continue; + } + + if ch == '"' { + let after = &rest[i + ch.len_utf8()..]; + if after.starts_with(" exited with code") { + break; + } + out.push(ch); + continue; + } + + out.push(ch); + } + if out.is_empty() { + return None; + } + + // Normalize MSBuild escaping so paths are readable. + // Keep this minimal: this is display output only. + let out = out.replace(r#"\""#, r#"""#); + Some(out) +} + +fn first_real_error(diags: &[MsbuildDiag]) -> Option { + diags.iter().find(|d| d.severity == Severity::Error).cloned() +} + +fn dedup_diags(diags: &[MsbuildDiag]) -> Vec { + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for d in diags { + let key = format!("{}|{}", d.code, d.raw); + if !seen.insert(key) { + continue; + } + out.push(d.clone()); + } + out +} + +struct ContextWindow { + prev: Vec, + next: Vec, +} + +fn extract_context(lines: &[&str], idx: usize, prev_n: usize, next_n: usize) -> ContextWindow { + let mut prev = Vec::new(); + let mut next = Vec::new(); + + let mut i = idx; + while i > 0 && prev.len() < prev_n { + i -= 1; + let t = lines[i].trim_end(); + if t.is_empty() { + continue; + } + if is_msbuild_context_noise(t) { + continue; + } + prev.push(sanitize_context_line(t)); + } + prev.reverse(); + + let mut j = idx + 1; + while j < lines.len() && next.len() < next_n { + let t = lines[j].trim_end(); + j += 1; + if t.is_empty() { + continue; + } + if is_msbuild_context_noise(t) { + continue; + } + next.push(sanitize_context_line(t)); + } + + ContextWindow { prev, next } +} + +fn is_msbuild_context_noise(line: &str) -> bool { + let l = line.trim_start(); + let lower = l.to_ascii_lowercase(); + lower.starts_with("project \"") + || lower.starts_with("done building project ") + || lower.starts_with("build started ") + || lower.starts_with("time elapsed ") + || lower == "build failed." + || lower == "build succeeded." +} + +fn sanitize_context_line(line: &str) -> String { + // Common MSBuild suffix noise: " ... [C:\path\Project.vcxproj]" + // Keep behavior consistent with MSVC_COMPILER_RE stripping. + if line.ends_with(']') && line.contains(".vcxproj") { + if let Some(i) = line.rfind(" [") { + return line[..i].to_string(); + } + } + line.to_string() +} + +fn configuration_summary(args: &[String]) -> String { + let solution = args + .iter() + .find(|a| { + !a.starts_with('/') + && !a.starts_with('-') + && (a.ends_with(".sln") + || a.ends_with(".csproj") + || a.ends_with(".vcxproj") + || a.ends_with(".proj")) + }) + .cloned() + .unwrap_or_default(); + + let mut config = String::new(); + let mut platform = String::new(); + for a in args { + if let Some(rest) = a + .strip_prefix("/p:Configuration=") + .or_else(|| a.strip_prefix("-p:Configuration=")) + { + config = rest.to_string(); + } else if let Some(rest) = a + .strip_prefix("/p:Platform=") + .or_else(|| a.strip_prefix("-p:Platform=")) + { + platform = rest.to_string(); + } + } + + let mut parts = Vec::new(); + if !solution.is_empty() { + parts.push(solution); + } + if !config.is_empty() && !platform.is_empty() { + parts.push(format!("{}|{}", config, platform)); + } else if !config.is_empty() { + parts.push(config); + } + parts.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compiler_error_strips_project_suffix() { + let raw = "C:\\src\\main.cpp(42): error C2065: 'foo': undeclared identifier [C:\\proj\\MyProject.vcxproj]\n\ + Build FAILED.\n\ + 1 Error(s)\n"; + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("main.cpp(42): error C2065")); + assert!(!out.contains("[C:\\proj\\MyProject.vcxproj]")); + assert!(out.contains("Build FAILED")); + } + + #[test] + fn test_linker_error_kept_verbatim() { + let raw = "MyProject.lib(module.obj) : error LNK2001: unresolved external symbol \"void __cdecl foo()\"\n\ + MyOtherDLL.dll : fatal error LNK1120: 3 unresolved externals\n\ + Build FAILED.\n"; + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("LNK2001")); + assert!(out.contains("LNK1120")); + assert!(out.contains("MyProject.lib(module.obj)")); + } + + #[test] + fn test_success_compact() { + let raw = "Microsoft (R) Build Engine version 17.8\n\ + Copyright (C) Microsoft Corporation.\n\ + \n\ + Build started 1/1/2025 12:00:00 PM.\n\ + Project \"MyProject.sln\" on node 1 (Build target(s)).\n\ + Copying file from x to y\n\ + Creating directory \"obj\\Debug\"\n\ + cl.exe /c main.cpp\n\ + Done Building Project \"MyProject.vcxproj\" (default targets).\n\ + \n\ + Build succeeded.\n\ + 0 Warning(s)\n\ + 0 Error(s)\n"; + let args = vec![ + "MyProject.sln".to_string(), + "/p:Configuration=Debug".to_string(), + "/p:Platform=Win32".to_string(), + ]; + let out = filter_output(raw, &args); + assert!(out.starts_with("msbuild: ok")); + assert!(out.contains("MyProject.sln")); + assert!(out.contains("Debug|Win32")); + } + + #[test] + fn test_empty_output() { + let args = vec!["MyProject.sln".to_string(), "/t:Build".to_string()]; + let out = filter_output("", &args); + assert!(out.contains("no output captured")); + } + + #[test] + fn test_fixture_success() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_success.txt"); + let args = vec![ + "MyProject.sln".to_string(), + "/p:Configuration=Debug".to_string(), + "/p:Platform=Win32".to_string(), + ]; + let out = filter_output(raw, &args); + assert!(out.starts_with("msbuild: ok")); + assert!(out.contains("Debug|Win32")); + } + + #[test] + fn test_fixture_compiler_failure() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_compiler.txt"); + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("C2065")); + assert!(out.contains("C2143")); + assert!(out.contains("C1004")); + assert!(!out.contains("[C:\\src\\MyProject\\MyProject.vcxproj]")); + } + + #[test] + fn test_fixture_linker_failure() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_linker.txt"); + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("LNK2001")); + assert!(out.contains("LNK1120")); + assert!(out.contains("MyProject.lib(util.obj)")); + } + + #[test] + fn test_fixture_rc_failure() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_rc.txt"); + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("RC1015")); + assert!(out.contains("FIRST_ERROR")); + assert!(out.contains("FAILED_PROJECTS")); + } + + #[test] + fn test_fixture_msb3073_extraction() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_msb3073.txt"); + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("MSB3073")); + assert!(out.contains("exit code 1")); + assert!(out.contains("copy /Y")); + assert!(out.contains("C:\\path with spaces\\out.dll")); + assert!(out.contains("C:\\dest\\bin")); + assert!(out.contains("FIRST_ERROR")); + } + + #[test] + fn test_fixture_msb8012_detection() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_msb8012.txt"); + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("MSB8012")); + assert!(out.contains("TargetPath")); + assert!(out.contains("FIRST_ERROR")); + } + + #[test] + fn test_fixture_empty_link() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_empty_link.txt"); + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.contains("no output captured")); + } + + #[test] + fn test_warnings_dropped_on_success() { + let raw = "C:\\src\\main.cpp(43): warning C4244: conversion from 'double' to 'int' [C:\\proj\\MyProject.vcxproj]\n\ + Build succeeded.\n\ + 1 Warning(s)\n\ + 0 Error(s)\n"; + let args = vec!["MyProject.sln".to_string()]; + let out = filter_output(raw, &args); + assert!(out.starts_with("msbuild: ok")); + assert!(!out.contains("C4244")); + } +} diff --git a/src/cmds/cpp/remove_item.rs b/src/cmds/cpp/remove_item.rs new file mode 100644 index 000000000..d41dc0315 --- /dev/null +++ b/src/cmds/cpp/remove_item.rs @@ -0,0 +1,124 @@ +//! Minimal handler for PowerShell `Remove-Item` rewrites. +//! +//! Executes the deletion via `pwsh -Command "Remove-Item ..."` if `pwsh` is on +//! PATH. Falls back to `powershell` (Windows PowerShell 5.1) on Windows. Emits +//! `ok ` on success, the error line + non-zero exit code on failure. + +use crate::core::tracking; +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::Command; + +pub fn run(args: &[String], verbose: u8) -> Result { + let timer = tracking::TimedExecution::start(); + let raw_cmd = format!("Remove-Item {}", args.join(" ")); + if verbose > 0 { + eprintln!("Running: {}", raw_cmd); + } + + let pwsh = locate_pwsh(); + let mut cmd = Command::new(&pwsh); + cmd.arg("-NoProfile").arg("-Command").arg(&raw_cmd); + + let output = cmd + .output() + .with_context(|| format!("Failed to execute {}", pwsh))?; + let exit_code = output.status.code().unwrap_or(1); + let raw = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let display = if exit_code == 0 { + let target = first_target(args); + let basename = target + .map(|t| { + Path::new(t) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(t) + .to_string() + }) + .unwrap_or_else(|| "(target)".to_string()); + format!("ok {}", basename) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let first_line = stderr.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + format!("Remove-Item failed (exit {}): {}", exit_code, first_line.trim()) + }; + + println!("{}", display); + timer.track(&raw_cmd, "rtk remove-item", &raw, &display); + + Ok(exit_code) +} + +fn locate_pwsh() -> String { + if which::which("pwsh").is_ok() { + return "pwsh".to_string(); + } + if cfg!(target_os = "windows") { + return "powershell".to_string(); + } + // Last-resort fallback — caller will get a sensible error. + "pwsh".to_string() +} + +/// Return the first user-supplied path argument (after stripping flag tokens). +fn first_target(args: &[String]) -> Option<&str> { + let mut iter = args.iter().peekable(); + while let Some(a) = iter.next() { + let lower = a.to_ascii_lowercase(); + if lower == "-literalpath" || lower == "-path" { + if let Some(next) = iter.peek() { + return Some(next.as_str()); + } + } + if let Some(rest) = a.strip_prefix("-LiteralPath:") { + return Some(rest); + } + if let Some(rest) = a.strip_prefix("-Path:") { + return Some(rest); + } + // Skip flags + if a.starts_with('-') { + continue; + } + return Some(a.as_str()); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_first_target_literalpath() { + let args = vec![ + "-LiteralPath".to_string(), + "D:\\MyProject\\lib\\Debug\\MyLib.obj".to_string(), + "-Force".to_string(), + ]; + assert_eq!(first_target(&args), Some("D:\\MyProject\\lib\\Debug\\MyLib.obj")); + } + + #[test] + fn test_first_target_path() { + let args = vec!["-Path".to_string(), ".\\build\\".to_string(), "-Recurse".to_string()]; + assert_eq!(first_target(&args), Some(".\\build\\")); + } + + #[test] + fn test_first_target_positional() { + let args = vec!["-Force".to_string(), "myfile.txt".to_string()]; + assert_eq!(first_target(&args), Some("myfile.txt")); + } + + #[test] + fn test_first_target_colon_form() { + let args = vec!["-LiteralPath:C:\\x\\y.obj".to_string(), "-Force".to_string()]; + assert_eq!(first_target(&args), Some("C:\\x\\y.obj")); + } +} diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 8ac6a5aaf..01b4055cb 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -103,6 +103,70 @@ pub fn run( } } +/// Re-insert `--` before the first path-like argument when clap has consumed it. +/// +/// clap's `trailing_var_arg = true` silently drops `--` when it appears as the +/// first positional argument (before any other positional). This means: +/// `rtk git diff -- file` → args = ["file"] (clap ate `--`) +/// `rtk git diff HEAD -- file` → args = ["HEAD", "--", "file"] (preserved) +/// +/// Without the `--` separator git may treat an unambiguous path as a revision and +/// emit "fatal: ambiguous argument". We re-insert `--` before the first path-like +/// argument; see `normalize_diff_args_impl` for the detection rules. +fn normalize_diff_args(args: &[String]) -> Vec { + normalize_diff_args_impl(args, |p| std::path::Path::new(p).exists()) +} + +/// Testable core of `normalize_diff_args` — accepts an injectable filesystem existence checker. +/// +/// The path-detection logic is: +/// 1. Explicit path prefixes (`.`, `~`) → always a path, no filesystem check needed. +/// 2. Contains path separator (`/`, `\`) → use `path_exists` to distinguish branch names +/// (e.g. `feature/auth`) from real paths (e.g. `src/main.rs`). +/// 3. Bare word with no separator → never a path (avoids injecting `--` when a file +/// happens to share a name with a branch or ref, e.g. a file named `main`). +fn normalize_diff_args_impl(args: &[String], path_exists: F) -> Vec +where + F: Fn(&str) -> bool, +{ + // Already has `--` — nothing to do + if args.iter().any(|a| a == "--") { + return args.to_vec(); + } + let path_start = args.iter().position(|arg| { + if arg.starts_with('-') { + return false; + } + // Explicit path prefixes — always treat as path regardless of existence + if arg.starts_with('.') || arg.starts_with('~') { + return true; + } + // Contains path separator — use filesystem check to distinguish + // branch names (feature/auth) from real paths (src/main.rs) + if arg.contains('/') || arg.contains('\\') { + return path_exists(arg); + } + // Filename with extension (README.md) - treat as path if it exists. + // This is a safe middle-ground between "bare word" (main) and a ref. + if arg.contains('.') { + return path_exists(arg); + } + // Bare word (no separator, no special prefix) — never inject `--` + // This avoids misidentifying a ref/branch as a path even if a same-named + // file happens to exist on disk. + false + }); + match path_start { + Some(idx) => { + let mut out = args[..idx].to_vec(); + out.push("--".to_string()); + out.extend_from_slice(&args[idx..]); + out + } + None => args.to_vec(), + } +} + fn run_diff( args: &[String], max_lines: Option, @@ -112,7 +176,7 @@ fn run_diff( let timer = tracking::TimedExecution::start(); // Re-insert `--` when clap's trailing_var_arg consumed it (issue #1215) - let args = &args_utils::restore_double_dash(args); + let args = normalize_diff_args(&args_utils::restore_double_dash(args)); // Check if user wants stat output let wants_stat = args @@ -126,7 +190,7 @@ fn run_diff( // User wants stat or explicitly no compacting - pass through directly let mut cmd = git_cmd(global_args); cmd.arg("diff"); - for arg in args { + for arg in &args { if arg == "--no-compact" { continue; // RTK flag, not a git flag } @@ -156,7 +220,7 @@ fn run_diff( let mut cmd = git_cmd(global_args); cmd.arg("diff").arg("--stat"); - for arg in args { + for arg in &args { cmd.arg(arg); } @@ -185,7 +249,7 @@ fn run_diff( // Now get actual diff but compact it let mut diff_cmd = git_cmd(global_args); diff_cmd.arg("diff"); - for arg in args { + for arg in &args { diff_cmd.arg(arg); } @@ -1961,6 +2025,196 @@ mod tests { ); } + // ----- normalize_diff_args (issue #1215 + branch-name fix #1431) ----- + // + // Tests use normalize_diff_args_impl with a mock path-existence checker so + // they don't depend on the real filesystem. + + fn exists_mock<'a>(existing: &'a [&'a str]) -> impl Fn(&str) -> bool + 'a { + move |p| existing.contains(&p) + } + + /// Baseline: `--` already present → no-op, args unchanged. + #[test] + fn test_normalize_diff_args_noop_when_separator_present() { + let args = vec![ + "HEAD".to_string(), + "--".to_string(), + "src/main.rs".to_string(), + ]; + assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); + } + + /// Baseline: `--` already present with multiple pathspecs → no-op, args unchanged. + #[test] + fn test_normalize_diff_args_noop_when_separator_present_multiple_paths() { + let args = vec![ + "--".to_string(), + "README.md".to_string(), + "src/cmds/system/README.md".to_string(), + ]; + assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); + } + + /// Core regression (issue #1215): clap ate `--` before a real file path. + /// When the path exists on disk, `--` must be re-inserted. + #[test] + fn test_normalize_diff_args_reinserts_separator_before_existing_path() { + let args = vec!["apps/client/frontend/src/MyComponent.tsx".to_string()]; + let normalized = normalize_diff_args_impl( + &args, + exists_mock(&["apps/client/frontend/src/MyComponent.tsx"]), + ); + assert_eq!( + normalized, + vec![ + "--".to_string(), + "apps/client/frontend/src/MyComponent.tsx".to_string() + ], + "-- must be injected before an existing path" + ); + } + + /// Ref before path: ["HEAD", "src/foo.rs"] where src/foo.rs exists → inject after HEAD. + #[test] + fn test_normalize_diff_args_reinserts_separator_after_ref() { + let args = vec!["HEAD".to_string(), "src/foo.rs".to_string()]; + let normalized = normalize_diff_args_impl(&args, exists_mock(&["src/foo.rs"])); + assert_eq!( + normalized, + vec![ + "HEAD".to_string(), + "--".to_string(), + "src/foo.rs".to_string() + ] + ); + } + + /// Ref with explicit separator before a filename-with-extension → no-op, args unchanged. + #[test] + fn test_normalize_diff_args_noop_ref_then_separator_then_filename() { + let args = vec!["HEAD".to_string(), "--".to_string(), "README.md".to_string()]; + assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); + } + + /// Flags before path: ["--cached", "src/foo.rs"] where src/foo.rs exists. + #[test] + fn test_normalize_diff_args_reinserts_separator_after_flag() { + let args = vec!["--cached".to_string(), "src/foo.rs".to_string()]; + let normalized = normalize_diff_args_impl(&args, exists_mock(&["src/foo.rs"])); + assert_eq!( + normalized, + vec![ + "--cached".to_string(), + "--".to_string(), + "src/foo.rs".to_string() + ] + ); + } + + /// Flag then filename-with-extension pathspec → inject separator after flag. + #[test] + fn test_normalize_diff_args_inject_after_flag_for_filename_with_extension() { + let args = vec!["--name-only".to_string(), "README.md".to_string()]; + assert_eq!( + normalize_diff_args_impl(&args, exists_mock(&["README.md"])), + vec![ + "--name-only".to_string(), + "--".to_string(), + "README.md".to_string() + ] + ); + } + + /// Pure flags (no paths) → no injection. + #[test] + fn test_normalize_diff_args_no_injection_for_pure_flags() { + let args = vec!["--stat".to_string(), "--cached".to_string()]; + assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); + } + + /// Dotfile that exists on disk → inject `--`. + #[test] + fn test_normalize_diff_args_dotfile_is_path() { + let args = vec![".gitignore".to_string()]; + let normalized = normalize_diff_args_impl(&args, exists_mock(&[".gitignore"])); + assert_eq!(normalized, vec!["--".to_string(), ".gitignore".to_string()]); + } + + /// A bare ref (HEAD) that doesn't exist as a file → no injection. + #[test] + fn test_normalize_diff_args_no_injection_for_bare_ref() { + let args = vec!["HEAD".to_string()]; + assert_eq!(normalize_diff_args_impl(&args, exists_mock(&[])), args); + } + + /// Branch name with `/` that does NOT exist as a file → no injection. + /// Regression for issue #1431: `rtk git diff feature/user-auth` must not inject `--`. + #[test] + fn test_normalize_diff_args_no_injection_for_branch_with_slash() { + let args = vec!["feature/user-auth".to_string()]; + assert_eq!( + normalize_diff_args_impl(&args, exists_mock(&[])), + args, + "branch names containing '/' must not trigger -- injection" + ); + } + + /// Range syntax with `/` → no injection. + /// Regression: `rtk git diff main...feature/user-auth` produced no output. + #[test] + fn test_normalize_diff_args_no_injection_for_range_with_slash() { + let args = vec!["main...feature/user-auth".to_string()]; + assert_eq!( + normalize_diff_args_impl(&args, exists_mock(&[])), + args, + "revision ranges like main...feature/user-auth must not trigger -- injection" + ); + } + + /// Bare word that happens to exist as a file on disk → still no injection. + /// A file named "main" must not cause `--` to be injected when the user + /// intends `rtk git diff main` as a branch comparison. + #[test] + fn test_normalize_diff_args_no_injection_for_bare_word_even_if_file_exists() { + let args = vec!["main".to_string()]; + assert_eq!( + normalize_diff_args_impl(&args, exists_mock(&["main"])), + args, + "bare words must never trigger -- injection even when a same-named file exists" + ); + } + + /// Filename with extension that exists on disk → inject `--`. + #[test] + fn test_normalize_diff_args_inject_for_filename_with_extension() { + let args = vec!["README.md".to_string()]; + assert_eq!( + normalize_diff_args_impl(&args, exists_mock(&["README.md"])), + vec!["--".to_string(), "README.md".to_string()] + ); + } + + /// Multiple existing paths (including a filename-with-extension) → inject once before first path. + #[test] + fn test_normalize_diff_args_inject_for_multiple_existing_paths() { + let args = vec![ + "README.md".to_string(), + "src/cmds/system/README.md".to_string(), + ]; + assert_eq!( + normalize_diff_args_impl( + &args, + exists_mock(&["README.md", "src/cmds/system/README.md"]), + ), + vec![ + "--".to_string(), + "README.md".to_string(), + "src/cmds/system/README.md".to_string() + ] + ); + } + #[test] fn test_is_blob_show_arg() { assert!(is_blob_show_arg("develop:modules/pairs_backtest.py")); diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs index d064182b3..9f7e95ae8 100644 --- a/src/cmds/mod.rs +++ b/src/cmds/mod.rs @@ -1,6 +1,7 @@ //! Command filter modules organized by language ecosystem. pub mod cloud; +pub mod cpp; pub mod dotnet; pub mod git; pub mod go; diff --git a/src/cmds/system/README.md b/src/cmds/system/README.md index 55de28912..8898e1429 100644 --- a/src/cmds/system/README.md +++ b/src/cmds/system/README.md @@ -5,7 +5,7 @@ ## Specifics - `read.rs` uses `core/filter` for language-aware code stripping (FilterLevel: none/minimal/aggressive) -- `grep_cmd.rs` reads `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw. +- `grep_cmd.rs` reads `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Flags: `--files-only`, `--count-by-file`, `--top-files `, `--max-matches`, `--max-per-file`, `--max-line-chars`, `--full-lines`, `--all`, `--agent-safe`, `--json`. Env: `RTK_AGENT_SAFE=1` behaves like `--agent-safe` (grep only). Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw. - `local_llm.rs` (`rtk smart`) uses `core/filter` for heuristic file summarization - `format_cmd.rs` is a cross-ecosystem dispatcher: auto-detects and routes to `prettier_cmd` or `ruff_cmd` (black is handled inline, not as a separate module) diff --git a/src/cmds/system/gci_cmd.rs b/src/cmds/system/gci_cmd.rs new file mode 100644 index 000000000..1feeaa16d --- /dev/null +++ b/src/cmds/system/gci_cmd.rs @@ -0,0 +1,209 @@ +//! PowerShell Get-ChildItem / gci / dir compatible (subset) file listing with compact output. + +use crate::core::tracking; +use anyhow::Result; +use ignore::WalkBuilder; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GciKind { + File, + Directory, + Any, +} + +#[derive(Debug)] +pub struct GciArgs { + pub path: PathBuf, + pub recurse: bool, + pub force: bool, + pub kind: GciKind, + pub filter: Option, + pub include: Vec, + pub max: usize, + pub select_full_name: bool, + pub select_last_write_time: bool, + pub select_length: bool, +} + +impl Default for GciArgs { + fn default() -> Self { + Self { + path: PathBuf::from("."), + recurse: false, + force: false, + kind: GciKind::Any, + filter: None, + include: Vec::new(), + max: 50, + select_full_name: true, + select_last_write_time: false, + select_length: false, + } + } +} + +pub fn run(args: &GciArgs, verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + if verbose > 0 { + eprintln!("gci: {} (recurse={})", args.path.display(), args.recurse); + } + + let mut builder = WalkBuilder::new(&args.path); + builder.git_ignore(true).git_exclude(true).hidden(!args.force); + if !args.recurse { + builder.max_depth(Some(1)); + } + + let filter = args.filter.as_deref(); + let include = &args.include; + + let mut matches: Vec = Vec::new(); + for dent in builder.build() { + let dent = match dent { + Ok(d) => d, + Err(_) => continue, + }; + let p = dent.path(); + if p == Path::new("") { + continue; + } + + let ft = match dent.file_type() { + Some(t) => t, + None => continue, + }; + match args.kind { + GciKind::File if !ft.is_file() => continue, + GciKind::Directory if !ft.is_dir() => continue, + _ => {} + } + + let name = match p.file_name().and_then(|s| s.to_str()) { + Some(n) => n, + None => continue, + }; + + if let Some(f) = filter { + if !glob_match(f, name) { + continue; + } + } + + if !include.is_empty() && !include.iter().any(|pat| glob_match(pat, name)) { + continue; + } + + matches.push(p.to_path_buf()); + } + + matches.sort(); + let total = matches.len(); + + let mut out = String::new(); + out.push_str(&format!("{} matches\n\n", total)); + + let shown = std::cmp::min(total, args.max); + for p in matches.iter().take(shown) { + if args.select_last_write_time || args.select_length { + let meta = fs::metadata(p).ok(); + let len = meta.as_ref().map(|m| m.len()); + let mtime = meta.as_ref().and_then(|m| m.modified().ok()); + + let mut parts = Vec::new(); + if args.select_full_name { + parts.push(p.display().to_string()); + } + if args.select_length { + parts.push(format!( + "len={}", + len.map(|v| v.to_string()).unwrap_or_else(|| "?".into()) + )); + } + if args.select_last_write_time { + parts.push(format!( + "mtime={}", + mtime.map(format_system_time).unwrap_or_else(|| "?".into()) + )); + } + out.push_str(&parts.join(" ")); + out.push('\n'); + } else { + out.push_str(&p.display().to_string()); + out.push('\n'); + } + } + + if total > shown { + out.push_str(&format!("[+{} more]\n", total - shown)); + } + + print!("{}", out); + timer.track( + &format!("gci {}", args.path.display()), + "rtk gci", + "", + &out, + ); + Ok(()) +} + +pub fn parse_select_list(spec: &str, out: &mut GciArgs) { + // Accept: "FullName,LastWriteTime,Length" (powershell-ish). + for raw in spec.split(',') { + let s = raw.trim().to_ascii_lowercase(); + match s.as_str() { + "fullname" => out.select_full_name = true, + "lastwritetime" => out.select_last_write_time = true, + "length" => out.select_length = true, + _ => {} + } + } +} + +fn format_system_time(t: SystemTime) -> String { + // Avoid chrono dependency; emit seconds since epoch for compactness. + match t.duration_since(UNIX_EPOCH) { + Ok(d) => format!("{}s", d.as_secs()), + Err(_) => "?".into(), + } +} + +fn glob_match(pattern: &str, name: &str) -> bool { + let pat = pattern.to_ascii_lowercase(); + let nm = name.to_ascii_lowercase(); + glob_match_inner(pat.as_bytes(), nm.as_bytes()) +} + +fn glob_match_inner(pat: &[u8], name: &[u8]) -> bool { + match (pat.first(), name.first()) { + (None, None) => true, + (Some(b'*'), _) => { + glob_match_inner(&pat[1..], name) + || (!name.is_empty() && glob_match_inner(pat, &name[1..])) + } + (Some(b'?'), Some(_)) => glob_match_inner(&pat[1..], &name[1..]), + (Some(&p), Some(&n)) if p == n => glob_match_inner(&pat[1..], &name[1..]), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_glob_match_case_insensitive() { + assert!(glob_match("*.CPP", "api_win.cpp")); + assert!(glob_match("API_WIN.OBJ", "api_win.obj")); + } + + #[test] + fn test_glob_match_wildcards() { + assert!(glob_match("a?c.txt", "abc.txt")); + assert!(glob_match("a*c.txt", "abbbbbc.txt")); + assert!(!glob_match("a?c.txt", "ac.txt")); + } +} diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index 3f7d9a327..6e2acad5d 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -6,16 +6,51 @@ use crate::core::tracking; use crate::core::utils::resolved_command; use anyhow::{Context, Result}; use regex::Regex; +use serde::Serialize; use std::collections::HashMap; +#[derive(Clone, Debug)] +struct GrepRenderOptions { + max_line_chars: Option, + max_matches: Option, + max_per_file: Option, + uncapped: bool, + files_only: bool, + count_by_file: bool, + agent_safe: bool, + summary_enabled: bool, + context_only: bool, +} + +#[derive(Clone, Debug, Default)] +#[allow(dead_code)] +struct GrepRenderStats { + total_matches: usize, + files_matched: usize, + shown: usize, + omitted_total: usize, + omitted_per_file: usize, + clipped_lines: usize, + printed_summary: bool, +} + #[allow(clippy::too_many_arguments)] pub fn run( pattern: &str, path: &str, - max_line_len: usize, - max_results: usize, + max_line_chars: Option, + max_matches: Option, + max_per_file: Option, + uncapped: bool, + files_only: bool, + count_by_file: bool, + agent_safe: bool, + summary_enabled: bool, + top_files: Option, + json: bool, context_only: bool, file_type: Option<&str>, + fixed: bool, extra_args: &[String], verbose: u8, ) -> Result { @@ -25,42 +60,39 @@ 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. - // -H: always emit the filename. - // -0: NUL-separate filename. Allows the parser to disambiguate filenames or - // content containing `:digits:` patterns (issue #1436). - rg_cmd.args(["-nH0", "--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(|_| { let mut grep_cmd = resolved_command("grep"); // When we fall back to grep, include all args, not just -rnHZ. - grep_cmd.args(["-rnHZ", pattern, path]).args(extra_args); + grep_cmd.arg("-rnHZ"); + if fixed { + grep_cmd.arg("-F"); + } + grep_cmd.args(extra_args); + grep_cmd.args([pattern, path]); exec_capture(&mut grep_cmd) }) .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) { + // In `--json` mode, always emit JSON (no human text), even for format flags. + if has_format_flag(extra_args) && !json { print!("{}", result.stdout); if !result.stderr.is_empty() { eprint!("{}", result.stderr.trim()); @@ -88,76 +120,543 @@ pub fn run( eprintln!("{}", result.stderr.trim()); } let msg = format!("0 matches for '{}'", pattern); - println!("{}", msg); + if json { + let out = GrepJsonOutput::no_matches(pattern, files_only, count_by_file, top_files); + println!("{}", serde_json::to_string(&out)?); + } else { + println!("{}", msg); + } timer.track( &format!("grep -rn '{}' {}", pattern, path), "rtk grep", &raw_output, - &msg, + if json { "" } else { &msg }, ); return Ok(exit_code); } - // Always filter: truncate long lines, apply per-file and global caps. - // Output in standard file:line:content format that AI agents can parse. - // (A passthrough approach yields 0% savings — no reason for RTK to exist on that path.) - let total_matches = result.stdout.lines().count(); + let (rtk_output, stats) = render_grep_output( + pattern, + &result.stdout, + &GrepRenderOptions { + max_line_chars, + max_matches, + max_per_file, + uncapped, + files_only, + count_by_file, + agent_safe, + summary_enabled, + context_only, + }, + top_files, + json, + ); - let context_re = if context_only { - Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(pattern))).ok() - } else { - None - }; + print!("{}", rtk_output); + timer.track( + &format!("grep -rn '{}' {}", pattern, path), + "rtk grep", + &raw_output, + &rtk_output, + ); + + if json && stats.printed_summary { + // In JSON mode, tracking output is the JSON itself; ensure no extra text sneaks in. + } + + Ok(exit_code) +} - let mut by_file: HashMap> = HashMap::new(); - for line in result.stdout.lines() { +fn render_grep_output( + pattern: &str, + stdout: &str, + opts: &GrepRenderOptions, + top_files: Option, + json: bool, +) -> (String, GrepRenderStats) { + // Filter: group by file, optionally cap/truncate, render in deterministic order. + // Output uses `file:line:content` so AI agents can parse it. + let mut by_file_raw: HashMap> = HashMap::new(); + for line in stdout.lines() { let Some((file, line_num, content)) = parse_match_line(line) else { continue; }; - let cleaned = clean_line(content, max_line_len, context_re.as_ref(), pattern); - by_file.entry(file).or_default().push((line_num, cleaned)); + by_file_raw.entry(file).or_default().push((line_num, content)); + } + let total_matches: usize = by_file_raw.values().map(|v| v.len()).sum(); + + if opts.files_only { + if json { + let mut rows: Vec<(usize, String)> = by_file_raw + .iter() + .map(|(file, matches)| (matches.len(), compact_path(file))) + .collect(); + rows.sort_by(|(a_cnt, a_file), (b_cnt, b_file)| { + b_cnt.cmp(a_cnt).then_with(|| a_file.cmp(b_file)) + }); + let out = GrepJsonOutput::file_counts(pattern, total_matches, by_file_raw.len(), &rows); + return ( + format!("{}\n", serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())), + GrepRenderStats { + total_matches, + files_matched: by_file_raw.len(), + shown: rows.len(), + printed_summary: true, + ..Default::default() + }, + ); + } else { + let mut files: Vec<&String> = by_file_raw.keys().collect(); + files.sort(); + let mut out = String::new(); + for f in files { + out.push_str(f); + out.push('\n'); + } + return ( + out, + GrepRenderStats { + total_matches, + files_matched: by_file_raw.len(), + shown: by_file_raw.len(), + ..Default::default() + }, + ); + } } + if opts.count_by_file { + let mut rows: Vec<(usize, &String)> = by_file_raw + .iter() + .map(|(file, matches)| (matches.len(), file)) + .collect(); + rows.sort_by(|(a_cnt, a_file), (b_cnt, b_file)| { + b_cnt.cmp(a_cnt).then_with(|| a_file.cmp(b_file)) + }); + + if json { + let out_rows: Vec<(usize, String)> = rows + .into_iter() + .map(|(cnt, file)| (cnt, compact_path(file))) + .collect(); + let out = + GrepJsonOutput::file_counts(pattern, total_matches, by_file_raw.len(), &out_rows); + return ( + format!("{}\n", serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())), + GrepRenderStats { + total_matches, + files_matched: by_file_raw.len(), + shown: out_rows.len(), + printed_summary: true, + ..Default::default() + }, + ); + } else { + let mut out = String::new(); + for (cnt, file) in rows { + out.push_str(&format!("{} {}\n", cnt, file)); + } + return ( + out, + GrepRenderStats { + total_matches, + files_matched: by_file_raw.len(), + shown: by_file_raw.len(), + ..Default::default() + }, + ); + } + } + + if let Some(n) = top_files { + let mut rows: Vec<(usize, &String)> = by_file_raw + .iter() + .map(|(file, matches)| (matches.len(), file)) + .collect(); + rows.sort_by(|(a_cnt, a_file), (b_cnt, b_file)| { + b_cnt.cmp(a_cnt).then_with(|| a_file.cmp(b_file)) + }); + + let mut out_files: Vec<(usize, String)> = Vec::new(); + for (cnt, file) in rows.into_iter().take(n) { + out_files.push((cnt, compact_path(file))); + } + + if json { + let out = GrepJsonOutput::top_files( + pattern, + total_matches, + by_file_raw.len(), + n, + &out_files, + ); + return ( + format!("{}\n", serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())), + GrepRenderStats { + total_matches, + files_matched: by_file_raw.len(), + shown: out_files.len(), + printed_summary: true, + ..Default::default() + }, + ); + } + + let mut out = String::new(); + out.push_str(&format!("{} matches in {} files\n\n", total_matches, by_file_raw.len())); + for (cnt, file) in &out_files { + out.push_str(&format!("{} {}\n", cnt, file)); + } + return ( + out, + GrepRenderStats { + total_matches, + files_matched: by_file_raw.len(), + shown: out_files.len(), + ..Default::default() + }, + ); + } + + let context_re = if opts.context_only { + Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(pattern))).ok() + } else { + None + }; + + let effective_per_file = if opts.uncapped { + None + } else { + Some(opts.max_per_file.unwrap_or(config::limits().grep_max_per_file)) + }; + let effective_total = if opts.uncapped { None } else { opts.max_matches }; + let effective_line_chars = opts.max_line_chars; + let mut rtk_output = String::new(); rtk_output.push_str(&format!( "{} matches in {} files:\n\n", total_matches, - by_file.len() + by_file_raw.len() )); - let mut shown = 0; - let mut files: Vec<_> = by_file.iter().collect(); + let mut shown = 0usize; + let mut omitted_total = 0usize; + let mut omitted_per_file = 0usize; + let mut clipped_lines = 0usize; + let mut first_displayed: Option<(String, usize)> = None; + + let mut files: Vec<_> = by_file_raw.iter().collect(); files.sort_by_key(|(f, _)| *f); - let per_file = config::limits().grep_max_per_file; for (file, matches) in files { - if shown >= max_results { - break; + if let Some(total_cap) = effective_total { + if shown >= total_cap { + omitted_total += matches.len(); + continue; + } } let file_display = compact_path(file); - for (line_num, content) in matches.iter().take(per_file) { - if shown >= max_results { - break; + let mut used_in_file = 0usize; + for (line_num, content) in matches.iter() { + if let Some(total_cap) = effective_total { + if shown >= total_cap { + omitted_total += 1; + continue; + } + } + + if let Some(per_file_cap) = effective_per_file { + if used_in_file >= per_file_cap { + omitted_per_file += 1; + continue; + } + } + + let cleaned = if let Some(max_len) = effective_line_chars { + let s = clean_line(content, max_len, context_re.as_ref(), pattern); + if s.trim().chars().count() < content.trim().chars().count() { + clipped_lines += 1; + } + s + } else { + content.trim().to_string() + }; + + rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, cleaned)); + if first_displayed.is_none() { + first_displayed = Some((file_display.clone(), *line_num)); } - rtk_output.push_str(&format!("{}:{}:{}\n", file_display, line_num, content)); shown += 1; + used_in_file += 1; } } - if total_matches > shown { + // Legacy overflow marker: keep `[+N more]` for uncapped mode. + if effective_total.is_none() && (total_matches > shown) { rtk_output.push_str(&format!("[+{} more]\n", total_matches - shown)); } - print!("{}", rtk_output); - timer.track( - &format!("grep -rn '{}' {}", pattern, path), - "rtk grep", - &raw_output, - &rtk_output, - ); + // Summary only when explicitly enabled (agent-safe or explicit new flags) AND + // there was actual omission/clipping, or agent-safe was used. + let print_summary = opts.summary_enabled + && (opts.agent_safe || clipped_lines > 0 || omitted_total > 0 || omitted_per_file > 0); + let hints = build_hints(pattern, first_displayed.as_ref()); - Ok(exit_code) + if json { + let out = GrepJsonOutput::normal( + pattern, + total_matches, + by_file_raw.len(), + shown, + omitted_total, + omitted_per_file, + clipped_lines, + &by_file_raw, + &hints, + effective_total, + effective_per_file, + effective_line_chars, + ); + return ( + format!("{}\n", serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())), + GrepRenderStats { + total_matches, + files_matched: by_file_raw.len(), + shown, + omitted_total, + omitted_per_file, + clipped_lines, + printed_summary: true, + }, + ); + } + + if print_summary { + rtk_output.push('\n'); + rtk_output.push_str(&format!( + "summary: total={} files={} shown={} omitted_total={} omitted_per_file={} clipped_lines={}\n", + total_matches, + by_file_raw.len(), + shown, + omitted_total, + omitted_per_file, + clipped_lines + )); + rtk_output.push_str("hints:\n"); + for h in &hints { + rtk_output.push_str(&format!(" {}\n", h)); + } + } + + ( + rtk_output, + GrepRenderStats { + total_matches, + files_matched: by_file_raw.len(), + shown, + omitted_total, + omitted_per_file, + clipped_lines, + printed_summary: print_summary, + }, + ) +} + +fn build_hints(pattern: &str, first_displayed: Option<&(String, usize)>) -> Vec { + let mut hints = vec![ + format!("rtk grep \"{}\" --files-only", pattern), + format!("rtk grep \"{}\" --count-by-file", pattern), + format!("rtk grep \"{}\" --agent-safe --max-matches 200", pattern), + ]; + if let Some((path, line)) = first_displayed { + let start = line.saturating_sub(5).max(1); + let end = line + 5; + hints.push(format!("rtk read \"{}\" --lines {}:{}", path, start, end)); + } else { + hints.push("rtk read \"\" --lines ".to_string()); + } + hints +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GrepJsonMatch { + line: usize, + text: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GrepJsonFile { + path: String, + count: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + matches: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GrepJsonOutput { + pattern: String, + total_matches: usize, + files_matched: usize, + displayed_matches: usize, + omitted_total: usize, + omitted_per_file: usize, + clipped_lines: usize, + truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + top_files: Option, + files: Vec, + hints: Vec, +} + +impl GrepJsonOutput { + fn no_matches(pattern: &str, files_only: bool, count_by_file: bool, top_files: Option) -> Self { + let _ = (files_only, count_by_file); + Self { + pattern: pattern.to_string(), + total_matches: 0, + files_matched: 0, + displayed_matches: 0, + omitted_total: 0, + omitted_per_file: 0, + clipped_lines: 0, + truncated: false, + top_files, + files: Vec::new(), + hints: build_hints(pattern, None), + } + } + + fn file_counts(pattern: &str, total_matches: usize, files_matched: usize, rows: &[(usize, String)]) -> Self { + Self { + pattern: pattern.to_string(), + total_matches, + files_matched, + displayed_matches: 0, + omitted_total: 0, + omitted_per_file: 0, + clipped_lines: 0, + truncated: false, + top_files: None, + files: rows + .iter() + .map(|(cnt, path)| GrepJsonFile { + path: path.clone(), + count: *cnt, + matches: Vec::new(), + }) + .collect(), + hints: build_hints(pattern, None), + } + } + + fn top_files( + pattern: &str, + total_matches: usize, + files_matched: usize, + requested: usize, + rows: &[(usize, String)], + ) -> Self { + Self { + pattern: pattern.to_string(), + total_matches, + files_matched, + displayed_matches: 0, + omitted_total: 0, + omitted_per_file: 0, + clipped_lines: 0, + truncated: false, + top_files: Some(requested), + files: rows + .iter() + .map(|(cnt, path)| GrepJsonFile { + path: path.clone(), + count: *cnt, + matches: Vec::new(), + }) + .collect(), + hints: build_hints(pattern, None), + } + } + + #[allow(clippy::too_many_arguments)] + fn normal( + pattern: &str, + total_matches: usize, + files_matched: usize, + _displayed_matches: usize, + omitted_total: usize, + omitted_per_file: usize, + clipped_lines: usize, + by_file_raw: &HashMap>, + hints: &[String], + effective_total: Option, + effective_per_file: Option, + effective_line_chars: Option, + ) -> Self { + let truncated = omitted_total > 0 || omitted_per_file > 0; + let mut files: Vec<(&String, &Vec<(usize, &str)>)> = by_file_raw.iter().collect(); + files.sort_by_key(|(f, _)| *f); + + let mut current_count = 0usize; + let mut out_files: Vec = Vec::new(); + for (file, matches) in files { + if let Some(total_cap) = effective_total { + if current_count >= total_cap { + break; + } + } + let mut out_matches: Vec = Vec::new(); + for (used_in_file, (line, content)) in matches.iter().enumerate() { + if let Some(total_cap) = effective_total { + if current_count >= total_cap { + break; + } + } + if let Some(per_file_cap) = effective_per_file { + if used_in_file >= per_file_cap { + break; + } + } + + let text = if let Some(max_len) = effective_line_chars { + clean_line(content, max_len, None, pattern) + } else { + content.trim().to_string() + }; + out_matches.push(GrepJsonMatch { line: *line, text }); + current_count += 1; + } + + // In normal JSON mode, avoid emitting empty file entries that can be + // created when we early-break due to a total cap. + if !out_matches.is_empty() { + out_files.push(GrepJsonFile { + path: compact_path(file), + count: matches.len(), + matches: out_matches, + }); + } + } + + Self { + pattern: pattern.to_string(), + total_matches, + files_matched, + displayed_matches: current_count, + omitted_total, + omitted_per_file, + clipped_lines, + truncated, + top_files: None, + files: out_files, + hints: hints.to_vec(), + } + } } /// Parses a single rg/grep match line of the form `file\0line_number:content`. @@ -182,6 +681,55 @@ fn parse_match_line(line: &str) -> Option<(String, usize, &str)> { }) } +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: include line numbers. + // -H: always emit the filename. + // -0: NUL-separate filename from `line:content` for unambiguous parsing. + "-nH0".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()); + } + + // Insert extra args before pattern/path so flag ordering matches rg expectations. + 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.push(rg_pattern); + args.push(path.to_string()); + + args +} + fn has_format_flag(extra_args: &[String]) -> bool { extra_args.iter().any(|arg| { matches!( @@ -202,44 +750,93 @@ fn has_format_flag(extra_args: &[String]) -> bool { fn clean_line(line: &str, max_len: usize, context_re: Option<&Regex>, pattern: &str) -> String { let trimmed = line.trim(); + if max_len == 0 { + return String::new(); + } + if let Some(re) = context_re { if let Some(m) = re.find(trimmed) { let matched = m.as_str(); - if matched.len() <= max_len { + if matched.chars().count() <= max_len { return matched.to_string(); } } } - if trimmed.len() <= max_len { + if trimmed.chars().count() <= max_len { trimmed.to_string() } else { + if max_len <= 3 { + return trimmed.chars().take(max_len).collect(); + } + if max_len <= 6 { + let t: String = trimmed.chars().take(max_len - 3).collect(); + return format!("{}...", t); + } + let lower = trimmed.to_lowercase(); let pattern_lower = pattern.to_lowercase(); - if let Some(pos) = lower.find(&pattern_lower) { - let char_pos = lower[..pos].chars().count(); + if lower.contains(&pattern_lower) { let chars: Vec = trimmed.chars().collect(); + let lower_chars: Vec = lower.chars().collect(); + let pat_chars: Vec = pattern_lower.chars().collect(); + + // Find match start/end in char indices (not bytes) so we don't break UTF-8. + let mut match_start = 0usize; + let mut match_end = 0usize; + 'outer: for i in 0..=lower_chars.len().saturating_sub(pat_chars.len()) { + for j in 0..pat_chars.len() { + if lower_chars[i + j] != pat_chars[j] { + continue 'outer; + } + } + match_start = i; + match_end = i + pat_chars.len(); + break; + } + let char_len = chars.len(); + if match_end <= match_start || match_end > char_len { + let t: String = trimmed.chars().take(max_len.saturating_sub(3)).collect(); + return format!("{}...", t); + } - let start = char_pos.saturating_sub(max_len / 3); - let end = (start + max_len).min(char_len); - let start = if end == char_len { - end.saturating_sub(max_len) - } else { - start - }; + // Reserve room for prefix/suffix + ellipses so match stays visible. + let ellipses = 3usize; + let remaining = max_len.saturating_sub(ellipses * 2); + let match_len = match_end - match_start; + if remaining <= match_len + 2 { + // Not enough room for context; show match-centered slice. + let start = match_start.saturating_sub(1); + let end = (start + remaining).min(char_len); + let slice: String = chars[start..end].iter().collect(); + return format!("...{}...", slice); + } - let slice: String = chars[start..end].iter().collect(); - if start > 0 && end < char_len { - format!("...{}...", slice) - } else if start > 0 { - format!("...{}", slice) - } else { - format!("{}...", slice) + let context_budget = remaining - match_len; + let prefix_budget = context_budget / 2; + let suffix_budget = context_budget - prefix_budget; + + let prefix_start = match_start.saturating_sub(prefix_budget); + let prefix = &chars[prefix_start..match_start]; + let matched = &chars[match_start..match_end]; + let suffix_end = (match_end + suffix_budget).min(char_len); + let suffix = &chars[match_end..suffix_end]; + + let mut out = String::new(); + if prefix_start > 0 { + out.push_str("..."); } + out.push_str(&prefix.iter().collect::()); + out.push_str(&matched.iter().collect::()); + out.push_str(&suffix.iter().collect::()); + if suffix_end < char_len { + out.push_str("..."); + } + out } else { - let t: String = trimmed.chars().take(max_len - 3).collect(); + let t: String = trimmed.chars().take(max_len.saturating_sub(3)).collect(); format!("{}...", t) } } @@ -266,6 +863,7 @@ fn compact_path(path: &str) -> String { #[cfg(test)] mod tests { use super::*; + use serde_json::Value; #[test] fn test_clean_line() { @@ -299,6 +897,53 @@ mod tests { assert!(!cleaned.is_empty()); } + #[test] + fn test_clean_line_utf8_croatian() { + let line = " Ovo je dugačka rečenica sa slovima čćđšž i uzorkom FooBar negdje u sredini. "; + let cleaned = clean_line(line, 24, None, "FooBar"); + assert!(cleaned.chars().count() <= 24); + assert!(cleaned.contains("FooBar")); + } + + #[test] + fn test_clean_line_tiny_max_len() { + let line = " abcdef "; + assert_eq!(clean_line(line, 0, None, "c"), ""); + assert_eq!(clean_line(line, 1, None, "c").chars().count(), 1); + assert_eq!(clean_line(line, 2, None, "c").chars().count(), 2); + assert_eq!(clean_line(line, 3, None, "c").chars().count(), 3); + assert!(clean_line(line, 4, None, "c").chars().count() <= 4); + assert!(clean_line(line, 5, None, "c").chars().count() <= 5); + assert!(clean_line(line, 6, None, "c").chars().count() <= 6); + } + + #[test] + fn test_legacy_caps_do_not_print_summary_by_default() { + let stdout = "b.txt\x001:foo bar baz\n\ +a.txt\x001:foo x\n\ +a.txt\x002:foo y\n"; + let (out, stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + // Legacy-like defaults (from CLI flags -l/--max and config per-file). + max_line_chars: Some(5), + max_matches: Some(1), + max_per_file: None, + }, + None, + false, + ); + assert!(!out.contains("summary:")); + assert!(stats.omitted_total > 0 || stats.clipped_lines > 0); + } + #[test] fn test_clean_line_emoji() { let line = "🎉🎊🎈🎁🎂🎄 some text 🎃🎆🎇✨"; @@ -310,8 +955,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) @@ -507,4 +1168,446 @@ mod tests { } // If rg is not installed, skip gracefully (test still passes) } + + fn sample_stdout() -> &'static str { + // Shape: `file\0line:content` (rg -0 / grep -Z) + "b.txt\x001:foo bar baz\n\ +a.txt\x001:foo x\n\ +a.txt\x002:foo y\n\ +a.txt\x003:foo z\n\ +c.txt\x001:foo c1\n\ +c.txt\x002:foo c2\n" + } + + #[test] + fn test_files_only_unique_sorted() { + let (out, _stats) = render_grep_output( + "foo", + sample_stdout(), + &GrepRenderOptions { + files_only: true, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + max_matches: None, + max_per_file: None, + max_line_chars: None, + }, + None, + false, + ); + assert_eq!(out, "a.txt\nb.txt\nc.txt\n"); + } + + #[test] + fn test_count_by_file_sorted() { + let (out, _stats) = render_grep_output( + "foo", + sample_stdout(), + &GrepRenderOptions { + files_only: false, + count_by_file: true, + uncapped: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + max_matches: None, + max_per_file: None, + max_line_chars: None, + }, + None, + false, + ); + // a.txt has 3, c.txt has 2, b.txt has 1 + assert_eq!(out, "3 a.txt\n2 c.txt\n1 b.txt\n"); + } + + #[test] + fn test_total_cap_omits_and_summarizes() { + let (out, stats) = render_grep_output( + "foo", + sample_stdout(), + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: true, + context_only: false, + max_matches: Some(2), + max_per_file: Some(10), + max_line_chars: None, + }, + None, + false, + ); + assert!(out.contains("summary:")); + assert_eq!(stats.shown, 2); + assert!(stats.omitted_total > 0); + } + + #[test] + fn test_per_file_cap_omits_and_summarizes() { + let (out, stats) = render_grep_output( + "foo", + sample_stdout(), + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: true, + context_only: false, + max_matches: Some(100), + max_per_file: Some(1), + max_line_chars: None, + }, + None, + false, + ); + assert!(out.contains("summary:")); + assert!(stats.omitted_per_file > 0); + } + + #[test] + fn test_line_clipping_and_full_lines_escape_hatch() { + let stdout = "a.txt\x001:prefix foo suffix and extra\n"; + let (clipped, stats_clipped) = render_grep_output( + "foo", + stdout, + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: true, + context_only: false, + max_matches: Some(100), + max_per_file: Some(10), + max_line_chars: Some(10), + }, + None, + false, + ); + assert!(stats_clipped.clipped_lines >= 1); + assert!(clipped.contains("foo")); + + let (full, stats_full) = render_grep_output( + "foo", + stdout, + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + max_matches: Some(100), + max_per_file: Some(10), + max_line_chars: None, + }, + None, + false, + ); + assert_eq!(stats_full.clipped_lines, 0); + assert!(full.contains("prefix foo suffix and extra")); + } + + #[test] + fn test_agent_safe_preset_and_override_semantics() { + // agent-safe: total=80, per-file=5, line=240; explicit max_per_file overrides to 30. + // (Dispatch logic in main.rs; we validate render behavior here.) + let mut many = String::new(); + for i in 1..=40 { + many.push_str(&format!("a.txt\x00{}:foo {}\n", i, i)); + } + + let (_out, stats_default) = render_grep_output( + "foo", + &many, + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + max_matches: Some(80), + max_per_file: Some(5), + max_line_chars: Some(240), + }, + None, + false, + ); + assert_eq!(stats_default.shown, 5); + + let (_out, stats_override) = render_grep_output( + "foo", + &many, + &GrepRenderOptions { + files_only: false, + count_by_file: false, + uncapped: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + max_matches: Some(80), + max_per_file: Some(30), + max_line_chars: Some(240), + }, + None, + false, + ); + assert_eq!(stats_override.shown, 30); + } + + #[test] + fn test_summary_hint_includes_concrete_file_and_line() { + let stdout = "src\\\\main.rs\u{0}371:Foo bar\n"; + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(200), + max_per_file: Some(25), + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + false, + ); + assert!(out.contains("rtk read \"src\\\\main.rs\" --lines 366:376")); + } + + #[test] + fn test_top_files_sorts_and_limits() { + let stdout = concat!( + "b.rs\u{0}1:Foo\n", + "a.rs\u{0}1:Foo\n", + "a.rs\u{0}2:Foo\n" + ); + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(200), + max_per_file: Some(25), + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + }, + Some(1), + false, + ); + assert!(out.contains("2 a.rs")); + assert!(!out.contains("1 b.rs")); + } + + #[test] + fn test_json_output_is_valid_json_only() { + let stdout = "src\\\\main.rs\u{0}371:Foo bar\n"; + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(1), + max_per_file: Some(1), + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + true, + ); + let v: Value = serde_json::from_str(out.trim()).expect("valid json"); + assert_eq!(v["pattern"], "Foo"); + } + + #[test] + fn test_json_output_total_cap_does_not_emit_empty_files() { + let stdout = concat!( + "b.rs\u{0}1:Foo b\n", + "a.rs\u{0}1:Foo a\n", + "a.rs\u{0}2:Foo a2\n" + ); + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(1), + max_per_file: Some(25), + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + true, + ); + let v: Value = serde_json::from_str(out.trim()).expect("valid json"); + assert_eq!(v["files"].as_array().unwrap().len(), 1); + assert_eq!(v["files"][0]["matches"].as_array().unwrap().len(), 1); + } + + #[test] + fn test_json_output_is_valid_for_files_only_and_count_by_file() { + let stdout = concat!( + "b.rs\u{0}1:Foo\n", + "a.rs\u{0}1:Foo\n", + "a.rs\u{0}2:Foo\n" + ); + + let (out_files_only, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(200), + max_per_file: Some(25), + uncapped: false, + files_only: true, + count_by_file: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + true, + ); + let v1: Value = serde_json::from_str(out_files_only.trim()).expect("valid json"); + assert_eq!(v1["pattern"], "Foo"); + assert!(v1["files"].is_array()); + + let (out_count_by_file, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(200), + max_per_file: Some(25), + uncapped: false, + files_only: false, + count_by_file: true, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + true, + ); + let v2: Value = serde_json::from_str(out_count_by_file.trim()).expect("valid json"); + assert_eq!(v2["pattern"], "Foo"); + assert!(v2["files"].is_array()); + } + + #[test] + fn test_json_total_cap_one_emits_one_match_and_non_empty_files() { + let stdout = concat!("b.rs\u{0}1:Foo\n", "a.rs\u{0}1:Foo\n", "a.rs\u{0}2:Foo\n"); + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(1), + max_per_file: None, + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + }, + None, + true, + ); + let v: Value = serde_json::from_str(out.trim()).expect("valid json"); + assert_eq!(v["displayedMatches"], 1); + assert!(!v["files"].as_array().unwrap().is_empty()); + let mut total_json_matches = 0usize; + for f in v["files"].as_array().unwrap() { + total_json_matches += f["matches"].as_array().unwrap().len(); + } + assert_eq!(total_json_matches, 1); + } + + #[test] + fn test_agent_safe_json_total_cap_does_not_emit_empty_files_when_matches_exist() { + let stdout = concat!("b.rs\u{0}1:Foo\n", "a.rs\u{0}1:Foo\n", "a.rs\u{0}2:Foo\n"); + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(1), + max_per_file: None, + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: true, + summary_enabled: true, + context_only: false, + }, + None, + true, + ); + let v: Value = serde_json::from_str(out.trim()).expect("valid json"); + assert_eq!(v["displayedMatches"], 1); + let files = v["files"].as_array().unwrap(); + assert!(!files.is_empty()); + assert!(!files[0]["matches"].as_array().unwrap().is_empty()); + } + + #[test] + fn test_json_total_and_per_file_caps_both_respected() { + let stdout = concat!( + "a.rs\u{0}1:Foo\n", + "a.rs\u{0}2:Foo\n", + "b.rs\u{0}1:Foo\n", + "b.rs\u{0}2:Foo\n" + ); + let (out, _stats) = render_grep_output( + "Foo", + stdout, + &GrepRenderOptions { + max_line_chars: Some(80), + max_matches: Some(2), + max_per_file: Some(1), + uncapped: false, + files_only: false, + count_by_file: false, + agent_safe: false, + summary_enabled: false, + context_only: false, + }, + None, + true, + ); + let v: Value = serde_json::from_str(out.trim()).expect("valid json"); + assert_eq!(v["displayedMatches"], 2); + let mut total_json_matches = 0usize; + for f in v["files"].as_array().unwrap() { + let m = f["matches"].as_array().unwrap().len(); + assert!(m <= 1); + total_json_matches += m; + } + assert_eq!(total_json_matches, 2); + } } diff --git a/src/cmds/system/log_cmd.rs b/src/cmds/system/log_cmd.rs index 7c765f5e6..627746c7c 100644 --- a/src/cmds/system/log_cmd.rs +++ b/src/cmds/system/log_cmd.rs @@ -22,7 +22,7 @@ lazy_static! { } /// Filter and deduplicate log output -pub fn run_file(file: &Path, verbose: u8) -> Result<()> { +pub fn run_file(file: &Path, recent_events: usize, keywords: &[String], verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); if verbose > 0 { @@ -30,7 +30,7 @@ pub fn run_file(file: &Path, verbose: u8) -> Result<()> { } let content = fs::read_to_string(file)?; - let result = analyze_logs(&content); + let result = analyze_logs_with_options(&content, recent_events, keywords); println!("{}", result); timer.track( &format!("cat {}", file.display()), @@ -42,7 +42,7 @@ pub fn run_file(file: &Path, verbose: u8) -> Result<()> { } /// Filter logs from stdin -pub fn run_stdin(_verbose: u8) -> Result<()> { +pub fn run_stdin(recent_events: usize, keywords: &[String], _verbose: u8) -> Result<()> { let timer = tracking::TimedExecution::start(); let mut content = String::new(); @@ -52,7 +52,7 @@ pub fn run_stdin(_verbose: u8) -> Result<()> { content.push('\n'); } - let result = analyze_logs(&content); + let result = analyze_logs_with_options(&content, recent_events, keywords); println!("{}", result); timer.track("log (stdin)", "rtk log (stdin)", &content, &result); @@ -149,7 +149,7 @@ fn analyze_logs(content: &str) -> String { .map(|s| s.as_str()) .unwrap_or(normalized); - let truncated = if original.len() > 100 { + let truncated = if original.chars().count() > 100 { let t: String = original.chars().take(97).collect(); format!("{}...", t) } else { @@ -191,7 +191,7 @@ fn analyze_logs(content: &str) -> String { .map(|s| s.as_str()) .unwrap_or(normalized); - let truncated = if original.len() > 100 { + let truncated = if original.chars().count() > 100 { let t: String = original.chars().take(97).collect(); format!("{}...", t) } else { @@ -216,6 +216,75 @@ fn analyze_logs(content: &str) -> String { result.join("\n") } +fn analyze_logs_with_options(content: &str, recent_events: usize, keywords: &[String]) -> String { + let mut base = analyze_logs(content); + if recent_events == 0 { + return base; + } + + let keys: Vec = if keywords.is_empty() { + vec![ + "assert", + "error", + "failed", + "fail", + "exception", + "crash", + "load", + "oninitialize", + "onpostinitialize", + "streamread", + ] + .into_iter() + .map(|s| s.to_string()) + .collect() + } else { + keywords.iter().map(|s| s.to_ascii_lowercase()).collect() + }; + + let mut picked: Vec<(usize, String)> = Vec::new(); + let mut seen_norm: Vec = Vec::new(); + + let lines: Vec<&str> = content.lines().collect(); + for idx0 in (0..lines.len()).rev() { + let l = lines[idx0].trim_end(); + if l.is_empty() { + continue; + } + let lower = l.to_ascii_lowercase(); + if !keys.iter().any(|k| lower.contains(k)) { + continue; + } + + let norm = normalize_log_line(l, &TIMESTAMP_RE, &UUID_RE, &HEX_RE, &NUM_RE, &PATH_RE); + if seen_norm.contains(&norm) { + continue; + } + seen_norm.push(norm); + picked.push((idx0 + 1, l.to_string())); + if picked.len() >= recent_events { + break; + } + } + + picked.reverse(); + + if !picked.is_empty() { + base.push_str("\n\n[RECENT_EVENTS]\n"); + for (ln, msg) in picked { + let truncated = if msg.chars().count() > 200 { + let t: String = msg.chars().take(197).collect(); + format!("{}...", t) + } else { + msg + }; + base.push_str(&format!(" {}: {}\n", ln, truncated)); + } + } + + base.trim_end().to_string() +} + fn normalize_log_line( line: &str, timestamp_re: &Regex, @@ -274,4 +343,56 @@ mod tests { // Should not panic even with very long multi-byte messages assert!(result.contains("ERRORS")); } + + #[test] + fn test_analyze_logs_does_not_truncate_when_under_char_limit_but_over_byte_limit() { + let msg = "界".repeat(70); // keep total line <= 100 chars, but >100 bytes + let line = format!("2024-01-01 10:00:00 ERROR: {msg}"); + let logs = format!("{line}\n"); + let result = analyze_logs(&logs); + assert!(result.contains(&line)); + } + + #[test] + fn test_analyze_logs_truncates_when_over_char_limit() { + let msg = "a".repeat(101); + let line = format!("2024-01-01 10:00:00 ERROR: {msg}"); + let logs = format!("{line}\n"); + let result = analyze_logs(&logs); + let expected_prefix: String = line.chars().take(97).collect(); + assert!(result.contains(&format!("{expected_prefix}..."))); + } + + #[test] + fn test_recent_events_tail_dedup() { + let logs = "INFO: startup\n\ + ERROR: Load failed\n\ + ERROR: Load failed\n\ + OnInitialize: begin\n\ + ASSERT failed: x\n"; + let out = analyze_logs_with_options(logs, 3, &[]); + assert!(out.contains("[RECENT_EVENTS]")); + assert!(out.contains("ERROR: Load failed")); + assert!(out.contains("ASSERT failed")); + // Dedup identical ERROR line in recent events + assert_eq!(out.matches("ERROR: Load failed").count(), 2); // one in summary, one in recent events + } + + #[test] + fn test_truncation_does_not_use_byte_len() { + // 60 emojis: >100 bytes, but only 60 chars → should not truncate. + let msg = "🎉".repeat(60); + let logs = format!("2024-01-01 10:00:00 ERROR: {}\n", msg); + let out = analyze_logs(&logs); + assert!(out.contains(&msg)); + assert!(!out.contains("...")); + } + + #[test] + fn test_truncation_uses_char_count_and_appends_ellipsis() { + let msg = "é".repeat(101); + let logs = format!("2024-01-01 10:00:00 ERROR: {}\n", msg); + let out = analyze_logs(&logs); + assert!(out.contains("...")); + } } diff --git a/src/cmds/system/patch.rs b/src/cmds/system/patch.rs new file mode 100644 index 000000000..447e0eba4 --- /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/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index 6dcc4cdb6..918f3fd08 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -12,6 +12,7 @@ pub fn resolve_filter(name: &str) -> Option String> { match name { "cargo-test" | "cargo" => Some(crate::cmds::rust::cargo_cmd::filter_cargo_test), "pytest" => Some(crate::cmds::python::pytest_cmd::filter_pytest_output), + "ctest" => Some(crate::cmds::cpp::ctest_cmd::filter_ctest_output), "go-test" => Some(go_test_wrapper), "go-build" => Some(crate::cmds::go::go_cmd::filter_go_build), "tsc" => Some(crate::cmds::js::tsc_cmd::filter_tsc_output), diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 2f56687ee..bb492d810 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -1,17 +1,31 @@ //! Reads source files with optional language-aware filtering to strip boilerplate. +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; +use regex::Regex; use std::fs; use std::path::Path; +lazy_static! { + // Compiler / managed-code diagnostic: ": error C2065" / ": error L1234" / ": error MSB..." + static ref MSBUILD_DIAG_RE: Regex = Regex::new(r": error [CLM]\d+").unwrap(); + // Linker diagnostic: ": error LNK2001" (covers "fatal error LNK..." too via the colon) + static ref MSBUILD_LNK_RE: Regex = Regex::new(r": error LNK\d+").unwrap(); +} + +#[allow(clippy::too_many_arguments)] pub fn run( file: &Path, 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(); @@ -20,9 +34,32 @@ pub fn run( eprintln!("Reading: {} (filter: {})", file.display(), level); } - // Read file content - let content = fs::read_to_string(file) - .with_context(|| format!("Failed to read file: {}", file.display()))?; + // Read file content (handles UTF-16 LE/BE BOM — MSBuild logs on Windows) + 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 + // pass through verbose project/task chatter at near-zero token savings. + if let Some(filtered) = maybe_apply_msbuild_filter(&content) { + if verbose > 0 { + eprintln!("Detected MSBuild log — applying msbuild filter"); + } + print!("{}", filtered); + timer.track( + &format!("cat {}", file.display()), + "rtk read", + &content, + &filtered, + ); + return Ok(()); + } // Detect language from extension let lang = file @@ -63,10 +100,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() }; @@ -84,7 +124,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}; @@ -127,10 +169,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() }; @@ -140,6 +185,183 @@ pub fn run_stdin( Ok(()) } +/// Heuristic: returns `true` if the first 200 lines contain ANY MSBuild marker. +/// +/// Single marker is enough — real MSBuild logs may have hundreds of lines of +/// progress chatter before the first error/build-result line, so requiring +/// multiple markers in a 200-line sample misses logs where only `.vcxproj` +/// references show up early. The detection runs on transcoded UTF-8 content +/// (after BOM strip + UTF-16 → UTF-8 conversion) and tolerates `\r\n` line +/// endings (`str::lines()` already strips `\r`, but explicit trim is kept +/// for defense in depth). +fn is_msbuild_log(content: &str) -> bool { + for (i, raw_line) in content.lines().take(200).enumerate() { + // Defense in depth: strip a stray UTF-8 BOM that survived decoding. + let line = if i == 0 { + raw_line.trim_start_matches('\u{FEFF}') + } else { + raw_line + }; + let line = line.trim_end_matches('\r'); + + if line.contains("Build FAILED") + || line.contains("Build succeeded") + || line.contains(".vcxproj") + || MSBUILD_DIAG_RE.is_match(line) + || MSBUILD_LNK_RE.is_match(line) + { + return true; + } + } + false +} + +/// If `content` is an MSBuild log, run it through the msbuild filter and return +/// the compressed output. Returns `None` for non-MSBuild content. +fn maybe_apply_msbuild_filter(content: &str) -> Option { + if !is_msbuild_log(content) { + return None; + } + let filtered = msbuild_cmd::filter_output(content, &[]); + if filtered.starts_with("msbuild: ok") { + return Some("rtk read: build ok \u{2014} no errors found in log\n".to_string()); + } + if filtered.starts_with("msbuild: no output captured") { + // Detection passed but the filter found nothing structured — fall + // back to the regular read pipeline so the user still sees content. + return None; + } + let mut out = filtered; + if !out.ends_with('\n') { + out.push('\n'); + } + Some(out) +} + +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()))?; + + // Avoid dumping binary-ish content as Latin1 garbage in --encoding auto mode. + // This is intentionally conservative and only triggers for obvious cases. + if encoding == TextEncoding::Auto && looks_binary_bytes(&bytes) { + let preview = hex_preview(&bytes, 64); + let nul = bytes.iter().take(8192).filter(|b| **b == 0).count(); + let msg = format!( + "rtk read: file appears binary ({} bytes, nul={} in first 8192)\n\ +binary preview (first {} bytes): {}\n\ +hint: use `rtk read --encoding latin1 ` to force raw bytes-as-text\n", + bytes.len(), + nul, + preview.len, + preview.hex + ); + return Ok((msg, text_encoding::UsedEncoding::Utf8, false)); + } + + 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)) +} + +struct HexPreview { + hex: String, + len: usize, +} + +fn hex_preview(bytes: &[u8], max: usize) -> HexPreview { + let n = std::cmp::min(bytes.len(), max); + let mut out = String::new(); + for (i, b) in bytes.iter().take(n).enumerate() { + if i > 0 { + out.push(' '); + } + out.push_str(&format!("{:02X}", b)); + } + HexPreview { hex: out, len: n } +} + +fn looks_binary_bytes(bytes: &[u8]) -> bool { + if bytes.len() >= 2 && ((bytes[0] == 0xFF && bytes[1] == 0xFE) || (bytes[0] == 0xFE && bytes[1] == 0xFF)) { + return false; + } + let len = std::cmp::min(bytes.len(), 8192); + let sample = &bytes[..len]; + if sample.is_empty() { + return false; + } + + // UTF-16 without BOM can contain many NULs; don't treat it as binary. + if looks_utf16_no_bom(sample) { + return false; + } + + // A single NUL byte is a strong signal for binary in this tool's context. + if sample.contains(&0) { + return true; + } + + // If a large fraction of bytes are control chars (excluding \t,\n,\r), treat as binary-ish. + let mut control = 0usize; + for b in sample { + if *b < 0x09 || (*b > 0x0D && *b < 0x20) { + control += 1; + } + } + (control as f64 / sample.len() as f64) > 0.30 +} + +fn looks_utf16_no_bom(sample: &[u8]) -> bool { + if sample.len() < 4 { + return false; + } + let mut zeros_even = 0usize; + let mut zeros_odd = 0usize; + let mut pairs = 0usize; + for chunk in sample.chunks_exact(2).take(4096) { + pairs += 1; + if chunk[0] == 0 { + zeros_even += 1; + } + if chunk[1] == 0 { + zeros_odd += 1; + } + } + if pairs == 0 { + return false; + } + let even_ratio = zeros_even as f64 / pairs as f64; + let odd_ratio = zeros_odd as f64 / pairs as f64; + + fn ascii_lane_ratio(sample: &[u8], lane: usize) -> f64 { + let mut total = 0usize; + let mut ascii = 0usize; + for chunk in sample.chunks_exact(2).take(4096) { + let b = chunk[lane]; + if b == 0 { + continue; + } + total += 1; + let is_ascii = + b == b'\t' || b == b'\n' || b == b'\r' || (0x20..=0x7E).contains(&b); + if is_ascii { + ascii += 1; + } + } + if total == 0 { + 0.0 + } else { + ascii as f64 / total as f64 + } + } + + (odd_ratio >= 0.60 && even_ratio < 0.10 && ascii_lane_ratio(sample, 0) >= 0.85) + || (even_ratio >= 0.60 && odd_ratio < 0.10 && ascii_lane_ratio(sample, 1) >= 0.85) +} + fn format_with_line_numbers(content: &str) -> String { let lines: Vec<&str> = content.lines().collect(); let width = lines.len().to_string().len(); @@ -150,12 +372,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(); @@ -194,10 +450,104 @@ 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_read_auto_utf16_le_no_bom() -> Result<()> { + let mut file = NamedTempFile::new()?; + // "Hi\n" in UTF-16 LE without BOM + file.write_all(&[0x48, 0x00, 0x69, 0x00, 0x0A, 0x00])?; + let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?; + assert!(used_fallback); + assert_eq!(used, text_encoding::UsedEncoding::Utf16Le); + assert!(txt.contains("Hi")); Ok(()) } + #[test] + fn test_read_auto_utf16_le_bom() -> Result<()> { + let mut file = NamedTempFile::new()?; + // BOM + "Hi\n" in UTF-16 LE + file.write_all(&[0xFF, 0xFE, 0x48, 0x00, 0x69, 0x00, 0x0A, 0x00])?; + let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?; + assert!(!used_fallback); + assert_eq!(used, text_encoding::UsedEncoding::Utf16Le); + assert!(txt.contains("Hi")); + Ok(()) + } + + #[test] + fn test_read_auto_windows_1252() -> Result<()> { + let mut file = NamedTempFile::new()?; + // "Hé" in windows-1252: 0x48 0xE9 (invalid UTF-8) + file.write_all(&[0x48, 0xE9])?; + let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?; + assert!(used_fallback); + assert_eq!(used, text_encoding::UsedEncoding::Windows1252); + assert!(txt.contains('é')); + Ok(()) + } + + #[test] + fn test_read_auto_binary_preview() -> Result<()> { + let mut file = NamedTempFile::new()?; + file.write_all(&[0x00, 0x01, 0x02, 0x03, 0x00, 0xFF])?; + let (txt, used, used_fallback) = read_file_text(file.path(), TextEncoding::Auto)?; + assert!(!used_fallback); + assert_eq!(used, text_encoding::UsedEncoding::Utf8); + assert!(txt.contains("file appears binary")); + assert!(txt.contains("binary preview")); + 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_apply_line_window_invalid_range_empty() { + let lang = Language::Unknown; + let s = "a\nb\nc\n"; + assert_eq!(apply_line_window(s, None, None, Some((0, 2)), &lang), ""); + assert_eq!(apply_line_window(s, None, None, Some((3, 2)), &lang), ""); + } + + #[test] + fn test_format_with_line_numbers_offset() { + let s = "b\nc\n"; + let out = format_with_line_numbers_offset(s, 2); + assert!(out.contains("2 │ b")); + assert!(out.contains("3 │ c")); + } + #[test] fn test_stdin_support_signature() { // Test that run_stdin has correct signature and compiles @@ -205,24 +555,202 @@ fn main() {{ // Compile-time verification that the function exists with correct signature } + #[test] + fn test_is_msbuild_log_failure_fixture() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_compiler.txt"); + assert!(is_msbuild_log(raw)); + } + + #[test] + fn test_is_msbuild_log_success_fixture() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_success.txt"); + assert!(is_msbuild_log(raw)); + } + + #[test] + fn test_is_msbuild_log_rejects_plain_text() { + let plain = "This is just\nsome regular text\nno build markers here\n"; + assert!(!is_msbuild_log(plain)); + } + + #[test] + fn test_is_msbuild_log_single_marker_is_enough() { + // Single marker is sufficient under OR semantics (real msbuild logs + // often start with hundreds of lines of progress chatter before the + // first error/build-result line). + assert!(is_msbuild_log("see MyProject.vcxproj for details\n")); + assert!(is_msbuild_log("Build FAILED.\n")); + assert!(is_msbuild_log("Build succeeded.\n")); + assert!(is_msbuild_log("foo.lib(bar.obj) : error LNK2001: x\n")); + assert!(is_msbuild_log("a.cpp(1): error C2065: x\n")); + } + + #[test] + fn test_is_msbuild_log_skips_first_200_only() { + // 250 plain lines then an MSBuild marker → not detected (200-line cap). + let mut s = String::new(); + for _ in 0..250 { + s.push_str("plain line\n"); + } + s.push_str("Build FAILED.\n"); + assert!(!is_msbuild_log(&s)); + } + + #[test] + fn test_is_msbuild_log_strips_utf8_bom_first_line() { + let txt = "\u{FEFF}.vcxproj reference\nmore content\n"; + assert!(is_msbuild_log(txt)); + } + + #[test] + fn test_is_msbuild_log_handles_crlf() { + let txt = "header\r\nBuild FAILED.\r\n"; + assert!(is_msbuild_log(txt)); + } + + #[test] + fn test_maybe_apply_msbuild_filter_success() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_success.txt"); + let out = maybe_apply_msbuild_filter(raw).expect("should detect"); + assert!(out.starts_with("rtk read: build ok")); + } + + #[test] + fn test_maybe_apply_msbuild_filter_failure_compresses() { + let raw = include_str!("../../../tests/fixtures/cpp/msbuild_failure_compiler.txt"); + let out = maybe_apply_msbuild_filter(raw).expect("should detect"); + assert!(out.contains("C2065")); + assert!(out.contains("Build FAILED")); + // Must drop the verbose header chatter + assert!(!out.contains("Microsoft (R) Build Engine")); + assert!(!out.contains("Done Building Project")); + // Must compress + assert!( + out.len() < raw.len(), + "filter should reduce size: raw={} filtered={}", + raw.len(), + out.len() + ); + } + + #[test] + fn test_maybe_apply_msbuild_filter_skips_non_msbuild() { + let plain = "Just some\nregular file content\n"; + assert!(maybe_apply_msbuild_filter(plain).is_none()); + } + + #[test] + 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!( + 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!( + 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!( + text_encoding::decode_bytes(bytes, TextEncoding::Auto) + .unwrap() + .text, + "hi" + ); + } + + #[test] + fn test_decode_plain_utf8() { + assert_eq!( + text_encoding::decode_bytes(b"plain text", TextEncoding::Auto) + .unwrap() + .text, + "plain text" + ); + } + + #[test] + fn test_decode_utf16_le_msbuild_style() { + // Simulate a tiny MSBuild log line in UTF-16 LE + let line = "Build succeeded.\r\n"; + let mut bytes = vec![0xFF, 0xFE]; + for c in line.encode_utf16() { + bytes.extend_from_slice(&c.to_le_bytes()); + } + assert_eq!( + text_encoding::decode_bytes(&bytes, TextEncoding::Auto) + .unwrap() + .text, + line + ); + } + + #[test] + fn test_looks_utf16_no_bom_allows_odd_length_sample() { + // UTF-16LE-like ASCII: H i ! with a trailing odd byte. + let sample = vec![0x48, 0x00, 0x69, 0x00, 0x21, 0x00, 0xFF]; + assert!(looks_utf16_no_bom(&sample)); + } + + #[test] + fn test_looks_utf16_no_bom_short_sample_false() { + assert!(!looks_utf16_no_bom(&[0x00, 0x41, 0x00])); + } + + #[test] + fn test_read_utf16_le_file() -> Result<()> { + // End-to-end: rtk read on a UTF-16 LE file should not crash + let mut file = NamedTempFile::with_suffix(".log")?; + file.write_all(&[0xFF, 0xFE])?; + for c in "Build succeeded.\n".encode_utf16() { + file.write_all(&c.to_le_bytes())?; + } + 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/config.rs b/src/core/config.rs index ed0f00c6c..edc3be83a 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -21,6 +21,14 @@ pub struct Config { pub hooks: HooksConfig, #[serde(default)] pub limits: LimitsConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct AgentConfig { + #[serde(default)] + pub safe_mode: bool, } #[derive(Debug, Serialize, Deserialize, Default)] diff --git a/src/core/mod.rs b/src/core/mod.rs index d5182bd34..eefae2488 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -10,6 +10,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 truncate; diff --git a/src/core/text_encoding.rs b/src/core/text_encoding.rs new file mode 100644 index 000000000..707f8b847 --- /dev/null +++ b/src/core/text_encoding.rs @@ -0,0 +1,273 @@ +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 => { + // Heuristic: UTF-16 without BOM (common for some Windows logs / legacy tools). + // Check this BEFORE accepting UTF-8 when NUL bytes are present, because UTF-16 + // payloads like "H\0i\0" are valid UTF-8 but produce unreadable output. + if payload.contains(&0) { + if let Some(utf16) = detect_utf16_no_bom(payload) { + return Ok(DecodedText { + text: utf16.text, + used: utf16.used, + used_fallback: true, + }); + } + } + + 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, + }) + } + } +} + +struct Utf16Guess { + text: String, + used: UsedEncoding, +} + +fn detect_utf16_no_bom(payload: &[u8]) -> Option { + #[allow(clippy::manual_is_multiple_of)] + if payload.len() < 4 || payload.len() % 2 != 0 { + return None; + } + + let mut zeros_even = 0usize; + let mut zeros_odd = 0usize; + let mut pairs = 0usize; + for chunk in payload.chunks_exact(2).take(4096) { + pairs += 1; + if chunk[0] == 0 { + zeros_even += 1; + } + if chunk[1] == 0 { + zeros_odd += 1; + } + } + if pairs == 0 { + return None; + } + + let even_ratio = zeros_even as f64 / pairs as f64; + let odd_ratio = zeros_odd as f64 / pairs as f64; + + // For ASCII-ish UTF-16, every other byte is often 0x00 AND the other lane is + // mostly printable ASCII. + const THRESH: f64 = 0.60; + if odd_ratio >= THRESH && even_ratio < 0.10 && ascii_lane_ratio(payload, 0) >= 0.85 { + return Some(Utf16Guess { + text: decode_utf16(payload, true), + used: UsedEncoding::Utf16Le, + }); + } + if even_ratio >= THRESH && odd_ratio < 0.10 && ascii_lane_ratio(payload, 1) >= 0.85 { + return Some(Utf16Guess { + text: decode_utf16(payload, false), + used: UsedEncoding::Utf16Be, + }); + } + + None +} + +fn ascii_lane_ratio(payload: &[u8], lane: usize) -> f64 { + let mut total = 0usize; + let mut ascii = 0usize; + for chunk in payload.chunks_exact(2).take(4096) { + let b = chunk[lane]; + if b == 0 { + continue; + } + total += 1; + let is_ascii = b == b'\t' || b == b'\n' || b == b'\r' || (0x20..=0x7E).contains(&b); + if is_ascii { + ascii += 1; + } + } + if total == 0 { + 0.0 + } else { + ascii as f64 / total as f64 + } +} + +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/discover/registry.rs b/src/discover/registry.rs index 4fd716828..51e9ba09d 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -73,6 +73,40 @@ lazy_static! { static ref TAIL_N_SPACE: Regex = Regex::new(r"^tail\s+-n\s+(\d+)\s+(\S+)$").unwrap(); static ref TAIL_LINES_EQ: Regex = Regex::new(r"^tail\s+--lines=(\d+)\s+(\S+)$").unwrap(); static ref TAIL_LINES_SPACE: Regex = Regex::new(r"^tail\s+--lines\s+(\d+)\s+(\S+)$").unwrap(); + + // PowerShell: Select-String → rtk grep (limited, safety-first). + // + // We only rewrite when we can prove equivalence. Most importantly: + // - Select-String is case-insensitive by default; rg/grep are not. + // - Pipelines ($_.FullName) and multi-pattern arrays are not safely rewritable here. + static ref SELECT_STRING_PATH_FIRST: Regex = Regex::new( + r#"(?i)^Select-String\s+.*?-Path\s+(\S+).*?-Pattern\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+)(?:\s|$)"# + ).unwrap(); + static ref SELECT_STRING_PATTERN_FIRST: Regex = Regex::new( + r#"(?i)^Select-String\s+.*?-Pattern\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\S+).*?-Path\s+(\S+)(?:\s|$)"# + ).unwrap(); + static ref SELECT_STRING_CASE_SENSITIVE_RE: Regex = Regex::new(r"(?i)\s-CaseSensitive(?:\s|$)").unwrap(); + static ref SELECT_STRING_SIMPLE_MATCH_RE: Regex = Regex::new(r"(?i)\s-SimpleMatch(?:\s|$)").unwrap(); + static ref SELECT_STRING_CONTEXT_RE: Regex = Regex::new(r"(?i)\s-Context\s+(\d+)\s*,\s*(\d+)(?:\s|$)").unwrap(); + static ref SELECT_STRING_RECURSE_RE: Regex = Regex::new(r"(?i)\s-Recurse(?:\s|$)").unwrap(); + + // PowerShell: Get-Content / GC → rtk read + static ref GET_CONTENT_RE: Regex = + Regex::new(r"(?i)^(?:Get-Content|GC)\s+(\S+)").unwrap(); + + // PowerShell: Remove-Item → rtk remove-item (preserves all original args) + static ref REMOVE_ITEM_RE: Regex = Regex::new(r"(?i)^Remove-Item\b").unwrap(); + + // NOTE: a previous `MSBUILD_REDIRECT_RE` rule attempted to detect + // `msbuild ... *> file.log` and emit a hint pointing at the log. It was + // removed because PowerShell consumes `*>` as an all-streams redirect + // operator BEFORE the command reaches RTK's hook — by the time + // `rtk rewrite` is invoked, the command string is just `msbuild ...` + // (no `*>`, no log path). The rule only ever fired on quoted/escaped + // inputs that bypass the shell, which never occurs in real Claude Code + // hook traffic. Document the limitation in user-facing docs (RTK.md): + // when output is redirected with `*>`, use `rtk read ` after + // the build completes. } const GOLANGCI_GLOBAL_OPT_WITH_VALUE: &[&str] = &[ @@ -557,6 +591,26 @@ fn rewrite_compound( } TokenKind::Pipe => { let seg = cmd[seg_start..tok.offset].trim(); + let pipe_group_end = tokens.iter().find(|t| { + t.offset > tok.offset + && (t.kind == TokenKind::Operator + || (t.kind == TokenKind::Shellism && t.value == "&")) + }); + let pipe_end = pipe_group_end.map(|t| t.offset).unwrap_or(cmd.len()); + let pipe_group = cmd[tok.offset..pipe_end].trim(); + + if let Some(rewritten) = try_rewrite_powershell_pipe_group(seg, pipe_group) { + if rewritten != seg { + any_changed = true; + } + result.push_str(&rewritten); + seg_start = pipe_end; + if pipe_group_end.is_none() { + return if any_changed { Some(result) } else { None }; + } + continue; + } + let is_pipe_incompatible = seg.starts_with("find ") || seg == "find" || seg.starts_with("fd ") @@ -572,12 +626,6 @@ fn rewrite_compound( } result.push_str(&rewritten); - let pipe_group_end = tokens.iter().find(|t| { - t.offset > tok.offset - && (t.kind == TokenKind::Operator - || (t.kind == TokenKind::Shellism && t.value == "&")) - }); - match pipe_group_end { Some(next_op) => { result.push(' '); @@ -714,6 +762,277 @@ fn rewrite_segment( rewrite_segment_inner(seg, excluded, transparent_prefixes, 0) } +/// Rewrite PowerShell built-ins (`Select-String`, `Get-Content`/`GC`, `Remove-Item`) +/// to their RTK equivalents. Returns `None` if no PowerShell pattern matches. +fn try_powershell_rewrite(cmd: &str) -> Option { + if let Some(caps) = SELECT_STRING_PATH_FIRST + .captures(cmd) + .or_else(|| SELECT_STRING_PATTERN_FIRST.captures(cmd)) + { + // Capture order differs between the two regexes: disambiguate by checking + // whether `-Path` appears before `-Pattern` in the original command. + let lower = cmd.to_ascii_lowercase(); + let path_before_pattern = match (lower.find("-path"), lower.find("-pattern")) { + (Some(p), Some(q)) => p < q, + _ => false, + }; + let (pattern, path) = if path_before_pattern { + (caps.get(2)?.as_str(), caps.get(1)?.as_str()) + } else { + (caps.get(1)?.as_str(), caps.get(2)?.as_str()) + }; + + // Safety: do not rewrite pipeline placeholders or obvious variables. + // Those depend on runtime values (e.g. $_.FullName) that rtk rewrite cannot evaluate. + if path.contains("$_") || path.starts_with('$') { + return None; + } + // Safety: multi-pattern arrays ("x","y") are not representable in a single rtk grep pattern arg. + if pattern.contains(',') { + return None; + } + // Safety: Select-String -Recurse with wildcard paths relies on PowerShell expansion semantics. + // rtk grep is recursive by default but cannot reliably reproduce PS globbing here. + if SELECT_STRING_RECURSE_RE.is_match(cmd) && (path.contains('*') || path.contains('?')) { + return None; + } + + let mut out = String::new(); + out.push_str("rtk grep "); + + if SELECT_STRING_SIMPLE_MATCH_RE.is_match(cmd) { + out.push_str("--fixed "); + } + + out.push_str(pattern); + out.push(' '); + out.push_str(path); + + // Select-String is case-insensitive by default. + if !SELECT_STRING_CASE_SENSITIVE_RE.is_match(cmd) { + out.push_str(" -- -i"); + } + + // Context window: -Context before,after + if let Some(ctx) = SELECT_STRING_CONTEXT_RE.captures(cmd) { + let before = ctx.get(1)?.as_str(); + let after = ctx.get(2)?.as_str(); + out.push_str(&format!(" -B {} -A {}", before, after)); + } + + return Some(out); + } + + // PowerShell: Get-ChildItem / gci / dir → rtk gci (subset) + { + let lower = cmd.trim_start().to_ascii_lowercase(); + if lower.starts_with("get-childitem") + || lower.starts_with("gci") + || lower.starts_with("dir") + { + if let Some(rewritten) = try_rewrite_powershell_get_child_item(cmd) { + return Some(rewritten); + } + } + } + + if let Some(caps) = GET_CONTENT_RE.captures(cmd) { + let path = caps.get(1)?.as_str(); + return Some(format!("rtk read {}", path)); + } + + if REMOVE_ITEM_RE.is_match(cmd) { + let rest = cmd[cmd.find(|c: char| c.is_whitespace()).unwrap_or(cmd.len())..].trim_start(); + if rest.is_empty() { + return Some("rtk remove-item".to_string()); + } + return Some(format!("rtk remove-item {}", rest)); + } + + None +} + +fn try_rewrite_powershell_pipe_group(left: &str, pipe_group: &str) -> Option { + // Only supports: Get-ChildItem ... | Select-Object FullName,LastWriteTime,Length + // Rewrites the whole pipe group into: rtk gci ... --select ... + let lower_left = left.trim_start().to_ascii_lowercase(); + if !(lower_left.starts_with("get-childitem") + || lower_left.starts_with("gci") + || lower_left.starts_with("dir")) + { + return None; + } + + let pg = pipe_group.trim(); + let pg_lower = pg.to_ascii_lowercase(); + if !pg_lower.starts_with("| select-object") { + return None; + } + + // Very small, safety-first parser: do not attempt to evaluate variables. + if left.contains("$_") || left.contains('$') { + return None; + } + + // Extract the property list after Select-Object. + let props = pg.split_once(char::is_whitespace)?.1.trim(); // "Select-Object ..." + let props = props + .strip_prefix("Select-Object") + .or_else(|| props.strip_prefix("select-object"))? + .trim(); + if props.is_empty() { + return None; + } + + let rewritten_left = try_rewrite_powershell_get_child_item(left)?; + Some(format!("{} --select {}", rewritten_left, props)) +} + +fn try_rewrite_powershell_get_child_item(cmd: &str) -> Option { + // Supports a small subset of PowerShell Get-ChildItem/gci/dir flags and rewrites + // to `rtk gci` with equivalent-ish behavior. + // + // Safety: no variables, no pipeline placeholders. + if cmd.contains("$_") || cmd.contains('$') { + return None; + } + + let mut tokens: Vec = split_powershell_args_quote_aware(cmd)?; + if tokens.is_empty() { + return None; + } + + // Drop the leading command word (Get-ChildItem/gci/dir) + tokens.remove(0); + + let mut path: Option = None; + let mut recurse = false; + let mut force = false; + let mut kind_file = false; + let mut kind_dir = false; + let mut filter: Option = None; + let mut include: Option = None; + + let mut i = 0; + while i < tokens.len() { + let t = tokens[i].as_str(); + let lower = t.to_ascii_lowercase(); + if !t.starts_with('-') && path.is_none() { + path = Some(tokens[i].clone()); + i += 1; + continue; + } + match lower.as_str() { + "-recurse" => { + recurse = true; + i += 1; + } + "-force" => { + force = true; + i += 1; + } + "-file" => { + kind_file = true; + i += 1; + } + "-directory" => { + kind_dir = true; + i += 1; + } + "-filter" => { + filter = tokens.get(i + 1).cloned(); + i += 2; + } + "-include" => { + include = tokens.get(i + 1).cloned(); + i += 2; + } + _ => { + // Unknown flag → do not rewrite (safety). + return None; + } + } + } + + let mut out = String::new(); + out.push_str("rtk gci "); + out.push_str(path.as_deref().unwrap_or(".")); + if recurse { + out.push_str(" --recurse"); + } + if force { + out.push_str(" --force"); + } + if kind_file { + out.push_str(" --file"); + } else if kind_dir { + out.push_str(" --directory"); + } + if let Some(f) = filter { + out.push_str(" --filter "); + out.push_str(&f); + } + if let Some(inc) = include { + out.push_str(" --include "); + out.push_str(&inc); + } + Some(out) +} + +fn split_powershell_args_quote_aware(cmd: &str) -> Option> { + let mut out: Vec = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + let mut escape = false; + + for ch in cmd.chars() { + if escape { + cur.push(ch); + escape = false; + continue; + } + + if quote == Some('"') && ch == '\\' { + // Treat backslash-escaped chars inside double quotes as literal. + escape = true; + cur.push(ch); + continue; + } + + if let Some(q) = quote { + cur.push(ch); + if ch == q { + quote = None; + } + continue; + } + + if ch == '"' || ch == '\'' { + quote = Some(ch); + cur.push(ch); + continue; + } + + if ch.is_whitespace() { + if !cur.is_empty() { + out.push(cur.clone()); + cur.clear(); + } + continue; + } + + cur.push(ch); + } + + if quote.is_some() { + return None; + } + if !cur.is_empty() { + out.push(cur); + } + Some(out) +} + fn is_excluded(cmd: &str, excluded: &[ExcludePattern]) -> bool { excluded.iter().any(|pat| match pat { ExcludePattern::Regex(re) => re.is_match(cmd), @@ -774,6 +1093,17 @@ fn rewrite_segment_inner( } } + // PowerShell-specific special cases. These rewrites do not pass through the + // standard prefix-swap path because the source command and rtk command have + // different argument orderings (Select-String) or fixed shapes. + if let Some(rewritten) = try_powershell_rewrite(trimmed) { + return Some(rewritten); + } + + // (PowerShell `msbuild ... *> file.log` cannot be intercepted at rewrite + // time — the shell consumes `*>` before RTK sees the command string. + // See registry-level comment near MSBUILD_REDIRECT_RE removal.) + // Strip trailing stderr/stdout redirects before matching (#530) // e.g. "git status 2>&1" → match "git status", re-append " 2>&1" let (cmd_part, redirect_suffix) = strip_trailing_redirects(trimmed); @@ -783,6 +1113,15 @@ fn rewrite_segment_inner( return Some(trimmed.to_string()); } + // make install/clean/distclean — pass through unchanged. The regex crate + // does not support negative lookahead, so this is enforced procedurally. + if let Some(rest) = cmd_part.strip_prefix("make ") { + let target = rest.split_whitespace().next().unwrap_or(""); + if matches!(target, "install" | "clean" | "distclean") { + return None; + } + } + if cmd_part.starts_with("head -") || cmd_part.starts_with("tail ") { return rewrite_line_range(cmd_part).map(|r| format!("{}{}", r, redirect_suffix)); } @@ -1233,6 +1572,86 @@ mod tests { ); } + #[test] + fn test_rewrite_powershell_select_string_default_case_insensitive() { + assert_eq!( + rewrite_command_no_prefixes("Select-String -Path f -Pattern p", &[]), + Some("rtk grep p f -- -i".into()) + ); + } + + #[test] + fn test_rewrite_powershell_select_string_case_sensitive() { + assert_eq!( + rewrite_command_no_prefixes("Select-String -Path f -Pattern p -CaseSensitive", &[]), + Some("rtk grep p f".into()) + ); + } + + #[test] + fn test_rewrite_powershell_select_string_simple_match_and_context() { + assert_eq!( + rewrite_command_no_prefixes( + "Select-String -Path f -Pattern p -SimpleMatch -Context 2,3", + &[] + ), + Some("rtk grep --fixed p f -- -i -B 2 -A 3".into()) + ); + } + + #[test] + fn test_rewrite_powershell_select_string_skips_variable_path() { + assert_eq!( + rewrite_command_no_prefixes("Select-String -Path $p -Pattern p", &[]), + None + ); + assert_eq!( + rewrite_command_no_prefixes("Select-String -Path $_.FullName -Pattern p", &[]), + None + ); + } + + #[test] + fn test_rewrite_powershell_get_child_item_simple() { + assert_eq!( + rewrite_command_no_prefixes("Get-ChildItem . -Recurse -File -Filter API_win.obj", &[]), + Some("rtk gci . --recurse --file --filter API_win.obj".into()) + ); + } + + #[test] + fn test_rewrite_powershell_get_child_item_quoted_path_and_filter() { + assert_eq!( + rewrite_command_no_prefixes( + "Get-ChildItem \"C:\\My Path\" -Recurse -File -Filter \"API win.obj\"", + &[] + ), + Some("rtk gci \"C:\\My Path\" --recurse --file --filter \"API win.obj\"".into()) + ); + } + + #[test] + fn test_rewrite_powershell_get_child_item_quoted_glob_filter() { + assert_eq!( + rewrite_command_no_prefixes("Get-ChildItem . -Filter \"*.CPP\"", &[]), + Some("rtk gci . --filter \"*.CPP\"".into()) + ); + } + + #[test] + fn test_rewrite_powershell_get_child_item_pipe_select_object_absorbed() { + assert_eq!( + rewrite_command_no_prefixes( + "Get-ChildItem . -Recurse -Force -File -Filter API_win.obj | Select-Object FullName,LastWriteTime,Length", + &[] + ), + Some( + "rtk gci . --recurse --force --file --filter API_win.obj --select FullName,LastWriteTime,Length" + .into() + ) + ); + } + // --- git -C support (#555) --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index df7c72d03..5be99ec62 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -878,6 +878,66 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // C++ / CMake / MSBuild + RtkRule { + pattern: r"^cmake\s+(--build|-B)\b", + rtk_cmd: "rtk cmake", + rewrite_prefixes: &["cmake"], + category: "Build", + savings_pct: 80.0, + subcmd_savings: &[("--build", 85.0), ("-B", 70.0)], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^ctest(?:\s|$)", + rtk_cmd: "rtk ctest", + rewrite_prefixes: &["ctest"], + category: "Tests", + savings_pct: 85.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + // `make` rule covers any target except install/clean/distclean — the lifecycle + // exclusion is enforced in registry::rewrite_segment_inner since the regex + // crate does not support lookahead. + RtkRule { + pattern: r"^make(?:\s|$)", + rtk_cmd: "rtk make", + rewrite_prefixes: &["make"], + category: "Build", + savings_pct: 80.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^ninja(?:\s|$)", + rtk_cmd: "rtk ninja", + rewrite_prefixes: &["ninja"], + category: "Build", + savings_pct: 80.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + RtkRule { + pattern: r"^msbuild(?:\s|$)", + rtk_cmd: "rtk msbuild", + rewrite_prefixes: &["msbuild"], + category: "Build", + savings_pct: 80.0, + subcmd_savings: &[], + subcmd_status: &[], + }, + // codegraph: only the listed CLI subcommands are rewritten. `serve`, `install`, + // `watch`, `visualize`, `doctor`, and `config` deliberately fall through. + RtkRule { + pattern: r"^codegraph\s+(index|update|stats|find-symbol|search|callers|callees|impact|affected-tests)\b", + rtk_cmd: "rtk codegraph", + rewrite_prefixes: &["codegraph"], + category: "Build", + savings_pct: 85.0, + subcmd_savings: &[("index", 90.0), ("update", 90.0)], + subcmd_status: &[], + }, ]; pub const IGNORED_PREFIXES: &[&str] = &[ diff --git a/src/hooks/constants.rs b/src/hooks/constants.rs index 23d2e1089..3094a63c5 100644 --- a/src/hooks/constants.rs +++ b/src/hooks/constants.rs @@ -25,7 +25,9 @@ pub const GEMINI_DIR: &str = ".gemini"; pub const GITHUB_DIR: &str = ".github"; pub const COPILOT_HOOK_FILE: &str = "rtk-rewrite.json"; pub const COPILOT_INSTRUCTIONS_FILE: &str = "copilot-instructions.md"; +#[allow(dead_code)] pub const COPILOT_USER_DIR: &str = ".copilot"; +#[allow(dead_code)] pub const COPILOT_HOME_ENV: &str = "COPILOT_HOME"; pub const PI_DIR: &str = ".pi/agent"; diff --git a/src/hooks/init.rs b/src/hooks/init.rs index 4c68ec49b..1b4b73164 100644 --- a/src/hooks/init.rs +++ b/src/hooks/init.rs @@ -3960,6 +3960,7 @@ fn run_copilot_at(base: &Path, ctx: InitContext) -> Result<()> { } /// Entry point for `rtk init --uninstall --copilot` (project-scoped, like install). +#[allow(dead_code)] pub fn uninstall_copilot(ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; let removed = uninstall_copilot_at(Path::new("."), ctx)?; @@ -3988,6 +3989,7 @@ pub fn uninstall_copilot(ctx: InitContext) -> Result<()> { } /// Same as [`uninstall_copilot`] but operates relative to an explicit base path. +#[allow(dead_code)] fn uninstall_copilot_at(base: &Path, ctx: InitContext) -> Result> { let InitContext { dry_run, .. } = ctx; let github_dir = base.join(GITHUB_DIR); @@ -4036,6 +4038,7 @@ fn uninstall_copilot_at(base: &Path, ctx: InitContext) -> Result> { Ok(removed) } +#[allow(dead_code)] fn copilot_user_dir() -> Result { if let Ok(custom) = std::env::var(COPILOT_HOME_ENV) { return Ok(PathBuf::from(custom)); @@ -4044,11 +4047,13 @@ fn copilot_user_dir() -> Result { Ok(home.join(COPILOT_USER_DIR)) } +#[allow(dead_code)] pub fn run_copilot_global(ctx: InitContext) -> Result<()> { let copilot_dir = copilot_user_dir()?; run_copilot_global_at(&copilot_dir, ctx) } +#[allow(dead_code)] fn run_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result<()> { let InitContext { dry_run, .. } = ctx; let hooks_dir = copilot_dir.join(HOOKS_SUBDIR); @@ -4088,6 +4093,7 @@ fn run_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result<()> { Ok(()) } +#[allow(dead_code)] pub fn uninstall_copilot_global(ctx: InitContext) -> Result<()> { let copilot_dir = copilot_user_dir()?; let InitContext { dry_run, .. } = ctx; @@ -4116,6 +4122,7 @@ pub fn uninstall_copilot_global(ctx: InitContext) -> Result<()> { Ok(()) } +#[allow(dead_code)] fn uninstall_copilot_global_at(copilot_dir: &Path, ctx: InitContext) -> Result> { let InitContext { dry_run, .. } = ctx; let hook_path = copilot_dir.join(HOOKS_SUBDIR).join(COPILOT_HOOK_FILE); diff --git a/src/main.rs b/src/main.rs index 992f865a2..60a85424d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod parser; // Re-export command modules for routing use cmds::cloud::{aws_cmd, container, curl_cmd, psql_cmd, wget_cmd}; +use cmds::cpp::{cmake_cmd, codegraph_cmd, ctest_cmd, make_cmd, msbuild_cmd, remove_item}; use cmds::dotnet::{binlog, dotnet_cmd, dotnet_format_report, dotnet_trx}; use cmds::git::{diff_cmd, gh_cmd, git, glab_cmd, gt_cmd}; use cmds::go::{go_cmd, golangci_cmd}; @@ -20,8 +21,8 @@ use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd}; 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, + deps, env_cmd, find_cmd, format_cmd, gci_cmd, grep_cmd, json_cmd, local_llm, log_cmd, ls, + patch, pipe_cmd, read, summary, tree, wc_cmd, }; use anyhow::{Context, Result}; @@ -30,6 +31,175 @@ use clap::{Parser, Subcommand, ValueEnum}; use std::ffi::OsString; use std::path::{Path, PathBuf}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct GrepEffectiveLimits { + max_line_chars: Option, + max_matches: Option, + max_per_file: Option, + summary_enabled: bool, +} + +#[allow(clippy::too_many_arguments)] +fn compute_grep_effective_limits( + max_len: usize, + max: usize, + all: bool, + full_lines: bool, + agent_safe: bool, + max_matches: Option, + max_per_file: Option, + max_line_chars: Option, +) -> GrepEffectiveLimits { + // Keep legacy defaults unless user opts in. `--agent-safe` supplies caps unless + // explicit override flags are present. `--all` forces uncapped. + let mut effective_max_matches: Option = None; + let mut effective_max_per_file: Option = None; + let mut effective_max_line_chars: Option = None; + + if agent_safe { + effective_max_matches = Some(80); + effective_max_per_file = Some(5); + effective_max_line_chars = Some(240); + } + + if let Some(n) = max_matches { + effective_max_matches = Some(n); + } + if let Some(n) = max_per_file { + effective_max_per_file = Some(n); + } + if let Some(n) = max_line_chars { + effective_max_line_chars = Some(n); + } + + // Back-compat: legacy flags set the baseline when not using explicit new overrides. + if effective_max_matches.is_none() { + effective_max_matches = Some(max); + } + if effective_max_line_chars.is_none() { + effective_max_line_chars = Some(max_len); + } + + if full_lines { + effective_max_line_chars = None; + } + if all { + effective_max_matches = None; + effective_max_per_file = None; + } + + let summary_enabled = + agent_safe || max_matches.is_some() || max_per_file.is_some() || max_line_chars.is_some(); + + GrepEffectiveLimits { + max_line_chars: effective_max_line_chars, + max_matches: effective_max_matches, + max_per_file: effective_max_per_file, + summary_enabled, + } +} + +fn parse_truthy_env_var(name: &str) -> bool { + let Ok(v) = std::env::var(name) else { + return false; + }; + matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "y" | "on" + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct GrepCliFixups { + top_files: Option, + json: bool, +} + +struct GrepCliArgs { + files_only: bool, + count_by_file: bool, + all: bool, + max_matches: Option, + max_per_file: Option, + max_line_chars: Option, + full_lines: bool, + agent_safe: bool, + summary_enabled: bool, + fixups: GrepCliFixups, +} + +fn apply_grep_rtk_flags_from_extra_args( + state: &mut GrepCliArgs, + extra_args: Vec, +) -> Result> { + let mut forwarded: Vec = Vec::new(); + let mut i = 0usize; + while i < extra_args.len() { + let a = &extra_args[i]; + + let take_value = |name: &str| -> Result { + let Some(v) = extra_args.get(i + 1) else { + return Err(anyhow::anyhow!("missing value for {}", name)); + }; + Ok(v.clone()) + }; + + match a.as_str() { + "--files-only" => { + state.files_only = true; + i += 1; + } + "--count-by-file" => { + state.count_by_file = true; + i += 1; + } + "--all" => { + state.all = true; + i += 1; + } + "--max-matches" => { + let v = take_value("--max-matches")?; + state.max_matches = Some(v.parse().context("invalid --max-matches")?); + i += 2; + } + "--max-per-file" => { + let v = take_value("--max-per-file")?; + state.max_per_file = Some(v.parse().context("invalid --max-per-file")?); + i += 2; + } + "--max-line-chars" => { + let v = take_value("--max-line-chars")?; + state.max_line_chars = Some(v.parse().context("invalid --max-line-chars")?); + i += 2; + } + "--full-lines" => { + state.full_lines = true; + i += 1; + } + "--agent-safe" => { + state.agent_safe = true; + state.summary_enabled = true; + i += 1; + } + "--top-files" => { + let v = take_value("--top-files")?; + state.fixups.top_files = Some(v.parse().context("invalid --top-files")?); + i += 2; + } + "--json" => { + state.fixups.json = true; + i += 1; + } + _ => { + forwarded.push(a.clone()); + i += 1; + } + } + } + + Ok(forwarded) +} + /// Target agent for hook installation. #[derive(Debug, Clone, Copy, PartialEq, ValueEnum)] pub enum AgentTarget { @@ -105,9 +275,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) @@ -261,6 +459,37 @@ enum Commands { args: Vec, }, + /// PowerShell-like Get-ChildItem (subset) with compact output + Gci { + /// Root path + #[arg(default_value = ".")] + path: PathBuf, + /// Recurse into subdirectories + #[arg(long)] + recurse: bool, + /// Include hidden files/directories + #[arg(long)] + force: bool, + /// Files only + #[arg(long, conflicts_with = "directory")] + file: bool, + /// Directories only + #[arg(long)] + directory: bool, + /// Name filter (glob, e.g. "*.cpp" or "API_win.obj") + #[arg(long)] + filter: Option, + /// Include patterns (comma-separated globs) + #[arg(long)] + include: Option, + /// Max results to show + #[arg(long, default_value = "50")] + max: usize, + /// Select properties (comma-separated: FullName,LastWriteTime,Length) + #[arg(long)] + select: Option, + }, + /// Ultra-condensed diff (only changed lines) Diff { /// First file or - for stdin (unified diff) @@ -273,6 +502,12 @@ enum Commands { Log { /// Log file (omit for stdin) file: Option, + /// Show last N matching events (deduped) + #[arg(long, default_value = "0")] + events: usize, + /// Additional keywords to treat as events (can be repeated) + #[arg(long = "keyword", action = clap::ArgAction::Append)] + keyword: Vec, }, /// .NET commands with compact output (build/test/restore/format) @@ -301,6 +536,7 @@ enum Commands { }, /// Compact grep - strips whitespace, truncates, groups by file + #[command(alias = "fgrep")] Grep { /// Pattern to search pattern: String, @@ -313,6 +549,40 @@ enum Commands { /// Max results to show #[arg(short, long, default_value = "200")] max: usize, + /// Print only unique matching file paths (no match lines) + #[arg(long, conflicts_with = "count_by_file")] + files_only: bool, + /// Print one row per matching file: ` ` (no match lines) + #[arg(long, conflicts_with = "files_only")] + count_by_file: bool, + /// Uncapped/full normal match output (no total/per-file caps; does not affect line clipping) + #[arg(long)] + all: bool, + /// Optional total match cap for normal match-line output (does not apply to --files-only/--count-by-file) + #[arg(long, conflicts_with = "all")] + max_matches: Option, + /// Optional max matches per file for normal match-line output (does not apply to --files-only/--count-by-file) + #[arg(long, conflicts_with = "all")] + max_per_file: Option, + /// Optional max displayed line length for normal match-line output + #[arg(long, conflicts_with = "full_lines")] + max_line_chars: Option, + /// Do not clip/truncate match lines (does not affect caps; use --all for uncapped) + #[arg(long)] + full_lines: bool, + /// Convenience preset for token-safe agent usage (explicit flags override) + #[arg(long)] + agent_safe: bool, + /// Show only the top N files by match count (no match lines) + #[arg( + long, + value_parser = clap::value_parser!(usize), + conflicts_with_all = ["files_only", "count_by_file"] + )] + top_files: Option, + /// Output JSON only (no human output) + #[arg(long)] + json: bool, /// Show only match context (not full line) #[arg(long)] context_only: bool, @@ -322,6 +592,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, @@ -763,6 +1039,100 @@ enum Commands { #[command(subcommand)] command: HookCommands, }, + + /// CMake build / configure with compact diagnostics + Cmake { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// CTest with failure-only output + Ctest { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// make with errors-only output + Make { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// ninja with errors-only output + Ninja { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// MSBuild with MSVC compile/link diagnostics only + Msbuild { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// codegraph CLI with compact summaries + Codegraph { + #[command(subcommand)] + command: CodegraphCommands, + }, + + /// PowerShell Remove-Item wrapper (compact ok / error output) + #[command(name = "remove-item")] + RemoveItem { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, +} + +#[derive(Debug, Subcommand)] +enum CodegraphCommands { + /// Index a repository (per-file progress stripped) + Index { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Update an existing index (changed files only) + Update { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Repository statistics (decorative noise stripped) + Stats { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Find a symbol by name (truncated to 20 results) + #[command(name = "find-symbol")] + FindSymbol { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Semantic search (truncated to 20 results) + Search { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Find callers of a symbol (truncated to 20 results) + Callers { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Find callees of a symbol (truncated to 20 results) + Callees { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Impact analysis (truncated to 20 results) + Impact { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Affected tests — output passed through unchanged for CI consumers + #[command(name = "affected-tests")] + AffectedTests { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, } #[derive(Debug, Subcommand)] @@ -1355,18 +1725,26 @@ 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, Err(e) => { @@ -1437,10 +1815,27 @@ 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 { @@ -1448,14 +1843,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, ) }; @@ -1471,6 +1876,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, @@ -1697,6 +2121,44 @@ fn run_cli() -> Result { 0 } + Commands::Gci { + path, + recurse, + force, + file, + directory, + filter, + include, + max, + select, + } => { + let mut parsed = gci_cmd::GciArgs { + path, + recurse, + force, + max, + filter, + ..Default::default() + }; + if file { + parsed.kind = gci_cmd::GciKind::File; + } else if directory { + parsed.kind = gci_cmd::GciKind::Directory; + } + if let Some(spec) = include { + parsed.include = spec + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + if let Some(sel) = select.as_deref() { + gci_cmd::parse_select_list(sel, &mut parsed); + } + gci_cmd::run(&parsed, cli.verbose)?; + 0 + } + Commands::Diff { file1, file2 } => { if let Some(f2) = file2 { diff_cmd::run(&file1, &f2, cli.verbose)?; @@ -1706,11 +2168,15 @@ fn run_cli() -> Result { 0 } - Commands::Log { file } => { + Commands::Log { + file, + events, + keyword, + } => { if let Some(f) = file { - log_cmd::run_file(&f, cli.verbose)?; + log_cmd::run_file(&f, events, &keyword, cli.verbose)?; } else { - log_cmd::run_stdin(cli.verbose)?; + log_cmd::run_stdin(events, &keyword, cli.verbose)?; } 0 } @@ -1796,20 +2262,107 @@ fn run_cli() -> Result { path, max_len, max, + files_only, + count_by_file, + all, + max_matches, + max_per_file, + max_line_chars, + full_lines, + agent_safe, + top_files, + json, 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; + + let mut state = GrepCliArgs { + files_only, + count_by_file, + all, + max_matches, + max_per_file, + max_line_chars, + full_lines, + agent_safe, + summary_enabled: false, + fixups: GrepCliFixups { top_files, json }, + }; + + let forwarded_extra_args = apply_grep_rtk_flags_from_extra_args(&mut state, extra_args) + .map_err(|e| anyhow::anyhow!("rtk grep: {}", e))?; + + // Env/config: opt-in agent-safe preset for grep only. + // Precedence: CLI > env > config. + let config_agent_safe = crate::core::config::Config::load() + .ok() + .and_then(|c| c.agent.map(|a| a.safe_mode)) + .unwrap_or(false); + let env_agent_safe = parse_truthy_env_var("RTK_AGENT_SAFE"); + if !state.agent_safe && (env_agent_safe || config_agent_safe) { + state.agent_safe = true; + state.summary_enabled = true; + } + + if state.files_only && state.count_by_file { + return Err(clap::Error::raw( + ErrorKind::ArgumentConflict, + "--files-only conflicts with --count-by-file", + ) + .into()); + } + if state.files_only && state.fixups.top_files.is_some() { + return Err(clap::Error::raw( + ErrorKind::ArgumentConflict, + "--files-only conflicts with --top-files", + ) + .into()); + } + if state.count_by_file && state.fixups.top_files.is_some() { + return Err(clap::Error::raw( + ErrorKind::ArgumentConflict, + "--count-by-file conflicts with --top-files", + ) + .into()); + } + + let effective = compute_grep_effective_limits( + max_len, + max, + state.all, + state.full_lines, + state.agent_safe, + state.max_matches, + state.max_per_file, + state.max_line_chars, + ); + grep_cmd::run( + &pattern, + &path, + effective.max_line_chars, + effective.max_matches, + effective.max_per_file, + state.all, + state.files_only, + state.count_by_file, + state.agent_safe, + effective.summary_enabled || state.summary_enabled, + state.fixups.top_files, + state.fixups.json, + context_only, + file_type.as_deref(), + fixed_mode, + &forwarded_extra_args, + cli.verbose, + )? + } Commands::Init { global, @@ -1832,12 +2385,6 @@ fn run_cli() -> Result { }; if show { hooks::init::show_config(codex)?; - } else if uninstall && copilot { - if global { - hooks::init::uninstall_copilot_global(ctx)?; - } else { - hooks::init::uninstall_copilot(ctx)?; - } } else if uninstall { uninstall_init_dispatch( agent, @@ -1858,11 +2405,7 @@ fn run_cli() -> Result { }; hooks::init::run_gemini(global, hook_only, patch_mode, ctx)?; } else if copilot { - if global { - hooks::init::run_copilot_global(ctx)?; - } else { - hooks::init::run_copilot(ctx)?; - } + hooks::init::run_copilot(ctx)?; } else if agent == Some(AgentTarget::Pi) { hooks::init::run_pi_mode(global, ctx)? } else if agent == Some(AgentTarget::Kilocode) { @@ -2237,6 +2780,42 @@ fn run_cli() -> Result { 0 } + Commands::Cmake { args } => cmake_cmd::run(&args, cli.verbose)?, + + Commands::Ctest { args } => ctest_cmd::run(&args, cli.verbose)?, + + Commands::Make { args } => make_cmd::run_make(&args, cli.verbose)?, + + Commands::Ninja { args } => make_cmd::run_ninja(&args, cli.verbose)?, + + Commands::Msbuild { args } => msbuild_cmd::run(&args, cli.verbose)?, + + Commands::Codegraph { command } => match command { + CodegraphCommands::Index { args } => codegraph_cmd::run_index(&args, cli.verbose)?, + CodegraphCommands::Update { args } => codegraph_cmd::run_update(&args, cli.verbose)?, + CodegraphCommands::Stats { args } => codegraph_cmd::run_stats(&args, cli.verbose)?, + CodegraphCommands::FindSymbol { args } => { + codegraph_cmd::run_search_like("find-symbol", &args, cli.verbose)? + } + CodegraphCommands::Search { args } => { + codegraph_cmd::run_search_like("search", &args, cli.verbose)? + } + CodegraphCommands::Callers { args } => { + codegraph_cmd::run_search_like("callers", &args, cli.verbose)? + } + CodegraphCommands::Callees { args } => { + codegraph_cmd::run_search_like("callees", &args, cli.verbose)? + } + CodegraphCommands::Impact { args } => { + codegraph_cmd::run_search_like("impact", &args, cli.verbose)? + } + CodegraphCommands::AffectedTests { args } => { + codegraph_cmd::run_affected_tests(&args, cli.verbose)? + } + }, + + Commands::RemoveItem { args } => remove_item::run(&args, cli.verbose)?, + Commands::Pipe { filter, passthrough, @@ -2491,6 +3070,7 @@ fn is_operational_command(cmd: &Commands) -> bool { Commands::Ls { .. } | Commands::Tree { .. } | Commands::Read { .. } + | Commands::Patch { .. } | Commands::Smart { .. } | Commands::Git { .. } | Commands::Gh { .. } @@ -2530,6 +3110,13 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Go { .. } | Commands::GolangciLint { .. } | Commands::Gt { .. } + | Commands::Cmake { .. } + | Commands::Ctest { .. } + | Commands::Make { .. } + | Commands::Ninja { .. } + | Commands::Msbuild { .. } + | Commands::Codegraph { .. } + | Commands::RemoveItem { .. } ) } @@ -3174,40 +3761,6 @@ mod tests { } } - #[test] - #[ignore] // Integration test: requires `cargo build` first - fn test_broken_pipe_does_not_crash() { - let bin_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("target") - .join("debug") - .join("rtk"); - assert!( - bin_path.exists(), - "Debug binary not found at {:?} - run `cargo build` first", - bin_path - ); - - let mut child = std::process::Command::new(&bin_path) - .args(["git", "log", "--oneline", "-50"]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .expect("Failed to spawn rtk"); - - // Read one byte then drop stdout to close the pipe. - let mut stdout = child.stdout.take().unwrap(); - let mut buf = [0u8; 1]; - let _ = std::io::Read::read(&mut stdout, &mut buf); - - let status = child.wait().expect("Failed to wait for rtk"); - let code = status.code().unwrap_or(-1); - - assert_ne!( - code, 134, - "rtk crashed with SIGABRT (exit 134) on broken pipe - SIGPIPE handler missing" - ); - } - #[test] fn test_ultra_compact_long_form_still_works() { let cli = Cli::try_parse_from(["rtk", "--ultra-compact", "git", "status"]).unwrap(); @@ -3272,4 +3825,189 @@ mod tests { _ => panic!("Expected Init command"), } } + + #[test] + fn test_grep_agent_safe_overrides_per_file() { + let cli = + Cli::try_parse_from(["rtk", "grep", "Foo", "--agent-safe", "--max-per-file", "30"]) + .unwrap(); + match cli.command { + Commands::Grep { + max_len, + max, + all, + full_lines, + agent_safe, + max_matches, + max_per_file, + max_line_chars, + .. + } => { + let effective = compute_grep_effective_limits( + max_len, + max, + all, + full_lines, + agent_safe, + max_matches, + max_per_file, + max_line_chars, + ); + assert_eq!(effective.max_matches, Some(80)); + assert_eq!(effective.max_per_file, Some(30)); + assert_eq!(effective.max_line_chars, Some(240)); + } + _ => panic!("Expected Grep command"), + } + } + + #[test] + fn test_grep_agent_safe_overrides_total_only() { + let cli = + Cli::try_parse_from(["rtk", "grep", "Foo", "--agent-safe", "--max-matches", "200"]) + .unwrap(); + match cli.command { + Commands::Grep { + max_len, + max, + all, + full_lines, + agent_safe, + max_matches, + max_per_file, + max_line_chars, + .. + } => { + let effective = compute_grep_effective_limits( + max_len, + max, + all, + full_lines, + agent_safe, + max_matches, + max_per_file, + max_line_chars, + ); + assert_eq!(effective.max_matches, Some(200)); + assert_eq!(effective.max_per_file, Some(5)); + assert_eq!(effective.max_line_chars, Some(240)); + } + _ => panic!("Expected Grep command"), + } + } + + #[test] + fn test_grep_all_disables_caps_but_not_full_lines() { + let cli = Cli::try_parse_from(["rtk", "grep", "Foo", "--all"]).unwrap(); + match cli.command { + Commands::Grep { + max_len, + max, + all, + full_lines, + agent_safe, + max_matches, + max_per_file, + max_line_chars, + .. + } => { + let effective = compute_grep_effective_limits( + max_len, + max, + all, + full_lines, + agent_safe, + max_matches, + max_per_file, + max_line_chars, + ); + assert_eq!(effective.max_matches, None); + assert_eq!(effective.max_per_file, None); + assert_eq!(effective.max_line_chars, Some(80)); + } + _ => panic!("Expected Grep command"), + } + } + + #[test] + fn test_grep_full_lines_disables_clipping() { + let cli = Cli::try_parse_from(["rtk", "grep", "Foo", "--full-lines"]).unwrap(); + match cli.command { + Commands::Grep { + max_len, + max, + all, + full_lines, + agent_safe, + max_matches, + max_per_file, + max_line_chars, + .. + } => { + let effective = compute_grep_effective_limits( + max_len, + max, + all, + full_lines, + agent_safe, + max_matches, + max_per_file, + max_line_chars, + ); + assert_eq!(effective.max_line_chars, None); + } + _ => panic!("Expected Grep command"), + } + } + + #[test] + fn test_grep_flags_after_path_are_parsed_by_rtk() { + let cli = + Cli::try_parse_from(["rtk", "grep", "Foo", "tmp_grep_test", "--files-only"]).unwrap(); + match cli.command { + Commands::Grep { + files_only, + count_by_file, + all, + max_matches, + max_per_file, + max_line_chars, + full_lines, + agent_safe, + top_files, + json, + extra_args, + .. + } => { + let mut state = GrepCliArgs { + files_only, + count_by_file, + all, + max_matches, + max_per_file, + max_line_chars, + full_lines, + agent_safe, + summary_enabled: false, + fixups: GrepCliFixups { top_files, json }, + }; + let forwarded = + apply_grep_rtk_flags_from_extra_args(&mut state, extra_args).unwrap(); + assert!(state.files_only); + assert!(!state.count_by_file); + assert!(forwarded.is_empty(), "rtk flags must not forward to rg"); + } + _ => panic!("Expected Grep command"), + } + } + + #[test] + fn test_parse_truthy_env_var() { + std::env::set_var("RTK_AGENT_SAFE", "yes"); + assert!(parse_truthy_env_var("RTK_AGENT_SAFE")); + std::env::set_var("RTK_AGENT_SAFE", "0"); + assert!(!parse_truthy_env_var("RTK_AGENT_SAFE")); + std::env::remove_var("RTK_AGENT_SAFE"); + assert!(!parse_truthy_env_var("RTK_AGENT_SAFE")); + } } diff --git a/tests/fixtures/cpp/cmake_build_failure.txt b/tests/fixtures/cpp/cmake_build_failure.txt new file mode 100644 index 000000000..67c32aec5 --- /dev/null +++ b/tests/fixtures/cpp/cmake_build_failure.txt @@ -0,0 +1,16 @@ +[ 10%] Building CXX object CMakeFiles/myapp.dir/main.cpp.o +[ 20%] Building CXX object CMakeFiles/myapp.dir/util.cpp.o +[ 30%] Building CXX object CMakeFiles/myapp.dir/parser.cpp.o +/home/user/proj/src/parser.cpp:42:14: error: 'undefined_symbol' was not declared in this scope + 42 | return undefined_symbol(token); + | ^~~~~~~~~~~~~~~~ +/home/user/proj/src/parser.cpp:55:5: warning: unused variable 'tmp' [-Wunused-variable] + 55 | int tmp = 0; + | ^~~ +[ 40%] Building CXX object CMakeFiles/myapp.dir/lexer.cpp.o +/home/user/proj/src/lexer.cpp:120:9: error: expected ';' before 'return' + 120 | return tok + | ^~~~~~~~~~ +make[2]: *** [CMakeFiles/myapp.dir/build.make:84: CMakeFiles/myapp.dir/parser.cpp.o] Error 1 +make[1]: *** [CMakeFiles/Makefile2:99: CMakeFiles/myapp.dir/all] Error 2 +make: *** [Makefile:130: all] Error 2 diff --git a/tests/fixtures/cpp/cmake_build_success.txt b/tests/fixtures/cpp/cmake_build_success.txt new file mode 100644 index 000000000..3366fd41b --- /dev/null +++ b/tests/fixtures/cpp/cmake_build_success.txt @@ -0,0 +1,20 @@ +[ 5%] Building CXX object CMakeFiles/myapp.dir/main.cpp.o +[ 10%] Building CXX object CMakeFiles/myapp.dir/util.cpp.o +[ 15%] Building CXX object CMakeFiles/myapp.dir/parser.cpp.o +[ 20%] Building CXX object CMakeFiles/myapp.dir/lexer.cpp.o +[ 25%] Building CXX object CMakeFiles/myapp.dir/codegen.cpp.o +[ 30%] Building CXX object CMakeFiles/myapp.dir/optimizer.cpp.o +[ 35%] Building CXX object CMakeFiles/myapp.dir/diagnostics.cpp.o +[ 40%] Building CXX object CMakeFiles/myapp.dir/types.cpp.o +[ 45%] Building CXX object CMakeFiles/myapp.dir/scope.cpp.o +[ 50%] Building CXX object CMakeFiles/myapp.dir/symbol.cpp.o +[ 55%] Building CXX object CMakeFiles/myapp.dir/ir.cpp.o +[ 60%] Building CXX object CMakeFiles/myapp.dir/asm.cpp.o +[ 65%] Building CXX object CMakeFiles/myapp.dir/link.cpp.o +[ 70%] Building CXX object CMakeFiles/myapp.dir/io.cpp.o +[ 75%] Building CXX object CMakeFiles/myapp.dir/runtime.cpp.o +[ 80%] Linking CXX static library libmyapp_core.a +[ 85%] Built target myapp_core +[ 90%] Building CXX object CMakeFiles/myapp.dir/main_app.cpp.o +[ 95%] Linking CXX executable myapp +[100%] Built target myapp diff --git a/tests/fixtures/cpp/cmake_configure.txt b/tests/fixtures/cpp/cmake_configure.txt new file mode 100644 index 000000000..2ab052bf9 --- /dev/null +++ b/tests/fixtures/cpp/cmake_configure.txt @@ -0,0 +1,27 @@ +-- The C compiler identification is GNU 13.2.0 +-- The CXX compiler identification is GNU 13.2.0 +-- Detecting C compiler ABI info +-- Detecting C compiler ABI info - done +-- Check for working C compiler: /usr/bin/cc - skipped +-- Detecting C compile features +-- Detecting C compile features - done +-- Detecting CXX compiler ABI info +-- Detecting CXX compiler ABI info - done +-- Check for working CXX compiler: /usr/bin/c++ - skipped +-- Detecting CXX compile features +-- Detecting CXX compile features - done +-- Looking for sys/types.h +-- Looking for sys/types.h - found +-- Looking for stdint.h +-- Looking for stdint.h - found +-- Looking for stddef.h +-- Looking for stddef.h - found +-- Found Threads: TRUE +-- Found ZLIB: /usr/lib/x86_64-linux-gnu/libz.so (found version "1.3.1") +-- Performing Test HAVE_CXX17 - Success +-- Performing Test HAVE_CXX20 - Success +-- Build type: Release +-- Install prefix: /usr/local +-- Configuring done (1.2s) +-- Generating done (0.1s) +-- Build files have been written to: /home/user/proj/build diff --git a/tests/fixtures/cpp/codegraph_index_verbose.txt b/tests/fixtures/cpp/codegraph_index_verbose.txt new file mode 100644 index 000000000..647677eef --- /dev/null +++ b/tests/fixtures/cpp/codegraph_index_verbose.txt @@ -0,0 +1,28 @@ +codegraph 0.4.2 +Indexing repo at /home/user/proj +Parsing src/main.rs ok (12 symbols) +Parsing src/lib.rs ok (8 symbols) +Parsing src/util.rs ok (15 symbols) +Parsing src/parser.rs ok (47 symbols) +Parsing src/lexer.rs ok (32 symbols) +Parsing src/codegen.rs ok (58 symbols) +Parsing src/optimizer.rs ok (24 symbols) +Parsing src/diagnostics.rs ok (19 symbols) +Parsing src/types.rs ok (40 symbols) +Parsing src/scope.rs ok (12 symbols) +Parsing src/symbol.rs ok (28 symbols) +Parsing src/ir.rs ok (51 symbols) +Parsing src/asm.rs ok (44 symbols) +Parsing src/link.rs ok (36 symbols) +Parsing src/io.rs ok (17 symbols) +Parsing src/runtime.rs ok (22 symbols) +Parsing src/cmds/git.rs ok (88 symbols) +Parsing src/cmds/cargo.rs ok (66 symbols) +Parsing src/cmds/dotnet.rs ok (95 symbols) +Parsing src/cmds/make.rs ok (24 symbols) +Parsing src/cmds/cmake.rs ok (33 symbols) +Files: 21 +Symbols: 771 +Edges: 2412 +Errors: 0 +Time: 0.92s diff --git a/tests/fixtures/cpp/codegraph_search_results.txt b/tests/fixtures/cpp/codegraph_search_results.txt new file mode 100644 index 000000000..0604a1e11 --- /dev/null +++ b/tests/fixtures/cpp/codegraph_search_results.txt @@ -0,0 +1,50 @@ +parse_expr at src/parser.rs:42 +parse_expr at src/parser.rs:120 +parse_expr_test at tests/parser_test.rs:18 +parse_call at src/parser.rs:88 +parse_call at src/codegen.rs:215 +parse_token at src/lexer.rs:33 +parse_string at src/lexer.rs:78 +parse_number at src/lexer.rs:104 +parse_identifier at src/lexer.rs:130 +parse_keyword at src/lexer.rs:158 +parse_operator at src/lexer.rs:182 +parse_punct at src/lexer.rs:201 +parse_block at src/parser.rs:55 +parse_stmt at src/parser.rs:200 +parse_decl at src/parser.rs:280 +parse_function at src/parser.rs:340 +parse_struct at src/parser.rs:412 +parse_enum at src/parser.rs:478 +parse_trait at src/parser.rs:520 +parse_impl at src/parser.rs:580 +parse_use at src/parser.rs:610 +parse_mod at src/parser.rs:640 +parse_pub at src/parser.rs:680 +parse_unsafe at src/parser.rs:720 +parse_async at src/parser.rs:760 +parse_const at src/parser.rs:800 +parse_static at src/parser.rs:830 +parse_let at src/parser.rs:855 +parse_match at src/parser.rs:890 +parse_if at src/parser.rs:920 +parse_loop at src/parser.rs:945 +parse_return at src/parser.rs:975 +parse_break at src/parser.rs:1000 +parse_continue at src/parser.rs:1020 +parse_yield at src/parser.rs:1040 +parse_await at src/parser.rs:1060 +parse_macro at src/parser.rs:1080 +parse_type at src/parser.rs:1100 +parse_path at src/parser.rs:1120 +parse_attr at src/parser.rs:1140 +parse_generic at src/parser.rs:1160 +parse_lifetime at src/parser.rs:1180 +parse_where at src/parser.rs:1200 +parse_visibility at src/parser.rs:1220 +parse_extern at src/parser.rs:1240 +parse_label at src/parser.rs:1260 +parse_arg at src/parser.rs:1280 +parse_pat at src/parser.rs:1300 +parse_field at src/parser.rs:1320 +parse_variant at src/parser.rs:1340 diff --git a/tests/fixtures/cpp/ctest_failure.txt b/tests/fixtures/cpp/ctest_failure.txt new file mode 100644 index 000000000..0b6495617 --- /dev/null +++ b/tests/fixtures/cpp/ctest_failure.txt @@ -0,0 +1,24 @@ +Test project /home/user/proj/build + Start 1: test_lexer +1/5 Test #1: test_lexer ...................... Passed 0.02 sec + Start 2: test_parser_failure +2/5 Test #2: test_parser_failure .............***Failed 0.04 sec +unit_test_parser.cpp:42: assertion failed + expected: 4 + got: 5 + Start 3: test_codegen +3/5 Test #3: test_codegen .................... Passed 0.03 sec + Start 4: test_runtime_failure +4/5 Test #4: test_runtime_failure ............***Failed 0.10 sec +runtime_test.cpp:88: SIGSEGV in __cxa_throw + Start 5: test_io +5/5 Test #5: test_io ......................... Passed 0.02 sec + +60% tests passed, 2 tests failed out of 5 + +Total Test time (real) = 0.21 sec + +The following tests FAILED: + 2 - test_parser_failure (Failed) + 4 - test_runtime_failure (Failed) +Errors while running CTest diff --git a/tests/fixtures/cpp/ctest_success.txt b/tests/fixtures/cpp/ctest_success.txt new file mode 100644 index 000000000..bee4ea2b5 --- /dev/null +++ b/tests/fixtures/cpp/ctest_success.txt @@ -0,0 +1,35 @@ +Test project /home/user/proj/build + Start 1: test_lexer_basic +1/15 Test #1: test_lexer_basic ............................ Passed 0.02 sec + Start 2: test_lexer_unicode +2/15 Test #2: test_lexer_unicode .......................... Passed 0.03 sec + Start 3: test_lexer_overflow +3/15 Test #3: test_lexer_overflow ......................... Passed 0.01 sec + Start 4: test_parser_simple +4/15 Test #4: test_parser_simple .......................... Passed 0.04 sec + Start 5: test_parser_recursion +5/15 Test #5: test_parser_recursion ....................... Passed 0.06 sec + Start 6: test_parser_errors +6/15 Test #6: test_parser_errors .......................... Passed 0.03 sec + Start 7: test_codegen_basic +7/15 Test #7: test_codegen_basic .......................... Passed 0.02 sec + Start 8: test_codegen_optimize +8/15 Test #8: test_codegen_optimize ....................... Passed 0.05 sec + Start 9: test_runtime +9/15 Test #9: test_runtime ................................ Passed 0.10 sec + Start 10: test_diagnostics +10/15 Test #10: test_diagnostics ........................... Passed 0.01 sec + Start 11: test_symbols +11/15 Test #11: test_symbols ............................... Passed 0.01 sec + Start 12: test_io +12/15 Test #12: test_io .................................... Passed 0.04 sec + Start 13: test_ir +13/15 Test #13: test_ir .................................... Passed 0.03 sec + Start 14: test_asm +14/15 Test #14: test_asm ................................... Passed 0.04 sec + Start 15: test_link +15/15 Test #15: test_link .................................. Passed 0.02 sec + +100% tests passed, 0 tests failed out of 15 + +Total Test time (real) = 0.51 sec diff --git a/tests/fixtures/cpp/make_failure.txt b/tests/fixtures/cpp/make_failure.txt new file mode 100644 index 000000000..8a8db5781 --- /dev/null +++ b/tests/fixtures/cpp/make_failure.txt @@ -0,0 +1,13 @@ +make: Entering directory '/home/user/proj' +cc -Wall -O2 -c src/main.c -o build/main.o +cc -Wall -O2 -c src/parser.c -o build/parser.o +src/parser.c: In function 'parse_expr': +src/parser.c:42:5: error: implicit declaration of function 'lookup_token' + 42 | return lookup_token(t); + | ^~~~~~~~~~~~~~~~~~ +src/parser.c:55:9: warning: unused variable 'unused' [-Wunused-variable] + 55 | int unused = 0; + | ^~~~~~ +make[1]: *** [Makefile:24: build/parser.o] Error 1 +make[1]: Leaving directory '/home/user/proj' +make: *** [Makefile:8: all] Error 2 diff --git a/tests/fixtures/cpp/msbuild_empty_link.txt b/tests/fixtures/cpp/msbuild_empty_link.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/cpp/msbuild_failure_compiler.txt b/tests/fixtures/cpp/msbuild_failure_compiler.txt new file mode 100644 index 000000000..444fe6307 --- /dev/null +++ b/tests/fixtures/cpp/msbuild_failure_compiler.txt @@ -0,0 +1,26 @@ +Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework +Copyright (C) Microsoft Corporation. All rights reserved. + +Build started 1/15/2026 9:05:00 AM. +Project "C:\src\MyProject.sln" on node 1 (Build target(s)). +ClCompile: + main.cpp + util.cpp + parser.cpp +C:\src\MyProject\src\main.cpp(42): error C2065: 'foo': undeclared identifier [C:\src\MyProject\MyProject.vcxproj] +C:\src\MyProject\src\main.cpp(43): warning C4244: conversion from 'double' to 'int', possible loss of data [C:\src\MyProject\MyProject.vcxproj] +C:\src\MyProject\src\parser.cpp(120): error C2143: syntax error: missing ';' before 'return' [C:\src\MyProject\MyProject.vcxproj] +C:\src\MyProject\src\parser.cpp(121): fatal error C1004: unexpected end-of-file found [C:\src\MyProject\MyProject.vcxproj] +Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED. +Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED. + +Build FAILED. + +C:\src\MyProject\src\main.cpp(42): error C2065: 'foo': undeclared identifier [C:\src\MyProject\MyProject.vcxproj] +C:\src\MyProject\src\main.cpp(43): warning C4244: conversion from 'double' to 'int', possible loss of data [C:\src\MyProject\MyProject.vcxproj] +C:\src\MyProject\src\parser.cpp(120): error C2143: syntax error: missing ';' before 'return' [C:\src\MyProject\MyProject.vcxproj] +C:\src\MyProject\src\parser.cpp(121): fatal error C1004: unexpected end-of-file found [C:\src\MyProject\MyProject.vcxproj] + 1 Warning(s) + 3 Error(s) + +Time Elapsed 00:00:03.22 diff --git a/tests/fixtures/cpp/msbuild_failure_linker.txt b/tests/fixtures/cpp/msbuild_failure_linker.txt new file mode 100644 index 000000000..a68923e56 --- /dev/null +++ b/tests/fixtures/cpp/msbuild_failure_linker.txt @@ -0,0 +1,24 @@ +Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework +Copyright (C) Microsoft Corporation. All rights reserved. + +Build started 1/15/2026 9:10:00 AM. +Project "C:\src\MyProject.sln" on node 1 (Build target(s)). +ClCompile: + main.cpp + util.cpp +Link: + C:\BuildTools\bin\link.exe /OUT:"Debug\MyProject.dll" /NOLOGO main.obj util.obj +MyProject.lib(util.obj) : error LNK2001: unresolved external symbol "void __cdecl bar(int)" (?bar@@YAXH@Z) +MyProject.lib(main.obj) : error LNK2001: unresolved external symbol "class Logger * __cdecl get_logger(void)" (?get_logger@@YAPAVLogger@@XZ) +MyOtherDLL.dll : fatal error LNK1120: 2 unresolved externals +Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED. + +Build FAILED. + +MyProject.lib(util.obj) : error LNK2001: unresolved external symbol "void __cdecl bar(int)" (?bar@@YAXH@Z) +MyProject.lib(main.obj) : error LNK2001: unresolved external symbol "class Logger * __cdecl get_logger(void)" (?get_logger@@YAPAVLogger@@XZ) +MyOtherDLL.dll : fatal error LNK1120: 2 unresolved externals + 0 Warning(s) + 3 Error(s) + +Time Elapsed 00:00:08.45 diff --git a/tests/fixtures/cpp/msbuild_failure_msb3073.txt b/tests/fixtures/cpp/msbuild_failure_msb3073.txt new file mode 100644 index 000000000..7a16cf46c --- /dev/null +++ b/tests/fixtures/cpp/msbuild_failure_msb3073.txt @@ -0,0 +1,20 @@ +Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework +Copyright (C) Microsoft Corporation. All rights reserved. + +Build started 1/15/2026 9:06:00 AM. +Project "C:\src\MyProject.sln" on node 1 (Build target(s)). +Project "C:\src\MyProject\MyProject.vcxproj" on node 1 (default target(s)). +PrepareForBuild: + Creating directory "obj\Debug\". +PostBuildEvent: + copy /Y "C:\path with spaces\out.dll" "C:\dest\bin\" +C:\src\MyProject\MyProject.vcxproj(123,5): error MSB3073: The command "copy /Y \"C:\\path with spaces\\out.dll\" \"C:\\dest\\bin\\\"" exited with code 1. [C:\src\MyProject\MyProject.vcxproj] +Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED. +Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED. + +Build FAILED. + + 0 Warning(s) + 1 Error(s) + +Time Elapsed 00:00:02.00 diff --git a/tests/fixtures/cpp/msbuild_failure_msb8012.txt b/tests/fixtures/cpp/msbuild_failure_msb8012.txt new file mode 100644 index 000000000..996e01162 --- /dev/null +++ b/tests/fixtures/cpp/msbuild_failure_msb8012.txt @@ -0,0 +1,16 @@ +Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework +Copyright (C) Microsoft Corporation. All rights reserved. + +Build started 1/15/2026 9:08:00 AM. +Project "C:\src\MyProject.sln" on node 1 (Build target(s)). +Project "C:\src\MyProject\MyProject.vcxproj" on node 1 (default target(s)). +C:\src\MyProject\MyProject.vcxproj(56,5): error MSB8012: TargetPath (C:\src\MyProject\bin\Debug\MyProject.dll) does not match the Linker's OutputFile property value (C:\src\MyProject\bin\Debug\MyProjectWrong.dll). This may cause your project to build incorrectly. [C:\src\MyProject\MyProject.vcxproj] +Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED. +Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED. + +Build FAILED. + + 0 Warning(s) + 1 Error(s) + +Time Elapsed 00:00:01.00 diff --git a/tests/fixtures/cpp/msbuild_failure_rc.txt b/tests/fixtures/cpp/msbuild_failure_rc.txt new file mode 100644 index 000000000..e75d0eb73 --- /dev/null +++ b/tests/fixtures/cpp/msbuild_failure_rc.txt @@ -0,0 +1,18 @@ +Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework +Copyright (C) Microsoft Corporation. All rights reserved. + +Build started 1/15/2026 9:07:00 AM. +Project "C:\src\MyProject.sln" on node 1 (Build target(s)). +Project "C:\src\MyProject\MyProject.vcxproj" on node 1 (default target(s)). +ResourceCompile: + C:\src\MyProject\res\app.rc +C:\src\MyProject\res\app.rc(10): fatal error RC1015: cannot open include file 'windows.h'. [C:\src\MyProject\MyProject.vcxproj] +Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets) -- FAILED. +Done Building Project "C:\src\MyProject.sln" (default targets) -- FAILED. + +Build FAILED. + + 0 Warning(s) + 1 Error(s) + +Time Elapsed 00:00:01.00 diff --git a/tests/fixtures/cpp/msbuild_redirected.txt b/tests/fixtures/cpp/msbuild_redirected.txt new file mode 100644 index 000000000..20a3e9b87 --- /dev/null +++ b/tests/fixtures/cpp/msbuild_redirected.txt @@ -0,0 +1 @@ +msbuild MyProject.sln /t:Build /p:Configuration=Debug /p:Platform=Win32 /m:1 /nologo /v:m *> build-logs/msbuild_run1.log diff --git a/tests/fixtures/cpp/msbuild_success.txt b/tests/fixtures/cpp/msbuild_success.txt new file mode 100644 index 000000000..f313310c9 --- /dev/null +++ b/tests/fixtures/cpp/msbuild_success.txt @@ -0,0 +1,33 @@ +Microsoft (R) Build Engine version 17.8.5+b5c6332e2 for .NET Framework +Copyright (C) Microsoft Corporation. All rights reserved. + +Build started 1/15/2026 9:00:00 AM. +Project "C:\src\MyProject.sln" on node 1 (Build target(s)). +Project "C:\src\MyProject.sln" (1) is building "C:\src\MyProject\MyProject.vcxproj" (2) on node 1 (Build target(s)). +PrepareForBuild: + Creating directory "obj\Win32\Debug\". + Creating directory "C:\src\MyProject\Debug\". +InitializeBuildStatus: + Creating "obj\Win32\Debug\MyProject.tlog\unsuccessfulbuild" because "AlwaysCreate" was specified. +ClCompile: + C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.38.33130\bin\HostX64\x86\CL.exe /c /Z7 /nologo /W3 /WX- /diagnostics:column /sdl /Od main.cpp util.cpp parser.cpp + main.cpp + util.cpp + parser.cpp +C:\src\MyProject\src\util.cpp(33): warning C4244: 'argument': conversion from 'int64_t' to 'int', possible loss of data [C:\src\MyProject\MyProject.vcxproj] +Link: + C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\MSVC\14.38.33130\bin\HostX64\x86\link.exe /OUT:"Debug\MyProject.dll" /NOLOGO /DLL main.obj util.obj parser.obj + Creating library Debug\MyProject.lib and object Debug\MyProject.exp + Generating code + Finished generating code + MyProject.vcxproj -> C:\src\MyProject\Debug\MyProject.dll +FinalizeBuildStatus: + Deleting file "obj\Win32\Debug\MyProject.tlog\unsuccessfulbuild". +Done Building Project "C:\src\MyProject\MyProject.vcxproj" (default targets). +Done Building Project "C:\src\MyProject.sln" (default targets). + +Build succeeded. + 1 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:14.32