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
166#[derive(Debug, Clone, PartialEq, Eq, Hash)]
167struct ToolCacheKey {
168 command: String,
169 project_root: PathBuf,
170}
171
172static TOOL_RESOLUTION_CACHE: std::sync::LazyLock<
173 Mutex<HashMap<ToolCacheKey, (Option<PathBuf>, Instant)>>,
174> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
175
176static TOOL_AVAILABILITY_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
177 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
178
179fn tool_cache_key(command: &str, project_root: Option<&Path>) -> ToolCacheKey {
180 ToolCacheKey {
181 command: command.to_string(),
182 project_root: project_root.map(Path::to_path_buf).unwrap_or_default(),
183 }
184}
185
186fn availability_cache_key(command: &str, project_root: Option<&Path>) -> String {
187 let root = project_root
188 .map(|path| path.to_string_lossy())
189 .unwrap_or_default();
190 format!("{}\0{}", command, root)
191}
192
193pub fn clear_tool_cache() {
194 if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
195 cache.clear();
196 }
197 if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
198 cache.clear();
199 }
200}
201
202fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
205 let key = tool_cache_key(command, project_root);
206 if let Ok(cache) = TOOL_RESOLUTION_CACHE.lock() {
207 if let Some((resolved, checked_at)) = cache.get(&key) {
208 if checked_at.elapsed() < TOOL_CACHE_TTL {
209 return resolved
210 .as_ref()
211 .map(|path| path.to_string_lossy().to_string());
212 }
213 }
214 }
215
216 let resolved = resolve_tool_uncached(command, project_root);
217 if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
218 cache.insert(key, (resolved.clone(), Instant::now()));
219 }
220 resolved.map(|path| path.to_string_lossy().to_string())
221}
222
223fn resolve_tool_uncached(command: &str, project_root: Option<&Path>) -> Option<PathBuf> {
224 if let Some(root) = project_root {
226 let local_bin = root.join("node_modules").join(".bin").join(command);
227 if local_bin.exists() {
228 return Some(local_bin);
229 }
230 }
231
232 match Command::new(command)
234 .arg("--version")
235 .stdin(Stdio::null())
236 .stdout(Stdio::null())
237 .stderr(Stdio::null())
238 .spawn()
239 {
240 Ok(mut child) => {
241 let start = Instant::now();
242 let timeout = Duration::from_secs(2);
243 loop {
244 match child.try_wait() {
245 Ok(Some(status)) => {
246 return if status.success() {
247 Some(PathBuf::from(command))
248 } else {
249 None
250 };
251 }
252 Ok(None) if start.elapsed() > timeout => {
253 let _ = child.kill();
254 let _ = child.wait();
255 return None;
256 }
257 Ok(None) => thread::sleep(Duration::from_millis(50)),
258 Err(_) => return None,
259 }
260 }
261 }
262 Err(_) => None,
263 }
264}
265
266fn ruff_format_available(project_root: Option<&Path>) -> bool {
273 let key = availability_cache_key("ruff-format", project_root);
274 if let Ok(cache) = TOOL_AVAILABILITY_CACHE.lock() {
275 if let Some((available, checked_at)) = cache.get(&key) {
276 if checked_at.elapsed() < TOOL_CACHE_TTL {
277 return *available;
278 }
279 }
280 }
281
282 let result = ruff_format_available_uncached(project_root);
283 if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
284 cache.insert(key, (result, Instant::now()));
285 }
286 result
287}
288
289fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
290 let command = match resolve_tool("ruff", project_root) {
291 Some(command) => command,
292 None => return false,
293 };
294 let output = match Command::new(&command)
295 .arg("--version")
296 .stdout(Stdio::piped())
297 .stderr(Stdio::null())
298 .output()
299 {
300 Ok(o) => o,
301 Err(_) => return false,
302 };
303
304 let version_str = String::from_utf8_lossy(&output.stdout);
305 let version_part = version_str
307 .trim()
308 .strip_prefix("ruff ")
309 .unwrap_or(version_str.trim());
310
311 let parts: Vec<&str> = version_part.split('.').collect();
312 if parts.len() < 3 {
313 return false;
314 }
315
316 let major: u32 = match parts[0].parse() {
317 Ok(v) => v,
318 Err(_) => return false,
319 };
320 let minor: u32 = match parts[1].parse() {
321 Ok(v) => v,
322 Err(_) => return false,
323 };
324 let patch: u32 = match parts[2].parse() {
325 Ok(v) => v,
326 Err(_) => return false,
327 };
328
329 (major, minor, patch) >= (0, 1, 2)
331}
332
333fn resolve_candidate_tool(
334 candidate: &ToolCandidate,
335 project_root: Option<&Path>,
336) -> Option<String> {
337 if candidate.tool == "ruff" && !ruff_format_available(project_root) {
338 return None;
339 }
340
341 resolve_tool(&candidate.tool, project_root)
342}
343
344fn lang_key(lang: LangId) -> &'static str {
345 match lang {
346 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
347 LangId::Python => "python",
348 LangId::Rust => "rust",
349 LangId::Go => "go",
350 LangId::C => "c",
351 LangId::Cpp => "cpp",
352 LangId::Zig => "zig",
353 LangId::CSharp => "csharp",
354 LangId::Bash => "bash",
355 LangId::Solidity => "solidity",
356 LangId::Html => "html",
357 LangId::Markdown => "markdown",
358 }
359}
360
361fn has_formatter_support(lang: LangId) -> bool {
362 matches!(
363 lang,
364 LangId::TypeScript
365 | LangId::JavaScript
366 | LangId::Tsx
367 | LangId::Python
368 | LangId::Rust
369 | LangId::Go
370 )
371}
372
373fn has_checker_support(lang: LangId) -> bool {
374 matches!(
375 lang,
376 LangId::TypeScript
377 | LangId::JavaScript
378 | LangId::Tsx
379 | LangId::Python
380 | LangId::Rust
381 | LangId::Go
382 )
383}
384
385fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
386 let project_root = config.project_root.as_deref();
387 if let Some(preferred) = config.formatter.get(lang_key(lang)) {
388 return explicit_formatter_candidate(preferred, file_str);
389 }
390
391 match lang {
392 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
393 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
394 vec![ToolCandidate {
395 tool: "biome".to_string(),
396 source: "biome.json".to_string(),
397 args: vec![
398 "format".to_string(),
399 "--write".to_string(),
400 file_str.to_string(),
401 ],
402 required: true,
403 }]
404 } else if has_project_config(
405 project_root,
406 &[
407 ".prettierrc",
408 ".prettierrc.json",
409 ".prettierrc.yml",
410 ".prettierrc.yaml",
411 ".prettierrc.js",
412 ".prettierrc.cjs",
413 ".prettierrc.mjs",
414 ".prettierrc.toml",
415 "prettier.config.js",
416 "prettier.config.cjs",
417 "prettier.config.mjs",
418 ],
419 ) {
420 vec![ToolCandidate {
421 tool: "prettier".to_string(),
422 source: "Prettier config".to_string(),
423 args: vec!["--write".to_string(), file_str.to_string()],
424 required: true,
425 }]
426 } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
427 vec![ToolCandidate {
428 tool: "deno".to_string(),
429 source: "deno.json".to_string(),
430 args: vec!["fmt".to_string(), file_str.to_string()],
431 required: true,
432 }]
433 } else {
434 Vec::new()
435 }
436 }
437 LangId::Python => {
438 if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
439 || has_pyproject_tool(project_root, "ruff")
440 {
441 vec![ToolCandidate {
442 tool: "ruff".to_string(),
443 source: "ruff config".to_string(),
444 args: vec!["format".to_string(), file_str.to_string()],
445 required: true,
446 }]
447 } else if has_pyproject_tool(project_root, "black") {
448 vec![ToolCandidate {
449 tool: "black".to_string(),
450 source: "pyproject.toml".to_string(),
451 args: vec![file_str.to_string()],
452 required: true,
453 }]
454 } else {
455 Vec::new()
456 }
457 }
458 LangId::Rust => {
459 if has_project_config(project_root, &["Cargo.toml"]) {
460 vec![ToolCandidate {
461 tool: "rustfmt".to_string(),
462 source: "Cargo.toml".to_string(),
463 args: vec![file_str.to_string()],
464 required: true,
465 }]
466 } else {
467 Vec::new()
468 }
469 }
470 LangId::Go => {
471 if has_project_config(project_root, &["go.mod"]) {
472 vec![
473 ToolCandidate {
474 tool: "goimports".to_string(),
475 source: "go.mod".to_string(),
476 args: vec!["-w".to_string(), file_str.to_string()],
477 required: false,
478 },
479 ToolCandidate {
480 tool: "gofmt".to_string(),
481 source: "go.mod".to_string(),
482 args: vec!["-w".to_string(), file_str.to_string()],
483 required: true,
484 },
485 ]
486 } else {
487 Vec::new()
488 }
489 }
490 LangId::C
491 | LangId::Cpp
492 | LangId::Zig
493 | LangId::CSharp
494 | LangId::Bash
495 | LangId::Solidity => Vec::new(),
496 LangId::Html => Vec::new(),
497 LangId::Markdown => Vec::new(),
498 }
499}
500
501fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
502 let project_root = config.project_root.as_deref();
503 if let Some(preferred) = config.checker.get(lang_key(lang)) {
504 return explicit_checker_candidate(preferred, file_str);
505 }
506
507 match lang {
508 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
509 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
510 vec![ToolCandidate {
511 tool: "biome".to_string(),
512 source: "biome.json".to_string(),
513 args: vec!["check".to_string(), file_str.to_string()],
514 required: true,
515 }]
516 } else if has_project_config(project_root, &["tsconfig.json"]) {
517 vec![ToolCandidate {
518 tool: "tsc".to_string(),
519 source: "tsconfig.json".to_string(),
520 args: vec![
521 "--noEmit".to_string(),
522 "--pretty".to_string(),
523 "false".to_string(),
524 ],
525 required: true,
526 }]
527 } else {
528 Vec::new()
529 }
530 }
531 LangId::Python => {
532 if has_project_config(project_root, &["pyrightconfig.json"])
533 || has_pyproject_tool(project_root, "pyright")
534 {
535 vec![ToolCandidate {
536 tool: "pyright".to_string(),
537 source: "pyright config".to_string(),
538 args: vec!["--outputjson".to_string(), file_str.to_string()],
539 required: true,
540 }]
541 } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
542 || has_pyproject_tool(project_root, "ruff")
543 {
544 vec![ToolCandidate {
545 tool: "ruff".to_string(),
546 source: "ruff config".to_string(),
547 args: vec![
548 "check".to_string(),
549 "--output-format=json".to_string(),
550 file_str.to_string(),
551 ],
552 required: true,
553 }]
554 } else {
555 Vec::new()
556 }
557 }
558 LangId::Rust => {
559 if has_project_config(project_root, &["Cargo.toml"]) {
560 vec![ToolCandidate {
561 tool: "cargo".to_string(),
562 source: "Cargo.toml".to_string(),
563 args: vec!["check".to_string(), "--message-format=json".to_string()],
564 required: true,
565 }]
566 } else {
567 Vec::new()
568 }
569 }
570 LangId::Go => {
571 if has_project_config(project_root, &["go.mod"]) {
572 vec![
573 ToolCandidate {
574 tool: "staticcheck".to_string(),
575 source: "go.mod".to_string(),
576 args: vec![file_str.to_string()],
577 required: false,
578 },
579 ToolCandidate {
580 tool: "go".to_string(),
581 source: "go.mod".to_string(),
582 args: vec!["vet".to_string(), file_str.to_string()],
583 required: true,
584 },
585 ]
586 } else {
587 Vec::new()
588 }
589 }
590 LangId::C
591 | LangId::Cpp
592 | LangId::Zig
593 | LangId::CSharp
594 | LangId::Bash
595 | LangId::Solidity => Vec::new(),
596 LangId::Html => Vec::new(),
597 LangId::Markdown => Vec::new(),
598 }
599}
600
601fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
602 match name {
603 "none" | "off" | "false" => Vec::new(),
604 "biome" => vec![ToolCandidate {
605 tool: name.to_string(),
606 source: "formatter config".to_string(),
607 args: vec![
608 "format".to_string(),
609 "--write".to_string(),
610 file_str.to_string(),
611 ],
612 required: true,
613 }],
614 "prettier" => vec![ToolCandidate {
615 tool: name.to_string(),
616 source: "formatter config".to_string(),
617 args: vec!["--write".to_string(), file_str.to_string()],
618 required: true,
619 }],
620 "deno" => vec![ToolCandidate {
621 tool: name.to_string(),
622 source: "formatter config".to_string(),
623 args: vec!["fmt".to_string(), file_str.to_string()],
624 required: true,
625 }],
626 "ruff" => vec![ToolCandidate {
627 tool: name.to_string(),
628 source: "formatter config".to_string(),
629 args: vec!["format".to_string(), file_str.to_string()],
630 required: true,
631 }],
632 "black" | "rustfmt" => vec![ToolCandidate {
633 tool: name.to_string(),
634 source: "formatter config".to_string(),
635 args: vec![file_str.to_string()],
636 required: true,
637 }],
638 "goimports" | "gofmt" => vec![ToolCandidate {
639 tool: name.to_string(),
640 source: "formatter config".to_string(),
641 args: vec!["-w".to_string(), file_str.to_string()],
642 required: true,
643 }],
644 _ => Vec::new(),
645 }
646}
647
648fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
649 match name {
650 "none" | "off" | "false" => Vec::new(),
651 "tsc" => vec![ToolCandidate {
652 tool: name.to_string(),
653 source: "checker config".to_string(),
654 args: vec![
655 "--noEmit".to_string(),
656 "--pretty".to_string(),
657 "false".to_string(),
658 ],
659 required: true,
660 }],
661 "cargo" => vec![ToolCandidate {
662 tool: name.to_string(),
663 source: "checker config".to_string(),
664 args: vec!["check".to_string(), "--message-format=json".to_string()],
665 required: true,
666 }],
667 "go" => vec![ToolCandidate {
668 tool: name.to_string(),
669 source: "checker config".to_string(),
670 args: vec!["vet".to_string(), file_str.to_string()],
671 required: true,
672 }],
673 "biome" => vec![ToolCandidate {
674 tool: name.to_string(),
675 source: "checker config".to_string(),
676 args: vec!["check".to_string(), file_str.to_string()],
677 required: true,
678 }],
679 "pyright" => vec![ToolCandidate {
680 tool: name.to_string(),
681 source: "checker config".to_string(),
682 args: vec!["--outputjson".to_string(), file_str.to_string()],
683 required: true,
684 }],
685 "ruff" => vec![ToolCandidate {
686 tool: name.to_string(),
687 source: "checker config".to_string(),
688 args: vec![
689 "check".to_string(),
690 "--output-format=json".to_string(),
691 file_str.to_string(),
692 ],
693 required: true,
694 }],
695 "staticcheck" => vec![ToolCandidate {
696 tool: name.to_string(),
697 source: "checker config".to_string(),
698 args: vec![file_str.to_string()],
699 required: true,
700 }],
701 _ => Vec::new(),
702 }
703}
704
705fn resolve_tool_candidates(
706 candidates: Vec<ToolCandidate>,
707 project_root: Option<&Path>,
708) -> ToolDetection {
709 if candidates.is_empty() {
710 return ToolDetection::NotConfigured;
711 }
712
713 let mut missing_required = None;
714 for candidate in candidates {
715 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
716 return ToolDetection::Found(command, candidate.args);
717 }
718 if candidate.required && missing_required.is_none() {
719 missing_required = Some(candidate.tool);
720 }
721 }
722
723 match missing_required {
724 Some(tool) => ToolDetection::NotInstalled { tool },
725 None => ToolDetection::NotConfigured,
726 }
727}
728
729fn checker_command(candidate: &ToolCandidate, resolved: String) -> String {
730 match candidate.tool.as_str() {
731 "tsc" => resolved,
732 "cargo" => "cargo".to_string(),
733 "go" => "go".to_string(),
734 _ => resolved,
735 }
736}
737
738fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
739 if candidate.tool == "tsc" {
740 vec![
741 "--noEmit".to_string(),
742 "--pretty".to_string(),
743 "false".to_string(),
744 ]
745 } else {
746 candidate.args.clone()
747 }
748}
749
750fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
751 let file_str = path.to_string_lossy().to_string();
752 resolve_tool_candidates(
753 formatter_candidates(lang, config, &file_str),
754 config.project_root.as_deref(),
755 )
756}
757
758fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
759 let file_str = path.to_string_lossy().to_string();
760 let candidates = checker_candidates(lang, config, &file_str);
761 if candidates.is_empty() {
762 return ToolDetection::NotConfigured;
763 }
764
765 let project_root = config.project_root.as_deref();
766 let mut missing_required = None;
767 for candidate in candidates {
768 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
769 return ToolDetection::Found(
770 checker_command(&candidate, command),
771 checker_args(&candidate),
772 );
773 }
774 if candidate.required && missing_required.is_none() {
775 missing_required = Some(candidate.tool);
776 }
777 }
778
779 match missing_required {
780 Some(tool) => ToolDetection::NotInstalled { tool },
781 None => ToolDetection::NotConfigured,
782 }
783}
784
785fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
786 crate::callgraph::walk_project_files(project_root)
787 .filter_map(|path| detect_language(&path))
788 .collect()
789}
790
791fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
792 let filename = match lang {
793 LangId::TypeScript => "aft-tool-detection.ts",
794 LangId::Tsx => "aft-tool-detection.tsx",
795 LangId::JavaScript => "aft-tool-detection.js",
796 LangId::Python => "aft-tool-detection.py",
797 LangId::Rust => "aft_tool_detection.rs",
798 LangId::Go => "aft_tool_detection.go",
799 LangId::C => "aft_tool_detection.c",
800 LangId::Cpp => "aft_tool_detection.cpp",
801 LangId::Zig => "aft_tool_detection.zig",
802 LangId::CSharp => "aft_tool_detection.cs",
803 LangId::Bash => "aft_tool_detection.sh",
804 LangId::Solidity => "aft_tool_detection.sol",
805 LangId::Html => "aft-tool-detection.html",
806 LangId::Markdown => "aft-tool-detection.md",
807 };
808 project_root.join(filename)
809}
810
811pub(crate) fn install_hint(tool: &str) -> String {
812 match tool {
813 "biome" => {
814 "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
815 }
816 "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
817 "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
818 "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
819 "ruff" => {
820 "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
821 }
822 "black" => {
823 "Install: `pip install black` or your Python package manager equivalent.".to_string()
824 }
825 "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
826 "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
827 "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
828 "go" => "Install Go from https://go.dev/dl/.".to_string(),
829 "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
830 "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
831 "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
832 "typescript-language-server" => {
833 "Install: `npm install -g typescript-language-server typescript`".to_string()
834 }
835 "deno" => "Install Deno from https://deno.com/.".to_string(),
836 "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
837 "staticcheck" => {
838 "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
839 }
840 other => format!("Install `{other}` and ensure it is on PATH."),
841 }
842}
843
844fn configured_tool_hint(tool: &str, source: &str) -> String {
845 format!(
846 "{tool} is configured in {source} but not installed. {}",
847 install_hint(tool)
848 )
849}
850
851fn missing_tool_warning(
852 kind: &str,
853 language: &str,
854 candidate: &ToolCandidate,
855 project_root: Option<&Path>,
856) -> Option<MissingTool> {
857 if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
858 return None;
859 }
860
861 Some(MissingTool {
862 kind: kind.to_string(),
863 language: language.to_string(),
864 tool: candidate.tool.clone(),
865 hint: configured_tool_hint(&candidate.tool, &candidate.source),
866 })
867}
868
869pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
871 let languages = languages_in_project(project_root);
872 let mut warnings = Vec::new();
873 let mut seen = HashSet::new();
874
875 for lang in languages {
876 let language = lang_key(lang);
877 let placeholder = placeholder_file_for_language(project_root, lang);
878 let file_str = placeholder.to_string_lossy().to_string();
879
880 for candidate in formatter_candidates(lang, config, &file_str) {
881 if let Some(warning) = missing_tool_warning(
882 "formatter_not_installed",
883 language,
884 &candidate,
885 config.project_root.as_deref(),
886 ) {
887 if seen.insert((
888 warning.kind.clone(),
889 warning.language.clone(),
890 warning.tool.clone(),
891 )) {
892 warnings.push(warning);
893 }
894 }
895 }
896
897 for candidate in checker_candidates(lang, config, &file_str) {
898 if let Some(warning) = missing_tool_warning(
899 "checker_not_installed",
900 language,
901 &candidate,
902 config.project_root.as_deref(),
903 ) {
904 if seen.insert((
905 warning.kind.clone(),
906 warning.language.clone(),
907 warning.tool.clone(),
908 )) {
909 warnings.push(warning);
910 }
911 }
912 }
913 }
914
915 warnings.sort_by(|left, right| {
916 (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
917 });
918 warnings
919}
920
921pub fn detect_formatter(
931 path: &Path,
932 lang: LangId,
933 config: &Config,
934) -> Option<(String, Vec<String>)> {
935 match detect_formatter_for_path(path, lang, config) {
936 ToolDetection::Found(cmd, args) => Some((cmd, args)),
937 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
938 }
939}
940
941fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
943 let root = match project_root {
944 Some(r) => r,
945 None => return false,
946 };
947 filenames.iter().any(|f| root.join(f).exists())
948}
949
950fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
952 let root = match project_root {
953 Some(r) => r,
954 None => return false,
955 };
956 let pyproject = root.join("pyproject.toml");
957 if !pyproject.exists() {
958 return false;
959 }
960 match std::fs::read_to_string(&pyproject) {
961 Ok(content) => {
962 let pattern = format!("[tool.{}]", tool_name);
963 content.contains(&pattern)
964 }
965 Err(_) => false,
966 }
967}
968
969fn formatter_excluded_path(stderr: &str) -> bool {
988 let s = stderr.to_lowercase();
989 s.contains("no files were processed")
990 || s.contains("ignored by the configuration")
991 || s.contains("no files matching the pattern")
992 || s.contains("no python files found")
993}
994
995pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1016 if !config.format_on_edit {
1018 return (false, Some("no_formatter_configured".to_string()));
1019 }
1020
1021 let lang = match detect_language(path) {
1022 Some(l) => l,
1023 None => {
1024 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1025 return (false, Some("unsupported_language".to_string()));
1026 }
1027 };
1028 if !has_formatter_support(lang) {
1029 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1030 return (false, Some("unsupported_language".to_string()));
1031 }
1032
1033 let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1034 ToolDetection::Found(cmd, args) => (cmd, args),
1035 ToolDetection::NotConfigured => {
1036 log::debug!(
1037 "format: {} (skipped: no_formatter_configured)",
1038 path.display()
1039 );
1040 return (false, Some("no_formatter_configured".to_string()));
1041 }
1042 ToolDetection::NotInstalled { tool } => {
1043 log::warn!(
1044 "format: {} (skipped: formatter_not_installed: {})",
1045 path.display(),
1046 tool
1047 );
1048 return (false, Some("formatter_not_installed".to_string()));
1049 }
1050 };
1051
1052 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1053
1054 let working_dir = config.project_root.as_deref();
1061
1062 match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1063 Ok(_) => {
1064 log::info!("format: {} ({})", path.display(), cmd);
1065 (true, None)
1066 }
1067 Err(FormatError::Timeout { .. }) => {
1068 log::warn!("format: {} (skipped: timeout)", path.display());
1069 (false, Some("timeout".to_string()))
1070 }
1071 Err(FormatError::NotFound { .. }) => {
1072 log::warn!(
1073 "format: {} (skipped: formatter_not_installed)",
1074 path.display()
1075 );
1076 (false, Some("formatter_not_installed".to_string()))
1077 }
1078 Err(FormatError::Failed { stderr, .. }) => {
1079 if formatter_excluded_path(&stderr) {
1091 log::info!(
1092 "format: {} (skipped: formatter_excluded_path; stderr: {})",
1093 path.display(),
1094 stderr.lines().next().unwrap_or("").trim()
1095 );
1096 return (false, Some("formatter_excluded_path".to_string()));
1097 }
1098 log::warn!(
1099 "format: {} (skipped: error: {})",
1100 path.display(),
1101 stderr.lines().next().unwrap_or("unknown").trim()
1102 );
1103 (false, Some("error".to_string()))
1104 }
1105 Err(FormatError::UnsupportedLanguage) => {
1106 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1107 (false, Some("unsupported_language".to_string()))
1108 }
1109 }
1110}
1111
1112pub fn run_external_tool_capture(
1119 command: &str,
1120 args: &[&str],
1121 working_dir: Option<&Path>,
1122 timeout_secs: u32,
1123) -> Result<ExternalToolResult, FormatError> {
1124 let mut cmd = Command::new(command);
1125 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1126
1127 if let Some(dir) = working_dir {
1128 cmd.current_dir(dir);
1129 }
1130
1131 let mut child = match cmd.spawn() {
1132 Ok(c) => c,
1133 Err(e) if e.kind() == ErrorKind::NotFound => {
1134 return Err(FormatError::NotFound {
1135 tool: command.to_string(),
1136 });
1137 }
1138 Err(e) => {
1139 return Err(FormatError::Failed {
1140 tool: command.to_string(),
1141 stderr: e.to_string(),
1142 });
1143 }
1144 };
1145
1146 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
1147
1148 loop {
1149 match child.try_wait() {
1150 Ok(Some(status)) => {
1151 let stdout = child
1152 .stdout
1153 .take()
1154 .map(|s| std::io::read_to_string(s).unwrap_or_default())
1155 .unwrap_or_default();
1156 let stderr = child
1157 .stderr
1158 .take()
1159 .map(|s| std::io::read_to_string(s).unwrap_or_default())
1160 .unwrap_or_default();
1161
1162 return Ok(ExternalToolResult {
1163 stdout,
1164 stderr,
1165 exit_code: status.code().unwrap_or(-1),
1166 });
1167 }
1168 Ok(None) => {
1169 if Instant::now() >= deadline {
1170 let _ = child.kill();
1171 let _ = child.wait();
1172 return Err(FormatError::Timeout {
1173 tool: command.to_string(),
1174 timeout_secs,
1175 });
1176 }
1177 thread::sleep(Duration::from_millis(50));
1178 }
1179 Err(e) => {
1180 return Err(FormatError::Failed {
1181 tool: command.to_string(),
1182 stderr: format!("try_wait error: {}", e),
1183 });
1184 }
1185 }
1186 }
1187}
1188
1189#[derive(Debug, Clone, serde::Serialize)]
1195pub struct ValidationError {
1196 pub line: u32,
1197 pub column: u32,
1198 pub message: String,
1199 pub severity: String,
1200}
1201
1202pub fn detect_type_checker(
1213 path: &Path,
1214 lang: LangId,
1215 config: &Config,
1216) -> Option<(String, Vec<String>)> {
1217 match detect_checker_for_path(path, lang, config) {
1218 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1219 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1220 }
1221}
1222
1223pub fn parse_checker_output(
1228 stdout: &str,
1229 stderr: &str,
1230 file: &Path,
1231 checker: &str,
1232) -> Vec<ValidationError> {
1233 let checker_name = Path::new(checker)
1234 .file_name()
1235 .and_then(|name| name.to_str())
1236 .unwrap_or(checker);
1237 match checker_name {
1238 "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
1239 "pyright" => parse_pyright_output(stdout, file),
1240 "cargo" => parse_cargo_output(stdout, stderr, file),
1241 "go" => parse_go_vet_output(stderr, file),
1242 _ => Vec::new(),
1243 }
1244}
1245
1246fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1248 let mut errors = Vec::new();
1249 let file_str = file.to_string_lossy();
1250 let combined = format!("{}{}", stdout, stderr);
1252 for line in combined.lines() {
1253 if let Some((loc, rest)) = line.split_once("): ") {
1256 let file_part = loc.split('(').next().unwrap_or("");
1258 if !file_str.ends_with(file_part)
1259 && !file_part.ends_with(&*file_str)
1260 && file_part != &*file_str
1261 {
1262 continue;
1263 }
1264
1265 let coords = loc.split('(').last().unwrap_or("");
1267 let parts: Vec<&str> = coords.split(',').collect();
1268 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1269 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1270
1271 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1273 ("error".to_string(), msg.to_string())
1274 } else if let Some(msg) = rest.strip_prefix("warning ") {
1275 ("warning".to_string(), msg.to_string())
1276 } else {
1277 ("error".to_string(), rest.to_string())
1278 };
1279
1280 errors.push(ValidationError {
1281 line: line_num,
1282 column: col_num,
1283 message,
1284 severity,
1285 });
1286 }
1287 }
1288 errors
1289}
1290
1291fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1293 let mut errors = Vec::new();
1294 let file_str = file.to_string_lossy();
1295
1296 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1298 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1299 for diag in diags {
1300 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1302 if !diag_file.is_empty()
1303 && !file_str.ends_with(diag_file)
1304 && !diag_file.ends_with(&*file_str)
1305 && diag_file != &*file_str
1306 {
1307 continue;
1308 }
1309
1310 let line_num = diag
1311 .get("range")
1312 .and_then(|r| r.get("start"))
1313 .and_then(|s| s.get("line"))
1314 .and_then(|l| l.as_u64())
1315 .unwrap_or(0) as u32;
1316 let col_num = diag
1317 .get("range")
1318 .and_then(|r| r.get("start"))
1319 .and_then(|s| s.get("character"))
1320 .and_then(|c| c.as_u64())
1321 .unwrap_or(0) as u32;
1322 let message = diag
1323 .get("message")
1324 .and_then(|m| m.as_str())
1325 .unwrap_or("unknown error")
1326 .to_string();
1327 let severity = diag
1328 .get("severity")
1329 .and_then(|s| s.as_str())
1330 .unwrap_or("error")
1331 .to_lowercase();
1332
1333 errors.push(ValidationError {
1334 line: line_num + 1, column: col_num,
1336 message,
1337 severity,
1338 });
1339 }
1340 }
1341 }
1342 errors
1343}
1344
1345fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1347 let mut errors = Vec::new();
1348 let file_str = file.to_string_lossy();
1349
1350 for line in stdout.lines() {
1351 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1352 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1353 continue;
1354 }
1355 let message_obj = match msg.get("message") {
1356 Some(m) => m,
1357 None => continue,
1358 };
1359
1360 let level = message_obj
1361 .get("level")
1362 .and_then(|l| l.as_str())
1363 .unwrap_or("error");
1364
1365 if level != "error" && level != "warning" {
1367 continue;
1368 }
1369
1370 let text = message_obj
1371 .get("message")
1372 .and_then(|m| m.as_str())
1373 .unwrap_or("unknown error")
1374 .to_string();
1375
1376 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1378 for span in spans {
1379 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1380 let is_primary = span
1381 .get("is_primary")
1382 .and_then(|p| p.as_bool())
1383 .unwrap_or(false);
1384
1385 if !is_primary {
1386 continue;
1387 }
1388
1389 if !file_str.ends_with(span_file)
1391 && !span_file.ends_with(&*file_str)
1392 && span_file != &*file_str
1393 {
1394 continue;
1395 }
1396
1397 let line_num =
1398 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1399 let col_num = span
1400 .get("column_start")
1401 .and_then(|c| c.as_u64())
1402 .unwrap_or(0) as u32;
1403
1404 errors.push(ValidationError {
1405 line: line_num,
1406 column: col_num,
1407 message: text.clone(),
1408 severity: level.to_string(),
1409 });
1410 }
1411 }
1412 }
1413 }
1414 errors
1415}
1416
1417fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1419 let mut errors = Vec::new();
1420 let file_str = file.to_string_lossy();
1421
1422 for line in stderr.lines() {
1423 let parts: Vec<&str> = line.splitn(4, ':').collect();
1425 if parts.len() < 3 {
1426 continue;
1427 }
1428
1429 let err_file = parts[0].trim();
1430 if !file_str.ends_with(err_file)
1431 && !err_file.ends_with(&*file_str)
1432 && err_file != &*file_str
1433 {
1434 continue;
1435 }
1436
1437 let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1438 let (col_num, message) = if parts.len() >= 4 {
1439 if let Ok(col) = parts[2].trim().parse::<u32>() {
1440 (col, parts[3].trim().to_string())
1441 } else {
1442 (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1444 }
1445 } else {
1446 (0, parts[2].trim().to_string())
1447 };
1448
1449 errors.push(ValidationError {
1450 line: line_num,
1451 column: col_num,
1452 message,
1453 severity: "error".to_string(),
1454 });
1455 }
1456 errors
1457}
1458
1459pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1468 let lang = match detect_language(path) {
1469 Some(l) => l,
1470 None => {
1471 log::debug!(
1472 "validate: {} (skipped: unsupported_language)",
1473 path.display()
1474 );
1475 return (Vec::new(), Some("unsupported_language".to_string()));
1476 }
1477 };
1478 if !has_checker_support(lang) {
1479 log::debug!(
1480 "validate: {} (skipped: unsupported_language)",
1481 path.display()
1482 );
1483 return (Vec::new(), Some("unsupported_language".to_string()));
1484 }
1485
1486 let (cmd, args) = match detect_checker_for_path(path, lang, config) {
1487 ToolDetection::Found(cmd, args) => (cmd, args),
1488 ToolDetection::NotConfigured => {
1489 log::debug!(
1490 "validate: {} (skipped: no_checker_configured)",
1491 path.display()
1492 );
1493 return (Vec::new(), Some("no_checker_configured".to_string()));
1494 }
1495 ToolDetection::NotInstalled { tool } => {
1496 log::warn!(
1497 "validate: {} (skipped: checker_not_installed: {})",
1498 path.display(),
1499 tool
1500 );
1501 return (Vec::new(), Some("checker_not_installed".to_string()));
1502 }
1503 };
1504
1505 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1506
1507 let working_dir = config.project_root.as_deref();
1509
1510 match run_external_tool_capture(
1511 &cmd,
1512 &arg_refs,
1513 working_dir,
1514 config.type_checker_timeout_secs,
1515 ) {
1516 Ok(result) => {
1517 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1518 log::debug!(
1519 "validate: {} ({}, {} errors)",
1520 path.display(),
1521 cmd,
1522 errors.len()
1523 );
1524 (errors, None)
1525 }
1526 Err(FormatError::Timeout { .. }) => {
1527 log::error!("validate: {} (skipped: timeout)", path.display());
1528 (Vec::new(), Some("timeout".to_string()))
1529 }
1530 Err(FormatError::NotFound { .. }) => {
1531 log::warn!(
1532 "validate: {} (skipped: checker_not_installed)",
1533 path.display()
1534 );
1535 (Vec::new(), Some("checker_not_installed".to_string()))
1536 }
1537 Err(FormatError::Failed { stderr, .. }) => {
1538 log::debug!(
1539 "validate: {} (skipped: error: {})",
1540 path.display(),
1541 stderr.lines().next().unwrap_or("unknown")
1542 );
1543 (Vec::new(), Some("error".to_string()))
1544 }
1545 Err(FormatError::UnsupportedLanguage) => {
1546 log::debug!(
1547 "validate: {} (skipped: unsupported_language)",
1548 path.display()
1549 );
1550 (Vec::new(), Some("unsupported_language".to_string()))
1551 }
1552 }
1553}
1554
1555#[cfg(test)]
1556mod tests {
1557 use super::*;
1558 use std::fs;
1559 use std::io::Write;
1560 use std::sync::{Mutex, MutexGuard, OnceLock};
1561
1562 fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
1569 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1570 let mutex = LOCK.get_or_init(|| Mutex::new(()));
1571 match mutex.lock() {
1574 Ok(guard) => guard,
1575 Err(poisoned) => poisoned.into_inner(),
1576 }
1577 }
1578
1579 #[test]
1580 fn run_external_tool_not_found() {
1581 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1582 assert!(result.is_err());
1583 match result.unwrap_err() {
1584 FormatError::NotFound { tool } => {
1585 assert_eq!(tool, "__nonexistent_tool_xyz__");
1586 }
1587 other => panic!("expected NotFound, got: {:?}", other),
1588 }
1589 }
1590
1591 #[test]
1592 fn run_external_tool_timeout_kills_subprocess() {
1593 let result = run_external_tool("sleep", &["60"], None, 1);
1595 assert!(result.is_err());
1596 match result.unwrap_err() {
1597 FormatError::Timeout { tool, timeout_secs } => {
1598 assert_eq!(tool, "sleep");
1599 assert_eq!(timeout_secs, 1);
1600 }
1601 other => panic!("expected Timeout, got: {:?}", other),
1602 }
1603 }
1604
1605 #[test]
1606 fn run_external_tool_success() {
1607 let result = run_external_tool("echo", &["hello"], None, 5);
1608 assert!(result.is_ok());
1609 let res = result.unwrap();
1610 assert_eq!(res.exit_code, 0);
1611 assert!(res.stdout.contains("hello"));
1612 }
1613
1614 #[test]
1615 fn run_external_tool_nonzero_exit() {
1616 let result = run_external_tool("false", &[], None, 5);
1618 assert!(result.is_err());
1619 match result.unwrap_err() {
1620 FormatError::Failed { tool, .. } => {
1621 assert_eq!(tool, "false");
1622 }
1623 other => panic!("expected Failed, got: {:?}", other),
1624 }
1625 }
1626
1627 #[test]
1628 fn auto_format_unsupported_language() {
1629 let dir = tempfile::tempdir().unwrap();
1630 let path = dir.path().join("file.txt");
1631 fs::write(&path, "hello").unwrap();
1632
1633 let config = Config::default();
1634 let (formatted, reason) = auto_format(&path, &config);
1635 assert!(!formatted);
1636 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1637 }
1638
1639 #[test]
1640 fn detect_formatter_rust_when_rustfmt_available() {
1641 let dir = tempfile::tempdir().unwrap();
1642 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1643 let path = dir.path().join("test.rs");
1644 let config = Config {
1645 project_root: Some(dir.path().to_path_buf()),
1646 ..Config::default()
1647 };
1648 let result = detect_formatter(&path, LangId::Rust, &config);
1649 if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
1650 let (cmd, args) = result.unwrap();
1651 assert_eq!(cmd, "rustfmt");
1652 assert!(args.iter().any(|a| a.ends_with("test.rs")));
1653 } else {
1654 assert!(result.is_none());
1655 }
1656 }
1657
1658 #[test]
1659 fn detect_formatter_go_mapping() {
1660 let dir = tempfile::tempdir().unwrap();
1661 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1662 let path = dir.path().join("main.go");
1663 let config = Config {
1664 project_root: Some(dir.path().to_path_buf()),
1665 ..Config::default()
1666 };
1667 let result = detect_formatter(&path, LangId::Go, &config);
1668 if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
1669 let (cmd, args) = result.unwrap();
1670 assert_eq!(cmd, "goimports");
1671 assert!(args.contains(&"-w".to_string()));
1672 } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
1673 let (cmd, args) = result.unwrap();
1674 assert_eq!(cmd, "gofmt");
1675 assert!(args.contains(&"-w".to_string()));
1676 } else {
1677 assert!(result.is_none());
1678 }
1679 }
1680
1681 #[test]
1682 fn detect_formatter_python_mapping() {
1683 let dir = tempfile::tempdir().unwrap();
1684 fs::write(dir.path().join("ruff.toml"), "").unwrap();
1685 let path = dir.path().join("main.py");
1686 let config = Config {
1687 project_root: Some(dir.path().to_path_buf()),
1688 ..Config::default()
1689 };
1690 let result = detect_formatter(&path, LangId::Python, &config);
1691 if ruff_format_available(config.project_root.as_deref()) {
1692 let (cmd, args) = result.unwrap();
1693 assert_eq!(cmd, "ruff");
1694 assert!(args.contains(&"format".to_string()));
1695 } else {
1696 assert!(result.is_none());
1697 }
1698 }
1699
1700 #[test]
1701 fn detect_formatter_no_config_returns_none() {
1702 let path = Path::new("test.ts");
1703 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1704 assert!(
1705 result.is_none(),
1706 "expected no formatter without project config"
1707 );
1708 }
1709
1710 #[cfg(unix)]
1716 #[test]
1717 fn detect_formatter_explicit_override() {
1718 let dir = tempfile::tempdir().unwrap();
1720 let bin_dir = dir.path().join("node_modules").join(".bin");
1721 fs::create_dir_all(&bin_dir).unwrap();
1722 use std::os::unix::fs::PermissionsExt;
1723 let fake = bin_dir.join("biome");
1724 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1725 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1726
1727 let path = Path::new("test.ts");
1728 let mut config = Config {
1729 project_root: Some(dir.path().to_path_buf()),
1730 ..Config::default()
1731 };
1732 config
1733 .formatter
1734 .insert("typescript".to_string(), "biome".to_string());
1735 let result = detect_formatter(path, LangId::TypeScript, &config);
1736 let (cmd, args) = result.unwrap();
1737 assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1738 assert!(args.contains(&"format".to_string()));
1739 assert!(args.contains(&"--write".to_string()));
1740 }
1741
1742 #[test]
1743 fn resolve_tool_caches_positive_result_until_clear() {
1744 let _guard = tool_cache_test_lock();
1745 clear_tool_cache();
1746 let dir = tempfile::tempdir().unwrap();
1747 let bin_dir = dir.path().join("node_modules").join(".bin");
1748 fs::create_dir_all(&bin_dir).unwrap();
1749 let tool = bin_dir.join("aft-cache-hit-tool");
1750 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1751
1752 let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1753 assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
1754
1755 fs::remove_file(&tool).unwrap();
1756 let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1757 assert_eq!(cached, first);
1758
1759 clear_tool_cache();
1760 assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
1761 }
1762
1763 #[test]
1764 fn resolve_tool_caches_negative_result_until_clear() {
1765 let _guard = tool_cache_test_lock();
1766 clear_tool_cache();
1767 let dir = tempfile::tempdir().unwrap();
1768 let bin_dir = dir.path().join("node_modules").join(".bin");
1769 let tool = bin_dir.join("aft-cache-miss-tool");
1770
1771 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1772
1773 fs::create_dir_all(&bin_dir).unwrap();
1774 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1775 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1776
1777 clear_tool_cache();
1778 assert_eq!(
1779 resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
1780 Some(tool.to_string_lossy().as_ref())
1781 );
1782 }
1783
1784 #[test]
1785 fn auto_format_happy_path_rustfmt() {
1786 if resolve_tool("rustfmt", None).is_none() {
1787 log::warn!("skipping: rustfmt not available");
1788 return;
1789 }
1790
1791 let dir = tempfile::tempdir().unwrap();
1792 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1793 let path = dir.path().join("test.rs");
1794
1795 let mut f = fs::File::create(&path).unwrap();
1796 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
1797 drop(f);
1798
1799 let config = Config {
1800 project_root: Some(dir.path().to_path_buf()),
1801 ..Config::default()
1802 };
1803 let (formatted, reason) = auto_format(&path, &config);
1804 assert!(formatted, "expected formatting to succeed");
1805 assert!(reason.is_none());
1806
1807 let content = fs::read_to_string(&path).unwrap();
1808 assert!(
1809 !content.contains("fn main"),
1810 "expected rustfmt to fix spacing"
1811 );
1812 }
1813
1814 #[test]
1815 fn formatter_excluded_path_detects_biome_messages() {
1816 let stderr = "format ━━━━━━━━━━━━━━━━━\n\n × No files were processed in the specified paths.\n\n i Check your biome.json or biome.jsonc to ensure the paths are not ignored by the configuration.\n";
1818 assert!(
1819 formatter_excluded_path(stderr),
1820 "expected biome exclusion stderr to be detected"
1821 );
1822 }
1823
1824 #[test]
1825 fn formatter_excluded_path_detects_prettier_messages() {
1826 let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
1829 assert!(
1830 formatter_excluded_path(stderr),
1831 "expected prettier exclusion stderr to be detected"
1832 );
1833 }
1834
1835 #[test]
1836 fn formatter_excluded_path_detects_ruff_messages() {
1837 let stderr = "warning: No Python files found under the given path(s).\n";
1839 assert!(
1840 formatter_excluded_path(stderr),
1841 "expected ruff exclusion stderr to be detected"
1842 );
1843 }
1844
1845 #[test]
1846 fn formatter_excluded_path_is_case_insensitive() {
1847 assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
1848 assert!(formatter_excluded_path("Ignored By The Configuration"));
1849 }
1850
1851 #[test]
1852 fn formatter_excluded_path_rejects_real_errors() {
1853 assert!(!formatter_excluded_path(""));
1856 assert!(!formatter_excluded_path("syntax error: unexpected token"));
1857 assert!(!formatter_excluded_path("formatter crashed: out of memory"));
1858 assert!(!formatter_excluded_path(
1859 "permission denied: /readonly/file"
1860 ));
1861 assert!(!formatter_excluded_path(
1862 "biome internal error: please report"
1863 ));
1864 }
1865
1866 #[test]
1867 fn parse_tsc_output_basic() {
1868 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";
1869 let file = Path::new("src/app.ts");
1870 let errors = parse_tsc_output(stdout, "", file);
1871 assert_eq!(errors.len(), 2);
1872 assert_eq!(errors[0].line, 10);
1873 assert_eq!(errors[0].column, 5);
1874 assert_eq!(errors[0].severity, "error");
1875 assert!(errors[0].message.contains("TS2322"));
1876 assert_eq!(errors[1].line, 20);
1877 }
1878
1879 #[test]
1880 fn parse_tsc_output_filters_other_files() {
1881 let stdout =
1882 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1883 let file = Path::new("src/app.ts");
1884 let errors = parse_tsc_output(stdout, "", file);
1885 assert_eq!(errors.len(), 1);
1886 assert_eq!(errors[0].line, 5);
1887 }
1888
1889 #[test]
1890 fn parse_cargo_output_basic() {
1891 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}]}}"#;
1892 let file = Path::new("src/main.rs");
1893 let errors = parse_cargo_output(json_line, "", file);
1894 assert_eq!(errors.len(), 1);
1895 assert_eq!(errors[0].line, 10);
1896 assert_eq!(errors[0].column, 5);
1897 assert_eq!(errors[0].severity, "error");
1898 assert!(errors[0].message.contains("mismatched types"));
1899 }
1900
1901 #[test]
1902 fn parse_cargo_output_skips_notes() {
1903 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}]}}"#;
1905 let file = Path::new("src/main.rs");
1906 let errors = parse_cargo_output(json_line, "", file);
1907 assert_eq!(errors.len(), 0);
1908 }
1909
1910 #[test]
1911 fn parse_cargo_output_filters_other_files() {
1912 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}]}}"#;
1913 let file = Path::new("src/main.rs");
1914 let errors = parse_cargo_output(json_line, "", file);
1915 assert_eq!(errors.len(), 0);
1916 }
1917
1918 #[test]
1919 fn parse_go_vet_output_basic() {
1920 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
1921 let file = Path::new("main.go");
1922 let errors = parse_go_vet_output(stderr, file);
1923 assert_eq!(errors.len(), 2);
1924 assert_eq!(errors[0].line, 10);
1925 assert_eq!(errors[0].column, 5);
1926 assert!(errors[0].message.contains("unreachable code"));
1927 assert_eq!(errors[1].line, 20);
1928 assert_eq!(errors[1].column, 0);
1929 }
1930
1931 #[test]
1932 fn parse_pyright_output_basic() {
1933 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
1934 let file = Path::new("test.py");
1935 let errors = parse_pyright_output(stdout, file);
1936 assert_eq!(errors.len(), 1);
1937 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 10);
1939 assert_eq!(errors[0].severity, "error");
1940 assert!(errors[0].message.contains("Type error here"));
1941 }
1942
1943 #[test]
1944 fn validate_full_unsupported_language() {
1945 let dir = tempfile::tempdir().unwrap();
1946 let path = dir.path().join("file.txt");
1947 fs::write(&path, "hello").unwrap();
1948
1949 let config = Config::default();
1950 let (errors, reason) = validate_full(&path, &config);
1951 assert!(errors.is_empty());
1952 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1953 }
1954
1955 #[test]
1956 fn detect_type_checker_rust() {
1957 let dir = tempfile::tempdir().unwrap();
1958 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1959 let path = dir.path().join("src/main.rs");
1960 let config = Config {
1961 project_root: Some(dir.path().to_path_buf()),
1962 ..Config::default()
1963 };
1964 let result = detect_type_checker(&path, LangId::Rust, &config);
1965 if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
1966 let (cmd, args) = result.unwrap();
1967 assert_eq!(cmd, "cargo");
1968 assert!(args.contains(&"check".to_string()));
1969 } else {
1970 assert!(result.is_none());
1971 }
1972 }
1973
1974 #[test]
1975 fn detect_type_checker_go() {
1976 let dir = tempfile::tempdir().unwrap();
1977 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1978 let path = dir.path().join("main.go");
1979 let config = Config {
1980 project_root: Some(dir.path().to_path_buf()),
1981 ..Config::default()
1982 };
1983 let result = detect_type_checker(&path, LangId::Go, &config);
1984 if resolve_tool("go", config.project_root.as_deref()).is_some() {
1985 let (cmd, _args) = result.unwrap();
1986 assert!(cmd == "go" || cmd == "staticcheck");
1988 } else {
1989 assert!(result.is_none());
1990 }
1991 }
1992 #[test]
1993 fn run_external_tool_capture_nonzero_not_error() {
1994 let result = run_external_tool_capture("false", &[], None, 5);
1996 assert!(result.is_ok(), "capture should not error on non-zero exit");
1997 assert_eq!(result.unwrap().exit_code, 1);
1998 }
1999
2000 #[test]
2001 fn run_external_tool_capture_not_found() {
2002 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2003 assert!(result.is_err());
2004 match result.unwrap_err() {
2005 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2006 other => panic!("expected NotFound, got: {:?}", other),
2007 }
2008 }
2009}