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