1use std::io::ErrorKind;
7use std::path::Path;
8use std::process::{Command, Stdio};
9use std::thread;
10use std::time::{Duration, Instant};
11
12use crate::config::Config;
13use crate::parser::{detect_language, LangId};
14
15#[derive(Debug)]
17pub struct ExternalToolResult {
18 pub stdout: String,
19 pub stderr: String,
20 pub exit_code: i32,
21}
22
23#[derive(Debug)]
25pub enum FormatError {
26 NotFound { tool: String },
28 Timeout { tool: String, timeout_secs: u32 },
30 Failed { tool: String, stderr: String },
32 UnsupportedLanguage,
34}
35
36impl std::fmt::Display for FormatError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
40 FormatError::Timeout { tool, timeout_secs } => {
41 write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
42 }
43 FormatError::Failed { tool, stderr } => {
44 write!(f, "formatter '{}' failed: {}", tool, stderr)
45 }
46 FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
47 }
48 }
49}
50
51pub fn run_external_tool(
57 command: &str,
58 args: &[&str],
59 working_dir: Option<&Path>,
60 timeout_secs: u32,
61) -> Result<ExternalToolResult, FormatError> {
62 let mut cmd = Command::new(command);
63 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
64
65 if let Some(dir) = working_dir {
66 cmd.current_dir(dir);
67 }
68
69 let mut child = match cmd.spawn() {
70 Ok(c) => c,
71 Err(e) if e.kind() == ErrorKind::NotFound => {
72 return Err(FormatError::NotFound {
73 tool: command.to_string(),
74 });
75 }
76 Err(e) => {
77 return Err(FormatError::Failed {
78 tool: command.to_string(),
79 stderr: e.to_string(),
80 });
81 }
82 };
83
84 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
85
86 loop {
87 match child.try_wait() {
88 Ok(Some(status)) => {
89 let stdout = child
90 .stdout
91 .take()
92 .map(|s| std::io::read_to_string(s).unwrap_or_default())
93 .unwrap_or_default();
94 let stderr = child
95 .stderr
96 .take()
97 .map(|s| std::io::read_to_string(s).unwrap_or_default())
98 .unwrap_or_default();
99
100 let exit_code = status.code().unwrap_or(-1);
101 if exit_code != 0 {
102 return Err(FormatError::Failed {
103 tool: command.to_string(),
104 stderr,
105 });
106 }
107
108 return Ok(ExternalToolResult {
109 stdout,
110 stderr,
111 exit_code,
112 });
113 }
114 Ok(None) => {
115 if Instant::now() >= deadline {
117 let _ = child.kill();
119 let _ = child.wait();
120 return Err(FormatError::Timeout {
121 tool: command.to_string(),
122 timeout_secs,
123 });
124 }
125 thread::sleep(Duration::from_millis(50));
126 }
127 Err(e) => {
128 return Err(FormatError::Failed {
129 tool: command.to_string(),
130 stderr: format!("try_wait error: {}", e),
131 });
132 }
133 }
134 }
135}
136
137fn tool_available(command: &str) -> bool {
143 resolve_tool(command, None).is_some()
144}
145
146fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
149 if let Some(root) = project_root {
151 let local_bin = root.join("node_modules").join(".bin").join(command);
152 if local_bin.exists() {
153 return Some(local_bin.to_string_lossy().to_string());
154 }
155 }
156
157 match Command::new(command)
159 .arg("--version")
160 .stdout(Stdio::null())
161 .stderr(Stdio::null())
162 .spawn()
163 {
164 Ok(mut child) => {
165 let _ = child.wait();
166 Some(command.to_string())
167 }
168 Err(_) => None,
169 }
170}
171
172fn ruff_format_available() -> bool {
179 let output = match Command::new("ruff")
180 .arg("--version")
181 .stdout(Stdio::piped())
182 .stderr(Stdio::null())
183 .output()
184 {
185 Ok(o) => o,
186 Err(_) => return false,
187 };
188
189 let version_str = String::from_utf8_lossy(&output.stdout);
190 let version_part = version_str
192 .trim()
193 .strip_prefix("ruff ")
194 .unwrap_or(version_str.trim());
195
196 let parts: Vec<&str> = version_part.split('.').collect();
197 if parts.len() < 3 {
198 return false;
199 }
200
201 let major: u32 = match parts[0].parse() {
202 Ok(v) => v,
203 Err(_) => return false,
204 };
205 let minor: u32 = match parts[1].parse() {
206 Ok(v) => v,
207 Err(_) => return false,
208 };
209 let patch: u32 = match parts[2].parse() {
210 Ok(v) => v,
211 Err(_) => return false,
212 };
213
214 (major, minor, patch) >= (0, 1, 2)
216}
217
218pub fn detect_formatter(
228 path: &Path,
229 lang: LangId,
230 config: &Config,
231) -> Option<(String, Vec<String>)> {
232 let file_str = path.to_string_lossy().to_string();
233
234 let lang_key = match lang {
236 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
237 LangId::Python => "python",
238 LangId::Rust => "rust",
239 LangId::Go => "go",
240 LangId::Markdown => "markdown",
241 };
242 if let Some(preferred) = config.formatter.get(lang_key) {
243 return resolve_explicit_formatter(preferred, &file_str, lang);
244 }
245
246 let project_root = config.project_root.as_deref();
249
250 match lang {
251 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
252 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
254 if let Some(biome_cmd) = resolve_tool("biome", project_root) {
255 return Some((
256 biome_cmd,
257 vec!["format".to_string(), "--write".to_string(), file_str],
258 ));
259 }
260 }
261 if has_project_config(
263 project_root,
264 &[
265 ".prettierrc",
266 ".prettierrc.json",
267 ".prettierrc.yml",
268 ".prettierrc.yaml",
269 ".prettierrc.js",
270 ".prettierrc.cjs",
271 ".prettierrc.mjs",
272 ".prettierrc.toml",
273 "prettier.config.js",
274 "prettier.config.cjs",
275 "prettier.config.mjs",
276 ],
277 ) {
278 if let Some(prettier_cmd) = resolve_tool("prettier", project_root) {
279 return Some((prettier_cmd, vec!["--write".to_string(), file_str]));
280 }
281 }
282 if has_project_config(project_root, &["deno.json", "deno.jsonc"])
284 && tool_available("deno")
285 {
286 return Some(("deno".to_string(), vec!["fmt".to_string(), file_str]));
287 }
288 None
290 }
291 LangId::Python => {
292 if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
294 || has_pyproject_tool(project_root, "ruff"))
295 && ruff_format_available()
296 {
297 return Some(("ruff".to_string(), vec!["format".to_string(), file_str]));
298 }
299 if has_pyproject_tool(project_root, "black") && tool_available("black") {
301 return Some(("black".to_string(), vec![file_str]));
302 }
303 None
305 }
306 LangId::Rust => {
307 if has_project_config(project_root, &["Cargo.toml"]) && tool_available("rustfmt") {
309 Some(("rustfmt".to_string(), vec![file_str]))
310 } else {
311 None
312 }
313 }
314 LangId::Go => {
315 if has_project_config(project_root, &["go.mod"]) {
317 if tool_available("goimports") {
318 Some(("goimports".to_string(), vec!["-w".to_string(), file_str]))
319 } else if tool_available("gofmt") {
320 Some(("gofmt".to_string(), vec!["-w".to_string(), file_str]))
321 } else {
322 None
323 }
324 } else {
325 None
326 }
327 }
328 LangId::Markdown => None,
329 }
330}
331
332fn resolve_explicit_formatter(
334 name: &str,
335 file_str: &str,
336 lang: LangId,
337) -> Option<(String, Vec<String>)> {
338 match name {
339 "biome" => Some((
340 "biome".to_string(),
341 vec![
342 "format".to_string(),
343 "--write".to_string(),
344 file_str.to_string(),
345 ],
346 )),
347 "prettier" => Some((
348 "prettier".to_string(),
349 vec!["--write".to_string(), file_str.to_string()],
350 )),
351 "deno" => Some((
352 "deno".to_string(),
353 vec!["fmt".to_string(), file_str.to_string()],
354 )),
355 "ruff" => Some((
356 "ruff".to_string(),
357 vec!["format".to_string(), file_str.to_string()],
358 )),
359 "black" => Some(("black".to_string(), vec![file_str.to_string()])),
360 "rustfmt" => Some(("rustfmt".to_string(), vec![file_str.to_string()])),
361 "goimports" => Some((
362 "goimports".to_string(),
363 vec!["-w".to_string(), file_str.to_string()],
364 )),
365 "gofmt" => Some((
366 "gofmt".to_string(),
367 vec!["-w".to_string(), file_str.to_string()],
368 )),
369 "none" | "off" | "false" => None,
370 _ => {
371 eprintln!(
372 "[aft] format: unknown preferred_formatter '{}' for {:?}, falling back to auto",
373 name, lang
374 );
375 None
376 }
377 }
378}
379
380fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
382 let root = match project_root {
383 Some(r) => r,
384 None => return false,
385 };
386 filenames.iter().any(|f| root.join(f).exists())
387}
388
389fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
391 let root = match project_root {
392 Some(r) => r,
393 None => return false,
394 };
395 let pyproject = root.join("pyproject.toml");
396 if !pyproject.exists() {
397 return false;
398 }
399 match std::fs::read_to_string(&pyproject) {
400 Ok(content) => {
401 let pattern = format!("[tool.{}]", tool_name);
402 content.contains(&pattern)
403 }
404 Err(_) => false,
405 }
406}
407
408pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
416 if !config.format_on_edit {
418 return (false, Some("disabled".to_string()));
419 }
420
421 let lang = match detect_language(path) {
422 Some(l) => l,
423 None => {
424 eprintln!(
425 "[aft] format: {} (skipped: unsupported_language)",
426 path.display()
427 );
428 return (false, Some("unsupported_language".to_string()));
429 }
430 };
431
432 let (cmd, args) = match detect_formatter(path, lang, config) {
433 Some(pair) => pair,
434 None => {
435 eprintln!("[aft] format: {} (skipped: not_found)", path.display());
436 return (false, Some("not_found".to_string()));
437 }
438 };
439
440 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
441
442 match run_external_tool(&cmd, &arg_refs, None, config.formatter_timeout_secs) {
443 Ok(_) => {
444 eprintln!("[aft] format: {} ({})", path.display(), cmd);
445 (true, None)
446 }
447 Err(FormatError::Timeout { .. }) => {
448 eprintln!("[aft] format: {} (skipped: timeout)", path.display());
449 (false, Some("timeout".to_string()))
450 }
451 Err(FormatError::NotFound { .. }) => {
452 eprintln!("[aft] format: {} (skipped: not_found)", path.display());
453 (false, Some("not_found".to_string()))
454 }
455 Err(FormatError::Failed { stderr, .. }) => {
456 eprintln!(
457 "[aft] format: {} (skipped: error: {})",
458 path.display(),
459 stderr.lines().next().unwrap_or("unknown")
460 );
461 (false, Some("error".to_string()))
462 }
463 Err(FormatError::UnsupportedLanguage) => {
464 eprintln!(
465 "[aft] format: {} (skipped: unsupported_language)",
466 path.display()
467 );
468 (false, Some("unsupported_language".to_string()))
469 }
470 }
471}
472
473pub fn run_external_tool_capture(
480 command: &str,
481 args: &[&str],
482 working_dir: Option<&Path>,
483 timeout_secs: u32,
484) -> Result<ExternalToolResult, FormatError> {
485 let mut cmd = Command::new(command);
486 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
487
488 if let Some(dir) = working_dir {
489 cmd.current_dir(dir);
490 }
491
492 let mut child = match cmd.spawn() {
493 Ok(c) => c,
494 Err(e) if e.kind() == ErrorKind::NotFound => {
495 return Err(FormatError::NotFound {
496 tool: command.to_string(),
497 });
498 }
499 Err(e) => {
500 return Err(FormatError::Failed {
501 tool: command.to_string(),
502 stderr: e.to_string(),
503 });
504 }
505 };
506
507 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
508
509 loop {
510 match child.try_wait() {
511 Ok(Some(status)) => {
512 let stdout = child
513 .stdout
514 .take()
515 .map(|s| std::io::read_to_string(s).unwrap_or_default())
516 .unwrap_or_default();
517 let stderr = child
518 .stderr
519 .take()
520 .map(|s| std::io::read_to_string(s).unwrap_or_default())
521 .unwrap_or_default();
522
523 return Ok(ExternalToolResult {
524 stdout,
525 stderr,
526 exit_code: status.code().unwrap_or(-1),
527 });
528 }
529 Ok(None) => {
530 if Instant::now() >= deadline {
531 let _ = child.kill();
532 let _ = child.wait();
533 return Err(FormatError::Timeout {
534 tool: command.to_string(),
535 timeout_secs,
536 });
537 }
538 thread::sleep(Duration::from_millis(50));
539 }
540 Err(e) => {
541 return Err(FormatError::Failed {
542 tool: command.to_string(),
543 stderr: format!("try_wait error: {}", e),
544 });
545 }
546 }
547 }
548}
549
550#[derive(Debug, Clone, serde::Serialize)]
556pub struct ValidationError {
557 pub line: u32,
558 pub column: u32,
559 pub message: String,
560 pub severity: String,
561}
562
563pub fn detect_type_checker(
574 path: &Path,
575 lang: LangId,
576 config: &Config,
577) -> Option<(String, Vec<String>)> {
578 let file_str = path.to_string_lossy().to_string();
579 let project_root = config.project_root.as_deref();
580
581 let lang_key = match lang {
583 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
584 LangId::Python => "python",
585 LangId::Rust => "rust",
586 LangId::Go => "go",
587 LangId::Markdown => "markdown",
588 };
589 if let Some(preferred) = config.checker.get(lang_key) {
590 return resolve_explicit_checker(preferred, &file_str, lang);
591 }
592
593 match lang {
594 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
595 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
597 if let Some(biome_cmd) = resolve_tool("biome", project_root) {
598 return Some((biome_cmd, vec!["check".to_string(), file_str]));
599 }
600 }
601 if has_project_config(project_root, &["tsconfig.json"]) {
603 if let Some(tsc_cmd) = resolve_tool("tsc", project_root) {
604 return Some((
605 tsc_cmd,
606 vec![
607 "--noEmit".to_string(),
608 "--pretty".to_string(),
609 "false".to_string(),
610 ],
611 ));
612 } else if tool_available("npx") {
613 return Some((
614 "npx".to_string(),
615 vec![
616 "tsc".to_string(),
617 "--noEmit".to_string(),
618 "--pretty".to_string(),
619 "false".to_string(),
620 ],
621 ));
622 }
623 }
624 None
625 }
626 LangId::Python => {
627 if has_project_config(project_root, &["pyrightconfig.json"])
629 || has_pyproject_tool(project_root, "pyright")
630 {
631 if let Some(pyright_cmd) = resolve_tool("pyright", project_root) {
632 return Some((pyright_cmd, vec!["--outputjson".to_string(), file_str]));
633 }
634 }
635 if (has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
637 || has_pyproject_tool(project_root, "ruff"))
638 && ruff_format_available()
639 {
640 return Some((
641 "ruff".to_string(),
642 vec![
643 "check".to_string(),
644 "--output-format=json".to_string(),
645 file_str,
646 ],
647 ));
648 }
649 None
650 }
651 LangId::Rust => {
652 if has_project_config(project_root, &["Cargo.toml"]) && tool_available("cargo") {
654 Some((
655 "cargo".to_string(),
656 vec!["check".to_string(), "--message-format=json".to_string()],
657 ))
658 } else {
659 None
660 }
661 }
662 LangId::Go => {
663 if has_project_config(project_root, &["go.mod"]) {
665 if tool_available("staticcheck") {
666 Some(("staticcheck".to_string(), vec![file_str]))
667 } else if tool_available("go") {
668 Some(("go".to_string(), vec!["vet".to_string(), file_str]))
669 } else {
670 None
671 }
672 } else {
673 None
674 }
675 }
676 LangId::Markdown => None,
677 }
678}
679
680fn resolve_explicit_checker(
682 name: &str,
683 file_str: &str,
684 _lang: LangId,
685) -> Option<(String, Vec<String>)> {
686 match name {
687 "tsc" => Some((
688 "npx".to_string(),
689 vec![
690 "tsc".to_string(),
691 "--noEmit".to_string(),
692 "--pretty".to_string(),
693 "false".to_string(),
694 ],
695 )),
696 "biome" => Some((
697 "biome".to_string(),
698 vec!["check".to_string(), file_str.to_string()],
699 )),
700 "pyright" => Some((
701 "pyright".to_string(),
702 vec!["--outputjson".to_string(), file_str.to_string()],
703 )),
704 "ruff" => Some((
705 "ruff".to_string(),
706 vec![
707 "check".to_string(),
708 "--output-format=json".to_string(),
709 file_str.to_string(),
710 ],
711 )),
712 "cargo" => Some((
713 "cargo".to_string(),
714 vec!["check".to_string(), "--message-format=json".to_string()],
715 )),
716 "go" => Some((
717 "go".to_string(),
718 vec!["vet".to_string(), file_str.to_string()],
719 )),
720 "staticcheck" => Some(("staticcheck".to_string(), vec![file_str.to_string()])),
721 "none" | "off" | "false" => None,
722 _ => {
723 eprintln!(
724 "[aft] validate: unknown preferred_checker '{}', falling back to auto",
725 name
726 );
727 None
728 }
729 }
730}
731
732pub fn parse_checker_output(
737 stdout: &str,
738 stderr: &str,
739 file: &Path,
740 checker: &str,
741) -> Vec<ValidationError> {
742 match checker {
743 "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
744 "pyright" => parse_pyright_output(stdout, file),
745 "cargo" => parse_cargo_output(stdout, stderr, file),
746 "go" => parse_go_vet_output(stderr, file),
747 _ => Vec::new(),
748 }
749}
750
751fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
753 let mut errors = Vec::new();
754 let file_str = file.to_string_lossy();
755 let combined = format!("{}{}", stdout, stderr);
757 for line in combined.lines() {
758 if let Some((loc, rest)) = line.split_once("): ") {
761 let file_part = loc.split('(').next().unwrap_or("");
763 if !file_str.ends_with(file_part)
764 && !file_part.ends_with(&*file_str)
765 && file_part != &*file_str
766 {
767 continue;
768 }
769
770 let coords = loc.split('(').last().unwrap_or("");
772 let parts: Vec<&str> = coords.split(',').collect();
773 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
774 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
775
776 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
778 ("error".to_string(), msg.to_string())
779 } else if let Some(msg) = rest.strip_prefix("warning ") {
780 ("warning".to_string(), msg.to_string())
781 } else {
782 ("error".to_string(), rest.to_string())
783 };
784
785 errors.push(ValidationError {
786 line: line_num,
787 column: col_num,
788 message,
789 severity,
790 });
791 }
792 }
793 errors
794}
795
796fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
798 let mut errors = Vec::new();
799 let file_str = file.to_string_lossy();
800
801 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
803 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
804 for diag in diags {
805 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
807 if !diag_file.is_empty()
808 && !file_str.ends_with(diag_file)
809 && !diag_file.ends_with(&*file_str)
810 && diag_file != &*file_str
811 {
812 continue;
813 }
814
815 let line_num = diag
816 .get("range")
817 .and_then(|r| r.get("start"))
818 .and_then(|s| s.get("line"))
819 .and_then(|l| l.as_u64())
820 .unwrap_or(0) as u32;
821 let col_num = diag
822 .get("range")
823 .and_then(|r| r.get("start"))
824 .and_then(|s| s.get("character"))
825 .and_then(|c| c.as_u64())
826 .unwrap_or(0) as u32;
827 let message = diag
828 .get("message")
829 .and_then(|m| m.as_str())
830 .unwrap_or("unknown error")
831 .to_string();
832 let severity = diag
833 .get("severity")
834 .and_then(|s| s.as_str())
835 .unwrap_or("error")
836 .to_lowercase();
837
838 errors.push(ValidationError {
839 line: line_num + 1, column: col_num,
841 message,
842 severity,
843 });
844 }
845 }
846 }
847 errors
848}
849
850fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
852 let mut errors = Vec::new();
853 let file_str = file.to_string_lossy();
854
855 for line in stdout.lines() {
856 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
857 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
858 continue;
859 }
860 let message_obj = match msg.get("message") {
861 Some(m) => m,
862 None => continue,
863 };
864
865 let level = message_obj
866 .get("level")
867 .and_then(|l| l.as_str())
868 .unwrap_or("error");
869
870 if level != "error" && level != "warning" {
872 continue;
873 }
874
875 let text = message_obj
876 .get("message")
877 .and_then(|m| m.as_str())
878 .unwrap_or("unknown error")
879 .to_string();
880
881 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
883 for span in spans {
884 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
885 let is_primary = span
886 .get("is_primary")
887 .and_then(|p| p.as_bool())
888 .unwrap_or(false);
889
890 if !is_primary {
891 continue;
892 }
893
894 if !file_str.ends_with(span_file)
896 && !span_file.ends_with(&*file_str)
897 && span_file != &*file_str
898 {
899 continue;
900 }
901
902 let line_num =
903 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
904 let col_num = span
905 .get("column_start")
906 .and_then(|c| c.as_u64())
907 .unwrap_or(0) as u32;
908
909 errors.push(ValidationError {
910 line: line_num,
911 column: col_num,
912 message: text.clone(),
913 severity: level.to_string(),
914 });
915 }
916 }
917 }
918 }
919 errors
920}
921
922fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
924 let mut errors = Vec::new();
925 let file_str = file.to_string_lossy();
926
927 for line in stderr.lines() {
928 let parts: Vec<&str> = line.splitn(4, ':').collect();
930 if parts.len() < 3 {
931 continue;
932 }
933
934 let err_file = parts[0].trim();
935 if !file_str.ends_with(err_file)
936 && !err_file.ends_with(&*file_str)
937 && err_file != &*file_str
938 {
939 continue;
940 }
941
942 let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
943 let (col_num, message) = if parts.len() >= 4 {
944 if let Ok(col) = parts[2].trim().parse::<u32>() {
945 (col, parts[3].trim().to_string())
946 } else {
947 (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
949 }
950 } else {
951 (0, parts[2].trim().to_string())
952 };
953
954 errors.push(ValidationError {
955 line: line_num,
956 column: col_num,
957 message,
958 severity: "error".to_string(),
959 });
960 }
961 errors
962}
963
964pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
972 let lang = match detect_language(path) {
973 Some(l) => l,
974 None => {
975 eprintln!(
976 "[aft] validate: {} (skipped: unsupported_language)",
977 path.display()
978 );
979 return (Vec::new(), Some("unsupported_language".to_string()));
980 }
981 };
982
983 let (cmd, args) = match detect_type_checker(path, lang, config) {
984 Some(pair) => pair,
985 None => {
986 eprintln!("[aft] validate: {} (skipped: not_found)", path.display());
987 return (Vec::new(), Some("not_found".to_string()));
988 }
989 };
990
991 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
992
993 let working_dir = path.parent();
995
996 match run_external_tool_capture(
997 &cmd,
998 &arg_refs,
999 working_dir,
1000 config.type_checker_timeout_secs,
1001 ) {
1002 Ok(result) => {
1003 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1004 eprintln!(
1005 "[aft] validate: {} ({}, {} errors)",
1006 path.display(),
1007 cmd,
1008 errors.len()
1009 );
1010 (errors, None)
1011 }
1012 Err(FormatError::Timeout { .. }) => {
1013 eprintln!("[aft] validate: {} (skipped: timeout)", path.display());
1014 (Vec::new(), Some("timeout".to_string()))
1015 }
1016 Err(FormatError::NotFound { .. }) => {
1017 eprintln!("[aft] validate: {} (skipped: not_found)", path.display());
1018 (Vec::new(), Some("not_found".to_string()))
1019 }
1020 Err(FormatError::Failed { stderr, .. }) => {
1021 eprintln!(
1022 "[aft] validate: {} (skipped: error: {})",
1023 path.display(),
1024 stderr.lines().next().unwrap_or("unknown")
1025 );
1026 (Vec::new(), Some("error".to_string()))
1027 }
1028 Err(FormatError::UnsupportedLanguage) => {
1029 eprintln!(
1030 "[aft] validate: {} (skipped: unsupported_language)",
1031 path.display()
1032 );
1033 (Vec::new(), Some("unsupported_language".to_string()))
1034 }
1035 }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040 use super::*;
1041 use std::fs;
1042 use std::io::Write;
1043
1044 #[test]
1045 fn run_external_tool_not_found() {
1046 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1047 assert!(result.is_err());
1048 match result.unwrap_err() {
1049 FormatError::NotFound { tool } => {
1050 assert_eq!(tool, "__nonexistent_tool_xyz__");
1051 }
1052 other => panic!("expected NotFound, got: {:?}", other),
1053 }
1054 }
1055
1056 #[test]
1057 fn run_external_tool_timeout_kills_subprocess() {
1058 let result = run_external_tool("sleep", &["60"], None, 1);
1060 assert!(result.is_err());
1061 match result.unwrap_err() {
1062 FormatError::Timeout { tool, timeout_secs } => {
1063 assert_eq!(tool, "sleep");
1064 assert_eq!(timeout_secs, 1);
1065 }
1066 other => panic!("expected Timeout, got: {:?}", other),
1067 }
1068 }
1069
1070 #[test]
1071 fn run_external_tool_success() {
1072 let result = run_external_tool("echo", &["hello"], None, 5);
1073 assert!(result.is_ok());
1074 let res = result.unwrap();
1075 assert_eq!(res.exit_code, 0);
1076 assert!(res.stdout.contains("hello"));
1077 }
1078
1079 #[test]
1080 fn run_external_tool_nonzero_exit() {
1081 let result = run_external_tool("false", &[], None, 5);
1083 assert!(result.is_err());
1084 match result.unwrap_err() {
1085 FormatError::Failed { tool, .. } => {
1086 assert_eq!(tool, "false");
1087 }
1088 other => panic!("expected Failed, got: {:?}", other),
1089 }
1090 }
1091
1092 #[test]
1093 fn auto_format_unsupported_language() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let path = dir.path().join("file.txt");
1096 fs::write(&path, "hello").unwrap();
1097
1098 let config = Config::default();
1099 let (formatted, reason) = auto_format(&path, &config);
1100 assert!(!formatted);
1101 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1102 }
1103
1104 #[test]
1105 fn detect_formatter_rust_when_rustfmt_available() {
1106 let dir = tempfile::tempdir().unwrap();
1107 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1108 let path = dir.path().join("test.rs");
1109 let config = Config {
1110 project_root: Some(dir.path().to_path_buf()),
1111 ..Config::default()
1112 };
1113 let result = detect_formatter(&path, LangId::Rust, &config);
1114 if tool_available("rustfmt") {
1115 let (cmd, args) = result.unwrap();
1116 assert_eq!(cmd, "rustfmt");
1117 assert!(args.iter().any(|a| a.ends_with("test.rs")));
1118 } else {
1119 assert!(result.is_none());
1120 }
1121 }
1122
1123 #[test]
1124 fn detect_formatter_go_mapping() {
1125 let dir = tempfile::tempdir().unwrap();
1126 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1127 let path = dir.path().join("main.go");
1128 let config = Config {
1129 project_root: Some(dir.path().to_path_buf()),
1130 ..Config::default()
1131 };
1132 let result = detect_formatter(&path, LangId::Go, &config);
1133 if tool_available("goimports") {
1134 let (cmd, args) = result.unwrap();
1135 assert_eq!(cmd, "goimports");
1136 assert!(args.contains(&"-w".to_string()));
1137 } else if tool_available("gofmt") {
1138 let (cmd, args) = result.unwrap();
1139 assert_eq!(cmd, "gofmt");
1140 assert!(args.contains(&"-w".to_string()));
1141 } else {
1142 assert!(result.is_none());
1143 }
1144 }
1145
1146 #[test]
1147 fn detect_formatter_python_mapping() {
1148 let dir = tempfile::tempdir().unwrap();
1149 fs::write(dir.path().join("ruff.toml"), "").unwrap();
1150 let path = dir.path().join("main.py");
1151 let config = Config {
1152 project_root: Some(dir.path().to_path_buf()),
1153 ..Config::default()
1154 };
1155 let result = detect_formatter(&path, LangId::Python, &config);
1156 if ruff_format_available() {
1157 let (cmd, args) = result.unwrap();
1158 assert_eq!(cmd, "ruff");
1159 assert!(args.contains(&"format".to_string()));
1160 } else {
1161 assert!(result.is_none());
1162 }
1163 }
1164
1165 #[test]
1166 fn detect_formatter_no_config_returns_none() {
1167 let path = Path::new("test.ts");
1168 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1169 assert!(
1170 result.is_none(),
1171 "expected no formatter without project config"
1172 );
1173 }
1174
1175 #[test]
1176 fn detect_formatter_explicit_override() {
1177 let path = Path::new("test.ts");
1178 let mut config = Config::default();
1179 config
1180 .formatter
1181 .insert("typescript".to_string(), "biome".to_string());
1182 let result = detect_formatter(path, LangId::TypeScript, &config);
1183 let (cmd, _) = result.unwrap();
1184 assert_eq!(cmd, "biome");
1185 }
1186
1187 #[test]
1188 fn auto_format_happy_path_rustfmt() {
1189 if !tool_available("rustfmt") {
1190 eprintln!("skipping: rustfmt not available");
1191 return;
1192 }
1193
1194 let dir = tempfile::tempdir().unwrap();
1195 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1196 let path = dir.path().join("test.rs");
1197
1198 let mut f = fs::File::create(&path).unwrap();
1199 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
1200 drop(f);
1201
1202 let config = Config {
1203 project_root: Some(dir.path().to_path_buf()),
1204 ..Config::default()
1205 };
1206 let (formatted, reason) = auto_format(&path, &config);
1207 assert!(formatted, "expected formatting to succeed");
1208 assert!(reason.is_none());
1209
1210 let content = fs::read_to_string(&path).unwrap();
1211 assert!(
1212 !content.contains("fn main"),
1213 "expected rustfmt to fix spacing"
1214 );
1215 }
1216
1217 #[test]
1218 fn parse_tsc_output_basic() {
1219 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";
1220 let file = Path::new("src/app.ts");
1221 let errors = parse_tsc_output(stdout, "", file);
1222 assert_eq!(errors.len(), 2);
1223 assert_eq!(errors[0].line, 10);
1224 assert_eq!(errors[0].column, 5);
1225 assert_eq!(errors[0].severity, "error");
1226 assert!(errors[0].message.contains("TS2322"));
1227 assert_eq!(errors[1].line, 20);
1228 }
1229
1230 #[test]
1231 fn parse_tsc_output_filters_other_files() {
1232 let stdout =
1233 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1234 let file = Path::new("src/app.ts");
1235 let errors = parse_tsc_output(stdout, "", file);
1236 assert_eq!(errors.len(), 1);
1237 assert_eq!(errors[0].line, 5);
1238 }
1239
1240 #[test]
1241 fn parse_cargo_output_basic() {
1242 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}]}}"#;
1243 let file = Path::new("src/main.rs");
1244 let errors = parse_cargo_output(json_line, "", file);
1245 assert_eq!(errors.len(), 1);
1246 assert_eq!(errors[0].line, 10);
1247 assert_eq!(errors[0].column, 5);
1248 assert_eq!(errors[0].severity, "error");
1249 assert!(errors[0].message.contains("mismatched types"));
1250 }
1251
1252 #[test]
1253 fn parse_cargo_output_skips_notes() {
1254 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}]}}"#;
1256 let file = Path::new("src/main.rs");
1257 let errors = parse_cargo_output(json_line, "", file);
1258 assert_eq!(errors.len(), 0);
1259 }
1260
1261 #[test]
1262 fn parse_cargo_output_filters_other_files() {
1263 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}]}}"#;
1264 let file = Path::new("src/main.rs");
1265 let errors = parse_cargo_output(json_line, "", file);
1266 assert_eq!(errors.len(), 0);
1267 }
1268
1269 #[test]
1270 fn parse_go_vet_output_basic() {
1271 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1272 let file = Path::new("main.go");
1273 let errors = parse_go_vet_output(stderr, file);
1274 assert_eq!(errors.len(), 2);
1275 assert_eq!(errors[0].line, 10);
1276 assert_eq!(errors[0].column, 5);
1277 assert!(errors[0].message.contains("unreachable code"));
1278 assert_eq!(errors[1].line, 20);
1279 assert_eq!(errors[1].column, 0);
1280 }
1281
1282 #[test]
1283 fn parse_pyright_output_basic() {
1284 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1285 let file = Path::new("test.py");
1286 let errors = parse_pyright_output(stdout, file);
1287 assert_eq!(errors.len(), 1);
1288 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 10);
1290 assert_eq!(errors[0].severity, "error");
1291 assert!(errors[0].message.contains("Type error here"));
1292 }
1293
1294 #[test]
1295 fn validate_full_unsupported_language() {
1296 let dir = tempfile::tempdir().unwrap();
1297 let path = dir.path().join("file.txt");
1298 fs::write(&path, "hello").unwrap();
1299
1300 let config = Config::default();
1301 let (errors, reason) = validate_full(&path, &config);
1302 assert!(errors.is_empty());
1303 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1304 }
1305
1306 #[test]
1307 fn detect_type_checker_rust() {
1308 let dir = tempfile::tempdir().unwrap();
1309 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1310 let path = dir.path().join("src/main.rs");
1311 let config = Config {
1312 project_root: Some(dir.path().to_path_buf()),
1313 ..Config::default()
1314 };
1315 let result = detect_type_checker(&path, LangId::Rust, &config);
1316 if tool_available("cargo") {
1317 let (cmd, args) = result.unwrap();
1318 assert_eq!(cmd, "cargo");
1319 assert!(args.contains(&"check".to_string()));
1320 } else {
1321 assert!(result.is_none());
1322 }
1323 }
1324
1325 #[test]
1326 fn detect_type_checker_go() {
1327 let dir = tempfile::tempdir().unwrap();
1328 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1329 let path = dir.path().join("main.go");
1330 let config = Config {
1331 project_root: Some(dir.path().to_path_buf()),
1332 ..Config::default()
1333 };
1334 let result = detect_type_checker(&path, LangId::Go, &config);
1335 if tool_available("go") {
1336 let (cmd, _args) = result.unwrap();
1337 assert!(cmd == "go" || cmd == "staticcheck");
1339 } else {
1340 assert!(result.is_none());
1341 }
1342 }
1343 #[test]
1344 fn run_external_tool_capture_nonzero_not_error() {
1345 let result = run_external_tool_capture("false", &[], None, 5);
1347 assert!(result.is_ok(), "capture should not error on non-zero exit");
1348 assert_eq!(result.unwrap().exit_code, 1);
1349 }
1350
1351 #[test]
1352 fn run_external_tool_capture_not_found() {
1353 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
1354 assert!(result.is_err());
1355 match result.unwrap_err() {
1356 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
1357 other => panic!("expected NotFound, got: {:?}", other),
1358 }
1359 }
1360}