diff --git a/README.md b/README.md index e019e80e82..d525d14322 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,11 @@ rtk next build # Next.js build compact rtk prettier --check . # Files needing formatting rtk cargo build # Cargo build (-80%) rtk cargo clippy # Cargo clippy (-80%) +rtk cmake --build build # CMake diagnostics only +rtk ctest --test-dir build # CTest failures and summaries +rtk make -j8 # Make diagnostics only +rtk ninja -C build # Ninja diagnostics only +rtk msbuild app.sln # MSVC, linker, RC, and MSBuild diagnostics rtk ruff check # Python linting (JSON, -80%) rtk golangci-lint run # Go linting (JSON, -85%) rtk rubocop # Ruby linting (JSON, -60%+) diff --git a/src/cmds/cpp/cmake_cmd.rs b/src/cmds/cpp/cmake_cmd.rs new file mode 100644 index 0000000000..05605a0370 --- /dev/null +++ b/src/cmds/cpp/cmake_cmd.rs @@ -0,0 +1,343 @@ +//! Filters cmake build/configure output — keep diagnostics, drop progress noise. + +use super::failure_fallback; +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::resolved_command; +use anyhow::Result; +use regex::Regex; +use std::sync::LazyLock; + +static GCC_DIAG_RE: LazyLock = LazyLock::new(|| { + // GCC/Clang diagnostic: file.cpp:line:col: error|warning|note: message + Regex::new(r"^[^:\s].*:\d+:\d+:\s+(?:error|warning|note|fatal error):").unwrap() +}); +static MSVC_DIAG_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^.+\(\d+(?:,\d+)?\):\s+(?:warning|error|fatal error)\s+[A-Z]+\d+:").unwrap() +}); +static LINK_DIAG_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?:LINK|.+\.obj)\s*:\s+(?:warning|error|fatal error)\s+LNK\d+:").unwrap() +}); +static RC_DIAG_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^.+\.rc\(\d+(?:,\d+)?\):\s+(?:warning|error|fatal error)\s+RC\d+:").unwrap() +}); + // make[N]: *** error +static MAKE_ERR_RE: LazyLock = LazyLock::new(|| Regex::new(r"^make(\[\d+\])?:\s+\*\*\*").unwrap()); + // [ N%] Building CXX object ... or [ N%] Linking ... +static CMAKE_PROGRESS_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\[\s*\d+%\]\s+(Building|Linking|Built target|Generating|Built)").unwrap() +}); + // ninja-style progress: [N/M] Building ... +static NINJA_PROGRESS_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^\[\d+/\d+\]\s+(Building|Linking|Generating)").unwrap()); + // CMake configure noise lines +static CMAKE_PROBE_RE: LazyLock = LazyLock::new(|| Regex::new( + r"^-- (Check for working|Detecting|Looking for|Found|Performing Test|Checking)" + ).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_with_exit( + cmd, + "cmake", + &args.join(" "), + move |raw, exit_code| { + if is_build { + filter_build(raw, &args_owned, exit_code) + } else { + filter_configure(raw, exit_code) + } + }, + RunOptions::with_tee("cmake").preserve_filtered_failure_output(), + ) +} + +fn filter_build(raw: &str, args: &[String], exit_code: i32) -> 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)) + && !line.trim_start().starts_with("FAILED:") + { + file_count += 1; + continue; + } + if line.contains("Entering directory") || line.contains("Leaving directory") { + continue; + } + + if GCC_DIAG_RE.is_match(line) + || MSVC_DIAG_RE.is_match(line) + || LINK_DIAG_RE.is_match(line) + || RC_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") + || line.trim_start().starts_with("FAILED:") + || line.to_ascii_lowercase().contains("build stopped: subcommand failed") + { + 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 { + if exit_code != 0 { + return failure_fallback("cmake", exit_code, raw); + } + 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, exit_code: i32) -> String { + let mut out = Vec::new(); + let mut error_context = 0usize; + let mut emitted_failure = 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()); + emitted_failure |= trimmed.starts_with("CMake Error"); + error_context = if trimmed.starts_with("CMake Error") { 6 } else { 0 }; + continue; + } + if error_context > 0 + && (line.starts_with(' ') || line.starts_with('\t') || trimmed.starts_with('|')) + { + out.push(line.to_string()); + error_context -= 1; + continue; + } + error_context = 0; + if line.starts_with("ERROR") + || line.contains("error:") + || MSVC_DIAG_RE.is_match(line) + || LINK_DIAG_RE.is_match(line) + || RC_DIAG_RE.is_match(line) + || trimmed.contains("Configuring incomplete") + { + out.push(line.to_string()); + emitted_failure = 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("Could NOT find") + || 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 exit_code != 0 && !emitted_failure { + return failure_fallback("cmake", exit_code, raw); + } + if out.is_empty() { + 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, 0); + 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, 1); + 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, 0); + 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, 1); + 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, 0); + 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, 1); + 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, 0); + assert!(out.contains("Configuring done")); + assert!(!out.contains("Detecting")); + assert!(!out.contains("Looking for")); + } + + #[test] + fn configure_keeps_missing_optional_dependency() { + let raw = "-- Looking for ZLIB\n-- Could NOT find ZLIB (missing: ZLIB_LIBRARY)\n-- Configuring done\n-- Build files have been written to: build\n"; + let out = filter_configure(raw, 0); + assert!(out.contains("Could NOT find ZLIB")); + assert!(!out.contains("Looking for ZLIB")); + } + + #[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, 0); + 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 msvc_and_link_diagnostics_survive() { + let raw = "C:\\src\\main.cpp(42,7): error C2065: name\nLINK : fatal error LNK1104: missing.lib\n"; + let out = filter_build(raw, &["--build".into(), "build".into()], 1); + assert!(out.contains("C2065")); + assert!(out.contains("LNK1104")); + assert!(!out.contains("cmake: ok")); + } + + #[test] + fn unknown_or_empty_nonzero_build_is_failure() { + let args = vec!["--build".into(), "build".into()]; + assert!(filter_build("unrecognized failure", &args, 2).contains("cmake: failed (exit 2)")); + assert_eq!(filter_build("", &args, 2), "cmake: failed (exit 2)"); + } +} diff --git a/src/cmds/cpp/ctest_cmd.rs b/src/cmds/cpp/ctest_cmd.rs new file mode 100644 index 0000000000..615d8e3b28 --- /dev/null +++ b/src/cmds/cpp/ctest_cmd.rs @@ -0,0 +1,468 @@ +//! Filters ctest output — keep GoogleTest failures and final summaries. + +use super::failure_fallback; +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_with_exit( + cmd, + "ctest", + &args.join(" "), + filter_ctest_output, + RunOptions::with_tee("ctest").preserve_filtered_failure_output(), + ) +} + +pub(crate) fn filter_ctest_output(output: &str, exit_code: i32) -> 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; + let mut ordinary_failure_context = 0usize; + let mut saw_failure = 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) { + saw_failure = true; + 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) { + saw_failure |= m.starts_with("[ FAILED ]"); + emitting_failure_block = false; + emitted_test_header_for_block = false; + out.push(trimmed.to_string()); + continue; + } + + if is_ctest_summary_line(m) { + saw_failure |= is_ctest_failure_summary_line(m); + emitting_failure_block = false; + emitted_test_header_for_block = false; + out.push(trimmed.to_string()); + continue; + } + + if is_ctest_failure_marker(m) { + saw_failure = true; + out.push(trimmed.to_string()); + ordinary_failure_context = 8; + continue; + } + + if is_important_non_gtest_line(m) { + saw_failure = true; + out.push(trimmed.to_string()); + ordinary_failure_context = 8; + continue; + } + + if ordinary_failure_context > 0 { + if is_ctest_test_header(m) { + ordinary_failure_context = 0; + } else { + out.push(trimmed.to_string()); + ordinary_failure_context -= 1; + 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()); + } + } + + let filtered = compact_lines(out).join("\n"); + if exit_code != 0 && !saw_failure { + failure_fallback("ctest", exit_code, output) + } else if filtered.trim().is_empty() { + "ctest: ok".to_string() + } else { + filtered + } +} + +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_ctest_failure_summary_line(line: &str) -> bool { + let l = line.to_ascii_lowercase(); + if l.contains("errors while running ctest") + || l.contains("ctest error") + || l.contains("the following tests failed") + { + return true; + } + let Some(index) = l.find("tests failed") else { + return false; + }; + l[..index] + .split_whitespace() + .rev() + .find_map(|token| token.parse::().ok()) + .is_some_and(|count| count > 0) +} + +fn is_ctest_failure_marker(line: &str) -> bool { + let l = line.to_ascii_lowercase(); + l.contains("***failed") + || l.contains("***timeout") + || l.contains("***exception") + || l.contains("***not run") + || l.contains("timeout") + || l.contains("crash") + || l.contains("process terminated") + || l.contains("segmentation fault") + || l.contains("sanitizer") +} + +fn is_ctest_test_header(line: &str) -> bool { + line.starts_with("Test #") || line.starts_with("[ RUN") || line.starts_with("[ OK ]") +} + +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, 0); + 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, 1); + 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, 1); + 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, 1); + 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, 1); + 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, 1); + 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"); + } + + #[test] + fn ordinary_timeout_and_sanitizer_failure_survive() { + let input = "1/1 Test #1: integration ...***Timeout 5.00 sec\nProcess terminated due to timeout\nAddressSanitizer: crash\nThe following tests FAILED:\n 1 - integration (Timeout)\n"; + let out = filter_ctest_output(input, 1); + assert!(out.contains("***Timeout")); + assert!(out.contains("Process terminated")); + assert!(out.contains("AddressSanitizer")); + assert!(out.contains("tests FAILED")); + } + + #[test] + fn unknown_and_empty_nonzero_ctest_output_is_failure() { + assert!(filter_ctest_output("localized failure", 3).contains("ctest: failed (exit 3)")); + assert_eq!(filter_ctest_output("", 1), "ctest: failed (exit 1)"); + } + + #[test] + fn passed_summary_does_not_hide_nonzero_exit() { + let out = filter_ctest_output("[ PASSED ] 2 tests.\nlocalized infrastructure failure", 3); + assert!(out.starts_with("ctest: failed (exit 3)")); + assert!(out.contains("[ PASSED ] 2 tests.")); + } + + #[test] + fn zero_failed_summary_does_not_prove_failure() { + let out = filter_ctest_output( + "100% tests passed, 0 tests failed out of 2\nlocalized infrastructure failure", + 3, + ); + assert!(out.starts_with("ctest: failed (exit 3)")); + } + + #[test] + fn positive_failed_summary_proves_failure() { + let out = filter_ctest_output("50% tests passed, 1 tests failed out of 2", 1); + assert!(out.contains("50% tests passed, 1 tests failed out of 2")); + assert!(!out.starts_with("ctest: ok")); + assert!(!out.starts_with("ctest: failed")); + } +} diff --git a/src/cmds/cpp/make_cmd.rs b/src/cmds/cpp/make_cmd.rs new file mode 100644 index 0000000000..2963e87124 --- /dev/null +++ b/src/cmds/cpp/make_cmd.rs @@ -0,0 +1,181 @@ +//! Filters make/ninja output — strips per-file noise, surfaces compiler diagnostics. + +use super::failure_fallback; +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::resolved_command; +use anyhow::Result; +use regex::Regex; +use std::sync::LazyLock; + +static GCC_DIAG_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^[^:\s].*:\d+:\d+:\s+(?:error|warning|note|fatal error):").unwrap() +}); +static MAKE_ERR_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^make(\[\d+\])?:\s+\*\*\*").unwrap()); +static NINJA_PROGRESS_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\[\d+/\d+\]\s+(Building|Linking|Generating|Compiling)").unwrap() +}); +static MAKE_BUILD_LINE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?:cc|gcc|g\+\+|clang|clang\+\+|c\+\+|ld|ar)\b").unwrap() +}); +static DRIVER_DIAG_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(?:clang|clang\+\+|gcc|g\+\+|cc|c\+\+|ld):\s+(?:fatal )?error:").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_with_exit( + cmd, + tool, + &args.join(" "), + move |raw, exit_code| filter_output(raw, tool, exit_code), + RunOptions::with_tee(tool).preserve_filtered_failure_output(), + ) +} + +fn filter_output(raw: &str, tool: &str, exit_code: i32) -> 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 GCC_DIAG_RE.is_match(line) + || DRIVER_DIAG_RE.is_match(line) + || MAKE_ERR_RE.is_match(line) + || line.contains("undefined reference") + || (tool == "ninja" + && (line.trim_start().starts_with("FAILED:") + || line.to_ascii_lowercase().contains("build stopped: subcommand failed"))) + { + out.push(line.to_string()); + diag_context = 3; + emitted_diag = true; + continue; + } + + if MAKE_BUILD_LINE_RE.is_match(line) { + continue; + } + if diag_context > 0 { + let trimmed = line.trim_start(); + if trimmed.is_empty() + || trimmed.starts_with('|') + || line.starts_with(' ') + || line.starts_with('\t') + || (tool == "ninja" && diag_context > 0) + { + out.push(line.to_string()); + diag_context -= 1; + continue; + } + diag_context = 0; + } + } + + if !emitted_diag { + if exit_code != 0 { + return failure_fallback(tool, exit_code, raw); + } + 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", 0), "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", 1); + 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", 1); + 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", 0), "ninja: ok"); + } + + #[test] + fn test_ninja_failure_keeps_diagnostic() { + let raw = "[1/2] Building CXX object main.o\n\ + main.cpp:7:1: error: missing ';'\n\ + ninja: build stopped: subcommand failed.\n"; + let out = filter_output(raw, "ninja", 1); + assert!(out.contains("main.cpp:7:1: error:")); + assert!(out.contains("ninja: build stopped")); + assert!(!out.contains("[1/2] Building")); + } + + #[test] + fn unknown_and_empty_nonzero_output_is_failure() { + assert!(filter_output("unknown failure", "make", 2).contains("make: failed (exit 2)")); + assert_eq!(filter_output("", "ninja", 1), "ninja: failed (exit 1)"); + } + + #[test] + fn clang_driver_and_ninja_failed_block_survive() { + let raw = "FAILED: app\nclang: error: invalid argument\nninja: build stopped: subcommand failed.\n"; + let out = filter_output(raw, "ninja", 1); + assert!(out.contains("FAILED: app")); + assert!(out.contains("clang: error")); + assert!(out.contains("build stopped")); + assert!(!out.contains("ninja: ok")); + } + + #[test] + fn tee_label_matches_tool() { + for tool in ["make", "ninja"] { + assert_eq!(RunOptions::with_tee(tool).tee_label, Some(tool)); + } + } +} diff --git a/src/cmds/cpp/mod.rs b/src/cmds/cpp/mod.rs new file mode 100644 index 0000000000..4722b06f6a --- /dev/null +++ b/src/cmds/cpp/mod.rs @@ -0,0 +1,71 @@ +automod::dir!(pub "src/cmds/cpp"); + +pub(crate) fn failure_fallback(tool: &str, exit_code: i32, raw: &str) -> String { + const MAX_LINES: usize = 39; + const MAX_CHARS: usize = 4096; + + let lines: Vec<&str> = raw.lines().filter(|line| !line.trim().is_empty()).collect(); + let mut excerpt = Vec::new(); + if lines.len() <= MAX_LINES { + excerpt.extend(lines.iter().copied()); + } else { + excerpt.extend(lines.iter().take(28).copied()); + excerpt.push("... [output omitted] ..."); + excerpt.extend(lines.iter().skip(lines.len() - 10).copied()); + } + + let mut out = format!("{}: failed (exit {})", tool, exit_code); + let mut truncated = false; + let mut emitted_omission_marker = false; + for line in excerpt { + let room = MAX_CHARS.saturating_sub(out.chars().count() + 1); + if room < 3 { + truncated = true; + break; + } + let line = crate::core::utils::truncate(line, room.min(512)); + if out.chars().count() + line.chars().count() + 1 > MAX_CHARS { + truncated = true; + break; + } + out.push('\n'); + out.push_str(&line); + emitted_omission_marker |= line == "... [output omitted] ..."; + } + if truncated && !emitted_omission_marker { + let marker = "\n... [output omitted] ..."; + let keep = MAX_CHARS.saturating_sub(marker.chars().count()); + if out.chars().count() > keep { + out = out.chars().take(keep).collect(); + } + out.push_str(marker); + } + out +} + +#[cfg(test)] +mod tests { + use super::failure_fallback; + + #[test] + fn failure_fallback_line_cap_is_bounded_and_marked() { + let raw = (0..100) + .map(|i| format!("line {i}")) + .collect::>() + .join("\n"); + let out = failure_fallback("cmake", 1, &raw); + assert!(out.lines().count() <= 40); + assert!(out.contains("output omitted")); + } + + #[test] + fn failure_fallback_character_cap_is_bounded_and_marked() { + let raw = (0..100) + .map(|i| format!("line {} {}", i, "x".repeat(500))) + .collect::>() + .join("\n"); + let out = failure_fallback("cmake", 1, &raw); + assert!(out.chars().count() <= 4096); + assert!(out.contains("output omitted")); + } +} diff --git a/src/cmds/cpp/msbuild_cmd.rs b/src/cmds/cpp/msbuild_cmd.rs new file mode 100644 index 0000000000..05b5cb0afd --- /dev/null +++ b/src/cmds/cpp/msbuild_cmd.rs @@ -0,0 +1,736 @@ +//! 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 super::failure_fallback; +use crate::core::runner::{self, RunOptions}; +use crate::core::utils::resolved_command; +use anyhow::Result; +use regex::Regex; +use std::collections::HashSet; +use std::process::Command; +use std::sync::LazyLock; + +static MSVC_COMPILER_RE: LazyLock = LazyLock::new(|| { + // Compiler: file(line): error|warning C1234: message [project.vcxproj] + Regex::new(r"^(.+)\((\d+)(?:,\d+)?\): (error|warning|fatal error) (C\d+): (.+?)(?:\s+\[.+\])?$").unwrap() +}); + // Linker: module : error|fatal error LNK1234: message +static MSVC_LINKER_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^(.+) : (error|fatal error) (LNK\d+): (.+)$").unwrap()); + // Linker tool (no file prefix): "LINK : fatal error LNK1104: ..." +static MSVC_LINK_TOOL_RE: LazyLock = LazyLock::new(|| { + 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 RC_DIAG_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^(.+)\((\d+)(?:,\d+)?\): (warning|error|fatal error) (RC\d+): (.+?)(?:\s+\[.+\])?$") + .unwrap() +}); + // MSBuild-style diagnostics: path.vcxproj(123,5): error MSB3073: ... +static MSBUILD_DIAG_RE: LazyLock = LazyLock::new(|| Regex::new( + r"^(.+?)\((\d+)(?:,(\d+))?\): (warning|error|fatal error) ((?:MSB|PRJ|CVT|LNK|RC|C)\d+): (.+)$" + ).unwrap()); +static MSB3073_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)\b(MSB3073|MSB3721)\b").unwrap()); +static EXIT_CODE_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)\bexited with code\s+(\d+)\b").unwrap()); +static PROJECT_ON_NODE_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"^Project \"(.+?)\" on node \d+ \((.+?) target\(s\)\)\."#).unwrap() +}); +static DONE_BUILDING_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"^Done Building Project \"(.+?)\" \(.+\) -- (FAILED|SUCCESSFUL)\."#).unwrap() +}); + // Build FAILED. or Build succeeded. +static BUILD_RESULT_RE: LazyLock = LazyLock::new(|| Regex::new(r"^Build (FAILED|succeeded)\.").unwrap()); + // " N Error(s)" or " N Warning(s)" +static ERR_WARN_COUNT_RE: LazyLock = + LazyLock::new(|| 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 cmd = msbuild_command(args); + if verbose > 0 { + eprintln!("Running: msbuild {}", args.join(" ")); + } + + let args_owned = args.to_vec(); + runner::run_filtered_with_exit( + cmd, + "msbuild", + &args.join(" "), + move |raw, exit_code| filter_output(raw, &args_owned, exit_code), + RunOptions::with_tee("msbuild").preserve_filtered_failure_output(), + ) +} + +fn msbuild_command(args: &[String]) -> Command { + let mut cmd = resolved_command("msbuild"); + cmd.args(args); + cmd +} + +pub(crate) fn filter_output(raw: &str, args: &[String], exit_code: i32) -> 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 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 PROJECT_ON_NODE_RE.is_match(trimmed) { + 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()); + } + continue; + } + + if let Some(diag) = parse_diag_line(trimmed, idx) { + 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 exit_code != 0 && !has_errors { + return failure_fallback("msbuild", exit_code, raw); + } + + 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 a linker-enabled target\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) -> 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; + + 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.project.as_deref().unwrap_or(""), 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 msbuild_preserves_original_arguments() { + let args = vec!["app.sln".to_string(), "/t:Link".to_string(), "/p:Configuration=Release".to_string()]; + let command = msbuild_command(&args); + let actual: Vec = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert_eq!(actual, args); + assert!(actual.contains(&"/t:Link".to_string())); + assert!(!actual.contains(&"/t:Build".to_string())); + } + + #[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, 1); + 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, 1); + 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, 0); + 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, 0); + 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, 0); + 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, 1); + 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, 1); + 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, 1); + 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, 1); + 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, 1); + 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, 0); + 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, 0); + assert!(out.starts_with("msbuild: ok")); + assert!(!out.contains("C4244")); + } + + #[test] + fn identical_diagnostics_from_distinct_projects_are_not_deduped() { + let raw = "C:\\src\\main.cpp(42): error C2065: name [C:\\a\\a.vcxproj]\nC:\\src\\main.cpp(42): error C2065: name [C:\\b\\b.vcxproj]\n"; + let out = filter_output(raw, &["all.sln".into()], 1); + assert_eq!(out.matches("C:\\src\\main.cpp(42): error C2065").count(), 3); + } + + #[test] + fn projectless_linker_error_has_no_stale_project() { + let raw = "Project \"a.vcxproj\" on node 1 (Build target(s)).\nLINK : fatal error LNK1104: missing.lib\n"; + let out = filter_output(raw, &["all.sln".into()], 1); + assert!(out.contains("LNK1104")); + assert!(!out.contains("project: a.vcxproj")); + } + + #[test] + fn unknown_and_empty_nonzero_msbuild_output_is_failure() { + assert!(filter_output("localized failure", &[], 5).contains("msbuild: failed (exit 5)")); + assert_eq!(filter_output("", &[], 1), "msbuild: failed (exit 1)"); + } +} diff --git a/src/cmds/mod.rs b/src/cmds/mod.rs index 1198aacf34..de7e37f7bb 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/core/runner.rs b/src/core/runner.rs index b893357491..e293e4e8f0 100644 --- a/src/core/runner.rs +++ b/src/core/runner.rs @@ -30,11 +30,37 @@ pub fn print_with_hint( emit_guarded(filtered, hint.as_deref(), guard_raw) } +fn select_filtered_output( + tool_name: &str, + filtered: &str, + hint: Option<&str>, + raw: &str, + exit_code: i32, + preserve_filtered_failure_output: bool, +) -> String { + let filtered = + if preserve_filtered_failure_output && exit_code != 0 && filtered.trim().is_empty() { + format!("{}: failed (exit {})", tool_name, exit_code) + } else { + filtered.to_string() + }; + let body = match hint { + Some(h) => format!("{}\n{}", filtered, h), + None => filtered, + }; + if preserve_filtered_failure_output && exit_code != 0 { + body + } else { + crate::core::guard::never_worse(raw, &body).to_string() + } +} + #[derive(Default)] pub struct RunOptions<'a> { pub tee_label: Option<&'a str>, pub filter_stdout_only: bool, pub skip_filter_on_failure: bool, + pub preserve_filtered_failure_output: bool, pub no_trailing_newline: bool, /// Forward rtk's own stdin to the child process. Needed for commands that /// can read from a pipe (e.g. `cat file | rtk wc`); without it the child @@ -67,6 +93,11 @@ impl<'a> RunOptions<'a> { self } + pub fn preserve_filtered_failure_output(mut self) -> Self { + self.preserve_filtered_failure_output = true; + self + } + pub fn no_trailing_newline(mut self) -> Self { self.no_trailing_newline = true; self @@ -136,9 +167,34 @@ where }; let shown = if let Some(label) = opts.tee_label { - print_with_hint(&filtered, raw, raw_for_tracking, label, exit_code) + if opts.preserve_filtered_failure_output && exit_code != 0 { + let hint = crate::core::tee::tee_and_hint(raw, label, exit_code); + let shown = select_filtered_output( + tool_name, + &filtered, + hint.as_deref(), + raw_for_tracking, + exit_code, + true, + ); + if opts.no_trailing_newline { + print!("{}", shown); + } else { + println!("{}", shown); + } + shown + } else { + print_with_hint(&filtered, raw, raw_for_tracking, label, exit_code) + } } else { - let guarded = crate::core::guard::never_worse(raw_for_tracking, &filtered).to_string(); + let guarded = select_filtered_output( + tool_name, + &filtered, + None, + raw_for_tracking, + exit_code, + opts.preserve_filtered_failure_output, + ); if opts.no_trailing_newline { print!("{}", guarded); } else { @@ -284,3 +340,79 @@ pub fn run_streamed( opts, ) } + +#[cfg(test)] +mod tests { + use super::select_filtered_output; + + #[test] + fn default_selection_keeps_never_worse_behavior() { + assert_eq!( + select_filtered_output("tool", "structured failure", None, "x", 1, false), + "x" + ); + } + + #[test] + fn success_with_opt_in_still_uses_guard() { + assert_eq!( + select_filtered_output("tool", "long success", None, "x", 0, true), + "x" + ); + } + + #[test] + fn empty_nonzero_raw_keeps_failure_summary() { + assert_eq!( + select_filtered_output("cmake", "cmake: failed (exit 2)", None, "", 2, true), + "cmake: failed (exit 2)" + ); + } + + #[test] + fn larger_structured_failure_is_authoritative_on_nonzero() { + let structured = "FIRST_ERROR\n code: C2065\n message: undeclared"; + assert_eq!( + select_filtered_output("msbuild", structured, None, "x", 1, true), + structured + ); + } + + #[test] + fn empty_filter_synthesizes_failure_summary() { + assert_eq!( + select_filtered_output("ninja", "", None, "", 1, true), + "ninja: failed (exit 1)" + ); + } + + #[test] + fn tee_hint_cannot_erase_failure_summary() { + let shown = select_filtered_output( + "make", + "make: failed (exit 2)", + Some("see raw"), + "", + 2, + true, + ); + assert!(shown.starts_with("make: failed (exit 2)")); + assert!(shown.contains("see raw")); + } + + #[cfg(windows)] + #[test] + fn native_nonzero_exit_code_is_preserved() { + let mut cmd = std::process::Command::new("cmd"); + cmd.args(["/C", "exit", "7"]); + let code = super::run_filtered_with_exit( + cmd, + "test", + "", + |_, exit| format!("test: failed (exit {})", exit), + super::RunOptions::default().preserve_filtered_failure_output(), + ) + .unwrap(); + assert_eq!(code, 7); + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index 6469b178d8..71bbc8e26a 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1361,6 +1361,11 @@ fn rewrite_segment_inner( return Some(trimmed.to_string()); } + // Lifecycle targets have native semantics; do not replace them with a filter wrapper. + if cmd_part.starts_with("make ") && make_has_lifecycle_target(cmd_part) { + return None; + } + if context == RewriteContext::Normal && (cmd_part.starts_with("head -") || cmd_part.starts_with("tail ")) { @@ -1413,6 +1418,9 @@ fn rewrite_segment_inner( // Find the matching rule (rtk_cmd values are unique across all rules) let rule = RULES.iter().find(|r| r.rtk_cmd == rtk_equivalent)?; + if !cpp_rewrite_is_safe(rule.rtk_cmd, cmd_part) { + return None; + } if context == RewriteContext::PipelineFinal && (!rule.pipeline_final_safe || !pipeline_final_command_is_safe(rule.rtk_cmd, cmd_part)) { @@ -1479,6 +1487,184 @@ fn rewrite_segment_inner( None } +fn cpp_rewrite_is_safe(rtk_cmd: &str, command: &str) -> bool { + if !matches!( + rtk_cmd, + "rtk cmake" | "rtk ctest" | "rtk make" | "rtk ninja" | "rtk msbuild" + ) { + return true; + } + let args = shell_split(command); + let mut options = args.iter().skip(1); + match rtk_cmd { + "rtk cmake" => { + !options.clone().any(|arg| { + matches!( + arg.as_str(), + "-E" | "-P" | "--find-package" | "--workflow" | "--install" | "--open" + ) || arg == "--version" + || arg == "--help" + || arg.starts_with("--help-") + }) && options.clone().any(|arg| arg == "--build" || arg == "-B") + } + "rtk ctest" => !options.clone().any(|arg| { + matches!( + arg.as_str(), + "-N" | "--show-only" | "--print-labels" | "--help" | "--version" + ) || arg.starts_with("--show-only=") + || arg.starts_with("--help-") + }), + "rtk make" => !make_has_unsafe_option(command), + "rtk ninja" => !options.clone().any(|arg| { + matches!( + arg.as_str(), + "-t" | "-n" + | "--dry-run" + | "-d" + | "-v" + | "--verbose" + | "-h" + | "--help" + | "--version" + ) + }), + "rtk msbuild" => !options.any(|arg| { + let lower = arg.to_ascii_lowercase(); + matches!( + lower.as_str(), + "-help" + | "/help" + | "/?" + | "-version" + | "/version" + | "-getproperty" + | "/getproperty" + | "-getitem" + | "/getitem" + | "-gettargetresult" + | "/gettargetresult" + | "-preprocess" + | "/preprocess" + | "-targets" + | "/targets" + ) || lower.starts_with("-getproperty:") + || lower.starts_with("/getproperty:") + || lower.starts_with("-getitem:") + || lower.starts_with("/getitem:") + || lower.starts_with("-gettargetresult:") + || lower.starts_with("/gettargetresult:") + || lower.starts_with("-preprocess:") + || lower.starts_with("/preprocess:") + }), + _ => true, + } +} + +fn make_has_unsafe_option(command: &str) -> bool { + let mut args = shell_split(command).into_iter().skip(1); + while let Some(arg) = args.next() { + if arg == "--" { + break; + } + if !arg.starts_with('-') || arg == "-" { + continue; + } + if arg.starts_with("--") { + if matches!( + arg.as_str(), + "--dry-run" + | "--just-print" + | "--recon" + | "--question" + | "--print-data-base" + | "--debug" + | "--trace" + | "--help" + | "--version" + ) || arg.starts_with("--debug=") + { + return true; + } + if matches!( + arg.as_str(), + "--directory" + | "--file" + | "--makefile" + | "--include-dir" + | "--old-file" + | "--assume-old" + | "--what-if" + | "--new-file" + | "--assume-new" + | "--eval" + ) { + let _ = args.next(); + } + continue; + } + + let mut short_options = arg[1..].chars().peekable(); + while let Some(flag) = short_options.next() { + if matches!(flag, 'n' | 'q' | 'p' | 'd' | 'h' | 'v') { + return true; + } + if matches!(flag, 'C' | 'f' | 'I' | 'o' | 'W' | 'E') { + if short_options.peek().is_none() { + let _ = args.next(); + } + break; + } + } + } + false +} + +fn make_has_lifecycle_target(command: &str) -> bool { + let mut skip_next = false; + let mut after_options = true; + for (idx, arg) in shell_split(command).into_iter().enumerate() { + if idx == 0 { + continue; + } + if skip_next { + skip_next = false; + continue; + } + if arg == "--" { + after_options = false; + continue; + } + if after_options + && matches!( + arg.as_str(), + "-C" | "--directory" | "-f" | "--file" | "-I" | "--include-dir" + ) + { + skip_next = true; + continue; + } + if after_options + && (arg.starts_with("--directory=") + || arg.starts_with("--file=") + || arg.starts_with("--include-dir=") + || arg.starts_with("-C") + || arg.starts_with("-f")) + { + continue; + } + if after_options && arg.starts_with('-') { + continue; + } + if arg.contains('=') { + continue; + } + if matches!(arg.as_str(), "install" | "clean" | "distclean") { + return true; + } + } + false +} + /// Strip a command prefix with word-boundary check. /// Returns the remainder of the command after the prefix, or `None` if no match. fn strip_word_prefix<'a>(cmd: &'a str, prefix: &str) -> Option<&'a str> { @@ -1503,6 +1689,172 @@ mod tests { super::rewrite_command(cmd, excluded, &[]) } + #[test] + fn cpp_build_rewrites_preserve_arguments() { + let cases = [ + ( + "cmake --build build --config Release", + "rtk cmake --build build --config Release", + ), + ("cmake -B build", "rtk cmake -B build"), + ("cmake -S src -B build", "rtk cmake -S src -B build"), + ( + "cmake -G Ninja -S src -B build", + "rtk cmake -G Ninja -S src -B build", + ), + ( + "ctest --test-dir build -C Release", + "rtk ctest --test-dir build -C Release", + ), + ("make -j8 all", "rtk make -j8 all"), + ("make -B all", "rtk make -B all"), + ("make -C build all", "rtk make -C build all"), + ("make -Cbuild all", "rtk make -Cbuild all"), + ("make -f Makefile -B all", "rtk make -f Makefile -B all"), + ("make -fMakefile -B all", "rtk make -fMakefile -B all"), + ("make -I include -B all", "rtk make -I include -B all"), + ("make -Iinclude -B all", "rtk make -Iinclude -B all"), + ("make -o output -B all", "rtk make -o output -B all"), + ("make -ooutput -B all", "rtk make -ooutput -B all"), + ("make -W file -B all", "rtk make -W file -B all"), + ("make -Wfile -B all", "rtk make -Wfile -B all"), + ("make MODE=-q all", "rtk make MODE=-q all"), + ("make TARGET=-p build", "rtk make TARGET=-p build"), + ("make -- -q", "rtk make -- -q"), + ("make -- -p", "rtk make -- -p"), + ("ninja -C build app", "rtk ninja -C build app"), + ( + "msbuild app.sln /t:Link /p:Configuration=Release", + "rtk msbuild app.sln /t:Link /p:Configuration=Release", + ), + ]; + for (input, expected) in cases { + assert_eq!( + rewrite_command_no_prefixes(input, &[]), + Some(expected.into()) + ); + } + } + + #[test] + fn cpp_build_rewrite_exclusions_preserve_native_commands() { + for input in [ + "make clean", + "make install", + "make distclean", + "make -C build clean", + "make --directory build clean", + "make -f Makefile install", + "make VAR=value distclean", + "make all clean", + "make -j8 install", + ] { + assert_eq!(rewrite_command_no_prefixes(input, &[]), None); + } + for input in [ + "make", + "make all", + "make -C build all", + "make MODE=clean all", + "make TARGET=install build", + "make cleanly", + "make install-tools", + "make MODE=x all", + "make MODE=-q all", + "make TARGET=-p build", + "make --directory build all", + "make -f Makefile all", + "make --file Makefile all", + "make -C -q all", + "make -f -n all", + "make --directory -q all", + "make --file -n all", + ] { + assert!(rewrite_command_no_prefixes(input, &[]).is_some(), "{input}"); + } + for input in [ + "cmake --version", + "cmake --help", + "cmake --help-full", + "cmake -E echo -B", + "cmake -P script.cmake -B build", + "cmake --find-package -B build", + "cmake --workflow workflow --preset default -B build", + "cmake --install build -B other", + "cmake --open build -B other", + "ctest -N", + "ctest --show-only=json-v1", + "ctest --print-labels", + "ctest --help-full", + "ctest --version", + "make -n all", + "make --dry-run all", + "make --just-print all", + "make --recon all", + "make -q all", + "make --question all", + "make -p all", + "make --print-data-base all", + "make -d all", + "make --debug all", + "make --debug=basic all", + "make --trace all", + "make --help", + "make --version", + "make -qp", + "make -pq", + "make -nB all", + "make -dB all", + "make -h", + "make -v", + "make -C build -qp", + "make -Cbuild -qp", + "make -f Makefile -n all", + "make all -n", + "make MODE=x -n all", + "make all -q", + "make all -qp", + "make -C build all -n", + "make -Cbuild all -q", + "make --directory build all -n", + "make --directory build -qp", + "make -f Makefile all -q", + "make --file Makefile all -q", + "make TARGET=x all --question", + "make clean -n", + "make all --debug=basic", + "make all -v", + "ninja -t targets", + "ninja -t compdb", + "ninja -n", + "ninja --dry-run", + "ninja -d explain", + "ninja -v", + "ninja --verbose", + "ninja -h", + "ninja --help", + "ninja --version", + "msbuild -version", + "msbuild -getProperty:TargetPath app.vcxproj", + "msbuild -getProperty TargetPath app.vcxproj", + "msbuild /GETITEM TargetPath app.vcxproj", + "msbuild -getTargetResult Target app.vcxproj", + "msbuild /preprocess:out.xml app.vcxproj", + "msbuild /targets app.vcxproj", + "cmakex -B build", + ] { + assert_eq!(rewrite_command_no_prefixes(input, &[]), None); + } + for input in [ + "dirname src/main.rs", + "dir", + "Get-Content README.md", + "powershell -NoProfile", + ] { + assert_eq!(rewrite_command_no_prefixes(input, &[]), None); + } + } + mod multiline_blocks { use super::rewrite_command_no_prefixes; diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 49c0ff740a..0422bde68e 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -952,6 +952,51 @@ pub const RULES: &[RtkRule] = &[ savings_pct: 65.0, ..RtkRule::DEFAULT }, + RtkRule { + pattern: r"^cmake\s+(?:--build(?:\s|$)|(?:.*\s)?-B(?:\s|$))", + rtk_cmd: "rtk cmake", + rewrite_prefixes: &["cmake"], + category: "Build", + pipeline_final_safe: false, + savings_pct: 80.0, + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^ctest(?:\s|$)", + rtk_cmd: "rtk ctest", + rewrite_prefixes: &["ctest"], + category: "Tests", + pipeline_final_safe: false, + savings_pct: 85.0, + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^make(?:\s|$)", + rtk_cmd: "rtk make", + rewrite_prefixes: &["make"], + category: "Build", + pipeline_final_safe: false, + savings_pct: 80.0, + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^ninja(?:\s|$)", + rtk_cmd: "rtk ninja", + rewrite_prefixes: &["ninja"], + category: "Build", + pipeline_final_safe: false, + savings_pct: 80.0, + ..RtkRule::DEFAULT + }, + RtkRule { + pattern: r"^msbuild(?:\s|$)", + rtk_cmd: "rtk msbuild", + rewrite_prefixes: &["msbuild"], + category: "Build", + pipeline_final_safe: false, + savings_pct: 80.0, + ..RtkRule::DEFAULT + }, ]; pub const IGNORED_PREFIXES: &[&str] = &[ diff --git a/src/main.rs b/src/main.rs index d1e0269f5a..21f1868703 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, ctest_cmd, make_cmd, msbuild_cmd}; 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}; @@ -847,6 +848,36 @@ enum Commands { args: Vec, }, + /// CMake build / configure with compact diagnostics + Cmake { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// CTest runner with compact failure output + Ctest { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// make with compact diagnostics + Make { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// ninja with compact diagnostics + Ninja { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// MSBuild with compact compiler/linker diagnostics + Msbuild { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Hook processors for LLM CLI tools (Gemini CLI, Copilot, etc.) Hook { #[command(subcommand)] @@ -2412,6 +2443,16 @@ fn run_cli() -> Result { Commands::Mvn { args } => mvn_cmd::run(&args, cli.verbose)?, + 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::HookAudit { since } => { hooks::hook_audit_cmd::run(since, cli.verbose)?; 0 @@ -2767,6 +2808,11 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Sbt { .. } | Commands::GolangciLint { .. } | Commands::Gt { .. } + | Commands::Cmake { .. } + | Commands::Ctest { .. } + | Commands::Make { .. } + | Commands::Ninja { .. } + | Commands::Msbuild { .. } ) } @@ -3145,6 +3191,11 @@ mod tests { "gradlew", "mvn", "sbt", + "cmake", + "ctest", + "make", + "ninja", + "msbuild", "php", "phpunit", "phpstan", diff --git a/tests/fixtures/cpp/cmake_build_failure.txt b/tests/fixtures/cpp/cmake_build_failure.txt new file mode 100644 index 0000000000..67c32aec59 --- /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 0000000000..3366fd41b5 --- /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 0000000000..2ab052bf99 --- /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/ctest_failure.txt b/tests/fixtures/cpp/ctest_failure.txt new file mode 100644 index 0000000000..0b64956178 --- /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 0000000000..bee4ea2b52 --- /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 0000000000..8a8db57815 --- /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 0000000000..e69de29bb2 diff --git a/tests/fixtures/cpp/msbuild_failure_compiler.txt b/tests/fixtures/cpp/msbuild_failure_compiler.txt new file mode 100644 index 0000000000..444fe63079 --- /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 0000000000..a68923e56f --- /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 0000000000..7a16cf46ca --- /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 0000000000..996e01162c --- /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 0000000000..e75d0eb73a --- /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_success.txt b/tests/fixtures/cpp/msbuild_success.txt new file mode 100644 index 0000000000..f313310c99 --- /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