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_available(command: &str) -> bool {
151 if let Ok(cache) = TOOL_CACHE.lock() {
152 if let Some((available, checked_at)) = cache.get(command) {
153 if checked_at.elapsed() < TOOL_CACHE_TTL {
154 return *available;
155 }
156 }
157 }
158 let result = resolve_tool(command, None).is_some();
159 if let Ok(mut cache) = TOOL_CACHE.lock() {
160 cache.insert(command.to_string(), (result, Instant::now()));
161 }
162 result
163}
164
165fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
168 if let Some(root) = project_root {
170 let local_bin = root.join("node_modules").join(".bin").join(command);
171 if local_bin.exists() {
172 return Some(local_bin.to_string_lossy().to_string());
173 }
174 }
175
176 match Command::new(command)
178 .arg("--version")
179 .stdout(Stdio::null())
180 .stderr(Stdio::null())
181 .spawn()
182 {
183 Ok(mut child) => {
184 let _ = child.wait();
185 Some(command.to_string())
186 }
187 Err(_) => None,
188 }
189}
190
191fn ruff_format_available() -> bool {
198 let output = match Command::new("ruff")
199 .arg("--version")
200 .stdout(Stdio::piped())
201 .stderr(Stdio::null())
202 .output()
203 {
204 Ok(o) => o,
205 Err(_) => return false,
206 };
207
208 let version_str = String::from_utf8_lossy(&output.stdout);
209 let version_part = version_str
211 .trim()
212 .strip_prefix("ruff ")
213 .unwrap_or(version_str.trim());
214
215 let parts: Vec<&str> = version_part.split('.').collect();
216 if parts.len() < 3 {
217 return false;
218 }
219
220 let major: u32 = match parts[0].parse() {
221 Ok(v) => v,
222 Err(_) => return false,
223 };
224 let minor: u32 = match parts[1].parse() {
225 Ok(v) => v,
226 Err(_) => return false,
227 };
228 let patch: u32 = match parts[2].parse() {
229 Ok(v) => v,
230 Err(_) => return false,
231 };
232
233 (major, minor, patch) >= (0, 1, 2)
235}
236
237pub fn detect_formatter(
247 path: &Path,
248 lang: LangId,
249 config: &Config,
250) -> Option<(String, Vec<String>)> {
251 let file_str = path.to_string_lossy().to_string();
252
253 let lang_key = match lang {
255 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
256 LangId::Python => "python",
257 LangId::Rust => "rust",
258 LangId::Go => "go",
259 LangId::C => "c",
260 LangId::Cpp => "cpp",
261 LangId::Zig => "zig",
262 LangId::CSharp => "csharp",
263 LangId::Bash => "bash",
264 LangId::Html => "html",
265 LangId::Markdown => "markdown",
266 };
267 let project_root = config.project_root.as_deref();
268 if let Some(preferred) = config.formatter.get(lang_key) {
269 return resolve_explicit_formatter(preferred, &file_str, lang, project_root);
270 }
271
272 match lang {
276 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
277 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
279 if let Some(biome_cmd) = resolve_tool("biome", project_root) {
280 return Some((
281 biome_cmd,
282 vec!["format".to_string(), "--write".to_string(), file_str],
283 ));
284 }
285 }
286 if has_project_config(
288 project_root,
289 &[
290 ".prettierrc",
291 ".prettierrc.json",
292 ".prettierrc.yml",
293 ".prettierrc.yaml",
294 ".prettierrc.js",
295 ".prettierrc.cjs",
296 ".prettierrc.mjs",
297 ".prettierrc.toml",
298 "prettier.config.js",
299 "prettier.config.cjs",
300 "prettier.config.mjs",
301 ],
302 ) {
303 if let Some(prettier_cmd) = resolve_tool("prettier", project_root) {
304 return Some((prettier_cmd, vec!["--write".to_string(), file_str]));
305 }
306 }
307 if has_project_config(project_root, &["deno.json", "deno.jsonc"])
309 && tool_available("deno")
310 {
311 return Some(("deno".to_string(), vec!["fmt".to_string(), file_str]));
312 }
313 None
315 }
316 LangId::Python => {
317 if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
319 || has_pyproject_tool(project_root, "ruff"))
320 && ruff_format_available()
321 {
322 return Some(("ruff".to_string(), vec!["format".to_string(), file_str]));
323 }
324 if has_pyproject_tool(project_root, "black") && tool_available("black") {
326 return Some(("black".to_string(), vec![file_str]));
327 }
328 None
330 }
331 LangId::Rust => {
332 if has_project_config(project_root, &["Cargo.toml"]) && tool_available("rustfmt") {
334 Some(("rustfmt".to_string(), vec![file_str]))
335 } else {
336 None
337 }
338 }
339 LangId::Go => {
340 if has_project_config(project_root, &["go.mod"]) {
342 if tool_available("goimports") {
343 Some(("goimports".to_string(), vec!["-w".to_string(), file_str]))
344 } else if tool_available("gofmt") {
345 Some(("gofmt".to_string(), vec!["-w".to_string(), file_str]))
346 } else {
347 None
348 }
349 } else {
350 None
351 }
352 }
353 LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp | LangId::Bash => None,
354 LangId::Html => None,
355 LangId::Markdown => None,
356 }
357}
358
359fn resolve_explicit_formatter(
363 name: &str,
364 file_str: &str,
365 lang: LangId,
366 project_root: Option<&Path>,
367) -> Option<(String, Vec<String>)> {
368 let cmd = match name {
369 "none" | "off" | "false" => return None,
370 "biome" | "prettier" | "deno" | "ruff" | "black" | "rustfmt" | "goimports" | "gofmt" => {
371 match resolve_tool(name, project_root) {
373 Some(resolved) => resolved,
374 None => {
375 log::warn!(
376 "[aft] format: configured formatter '{}' not found in node_modules/.bin or PATH",
377 name
378 );
379 return None;
380 }
381 }
382 }
383 _ => {
384 log::debug!(
385 "[aft] format: unknown preferred_formatter '{}' for {:?}, falling back to auto",
386 name,
387 lang
388 );
389 return None;
390 }
391 };
392
393 let args = match name {
394 "biome" => vec![
395 "format".to_string(),
396 "--write".to_string(),
397 file_str.to_string(),
398 ],
399 "prettier" => vec!["--write".to_string(), file_str.to_string()],
400 "deno" => vec!["fmt".to_string(), file_str.to_string()],
401 "ruff" => vec!["format".to_string(), file_str.to_string()],
402 "black" => vec![file_str.to_string()],
403 "rustfmt" => vec![file_str.to_string()],
404 "goimports" => vec!["-w".to_string(), file_str.to_string()],
405 "gofmt" => vec!["-w".to_string(), file_str.to_string()],
406 _ => unreachable!(), };
408
409 Some((cmd, args))
410}
411
412fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
414 let root = match project_root {
415 Some(r) => r,
416 None => return false,
417 };
418 filenames.iter().any(|f| root.join(f).exists())
419}
420
421fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
423 let root = match project_root {
424 Some(r) => r,
425 None => return false,
426 };
427 let pyproject = root.join("pyproject.toml");
428 if !pyproject.exists() {
429 return false;
430 }
431 match std::fs::read_to_string(&pyproject) {
432 Ok(content) => {
433 let pattern = format!("[tool.{}]", tool_name);
434 content.contains(&pattern)
435 }
436 Err(_) => false,
437 }
438}
439
440pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
448 if !config.format_on_edit {
450 return (false, Some("disabled".to_string()));
451 }
452
453 let lang = match detect_language(path) {
454 Some(l) => l,
455 None => {
456 log::debug!(
457 "[aft] format: {} (skipped: unsupported_language)",
458 path.display()
459 );
460 return (false, Some("unsupported_language".to_string()));
461 }
462 };
463
464 let (cmd, args) = match detect_formatter(path, lang, config) {
465 Some(pair) => pair,
466 None => {
467 log::warn!("format: {} (skipped: not_found)", path.display());
468 return (false, Some("not_found".to_string()));
469 }
470 };
471
472 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
473
474 match run_external_tool(&cmd, &arg_refs, None, config.formatter_timeout_secs) {
475 Ok(_) => {
476 log::info!("format: {} ({})", path.display(), cmd);
477 (true, None)
478 }
479 Err(FormatError::Timeout { .. }) => {
480 log::warn!("format: {} (skipped: timeout)", path.display());
481 (false, Some("timeout".to_string()))
482 }
483 Err(FormatError::NotFound { .. }) => {
484 log::warn!("format: {} (skipped: not_found)", path.display());
485 (false, Some("not_found".to_string()))
486 }
487 Err(FormatError::Failed { stderr, .. }) => {
488 log::debug!(
489 "[aft] format: {} (skipped: error: {})",
490 path.display(),
491 stderr.lines().next().unwrap_or("unknown")
492 );
493 (false, Some("error".to_string()))
494 }
495 Err(FormatError::UnsupportedLanguage) => {
496 log::debug!(
497 "[aft] format: {} (skipped: unsupported_language)",
498 path.display()
499 );
500 (false, Some("unsupported_language".to_string()))
501 }
502 }
503}
504
505pub fn run_external_tool_capture(
512 command: &str,
513 args: &[&str],
514 working_dir: Option<&Path>,
515 timeout_secs: u32,
516) -> Result<ExternalToolResult, FormatError> {
517 let mut cmd = Command::new(command);
518 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
519
520 if let Some(dir) = working_dir {
521 cmd.current_dir(dir);
522 }
523
524 let mut child = match cmd.spawn() {
525 Ok(c) => c,
526 Err(e) if e.kind() == ErrorKind::NotFound => {
527 return Err(FormatError::NotFound {
528 tool: command.to_string(),
529 });
530 }
531 Err(e) => {
532 return Err(FormatError::Failed {
533 tool: command.to_string(),
534 stderr: e.to_string(),
535 });
536 }
537 };
538
539 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
540
541 loop {
542 match child.try_wait() {
543 Ok(Some(status)) => {
544 let stdout = child
545 .stdout
546 .take()
547 .map(|s| std::io::read_to_string(s).unwrap_or_default())
548 .unwrap_or_default();
549 let stderr = child
550 .stderr
551 .take()
552 .map(|s| std::io::read_to_string(s).unwrap_or_default())
553 .unwrap_or_default();
554
555 return Ok(ExternalToolResult {
556 stdout,
557 stderr,
558 exit_code: status.code().unwrap_or(-1),
559 });
560 }
561 Ok(None) => {
562 if Instant::now() >= deadline {
563 let _ = child.kill();
564 let _ = child.wait();
565 return Err(FormatError::Timeout {
566 tool: command.to_string(),
567 timeout_secs,
568 });
569 }
570 thread::sleep(Duration::from_millis(50));
571 }
572 Err(e) => {
573 return Err(FormatError::Failed {
574 tool: command.to_string(),
575 stderr: format!("try_wait error: {}", e),
576 });
577 }
578 }
579 }
580}
581
582#[derive(Debug, Clone, serde::Serialize)]
588pub struct ValidationError {
589 pub line: u32,
590 pub column: u32,
591 pub message: String,
592 pub severity: String,
593}
594
595pub fn detect_type_checker(
606 path: &Path,
607 lang: LangId,
608 config: &Config,
609) -> Option<(String, Vec<String>)> {
610 let file_str = path.to_string_lossy().to_string();
611 let project_root = config.project_root.as_deref();
612
613 let lang_key = match lang {
615 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
616 LangId::Python => "python",
617 LangId::Rust => "rust",
618 LangId::Go => "go",
619 LangId::C => "c",
620 LangId::Cpp => "cpp",
621 LangId::Zig => "zig",
622 LangId::CSharp => "csharp",
623 LangId::Bash => "bash",
624 LangId::Html => "html",
625 LangId::Markdown => "markdown",
626 };
627 if let Some(preferred) = config.checker.get(lang_key) {
628 return resolve_explicit_checker(preferred, &file_str, lang, project_root);
629 }
630
631 match lang {
632 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
633 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
635 if let Some(biome_cmd) = resolve_tool("biome", project_root) {
636 return Some((biome_cmd, vec!["check".to_string(), file_str]));
637 }
638 }
639 if has_project_config(project_root, &["tsconfig.json"]) {
641 if let Some(tsc_cmd) = resolve_tool("tsc", project_root) {
642 return Some((
643 tsc_cmd,
644 vec![
645 "--noEmit".to_string(),
646 "--pretty".to_string(),
647 "false".to_string(),
648 ],
649 ));
650 } else if tool_available("npx") {
651 return Some((
652 "npx".to_string(),
653 vec![
654 "tsc".to_string(),
655 "--noEmit".to_string(),
656 "--pretty".to_string(),
657 "false".to_string(),
658 ],
659 ));
660 }
661 }
662 None
663 }
664 LangId::Python => {
665 if has_project_config(project_root, &["pyrightconfig.json"])
667 || has_pyproject_tool(project_root, "pyright")
668 {
669 if let Some(pyright_cmd) = resolve_tool("pyright", project_root) {
670 return Some((pyright_cmd, vec!["--outputjson".to_string(), file_str]));
671 }
672 }
673 if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
675 || has_pyproject_tool(project_root, "ruff"))
676 && ruff_format_available()
677 {
678 return Some((
679 "ruff".to_string(),
680 vec![
681 "check".to_string(),
682 "--output-format=json".to_string(),
683 file_str,
684 ],
685 ));
686 }
687 None
688 }
689 LangId::Rust => {
690 if has_project_config(project_root, &["Cargo.toml"]) && tool_available("cargo") {
692 Some((
693 "cargo".to_string(),
694 vec!["check".to_string(), "--message-format=json".to_string()],
695 ))
696 } else {
697 None
698 }
699 }
700 LangId::Go => {
701 if has_project_config(project_root, &["go.mod"]) {
703 if tool_available("staticcheck") {
704 Some(("staticcheck".to_string(), vec![file_str]))
705 } else if tool_available("go") {
706 Some(("go".to_string(), vec!["vet".to_string(), file_str]))
707 } else {
708 None
709 }
710 } else {
711 None
712 }
713 }
714 LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp | LangId::Bash => None,
715 LangId::Html => None,
716 LangId::Markdown => None,
717 }
718}
719
720fn resolve_explicit_checker(
724 name: &str,
725 file_str: &str,
726 _lang: LangId,
727 project_root: Option<&Path>,
728) -> Option<(String, Vec<String>)> {
729 match name {
730 "none" | "off" | "false" => return None,
731 _ => {}
732 }
733
734 if name == "tsc" {
736 return Some((
737 "npx".to_string(),
738 vec![
739 "tsc".to_string(),
740 "--noEmit".to_string(),
741 "--pretty".to_string(),
742 "false".to_string(),
743 ],
744 ));
745 }
746 if name == "cargo" {
748 return Some((
749 "cargo".to_string(),
750 vec!["check".to_string(), "--message-format=json".to_string()],
751 ));
752 }
753 if name == "go" {
754 return Some((
755 "go".to_string(),
756 vec!["vet".to_string(), file_str.to_string()],
757 ));
758 }
759
760 let known_tools = ["biome", "pyright", "ruff", "staticcheck"];
762 if known_tools.contains(&name) {
763 let cmd = match resolve_tool(name, project_root) {
764 Some(resolved) => resolved,
765 None => {
766 log::warn!(
767 "[aft] validate: configured checker '{}' not found in node_modules/.bin or PATH",
768 name
769 );
770 return None;
771 }
772 };
773
774 let args = match name {
775 "biome" => vec!["check".to_string(), file_str.to_string()],
776 "pyright" => vec!["--outputjson".to_string(), file_str.to_string()],
777 "ruff" => vec![
778 "check".to_string(),
779 "--output-format=json".to_string(),
780 file_str.to_string(),
781 ],
782 "staticcheck" => vec![file_str.to_string()],
783 _ => unreachable!(),
784 };
785
786 return Some((cmd, args));
787 }
788
789 log::debug!(
790 "[aft] validate: unknown preferred_checker '{}', falling back to auto",
791 name
792 );
793 None
794}
795
796pub fn parse_checker_output(
801 stdout: &str,
802 stderr: &str,
803 file: &Path,
804 checker: &str,
805) -> Vec<ValidationError> {
806 match checker {
807 "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
808 "pyright" => parse_pyright_output(stdout, file),
809 "cargo" => parse_cargo_output(stdout, stderr, file),
810 "go" => parse_go_vet_output(stderr, file),
811 _ => Vec::new(),
812 }
813}
814
815fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
817 let mut errors = Vec::new();
818 let file_str = file.to_string_lossy();
819 let combined = format!("{}{}", stdout, stderr);
821 for line in combined.lines() {
822 if let Some((loc, rest)) = line.split_once("): ") {
825 let file_part = loc.split('(').next().unwrap_or("");
827 if !file_str.ends_with(file_part)
828 && !file_part.ends_with(&*file_str)
829 && file_part != &*file_str
830 {
831 continue;
832 }
833
834 let coords = loc.split('(').last().unwrap_or("");
836 let parts: Vec<&str> = coords.split(',').collect();
837 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
838 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
839
840 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
842 ("error".to_string(), msg.to_string())
843 } else if let Some(msg) = rest.strip_prefix("warning ") {
844 ("warning".to_string(), msg.to_string())
845 } else {
846 ("error".to_string(), rest.to_string())
847 };
848
849 errors.push(ValidationError {
850 line: line_num,
851 column: col_num,
852 message,
853 severity,
854 });
855 }
856 }
857 errors
858}
859
860fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
862 let mut errors = Vec::new();
863 let file_str = file.to_string_lossy();
864
865 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
867 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
868 for diag in diags {
869 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
871 if !diag_file.is_empty()
872 && !file_str.ends_with(diag_file)
873 && !diag_file.ends_with(&*file_str)
874 && diag_file != &*file_str
875 {
876 continue;
877 }
878
879 let line_num = diag
880 .get("range")
881 .and_then(|r| r.get("start"))
882 .and_then(|s| s.get("line"))
883 .and_then(|l| l.as_u64())
884 .unwrap_or(0) as u32;
885 let col_num = diag
886 .get("range")
887 .and_then(|r| r.get("start"))
888 .and_then(|s| s.get("character"))
889 .and_then(|c| c.as_u64())
890 .unwrap_or(0) as u32;
891 let message = diag
892 .get("message")
893 .and_then(|m| m.as_str())
894 .unwrap_or("unknown error")
895 .to_string();
896 let severity = diag
897 .get("severity")
898 .and_then(|s| s.as_str())
899 .unwrap_or("error")
900 .to_lowercase();
901
902 errors.push(ValidationError {
903 line: line_num + 1, column: col_num,
905 message,
906 severity,
907 });
908 }
909 }
910 }
911 errors
912}
913
914fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
916 let mut errors = Vec::new();
917 let file_str = file.to_string_lossy();
918
919 for line in stdout.lines() {
920 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
921 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
922 continue;
923 }
924 let message_obj = match msg.get("message") {
925 Some(m) => m,
926 None => continue,
927 };
928
929 let level = message_obj
930 .get("level")
931 .and_then(|l| l.as_str())
932 .unwrap_or("error");
933
934 if level != "error" && level != "warning" {
936 continue;
937 }
938
939 let text = message_obj
940 .get("message")
941 .and_then(|m| m.as_str())
942 .unwrap_or("unknown error")
943 .to_string();
944
945 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
947 for span in spans {
948 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
949 let is_primary = span
950 .get("is_primary")
951 .and_then(|p| p.as_bool())
952 .unwrap_or(false);
953
954 if !is_primary {
955 continue;
956 }
957
958 if !file_str.ends_with(span_file)
960 && !span_file.ends_with(&*file_str)
961 && span_file != &*file_str
962 {
963 continue;
964 }
965
966 let line_num =
967 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
968 let col_num = span
969 .get("column_start")
970 .and_then(|c| c.as_u64())
971 .unwrap_or(0) as u32;
972
973 errors.push(ValidationError {
974 line: line_num,
975 column: col_num,
976 message: text.clone(),
977 severity: level.to_string(),
978 });
979 }
980 }
981 }
982 }
983 errors
984}
985
986fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
988 let mut errors = Vec::new();
989 let file_str = file.to_string_lossy();
990
991 for line in stderr.lines() {
992 let parts: Vec<&str> = line.splitn(4, ':').collect();
994 if parts.len() < 3 {
995 continue;
996 }
997
998 let err_file = parts[0].trim();
999 if !file_str.ends_with(err_file)
1000 && !err_file.ends_with(&*file_str)
1001 && err_file != &*file_str
1002 {
1003 continue;
1004 }
1005
1006 let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1007 let (col_num, message) = if parts.len() >= 4 {
1008 if let Ok(col) = parts[2].trim().parse::<u32>() {
1009 (col, parts[3].trim().to_string())
1010 } else {
1011 (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1013 }
1014 } else {
1015 (0, parts[2].trim().to_string())
1016 };
1017
1018 errors.push(ValidationError {
1019 line: line_num,
1020 column: col_num,
1021 message,
1022 severity: "error".to_string(),
1023 });
1024 }
1025 errors
1026}
1027
1028pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1036 let lang = match detect_language(path) {
1037 Some(l) => l,
1038 None => {
1039 log::debug!(
1040 "[aft] validate: {} (skipped: unsupported_language)",
1041 path.display()
1042 );
1043 return (Vec::new(), Some("unsupported_language".to_string()));
1044 }
1045 };
1046
1047 let (cmd, args) = match detect_type_checker(path, lang, config) {
1048 Some(pair) => pair,
1049 None => {
1050 log::warn!("validate: {} (skipped: not_found)", path.display());
1051 return (Vec::new(), Some("not_found".to_string()));
1052 }
1053 };
1054
1055 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1056
1057 let working_dir = path.parent();
1059
1060 match run_external_tool_capture(
1061 &cmd,
1062 &arg_refs,
1063 working_dir,
1064 config.type_checker_timeout_secs,
1065 ) {
1066 Ok(result) => {
1067 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1068 log::debug!(
1069 "[aft] validate: {} ({}, {} errors)",
1070 path.display(),
1071 cmd,
1072 errors.len()
1073 );
1074 (errors, None)
1075 }
1076 Err(FormatError::Timeout { .. }) => {
1077 log::error!("validate: {} (skipped: timeout)", path.display());
1078 (Vec::new(), Some("timeout".to_string()))
1079 }
1080 Err(FormatError::NotFound { .. }) => {
1081 log::warn!("validate: {} (skipped: not_found)", path.display());
1082 (Vec::new(), Some("not_found".to_string()))
1083 }
1084 Err(FormatError::Failed { stderr, .. }) => {
1085 log::debug!(
1086 "[aft] validate: {} (skipped: error: {})",
1087 path.display(),
1088 stderr.lines().next().unwrap_or("unknown")
1089 );
1090 (Vec::new(), Some("error".to_string()))
1091 }
1092 Err(FormatError::UnsupportedLanguage) => {
1093 log::debug!(
1094 "[aft] validate: {} (skipped: unsupported_language)",
1095 path.display()
1096 );
1097 (Vec::new(), Some("unsupported_language".to_string()))
1098 }
1099 }
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 use super::*;
1105 use std::fs;
1106 use std::io::Write;
1107
1108 #[test]
1109 fn run_external_tool_not_found() {
1110 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1111 assert!(result.is_err());
1112 match result.unwrap_err() {
1113 FormatError::NotFound { tool } => {
1114 assert_eq!(tool, "__nonexistent_tool_xyz__");
1115 }
1116 other => panic!("expected NotFound, got: {:?}", other),
1117 }
1118 }
1119
1120 #[test]
1121 fn run_external_tool_timeout_kills_subprocess() {
1122 let result = run_external_tool("sleep", &["60"], None, 1);
1124 assert!(result.is_err());
1125 match result.unwrap_err() {
1126 FormatError::Timeout { tool, timeout_secs } => {
1127 assert_eq!(tool, "sleep");
1128 assert_eq!(timeout_secs, 1);
1129 }
1130 other => panic!("expected Timeout, got: {:?}", other),
1131 }
1132 }
1133
1134 #[test]
1135 fn run_external_tool_success() {
1136 let result = run_external_tool("echo", &["hello"], None, 5);
1137 assert!(result.is_ok());
1138 let res = result.unwrap();
1139 assert_eq!(res.exit_code, 0);
1140 assert!(res.stdout.contains("hello"));
1141 }
1142
1143 #[test]
1144 fn run_external_tool_nonzero_exit() {
1145 let result = run_external_tool("false", &[], None, 5);
1147 assert!(result.is_err());
1148 match result.unwrap_err() {
1149 FormatError::Failed { tool, .. } => {
1150 assert_eq!(tool, "false");
1151 }
1152 other => panic!("expected Failed, got: {:?}", other),
1153 }
1154 }
1155
1156 #[test]
1157 fn auto_format_unsupported_language() {
1158 let dir = tempfile::tempdir().unwrap();
1159 let path = dir.path().join("file.txt");
1160 fs::write(&path, "hello").unwrap();
1161
1162 let config = Config::default();
1163 let (formatted, reason) = auto_format(&path, &config);
1164 assert!(!formatted);
1165 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1166 }
1167
1168 #[test]
1169 fn detect_formatter_rust_when_rustfmt_available() {
1170 let dir = tempfile::tempdir().unwrap();
1171 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1172 let path = dir.path().join("test.rs");
1173 let config = Config {
1174 project_root: Some(dir.path().to_path_buf()),
1175 ..Config::default()
1176 };
1177 let result = detect_formatter(&path, LangId::Rust, &config);
1178 if tool_available("rustfmt") {
1179 let (cmd, args) = result.unwrap();
1180 assert_eq!(cmd, "rustfmt");
1181 assert!(args.iter().any(|a| a.ends_with("test.rs")));
1182 } else {
1183 assert!(result.is_none());
1184 }
1185 }
1186
1187 #[test]
1188 fn detect_formatter_go_mapping() {
1189 let dir = tempfile::tempdir().unwrap();
1190 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1191 let path = dir.path().join("main.go");
1192 let config = Config {
1193 project_root: Some(dir.path().to_path_buf()),
1194 ..Config::default()
1195 };
1196 let result = detect_formatter(&path, LangId::Go, &config);
1197 if tool_available("goimports") {
1198 let (cmd, args) = result.unwrap();
1199 assert_eq!(cmd, "goimports");
1200 assert!(args.contains(&"-w".to_string()));
1201 } else if tool_available("gofmt") {
1202 let (cmd, args) = result.unwrap();
1203 assert_eq!(cmd, "gofmt");
1204 assert!(args.contains(&"-w".to_string()));
1205 } else {
1206 assert!(result.is_none());
1207 }
1208 }
1209
1210 #[test]
1211 fn detect_formatter_python_mapping() {
1212 let dir = tempfile::tempdir().unwrap();
1213 fs::write(dir.path().join("ruff.toml"), "").unwrap();
1214 let path = dir.path().join("main.py");
1215 let config = Config {
1216 project_root: Some(dir.path().to_path_buf()),
1217 ..Config::default()
1218 };
1219 let result = detect_formatter(&path, LangId::Python, &config);
1220 if ruff_format_available() {
1221 let (cmd, args) = result.unwrap();
1222 assert_eq!(cmd, "ruff");
1223 assert!(args.contains(&"format".to_string()));
1224 } else {
1225 assert!(result.is_none());
1226 }
1227 }
1228
1229 #[test]
1230 fn detect_formatter_no_config_returns_none() {
1231 let path = Path::new("test.ts");
1232 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1233 assert!(
1234 result.is_none(),
1235 "expected no formatter without project config"
1236 );
1237 }
1238
1239 #[test]
1240 fn detect_formatter_explicit_override() {
1241 let dir = tempfile::tempdir().unwrap();
1243 let bin_dir = dir.path().join("node_modules").join(".bin");
1244 fs::create_dir_all(&bin_dir).unwrap();
1245 #[cfg(unix)]
1246 {
1247 use std::os::unix::fs::PermissionsExt;
1248 let fake = bin_dir.join("biome");
1249 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1250 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1251 }
1252 #[cfg(not(unix))]
1253 {
1254 fs::write(bin_dir.join("biome.cmd"), "@echo 1.0.0").unwrap();
1255 }
1256
1257 let path = Path::new("test.ts");
1258 let mut config = Config::default();
1259 config.project_root = Some(dir.path().to_path_buf());
1260 config
1261 .formatter
1262 .insert("typescript".to_string(), "biome".to_string());
1263 let result = detect_formatter(path, LangId::TypeScript, &config);
1264 let (cmd, args) = result.unwrap();
1265 assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1266 assert!(args.contains(&"format".to_string()));
1267 assert!(args.contains(&"--write".to_string()));
1268 }
1269
1270 #[test]
1271 fn auto_format_happy_path_rustfmt() {
1272 if !tool_available("rustfmt") {
1273 log::warn!("skipping: rustfmt not available");
1274 return;
1275 }
1276
1277 let dir = tempfile::tempdir().unwrap();
1278 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1279 let path = dir.path().join("test.rs");
1280
1281 let mut f = fs::File::create(&path).unwrap();
1282 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
1283 drop(f);
1284
1285 let config = Config {
1286 project_root: Some(dir.path().to_path_buf()),
1287 ..Config::default()
1288 };
1289 let (formatted, reason) = auto_format(&path, &config);
1290 assert!(formatted, "expected formatting to succeed");
1291 assert!(reason.is_none());
1292
1293 let content = fs::read_to_string(&path).unwrap();
1294 assert!(
1295 !content.contains("fn main"),
1296 "expected rustfmt to fix spacing"
1297 );
1298 }
1299
1300 #[test]
1301 fn parse_tsc_output_basic() {
1302 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";
1303 let file = Path::new("src/app.ts");
1304 let errors = parse_tsc_output(stdout, "", file);
1305 assert_eq!(errors.len(), 2);
1306 assert_eq!(errors[0].line, 10);
1307 assert_eq!(errors[0].column, 5);
1308 assert_eq!(errors[0].severity, "error");
1309 assert!(errors[0].message.contains("TS2322"));
1310 assert_eq!(errors[1].line, 20);
1311 }
1312
1313 #[test]
1314 fn parse_tsc_output_filters_other_files() {
1315 let stdout =
1316 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1317 let file = Path::new("src/app.ts");
1318 let errors = parse_tsc_output(stdout, "", file);
1319 assert_eq!(errors.len(), 1);
1320 assert_eq!(errors[0].line, 5);
1321 }
1322
1323 #[test]
1324 fn parse_cargo_output_basic() {
1325 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}]}}"#;
1326 let file = Path::new("src/main.rs");
1327 let errors = parse_cargo_output(json_line, "", file);
1328 assert_eq!(errors.len(), 1);
1329 assert_eq!(errors[0].line, 10);
1330 assert_eq!(errors[0].column, 5);
1331 assert_eq!(errors[0].severity, "error");
1332 assert!(errors[0].message.contains("mismatched types"));
1333 }
1334
1335 #[test]
1336 fn parse_cargo_output_skips_notes() {
1337 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}]}}"#;
1339 let file = Path::new("src/main.rs");
1340 let errors = parse_cargo_output(json_line, "", file);
1341 assert_eq!(errors.len(), 0);
1342 }
1343
1344 #[test]
1345 fn parse_cargo_output_filters_other_files() {
1346 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}]}}"#;
1347 let file = Path::new("src/main.rs");
1348 let errors = parse_cargo_output(json_line, "", file);
1349 assert_eq!(errors.len(), 0);
1350 }
1351
1352 #[test]
1353 fn parse_go_vet_output_basic() {
1354 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1355 let file = Path::new("main.go");
1356 let errors = parse_go_vet_output(stderr, file);
1357 assert_eq!(errors.len(), 2);
1358 assert_eq!(errors[0].line, 10);
1359 assert_eq!(errors[0].column, 5);
1360 assert!(errors[0].message.contains("unreachable code"));
1361 assert_eq!(errors[1].line, 20);
1362 assert_eq!(errors[1].column, 0);
1363 }
1364
1365 #[test]
1366 fn parse_pyright_output_basic() {
1367 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1368 let file = Path::new("test.py");
1369 let errors = parse_pyright_output(stdout, file);
1370 assert_eq!(errors.len(), 1);
1371 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 10);
1373 assert_eq!(errors[0].severity, "error");
1374 assert!(errors[0].message.contains("Type error here"));
1375 }
1376
1377 #[test]
1378 fn validate_full_unsupported_language() {
1379 let dir = tempfile::tempdir().unwrap();
1380 let path = dir.path().join("file.txt");
1381 fs::write(&path, "hello").unwrap();
1382
1383 let config = Config::default();
1384 let (errors, reason) = validate_full(&path, &config);
1385 assert!(errors.is_empty());
1386 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1387 }
1388
1389 #[test]
1390 fn detect_type_checker_rust() {
1391 let dir = tempfile::tempdir().unwrap();
1392 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1393 let path = dir.path().join("src/main.rs");
1394 let config = Config {
1395 project_root: Some(dir.path().to_path_buf()),
1396 ..Config::default()
1397 };
1398 let result = detect_type_checker(&path, LangId::Rust, &config);
1399 if tool_available("cargo") {
1400 let (cmd, args) = result.unwrap();
1401 assert_eq!(cmd, "cargo");
1402 assert!(args.contains(&"check".to_string()));
1403 } else {
1404 assert!(result.is_none());
1405 }
1406 }
1407
1408 #[test]
1409 fn detect_type_checker_go() {
1410 let dir = tempfile::tempdir().unwrap();
1411 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1412 let path = dir.path().join("main.go");
1413 let config = Config {
1414 project_root: Some(dir.path().to_path_buf()),
1415 ..Config::default()
1416 };
1417 let result = detect_type_checker(&path, LangId::Go, &config);
1418 if tool_available("go") {
1419 let (cmd, _args) = result.unwrap();
1420 assert!(cmd == "go" || cmd == "staticcheck");
1422 } else {
1423 assert!(result.is_none());
1424 }
1425 }
1426 #[test]
1427 fn run_external_tool_capture_nonzero_not_error() {
1428 let result = run_external_tool_capture("false", &[], None, 5);
1430 assert!(result.is_ok(), "capture should not error on non-zero exit");
1431 assert_eq!(result.unwrap().exit_code, 1);
1432 }
1433
1434 #[test]
1435 fn run_external_tool_capture_not_found() {
1436 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
1437 assert!(result.is_err());
1438 match result.unwrap_err() {
1439 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
1440 other => panic!("expected NotFound, got: {:?}", other),
1441 }
1442 }
1443}