1use std::collections::{HashMap, HashSet};
7use std::io::ErrorKind;
8use std::path::{Path, PathBuf};
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
38#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
40pub struct MissingTool {
41 pub kind: String,
42 pub language: String,
43 pub tool: String,
44 pub hint: String,
45}
46
47#[derive(Debug, Clone)]
48struct ToolCandidate {
49 tool: String,
50 source: String,
51 args: Vec<String>,
52 required: bool,
53}
54
55#[derive(Debug, Clone)]
56enum ToolDetection {
57 Found(String, Vec<String>),
58 NotConfigured,
59 NotInstalled { tool: String },
60}
61
62impl std::fmt::Display for FormatError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
66 FormatError::Timeout { tool, timeout_secs } => {
67 write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
68 }
69 FormatError::Failed { tool, stderr } => {
70 write!(f, "formatter '{}' failed: {}", tool, stderr)
71 }
72 FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
73 }
74 }
75}
76
77pub fn run_external_tool(
83 command: &str,
84 args: &[&str],
85 working_dir: Option<&Path>,
86 timeout_secs: u32,
87) -> Result<ExternalToolResult, FormatError> {
88 let mut cmd = Command::new(command);
89 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
90
91 if let Some(dir) = working_dir {
92 cmd.current_dir(dir);
93 }
94
95 let mut child = match cmd.spawn() {
96 Ok(c) => c,
97 Err(e) if e.kind() == ErrorKind::NotFound => {
98 return Err(FormatError::NotFound {
99 tool: command.to_string(),
100 });
101 }
102 Err(e) => {
103 return Err(FormatError::Failed {
104 tool: command.to_string(),
105 stderr: e.to_string(),
106 });
107 }
108 };
109
110 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
111
112 loop {
113 match child.try_wait() {
114 Ok(Some(status)) => {
115 let stdout = child
116 .stdout
117 .take()
118 .map(|s| std::io::read_to_string(s).unwrap_or_default())
119 .unwrap_or_default();
120 let stderr = child
121 .stderr
122 .take()
123 .map(|s| std::io::read_to_string(s).unwrap_or_default())
124 .unwrap_or_default();
125
126 let exit_code = status.code().unwrap_or(-1);
127 if exit_code != 0 {
128 return Err(FormatError::Failed {
129 tool: command.to_string(),
130 stderr,
131 });
132 }
133
134 return Ok(ExternalToolResult {
135 stdout,
136 stderr,
137 exit_code,
138 });
139 }
140 Ok(None) => {
141 if Instant::now() >= deadline {
143 let _ = child.kill();
145 let _ = child.wait();
146 return Err(FormatError::Timeout {
147 tool: command.to_string(),
148 timeout_secs,
149 });
150 }
151 thread::sleep(Duration::from_millis(50));
152 }
153 Err(e) => {
154 return Err(FormatError::Failed {
155 tool: command.to_string(),
156 stderr: format!("try_wait error: {}", e),
157 });
158 }
159 }
160 }
161}
162
163const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
165
166static TOOL_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
167 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
168
169fn tool_cache_key(command: &str, project_root: Option<&Path>) -> String {
170 let root = project_root
171 .map(|path| path.to_string_lossy())
172 .unwrap_or_default();
173 format!("{}\0{}", command, root)
174}
175
176pub fn clear_tool_cache() {
177 if let Ok(mut cache) = TOOL_CACHE.lock() {
178 cache.clear();
179 }
180}
181
182fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
185 if let Some(root) = project_root {
187 let local_bin = root.join("node_modules").join(".bin").join(command);
188 if local_bin.exists() {
189 return Some(local_bin.to_string_lossy().to_string());
190 }
191 }
192
193 match Command::new(command)
195 .arg("--version")
196 .stdin(Stdio::null())
197 .stdout(Stdio::null())
198 .stderr(Stdio::null())
199 .spawn()
200 {
201 Ok(mut child) => {
202 let start = Instant::now();
203 let timeout = Duration::from_secs(2);
204 loop {
205 match child.try_wait() {
206 Ok(Some(status)) => {
207 return if status.success() {
208 Some(command.to_string())
209 } else {
210 None
211 };
212 }
213 Ok(None) if start.elapsed() > timeout => {
214 let _ = child.kill();
215 let _ = child.wait();
216 return None;
217 }
218 Ok(None) => thread::sleep(Duration::from_millis(50)),
219 Err(_) => return None,
220 }
221 }
222 }
223 Err(_) => None,
224 }
225}
226
227fn ruff_format_available(project_root: Option<&Path>) -> bool {
234 let key = tool_cache_key("ruff-format", project_root);
235 if let Ok(cache) = TOOL_CACHE.lock() {
236 if let Some((available, checked_at)) = cache.get(&key) {
237 if checked_at.elapsed() < TOOL_CACHE_TTL {
238 return *available;
239 }
240 }
241 }
242
243 let result = ruff_format_available_uncached(project_root);
244 if let Ok(mut cache) = TOOL_CACHE.lock() {
245 cache.insert(key, (result, Instant::now()));
246 }
247 result
248}
249
250fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
251 let command = match resolve_tool("ruff", project_root) {
252 Some(command) => command,
253 None => return false,
254 };
255 let output = match Command::new(&command)
256 .arg("--version")
257 .stdout(Stdio::piped())
258 .stderr(Stdio::null())
259 .output()
260 {
261 Ok(o) => o,
262 Err(_) => return false,
263 };
264
265 let version_str = String::from_utf8_lossy(&output.stdout);
266 let version_part = version_str
268 .trim()
269 .strip_prefix("ruff ")
270 .unwrap_or(version_str.trim());
271
272 let parts: Vec<&str> = version_part.split('.').collect();
273 if parts.len() < 3 {
274 return false;
275 }
276
277 let major: u32 = match parts[0].parse() {
278 Ok(v) => v,
279 Err(_) => return false,
280 };
281 let minor: u32 = match parts[1].parse() {
282 Ok(v) => v,
283 Err(_) => return false,
284 };
285 let patch: u32 = match parts[2].parse() {
286 Ok(v) => v,
287 Err(_) => return false,
288 };
289
290 (major, minor, patch) >= (0, 1, 2)
292}
293
294fn resolve_candidate_tool(
295 candidate: &ToolCandidate,
296 project_root: Option<&Path>,
297) -> Option<String> {
298 if candidate.tool == "ruff" && !ruff_format_available(project_root) {
299 return None;
300 }
301
302 resolve_tool(&candidate.tool, project_root)
303}
304
305fn lang_key(lang: LangId) -> &'static str {
306 match lang {
307 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
308 LangId::Python => "python",
309 LangId::Rust => "rust",
310 LangId::Go => "go",
311 LangId::C => "c",
312 LangId::Cpp => "cpp",
313 LangId::Zig => "zig",
314 LangId::CSharp => "csharp",
315 LangId::Bash => "bash",
316 LangId::Html => "html",
317 LangId::Markdown => "markdown",
318 }
319}
320
321fn has_formatter_support(lang: LangId) -> bool {
322 matches!(
323 lang,
324 LangId::TypeScript
325 | LangId::JavaScript
326 | LangId::Tsx
327 | LangId::Python
328 | LangId::Rust
329 | LangId::Go
330 )
331}
332
333fn has_checker_support(lang: LangId) -> bool {
334 matches!(
335 lang,
336 LangId::TypeScript
337 | LangId::JavaScript
338 | LangId::Tsx
339 | LangId::Python
340 | LangId::Rust
341 | LangId::Go
342 )
343}
344
345fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
346 let project_root = config.project_root.as_deref();
347 if let Some(preferred) = config.formatter.get(lang_key(lang)) {
348 return explicit_formatter_candidate(preferred, file_str);
349 }
350
351 match lang {
352 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
353 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
354 vec![ToolCandidate {
355 tool: "biome".to_string(),
356 source: "biome.json".to_string(),
357 args: vec![
358 "format".to_string(),
359 "--write".to_string(),
360 file_str.to_string(),
361 ],
362 required: true,
363 }]
364 } else if has_project_config(
365 project_root,
366 &[
367 ".prettierrc",
368 ".prettierrc.json",
369 ".prettierrc.yml",
370 ".prettierrc.yaml",
371 ".prettierrc.js",
372 ".prettierrc.cjs",
373 ".prettierrc.mjs",
374 ".prettierrc.toml",
375 "prettier.config.js",
376 "prettier.config.cjs",
377 "prettier.config.mjs",
378 ],
379 ) {
380 vec![ToolCandidate {
381 tool: "prettier".to_string(),
382 source: "Prettier config".to_string(),
383 args: vec!["--write".to_string(), file_str.to_string()],
384 required: true,
385 }]
386 } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
387 vec![ToolCandidate {
388 tool: "deno".to_string(),
389 source: "deno.json".to_string(),
390 args: vec!["fmt".to_string(), file_str.to_string()],
391 required: true,
392 }]
393 } else {
394 Vec::new()
395 }
396 }
397 LangId::Python => {
398 if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
399 || has_pyproject_tool(project_root, "ruff")
400 {
401 vec![ToolCandidate {
402 tool: "ruff".to_string(),
403 source: "ruff config".to_string(),
404 args: vec!["format".to_string(), file_str.to_string()],
405 required: true,
406 }]
407 } else if has_pyproject_tool(project_root, "black") {
408 vec![ToolCandidate {
409 tool: "black".to_string(),
410 source: "pyproject.toml".to_string(),
411 args: vec![file_str.to_string()],
412 required: true,
413 }]
414 } else {
415 Vec::new()
416 }
417 }
418 LangId::Rust => {
419 if has_project_config(project_root, &["Cargo.toml"]) {
420 vec![ToolCandidate {
421 tool: "rustfmt".to_string(),
422 source: "Cargo.toml".to_string(),
423 args: vec![file_str.to_string()],
424 required: true,
425 }]
426 } else {
427 Vec::new()
428 }
429 }
430 LangId::Go => {
431 if has_project_config(project_root, &["go.mod"]) {
432 vec![
433 ToolCandidate {
434 tool: "goimports".to_string(),
435 source: "go.mod".to_string(),
436 args: vec!["-w".to_string(), file_str.to_string()],
437 required: false,
438 },
439 ToolCandidate {
440 tool: "gofmt".to_string(),
441 source: "go.mod".to_string(),
442 args: vec!["-w".to_string(), file_str.to_string()],
443 required: true,
444 },
445 ]
446 } else {
447 Vec::new()
448 }
449 }
450 LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp | LangId::Bash => Vec::new(),
451 LangId::Html => Vec::new(),
452 LangId::Markdown => Vec::new(),
453 }
454}
455
456fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
457 let project_root = config.project_root.as_deref();
458 if let Some(preferred) = config.checker.get(lang_key(lang)) {
459 return explicit_checker_candidate(preferred, file_str);
460 }
461
462 match lang {
463 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
464 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
465 vec![ToolCandidate {
466 tool: "biome".to_string(),
467 source: "biome.json".to_string(),
468 args: vec!["check".to_string(), file_str.to_string()],
469 required: true,
470 }]
471 } else if has_project_config(project_root, &["tsconfig.json"]) {
472 vec![ToolCandidate {
473 tool: "tsc".to_string(),
474 source: "tsconfig.json".to_string(),
475 args: vec![
476 "--noEmit".to_string(),
477 "--pretty".to_string(),
478 "false".to_string(),
479 ],
480 required: true,
481 }]
482 } else {
483 Vec::new()
484 }
485 }
486 LangId::Python => {
487 if has_project_config(project_root, &["pyrightconfig.json"])
488 || has_pyproject_tool(project_root, "pyright")
489 {
490 vec![ToolCandidate {
491 tool: "pyright".to_string(),
492 source: "pyright config".to_string(),
493 args: vec!["--outputjson".to_string(), file_str.to_string()],
494 required: true,
495 }]
496 } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
497 || has_pyproject_tool(project_root, "ruff")
498 {
499 vec![ToolCandidate {
500 tool: "ruff".to_string(),
501 source: "ruff config".to_string(),
502 args: vec![
503 "check".to_string(),
504 "--output-format=json".to_string(),
505 file_str.to_string(),
506 ],
507 required: true,
508 }]
509 } else {
510 Vec::new()
511 }
512 }
513 LangId::Rust => {
514 if has_project_config(project_root, &["Cargo.toml"]) {
515 vec![ToolCandidate {
516 tool: "cargo".to_string(),
517 source: "Cargo.toml".to_string(),
518 args: vec!["check".to_string(), "--message-format=json".to_string()],
519 required: true,
520 }]
521 } else {
522 Vec::new()
523 }
524 }
525 LangId::Go => {
526 if has_project_config(project_root, &["go.mod"]) {
527 vec![
528 ToolCandidate {
529 tool: "staticcheck".to_string(),
530 source: "go.mod".to_string(),
531 args: vec![file_str.to_string()],
532 required: false,
533 },
534 ToolCandidate {
535 tool: "go".to_string(),
536 source: "go.mod".to_string(),
537 args: vec!["vet".to_string(), file_str.to_string()],
538 required: true,
539 },
540 ]
541 } else {
542 Vec::new()
543 }
544 }
545 LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp | LangId::Bash => Vec::new(),
546 LangId::Html => Vec::new(),
547 LangId::Markdown => Vec::new(),
548 }
549}
550
551fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
552 match name {
553 "none" | "off" | "false" => Vec::new(),
554 "biome" => vec![ToolCandidate {
555 tool: name.to_string(),
556 source: "formatter config".to_string(),
557 args: vec![
558 "format".to_string(),
559 "--write".to_string(),
560 file_str.to_string(),
561 ],
562 required: true,
563 }],
564 "prettier" => vec![ToolCandidate {
565 tool: name.to_string(),
566 source: "formatter config".to_string(),
567 args: vec!["--write".to_string(), file_str.to_string()],
568 required: true,
569 }],
570 "deno" => vec![ToolCandidate {
571 tool: name.to_string(),
572 source: "formatter config".to_string(),
573 args: vec!["fmt".to_string(), file_str.to_string()],
574 required: true,
575 }],
576 "ruff" => vec![ToolCandidate {
577 tool: name.to_string(),
578 source: "formatter config".to_string(),
579 args: vec!["format".to_string(), file_str.to_string()],
580 required: true,
581 }],
582 "black" | "rustfmt" => vec![ToolCandidate {
583 tool: name.to_string(),
584 source: "formatter config".to_string(),
585 args: vec![file_str.to_string()],
586 required: true,
587 }],
588 "goimports" | "gofmt" => vec![ToolCandidate {
589 tool: name.to_string(),
590 source: "formatter config".to_string(),
591 args: vec!["-w".to_string(), file_str.to_string()],
592 required: true,
593 }],
594 _ => Vec::new(),
595 }
596}
597
598fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
599 match name {
600 "none" | "off" | "false" => Vec::new(),
601 "tsc" => vec![ToolCandidate {
602 tool: name.to_string(),
603 source: "checker config".to_string(),
604 args: vec![
605 "--noEmit".to_string(),
606 "--pretty".to_string(),
607 "false".to_string(),
608 ],
609 required: true,
610 }],
611 "cargo" => vec![ToolCandidate {
612 tool: name.to_string(),
613 source: "checker config".to_string(),
614 args: vec!["check".to_string(), "--message-format=json".to_string()],
615 required: true,
616 }],
617 "go" => vec![ToolCandidate {
618 tool: name.to_string(),
619 source: "checker config".to_string(),
620 args: vec!["vet".to_string(), file_str.to_string()],
621 required: true,
622 }],
623 "biome" => vec![ToolCandidate {
624 tool: name.to_string(),
625 source: "checker config".to_string(),
626 args: vec!["check".to_string(), file_str.to_string()],
627 required: true,
628 }],
629 "pyright" => vec![ToolCandidate {
630 tool: name.to_string(),
631 source: "checker config".to_string(),
632 args: vec!["--outputjson".to_string(), file_str.to_string()],
633 required: true,
634 }],
635 "ruff" => vec![ToolCandidate {
636 tool: name.to_string(),
637 source: "checker config".to_string(),
638 args: vec![
639 "check".to_string(),
640 "--output-format=json".to_string(),
641 file_str.to_string(),
642 ],
643 required: true,
644 }],
645 "staticcheck" => vec![ToolCandidate {
646 tool: name.to_string(),
647 source: "checker config".to_string(),
648 args: vec![file_str.to_string()],
649 required: true,
650 }],
651 _ => Vec::new(),
652 }
653}
654
655fn resolve_tool_candidates(
656 candidates: Vec<ToolCandidate>,
657 project_root: Option<&Path>,
658) -> ToolDetection {
659 if candidates.is_empty() {
660 return ToolDetection::NotConfigured;
661 }
662
663 let mut missing_required = None;
664 for candidate in candidates {
665 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
666 return ToolDetection::Found(command, candidate.args);
667 }
668 if candidate.required && missing_required.is_none() {
669 missing_required = Some(candidate.tool);
670 }
671 }
672
673 match missing_required {
674 Some(tool) => ToolDetection::NotInstalled { tool },
675 None => ToolDetection::NotConfigured,
676 }
677}
678
679fn checker_command(candidate: &ToolCandidate, resolved: String) -> String {
680 match candidate.tool.as_str() {
681 "tsc" => resolved,
682 "cargo" => "cargo".to_string(),
683 "go" => "go".to_string(),
684 _ => resolved,
685 }
686}
687
688fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
689 if candidate.tool == "tsc" {
690 vec![
691 "--noEmit".to_string(),
692 "--pretty".to_string(),
693 "false".to_string(),
694 ]
695 } else {
696 candidate.args.clone()
697 }
698}
699
700fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
701 let file_str = path.to_string_lossy().to_string();
702 resolve_tool_candidates(
703 formatter_candidates(lang, config, &file_str),
704 config.project_root.as_deref(),
705 )
706}
707
708fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
709 let file_str = path.to_string_lossy().to_string();
710 let candidates = checker_candidates(lang, config, &file_str);
711 if candidates.is_empty() {
712 return ToolDetection::NotConfigured;
713 }
714
715 let project_root = config.project_root.as_deref();
716 let mut missing_required = None;
717 for candidate in candidates {
718 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
719 return ToolDetection::Found(
720 checker_command(&candidate, command),
721 checker_args(&candidate),
722 );
723 }
724 if candidate.required && missing_required.is_none() {
725 missing_required = Some(candidate.tool);
726 }
727 }
728
729 match missing_required {
730 Some(tool) => ToolDetection::NotInstalled { tool },
731 None => ToolDetection::NotConfigured,
732 }
733}
734
735fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
736 crate::callgraph::walk_project_files(project_root)
737 .filter_map(|path| detect_language(&path))
738 .collect()
739}
740
741fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
742 let filename = match lang {
743 LangId::TypeScript => "aft-tool-detection.ts",
744 LangId::Tsx => "aft-tool-detection.tsx",
745 LangId::JavaScript => "aft-tool-detection.js",
746 LangId::Python => "aft-tool-detection.py",
747 LangId::Rust => "aft_tool_detection.rs",
748 LangId::Go => "aft_tool_detection.go",
749 LangId::C => "aft_tool_detection.c",
750 LangId::Cpp => "aft_tool_detection.cpp",
751 LangId::Zig => "aft_tool_detection.zig",
752 LangId::CSharp => "aft_tool_detection.cs",
753 LangId::Bash => "aft_tool_detection.sh",
754 LangId::Html => "aft-tool-detection.html",
755 LangId::Markdown => "aft-tool-detection.md",
756 };
757 project_root.join(filename)
758}
759
760pub(crate) fn install_hint(tool: &str) -> String {
761 match tool {
762 "biome" => {
763 "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
764 }
765 "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
766 "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
767 "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
768 "ruff" => {
769 "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
770 }
771 "black" => {
772 "Install: `pip install black` or your Python package manager equivalent.".to_string()
773 }
774 "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
775 "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
776 "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
777 "go" => "Install Go from https://go.dev/dl/.".to_string(),
778 "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
779 "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
780 "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
781 "typescript-language-server" => {
782 "Install: `npm install -g typescript-language-server typescript`".to_string()
783 }
784 "deno" => "Install Deno from https://deno.com/.".to_string(),
785 "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
786 "staticcheck" => {
787 "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
788 }
789 other => format!("Install `{other}` and ensure it is on PATH."),
790 }
791}
792
793fn configured_tool_hint(tool: &str, source: &str) -> String {
794 format!(
795 "{tool} is configured in {source} but not installed. {}",
796 install_hint(tool)
797 )
798}
799
800fn missing_tool_warning(
801 kind: &str,
802 language: &str,
803 candidate: &ToolCandidate,
804 project_root: Option<&Path>,
805) -> Option<MissingTool> {
806 if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
807 return None;
808 }
809
810 Some(MissingTool {
811 kind: kind.to_string(),
812 language: language.to_string(),
813 tool: candidate.tool.clone(),
814 hint: configured_tool_hint(&candidate.tool, &candidate.source),
815 })
816}
817
818pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
820 let languages = languages_in_project(project_root);
821 let mut warnings = Vec::new();
822 let mut seen = HashSet::new();
823
824 for lang in languages {
825 let language = lang_key(lang);
826 let placeholder = placeholder_file_for_language(project_root, lang);
827 let file_str = placeholder.to_string_lossy().to_string();
828
829 for candidate in formatter_candidates(lang, config, &file_str) {
830 if let Some(warning) = missing_tool_warning(
831 "formatter_not_installed",
832 language,
833 &candidate,
834 config.project_root.as_deref(),
835 ) {
836 if seen.insert((
837 warning.kind.clone(),
838 warning.language.clone(),
839 warning.tool.clone(),
840 )) {
841 warnings.push(warning);
842 }
843 }
844 }
845
846 for candidate in checker_candidates(lang, config, &file_str) {
847 if let Some(warning) = missing_tool_warning(
848 "checker_not_installed",
849 language,
850 &candidate,
851 config.project_root.as_deref(),
852 ) {
853 if seen.insert((
854 warning.kind.clone(),
855 warning.language.clone(),
856 warning.tool.clone(),
857 )) {
858 warnings.push(warning);
859 }
860 }
861 }
862 }
863
864 warnings.sort_by(|left, right| {
865 (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
866 });
867 warnings
868}
869
870pub fn detect_formatter(
880 path: &Path,
881 lang: LangId,
882 config: &Config,
883) -> Option<(String, Vec<String>)> {
884 match detect_formatter_for_path(path, lang, config) {
885 ToolDetection::Found(cmd, args) => Some((cmd, args)),
886 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
887 }
888}
889
890fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
892 let root = match project_root {
893 Some(r) => r,
894 None => return false,
895 };
896 filenames.iter().any(|f| root.join(f).exists())
897}
898
899fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
901 let root = match project_root {
902 Some(r) => r,
903 None => return false,
904 };
905 let pyproject = root.join("pyproject.toml");
906 if !pyproject.exists() {
907 return false;
908 }
909 match std::fs::read_to_string(&pyproject) {
910 Ok(content) => {
911 let pattern = format!("[tool.{}]", tool_name);
912 content.contains(&pattern)
913 }
914 Err(_) => false,
915 }
916}
917
918pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
927 if !config.format_on_edit {
929 return (false, Some("no_formatter_configured".to_string()));
930 }
931
932 let lang = match detect_language(path) {
933 Some(l) => l,
934 None => {
935 log::debug!(
936 "[aft] format: {} (skipped: unsupported_language)",
937 path.display()
938 );
939 return (false, Some("unsupported_language".to_string()));
940 }
941 };
942 if !has_formatter_support(lang) {
943 log::debug!(
944 "[aft] format: {} (skipped: unsupported_language)",
945 path.display()
946 );
947 return (false, Some("unsupported_language".to_string()));
948 }
949
950 let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
951 ToolDetection::Found(cmd, args) => (cmd, args),
952 ToolDetection::NotConfigured => {
953 log::debug!(
954 "[aft] format: {} (skipped: no_formatter_configured)",
955 path.display()
956 );
957 return (false, Some("no_formatter_configured".to_string()));
958 }
959 ToolDetection::NotInstalled { tool } => {
960 log::warn!(
961 "format: {} (skipped: formatter_not_installed: {})",
962 path.display(),
963 tool
964 );
965 return (false, Some("formatter_not_installed".to_string()));
966 }
967 };
968
969 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
970
971 let working_dir = config.project_root.as_deref();
978
979 match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
980 Ok(_) => {
981 log::info!("format: {} ({})", path.display(), cmd);
982 (true, None)
983 }
984 Err(FormatError::Timeout { .. }) => {
985 log::warn!("format: {} (skipped: timeout)", path.display());
986 (false, Some("timeout".to_string()))
987 }
988 Err(FormatError::NotFound { .. }) => {
989 log::warn!(
990 "format: {} (skipped: formatter_not_installed)",
991 path.display()
992 );
993 (false, Some("formatter_not_installed".to_string()))
994 }
995 Err(FormatError::Failed { stderr, .. }) => {
996 log::debug!(
997 "[aft] format: {} (skipped: error: {})",
998 path.display(),
999 stderr.lines().next().unwrap_or("unknown")
1000 );
1001 (false, Some("error".to_string()))
1002 }
1003 Err(FormatError::UnsupportedLanguage) => {
1004 log::debug!(
1005 "[aft] format: {} (skipped: unsupported_language)",
1006 path.display()
1007 );
1008 (false, Some("unsupported_language".to_string()))
1009 }
1010 }
1011}
1012
1013pub fn run_external_tool_capture(
1020 command: &str,
1021 args: &[&str],
1022 working_dir: Option<&Path>,
1023 timeout_secs: u32,
1024) -> Result<ExternalToolResult, FormatError> {
1025 let mut cmd = Command::new(command);
1026 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1027
1028 if let Some(dir) = working_dir {
1029 cmd.current_dir(dir);
1030 }
1031
1032 let mut child = match cmd.spawn() {
1033 Ok(c) => c,
1034 Err(e) if e.kind() == ErrorKind::NotFound => {
1035 return Err(FormatError::NotFound {
1036 tool: command.to_string(),
1037 });
1038 }
1039 Err(e) => {
1040 return Err(FormatError::Failed {
1041 tool: command.to_string(),
1042 stderr: e.to_string(),
1043 });
1044 }
1045 };
1046
1047 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
1048
1049 loop {
1050 match child.try_wait() {
1051 Ok(Some(status)) => {
1052 let stdout = child
1053 .stdout
1054 .take()
1055 .map(|s| std::io::read_to_string(s).unwrap_or_default())
1056 .unwrap_or_default();
1057 let stderr = child
1058 .stderr
1059 .take()
1060 .map(|s| std::io::read_to_string(s).unwrap_or_default())
1061 .unwrap_or_default();
1062
1063 return Ok(ExternalToolResult {
1064 stdout,
1065 stderr,
1066 exit_code: status.code().unwrap_or(-1),
1067 });
1068 }
1069 Ok(None) => {
1070 if Instant::now() >= deadline {
1071 let _ = child.kill();
1072 let _ = child.wait();
1073 return Err(FormatError::Timeout {
1074 tool: command.to_string(),
1075 timeout_secs,
1076 });
1077 }
1078 thread::sleep(Duration::from_millis(50));
1079 }
1080 Err(e) => {
1081 return Err(FormatError::Failed {
1082 tool: command.to_string(),
1083 stderr: format!("try_wait error: {}", e),
1084 });
1085 }
1086 }
1087 }
1088}
1089
1090#[derive(Debug, Clone, serde::Serialize)]
1096pub struct ValidationError {
1097 pub line: u32,
1098 pub column: u32,
1099 pub message: String,
1100 pub severity: String,
1101}
1102
1103pub fn detect_type_checker(
1114 path: &Path,
1115 lang: LangId,
1116 config: &Config,
1117) -> Option<(String, Vec<String>)> {
1118 match detect_checker_for_path(path, lang, config) {
1119 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1120 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1121 }
1122}
1123
1124pub fn parse_checker_output(
1129 stdout: &str,
1130 stderr: &str,
1131 file: &Path,
1132 checker: &str,
1133) -> Vec<ValidationError> {
1134 let checker_name = Path::new(checker)
1135 .file_name()
1136 .and_then(|name| name.to_str())
1137 .unwrap_or(checker);
1138 match checker_name {
1139 "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
1140 "pyright" => parse_pyright_output(stdout, file),
1141 "cargo" => parse_cargo_output(stdout, stderr, file),
1142 "go" => parse_go_vet_output(stderr, file),
1143 _ => Vec::new(),
1144 }
1145}
1146
1147fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1149 let mut errors = Vec::new();
1150 let file_str = file.to_string_lossy();
1151 let combined = format!("{}{}", stdout, stderr);
1153 for line in combined.lines() {
1154 if let Some((loc, rest)) = line.split_once("): ") {
1157 let file_part = loc.split('(').next().unwrap_or("");
1159 if !file_str.ends_with(file_part)
1160 && !file_part.ends_with(&*file_str)
1161 && file_part != &*file_str
1162 {
1163 continue;
1164 }
1165
1166 let coords = loc.split('(').last().unwrap_or("");
1168 let parts: Vec<&str> = coords.split(',').collect();
1169 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1170 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1171
1172 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1174 ("error".to_string(), msg.to_string())
1175 } else if let Some(msg) = rest.strip_prefix("warning ") {
1176 ("warning".to_string(), msg.to_string())
1177 } else {
1178 ("error".to_string(), rest.to_string())
1179 };
1180
1181 errors.push(ValidationError {
1182 line: line_num,
1183 column: col_num,
1184 message,
1185 severity,
1186 });
1187 }
1188 }
1189 errors
1190}
1191
1192fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1194 let mut errors = Vec::new();
1195 let file_str = file.to_string_lossy();
1196
1197 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1199 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1200 for diag in diags {
1201 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1203 if !diag_file.is_empty()
1204 && !file_str.ends_with(diag_file)
1205 && !diag_file.ends_with(&*file_str)
1206 && diag_file != &*file_str
1207 {
1208 continue;
1209 }
1210
1211 let line_num = diag
1212 .get("range")
1213 .and_then(|r| r.get("start"))
1214 .and_then(|s| s.get("line"))
1215 .and_then(|l| l.as_u64())
1216 .unwrap_or(0) as u32;
1217 let col_num = diag
1218 .get("range")
1219 .and_then(|r| r.get("start"))
1220 .and_then(|s| s.get("character"))
1221 .and_then(|c| c.as_u64())
1222 .unwrap_or(0) as u32;
1223 let message = diag
1224 .get("message")
1225 .and_then(|m| m.as_str())
1226 .unwrap_or("unknown error")
1227 .to_string();
1228 let severity = diag
1229 .get("severity")
1230 .and_then(|s| s.as_str())
1231 .unwrap_or("error")
1232 .to_lowercase();
1233
1234 errors.push(ValidationError {
1235 line: line_num + 1, column: col_num,
1237 message,
1238 severity,
1239 });
1240 }
1241 }
1242 }
1243 errors
1244}
1245
1246fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1248 let mut errors = Vec::new();
1249 let file_str = file.to_string_lossy();
1250
1251 for line in stdout.lines() {
1252 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1253 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1254 continue;
1255 }
1256 let message_obj = match msg.get("message") {
1257 Some(m) => m,
1258 None => continue,
1259 };
1260
1261 let level = message_obj
1262 .get("level")
1263 .and_then(|l| l.as_str())
1264 .unwrap_or("error");
1265
1266 if level != "error" && level != "warning" {
1268 continue;
1269 }
1270
1271 let text = message_obj
1272 .get("message")
1273 .and_then(|m| m.as_str())
1274 .unwrap_or("unknown error")
1275 .to_string();
1276
1277 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1279 for span in spans {
1280 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1281 let is_primary = span
1282 .get("is_primary")
1283 .and_then(|p| p.as_bool())
1284 .unwrap_or(false);
1285
1286 if !is_primary {
1287 continue;
1288 }
1289
1290 if !file_str.ends_with(span_file)
1292 && !span_file.ends_with(&*file_str)
1293 && span_file != &*file_str
1294 {
1295 continue;
1296 }
1297
1298 let line_num =
1299 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1300 let col_num = span
1301 .get("column_start")
1302 .and_then(|c| c.as_u64())
1303 .unwrap_or(0) as u32;
1304
1305 errors.push(ValidationError {
1306 line: line_num,
1307 column: col_num,
1308 message: text.clone(),
1309 severity: level.to_string(),
1310 });
1311 }
1312 }
1313 }
1314 }
1315 errors
1316}
1317
1318fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1320 let mut errors = Vec::new();
1321 let file_str = file.to_string_lossy();
1322
1323 for line in stderr.lines() {
1324 let parts: Vec<&str> = line.splitn(4, ':').collect();
1326 if parts.len() < 3 {
1327 continue;
1328 }
1329
1330 let err_file = parts[0].trim();
1331 if !file_str.ends_with(err_file)
1332 && !err_file.ends_with(&*file_str)
1333 && err_file != &*file_str
1334 {
1335 continue;
1336 }
1337
1338 let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1339 let (col_num, message) = if parts.len() >= 4 {
1340 if let Ok(col) = parts[2].trim().parse::<u32>() {
1341 (col, parts[3].trim().to_string())
1342 } else {
1343 (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1345 }
1346 } else {
1347 (0, parts[2].trim().to_string())
1348 };
1349
1350 errors.push(ValidationError {
1351 line: line_num,
1352 column: col_num,
1353 message,
1354 severity: "error".to_string(),
1355 });
1356 }
1357 errors
1358}
1359
1360pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1369 let lang = match detect_language(path) {
1370 Some(l) => l,
1371 None => {
1372 log::debug!(
1373 "[aft] validate: {} (skipped: unsupported_language)",
1374 path.display()
1375 );
1376 return (Vec::new(), Some("unsupported_language".to_string()));
1377 }
1378 };
1379 if !has_checker_support(lang) {
1380 log::debug!(
1381 "[aft] validate: {} (skipped: unsupported_language)",
1382 path.display()
1383 );
1384 return (Vec::new(), Some("unsupported_language".to_string()));
1385 }
1386
1387 let (cmd, args) = match detect_checker_for_path(path, lang, config) {
1388 ToolDetection::Found(cmd, args) => (cmd, args),
1389 ToolDetection::NotConfigured => {
1390 log::debug!(
1391 "[aft] validate: {} (skipped: no_checker_configured)",
1392 path.display()
1393 );
1394 return (Vec::new(), Some("no_checker_configured".to_string()));
1395 }
1396 ToolDetection::NotInstalled { tool } => {
1397 log::warn!(
1398 "validate: {} (skipped: checker_not_installed: {})",
1399 path.display(),
1400 tool
1401 );
1402 return (Vec::new(), Some("checker_not_installed".to_string()));
1403 }
1404 };
1405
1406 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1407
1408 let working_dir = config.project_root.as_deref();
1410
1411 match run_external_tool_capture(
1412 &cmd,
1413 &arg_refs,
1414 working_dir,
1415 config.type_checker_timeout_secs,
1416 ) {
1417 Ok(result) => {
1418 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1419 log::debug!(
1420 "[aft] validate: {} ({}, {} errors)",
1421 path.display(),
1422 cmd,
1423 errors.len()
1424 );
1425 (errors, None)
1426 }
1427 Err(FormatError::Timeout { .. }) => {
1428 log::error!("validate: {} (skipped: timeout)", path.display());
1429 (Vec::new(), Some("timeout".to_string()))
1430 }
1431 Err(FormatError::NotFound { .. }) => {
1432 log::warn!(
1433 "validate: {} (skipped: checker_not_installed)",
1434 path.display()
1435 );
1436 (Vec::new(), Some("checker_not_installed".to_string()))
1437 }
1438 Err(FormatError::Failed { stderr, .. }) => {
1439 log::debug!(
1440 "[aft] validate: {} (skipped: error: {})",
1441 path.display(),
1442 stderr.lines().next().unwrap_or("unknown")
1443 );
1444 (Vec::new(), Some("error".to_string()))
1445 }
1446 Err(FormatError::UnsupportedLanguage) => {
1447 log::debug!(
1448 "[aft] validate: {} (skipped: unsupported_language)",
1449 path.display()
1450 );
1451 (Vec::new(), Some("unsupported_language".to_string()))
1452 }
1453 }
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458 use super::*;
1459 use std::fs;
1460 use std::io::Write;
1461
1462 #[test]
1463 fn run_external_tool_not_found() {
1464 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1465 assert!(result.is_err());
1466 match result.unwrap_err() {
1467 FormatError::NotFound { tool } => {
1468 assert_eq!(tool, "__nonexistent_tool_xyz__");
1469 }
1470 other => panic!("expected NotFound, got: {:?}", other),
1471 }
1472 }
1473
1474 #[test]
1475 fn run_external_tool_timeout_kills_subprocess() {
1476 let result = run_external_tool("sleep", &["60"], None, 1);
1478 assert!(result.is_err());
1479 match result.unwrap_err() {
1480 FormatError::Timeout { tool, timeout_secs } => {
1481 assert_eq!(tool, "sleep");
1482 assert_eq!(timeout_secs, 1);
1483 }
1484 other => panic!("expected Timeout, got: {:?}", other),
1485 }
1486 }
1487
1488 #[test]
1489 fn run_external_tool_success() {
1490 let result = run_external_tool("echo", &["hello"], None, 5);
1491 assert!(result.is_ok());
1492 let res = result.unwrap();
1493 assert_eq!(res.exit_code, 0);
1494 assert!(res.stdout.contains("hello"));
1495 }
1496
1497 #[test]
1498 fn run_external_tool_nonzero_exit() {
1499 let result = run_external_tool("false", &[], None, 5);
1501 assert!(result.is_err());
1502 match result.unwrap_err() {
1503 FormatError::Failed { tool, .. } => {
1504 assert_eq!(tool, "false");
1505 }
1506 other => panic!("expected Failed, got: {:?}", other),
1507 }
1508 }
1509
1510 #[test]
1511 fn auto_format_unsupported_language() {
1512 let dir = tempfile::tempdir().unwrap();
1513 let path = dir.path().join("file.txt");
1514 fs::write(&path, "hello").unwrap();
1515
1516 let config = Config::default();
1517 let (formatted, reason) = auto_format(&path, &config);
1518 assert!(!formatted);
1519 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1520 }
1521
1522 #[test]
1523 fn detect_formatter_rust_when_rustfmt_available() {
1524 let dir = tempfile::tempdir().unwrap();
1525 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1526 let path = dir.path().join("test.rs");
1527 let config = Config {
1528 project_root: Some(dir.path().to_path_buf()),
1529 ..Config::default()
1530 };
1531 let result = detect_formatter(&path, LangId::Rust, &config);
1532 if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
1533 let (cmd, args) = result.unwrap();
1534 assert_eq!(cmd, "rustfmt");
1535 assert!(args.iter().any(|a| a.ends_with("test.rs")));
1536 } else {
1537 assert!(result.is_none());
1538 }
1539 }
1540
1541 #[test]
1542 fn detect_formatter_go_mapping() {
1543 let dir = tempfile::tempdir().unwrap();
1544 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1545 let path = dir.path().join("main.go");
1546 let config = Config {
1547 project_root: Some(dir.path().to_path_buf()),
1548 ..Config::default()
1549 };
1550 let result = detect_formatter(&path, LangId::Go, &config);
1551 if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
1552 let (cmd, args) = result.unwrap();
1553 assert_eq!(cmd, "goimports");
1554 assert!(args.contains(&"-w".to_string()));
1555 } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
1556 let (cmd, args) = result.unwrap();
1557 assert_eq!(cmd, "gofmt");
1558 assert!(args.contains(&"-w".to_string()));
1559 } else {
1560 assert!(result.is_none());
1561 }
1562 }
1563
1564 #[test]
1565 fn detect_formatter_python_mapping() {
1566 let dir = tempfile::tempdir().unwrap();
1567 fs::write(dir.path().join("ruff.toml"), "").unwrap();
1568 let path = dir.path().join("main.py");
1569 let config = Config {
1570 project_root: Some(dir.path().to_path_buf()),
1571 ..Config::default()
1572 };
1573 let result = detect_formatter(&path, LangId::Python, &config);
1574 if ruff_format_available(config.project_root.as_deref()) {
1575 let (cmd, args) = result.unwrap();
1576 assert_eq!(cmd, "ruff");
1577 assert!(args.contains(&"format".to_string()));
1578 } else {
1579 assert!(result.is_none());
1580 }
1581 }
1582
1583 #[test]
1584 fn detect_formatter_no_config_returns_none() {
1585 let path = Path::new("test.ts");
1586 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1587 assert!(
1588 result.is_none(),
1589 "expected no formatter without project config"
1590 );
1591 }
1592
1593 #[test]
1594 fn detect_formatter_explicit_override() {
1595 let dir = tempfile::tempdir().unwrap();
1597 let bin_dir = dir.path().join("node_modules").join(".bin");
1598 fs::create_dir_all(&bin_dir).unwrap();
1599 #[cfg(unix)]
1600 {
1601 use std::os::unix::fs::PermissionsExt;
1602 let fake = bin_dir.join("biome");
1603 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1604 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1605 }
1606 #[cfg(not(unix))]
1607 {
1608 fs::write(bin_dir.join("biome.cmd"), "@echo 1.0.0").unwrap();
1609 }
1610
1611 let path = Path::new("test.ts");
1612 let mut config = Config {
1613 project_root: Some(dir.path().to_path_buf()),
1614 ..Config::default()
1615 };
1616 config
1617 .formatter
1618 .insert("typescript".to_string(), "biome".to_string());
1619 let result = detect_formatter(path, LangId::TypeScript, &config);
1620 let (cmd, args) = result.unwrap();
1621 assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1622 assert!(args.contains(&"format".to_string()));
1623 assert!(args.contains(&"--write".to_string()));
1624 }
1625
1626 #[test]
1627 fn auto_format_happy_path_rustfmt() {
1628 if resolve_tool("rustfmt", None).is_none() {
1629 log::warn!("skipping: rustfmt not available");
1630 return;
1631 }
1632
1633 let dir = tempfile::tempdir().unwrap();
1634 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1635 let path = dir.path().join("test.rs");
1636
1637 let mut f = fs::File::create(&path).unwrap();
1638 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
1639 drop(f);
1640
1641 let config = Config {
1642 project_root: Some(dir.path().to_path_buf()),
1643 ..Config::default()
1644 };
1645 let (formatted, reason) = auto_format(&path, &config);
1646 assert!(formatted, "expected formatting to succeed");
1647 assert!(reason.is_none());
1648
1649 let content = fs::read_to_string(&path).unwrap();
1650 assert!(
1651 !content.contains("fn main"),
1652 "expected rustfmt to fix spacing"
1653 );
1654 }
1655
1656 #[test]
1657 fn parse_tsc_output_basic() {
1658 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";
1659 let file = Path::new("src/app.ts");
1660 let errors = parse_tsc_output(stdout, "", file);
1661 assert_eq!(errors.len(), 2);
1662 assert_eq!(errors[0].line, 10);
1663 assert_eq!(errors[0].column, 5);
1664 assert_eq!(errors[0].severity, "error");
1665 assert!(errors[0].message.contains("TS2322"));
1666 assert_eq!(errors[1].line, 20);
1667 }
1668
1669 #[test]
1670 fn parse_tsc_output_filters_other_files() {
1671 let stdout =
1672 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1673 let file = Path::new("src/app.ts");
1674 let errors = parse_tsc_output(stdout, "", file);
1675 assert_eq!(errors.len(), 1);
1676 assert_eq!(errors[0].line, 5);
1677 }
1678
1679 #[test]
1680 fn parse_cargo_output_basic() {
1681 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}]}}"#;
1682 let file = Path::new("src/main.rs");
1683 let errors = parse_cargo_output(json_line, "", file);
1684 assert_eq!(errors.len(), 1);
1685 assert_eq!(errors[0].line, 10);
1686 assert_eq!(errors[0].column, 5);
1687 assert_eq!(errors[0].severity, "error");
1688 assert!(errors[0].message.contains("mismatched types"));
1689 }
1690
1691 #[test]
1692 fn parse_cargo_output_skips_notes() {
1693 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}]}}"#;
1695 let file = Path::new("src/main.rs");
1696 let errors = parse_cargo_output(json_line, "", file);
1697 assert_eq!(errors.len(), 0);
1698 }
1699
1700 #[test]
1701 fn parse_cargo_output_filters_other_files() {
1702 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}]}}"#;
1703 let file = Path::new("src/main.rs");
1704 let errors = parse_cargo_output(json_line, "", file);
1705 assert_eq!(errors.len(), 0);
1706 }
1707
1708 #[test]
1709 fn parse_go_vet_output_basic() {
1710 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1711 let file = Path::new("main.go");
1712 let errors = parse_go_vet_output(stderr, file);
1713 assert_eq!(errors.len(), 2);
1714 assert_eq!(errors[0].line, 10);
1715 assert_eq!(errors[0].column, 5);
1716 assert!(errors[0].message.contains("unreachable code"));
1717 assert_eq!(errors[1].line, 20);
1718 assert_eq!(errors[1].column, 0);
1719 }
1720
1721 #[test]
1722 fn parse_pyright_output_basic() {
1723 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1724 let file = Path::new("test.py");
1725 let errors = parse_pyright_output(stdout, file);
1726 assert_eq!(errors.len(), 1);
1727 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 10);
1729 assert_eq!(errors[0].severity, "error");
1730 assert!(errors[0].message.contains("Type error here"));
1731 }
1732
1733 #[test]
1734 fn validate_full_unsupported_language() {
1735 let dir = tempfile::tempdir().unwrap();
1736 let path = dir.path().join("file.txt");
1737 fs::write(&path, "hello").unwrap();
1738
1739 let config = Config::default();
1740 let (errors, reason) = validate_full(&path, &config);
1741 assert!(errors.is_empty());
1742 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1743 }
1744
1745 #[test]
1746 fn detect_type_checker_rust() {
1747 let dir = tempfile::tempdir().unwrap();
1748 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1749 let path = dir.path().join("src/main.rs");
1750 let config = Config {
1751 project_root: Some(dir.path().to_path_buf()),
1752 ..Config::default()
1753 };
1754 let result = detect_type_checker(&path, LangId::Rust, &config);
1755 if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
1756 let (cmd, args) = result.unwrap();
1757 assert_eq!(cmd, "cargo");
1758 assert!(args.contains(&"check".to_string()));
1759 } else {
1760 assert!(result.is_none());
1761 }
1762 }
1763
1764 #[test]
1765 fn detect_type_checker_go() {
1766 let dir = tempfile::tempdir().unwrap();
1767 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1768 let path = dir.path().join("main.go");
1769 let config = Config {
1770 project_root: Some(dir.path().to_path_buf()),
1771 ..Config::default()
1772 };
1773 let result = detect_type_checker(&path, LangId::Go, &config);
1774 if resolve_tool("go", config.project_root.as_deref()).is_some() {
1775 let (cmd, _args) = result.unwrap();
1776 assert!(cmd == "go" || cmd == "staticcheck");
1778 } else {
1779 assert!(result.is_none());
1780 }
1781 }
1782 #[test]
1783 fn run_external_tool_capture_nonzero_not_error() {
1784 let result = run_external_tool_capture("false", &[], None, 5);
1786 assert!(result.is_ok(), "capture should not error on non-zero exit");
1787 assert_eq!(result.unwrap().exit_code, 1);
1788 }
1789
1790 #[test]
1791 fn run_external_tool_capture_not_found() {
1792 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
1793 assert!(result.is_err());
1794 match result.unwrap_err() {
1795 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
1796 other => panic!("expected NotFound, got: {:?}", other),
1797 }
1798 }
1799}