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