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}
24
25struct SubprocessOutcome {
26    stdout: String,
27    stderr: String,
28    status: ExitStatus,
29}
30
31/// Errors from external tool execution.
32#[derive(Debug)]
33pub enum FormatError {
34    /// The tool binary was not found on PATH.
35    NotFound { tool: String },
36    /// The tool exceeded its timeout and was killed.
37    Timeout { tool: String, timeout_secs: u32 },
38    /// The tool exited with a non-zero status.
39    Failed { tool: String, stderr: String },
40    /// No formatter is configured for this language.
41    UnsupportedLanguage,
42}
43
44/// A configured formatter/checker that cannot be resolved for configure warnings.
45#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
46pub struct MissingTool {
47    pub kind: String,
48    pub language: String,
49    pub tool: String,
50    pub hint: String,
51}
52
53#[derive(Debug, Clone)]
54struct ToolCandidate {
55    tool: String,
56    source: String,
57    args: Vec<String>,
58    required: bool,
59}
60
61#[derive(Debug, Clone)]
62enum ToolDetection {
63    Found(String, Vec<String>),
64    NotConfigured,
65    NotInstalled { tool: String },
66}
67
68impl std::fmt::Display for FormatError {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        match self {
71            FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
72            FormatError::Timeout { tool, timeout_secs } => {
73                write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
74            }
75            FormatError::Failed { tool, stderr } => {
76                write!(f, "formatter '{}' failed: {}", tool, stderr)
77            }
78            FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
79        }
80    }
81}
82
83/// Apply Unix-specific isolation so a kill() on timeout terminates
84/// grandchildren too (e.g. `sh -c 'sleep 60'` orphaning `sleep`).
85///
86/// Without this, killing the immediate child (`sh`) leaves `sleep`
87/// holding stdout/stderr pipes open, and the reader threads block
88/// until `sleep` terminates — turning a 2s timeout into a 60s hang.
89#[cfg(unix)]
90fn isolate_in_process_group(cmd: &mut Command) {
91    use std::os::unix::process::CommandExt;
92    // SAFETY: setsid is async-signal-safe.
93    unsafe {
94        cmd.pre_exec(|| {
95            if libc::setsid() == -1 {
96                return Err(std::io::Error::last_os_error());
97            }
98            Ok(())
99        });
100    }
101}
102
103#[cfg(not(unix))]
104fn isolate_in_process_group(_cmd: &mut Command) {
105    // Best-effort no-op on Windows; child.kill() does not propagate
106    // to grandchildren but reader threads will still close pipes
107    // when the immediate child exits.
108}
109
110/// Kill the child and (on Unix) its entire process group, so orphaned
111/// grandchildren don't keep pipes open after a timeout.
112#[cfg(unix)]
113fn kill_process_tree(child: &mut Child) {
114    let pid = child.id() as i32;
115    if pid > 0 {
116        // SAFETY: killpg with SIGKILL on a process group leader is safe.
117        // Negative pid form (kill -pgid) targets the whole group.
118        unsafe {
119            libc::killpg(pid, libc::SIGKILL);
120        }
121    }
122    let _ = child.kill();
123}
124
125#[cfg(not(unix))]
126fn kill_process_tree(child: &mut Child) {
127    let _ = child.kill();
128}
129
130/// Spawn a subprocess and wait for completion with timeout protection.
131///
132/// Polls `try_wait()` at 50ms intervals. On timeout, kills the child process
133/// and waits for it to exit. Returns `FormatError::NotFound` when the binary
134/// isn't on PATH.
135pub fn run_external_tool(
136    command: &str,
137    args: &[&str],
138    working_dir: Option<&Path>,
139    timeout_secs: u32,
140) -> Result<ExternalToolResult, FormatError> {
141    let mut cmd = Command::new(command);
142    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
143
144    if let Some(dir) = working_dir {
145        cmd.current_dir(dir);
146    }
147
148    isolate_in_process_group(&mut cmd);
149
150    let child = match cmd.spawn() {
151        Ok(c) => c,
152        Err(e) if e.kind() == ErrorKind::NotFound => {
153            return Err(FormatError::NotFound {
154                tool: command.to_string(),
155            });
156        }
157        Err(e) => {
158            return Err(FormatError::Failed {
159                tool: command.to_string(),
160                stderr: e.to_string(),
161            });
162        }
163    };
164
165    let outcome = wait_with_timeout(child, command, timeout_secs)?;
166    let exit_code = outcome.status.code().unwrap_or(-1);
167    if exit_code != 0 {
168        return Err(FormatError::Failed {
169            tool: command.to_string(),
170            stderr: outcome.stderr,
171        });
172    }
173
174    Ok(ExternalToolResult {
175        stdout: outcome.stdout,
176        stderr: outcome.stderr,
177        exit_code,
178    })
179}
180
181fn wait_with_timeout(
182    mut child: Child,
183    command: &str,
184    timeout_secs: u32,
185) -> Result<SubprocessOutcome, FormatError> {
186    let mut stdout_pipe = child.stdout.take().expect("piped stdout");
187    let mut stderr_pipe = child.stderr.take().expect("piped stderr");
188    let stdout_thread = thread::spawn(move || {
189        let mut buf = String::new();
190        let _ = stdout_pipe.read_to_string(&mut buf);
191        buf
192    });
193    let stderr_thread = thread::spawn(move || {
194        let mut buf = String::new();
195        let _ = stderr_pipe.read_to_string(&mut buf);
196        buf
197    });
198    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
199
200    loop {
201        match child.try_wait() {
202            Ok(Some(status)) => {
203                let stdout = stdout_thread.join().unwrap_or_default();
204                let stderr = stderr_thread.join().unwrap_or_default();
205                return Ok(SubprocessOutcome {
206                    stdout,
207                    stderr,
208                    status,
209                });
210            }
211            Ok(None) => {
212                if Instant::now() >= deadline {
213                    kill_process_tree(&mut child);
214                    let _ = child.wait();
215                    // Do NOT block joining the reader threads — orphaned
216                    // grandchildren may still hold the pipes open even after
217                    // the immediate child is gone. The threads will detach
218                    // and clean up when pipes finally close.
219                    return Err(FormatError::Timeout {
220                        tool: command.to_string(),
221                        timeout_secs,
222                    });
223                }
224                thread::sleep(Duration::from_millis(50));
225            }
226            Err(e) => {
227                kill_process_tree(&mut child);
228                let _ = child.wait();
229                // Same rationale as the timeout branch: don't block on join.
230                return Err(FormatError::Failed {
231                    tool: command.to_string(),
232                    stderr: format!("try_wait error: {}", e),
233                });
234            }
235        }
236    }
237}
238
239/// TTL for tool availability and resolution cache entries.
240const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
241
242#[derive(Debug, Clone, PartialEq, Eq, Hash)]
243struct ToolCacheKey {
244    command: String,
245    project_root: PathBuf,
246}
247
248static TOOL_RESOLUTION_CACHE: std::sync::LazyLock<
249    Mutex<HashMap<ToolCacheKey, (Option<PathBuf>, Instant)>>,
250> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
251
252static TOOL_AVAILABILITY_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
253    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
254
255fn tool_cache_key(command: &str, project_root: Option<&Path>) -> ToolCacheKey {
256    ToolCacheKey {
257        command: command.to_string(),
258        project_root: project_root.map(Path::to_path_buf).unwrap_or_default(),
259    }
260}
261
262fn availability_cache_key(command: &str, project_root: Option<&Path>) -> String {
263    let root = project_root
264        .map(|path| path.to_string_lossy())
265        .unwrap_or_default();
266    format!("{}\0{}", command, root)
267}
268
269pub fn clear_tool_cache() {
270    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
271        cache.clear();
272    }
273    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
274        cache.clear();
275    }
276}
277
278/// Resolve a tool by checking node_modules/.bin relative to project_root, then PATH.
279/// Returns the full path to the tool if found, otherwise None.
280fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
281    let key = tool_cache_key(command, project_root);
282    if let Ok(cache) = TOOL_RESOLUTION_CACHE.lock() {
283        if let Some((resolved, checked_at)) = cache.get(&key) {
284            if checked_at.elapsed() < TOOL_CACHE_TTL {
285                return resolved
286                    .as_ref()
287                    .map(|path| path.to_string_lossy().to_string());
288            }
289        }
290    }
291
292    let resolved = resolve_tool_uncached(command, project_root);
293    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
294        cache.insert(key, (resolved.clone(), Instant::now()));
295    }
296    resolved.map(|path| path.to_string_lossy().to_string())
297}
298
299fn resolve_tool_uncached(command: &str, project_root: Option<&Path>) -> Option<PathBuf> {
300    // 1. Check node_modules/.bin/<command> relative to project root
301    if let Some(root) = project_root {
302        let local_bin = root.join("node_modules").join(".bin").join(command);
303        if local_bin.exists() {
304            return Some(local_bin);
305        }
306    }
307
308    // 2. Try PATH lookup first. This is the fast common path: spawning the
309    // tool with `--version` and waiting briefly for it to exit. When the
310    // editor (OpenCode, Pi, etc.) is launched from a login shell the PATH
311    // is usually complete, so this finds Homebrew/cargo/etc. binaries.
312    if let Some(path) = try_path_lookup(command) {
313        return Some(path);
314    }
315
316    // 3. Fall back to well-known install locations the editor's PATH may
317    // not contain. GitHub issue #47: macOS GUI launches (Spotlight, Dock,
318    // Alfred) and some Linux desktop launchers drop /opt/homebrew/bin and
319    // similar from PATH, making PATH lookups fail even though the user
320    // genuinely has the tool installed. Returning the absolute path here
321    // means downstream `Command::new(resolved)` works regardless.
322    try_well_known_path_lookup(command)
323}
324
325/// Try spawning the tool via the inherited PATH. Returns the bare command
326/// name on success (downstream `Command::new` re-resolves through PATH),
327/// or None if the spawn fails or the tool exits with non-zero status.
328fn try_path_lookup(command: &str) -> Option<PathBuf> {
329    let mut child = Command::new(command)
330        .arg("--version")
331        .stdin(Stdio::null())
332        .stdout(Stdio::null())
333        .stderr(Stdio::null())
334        .spawn()
335        .ok()?;
336    let start = Instant::now();
337    let timeout = Duration::from_secs(2);
338    loop {
339        match child.try_wait() {
340            Ok(Some(status)) => {
341                return if status.success() {
342                    Some(PathBuf::from(command))
343                } else {
344                    None
345                };
346            }
347            Ok(None) if start.elapsed() > timeout => {
348                let _ = child.kill();
349                let _ = child.wait();
350                return None;
351            }
352            Ok(None) => thread::sleep(Duration::from_millis(50)),
353            Err(_) => return None,
354        }
355    }
356}
357
358/// Look up `command` in the well-known install locations that GUI-launched
359/// editors commonly miss from PATH. Returns the absolute path so the caller
360/// invokes the tool via `Command::new(absolute_path)` regardless of PATH.
361///
362/// Search order is built by `well_known_search_paths`:
363/// 1. `/opt/homebrew/bin` (Apple Silicon Homebrew)
364/// 2. `/usr/local/bin` (Intel Mac Homebrew + most manual Linux installs)
365/// 3. `$HOME/.cargo/bin` (cargo install — rustfmt, etc.)
366/// 4. `$HOME/go/bin` (`go install` default GOPATH layout)
367/// 5. `$HOME/.local/bin` (pip --user, pipx, npm prefix, many shell scripts)
368///
369/// Each candidate is verified to (a) exist as a regular file and (b) be
370/// executable; we don't spawn `--version` here because spawning an
371/// absolute-path candidate that doesn't accept `--version` would emit a
372/// false negative (and Rust's `fs::metadata` is much cheaper than a spawn).
373fn try_well_known_path_lookup(command: &str) -> Option<PathBuf> {
374    if cfg!(windows) {
375        // On Windows, well-known POSIX paths don't apply. Skip the fallback
376        // entirely — the user's tool is either on PATH or genuinely missing.
377        return None;
378    }
379    // Test-only escape hatch: integration tests that need to assert
380    // "tool not installed" semantics set AFT_DISABLE_WELL_KNOWN_LOOKUP=1
381    // so CI runners with a system tsc/biome/etc. at /usr/local/bin don't
382    // silently make those tests pass. Production callers never set this.
383    if std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP").is_some() {
384        return None;
385    }
386    let candidates = well_known_search_paths(command, std::env::var_os("HOME").as_deref());
387    try_well_known_path_lookup_in(&candidates)
388}
389
390/// Build the candidate path list for the given command name and HOME value.
391/// Extracted so tests can drive the lookup with a controlled HOME without
392/// mutating process-global env vars.
393fn well_known_search_paths(command: &str, home: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
394    let mut candidates: Vec<PathBuf> = Vec::with_capacity(5);
395    candidates.push(PathBuf::from("/opt/homebrew/bin").join(command));
396    candidates.push(PathBuf::from("/usr/local/bin").join(command));
397    if let Some(home) = home {
398        let home_path = PathBuf::from(home);
399        candidates.push(home_path.join(".cargo/bin").join(command));
400        candidates.push(home_path.join("go/bin").join(command));
401        candidates.push(home_path.join(".local/bin").join(command));
402    }
403    candidates
404}
405
406/// Walk a pre-built candidate list, returning the first file that exists and
407/// is executable. Extracted from `try_well_known_path_lookup` so tests can
408/// inject candidates anchored at a tempdir.
409fn try_well_known_path_lookup_in(candidates: &[PathBuf]) -> Option<PathBuf> {
410    for candidate in candidates {
411        if let Ok(metadata) = std::fs::metadata(candidate) {
412            if metadata.is_file() && is_executable(&metadata) {
413                return Some(candidate.clone());
414            }
415        }
416    }
417    None
418}
419
420#[cfg(unix)]
421fn is_executable(metadata: &std::fs::Metadata) -> bool {
422    use std::os::unix::fs::PermissionsExt;
423    metadata.permissions().mode() & 0o111 != 0
424}
425
426#[cfg(not(unix))]
427fn is_executable(_metadata: &std::fs::Metadata) -> bool {
428    // Windows: regular files in well-known POSIX paths don't apply
429    // (try_well_known_path_lookup returns early on Windows). This stub
430    // exists only so the file compiles on Windows.
431    true
432}
433
434/// Check if `ruff format` is available with a stable formatter.
435///
436/// Ruff's formatter became stable in v0.1.2. Versions before that output
437/// `NOT_YET_IMPLEMENTED_*` stubs instead of formatted code. We parse the
438/// version from `ruff --version` (format: "ruff X.Y.Z") and require >= 0.1.2.
439/// Falls back to false if ruff is not found or version cannot be parsed.
440fn ruff_format_available(project_root: Option<&Path>) -> bool {
441    let key = availability_cache_key("ruff-format", project_root);
442    if let Ok(cache) = TOOL_AVAILABILITY_CACHE.lock() {
443        if let Some((available, checked_at)) = cache.get(&key) {
444            if checked_at.elapsed() < TOOL_CACHE_TTL {
445                return *available;
446            }
447        }
448    }
449
450    let result = ruff_format_available_uncached(project_root);
451    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
452        cache.insert(key, (result, Instant::now()));
453    }
454    result
455}
456
457fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
458    let command = match resolve_tool("ruff", project_root) {
459        Some(command) => command,
460        None => return false,
461    };
462    let output = match Command::new(&command)
463        .arg("--version")
464        .stdout(Stdio::piped())
465        .stderr(Stdio::null())
466        .output()
467    {
468        Ok(o) => o,
469        Err(_) => return false,
470    };
471
472    let version_str = String::from_utf8_lossy(&output.stdout);
473    // Parse "ruff X.Y.Z" or just "X.Y.Z"
474    let version_part = version_str
475        .trim()
476        .strip_prefix("ruff ")
477        .unwrap_or(version_str.trim());
478
479    let parts: Vec<&str> = version_part.split('.').collect();
480    if parts.len() < 3 {
481        return false;
482    }
483
484    let major: u32 = match parts[0].parse() {
485        Ok(v) => v,
486        Err(_) => return false,
487    };
488    let minor: u32 = match parts[1].parse() {
489        Ok(v) => v,
490        Err(_) => return false,
491    };
492    let patch: u32 = match parts[2].parse() {
493        Ok(v) => v,
494        Err(_) => return false,
495    };
496
497    // Require >= 0.1.2 where ruff format became stable
498    (major, minor, patch) >= (0, 1, 2)
499}
500
501fn resolve_candidate_tool(
502    candidate: &ToolCandidate,
503    project_root: Option<&Path>,
504) -> Option<String> {
505    if candidate.tool == "ruff" && !ruff_format_available(project_root) {
506        return None;
507    }
508
509    resolve_tool(&candidate.tool, project_root)
510}
511
512fn lang_key(lang: LangId) -> &'static str {
513    match lang {
514        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
515        LangId::Python => "python",
516        LangId::Rust => "rust",
517        LangId::Go => "go",
518        LangId::C => "c",
519        LangId::Cpp => "cpp",
520        LangId::Zig => "zig",
521        LangId::CSharp => "csharp",
522        LangId::Bash => "bash",
523        LangId::Solidity => "solidity",
524        LangId::Vue => "vue",
525        LangId::Json => "json",
526        LangId::Scala => "scala",
527        LangId::Java => "java",
528        LangId::Ruby => "ruby",
529        LangId::Kotlin => "kotlin",
530        LangId::Swift => "swift",
531        LangId::Php => "php",
532        LangId::Lua => "lua",
533        LangId::Perl => "perl",
534        LangId::Html => "html",
535        LangId::Markdown => "markdown",
536    }
537}
538
539fn has_formatter_support(lang: LangId) -> bool {
540    matches!(
541        lang,
542        LangId::TypeScript
543            | LangId::JavaScript
544            | LangId::Tsx
545            | LangId::Python
546            | LangId::Rust
547            | LangId::Go
548    )
549}
550
551fn has_checker_support(lang: LangId) -> bool {
552    matches!(
553        lang,
554        LangId::TypeScript
555            | LangId::JavaScript
556            | LangId::Tsx
557            | LangId::Python
558            | LangId::Rust
559            | LangId::Go
560    )
561}
562
563fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
564    let project_root = config.project_root.as_deref();
565    if let Some(preferred) = config.formatter.get(lang_key(lang)) {
566        return explicit_formatter_candidate(preferred, file_str);
567    }
568
569    match lang {
570        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
571            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
572                vec![ToolCandidate {
573                    tool: "biome".to_string(),
574                    source: "biome.json".to_string(),
575                    args: vec![
576                        "format".to_string(),
577                        "--write".to_string(),
578                        file_str.to_string(),
579                    ],
580                    required: true,
581                }]
582            } else if has_project_config(
583                project_root,
584                &[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts"],
585            ) {
586                vec![ToolCandidate {
587                    tool: "oxfmt".to_string(),
588                    source: "oxfmt config".to_string(),
589                    args: vec!["--write".to_string(), file_str.to_string()],
590                    required: true,
591                }]
592            } else if has_project_config(
593                project_root,
594                &[
595                    ".prettierrc",
596                    ".prettierrc.json",
597                    ".prettierrc.yml",
598                    ".prettierrc.yaml",
599                    ".prettierrc.js",
600                    ".prettierrc.cjs",
601                    ".prettierrc.mjs",
602                    ".prettierrc.toml",
603                    "prettier.config.js",
604                    "prettier.config.cjs",
605                    "prettier.config.mjs",
606                ],
607            ) {
608                vec![ToolCandidate {
609                    tool: "prettier".to_string(),
610                    source: "Prettier config".to_string(),
611                    args: vec!["--write".to_string(), file_str.to_string()],
612                    required: true,
613                }]
614            } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
615                vec![ToolCandidate {
616                    tool: "deno".to_string(),
617                    source: "deno.json".to_string(),
618                    args: vec!["fmt".to_string(), file_str.to_string()],
619                    required: true,
620                }]
621            } else {
622                Vec::new()
623            }
624        }
625        LangId::Python => {
626            if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
627                || has_pyproject_tool(project_root, "ruff")
628            {
629                vec![ToolCandidate {
630                    tool: "ruff".to_string(),
631                    source: "ruff config".to_string(),
632                    args: vec!["format".to_string(), file_str.to_string()],
633                    required: true,
634                }]
635            } else if has_pyproject_tool(project_root, "black") {
636                vec![ToolCandidate {
637                    tool: "black".to_string(),
638                    source: "pyproject.toml".to_string(),
639                    args: vec![file_str.to_string()],
640                    required: true,
641                }]
642            } else {
643                Vec::new()
644            }
645        }
646        LangId::Rust => {
647            if has_project_config(project_root, &["Cargo.toml"]) {
648                vec![ToolCandidate {
649                    tool: "rustfmt".to_string(),
650                    source: "Cargo.toml".to_string(),
651                    args: vec![file_str.to_string()],
652                    required: true,
653                }]
654            } else {
655                Vec::new()
656            }
657        }
658        LangId::Go => {
659            if has_project_config(project_root, &["go.mod"]) {
660                vec![
661                    ToolCandidate {
662                        tool: "goimports".to_string(),
663                        source: "go.mod".to_string(),
664                        args: vec!["-w".to_string(), file_str.to_string()],
665                        required: false,
666                    },
667                    ToolCandidate {
668                        tool: "gofmt".to_string(),
669                        source: "go.mod".to_string(),
670                        args: vec!["-w".to_string(), file_str.to_string()],
671                        required: true,
672                    },
673                ]
674            } else {
675                Vec::new()
676            }
677        }
678        LangId::C
679        | LangId::Cpp
680        | LangId::Zig
681        | LangId::CSharp
682        | LangId::Bash
683        | LangId::Solidity
684        | LangId::Vue
685        | LangId::Json
686        | LangId::Scala
687        | LangId::Java
688        | LangId::Ruby
689        | LangId::Kotlin
690        | LangId::Swift
691        | LangId::Php
692        | LangId::Lua
693        | LangId::Perl => Vec::new(),
694        LangId::Html => Vec::new(),
695        LangId::Markdown => Vec::new(),
696    }
697}
698
699fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
700    let project_root = config.project_root.as_deref();
701    if let Some(preferred) = config.checker.get(lang_key(lang)) {
702        return explicit_checker_candidate(preferred, file_str);
703    }
704
705    match lang {
706        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
707            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
708                vec![ToolCandidate {
709                    tool: "biome".to_string(),
710                    source: "biome.json".to_string(),
711                    args: vec!["check".to_string(), file_str.to_string()],
712                    required: true,
713                }]
714            } else if has_project_config(project_root, &["tsconfig.json"]) {
715                vec![ToolCandidate {
716                    tool: "tsc".to_string(),
717                    source: "tsconfig.json".to_string(),
718                    args: vec![
719                        "--noEmit".to_string(),
720                        "--pretty".to_string(),
721                        "false".to_string(),
722                    ],
723                    required: true,
724                }]
725            } else {
726                Vec::new()
727            }
728        }
729        LangId::Python => {
730            if has_project_config(project_root, &["pyrightconfig.json"])
731                || has_pyproject_tool(project_root, "pyright")
732            {
733                vec![ToolCandidate {
734                    tool: "pyright".to_string(),
735                    source: "pyright config".to_string(),
736                    args: vec!["--outputjson".to_string(), file_str.to_string()],
737                    required: true,
738                }]
739            } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
740                || has_pyproject_tool(project_root, "ruff")
741            {
742                vec![ToolCandidate {
743                    tool: "ruff".to_string(),
744                    source: "ruff config".to_string(),
745                    args: vec![
746                        "check".to_string(),
747                        "--output-format=json".to_string(),
748                        file_str.to_string(),
749                    ],
750                    required: true,
751                }]
752            } else {
753                Vec::new()
754            }
755        }
756        LangId::Rust => {
757            if has_project_config(project_root, &["Cargo.toml"]) {
758                vec![ToolCandidate {
759                    tool: "cargo".to_string(),
760                    source: "Cargo.toml".to_string(),
761                    args: vec!["check".to_string(), "--message-format=json".to_string()],
762                    required: true,
763                }]
764            } else {
765                Vec::new()
766            }
767        }
768        LangId::Go => {
769            if has_project_config(project_root, &["go.mod"]) {
770                vec![
771                    ToolCandidate {
772                        tool: "staticcheck".to_string(),
773                        source: "go.mod".to_string(),
774                        args: vec![file_str.to_string()],
775                        required: false,
776                    },
777                    ToolCandidate {
778                        tool: "go".to_string(),
779                        source: "go.mod".to_string(),
780                        args: vec!["vet".to_string(), file_str.to_string()],
781                        required: true,
782                    },
783                ]
784            } else {
785                Vec::new()
786            }
787        }
788        LangId::C
789        | LangId::Cpp
790        | LangId::Zig
791        | LangId::CSharp
792        | LangId::Bash
793        | LangId::Solidity
794        | LangId::Vue
795        | LangId::Json
796        | LangId::Scala
797        | LangId::Java
798        | LangId::Ruby
799        | LangId::Kotlin
800        | LangId::Swift
801        | LangId::Php
802        | LangId::Lua
803        | LangId::Perl => Vec::new(),
804        LangId::Html => Vec::new(),
805        LangId::Markdown => Vec::new(),
806    }
807}
808
809fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
810    match name {
811        "none" | "off" | "false" => Vec::new(),
812        "biome" => vec![ToolCandidate {
813            tool: name.to_string(),
814            source: "formatter config".to_string(),
815            args: vec![
816                "format".to_string(),
817                "--write".to_string(),
818                file_str.to_string(),
819            ],
820            required: true,
821        }],
822        "oxfmt" => vec![ToolCandidate {
823            tool: name.to_string(),
824            source: "formatter config".to_string(),
825            args: vec!["--write".to_string(), file_str.to_string()],
826            required: true,
827        }],
828        "prettier" => vec![ToolCandidate {
829            tool: name.to_string(),
830            source: "formatter config".to_string(),
831            args: vec!["--write".to_string(), file_str.to_string()],
832            required: true,
833        }],
834        "deno" => vec![ToolCandidate {
835            tool: name.to_string(),
836            source: "formatter config".to_string(),
837            args: vec!["fmt".to_string(), file_str.to_string()],
838            required: true,
839        }],
840        "ruff" => vec![ToolCandidate {
841            tool: name.to_string(),
842            source: "formatter config".to_string(),
843            args: vec!["format".to_string(), file_str.to_string()],
844            required: true,
845        }],
846        "black" | "rustfmt" => vec![ToolCandidate {
847            tool: name.to_string(),
848            source: "formatter config".to_string(),
849            args: vec![file_str.to_string()],
850            required: true,
851        }],
852        "goimports" | "gofmt" => vec![ToolCandidate {
853            tool: name.to_string(),
854            source: "formatter config".to_string(),
855            args: vec!["-w".to_string(), file_str.to_string()],
856            required: true,
857        }],
858        _ => Vec::new(),
859    }
860}
861
862fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
863    match name {
864        "none" | "off" | "false" => Vec::new(),
865        "tsc" | "tsgo" => vec![ToolCandidate {
866            tool: name.to_string(),
867            source: "checker config".to_string(),
868            args: vec![
869                "--noEmit".to_string(),
870                "--pretty".to_string(),
871                "false".to_string(),
872            ],
873            required: true,
874        }],
875        "cargo" => vec![ToolCandidate {
876            tool: name.to_string(),
877            source: "checker config".to_string(),
878            args: vec!["check".to_string(), "--message-format=json".to_string()],
879            required: true,
880        }],
881        "go" => vec![ToolCandidate {
882            tool: name.to_string(),
883            source: "checker config".to_string(),
884            args: vec!["vet".to_string(), file_str.to_string()],
885            required: true,
886        }],
887        "biome" => vec![ToolCandidate {
888            tool: name.to_string(),
889            source: "checker config".to_string(),
890            args: vec!["check".to_string(), file_str.to_string()],
891            required: true,
892        }],
893        "pyright" => vec![ToolCandidate {
894            tool: name.to_string(),
895            source: "checker config".to_string(),
896            args: vec!["--outputjson".to_string(), file_str.to_string()],
897            required: true,
898        }],
899        "ruff" => vec![ToolCandidate {
900            tool: name.to_string(),
901            source: "checker config".to_string(),
902            args: vec![
903                "check".to_string(),
904                "--output-format=json".to_string(),
905                file_str.to_string(),
906            ],
907            required: true,
908        }],
909        "staticcheck" => vec![ToolCandidate {
910            tool: name.to_string(),
911            source: "checker config".to_string(),
912            args: vec![file_str.to_string()],
913            required: true,
914        }],
915        _ => Vec::new(),
916    }
917}
918
919fn resolve_tool_candidates(
920    candidates: Vec<ToolCandidate>,
921    project_root: Option<&Path>,
922) -> ToolDetection {
923    if candidates.is_empty() {
924        return ToolDetection::NotConfigured;
925    }
926
927    let mut missing_required = None;
928    for candidate in candidates {
929        if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
930            return ToolDetection::Found(command, candidate.args);
931        }
932        if candidate.required && missing_required.is_none() {
933            missing_required = Some(candidate.tool);
934        }
935    }
936
937    match missing_required {
938        Some(tool) => ToolDetection::NotInstalled { tool },
939        None => ToolDetection::NotConfigured,
940    }
941}
942
943fn checker_command(candidate: &ToolCandidate, resolved: String) -> String {
944    match candidate.tool.as_str() {
945        "tsc" | "tsgo" => resolved,
946        "cargo" => "cargo".to_string(),
947        "go" => "go".to_string(),
948        _ => resolved,
949    }
950}
951
952fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
953    if candidate.tool == "tsc" || candidate.tool == "tsgo" {
954        vec![
955            "--noEmit".to_string(),
956            "--pretty".to_string(),
957            "false".to_string(),
958        ]
959    } else {
960        candidate.args.clone()
961    }
962}
963
964fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
965    let file_str = path.to_string_lossy().to_string();
966    resolve_tool_candidates(
967        formatter_candidates(lang, config, &file_str),
968        config.project_root.as_deref(),
969    )
970}
971
972fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
973    let file_str = path.to_string_lossy().to_string();
974    let candidates = checker_candidates(lang, config, &file_str);
975    if candidates.is_empty() {
976        return ToolDetection::NotConfigured;
977    }
978
979    let project_root = config.project_root.as_deref();
980    let mut missing_required = None;
981    for candidate in candidates {
982        if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
983            return ToolDetection::Found(
984                checker_command(&candidate, command),
985                checker_args(&candidate),
986            );
987        }
988        if candidate.required && missing_required.is_none() {
989            missing_required = Some(candidate.tool);
990        }
991    }
992
993    match missing_required {
994        Some(tool) => ToolDetection::NotInstalled { tool },
995        None => ToolDetection::NotConfigured,
996    }
997}
998
999fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
1000    crate::callgraph::walk_project_files(project_root)
1001        .filter_map(|path| detect_language(&path))
1002        .collect()
1003}
1004
1005fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
1006    let filename = match lang {
1007        LangId::TypeScript => "aft-tool-detection.ts",
1008        LangId::Tsx => "aft-tool-detection.tsx",
1009        LangId::JavaScript => "aft-tool-detection.js",
1010        LangId::Python => "aft-tool-detection.py",
1011        LangId::Rust => "aft_tool_detection.rs",
1012        LangId::Go => "aft_tool_detection.go",
1013        LangId::C => "aft_tool_detection.c",
1014        LangId::Cpp => "aft_tool_detection.cpp",
1015        LangId::Zig => "aft_tool_detection.zig",
1016        LangId::CSharp => "aft_tool_detection.cs",
1017        LangId::Bash => "aft_tool_detection.sh",
1018        LangId::Solidity => "aft_tool_detection.sol",
1019        LangId::Vue => "aft-tool-detection.vue",
1020        LangId::Json => "aft-tool-detection.json",
1021        LangId::Scala => "aft-tool-detection.scala",
1022        LangId::Java => "aft-tool-detection.java",
1023        LangId::Ruby => "aft-tool-detection.rb",
1024        LangId::Kotlin => "aft-tool-detection.kt",
1025        LangId::Swift => "aft-tool-detection.swift",
1026        LangId::Php => "aft-tool-detection.php",
1027        LangId::Lua => "aft-tool-detection.lua",
1028        LangId::Perl => "aft-tool-detection.pl",
1029        LangId::Html => "aft-tool-detection.html",
1030        LangId::Markdown => "aft-tool-detection.md",
1031    };
1032    project_root.join(filename)
1033}
1034
1035pub(crate) fn install_hint(tool: &str) -> String {
1036    match tool {
1037        "biome" => {
1038            "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
1039        }
1040        "oxfmt" => "Run `npm install -D oxfmt` or install globally.".to_string(),
1041        "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
1042        "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
1043        "tsgo" => {
1044            "Run `npm install -D @typescript/native-preview` or install globally.".to_string()
1045        }
1046        "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
1047        "ruff" => {
1048            "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
1049        }
1050        "black" => {
1051            "Install: `pip install black` or your Python package manager equivalent.".to_string()
1052        }
1053        "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
1054        "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
1055        "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
1056        "go" => [
1057            "Install Go from https://go.dev/dl/, or — if it's already installed —",
1058            "ensure its bin directory is on PATH (Homebrew typically uses",
1059            "/opt/homebrew/bin on Apple Silicon, /usr/local/bin on Intel macOS).",
1060            "GUI-launched editors often don't inherit login-shell PATH.",
1061        ]
1062        .join(" "),
1063        "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
1064        "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
1065        "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
1066        "typescript-language-server" => {
1067            "Install: `npm install -g typescript-language-server typescript`".to_string()
1068        }
1069        "deno" => "Install Deno from https://deno.com/.".to_string(),
1070        "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
1071        "staticcheck" => {
1072            "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
1073        }
1074        other => format!("Install `{other}` and ensure it is on PATH."),
1075    }
1076}
1077
1078fn configured_tool_hint(tool: &str, source: &str) -> String {
1079    // GitHub issue #47: editors launched from a non-login GUI shell (Spotlight,
1080    // Dock, Alfred, etc.) often don't inherit the user's full PATH, so a tool
1081    // that's installed but lives under /opt/homebrew/bin, ~/.cargo/bin, or
1082    // similar can fail this lookup. We already check those well-known
1083    // locations in `resolve_tool_uncached`; if we still didn't find the tool,
1084    // it's genuinely missing OR sits in an unusual install prefix.
1085    //
1086    // Word the message so users know to check both "is it installed at all"
1087    // and "is it on AFT's PATH" — rather than implying definite absence.
1088    format!(
1089        "{tool} is configured in {source} but was not found on PATH or in common install locations. {}",
1090        install_hint(tool)
1091    )
1092}
1093
1094fn missing_tool_warning(
1095    kind: &str,
1096    language: &str,
1097    candidate: &ToolCandidate,
1098    project_root: Option<&Path>,
1099) -> Option<MissingTool> {
1100    if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
1101        return None;
1102    }
1103
1104    Some(MissingTool {
1105        kind: kind.to_string(),
1106        language: language.to_string(),
1107        tool: candidate.tool.clone(),
1108        hint: configured_tool_hint(&candidate.tool, &candidate.source),
1109    })
1110}
1111
1112/// Detect configured formatters/checkers that are missing for languages present in the project.
1113pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
1114    let languages = languages_in_project(project_root);
1115    let mut warnings = Vec::new();
1116    let mut seen = HashSet::new();
1117
1118    for lang in languages {
1119        let language = lang_key(lang);
1120        let placeholder = placeholder_file_for_language(project_root, lang);
1121        let file_str = placeholder.to_string_lossy().to_string();
1122
1123        for candidate in formatter_candidates(lang, config, &file_str) {
1124            if let Some(warning) = missing_tool_warning(
1125                "formatter_not_installed",
1126                language,
1127                &candidate,
1128                config.project_root.as_deref(),
1129            ) {
1130                if seen.insert((
1131                    warning.kind.clone(),
1132                    warning.language.clone(),
1133                    warning.tool.clone(),
1134                )) {
1135                    warnings.push(warning);
1136                }
1137            }
1138        }
1139
1140        for candidate in checker_candidates(lang, config, &file_str) {
1141            if let Some(warning) = missing_tool_warning(
1142                "checker_not_installed",
1143                language,
1144                &candidate,
1145                config.project_root.as_deref(),
1146            ) {
1147                if seen.insert((
1148                    warning.kind.clone(),
1149                    warning.language.clone(),
1150                    warning.tool.clone(),
1151                )) {
1152                    warnings.push(warning);
1153                }
1154            }
1155        }
1156    }
1157
1158    warnings.sort_by(|left, right| {
1159        (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1160    });
1161    warnings
1162}
1163
1164/// Detect the appropriate formatter command and arguments for a file.
1165///
1166/// Priority per language:
1167/// - TypeScript/JavaScript/TSX: `prettier --write <file>`
1168/// - Python: `ruff format <file>` (fallback: `black <file>`)
1169/// - Rust: `rustfmt <file>`
1170/// - Go: `gofmt -w <file>`
1171///
1172/// Returns `None` if no formatter is available for the language.
1173pub fn detect_formatter(
1174    path: &Path,
1175    lang: LangId,
1176    config: &Config,
1177) -> Option<(String, Vec<String>)> {
1178    match detect_formatter_for_path(path, lang, config) {
1179        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1180        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1181    }
1182}
1183
1184/// Check if any of the given config file names exist in the project root.
1185fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1186    let root = match project_root {
1187        Some(r) => r,
1188        None => return false,
1189    };
1190    filenames.iter().any(|f| root.join(f).exists())
1191}
1192
1193/// Check if pyproject.toml exists and contains a `[tool.<name>]` section.
1194fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1195    let root = match project_root {
1196        Some(r) => r,
1197        None => return false,
1198    };
1199    let pyproject = root.join("pyproject.toml");
1200    if !pyproject.exists() {
1201        return false;
1202    }
1203    match std::fs::read_to_string(&pyproject) {
1204        Ok(content) => {
1205            let pattern = format!("[tool.{}]", tool_name);
1206            content.contains(&pattern)
1207        }
1208        Err(_) => false,
1209    }
1210}
1211
1212/// Detect whether a non-zero formatter exit was caused by the formatter
1213/// intentionally excluding the path (per its own config) rather than an
1214/// actual formatter or input error.
1215///
1216/// The patterns below come from real stderr output observed during
1217/// dogfooding. They're intentionally substring-based and case-insensitive
1218/// so minor formatter version differences in wording don't bypass the
1219/// check. Each pattern corresponds to a specific formatter's exclusion
1220/// signal:
1221/// - biome: `"No files were processed in the specified paths."`,
1222///   `"ignored by the configuration"`
1223/// - oxfmt: `"Expected at least one target file"`,
1224///   `"No files found matching the given patterns"`
1225/// - prettier: `"No files matching the pattern were found"`
1226/// - ruff: `"No Python files found under the given path(s)"`
1227///
1228/// rustfmt and gofmt/goimports rarely scope-restrict and have no known
1229/// stable marker, so they're not detected here. They'll fall through to
1230/// the generic `"error"` reason — acceptable because they almost never
1231/// emit a path-exclusion exit in practice.
1232fn formatter_excluded_path(stderr: &str) -> bool {
1233    let s = stderr.to_lowercase();
1234    s.contains("no files were processed")
1235        || s.contains("ignored by the configuration")
1236        || s.contains("expected at least one target file")
1237        || s.contains("no files found matching the given patterns")
1238        || s.contains("no files matching the pattern")
1239        || s.contains("no python files found")
1240}
1241
1242/// Auto-format a file using the detected formatter for its language.
1243///
1244/// Returns `(formatted, skip_reason)`:
1245/// - `(true, None)` — file was successfully formatted
1246/// - `(false, Some(reason))` — formatting was skipped, reason explains why
1247///
1248/// Skip reasons:
1249/// - `"unsupported_language"` — language has no formatter support in AFT
1250/// - `"no_formatter_configured"` — `format_on_edit=false` or no formatter
1251///   detected for the language in the project
1252/// - `"formatter_not_installed"` — configured formatter binary missing on
1253///   PATH and not in project's `node_modules/.bin`
1254/// - `"formatter_excluded_path"` — formatter ran but refused to process this
1255///   path because the project formatter config (e.g. biome.json `files.includes`,
1256///   prettier `.prettierignore`) excludes it. NOT an error in AFT or the user's
1257///   formatter — the user told the formatter not to touch this path. Agents
1258///   should treat this as informational.
1259/// - `"timeout"` — formatter exceeded `formatter_timeout_secs`
1260/// - `"error"` — formatter exited non-zero with an unrecognized error
1261///   (likely a real bug in the user's input or the formatter itself)
1262pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1263    // Check if formatting is disabled via plugin config
1264    if !config.format_on_edit {
1265        return (false, Some("no_formatter_configured".to_string()));
1266    }
1267
1268    let lang = match detect_language(path) {
1269        Some(l) => l,
1270        None => {
1271            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1272            return (false, Some("unsupported_language".to_string()));
1273        }
1274    };
1275    if !has_formatter_support(lang) {
1276        log::debug!("format: {} (skipped: unsupported_language)", path.display());
1277        return (false, Some("unsupported_language".to_string()));
1278    }
1279
1280    let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1281        ToolDetection::Found(cmd, args) => (cmd, args),
1282        ToolDetection::NotConfigured => {
1283            log::debug!(
1284                "format: {} (skipped: no_formatter_configured)",
1285                path.display()
1286            );
1287            return (false, Some("no_formatter_configured".to_string()));
1288        }
1289        ToolDetection::NotInstalled { tool } => {
1290            crate::slog_warn!(
1291                "format: {} (skipped: formatter_not_installed: {})",
1292                path.display(),
1293                tool
1294            );
1295            return (false, Some("formatter_not_installed".to_string()));
1296        }
1297    };
1298
1299    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1300
1301    // Run the formatter in the project root so tool-local config files
1302    // (biome.json, .prettierrc, rustfmt.toml, etc.) are discovered. The
1303    // type-checker path (`validate_full`) already does this via
1304    // `path.parent()`; formatters need the same treatment. Without it,
1305    // formatters silently fall back to built-in defaults when the aft
1306    // process CWD differs from the project root (audit #18).
1307    let working_dir = config.project_root.as_deref();
1308
1309    match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1310        Ok(_) => {
1311            crate::slog_info!("format: {} ({})", path.display(), cmd);
1312            (true, None)
1313        }
1314        Err(FormatError::Timeout { .. }) => {
1315            crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1316            (false, Some("timeout".to_string()))
1317        }
1318        Err(FormatError::NotFound { .. }) => {
1319            crate::slog_warn!(
1320                "format: {} (skipped: formatter_not_installed)",
1321                path.display()
1322            );
1323            (false, Some("formatter_not_installed".to_string()))
1324        }
1325        Err(FormatError::Failed { stderr, .. }) => {
1326            // Distinguish "formatter intentionally ignored this path" from
1327            // "formatter actually errored". Many formatters scope themselves
1328            // to a project subtree (biome.json `files.includes`, prettier
1329            // `.prettierignore`, ruff `[tool.ruff]` config) and exit non-zero
1330            // when invoked on a path outside that scope. From AFT's perspective
1331            // that's not an error — the user told the formatter not to touch
1332            // this path. But the previous code returned a generic `"error"`
1333            // skip reason and logged at `debug` (silent under default
1334            // RUST_LOG=info), so the agent had no signal that the file
1335            // landed unformatted. Detect the common stderr fingerprints and
1336            // return a distinct, surfaced skip reason.
1337            if formatter_excluded_path(&stderr) {
1338                crate::slog_info!(
1339                    "format: {} (skipped: formatter_excluded_path; stderr: {})",
1340                    path.display(),
1341                    stderr.lines().next().unwrap_or("").trim()
1342                );
1343                return (false, Some("formatter_excluded_path".to_string()));
1344            }
1345            crate::slog_warn!(
1346                "format: {} (skipped: error: {})",
1347                path.display(),
1348                stderr.lines().next().unwrap_or("unknown").trim()
1349            );
1350            (false, Some("error".to_string()))
1351        }
1352        Err(FormatError::UnsupportedLanguage) => {
1353            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1354            (false, Some("unsupported_language".to_string()))
1355        }
1356    }
1357}
1358
1359/// Spawn a subprocess and capture output regardless of exit code.
1360///
1361/// Unlike `run_external_tool`, this does NOT treat non-zero exit as an error —
1362/// type checkers return non-zero when they find issues, which is expected.
1363/// Returns `FormatError::NotFound` when the binary isn't on PATH, and
1364/// `FormatError::Timeout` if the deadline is exceeded.
1365pub fn run_external_tool_capture(
1366    command: &str,
1367    args: &[&str],
1368    working_dir: Option<&Path>,
1369    timeout_secs: u32,
1370) -> Result<ExternalToolResult, FormatError> {
1371    let mut cmd = Command::new(command);
1372    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1373
1374    if let Some(dir) = working_dir {
1375        cmd.current_dir(dir);
1376    }
1377
1378    isolate_in_process_group(&mut cmd);
1379
1380    let child = match cmd.spawn() {
1381        Ok(c) => c,
1382        Err(e) if e.kind() == ErrorKind::NotFound => {
1383            return Err(FormatError::NotFound {
1384                tool: command.to_string(),
1385            });
1386        }
1387        Err(e) => {
1388            return Err(FormatError::Failed {
1389                tool: command.to_string(),
1390                stderr: e.to_string(),
1391            });
1392        }
1393    };
1394
1395    let outcome = wait_with_timeout(child, command, timeout_secs)?;
1396    Ok(ExternalToolResult {
1397        stdout: outcome.stdout,
1398        stderr: outcome.stderr,
1399        exit_code: outcome.status.code().unwrap_or(-1),
1400    })
1401}
1402
1403// ============================================================================
1404// Type-checker validation (R017)
1405// ============================================================================
1406
1407/// A structured error from a type checker.
1408#[derive(Debug, Clone, serde::Serialize)]
1409pub struct ValidationError {
1410    pub line: u32,
1411    pub column: u32,
1412    pub message: String,
1413    pub severity: String,
1414}
1415
1416/// Detect the appropriate type checker command and arguments for a file.
1417///
1418/// Returns `(command, args)` for the type checker. The `--noEmit` / equivalent
1419/// flags ensure no output files are produced.
1420///
1421/// Supported:
1422/// - TypeScript/JavaScript/TSX → `tsc --noEmit` (or `tsgo --noEmit` when explicitly configured)
1423/// - Python → `pyright`
1424/// - Rust → `cargo check`
1425/// - Go → `go vet`
1426pub fn detect_type_checker(
1427    path: &Path,
1428    lang: LangId,
1429    config: &Config,
1430) -> Option<(String, Vec<String>)> {
1431    match detect_checker_for_path(path, lang, config) {
1432        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1433        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1434    }
1435}
1436
1437/// Parse type checker output into structured validation errors.
1438///
1439/// Handles output formats from tsc, pyright (JSON), cargo check (JSON), and go vet.
1440/// Filters to errors related to the edited file where feasible.
1441pub fn parse_checker_output(
1442    stdout: &str,
1443    stderr: &str,
1444    file: &Path,
1445    checker: &str,
1446) -> Vec<ValidationError> {
1447    let checker_name = Path::new(checker)
1448        .file_name()
1449        .and_then(|name| name.to_str())
1450        .unwrap_or(checker);
1451    match checker_name {
1452        "npx" | "tsc" | "tsgo" => parse_tsc_output(stdout, stderr, file),
1453        "pyright" => parse_pyright_output(stdout, file),
1454        "cargo" => parse_cargo_output(stdout, stderr, file),
1455        "go" => parse_go_vet_output(stderr, file),
1456        _ => Vec::new(),
1457    }
1458}
1459
1460/// Parse tsc output lines like: `path(line,col): error TSxxxx: message`
1461fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1462    let mut errors = Vec::new();
1463    let file_str = file.to_string_lossy();
1464    // tsc writes diagnostics to stdout (with --pretty false)
1465    let combined = format!("{}{}", stdout, stderr);
1466    for line in combined.lines() {
1467        // Format: path(line,col): severity TSxxxx: message
1468        // or: path(line,col): severity: message
1469        if let Some((loc, rest)) = line.split_once("): ") {
1470            // Check if this error is for our file (compare filename part)
1471            let file_part = loc.split('(').next().unwrap_or("");
1472            if !file_str.ends_with(file_part)
1473                && !file_part.ends_with(&*file_str)
1474                && file_part != &*file_str
1475            {
1476                continue;
1477            }
1478
1479            // Parse (line,col) from the location part
1480            let coords = loc.split('(').last().unwrap_or("");
1481            let parts: Vec<&str> = coords.split(',').collect();
1482            let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1483            let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1484
1485            // Parse severity and message
1486            let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1487                ("error".to_string(), msg.to_string())
1488            } else if let Some(msg) = rest.strip_prefix("warning ") {
1489                ("warning".to_string(), msg.to_string())
1490            } else {
1491                ("error".to_string(), rest.to_string())
1492            };
1493
1494            errors.push(ValidationError {
1495                line: line_num,
1496                column: col_num,
1497                message,
1498                severity,
1499            });
1500        }
1501    }
1502    errors
1503}
1504
1505/// Parse pyright JSON output.
1506fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1507    let mut errors = Vec::new();
1508    let file_str = file.to_string_lossy();
1509
1510    // pyright --outputjson emits JSON with generalDiagnostics array
1511    if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1512        if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1513            for diag in diags {
1514                // Filter to our file
1515                let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1516                if !diag_file.is_empty()
1517                    && !file_str.ends_with(diag_file)
1518                    && !diag_file.ends_with(&*file_str)
1519                    && diag_file != &*file_str
1520                {
1521                    continue;
1522                }
1523
1524                let line_num = diag
1525                    .get("range")
1526                    .and_then(|r| r.get("start"))
1527                    .and_then(|s| s.get("line"))
1528                    .and_then(|l| l.as_u64())
1529                    .unwrap_or(0) as u32;
1530                let col_num = diag
1531                    .get("range")
1532                    .and_then(|r| r.get("start"))
1533                    .and_then(|s| s.get("character"))
1534                    .and_then(|c| c.as_u64())
1535                    .unwrap_or(0) as u32;
1536                let message = diag
1537                    .get("message")
1538                    .and_then(|m| m.as_str())
1539                    .unwrap_or("unknown error")
1540                    .to_string();
1541                let severity = diag
1542                    .get("severity")
1543                    .and_then(|s| s.as_str())
1544                    .unwrap_or("error")
1545                    .to_lowercase();
1546
1547                errors.push(ValidationError {
1548                    line: line_num + 1, // pyright uses 0-indexed lines
1549                    column: col_num,
1550                    message,
1551                    severity,
1552                });
1553            }
1554        }
1555    }
1556    errors
1557}
1558
1559/// Parse cargo check JSON output, filtering to errors in the target file.
1560fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1561    let mut errors = Vec::new();
1562    let file_str = file.to_string_lossy();
1563
1564    for line in stdout.lines() {
1565        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1566            if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1567                continue;
1568            }
1569            let message_obj = match msg.get("message") {
1570                Some(m) => m,
1571                None => continue,
1572            };
1573
1574            let level = message_obj
1575                .get("level")
1576                .and_then(|l| l.as_str())
1577                .unwrap_or("error");
1578
1579            // Only include errors and warnings, skip notes/help
1580            if level != "error" && level != "warning" {
1581                continue;
1582            }
1583
1584            let text = message_obj
1585                .get("message")
1586                .and_then(|m| m.as_str())
1587                .unwrap_or("unknown error")
1588                .to_string();
1589
1590            // Find the primary span for our file
1591            if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1592                for span in spans {
1593                    let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1594                    let is_primary = span
1595                        .get("is_primary")
1596                        .and_then(|p| p.as_bool())
1597                        .unwrap_or(false);
1598
1599                    if !is_primary {
1600                        continue;
1601                    }
1602
1603                    // Filter to our file
1604                    if !file_str.ends_with(span_file)
1605                        && !span_file.ends_with(&*file_str)
1606                        && span_file != &*file_str
1607                    {
1608                        continue;
1609                    }
1610
1611                    let line_num =
1612                        span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1613                    let col_num = span
1614                        .get("column_start")
1615                        .and_then(|c| c.as_u64())
1616                        .unwrap_or(0) as u32;
1617
1618                    errors.push(ValidationError {
1619                        line: line_num,
1620                        column: col_num,
1621                        message: text.clone(),
1622                        severity: level.to_string(),
1623                    });
1624                }
1625            }
1626        }
1627    }
1628    errors
1629}
1630
1631/// Parse go vet output lines like: `path:line:col: message`
1632fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1633    let mut errors = Vec::new();
1634    let file_str = file.to_string_lossy();
1635
1636    for line in stderr.lines() {
1637        // Format: path:line:col: message  OR  path:line: message
1638        let parts: Vec<&str> = line.splitn(4, ':').collect();
1639        if parts.len() < 3 {
1640            continue;
1641        }
1642
1643        let err_file = parts[0].trim();
1644        if !file_str.ends_with(err_file)
1645            && !err_file.ends_with(&*file_str)
1646            && err_file != &*file_str
1647        {
1648            continue;
1649        }
1650
1651        let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1652        let (col_num, message) = if parts.len() >= 4 {
1653            if let Ok(col) = parts[2].trim().parse::<u32>() {
1654                (col, parts[3].trim().to_string())
1655            } else {
1656                // parts[2] is part of the message, not a column
1657                (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1658            }
1659        } else {
1660            (0, parts[2].trim().to_string())
1661        };
1662
1663        errors.push(ValidationError {
1664            line: line_num,
1665            column: col_num,
1666            message,
1667            severity: "error".to_string(),
1668        });
1669    }
1670    errors
1671}
1672
1673/// Run the project's type checker and return structured validation errors.
1674///
1675/// Returns `(errors, skip_reason)`:
1676/// - `(errors, None)` — checker ran, errors may be empty (= valid code)
1677/// - `([], Some(reason))` — checker was skipped
1678///
1679/// Skip reasons: `"unsupported_language"`, `"no_checker_configured"`,
1680/// `"checker_not_installed"`, `"timeout"`, `"error"`
1681pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1682    let lang = match detect_language(path) {
1683        Some(l) => l,
1684        None => {
1685            log::debug!(
1686                "validate: {} (skipped: unsupported_language)",
1687                path.display()
1688            );
1689            return (Vec::new(), Some("unsupported_language".to_string()));
1690        }
1691    };
1692    if !has_checker_support(lang) {
1693        log::debug!(
1694            "validate: {} (skipped: unsupported_language)",
1695            path.display()
1696        );
1697        return (Vec::new(), Some("unsupported_language".to_string()));
1698    }
1699
1700    let (cmd, args) = match detect_checker_for_path(path, lang, config) {
1701        ToolDetection::Found(cmd, args) => (cmd, args),
1702        ToolDetection::NotConfigured => {
1703            log::debug!(
1704                "validate: {} (skipped: no_checker_configured)",
1705                path.display()
1706            );
1707            return (Vec::new(), Some("no_checker_configured".to_string()));
1708        }
1709        ToolDetection::NotInstalled { tool } => {
1710            crate::slog_warn!(
1711                "validate: {} (skipped: checker_not_installed: {})",
1712                path.display(),
1713                tool
1714            );
1715            return (Vec::new(), Some("checker_not_installed".to_string()));
1716        }
1717    };
1718
1719    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1720
1721    // Type checkers may need to run from the project root
1722    let working_dir = config.project_root.as_deref();
1723
1724    match run_external_tool_capture(
1725        &cmd,
1726        &arg_refs,
1727        working_dir,
1728        config.type_checker_timeout_secs,
1729    ) {
1730        Ok(result) => {
1731            let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1732            log::debug!(
1733                "validate: {} ({}, {} errors)",
1734                path.display(),
1735                cmd,
1736                errors.len()
1737            );
1738            (errors, None)
1739        }
1740        Err(FormatError::Timeout { .. }) => {
1741            crate::slog_error!("validate: {} (skipped: timeout)", path.display());
1742            (Vec::new(), Some("timeout".to_string()))
1743        }
1744        Err(FormatError::NotFound { .. }) => {
1745            crate::slog_warn!(
1746                "validate: {} (skipped: checker_not_installed)",
1747                path.display()
1748            );
1749            (Vec::new(), Some("checker_not_installed".to_string()))
1750        }
1751        Err(FormatError::Failed { stderr, .. }) => {
1752            log::debug!(
1753                "validate: {} (skipped: error: {})",
1754                path.display(),
1755                stderr.lines().next().unwrap_or("unknown")
1756            );
1757            (Vec::new(), Some("error".to_string()))
1758        }
1759        Err(FormatError::UnsupportedLanguage) => {
1760            log::debug!(
1761                "validate: {} (skipped: unsupported_language)",
1762                path.display()
1763            );
1764            (Vec::new(), Some("unsupported_language".to_string()))
1765        }
1766    }
1767}
1768
1769#[cfg(test)]
1770mod tests {
1771    use super::*;
1772    use std::fs;
1773    use std::io::Write;
1774    use std::sync::{Mutex, MutexGuard, OnceLock};
1775
1776    /// Serializes tests that mutate the global TOOL_RESOLUTION_CACHE /
1777    /// TOOL_AVAILABILITY_CACHE. Cargo runs tests in parallel by default, and
1778    /// `clear_tool_cache()` from one test would otherwise wipe cached entries
1779    /// that another test had just written, causing flaky CI failures (the
1780    /// `resolve_tool_caches_negative_result_until_clear` failure on Linux
1781    /// runners had exactly this shape).
1782    fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
1783        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1784        let mutex = LOCK.get_or_init(|| Mutex::new(()));
1785        // Recover from poisoning so a panic in one test doesn't permanently
1786        // wedge the rest of the suite.
1787        match mutex.lock() {
1788            Ok(guard) => guard,
1789            Err(poisoned) => poisoned.into_inner(),
1790        }
1791    }
1792
1793    #[test]
1794    fn run_external_tool_not_found() {
1795        let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1796        assert!(result.is_err());
1797        match result.unwrap_err() {
1798            FormatError::NotFound { tool } => {
1799                assert_eq!(tool, "__nonexistent_tool_xyz__");
1800            }
1801            other => panic!("expected NotFound, got: {:?}", other),
1802        }
1803    }
1804
1805    #[test]
1806    fn run_external_tool_timeout_kills_subprocess() {
1807        // Use `sleep 60` as a long-running process, timeout after 1 second
1808        let result = run_external_tool("sleep", &["60"], None, 1);
1809        assert!(result.is_err());
1810        match result.unwrap_err() {
1811            FormatError::Timeout { tool, timeout_secs } => {
1812                assert_eq!(tool, "sleep");
1813                assert_eq!(timeout_secs, 1);
1814            }
1815            other => panic!("expected Timeout, got: {:?}", other),
1816        }
1817    }
1818
1819    #[test]
1820    fn run_external_tool_success() {
1821        let result = run_external_tool("echo", &["hello"], None, 5);
1822        assert!(result.is_ok());
1823        let res = result.unwrap();
1824        assert_eq!(res.exit_code, 0);
1825        assert!(res.stdout.contains("hello"));
1826    }
1827
1828    #[cfg(unix)]
1829    #[test]
1830    fn format_helper_handles_large_stderr_without_deadlock() {
1831        let start = Instant::now();
1832        let result = run_external_tool_capture(
1833            "sh",
1834            &[
1835                "-c",
1836                "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
1837            ],
1838            None,
1839            2,
1840        )
1841        .expect("large stderr command should complete");
1842
1843        assert_eq!(result.exit_code, 0);
1844        assert!(
1845            result.stderr.len() >= 1024 * 1024,
1846            "expected full stderr capture, got {} bytes",
1847            result.stderr.len()
1848        );
1849        assert!(start.elapsed() < Duration::from_secs(2));
1850    }
1851
1852    #[test]
1853    fn run_external_tool_nonzero_exit() {
1854        // `false` always exits with code 1
1855        let result = run_external_tool("false", &[], None, 5);
1856        assert!(result.is_err());
1857        match result.unwrap_err() {
1858            FormatError::Failed { tool, .. } => {
1859                assert_eq!(tool, "false");
1860            }
1861            other => panic!("expected Failed, got: {:?}", other),
1862        }
1863    }
1864
1865    #[test]
1866    fn auto_format_unsupported_language() {
1867        let dir = tempfile::tempdir().unwrap();
1868        let path = dir.path().join("file.txt");
1869        fs::write(&path, "hello").unwrap();
1870
1871        let config = Config::default();
1872        let (formatted, reason) = auto_format(&path, &config);
1873        assert!(!formatted);
1874        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1875    }
1876
1877    #[test]
1878    fn detect_formatter_rust_when_rustfmt_available() {
1879        let dir = tempfile::tempdir().unwrap();
1880        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1881        let path = dir.path().join("test.rs");
1882        let config = Config {
1883            project_root: Some(dir.path().to_path_buf()),
1884            ..Config::default()
1885        };
1886        let result = detect_formatter(&path, LangId::Rust, &config);
1887        if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
1888            let (cmd, args) = result.unwrap();
1889            assert_eq!(cmd, "rustfmt");
1890            assert!(args.iter().any(|a| a.ends_with("test.rs")));
1891        } else {
1892            assert!(result.is_none());
1893        }
1894    }
1895
1896    #[test]
1897    fn detect_formatter_go_mapping() {
1898        let dir = tempfile::tempdir().unwrap();
1899        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1900        let path = dir.path().join("main.go");
1901        let config = Config {
1902            project_root: Some(dir.path().to_path_buf()),
1903            ..Config::default()
1904        };
1905        let result = detect_formatter(&path, LangId::Go, &config);
1906        if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
1907            let (cmd, args) = result.unwrap();
1908            assert_eq!(cmd, "goimports");
1909            assert!(args.contains(&"-w".to_string()));
1910        } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
1911            let (cmd, args) = result.unwrap();
1912            assert_eq!(cmd, "gofmt");
1913            assert!(args.contains(&"-w".to_string()));
1914        } else {
1915            assert!(result.is_none());
1916        }
1917    }
1918
1919    #[test]
1920    fn detect_formatter_python_mapping() {
1921        let dir = tempfile::tempdir().unwrap();
1922        fs::write(dir.path().join("ruff.toml"), "").unwrap();
1923        let path = dir.path().join("main.py");
1924        let config = Config {
1925            project_root: Some(dir.path().to_path_buf()),
1926            ..Config::default()
1927        };
1928        let result = detect_formatter(&path, LangId::Python, &config);
1929        if ruff_format_available(config.project_root.as_deref()) {
1930            let (cmd, args) = result.unwrap();
1931            assert_eq!(cmd, "ruff");
1932            assert!(args.contains(&"format".to_string()));
1933        } else {
1934            assert!(result.is_none());
1935        }
1936    }
1937
1938    #[test]
1939    fn detect_formatter_no_config_returns_none() {
1940        let path = Path::new("test.ts");
1941        let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1942        assert!(
1943            result.is_none(),
1944            "expected no formatter without project config"
1945        );
1946    }
1947
1948    #[cfg(unix)]
1949    #[test]
1950    fn detect_formatter_oxfmt_config_for_typescript_projects() {
1951        let _guard = tool_cache_test_lock();
1952        clear_tool_cache();
1953        let dir = tempfile::tempdir().unwrap();
1954        fs::write(dir.path().join(".oxfmtrc.json"), "{}\n").unwrap();
1955        let bin_dir = dir.path().join("node_modules").join(".bin");
1956        fs::create_dir_all(&bin_dir).unwrap();
1957        let fake = bin_dir.join("oxfmt");
1958        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1959        use std::os::unix::fs::PermissionsExt;
1960        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1961
1962        let path = dir.path().join("src/app.ts");
1963        let config = Config {
1964            project_root: Some(dir.path().to_path_buf()),
1965            ..Config::default()
1966        };
1967
1968        let (cmd, args) = detect_formatter(&path, LangId::TypeScript, &config).unwrap();
1969        assert!(cmd.ends_with("oxfmt"), "expected oxfmt, got {cmd}");
1970        assert_eq!(args[0], "--write");
1971        assert!(args.iter().any(|arg| arg.ends_with("src/app.ts")));
1972    }
1973
1974    // Unix-only: `resolve_tool_uncached` checks `node_modules/.bin/<name>`
1975    // without trying Windows extensions (.cmd/.exe/.bat). Writing
1976    // `biome.cmd` would not be found by the resolver. A future product
1977    // fix could extend resolve_tool to honor PATHEXT; for now this test
1978    // focuses on the explicit-override semantics on Unix.
1979    #[cfg(unix)]
1980    #[test]
1981    fn detect_formatter_explicit_override() {
1982        // Create a temp dir with a fake node_modules/.bin/biome so resolve_tool finds it
1983        let dir = tempfile::tempdir().unwrap();
1984        let bin_dir = dir.path().join("node_modules").join(".bin");
1985        fs::create_dir_all(&bin_dir).unwrap();
1986        use std::os::unix::fs::PermissionsExt;
1987        let fake = bin_dir.join("biome");
1988        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1989        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1990
1991        let path = Path::new("test.ts");
1992        let mut config = Config {
1993            project_root: Some(dir.path().to_path_buf()),
1994            ..Config::default()
1995        };
1996        config
1997            .formatter
1998            .insert("typescript".to_string(), "biome".to_string());
1999        let result = detect_formatter(path, LangId::TypeScript, &config);
2000        let (cmd, args) = result.unwrap();
2001        assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
2002        assert!(args.contains(&"format".to_string()));
2003        assert!(args.contains(&"--write".to_string()));
2004    }
2005
2006    #[cfg(unix)]
2007    #[test]
2008    fn detect_formatter_explicit_oxfmt_override() {
2009        let _guard = tool_cache_test_lock();
2010        clear_tool_cache();
2011        let dir = tempfile::tempdir().unwrap();
2012        let bin_dir = dir.path().join("node_modules").join(".bin");
2013        fs::create_dir_all(&bin_dir).unwrap();
2014        use std::os::unix::fs::PermissionsExt;
2015        let fake = bin_dir.join("oxfmt");
2016        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2017        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2018
2019        let path = Path::new("test.ts");
2020        let mut config = Config {
2021            project_root: Some(dir.path().to_path_buf()),
2022            ..Config::default()
2023        };
2024        config
2025            .formatter
2026            .insert("typescript".to_string(), "oxfmt".to_string());
2027
2028        let (cmd, args) = detect_formatter(path, LangId::TypeScript, &config).unwrap();
2029        assert!(cmd.contains("oxfmt"), "expected oxfmt in cmd, got: {cmd}");
2030        assert_eq!(args, vec!["--write".to_string(), "test.ts".to_string()]);
2031    }
2032
2033    #[test]
2034    fn resolve_tool_caches_positive_result_until_clear() {
2035        let _guard = tool_cache_test_lock();
2036        clear_tool_cache();
2037        let dir = tempfile::tempdir().unwrap();
2038        let bin_dir = dir.path().join("node_modules").join(".bin");
2039        fs::create_dir_all(&bin_dir).unwrap();
2040        let tool = bin_dir.join("aft-cache-hit-tool");
2041        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2042
2043        let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2044        assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
2045
2046        fs::remove_file(&tool).unwrap();
2047        let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2048        assert_eq!(cached, first);
2049
2050        clear_tool_cache();
2051        assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
2052    }
2053
2054    #[test]
2055    fn resolve_tool_caches_negative_result_until_clear() {
2056        let _guard = tool_cache_test_lock();
2057        clear_tool_cache();
2058        let dir = tempfile::tempdir().unwrap();
2059        let bin_dir = dir.path().join("node_modules").join(".bin");
2060        let tool = bin_dir.join("aft-cache-miss-tool");
2061
2062        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2063
2064        fs::create_dir_all(&bin_dir).unwrap();
2065        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2066        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2067
2068        clear_tool_cache();
2069        assert_eq!(
2070            resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
2071            Some(tool.to_string_lossy().as_ref())
2072        );
2073    }
2074
2075    #[test]
2076    fn auto_format_happy_path_rustfmt() {
2077        if resolve_tool("rustfmt", None).is_none() {
2078            crate::slog_warn!("skipping: rustfmt not available");
2079            return;
2080        }
2081
2082        let dir = tempfile::tempdir().unwrap();
2083        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2084        let path = dir.path().join("test.rs");
2085
2086        let mut f = fs::File::create(&path).unwrap();
2087        writeln!(f, "fn    main()   {{  println!(\"hello\");  }}").unwrap();
2088        drop(f);
2089
2090        let config = Config {
2091            project_root: Some(dir.path().to_path_buf()),
2092            ..Config::default()
2093        };
2094        let (formatted, reason) = auto_format(&path, &config);
2095        assert!(formatted, "expected formatting to succeed");
2096        assert!(reason.is_none());
2097
2098        let content = fs::read_to_string(&path).unwrap();
2099        assert!(
2100            !content.contains("fn    main"),
2101            "expected rustfmt to fix spacing"
2102        );
2103    }
2104
2105    #[test]
2106    fn formatter_excluded_path_detects_biome_messages() {
2107        // Real biome 1.x output when invoked on a path outside files.includes.
2108        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";
2109        assert!(
2110            formatter_excluded_path(stderr),
2111            "expected biome exclusion stderr to be detected"
2112        );
2113    }
2114
2115    #[test]
2116    fn formatter_excluded_path_detects_prettier_messages() {
2117        // Real prettier output when given a glob/path that resolves to nothing
2118        // it's allowed to format (after .prettierignore filtering).
2119        let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
2120        assert!(
2121            formatter_excluded_path(stderr),
2122            "expected prettier exclusion stderr to be detected"
2123        );
2124    }
2125
2126    #[test]
2127    fn formatter_excluded_path_detects_oxfmt_messages() {
2128        assert!(formatter_excluded_path(
2129            "Expected at least one target file. All matched files may have been excluded by ignore rules."
2130        ));
2131        assert!(formatter_excluded_path(
2132            "No files found matching the given patterns."
2133        ));
2134    }
2135
2136    #[test]
2137    fn formatter_excluded_path_detects_ruff_messages() {
2138        // Real ruff output when invoked outside its [tool.ruff] scope.
2139        let stderr = "warning: No Python files found under the given path(s).\n";
2140        assert!(
2141            formatter_excluded_path(stderr),
2142            "expected ruff exclusion stderr to be detected"
2143        );
2144    }
2145
2146    #[test]
2147    fn formatter_excluded_path_is_case_insensitive() {
2148        assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
2149        assert!(formatter_excluded_path("Ignored By The Configuration"));
2150        assert!(formatter_excluded_path("EXPECTED AT LEAST ONE TARGET FILE"));
2151    }
2152
2153    #[test]
2154    fn formatter_excluded_path_rejects_real_errors() {
2155        // Counter-cases: actual formatter errors must NOT be treated as
2156        // exclusion. This guards against the detection being too greedy.
2157        assert!(!formatter_excluded_path(""));
2158        assert!(!formatter_excluded_path("syntax error: unexpected token"));
2159        assert!(!formatter_excluded_path("formatter crashed: out of memory"));
2160        assert!(!formatter_excluded_path(
2161            "permission denied: /readonly/file"
2162        ));
2163        assert!(!formatter_excluded_path(
2164            "biome internal error: please report"
2165        ));
2166    }
2167
2168    #[test]
2169    fn parse_tsc_output_basic() {
2170        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";
2171        let file = Path::new("src/app.ts");
2172        let errors = parse_tsc_output(stdout, "", file);
2173        assert_eq!(errors.len(), 2);
2174        assert_eq!(errors[0].line, 10);
2175        assert_eq!(errors[0].column, 5);
2176        assert_eq!(errors[0].severity, "error");
2177        assert!(errors[0].message.contains("TS2322"));
2178        assert_eq!(errors[1].line, 20);
2179    }
2180
2181    #[test]
2182    fn parse_tsc_output_filters_other_files() {
2183        let stdout =
2184            "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
2185        let file = Path::new("src/app.ts");
2186        let errors = parse_tsc_output(stdout, "", file);
2187        assert_eq!(errors.len(), 1);
2188        assert_eq!(errors[0].line, 5);
2189    }
2190
2191    #[test]
2192    fn parse_cargo_output_basic() {
2193        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}]}}"#;
2194        let file = Path::new("src/main.rs");
2195        let errors = parse_cargo_output(json_line, "", file);
2196        assert_eq!(errors.len(), 1);
2197        assert_eq!(errors[0].line, 10);
2198        assert_eq!(errors[0].column, 5);
2199        assert_eq!(errors[0].severity, "error");
2200        assert!(errors[0].message.contains("mismatched types"));
2201    }
2202
2203    #[test]
2204    fn parse_cargo_output_skips_notes() {
2205        // Notes and help messages should be filtered out
2206        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}]}}"#;
2207        let file = Path::new("src/main.rs");
2208        let errors = parse_cargo_output(json_line, "", file);
2209        assert_eq!(errors.len(), 0);
2210    }
2211
2212    #[test]
2213    fn parse_cargo_output_filters_other_files() {
2214        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}]}}"#;
2215        let file = Path::new("src/main.rs");
2216        let errors = parse_cargo_output(json_line, "", file);
2217        assert_eq!(errors.len(), 0);
2218    }
2219
2220    #[test]
2221    fn parse_go_vet_output_basic() {
2222        let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2223        let file = Path::new("main.go");
2224        let errors = parse_go_vet_output(stderr, file);
2225        assert_eq!(errors.len(), 2);
2226        assert_eq!(errors[0].line, 10);
2227        assert_eq!(errors[0].column, 5);
2228        assert!(errors[0].message.contains("unreachable code"));
2229        assert_eq!(errors[1].line, 20);
2230        assert_eq!(errors[1].column, 0);
2231    }
2232
2233    #[test]
2234    fn parse_pyright_output_basic() {
2235        let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2236        let file = Path::new("test.py");
2237        let errors = parse_pyright_output(stdout, file);
2238        assert_eq!(errors.len(), 1);
2239        assert_eq!(errors[0].line, 5); // 0-indexed → 1-indexed
2240        assert_eq!(errors[0].column, 10);
2241        assert_eq!(errors[0].severity, "error");
2242        assert!(errors[0].message.contains("Type error here"));
2243    }
2244
2245    #[test]
2246    fn validate_full_unsupported_language() {
2247        let dir = tempfile::tempdir().unwrap();
2248        let path = dir.path().join("file.txt");
2249        fs::write(&path, "hello").unwrap();
2250
2251        let config = Config::default();
2252        let (errors, reason) = validate_full(&path, &config);
2253        assert!(errors.is_empty());
2254        assert_eq!(reason.as_deref(), Some("unsupported_language"));
2255    }
2256
2257    #[test]
2258    fn detect_type_checker_rust() {
2259        let dir = tempfile::tempdir().unwrap();
2260        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2261        let path = dir.path().join("src/main.rs");
2262        let config = Config {
2263            project_root: Some(dir.path().to_path_buf()),
2264            ..Config::default()
2265        };
2266        let result = detect_type_checker(&path, LangId::Rust, &config);
2267        if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2268            let (cmd, args) = result.unwrap();
2269            assert_eq!(cmd, "cargo");
2270            assert!(args.contains(&"check".to_string()));
2271        } else {
2272            assert!(result.is_none());
2273        }
2274    }
2275
2276    #[test]
2277    fn detect_type_checker_go() {
2278        let dir = tempfile::tempdir().unwrap();
2279        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2280        let path = dir.path().join("main.go");
2281        let config = Config {
2282            project_root: Some(dir.path().to_path_buf()),
2283            ..Config::default()
2284        };
2285        let result = detect_type_checker(&path, LangId::Go, &config);
2286        if resolve_tool("go", config.project_root.as_deref()).is_some() {
2287            let (cmd, _args) = result.unwrap();
2288            // Could be staticcheck or go vet depending on what's installed
2289            assert!(cmd == "go" || cmd == "staticcheck");
2290        } else {
2291            assert!(result.is_none());
2292        }
2293    }
2294
2295    #[cfg(unix)]
2296    #[test]
2297    fn detect_type_checker_defaults_to_tsc_for_typescript() {
2298        let _guard = tool_cache_test_lock();
2299        clear_tool_cache();
2300        let dir = tempfile::tempdir().unwrap();
2301        fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2302        let bin_dir = dir.path().join("node_modules").join(".bin");
2303        fs::create_dir_all(&bin_dir).unwrap();
2304        use std::os::unix::fs::PermissionsExt;
2305        let fake_tsc = bin_dir.join("tsc");
2306        fs::write(&fake_tsc, "#!/bin/sh\nexit 0").unwrap();
2307        fs::set_permissions(&fake_tsc, fs::Permissions::from_mode(0o755)).unwrap();
2308        let fake_tsgo = bin_dir.join("tsgo");
2309        fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2310        fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2311
2312        let path = dir.path().join("src/app.ts");
2313        let config = Config {
2314            project_root: Some(dir.path().to_path_buf()),
2315            ..Config::default()
2316        };
2317
2318        let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2319        assert!(cmd.ends_with("tsc"), "expected tsc by default, got: {cmd}");
2320        assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2321    }
2322
2323    #[cfg(unix)]
2324    #[test]
2325    fn detect_type_checker_uses_tsgo_when_explicitly_configured() {
2326        let _guard = tool_cache_test_lock();
2327        clear_tool_cache();
2328        let dir = tempfile::tempdir().unwrap();
2329        fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2330        let bin_dir = dir.path().join("node_modules").join(".bin");
2331        fs::create_dir_all(&bin_dir).unwrap();
2332        use std::os::unix::fs::PermissionsExt;
2333        let fake_tsgo = bin_dir.join("tsgo");
2334        fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2335        fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2336
2337        let path = dir.path().join("src/app.ts");
2338        let mut config = Config {
2339            project_root: Some(dir.path().to_path_buf()),
2340            ..Config::default()
2341        };
2342        config
2343            .checker
2344            .insert("typescript".to_string(), "tsgo".to_string());
2345
2346        let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2347        assert!(cmd.ends_with("tsgo"), "expected tsgo, got: {cmd}");
2348        assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2349    }
2350
2351    #[cfg(unix)]
2352    #[test]
2353    fn validate_full_explicit_tsgo_parses_diagnostics() {
2354        let _guard = tool_cache_test_lock();
2355        clear_tool_cache();
2356        let dir = tempfile::tempdir().unwrap();
2357        fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2358        let src_dir = dir.path().join("src");
2359        fs::create_dir_all(&src_dir).unwrap();
2360        let path = src_dir.join("app.ts");
2361        fs::write(&path, "const value: number = 'nope';\n").unwrap();
2362
2363        let bin_dir = dir.path().join("node_modules").join(".bin");
2364        fs::create_dir_all(&bin_dir).unwrap();
2365        use std::os::unix::fs::PermissionsExt;
2366        let fake_tsgo = bin_dir.join("tsgo");
2367        fs::write(
2368            &fake_tsgo,
2369            "#!/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",
2370        )
2371        .unwrap();
2372        fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2373
2374        let mut config = Config {
2375            project_root: Some(dir.path().to_path_buf()),
2376            ..Config::default()
2377        };
2378        config
2379            .checker
2380            .insert("typescript".to_string(), "tsgo".to_string());
2381
2382        let (errors, reason) = validate_full(&path, &config);
2383        assert_eq!(reason, None);
2384        assert_eq!(errors.len(), 1);
2385        assert_eq!(errors[0].line, 1);
2386        assert_eq!(errors[0].column, 23);
2387        assert!(errors[0].message.contains("TS2322"));
2388    }
2389
2390    #[test]
2391    fn run_external_tool_capture_nonzero_not_error() {
2392        // `false` exits with code 1 — capture should still return Ok
2393        let result = run_external_tool_capture("false", &[], None, 5);
2394        assert!(result.is_ok(), "capture should not error on non-zero exit");
2395        assert_eq!(result.unwrap().exit_code, 1);
2396    }
2397
2398    #[test]
2399    fn run_external_tool_capture_not_found() {
2400        let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2401        assert!(result.is_err());
2402        match result.unwrap_err() {
2403            FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2404            other => panic!("expected NotFound, got: {:?}", other),
2405        }
2406    }
2407
2408    // GitHub issue #47: GUI-launched editors miss /opt/homebrew/bin etc. from
2409    // PATH. `try_well_known_path_lookup` should find the tool at well-known
2410    // install locations even when PATH wouldn't.
2411    #[cfg(unix)]
2412    #[test]
2413    fn well_known_search_paths_include_homebrew_cargo_go_and_local() {
2414        let home = std::ffi::OsString::from("/Users/test-home");
2415        let paths = well_known_search_paths("toolx", Some(&home));
2416        let strs: Vec<String> = paths
2417            .iter()
2418            .map(|p| p.to_string_lossy().into_owned())
2419            .collect();
2420        // Order matters: Homebrew prefixes come first so an installed-via-brew
2421        // tool wins over a HOME-rooted shim.
2422        assert_eq!(strs[0], "/opt/homebrew/bin/toolx");
2423        assert_eq!(strs[1], "/usr/local/bin/toolx");
2424        assert_eq!(strs[2], "/Users/test-home/.cargo/bin/toolx");
2425        assert_eq!(strs[3], "/Users/test-home/go/bin/toolx");
2426        assert_eq!(strs[4], "/Users/test-home/.local/bin/toolx");
2427        assert_eq!(strs.len(), 5);
2428    }
2429
2430    #[cfg(unix)]
2431    #[test]
2432    fn well_known_search_paths_skips_home_when_unset() {
2433        let paths = well_known_search_paths("toolx", None);
2434        assert_eq!(paths.len(), 2);
2435        assert!(paths[0].ends_with("opt/homebrew/bin/toolx"));
2436        assert!(paths[1].ends_with("usr/local/bin/toolx"));
2437    }
2438
2439    #[cfg(unix)]
2440    #[test]
2441    fn try_well_known_path_lookup_in_finds_executable_file() {
2442        use std::os::unix::fs::PermissionsExt;
2443        let dir = tempfile::tempdir().unwrap();
2444        let bin_dir = dir.path().join("bin");
2445        fs::create_dir_all(&bin_dir).unwrap();
2446        let tool_path = bin_dir.join("toolx");
2447        fs::write(&tool_path, "#!/bin/sh\necho test").unwrap();
2448        let mut perms = fs::metadata(&tool_path).unwrap().permissions();
2449        perms.set_mode(0o755);
2450        fs::set_permissions(&tool_path, perms).unwrap();
2451
2452        let candidates = vec![
2453            dir.path().join("missing/toolx"),
2454            tool_path.clone(),
2455            dir.path().join("alt/toolx"),
2456        ];
2457        let found = try_well_known_path_lookup_in(&candidates);
2458        assert_eq!(found, Some(tool_path));
2459    }
2460
2461    #[cfg(unix)]
2462    #[test]
2463    fn try_well_known_path_lookup_in_skips_non_executable_file() {
2464        let dir = tempfile::tempdir().unwrap();
2465        let bin_dir = dir.path().join("bin");
2466        fs::create_dir_all(&bin_dir).unwrap();
2467        // File exists but is not marked executable (default 0o644 on most umasks).
2468        let tool_path = bin_dir.join("toolx");
2469        fs::write(&tool_path, "not a real tool").unwrap();
2470
2471        let found = try_well_known_path_lookup_in(&std::slice::from_ref(&tool_path));
2472        assert!(found.is_none(), "non-executable file should be skipped");
2473    }
2474
2475    #[cfg(unix)]
2476    #[test]
2477    fn try_well_known_path_lookup_in_skips_directories_and_missing_paths() {
2478        let dir = tempfile::tempdir().unwrap();
2479        // A directory at the expected path should not count as a tool.
2480        let candidates = vec![dir.path().to_path_buf(), dir.path().join("does-not-exist")];
2481        assert!(try_well_known_path_lookup_in(&candidates).is_none());
2482    }
2483
2484    #[cfg(windows)]
2485    #[test]
2486    fn try_well_known_path_lookup_is_noop_on_windows() {
2487        // On Windows we deliberately skip POSIX well-known paths; only PATH
2488        // lookup applies. The public entry point should always return None.
2489        assert!(try_well_known_path_lookup("biome").is_none());
2490    }
2491
2492    // GitHub issue #47: wording must not claim "but not installed" — the tool
2493    // may be installed but missing from AFT's PATH (GUI-launched editor).
2494    #[test]
2495    fn configured_tool_hint_does_not_claim_not_installed() {
2496        let hint = configured_tool_hint("biome", "biome.json");
2497        assert!(
2498            hint.contains("was not found on PATH or in common install locations"),
2499            "hint should explain the PATH miss: got {:?}",
2500            hint
2501        );
2502        assert!(
2503            !hint.contains("but not installed"),
2504            "hint must not claim the tool isn't installed: got {:?}",
2505            hint
2506        );
2507    }
2508
2509    #[test]
2510    fn install_hint_for_go_mentions_path() {
2511        // Verify the Go-specific hint nudges users toward checking PATH
2512        // (Homebrew install location is the most common GUI-launch PATH miss).
2513        let hint = install_hint("go");
2514        assert!(
2515            hint.contains("PATH"),
2516            "go install hint should mention PATH: got {:?}",
2517            hint
2518        );
2519    }
2520}