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