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;
8use std::path::{Path, PathBuf};
9use std::process::{Command, 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
25/// Errors from external tool execution.
26#[derive(Debug)]
27pub enum FormatError {
28    /// The tool binary was not found on PATH.
29    NotFound { tool: String },
30    /// The tool exceeded its timeout and was killed.
31    Timeout { tool: String, timeout_secs: u32 },
32    /// The tool exited with a non-zero status.
33    Failed { tool: String, stderr: String },
34    /// No formatter is configured for this language.
35    UnsupportedLanguage,
36}
37
38/// A configured formatter/checker that cannot be resolved for configure warnings.
39#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
40pub struct MissingTool {
41    pub kind: String,
42    pub language: String,
43    pub tool: String,
44    pub hint: String,
45}
46
47#[derive(Debug, Clone)]
48struct ToolCandidate {
49    tool: String,
50    source: String,
51    args: Vec<String>,
52    required: bool,
53}
54
55#[derive(Debug, Clone)]
56enum ToolDetection {
57    Found(String, Vec<String>),
58    NotConfigured,
59    NotInstalled { tool: String },
60}
61
62impl std::fmt::Display for FormatError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
66            FormatError::Timeout { tool, timeout_secs } => {
67                write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
68            }
69            FormatError::Failed { tool, stderr } => {
70                write!(f, "formatter '{}' failed: {}", tool, stderr)
71            }
72            FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
73        }
74    }
75}
76
77/// Spawn a subprocess and wait for completion with timeout protection.
78///
79/// Polls `try_wait()` at 50ms intervals. On timeout, kills the child process
80/// and waits for it to exit. Returns `FormatError::NotFound` when the binary
81/// isn't on PATH.
82pub fn run_external_tool(
83    command: &str,
84    args: &[&str],
85    working_dir: Option<&Path>,
86    timeout_secs: u32,
87) -> Result<ExternalToolResult, FormatError> {
88    let mut cmd = Command::new(command);
89    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
90
91    if let Some(dir) = working_dir {
92        cmd.current_dir(dir);
93    }
94
95    let mut child = match cmd.spawn() {
96        Ok(c) => c,
97        Err(e) if e.kind() == ErrorKind::NotFound => {
98            return Err(FormatError::NotFound {
99                tool: command.to_string(),
100            });
101        }
102        Err(e) => {
103            return Err(FormatError::Failed {
104                tool: command.to_string(),
105                stderr: e.to_string(),
106            });
107        }
108    };
109
110    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
111
112    loop {
113        match child.try_wait() {
114            Ok(Some(status)) => {
115                let stdout = child
116                    .stdout
117                    .take()
118                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
119                    .unwrap_or_default();
120                let stderr = child
121                    .stderr
122                    .take()
123                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
124                    .unwrap_or_default();
125
126                let exit_code = status.code().unwrap_or(-1);
127                if exit_code != 0 {
128                    return Err(FormatError::Failed {
129                        tool: command.to_string(),
130                        stderr,
131                    });
132                }
133
134                return Ok(ExternalToolResult {
135                    stdout,
136                    stderr,
137                    exit_code,
138                });
139            }
140            Ok(None) => {
141                // Still running
142                if Instant::now() >= deadline {
143                    // Kill the process and reap it
144                    let _ = child.kill();
145                    let _ = child.wait();
146                    return Err(FormatError::Timeout {
147                        tool: command.to_string(),
148                        timeout_secs,
149                    });
150                }
151                thread::sleep(Duration::from_millis(50));
152            }
153            Err(e) => {
154                return Err(FormatError::Failed {
155                    tool: command.to_string(),
156                    stderr: format!("try_wait error: {}", e),
157                });
158            }
159        }
160    }
161}
162
163/// TTL for tool availability and resolution cache entries.
164const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
165
166#[derive(Debug, Clone, PartialEq, Eq, Hash)]
167struct ToolCacheKey {
168    command: String,
169    project_root: PathBuf,
170}
171
172static TOOL_RESOLUTION_CACHE: std::sync::LazyLock<
173    Mutex<HashMap<ToolCacheKey, (Option<PathBuf>, Instant)>>,
174> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
175
176static TOOL_AVAILABILITY_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
177    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
178
179fn tool_cache_key(command: &str, project_root: Option<&Path>) -> ToolCacheKey {
180    ToolCacheKey {
181        command: command.to_string(),
182        project_root: project_root.map(Path::to_path_buf).unwrap_or_default(),
183    }
184}
185
186fn availability_cache_key(command: &str, project_root: Option<&Path>) -> String {
187    let root = project_root
188        .map(|path| path.to_string_lossy())
189        .unwrap_or_default();
190    format!("{}\0{}", command, root)
191}
192
193pub fn clear_tool_cache() {
194    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
195        cache.clear();
196    }
197    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
198        cache.clear();
199    }
200}
201
202/// Resolve a tool by checking node_modules/.bin relative to project_root, then PATH.
203/// Returns the full path to the tool if found, otherwise None.
204fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
205    let key = tool_cache_key(command, project_root);
206    if let Ok(cache) = TOOL_RESOLUTION_CACHE.lock() {
207        if let Some((resolved, checked_at)) = cache.get(&key) {
208            if checked_at.elapsed() < TOOL_CACHE_TTL {
209                return resolved
210                    .as_ref()
211                    .map(|path| path.to_string_lossy().to_string());
212            }
213        }
214    }
215
216    let resolved = resolve_tool_uncached(command, project_root);
217    if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
218        cache.insert(key, (resolved.clone(), Instant::now()));
219    }
220    resolved.map(|path| path.to_string_lossy().to_string())
221}
222
223fn resolve_tool_uncached(command: &str, project_root: Option<&Path>) -> Option<PathBuf> {
224    // 1. Check node_modules/.bin/<command> relative to project root
225    if let Some(root) = project_root {
226        let local_bin = root.join("node_modules").join(".bin").join(command);
227        if local_bin.exists() {
228            return Some(local_bin);
229        }
230    }
231
232    // 2. Fall back to PATH lookup
233    match Command::new(command)
234        .arg("--version")
235        .stdin(Stdio::null())
236        .stdout(Stdio::null())
237        .stderr(Stdio::null())
238        .spawn()
239    {
240        Ok(mut child) => {
241            let start = Instant::now();
242            let timeout = Duration::from_secs(2);
243            loop {
244                match child.try_wait() {
245                    Ok(Some(status)) => {
246                        return if status.success() {
247                            Some(PathBuf::from(command))
248                        } else {
249                            None
250                        };
251                    }
252                    Ok(None) if start.elapsed() > timeout => {
253                        let _ = child.kill();
254                        let _ = child.wait();
255                        return None;
256                    }
257                    Ok(None) => thread::sleep(Duration::from_millis(50)),
258                    Err(_) => return None,
259                }
260            }
261        }
262        Err(_) => None,
263    }
264}
265
266/// Check if `ruff format` is available with a stable formatter.
267///
268/// Ruff's formatter became stable in v0.1.2. Versions before that output
269/// `NOT_YET_IMPLEMENTED_*` stubs instead of formatted code. We parse the
270/// version from `ruff --version` (format: "ruff X.Y.Z") and require >= 0.1.2.
271/// Falls back to false if ruff is not found or version cannot be parsed.
272fn ruff_format_available(project_root: Option<&Path>) -> bool {
273    let key = availability_cache_key("ruff-format", project_root);
274    if let Ok(cache) = TOOL_AVAILABILITY_CACHE.lock() {
275        if let Some((available, checked_at)) = cache.get(&key) {
276            if checked_at.elapsed() < TOOL_CACHE_TTL {
277                return *available;
278            }
279        }
280    }
281
282    let result = ruff_format_available_uncached(project_root);
283    if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
284        cache.insert(key, (result, Instant::now()));
285    }
286    result
287}
288
289fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
290    let command = match resolve_tool("ruff", project_root) {
291        Some(command) => command,
292        None => return false,
293    };
294    let output = match Command::new(&command)
295        .arg("--version")
296        .stdout(Stdio::piped())
297        .stderr(Stdio::null())
298        .output()
299    {
300        Ok(o) => o,
301        Err(_) => return false,
302    };
303
304    let version_str = String::from_utf8_lossy(&output.stdout);
305    // Parse "ruff X.Y.Z" or just "X.Y.Z"
306    let version_part = version_str
307        .trim()
308        .strip_prefix("ruff ")
309        .unwrap_or(version_str.trim());
310
311    let parts: Vec<&str> = version_part.split('.').collect();
312    if parts.len() < 3 {
313        return false;
314    }
315
316    let major: u32 = match parts[0].parse() {
317        Ok(v) => v,
318        Err(_) => return false,
319    };
320    let minor: u32 = match parts[1].parse() {
321        Ok(v) => v,
322        Err(_) => return false,
323    };
324    let patch: u32 = match parts[2].parse() {
325        Ok(v) => v,
326        Err(_) => return false,
327    };
328
329    // Require >= 0.1.2 where ruff format became stable
330    (major, minor, patch) >= (0, 1, 2)
331}
332
333fn resolve_candidate_tool(
334    candidate: &ToolCandidate,
335    project_root: Option<&Path>,
336) -> Option<String> {
337    if candidate.tool == "ruff" && !ruff_format_available(project_root) {
338        return None;
339    }
340
341    resolve_tool(&candidate.tool, project_root)
342}
343
344fn lang_key(lang: LangId) -> &'static str {
345    match lang {
346        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
347        LangId::Python => "python",
348        LangId::Rust => "rust",
349        LangId::Go => "go",
350        LangId::C => "c",
351        LangId::Cpp => "cpp",
352        LangId::Zig => "zig",
353        LangId::CSharp => "csharp",
354        LangId::Bash => "bash",
355        LangId::Solidity => "solidity",
356        LangId::Vue => "vue",
357        LangId::Html => "html",
358        LangId::Markdown => "markdown",
359    }
360}
361
362fn has_formatter_support(lang: LangId) -> bool {
363    matches!(
364        lang,
365        LangId::TypeScript
366            | LangId::JavaScript
367            | LangId::Tsx
368            | LangId::Python
369            | LangId::Rust
370            | LangId::Go
371    )
372}
373
374fn has_checker_support(lang: LangId) -> bool {
375    matches!(
376        lang,
377        LangId::TypeScript
378            | LangId::JavaScript
379            | LangId::Tsx
380            | LangId::Python
381            | LangId::Rust
382            | LangId::Go
383    )
384}
385
386fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
387    let project_root = config.project_root.as_deref();
388    if let Some(preferred) = config.formatter.get(lang_key(lang)) {
389        return explicit_formatter_candidate(preferred, file_str);
390    }
391
392    match lang {
393        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
394            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
395                vec![ToolCandidate {
396                    tool: "biome".to_string(),
397                    source: "biome.json".to_string(),
398                    args: vec![
399                        "format".to_string(),
400                        "--write".to_string(),
401                        file_str.to_string(),
402                    ],
403                    required: true,
404                }]
405            } else if has_project_config(
406                project_root,
407                &[
408                    ".prettierrc",
409                    ".prettierrc.json",
410                    ".prettierrc.yml",
411                    ".prettierrc.yaml",
412                    ".prettierrc.js",
413                    ".prettierrc.cjs",
414                    ".prettierrc.mjs",
415                    ".prettierrc.toml",
416                    "prettier.config.js",
417                    "prettier.config.cjs",
418                    "prettier.config.mjs",
419                ],
420            ) {
421                vec![ToolCandidate {
422                    tool: "prettier".to_string(),
423                    source: "Prettier config".to_string(),
424                    args: vec!["--write".to_string(), file_str.to_string()],
425                    required: true,
426                }]
427            } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
428                vec![ToolCandidate {
429                    tool: "deno".to_string(),
430                    source: "deno.json".to_string(),
431                    args: vec!["fmt".to_string(), file_str.to_string()],
432                    required: true,
433                }]
434            } else {
435                Vec::new()
436            }
437        }
438        LangId::Python => {
439            if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
440                || has_pyproject_tool(project_root, "ruff")
441            {
442                vec![ToolCandidate {
443                    tool: "ruff".to_string(),
444                    source: "ruff config".to_string(),
445                    args: vec!["format".to_string(), file_str.to_string()],
446                    required: true,
447                }]
448            } else if has_pyproject_tool(project_root, "black") {
449                vec![ToolCandidate {
450                    tool: "black".to_string(),
451                    source: "pyproject.toml".to_string(),
452                    args: vec![file_str.to_string()],
453                    required: true,
454                }]
455            } else {
456                Vec::new()
457            }
458        }
459        LangId::Rust => {
460            if has_project_config(project_root, &["Cargo.toml"]) {
461                vec![ToolCandidate {
462                    tool: "rustfmt".to_string(),
463                    source: "Cargo.toml".to_string(),
464                    args: vec![file_str.to_string()],
465                    required: true,
466                }]
467            } else {
468                Vec::new()
469            }
470        }
471        LangId::Go => {
472            if has_project_config(project_root, &["go.mod"]) {
473                vec![
474                    ToolCandidate {
475                        tool: "goimports".to_string(),
476                        source: "go.mod".to_string(),
477                        args: vec!["-w".to_string(), file_str.to_string()],
478                        required: false,
479                    },
480                    ToolCandidate {
481                        tool: "gofmt".to_string(),
482                        source: "go.mod".to_string(),
483                        args: vec!["-w".to_string(), file_str.to_string()],
484                        required: true,
485                    },
486                ]
487            } else {
488                Vec::new()
489            }
490        }
491        LangId::C
492        | LangId::Cpp
493        | LangId::Zig
494        | LangId::CSharp
495        | LangId::Bash
496        | LangId::Solidity
497        | LangId::Vue => Vec::new(),
498        LangId::Html => Vec::new(),
499        LangId::Markdown => Vec::new(),
500    }
501}
502
503fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
504    let project_root = config.project_root.as_deref();
505    if let Some(preferred) = config.checker.get(lang_key(lang)) {
506        return explicit_checker_candidate(preferred, file_str);
507    }
508
509    match lang {
510        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
511            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
512                vec![ToolCandidate {
513                    tool: "biome".to_string(),
514                    source: "biome.json".to_string(),
515                    args: vec!["check".to_string(), file_str.to_string()],
516                    required: true,
517                }]
518            } else if has_project_config(project_root, &["tsconfig.json"]) {
519                vec![ToolCandidate {
520                    tool: "tsc".to_string(),
521                    source: "tsconfig.json".to_string(),
522                    args: vec![
523                        "--noEmit".to_string(),
524                        "--pretty".to_string(),
525                        "false".to_string(),
526                    ],
527                    required: true,
528                }]
529            } else {
530                Vec::new()
531            }
532        }
533        LangId::Python => {
534            if has_project_config(project_root, &["pyrightconfig.json"])
535                || has_pyproject_tool(project_root, "pyright")
536            {
537                vec![ToolCandidate {
538                    tool: "pyright".to_string(),
539                    source: "pyright config".to_string(),
540                    args: vec!["--outputjson".to_string(), file_str.to_string()],
541                    required: true,
542                }]
543            } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
544                || has_pyproject_tool(project_root, "ruff")
545            {
546                vec![ToolCandidate {
547                    tool: "ruff".to_string(),
548                    source: "ruff config".to_string(),
549                    args: vec![
550                        "check".to_string(),
551                        "--output-format=json".to_string(),
552                        file_str.to_string(),
553                    ],
554                    required: true,
555                }]
556            } else {
557                Vec::new()
558            }
559        }
560        LangId::Rust => {
561            if has_project_config(project_root, &["Cargo.toml"]) {
562                vec![ToolCandidate {
563                    tool: "cargo".to_string(),
564                    source: "Cargo.toml".to_string(),
565                    args: vec!["check".to_string(), "--message-format=json".to_string()],
566                    required: true,
567                }]
568            } else {
569                Vec::new()
570            }
571        }
572        LangId::Go => {
573            if has_project_config(project_root, &["go.mod"]) {
574                vec![
575                    ToolCandidate {
576                        tool: "staticcheck".to_string(),
577                        source: "go.mod".to_string(),
578                        args: vec![file_str.to_string()],
579                        required: false,
580                    },
581                    ToolCandidate {
582                        tool: "go".to_string(),
583                        source: "go.mod".to_string(),
584                        args: vec!["vet".to_string(), file_str.to_string()],
585                        required: true,
586                    },
587                ]
588            } else {
589                Vec::new()
590            }
591        }
592        LangId::C
593        | LangId::Cpp
594        | LangId::Zig
595        | LangId::CSharp
596        | LangId::Bash
597        | LangId::Solidity
598        | LangId::Vue => Vec::new(),
599        LangId::Html => Vec::new(),
600        LangId::Markdown => Vec::new(),
601    }
602}
603
604fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
605    match name {
606        "none" | "off" | "false" => Vec::new(),
607        "biome" => vec![ToolCandidate {
608            tool: name.to_string(),
609            source: "formatter config".to_string(),
610            args: vec![
611                "format".to_string(),
612                "--write".to_string(),
613                file_str.to_string(),
614            ],
615            required: true,
616        }],
617        "prettier" => vec![ToolCandidate {
618            tool: name.to_string(),
619            source: "formatter config".to_string(),
620            args: vec!["--write".to_string(), file_str.to_string()],
621            required: true,
622        }],
623        "deno" => vec![ToolCandidate {
624            tool: name.to_string(),
625            source: "formatter config".to_string(),
626            args: vec!["fmt".to_string(), file_str.to_string()],
627            required: true,
628        }],
629        "ruff" => vec![ToolCandidate {
630            tool: name.to_string(),
631            source: "formatter config".to_string(),
632            args: vec!["format".to_string(), file_str.to_string()],
633            required: true,
634        }],
635        "black" | "rustfmt" => vec![ToolCandidate {
636            tool: name.to_string(),
637            source: "formatter config".to_string(),
638            args: vec![file_str.to_string()],
639            required: true,
640        }],
641        "goimports" | "gofmt" => vec![ToolCandidate {
642            tool: name.to_string(),
643            source: "formatter config".to_string(),
644            args: vec!["-w".to_string(), file_str.to_string()],
645            required: true,
646        }],
647        _ => Vec::new(),
648    }
649}
650
651fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
652    match name {
653        "none" | "off" | "false" => Vec::new(),
654        "tsc" => vec![ToolCandidate {
655            tool: name.to_string(),
656            source: "checker config".to_string(),
657            args: vec![
658                "--noEmit".to_string(),
659                "--pretty".to_string(),
660                "false".to_string(),
661            ],
662            required: true,
663        }],
664        "cargo" => vec![ToolCandidate {
665            tool: name.to_string(),
666            source: "checker config".to_string(),
667            args: vec!["check".to_string(), "--message-format=json".to_string()],
668            required: true,
669        }],
670        "go" => vec![ToolCandidate {
671            tool: name.to_string(),
672            source: "checker config".to_string(),
673            args: vec!["vet".to_string(), file_str.to_string()],
674            required: true,
675        }],
676        "biome" => vec![ToolCandidate {
677            tool: name.to_string(),
678            source: "checker config".to_string(),
679            args: vec!["check".to_string(), file_str.to_string()],
680            required: true,
681        }],
682        "pyright" => vec![ToolCandidate {
683            tool: name.to_string(),
684            source: "checker config".to_string(),
685            args: vec!["--outputjson".to_string(), file_str.to_string()],
686            required: true,
687        }],
688        "ruff" => vec![ToolCandidate {
689            tool: name.to_string(),
690            source: "checker config".to_string(),
691            args: vec![
692                "check".to_string(),
693                "--output-format=json".to_string(),
694                file_str.to_string(),
695            ],
696            required: true,
697        }],
698        "staticcheck" => vec![ToolCandidate {
699            tool: name.to_string(),
700            source: "checker config".to_string(),
701            args: vec![file_str.to_string()],
702            required: true,
703        }],
704        _ => Vec::new(),
705    }
706}
707
708fn resolve_tool_candidates(
709    candidates: Vec<ToolCandidate>,
710    project_root: Option<&Path>,
711) -> ToolDetection {
712    if candidates.is_empty() {
713        return ToolDetection::NotConfigured;
714    }
715
716    let mut missing_required = None;
717    for candidate in candidates {
718        if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
719            return ToolDetection::Found(command, candidate.args);
720        }
721        if candidate.required && missing_required.is_none() {
722            missing_required = Some(candidate.tool);
723        }
724    }
725
726    match missing_required {
727        Some(tool) => ToolDetection::NotInstalled { tool },
728        None => ToolDetection::NotConfigured,
729    }
730}
731
732fn checker_command(candidate: &ToolCandidate, resolved: String) -> String {
733    match candidate.tool.as_str() {
734        "tsc" => resolved,
735        "cargo" => "cargo".to_string(),
736        "go" => "go".to_string(),
737        _ => resolved,
738    }
739}
740
741fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
742    if candidate.tool == "tsc" {
743        vec![
744            "--noEmit".to_string(),
745            "--pretty".to_string(),
746            "false".to_string(),
747        ]
748    } else {
749        candidate.args.clone()
750    }
751}
752
753fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
754    let file_str = path.to_string_lossy().to_string();
755    resolve_tool_candidates(
756        formatter_candidates(lang, config, &file_str),
757        config.project_root.as_deref(),
758    )
759}
760
761fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
762    let file_str = path.to_string_lossy().to_string();
763    let candidates = checker_candidates(lang, config, &file_str);
764    if candidates.is_empty() {
765        return ToolDetection::NotConfigured;
766    }
767
768    let project_root = config.project_root.as_deref();
769    let mut missing_required = None;
770    for candidate in candidates {
771        if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
772            return ToolDetection::Found(
773                checker_command(&candidate, command),
774                checker_args(&candidate),
775            );
776        }
777        if candidate.required && missing_required.is_none() {
778            missing_required = Some(candidate.tool);
779        }
780    }
781
782    match missing_required {
783        Some(tool) => ToolDetection::NotInstalled { tool },
784        None => ToolDetection::NotConfigured,
785    }
786}
787
788fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
789    crate::callgraph::walk_project_files(project_root)
790        .filter_map(|path| detect_language(&path))
791        .collect()
792}
793
794fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
795    let filename = match lang {
796        LangId::TypeScript => "aft-tool-detection.ts",
797        LangId::Tsx => "aft-tool-detection.tsx",
798        LangId::JavaScript => "aft-tool-detection.js",
799        LangId::Python => "aft-tool-detection.py",
800        LangId::Rust => "aft_tool_detection.rs",
801        LangId::Go => "aft_tool_detection.go",
802        LangId::C => "aft_tool_detection.c",
803        LangId::Cpp => "aft_tool_detection.cpp",
804        LangId::Zig => "aft_tool_detection.zig",
805        LangId::CSharp => "aft_tool_detection.cs",
806        LangId::Bash => "aft_tool_detection.sh",
807        LangId::Solidity => "aft_tool_detection.sol",
808        LangId::Vue => "aft-tool-detection.vue",
809        LangId::Html => "aft-tool-detection.html",
810        LangId::Markdown => "aft-tool-detection.md",
811    };
812    project_root.join(filename)
813}
814
815pub(crate) fn install_hint(tool: &str) -> String {
816    match tool {
817        "biome" => {
818            "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
819        }
820        "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
821        "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
822        "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
823        "ruff" => {
824            "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
825        }
826        "black" => {
827            "Install: `pip install black` or your Python package manager equivalent.".to_string()
828        }
829        "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
830        "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
831        "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
832        "go" => "Install Go from https://go.dev/dl/.".to_string(),
833        "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
834        "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
835        "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
836        "typescript-language-server" => {
837            "Install: `npm install -g typescript-language-server typescript`".to_string()
838        }
839        "deno" => "Install Deno from https://deno.com/.".to_string(),
840        "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
841        "staticcheck" => {
842            "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
843        }
844        other => format!("Install `{other}` and ensure it is on PATH."),
845    }
846}
847
848fn configured_tool_hint(tool: &str, source: &str) -> String {
849    format!(
850        "{tool} is configured in {source} but not installed. {}",
851        install_hint(tool)
852    )
853}
854
855fn missing_tool_warning(
856    kind: &str,
857    language: &str,
858    candidate: &ToolCandidate,
859    project_root: Option<&Path>,
860) -> Option<MissingTool> {
861    if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
862        return None;
863    }
864
865    Some(MissingTool {
866        kind: kind.to_string(),
867        language: language.to_string(),
868        tool: candidate.tool.clone(),
869        hint: configured_tool_hint(&candidate.tool, &candidate.source),
870    })
871}
872
873/// Detect configured formatters/checkers that are missing for languages present in the project.
874pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
875    let languages = languages_in_project(project_root);
876    let mut warnings = Vec::new();
877    let mut seen = HashSet::new();
878
879    for lang in languages {
880        let language = lang_key(lang);
881        let placeholder = placeholder_file_for_language(project_root, lang);
882        let file_str = placeholder.to_string_lossy().to_string();
883
884        for candidate in formatter_candidates(lang, config, &file_str) {
885            if let Some(warning) = missing_tool_warning(
886                "formatter_not_installed",
887                language,
888                &candidate,
889                config.project_root.as_deref(),
890            ) {
891                if seen.insert((
892                    warning.kind.clone(),
893                    warning.language.clone(),
894                    warning.tool.clone(),
895                )) {
896                    warnings.push(warning);
897                }
898            }
899        }
900
901        for candidate in checker_candidates(lang, config, &file_str) {
902            if let Some(warning) = missing_tool_warning(
903                "checker_not_installed",
904                language,
905                &candidate,
906                config.project_root.as_deref(),
907            ) {
908                if seen.insert((
909                    warning.kind.clone(),
910                    warning.language.clone(),
911                    warning.tool.clone(),
912                )) {
913                    warnings.push(warning);
914                }
915            }
916        }
917    }
918
919    warnings.sort_by(|left, right| {
920        (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
921    });
922    warnings
923}
924
925/// Detect the appropriate formatter command and arguments for a file.
926///
927/// Priority per language:
928/// - TypeScript/JavaScript/TSX: `prettier --write <file>`
929/// - Python: `ruff format <file>` (fallback: `black <file>`)
930/// - Rust: `rustfmt <file>`
931/// - Go: `gofmt -w <file>`
932///
933/// Returns `None` if no formatter is available for the language.
934pub fn detect_formatter(
935    path: &Path,
936    lang: LangId,
937    config: &Config,
938) -> Option<(String, Vec<String>)> {
939    match detect_formatter_for_path(path, lang, config) {
940        ToolDetection::Found(cmd, args) => Some((cmd, args)),
941        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
942    }
943}
944
945/// Check if any of the given config file names exist in the project root.
946fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
947    let root = match project_root {
948        Some(r) => r,
949        None => return false,
950    };
951    filenames.iter().any(|f| root.join(f).exists())
952}
953
954/// Check if pyproject.toml exists and contains a `[tool.<name>]` section.
955fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
956    let root = match project_root {
957        Some(r) => r,
958        None => return false,
959    };
960    let pyproject = root.join("pyproject.toml");
961    if !pyproject.exists() {
962        return false;
963    }
964    match std::fs::read_to_string(&pyproject) {
965        Ok(content) => {
966            let pattern = format!("[tool.{}]", tool_name);
967            content.contains(&pattern)
968        }
969        Err(_) => false,
970    }
971}
972
973/// Detect whether a non-zero formatter exit was caused by the formatter
974/// intentionally excluding the path (per its own config) rather than an
975/// actual formatter or input error.
976///
977/// The patterns below come from real stderr output observed during
978/// dogfooding. They're intentionally substring-based and case-insensitive
979/// so minor formatter version differences in wording don't bypass the
980/// check. Each pattern corresponds to a specific formatter's exclusion
981/// signal:
982/// - biome: `"No files were processed in the specified paths."`,
983///   `"ignored by the configuration"`
984/// - prettier: `"No files matching the pattern were found"`
985/// - ruff: `"No Python files found under the given path(s)"`
986///
987/// rustfmt and gofmt/goimports rarely scope-restrict and have no known
988/// stable marker, so they're not detected here. They'll fall through to
989/// the generic `"error"` reason — acceptable because they almost never
990/// emit a path-exclusion exit in practice.
991fn formatter_excluded_path(stderr: &str) -> bool {
992    let s = stderr.to_lowercase();
993    s.contains("no files were processed")
994        || s.contains("ignored by the configuration")
995        || s.contains("no files matching the pattern")
996        || s.contains("no python files found")
997}
998
999/// Auto-format a file using the detected formatter for its language.
1000///
1001/// Returns `(formatted, skip_reason)`:
1002/// - `(true, None)` — file was successfully formatted
1003/// - `(false, Some(reason))` — formatting was skipped, reason explains why
1004///
1005/// Skip reasons:
1006/// - `"unsupported_language"` — language has no formatter support in AFT
1007/// - `"no_formatter_configured"` — `format_on_edit=false` or no formatter
1008///   detected for the language in the project
1009/// - `"formatter_not_installed"` — configured formatter binary missing on
1010///   PATH and not in project's `node_modules/.bin`
1011/// - `"formatter_excluded_path"` — formatter ran but refused to process this
1012///   path because the project formatter config (e.g. biome.json `files.includes`,
1013///   prettier `.prettierignore`) excludes it. NOT an error in AFT or the user's
1014///   formatter — the user told the formatter not to touch this path. Agents
1015///   should treat this as informational.
1016/// - `"timeout"` — formatter exceeded `formatter_timeout_secs`
1017/// - `"error"` — formatter exited non-zero with an unrecognized error
1018///   (likely a real bug in the user's input or the formatter itself)
1019pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1020    // Check if formatting is disabled via plugin config
1021    if !config.format_on_edit {
1022        return (false, Some("no_formatter_configured".to_string()));
1023    }
1024
1025    let lang = match detect_language(path) {
1026        Some(l) => l,
1027        None => {
1028            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1029            return (false, Some("unsupported_language".to_string()));
1030        }
1031    };
1032    if !has_formatter_support(lang) {
1033        log::debug!("format: {} (skipped: unsupported_language)", path.display());
1034        return (false, Some("unsupported_language".to_string()));
1035    }
1036
1037    let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1038        ToolDetection::Found(cmd, args) => (cmd, args),
1039        ToolDetection::NotConfigured => {
1040            log::debug!(
1041                "format: {} (skipped: no_formatter_configured)",
1042                path.display()
1043            );
1044            return (false, Some("no_formatter_configured".to_string()));
1045        }
1046        ToolDetection::NotInstalled { tool } => {
1047            log::warn!(
1048                "format: {} (skipped: formatter_not_installed: {})",
1049                path.display(),
1050                tool
1051            );
1052            return (false, Some("formatter_not_installed".to_string()));
1053        }
1054    };
1055
1056    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1057
1058    // Run the formatter in the project root so tool-local config files
1059    // (biome.json, .prettierrc, rustfmt.toml, etc.) are discovered. The
1060    // type-checker path (`validate_full`) already does this via
1061    // `path.parent()`; formatters need the same treatment. Without it,
1062    // formatters silently fall back to built-in defaults when the aft
1063    // process CWD differs from the project root (audit #18).
1064    let working_dir = config.project_root.as_deref();
1065
1066    match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1067        Ok(_) => {
1068            log::info!("format: {} ({})", path.display(), cmd);
1069            (true, None)
1070        }
1071        Err(FormatError::Timeout { .. }) => {
1072            log::warn!("format: {} (skipped: timeout)", path.display());
1073            (false, Some("timeout".to_string()))
1074        }
1075        Err(FormatError::NotFound { .. }) => {
1076            log::warn!(
1077                "format: {} (skipped: formatter_not_installed)",
1078                path.display()
1079            );
1080            (false, Some("formatter_not_installed".to_string()))
1081        }
1082        Err(FormatError::Failed { stderr, .. }) => {
1083            // Distinguish "formatter intentionally ignored this path" from
1084            // "formatter actually errored". Many formatters scope themselves
1085            // to a project subtree (biome.json `files.includes`, prettier
1086            // `.prettierignore`, ruff `[tool.ruff]` config) and exit non-zero
1087            // when invoked on a path outside that scope. From AFT's perspective
1088            // that's not an error — the user told the formatter not to touch
1089            // this path. But the previous code returned a generic `"error"`
1090            // skip reason and logged at `debug` (silent under default
1091            // RUST_LOG=info), so the agent had no signal that the file
1092            // landed unformatted. Detect the common stderr fingerprints and
1093            // return a distinct, surfaced skip reason.
1094            if formatter_excluded_path(&stderr) {
1095                log::info!(
1096                    "format: {} (skipped: formatter_excluded_path; stderr: {})",
1097                    path.display(),
1098                    stderr.lines().next().unwrap_or("").trim()
1099                );
1100                return (false, Some("formatter_excluded_path".to_string()));
1101            }
1102            log::warn!(
1103                "format: {} (skipped: error: {})",
1104                path.display(),
1105                stderr.lines().next().unwrap_or("unknown").trim()
1106            );
1107            (false, Some("error".to_string()))
1108        }
1109        Err(FormatError::UnsupportedLanguage) => {
1110            log::debug!("format: {} (skipped: unsupported_language)", path.display());
1111            (false, Some("unsupported_language".to_string()))
1112        }
1113    }
1114}
1115
1116/// Spawn a subprocess and capture output regardless of exit code.
1117///
1118/// Unlike `run_external_tool`, this does NOT treat non-zero exit as an error —
1119/// type checkers return non-zero when they find issues, which is expected.
1120/// Returns `FormatError::NotFound` when the binary isn't on PATH, and
1121/// `FormatError::Timeout` if the deadline is exceeded.
1122pub fn run_external_tool_capture(
1123    command: &str,
1124    args: &[&str],
1125    working_dir: Option<&Path>,
1126    timeout_secs: u32,
1127) -> Result<ExternalToolResult, FormatError> {
1128    let mut cmd = Command::new(command);
1129    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1130
1131    if let Some(dir) = working_dir {
1132        cmd.current_dir(dir);
1133    }
1134
1135    let mut child = match cmd.spawn() {
1136        Ok(c) => c,
1137        Err(e) if e.kind() == ErrorKind::NotFound => {
1138            return Err(FormatError::NotFound {
1139                tool: command.to_string(),
1140            });
1141        }
1142        Err(e) => {
1143            return Err(FormatError::Failed {
1144                tool: command.to_string(),
1145                stderr: e.to_string(),
1146            });
1147        }
1148    };
1149
1150    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
1151
1152    loop {
1153        match child.try_wait() {
1154            Ok(Some(status)) => {
1155                let stdout = child
1156                    .stdout
1157                    .take()
1158                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
1159                    .unwrap_or_default();
1160                let stderr = child
1161                    .stderr
1162                    .take()
1163                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
1164                    .unwrap_or_default();
1165
1166                return Ok(ExternalToolResult {
1167                    stdout,
1168                    stderr,
1169                    exit_code: status.code().unwrap_or(-1),
1170                });
1171            }
1172            Ok(None) => {
1173                if Instant::now() >= deadline {
1174                    let _ = child.kill();
1175                    let _ = child.wait();
1176                    return Err(FormatError::Timeout {
1177                        tool: command.to_string(),
1178                        timeout_secs,
1179                    });
1180                }
1181                thread::sleep(Duration::from_millis(50));
1182            }
1183            Err(e) => {
1184                return Err(FormatError::Failed {
1185                    tool: command.to_string(),
1186                    stderr: format!("try_wait error: {}", e),
1187                });
1188            }
1189        }
1190    }
1191}
1192
1193// ============================================================================
1194// Type-checker validation (R017)
1195// ============================================================================
1196
1197/// A structured error from a type checker.
1198#[derive(Debug, Clone, serde::Serialize)]
1199pub struct ValidationError {
1200    pub line: u32,
1201    pub column: u32,
1202    pub message: String,
1203    pub severity: String,
1204}
1205
1206/// Detect the appropriate type checker command and arguments for a file.
1207///
1208/// Returns `(command, args)` for the type checker. The `--noEmit` / equivalent
1209/// flags ensure no output files are produced.
1210///
1211/// Supported:
1212/// - TypeScript/JavaScript/TSX → `npx tsc --noEmit` (fallback: `tsc --noEmit`)
1213/// - Python → `pyright`
1214/// - Rust → `cargo check`
1215/// - Go → `go vet`
1216pub fn detect_type_checker(
1217    path: &Path,
1218    lang: LangId,
1219    config: &Config,
1220) -> Option<(String, Vec<String>)> {
1221    match detect_checker_for_path(path, lang, config) {
1222        ToolDetection::Found(cmd, args) => Some((cmd, args)),
1223        ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1224    }
1225}
1226
1227/// Parse type checker output into structured validation errors.
1228///
1229/// Handles output formats from tsc, pyright (JSON), cargo check (JSON), and go vet.
1230/// Filters to errors related to the edited file where feasible.
1231pub fn parse_checker_output(
1232    stdout: &str,
1233    stderr: &str,
1234    file: &Path,
1235    checker: &str,
1236) -> Vec<ValidationError> {
1237    let checker_name = Path::new(checker)
1238        .file_name()
1239        .and_then(|name| name.to_str())
1240        .unwrap_or(checker);
1241    match checker_name {
1242        "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
1243        "pyright" => parse_pyright_output(stdout, file),
1244        "cargo" => parse_cargo_output(stdout, stderr, file),
1245        "go" => parse_go_vet_output(stderr, file),
1246        _ => Vec::new(),
1247    }
1248}
1249
1250/// Parse tsc output lines like: `path(line,col): error TSxxxx: message`
1251fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1252    let mut errors = Vec::new();
1253    let file_str = file.to_string_lossy();
1254    // tsc writes diagnostics to stdout (with --pretty false)
1255    let combined = format!("{}{}", stdout, stderr);
1256    for line in combined.lines() {
1257        // Format: path(line,col): severity TSxxxx: message
1258        // or: path(line,col): severity: message
1259        if let Some((loc, rest)) = line.split_once("): ") {
1260            // Check if this error is for our file (compare filename part)
1261            let file_part = loc.split('(').next().unwrap_or("");
1262            if !file_str.ends_with(file_part)
1263                && !file_part.ends_with(&*file_str)
1264                && file_part != &*file_str
1265            {
1266                continue;
1267            }
1268
1269            // Parse (line,col) from the location part
1270            let coords = loc.split('(').last().unwrap_or("");
1271            let parts: Vec<&str> = coords.split(',').collect();
1272            let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1273            let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1274
1275            // Parse severity and message
1276            let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1277                ("error".to_string(), msg.to_string())
1278            } else if let Some(msg) = rest.strip_prefix("warning ") {
1279                ("warning".to_string(), msg.to_string())
1280            } else {
1281                ("error".to_string(), rest.to_string())
1282            };
1283
1284            errors.push(ValidationError {
1285                line: line_num,
1286                column: col_num,
1287                message,
1288                severity,
1289            });
1290        }
1291    }
1292    errors
1293}
1294
1295/// Parse pyright JSON output.
1296fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1297    let mut errors = Vec::new();
1298    let file_str = file.to_string_lossy();
1299
1300    // pyright --outputjson emits JSON with generalDiagnostics array
1301    if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1302        if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1303            for diag in diags {
1304                // Filter to our file
1305                let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1306                if !diag_file.is_empty()
1307                    && !file_str.ends_with(diag_file)
1308                    && !diag_file.ends_with(&*file_str)
1309                    && diag_file != &*file_str
1310                {
1311                    continue;
1312                }
1313
1314                let line_num = diag
1315                    .get("range")
1316                    .and_then(|r| r.get("start"))
1317                    .and_then(|s| s.get("line"))
1318                    .and_then(|l| l.as_u64())
1319                    .unwrap_or(0) as u32;
1320                let col_num = diag
1321                    .get("range")
1322                    .and_then(|r| r.get("start"))
1323                    .and_then(|s| s.get("character"))
1324                    .and_then(|c| c.as_u64())
1325                    .unwrap_or(0) as u32;
1326                let message = diag
1327                    .get("message")
1328                    .and_then(|m| m.as_str())
1329                    .unwrap_or("unknown error")
1330                    .to_string();
1331                let severity = diag
1332                    .get("severity")
1333                    .and_then(|s| s.as_str())
1334                    .unwrap_or("error")
1335                    .to_lowercase();
1336
1337                errors.push(ValidationError {
1338                    line: line_num + 1, // pyright uses 0-indexed lines
1339                    column: col_num,
1340                    message,
1341                    severity,
1342                });
1343            }
1344        }
1345    }
1346    errors
1347}
1348
1349/// Parse cargo check JSON output, filtering to errors in the target file.
1350fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1351    let mut errors = Vec::new();
1352    let file_str = file.to_string_lossy();
1353
1354    for line in stdout.lines() {
1355        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1356            if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1357                continue;
1358            }
1359            let message_obj = match msg.get("message") {
1360                Some(m) => m,
1361                None => continue,
1362            };
1363
1364            let level = message_obj
1365                .get("level")
1366                .and_then(|l| l.as_str())
1367                .unwrap_or("error");
1368
1369            // Only include errors and warnings, skip notes/help
1370            if level != "error" && level != "warning" {
1371                continue;
1372            }
1373
1374            let text = message_obj
1375                .get("message")
1376                .and_then(|m| m.as_str())
1377                .unwrap_or("unknown error")
1378                .to_string();
1379
1380            // Find the primary span for our file
1381            if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1382                for span in spans {
1383                    let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1384                    let is_primary = span
1385                        .get("is_primary")
1386                        .and_then(|p| p.as_bool())
1387                        .unwrap_or(false);
1388
1389                    if !is_primary {
1390                        continue;
1391                    }
1392
1393                    // Filter to our file
1394                    if !file_str.ends_with(span_file)
1395                        && !span_file.ends_with(&*file_str)
1396                        && span_file != &*file_str
1397                    {
1398                        continue;
1399                    }
1400
1401                    let line_num =
1402                        span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1403                    let col_num = span
1404                        .get("column_start")
1405                        .and_then(|c| c.as_u64())
1406                        .unwrap_or(0) as u32;
1407
1408                    errors.push(ValidationError {
1409                        line: line_num,
1410                        column: col_num,
1411                        message: text.clone(),
1412                        severity: level.to_string(),
1413                    });
1414                }
1415            }
1416        }
1417    }
1418    errors
1419}
1420
1421/// Parse go vet output lines like: `path:line:col: message`
1422fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1423    let mut errors = Vec::new();
1424    let file_str = file.to_string_lossy();
1425
1426    for line in stderr.lines() {
1427        // Format: path:line:col: message  OR  path:line: message
1428        let parts: Vec<&str> = line.splitn(4, ':').collect();
1429        if parts.len() < 3 {
1430            continue;
1431        }
1432
1433        let err_file = parts[0].trim();
1434        if !file_str.ends_with(err_file)
1435            && !err_file.ends_with(&*file_str)
1436            && err_file != &*file_str
1437        {
1438            continue;
1439        }
1440
1441        let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1442        let (col_num, message) = if parts.len() >= 4 {
1443            if let Ok(col) = parts[2].trim().parse::<u32>() {
1444                (col, parts[3].trim().to_string())
1445            } else {
1446                // parts[2] is part of the message, not a column
1447                (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1448            }
1449        } else {
1450            (0, parts[2].trim().to_string())
1451        };
1452
1453        errors.push(ValidationError {
1454            line: line_num,
1455            column: col_num,
1456            message,
1457            severity: "error".to_string(),
1458        });
1459    }
1460    errors
1461}
1462
1463/// Run the project's type checker and return structured validation errors.
1464///
1465/// Returns `(errors, skip_reason)`:
1466/// - `(errors, None)` — checker ran, errors may be empty (= valid code)
1467/// - `([], Some(reason))` — checker was skipped
1468///
1469/// Skip reasons: `"unsupported_language"`, `"no_checker_configured"`,
1470/// `"checker_not_installed"`, `"timeout"`, `"error"`
1471pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1472    let lang = match detect_language(path) {
1473        Some(l) => l,
1474        None => {
1475            log::debug!(
1476                "validate: {} (skipped: unsupported_language)",
1477                path.display()
1478            );
1479            return (Vec::new(), Some("unsupported_language".to_string()));
1480        }
1481    };
1482    if !has_checker_support(lang) {
1483        log::debug!(
1484            "validate: {} (skipped: unsupported_language)",
1485            path.display()
1486        );
1487        return (Vec::new(), Some("unsupported_language".to_string()));
1488    }
1489
1490    let (cmd, args) = match detect_checker_for_path(path, lang, config) {
1491        ToolDetection::Found(cmd, args) => (cmd, args),
1492        ToolDetection::NotConfigured => {
1493            log::debug!(
1494                "validate: {} (skipped: no_checker_configured)",
1495                path.display()
1496            );
1497            return (Vec::new(), Some("no_checker_configured".to_string()));
1498        }
1499        ToolDetection::NotInstalled { tool } => {
1500            log::warn!(
1501                "validate: {} (skipped: checker_not_installed: {})",
1502                path.display(),
1503                tool
1504            );
1505            return (Vec::new(), Some("checker_not_installed".to_string()));
1506        }
1507    };
1508
1509    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1510
1511    // Type checkers may need to run from the project root
1512    let working_dir = config.project_root.as_deref();
1513
1514    match run_external_tool_capture(
1515        &cmd,
1516        &arg_refs,
1517        working_dir,
1518        config.type_checker_timeout_secs,
1519    ) {
1520        Ok(result) => {
1521            let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1522            log::debug!(
1523                "validate: {} ({}, {} errors)",
1524                path.display(),
1525                cmd,
1526                errors.len()
1527            );
1528            (errors, None)
1529        }
1530        Err(FormatError::Timeout { .. }) => {
1531            log::error!("validate: {} (skipped: timeout)", path.display());
1532            (Vec::new(), Some("timeout".to_string()))
1533        }
1534        Err(FormatError::NotFound { .. }) => {
1535            log::warn!(
1536                "validate: {} (skipped: checker_not_installed)",
1537                path.display()
1538            );
1539            (Vec::new(), Some("checker_not_installed".to_string()))
1540        }
1541        Err(FormatError::Failed { stderr, .. }) => {
1542            log::debug!(
1543                "validate: {} (skipped: error: {})",
1544                path.display(),
1545                stderr.lines().next().unwrap_or("unknown")
1546            );
1547            (Vec::new(), Some("error".to_string()))
1548        }
1549        Err(FormatError::UnsupportedLanguage) => {
1550            log::debug!(
1551                "validate: {} (skipped: unsupported_language)",
1552                path.display()
1553            );
1554            (Vec::new(), Some("unsupported_language".to_string()))
1555        }
1556    }
1557}
1558
1559#[cfg(test)]
1560mod tests {
1561    use super::*;
1562    use std::fs;
1563    use std::io::Write;
1564    use std::sync::{Mutex, MutexGuard, OnceLock};
1565
1566    /// Serializes tests that mutate the global TOOL_RESOLUTION_CACHE /
1567    /// TOOL_AVAILABILITY_CACHE. Cargo runs tests in parallel by default, and
1568    /// `clear_tool_cache()` from one test would otherwise wipe cached entries
1569    /// that another test had just written, causing flaky CI failures (the
1570    /// `resolve_tool_caches_negative_result_until_clear` failure on Linux
1571    /// runners had exactly this shape).
1572    fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
1573        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1574        let mutex = LOCK.get_or_init(|| Mutex::new(()));
1575        // Recover from poisoning so a panic in one test doesn't permanently
1576        // wedge the rest of the suite.
1577        match mutex.lock() {
1578            Ok(guard) => guard,
1579            Err(poisoned) => poisoned.into_inner(),
1580        }
1581    }
1582
1583    #[test]
1584    fn run_external_tool_not_found() {
1585        let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1586        assert!(result.is_err());
1587        match result.unwrap_err() {
1588            FormatError::NotFound { tool } => {
1589                assert_eq!(tool, "__nonexistent_tool_xyz__");
1590            }
1591            other => panic!("expected NotFound, got: {:?}", other),
1592        }
1593    }
1594
1595    #[test]
1596    fn run_external_tool_timeout_kills_subprocess() {
1597        // Use `sleep 60` as a long-running process, timeout after 1 second
1598        let result = run_external_tool("sleep", &["60"], None, 1);
1599        assert!(result.is_err());
1600        match result.unwrap_err() {
1601            FormatError::Timeout { tool, timeout_secs } => {
1602                assert_eq!(tool, "sleep");
1603                assert_eq!(timeout_secs, 1);
1604            }
1605            other => panic!("expected Timeout, got: {:?}", other),
1606        }
1607    }
1608
1609    #[test]
1610    fn run_external_tool_success() {
1611        let result = run_external_tool("echo", &["hello"], None, 5);
1612        assert!(result.is_ok());
1613        let res = result.unwrap();
1614        assert_eq!(res.exit_code, 0);
1615        assert!(res.stdout.contains("hello"));
1616    }
1617
1618    #[test]
1619    fn run_external_tool_nonzero_exit() {
1620        // `false` always exits with code 1
1621        let result = run_external_tool("false", &[], None, 5);
1622        assert!(result.is_err());
1623        match result.unwrap_err() {
1624            FormatError::Failed { tool, .. } => {
1625                assert_eq!(tool, "false");
1626            }
1627            other => panic!("expected Failed, got: {:?}", other),
1628        }
1629    }
1630
1631    #[test]
1632    fn auto_format_unsupported_language() {
1633        let dir = tempfile::tempdir().unwrap();
1634        let path = dir.path().join("file.txt");
1635        fs::write(&path, "hello").unwrap();
1636
1637        let config = Config::default();
1638        let (formatted, reason) = auto_format(&path, &config);
1639        assert!(!formatted);
1640        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1641    }
1642
1643    #[test]
1644    fn detect_formatter_rust_when_rustfmt_available() {
1645        let dir = tempfile::tempdir().unwrap();
1646        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1647        let path = dir.path().join("test.rs");
1648        let config = Config {
1649            project_root: Some(dir.path().to_path_buf()),
1650            ..Config::default()
1651        };
1652        let result = detect_formatter(&path, LangId::Rust, &config);
1653        if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
1654            let (cmd, args) = result.unwrap();
1655            assert_eq!(cmd, "rustfmt");
1656            assert!(args.iter().any(|a| a.ends_with("test.rs")));
1657        } else {
1658            assert!(result.is_none());
1659        }
1660    }
1661
1662    #[test]
1663    fn detect_formatter_go_mapping() {
1664        let dir = tempfile::tempdir().unwrap();
1665        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1666        let path = dir.path().join("main.go");
1667        let config = Config {
1668            project_root: Some(dir.path().to_path_buf()),
1669            ..Config::default()
1670        };
1671        let result = detect_formatter(&path, LangId::Go, &config);
1672        if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
1673            let (cmd, args) = result.unwrap();
1674            assert_eq!(cmd, "goimports");
1675            assert!(args.contains(&"-w".to_string()));
1676        } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
1677            let (cmd, args) = result.unwrap();
1678            assert_eq!(cmd, "gofmt");
1679            assert!(args.contains(&"-w".to_string()));
1680        } else {
1681            assert!(result.is_none());
1682        }
1683    }
1684
1685    #[test]
1686    fn detect_formatter_python_mapping() {
1687        let dir = tempfile::tempdir().unwrap();
1688        fs::write(dir.path().join("ruff.toml"), "").unwrap();
1689        let path = dir.path().join("main.py");
1690        let config = Config {
1691            project_root: Some(dir.path().to_path_buf()),
1692            ..Config::default()
1693        };
1694        let result = detect_formatter(&path, LangId::Python, &config);
1695        if ruff_format_available(config.project_root.as_deref()) {
1696            let (cmd, args) = result.unwrap();
1697            assert_eq!(cmd, "ruff");
1698            assert!(args.contains(&"format".to_string()));
1699        } else {
1700            assert!(result.is_none());
1701        }
1702    }
1703
1704    #[test]
1705    fn detect_formatter_no_config_returns_none() {
1706        let path = Path::new("test.ts");
1707        let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1708        assert!(
1709            result.is_none(),
1710            "expected no formatter without project config"
1711        );
1712    }
1713
1714    // Unix-only: `resolve_tool_uncached` checks `node_modules/.bin/<name>`
1715    // without trying Windows extensions (.cmd/.exe/.bat). Writing
1716    // `biome.cmd` would not be found by the resolver. A future product
1717    // fix could extend resolve_tool to honor PATHEXT; for now this test
1718    // focuses on the explicit-override semantics on Unix.
1719    #[cfg(unix)]
1720    #[test]
1721    fn detect_formatter_explicit_override() {
1722        // Create a temp dir with a fake node_modules/.bin/biome so resolve_tool finds it
1723        let dir = tempfile::tempdir().unwrap();
1724        let bin_dir = dir.path().join("node_modules").join(".bin");
1725        fs::create_dir_all(&bin_dir).unwrap();
1726        use std::os::unix::fs::PermissionsExt;
1727        let fake = bin_dir.join("biome");
1728        fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1729        fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1730
1731        let path = Path::new("test.ts");
1732        let mut config = Config {
1733            project_root: Some(dir.path().to_path_buf()),
1734            ..Config::default()
1735        };
1736        config
1737            .formatter
1738            .insert("typescript".to_string(), "biome".to_string());
1739        let result = detect_formatter(path, LangId::TypeScript, &config);
1740        let (cmd, args) = result.unwrap();
1741        assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1742        assert!(args.contains(&"format".to_string()));
1743        assert!(args.contains(&"--write".to_string()));
1744    }
1745
1746    #[test]
1747    fn resolve_tool_caches_positive_result_until_clear() {
1748        let _guard = tool_cache_test_lock();
1749        clear_tool_cache();
1750        let dir = tempfile::tempdir().unwrap();
1751        let bin_dir = dir.path().join("node_modules").join(".bin");
1752        fs::create_dir_all(&bin_dir).unwrap();
1753        let tool = bin_dir.join("aft-cache-hit-tool");
1754        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1755
1756        let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1757        assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
1758
1759        fs::remove_file(&tool).unwrap();
1760        let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1761        assert_eq!(cached, first);
1762
1763        clear_tool_cache();
1764        assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
1765    }
1766
1767    #[test]
1768    fn resolve_tool_caches_negative_result_until_clear() {
1769        let _guard = tool_cache_test_lock();
1770        clear_tool_cache();
1771        let dir = tempfile::tempdir().unwrap();
1772        let bin_dir = dir.path().join("node_modules").join(".bin");
1773        let tool = bin_dir.join("aft-cache-miss-tool");
1774
1775        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1776
1777        fs::create_dir_all(&bin_dir).unwrap();
1778        fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1779        assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1780
1781        clear_tool_cache();
1782        assert_eq!(
1783            resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
1784            Some(tool.to_string_lossy().as_ref())
1785        );
1786    }
1787
1788    #[test]
1789    fn auto_format_happy_path_rustfmt() {
1790        if resolve_tool("rustfmt", None).is_none() {
1791            log::warn!("skipping: rustfmt not available");
1792            return;
1793        }
1794
1795        let dir = tempfile::tempdir().unwrap();
1796        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1797        let path = dir.path().join("test.rs");
1798
1799        let mut f = fs::File::create(&path).unwrap();
1800        writeln!(f, "fn    main()   {{  println!(\"hello\");  }}").unwrap();
1801        drop(f);
1802
1803        let config = Config {
1804            project_root: Some(dir.path().to_path_buf()),
1805            ..Config::default()
1806        };
1807        let (formatted, reason) = auto_format(&path, &config);
1808        assert!(formatted, "expected formatting to succeed");
1809        assert!(reason.is_none());
1810
1811        let content = fs::read_to_string(&path).unwrap();
1812        assert!(
1813            !content.contains("fn    main"),
1814            "expected rustfmt to fix spacing"
1815        );
1816    }
1817
1818    #[test]
1819    fn formatter_excluded_path_detects_biome_messages() {
1820        // Real biome 1.x output when invoked on a path outside files.includes.
1821        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";
1822        assert!(
1823            formatter_excluded_path(stderr),
1824            "expected biome exclusion stderr to be detected"
1825        );
1826    }
1827
1828    #[test]
1829    fn formatter_excluded_path_detects_prettier_messages() {
1830        // Real prettier output when given a glob/path that resolves to nothing
1831        // it's allowed to format (after .prettierignore filtering).
1832        let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
1833        assert!(
1834            formatter_excluded_path(stderr),
1835            "expected prettier exclusion stderr to be detected"
1836        );
1837    }
1838
1839    #[test]
1840    fn formatter_excluded_path_detects_ruff_messages() {
1841        // Real ruff output when invoked outside its [tool.ruff] scope.
1842        let stderr = "warning: No Python files found under the given path(s).\n";
1843        assert!(
1844            formatter_excluded_path(stderr),
1845            "expected ruff exclusion stderr to be detected"
1846        );
1847    }
1848
1849    #[test]
1850    fn formatter_excluded_path_is_case_insensitive() {
1851        assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
1852        assert!(formatter_excluded_path("Ignored By The Configuration"));
1853    }
1854
1855    #[test]
1856    fn formatter_excluded_path_rejects_real_errors() {
1857        // Counter-cases: actual formatter errors must NOT be treated as
1858        // exclusion. This guards against the detection being too greedy.
1859        assert!(!formatter_excluded_path(""));
1860        assert!(!formatter_excluded_path("syntax error: unexpected token"));
1861        assert!(!formatter_excluded_path("formatter crashed: out of memory"));
1862        assert!(!formatter_excluded_path(
1863            "permission denied: /readonly/file"
1864        ));
1865        assert!(!formatter_excluded_path(
1866            "biome internal error: please report"
1867        ));
1868    }
1869
1870    #[test]
1871    fn parse_tsc_output_basic() {
1872        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";
1873        let file = Path::new("src/app.ts");
1874        let errors = parse_tsc_output(stdout, "", file);
1875        assert_eq!(errors.len(), 2);
1876        assert_eq!(errors[0].line, 10);
1877        assert_eq!(errors[0].column, 5);
1878        assert_eq!(errors[0].severity, "error");
1879        assert!(errors[0].message.contains("TS2322"));
1880        assert_eq!(errors[1].line, 20);
1881    }
1882
1883    #[test]
1884    fn parse_tsc_output_filters_other_files() {
1885        let stdout =
1886            "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1887        let file = Path::new("src/app.ts");
1888        let errors = parse_tsc_output(stdout, "", file);
1889        assert_eq!(errors.len(), 1);
1890        assert_eq!(errors[0].line, 5);
1891    }
1892
1893    #[test]
1894    fn parse_cargo_output_basic() {
1895        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}]}}"#;
1896        let file = Path::new("src/main.rs");
1897        let errors = parse_cargo_output(json_line, "", file);
1898        assert_eq!(errors.len(), 1);
1899        assert_eq!(errors[0].line, 10);
1900        assert_eq!(errors[0].column, 5);
1901        assert_eq!(errors[0].severity, "error");
1902        assert!(errors[0].message.contains("mismatched types"));
1903    }
1904
1905    #[test]
1906    fn parse_cargo_output_skips_notes() {
1907        // Notes and help messages should be filtered out
1908        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}]}}"#;
1909        let file = Path::new("src/main.rs");
1910        let errors = parse_cargo_output(json_line, "", file);
1911        assert_eq!(errors.len(), 0);
1912    }
1913
1914    #[test]
1915    fn parse_cargo_output_filters_other_files() {
1916        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}]}}"#;
1917        let file = Path::new("src/main.rs");
1918        let errors = parse_cargo_output(json_line, "", file);
1919        assert_eq!(errors.len(), 0);
1920    }
1921
1922    #[test]
1923    fn parse_go_vet_output_basic() {
1924        let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1925        let file = Path::new("main.go");
1926        let errors = parse_go_vet_output(stderr, file);
1927        assert_eq!(errors.len(), 2);
1928        assert_eq!(errors[0].line, 10);
1929        assert_eq!(errors[0].column, 5);
1930        assert!(errors[0].message.contains("unreachable code"));
1931        assert_eq!(errors[1].line, 20);
1932        assert_eq!(errors[1].column, 0);
1933    }
1934
1935    #[test]
1936    fn parse_pyright_output_basic() {
1937        let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1938        let file = Path::new("test.py");
1939        let errors = parse_pyright_output(stdout, file);
1940        assert_eq!(errors.len(), 1);
1941        assert_eq!(errors[0].line, 5); // 0-indexed → 1-indexed
1942        assert_eq!(errors[0].column, 10);
1943        assert_eq!(errors[0].severity, "error");
1944        assert!(errors[0].message.contains("Type error here"));
1945    }
1946
1947    #[test]
1948    fn validate_full_unsupported_language() {
1949        let dir = tempfile::tempdir().unwrap();
1950        let path = dir.path().join("file.txt");
1951        fs::write(&path, "hello").unwrap();
1952
1953        let config = Config::default();
1954        let (errors, reason) = validate_full(&path, &config);
1955        assert!(errors.is_empty());
1956        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1957    }
1958
1959    #[test]
1960    fn detect_type_checker_rust() {
1961        let dir = tempfile::tempdir().unwrap();
1962        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1963        let path = dir.path().join("src/main.rs");
1964        let config = Config {
1965            project_root: Some(dir.path().to_path_buf()),
1966            ..Config::default()
1967        };
1968        let result = detect_type_checker(&path, LangId::Rust, &config);
1969        if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
1970            let (cmd, args) = result.unwrap();
1971            assert_eq!(cmd, "cargo");
1972            assert!(args.contains(&"check".to_string()));
1973        } else {
1974            assert!(result.is_none());
1975        }
1976    }
1977
1978    #[test]
1979    fn detect_type_checker_go() {
1980        let dir = tempfile::tempdir().unwrap();
1981        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1982        let path = dir.path().join("main.go");
1983        let config = Config {
1984            project_root: Some(dir.path().to_path_buf()),
1985            ..Config::default()
1986        };
1987        let result = detect_type_checker(&path, LangId::Go, &config);
1988        if resolve_tool("go", config.project_root.as_deref()).is_some() {
1989            let (cmd, _args) = result.unwrap();
1990            // Could be staticcheck or go vet depending on what's installed
1991            assert!(cmd == "go" || cmd == "staticcheck");
1992        } else {
1993            assert!(result.is_none());
1994        }
1995    }
1996    #[test]
1997    fn run_external_tool_capture_nonzero_not_error() {
1998        // `false` exits with code 1 — capture should still return Ok
1999        let result = run_external_tool_capture("false", &[], None, 5);
2000        assert!(result.is_ok(), "capture should not error on non-zero exit");
2001        assert_eq!(result.unwrap().exit_code, 1);
2002    }
2003
2004    #[test]
2005    fn run_external_tool_capture_not_found() {
2006        let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2007        assert!(result.is_err());
2008        match result.unwrap_err() {
2009            FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2010            other => panic!("expected NotFound, got: {:?}", other),
2011        }
2012    }
2013}