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