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