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