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