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