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