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