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