diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs index ce1443c2db..710d09f3a3 100644 --- a/src/cmds/mod.rs +++ b/src/cmds/mod.rs @@ -6,6 +6,7 @@ pub mod git; pub mod go; pub mod js; pub mod jvm; +pub mod native; pub mod php; pub mod python; pub mod ruby; diff --git a/src/cmds/native/README.md b/src/cmds/native/README.md new file mode 100644 index 0000000000..f39daa3ac8 --- /dev/null +++ b/src/cmds/native/README.md @@ -0,0 +1,10 @@ +# Native Ecosystem + +> Part of [`src/cmds/`](../README.md) — C/C++ and other native build tooling. + +## Specifics + +- `cmake_cmd.rs` filters `cmake`/`cmake --build` output: per-translation-unit + `[ NN%] Building ...` progress and `make[N]` directory chatter are tallied and + dropped; warnings, errors and the configure verdict are kept verbatim. +- Uses `RunOptions::with_tee("cmake")` so the full raw log is recoverable on failure. diff --git a/src/cmds/native/cmake_cmd.rs b/src/cmds/native/cmake_cmd.rs new file mode 100644 index 0000000000..143de3372f --- /dev/null +++ b/src/cmds/native/cmake_cmd.rs @@ -0,0 +1,226 @@ +//! cmake build/configure output — drown the progress spam, keep the diagnostics. + +use crate::core::runner; +use crate::core::utils::resolved_command; +use anyhow::Result; + +pub fn run(args: &[String], verbose: u8) -> Result { + let mut cmd = resolved_command("cmake"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: cmake {}", args.join(" ")); + } + + runner::run_filtered_with_exit( + cmd, + "cmake", + &args.join(" "), + filter_cmake_with_exit, + runner::RunOptions::with_tee("cmake"), + ) +} + +pub(crate) fn filter_cmake(output: &str) -> String { + filter_cmake_with_exit(output, 0) +} + +fn filter_cmake_with_exit(output: &str, exit_code: i32) -> String { + let mut objects = 0usize; + let mut targets = 0usize; + let mut saw_progress = false; + let mut saw_error = false; + let mut kept: Vec<&str> = Vec::new(); + + for raw in output.lines() { + let line = raw.trim_end(); + let t = line.trim_start(); + + // "[ 42%] Building CXX object ..." — one per translation unit. Tally, don't echo. + if is_progress_pct(t) { + saw_progress = true; + if t.contains("] Built target ") { + targets += 1; + } else if t.contains(" Building ") && t.contains(" object ") { + objects += 1; + } + continue; + } + + // make's recursive Entering/Leaving-directory bookkeeping: pure noise. + if t.starts_with("make[") + && (t.contains("Entering directory") + || t.contains("Leaving directory") + || t.contains("Nothing to be done")) + { + continue; + } + if t.starts_with("Scanning dependencies of target") { + continue; + } + // configure spends 200 lines poking the toolchain; keep the verdict, ditch the poking. + if is_probe(t) { + continue; + } + + if is_error(t) { + saw_error = true; + } + if !t.is_empty() { + kept.push(line); + } + } + + let failed = exit_code != 0 || saw_error; + let mut out = String::new(); + + if failed { + if exit_code != 0 { + out.push_str(&format!("cmake: build failed (exit {exit_code})\n")); + } else { + out.push_str("cmake: build failed\n"); + } + } else if saw_progress { + out.push_str(&format!( + "cmake: built {targets} target{}, {objects} object{}\n", + plural(targets), + plural(objects), + )); + } + + for line in &kept { + out.push_str(line); + out.push('\n'); + } + + let trimmed = out.trim_end(); + if trimmed.is_empty() { + "cmake: nothing to do".to_string() + } else { + trimmed.to_string() + } +} + +// A leading "[%]" marker — cmake/make progress, regardless of verb. +fn is_progress_pct(line: &str) -> bool { + let Some(rest) = line.strip_prefix('[') else { + return false; + }; + let Some(close) = rest.find("%]") else { + return false; + }; + let pct = rest[..close].trim(); + !pct.is_empty() && pct.bytes().all(|b| b.is_ascii_digit()) +} + +fn is_probe(line: &str) -> bool { + let Some(rest) = line.strip_prefix("-- ") else { + return false; + }; + rest.starts_with("Check") + || rest.starts_with("Detecting") + || rest.starts_with("Looking for") + || rest.starts_with("Performing Test") +} + +fn is_error(line: &str) -> bool { + line.contains("error:") + || line.contains("CMake Error") + || line.contains("] Error ") + || line.contains("***") +} + +fn plural(n: usize) -> &'static str { + if n == 1 { + "" + } else { + "s" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BUILD_OK: &str = r#"[ 2%] Building CXX object src/CMakeFiles/engine.dir/alloc.cpp.o +[ 5%] Building CXX object src/CMakeFiles/engine.dir/buffer.cpp.o +[ 8%] Building CXX object src/CMakeFiles/engine.dir/cache.cpp.o +[ 11%] Building CXX object src/CMakeFiles/engine.dir/codec.cpp.o +[ 14%] Building CXX object src/CMakeFiles/engine.dir/config.cpp.o +src/config.cpp: In function 'int load(Ctx&)': +src/config.cpp:42:9: warning: unused variable 'tmp' [-Wunused-variable] + 42 | int tmp = 0; + | ^~~ +[ 17%] Building CXX object src/CMakeFiles/engine.dir/db.cpp.o +[ 20%] Building CXX object src/CMakeFiles/engine.dir/exec.cpp.o +[ 23%] Building CXX object src/CMakeFiles/engine.dir/hash.cpp.o +[ 26%] Building CXX object src/CMakeFiles/engine.dir/index.cpp.o +[ 29%] Building CXX object src/CMakeFiles/engine.dir/journal.cpp.o +[ 32%] Building CXX object src/CMakeFiles/engine.dir/lexer.cpp.o +Scanning dependencies of target engine +[ 47%] Linking CXX static library libengine.a +[ 50%] Built target engine +make[1]: Entering directory '/home/dev/proj/build' +[ 55%] Building CXX object app/CMakeFiles/shell.dir/main.cpp.o +[ 61%] Building CXX object app/CMakeFiles/shell.dir/repl.cpp.o +[ 88%] Building CXX object app/CMakeFiles/shell.dir/render.cpp.o +make[1]: Leaving directory '/home/dev/proj/build' +[ 94%] Linking CXX executable shell +[100%] Built target shell"#; + + #[test] + fn collapses_progress_and_keeps_the_warning() { + let out = filter_cmake(BUILD_OK); + assert!(out.starts_with("cmake: built 2 targets, 14 objects"), "{out}"); + // the single warning is the whole point of reading build output — keep it verbatim + assert!(out.contains("unused variable 'tmp'")); + assert!(out.contains("src/config.cpp:42:9")); + // ...and the noise is gone + assert!(!out.contains("Building CXX object")); + assert!(!out.contains("Scanning dependencies")); + assert!(!out.contains("Entering directory")); + assert!(!out.contains("Linking CXX")); + + let savings = 1.0 - (out.len() as f64 / BUILD_OK.len() as f64); + assert!(savings >= 0.60, "expected >=60% savings, got {savings:.2}"); + } + + #[test] + fn failure_preserves_diagnostics_drops_progress() { + let input = r#"[ 20%] Building CXX object src/CMakeFiles/x.dir/main.cpp.o +src/main.cpp:10:5: error: 'foo' was not declared in this scope + 10 | foo(); + | ^~~ +make[2]: *** [CMakeFiles/x.dir/build.make:76: main.cpp.o] Error 1 +make[1]: *** [CMakeFiles/Makefile2:83: x] Error 2 +make: *** [Makefile:91: all] Error 2"#; + let out = filter_cmake_with_exit(input, 2); + assert!(out.starts_with("cmake: build failed (exit 2)")); + assert!(out.contains("error: 'foo' was not declared")); + assert!(out.contains("main.cpp:10:5")); + assert!(!out.contains("Building CXX object")); + } + + #[test] + fn configure_keeps_summary_drops_probe_spam() { + let input = r#"-- The C compiler identification is GNU 11.4.0 +-- Detecting C compiler ABI info +-- Detecting C compiler ABI info - done +-- Check for working C compiler: /usr/bin/cc - skipped +-- Looking for pthread.h +-- Looking for pthread.h - found +-- Performing Test HAVE_STD_FILESYSTEM +-- Performing Test HAVE_STD_FILESYSTEM - Success +-- Configuring done +-- Generating done +-- Build files have been written to: /home/dev/proj/build"#; + let out = filter_cmake(input); + assert!(out.contains("Configuring done")); + assert!(out.contains("Build files have been written")); + assert!(!out.contains("Detecting C compiler ABI")); + assert!(!out.contains("Performing Test")); + assert!(!out.contains("Looking for pthread")); + } +} diff --git a/src/cmds/native/mod.rs b/src/cmds/native/mod.rs new file mode 100644 index 0000000000..442ff35a0b --- /dev/null +++ b/src/cmds/native/mod.rs @@ -0,0 +1 @@ +automod::dir!(pub "src/cmds/native"); diff --git a/src/cmds/system/pipe_cmd.rs b/src/cmds/system/pipe_cmd.rs index 563d54a10f..aa3cc3e5a9 100644 --- a/src/cmds/system/pipe_cmd.rs +++ b/src/cmds/system/pipe_cmd.rs @@ -15,6 +15,7 @@ pub fn resolve_filter(name: &str) -> Option String> { "pytest" => Some(crate::cmds::python::pytest_cmd::filter_pytest_output), "go-test" => Some(go_test_wrapper), "go-build" => Some(crate::cmds::go::go_cmd::filter_go_build), + "cmake" => Some(crate::cmds::native::cmake_cmd::filter_cmake), "tsc" => Some(crate::cmds::js::tsc_cmd::filter_tsc_output), "vitest" => Some(vitest_wrapper), "grep" | "rg" => Some(grep_wrapper), diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 76d71cf9f6..dff931734e 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -685,6 +685,15 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + pattern: r"^cmake\b", + rtk_cmd: "rtk cmake", + rewrite_prefixes: &["cmake"], + category: "Build", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { pattern: r"^df(\s|$)", rtk_cmd: "rtk df", diff --git a/src/main.rs b/src/main.rs index cd69ea7026..81dd084772 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use cmds::js::{ vitest_cmd, }; use cmds::jvm::{gradlew_cmd, mvn_cmd}; +use cmds::native::cmake_cmd; use cmds::php::{ecs_cmd, paratest_cmd, pest_cmd, php_cmd, phpstan_cmd, phpunit_cmd, pint_cmd}; use cmds::python::{mypy_cmd, pip_cmd, pytest_cmd, ruff_cmd, uv_cmd}; use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd}; @@ -779,6 +780,14 @@ enum Commands { command: GtCommands, }, + /// CMake build/configure with compact output — progress collapsed, diagnostics kept + #[command(name = "cmake")] + Cmake { + /// Additional cmake arguments (e.g. --build, --target, -S, -B) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// golangci-lint wrapper with compact `run` support and passthrough for other invocations #[command(name = "golangci-lint")] GolangciLint { @@ -2326,6 +2335,7 @@ fn run_cli() -> Result { GtCommands::Other(args) => gt_cmd::run_other(&args, cli.verbose)?, }, + Commands::Cmake { args } => cmake_cmd::run(&args, cli.verbose)?, Commands::GolangciLint { args } => golangci_cmd::run(&args, cli.verbose)?, Commands::Gradlew { args } => gradlew_cmd::run(&args, cli.verbose)?, @@ -2681,6 +2691,7 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Uv { .. } | Commands::Go { .. } | Commands::GolangciLint { .. } + | Commands::Cmake { .. } | Commands::Gt { .. } ) } @@ -3044,6 +3055,7 @@ mod tests { "format", "playwright", "cargo", + "cmake", "npm", "npx", "curl",