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