Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,7 @@ pub fn execute_with_filter(cmd: &str, args: &[&str]) -> Result<()> {
- `rtk go test`: NDJSON line-by-line parser for interleaved events (90%+ reduction)
- `rtk go build`: Text filter showing errors only (80% reduction)
- `rtk go vet`: Text filter for issues (75% reduction)
- `rtk golangci-lint`: JSON parsing grouped by rule (85% reduction)
- `rtk golangci-lint run`: JSON parsing grouped by rule (85% reduction)
- **Architecture**: Standalone Python commands (mirror lint/prettier), Go sub-enum (mirror git/cargo)
- **Patterns**: JSON for structured output (ruff check, golangci-lint, pip), NDJSON streaming (go test), text state machine (pytest), text filters (go build/vet, ruff format)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ Plugin in `openclaw/` directory. Uses `before_tool_call` hook, delegates to `rtk
| `pytest` | `rtk pytest` |
| `pip list/install` | `rtk pip ...` |
| `go test/build/vet` | `rtk go ...` |
| `golangci-lint` | `rtk golangci-lint` |
| `golangci-lint run` | `rtk golangci-lint run` |
| `rake test` / `rails test` | `rtk rake test` |
| `rspec` / `bundle exec rspec` | `rtk rspec` |
| `rubocop` / `bundle exec rubocop` | `rtk rubocop` |
Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ Regroupe les erreurs de type par fichier.

---

### `rtk golangci-lint` -- Linter Go
### `rtk golangci-lint run` -- Linter Go

**Economies :** ~85%

Expand Down Expand Up @@ -1269,7 +1269,7 @@ rtk verify
| `mypy` | `rtk mypy` |
| `pip list/install` | `rtk pip ...` |
| `go test/build/vet` | `rtk go ...` |
| `golangci-lint` | `rtk golangci-lint` |
| `golangci-lint run` | `rtk golangci-lint run` |
| `docker ps/images/logs` | `rtk docker ...` |
| `kubectl get/logs` | `rtk kubectl ...` |
| `curl` | `rtk curl` |
Expand Down
2 changes: 1 addition & 1 deletion docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ rtk --version # Should be 0.23.1+
```

### Affected Commands
All commands that spawn external tools: `rtk vitest`, `rtk lint`, `rtk tsc`, `rtk pnpm`, `rtk playwright`, `rtk prisma`, `rtk next`, `rtk prettier`, `rtk ruff`, `rtk pytest`, `rtk pip`, `rtk mypy`, `rtk golangci-lint`, and others.
All commands that spawn external tools: `rtk vitest`, `rtk lint`, `rtk tsc`, `rtk pnpm`, `rtk playwright`, `rtk prisma`, `rtk next`, `rtk prettier`, `rtk ruff`, `rtk pytest`, `rtk pip`, `rtk mypy`, `rtk golangci-lint run`, and others.

---

Expand Down
280 changes: 279 additions & 1 deletion src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ lazy_static! {
Regex::new(r"^(?:(?:-C\s+\S+|-c\s+\S+|--git-dir(?:=\S+|\s+\S+)|--work-tree(?:=\S+|\s+\S+)|--no-pager|--no-optional-locks|--bare|--literal-pathspecs)\s+)+").unwrap();
}

const GOLANGCI_GLOBAL_OPT_WITH_VALUE: &[&str] = &[
"-c",
"--color",
"--config",
"--cpu-profile-path",
"--mem-profile-path",
"--trace-path",
];

#[derive(Debug, Clone, Copy)]
struct GolangciRunParts<'a> {
global_segment: &'a str,
run_segment: &'a str,
}

