Skip to main content

aft/
format.rs

1//! External tool runner and auto-formatter detection.
2//!
3//! Provides subprocess execution with timeout protection, language-to-formatter
4//! mapping, and the `auto_format` entry point used by `write_format_validate`.
5
6use std::collections::{HashMap, HashSet};
7use std::io::{ErrorKind, Read};
8use std::path::{Path, PathBuf};
9use std::process::{Child, Command, ExitStatus, Stdio};
10use std::sync::Mutex;
11use std::thread;
12use std::time::{Duration, Instant};
13
14use crate::config::Config;
15use crate::parser::{detect_language, LangId};
16
17/// Result of running an external tool subprocess.
18#[derive(Debug)]
19pub struct ExternalToolResult {
20    pub stdout: String,
21    pub stderr: String,
22    pub exit_code: i32,
23    pub truncated: bool,
24}
25
26struct SubprocessOutcome {
27    stdout: String,
28    stderr: String,
29    status: ExitStatus,
30    truncated: bool,
31}
32
33/// Errors from external tool execution.
34#[derive(Debug)]
35pub enum FormatError {
36    /// The tool binary was not found on PATH.
37    NotFound { tool: String },
38    /// The tool exceeded its timeout and was killed.
39    Timeout { tool: String, timeout_secs: u32 },
40    /// The tool exited with a non-zero status.
41    Failed { tool: String, stderr: String },
42    /// No formatter is configured for this language.
43    UnsupportedLanguage,
44}
45
46/// A configured formatter/checker that cannot be resolved for configure warnings.
47#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
48pub struct MissingTool {
49    pub kind: String,
50    pub language: String,
51    pub tool: String,
52    pub hint: String,
53}
54
55#[derive(Debug, Clone)]
56struct ToolCandidate {
57    tool: String,
58    source: String,
59    args: Vec<String>,
60    required: bool,
61}
62
63#[derive(Debug, Clone)]
64enum ToolDetection {
65    Found(String, Vec<String>),
66    NotConfigured,
67    NotInstalled { tool: String },
68}
69
70impl std::fmt::Display for FormatError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
74            FormatError::Timeout { tool, timeout_secs } => {
75                write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
76            }
77            FormatError::Failed { tool, stderr } => {
78                write!(f, "formatter '{}' failed: {}", tool, stderr)
79            }
80            FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
81        }
82    }
83}
84
85/// Apply Unix-specific isolation so a kill() on timeout terminates
86/// grandchildren too (e.g. `sh -c 'sleep 60'` orphaning `sleep`).
87///
88/// Without this, killing the immediate child (`sh`) leaves `sleep`
89/// holding stdout/stderr pipes open, and the reader threads block
90/// until `sleep` terminates — turning a 2s timeout into a 60s hang.
91#[cfg(unix)]
92fn isolate_in_process_group(cmd: &mut Command) {
93    use std::os::unix::process::CommandExt;
94    // SAFETY: setsid is async-signal-safe.
95    unsafe {
96        cmd.pre_exec(|| {
97            if libc::setsid() == -1 {
98                return Err(std::io::Error::last_os_error());
99            }
100            Ok(())
101        });
102    }
103}
104
105#[cfg(not(unix))]
106fn isolate_in_process_group(_cmd: &mut Command) {
107    // Best-effort no-op outside Unix. Windows timeout cleanup uses taskkill /T
108    // in kill_process_tree so .cmd wrappers and grandchildren are terminated.
109}
110
111/// Kill the child and (on Unix) its entire process group, so orphaned
112/// grandchildren don't keep pipes open after a timeout.
113#[cfg(unix)]
114fn kill_process_tree(child: &mut Child) {
115    let pid = child.id() as i32;
116    if pid > 0 {
117        // SAFETY: killpg with SIGKILL on a process group leader is safe.
118        // Negative pid form (kill -pgid) targets the whole group.
119        unsafe {
120            libc::killpg(pid, libc::SIGKILL);
121        }
122    }
123    let _ = child.kill();
124}
125
126#[cfg(windows)]
127fn kill_process_tree(child: &mut Child) {
128    let pid = child.id().to_string();
129    let _ = Command::new("taskkill")
130        .args(["/PID", pid.as_str(), "/T", "/F"])
131        .stdin(Stdio::null())
132        .stdout(Stdio::null())
133        .stderr(Stdio::null())
134        .status();
135    let _ = child.kill();
136}
137
138#[cfg(not(any(unix, windows)))]
139fn kill_process_tree(child: &mut Child) {
140    let _ = child.kill();
141}
142
143/// Spawn a subprocess and wait for completion with timeout protection.
144///
145/// Polls `try_wait()` at 50ms intervals. On timeout, kills the child process
146/// and waits for it to exit. Returns `FormatError::NotFound` when the binary
147/// isn't on PATH.
148pub fn run_external_tool(
149    command: &str,
150    args: &[&str],
151    working_dir: Option<&Path>,
152    timeout_secs: u32,
153) -> Result<ExternalToolResult, FormatError> {
154    let mut cmd = crate::effective_path::new_command(command);
155    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
156
157    if let Some(dir) = working_dir {
158        cmd.current_dir(dir);
159    }
160
161    isolate_in_process_group(&mut cmd);
162
163    let child = match cmd.spawn() {
164        Ok(c) => c,
165        Err(e) if e.kind() == ErrorKind::NotFound => {
166            return Err(FormatError::NotFound {
167                tool: command.to_string(),
168            });
169        }
170        Err(e) => {
171            return Err(FormatError::Failed {
172                tool: command.to_string(),
173                stderr: e.to_string(),
174            });
175        }
176    };
177
178    let outcome = wait_with_timeout(child, command, timeout_secs)?;
179    let exit_code = outcome.status.code().unwrap_or(-1);
180    if exit_code != 0 {
181        return Err(FormatError::Failed {
182            tool: command.to_string(),
183            stderr: outcome.stderr,
184        });
185    }
186
187    Ok(ExternalToolResult {
188        stdout: outcome.stdout,
189        stderr: outcome.stderr,
190        exit_code,
191        truncated: outcome.truncated,
192    })
193}
194
195const MAX_CAPTURE_BYTES: usize = 16 * 1024 * 1024;
196
197fn wait_with_timeout(
198    mut child: Child,
199    command: &str,
200    timeout_secs: u32,
201) -> Result<SubprocessOutcome, FormatError> {
202    let stdout_pipe = child.stdout.take().expect("piped stdout");
203    let stderr_pipe = child.stderr.take().expect("piped stderr");
204    let stdout_thread =
205        thread::spawn(move || read_bounded_to_string(stdout_pipe, MAX_CAPTURE_BYTES));
206    let stderr_thread =
207        thread::spawn(move || read_bounded_to_string(stderr_pipe, MAX_CAPTURE_BYTES));
208    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
209
210    loop {
211        match child.try_wait() {
212            Ok(Some(status)) => {
213                let (stdout, stdout_truncated) = stdout_thread.join().unwrap_or_default();
214                let (stderr, stderr_truncated) = stderr_thread.join().unwrap_or_default();
215                return Ok(SubprocessOutcome {
216                    stdout,
217                    stderr,
218                    status,
219                    truncated: stdout_truncated || stderr_truncated,
220                });
221            }
222            Ok(None) => {
223                if Instant::now() >= deadline {
224                    kill_process_tree(&mut child);
225                    let _ = child.wait();
226                    // Do NOT block joining the reader threads — orphaned
227                    // grandchildren may still hold the pipes open even after
228                    // the immediate child is gone. The threads will detach
229                    // and clean up when pipes finally close.
230                    return Err(FormatError::Timeout {
231                        tool: command.to_string(),
232                        timeout_secs,
233                    });
234                }
235                thread::sleep(Duration::from_millis(50));
236            }
237            Err(e) => {
238                kill_process_tree(&mut child);
239                let _ = child.wait();
240                // Same rationale as the timeout branch: don't block on join.
241                return Err(FormatError::Failed {
242                    tool: command.to_string(),
243                    stderr: format!("try_wait error: {}", e),
244                });
245            }
246        }
247    }
248}
249
250fn read_bounded_to_string<R: Read>(mut reader: R, limit: usize) -> (String, bool) {
251    let mut bytes = Vec::with_capacity(limit.min(8192));
252    let mut scratch = [0u8; 8192];
253    let mut truncated = false;
254
255    loop {
256        let read = match reader.read(&mut scratch) {
257            Ok(0) => break,
258            Ok(read) => read,
259            Err(_) => break,
260        };
261
262        let remaining = limit.saturating_sub(bytes.len());
263        if remaining > 0 {
264            let keep = remaining.min(read);
265            bytes.extend_from_slice(&scratch[..keep]);
266            if keep < read {
267                truncated = true;
268            }
269        } else {
270            truncated = true;
271        }
272    }
273
274    (String::from_utf8_lossy(&bytes).into_owned(), truncated)
275}
276
277/// TTL for tool availability and resolution cache entries.
278const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
279
280#[derive(Debug, Clone, PartialEq, Eq, Hash)]
281struct ToolCacheKey {
282    command: String,
283    project_root: PathBuf,
284}
285
286static TOOL_RESOLUTION_CACHE: std::sync::LazyLock<
287    Mutex<HashMap<ToolCacheKey, (Option<PathBuf>, Instant)>>,
288> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
289
290static TOOL_AVAILABILITY_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
291    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
292
293fn tool_cache_key(command: &str, project_root: Option<&Path>) -> ToolCacheKey {
294    ToolCacheKey {
295        command: command.to_string(),
296        project_root: project_root.map(Path::to_path_buf).unwrap_or_default(),
297    }
298}
299
300fn availability_cache_key(command: &str, project_root: Option<&Path>) -> String {
301    let root = project_root
302        .map(|path| path.to_string_lossy())
303        .unwrap_or_default();
304    format!("{}\0{}", command, root)
305}
306
307pub fn clear_tool_cache() {
308    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
309        cache.clear();
310    }
311    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
312        cache.clear();
313    }
314}
315
316/// Resolve a tool by checking node_modules/.bin relative to project_root, then PATH.
317/// Returns the full path to the tool if found, otherwise None.
318fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
319    let key = tool_cache_key(command, project_root);
320    if let Ok(cache) = TOOL_RESOLUTION_CACHE.lock() {
321        if let Some((resolved, checked_at)) = cache.get(&key) {
322            if checked_at.elapsed() < TOOL_CACHE_TTL {
323                return resolved
324                    .as_ref()
325                    .map(|path| path.to_string_lossy().to_string());
326            }
327        }
328    }
329
330    let resolved = resolve_tool_uncached(command, project_root);
331    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
332        cache.insert(key, (resolved.clone(), Instant::now()));
333    }
334    resolved.map(|path| path.to_string_lossy().to_string())
335}
336
337pub(crate) fn resolve_tool_uncached(command: &str, project_root: Option<&Path>) -> Option<PathBuf> {
338    // 1. Check node_modules/.bin/<command> relative to project root. On
339    // Windows, package managers usually create .cmd/.bat/.ps1 shims rather
340    // than extensionless executables, so probe PATHEXT-style variants too.
341    if let Some(root) = project_root {
342        let local_bin_dir = root.join("node_modules").join(".bin");
343        for local_bin in local_node_bin_candidates(&local_bin_dir, command) {
344            if local_bin.exists() {
345                return Some(local_bin);
346            }
347        }
348    }
349
350    // 2. PATH via `which` + manual walk (mirrors magic-context findOnPath).
351    if let Some(path) = crate::tool_path::resolve_on_path(command) {
352        return Some(path);
353    }
354
355    // 3. Fall back to well-known install locations the editor's PATH may
356    // not contain. GitHub issue #47: macOS GUI launches (Spotlight, Dock,
357    // Alfred) and some Linux desktop launchers drop /opt/homebrew/bin and
358    // similar from PATH, making PATH lookups fail even though the user
359    // genuinely has the tool installed. Returning the absolute path here
360    // means downstream `Command::new(resolved)` works regardless.
361    try_well_known_path_lookup(command)
362}
363
364fn local_node_bin_candidates(bin_dir: &Path, command: &str) -> Vec<PathBuf> {
365    #[cfg(windows)]
366    {
367        let command_path = Path::new(command);
368        if command_path.extension().is_some() {
369            return vec![bin_dir.join(command)];
370        }
371
372        let mut candidates = vec![bin_dir.join(command)];
373        candidates.extend(
374            windows_local_node_bin_extensions(std::env::var_os("PATHEXT").as_deref())
375                .into_iter()
376                .map(|ext| bin_dir.join(format!("{command}{ext}"))),
377        );
378        candidates
379    }
380
381    #[cfg(not(windows))]
382    {
383        vec![bin_dir.join(command)]
384    }
385}
386
387#[cfg(any(windows, test))]
388fn windows_local_node_bin_extensions(pathext: Option<&std::ffi::OsStr>) -> Vec<String> {
389    const DEFAULT_ORDER: [&str; 4] = [".cmd", ".exe", ".bat", ".ps1"];
390    let allowed: HashSet<&str> = DEFAULT_ORDER.into_iter().collect();
391
392    let mut ordered = Vec::new();
393    if let Some(pathext) = pathext.and_then(|value| value.to_str()) {
394        for ext in pathext.split(';') {
395            let normalized = ext.trim().to_ascii_lowercase();
396            if allowed.contains(normalized.as_str()) && !ordered.contains(&normalized) {
397                ordered.push(normalized);
398            }
399        }
400    }
401
402    for ext in DEFAULT_ORDER {
403        if !ordered.iter().any(|existing| existing == ext) {
404            ordered.push(ext.to_string());
405        }
406    }
407
408    ordered
409}
410
411/// Look up `command` in the well-known install locations that GUI-launched
412/// editors commonly miss from PATH. Returns the absolute path so the caller
413/// invokes the tool via `Command::new(absolute_path)` regardless of PATH.
414///
415/// Search order is built by `well_known_search_paths`:
416/// 1. `/opt/homebrew/bin` (Apple Silicon Homebrew)
417/// 2. `/usr/local/bin` (Intel Mac Homebrew + most manual Linux installs)
418/// 3. `/usr/local/go/bin` (official go.dev installer)
419/// 4. `/usr/bin` (distro-packaged tools)
420/// 5. `/snap/bin` (snap-packaged tools)
421/// 6. `$HOME/.cargo/bin` (cargo install — rustfmt, etc.)
422/// 7. `$HOME/go/bin` (`go install` default GOPATH layout)
423/// 8. `$HOME/.local/bin` (pip --user, pipx, npm prefix, many shell scripts)
424///
425/// Each candidate is verified to (a) exist as a regular file and (b) be
426/// executable; we don't spawn `--version` here because spawning an
427/// absolute-path candidate that doesn't accept `--version` would emit a
428/// false negative (and Rust's `fs::metadata` is much cheaper than a spawn).
429fn try_well_known_path_lookup(command: &str) -> Option<PathBuf> {
430    // Test-only escape hatch: integration tests that need to assert
431    // "tool not installed" semantics set AFT_DISABLE_WELL_KNOWN_LOOKUP=1
432    // so CI runners with a system tsc/biome/etc. at /usr/local/bin don't
433    // silently make those tests pass. Production callers never set this.
434    if std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP").is_some() {
435        return None;
436    }
437    if cfg!(windows) {
438        for dir in crate::tool_path::well_known_windows_bin_dirs(
439            std::env::var_os("USERPROFILE").as_deref(),
440        ) {
441            if let Some(found) = crate::tool_path::probe_tool_in_dir(&dir, command) {
442                return Some(found);
443            }
444        }
445        return None;
446    }
447    let candidates = well_known_search_paths(command, std::env::var_os("HOME").as_deref());
448    try_well_known_path_lookup_in(&candidates)
449}
450
451/// Build the candidate path list for the given command name and HOME value.
452/// Extracted so tests can drive the lookup with a controlled HOME without
453/// mutating process-global env vars.
454fn well_known_search_paths(command: &str, home: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
455    let mut candidates: Vec<PathBuf> = Vec::with_capacity(8);
456    candidates.push(PathBuf::from("/opt/homebrew/bin").join(command));
457    candidates.push(PathBuf::from("/usr/local/bin").join(command));
458    // System/distro install locations a GUI-launched editor's truncated PATH
459    // often misses. /usr/local/go/bin is where the official go.dev installer
460    // puts the Go toolchain (gofmt, go); /snap/bin and /usr/bin cover
461    // distro-packaged installs (Go from apt/snap, etc.).
462    candidates.push(PathBuf::from("/usr/local/go/bin").join(command));
463    candidates.push(PathBuf::from("/usr/bin").join(command));
464    candidates.push(PathBuf::from("/snap/bin").join(command));
465    if let Some(home) = home {
466        let home_path = PathBuf::from(home);
467        candidates.push(home_path.join(".cargo/bin").join(command));
468        candidates.push(home_path.join("go/bin").join(command));
469        candidates.push(home_path.join(".local/bin").join(command));
470    }
471    candidates
472}
473
474/// Build the candidate path list for the given command name using well-known
475/// Windows install locations. Extracted so tests can drive the lookup with a
476/// controlled USERPROFILE without mutating process-global env vars.
477///
478/// Search order:
479/// 1. `C:\Go\bin\<command>.exe` — Windows Go installer (default path)
480/// 2. `C:\Program Files\Go\bin\<command>.exe` — Windows Go installer (Program Files)
481/// 3. `%USERPROFILE%\.cargo\bin\<command>.exe` — `cargo install`
482/// 4. `%USERPROFILE%\go\bin\<command>.exe` — `go install` with default GOPATH
483///
484/// Walk a pre-built candidate list, returning the first file that exists and
485/// is executable. Extracted from `try_well_known_path_lookup` so tests can
486/// inject candidates anchored at a tempdir.
487fn try_well_known_path_lookup_in(candidates: &[PathBuf]) -> Option<PathBuf> {
488    for candidate in candidates {
489        if let Ok(metadata) = std::fs::metadata(candidate) {
490            if metadata.is_file() && is_executable(&metadata) {
491                return Some(candidate.clone());
492            }
493        }
494    }
495    None
496}
497
498#[cfg(unix)]
499fn is_executable(metadata: &std::fs::Metadata) -> bool {
500    use std::os::unix::fs::PermissionsExt;
501    metadata.permissions().mode() & 0o111 != 0
502}
503
504#[cfg(not(unix))]
505fn is_executable(_metadata: &std::fs::Metadata) -> bool {
506    // Windows: the well-known Windows paths in `try_well_known_path_lookup`
507    // construct .exe paths which are always executable (or the metadata check
508    // already filters out non-files). This stub exists for compile-time
509    // completeness on the POSIX candidate path used during non-Windows builds.
510    true
511}
512
513/// Check if `ruff format` is available with a stable formatter.
514///
515/// Ruff's formatter became stable in v0.1.2. Versions before that output
516/// `NOT_YET_IMPLEMENTED_*` stubs instead of formatted code. We parse the
517/// version from `ruff --version` (format: "ruff X.Y.Z") and require >= 0.1.2.
518/// Falls back to false if ruff is not found or version cannot be parsed.
519/// Whether a tool referenced by configure missing-tool warnings is resolvable.
520pub(crate) fn tool_available_for_missing_warning(tool: &str, project_root: Option<&Path>) -> bool {
521    if tool == "ruff" {
522        return resolve_tool_uncached("ruff", project_root).is_some()
523            && ruff_format_available(project_root);
524    }
525    resolve_tool_uncached(tool, project_root).is_some()
526}
527
528fn ruff_format_available(project_root: Option<&Path>) -> bool {
529    let key = availability_cache_key("ruff-format", project_root);
530    if let Ok(cache) = TOOL_AVAILABILITY_CACHE.lock() {
531        if let Some((available, checked_at)) = cache.get(&key) {
532            if checked_at.elapsed() < TOOL_CACHE_TTL {
533                return *available;
534            }
535        }
536    }
537
538    let result = ruff_format_available_uncached(project_root);
539    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
540        cache.insert(key, (result, Instant::now()));
541    }
542    result
543}
544
545fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
546    let command = match resolve_tool("ruff", project_root) {
547        Some(command) => command,
548        None => return false,
549    };
550    let output = match crate::effective_path::new_command(&command)
551        .arg("--version")
552        .stdout(Stdio::piped())
553        .stderr(Stdio::null())
554        .output()
555    {
556        Ok(o) => o,
557        Err(_) => return false,
558    };
559
560    let version_str = String::from_utf8_lossy(&output.stdout);
561    // Parse "ruff X.Y.Z" or just "X.Y.Z"
562    let version_part = version_str
563        .trim()
564        .strip_prefix("ruff ")
565        .unwrap_or(version_str.trim());
566
567    let parts: Vec<&str> = version_part.split('.').collect();
568    if parts.len() < 3 {
569        return false;
570    }
571
572    let major: u32 = match parts[0].parse() {
573        Ok(v) => v,
574        Err(_) => return false,
575    };
576    let minor: u32 = match parts[1].parse() {
577        Ok(v) => v,
578        Err(_) => return false,
579    };
580    let patch: u32 = match parts[2].parse() {
581        Ok(v) => v,
582        Err(_) => return false,
583    };
584
585    // Require >= 0.1.2 where ruff format became stable
586    (major, minor, patch) >= (0, 1, 2)
587}
588
589fn resolve_candidate_tool(
590    candidate: &ToolCandidate,
591    project_root: Option<&Path>,
592    require_ruff_format: bool,
593) -> Option<String> {
594    if require_ruff_format && candidate.tool == "ruff" && !ruff_format_available(project_root) {
595        return None;
596    }
597
598    resolve_tool(&candidate.tool, project_root)
599}
600
601fn lang_key(lang: LangId) -> &'static str {
602    match lang {
603        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
604        LangId::Python => "python",
605        LangId::Rust => "rust",
606        LangId::Go => "go",
607        LangId::C => "c",
608        LangId::Cpp => "cpp",
609        LangId::Zig => "zig",
610        LangId::CSharp => "csharp",
611        LangId::Bash => "bash",
612        LangId::Solidity => "solidity",
613        LangId::Scss => "scss",
614        LangId::Vue => "vue",
615        LangId::Json => "json",
616        LangId::Scala => "scala",
617        LangId::Java => "java",
618        LangId::Ruby => "ruby",
619        LangId::Kotlin => "kotlin",
620        LangId::Swift => "swift",
621        LangId::Php => "php",
622        LangId::Lua => "lua",
623        LangId::Perl => "perl",
624        LangId::Html => "html",
625        LangId::Markdown => "markdown",
626        LangId::Yaml => "yaml",
627        LangId::Pascal => "pascal",
628        LangId::R => "r",
629        LangId::Groovy => "groovy",
630        LangId::ObjC => "objc",
631    }
632}
633
634fn has_formatter_support(lang: LangId) -> bool {
635    matches!(
636        lang,
637        LangId::TypeScript
638            | LangId::JavaScript
639            | LangId::Tsx
640            | LangId::Python
641            | LangId::Rust
642            | LangId::Go
643    )
644}
645
646fn has_checker_support(lang: LangId) -> bool {
647    matches!(
648        lang,
649        LangId::TypeScript
650            | LangId::JavaScript
651            | LangId::Tsx
652            | LangId::Python
653            | LangId::Rust
654            | LangId::Go
655    )
656}
657
658fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
659    let project_root = config.project_root.as_deref();
660    if let Some(preferred) = config.formatter.get(lang_key(lang)) {
661        return explicit_formatter_candidate(preferred, file_str);
662    }
663
664    match lang {
665        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
666            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
667                vec![ToolCandidate {
668                    tool: "biome".to_string(),
669                    source: "biome.json".to_string(),
670                    args: vec![
671                        "format".to_string(),
672                        "--write".to_string(),
673                        file_str.to_string(),
674                    ],
675                    required: true,
676                }]
677            } else if has_project_config(
678                project_root,
679                &[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts"],
680            ) {
681                vec![ToolCandidate {
682                    tool: "oxfmt".to_string(),
683                    source: "oxfmt config".to_string(),
684                    args: vec!["--write".to_string(), file_str.to_string()],
685                    required: true,
686                }]
687            } else if has_project_config(
688                project_root,
689                &[
690                    ".prettierrc",
691                    ".prettierrc.json",
692                    ".prettierrc.yml",
693                    ".prettierrc.yaml",
694                    ".prettierrc.js",
695                    ".prettierrc.cjs",
696                    ".prettierrc.mjs",
697                    ".prettierrc.toml",
698                    "prettier.config.js",
699                    "prettier.config.cjs",
700                    "prettier.config.mjs",
701                ],
702            ) {
703                vec![ToolCandidate {
704                    tool: "prettier".to_string(),
705                    source: "Prettier config".to_string(),
706                    args: vec!["--write".to_string(), file_str.to_string()],
707                    required: true,
708                }]
709            } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
710                vec![ToolCandidate {
711                    tool: "deno".to_string(),
712                    source: "deno.json".to_string(),
713                    args: vec!["fmt".to_string(), file_str.to_string()],
714                    required: true,
715                }]
716            } else {
717                Vec::new()
718            }
719        }
720        LangId::Python => {
721            if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
722                || has_pyproject_tool(project_root, "ruff")
723            {
724                vec![ToolCandidate {
725                    tool: "ruff".to_string(),
726                    source: "ruff config".to_string(),
727                    args: vec!["format".to_string(), file_str.to_string()],
728                    required: true,
729                }]
730            } else if has_pyproject_tool(project_root, "black") {
731                vec![ToolCandidate {
732                    tool: "black".to_string(),
733                    source: "pyproject.toml".to_string(),
734                    args: vec![file_str.to_string()],
735                    required: true,
736                }]
737            } else {
738                Vec::new()
739            }
740        }
741        LangId::Rust => {
742            if has_project_config(project_root, &["Cargo.toml"]) {
743                vec![ToolCandidate {
744                    tool: "rustfmt".to_string(),
745                    source: "Cargo.toml".to_string(),
746                    args: vec![file_str.to_string()],
747                    required: true,
748                }]
749            } else {
750                Vec::new()
751            }
752        }
753        LangId::Go => {
754            if has_project_config(project_root, &["go.mod"]) {
755                vec![
756                    ToolCandidate {
757                        tool: "goimports".to_string(),
758                        source: "go.mod".to_string(),
759                        args: vec!["-w".to_string(), file_str.to_string()],
760                        required: false,
761                    },
762                    ToolCandidate {
763                        tool: "gofmt".to_string(),
764                        source: "go.mod".to_string(),
765                        args: vec!["-w".to_string(), file_str.to_string()],
766                        required: true,
767                    },
768                ]
769            } else {
770                Vec::new()
771            }
772        }
773        LangId::C
774        | LangId::Cpp
775        | LangId::Zig
776        | LangId::CSharp
777        | LangId::Bash
778        | LangId::Solidity
779        | LangId::Scss
780        | LangId::Vue
781        | LangId::Json
782        | LangId::Scala
783        | LangId::Java
784        | LangId::Ruby
785        | LangId::Kotlin
786        | LangId::Swift
787        | LangId::Php
788        | LangId::Lua
789        | LangId::Perl
790        | LangId::Pascal
791        | LangId::R
792        | LangId::Groovy
793        | LangId::ObjC => Vec::new(),
794        LangId::Html => Vec::new(),
795        LangId::Markdown => Vec::new(),
796        LangId::Yaml => Vec::new(),
797    }
798}
799
800fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
801    let project_root = config.project_root.as_deref();
802    if let Some(preferred) = config.checker.get(lang_key(lang)) {
803        return explicit_checker_candidate(preferred, file_str);
804    }
805
806    match lang {
807        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
808            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
809                vec![ToolCandidate {
810                    tool: "biome".to_string(),
811                    source: "biome.json".to_string(),
812                    args: vec![
813                        "check".to_string(),
814                        "--reporter=json".to_string(),
815                        file_str.to_string(),
816                    ],
817                    required: true,
818                }]
819            } else if has_project_config(project_root, &["tsconfig.json"]) {
820                vec![ToolCandidate {
821                    tool: "tsc".to_string(),
822                    source: "tsconfig.json".to_string(),
823                    args: vec![
824                        "--noEmit".to_string(),
825                        "--pretty".to_string(),
826                        "false".to_string(),
827                    ],
828                    required: true,
829                }]
830            } else {
831                Vec::new()
832            }
833        }
834        LangId::Python => {
835            if has_project_config(project_root, &["pyrightconfig.json"])
836                || has_pyproject_tool(project_root, "pyright")
837            {
838                vec![ToolCandidate {
839                    tool: "pyright".to_string(),
840                    source: "pyright config".to_string(),
841                    args: vec!["--outputjson".to_string(), file_str.to_string()],
842                    required: true,
843                }]
844            } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
845                || has_pyproject_tool(project_root, "ruff")
846            {
847                vec![ToolCandidate {
848                    tool: "ruff".to_string(),
849                    source: "ruff config".to_string(),
850                    args: vec![
851                        "check".to_string(),
852                        "--output-format=json".to_string(),
853                        file_str.to_string(),
854                    ],
855                    required: true,
856                }]
857            } else {
858                Vec::new()
859            }
860        }
861        LangId::Rust => {
862            if has_project_config(project_root, &["Cargo.toml"]) {
863                vec![ToolCandidate {
864                    tool: "cargo".to_string(),
865                    source: "Cargo.toml".to_string(),
866                    args: vec!["check".to_string(), "--message-format=json".to_string()],
867                    required: true,
868                }]
869            } else {
870                Vec::new()
871            }
872        }
873        LangId::Go => {
874            if has_project_config(project_root, &["go.mod"]) {
875                vec![
876                    ToolCandidate {
877                        tool: "staticcheck".to_string(),
878                        source: "go.mod".to_string(),
879                        args: vec!["-f".to_string(), "json".to_string(), file_str.to_string()],
880                        required: false,
881                    },
882                    ToolCandidate {
883                        tool: "go".to_string(),
884                        source: "go.mod".to_string(),
885                        args: vec!["vet".to_string(), file_str.to_string()],
886                        required: true,
887                    },
888                ]
889            } else {
890                Vec::new()
891            }
892        }
893        LangId::C
894        | LangId::Cpp
895        | LangId::Zig
896        | LangId::CSharp
897        | LangId::Bash
898        | LangId::Solidity
899        | LangId::Scss
900        | LangId::Vue
901        | LangId::Json
902        | LangId::Scala
903        | LangId::Java
904        | LangId::Ruby
905        | LangId::Kotlin
906        | LangId::Swift
907        | LangId::Php
908        | LangId::Lua
909        | LangId::Perl
910        | LangId::Pascal
911        | LangId::R
912        | LangId::Groovy
913        | LangId::ObjC => Vec::new(),
914        LangId::Html => Vec::new(),
915        LangId::Markdown => Vec::new(),
916        LangId::Yaml => Vec::new(),
917    }
918}
919
920fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
921    match name {
922        "none" | "off" | "false" => Vec::new(),
923        "biome" => vec![ToolCandidate {
924            tool: name.to_string(),
925            source: "formatter config".to_string(),
926            args: vec![
927                "format".to_string(),
928                "--write".to_string(),
929                file_str.to_string(),
930            ],
931            required: true,
932        }],
933        "oxfmt" => vec![ToolCandidate {
934            tool: name.to_string(),
935            source: "formatter config".to_string(),
936            args: vec!["--write".to_string(), file_str.to_string()],
937            required: true,
938        }],
939        "prettier" => vec![ToolCandidate {
940            tool: name.to_string(),
941            source: "formatter config".to_string(),
942            args: vec!["--write".to_string(), file_str.to_string()],
943            required: true,
944        }],
945        "deno" => vec![ToolCandidate {
946            tool: name.to_string(),
947            source: "formatter config".to_string(),
948            args: vec!["fmt".to_string(), file_str.to_string()],
949            required: true,
950        }],
951        "ruff" => vec![ToolCandidate {
952            tool: name.to_string(),
953            source: "formatter config".to_string(),
954            args: vec!["format".to_string(), file_str.to_string()],
955            required: true,
956        }],
957        "black" | "rustfmt" => vec![ToolCandidate {
958            tool: name.to_string(),
959            source: "formatter config".to_string(),
960            args: vec![file_str.to_string()],
961            required: true,
962        }],
963        "goimports" | "gofmt" => vec![ToolCandidate {
964            tool: name.to_string(),
965            source: "formatter config".to_string(),
966            args: vec!["-w".to_string(), file_str.to_string()],
967            required: true,
968        }],
969        _ => Vec::new(),
970    }
971}
972
973fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
974    match name {
975        "none" | "off" | "false" => Vec::new(),
976        "tsc" | "tsgo" => vec![ToolCandidate {
977            tool: name.to_string(),
978            source: "checker config".to_string(),
979            args: vec![
980                "--noEmit".to_string(),
981                "--pretty".to_string(),
982                "false".to_string(),
983            ],
984            required: true,
985        }],
986        "cargo" => vec![ToolCandidate {
987            tool: name.to_string(),
988            source: "checker config".to_string(),
989            args: vec!["check".to_string(), "--message-format=json".to_string()],
990            required: true,
991        }],
992        "go" => vec![ToolCandidate {
993            tool: name.to_string(),
994            source: "checker config".to_string(),
995            args: vec!["vet".to_string(), file_str.to_string()],
996            required: true,
997        }],
998        "biome" => vec![ToolCandidate {
999            tool: name.to_string(),
1000            source: "checker config".to_string(),
1001            args: vec![
1002                "check".to_string(),
1003                "--reporter=json".to_string(),
1004                file_str.to_string(),
1005            ],
1006            required: true,
1007        }],
1008        "pyright" => vec![ToolCandidate {
1009            tool: name.to_string(),
1010            source: "checker config".to_string(),
1011            args: vec!["--outputjson".to_string(), file_str.to_string()],
1012            required: true,
1013        }],
1014        "ruff" => vec![ToolCandidate {
1015            tool: name.to_string(),
1016            source: "checker config".to_string(),
1017            args: vec![
1018                "check".to_string(),
1019                "--output-format=json".to_string(),
1020                file_str.to_string(),
1021            ],
1022            required: true,
1023        }],
1024        "staticcheck" => vec![ToolCandidate {
1025            tool: name.to_string(),
1026            source: "checker config".to_string(),
1027            args: vec!["-f".to_string(), "json".to_string(), file_str.to_string()],
1028            required: true,
1029        }],
1030        _ => Vec::new(),
1031    }
1032}
1033
1034fn resolve_tool_candidates(
1035    candidates: Vec<ToolCandidate>,
1036    project_root: Option<&Path>,
1037    require_ruff_format: bool,
1038) -> ToolDetection {
1039    if candidates.is_empty() {
1040        return ToolDetection::NotConfigured;
1041    }
1042
1043    let mut missing_required = None;
1044    for candidate in candidates {
1045        if let Some(command) = resolve_candidate_tool(&candidate, project_root, require_ruff_format)
1046        {
1047            return ToolDetection::Found(command, candidate.args);
1048        }
1049        if candidate.required && missing_required.is_none() {
1050            missing_required = Some(candidate.tool);
1051        }
1052    }
1053
1054    match missing_required {
1055        Some(tool) => ToolDetection::NotInstalled { tool },
1056        None => ToolDetection::NotConfigured,
1057    }
1058}
1059
1060fn checker_command(_candidate: &ToolCandidate, resolved: String) -> String {
1061    resolved
1062}
1063
1064fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
1065    if candidate.tool == "tsc" || candidate.tool == "tsgo" {
1066        vec![
1067            "--noEmit".to_string(),
1068            "--pretty".to_string(),
1069            "false".to_string(),
1070        ]
1071    } else {
1072        candidate.args.clone()
1073    }
1074}
1075
1076fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
1077    let file_str = path.to_string_lossy().to_string();
1078    resolve_tool_candidates(
1079        formatter_candidates(lang, config, &file_str),
1080        config.project_root.as_deref(),
1081        true,
1082    )
1083}
1084
1085fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
1086    let file_str = path.to_string_lossy().to_string();
1087    let candidates = checker_candidates(lang, config, &file_str);
1088    if candidates.is_empty() {
1089        return ToolDetection::NotConfigured;
1090    }
1091
1092    let project_root = config.project_root.as_deref();
1093    let mut missing_required = None;
1094    for candidate in candidates {
1095        if let Some(command) = resolve_candidate_tool(&candidate, project_root, false) {
1096            return ToolDetection::Found(
1097                checker_command(&candidate, command),
1098                checker_args(&candidate),
1099            );
1100        }
1101        if candidate.required && missing_required.is_none() {
1102            missing_required = Some(candidate.tool);
1103        }
1104    }
1105
1106    match missing_required {
1107        Some(tool) => ToolDetection::NotInstalled { tool },
1108        None => ToolDetection::NotConfigured,
1109    }
1110}
1111
1112fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
1113    crate::callgraph::walk_project_files(project_root)
1114        .filter_map(|path| detect_language(&path))
1115        .collect()
1116}
1117
1118fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
1119    let filename = match lang {
1120        LangId::TypeScript => "aft-tool-detection.ts",
1121        LangId::Tsx => "aft-tool-detection.tsx",
1122        LangId::JavaScript => "aft-tool-detection.js",
1123        LangId::Python => "aft-tool-detection.py",
1124        LangId::Rust => "aft_tool_detection.rs",
1125        LangId::Go => "aft_tool_detection.go",
1126        LangId::C => "aft_tool_detection.c",
1127        LangId::Cpp => "aft_tool_detection.cpp",
1128        LangId::Zig => "aft_tool_detection.zig",
1129        LangId::CSharp => "aft_tool_detection.cs",
1130        LangId::Bash => "aft_tool_detection.sh",
1131        LangId::Solidity => "aft_tool_detection.sol",
1132        LangId::Scss => "aft-tool-detection.scss",
1133        LangId::Vue => "aft-tool-detection.vue",
1134        LangId::Json => "aft-tool-detection.json",
1135        LangId::Scala => "aft-tool-detection.scala",
1136        LangId::Java => "aft-tool-detection.java",
1137        LangId::Ruby => "aft-tool-detection.rb",
1138        LangId::Kotlin => "aft-tool-detection.kt",
1139        LangId::Swift => "aft-tool-detection.swift",
1140        LangId::Php => "aft-tool-detection.php",
1141        LangId::Lua => "aft-tool-detection.lua",
1142        LangId::Perl => "aft-tool-detection.pl",
1143        LangId::Html => "aft-tool-detection.html",
1144        LangId::Markdown => "aft-tool-detection.md",
1145        LangId::Yaml => "aft-tool-detection.yaml",
1146        LangId::Pascal => "aft-tool-detection.pas",
1147        LangId::R => "aft-tool-detection.R",
1148        LangId::Groovy => "aft-tool-detection.groovy",
1149        LangId::ObjC => "aft-tool-detection.m",
1150    };
1151    project_root.join(filename)
1152}
1153
1154pub(crate) fn install_hint(tool: &str) -> String {
1155    match tool {
1156        "biome" => {
1157            "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
1158        }
1159        "oxfmt" => "Run `npm install -D oxfmt` or install globally.".to_string(),
1160        "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
1161        "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
1162        "tsgo" => {
1163            "Run `npm install -D @typescript/native-preview` or install globally.".to_string()
1164        }
1165        "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
1166        "ruff" => {
1167            "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
1168        }
1169        "black" => {
1170            "Install: `pip install black` or your Python package manager equivalent.".to_string()
1171        }
1172        "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
1173        "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
1174        "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
1175        "go" => if cfg!(windows) {
1176            "Install Go from https://go.dev/dl/. Common install paths:\
1177                 C:\\Go\\bin, C:\\Program Files\\Go\\bin. \
1178                 GUI-launched editors often don't inherit login-shell PATH."
1179        } else {
1180            "Install Go from https://go.dev/dl/, or — if it's already installed —\
1181                 ensure its bin directory is on PATH (Homebrew typically uses\
1182                 /opt/homebrew/bin on Apple Silicon, /usr/local/bin on Intel macOS).\
1183                 GUI-launched editors often don't inherit login-shell PATH."
1184        }
1185        .to_string(),
1186        "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
1187        "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
1188        "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
1189        "typescript-language-server" => {
1190            "Install: `npm install -g typescript-language-server typescript`".to_string()
1191        }
1192        "deno" => "Install Deno from https://deno.com/.".to_string(),
1193        "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
1194        "staticcheck" => {
1195            "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
1196        }
1197        other => format!("Install `{other}` and ensure it is on PATH."),
1198    }
1199}
1200
1201fn configured_tool_hint(tool: &str, source: &str) -> String {
1202    // GitHub issue #47: editors launched from a non-login GUI shell (Spotlight,
1203    // Dock, Alfred, etc.) often don't inherit the user's full PATH, so a tool
1204    // that's installed but lives under /opt/homebrew/bin, ~/.cargo/bin, or
1205    // similar can fail this lookup. We already check those well-known
1206    // locations in `resolve_tool_uncached`; if we still didn't find the tool,
1207    // it's genuinely missing OR sits in an unusual install prefix.
1208    //
1209    // Word the message so users know to check both "is it installed at all"
1210    // and "is it on AFT's PATH" — rather than implying definite absence.
1211    format!(
1212        "{tool} is configured in {source} but was not found on PATH or in common install locations. {}",
1213        install_hint(tool)
1214    )
1215}
1216
1217fn missing_tool_warning(
1218    kind: &str,
1219    language: &str,
1220    candidate: &ToolCandidate,
1221    project_root: Option<&Path>,
1222    require_ruff_format: bool,
1223) -> Option<MissingTool> {
1224    if !candidate.required
1225        || resolve_candidate_tool(candidate, project_root, require_ruff_format).is_some()
1226    {
1227        return None;
1228    }
1229
1230    Some(MissingTool {
1231        kind: kind.to_string(),
1232        language: language.to_string(),
1233        tool: candidate.tool.clone(),
1234        hint: configured_tool_hint(&candidate.tool, &candidate.source),
1235    })
1236}
1237
1238/// Detect configured formatters/checkers that are missing for languages present in the project.
1239pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
1240    let languages = languages_in_project(project_root);
1241    let mut warnings = Vec::new();
1242    let mut seen = HashSet::new();
1243
1244    for lang in languages {
1245        let language = lang_key(lang);
1246        let placeholder = placeholder_file_for_language(project_root, lang);
1247        let file_str = placeholder.to_string_lossy().to_string();
1248
1249        for candidate in formatter_candidates(lang, config, &file_str) {
1250            if let Some(warning) = missing_tool_warning(
1251                "formatter_not_installed",
1252                language,
1253                &candidate,
1254                config.project_root.as_deref(),
1255                true,
1256            ) {
1257                if seen.insert((
1258                    warning.kind.clone(),
1259                    warning.language.clone(),
1260                    warning.tool.clone(),
1261                )) {
1262                    warnings.push(warning);
1263                }
1264            }
1265        }
1266
1267        for candidate in checker_candidates(lang, config, &file_str) {
1268            if let Some(warning) = missing_tool_warning(
1269                "checker_not_installed",
1270                language,
1271                &candidate,
1272                config.project_root.as_deref(),
1273                false,
1274            ) {
1275                if seen.insert((
1276                    warning.kind.clone(),
1277                    warning.language.clone(),
1278                    warning.tool.clone(),
1279                )) {
1280                    warnings.push(warning);
1281                }
1282            }
1283        }
1284    }
1285
1286    warnings.sort_by(|left, right| {
1287        (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1288    });
1289    warnings
1290}
1291
1292/// Detect the appropriate formatter command and arguments for a file.
1293///
1294/// Priority per language:
1295/// - TypeScript/JavaScript/TSX: `prettier --write <file>`
1296/// - Python: `ruff format <file>` (fallback: `black <file>`)
1297/// - Rust: `rustfmt <file>`
1298/// - Go: `gofmt -w <file>`
1299///
1300/// Returns `None` if no formatter is available for the language.
1301pub fn detect_formatter(
1302    path: &Path,
1303    lang: LangId,
1304    config: &Config,
1305) -> Option<(String, Vec<String>)> {
1306    match detect_formatter_for_path(path, lang, config) {
1307        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1308        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1309    }
1310}
1311
1312/// Check if any of the given config file names exist in the project root.
1313fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1314    let root = match project_root {
1315        Some(r) => r,
1316        None => return false,
1317    };
1318    filenames.iter().any(|f| root.join(f).exists())
1319}
1320
1321/// Check if pyproject.toml exists and contains a `[tool.<name>]` section.
1322fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1323    let root = match project_root {
1324        Some(r) => r,
1325        None => return false,
1326    };
1327    let pyproject = root.join("pyproject.toml");
1328    if !pyproject.exists() {
1329        return false;
1330    }
1331    match std::fs::read_to_string(&pyproject) {
1332        Ok(content) => {
1333            let pattern = format!("[tool.{}]", tool_name);
1334            content.contains(&pattern)
1335        }
1336        Err(_) => false,
1337    }
1338}
1339
1340/// Detect whether a non-zero formatter exit was caused by the formatter
1341/// intentionally excluding the path (per its own config) rather than an
1342/// actual formatter or input error.
1343///
1344/// The patterns below come from real stderr output observed during
1345/// dogfooding. They're intentionally substring-based and case-insensitive
1346/// so minor formatter version differences in wording don't bypass the
1347/// check. Each pattern corresponds to a specific formatter's exclusion
1348/// signal:
1349/// - biome: `"No files were processed in the specified paths."`,
1350///   `"ignored by the configuration"`
1351/// - oxfmt: `"Expected at least one target file"`,
1352///   `"No files found matching the given patterns"`
1353/// - prettier: `"No files matching the pattern were found"`
1354/// - ruff: `"No Python files found under the given path(s)"`
1355///
1356/// rustfmt and gofmt/goimports rarely scope-restrict and have no known
1357/// stable marker, so they're not detected here. They'll fall through to
1358/// the generic `"error"` reason — acceptable because they almost never
1359/// emit a path-exclusion exit in practice.
1360fn formatter_excluded_path(stderr: &str) -> bool {
1361    let s = stderr.to_lowercase();
1362    s.contains("no files were processed")
1363        || s.contains("ignored by the configuration")
1364        || s.contains("expected at least one target file")
1365        || s.contains("no files found matching the given patterns")
1366        || s.contains("no files matching the pattern")
1367        || s.contains("no python files found")
1368}
1369
1370/// Auto-format a file using the detected formatter for its language.
1371///
1372/// Returns `(formatted, skip_reason)`:
1373/// - `(true, None)` — file was successfully formatted
1374/// - `(false, Some(reason))` — formatting was skipped, reason explains why
1375///
1376/// Skip reasons:
1377/// - `"unsupported_language"` — language has no formatter support in AFT
1378/// - `"no_formatter_configured"` — `format_on_edit=false` or no formatter
1379///   detected for the language in the project
1380/// - `"formatter_not_installed"` — configured formatter binary missing on
1381///   PATH and not in project's `node_modules/.bin`
1382/// - `"formatter_excluded_path"` — formatter ran but refused to process this
1383///   path because the project formatter config (e.g. biome.json `files.includes`,
1384///   prettier `.prettierignore`) excludes it. NOT an error in AFT or the user's
1385///   formatter — the user told the formatter not to touch this path. Agents
1386///   should treat this as informational.
1387/// - `"timeout"` — formatter exceeded `formatter_timeout_secs`
1388/// - `"error"` — formatter exited non-zero with an unrecognized error
1389///   (likely a real bug in the user's input or the formatter itself)
1390pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1391    // Check if formatting is disabled via plugin config
1392    if !config.format_on_edit {
1393        return (false, Some("no_formatter_configured".to_string()));
1394    }
1395
1396    let lang = match detect_language(path) {
1397        Some(l) => l,
1398        None => {
1399            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1400            return (false, Some("unsupported_language".to_string()));
1401        }
1402    };
1403    if !has_formatter_support(lang) {
1404        log::debug!("format: {} (skipped: unsupported_language)", path.display());
1405        return (false, Some("unsupported_language".to_string()));
1406    }
1407
1408    let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1409        ToolDetection::Found(cmd, args) => (cmd, args),
1410        ToolDetection::NotConfigured => {
1411            log::debug!(
1412                "format: {} (skipped: no_formatter_configured)",
1413                path.display()
1414            );
1415            return (false, Some("no_formatter_configured".to_string()));
1416        }
1417        ToolDetection::NotInstalled { tool } => {
1418            crate::slog_warn!(
1419                "format: {} (skipped: formatter_not_installed: {})",
1420                path.display(),
1421                tool
1422            );
1423            return (false, Some("formatter_not_installed".to_string()));
1424        }
1425    };
1426
1427    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1428
1429    // Run the formatter in the project root so tool-local config files
1430    // (biome.json, .prettierrc, rustfmt.toml, etc.) are discovered. The
1431    // type-checker path (`validate_full`) already does this via
1432    // `path.parent()`; formatters need the same treatment. Without it,
1433    // formatters silently fall back to built-in defaults when the aft
1434    // process CWD differs from the project root.
1435    let working_dir = config.project_root.as_deref();
1436
1437    match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1438        Ok(_) => {
1439            crate::slog_info!("format: {} ({})", path.display(), cmd);
1440            (true, None)
1441        }
1442        Err(FormatError::Timeout { .. }) => {
1443            crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1444            (false, Some("timeout".to_string()))
1445        }
1446        Err(FormatError::NotFound { .. }) => {
1447            crate::slog_warn!(
1448                "format: {} (skipped: formatter_not_installed)",
1449                path.display()
1450            );
1451            (false, Some("formatter_not_installed".to_string()))
1452        }
1453        Err(FormatError::Failed { stderr, .. }) => {
1454            // Distinguish "formatter intentionally ignored this path" from
1455            // "formatter actually errored". Many formatters scope themselves
1456            // to a project subtree (biome.json `files.includes`, prettier
1457            // `.prettierignore`, ruff `[tool.ruff]` config) and exit non-zero
1458            // when invoked on a path outside that scope. From AFT's perspective
1459            // that's not an error — the user told the formatter not to touch
1460            // this path. But the previous code returned a generic `"error"`
1461            // skip reason and logged at `debug` (silent under default
1462            // RUST_LOG=info), so the agent had no signal that the file
1463            // landed unformatted. Detect the common stderr fingerprints and
1464            // return a distinct, surfaced skip reason.
1465            if formatter_excluded_path(&stderr) {
1466                crate::slog_info!(
1467                    "format: {} (skipped: formatter_excluded_path; stderr: {})",
1468                    path.display(),
1469                    stderr.lines().next().unwrap_or("").trim()
1470                );
1471                return (false, Some("formatter_excluded_path".to_string()));
1472            }
1473            crate::slog_warn!(
1474                "format: {} (skipped: error: {})",
1475                path.display(),
1476                stderr.lines().next().unwrap_or("unknown").trim()
1477            );
1478            (false, Some("error".to_string()))
1479        }
1480        Err(FormatError::UnsupportedLanguage) => {
1481            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1482            (false, Some("unsupported_language".to_string()))
1483        }
1484    }
1485}
1486
1487/// Spawn a subprocess and capture output regardless of exit code.
1488///
1489/// Unlike `run_external_tool`, this does NOT treat non-zero exit as an error —
1490/// type checkers return non-zero when they find issues, which is expected.
1491/// Returns `FormatError::NotFound` when the binary isn't on PATH, and
1492/// `FormatError::Timeout` if the deadline is exceeded.
1493pub fn run_external_tool_capture(
1494    command: &str,
1495    args: &[&str],
1496    working_dir: Option<&Path>,
1497    timeout_secs: u32,
1498) -> Result<ExternalToolResult, FormatError> {
1499    let mut cmd = crate::effective_path::new_command(command);
1500    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1501
1502    if let Some(dir) = working_dir {
1503        cmd.current_dir(dir);
1504    }
1505
1506    isolate_in_process_group(&mut cmd);
1507
1508    let child = match cmd.spawn() {
1509        Ok(c) => c,
1510        Err(e) if e.kind() == ErrorKind::NotFound => {
1511            return Err(FormatError::NotFound {
1512                tool: command.to_string(),
1513            });
1514        }
1515        Err(e) => {
1516            return Err(FormatError::Failed {
1517                tool: command.to_string(),
1518                stderr: e.to_string(),
1519            });
1520        }
1521    };
1522
1523    let outcome = wait_with_timeout(child, command, timeout_secs)?;
1524    Ok(ExternalToolResult {
1525        stdout: outcome.stdout,
1526        stderr: outcome.stderr,
1527        exit_code: outcome.status.code().unwrap_or(-1),
1528        truncated: outcome.truncated,
1529    })
1530}
1531
1532// ============================================================================
1533// Type-checker validation (R017)
1534// ============================================================================
1535
1536/// A structured error from a type checker.
1537#[derive(Debug, Clone, serde::Serialize)]
1538pub struct ValidationError {
1539    pub line: u32,
1540    pub column: u32,
1541    pub message: String,
1542    pub severity: String,
1543}
1544
1545/// Detect the appropriate type checker command and arguments for a file.
1546///
1547/// Returns `(command, args)` for the type checker. The `--noEmit` / equivalent
1548/// flags ensure no output files are produced.
1549///
1550/// Supported:
1551/// - TypeScript/JavaScript/TSX → `tsc --noEmit` (or `tsgo --noEmit` when explicitly configured)
1552/// - Python → `pyright`
1553/// - Rust → `cargo check`
1554/// - Go → `go vet`
1555pub fn detect_type_checker(
1556    path: &Path,
1557    lang: LangId,
1558    config: &Config,
1559) -> Option<(String, Vec<String>)> {
1560    match detect_checker_for_path(path, lang, config) {
1561        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1562        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1563    }
1564}
1565
1566/// Parse type checker output into structured validation errors.
1567///
1568/// Handles output formats from tsc, pyright (JSON), cargo check (JSON), and go vet.
1569/// Filters to errors related to the edited file where feasible.
1570pub fn parse_checker_output(
1571    stdout: &str,
1572    stderr: &str,
1573    file: &Path,
1574    checker: &str,
1575) -> Vec<ValidationError> {
1576    let checker_name = checker_executable_name(checker);
1577    match checker_name.as_str() {
1578        "npx" | "tsc" | "tsgo" => parse_tsc_output(stdout, stderr, file),
1579        "biome" => parse_biome_output(stdout, stderr, file),
1580        "pyright" => parse_pyright_output(stdout, file),
1581        "ruff" => parse_ruff_output(stdout, stderr, file),
1582        "cargo" => parse_cargo_output(stdout, stderr, file),
1583        "go" => parse_go_vet_output(stderr, file),
1584        "staticcheck" => parse_staticcheck_output(stdout, stderr, file),
1585        _ => Vec::new(),
1586    }
1587}
1588
1589fn checker_executable_name(checker: &str) -> String {
1590    let name = checker
1591        .rsplit(['/', '\\'])
1592        .next()
1593        .filter(|name| !name.is_empty())
1594        .unwrap_or(checker)
1595        .to_ascii_lowercase();
1596
1597    for suffix in [".exe", ".cmd", ".bat", ".ps1"] {
1598        if let Some(stripped) = name.strip_suffix(suffix) {
1599            return stripped.to_string();
1600        }
1601    }
1602
1603    name
1604}
1605
1606fn normalize_path_for_compare(path: &str) -> String {
1607    path.trim_start_matches("file://")
1608        .replace('\\', "/")
1609        .trim_start_matches("./")
1610        .to_string()
1611}
1612
1613fn diagnostic_path_matches(file: &Path, diagnostic_file: &str) -> bool {
1614    if diagnostic_file.is_empty() {
1615        return true;
1616    }
1617
1618    let file_str = normalize_path_for_compare(&file.to_string_lossy());
1619    let diagnostic_str = normalize_path_for_compare(diagnostic_file);
1620    file_str == diagnostic_str
1621        || file_str.ends_with(&diagnostic_str)
1622        || diagnostic_str.ends_with(&file_str)
1623}
1624
1625fn line_column_for_byte_offset(source: &str, offset: usize) -> (u32, u32) {
1626    let mut line = 1u32;
1627    let mut column = 1u32;
1628    for (idx, ch) in source.char_indices() {
1629        if idx >= offset {
1630            break;
1631        }
1632        if ch == '\n' {
1633            line += 1;
1634            column = 1;
1635        } else {
1636            column += 1;
1637        }
1638    }
1639    (line, column)
1640}
1641
1642fn json_string_at<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a str> {
1643    let mut current = value;
1644    for key in path {
1645        current = current.get(*key)?;
1646    }
1647    current.as_str()
1648}
1649
1650fn json_u32_at(value: &serde_json::Value, path: &[&str]) -> Option<u32> {
1651    let mut current = value;
1652    for key in path {
1653        current = current.get(*key)?;
1654    }
1655    current.as_u64().map(|n| n as u32)
1656}
1657
1658fn json_location_path(value: &serde_json::Value) -> Option<&str> {
1659    json_string_at(value, &["location", "path", "file"])
1660        .or_else(|| json_string_at(value, &["location", "path"]))
1661        .or_else(|| json_string_at(value, &["filename"]))
1662        .or_else(|| json_string_at(value, &["file"]))
1663}
1664
1665fn diagnostic_message(value: &serde_json::Value) -> String {
1666    json_string_at(value, &["description"])
1667        .or_else(|| json_string_at(value, &["message"]))
1668        .or_else(|| json_string_at(value, &["text"]))
1669        .or_else(|| json_string_at(value, &["category"]))
1670        .unwrap_or("unknown error")
1671        .to_string()
1672}
1673
1674/// Parse tsc output lines like: `path(line,col): error TSxxxx: message`
1675fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1676    let mut errors = Vec::new();
1677    let file_str = file.to_string_lossy();
1678    // tsc writes diagnostics to stdout (with --pretty false)
1679    let combined = format!("{}{}", stdout, stderr);
1680    for line in combined.lines() {
1681        // Format: path(line,col): severity TSxxxx: message
1682        // or: path(line,col): severity: message
1683        if let Some((loc, rest)) = line.split_once("): ") {
1684            // Check if this error is for our file (compare filename part)
1685            let file_part = loc.split('(').next().unwrap_or("");
1686            if !file_str.ends_with(file_part)
1687                && !file_part.ends_with(&*file_str)
1688                && file_part != &*file_str
1689            {
1690                continue;
1691            }
1692
1693            // Parse (line,col) from the location part
1694            let coords = loc.split('(').last().unwrap_or("");
1695            let parts: Vec<&str> = coords.split(',').collect();
1696            let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1697            let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1698
1699            // Parse severity and message
1700            let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1701                ("error".to_string(), msg.to_string())
1702            } else if let Some(msg) = rest.strip_prefix("warning ") {
1703                ("warning".to_string(), msg.to_string())
1704            } else {
1705                ("error".to_string(), rest.to_string())
1706            };
1707
1708            errors.push(ValidationError {
1709                line: line_num,
1710                column: col_num,
1711                message,
1712                severity,
1713            });
1714        }
1715    }
1716    errors
1717}
1718
1719fn parse_biome_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1720    let mut errors = Vec::new();
1721    for output in [stdout, stderr] {
1722        let trimmed = output.trim();
1723        if trimmed.is_empty() {
1724            continue;
1725        }
1726        if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
1727            parse_biome_json_value(&json, file, &mut errors);
1728        }
1729    }
1730    errors
1731}
1732
1733fn parse_biome_json_value(
1734    json: &serde_json::Value,
1735    file: &Path,
1736    errors: &mut Vec<ValidationError>,
1737) {
1738    let diagnostics: Vec<&serde_json::Value> = if let Some(diags) = json
1739        .get("diagnostics")
1740        .and_then(|diagnostics| diagnostics.as_array())
1741    {
1742        diags.iter().collect()
1743    } else if let Some(diags) = json.as_array() {
1744        diags.iter().collect()
1745    } else {
1746        Vec::new()
1747    };
1748
1749    let source = std::fs::read_to_string(file).ok();
1750    for diag in diagnostics {
1751        if let Some(diag_file) = json_location_path(diag) {
1752            if !diagnostic_path_matches(file, diag_file) {
1753                continue;
1754            }
1755        }
1756
1757        let (line, column) = biome_line_column(diag, source.as_deref());
1758        errors.push(ValidationError {
1759            line,
1760            column,
1761            message: diagnostic_message(diag),
1762            severity: diag
1763                .get("severity")
1764                .and_then(|severity| severity.as_str())
1765                .unwrap_or("error")
1766                .to_lowercase(),
1767        });
1768    }
1769}
1770
1771fn biome_line_column(diag: &serde_json::Value, source: Option<&str>) -> (u32, u32) {
1772    if let Some(line) =
1773        json_u32_at(diag, &["location", "line"]).or_else(|| json_u32_at(diag, &["line"]))
1774    {
1775        let column = json_u32_at(diag, &["location", "column"])
1776            .or_else(|| json_u32_at(diag, &["column"]))
1777            .unwrap_or(0);
1778        return (line, column);
1779    }
1780
1781    let offset = diag
1782        .get("location")
1783        .and_then(|location| location.get("span"))
1784        .and_then(|span| span.as_array())
1785        .and_then(|span| span.first())
1786        .and_then(|offset| offset.as_u64())
1787        .map(|offset| offset as usize);
1788
1789    match (source, offset) {
1790        (Some(source), Some(offset)) => line_column_for_byte_offset(source, offset),
1791        _ => (0, 0),
1792    }
1793}
1794
1795fn parse_ruff_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1796    let mut errors = Vec::new();
1797    for output in [stdout, stderr] {
1798        let trimmed = output.trim();
1799        if trimmed.is_empty() {
1800            continue;
1801        }
1802        if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
1803            parse_ruff_json_value(&json, file, &mut errors);
1804        }
1805    }
1806    errors
1807}
1808
1809fn parse_ruff_json_value(json: &serde_json::Value, file: &Path, errors: &mut Vec<ValidationError>) {
1810    let diagnostics: Vec<&serde_json::Value> = if let Some(diags) = json.as_array() {
1811        diags.iter().collect()
1812    } else if let Some(diags) = json.get("diagnostics").and_then(|d| d.as_array()) {
1813        diags.iter().collect()
1814    } else {
1815        Vec::new()
1816    };
1817
1818    for diag in diagnostics {
1819        let diag_file = diag
1820            .get("filename")
1821            .and_then(|filename| filename.as_str())
1822            .unwrap_or("");
1823        if !diagnostic_path_matches(file, diag_file) {
1824            continue;
1825        }
1826
1827        let message = match (
1828            diag.get("code").and_then(|code| code.as_str()),
1829            diag.get("message").and_then(|message| message.as_str()),
1830        ) {
1831            (Some(code), Some(message)) => format!("{code}: {message}"),
1832            (None, Some(message)) => message.to_string(),
1833            (Some(code), None) => code.to_string(),
1834            (None, None) => "unknown error".to_string(),
1835        };
1836
1837        errors.push(ValidationError {
1838            line: json_u32_at(diag, &["location", "row"])
1839                .or_else(|| json_u32_at(diag, &["location", "line"]))
1840                .unwrap_or(0),
1841            column: json_u32_at(diag, &["location", "column"]).unwrap_or(0),
1842            message,
1843            severity: diag
1844                .get("severity")
1845                .and_then(|severity| severity.as_str())
1846                .unwrap_or("error")
1847                .to_lowercase(),
1848        });
1849    }
1850}
1851
1852/// Parse pyright JSON output.
1853fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1854    let mut errors = Vec::new();
1855    // pyright --outputjson emits JSON with generalDiagnostics array
1856    if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1857        if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1858            for diag in diags {
1859                // Filter to our file
1860                let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1861                if !diagnostic_path_matches(file, diag_file) {
1862                    continue;
1863                }
1864
1865                let line_num = diag
1866                    .get("range")
1867                    .and_then(|r| r.get("start"))
1868                    .and_then(|s| s.get("line"))
1869                    .and_then(|l| l.as_u64())
1870                    .unwrap_or(0) as u32;
1871                let col_num = diag
1872                    .get("range")
1873                    .and_then(|r| r.get("start"))
1874                    .and_then(|s| s.get("character"))
1875                    .and_then(|c| c.as_u64())
1876                    .unwrap_or(0) as u32;
1877                let message = diag
1878                    .get("message")
1879                    .and_then(|m| m.as_str())
1880                    .unwrap_or("unknown error")
1881                    .to_string();
1882                let severity = diag
1883                    .get("severity")
1884                    .and_then(|s| s.as_str())
1885                    .unwrap_or("error")
1886                    .to_lowercase();
1887
1888                errors.push(ValidationError {
1889                    line: line_num + 1,  // pyright uses 0-indexed lines
1890                    column: col_num + 1, // pyright uses 0-indexed columns
1891                    message,
1892                    severity,
1893                });
1894            }
1895        }
1896    }
1897    errors
1898}
1899
1900/// Parse cargo check JSON output, filtering to errors in the target file.
1901fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1902    let mut errors = Vec::new();
1903    let file_str = file.to_string_lossy();
1904
1905    for line in stdout.lines() {
1906        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1907            if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1908                continue;
1909            }
1910            let message_obj = match msg.get("message") {
1911                Some(m) => m,
1912                None => continue,
1913            };
1914
1915            let level = message_obj
1916                .get("level")
1917                .and_then(|l| l.as_str())
1918                .unwrap_or("error");
1919
1920            // Only include errors and warnings, skip notes/help
1921            if level != "error" && level != "warning" {
1922                continue;
1923            }
1924
1925            let text = message_obj
1926                .get("message")
1927                .and_then(|m| m.as_str())
1928                .unwrap_or("unknown error")
1929                .to_string();
1930
1931            // Find the primary span for our file
1932            if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1933                for span in spans {
1934                    let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1935                    let is_primary = span
1936                        .get("is_primary")
1937                        .and_then(|p| p.as_bool())
1938                        .unwrap_or(false);
1939
1940                    if !is_primary {
1941                        continue;
1942                    }
1943
1944                    // Filter to our file
1945                    if !file_str.ends_with(span_file)
1946                        && !span_file.ends_with(&*file_str)
1947                        && span_file != &*file_str
1948                    {
1949                        continue;
1950                    }
1951
1952                    let line_num =
1953                        span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1954                    let col_num = span
1955                        .get("column_start")
1956                        .and_then(|c| c.as_u64())
1957                        .unwrap_or(0) as u32;
1958
1959                    errors.push(ValidationError {
1960                        line: line_num,
1961                        column: col_num,
1962                        message: text.clone(),
1963                        severity: level.to_string(),
1964                    });
1965                }
1966            }
1967        }
1968    }
1969    errors
1970}
1971
1972/// Parse go vet output lines like: `path:line:col: message`
1973fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1974    let mut errors = Vec::new();
1975    let pattern =
1976        regex::Regex::new(r"^(?P<file>.+?):(?P<line>\d+)(?::(?P<col>\d+))?:\s*(?P<message>.*)$")
1977            .expect("valid go vet diagnostic regex");
1978
1979    for line in stderr.lines() {
1980        let Some(captures) = pattern.captures(line) else {
1981            continue;
1982        };
1983
1984        let err_file = captures
1985            .name("file")
1986            .map(|m| m.as_str())
1987            .unwrap_or("")
1988            .trim();
1989        if !diagnostic_path_matches(file, err_file) {
1990            continue;
1991        }
1992
1993        errors.push(ValidationError {
1994            line: captures
1995                .name("line")
1996                .and_then(|m| m.as_str().parse().ok())
1997                .unwrap_or(0),
1998            column: captures
1999                .name("col")
2000                .and_then(|m| m.as_str().parse().ok())
2001                .unwrap_or(0),
2002            message: captures
2003                .name("message")
2004                .map(|m| m.as_str().trim().to_string())
2005                .unwrap_or_else(|| "unknown error".to_string()),
2006            severity: "error".to_string(),
2007        });
2008    }
2009    errors
2010}
2011
2012fn parse_staticcheck_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
2013    let combined = format!("{}\n{}", stdout, stderr);
2014    let trimmed = combined.trim();
2015    if trimmed.is_empty() {
2016        return Vec::new();
2017    }
2018
2019    let mut errors = Vec::new();
2020    if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
2021        parse_staticcheck_json_value(&json, file, &mut errors);
2022        return errors;
2023    }
2024
2025    for line in trimmed.lines() {
2026        let line = line.trim();
2027        if line.is_empty() {
2028            continue;
2029        }
2030        if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
2031            parse_staticcheck_json_value(&json, file, &mut errors);
2032        }
2033    }
2034
2035    errors
2036}
2037
2038fn parse_staticcheck_json_value(
2039    json: &serde_json::Value,
2040    file: &Path,
2041    errors: &mut Vec<ValidationError>,
2042) {
2043    if let Some(diags) = json.as_array() {
2044        for diag in diags {
2045            parse_staticcheck_diag(diag, file, errors);
2046        }
2047    } else if let Some(diags) = json.get("diagnostics").and_then(|d| d.as_array()) {
2048        for diag in diags {
2049            parse_staticcheck_diag(diag, file, errors);
2050        }
2051    } else if let Some(diags) = json.get("issues").and_then(|d| d.as_array()) {
2052        for diag in diags {
2053            parse_staticcheck_diag(diag, file, errors);
2054        }
2055    } else {
2056        parse_staticcheck_diag(json, file, errors);
2057    }
2058}
2059
2060fn parse_staticcheck_diag(
2061    diag: &serde_json::Value,
2062    file: &Path,
2063    errors: &mut Vec<ValidationError>,
2064) {
2065    let diag_file = json_string_at(diag, &["location", "file"])
2066        .or_else(|| json_string_at(diag, &["file"]))
2067        .unwrap_or("");
2068    if !diagnostic_path_matches(file, diag_file) {
2069        return;
2070    }
2071
2072    let message = match (
2073        diag.get("code").and_then(|code| code.as_str()),
2074        diag.get("message").and_then(|message| message.as_str()),
2075    ) {
2076        (Some(code), Some(message)) => format!("{code}: {message}"),
2077        (None, Some(message)) => message.to_string(),
2078        (Some(code), None) => code.to_string(),
2079        (None, None) => "unknown error".to_string(),
2080    };
2081
2082    errors.push(ValidationError {
2083        line: json_u32_at(diag, &["location", "line"])
2084            .or_else(|| json_u32_at(diag, &["line"]))
2085            .unwrap_or(0),
2086        column: json_u32_at(diag, &["location", "column"])
2087            .or_else(|| json_u32_at(diag, &["column"]))
2088            .unwrap_or(0),
2089        message,
2090        severity: diag
2091            .get("severity")
2092            .and_then(|severity| severity.as_str())
2093            .unwrap_or("error")
2094            .to_lowercase(),
2095    });
2096}
2097
2098fn output_tail_summary(stdout: &str, stderr: &str, truncated: bool) -> String {
2099    let mut parts = Vec::new();
2100    if let Some(tail) = short_output_tail(stderr) {
2101        parts.push(format!("stderr: {tail}"));
2102    }
2103    if let Some(tail) = short_output_tail(stdout) {
2104        parts.push(format!("stdout: {tail}"));
2105    }
2106    if truncated {
2107        parts.push("output truncated".to_string());
2108    }
2109
2110    if parts.is_empty() {
2111        "no output".to_string()
2112    } else {
2113        parts.join("; ")
2114    }
2115}
2116
2117fn short_output_tail(output: &str) -> Option<String> {
2118    let trimmed = output.trim();
2119    if trimmed.is_empty() {
2120        return None;
2121    }
2122
2123    let mut lines: Vec<&str> = trimmed.lines().rev().take(3).collect();
2124    lines.reverse();
2125    let mut tail = lines.join(" | ");
2126    const MAX_TAIL_CHARS: usize = 500;
2127    if tail.len() > MAX_TAIL_CHARS {
2128        let start = tail.len().saturating_sub(MAX_TAIL_CHARS);
2129        tail = format!("…{}", &tail[start..]);
2130    }
2131    Some(tail)
2132}
2133
2134/// Run the project's type checker and return structured validation errors.
2135///
2136/// Returns `(errors, skip_reason)`:
2137/// - `(errors, None)` — checker ran, errors may be empty (= valid code)
2138/// - `([], Some(reason))` — checker was skipped
2139///
2140/// Skip reasons: `"unsupported_language"`, `"no_checker_configured"`,
2141/// `"checker_not_installed"`, `"timeout"`, `"error"`
2142pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
2143    let lang = match detect_language(path) {
2144        Some(l) => l,
2145        None => {
2146            log::debug!(
2147                "validate: {} (skipped: unsupported_language)",
2148                path.display()
2149            );
2150            return (Vec::new(), Some("unsupported_language".to_string()));
2151        }
2152    };
2153    if !has_checker_support(lang) {
2154        log::debug!(
2155            "validate: {} (skipped: unsupported_language)",
2156            path.display()
2157        );
2158        return (Vec::new(), Some("unsupported_language".to_string()));
2159    }
2160
2161    let (cmd, args) = match detect_checker_for_path(path, lang, config) {
2162        ToolDetection::Found(cmd, args) => (cmd, args),
2163        ToolDetection::NotConfigured => {
2164            log::debug!(
2165                "validate: {} (skipped: no_checker_configured)",
2166                path.display()
2167            );
2168            return (Vec::new(), Some("no_checker_configured".to_string()));
2169        }
2170        ToolDetection::NotInstalled { tool } => {
2171            crate::slog_warn!(
2172                "validate: {} (skipped: checker_not_installed: {})",
2173                path.display(),
2174                tool
2175            );
2176            return (Vec::new(), Some("checker_not_installed".to_string()));
2177        }
2178    };
2179
2180    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
2181
2182    // Type checkers may need to run from the project root
2183    let working_dir = config.project_root.as_deref();
2184
2185    match run_external_tool_capture(
2186        &cmd,
2187        &arg_refs,
2188        working_dir,
2189        config.type_checker_timeout_secs,
2190    ) {
2191        Ok(result) => {
2192            let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
2193            if result.exit_code != 0 && errors.is_empty() {
2194                let summary = output_tail_summary(&result.stdout, &result.stderr, result.truncated);
2195                log::debug!(
2196                    "validate: {} (skipped: error: checker exited {} with {})",
2197                    path.display(),
2198                    result.exit_code,
2199                    summary
2200                );
2201                return (Vec::new(), Some("error".to_string()));
2202            }
2203            log::debug!(
2204                "validate: {} ({}, {} errors)",
2205                path.display(),
2206                cmd,
2207                errors.len()
2208            );
2209            (errors, None)
2210        }
2211        Err(FormatError::Timeout { .. }) => {
2212            crate::slog_error!("validate: {} (skipped: timeout)", path.display());
2213            (Vec::new(), Some("timeout".to_string()))
2214        }
2215        Err(FormatError::NotFound { .. }) => {
2216            crate::slog_warn!(
2217                "validate: {} (skipped: checker_not_installed)",
2218                path.display()
2219            );
2220            (Vec::new(), Some("checker_not_installed".to_string()))
2221        }
2222        Err(FormatError::Failed { stderr, .. }) => {
2223            log::debug!(
2224                "validate: {} (skipped: error: {})",
2225                path.display(),
2226                stderr.lines().next().unwrap_or("unknown")
2227            );
2228            (Vec::new(), Some("error".to_string()))
2229        }
2230        Err(FormatError::UnsupportedLanguage) => {
2231            log::debug!(
2232                "validate: {} (skipped: unsupported_language)",
2233                path.display()
2234            );
2235            (Vec::new(), Some("unsupported_language".to_string()))
2236        }
2237    }
2238}
2239
2240#[cfg(test)]
2241mod tests {
2242    use super::*;
2243    use std::fs;
2244    use std::io::Write;
2245    use std::sync::{Mutex, MutexGuard, OnceLock};
2246
2247    /// Serializes tests that mutate the global TOOL_RESOLUTION_CACHE /
2248    /// TOOL_AVAILABILITY_CACHE. Cargo runs tests in parallel by default, and
2249    /// `clear_tool_cache()` from one test would otherwise wipe cached entries
2250    /// that another test had just written, causing flaky CI failures (the
2251    /// `resolve_tool_caches_negative_result_until_clear` failure on Linux
2252    /// runners had exactly this shape).
2253    fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
2254        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2255        let mutex = LOCK.get_or_init(|| Mutex::new(()));
2256        // Recover from poisoning so a panic in one test doesn't permanently
2257        // wedge the rest of the suite.
2258        match mutex.lock() {
2259            Ok(guard) => guard,
2260            Err(poisoned) => poisoned.into_inner(),
2261        }
2262    }
2263
2264    #[test]
2265    fn run_external_tool_not_found() {
2266        let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
2267        assert!(result.is_err());
2268        match result.unwrap_err() {
2269            FormatError::NotFound { tool } => {
2270                assert_eq!(tool, "__nonexistent_tool_xyz__");
2271            }
2272            other => panic!("expected NotFound, got: {:?}", other),
2273        }
2274    }
2275
2276    #[test]
2277    fn run_external_tool_timeout_kills_subprocess() {
2278        // Use a native long-running command on each platform so the test reaches the
2279        // timeout mechanism instead of depending on Unix utilities being on Windows PATH.
2280        let (tool, args): (&str, &[&str]) = if cfg!(windows) {
2281            ("ping", &["-n", "60", "127.0.0.1"])
2282        } else {
2283            ("sleep", &["60"])
2284        };
2285        let result = run_external_tool(tool, args, None, 1);
2286        assert!(result.is_err());
2287        match result.unwrap_err() {
2288            FormatError::Timeout { tool, timeout_secs } => {
2289                assert_eq!(tool, if cfg!(windows) { "ping" } else { "sleep" });
2290                assert_eq!(timeout_secs, 1);
2291            }
2292            other => panic!("expected Timeout, got: {:?}", other),
2293        }
2294    }
2295
2296    #[test]
2297    fn run_external_tool_success() {
2298        // `echo` is a cmd builtin rather than an executable on Windows.
2299        let (tool, args): (&str, &[&str]) = if cfg!(windows) {
2300            ("cmd", &["/C", "echo hello"])
2301        } else {
2302            ("echo", &["hello"])
2303        };
2304        let result = run_external_tool(tool, args, None, 5);
2305        assert!(result.is_ok());
2306        let res = result.unwrap();
2307        assert_eq!(res.exit_code, 0);
2308        assert!(res.stdout.contains("hello"));
2309    }
2310
2311    #[cfg(unix)]
2312    #[test]
2313    fn format_helper_handles_large_stderr_without_deadlock() {
2314        let start = Instant::now();
2315        let result = run_external_tool_capture(
2316            "sh",
2317            &[
2318                "-c",
2319                "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
2320            ],
2321            None,
2322            2,
2323        )
2324        .expect("large stderr command should complete");
2325
2326        assert_eq!(result.exit_code, 0);
2327        assert!(
2328            result.stderr.len() >= 1024 * 1024,
2329            "expected full stderr capture, got {} bytes",
2330            result.stderr.len()
2331        );
2332        assert!(start.elapsed() < Duration::from_secs(2));
2333    }
2334
2335    #[test]
2336    fn run_external_tool_nonzero_exit() {
2337        // Use cmd's explicit exit on Windows because the Unix `false` utility is absent.
2338        let (tool, args): (&str, &[&str]) = if cfg!(windows) {
2339            ("cmd", &["/C", "exit /b 1"])
2340        } else {
2341            ("false", &[])
2342        };
2343        let result = run_external_tool(tool, args, None, 5);
2344        assert!(result.is_err());
2345        match result.unwrap_err() {
2346            FormatError::Failed { tool, .. } => {
2347                assert_eq!(tool, if cfg!(windows) { "cmd" } else { "false" });
2348            }
2349            other => panic!("expected Failed, got: {:?}", other),
2350        }
2351    }
2352
2353    #[test]
2354    fn auto_format_unsupported_language() {
2355        let dir = tempfile::tempdir().unwrap();
2356        let path = dir.path().join("file.txt");
2357        fs::write(&path, "hello").unwrap();
2358
2359        // format_on_edit defaults to false; opt in so we reach the
2360        // language-detection path this test asserts.
2361        let config = Config {
2362            format_on_edit: true,
2363            ..Config::default()
2364        };
2365        let (formatted, reason) = auto_format(&path, &config);
2366        assert!(!formatted);
2367        assert_eq!(reason.as_deref(), Some("unsupported_language"));
2368    }
2369
2370    #[test]
2371    fn detect_formatter_rust_when_rustfmt_available() {
2372        let dir = tempfile::tempdir().unwrap();
2373        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2374        let path = dir.path().join("test.rs");
2375        let config = Config {
2376            project_root: Some(dir.path().to_path_buf()),
2377            ..Config::default()
2378        };
2379        let result = detect_formatter(&path, LangId::Rust, &config);
2380        if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
2381            let (cmd, args) = result.unwrap();
2382            // Windows resolves to `rustfmt.exe` and may include a full path
2383            // (e.g. `C:\Users\...\.cargo\bin\rustfmt.exe`). Just require the
2384            // command stem to be `rustfmt`.
2385            let stem = std::path::Path::new(&cmd)
2386                .file_stem()
2387                .and_then(|s| s.to_str())
2388                .unwrap_or("");
2389            assert_eq!(stem, "rustfmt", "expected rustfmt, got {cmd}");
2390            assert!(args.iter().any(|a| a.ends_with("test.rs")));
2391        } else {
2392            assert!(result.is_none());
2393        }
2394    }
2395
2396    #[test]
2397    fn detect_formatter_go_mapping() {
2398        let dir = tempfile::tempdir().unwrap();
2399        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2400        let path = dir.path().join("main.go");
2401        let config = Config {
2402            project_root: Some(dir.path().to_path_buf()),
2403            ..Config::default()
2404        };
2405        let result = detect_formatter(&path, LangId::Go, &config);
2406        if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
2407            let (cmd, args) = result.unwrap();
2408            assert_eq!(
2409                std::path::Path::new(&cmd)
2410                    .file_stem()
2411                    .and_then(|s| s.to_str())
2412                    .unwrap_or(""),
2413                "goimports",
2414                "expected goimports, got {cmd}"
2415            );
2416            assert!(args.contains(&"-w".to_string()));
2417        } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
2418            let (cmd, args) = result.unwrap();
2419            assert_eq!(
2420                std::path::Path::new(&cmd)
2421                    .file_stem()
2422                    .and_then(|s| s.to_str())
2423                    .unwrap_or(""),
2424                "gofmt",
2425                "expected gofmt, got {cmd}"
2426            );
2427            assert!(args.contains(&"-w".to_string()));
2428        } else {
2429            assert!(result.is_none());
2430        }
2431    }
2432
2433    #[test]
2434    fn detect_formatter_python_mapping() {
2435        let dir = tempfile::tempdir().unwrap();
2436        fs::write(dir.path().join("ruff.toml"), "").unwrap();
2437        let path = dir.path().join("main.py");
2438        let config = Config {
2439            project_root: Some(dir.path().to_path_buf()),
2440            ..Config::default()
2441        };
2442        let result = detect_formatter(&path, LangId::Python, &config);
2443        if ruff_format_available(config.project_root.as_deref()) {
2444            let (cmd, args) = result.unwrap();
2445            assert_eq!(
2446                std::path::Path::new(&cmd)
2447                    .file_stem()
2448                    .and_then(|s| s.to_str())
2449                    .unwrap_or(""),
2450                "ruff",
2451                "expected ruff, got {cmd}"
2452            );
2453            assert!(args.contains(&"format".to_string()));
2454        } else {
2455            assert!(result.is_none());
2456        }
2457    }
2458
2459    #[test]
2460    fn detect_formatter_no_config_returns_none() {
2461        let path = Path::new("test.ts");
2462        let result = detect_formatter(path, LangId::TypeScript, &Config::default());
2463        assert!(
2464            result.is_none(),
2465            "expected no formatter without project config"
2466        );
2467    }
2468
2469    #[cfg(unix)]
2470    #[test]
2471    fn detect_formatter_oxfmt_config_for_typescript_projects() {
2472        let _guard = tool_cache_test_lock();
2473        clear_tool_cache();
2474        let dir = tempfile::tempdir().unwrap();
2475        fs::write(dir.path().join(".oxfmtrc.json"), "{}\n").unwrap();
2476        let bin_dir = dir.path().join("node_modules").join(".bin");
2477        fs::create_dir_all(&bin_dir).unwrap();
2478        let fake = bin_dir.join("oxfmt");
2479        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2480        use std::os::unix::fs::PermissionsExt;
2481        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2482
2483        let path = dir.path().join("src/app.ts");
2484        let config = Config {
2485            project_root: Some(dir.path().to_path_buf()),
2486            ..Config::default()
2487        };
2488
2489        let (cmd, args) = detect_formatter(&path, LangId::TypeScript, &config).unwrap();
2490        assert!(cmd.ends_with("oxfmt"), "expected oxfmt, got {cmd}");
2491        assert_eq!(args[0], "--write");
2492        assert!(args.iter().any(|arg| arg.ends_with("src/app.ts")));
2493    }
2494
2495    // Unix-only: `resolve_tool_uncached` checks `node_modules/.bin/<name>`
2496    // without trying Windows extensions (.cmd/.exe/.bat). Writing
2497    // `biome.cmd` would not be found by the resolver. A future product
2498    // fix could extend resolve_tool to honor PATHEXT; for now this test
2499    // focuses on the explicit-override semantics on Unix.
2500    #[cfg(unix)]
2501    #[test]
2502    fn detect_formatter_explicit_override() {
2503        // Create a temp dir with a fake node_modules/.bin/biome so resolve_tool finds it
2504        let dir = tempfile::tempdir().unwrap();
2505        let bin_dir = dir.path().join("node_modules").join(".bin");
2506        fs::create_dir_all(&bin_dir).unwrap();
2507        use std::os::unix::fs::PermissionsExt;
2508        let fake = bin_dir.join("biome");
2509        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2510        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2511
2512        let path = Path::new("test.ts");
2513        let mut config = Config {
2514            project_root: Some(dir.path().to_path_buf()),
2515            ..Config::default()
2516        };
2517        config
2518            .formatter
2519            .insert("typescript".to_string(), "biome".to_string());
2520        let result = detect_formatter(path, LangId::TypeScript, &config);
2521        let (cmd, args) = result.unwrap();
2522        assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
2523        assert!(args.contains(&"format".to_string()));
2524        assert!(args.contains(&"--write".to_string()));
2525    }
2526
2527    #[cfg(unix)]
2528    #[test]
2529    fn detect_formatter_explicit_oxfmt_override() {
2530        let _guard = tool_cache_test_lock();
2531        clear_tool_cache();
2532        let dir = tempfile::tempdir().unwrap();
2533        let bin_dir = dir.path().join("node_modules").join(".bin");
2534        fs::create_dir_all(&bin_dir).unwrap();
2535        use std::os::unix::fs::PermissionsExt;
2536        let fake = bin_dir.join("oxfmt");
2537        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2538        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2539
2540        let path = Path::new("test.ts");
2541        let mut config = Config {
2542            project_root: Some(dir.path().to_path_buf()),
2543            ..Config::default()
2544        };
2545        config
2546            .formatter
2547            .insert("typescript".to_string(), "oxfmt".to_string());
2548
2549        let (cmd, args) = detect_formatter(path, LangId::TypeScript, &config).unwrap();
2550        assert!(cmd.contains("oxfmt"), "expected oxfmt in cmd, got: {cmd}");
2551        assert_eq!(args, vec!["--write".to_string(), "test.ts".to_string()]);
2552    }
2553
2554    #[test]
2555    fn resolve_tool_caches_positive_result_until_clear() {
2556        let _guard = tool_cache_test_lock();
2557        clear_tool_cache();
2558        let dir = tempfile::tempdir().unwrap();
2559        let bin_dir = dir.path().join("node_modules").join(".bin");
2560        fs::create_dir_all(&bin_dir).unwrap();
2561        let tool = bin_dir.join("aft-cache-hit-tool");
2562        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2563
2564        let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2565        assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
2566
2567        fs::remove_file(&tool).unwrap();
2568        let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2569        assert_eq!(cached, first);
2570
2571        clear_tool_cache();
2572        assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
2573    }
2574
2575    #[test]
2576    fn resolve_tool_caches_negative_result_until_clear() {
2577        let _guard = tool_cache_test_lock();
2578        clear_tool_cache();
2579        let dir = tempfile::tempdir().unwrap();
2580        let bin_dir = dir.path().join("node_modules").join(".bin");
2581        let tool = bin_dir.join("aft-cache-miss-tool");
2582
2583        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2584
2585        fs::create_dir_all(&bin_dir).unwrap();
2586        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2587        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2588
2589        clear_tool_cache();
2590        assert_eq!(
2591            resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
2592            Some(tool.to_string_lossy().as_ref())
2593        );
2594    }
2595
2596    #[test]
2597    fn auto_format_happy_path_rustfmt() {
2598        if resolve_tool("rustfmt", None).is_none() {
2599            crate::slog_warn!("skipping: rustfmt not available");
2600            return;
2601        }
2602
2603        let dir = tempfile::tempdir().unwrap();
2604        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2605        let path = dir.path().join("test.rs");
2606
2607        let mut f = fs::File::create(&path).unwrap();
2608        writeln!(f, "fn    main()   {{  println!(\"hello\");  }}").unwrap();
2609        drop(f);
2610
2611        let config = Config {
2612            project_root: Some(dir.path().to_path_buf()),
2613            format_on_edit: true,
2614            ..Config::default()
2615        };
2616        let (formatted, reason) = auto_format(&path, &config);
2617        assert!(formatted, "expected formatting to succeed");
2618        assert!(reason.is_none());
2619
2620        let content = fs::read_to_string(&path).unwrap();
2621        assert!(
2622            !content.contains("fn    main"),
2623            "expected rustfmt to fix spacing"
2624        );
2625    }
2626
2627    #[test]
2628    fn formatter_excluded_path_detects_biome_messages() {
2629        // Real biome 1.x output when invoked on a path outside files.includes.
2630        let stderr = "format ━━━━━━━━━━━━━━━━━\n\n  × No files were processed in the specified paths.\n\n  i Check your biome.json or biome.jsonc to ensure the paths are not ignored by the configuration.\n";
2631        assert!(
2632            formatter_excluded_path(stderr),
2633            "expected biome exclusion stderr to be detected"
2634        );
2635    }
2636
2637    #[test]
2638    fn formatter_excluded_path_detects_prettier_messages() {
2639        // Real prettier output when given a glob/path that resolves to nothing
2640        // it's allowed to format (after .prettierignore filtering).
2641        let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
2642        assert!(
2643            formatter_excluded_path(stderr),
2644            "expected prettier exclusion stderr to be detected"
2645        );
2646    }
2647
2648    #[test]
2649    fn formatter_excluded_path_detects_oxfmt_messages() {
2650        assert!(formatter_excluded_path(
2651            "Expected at least one target file. All matched files may have been excluded by ignore rules."
2652        ));
2653        assert!(formatter_excluded_path(
2654            "No files found matching the given patterns."
2655        ));
2656    }
2657
2658    #[test]
2659    fn formatter_excluded_path_detects_ruff_messages() {
2660        // Real ruff output when invoked outside its [tool.ruff] scope.
2661        let stderr = "warning: No Python files found under the given path(s).\n";
2662        assert!(
2663            formatter_excluded_path(stderr),
2664            "expected ruff exclusion stderr to be detected"
2665        );
2666    }
2667
2668    #[test]
2669    fn formatter_excluded_path_is_case_insensitive() {
2670        assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
2671        assert!(formatter_excluded_path("Ignored By The Configuration"));
2672        assert!(formatter_excluded_path("EXPECTED AT LEAST ONE TARGET FILE"));
2673    }
2674
2675    #[test]
2676    fn formatter_excluded_path_rejects_real_errors() {
2677        // Counter-cases: actual formatter errors must NOT be treated as
2678        // exclusion. This guards against the detection being too greedy.
2679        assert!(!formatter_excluded_path(""));
2680        assert!(!formatter_excluded_path("syntax error: unexpected token"));
2681        assert!(!formatter_excluded_path("formatter crashed: out of memory"));
2682        assert!(!formatter_excluded_path(
2683            "permission denied: /readonly/file"
2684        ));
2685        assert!(!formatter_excluded_path(
2686            "biome internal error: please report"
2687        ));
2688    }
2689
2690    #[test]
2691    fn parse_tsc_output_basic() {
2692        let stdout = "src/app.ts(10,5): error TS2322: Type 'string' is not assignable to type 'number'.\nsrc/app.ts(20,1): error TS2304: Cannot find name 'foo'.\n";
2693        let file = Path::new("src/app.ts");
2694        let errors = parse_tsc_output(stdout, "", file);
2695        assert_eq!(errors.len(), 2);
2696        assert_eq!(errors[0].line, 10);
2697        assert_eq!(errors[0].column, 5);
2698        assert_eq!(errors[0].severity, "error");
2699        assert!(errors[0].message.contains("TS2322"));
2700        assert_eq!(errors[1].line, 20);
2701    }
2702
2703    #[test]
2704    fn parse_tsc_output_filters_other_files() {
2705        let stdout =
2706            "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
2707        let file = Path::new("src/app.ts");
2708        let errors = parse_tsc_output(stdout, "", file);
2709        assert_eq!(errors.len(), 1);
2710        assert_eq!(errors[0].line, 5);
2711    }
2712
2713    #[test]
2714    fn parse_cargo_output_basic() {
2715        let json_line = r#"{"reason":"compiler-message","message":{"level":"error","message":"mismatched types","spans":[{"file_name":"src/main.rs","line_start":10,"column_start":5,"is_primary":true}]}}"#;
2716        let file = Path::new("src/main.rs");
2717        let errors = parse_cargo_output(json_line, "", file);
2718        assert_eq!(errors.len(), 1);
2719        assert_eq!(errors[0].line, 10);
2720        assert_eq!(errors[0].column, 5);
2721        assert_eq!(errors[0].severity, "error");
2722        assert!(errors[0].message.contains("mismatched types"));
2723    }
2724
2725    #[test]
2726    fn parse_cargo_output_skips_notes() {
2727        // Notes and help messages should be filtered out
2728        let json_line = r#"{"reason":"compiler-message","message":{"level":"note","message":"expected this","spans":[{"file_name":"src/main.rs","line_start":10,"column_start":5,"is_primary":true}]}}"#;
2729        let file = Path::new("src/main.rs");
2730        let errors = parse_cargo_output(json_line, "", file);
2731        assert_eq!(errors.len(), 0);
2732    }
2733
2734    #[test]
2735    fn parse_cargo_output_filters_other_files() {
2736        let json_line = r#"{"reason":"compiler-message","message":{"level":"error","message":"err","spans":[{"file_name":"src/other.rs","line_start":1,"column_start":1,"is_primary":true}]}}"#;
2737        let file = Path::new("src/main.rs");
2738        let errors = parse_cargo_output(json_line, "", file);
2739        assert_eq!(errors.len(), 0);
2740    }
2741
2742    #[test]
2743    fn parse_go_vet_output_basic() {
2744        let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2745        let file = Path::new("main.go");
2746        let errors = parse_go_vet_output(stderr, file);
2747        assert_eq!(errors.len(), 2);
2748        assert_eq!(errors[0].line, 10);
2749        assert_eq!(errors[0].column, 5);
2750        assert!(errors[0].message.contains("unreachable code"));
2751        assert_eq!(errors[1].line, 20);
2752        assert_eq!(errors[1].column, 0);
2753    }
2754
2755    #[test]
2756    fn parse_pyright_output_basic() {
2757        let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2758        let file = Path::new("test.py");
2759        let errors = parse_pyright_output(stdout, file);
2760        assert_eq!(errors.len(), 1);
2761        assert_eq!(errors[0].line, 5); // 0-indexed → 1-indexed
2762        assert_eq!(errors[0].column, 11);
2763        assert_eq!(errors[0].severity, "error");
2764        assert!(errors[0].message.contains("Type error here"));
2765    }
2766
2767    #[test]
2768    fn validate_full_unsupported_language() {
2769        let dir = tempfile::tempdir().unwrap();
2770        let path = dir.path().join("file.txt");
2771        fs::write(&path, "hello").unwrap();
2772
2773        let config = Config::default();
2774        let (errors, reason) = validate_full(&path, &config);
2775        assert!(errors.is_empty());
2776        assert_eq!(reason.as_deref(), Some("unsupported_language"));
2777    }
2778
2779    #[test]
2780    fn detect_type_checker_rust() {
2781        let dir = tempfile::tempdir().unwrap();
2782        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2783        let path = dir.path().join("src/main.rs");
2784        let config = Config {
2785            project_root: Some(dir.path().to_path_buf()),
2786            ..Config::default()
2787        };
2788        let result = detect_type_checker(&path, LangId::Rust, &config);
2789        if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2790            let (cmd, args) = result.unwrap();
2791            assert_eq!(
2792                std::path::Path::new(&cmd)
2793                    .file_stem()
2794                    .and_then(|s| s.to_str())
2795                    .unwrap_or(""),
2796                "cargo",
2797                "expected cargo, got {cmd}"
2798            );
2799            assert!(args.contains(&"check".to_string()));
2800        } else {
2801            assert!(result.is_none());
2802        }
2803    }
2804
2805    #[test]
2806    fn detect_type_checker_go() {
2807        let dir = tempfile::tempdir().unwrap();
2808        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2809        let path = dir.path().join("main.go");
2810        let config = Config {
2811            project_root: Some(dir.path().to_path_buf()),
2812            ..Config::default()
2813        };
2814        let result = detect_type_checker(&path, LangId::Go, &config);
2815        if resolve_tool("go", config.project_root.as_deref()).is_some() {
2816            let (cmd, _args) = result.unwrap();
2817            // Resolved paths may be absolute after PATH / well-known lookup.
2818            let name = checker_executable_name(&cmd);
2819            assert!(
2820                name == "go" || name == "staticcheck",
2821                "expected go or staticcheck, got {cmd}"
2822            );
2823        } else {
2824            assert!(result.is_none());
2825        }
2826    }
2827
2828    #[cfg(unix)]
2829    #[test]
2830    fn detect_type_checker_defaults_to_tsc_for_typescript() {
2831        let _guard = tool_cache_test_lock();
2832        clear_tool_cache();
2833        let dir = tempfile::tempdir().unwrap();
2834        fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2835        let bin_dir = dir.path().join("node_modules").join(".bin");
2836        fs::create_dir_all(&bin_dir).unwrap();
2837        use std::os::unix::fs::PermissionsExt;
2838        let fake_tsc = bin_dir.join("tsc");
2839        fs::write(&fake_tsc, "#!/bin/sh\nexit 0").unwrap();
2840        fs::set_permissions(&fake_tsc, fs::Permissions::from_mode(0o755)).unwrap();
2841        let fake_tsgo = bin_dir.join("tsgo");
2842        fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2843        fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2844
2845        let path = dir.path().join("src/app.ts");
2846        let config = Config {
2847            project_root: Some(dir.path().to_path_buf()),
2848            ..Config::default()
2849        };
2850
2851        let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2852        assert!(cmd.ends_with("tsc"), "expected tsc by default, got: {cmd}");
2853        assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2854    }
2855
2856    #[cfg(unix)]
2857    #[test]
2858    fn detect_type_checker_uses_tsgo_when_explicitly_configured() {
2859        let _guard = tool_cache_test_lock();
2860        clear_tool_cache();
2861        let dir = tempfile::tempdir().unwrap();
2862        fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2863        let bin_dir = dir.path().join("node_modules").join(".bin");
2864        fs::create_dir_all(&bin_dir).unwrap();
2865        use std::os::unix::fs::PermissionsExt;
2866        let fake_tsgo = bin_dir.join("tsgo");
2867        fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2868        fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2869
2870        let path = dir.path().join("src/app.ts");
2871        let mut config = Config {
2872            project_root: Some(dir.path().to_path_buf()),
2873            ..Config::default()
2874        };
2875        config
2876            .checker
2877            .insert("typescript".to_string(), "tsgo".to_string());
2878
2879        let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2880        assert!(cmd.ends_with("tsgo"), "expected tsgo, got: {cmd}");
2881        assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2882    }
2883
2884    #[cfg(unix)]
2885    #[test]
2886    fn validate_full_explicit_tsgo_parses_diagnostics() {
2887        let _guard = tool_cache_test_lock();
2888        clear_tool_cache();
2889        let dir = tempfile::tempdir().unwrap();
2890        fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2891        let src_dir = dir.path().join("src");
2892        fs::create_dir_all(&src_dir).unwrap();
2893        let path = src_dir.join("app.ts");
2894        fs::write(&path, "const value: number = 'nope';\n").unwrap();
2895
2896        let bin_dir = dir.path().join("node_modules").join(".bin");
2897        fs::create_dir_all(&bin_dir).unwrap();
2898        use std::os::unix::fs::PermissionsExt;
2899        let fake_tsgo = bin_dir.join("tsgo");
2900        fs::write(
2901            &fake_tsgo,
2902            "#!/bin/sh\nif [ \"$1 $2 $3\" != \"--noEmit --pretty false\" ]; then echo \"bad args: $*\" >&2; exit 3; fi\nprintf '%s\n' \"src/app.ts(1,23): error TS2322: Type 'string' is not assignable to type 'number'.\"\nexit 2\n",
2903        )
2904        .unwrap();
2905        fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2906        // Pay the macOS first-exec assessment for the fresh shim in setup:
2907        // under a busy syspolicyd it can exceed the checker timeout inside
2908        // validate_full and fail this test as a spurious "timeout" skip.
2909        let _ = std::process::Command::new(&fake_tsgo)
2910            .stdout(std::process::Stdio::null())
2911            .stderr(std::process::Stdio::null())
2912            .status();
2913
2914        let mut config = Config {
2915            project_root: Some(dir.path().to_path_buf()),
2916            ..Config::default()
2917        };
2918        config
2919            .checker
2920            .insert("typescript".to_string(), "tsgo".to_string());
2921
2922        let (errors, reason) = validate_full(&path, &config);
2923        assert_eq!(reason, None);
2924        assert_eq!(errors.len(), 1);
2925        assert_eq!(errors[0].line, 1);
2926        assert_eq!(errors[0].column, 23);
2927        assert!(errors[0].message.contains("TS2322"));
2928    }
2929
2930    #[test]
2931    fn run_external_tool_capture_nonzero_not_error() {
2932        // Use a native non-zero command on each platform so this remains a capture test.
2933        let (tool, args): (&str, &[&str]) = if cfg!(windows) {
2934            ("cmd", &["/C", "exit /b 1"])
2935        } else {
2936            ("false", &[])
2937        };
2938        let result = run_external_tool_capture(tool, args, None, 5);
2939        assert!(result.is_ok(), "capture should not error on non-zero exit");
2940        assert_eq!(result.unwrap().exit_code, 1);
2941    }
2942
2943    #[test]
2944    fn run_external_tool_capture_not_found() {
2945        let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2946        assert!(result.is_err());
2947        match result.unwrap_err() {
2948            FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2949            other => panic!("expected NotFound, got: {:?}", other),
2950        }
2951    }
2952
2953    // GitHub issue #47: GUI-launched editors miss /opt/homebrew/bin etc. from
2954    // PATH. `try_well_known_path_lookup` should find the tool at well-known
2955    // install locations even when PATH wouldn't.
2956    #[cfg(unix)]
2957    #[test]
2958    fn well_known_search_paths_include_homebrew_cargo_go_and_local() {
2959        let home = std::ffi::OsString::from("/Users/test-home");
2960        let paths = well_known_search_paths("toolx", Some(&home));
2961        let strs: Vec<String> = paths
2962            .iter()
2963            .map(|p| p.to_string_lossy().into_owned())
2964            .collect();
2965        // Order matters: Homebrew prefixes come first so an installed-via-brew
2966        // tool wins over a HOME-rooted shim.
2967        assert_eq!(strs[0], "/opt/homebrew/bin/toolx");
2968        assert_eq!(strs[1], "/usr/local/bin/toolx");
2969        assert_eq!(strs[2], "/usr/local/go/bin/toolx");
2970        assert_eq!(strs[3], "/usr/bin/toolx");
2971        assert_eq!(strs[4], "/snap/bin/toolx");
2972        assert_eq!(strs[5], "/Users/test-home/.cargo/bin/toolx");
2973        assert_eq!(strs[6], "/Users/test-home/go/bin/toolx");
2974        assert_eq!(strs[7], "/Users/test-home/.local/bin/toolx");
2975        assert_eq!(strs.len(), 8);
2976    }
2977
2978    #[cfg(unix)]
2979    #[test]
2980    fn well_known_search_paths_skips_home_when_unset() {
2981        let paths = well_known_search_paths("toolx", None);
2982        assert_eq!(paths.len(), 5);
2983        assert!(paths[0].ends_with("opt/homebrew/bin/toolx"));
2984        assert!(paths[1].ends_with("usr/local/bin/toolx"));
2985        assert!(paths[2].ends_with("usr/local/go/bin/toolx"));
2986        assert!(paths[3].ends_with("usr/bin/toolx"));
2987        assert!(paths[4].ends_with("snap/bin/toolx"));
2988    }
2989
2990    #[cfg(unix)]
2991    #[test]
2992    fn try_well_known_path_lookup_in_finds_executable_file() {
2993        use std::os::unix::fs::PermissionsExt;
2994        let dir = tempfile::tempdir().unwrap();
2995        let bin_dir = dir.path().join("bin");
2996        fs::create_dir_all(&bin_dir).unwrap();
2997        let tool_path = bin_dir.join("toolx");
2998        fs::write(&tool_path, "#!/bin/sh\necho test").unwrap();
2999        let mut perms = fs::metadata(&tool_path).unwrap().permissions();
3000        perms.set_mode(0o755);
3001        fs::set_permissions(&tool_path, perms).unwrap();
3002
3003        let candidates = vec![
3004            dir.path().join("missing/toolx"),
3005            tool_path.clone(),
3006            dir.path().join("alt/toolx"),
3007        ];
3008        let found = try_well_known_path_lookup_in(&candidates);
3009        assert_eq!(found, Some(tool_path));
3010    }
3011
3012    #[cfg(unix)]
3013    #[test]
3014    fn try_well_known_path_lookup_in_skips_non_executable_file() {
3015        let dir = tempfile::tempdir().unwrap();
3016        let bin_dir = dir.path().join("bin");
3017        fs::create_dir_all(&bin_dir).unwrap();
3018        // File exists but is not marked executable (default 0o644 on most umasks).
3019        let tool_path = bin_dir.join("toolx");
3020        fs::write(&tool_path, "not a real tool").unwrap();
3021
3022        let found = try_well_known_path_lookup_in(&std::slice::from_ref(&tool_path));
3023        assert!(found.is_none(), "non-executable file should be skipped");
3024    }
3025
3026    #[cfg(unix)]
3027    #[test]
3028    fn try_well_known_path_lookup_in_skips_directories_and_missing_paths() {
3029        let dir = tempfile::tempdir().unwrap();
3030        // A directory at the expected path should not count as a tool.
3031        let candidates = vec![dir.path().to_path_buf(), dir.path().join("does-not-exist")];
3032        assert!(try_well_known_path_lookup_in(&candidates).is_none());
3033    }
3034
3035    #[cfg(windows)]
3036    #[test]
3037    fn try_well_known_path_lookup_finds_npm_global_shim() {
3038        let dir = tempfile::tempdir().unwrap();
3039        let npm_bin = dir.path().join("npm");
3040        fs::create_dir_all(&npm_bin).unwrap();
3041        let shim = npm_bin.join("biome.cmd");
3042        fs::write(&shim, "@echo off\n").unwrap();
3043
3044        let saved_disable = std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP");
3045        std::env::remove_var("AFT_DISABLE_WELL_KNOWN_LOOKUP");
3046        let saved_appdata = std::env::var_os("APPDATA");
3047        std::env::set_var("APPDATA", dir.path());
3048
3049        let found = try_well_known_path_lookup("biome");
3050
3051        if let Some(value) = saved_appdata {
3052            std::env::set_var("APPDATA", value);
3053        } else {
3054            std::env::remove_var("APPDATA");
3055        }
3056        if let Some(value) = saved_disable {
3057            std::env::set_var("AFT_DISABLE_WELL_KNOWN_LOOKUP", value);
3058        }
3059
3060        assert_eq!(found.as_deref(), Some(shim.as_path()));
3061    }
3062
3063    // GitHub issue #47: wording must not claim "but not installed" — the tool
3064    // may be installed but missing from AFT's PATH (GUI-launched editor).
3065    #[test]
3066    fn configured_tool_hint_does_not_claim_not_installed() {
3067        let hint = configured_tool_hint("biome", "biome.json");
3068        assert!(
3069            hint.contains("was not found on PATH or in common install locations"),
3070            "hint should explain the PATH miss: got {:?}",
3071            hint
3072        );
3073        assert!(
3074            !hint.contains("but not installed"),
3075            "hint must not claim the tool isn't installed: got {:?}",
3076            hint
3077        );
3078    }
3079
3080    #[test]
3081    fn install_hint_for_go_mentions_path() {
3082        // Verify the Go-specific hint nudges users toward checking PATH
3083        // (Homebrew install location is the most common GUI-launch PATH miss).
3084        let hint = install_hint("go");
3085        assert!(
3086            hint.contains("PATH"),
3087            "go install hint should mention PATH: got {:?}",
3088            hint
3089        );
3090    }
3091
3092    #[test]
3093    fn read_bounded_to_string_truncates_after_limit() {
3094        let (text, truncated) = read_bounded_to_string(std::io::Cursor::new(b"abcdef"), 4);
3095        assert_eq!(text, "abcd");
3096        assert!(truncated);
3097
3098        let (text, truncated) = read_bounded_to_string(std::io::Cursor::new(b"abc"), 4);
3099        assert_eq!(text, "abc");
3100        assert!(!truncated);
3101    }
3102
3103    #[test]
3104    fn windows_local_node_bin_extensions_follow_pathext_then_defaults() {
3105        let pathext = std::ffi::OsString::from(".EXE;.CMD;.BAT;.CMD");
3106        let extensions = windows_local_node_bin_extensions(Some(&pathext));
3107        assert_eq!(extensions, vec![".exe", ".cmd", ".bat", ".ps1"]);
3108    }
3109
3110    #[test]
3111    fn checker_executable_name_strips_paths_and_windows_extensions() {
3112        assert_eq!(checker_executable_name("/usr/local/bin/ruff"), "ruff");
3113        assert_eq!(checker_executable_name(r"C:\Go\bin\go.exe"), "go");
3114        assert_eq!(
3115            checker_executable_name(r"C:\repo\node_modules\.bin\biome.cmd"),
3116            "biome"
3117        );
3118    }
3119
3120    #[test]
3121    fn parse_biome_output_json_reporter() {
3122        let dir = tempfile::tempdir().unwrap();
3123        let file = dir.path().join("src/app.ts");
3124        fs::create_dir_all(file.parent().unwrap()).unwrap();
3125        fs::write(&file, "const value = 1;\nconsole.log(value);\n").unwrap();
3126        // Build the JSON via serde so the path is correctly escaped on Windows
3127        // (backslashes in paths would otherwise break a raw JSON string literal).
3128        let stdout = serde_json::json!({
3129            "diagnostics": [
3130                {
3131                    "severity": "warning",
3132                    "description": "Avoid console.log",
3133                    "location": {
3134                        "path": { "file": file.to_string_lossy() },
3135                        "span": [17, 28],
3136                    },
3137                },
3138            ],
3139        })
3140        .to_string();
3141
3142        let errors = parse_biome_output(&stdout, "", &file);
3143        assert_eq!(errors.len(), 1);
3144        assert_eq!(errors[0].line, 2);
3145        assert_eq!(errors[0].column, 1);
3146        assert_eq!(errors[0].severity, "warning");
3147        assert!(errors[0].message.contains("Avoid console.log"));
3148    }
3149
3150    #[test]
3151    fn parse_ruff_output_json() {
3152        let stdout = r#"[{"filename":"pkg/main.py","location":{"row":3,"column":5},"code":"F401","message":"`os` imported but unused"}]"#;
3153        let errors = parse_ruff_output(stdout, "", Path::new("pkg/main.py"));
3154        assert_eq!(errors.len(), 1);
3155        assert_eq!(errors[0].line, 3);
3156        assert_eq!(errors[0].column, 5);
3157        assert!(errors[0].message.contains("F401"));
3158    }
3159
3160    #[test]
3161    fn parse_staticcheck_output_json_lines() {
3162        let stdout = r#"{"code":"SA4006","severity":"error","location":{"file":"C:\\repo\\main.go","line":10,"column":5},"message":"value is never used"}"#;
3163        let errors = parse_staticcheck_output(stdout, "", Path::new(r"C:\repo\main.go"));
3164        assert_eq!(errors.len(), 1);
3165        assert_eq!(errors[0].line, 10);
3166        assert_eq!(errors[0].column, 5);
3167        assert!(errors[0].message.contains("SA4006"));
3168    }
3169
3170    #[test]
3171    fn parse_go_vet_output_handles_windows_drive_letters() {
3172        let stderr = r"C:\repo\main.go:10:5: unreachable code
3173C:\repo\other.go:1:1: other file
3174";
3175        let errors = parse_go_vet_output(stderr, Path::new(r"C:\repo\main.go"));
3176        assert_eq!(errors.len(), 1);
3177        assert_eq!(errors[0].line, 10);
3178        assert_eq!(errors[0].column, 5);
3179        assert_eq!(errors[0].message, "unreachable code");
3180    }
3181
3182    #[cfg(unix)]
3183    #[test]
3184    fn detect_type_checker_biome_uses_json_reporter() {
3185        let _guard = tool_cache_test_lock();
3186        clear_tool_cache();
3187        let dir = tempfile::tempdir().unwrap();
3188        fs::write(dir.path().join("biome.json"), "{}\n").unwrap();
3189        let bin_dir = dir.path().join("node_modules").join(".bin");
3190        fs::create_dir_all(&bin_dir).unwrap();
3191        let fake = bin_dir.join("biome");
3192        fs::write(&fake, "#!/bin/sh\necho 1.0.0\n").unwrap();
3193        use std::os::unix::fs::PermissionsExt;
3194        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3195
3196        let path = dir.path().join("src/app.ts");
3197        let config = Config {
3198            project_root: Some(dir.path().to_path_buf()),
3199            ..Config::default()
3200        };
3201
3202        let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
3203        assert!(cmd.ends_with("biome"), "expected biome, got: {cmd}");
3204        assert_eq!(args[0], "check");
3205        assert!(args.contains(&"--reporter=json".to_string()));
3206    }
3207
3208    #[cfg(unix)]
3209    #[test]
3210    fn detect_type_checker_ruff_does_not_require_formatter_version() {
3211        let _guard = tool_cache_test_lock();
3212        clear_tool_cache();
3213        let dir = tempfile::tempdir().unwrap();
3214        fs::write(dir.path().join("ruff.toml"), "\n").unwrap();
3215        let bin_dir = dir.path().join("node_modules").join(".bin");
3216        fs::create_dir_all(&bin_dir).unwrap();
3217        let fake = bin_dir.join("ruff");
3218        fs::write(&fake, "#!/bin/sh\necho 'ruff 0.0.1'\n").unwrap();
3219        use std::os::unix::fs::PermissionsExt;
3220        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3221
3222        let path = dir.path().join("main.py");
3223        let config = Config {
3224            project_root: Some(dir.path().to_path_buf()),
3225            ..Config::default()
3226        };
3227
3228        assert!(!ruff_format_available(config.project_root.as_deref()));
3229        let (cmd, args) = detect_type_checker(&path, LangId::Python, &config).unwrap();
3230        assert!(cmd.ends_with("ruff"), "expected ruff checker, got: {cmd}");
3231        assert_eq!(args[0], "check");
3232        assert!(args.contains(&"--output-format=json".to_string()));
3233    }
3234
3235    #[cfg(unix)]
3236    #[test]
3237    fn detect_type_checker_staticcheck_uses_json_reporter() {
3238        let _guard = tool_cache_test_lock();
3239        clear_tool_cache();
3240        let dir = tempfile::tempdir().unwrap();
3241        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21\n").unwrap();
3242        let bin_dir = dir.path().join("node_modules").join(".bin");
3243        fs::create_dir_all(&bin_dir).unwrap();
3244        let fake = bin_dir.join("staticcheck");
3245        fs::write(&fake, "#!/bin/sh\necho staticcheck\n").unwrap();
3246        use std::os::unix::fs::PermissionsExt;
3247        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3248
3249        let path = dir.path().join("main.go");
3250        let config = Config {
3251            project_root: Some(dir.path().to_path_buf()),
3252            ..Config::default()
3253        };
3254
3255        let (cmd, args) = detect_type_checker(&path, LangId::Go, &config).unwrap();
3256        assert!(
3257            cmd.ends_with("staticcheck"),
3258            "expected staticcheck, got: {cmd}"
3259        );
3260        assert_eq!(args[0], "-f");
3261        assert_eq!(args[1], "json");
3262    }
3263
3264    #[cfg(unix)]
3265    #[test]
3266    fn detect_type_checker_uses_resolved_cargo_and_go_paths() {
3267        let _guard = tool_cache_test_lock();
3268        clear_tool_cache();
3269        let dir = tempfile::tempdir().unwrap();
3270        let bin_dir = dir.path().join("node_modules").join(".bin");
3271        fs::create_dir_all(&bin_dir).unwrap();
3272        use std::os::unix::fs::PermissionsExt;
3273        for name in ["cargo", "go"] {
3274            let fake = bin_dir.join(name);
3275            fs::write(&fake, "#!/bin/sh\necho fake\n").unwrap();
3276            fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3277        }
3278
3279        fs::write(
3280            dir.path().join("Cargo.toml"),
3281            "[package]\nname = \"test\"\n",
3282        )
3283        .unwrap();
3284        let rust_config = Config {
3285            project_root: Some(dir.path().to_path_buf()),
3286            ..Config::default()
3287        };
3288        let (cargo_cmd, _) =
3289            detect_type_checker(&dir.path().join("src/main.rs"), LangId::Rust, &rust_config)
3290                .unwrap();
3291        assert_eq!(cargo_cmd, bin_dir.join("cargo").to_string_lossy());
3292
3293        fs::remove_file(dir.path().join("Cargo.toml")).unwrap();
3294        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21\n").unwrap();
3295        let mut go_config = Config {
3296            project_root: Some(dir.path().to_path_buf()),
3297            ..Config::default()
3298        };
3299        go_config.checker.insert("go".to_string(), "go".to_string());
3300        let (go_cmd, _) =
3301            detect_type_checker(&dir.path().join("main.go"), LangId::Go, &go_config).unwrap();
3302        assert_eq!(go_cmd, bin_dir.join("go").to_string_lossy());
3303    }
3304}