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