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