/// Classify a single (already-split) command.
pub fn classify_command(cmd: &str) -> Classification {
let trimmed = cmd.trim();
Expand Down Expand Up @@ -84,6 +99,9 @@ pub fn classify_command(cmd: &str) -> Classification {
let cmd_normalized = strip_absolute_path(cmd_clean);
// Strip git global options: git -C /tmp status → git status (#163)
let cmd_normalized = strip_git_global_opts(&cmd_normalized);
// Strip golangci-lint global options before `run` so classify/rewrite stays
// aligned with the runtime wrapper behavior.
let cmd_normalized = strip_golangci_global_opts(&cmd_normalized);
let cmd_clean = cmd_normalized.as_str();

// Exclude cat/head/tail with redirect operators — these are writes, not reads (#315)
Expand Down Expand Up @@ -285,6 +303,105 @@ fn strip_git_global_opts(cmd: &str) -> String {
format!("git {}", stripped.trim())
}

/// Strip golangci-lint global options before the `run` subcommand.
/// `golangci-lint --color never run ./...` → `golangci-lint run ./...`
/// Returns the original string unchanged if this is not a supported compact `run` invocation.
fn strip_golangci_global_opts(cmd: &str) -> String {
match parse_golangci_run_parts(cmd) {
Some(parts) => format!("golangci-lint {}", parts.run_segment),
None => cmd.to_string(),
}
}

/// Parse supported golangci-lint invocations with optional global flags before `run`.
fn parse_golangci_run_parts(cmd: &str) -> Option<GolangciRunParts<'_>> {
let tokens = split_token_spans(cmd);
let first = tokens.first()?;
if first.0 != "golangci-lint" && first.0 != "golangci" {
return None;
}

let mut i = 1;
while i < tokens.len() {
let token = tokens[i].0;

if token == "--" {
return None;
}

if !token.starts_with('-') {
if token == "run" {
let global_segment = if i > 1 {
cmd[tokens[1].1..tokens[i].1].trim()
} else {
""
};
let run_segment = cmd[tokens[i].1..].trim();
return Some(GolangciRunParts {
global_segment,
run_segment,
});
}
return None;
}

if let Some(flag) = split_golangci_flag_name(token) {
if golangci_flag_takes_separate_value(token, flag) {
i += 1;
}
}

i += 1;
}

None
}

fn split_golangci_flag_name(arg: &str) -> Option<&str> {
if arg.starts_with("--") {
return Some(arg.split_once('=').map(|(flag, _)| flag).unwrap_or(arg));
}

if arg.starts_with('-') {
return Some(arg);
}

None
}

fn golangci_flag_takes_separate_value(arg: &str, flag: &str) -> bool {
if !GOLANGCI_GLOBAL_OPT_WITH_VALUE.contains(&flag) {
return false;
}

if arg.starts_with("--") && arg.contains('=') {
return false;
}

true
}

fn split_token_spans(cmd: &str) -> Vec<(&str, usize, usize)> {
let mut tokens = Vec::new();
let mut start = None;

for (idx, ch) in cmd.char_indices() {
if ch.is_whitespace() {
if let Some(token_start) = start.take() {
tokens.push((&cmd[token_start..idx], token_start, idx));
}
} else if start.is_none() {
start = Some(idx);
}
}

if let Some(token_start) = start {
tokens.push((&cmd[token_start..], token_start, cmd.len()));
}

tokens
}

/// Normalize absolute binary paths: `/usr/bin/grep -rn foo` → `grep -rn foo` (#485)
/// Only strips if the first word contains a `/` (Unix path).
fn strip_absolute_path(cmd: &str) -> String {
Expand Down Expand Up @@ -640,6 +757,18 @@ fn rewrite_segment(seg: &str, excluded: &[String]) -> Option<String> {
return None;
}

if let Some(parts) = parse_golangci_run_parts(cmd_clean) {
let rewritten = if parts.global_segment.is_empty() {
format!("{}rtk golangci-lint {}", env_prefix, parts.run_segment)
} else {
format!(
"{}rtk golangci-lint {} {}",
env_prefix, parts.global_segment, parts.run_segment
)
};
return Some(rewritten);
}

// #196: gh with --json/--jq/--template produces structured output that
// rtk gh would corrupt — skip rewrite so the caller gets raw JSON.
if rule.rtk_cmd == "rtk gh" {
Expand Down Expand Up @@ -1855,7 +1984,73 @@ mod tests {
assert!(matches!(
classify_command("golangci-lint run"),
Classification::Supported {
rtk_equivalent: "rtk golangci-lint",
rtk_equivalent: "rtk golangci-lint run",
..
}
));
}

#[test]
fn test_classify_golangci_lint_with_flag_before_run() {
assert!(matches!(
classify_command("golangci-lint -v run ./..."),
Classification::Supported {
rtk_equivalent: "rtk golangci-lint run",
..
}
));
}

#[test]
fn test_classify_golangci_lint_with_value_flag_before_run() {
assert!(matches!(
classify_command("golangci-lint --color never run ./..."),
Classification::Supported {
rtk_equivalent: "rtk golangci-lint run",
..
}
));
}

#[test]
fn test_classify_golangci_lint_with_inline_value_flag_before_run() {
assert!(matches!(
classify_command("golangci-lint --color=never run ./..."),
Classification::Supported {
rtk_equivalent: "rtk golangci-lint run",
..
}
));
}

#[test]
fn test_classify_golangci_lint_with_inline_config_flag_before_run() {
assert!(matches!(
classify_command("golangci-lint --config=foo.yml run ./..."),
Classification::Supported {
rtk_equivalent: "rtk golangci-lint run",
..
}
));
}

#[test]
fn test_classify_golangci_lint_bare_is_not_compact_wrapper() {
assert!(!matches!(
classify_command("golangci-lint"),
Classification::Supported {
rtk_equivalent: "rtk golangci-lint run",
..
}
));
}

#[test]
fn test_classify_golangci_lint_other_subcommand_is_not_compact_wrapper() {
assert!(!matches!(
classify_command("golangci-lint version"),
Classification::Supported {
rtk_equivalent: "rtk golangci-lint run",
..
}
));
Expand Down Expand Up @@ -1893,6 +2088,64 @@ mod tests {
);
}

#[test]
fn test_rewrite_golangci_lint_with_flag_before_run() {
assert_eq!(
rewrite_command("golangci-lint -v run ./...", &[]),
Some("rtk golangci-lint -v run ./...".into())
);
}

#[test]
fn test_rewrite_golangci_lint_with_value_flag_before_run() {
assert_eq!(
rewrite_command("golangci-lint --color never run ./...", &[]),
Some("rtk golangci-lint --color never run ./...".into())
);
}

#[test]
fn test_rewrite_golangci_lint_with_inline_value_flag_before_run() {
assert_eq!(
rewrite_command("golangci-lint --color=never run ./...", &[]),
Some("rtk golangci-lint --color=never run ./...".into())
);
}

#[test]
fn test_rewrite_golangci_lint_with_inline_config_flag_before_run() {
assert_eq!(
rewrite_command("golangci-lint --config=foo.yml run ./...", &[]),
Some("rtk golangci-lint --config=foo.yml run ./...".into())
);
}

#[test]
fn test_rewrite_env_prefixed_golangci_lint_with_value_flag_before_run() {
assert_eq!(
rewrite_command("FOO=1 golangci-lint --color never run ./...", &[]),
Some("FOO=1 rtk golangci-lint --color never run ./...".into())
);
}

#[test]
fn test_rewrite_env_prefixed_golangci_lint_with_inline_value_flag_before_run() {
assert_eq!(
rewrite_command("FOO=1 golangci-lint --color=never run ./...", &[]),
Some("FOO=1 rtk golangci-lint --color=never run ./...".into())
);
}

#[test]
fn test_rewrite_bare_golangci_lint_skips_compact_wrapper() {
assert_eq!(rewrite_command("golangci-lint", &[]), None);
}

#[test]
fn test_rewrite_other_golangci_lint_subcommand_skips_compact_wrapper() {
assert_eq!(rewrite_command("golangci-lint version", &[]), None);
}

// --- JS/TS tooling ---

#[test]
Expand Down Expand Up @@ -2325,4 +2578,29 @@ mod tests {
assert_eq!(strip_git_global_opts("git status"), "git status");
assert_eq!(strip_git_global_opts("cargo test"), "cargo test");
}

#[test]
fn test_strip_golangci_global_opts_helper() {
assert_eq!(
strip_golangci_global_opts("golangci-lint -v run ./..."),
"golangci-lint run ./..."
);
assert_eq!(
strip_golangci_global_opts("golangci-lint --color never run ./..."),
"golangci-lint run ./..."
);
assert_eq!(
strip_golangci_global_opts("golangci-lint --color=never run ./..."),
"golangci-lint run ./..."
);
assert_eq!(
strip_golangci_global_opts("golangci-lint --config=foo.yml run ./..."),
"golangci-lint run ./..."
);
assert_eq!(
strip_golangci_global_opts("golangci-lint version"),
"golangci-lint version"
);
assert_eq!(strip_golangci_global_opts("cargo test"), "cargo test");
}
}
6 changes: 3 additions & 3 deletions src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub const PATTERNS: &[&str] = &[
r"^(pip3?|uv\s+pip)\s+(list|outdated|install)",
// Go tooling
r"^go\s+(test|build|vet)",
r"^golangci-lint(\s|$)",
r"^(?:golangci-lint|golangci)\s+(run)(?:\s|$)",
// Ruby tooling
r"^bundle\s+(install|update)\b",
r"^(?:bundle\s+exec\s+)?(?:bin/)?(?:rake|rails)\s+test",
Expand Down Expand Up @@ -330,8 +330,8 @@ pub const RULES: &[RtkRule] = &[
subcmd_status: &[],
},
RtkRule {
rtk_cmd: "rtk golangci-lint",
rewrite_prefixes: &["golangci-lint", "golangci"],
rtk_cmd: "rtk golangci-lint run",
rewrite_prefixes: &["golangci-lint run", "golangci run"],
category: "Go",
savings_pct: 85.0,
subcmd_savings: &[],
Expand Down
Loading