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 if let Some(path) = try_path_lookup(command) {
313 return Some(path);
314 }
315
316 try_well_known_path_lookup(command)
323}
324
325fn try_path_lookup(command: &str) -> Option<PathBuf> {
329 let mut child = Command::new(command)
330 .arg("--version")
331 .stdin(Stdio::null())
332 .stdout(Stdio::null())
333 .stderr(Stdio::null())
334 .spawn()
335 .ok()?;
336 let start = Instant::now();
337 let timeout = Duration::from_secs(2);
338 loop {
339 match child.try_wait() {
340 Ok(Some(status)) => {
341 return if status.success() {
342 Some(PathBuf::from(command))
343 } else {
344 None
345 };
346 }
347 Ok(None) if start.elapsed() > timeout => {
348 let _ = child.kill();
349 let _ = child.wait();
350 return None;
351 }
352 Ok(None) => thread::sleep(Duration::from_millis(50)),
353 Err(_) => return None,
354 }
355 }
356}
357
358fn try_well_known_path_lookup(command: &str) -> Option<PathBuf> {
374 if cfg!(windows) {
375 return None;
378 }
379 if std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP").is_some() {
384 return None;
385 }
386 let candidates = well_known_search_paths(command, std::env::var_os("HOME").as_deref());
387 try_well_known_path_lookup_in(&candidates)
388}
389
390fn well_known_search_paths(command: &str, home: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
394 let mut candidates: Vec<PathBuf> = Vec::with_capacity(5);
395 candidates.push(PathBuf::from("/opt/homebrew/bin").join(command));
396 candidates.push(PathBuf::from("/usr/local/bin").join(command));
397 if let Some(home) = home {
398 let home_path = PathBuf::from(home);
399 candidates.push(home_path.join(".cargo/bin").join(command));
400 candidates.push(home_path.join("go/bin").join(command));
401 candidates.push(home_path.join(".local/bin").join(command));
402 }
403 candidates
404}
405
406fn try_well_known_path_lookup_in(candidates: &[PathBuf]) -> Option<PathBuf> {
410 for candidate in candidates {
411 if let Ok(metadata) = std::fs::metadata(candidate) {
412 if metadata.is_file() && is_executable(&metadata) {
413 return Some(candidate.clone());
414 }
415 }
416 }
417 None
418}
419
420#[cfg(unix)]
421fn is_executable(metadata: &std::fs::Metadata) -> bool {
422 use std::os::unix::fs::PermissionsExt;
423 metadata.permissions().mode() & 0o111 != 0
424}
425
426#[cfg(not(unix))]
427fn is_executable(_metadata: &std::fs::Metadata) -> bool {
428 true
432}
433
434fn ruff_format_available(project_root: Option<&Path>) -> bool {
441 let key = availability_cache_key("ruff-format", project_root);
442 if let Ok(cache) = TOOL_AVAILABILITY_CACHE.lock() {
443 if let Some((available, checked_at)) = cache.get(&key) {
444 if checked_at.elapsed() < TOOL_CACHE_TTL {
445 return *available;
446 }
447 }
448 }
449
450 let result = ruff_format_available_uncached(project_root);
451 if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
452 cache.insert(key, (result, Instant::now()));
453 }
454 result
455}
456
457fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
458 let command = match resolve_tool("ruff", project_root) {
459 Some(command) => command,
460 None => return false,
461 };
462 let output = match Command::new(&command)
463 .arg("--version")
464 .stdout(Stdio::piped())
465 .stderr(Stdio::null())
466 .output()
467 {
468 Ok(o) => o,
469 Err(_) => return false,
470 };
471
472 let version_str = String::from_utf8_lossy(&output.stdout);
473 let version_part = version_str
475 .trim()
476 .strip_prefix("ruff ")
477 .unwrap_or(version_str.trim());
478
479 let parts: Vec<&str> = version_part.split('.').collect();
480 if parts.len() < 3 {
481 return false;
482 }
483
484 let major: u32 = match parts[0].parse() {
485 Ok(v) => v,
486 Err(_) => return false,
487 };
488 let minor: u32 = match parts[1].parse() {
489 Ok(v) => v,
490 Err(_) => return false,
491 };
492 let patch: u32 = match parts[2].parse() {
493 Ok(v) => v,
494 Err(_) => return false,
495 };
496
497 (major, minor, patch) >= (0, 1, 2)
499}
500
501fn resolve_candidate_tool(
502 candidate: &ToolCandidate,
503 project_root: Option<&Path>,
504) -> Option<String> {
505 if candidate.tool == "ruff" && !ruff_format_available(project_root) {
506 return None;
507 }
508
509 resolve_tool(&candidate.tool, project_root)
510}
511
512fn lang_key(lang: LangId) -> &'static str {
513 match lang {
514 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
515 LangId::Python => "python",
516 LangId::Rust => "rust",
517 LangId::Go => "go",
518 LangId::C => "c",
519 LangId::Cpp => "cpp",
520 LangId::Zig => "zig",
521 LangId::CSharp => "csharp",
522 LangId::Bash => "bash",
523 LangId::Solidity => "solidity",
524 LangId::Vue => "vue",
525 LangId::Json => "json",
526 LangId::Scala => "scala",
527 LangId::Java => "java",
528 LangId::Ruby => "ruby",
529 LangId::Kotlin => "kotlin",
530 LangId::Swift => "swift",
531 LangId::Php => "php",
532 LangId::Lua => "lua",
533 LangId::Perl => "perl",
534 LangId::Html => "html",
535 LangId::Markdown => "markdown",
536 }
537}
538
539fn has_formatter_support(lang: LangId) -> bool {
540 matches!(
541 lang,
542 LangId::TypeScript
543 | LangId::JavaScript
544 | LangId::Tsx
545 | LangId::Python
546 | LangId::Rust
547 | LangId::Go
548 )
549}
550
551fn has_checker_support(lang: LangId) -> bool {
552 matches!(
553 lang,
554 LangId::TypeScript
555 | LangId::JavaScript
556 | LangId::Tsx
557 | LangId::Python
558 | LangId::Rust
559 | LangId::Go
560 )
561}
562
563fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
564 let project_root = config.project_root.as_deref();
565 if let Some(preferred) = config.formatter.get(lang_key(lang)) {
566 return explicit_formatter_candidate(preferred, file_str);
567 }
568
569 match lang {
570 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
571 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
572 vec![ToolCandidate {
573 tool: "biome".to_string(),
574 source: "biome.json".to_string(),
575 args: vec![
576 "format".to_string(),
577 "--write".to_string(),
578 file_str.to_string(),
579 ],
580 required: true,
581 }]
582 } else if has_project_config(
583 project_root,
584 &[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts"],
585 ) {
586 vec![ToolCandidate {
587 tool: "oxfmt".to_string(),
588 source: "oxfmt config".to_string(),
589 args: vec!["--write".to_string(), file_str.to_string()],
590 required: true,
591 }]
592 } else if has_project_config(
593 project_root,
594 &[
595 ".prettierrc",
596 ".prettierrc.json",
597 ".prettierrc.yml",
598 ".prettierrc.yaml",
599 ".prettierrc.js",
600 ".prettierrc.cjs",
601 ".prettierrc.mjs",
602 ".prettierrc.toml",
603 "prettier.config.js",
604 "prettier.config.cjs",
605 "prettier.config.mjs",
606 ],
607 ) {
608 vec![ToolCandidate {
609 tool: "prettier".to_string(),
610 source: "Prettier config".to_string(),
611 args: vec!["--write".to_string(), file_str.to_string()],
612 required: true,
613 }]
614 } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
615 vec![ToolCandidate {
616 tool: "deno".to_string(),
617 source: "deno.json".to_string(),
618 args: vec!["fmt".to_string(), file_str.to_string()],
619 required: true,
620 }]
621 } else {
622 Vec::new()
623 }
624 }
625 LangId::Python => {
626 if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
627 || has_pyproject_tool(project_root, "ruff")
628 {
629 vec![ToolCandidate {
630 tool: "ruff".to_string(),
631 source: "ruff config".to_string(),
632 args: vec!["format".to_string(), file_str.to_string()],
633 required: true,
634 }]
635 } else if has_pyproject_tool(project_root, "black") {
636 vec![ToolCandidate {
637 tool: "black".to_string(),
638 source: "pyproject.toml".to_string(),
639 args: vec![file_str.to_string()],
640 required: true,
641 }]
642 } else {
643 Vec::new()
644 }
645 }
646 LangId::Rust => {
647 if has_project_config(project_root, &["Cargo.toml"]) {
648 vec![ToolCandidate {
649 tool: "rustfmt".to_string(),
650 source: "Cargo.toml".to_string(),
651 args: vec![file_str.to_string()],
652 required: true,
653 }]
654 } else {
655 Vec::new()
656 }
657 }
658 LangId::Go => {
659 if has_project_config(project_root, &["go.mod"]) {
660 vec![
661 ToolCandidate {
662 tool: "goimports".to_string(),
663 source: "go.mod".to_string(),
664 args: vec!["-w".to_string(), file_str.to_string()],
665 required: false,
666 },
667 ToolCandidate {
668 tool: "gofmt".to_string(),
669 source: "go.mod".to_string(),
670 args: vec!["-w".to_string(), file_str.to_string()],
671 required: true,
672 },
673 ]
674 } else {
675 Vec::new()
676 }
677 }
678 LangId::C
679 | LangId::Cpp
680 | LangId::Zig
681 | LangId::CSharp
682 | LangId::Bash
683 | LangId::Solidity
684 | LangId::Vue
685 | LangId::Json
686 | LangId::Scala
687 | LangId::Java
688 | LangId::Ruby
689 | LangId::Kotlin
690 | LangId::Swift
691 | LangId::Php
692 | LangId::Lua
693 | LangId::Perl => Vec::new(),
694 LangId::Html => Vec::new(),
695 LangId::Markdown => Vec::new(),
696 }
697}
698
699fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
700 let project_root = config.project_root.as_deref();
701 if let Some(preferred) = config.checker.get(lang_key(lang)) {
702 return explicit_checker_candidate(preferred, file_str);
703 }
704
705 match lang {
706 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
707 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
708 vec![ToolCandidate {
709 tool: "biome".to_string(),
710 source: "biome.json".to_string(),
711 args: vec!["check".to_string(), file_str.to_string()],
712 required: true,
713 }]
714 } else if has_project_config(project_root, &["tsconfig.json"]) {
715 vec![ToolCandidate {
716 tool: "tsc".to_string(),
717 source: "tsconfig.json".to_string(),
718 args: vec![
719 "--noEmit".to_string(),
720 "--pretty".to_string(),
721 "false".to_string(),
722 ],
723 required: true,
724 }]
725 } else {
726 Vec::new()
727 }
728 }
729 LangId::Python => {
730 if has_project_config(project_root, &["pyrightconfig.json"])
731 || has_pyproject_tool(project_root, "pyright")
732 {
733 vec![ToolCandidate {
734 tool: "pyright".to_string(),
735 source: "pyright config".to_string(),
736 args: vec!["--outputjson".to_string(), file_str.to_string()],
737 required: true,
738 }]
739 } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
740 || has_pyproject_tool(project_root, "ruff")
741 {
742 vec![ToolCandidate {
743 tool: "ruff".to_string(),
744 source: "ruff config".to_string(),
745 args: vec![
746 "check".to_string(),
747 "--output-format=json".to_string(),
748 file_str.to_string(),
749 ],
750 required: true,
751 }]
752 } else {
753 Vec::new()
754 }
755 }
756 LangId::Rust => {
757 if has_project_config(project_root, &["Cargo.toml"]) {
758 vec![ToolCandidate {
759 tool: "cargo".to_string(),
760 source: "Cargo.toml".to_string(),
761 args: vec!["check".to_string(), "--message-format=json".to_string()],
762 required: true,
763 }]
764 } else {
765 Vec::new()
766 }
767 }
768 LangId::Go => {
769 if has_project_config(project_root, &["go.mod"]) {
770 vec![
771 ToolCandidate {
772 tool: "staticcheck".to_string(),
773 source: "go.mod".to_string(),
774 args: vec![file_str.to_string()],
775 required: false,
776 },
777 ToolCandidate {
778 tool: "go".to_string(),
779 source: "go.mod".to_string(),
780 args: vec!["vet".to_string(), file_str.to_string()],
781 required: true,
782 },
783 ]
784 } else {
785 Vec::new()
786 }
787 }
788 LangId::C
789 | LangId::Cpp
790 | LangId::Zig
791 | LangId::CSharp
792 | LangId::Bash
793 | LangId::Solidity
794 | LangId::Vue
795 | LangId::Json
796 | LangId::Scala
797 | LangId::Java
798 | LangId::Ruby
799 | LangId::Kotlin
800 | LangId::Swift
801 | LangId::Php
802 | LangId::Lua
803 | LangId::Perl => Vec::new(),
804 LangId::Html => Vec::new(),
805 LangId::Markdown => Vec::new(),
806 }
807}
808
809fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
810 match name {
811 "none" | "off" | "false" => Vec::new(),
812 "biome" => vec![ToolCandidate {
813 tool: name.to_string(),
814 source: "formatter config".to_string(),
815 args: vec![
816 "format".to_string(),
817 "--write".to_string(),
818 file_str.to_string(),
819 ],
820 required: true,
821 }],
822 "oxfmt" => vec![ToolCandidate {
823 tool: name.to_string(),
824 source: "formatter config".to_string(),
825 args: vec!["--write".to_string(), file_str.to_string()],
826 required: true,
827 }],
828 "prettier" => vec![ToolCandidate {
829 tool: name.to_string(),
830 source: "formatter config".to_string(),
831 args: vec!["--write".to_string(), file_str.to_string()],
832 required: true,
833 }],
834 "deno" => vec![ToolCandidate {
835 tool: name.to_string(),
836 source: "formatter config".to_string(),
837 args: vec!["fmt".to_string(), file_str.to_string()],
838 required: true,
839 }],
840 "ruff" => vec![ToolCandidate {
841 tool: name.to_string(),
842 source: "formatter config".to_string(),
843 args: vec!["format".to_string(), file_str.to_string()],
844 required: true,
845 }],
846 "black" | "rustfmt" => vec![ToolCandidate {
847 tool: name.to_string(),
848 source: "formatter config".to_string(),
849 args: vec![file_str.to_string()],
850 required: true,
851 }],
852 "goimports" | "gofmt" => vec![ToolCandidate {
853 tool: name.to_string(),
854 source: "formatter config".to_string(),
855 args: vec!["-w".to_string(), file_str.to_string()],
856 required: true,
857 }],
858 _ => Vec::new(),
859 }
860}
861
862fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
863 match name {
864 "none" | "off" | "false" => Vec::new(),
865 "tsc" | "tsgo" => vec![ToolCandidate {
866 tool: name.to_string(),
867 source: "checker config".to_string(),
868 args: vec![
869 "--noEmit".to_string(),
870 "--pretty".to_string(),
871 "false".to_string(),
872 ],
873 required: true,
874 }],
875 "cargo" => vec![ToolCandidate {
876 tool: name.to_string(),
877 source: "checker config".to_string(),
878 args: vec!["check".to_string(), "--message-format=json".to_string()],
879 required: true,
880 }],
881 "go" => vec![ToolCandidate {
882 tool: name.to_string(),
883 source: "checker config".to_string(),
884 args: vec!["vet".to_string(), file_str.to_string()],
885 required: true,
886 }],
887 "biome" => vec![ToolCandidate {
888 tool: name.to_string(),
889 source: "checker config".to_string(),
890 args: vec!["check".to_string(), file_str.to_string()],
891 required: true,
892 }],
893 "pyright" => vec![ToolCandidate {
894 tool: name.to_string(),
895 source: "checker config".to_string(),
896 args: vec!["--outputjson".to_string(), file_str.to_string()],
897 required: true,
898 }],
899 "ruff" => vec![ToolCandidate {
900 tool: name.to_string(),
901 source: "checker config".to_string(),
902 args: vec![
903 "check".to_string(),
904 "--output-format=json".to_string(),
905 file_str.to_string(),
906 ],
907 required: true,
908 }],
909 "staticcheck" => vec![ToolCandidate {
910 tool: name.to_string(),
911 source: "checker config".to_string(),
912 args: vec![file_str.to_string()],
913 required: true,
914 }],
915 _ => Vec::new(),
916 }
917}
918
919fn resolve_tool_candidates(
920 candidates: Vec<ToolCandidate>,
921 project_root: Option<&Path>,
922) -> ToolDetection {
923 if candidates.is_empty() {
924 return ToolDetection::NotConfigured;
925 }
926
927 let mut missing_required = None;
928 for candidate in candidates {
929 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
930 return ToolDetection::Found(command, candidate.args);
931 }
932 if candidate.required && missing_required.is_none() {
933 missing_required = Some(candidate.tool);
934 }
935 }
936
937 match missing_required {
938 Some(tool) => ToolDetection::NotInstalled { tool },
939 None => ToolDetection::NotConfigured,
940 }
941}
942
943fn checker_command(candidate: &ToolCandidate, resolved: String) -> String {
944 match candidate.tool.as_str() {
945 "tsc" | "tsgo" => resolved,
946 "cargo" => "cargo".to_string(),
947 "go" => "go".to_string(),
948 _ => resolved,
949 }
950}
951
952fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
953 if candidate.tool == "tsc" || candidate.tool == "tsgo" {
954 vec![
955 "--noEmit".to_string(),
956 "--pretty".to_string(),
957 "false".to_string(),
958 ]
959 } else {
960 candidate.args.clone()
961 }
962}
963
964fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
965 let file_str = path.to_string_lossy().to_string();
966 resolve_tool_candidates(
967 formatter_candidates(lang, config, &file_str),
968 config.project_root.as_deref(),
969 )
970}
971
972fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
973 let file_str = path.to_string_lossy().to_string();
974 let candidates = checker_candidates(lang, config, &file_str);
975 if candidates.is_empty() {
976 return ToolDetection::NotConfigured;
977 }
978
979 let project_root = config.project_root.as_deref();
980 let mut missing_required = None;
981 for candidate in candidates {
982 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
983 return ToolDetection::Found(
984 checker_command(&candidate, command),
985 checker_args(&candidate),
986 );
987 }
988 if candidate.required && missing_required.is_none() {
989 missing_required = Some(candidate.tool);
990 }
991 }
992
993 match missing_required {
994 Some(tool) => ToolDetection::NotInstalled { tool },
995 None => ToolDetection::NotConfigured,
996 }
997}
998
999fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
1000 crate::callgraph::walk_project_files(project_root)
1001 .filter_map(|path| detect_language(&path))
1002 .collect()
1003}
1004
1005fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
1006 let filename = match lang {
1007 LangId::TypeScript => "aft-tool-detection.ts",
1008 LangId::Tsx => "aft-tool-detection.tsx",
1009 LangId::JavaScript => "aft-tool-detection.js",
1010 LangId::Python => "aft-tool-detection.py",
1011 LangId::Rust => "aft_tool_detection.rs",
1012 LangId::Go => "aft_tool_detection.go",
1013 LangId::C => "aft_tool_detection.c",
1014 LangId::Cpp => "aft_tool_detection.cpp",
1015 LangId::Zig => "aft_tool_detection.zig",
1016 LangId::CSharp => "aft_tool_detection.cs",
1017 LangId::Bash => "aft_tool_detection.sh",
1018 LangId::Solidity => "aft_tool_detection.sol",
1019 LangId::Vue => "aft-tool-detection.vue",
1020 LangId::Json => "aft-tool-detection.json",
1021 LangId::Scala => "aft-tool-detection.scala",
1022 LangId::Java => "aft-tool-detection.java",
1023 LangId::Ruby => "aft-tool-detection.rb",
1024 LangId::Kotlin => "aft-tool-detection.kt",
1025 LangId::Swift => "aft-tool-detection.swift",
1026 LangId::Php => "aft-tool-detection.php",
1027 LangId::Lua => "aft-tool-detection.lua",
1028 LangId::Perl => "aft-tool-detection.pl",
1029 LangId::Html => "aft-tool-detection.html",
1030 LangId::Markdown => "aft-tool-detection.md",
1031 };
1032 project_root.join(filename)
1033}
1034
1035pub(crate) fn install_hint(tool: &str) -> String {
1036 match tool {
1037 "biome" => {
1038 "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
1039 }
1040 "oxfmt" => "Run `npm install -D oxfmt` or install globally.".to_string(),
1041 "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
1042 "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
1043 "tsgo" => {
1044 "Run `npm install -D @typescript/native-preview` or install globally.".to_string()
1045 }
1046 "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
1047 "ruff" => {
1048 "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
1049 }
1050 "black" => {
1051 "Install: `pip install black` or your Python package manager equivalent.".to_string()
1052 }
1053 "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
1054 "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
1055 "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
1056 "go" => [
1057 "Install Go from https://go.dev/dl/, or — if it's already installed —",
1058 "ensure its bin directory is on PATH (Homebrew typically uses",
1059 "/opt/homebrew/bin on Apple Silicon, /usr/local/bin on Intel macOS).",
1060 "GUI-launched editors often don't inherit login-shell PATH.",
1061 ]
1062 .join(" "),
1063 "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
1064 "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
1065 "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
1066 "typescript-language-server" => {
1067 "Install: `npm install -g typescript-language-server typescript`".to_string()
1068 }
1069 "deno" => "Install Deno from https://deno.com/.".to_string(),
1070 "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
1071 "staticcheck" => {
1072 "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
1073 }
1074 other => format!("Install `{other}` and ensure it is on PATH."),
1075 }
1076}
1077
1078fn configured_tool_hint(tool: &str, source: &str) -> String {
1079 format!(
1089 "{tool} is configured in {source} but was not found on PATH or in common install locations. {}",
1090 install_hint(tool)
1091 )
1092}
1093
1094fn missing_tool_warning(
1095 kind: &str,
1096 language: &str,
1097 candidate: &ToolCandidate,
1098 project_root: Option<&Path>,
1099) -> Option<MissingTool> {
1100 if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
1101 return None;
1102 }
1103
1104 Some(MissingTool {
1105 kind: kind.to_string(),
1106 language: language.to_string(),
1107 tool: candidate.tool.clone(),
1108 hint: configured_tool_hint(&candidate.tool, &candidate.source),
1109 })
1110}
1111
1112pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
1114 let languages = languages_in_project(project_root);
1115 let mut warnings = Vec::new();
1116 let mut seen = HashSet::new();
1117
1118 for lang in languages {
1119 let language = lang_key(lang);
1120 let placeholder = placeholder_file_for_language(project_root, lang);
1121 let file_str = placeholder.to_string_lossy().to_string();
1122
1123 for candidate in formatter_candidates(lang, config, &file_str) {
1124 if let Some(warning) = missing_tool_warning(
1125 "formatter_not_installed",
1126 language,
1127 &candidate,
1128 config.project_root.as_deref(),
1129 ) {
1130 if seen.insert((
1131 warning.kind.clone(),
1132 warning.language.clone(),
1133 warning.tool.clone(),
1134 )) {
1135 warnings.push(warning);
1136 }
1137 }
1138 }
1139
1140 for candidate in checker_candidates(lang, config, &file_str) {
1141 if let Some(warning) = missing_tool_warning(
1142 "checker_not_installed",
1143 language,
1144 &candidate,
1145 config.project_root.as_deref(),
1146 ) {
1147 if seen.insert((
1148 warning.kind.clone(),
1149 warning.language.clone(),
1150 warning.tool.clone(),
1151 )) {
1152 warnings.push(warning);
1153 }
1154 }
1155 }
1156 }
1157
1158 warnings.sort_by(|left, right| {
1159 (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1160 });
1161 warnings
1162}
1163
1164pub fn detect_formatter(
1174 path: &Path,
1175 lang: LangId,
1176 config: &Config,
1177) -> Option<(String, Vec<String>)> {
1178 match detect_formatter_for_path(path, lang, config) {
1179 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1180 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1181 }
1182}
1183
1184fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1186 let root = match project_root {
1187 Some(r) => r,
1188 None => return false,
1189 };
1190 filenames.iter().any(|f| root.join(f).exists())
1191}
1192
1193fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1195 let root = match project_root {
1196 Some(r) => r,
1197 None => return false,
1198 };
1199 let pyproject = root.join("pyproject.toml");
1200 if !pyproject.exists() {
1201 return false;
1202 }
1203 match std::fs::read_to_string(&pyproject) {
1204 Ok(content) => {
1205 let pattern = format!("[tool.{}]", tool_name);
1206 content.contains(&pattern)
1207 }
1208 Err(_) => false,
1209 }
1210}
1211
1212fn formatter_excluded_path(stderr: &str) -> bool {
1233 let s = stderr.to_lowercase();
1234 s.contains("no files were processed")
1235 || s.contains("ignored by the configuration")
1236 || s.contains("expected at least one target file")
1237 || s.contains("no files found matching the given patterns")
1238 || s.contains("no files matching the pattern")
1239 || s.contains("no python files found")
1240}
1241
1242pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1263 if !config.format_on_edit {
1265 return (false, Some("no_formatter_configured".to_string()));
1266 }
1267
1268 let lang = match detect_language(path) {
1269 Some(l) => l,
1270 None => {
1271 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1272 return (false, Some("unsupported_language".to_string()));
1273 }
1274 };
1275 if !has_formatter_support(lang) {
1276 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1277 return (false, Some("unsupported_language".to_string()));
1278 }
1279
1280 let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1281 ToolDetection::Found(cmd, args) => (cmd, args),
1282 ToolDetection::NotConfigured => {
1283 log::debug!(
1284 "format: {} (skipped: no_formatter_configured)",
1285 path.display()
1286 );
1287 return (false, Some("no_formatter_configured".to_string()));
1288 }
1289 ToolDetection::NotInstalled { tool } => {
1290 crate::slog_warn!(
1291 "format: {} (skipped: formatter_not_installed: {})",
1292 path.display(),
1293 tool
1294 );
1295 return (false, Some("formatter_not_installed".to_string()));
1296 }
1297 };
1298
1299 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1300
1301 let working_dir = config.project_root.as_deref();
1308
1309 match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1310 Ok(_) => {
1311 crate::slog_info!("format: {} ({})", path.display(), cmd);
1312 (true, None)
1313 }
1314 Err(FormatError::Timeout { .. }) => {
1315 crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1316 (false, Some("timeout".to_string()))
1317 }
1318 Err(FormatError::NotFound { .. }) => {
1319 crate::slog_warn!(
1320 "format: {} (skipped: formatter_not_installed)",
1321 path.display()
1322 );
1323 (false, Some("formatter_not_installed".to_string()))
1324 }
1325 Err(FormatError::Failed { stderr, .. }) => {
1326 if formatter_excluded_path(&stderr) {
1338 crate::slog_info!(
1339 "format: {} (skipped: formatter_excluded_path; stderr: {})",
1340 path.display(),
1341 stderr.lines().next().unwrap_or("").trim()
1342 );
1343 return (false, Some("formatter_excluded_path".to_string()));
1344 }
1345 crate::slog_warn!(
1346 "format: {} (skipped: error: {})",
1347 path.display(),
1348 stderr.lines().next().unwrap_or("unknown").trim()
1349 );
1350 (false, Some("error".to_string()))
1351 }
1352 Err(FormatError::UnsupportedLanguage) => {
1353 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1354 (false, Some("unsupported_language".to_string()))
1355 }
1356 }
1357}
1358
1359pub fn run_external_tool_capture(
1366 command: &str,
1367 args: &[&str],
1368 working_dir: Option<&Path>,
1369 timeout_secs: u32,
1370) -> Result<ExternalToolResult, FormatError> {
1371 let mut cmd = Command::new(command);
1372 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1373
1374 if let Some(dir) = working_dir {
1375 cmd.current_dir(dir);
1376 }
1377
1378 isolate_in_process_group(&mut cmd);
1379
1380 let child = match cmd.spawn() {
1381 Ok(c) => c,
1382 Err(e) if e.kind() == ErrorKind::NotFound => {
1383 return Err(FormatError::NotFound {
1384 tool: command.to_string(),
1385 });
1386 }
1387 Err(e) => {
1388 return Err(FormatError::Failed {
1389 tool: command.to_string(),
1390 stderr: e.to_string(),
1391 });
1392 }
1393 };
1394
1395 let outcome = wait_with_timeout(child, command, timeout_secs)?;
1396 Ok(ExternalToolResult {
1397 stdout: outcome.stdout,
1398 stderr: outcome.stderr,
1399 exit_code: outcome.status.code().unwrap_or(-1),
1400 })
1401}
1402
1403#[derive(Debug, Clone, serde::Serialize)]
1409pub struct ValidationError {
1410 pub line: u32,
1411 pub column: u32,
1412 pub message: String,
1413 pub severity: String,
1414}
1415
1416pub fn detect_type_checker(
1427 path: &Path,
1428 lang: LangId,
1429 config: &Config,
1430) -> Option<(String, Vec<String>)> {
1431 match detect_checker_for_path(path, lang, config) {
1432 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1433 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1434 }
1435}
1436
1437pub fn parse_checker_output(
1442 stdout: &str,
1443 stderr: &str,
1444 file: &Path,
1445 checker: &str,
1446) -> Vec<ValidationError> {
1447 let checker_name = Path::new(checker)
1448 .file_name()
1449 .and_then(|name| name.to_str())
1450 .unwrap_or(checker);
1451 match checker_name {
1452 "npx" | "tsc" | "tsgo" => parse_tsc_output(stdout, stderr, file),
1453 "pyright" => parse_pyright_output(stdout, file),
1454 "cargo" => parse_cargo_output(stdout, stderr, file),
1455 "go" => parse_go_vet_output(stderr, file),
1456 _ => Vec::new(),
1457 }
1458}
1459
1460fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1462 let mut errors = Vec::new();
1463 let file_str = file.to_string_lossy();
1464 let combined = format!("{}{}", stdout, stderr);
1466 for line in combined.lines() {
1467 if let Some((loc, rest)) = line.split_once("): ") {
1470 let file_part = loc.split('(').next().unwrap_or("");
1472 if !file_str.ends_with(file_part)
1473 && !file_part.ends_with(&*file_str)
1474 && file_part != &*file_str
1475 {
1476 continue;
1477 }
1478
1479 let coords = loc.split('(').last().unwrap_or("");
1481 let parts: Vec<&str> = coords.split(',').collect();
1482 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1483 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1484
1485 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1487 ("error".to_string(), msg.to_string())
1488 } else if let Some(msg) = rest.strip_prefix("warning ") {
1489 ("warning".to_string(), msg.to_string())
1490 } else {
1491 ("error".to_string(), rest.to_string())
1492 };
1493
1494 errors.push(ValidationError {
1495 line: line_num,
1496 column: col_num,
1497 message,
1498 severity,
1499 });
1500 }
1501 }
1502 errors
1503}
1504
1505fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1507 let mut errors = Vec::new();
1508 let file_str = file.to_string_lossy();
1509
1510 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1512 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1513 for diag in diags {
1514 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1516 if !diag_file.is_empty()
1517 && !file_str.ends_with(diag_file)
1518 && !diag_file.ends_with(&*file_str)
1519 && diag_file != &*file_str
1520 {
1521 continue;
1522 }
1523
1524 let line_num = diag
1525 .get("range")
1526 .and_then(|r| r.get("start"))
1527 .and_then(|s| s.get("line"))
1528 .and_then(|l| l.as_u64())
1529 .unwrap_or(0) as u32;
1530 let col_num = diag
1531 .get("range")
1532 .and_then(|r| r.get("start"))
1533 .and_then(|s| s.get("character"))
1534 .and_then(|c| c.as_u64())
1535 .unwrap_or(0) as u32;
1536 let message = diag
1537 .get("message")
1538 .and_then(|m| m.as_str())
1539 .unwrap_or("unknown error")
1540 .to_string();
1541 let severity = diag
1542 .get("severity")
1543 .and_then(|s| s.as_str())
1544 .unwrap_or("error")
1545 .to_lowercase();
1546
1547 errors.push(ValidationError {
1548 line: line_num + 1, column: col_num,
1550 message,
1551 severity,
1552 });
1553 }
1554 }
1555 }
1556 errors
1557}
1558
1559fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1561 let mut errors = Vec::new();
1562 let file_str = file.to_string_lossy();
1563
1564 for line in stdout.lines() {
1565 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1566 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1567 continue;
1568 }
1569 let message_obj = match msg.get("message") {
1570 Some(m) => m,
1571 None => continue,
1572 };
1573
1574 let level = message_obj
1575 .get("level")
1576 .and_then(|l| l.as_str())
1577 .unwrap_or("error");
1578
1579 if level != "error" && level != "warning" {
1581 continue;
1582 }
1583
1584 let text = message_obj
1585 .get("message")
1586 .and_then(|m| m.as_str())
1587 .unwrap_or("unknown error")
1588 .to_string();
1589
1590 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1592 for span in spans {
1593 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1594 let is_primary = span
1595 .get("is_primary")
1596 .and_then(|p| p.as_bool())
1597 .unwrap_or(false);
1598
1599 if !is_primary {
1600 continue;
1601 }
1602
1603 if !file_str.ends_with(span_file)
1605 && !span_file.ends_with(&*file_str)
1606 && span_file != &*file_str
1607 {
1608 continue;
1609 }
1610
1611 let line_num =
1612 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1613 let col_num = span
1614 .get("column_start")
1615 .and_then(|c| c.as_u64())
1616 .unwrap_or(0) as u32;
1617
1618 errors.push(ValidationError {
1619 line: line_num,
1620 column: col_num,
1621 message: text.clone(),
1622 severity: level.to_string(),
1623 });
1624 }
1625 }
1626 }
1627 }
1628 errors
1629}
1630
1631fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1633 let mut errors = Vec::new();
1634 let file_str = file.to_string_lossy();
1635
1636 for line in stderr.lines() {
1637 let parts: Vec<&str> = line.splitn(4, ':').collect();
1639 if parts.len() < 3 {
1640 continue;
1641 }
1642
1643 let err_file = parts[0].trim();
1644 if !file_str.ends_with(err_file)
1645 && !err_file.ends_with(&*file_str)
1646 && err_file != &*file_str
1647 {
1648 continue;
1649 }
1650
1651 let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1652 let (col_num, message) = if parts.len() >= 4 {
1653 if let Ok(col) = parts[2].trim().parse::<u32>() {
1654 (col, parts[3].trim().to_string())
1655 } else {
1656 (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1658 }
1659 } else {
1660 (0, parts[2].trim().to_string())
1661 };
1662
1663 errors.push(ValidationError {
1664 line: line_num,
1665 column: col_num,
1666 message,
1667 severity: "error".to_string(),
1668 });
1669 }
1670 errors
1671}
1672
1673pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1682 let lang = match detect_language(path) {
1683 Some(l) => l,
1684 None => {
1685 log::debug!(
1686 "validate: {} (skipped: unsupported_language)",
1687 path.display()
1688 );
1689 return (Vec::new(), Some("unsupported_language".to_string()));
1690 }
1691 };
1692 if !has_checker_support(lang) {
1693 log::debug!(
1694 "validate: {} (skipped: unsupported_language)",
1695 path.display()
1696 );
1697 return (Vec::new(), Some("unsupported_language".to_string()));
1698 }
1699
1700 let (cmd, args) = match detect_checker_for_path(path, lang, config) {
1701 ToolDetection::Found(cmd, args) => (cmd, args),
1702 ToolDetection::NotConfigured => {
1703 log::debug!(
1704 "validate: {} (skipped: no_checker_configured)",
1705 path.display()
1706 );
1707 return (Vec::new(), Some("no_checker_configured".to_string()));
1708 }
1709 ToolDetection::NotInstalled { tool } => {
1710 crate::slog_warn!(
1711 "validate: {} (skipped: checker_not_installed: {})",
1712 path.display(),
1713 tool
1714 );
1715 return (Vec::new(), Some("checker_not_installed".to_string()));
1716 }
1717 };
1718
1719 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1720
1721 let working_dir = config.project_root.as_deref();
1723
1724 match run_external_tool_capture(
1725 &cmd,
1726 &arg_refs,
1727 working_dir,
1728 config.type_checker_timeout_secs,
1729 ) {
1730 Ok(result) => {
1731 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1732 log::debug!(
1733 "validate: {} ({}, {} errors)",
1734 path.display(),
1735 cmd,
1736 errors.len()
1737 );
1738 (errors, None)
1739 }
1740 Err(FormatError::Timeout { .. }) => {
1741 crate::slog_error!("validate: {} (skipped: timeout)", path.display());
1742 (Vec::new(), Some("timeout".to_string()))
1743 }
1744 Err(FormatError::NotFound { .. }) => {
1745 crate::slog_warn!(
1746 "validate: {} (skipped: checker_not_installed)",
1747 path.display()
1748 );
1749 (Vec::new(), Some("checker_not_installed".to_string()))
1750 }
1751 Err(FormatError::Failed { stderr, .. }) => {
1752 log::debug!(
1753 "validate: {} (skipped: error: {})",
1754 path.display(),
1755 stderr.lines().next().unwrap_or("unknown")
1756 );
1757 (Vec::new(), Some("error".to_string()))
1758 }
1759 Err(FormatError::UnsupportedLanguage) => {
1760 log::debug!(
1761 "validate: {} (skipped: unsupported_language)",
1762 path.display()
1763 );
1764 (Vec::new(), Some("unsupported_language".to_string()))
1765 }
1766 }
1767}
1768
1769#[cfg(test)]
1770mod tests {
1771 use super::*;
1772 use std::fs;
1773 use std::io::Write;
1774 use std::sync::{Mutex, MutexGuard, OnceLock};
1775
1776 fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
1783 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1784 let mutex = LOCK.get_or_init(|| Mutex::new(()));
1785 match mutex.lock() {
1788 Ok(guard) => guard,
1789 Err(poisoned) => poisoned.into_inner(),
1790 }
1791 }
1792
1793 #[test]
1794 fn run_external_tool_not_found() {
1795 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1796 assert!(result.is_err());
1797 match result.unwrap_err() {
1798 FormatError::NotFound { tool } => {
1799 assert_eq!(tool, "__nonexistent_tool_xyz__");
1800 }
1801 other => panic!("expected NotFound, got: {:?}", other),
1802 }
1803 }
1804
1805 #[test]
1806 fn run_external_tool_timeout_kills_subprocess() {
1807 let result = run_external_tool("sleep", &["60"], None, 1);
1809 assert!(result.is_err());
1810 match result.unwrap_err() {
1811 FormatError::Timeout { tool, timeout_secs } => {
1812 assert_eq!(tool, "sleep");
1813 assert_eq!(timeout_secs, 1);
1814 }
1815 other => panic!("expected Timeout, got: {:?}", other),
1816 }
1817 }
1818
1819 #[test]
1820 fn run_external_tool_success() {
1821 let result = run_external_tool("echo", &["hello"], None, 5);
1822 assert!(result.is_ok());
1823 let res = result.unwrap();
1824 assert_eq!(res.exit_code, 0);
1825 assert!(res.stdout.contains("hello"));
1826 }
1827
1828 #[cfg(unix)]
1829 #[test]
1830 fn format_helper_handles_large_stderr_without_deadlock() {
1831 let start = Instant::now();
1832 let result = run_external_tool_capture(
1833 "sh",
1834 &[
1835 "-c",
1836 "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
1837 ],
1838 None,
1839 2,
1840 )
1841 .expect("large stderr command should complete");
1842
1843 assert_eq!(result.exit_code, 0);
1844 assert!(
1845 result.stderr.len() >= 1024 * 1024,
1846 "expected full stderr capture, got {} bytes",
1847 result.stderr.len()
1848 );
1849 assert!(start.elapsed() < Duration::from_secs(2));
1850 }
1851
1852 #[test]
1853 fn run_external_tool_nonzero_exit() {
1854 let result = run_external_tool("false", &[], None, 5);
1856 assert!(result.is_err());
1857 match result.unwrap_err() {
1858 FormatError::Failed { tool, .. } => {
1859 assert_eq!(tool, "false");
1860 }
1861 other => panic!("expected Failed, got: {:?}", other),
1862 }
1863 }
1864
1865 #[test]
1866 fn auto_format_unsupported_language() {
1867 let dir = tempfile::tempdir().unwrap();
1868 let path = dir.path().join("file.txt");
1869 fs::write(&path, "hello").unwrap();
1870
1871 let config = Config::default();
1872 let (formatted, reason) = auto_format(&path, &config);
1873 assert!(!formatted);
1874 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1875 }
1876
1877 #[test]
1878 fn detect_formatter_rust_when_rustfmt_available() {
1879 let dir = tempfile::tempdir().unwrap();
1880 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1881 let path = dir.path().join("test.rs");
1882 let config = Config {
1883 project_root: Some(dir.path().to_path_buf()),
1884 ..Config::default()
1885 };
1886 let result = detect_formatter(&path, LangId::Rust, &config);
1887 if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
1888 let (cmd, args) = result.unwrap();
1889 assert_eq!(cmd, "rustfmt");
1890 assert!(args.iter().any(|a| a.ends_with("test.rs")));
1891 } else {
1892 assert!(result.is_none());
1893 }
1894 }
1895
1896 #[test]
1897 fn detect_formatter_go_mapping() {
1898 let dir = tempfile::tempdir().unwrap();
1899 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1900 let path = dir.path().join("main.go");
1901 let config = Config {
1902 project_root: Some(dir.path().to_path_buf()),
1903 ..Config::default()
1904 };
1905 let result = detect_formatter(&path, LangId::Go, &config);
1906 if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
1907 let (cmd, args) = result.unwrap();
1908 assert_eq!(cmd, "goimports");
1909 assert!(args.contains(&"-w".to_string()));
1910 } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
1911 let (cmd, args) = result.unwrap();
1912 assert_eq!(cmd, "gofmt");
1913 assert!(args.contains(&"-w".to_string()));
1914 } else {
1915 assert!(result.is_none());
1916 }
1917 }
1918
1919 #[test]
1920 fn detect_formatter_python_mapping() {
1921 let dir = tempfile::tempdir().unwrap();
1922 fs::write(dir.path().join("ruff.toml"), "").unwrap();
1923 let path = dir.path().join("main.py");
1924 let config = Config {
1925 project_root: Some(dir.path().to_path_buf()),
1926 ..Config::default()
1927 };
1928 let result = detect_formatter(&path, LangId::Python, &config);
1929 if ruff_format_available(config.project_root.as_deref()) {
1930 let (cmd, args) = result.unwrap();
1931 assert_eq!(cmd, "ruff");
1932 assert!(args.contains(&"format".to_string()));
1933 } else {
1934 assert!(result.is_none());
1935 }
1936 }
1937
1938 #[test]
1939 fn detect_formatter_no_config_returns_none() {
1940 let path = Path::new("test.ts");
1941 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1942 assert!(
1943 result.is_none(),
1944 "expected no formatter without project config"
1945 );
1946 }
1947
1948 #[cfg(unix)]
1949 #[test]
1950 fn detect_formatter_oxfmt_config_for_typescript_projects() {
1951 let _guard = tool_cache_test_lock();
1952 clear_tool_cache();
1953 let dir = tempfile::tempdir().unwrap();
1954 fs::write(dir.path().join(".oxfmtrc.json"), "{}\n").unwrap();
1955 let bin_dir = dir.path().join("node_modules").join(".bin");
1956 fs::create_dir_all(&bin_dir).unwrap();
1957 let fake = bin_dir.join("oxfmt");
1958 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1959 use std::os::unix::fs::PermissionsExt;
1960 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1961
1962 let path = dir.path().join("src/app.ts");
1963 let config = Config {
1964 project_root: Some(dir.path().to_path_buf()),
1965 ..Config::default()
1966 };
1967
1968 let (cmd, args) = detect_formatter(&path, LangId::TypeScript, &config).unwrap();
1969 assert!(cmd.ends_with("oxfmt"), "expected oxfmt, got {cmd}");
1970 assert_eq!(args[0], "--write");
1971 assert!(args.iter().any(|arg| arg.ends_with("src/app.ts")));
1972 }
1973
1974 #[cfg(unix)]
1980 #[test]
1981 fn detect_formatter_explicit_override() {
1982 let dir = tempfile::tempdir().unwrap();
1984 let bin_dir = dir.path().join("node_modules").join(".bin");
1985 fs::create_dir_all(&bin_dir).unwrap();
1986 use std::os::unix::fs::PermissionsExt;
1987 let fake = bin_dir.join("biome");
1988 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1989 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1990
1991 let path = Path::new("test.ts");
1992 let mut config = Config {
1993 project_root: Some(dir.path().to_path_buf()),
1994 ..Config::default()
1995 };
1996 config
1997 .formatter
1998 .insert("typescript".to_string(), "biome".to_string());
1999 let result = detect_formatter(path, LangId::TypeScript, &config);
2000 let (cmd, args) = result.unwrap();
2001 assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
2002 assert!(args.contains(&"format".to_string()));
2003 assert!(args.contains(&"--write".to_string()));
2004 }
2005
2006 #[cfg(unix)]
2007 #[test]
2008 fn detect_formatter_explicit_oxfmt_override() {
2009 let _guard = tool_cache_test_lock();
2010 clear_tool_cache();
2011 let dir = tempfile::tempdir().unwrap();
2012 let bin_dir = dir.path().join("node_modules").join(".bin");
2013 fs::create_dir_all(&bin_dir).unwrap();
2014 use std::os::unix::fs::PermissionsExt;
2015 let fake = bin_dir.join("oxfmt");
2016 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2017 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2018
2019 let path = Path::new("test.ts");
2020 let mut config = Config {
2021 project_root: Some(dir.path().to_path_buf()),
2022 ..Config::default()
2023 };
2024 config
2025 .formatter
2026 .insert("typescript".to_string(), "oxfmt".to_string());
2027
2028 let (cmd, args) = detect_formatter(path, LangId::TypeScript, &config).unwrap();
2029 assert!(cmd.contains("oxfmt"), "expected oxfmt in cmd, got: {cmd}");
2030 assert_eq!(args, vec!["--write".to_string(), "test.ts".to_string()]);
2031 }
2032
2033 #[test]
2034 fn resolve_tool_caches_positive_result_until_clear() {
2035 let _guard = tool_cache_test_lock();
2036 clear_tool_cache();
2037 let dir = tempfile::tempdir().unwrap();
2038 let bin_dir = dir.path().join("node_modules").join(".bin");
2039 fs::create_dir_all(&bin_dir).unwrap();
2040 let tool = bin_dir.join("aft-cache-hit-tool");
2041 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2042
2043 let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2044 assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
2045
2046 fs::remove_file(&tool).unwrap();
2047 let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2048 assert_eq!(cached, first);
2049
2050 clear_tool_cache();
2051 assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
2052 }
2053
2054 #[test]
2055 fn resolve_tool_caches_negative_result_until_clear() {
2056 let _guard = tool_cache_test_lock();
2057 clear_tool_cache();
2058 let dir = tempfile::tempdir().unwrap();
2059 let bin_dir = dir.path().join("node_modules").join(".bin");
2060 let tool = bin_dir.join("aft-cache-miss-tool");
2061
2062 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2063
2064 fs::create_dir_all(&bin_dir).unwrap();
2065 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2066 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2067
2068 clear_tool_cache();
2069 assert_eq!(
2070 resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
2071 Some(tool.to_string_lossy().as_ref())
2072 );
2073 }
2074
2075 #[test]
2076 fn auto_format_happy_path_rustfmt() {
2077 if resolve_tool("rustfmt", None).is_none() {
2078 crate::slog_warn!("skipping: rustfmt not available");
2079 return;
2080 }
2081
2082 let dir = tempfile::tempdir().unwrap();
2083 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2084 let path = dir.path().join("test.rs");
2085
2086 let mut f = fs::File::create(&path).unwrap();
2087 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
2088 drop(f);
2089
2090 let config = Config {
2091 project_root: Some(dir.path().to_path_buf()),
2092 ..Config::default()
2093 };
2094 let (formatted, reason) = auto_format(&path, &config);
2095 assert!(formatted, "expected formatting to succeed");
2096 assert!(reason.is_none());
2097
2098 let content = fs::read_to_string(&path).unwrap();
2099 assert!(
2100 !content.contains("fn main"),
2101 "expected rustfmt to fix spacing"
2102 );
2103 }
2104
2105 #[test]
2106 fn formatter_excluded_path_detects_biome_messages() {
2107 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";
2109 assert!(
2110 formatter_excluded_path(stderr),
2111 "expected biome exclusion stderr to be detected"
2112 );
2113 }
2114
2115 #[test]
2116 fn formatter_excluded_path_detects_prettier_messages() {
2117 let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
2120 assert!(
2121 formatter_excluded_path(stderr),
2122 "expected prettier exclusion stderr to be detected"
2123 );
2124 }
2125
2126 #[test]
2127 fn formatter_excluded_path_detects_oxfmt_messages() {
2128 assert!(formatter_excluded_path(
2129 "Expected at least one target file. All matched files may have been excluded by ignore rules."
2130 ));
2131 assert!(formatter_excluded_path(
2132 "No files found matching the given patterns."
2133 ));
2134 }
2135
2136 #[test]
2137 fn formatter_excluded_path_detects_ruff_messages() {
2138 let stderr = "warning: No Python files found under the given path(s).\n";
2140 assert!(
2141 formatter_excluded_path(stderr),
2142 "expected ruff exclusion stderr to be detected"
2143 );
2144 }
2145
2146 #[test]
2147 fn formatter_excluded_path_is_case_insensitive() {
2148 assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
2149 assert!(formatter_excluded_path("Ignored By The Configuration"));
2150 assert!(formatter_excluded_path("EXPECTED AT LEAST ONE TARGET FILE"));
2151 }
2152
2153 #[test]
2154 fn formatter_excluded_path_rejects_real_errors() {
2155 assert!(!formatter_excluded_path(""));
2158 assert!(!formatter_excluded_path("syntax error: unexpected token"));
2159 assert!(!formatter_excluded_path("formatter crashed: out of memory"));
2160 assert!(!formatter_excluded_path(
2161 "permission denied: /readonly/file"
2162 ));
2163 assert!(!formatter_excluded_path(
2164 "biome internal error: please report"
2165 ));
2166 }
2167
2168 #[test]
2169 fn parse_tsc_output_basic() {
2170 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";
2171 let file = Path::new("src/app.ts");
2172 let errors = parse_tsc_output(stdout, "", file);
2173 assert_eq!(errors.len(), 2);
2174 assert_eq!(errors[0].line, 10);
2175 assert_eq!(errors[0].column, 5);
2176 assert_eq!(errors[0].severity, "error");
2177 assert!(errors[0].message.contains("TS2322"));
2178 assert_eq!(errors[1].line, 20);
2179 }
2180
2181 #[test]
2182 fn parse_tsc_output_filters_other_files() {
2183 let stdout =
2184 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
2185 let file = Path::new("src/app.ts");
2186 let errors = parse_tsc_output(stdout, "", file);
2187 assert_eq!(errors.len(), 1);
2188 assert_eq!(errors[0].line, 5);
2189 }
2190
2191 #[test]
2192 fn parse_cargo_output_basic() {
2193 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}]}}"#;
2194 let file = Path::new("src/main.rs");
2195 let errors = parse_cargo_output(json_line, "", file);
2196 assert_eq!(errors.len(), 1);
2197 assert_eq!(errors[0].line, 10);
2198 assert_eq!(errors[0].column, 5);
2199 assert_eq!(errors[0].severity, "error");
2200 assert!(errors[0].message.contains("mismatched types"));
2201 }
2202
2203 #[test]
2204 fn parse_cargo_output_skips_notes() {
2205 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}]}}"#;
2207 let file = Path::new("src/main.rs");
2208 let errors = parse_cargo_output(json_line, "", file);
2209 assert_eq!(errors.len(), 0);
2210 }
2211
2212 #[test]
2213 fn parse_cargo_output_filters_other_files() {
2214 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}]}}"#;
2215 let file = Path::new("src/main.rs");
2216 let errors = parse_cargo_output(json_line, "", file);
2217 assert_eq!(errors.len(), 0);
2218 }
2219
2220 #[test]
2221 fn parse_go_vet_output_basic() {
2222 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2223 let file = Path::new("main.go");
2224 let errors = parse_go_vet_output(stderr, file);
2225 assert_eq!(errors.len(), 2);
2226 assert_eq!(errors[0].line, 10);
2227 assert_eq!(errors[0].column, 5);
2228 assert!(errors[0].message.contains("unreachable code"));
2229 assert_eq!(errors[1].line, 20);
2230 assert_eq!(errors[1].column, 0);
2231 }
2232
2233 #[test]
2234 fn parse_pyright_output_basic() {
2235 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2236 let file = Path::new("test.py");
2237 let errors = parse_pyright_output(stdout, file);
2238 assert_eq!(errors.len(), 1);
2239 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 10);
2241 assert_eq!(errors[0].severity, "error");
2242 assert!(errors[0].message.contains("Type error here"));
2243 }
2244
2245 #[test]
2246 fn validate_full_unsupported_language() {
2247 let dir = tempfile::tempdir().unwrap();
2248 let path = dir.path().join("file.txt");
2249 fs::write(&path, "hello").unwrap();
2250
2251 let config = Config::default();
2252 let (errors, reason) = validate_full(&path, &config);
2253 assert!(errors.is_empty());
2254 assert_eq!(reason.as_deref(), Some("unsupported_language"));
2255 }
2256
2257 #[test]
2258 fn detect_type_checker_rust() {
2259 let dir = tempfile::tempdir().unwrap();
2260 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2261 let path = dir.path().join("src/main.rs");
2262 let config = Config {
2263 project_root: Some(dir.path().to_path_buf()),
2264 ..Config::default()
2265 };
2266 let result = detect_type_checker(&path, LangId::Rust, &config);
2267 if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2268 let (cmd, args) = result.unwrap();
2269 assert_eq!(cmd, "cargo");
2270 assert!(args.contains(&"check".to_string()));
2271 } else {
2272 assert!(result.is_none());
2273 }
2274 }
2275
2276 #[test]
2277 fn detect_type_checker_go() {
2278 let dir = tempfile::tempdir().unwrap();
2279 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2280 let path = dir.path().join("main.go");
2281 let config = Config {
2282 project_root: Some(dir.path().to_path_buf()),
2283 ..Config::default()
2284 };
2285 let result = detect_type_checker(&path, LangId::Go, &config);
2286 if resolve_tool("go", config.project_root.as_deref()).is_some() {
2287 let (cmd, _args) = result.unwrap();
2288 assert!(cmd == "go" || cmd == "staticcheck");
2290 } else {
2291 assert!(result.is_none());
2292 }
2293 }
2294
2295 #[cfg(unix)]
2296 #[test]
2297 fn detect_type_checker_defaults_to_tsc_for_typescript() {
2298 let _guard = tool_cache_test_lock();
2299 clear_tool_cache();
2300 let dir = tempfile::tempdir().unwrap();
2301 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2302 let bin_dir = dir.path().join("node_modules").join(".bin");
2303 fs::create_dir_all(&bin_dir).unwrap();
2304 use std::os::unix::fs::PermissionsExt;
2305 let fake_tsc = bin_dir.join("tsc");
2306 fs::write(&fake_tsc, "#!/bin/sh\nexit 0").unwrap();
2307 fs::set_permissions(&fake_tsc, fs::Permissions::from_mode(0o755)).unwrap();
2308 let fake_tsgo = bin_dir.join("tsgo");
2309 fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2310 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2311
2312 let path = dir.path().join("src/app.ts");
2313 let config = Config {
2314 project_root: Some(dir.path().to_path_buf()),
2315 ..Config::default()
2316 };
2317
2318 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2319 assert!(cmd.ends_with("tsc"), "expected tsc by default, got: {cmd}");
2320 assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2321 }
2322
2323 #[cfg(unix)]
2324 #[test]
2325 fn detect_type_checker_uses_tsgo_when_explicitly_configured() {
2326 let _guard = tool_cache_test_lock();
2327 clear_tool_cache();
2328 let dir = tempfile::tempdir().unwrap();
2329 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2330 let bin_dir = dir.path().join("node_modules").join(".bin");
2331 fs::create_dir_all(&bin_dir).unwrap();
2332 use std::os::unix::fs::PermissionsExt;
2333 let fake_tsgo = bin_dir.join("tsgo");
2334 fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2335 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2336
2337 let path = dir.path().join("src/app.ts");
2338 let mut config = Config {
2339 project_root: Some(dir.path().to_path_buf()),
2340 ..Config::default()
2341 };
2342 config
2343 .checker
2344 .insert("typescript".to_string(), "tsgo".to_string());
2345
2346 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2347 assert!(cmd.ends_with("tsgo"), "expected tsgo, got: {cmd}");
2348 assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2349 }
2350
2351 #[cfg(unix)]
2352 #[test]
2353 fn validate_full_explicit_tsgo_parses_diagnostics() {
2354 let _guard = tool_cache_test_lock();
2355 clear_tool_cache();
2356 let dir = tempfile::tempdir().unwrap();
2357 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2358 let src_dir = dir.path().join("src");
2359 fs::create_dir_all(&src_dir).unwrap();
2360 let path = src_dir.join("app.ts");
2361 fs::write(&path, "const value: number = 'nope';\n").unwrap();
2362
2363 let bin_dir = dir.path().join("node_modules").join(".bin");
2364 fs::create_dir_all(&bin_dir).unwrap();
2365 use std::os::unix::fs::PermissionsExt;
2366 let fake_tsgo = bin_dir.join("tsgo");
2367 fs::write(
2368 &fake_tsgo,
2369 "#!/bin/sh\nif [ \"$1 $2 $3\" != \"--noEmit --pretty false\" ]; then echo \"bad args: $*\" >&2; exit 3; fi\nprintf '%s\n' \"src/app.ts(1,23): error TS2322: Type 'string' is not assignable to type 'number'.\"\nexit 2\n",
2370 )
2371 .unwrap();
2372 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2373
2374 let mut config = Config {
2375 project_root: Some(dir.path().to_path_buf()),
2376 ..Config::default()
2377 };
2378 config
2379 .checker
2380 .insert("typescript".to_string(), "tsgo".to_string());
2381
2382 let (errors, reason) = validate_full(&path, &config);
2383 assert_eq!(reason, None);
2384 assert_eq!(errors.len(), 1);
2385 assert_eq!(errors[0].line, 1);
2386 assert_eq!(errors[0].column, 23);
2387 assert!(errors[0].message.contains("TS2322"));
2388 }
2389
2390 #[test]
2391 fn run_external_tool_capture_nonzero_not_error() {
2392 let result = run_external_tool_capture("false", &[], None, 5);
2394 assert!(result.is_ok(), "capture should not error on non-zero exit");
2395 assert_eq!(result.unwrap().exit_code, 1);
2396 }
2397
2398 #[test]
2399 fn run_external_tool_capture_not_found() {
2400 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2401 assert!(result.is_err());
2402 match result.unwrap_err() {
2403 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2404 other => panic!("expected NotFound, got: {:?}", other),
2405 }
2406 }
2407
2408 #[cfg(unix)]
2412 #[test]
2413 fn well_known_search_paths_include_homebrew_cargo_go_and_local() {
2414 let home = std::ffi::OsString::from("/Users/test-home");
2415 let paths = well_known_search_paths("toolx", Some(&home));
2416 let strs: Vec<String> = paths
2417 .iter()
2418 .map(|p| p.to_string_lossy().into_owned())
2419 .collect();
2420 assert_eq!(strs[0], "/opt/homebrew/bin/toolx");
2423 assert_eq!(strs[1], "/usr/local/bin/toolx");
2424 assert_eq!(strs[2], "/Users/test-home/.cargo/bin/toolx");
2425 assert_eq!(strs[3], "/Users/test-home/go/bin/toolx");
2426 assert_eq!(strs[4], "/Users/test-home/.local/bin/toolx");
2427 assert_eq!(strs.len(), 5);
2428 }
2429
2430 #[cfg(unix)]
2431 #[test]
2432 fn well_known_search_paths_skips_home_when_unset() {
2433 let paths = well_known_search_paths("toolx", None);
2434 assert_eq!(paths.len(), 2);
2435 assert!(paths[0].ends_with("opt/homebrew/bin/toolx"));
2436 assert!(paths[1].ends_with("usr/local/bin/toolx"));
2437 }
2438
2439 #[cfg(unix)]
2440 #[test]
2441 fn try_well_known_path_lookup_in_finds_executable_file() {
2442 use std::os::unix::fs::PermissionsExt;
2443 let dir = tempfile::tempdir().unwrap();
2444 let bin_dir = dir.path().join("bin");
2445 fs::create_dir_all(&bin_dir).unwrap();
2446 let tool_path = bin_dir.join("toolx");
2447 fs::write(&tool_path, "#!/bin/sh\necho test").unwrap();
2448 let mut perms = fs::metadata(&tool_path).unwrap().permissions();
2449 perms.set_mode(0o755);
2450 fs::set_permissions(&tool_path, perms).unwrap();
2451
2452 let candidates = vec![
2453 dir.path().join("missing/toolx"),
2454 tool_path.clone(),
2455 dir.path().join("alt/toolx"),
2456 ];
2457 let found = try_well_known_path_lookup_in(&candidates);
2458 assert_eq!(found, Some(tool_path));
2459 }
2460
2461 #[cfg(unix)]
2462 #[test]
2463 fn try_well_known_path_lookup_in_skips_non_executable_file() {
2464 let dir = tempfile::tempdir().unwrap();
2465 let bin_dir = dir.path().join("bin");
2466 fs::create_dir_all(&bin_dir).unwrap();
2467 let tool_path = bin_dir.join("toolx");
2469 fs::write(&tool_path, "not a real tool").unwrap();
2470
2471 let found = try_well_known_path_lookup_in(&std::slice::from_ref(&tool_path));
2472 assert!(found.is_none(), "non-executable file should be skipped");
2473 }
2474
2475 #[cfg(unix)]
2476 #[test]
2477 fn try_well_known_path_lookup_in_skips_directories_and_missing_paths() {
2478 let dir = tempfile::tempdir().unwrap();
2479 let candidates = vec![dir.path().to_path_buf(), dir.path().join("does-not-exist")];
2481 assert!(try_well_known_path_lookup_in(&candidates).is_none());
2482 }
2483
2484 #[cfg(windows)]
2485 #[test]
2486 fn try_well_known_path_lookup_is_noop_on_windows() {
2487 assert!(try_well_known_path_lookup("biome").is_none());
2490 }
2491
2492 #[test]
2495 fn configured_tool_hint_does_not_claim_not_installed() {
2496 let hint = configured_tool_hint("biome", "biome.json");
2497 assert!(
2498 hint.contains("was not found on PATH or in common install locations"),
2499 "hint should explain the PATH miss: got {:?}",
2500 hint
2501 );
2502 assert!(
2503 !hint.contains("but not installed"),
2504 "hint must not claim the tool isn't installed: got {:?}",
2505 hint
2506 );
2507 }
2508
2509 #[test]
2510 fn install_hint_for_go_mentions_path() {
2511 let hint = install_hint("go");
2514 assert!(
2515 hint.contains("PATH"),
2516 "go install hint should mention PATH: got {:?}",
2517 hint
2518 );
2519 }
2520}