1use std::collections::HashMap;
7use std::io::ErrorKind;
8use std::path::Path;
9use std::process::{Command, Stdio};
10use std::sync::Mutex;
11use std::thread;
12use std::time::{Duration, Instant};
13
14use crate::config::Config;
15use crate::parser::{detect_language, LangId};
16
17#[derive(Debug)]
19pub struct ExternalToolResult {
20 pub stdout: String,
21 pub stderr: String,
22 pub exit_code: i32,
23}
24
25#[derive(Debug)]
27pub enum FormatError {
28 NotFound { tool: String },
30 Timeout { tool: String, timeout_secs: u32 },
32 Failed { tool: String, stderr: String },
34 UnsupportedLanguage,
36}
37
38impl std::fmt::Display for FormatError {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
42 FormatError::Timeout { tool, timeout_secs } => {
43 write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
44 }
45 FormatError::Failed { tool, stderr } => {
46 write!(f, "formatter '{}' failed: {}", tool, stderr)
47 }
48 FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
49 }
50 }
51}
52
53pub fn run_external_tool(
59 command: &str,
60 args: &[&str],
61 working_dir: Option<&Path>,
62 timeout_secs: u32,
63) -> Result<ExternalToolResult, FormatError> {
64 let mut cmd = Command::new(command);
65 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
66
67 if let Some(dir) = working_dir {
68 cmd.current_dir(dir);
69 }
70
71 let mut child = match cmd.spawn() {
72 Ok(c) => c,
73 Err(e) if e.kind() == ErrorKind::NotFound => {
74 return Err(FormatError::NotFound {
75 tool: command.to_string(),
76 });
77 }
78 Err(e) => {
79 return Err(FormatError::Failed {
80 tool: command.to_string(),
81 stderr: e.to_string(),
82 });
83 }
84 };
85
86 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
87
88 loop {
89 match child.try_wait() {
90 Ok(Some(status)) => {
91 let stdout = child
92 .stdout
93 .take()
94 .map(|s| std::io::read_to_string(s).unwrap_or_default())
95 .unwrap_or_default();
96 let stderr = child
97 .stderr
98 .take()
99 .map(|s| std::io::read_to_string(s).unwrap_or_default())
100 .unwrap_or_default();
101
102 let exit_code = status.code().unwrap_or(-1);
103 if exit_code != 0 {
104 return Err(FormatError::Failed {
105 tool: command.to_string(),
106 stderr,
107 });
108 }
109
110 return Ok(ExternalToolResult {
111 stdout,
112 stderr,
113 exit_code,
114 });
115 }
116 Ok(None) => {
117 if Instant::now() >= deadline {
119 let _ = child.kill();
121 let _ = child.wait();
122 return Err(FormatError::Timeout {
123 tool: command.to_string(),
124 timeout_secs,
125 });
126 }
127 thread::sleep(Duration::from_millis(50));
128 }
129 Err(e) => {
130 return Err(FormatError::Failed {
131 tool: command.to_string(),
132 stderr: format!("try_wait error: {}", e),
133 });
134 }
135 }
136 }
137}
138
139const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
141
142static TOOL_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
143 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
144
145fn tool_cache_key(command: &str, project_root: Option<&Path>) -> String {
146 let root = project_root
147 .map(|path| path.to_string_lossy())
148 .unwrap_or_default();
149 format!("{}\0{}", command, root)
150}
151
152pub fn clear_tool_cache() {
153 if let Ok(mut cache) = TOOL_CACHE.lock() {
154 cache.clear();
155 }
156}
157
158fn tool_available(command: &str, project_root: Option<&Path>) -> bool {
164 let key = tool_cache_key(command, project_root);
165 if let Ok(cache) = TOOL_CACHE.lock() {
166 if let Some((available, checked_at)) = cache.get(&key) {
167 if checked_at.elapsed() < TOOL_CACHE_TTL {
168 return *available;
169 }
170 }
171 }
172 let result = resolve_tool(command, project_root).is_some();
173 if let Ok(mut cache) = TOOL_CACHE.lock() {
174 cache.insert(key, (result, Instant::now()));
175 }
176 result
177}
178
179fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
182 if let Some(root) = project_root {
184 let local_bin = root.join("node_modules").join(".bin").join(command);
185 if local_bin.exists() {
186 return Some(local_bin.to_string_lossy().to_string());
187 }
188 }
189
190 match Command::new(command)
192 .arg("--version")
193 .stdin(Stdio::null())
194 .stdout(Stdio::null())
195 .stderr(Stdio::null())
196 .spawn()
197 {
198 Ok(mut child) => {
199 let start = Instant::now();
200 let timeout = Duration::from_secs(2);
201 loop {
202 match child.try_wait() {
203 Ok(Some(status)) => {
204 return if status.success() {
205 Some(command.to_string())
206 } else {
207 None
208 };
209 }
210 Ok(None) if start.elapsed() > timeout => {
211 let _ = child.kill();
212 let _ = child.wait();
213 return None;
214 }
215 Ok(None) => thread::sleep(Duration::from_millis(50)),
216 Err(_) => return None,
217 }
218 }
219 }
220 Err(_) => None,
221 }
222}
223
224fn ruff_format_available(project_root: Option<&Path>) -> bool {
231 let key = tool_cache_key("ruff-format", project_root);
232 if let Ok(cache) = TOOL_CACHE.lock() {
233 if let Some((available, checked_at)) = cache.get(&key) {
234 if checked_at.elapsed() < TOOL_CACHE_TTL {
235 return *available;
236 }
237 }
238 }
239
240 let result = ruff_format_available_uncached(project_root);
241 if let Ok(mut cache) = TOOL_CACHE.lock() {
242 cache.insert(key, (result, Instant::now()));
243 }
244 result
245}
246
247fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
248 let command = match resolve_tool("ruff", project_root) {
249 Some(command) => command,
250 None => return false,
251 };
252 let output = match Command::new(&command)
253 .arg("--version")
254 .stdout(Stdio::piped())
255 .stderr(Stdio::null())
256 .output()
257 {
258 Ok(o) => o,
259 Err(_) => return false,
260 };
261
262 let version_str = String::from_utf8_lossy(&output.stdout);
263 let version_part = version_str
265 .trim()
266 .strip_prefix("ruff ")
267 .unwrap_or(version_str.trim());
268
269 let parts: Vec<&str> = version_part.split('.').collect();
270 if parts.len() < 3 {
271 return false;
272 }
273
274 let major: u32 = match parts[0].parse() {
275 Ok(v) => v,
276 Err(_) => return false,
277 };
278 let minor: u32 = match parts[1].parse() {
279 Ok(v) => v,
280 Err(_) => return false,
281 };
282 let patch: u32 = match parts[2].parse() {
283 Ok(v) => v,
284 Err(_) => return false,
285 };
286
287 (major, minor, patch) >= (0, 1, 2)
289}
290
291pub fn detect_formatter(
301 path: &Path,
302 lang: LangId,
303 config: &Config,
304) -> Option<(String, Vec<String>)> {
305 let file_str = path.to_string_lossy().to_string();
306
307 let lang_key = match lang {
309 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
310 LangId::Python => "python",
311 LangId::Rust => "rust",
312 LangId::Go => "go",
313 LangId::C => "c",
314 LangId::Cpp => "cpp",
315 LangId::Zig => "zig",
316 LangId::CSharp => "csharp",
317 LangId::Bash => "bash",
318 LangId::Html => "html",
319 LangId::Markdown => "markdown",
320 };
321 let project_root = config.project_root.as_deref();
322 if let Some(preferred) = config.formatter.get(lang_key) {
323 return resolve_explicit_formatter(preferred, &file_str, lang, project_root);
324 }
325
326 match lang {
330 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
331 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
333 if let Some(biome_cmd) = resolve_tool("biome", project_root) {
334 return Some((
335 biome_cmd,
336 vec!["format".to_string(), "--write".to_string(), file_str],
337 ));
338 }
339 }
340 if has_project_config(
342 project_root,
343 &[
344 ".prettierrc",
345 ".prettierrc.json",
346 ".prettierrc.yml",
347 ".prettierrc.yaml",
348 ".prettierrc.js",
349 ".prettierrc.cjs",
350 ".prettierrc.mjs",
351 ".prettierrc.toml",
352 "prettier.config.js",
353 "prettier.config.cjs",
354 "prettier.config.mjs",
355 ],
356 ) {
357 if let Some(prettier_cmd) = resolve_tool("prettier", project_root) {
358 return Some((prettier_cmd, vec!["--write".to_string(), file_str]));
359 }
360 }
361 if has_project_config(project_root, &["deno.json", "deno.jsonc"])
363 && tool_available("deno", project_root)
364 {
365 return Some(("deno".to_string(), vec!["fmt".to_string(), file_str]));
366 }
367 None
369 }
370 LangId::Python => {
371 if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
373 || has_pyproject_tool(project_root, "ruff"))
374 && ruff_format_available(project_root)
375 {
376 return Some(("ruff".to_string(), vec!["format".to_string(), file_str]));
377 }
378 if has_pyproject_tool(project_root, "black") && tool_available("black", project_root) {
380 return Some(("black".to_string(), vec![file_str]));
381 }
382 None
384 }
385 LangId::Rust => {
386 if has_project_config(project_root, &["Cargo.toml"])
388 && tool_available("rustfmt", project_root)
389 {
390 Some(("rustfmt".to_string(), vec![file_str]))
391 } else {
392 None
393 }
394 }
395 LangId::Go => {
396 if has_project_config(project_root, &["go.mod"]) {
398 if tool_available("goimports", project_root) {
399 Some(("goimports".to_string(), vec!["-w".to_string(), file_str]))
400 } else if tool_available("gofmt", project_root) {
401 Some(("gofmt".to_string(), vec!["-w".to_string(), file_str]))
402 } else {
403 None
404 }
405 } else {
406 None
407 }
408 }
409 LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp | LangId::Bash => None,
410 LangId::Html => None,
411 LangId::Markdown => None,
412 }
413}
414
415fn resolve_explicit_formatter(
419 name: &str,
420 file_str: &str,
421 lang: LangId,
422 project_root: Option<&Path>,
423) -> Option<(String, Vec<String>)> {
424 let cmd = match name {
425 "none" | "off" | "false" => return None,
426 "biome" | "prettier" | "deno" | "ruff" | "black" | "rustfmt" | "goimports" | "gofmt" => {
427 match resolve_tool(name, project_root) {
429 Some(resolved) => resolved,
430 None => {
431 log::warn!(
432 "[aft] format: configured formatter '{}' not found in node_modules/.bin or PATH",
433 name
434 );
435 return None;
436 }
437 }
438 }
439 _ => {
440 log::debug!(
441 "[aft] format: unknown preferred_formatter '{}' for {:?}, falling back to auto",
442 name,
443 lang
444 );
445 return None;
446 }
447 };
448
449 let args = match name {
450 "biome" => vec![
451 "format".to_string(),
452 "--write".to_string(),
453 file_str.to_string(),
454 ],
455 "prettier" => vec!["--write".to_string(), file_str.to_string()],
456 "deno" => vec!["fmt".to_string(), file_str.to_string()],
457 "ruff" => vec!["format".to_string(), file_str.to_string()],
458 "black" => vec![file_str.to_string()],
459 "rustfmt" => vec![file_str.to_string()],
460 "goimports" => vec!["-w".to_string(), file_str.to_string()],
461 "gofmt" => vec!["-w".to_string(), file_str.to_string()],
462 _ => unreachable!(), };
464
465 Some((cmd, args))
466}
467
468fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
470 let root = match project_root {
471 Some(r) => r,
472 None => return false,
473 };
474 filenames.iter().any(|f| root.join(f).exists())
475}
476
477fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
479 let root = match project_root {
480 Some(r) => r,
481 None => return false,
482 };
483 let pyproject = root.join("pyproject.toml");
484 if !pyproject.exists() {
485 return false;
486 }
487 match std::fs::read_to_string(&pyproject) {
488 Ok(content) => {
489 let pattern = format!("[tool.{}]", tool_name);
490 content.contains(&pattern)
491 }
492 Err(_) => false,
493 }
494}
495
496pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
504 if !config.format_on_edit {
506 return (false, Some("disabled".to_string()));
507 }
508
509 let lang = match detect_language(path) {
510 Some(l) => l,
511 None => {
512 log::debug!(
513 "[aft] format: {} (skipped: unsupported_language)",
514 path.display()
515 );
516 return (false, Some("unsupported_language".to_string()));
517 }
518 };
519
520 let (cmd, args) = match detect_formatter(path, lang, config) {
521 Some(pair) => pair,
522 None => {
523 log::warn!("format: {} (skipped: not_found)", path.display());
524 return (false, Some("not_found".to_string()));
525 }
526 };
527
528 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
529
530 let working_dir = config.project_root.as_deref();
537
538 match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
539 Ok(_) => {
540 log::info!("format: {} ({})", path.display(), cmd);
541 (true, None)
542 }
543 Err(FormatError::Timeout { .. }) => {
544 log::warn!("format: {} (skipped: timeout)", path.display());
545 (false, Some("timeout".to_string()))
546 }
547 Err(FormatError::NotFound { .. }) => {
548 log::warn!("format: {} (skipped: not_found)", path.display());
549 (false, Some("not_found".to_string()))
550 }
551 Err(FormatError::Failed { stderr, .. }) => {
552 log::debug!(
553 "[aft] format: {} (skipped: error: {})",
554 path.display(),
555 stderr.lines().next().unwrap_or("unknown")
556 );
557 (false, Some("error".to_string()))
558 }
559 Err(FormatError::UnsupportedLanguage) => {
560 log::debug!(
561 "[aft] format: {} (skipped: unsupported_language)",
562 path.display()
563 );
564 (false, Some("unsupported_language".to_string()))
565 }
566 }
567}
568
569pub fn run_external_tool_capture(
576 command: &str,
577 args: &[&str],
578 working_dir: Option<&Path>,
579 timeout_secs: u32,
580) -> Result<ExternalToolResult, FormatError> {
581 let mut cmd = Command::new(command);
582 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
583
584 if let Some(dir) = working_dir {
585 cmd.current_dir(dir);
586 }
587
588 let mut child = match cmd.spawn() {
589 Ok(c) => c,
590 Err(e) if e.kind() == ErrorKind::NotFound => {
591 return Err(FormatError::NotFound {
592 tool: command.to_string(),
593 });
594 }
595 Err(e) => {
596 return Err(FormatError::Failed {
597 tool: command.to_string(),
598 stderr: e.to_string(),
599 });
600 }
601 };
602
603 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
604
605 loop {
606 match child.try_wait() {
607 Ok(Some(status)) => {
608 let stdout = child
609 .stdout
610 .take()
611 .map(|s| std::io::read_to_string(s).unwrap_or_default())
612 .unwrap_or_default();
613 let stderr = child
614 .stderr
615 .take()
616 .map(|s| std::io::read_to_string(s).unwrap_or_default())
617 .unwrap_or_default();
618
619 return Ok(ExternalToolResult {
620 stdout,
621 stderr,
622 exit_code: status.code().unwrap_or(-1),
623 });
624 }
625 Ok(None) => {
626 if Instant::now() >= deadline {
627 let _ = child.kill();
628 let _ = child.wait();
629 return Err(FormatError::Timeout {
630 tool: command.to_string(),
631 timeout_secs,
632 });
633 }
634 thread::sleep(Duration::from_millis(50));
635 }
636 Err(e) => {
637 return Err(FormatError::Failed {
638 tool: command.to_string(),
639 stderr: format!("try_wait error: {}", e),
640 });
641 }
642 }
643 }
644}
645
646#[derive(Debug, Clone, serde::Serialize)]
652pub struct ValidationError {
653 pub line: u32,
654 pub column: u32,
655 pub message: String,
656 pub severity: String,
657}
658
659pub fn detect_type_checker(
670 path: &Path,
671 lang: LangId,
672 config: &Config,
673) -> Option<(String, Vec<String>)> {
674 let file_str = path.to_string_lossy().to_string();
675 let project_root = config.project_root.as_deref();
676
677 let lang_key = match lang {
679 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
680 LangId::Python => "python",
681 LangId::Rust => "rust",
682 LangId::Go => "go",
683 LangId::C => "c",
684 LangId::Cpp => "cpp",
685 LangId::Zig => "zig",
686 LangId::CSharp => "csharp",
687 LangId::Bash => "bash",
688 LangId::Html => "html",
689 LangId::Markdown => "markdown",
690 };
691 if let Some(preferred) = config.checker.get(lang_key) {
692 return resolve_explicit_checker(preferred, &file_str, lang, project_root);
693 }
694
695 match lang {
696 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
697 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
699 if let Some(biome_cmd) = resolve_tool("biome", project_root) {
700 return Some((biome_cmd, vec!["check".to_string(), file_str]));
701 }
702 }
703 if has_project_config(project_root, &["tsconfig.json"]) {
705 if let Some(tsc_cmd) = resolve_tool("tsc", project_root) {
706 return Some((
707 tsc_cmd,
708 vec![
709 "--noEmit".to_string(),
710 "--pretty".to_string(),
711 "false".to_string(),
712 ],
713 ));
714 } else if tool_available("npx", project_root) {
715 return Some((
716 "npx".to_string(),
717 vec![
718 "tsc".to_string(),
719 "--noEmit".to_string(),
720 "--pretty".to_string(),
721 "false".to_string(),
722 ],
723 ));
724 }
725 }
726 None
727 }
728 LangId::Python => {
729 if has_project_config(project_root, &["pyrightconfig.json"])
731 || has_pyproject_tool(project_root, "pyright")
732 {
733 if let Some(pyright_cmd) = resolve_tool("pyright", project_root) {
734 return Some((pyright_cmd, vec!["--outputjson".to_string(), file_str]));
735 }
736 }
737 if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
739 || has_pyproject_tool(project_root, "ruff"))
740 && ruff_format_available(project_root)
741 {
742 return Some((
743 "ruff".to_string(),
744 vec![
745 "check".to_string(),
746 "--output-format=json".to_string(),
747 file_str,
748 ],
749 ));
750 }
751 None
752 }
753 LangId::Rust => {
754 if has_project_config(project_root, &["Cargo.toml"])
756 && tool_available("cargo", project_root)
757 {
758 Some((
759 "cargo".to_string(),
760 vec!["check".to_string(), "--message-format=json".to_string()],
761 ))
762 } else {
763 None
764 }
765 }
766 LangId::Go => {
767 if has_project_config(project_root, &["go.mod"]) {
769 if tool_available("staticcheck", project_root) {
770 Some(("staticcheck".to_string(), vec![file_str]))
771 } else if tool_available("go", project_root) {
772 Some(("go".to_string(), vec!["vet".to_string(), file_str]))
773 } else {
774 None
775 }
776 } else {
777 None
778 }
779 }
780 LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp | LangId::Bash => None,
781 LangId::Html => None,
782 LangId::Markdown => None,
783 }
784}
785
786fn resolve_explicit_checker(
790 name: &str,
791 file_str: &str,
792 _lang: LangId,
793 project_root: Option<&Path>,
794) -> Option<(String, Vec<String>)> {
795 match name {
796 "none" | "off" | "false" => return None,
797 _ => {}
798 }
799
800 if name == "tsc" {
802 return Some((
803 "npx".to_string(),
804 vec![
805 "tsc".to_string(),
806 "--noEmit".to_string(),
807 "--pretty".to_string(),
808 "false".to_string(),
809 ],
810 ));
811 }
812 if name == "cargo" {
814 return Some((
815 "cargo".to_string(),
816 vec!["check".to_string(), "--message-format=json".to_string()],
817 ));
818 }
819 if name == "go" {
820 return Some((
821 "go".to_string(),
822 vec!["vet".to_string(), file_str.to_string()],
823 ));
824 }
825
826 let known_tools = ["biome", "pyright", "ruff", "staticcheck"];
828 if known_tools.contains(&name) {
829 let cmd = match resolve_tool(name, project_root) {
830 Some(resolved) => resolved,
831 None => {
832 log::warn!(
833 "[aft] validate: configured checker '{}' not found in node_modules/.bin or PATH",
834 name
835 );
836 return None;
837 }
838 };
839
840 let args = match name {
841 "biome" => vec!["check".to_string(), file_str.to_string()],
842 "pyright" => vec!["--outputjson".to_string(), file_str.to_string()],
843 "ruff" => vec![
844 "check".to_string(),
845 "--output-format=json".to_string(),
846 file_str.to_string(),
847 ],
848 "staticcheck" => vec![file_str.to_string()],
849 _ => unreachable!(),
850 };
851
852 return Some((cmd, args));
853 }
854
855 log::debug!(
856 "[aft] validate: unknown preferred_checker '{}', falling back to auto",
857 name
858 );
859 None
860}
861
862pub fn parse_checker_output(
867 stdout: &str,
868 stderr: &str,
869 file: &Path,
870 checker: &str,
871) -> Vec<ValidationError> {
872 match checker {
873 "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
874 "pyright" => parse_pyright_output(stdout, file),
875 "cargo" => parse_cargo_output(stdout, stderr, file),
876 "go" => parse_go_vet_output(stderr, file),
877 _ => Vec::new(),
878 }
879}
880
881fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
883 let mut errors = Vec::new();
884 let file_str = file.to_string_lossy();
885 let combined = format!("{}{}", stdout, stderr);
887 for line in combined.lines() {
888 if let Some((loc, rest)) = line.split_once("): ") {
891 let file_part = loc.split('(').next().unwrap_or("");
893 if !file_str.ends_with(file_part)
894 && !file_part.ends_with(&*file_str)
895 && file_part != &*file_str
896 {
897 continue;
898 }
899
900 let coords = loc.split('(').last().unwrap_or("");
902 let parts: Vec<&str> = coords.split(',').collect();
903 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
904 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
905
906 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
908 ("error".to_string(), msg.to_string())
909 } else if let Some(msg) = rest.strip_prefix("warning ") {
910 ("warning".to_string(), msg.to_string())
911 } else {
912 ("error".to_string(), rest.to_string())
913 };
914
915 errors.push(ValidationError {
916 line: line_num,
917 column: col_num,
918 message,
919 severity,
920 });
921 }
922 }
923 errors
924}
925
926fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
928 let mut errors = Vec::new();
929 let file_str = file.to_string_lossy();
930
931 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
933 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
934 for diag in diags {
935 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
937 if !diag_file.is_empty()
938 && !file_str.ends_with(diag_file)
939 && !diag_file.ends_with(&*file_str)
940 && diag_file != &*file_str
941 {
942 continue;
943 }
944
945 let line_num = diag
946 .get("range")
947 .and_then(|r| r.get("start"))
948 .and_then(|s| s.get("line"))
949 .and_then(|l| l.as_u64())
950 .unwrap_or(0) as u32;
951 let col_num = diag
952 .get("range")
953 .and_then(|r| r.get("start"))
954 .and_then(|s| s.get("character"))
955 .and_then(|c| c.as_u64())
956 .unwrap_or(0) as u32;
957 let message = diag
958 .get("message")
959 .and_then(|m| m.as_str())
960 .unwrap_or("unknown error")
961 .to_string();
962 let severity = diag
963 .get("severity")
964 .and_then(|s| s.as_str())
965 .unwrap_or("error")
966 .to_lowercase();
967
968 errors.push(ValidationError {
969 line: line_num + 1, column: col_num,
971 message,
972 severity,
973 });
974 }
975 }
976 }
977 errors
978}
979
980fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
982 let mut errors = Vec::new();
983 let file_str = file.to_string_lossy();
984
985 for line in stdout.lines() {
986 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
987 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
988 continue;
989 }
990 let message_obj = match msg.get("message") {
991 Some(m) => m,
992 None => continue,
993 };
994
995 let level = message_obj
996 .get("level")
997 .and_then(|l| l.as_str())
998 .unwrap_or("error");
999
1000 if level != "error" && level != "warning" {
1002 continue;
1003 }
1004
1005 let text = message_obj
1006 .get("message")
1007 .and_then(|m| m.as_str())
1008 .unwrap_or("unknown error")
1009 .to_string();
1010
1011 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1013 for span in spans {
1014 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1015 let is_primary = span
1016 .get("is_primary")
1017 .and_then(|p| p.as_bool())
1018 .unwrap_or(false);
1019
1020 if !is_primary {
1021 continue;
1022 }
1023
1024 if !file_str.ends_with(span_file)
1026 && !span_file.ends_with(&*file_str)
1027 && span_file != &*file_str
1028 {
1029 continue;
1030 }
1031
1032 let line_num =
1033 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1034 let col_num = span
1035 .get("column_start")
1036 .and_then(|c| c.as_u64())
1037 .unwrap_or(0) as u32;
1038
1039 errors.push(ValidationError {
1040 line: line_num,
1041 column: col_num,
1042 message: text.clone(),
1043 severity: level.to_string(),
1044 });
1045 }
1046 }
1047 }
1048 }
1049 errors
1050}
1051
1052fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1054 let mut errors = Vec::new();
1055 let file_str = file.to_string_lossy();
1056
1057 for line in stderr.lines() {
1058 let parts: Vec<&str> = line.splitn(4, ':').collect();
1060 if parts.len() < 3 {
1061 continue;
1062 }
1063
1064 let err_file = parts[0].trim();
1065 if !file_str.ends_with(err_file)
1066 && !err_file.ends_with(&*file_str)
1067 && err_file != &*file_str
1068 {
1069 continue;
1070 }
1071
1072 let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1073 let (col_num, message) = if parts.len() >= 4 {
1074 if let Ok(col) = parts[2].trim().parse::<u32>() {
1075 (col, parts[3].trim().to_string())
1076 } else {
1077 (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1079 }
1080 } else {
1081 (0, parts[2].trim().to_string())
1082 };
1083
1084 errors.push(ValidationError {
1085 line: line_num,
1086 column: col_num,
1087 message,
1088 severity: "error".to_string(),
1089 });
1090 }
1091 errors
1092}
1093
1094pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1102 let lang = match detect_language(path) {
1103 Some(l) => l,
1104 None => {
1105 log::debug!(
1106 "[aft] validate: {} (skipped: unsupported_language)",
1107 path.display()
1108 );
1109 return (Vec::new(), Some("unsupported_language".to_string()));
1110 }
1111 };
1112
1113 let (cmd, args) = match detect_type_checker(path, lang, config) {
1114 Some(pair) => pair,
1115 None => {
1116 log::warn!("validate: {} (skipped: not_found)", path.display());
1117 return (Vec::new(), Some("not_found".to_string()));
1118 }
1119 };
1120
1121 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1122
1123 let working_dir = config.project_root.as_deref();
1125
1126 match run_external_tool_capture(
1127 &cmd,
1128 &arg_refs,
1129 working_dir,
1130 config.type_checker_timeout_secs,
1131 ) {
1132 Ok(result) => {
1133 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1134 log::debug!(
1135 "[aft] validate: {} ({}, {} errors)",
1136 path.display(),
1137 cmd,
1138 errors.len()
1139 );
1140 (errors, None)
1141 }
1142 Err(FormatError::Timeout { .. }) => {
1143 log::error!("validate: {} (skipped: timeout)", path.display());
1144 (Vec::new(), Some("timeout".to_string()))
1145 }
1146 Err(FormatError::NotFound { .. }) => {
1147 log::warn!("validate: {} (skipped: not_found)", path.display());
1148 (Vec::new(), Some("not_found".to_string()))
1149 }
1150 Err(FormatError::Failed { stderr, .. }) => {
1151 log::debug!(
1152 "[aft] validate: {} (skipped: error: {})",
1153 path.display(),
1154 stderr.lines().next().unwrap_or("unknown")
1155 );
1156 (Vec::new(), Some("error".to_string()))
1157 }
1158 Err(FormatError::UnsupportedLanguage) => {
1159 log::debug!(
1160 "[aft] validate: {} (skipped: unsupported_language)",
1161 path.display()
1162 );
1163 (Vec::new(), Some("unsupported_language".to_string()))
1164 }
1165 }
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170 use super::*;
1171 use std::fs;
1172 use std::io::Write;
1173
1174 #[test]
1175 fn run_external_tool_not_found() {
1176 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1177 assert!(result.is_err());
1178 match result.unwrap_err() {
1179 FormatError::NotFound { tool } => {
1180 assert_eq!(tool, "__nonexistent_tool_xyz__");
1181 }
1182 other => panic!("expected NotFound, got: {:?}", other),
1183 }
1184 }
1185
1186 #[test]
1187 fn run_external_tool_timeout_kills_subprocess() {
1188 let result = run_external_tool("sleep", &["60"], None, 1);
1190 assert!(result.is_err());
1191 match result.unwrap_err() {
1192 FormatError::Timeout { tool, timeout_secs } => {
1193 assert_eq!(tool, "sleep");
1194 assert_eq!(timeout_secs, 1);
1195 }
1196 other => panic!("expected Timeout, got: {:?}", other),
1197 }
1198 }
1199
1200 #[test]
1201 fn run_external_tool_success() {
1202 let result = run_external_tool("echo", &["hello"], None, 5);
1203 assert!(result.is_ok());
1204 let res = result.unwrap();
1205 assert_eq!(res.exit_code, 0);
1206 assert!(res.stdout.contains("hello"));
1207 }
1208
1209 #[test]
1210 fn run_external_tool_nonzero_exit() {
1211 let result = run_external_tool("false", &[], None, 5);
1213 assert!(result.is_err());
1214 match result.unwrap_err() {
1215 FormatError::Failed { tool, .. } => {
1216 assert_eq!(tool, "false");
1217 }
1218 other => panic!("expected Failed, got: {:?}", other),
1219 }
1220 }
1221
1222 #[test]
1223 fn auto_format_unsupported_language() {
1224 let dir = tempfile::tempdir().unwrap();
1225 let path = dir.path().join("file.txt");
1226 fs::write(&path, "hello").unwrap();
1227
1228 let config = Config::default();
1229 let (formatted, reason) = auto_format(&path, &config);
1230 assert!(!formatted);
1231 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1232 }
1233
1234 #[test]
1235 fn detect_formatter_rust_when_rustfmt_available() {
1236 let dir = tempfile::tempdir().unwrap();
1237 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1238 let path = dir.path().join("test.rs");
1239 let config = Config {
1240 project_root: Some(dir.path().to_path_buf()),
1241 ..Config::default()
1242 };
1243 let result = detect_formatter(&path, LangId::Rust, &config);
1244 if tool_available("rustfmt", config.project_root.as_deref()) {
1245 let (cmd, args) = result.unwrap();
1246 assert_eq!(cmd, "rustfmt");
1247 assert!(args.iter().any(|a| a.ends_with("test.rs")));
1248 } else {
1249 assert!(result.is_none());
1250 }
1251 }
1252
1253 #[test]
1254 fn detect_formatter_go_mapping() {
1255 let dir = tempfile::tempdir().unwrap();
1256 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1257 let path = dir.path().join("main.go");
1258 let config = Config {
1259 project_root: Some(dir.path().to_path_buf()),
1260 ..Config::default()
1261 };
1262 let result = detect_formatter(&path, LangId::Go, &config);
1263 if tool_available("goimports", config.project_root.as_deref()) {
1264 let (cmd, args) = result.unwrap();
1265 assert_eq!(cmd, "goimports");
1266 assert!(args.contains(&"-w".to_string()));
1267 } else if tool_available("gofmt", config.project_root.as_deref()) {
1268 let (cmd, args) = result.unwrap();
1269 assert_eq!(cmd, "gofmt");
1270 assert!(args.contains(&"-w".to_string()));
1271 } else {
1272 assert!(result.is_none());
1273 }
1274 }
1275
1276 #[test]
1277 fn detect_formatter_python_mapping() {
1278 let dir = tempfile::tempdir().unwrap();
1279 fs::write(dir.path().join("ruff.toml"), "").unwrap();
1280 let path = dir.path().join("main.py");
1281 let config = Config {
1282 project_root: Some(dir.path().to_path_buf()),
1283 ..Config::default()
1284 };
1285 let result = detect_formatter(&path, LangId::Python, &config);
1286 if ruff_format_available(config.project_root.as_deref()) {
1287 let (cmd, args) = result.unwrap();
1288 assert_eq!(cmd, "ruff");
1289 assert!(args.contains(&"format".to_string()));
1290 } else {
1291 assert!(result.is_none());
1292 }
1293 }
1294
1295 #[test]
1296 fn detect_formatter_no_config_returns_none() {
1297 let path = Path::new("test.ts");
1298 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1299 assert!(
1300 result.is_none(),
1301 "expected no formatter without project config"
1302 );
1303 }
1304
1305 #[test]
1306 fn detect_formatter_explicit_override() {
1307 let dir = tempfile::tempdir().unwrap();
1309 let bin_dir = dir.path().join("node_modules").join(".bin");
1310 fs::create_dir_all(&bin_dir).unwrap();
1311 #[cfg(unix)]
1312 {
1313 use std::os::unix::fs::PermissionsExt;
1314 let fake = bin_dir.join("biome");
1315 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1316 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1317 }
1318 #[cfg(not(unix))]
1319 {
1320 fs::write(bin_dir.join("biome.cmd"), "@echo 1.0.0").unwrap();
1321 }
1322
1323 let path = Path::new("test.ts");
1324 let mut config = Config::default();
1325 config.project_root = Some(dir.path().to_path_buf());
1326 config
1327 .formatter
1328 .insert("typescript".to_string(), "biome".to_string());
1329 let result = detect_formatter(path, LangId::TypeScript, &config);
1330 let (cmd, args) = result.unwrap();
1331 assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1332 assert!(args.contains(&"format".to_string()));
1333 assert!(args.contains(&"--write".to_string()));
1334 }
1335
1336 #[test]
1337 fn auto_format_happy_path_rustfmt() {
1338 if !tool_available("rustfmt", None) {
1339 log::warn!("skipping: rustfmt not available");
1340 return;
1341 }
1342
1343 let dir = tempfile::tempdir().unwrap();
1344 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1345 let path = dir.path().join("test.rs");
1346
1347 let mut f = fs::File::create(&path).unwrap();
1348 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
1349 drop(f);
1350
1351 let config = Config {
1352 project_root: Some(dir.path().to_path_buf()),
1353 ..Config::default()
1354 };
1355 let (formatted, reason) = auto_format(&path, &config);
1356 assert!(formatted, "expected formatting to succeed");
1357 assert!(reason.is_none());
1358
1359 let content = fs::read_to_string(&path).unwrap();
1360 assert!(
1361 !content.contains("fn main"),
1362 "expected rustfmt to fix spacing"
1363 );
1364 }
1365
1366 #[test]
1367 fn parse_tsc_output_basic() {
1368 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";
1369 let file = Path::new("src/app.ts");
1370 let errors = parse_tsc_output(stdout, "", file);
1371 assert_eq!(errors.len(), 2);
1372 assert_eq!(errors[0].line, 10);
1373 assert_eq!(errors[0].column, 5);
1374 assert_eq!(errors[0].severity, "error");
1375 assert!(errors[0].message.contains("TS2322"));
1376 assert_eq!(errors[1].line, 20);
1377 }
1378
1379 #[test]
1380 fn parse_tsc_output_filters_other_files() {
1381 let stdout =
1382 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1383 let file = Path::new("src/app.ts");
1384 let errors = parse_tsc_output(stdout, "", file);
1385 assert_eq!(errors.len(), 1);
1386 assert_eq!(errors[0].line, 5);
1387 }
1388
1389 #[test]
1390 fn parse_cargo_output_basic() {
1391 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}]}}"#;
1392 let file = Path::new("src/main.rs");
1393 let errors = parse_cargo_output(json_line, "", file);
1394 assert_eq!(errors.len(), 1);
1395 assert_eq!(errors[0].line, 10);
1396 assert_eq!(errors[0].column, 5);
1397 assert_eq!(errors[0].severity, "error");
1398 assert!(errors[0].message.contains("mismatched types"));
1399 }
1400
1401 #[test]
1402 fn parse_cargo_output_skips_notes() {
1403 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}]}}"#;
1405 let file = Path::new("src/main.rs");
1406 let errors = parse_cargo_output(json_line, "", file);
1407 assert_eq!(errors.len(), 0);
1408 }
1409
1410 #[test]
1411 fn parse_cargo_output_filters_other_files() {
1412 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}]}}"#;
1413 let file = Path::new("src/main.rs");
1414 let errors = parse_cargo_output(json_line, "", file);
1415 assert_eq!(errors.len(), 0);
1416 }
1417
1418 #[test]
1419 fn parse_go_vet_output_basic() {
1420 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1421 let file = Path::new("main.go");
1422 let errors = parse_go_vet_output(stderr, file);
1423 assert_eq!(errors.len(), 2);
1424 assert_eq!(errors[0].line, 10);
1425 assert_eq!(errors[0].column, 5);
1426 assert!(errors[0].message.contains("unreachable code"));
1427 assert_eq!(errors[1].line, 20);
1428 assert_eq!(errors[1].column, 0);
1429 }
1430
1431 #[test]
1432 fn parse_pyright_output_basic() {
1433 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1434 let file = Path::new("test.py");
1435 let errors = parse_pyright_output(stdout, file);
1436 assert_eq!(errors.len(), 1);
1437 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 10);
1439 assert_eq!(errors[0].severity, "error");
1440 assert!(errors[0].message.contains("Type error here"));
1441 }
1442
1443 #[test]
1444 fn validate_full_unsupported_language() {
1445 let dir = tempfile::tempdir().unwrap();
1446 let path = dir.path().join("file.txt");
1447 fs::write(&path, "hello").unwrap();
1448
1449 let config = Config::default();
1450 let (errors, reason) = validate_full(&path, &config);
1451 assert!(errors.is_empty());
1452 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1453 }
1454
1455 #[test]
1456 fn detect_type_checker_rust() {
1457 let dir = tempfile::tempdir().unwrap();
1458 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1459 let path = dir.path().join("src/main.rs");
1460 let config = Config {
1461 project_root: Some(dir.path().to_path_buf()),
1462 ..Config::default()
1463 };
1464 let result = detect_type_checker(&path, LangId::Rust, &config);
1465 if tool_available("cargo", config.project_root.as_deref()) {
1466 let (cmd, args) = result.unwrap();
1467 assert_eq!(cmd, "cargo");
1468 assert!(args.contains(&"check".to_string()));
1469 } else {
1470 assert!(result.is_none());
1471 }
1472 }
1473
1474 #[test]
1475 fn detect_type_checker_go() {
1476 let dir = tempfile::tempdir().unwrap();
1477 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1478 let path = dir.path().join("main.go");
1479 let config = Config {
1480 project_root: Some(dir.path().to_path_buf()),
1481 ..Config::default()
1482 };
1483 let result = detect_type_checker(&path, LangId::Go, &config);
1484 if tool_available("go", config.project_root.as_deref()) {
1485 let (cmd, _args) = result.unwrap();
1486 assert!(cmd == "go" || cmd == "staticcheck");
1488 } else {
1489 assert!(result.is_none());
1490 }
1491 }
1492 #[test]
1493 fn run_external_tool_capture_nonzero_not_error() {
1494 let result = run_external_tool_capture("false", &[], None, 5);
1496 assert!(result.is_ok(), "capture should not error on non-zero exit");
1497 assert_eq!(result.unwrap().exit_code, 1);
1498 }
1499
1500 #[test]
1501 fn run_external_tool_capture_not_found() {
1502 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
1503 assert!(result.is_err());
1504 match result.unwrap_err() {
1505 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
1506 other => panic!("expected NotFound, got: {:?}", other),
1507 }
1508 }
1509}