diff --git a/src/go_cmd.rs b/src/go_cmd.rs index d250c427..f0cd822a 100644 --- a/src/go_cmd.rs +++ b/src/go_cmd.rs @@ -1,3 +1,4 @@ +use crate::golangci_cmd; use crate::tracking; use crate::utils::{resolved_command, truncate}; use anyhow::{Context, Result}; @@ -206,6 +207,13 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { anyhow::bail!("go: no subcommand specified"); } + // Intercept: `go tool ` invocations for filtered output + if let Some((tool, tool_args)) = match_go_tool(args) { + match tool { + GoTool::GolangciLint => return run_go_tool_golangci_lint(tool_args, verbose), + } + } + let timer = tracking::TimedExecution::start(); let subcommand = args[0].to_string_lossy(); @@ -246,6 +254,146 @@ pub fn run_other(args: &[OsString], verbose: u8) -> Result<()> { Ok(()) } +/// Detect golangci-lint major version when invoked via `go tool`. +/// Returns 1 on any failure (safe fallback — v1 behaviour). +fn detect_go_tool_golangci_version() -> u32 { + let output = resolved_command("go") + .arg("tool") + .arg("golangci-lint") + .arg("--version") + .output(); + + match output { + Ok(o) => { + let stdout = String::from_utf8_lossy(&o.stdout); + let stderr = String::from_utf8_lossy(&o.stderr); + let version_text = if stdout.trim().is_empty() { + &*stderr + } else { + &*stdout + }; + golangci_cmd::parse_major_version(version_text) + } + Err(_) => 1, + } +} + +fn has_golangci_format_flag(args: &[OsString]) -> bool { + args.iter().any(|a| { + let s = a.to_string_lossy(); + s == "--out-format" + || s.starts_with("--out-format=") + || s == "--output.json.path" + || s.starts_with("--output.json.path=") + }) +} + +/// Known `go tool` subcommands that RTK provides filtered output for. +#[derive(Debug, Clone, Copy, PartialEq)] +enum GoTool { + GolangciLint, +} + +impl GoTool { + fn from_name(name: &str) -> Option { + match name { + "golangci-lint" => Some(Self::GolangciLint), + _ => None, + } + } +} + +/// If the first arg is `tool` identify if it is a tool we already handle. +fn match_go_tool(args: &[OsString]) -> Option<(GoTool, &[OsString])> { + if args.first().map(|a| a == "tool").unwrap_or(false) { + if let Some(tool_arg) = args.get(1) { + if let Some(tool) = GoTool::from_name(&tool_arg.to_string_lossy()) { + return Some((tool, &args[2..])); + } + } + } + None +} + +/// Run `go tool golangci-lint` and filter its output via the golangci JSON filter. +/// Reusing parts of golangci_cmd. +fn run_go_tool_golangci_lint(args: &[OsString], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let version = detect_go_tool_golangci_version(); + + let mut cmd = resolved_command("go"); + cmd.arg("tool").arg("golangci-lint"); + + let has_format = has_golangci_format_flag(args); + + if !has_format { + if version >= 2 { + cmd.arg("run").arg("--output.json.path").arg("stdout"); + } else { + cmd.arg("run").arg("--out-format=json"); + } + } else { + cmd.arg("run"); + } + + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + if version >= 2 { + eprintln!("Running: go tool golangci-lint run --output.json.path stdout"); + } else { + eprintln!("Running: go tool golangci-lint run --out-format=json"); + } + } + + let output = cmd + .output() + .context("Failed to run go tool golangci-lint")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + // v2 outputs JSON on first line + trailing text; v1 outputs just JSON + let json_output = if version >= 2 { + stdout.lines().next().unwrap_or("") + } else { + &*stdout + }; + + let filtered = golangci_cmd::filter_golangci_json(json_output, version); + println!("{}", filtered); + + if !stderr.trim().is_empty() && verbose > 0 { + eprintln!("{}", stderr.trim()); + } + + timer.track( + "go tool golangci-lint", + "rtk go tool golangci-lint", + &raw, + &filtered, + ); + + // golangci-lint: exit 0 = clean, exit 1 = lint issues, exit 2+ = config/build error + match output.status.code() { + Some(0) | Some(1) => Ok(()), + Some(code) => { + if !stderr.trim().is_empty() { + eprintln!("{}", stderr.trim()); + } + std::process::exit(code); + } + None => { + eprintln!("go tool golangci-lint: killed by signal"); + std::process::exit(130); + } + } +} + /// Parse go test -json output (NDJSON format) fn filter_go_test_json(output: &str) -> String { let mut packages: HashMap = HashMap::new(); @@ -588,4 +736,60 @@ utils.go:15:5: unreachable code"#; assert_eq!(compact_package_name("example.com/foo"), "foo"); assert_eq!(compact_package_name("simple"), "simple"); } + + fn os(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() + } + + #[test] + fn test_match_go_tool_golangci_lint() { + let args = os(&["tool", "golangci-lint", "run", "./..."]); + let (tool, rest) = match_go_tool(&args).expect("should match"); + assert_eq!(tool, GoTool::GolangciLint); + assert_eq!(rest.len(), 2); // ["run", "./..."] + } + + #[test] + fn test_match_go_tool_bare() { + let args = os(&["tool", "golangci-lint"]); + let (tool, rest) = match_go_tool(&args).expect("should match"); + assert_eq!(tool, GoTool::GolangciLint); + assert!(rest.is_empty()); + } + + #[test] + fn test_match_go_tool_rejects_unknown() { + assert!(match_go_tool(&os(&["tool", "pprof"])).is_none()); + assert!(match_go_tool(&os(&["tool"])).is_none()); + assert!(match_go_tool(&os(&["test", "./..."])).is_none()); + assert!(match_go_tool(&os(&[])).is_none()); + } + + #[test] + fn test_has_golangci_format_flag_v1() { + assert!(has_golangci_format_flag(&os(&["--out-format=json"]))); + assert!(has_golangci_format_flag(&os(&[ + "./...", + "--out-format", + "json" + ]))); + } + + #[test] + fn test_has_golangci_format_flag_v2() { + assert!(has_golangci_format_flag(&os(&[ + "--output.json.path", + "stdout" + ]))); + assert!(has_golangci_format_flag(&os(&[ + "--output.json.path=stdout" + ]))); + } + + #[test] + fn test_has_golangci_format_flag_absent() { + assert!(!has_golangci_format_flag(&os(&["run", "./..."]))); + assert!(!has_golangci_format_flag(&os(&[]))); + assert!(!has_golangci_format_flag(&os(&["--fix"]))); + } } diff --git a/src/golangci_cmd.rs b/src/golangci_cmd.rs index b2fdcd28..2a6f110b 100644 --- a/src/golangci_cmd.rs +++ b/src/golangci_cmd.rs @@ -44,7 +44,7 @@ struct GolangciOutput { /// Parse major version number from `golangci-lint --version` output. /// Returns 1 on any failure (safe fallback — v1 behaviour). -fn parse_major_version(version_output: &str) -> u32 { +pub(crate) fn parse_major_version(version_output: &str) -> u32 { // Handles: // "golangci-lint version 1.59.1" // "golangci-lint has version 2.10.0 built with ..." @@ -60,7 +60,7 @@ fn parse_major_version(version_output: &str) -> u32 { /// Run `golangci-lint --version` and return the major version number. /// Returns 1 on any failure. -fn detect_major_version() -> u32 { +pub(crate) fn detect_major_version() -> u32 { let output = resolved_command("golangci-lint").arg("--version").output(); match output { @@ -161,7 +161,7 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { } /// Filter golangci-lint JSON output - group by linter and file -fn filter_golangci_json(output: &str, version: u32) -> String { +pub(crate) fn filter_golangci_json(output: &str, version: u32) -> String { let result: Result = serde_json::from_str(output); let golangci_output = match result {