From a4d9bcd86129218a70e47a8a68979544d60f1922 Mon Sep 17 00:00:00 2001 From: shwoop Date: Wed, 18 Mar 2026 20:37:15 +0100 Subject: [PATCH] feat(go): Support golangci-lint via go tool As of go v1.24, tools such as golanci-lint can be managed via the mod file and called directly on the go binary via the tool subcommand. When called via tools, we now apply the same logic to compact the results as in the directly called command. Signed-off-by: shwoop --- src/go_cmd.rs | 204 ++++++++++++++++++++++++++++++++++++++++++++ src/golangci_cmd.rs | 6 +- 2 files changed, 207 insertions(+), 3 deletions(-) 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 {