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