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;
7use std::io::ErrorKind;
8use std::path::Path;
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
38impl std::fmt::Display for FormatError {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        match self {
41            FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
42            FormatError::Timeout { tool, timeout_secs } => {
43                write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
44            }
45            FormatError::Failed { tool, stderr } => {
46                write!(f, "formatter '{}' failed: {}", tool, stderr)
47            }
48            FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
49        }
50    }
51}
52
53/// Spawn a subprocess and wait for completion with timeout protection.
54///
55/// Polls `try_wait()` at 50ms intervals. On timeout, kills the child process
56/// and waits for it to exit. Returns `FormatError::NotFound` when the binary
57/// isn't on PATH.
58pub fn run_external_tool(
59    command: &str,
60    args: &[&str],
61    working_dir: Option<&Path>,
62    timeout_secs: u32,
63) -> Result<ExternalToolResult, FormatError> {
64    let mut cmd = Command::new(command);
65    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
66
67    if let Some(dir) = working_dir {
68        cmd.current_dir(dir);
69    }
70
71    let mut child = match cmd.spawn() {
72        Ok(c) => c,
73        Err(e) if e.kind() == ErrorKind::NotFound => {
74            return Err(FormatError::NotFound {
75                tool: command.to_string(),
76            });
77        }
78        Err(e) => {
79            return Err(FormatError::Failed {
80                tool: command.to_string(),
81                stderr: e.to_string(),
82            });
83        }
84    };
85
86    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
87
88    loop {
89        match child.try_wait() {
90            Ok(Some(status)) => {
91                let stdout = child
92                    .stdout
93                    .take()
94                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
95                    .unwrap_or_default();
96                let stderr = child
97                    .stderr
98                    .take()
99                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
100                    .unwrap_or_default();
101
102                let exit_code = status.code().unwrap_or(-1);
103                if exit_code != 0 {
104                    return Err(FormatError::Failed {
105                        tool: command.to_string(),
106                        stderr,
107                    });
108                }
109
110                return Ok(ExternalToolResult {
111                    stdout,
112                    stderr,
113                    exit_code,
114                });
115            }
116            Ok(None) => {
117                // Still running
118                if Instant::now() >= deadline {
119                    // Kill the process and reap it
120                    let _ = child.kill();
121                    let _ = child.wait();
122                    return Err(FormatError::Timeout {
123                        tool: command.to_string(),
124                        timeout_secs,
125                    });
126                }
127                thread::sleep(Duration::from_millis(50));
128            }
129            Err(e) => {
130                return Err(FormatError::Failed {
131                    tool: command.to_string(),
132                    stderr: format!("try_wait error: {}", e),
133                });
134            }
135        }
136    }
137}
138
139/// TTL for tool availability cache entries.
140const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
141
142static TOOL_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
143    std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
144
145/// Check if a command exists by attempting to spawn it with `--version`.
146///
147/// First checks `<project_root>/node_modules/.bin/<command>` (for locally installed tools
148/// like biome, prettier), then falls back to PATH lookup.
149/// Results are cached for 60 seconds to avoid repeated subprocess spawning.
150fn tool_available(command: &str) -> bool {
151    if let Ok(cache) = TOOL_CACHE.lock() {
152        if let Some((available, checked_at)) = cache.get(command) {
153            if checked_at.elapsed() < TOOL_CACHE_TTL {
154                return *available;
155            }
156        }
157    }
158    let result = resolve_tool(command, None).is_some();
159    if let Ok(mut cache) = TOOL_CACHE.lock() {
160        cache.insert(command.to_string(), (result, Instant::now()));
161    }
162    result
163}
164
165/// Like `tool_available` but also checks node_modules/.bin relative to project_root.
166/// Returns the full path to the tool if found, otherwise None.
167fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
168    // 1. Check node_modules/.bin/<command> relative to project root
169    if let Some(root) = project_root {
170        let local_bin = root.join("node_modules").join(".bin").join(command);
171        if local_bin.exists() {
172            return Some(local_bin.to_string_lossy().to_string());
173        }
174    }
175
176    // 2. Fall back to PATH lookup
177    match Command::new(command)
178        .arg("--version")
179        .stdout(Stdio::null())
180        .stderr(Stdio::null())
181        .spawn()
182    {
183        Ok(mut child) => {
184            let _ = child.wait();
185            Some(command.to_string())
186        }
187        Err(_) => None,
188    }
189}
190
191/// Check if `ruff format` is available with a stable formatter.
192///
193/// Ruff's formatter became stable in v0.1.2. Versions before that output
194/// `NOT_YET_IMPLEMENTED_*` stubs instead of formatted code. We parse the
195/// version from `ruff --version` (format: "ruff X.Y.Z") and require >= 0.1.2.
196/// Falls back to false if ruff is not found or version cannot be parsed.
197fn ruff_format_available() -> bool {
198    let output = match Command::new("ruff")
199        .arg("--version")
200        .stdout(Stdio::piped())
201        .stderr(Stdio::null())
202        .output()
203    {
204        Ok(o) => o,
205        Err(_) => return false,
206    };
207
208    let version_str = String::from_utf8_lossy(&output.stdout);
209    // Parse "ruff X.Y.Z" or just "X.Y.Z"
210    let version_part = version_str
211        .trim()
212        .strip_prefix("ruff ")
213        .unwrap_or(version_str.trim());
214
215    let parts: Vec<&str> = version_part.split('.').collect();
216    if parts.len() < 3 {
217        return false;
218    }
219
220    let major: u32 = match parts[0].parse() {
221        Ok(v) => v,
222        Err(_) => return false,
223    };
224    let minor: u32 = match parts[1].parse() {
225        Ok(v) => v,
226        Err(_) => return false,
227    };
228    let patch: u32 = match parts[2].parse() {
229        Ok(v) => v,
230        Err(_) => return false,
231    };
232
233    // Require >= 0.1.2 where ruff format became stable
234    (major, minor, patch) >= (0, 1, 2)
235}
236
237/// Detect the appropriate formatter command and arguments for a file.
238///
239/// Priority per language:
240/// - TypeScript/JavaScript/TSX: `prettier --write <file>`
241/// - Python: `ruff format <file>` (fallback: `black <file>`)
242/// - Rust: `rustfmt <file>`
243/// - Go: `gofmt -w <file>`
244///
245/// Returns `None` if no formatter is available for the language.
246pub fn detect_formatter(
247    path: &Path,
248    lang: LangId,
249    config: &Config,
250) -> Option<(String, Vec<String>)> {
251    let file_str = path.to_string_lossy().to_string();
252
253    // 1. Per-language override from plugin config
254    let lang_key = match lang {
255        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
256        LangId::Python => "python",
257        LangId::Rust => "rust",
258        LangId::Go => "go",
259        LangId::Markdown => "markdown",
260    };
261    if let Some(preferred) = config.formatter.get(lang_key) {
262        return resolve_explicit_formatter(preferred, &file_str, lang);
263    }
264
265    // 2. Project config file detection only — no config file means no formatting.
266    //    This avoids silently reformatting code in projects without formatter setup.
267    let project_root = config.project_root.as_deref();
268
269    match lang {
270        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
271            // biome.json / biome.jsonc → biome (check node_modules/.bin first)
272            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
273                if let Some(biome_cmd) = resolve_tool("biome", project_root) {
274                    return Some((
275                        biome_cmd,
276                        vec!["format".to_string(), "--write".to_string(), file_str],
277                    ));
278                }
279            }
280            // .prettierrc / .prettierrc.* / prettier.config.* → prettier (check node_modules/.bin first)
281            if has_project_config(
282                project_root,
283                &[
284                    ".prettierrc",
285                    ".prettierrc.json",
286                    ".prettierrc.yml",
287                    ".prettierrc.yaml",
288                    ".prettierrc.js",
289                    ".prettierrc.cjs",
290                    ".prettierrc.mjs",
291                    ".prettierrc.toml",
292                    "prettier.config.js",
293                    "prettier.config.cjs",
294                    "prettier.config.mjs",
295                ],
296            ) {
297                if let Some(prettier_cmd) = resolve_tool("prettier", project_root) {
298                    return Some((prettier_cmd, vec!["--write".to_string(), file_str]));
299                }
300            }
301            // deno.json / deno.jsonc → deno fmt
302            if has_project_config(project_root, &["deno.json", "deno.jsonc"])
303                && tool_available("deno")
304            {
305                return Some(("deno".to_string(), vec!["fmt".to_string(), file_str]));
306            }
307            // No config file found → do not format
308            None
309        }
310        LangId::Python => {
311            // ruff.toml or pyproject.toml with ruff config → ruff
312            if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
313                || has_pyproject_tool(project_root, "ruff"))
314                && ruff_format_available()
315            {
316                return Some(("ruff".to_string(), vec!["format".to_string(), file_str]));
317            }
318            // pyproject.toml with black config → black
319            if has_pyproject_tool(project_root, "black") && tool_available("black") {
320                return Some(("black".to_string(), vec![file_str]));
321            }
322            // No config file found → do not format
323            None
324        }
325        LangId::Rust => {
326            // Cargo.toml implies standard Rust formatting
327            if has_project_config(project_root, &["Cargo.toml"]) && tool_available("rustfmt") {
328                Some(("rustfmt".to_string(), vec![file_str]))
329            } else {
330                None
331            }
332        }
333        LangId::Go => {
334            // go.mod implies a Go project
335            if has_project_config(project_root, &["go.mod"]) {
336                if tool_available("goimports") {
337                    Some(("goimports".to_string(), vec!["-w".to_string(), file_str]))
338                } else if tool_available("gofmt") {
339                    Some(("gofmt".to_string(), vec!["-w".to_string(), file_str]))
340                } else {
341                    None
342                }
343            } else {
344                None
345            }
346        }
347        LangId::Markdown => None,
348    }
349}
350
351/// Resolve an explicitly configured formatter name to a command + args.
352fn resolve_explicit_formatter(
353    name: &str,
354    file_str: &str,
355    lang: LangId,
356) -> Option<(String, Vec<String>)> {
357    match name {
358        "biome" => Some((
359            "biome".to_string(),
360            vec![
361                "format".to_string(),
362                "--write".to_string(),
363                file_str.to_string(),
364            ],
365        )),
366        "prettier" => Some((
367            "prettier".to_string(),
368            vec!["--write".to_string(), file_str.to_string()],
369        )),
370        "deno" => Some((
371            "deno".to_string(),
372            vec!["fmt".to_string(), file_str.to_string()],
373        )),
374        "ruff" => Some((
375            "ruff".to_string(),
376            vec!["format".to_string(), file_str.to_string()],
377        )),
378        "black" => Some(("black".to_string(), vec![file_str.to_string()])),
379        "rustfmt" => Some(("rustfmt".to_string(), vec![file_str.to_string()])),
380        "goimports" => Some((
381            "goimports".to_string(),
382            vec!["-w".to_string(), file_str.to_string()],
383        )),
384        "gofmt" => Some((
385            "gofmt".to_string(),
386            vec!["-w".to_string(), file_str.to_string()],
387        )),
388        "none" | "off" | "false" => None,
389        _ => {
390            log::debug!(
391                "[aft] format: unknown preferred_formatter '{}' for {:?}, falling back to auto",
392                name,
393                lang
394            );
395            None
396        }
397    }
398}
399
400/// Check if any of the given config file names exist in the project root.
401fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
402    let root = match project_root {
403        Some(r) => r,
404        None => return false,
405    };
406    filenames.iter().any(|f| root.join(f).exists())
407}
408
409/// Check if pyproject.toml exists and contains a `[tool.<name>]` section.
410fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
411    let root = match project_root {
412        Some(r) => r,
413        None => return false,
414    };
415    let pyproject = root.join("pyproject.toml");
416    if !pyproject.exists() {
417        return false;
418    }
419    match std::fs::read_to_string(&pyproject) {
420        Ok(content) => {
421            let pattern = format!("[tool.{}]", tool_name);
422            content.contains(&pattern)
423        }
424        Err(_) => false,
425    }
426}
427
428/// Auto-format a file using the detected formatter for its language.
429///
430/// Returns `(formatted, skip_reason)`:
431/// - `(true, None)` — file was successfully formatted
432/// - `(false, Some(reason))` — formatting was skipped, reason explains why
433///
434/// Skip reasons: `"unsupported_language"`, `"not_found"`, `"timeout"`, `"error"`
435pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
436    // Check if formatting is disabled via plugin config
437    if !config.format_on_edit {
438        return (false, Some("disabled".to_string()));
439    }
440
441    let lang = match detect_language(path) {
442        Some(l) => l,
443        None => {
444            log::debug!(
445                "[aft] format: {} (skipped: unsupported_language)",
446                path.display()
447            );
448            return (false, Some("unsupported_language".to_string()));
449        }
450    };
451
452    let (cmd, args) = match detect_formatter(path, lang, config) {
453        Some(pair) => pair,
454        None => {
455            log::warn!("format: {} (skipped: not_found)", path.display());
456            return (false, Some("not_found".to_string()));
457        }
458    };
459
460    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
461
462    match run_external_tool(&cmd, &arg_refs, None, config.formatter_timeout_secs) {
463        Ok(_) => {
464            log::error!("format: {} ({})", path.display(), cmd);
465            (true, None)
466        }
467        Err(FormatError::Timeout { .. }) => {
468            log::error!("format: {} (skipped: timeout)", path.display());
469            (false, Some("timeout".to_string()))
470        }
471        Err(FormatError::NotFound { .. }) => {
472            log::warn!("format: {} (skipped: not_found)", path.display());
473            (false, Some("not_found".to_string()))
474        }
475        Err(FormatError::Failed { stderr, .. }) => {
476            log::debug!(
477                "[aft] format: {} (skipped: error: {})",
478                path.display(),
479                stderr.lines().next().unwrap_or("unknown")
480            );
481            (false, Some("error".to_string()))
482        }
483        Err(FormatError::UnsupportedLanguage) => {
484            log::debug!(
485                "[aft] format: {} (skipped: unsupported_language)",
486                path.display()
487            );
488            (false, Some("unsupported_language".to_string()))
489        }
490    }
491}
492
493/// Spawn a subprocess and capture output regardless of exit code.
494///
495/// Unlike `run_external_tool`, this does NOT treat non-zero exit as an error —
496/// type checkers return non-zero when they find issues, which is expected.
497/// Returns `FormatError::NotFound` when the binary isn't on PATH, and
498/// `FormatError::Timeout` if the deadline is exceeded.
499pub fn run_external_tool_capture(
500    command: &str,
501    args: &[&str],
502    working_dir: Option<&Path>,
503    timeout_secs: u32,
504) -> Result<ExternalToolResult, FormatError> {
505    let mut cmd = Command::new(command);
506    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
507
508    if let Some(dir) = working_dir {
509        cmd.current_dir(dir);
510    }
511
512    let mut child = match cmd.spawn() {
513        Ok(c) => c,
514        Err(e) if e.kind() == ErrorKind::NotFound => {
515            return Err(FormatError::NotFound {
516                tool: command.to_string(),
517            });
518        }
519        Err(e) => {
520            return Err(FormatError::Failed {
521                tool: command.to_string(),
522                stderr: e.to_string(),
523            });
524        }
525    };
526
527    let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
528
529    loop {
530        match child.try_wait() {
531            Ok(Some(status)) => {
532                let stdout = child
533                    .stdout
534                    .take()
535                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
536                    .unwrap_or_default();
537                let stderr = child
538                    .stderr
539                    .take()
540                    .map(|s| std::io::read_to_string(s).unwrap_or_default())
541                    .unwrap_or_default();
542
543                return Ok(ExternalToolResult {
544                    stdout,
545                    stderr,
546                    exit_code: status.code().unwrap_or(-1),
547                });
548            }
549            Ok(None) => {
550                if Instant::now() >= deadline {
551                    let _ = child.kill();
552                    let _ = child.wait();
553                    return Err(FormatError::Timeout {
554                        tool: command.to_string(),
555                        timeout_secs,
556                    });
557                }
558                thread::sleep(Duration::from_millis(50));
559            }
560            Err(e) => {
561                return Err(FormatError::Failed {
562                    tool: command.to_string(),
563                    stderr: format!("try_wait error: {}", e),
564                });
565            }
566        }
567    }
568}
569
570// ============================================================================
571// Type-checker validation (R017)
572// ============================================================================
573
574/// A structured error from a type checker.
575#[derive(Debug, Clone, serde::Serialize)]
576pub struct ValidationError {
577    pub line: u32,
578    pub column: u32,
579    pub message: String,
580    pub severity: String,
581}
582
583/// Detect the appropriate type checker command and arguments for a file.
584///
585/// Returns `(command, args)` for the type checker. The `--noEmit` / equivalent
586/// flags ensure no output files are produced.
587///
588/// Supported:
589/// - TypeScript/JavaScript/TSX → `npx tsc --noEmit` (fallback: `tsc --noEmit`)
590/// - Python → `pyright`
591/// - Rust → `cargo check`
592/// - Go → `go vet`
593pub fn detect_type_checker(
594    path: &Path,
595    lang: LangId,
596    config: &Config,
597) -> Option<(String, Vec<String>)> {
598    let file_str = path.to_string_lossy().to_string();
599    let project_root = config.project_root.as_deref();
600
601    // Per-language override from plugin config
602    let lang_key = match lang {
603        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
604        LangId::Python => "python",
605        LangId::Rust => "rust",
606        LangId::Go => "go",
607        LangId::Markdown => "markdown",
608    };
609    if let Some(preferred) = config.checker.get(lang_key) {
610        return resolve_explicit_checker(preferred, &file_str, lang);
611    }
612
613    match lang {
614        LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
615            // biome.json → biome check (lint + type errors, check node_modules/.bin first)
616            if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
617                if let Some(biome_cmd) = resolve_tool("biome", project_root) {
618                    return Some((biome_cmd, vec!["check".to_string(), file_str]));
619                }
620            }
621            // tsconfig.json → tsc (check node_modules/.bin first)
622            if has_project_config(project_root, &["tsconfig.json"]) {
623                if let Some(tsc_cmd) = resolve_tool("tsc", project_root) {
624                    return Some((
625                        tsc_cmd,
626                        vec![
627                            "--noEmit".to_string(),
628                            "--pretty".to_string(),
629                            "false".to_string(),
630                        ],
631                    ));
632                } else if tool_available("npx") {
633                    return Some((
634                        "npx".to_string(),
635                        vec![
636                            "tsc".to_string(),
637                            "--noEmit".to_string(),
638                            "--pretty".to_string(),
639                            "false".to_string(),
640                        ],
641                    ));
642                }
643            }
644            None
645        }
646        LangId::Python => {
647            // pyrightconfig.json or pyproject.toml with pyright → pyright
648            if has_project_config(project_root, &["pyrightconfig.json"])
649                || has_pyproject_tool(project_root, "pyright")
650            {
651                if let Some(pyright_cmd) = resolve_tool("pyright", project_root) {
652                    return Some((pyright_cmd, vec!["--outputjson".to_string(), file_str]));
653                }
654            }
655            // ruff.toml or pyproject.toml with ruff → ruff check
656            if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
657                || has_pyproject_tool(project_root, "ruff"))
658                && ruff_format_available()
659            {
660                return Some((
661                    "ruff".to_string(),
662                    vec![
663                        "check".to_string(),
664                        "--output-format=json".to_string(),
665                        file_str,
666                    ],
667                ));
668            }
669            None
670        }
671        LangId::Rust => {
672            // Cargo.toml implies cargo check
673            if has_project_config(project_root, &["Cargo.toml"]) && tool_available("cargo") {
674                Some((
675                    "cargo".to_string(),
676                    vec!["check".to_string(), "--message-format=json".to_string()],
677                ))
678            } else {
679                None
680            }
681        }
682        LangId::Go => {
683            // go.mod implies Go project
684            if has_project_config(project_root, &["go.mod"]) {
685                if tool_available("staticcheck") {
686                    Some(("staticcheck".to_string(), vec![file_str]))
687                } else if tool_available("go") {
688                    Some(("go".to_string(), vec!["vet".to_string(), file_str]))
689                } else {
690                    None
691                }
692            } else {
693                None
694            }
695        }
696        LangId::Markdown => None,
697    }
698}
699
700/// Resolve an explicitly configured checker name to a command + args.
701fn resolve_explicit_checker(
702    name: &str,
703    file_str: &str,
704    _lang: LangId,
705) -> Option<(String, Vec<String>)> {
706    match name {
707        "tsc" => Some((
708            "npx".to_string(),
709            vec![
710                "tsc".to_string(),
711                "--noEmit".to_string(),
712                "--pretty".to_string(),
713                "false".to_string(),
714            ],
715        )),
716        "biome" => Some((
717            "biome".to_string(),
718            vec!["check".to_string(), file_str.to_string()],
719        )),
720        "pyright" => Some((
721            "pyright".to_string(),
722            vec!["--outputjson".to_string(), file_str.to_string()],
723        )),
724        "ruff" => Some((
725            "ruff".to_string(),
726            vec![
727                "check".to_string(),
728                "--output-format=json".to_string(),
729                file_str.to_string(),
730            ],
731        )),
732        "cargo" => Some((
733            "cargo".to_string(),
734            vec!["check".to_string(), "--message-format=json".to_string()],
735        )),
736        "go" => Some((
737            "go".to_string(),
738            vec!["vet".to_string(), file_str.to_string()],
739        )),
740        "staticcheck" => Some(("staticcheck".to_string(), vec![file_str.to_string()])),
741        "none" | "off" | "false" => None,
742        _ => {
743            log::debug!(
744                "[aft] validate: unknown preferred_checker '{}', falling back to auto",
745                name
746            );
747            None
748        }
749    }
750}
751
752/// Parse type checker output into structured validation errors.
753///
754/// Handles output formats from tsc, pyright (JSON), cargo check (JSON), and go vet.
755/// Filters to errors related to the edited file where feasible.
756pub fn parse_checker_output(
757    stdout: &str,
758    stderr: &str,
759    file: &Path,
760    checker: &str,
761) -> Vec<ValidationError> {
762    match checker {
763        "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
764        "pyright" => parse_pyright_output(stdout, file),
765        "cargo" => parse_cargo_output(stdout, stderr, file),
766        "go" => parse_go_vet_output(stderr, file),
767        _ => Vec::new(),
768    }
769}
770
771/// Parse tsc output lines like: `path(line,col): error TSxxxx: message`
772fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
773    let mut errors = Vec::new();
774    let file_str = file.to_string_lossy();
775    // tsc writes diagnostics to stdout (with --pretty false)
776    let combined = format!("{}{}", stdout, stderr);
777    for line in combined.lines() {
778        // Format: path(line,col): severity TSxxxx: message
779        // or: path(line,col): severity: message
780        if let Some((loc, rest)) = line.split_once("): ") {
781            // Check if this error is for our file (compare filename part)
782            let file_part = loc.split('(').next().unwrap_or("");
783            if !file_str.ends_with(file_part)
784                && !file_part.ends_with(&*file_str)
785                && file_part != &*file_str
786            {
787                continue;
788            }
789
790            // Parse (line,col) from the location part
791            let coords = loc.split('(').last().unwrap_or("");
792            let parts: Vec<&str> = coords.split(',').collect();
793            let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
794            let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
795
796            // Parse severity and message
797            let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
798                ("error".to_string(), msg.to_string())
799            } else if let Some(msg) = rest.strip_prefix("warning ") {
800                ("warning".to_string(), msg.to_string())
801            } else {
802                ("error".to_string(), rest.to_string())
803            };
804
805            errors.push(ValidationError {
806                line: line_num,
807                column: col_num,
808                message,
809                severity,
810            });
811        }
812    }
813    errors
814}
815
816/// Parse pyright JSON output.
817fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
818    let mut errors = Vec::new();
819    let file_str = file.to_string_lossy();
820
821    // pyright --outputjson emits JSON with generalDiagnostics array
822    if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
823        if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
824            for diag in diags {
825                // Filter to our file
826                let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
827                if !diag_file.is_empty()
828                    && !file_str.ends_with(diag_file)
829                    && !diag_file.ends_with(&*file_str)
830                    && diag_file != &*file_str
831                {
832                    continue;
833                }
834
835                let line_num = diag
836                    .get("range")
837                    .and_then(|r| r.get("start"))
838                    .and_then(|s| s.get("line"))
839                    .and_then(|l| l.as_u64())
840                    .unwrap_or(0) as u32;
841                let col_num = diag
842                    .get("range")
843                    .and_then(|r| r.get("start"))
844                    .and_then(|s| s.get("character"))
845                    .and_then(|c| c.as_u64())
846                    .unwrap_or(0) as u32;
847                let message = diag
848                    .get("message")
849                    .and_then(|m| m.as_str())
850                    .unwrap_or("unknown error")
851                    .to_string();
852                let severity = diag
853                    .get("severity")
854                    .and_then(|s| s.as_str())
855                    .unwrap_or("error")
856                    .to_lowercase();
857
858                errors.push(ValidationError {
859                    line: line_num + 1, // pyright uses 0-indexed lines
860                    column: col_num,
861                    message,
862                    severity,
863                });
864            }
865        }
866    }
867    errors
868}
869
870/// Parse cargo check JSON output, filtering to errors in the target file.
871fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
872    let mut errors = Vec::new();
873    let file_str = file.to_string_lossy();
874
875    for line in stdout.lines() {
876        if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
877            if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
878                continue;
879            }
880            let message_obj = match msg.get("message") {
881                Some(m) => m,
882                None => continue,
883            };
884
885            let level = message_obj
886                .get("level")
887                .and_then(|l| l.as_str())
888                .unwrap_or("error");
889
890            // Only include errors and warnings, skip notes/help
891            if level != "error" && level != "warning" {
892                continue;
893            }
894
895            let text = message_obj
896                .get("message")
897                .and_then(|m| m.as_str())
898                .unwrap_or("unknown error")
899                .to_string();
900
901            // Find the primary span for our file
902            if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
903                for span in spans {
904                    let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
905                    let is_primary = span
906                        .get("is_primary")
907                        .and_then(|p| p.as_bool())
908                        .unwrap_or(false);
909
910                    if !is_primary {
911                        continue;
912                    }
913
914                    // Filter to our file
915                    if !file_str.ends_with(span_file)
916                        && !span_file.ends_with(&*file_str)
917                        && span_file != &*file_str
918                    {
919                        continue;
920                    }
921
922                    let line_num =
923                        span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
924                    let col_num = span
925                        .get("column_start")
926                        .and_then(|c| c.as_u64())
927                        .unwrap_or(0) as u32;
928
929                    errors.push(ValidationError {
930                        line: line_num,
931                        column: col_num,
932                        message: text.clone(),
933                        severity: level.to_string(),
934                    });
935                }
936            }
937        }
938    }
939    errors
940}
941
942/// Parse go vet output lines like: `path:line:col: message`
943fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
944    let mut errors = Vec::new();
945    let file_str = file.to_string_lossy();
946
947    for line in stderr.lines() {
948        // Format: path:line:col: message  OR  path:line: message
949        let parts: Vec<&str> = line.splitn(4, ':').collect();
950        if parts.len() < 3 {
951            continue;
952        }
953
954        let err_file = parts[0].trim();
955        if !file_str.ends_with(err_file)
956            && !err_file.ends_with(&*file_str)
957            && err_file != &*file_str
958        {
959            continue;
960        }
961
962        let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
963        let (col_num, message) = if parts.len() >= 4 {
964            if let Ok(col) = parts[2].trim().parse::<u32>() {
965                (col, parts[3].trim().to_string())
966            } else {
967                // parts[2] is part of the message, not a column
968                (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
969            }
970        } else {
971            (0, parts[2].trim().to_string())
972        };
973
974        errors.push(ValidationError {
975            line: line_num,
976            column: col_num,
977            message,
978            severity: "error".to_string(),
979        });
980    }
981    errors
982}
983
984/// Run the project's type checker and return structured validation errors.
985///
986/// Returns `(errors, skip_reason)`:
987/// - `(errors, None)` — checker ran, errors may be empty (= valid code)
988/// - `([], Some(reason))` — checker was skipped
989///
990/// Skip reasons: `"unsupported_language"`, `"not_found"`, `"timeout"`, `"error"`
991pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
992    let lang = match detect_language(path) {
993        Some(l) => l,
994        None => {
995            log::debug!(
996                "[aft] validate: {} (skipped: unsupported_language)",
997                path.display()
998            );
999            return (Vec::new(), Some("unsupported_language".to_string()));
1000        }
1001    };
1002
1003    let (cmd, args) = match detect_type_checker(path, lang, config) {
1004        Some(pair) => pair,
1005        None => {
1006            log::warn!("validate: {} (skipped: not_found)", path.display());
1007            return (Vec::new(), Some("not_found".to_string()));
1008        }
1009    };
1010
1011    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1012
1013    // Type checkers may need to run from the project root
1014    let working_dir = path.parent();
1015
1016    match run_external_tool_capture(
1017        &cmd,
1018        &arg_refs,
1019        working_dir,
1020        config.type_checker_timeout_secs,
1021    ) {
1022        Ok(result) => {
1023            let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1024            log::debug!(
1025                "[aft] validate: {} ({}, {} errors)",
1026                path.display(),
1027                cmd,
1028                errors.len()
1029            );
1030            (errors, None)
1031        }
1032        Err(FormatError::Timeout { .. }) => {
1033            log::error!("validate: {} (skipped: timeout)", path.display());
1034            (Vec::new(), Some("timeout".to_string()))
1035        }
1036        Err(FormatError::NotFound { .. }) => {
1037            log::warn!("validate: {} (skipped: not_found)", path.display());
1038            (Vec::new(), Some("not_found".to_string()))
1039        }
1040        Err(FormatError::Failed { stderr, .. }) => {
1041            log::debug!(
1042                "[aft] validate: {} (skipped: error: {})",
1043                path.display(),
1044                stderr.lines().next().unwrap_or("unknown")
1045            );
1046            (Vec::new(), Some("error".to_string()))
1047        }
1048        Err(FormatError::UnsupportedLanguage) => {
1049            log::debug!(
1050                "[aft] validate: {} (skipped: unsupported_language)",
1051                path.display()
1052            );
1053            (Vec::new(), Some("unsupported_language".to_string()))
1054        }
1055    }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::*;
1061    use std::fs;
1062    use std::io::Write;
1063
1064    #[test]
1065    fn run_external_tool_not_found() {
1066        let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1067        assert!(result.is_err());
1068        match result.unwrap_err() {
1069            FormatError::NotFound { tool } => {
1070                assert_eq!(tool, "__nonexistent_tool_xyz__");
1071            }
1072            other => panic!("expected NotFound, got: {:?}", other),
1073        }
1074    }
1075
1076    #[test]
1077    fn run_external_tool_timeout_kills_subprocess() {
1078        // Use `sleep 60` as a long-running process, timeout after 1 second
1079        let result = run_external_tool("sleep", &["60"], None, 1);
1080        assert!(result.is_err());
1081        match result.unwrap_err() {
1082            FormatError::Timeout { tool, timeout_secs } => {
1083                assert_eq!(tool, "sleep");
1084                assert_eq!(timeout_secs, 1);
1085            }
1086            other => panic!("expected Timeout, got: {:?}", other),
1087        }
1088    }
1089
1090    #[test]
1091    fn run_external_tool_success() {
1092        let result = run_external_tool("echo", &["hello"], None, 5);
1093        assert!(result.is_ok());
1094        let res = result.unwrap();
1095        assert_eq!(res.exit_code, 0);
1096        assert!(res.stdout.contains("hello"));
1097    }
1098
1099    #[test]
1100    fn run_external_tool_nonzero_exit() {
1101        // `false` always exits with code 1
1102        let result = run_external_tool("false", &[], None, 5);
1103        assert!(result.is_err());
1104        match result.unwrap_err() {
1105            FormatError::Failed { tool, .. } => {
1106                assert_eq!(tool, "false");
1107            }
1108            other => panic!("expected Failed, got: {:?}", other),
1109        }
1110    }
1111
1112    #[test]
1113    fn auto_format_unsupported_language() {
1114        let dir = tempfile::tempdir().unwrap();
1115        let path = dir.path().join("file.txt");
1116        fs::write(&path, "hello").unwrap();
1117
1118        let config = Config::default();
1119        let (formatted, reason) = auto_format(&path, &config);
1120        assert!(!formatted);
1121        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1122    }
1123
1124    #[test]
1125    fn detect_formatter_rust_when_rustfmt_available() {
1126        let dir = tempfile::tempdir().unwrap();
1127        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1128        let path = dir.path().join("test.rs");
1129        let config = Config {
1130            project_root: Some(dir.path().to_path_buf()),
1131            ..Config::default()
1132        };
1133        let result = detect_formatter(&path, LangId::Rust, &config);
1134        if tool_available("rustfmt") {
1135            let (cmd, args) = result.unwrap();
1136            assert_eq!(cmd, "rustfmt");
1137            assert!(args.iter().any(|a| a.ends_with("test.rs")));
1138        } else {
1139            assert!(result.is_none());
1140        }
1141    }
1142
1143    #[test]
1144    fn detect_formatter_go_mapping() {
1145        let dir = tempfile::tempdir().unwrap();
1146        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1147        let path = dir.path().join("main.go");
1148        let config = Config {
1149            project_root: Some(dir.path().to_path_buf()),
1150            ..Config::default()
1151        };
1152        let result = detect_formatter(&path, LangId::Go, &config);
1153        if tool_available("goimports") {
1154            let (cmd, args) = result.unwrap();
1155            assert_eq!(cmd, "goimports");
1156            assert!(args.contains(&"-w".to_string()));
1157        } else if tool_available("gofmt") {
1158            let (cmd, args) = result.unwrap();
1159            assert_eq!(cmd, "gofmt");
1160            assert!(args.contains(&"-w".to_string()));
1161        } else {
1162            assert!(result.is_none());
1163        }
1164    }
1165
1166    #[test]
1167    fn detect_formatter_python_mapping() {
1168        let dir = tempfile::tempdir().unwrap();
1169        fs::write(dir.path().join("ruff.toml"), "").unwrap();
1170        let path = dir.path().join("main.py");
1171        let config = Config {
1172            project_root: Some(dir.path().to_path_buf()),
1173            ..Config::default()
1174        };
1175        let result = detect_formatter(&path, LangId::Python, &config);
1176        if ruff_format_available() {
1177            let (cmd, args) = result.unwrap();
1178            assert_eq!(cmd, "ruff");
1179            assert!(args.contains(&"format".to_string()));
1180        } else {
1181            assert!(result.is_none());
1182        }
1183    }
1184
1185    #[test]
1186    fn detect_formatter_no_config_returns_none() {
1187        let path = Path::new("test.ts");
1188        let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1189        assert!(
1190            result.is_none(),
1191            "expected no formatter without project config"
1192        );
1193    }
1194
1195    #[test]
1196    fn detect_formatter_explicit_override() {
1197        let path = Path::new("test.ts");
1198        let mut config = Config::default();
1199        config
1200            .formatter
1201            .insert("typescript".to_string(), "biome".to_string());
1202        let result = detect_formatter(path, LangId::TypeScript, &config);
1203        let (cmd, _) = result.unwrap();
1204        assert_eq!(cmd, "biome");
1205    }
1206
1207    #[test]
1208    fn auto_format_happy_path_rustfmt() {
1209        if !tool_available("rustfmt") {
1210            log::warn!("skipping: rustfmt not available");
1211            return;
1212        }
1213
1214        let dir = tempfile::tempdir().unwrap();
1215        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1216        let path = dir.path().join("test.rs");
1217
1218        let mut f = fs::File::create(&path).unwrap();
1219        writeln!(f, "fn    main()   {{  println!(\"hello\");  }}").unwrap();
1220        drop(f);
1221
1222        let config = Config {
1223            project_root: Some(dir.path().to_path_buf()),
1224            ..Config::default()
1225        };
1226        let (formatted, reason) = auto_format(&path, &config);
1227        assert!(formatted, "expected formatting to succeed");
1228        assert!(reason.is_none());
1229
1230        let content = fs::read_to_string(&path).unwrap();
1231        assert!(
1232            !content.contains("fn    main"),
1233            "expected rustfmt to fix spacing"
1234        );
1235    }
1236
1237    #[test]
1238    fn parse_tsc_output_basic() {
1239        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";
1240        let file = Path::new("src/app.ts");
1241        let errors = parse_tsc_output(stdout, "", file);
1242        assert_eq!(errors.len(), 2);
1243        assert_eq!(errors[0].line, 10);
1244        assert_eq!(errors[0].column, 5);
1245        assert_eq!(errors[0].severity, "error");
1246        assert!(errors[0].message.contains("TS2322"));
1247        assert_eq!(errors[1].line, 20);
1248    }
1249
1250    #[test]
1251    fn parse_tsc_output_filters_other_files() {
1252        let stdout =
1253            "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1254        let file = Path::new("src/app.ts");
1255        let errors = parse_tsc_output(stdout, "", file);
1256        assert_eq!(errors.len(), 1);
1257        assert_eq!(errors[0].line, 5);
1258    }
1259
1260    #[test]
1261    fn parse_cargo_output_basic() {
1262        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}]}}"#;
1263        let file = Path::new("src/main.rs");
1264        let errors = parse_cargo_output(json_line, "", file);
1265        assert_eq!(errors.len(), 1);
1266        assert_eq!(errors[0].line, 10);
1267        assert_eq!(errors[0].column, 5);
1268        assert_eq!(errors[0].severity, "error");
1269        assert!(errors[0].message.contains("mismatched types"));
1270    }
1271
1272    #[test]
1273    fn parse_cargo_output_skips_notes() {
1274        // Notes and help messages should be filtered out
1275        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}]}}"#;
1276        let file = Path::new("src/main.rs");
1277        let errors = parse_cargo_output(json_line, "", file);
1278        assert_eq!(errors.len(), 0);
1279    }
1280
1281    #[test]
1282    fn parse_cargo_output_filters_other_files() {
1283        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}]}}"#;
1284        let file = Path::new("src/main.rs");
1285        let errors = parse_cargo_output(json_line, "", file);
1286        assert_eq!(errors.len(), 0);
1287    }
1288
1289    #[test]
1290    fn parse_go_vet_output_basic() {
1291        let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1292        let file = Path::new("main.go");
1293        let errors = parse_go_vet_output(stderr, file);
1294        assert_eq!(errors.len(), 2);
1295        assert_eq!(errors[0].line, 10);
1296        assert_eq!(errors[0].column, 5);
1297        assert!(errors[0].message.contains("unreachable code"));
1298        assert_eq!(errors[1].line, 20);
1299        assert_eq!(errors[1].column, 0);
1300    }
1301
1302    #[test]
1303    fn parse_pyright_output_basic() {
1304        let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1305        let file = Path::new("test.py");
1306        let errors = parse_pyright_output(stdout, file);
1307        assert_eq!(errors.len(), 1);
1308        assert_eq!(errors[0].line, 5); // 0-indexed → 1-indexed
1309        assert_eq!(errors[0].column, 10);
1310        assert_eq!(errors[0].severity, "error");
1311        assert!(errors[0].message.contains("Type error here"));
1312    }
1313
1314    #[test]
1315    fn validate_full_unsupported_language() {
1316        let dir = tempfile::tempdir().unwrap();
1317        let path = dir.path().join("file.txt");
1318        fs::write(&path, "hello").unwrap();
1319
1320        let config = Config::default();
1321        let (errors, reason) = validate_full(&path, &config);
1322        assert!(errors.is_empty());
1323        assert_eq!(reason.as_deref(), Some("unsupported_language"));
1324    }
1325
1326    #[test]
1327    fn detect_type_checker_rust() {
1328        let dir = tempfile::tempdir().unwrap();
1329        fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1330        let path = dir.path().join("src/main.rs");
1331        let config = Config {
1332            project_root: Some(dir.path().to_path_buf()),
1333            ..Config::default()
1334        };
1335        let result = detect_type_checker(&path, LangId::Rust, &config);
1336        if tool_available("cargo") {
1337            let (cmd, args) = result.unwrap();
1338            assert_eq!(cmd, "cargo");
1339            assert!(args.contains(&"check".to_string()));
1340        } else {
1341            assert!(result.is_none());
1342        }
1343    }
1344
1345    #[test]
1346    fn detect_type_checker_go() {
1347        let dir = tempfile::tempdir().unwrap();
1348        fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1349        let path = dir.path().join("main.go");
1350        let config = Config {
1351            project_root: Some(dir.path().to_path_buf()),
1352            ..Config::default()
1353        };
1354        let result = detect_type_checker(&path, LangId::Go, &config);
1355        if tool_available("go") {
1356            let (cmd, _args) = result.unwrap();
1357            // Could be staticcheck or go vet depending on what's installed
1358            assert!(cmd == "go" || cmd == "staticcheck");
1359        } else {
1360            assert!(result.is_none());
1361        }
1362    }
1363    #[test]
1364    fn run_external_tool_capture_nonzero_not_error() {
1365        // `false` exits with code 1 — capture should still return Ok
1366        let result = run_external_tool_capture("false", &[], None, 5);
1367        assert!(result.is_ok(), "capture should not error on non-zero exit");
1368        assert_eq!(result.unwrap().exit_code, 1);
1369    }
1370
1371    #[test]
1372    fn run_external_tool_capture_not_found() {
1373        let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
1374        assert!(result.is_err());
1375        match result.unwrap_err() {
1376            FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
1377            other => panic!("expected NotFound, got: {:?}", other),
1378        }
1379    }
1380}