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 &[
585 ".prettierrc",
586 ".prettierrc.json",
587 ".prettierrc.yml",
588 ".prettierrc.yaml",
589 ".prettierrc.js",
590 ".prettierrc.cjs",
591 ".prettierrc.mjs",
592 ".prettierrc.toml",
593 "prettier.config.js",
594 "prettier.config.cjs",
595 "prettier.config.mjs",
596 ],
597 ) {
598 vec![ToolCandidate {
599 tool: "prettier".to_string(),
600 source: "Prettier config".to_string(),
601 args: vec!["--write".to_string(), file_str.to_string()],
602 required: true,
603 }]
604 } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
605 vec![ToolCandidate {
606 tool: "deno".to_string(),
607 source: "deno.json".to_string(),
608 args: vec!["fmt".to_string(), file_str.to_string()],
609 required: true,
610 }]
611 } else {
612 Vec::new()
613 }
614 }
615 LangId::Python => {
616 if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
617 || has_pyproject_tool(project_root, "ruff")
618 {
619 vec![ToolCandidate {
620 tool: "ruff".to_string(),
621 source: "ruff config".to_string(),
622 args: vec!["format".to_string(), file_str.to_string()],
623 required: true,
624 }]
625 } else if has_pyproject_tool(project_root, "black") {
626 vec![ToolCandidate {
627 tool: "black".to_string(),
628 source: "pyproject.toml".to_string(),
629 args: vec![file_str.to_string()],
630 required: true,
631 }]
632 } else {
633 Vec::new()
634 }
635 }
636 LangId::Rust => {
637 if has_project_config(project_root, &["Cargo.toml"]) {
638 vec![ToolCandidate {
639 tool: "rustfmt".to_string(),
640 source: "Cargo.toml".to_string(),
641 args: vec![file_str.to_string()],
642 required: true,
643 }]
644 } else {
645 Vec::new()
646 }
647 }
648 LangId::Go => {
649 if has_project_config(project_root, &["go.mod"]) {
650 vec![
651 ToolCandidate {
652 tool: "goimports".to_string(),
653 source: "go.mod".to_string(),
654 args: vec!["-w".to_string(), file_str.to_string()],
655 required: false,
656 },
657 ToolCandidate {
658 tool: "gofmt".to_string(),
659 source: "go.mod".to_string(),
660 args: vec!["-w".to_string(), file_str.to_string()],
661 required: true,
662 },
663 ]
664 } else {
665 Vec::new()
666 }
667 }
668 LangId::C
669 | LangId::Cpp
670 | LangId::Zig
671 | LangId::CSharp
672 | LangId::Bash
673 | LangId::Solidity
674 | LangId::Vue
675 | LangId::Json
676 | LangId::Scala
677 | LangId::Java
678 | LangId::Ruby
679 | LangId::Kotlin
680 | LangId::Swift
681 | LangId::Php
682 | LangId::Lua
683 | LangId::Perl => Vec::new(),
684 LangId::Html => Vec::new(),
685 LangId::Markdown => Vec::new(),
686 }
687}
688
689fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
690 let project_root = config.project_root.as_deref();
691 if let Some(preferred) = config.checker.get(lang_key(lang)) {
692 return explicit_checker_candidate(preferred, file_str);
693 }
694
695 match lang {
696 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
697 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
698 vec![ToolCandidate {
699 tool: "biome".to_string(),
700 source: "biome.json".to_string(),
701 args: vec!["check".to_string(), file_str.to_string()],
702 required: true,
703 }]
704 } else if has_project_config(project_root, &["tsconfig.json"]) {
705 vec![ToolCandidate {
706 tool: "tsc".to_string(),
707 source: "tsconfig.json".to_string(),
708 args: vec![
709 "--noEmit".to_string(),
710 "--pretty".to_string(),
711 "false".to_string(),
712 ],
713 required: true,
714 }]
715 } else {
716 Vec::new()
717 }
718 }
719 LangId::Python => {
720 if has_project_config(project_root, &["pyrightconfig.json"])
721 || has_pyproject_tool(project_root, "pyright")
722 {
723 vec![ToolCandidate {
724 tool: "pyright".to_string(),
725 source: "pyright config".to_string(),
726 args: vec!["--outputjson".to_string(), file_str.to_string()],
727 required: true,
728 }]
729 } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
730 || has_pyproject_tool(project_root, "ruff")
731 {
732 vec![ToolCandidate {
733 tool: "ruff".to_string(),
734 source: "ruff config".to_string(),
735 args: vec![
736 "check".to_string(),
737 "--output-format=json".to_string(),
738 file_str.to_string(),
739 ],
740 required: true,
741 }]
742 } else {
743 Vec::new()
744 }
745 }
746 LangId::Rust => {
747 if has_project_config(project_root, &["Cargo.toml"]) {
748 vec![ToolCandidate {
749 tool: "cargo".to_string(),
750 source: "Cargo.toml".to_string(),
751 args: vec!["check".to_string(), "--message-format=json".to_string()],
752 required: true,
753 }]
754 } else {
755 Vec::new()
756 }
757 }
758 LangId::Go => {
759 if has_project_config(project_root, &["go.mod"]) {
760 vec![
761 ToolCandidate {
762 tool: "staticcheck".to_string(),
763 source: "go.mod".to_string(),
764 args: vec![file_str.to_string()],
765 required: false,
766 },
767 ToolCandidate {
768 tool: "go".to_string(),
769 source: "go.mod".to_string(),
770 args: vec!["vet".to_string(), file_str.to_string()],
771 required: true,
772 },
773 ]
774 } else {
775 Vec::new()
776 }
777 }
778 LangId::C
779 | LangId::Cpp
780 | LangId::Zig
781 | LangId::CSharp
782 | LangId::Bash
783 | LangId::Solidity
784 | LangId::Vue
785 | LangId::Json
786 | LangId::Scala
787 | LangId::Java
788 | LangId::Ruby
789 | LangId::Kotlin
790 | LangId::Swift
791 | LangId::Php
792 | LangId::Lua
793 | LangId::Perl => Vec::new(),
794 LangId::Html => Vec::new(),
795 LangId::Markdown => Vec::new(),
796 }
797}
798
799fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
800 match name {
801 "none" | "off" | "false" => Vec::new(),
802 "biome" => vec![ToolCandidate {
803 tool: name.to_string(),
804 source: "formatter config".to_string(),
805 args: vec![
806 "format".to_string(),
807 "--write".to_string(),
808 file_str.to_string(),
809 ],
810 required: true,
811 }],
812 "prettier" => vec![ToolCandidate {
813 tool: name.to_string(),
814 source: "formatter config".to_string(),
815 args: vec!["--write".to_string(), file_str.to_string()],
816 required: true,
817 }],
818 "deno" => vec![ToolCandidate {
819 tool: name.to_string(),
820 source: "formatter config".to_string(),
821 args: vec!["fmt".to_string(), file_str.to_string()],
822 required: true,
823 }],
824 "ruff" => vec![ToolCandidate {
825 tool: name.to_string(),
826 source: "formatter config".to_string(),
827 args: vec!["format".to_string(), file_str.to_string()],
828 required: true,
829 }],
830 "black" | "rustfmt" => vec![ToolCandidate {
831 tool: name.to_string(),
832 source: "formatter config".to_string(),
833 args: vec![file_str.to_string()],
834 required: true,
835 }],
836 "goimports" | "gofmt" => vec![ToolCandidate {
837 tool: name.to_string(),
838 source: "formatter config".to_string(),
839 args: vec!["-w".to_string(), file_str.to_string()],
840 required: true,
841 }],
842 _ => Vec::new(),
843 }
844}
845
846fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
847 match name {
848 "none" | "off" | "false" => Vec::new(),
849 "tsc" => vec![ToolCandidate {
850 tool: name.to_string(),
851 source: "checker config".to_string(),
852 args: vec![
853 "--noEmit".to_string(),
854 "--pretty".to_string(),
855 "false".to_string(),
856 ],
857 required: true,
858 }],
859 "cargo" => vec![ToolCandidate {
860 tool: name.to_string(),
861 source: "checker config".to_string(),
862 args: vec!["check".to_string(), "--message-format=json".to_string()],
863 required: true,
864 }],
865 "go" => vec![ToolCandidate {
866 tool: name.to_string(),
867 source: "checker config".to_string(),
868 args: vec!["vet".to_string(), file_str.to_string()],
869 required: true,
870 }],
871 "biome" => vec![ToolCandidate {
872 tool: name.to_string(),
873 source: "checker config".to_string(),
874 args: vec!["check".to_string(), file_str.to_string()],
875 required: true,
876 }],
877 "pyright" => vec![ToolCandidate {
878 tool: name.to_string(),
879 source: "checker config".to_string(),
880 args: vec!["--outputjson".to_string(), file_str.to_string()],
881 required: true,
882 }],
883 "ruff" => vec![ToolCandidate {
884 tool: name.to_string(),
885 source: "checker config".to_string(),
886 args: vec![
887 "check".to_string(),
888 "--output-format=json".to_string(),
889 file_str.to_string(),
890 ],
891 required: true,
892 }],
893 "staticcheck" => vec![ToolCandidate {
894 tool: name.to_string(),
895 source: "checker config".to_string(),
896 args: vec![file_str.to_string()],
897 required: true,
898 }],
899 _ => Vec::new(),
900 }
901}
902
903fn resolve_tool_candidates(
904 candidates: Vec<ToolCandidate>,
905 project_root: Option<&Path>,
906) -> ToolDetection {
907 if candidates.is_empty() {
908 return ToolDetection::NotConfigured;
909 }
910
911 let mut missing_required = None;
912 for candidate in candidates {
913 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
914 return ToolDetection::Found(command, candidate.args);
915 }
916 if candidate.required && missing_required.is_none() {
917 missing_required = Some(candidate.tool);
918 }
919 }
920
921 match missing_required {
922 Some(tool) => ToolDetection::NotInstalled { tool },
923 None => ToolDetection::NotConfigured,
924 }
925}
926
927fn checker_command(candidate: &ToolCandidate, resolved: String) -> String {
928 match candidate.tool.as_str() {
929 "tsc" => resolved,
930 "cargo" => "cargo".to_string(),
931 "go" => "go".to_string(),
932 _ => resolved,
933 }
934}
935
936fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
937 if candidate.tool == "tsc" {
938 vec![
939 "--noEmit".to_string(),
940 "--pretty".to_string(),
941 "false".to_string(),
942 ]
943 } else {
944 candidate.args.clone()
945 }
946}
947
948fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
949 let file_str = path.to_string_lossy().to_string();
950 resolve_tool_candidates(
951 formatter_candidates(lang, config, &file_str),
952 config.project_root.as_deref(),
953 )
954}
955
956fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
957 let file_str = path.to_string_lossy().to_string();
958 let candidates = checker_candidates(lang, config, &file_str);
959 if candidates.is_empty() {
960 return ToolDetection::NotConfigured;
961 }
962
963 let project_root = config.project_root.as_deref();
964 let mut missing_required = None;
965 for candidate in candidates {
966 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
967 return ToolDetection::Found(
968 checker_command(&candidate, command),
969 checker_args(&candidate),
970 );
971 }
972 if candidate.required && missing_required.is_none() {
973 missing_required = Some(candidate.tool);
974 }
975 }
976
977 match missing_required {
978 Some(tool) => ToolDetection::NotInstalled { tool },
979 None => ToolDetection::NotConfigured,
980 }
981}
982
983fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
984 crate::callgraph::walk_project_files(project_root)
985 .filter_map(|path| detect_language(&path))
986 .collect()
987}
988
989fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
990 let filename = match lang {
991 LangId::TypeScript => "aft-tool-detection.ts",
992 LangId::Tsx => "aft-tool-detection.tsx",
993 LangId::JavaScript => "aft-tool-detection.js",
994 LangId::Python => "aft-tool-detection.py",
995 LangId::Rust => "aft_tool_detection.rs",
996 LangId::Go => "aft_tool_detection.go",
997 LangId::C => "aft_tool_detection.c",
998 LangId::Cpp => "aft_tool_detection.cpp",
999 LangId::Zig => "aft_tool_detection.zig",
1000 LangId::CSharp => "aft_tool_detection.cs",
1001 LangId::Bash => "aft_tool_detection.sh",
1002 LangId::Solidity => "aft_tool_detection.sol",
1003 LangId::Vue => "aft-tool-detection.vue",
1004 LangId::Json => "aft-tool-detection.json",
1005 LangId::Scala => "aft-tool-detection.scala",
1006 LangId::Java => "aft-tool-detection.java",
1007 LangId::Ruby => "aft-tool-detection.rb",
1008 LangId::Kotlin => "aft-tool-detection.kt",
1009 LangId::Swift => "aft-tool-detection.swift",
1010 LangId::Php => "aft-tool-detection.php",
1011 LangId::Lua => "aft-tool-detection.lua",
1012 LangId::Perl => "aft-tool-detection.pl",
1013 LangId::Html => "aft-tool-detection.html",
1014 LangId::Markdown => "aft-tool-detection.md",
1015 };
1016 project_root.join(filename)
1017}
1018
1019pub(crate) fn install_hint(tool: &str) -> String {
1020 match tool {
1021 "biome" => {
1022 "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
1023 }
1024 "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
1025 "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
1026 "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
1027 "ruff" => {
1028 "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
1029 }
1030 "black" => {
1031 "Install: `pip install black` or your Python package manager equivalent.".to_string()
1032 }
1033 "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
1034 "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
1035 "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
1036 "go" => [
1037 "Install Go from https://go.dev/dl/, or — if it's already installed —",
1038 "ensure its bin directory is on PATH (Homebrew typically uses",
1039 "/opt/homebrew/bin on Apple Silicon, /usr/local/bin on Intel macOS).",
1040 "GUI-launched editors often don't inherit login-shell PATH.",
1041 ]
1042 .join(" "),
1043 "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
1044 "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
1045 "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
1046 "typescript-language-server" => {
1047 "Install: `npm install -g typescript-language-server typescript`".to_string()
1048 }
1049 "deno" => "Install Deno from https://deno.com/.".to_string(),
1050 "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
1051 "staticcheck" => {
1052 "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
1053 }
1054 other => format!("Install `{other}` and ensure it is on PATH."),
1055 }
1056}
1057
1058fn configured_tool_hint(tool: &str, source: &str) -> String {
1059 format!(
1069 "{tool} is configured in {source} but was not found on PATH or in common install locations. {}",
1070 install_hint(tool)
1071 )
1072}
1073
1074fn missing_tool_warning(
1075 kind: &str,
1076 language: &str,
1077 candidate: &ToolCandidate,
1078 project_root: Option<&Path>,
1079) -> Option<MissingTool> {
1080 if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
1081 return None;
1082 }
1083
1084 Some(MissingTool {
1085 kind: kind.to_string(),
1086 language: language.to_string(),
1087 tool: candidate.tool.clone(),
1088 hint: configured_tool_hint(&candidate.tool, &candidate.source),
1089 })
1090}
1091
1092pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
1094 let languages = languages_in_project(project_root);
1095 let mut warnings = Vec::new();
1096 let mut seen = HashSet::new();
1097
1098 for lang in languages {
1099 let language = lang_key(lang);
1100 let placeholder = placeholder_file_for_language(project_root, lang);
1101 let file_str = placeholder.to_string_lossy().to_string();
1102
1103 for candidate in formatter_candidates(lang, config, &file_str) {
1104 if let Some(warning) = missing_tool_warning(
1105 "formatter_not_installed",
1106 language,
1107 &candidate,
1108 config.project_root.as_deref(),
1109 ) {
1110 if seen.insert((
1111 warning.kind.clone(),
1112 warning.language.clone(),
1113 warning.tool.clone(),
1114 )) {
1115 warnings.push(warning);
1116 }
1117 }
1118 }
1119
1120 for candidate in checker_candidates(lang, config, &file_str) {
1121 if let Some(warning) = missing_tool_warning(
1122 "checker_not_installed",
1123 language,
1124 &candidate,
1125 config.project_root.as_deref(),
1126 ) {
1127 if seen.insert((
1128 warning.kind.clone(),
1129 warning.language.clone(),
1130 warning.tool.clone(),
1131 )) {
1132 warnings.push(warning);
1133 }
1134 }
1135 }
1136 }
1137
1138 warnings.sort_by(|left, right| {
1139 (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1140 });
1141 warnings
1142}
1143
1144pub fn detect_formatter(
1154 path: &Path,
1155 lang: LangId,
1156 config: &Config,
1157) -> Option<(String, Vec<String>)> {
1158 match detect_formatter_for_path(path, lang, config) {
1159 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1160 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1161 }
1162}
1163
1164fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1166 let root = match project_root {
1167 Some(r) => r,
1168 None => return false,
1169 };
1170 filenames.iter().any(|f| root.join(f).exists())
1171}
1172
1173fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1175 let root = match project_root {
1176 Some(r) => r,
1177 None => return false,
1178 };
1179 let pyproject = root.join("pyproject.toml");
1180 if !pyproject.exists() {
1181 return false;
1182 }
1183 match std::fs::read_to_string(&pyproject) {
1184 Ok(content) => {
1185 let pattern = format!("[tool.{}]", tool_name);
1186 content.contains(&pattern)
1187 }
1188 Err(_) => false,
1189 }
1190}
1191
1192fn formatter_excluded_path(stderr: &str) -> bool {
1211 let s = stderr.to_lowercase();
1212 s.contains("no files were processed")
1213 || s.contains("ignored by the configuration")
1214 || s.contains("no files matching the pattern")
1215 || s.contains("no python files found")
1216}
1217
1218pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1239 if !config.format_on_edit {
1241 return (false, Some("no_formatter_configured".to_string()));
1242 }
1243
1244 let lang = match detect_language(path) {
1245 Some(l) => l,
1246 None => {
1247 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1248 return (false, Some("unsupported_language".to_string()));
1249 }
1250 };
1251 if !has_formatter_support(lang) {
1252 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1253 return (false, Some("unsupported_language".to_string()));
1254 }
1255
1256 let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1257 ToolDetection::Found(cmd, args) => (cmd, args),
1258 ToolDetection::NotConfigured => {
1259 log::debug!(
1260 "format: {} (skipped: no_formatter_configured)",
1261 path.display()
1262 );
1263 return (false, Some("no_formatter_configured".to_string()));
1264 }
1265 ToolDetection::NotInstalled { tool } => {
1266 crate::slog_warn!(
1267 "format: {} (skipped: formatter_not_installed: {})",
1268 path.display(),
1269 tool
1270 );
1271 return (false, Some("formatter_not_installed".to_string()));
1272 }
1273 };
1274
1275 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1276
1277 let working_dir = config.project_root.as_deref();
1284
1285 match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1286 Ok(_) => {
1287 crate::slog_info!("format: {} ({})", path.display(), cmd);
1288 (true, None)
1289 }
1290 Err(FormatError::Timeout { .. }) => {
1291 crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1292 (false, Some("timeout".to_string()))
1293 }
1294 Err(FormatError::NotFound { .. }) => {
1295 crate::slog_warn!(
1296 "format: {} (skipped: formatter_not_installed)",
1297 path.display()
1298 );
1299 (false, Some("formatter_not_installed".to_string()))
1300 }
1301 Err(FormatError::Failed { stderr, .. }) => {
1302 if formatter_excluded_path(&stderr) {
1314 crate::slog_info!(
1315 "format: {} (skipped: formatter_excluded_path; stderr: {})",
1316 path.display(),
1317 stderr.lines().next().unwrap_or("").trim()
1318 );
1319 return (false, Some("formatter_excluded_path".to_string()));
1320 }
1321 crate::slog_warn!(
1322 "format: {} (skipped: error: {})",
1323 path.display(),
1324 stderr.lines().next().unwrap_or("unknown").trim()
1325 );
1326 (false, Some("error".to_string()))
1327 }
1328 Err(FormatError::UnsupportedLanguage) => {
1329 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1330 (false, Some("unsupported_language".to_string()))
1331 }
1332 }
1333}
1334
1335pub fn run_external_tool_capture(
1342 command: &str,
1343 args: &[&str],
1344 working_dir: Option<&Path>,
1345 timeout_secs: u32,
1346) -> Result<ExternalToolResult, FormatError> {
1347 let mut cmd = Command::new(command);
1348 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1349
1350 if let Some(dir) = working_dir {
1351 cmd.current_dir(dir);
1352 }
1353
1354 isolate_in_process_group(&mut cmd);
1355
1356 let child = match cmd.spawn() {
1357 Ok(c) => c,
1358 Err(e) if e.kind() == ErrorKind::NotFound => {
1359 return Err(FormatError::NotFound {
1360 tool: command.to_string(),
1361 });
1362 }
1363 Err(e) => {
1364 return Err(FormatError::Failed {
1365 tool: command.to_string(),
1366 stderr: e.to_string(),
1367 });
1368 }
1369 };
1370
1371 let outcome = wait_with_timeout(child, command, timeout_secs)?;
1372 Ok(ExternalToolResult {
1373 stdout: outcome.stdout,
1374 stderr: outcome.stderr,
1375 exit_code: outcome.status.code().unwrap_or(-1),
1376 })
1377}
1378
1379#[derive(Debug, Clone, serde::Serialize)]
1385pub struct ValidationError {
1386 pub line: u32,
1387 pub column: u32,
1388 pub message: String,
1389 pub severity: String,
1390}
1391
1392pub fn detect_type_checker(
1403 path: &Path,
1404 lang: LangId,
1405 config: &Config,
1406) -> Option<(String, Vec<String>)> {
1407 match detect_checker_for_path(path, lang, config) {
1408 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1409 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1410 }
1411}
1412
1413pub fn parse_checker_output(
1418 stdout: &str,
1419 stderr: &str,
1420 file: &Path,
1421 checker: &str,
1422) -> Vec<ValidationError> {
1423 let checker_name = Path::new(checker)
1424 .file_name()
1425 .and_then(|name| name.to_str())
1426 .unwrap_or(checker);
1427 match checker_name {
1428 "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
1429 "pyright" => parse_pyright_output(stdout, file),
1430 "cargo" => parse_cargo_output(stdout, stderr, file),
1431 "go" => parse_go_vet_output(stderr, file),
1432 _ => Vec::new(),
1433 }
1434}
1435
1436fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1438 let mut errors = Vec::new();
1439 let file_str = file.to_string_lossy();
1440 let combined = format!("{}{}", stdout, stderr);
1442 for line in combined.lines() {
1443 if let Some((loc, rest)) = line.split_once("): ") {
1446 let file_part = loc.split('(').next().unwrap_or("");
1448 if !file_str.ends_with(file_part)
1449 && !file_part.ends_with(&*file_str)
1450 && file_part != &*file_str
1451 {
1452 continue;
1453 }
1454
1455 let coords = loc.split('(').last().unwrap_or("");
1457 let parts: Vec<&str> = coords.split(',').collect();
1458 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1459 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1460
1461 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1463 ("error".to_string(), msg.to_string())
1464 } else if let Some(msg) = rest.strip_prefix("warning ") {
1465 ("warning".to_string(), msg.to_string())
1466 } else {
1467 ("error".to_string(), rest.to_string())
1468 };
1469
1470 errors.push(ValidationError {
1471 line: line_num,
1472 column: col_num,
1473 message,
1474 severity,
1475 });
1476 }
1477 }
1478 errors
1479}
1480
1481fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1483 let mut errors = Vec::new();
1484 let file_str = file.to_string_lossy();
1485
1486 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1488 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1489 for diag in diags {
1490 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1492 if !diag_file.is_empty()
1493 && !file_str.ends_with(diag_file)
1494 && !diag_file.ends_with(&*file_str)
1495 && diag_file != &*file_str
1496 {
1497 continue;
1498 }
1499
1500 let line_num = diag
1501 .get("range")
1502 .and_then(|r| r.get("start"))
1503 .and_then(|s| s.get("line"))
1504 .and_then(|l| l.as_u64())
1505 .unwrap_or(0) as u32;
1506 let col_num = diag
1507 .get("range")
1508 .and_then(|r| r.get("start"))
1509 .and_then(|s| s.get("character"))
1510 .and_then(|c| c.as_u64())
1511 .unwrap_or(0) as u32;
1512 let message = diag
1513 .get("message")
1514 .and_then(|m| m.as_str())
1515 .unwrap_or("unknown error")
1516 .to_string();
1517 let severity = diag
1518 .get("severity")
1519 .and_then(|s| s.as_str())
1520 .unwrap_or("error")
1521 .to_lowercase();
1522
1523 errors.push(ValidationError {
1524 line: line_num + 1, column: col_num,
1526 message,
1527 severity,
1528 });
1529 }
1530 }
1531 }
1532 errors
1533}
1534
1535fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1537 let mut errors = Vec::new();
1538 let file_str = file.to_string_lossy();
1539
1540 for line in stdout.lines() {
1541 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1542 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1543 continue;
1544 }
1545 let message_obj = match msg.get("message") {
1546 Some(m) => m,
1547 None => continue,
1548 };
1549
1550 let level = message_obj
1551 .get("level")
1552 .and_then(|l| l.as_str())
1553 .unwrap_or("error");
1554
1555 if level != "error" && level != "warning" {
1557 continue;
1558 }
1559
1560 let text = message_obj
1561 .get("message")
1562 .and_then(|m| m.as_str())
1563 .unwrap_or("unknown error")
1564 .to_string();
1565
1566 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1568 for span in spans {
1569 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1570 let is_primary = span
1571 .get("is_primary")
1572 .and_then(|p| p.as_bool())
1573 .unwrap_or(false);
1574
1575 if !is_primary {
1576 continue;
1577 }
1578
1579 if !file_str.ends_with(span_file)
1581 && !span_file.ends_with(&*file_str)
1582 && span_file != &*file_str
1583 {
1584 continue;
1585 }
1586
1587 let line_num =
1588 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1589 let col_num = span
1590 .get("column_start")
1591 .and_then(|c| c.as_u64())
1592 .unwrap_or(0) as u32;
1593
1594 errors.push(ValidationError {
1595 line: line_num,
1596 column: col_num,
1597 message: text.clone(),
1598 severity: level.to_string(),
1599 });
1600 }
1601 }
1602 }
1603 }
1604 errors
1605}
1606
1607fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1609 let mut errors = Vec::new();
1610 let file_str = file.to_string_lossy();
1611
1612 for line in stderr.lines() {
1613 let parts: Vec<&str> = line.splitn(4, ':').collect();
1615 if parts.len() < 3 {
1616 continue;
1617 }
1618
1619 let err_file = parts[0].trim();
1620 if !file_str.ends_with(err_file)
1621 && !err_file.ends_with(&*file_str)
1622 && err_file != &*file_str
1623 {
1624 continue;
1625 }
1626
1627 let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1628 let (col_num, message) = if parts.len() >= 4 {
1629 if let Ok(col) = parts[2].trim().parse::<u32>() {
1630 (col, parts[3].trim().to_string())
1631 } else {
1632 (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1634 }
1635 } else {
1636 (0, parts[2].trim().to_string())
1637 };
1638
1639 errors.push(ValidationError {
1640 line: line_num,
1641 column: col_num,
1642 message,
1643 severity: "error".to_string(),
1644 });
1645 }
1646 errors
1647}
1648
1649pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1658 let lang = match detect_language(path) {
1659 Some(l) => l,
1660 None => {
1661 log::debug!(
1662 "validate: {} (skipped: unsupported_language)",
1663 path.display()
1664 );
1665 return (Vec::new(), Some("unsupported_language".to_string()));
1666 }
1667 };
1668 if !has_checker_support(lang) {
1669 log::debug!(
1670 "validate: {} (skipped: unsupported_language)",
1671 path.display()
1672 );
1673 return (Vec::new(), Some("unsupported_language".to_string()));
1674 }
1675
1676 let (cmd, args) = match detect_checker_for_path(path, lang, config) {
1677 ToolDetection::Found(cmd, args) => (cmd, args),
1678 ToolDetection::NotConfigured => {
1679 log::debug!(
1680 "validate: {} (skipped: no_checker_configured)",
1681 path.display()
1682 );
1683 return (Vec::new(), Some("no_checker_configured".to_string()));
1684 }
1685 ToolDetection::NotInstalled { tool } => {
1686 crate::slog_warn!(
1687 "validate: {} (skipped: checker_not_installed: {})",
1688 path.display(),
1689 tool
1690 );
1691 return (Vec::new(), Some("checker_not_installed".to_string()));
1692 }
1693 };
1694
1695 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1696
1697 let working_dir = config.project_root.as_deref();
1699
1700 match run_external_tool_capture(
1701 &cmd,
1702 &arg_refs,
1703 working_dir,
1704 config.type_checker_timeout_secs,
1705 ) {
1706 Ok(result) => {
1707 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1708 log::debug!(
1709 "validate: {} ({}, {} errors)",
1710 path.display(),
1711 cmd,
1712 errors.len()
1713 );
1714 (errors, None)
1715 }
1716 Err(FormatError::Timeout { .. }) => {
1717 crate::slog_error!("validate: {} (skipped: timeout)", path.display());
1718 (Vec::new(), Some("timeout".to_string()))
1719 }
1720 Err(FormatError::NotFound { .. }) => {
1721 crate::slog_warn!(
1722 "validate: {} (skipped: checker_not_installed)",
1723 path.display()
1724 );
1725 (Vec::new(), Some("checker_not_installed".to_string()))
1726 }
1727 Err(FormatError::Failed { stderr, .. }) => {
1728 log::debug!(
1729 "validate: {} (skipped: error: {})",
1730 path.display(),
1731 stderr.lines().next().unwrap_or("unknown")
1732 );
1733 (Vec::new(), Some("error".to_string()))
1734 }
1735 Err(FormatError::UnsupportedLanguage) => {
1736 log::debug!(
1737 "validate: {} (skipped: unsupported_language)",
1738 path.display()
1739 );
1740 (Vec::new(), Some("unsupported_language".to_string()))
1741 }
1742 }
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747 use super::*;
1748 use std::fs;
1749 use std::io::Write;
1750 use std::sync::{Mutex, MutexGuard, OnceLock};
1751
1752 fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
1759 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1760 let mutex = LOCK.get_or_init(|| Mutex::new(()));
1761 match mutex.lock() {
1764 Ok(guard) => guard,
1765 Err(poisoned) => poisoned.into_inner(),
1766 }
1767 }
1768
1769 #[test]
1770 fn run_external_tool_not_found() {
1771 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1772 assert!(result.is_err());
1773 match result.unwrap_err() {
1774 FormatError::NotFound { tool } => {
1775 assert_eq!(tool, "__nonexistent_tool_xyz__");
1776 }
1777 other => panic!("expected NotFound, got: {:?}", other),
1778 }
1779 }
1780
1781 #[test]
1782 fn run_external_tool_timeout_kills_subprocess() {
1783 let result = run_external_tool("sleep", &["60"], None, 1);
1785 assert!(result.is_err());
1786 match result.unwrap_err() {
1787 FormatError::Timeout { tool, timeout_secs } => {
1788 assert_eq!(tool, "sleep");
1789 assert_eq!(timeout_secs, 1);
1790 }
1791 other => panic!("expected Timeout, got: {:?}", other),
1792 }
1793 }
1794
1795 #[test]
1796 fn run_external_tool_success() {
1797 let result = run_external_tool("echo", &["hello"], None, 5);
1798 assert!(result.is_ok());
1799 let res = result.unwrap();
1800 assert_eq!(res.exit_code, 0);
1801 assert!(res.stdout.contains("hello"));
1802 }
1803
1804 #[cfg(unix)]
1805 #[test]
1806 fn format_helper_handles_large_stderr_without_deadlock() {
1807 let start = Instant::now();
1808 let result = run_external_tool_capture(
1809 "sh",
1810 &[
1811 "-c",
1812 "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
1813 ],
1814 None,
1815 2,
1816 )
1817 .expect("large stderr command should complete");
1818
1819 assert_eq!(result.exit_code, 0);
1820 assert!(
1821 result.stderr.len() >= 1024 * 1024,
1822 "expected full stderr capture, got {} bytes",
1823 result.stderr.len()
1824 );
1825 assert!(start.elapsed() < Duration::from_secs(2));
1826 }
1827
1828 #[test]
1829 fn run_external_tool_nonzero_exit() {
1830 let result = run_external_tool("false", &[], None, 5);
1832 assert!(result.is_err());
1833 match result.unwrap_err() {
1834 FormatError::Failed { tool, .. } => {
1835 assert_eq!(tool, "false");
1836 }
1837 other => panic!("expected Failed, got: {:?}", other),
1838 }
1839 }
1840
1841 #[test]
1842 fn auto_format_unsupported_language() {
1843 let dir = tempfile::tempdir().unwrap();
1844 let path = dir.path().join("file.txt");
1845 fs::write(&path, "hello").unwrap();
1846
1847 let config = Config::default();
1848 let (formatted, reason) = auto_format(&path, &config);
1849 assert!(!formatted);
1850 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1851 }
1852
1853 #[test]
1854 fn detect_formatter_rust_when_rustfmt_available() {
1855 let dir = tempfile::tempdir().unwrap();
1856 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1857 let path = dir.path().join("test.rs");
1858 let config = Config {
1859 project_root: Some(dir.path().to_path_buf()),
1860 ..Config::default()
1861 };
1862 let result = detect_formatter(&path, LangId::Rust, &config);
1863 if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
1864 let (cmd, args) = result.unwrap();
1865 assert_eq!(cmd, "rustfmt");
1866 assert!(args.iter().any(|a| a.ends_with("test.rs")));
1867 } else {
1868 assert!(result.is_none());
1869 }
1870 }
1871
1872 #[test]
1873 fn detect_formatter_go_mapping() {
1874 let dir = tempfile::tempdir().unwrap();
1875 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1876 let path = dir.path().join("main.go");
1877 let config = Config {
1878 project_root: Some(dir.path().to_path_buf()),
1879 ..Config::default()
1880 };
1881 let result = detect_formatter(&path, LangId::Go, &config);
1882 if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
1883 let (cmd, args) = result.unwrap();
1884 assert_eq!(cmd, "goimports");
1885 assert!(args.contains(&"-w".to_string()));
1886 } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
1887 let (cmd, args) = result.unwrap();
1888 assert_eq!(cmd, "gofmt");
1889 assert!(args.contains(&"-w".to_string()));
1890 } else {
1891 assert!(result.is_none());
1892 }
1893 }
1894
1895 #[test]
1896 fn detect_formatter_python_mapping() {
1897 let dir = tempfile::tempdir().unwrap();
1898 fs::write(dir.path().join("ruff.toml"), "").unwrap();
1899 let path = dir.path().join("main.py");
1900 let config = Config {
1901 project_root: Some(dir.path().to_path_buf()),
1902 ..Config::default()
1903 };
1904 let result = detect_formatter(&path, LangId::Python, &config);
1905 if ruff_format_available(config.project_root.as_deref()) {
1906 let (cmd, args) = result.unwrap();
1907 assert_eq!(cmd, "ruff");
1908 assert!(args.contains(&"format".to_string()));
1909 } else {
1910 assert!(result.is_none());
1911 }
1912 }
1913
1914 #[test]
1915 fn detect_formatter_no_config_returns_none() {
1916 let path = Path::new("test.ts");
1917 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1918 assert!(
1919 result.is_none(),
1920 "expected no formatter without project config"
1921 );
1922 }
1923
1924 #[cfg(unix)]
1930 #[test]
1931 fn detect_formatter_explicit_override() {
1932 let dir = tempfile::tempdir().unwrap();
1934 let bin_dir = dir.path().join("node_modules").join(".bin");
1935 fs::create_dir_all(&bin_dir).unwrap();
1936 use std::os::unix::fs::PermissionsExt;
1937 let fake = bin_dir.join("biome");
1938 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1939 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1940
1941 let path = Path::new("test.ts");
1942 let mut config = Config {
1943 project_root: Some(dir.path().to_path_buf()),
1944 ..Config::default()
1945 };
1946 config
1947 .formatter
1948 .insert("typescript".to_string(), "biome".to_string());
1949 let result = detect_formatter(path, LangId::TypeScript, &config);
1950 let (cmd, args) = result.unwrap();
1951 assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1952 assert!(args.contains(&"format".to_string()));
1953 assert!(args.contains(&"--write".to_string()));
1954 }
1955
1956 #[test]
1957 fn resolve_tool_caches_positive_result_until_clear() {
1958 let _guard = tool_cache_test_lock();
1959 clear_tool_cache();
1960 let dir = tempfile::tempdir().unwrap();
1961 let bin_dir = dir.path().join("node_modules").join(".bin");
1962 fs::create_dir_all(&bin_dir).unwrap();
1963 let tool = bin_dir.join("aft-cache-hit-tool");
1964 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1965
1966 let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1967 assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
1968
1969 fs::remove_file(&tool).unwrap();
1970 let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1971 assert_eq!(cached, first);
1972
1973 clear_tool_cache();
1974 assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
1975 }
1976
1977 #[test]
1978 fn resolve_tool_caches_negative_result_until_clear() {
1979 let _guard = tool_cache_test_lock();
1980 clear_tool_cache();
1981 let dir = tempfile::tempdir().unwrap();
1982 let bin_dir = dir.path().join("node_modules").join(".bin");
1983 let tool = bin_dir.join("aft-cache-miss-tool");
1984
1985 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1986
1987 fs::create_dir_all(&bin_dir).unwrap();
1988 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1989 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1990
1991 clear_tool_cache();
1992 assert_eq!(
1993 resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
1994 Some(tool.to_string_lossy().as_ref())
1995 );
1996 }
1997
1998 #[test]
1999 fn auto_format_happy_path_rustfmt() {
2000 if resolve_tool("rustfmt", None).is_none() {
2001 crate::slog_warn!("skipping: rustfmt not available");
2002 return;
2003 }
2004
2005 let dir = tempfile::tempdir().unwrap();
2006 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2007 let path = dir.path().join("test.rs");
2008
2009 let mut f = fs::File::create(&path).unwrap();
2010 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
2011 drop(f);
2012
2013 let config = Config {
2014 project_root: Some(dir.path().to_path_buf()),
2015 ..Config::default()
2016 };
2017 let (formatted, reason) = auto_format(&path, &config);
2018 assert!(formatted, "expected formatting to succeed");
2019 assert!(reason.is_none());
2020
2021 let content = fs::read_to_string(&path).unwrap();
2022 assert!(
2023 !content.contains("fn main"),
2024 "expected rustfmt to fix spacing"
2025 );
2026 }
2027
2028 #[test]
2029 fn formatter_excluded_path_detects_biome_messages() {
2030 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";
2032 assert!(
2033 formatter_excluded_path(stderr),
2034 "expected biome exclusion stderr to be detected"
2035 );
2036 }
2037
2038 #[test]
2039 fn formatter_excluded_path_detects_prettier_messages() {
2040 let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
2043 assert!(
2044 formatter_excluded_path(stderr),
2045 "expected prettier exclusion stderr to be detected"
2046 );
2047 }
2048
2049 #[test]
2050 fn formatter_excluded_path_detects_ruff_messages() {
2051 let stderr = "warning: No Python files found under the given path(s).\n";
2053 assert!(
2054 formatter_excluded_path(stderr),
2055 "expected ruff exclusion stderr to be detected"
2056 );
2057 }
2058
2059 #[test]
2060 fn formatter_excluded_path_is_case_insensitive() {
2061 assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
2062 assert!(formatter_excluded_path("Ignored By The Configuration"));
2063 }
2064
2065 #[test]
2066 fn formatter_excluded_path_rejects_real_errors() {
2067 assert!(!formatter_excluded_path(""));
2070 assert!(!formatter_excluded_path("syntax error: unexpected token"));
2071 assert!(!formatter_excluded_path("formatter crashed: out of memory"));
2072 assert!(!formatter_excluded_path(
2073 "permission denied: /readonly/file"
2074 ));
2075 assert!(!formatter_excluded_path(
2076 "biome internal error: please report"
2077 ));
2078 }
2079
2080 #[test]
2081 fn parse_tsc_output_basic() {
2082 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";
2083 let file = Path::new("src/app.ts");
2084 let errors = parse_tsc_output(stdout, "", file);
2085 assert_eq!(errors.len(), 2);
2086 assert_eq!(errors[0].line, 10);
2087 assert_eq!(errors[0].column, 5);
2088 assert_eq!(errors[0].severity, "error");
2089 assert!(errors[0].message.contains("TS2322"));
2090 assert_eq!(errors[1].line, 20);
2091 }
2092
2093 #[test]
2094 fn parse_tsc_output_filters_other_files() {
2095 let stdout =
2096 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
2097 let file = Path::new("src/app.ts");
2098 let errors = parse_tsc_output(stdout, "", file);
2099 assert_eq!(errors.len(), 1);
2100 assert_eq!(errors[0].line, 5);
2101 }
2102
2103 #[test]
2104 fn parse_cargo_output_basic() {
2105 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}]}}"#;
2106 let file = Path::new("src/main.rs");
2107 let errors = parse_cargo_output(json_line, "", file);
2108 assert_eq!(errors.len(), 1);
2109 assert_eq!(errors[0].line, 10);
2110 assert_eq!(errors[0].column, 5);
2111 assert_eq!(errors[0].severity, "error");
2112 assert!(errors[0].message.contains("mismatched types"));
2113 }
2114
2115 #[test]
2116 fn parse_cargo_output_skips_notes() {
2117 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}]}}"#;
2119 let file = Path::new("src/main.rs");
2120 let errors = parse_cargo_output(json_line, "", file);
2121 assert_eq!(errors.len(), 0);
2122 }
2123
2124 #[test]
2125 fn parse_cargo_output_filters_other_files() {
2126 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}]}}"#;
2127 let file = Path::new("src/main.rs");
2128 let errors = parse_cargo_output(json_line, "", file);
2129 assert_eq!(errors.len(), 0);
2130 }
2131
2132 #[test]
2133 fn parse_go_vet_output_basic() {
2134 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2135 let file = Path::new("main.go");
2136 let errors = parse_go_vet_output(stderr, file);
2137 assert_eq!(errors.len(), 2);
2138 assert_eq!(errors[0].line, 10);
2139 assert_eq!(errors[0].column, 5);
2140 assert!(errors[0].message.contains("unreachable code"));
2141 assert_eq!(errors[1].line, 20);
2142 assert_eq!(errors[1].column, 0);
2143 }
2144
2145 #[test]
2146 fn parse_pyright_output_basic() {
2147 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2148 let file = Path::new("test.py");
2149 let errors = parse_pyright_output(stdout, file);
2150 assert_eq!(errors.len(), 1);
2151 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 10);
2153 assert_eq!(errors[0].severity, "error");
2154 assert!(errors[0].message.contains("Type error here"));
2155 }
2156
2157 #[test]
2158 fn validate_full_unsupported_language() {
2159 let dir = tempfile::tempdir().unwrap();
2160 let path = dir.path().join("file.txt");
2161 fs::write(&path, "hello").unwrap();
2162
2163 let config = Config::default();
2164 let (errors, reason) = validate_full(&path, &config);
2165 assert!(errors.is_empty());
2166 assert_eq!(reason.as_deref(), Some("unsupported_language"));
2167 }
2168
2169 #[test]
2170 fn detect_type_checker_rust() {
2171 let dir = tempfile::tempdir().unwrap();
2172 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2173 let path = dir.path().join("src/main.rs");
2174 let config = Config {
2175 project_root: Some(dir.path().to_path_buf()),
2176 ..Config::default()
2177 };
2178 let result = detect_type_checker(&path, LangId::Rust, &config);
2179 if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2180 let (cmd, args) = result.unwrap();
2181 assert_eq!(cmd, "cargo");
2182 assert!(args.contains(&"check".to_string()));
2183 } else {
2184 assert!(result.is_none());
2185 }
2186 }
2187
2188 #[test]
2189 fn detect_type_checker_go() {
2190 let dir = tempfile::tempdir().unwrap();
2191 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2192 let path = dir.path().join("main.go");
2193 let config = Config {
2194 project_root: Some(dir.path().to_path_buf()),
2195 ..Config::default()
2196 };
2197 let result = detect_type_checker(&path, LangId::Go, &config);
2198 if resolve_tool("go", config.project_root.as_deref()).is_some() {
2199 let (cmd, _args) = result.unwrap();
2200 assert!(cmd == "go" || cmd == "staticcheck");
2202 } else {
2203 assert!(result.is_none());
2204 }
2205 }
2206 #[test]
2207 fn run_external_tool_capture_nonzero_not_error() {
2208 let result = run_external_tool_capture("false", &[], None, 5);
2210 assert!(result.is_ok(), "capture should not error on non-zero exit");
2211 assert_eq!(result.unwrap().exit_code, 1);
2212 }
2213
2214 #[test]
2215 fn run_external_tool_capture_not_found() {
2216 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2217 assert!(result.is_err());
2218 match result.unwrap_err() {
2219 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2220 other => panic!("expected NotFound, got: {:?}", other),
2221 }
2222 }
2223
2224 #[cfg(unix)]
2228 #[test]
2229 fn well_known_search_paths_include_homebrew_cargo_go_and_local() {
2230 let home = std::ffi::OsString::from("/Users/test-home");
2231 let paths = well_known_search_paths("toolx", Some(&home));
2232 let strs: Vec<String> = paths
2233 .iter()
2234 .map(|p| p.to_string_lossy().into_owned())
2235 .collect();
2236 assert_eq!(strs[0], "/opt/homebrew/bin/toolx");
2239 assert_eq!(strs[1], "/usr/local/bin/toolx");
2240 assert_eq!(strs[2], "/Users/test-home/.cargo/bin/toolx");
2241 assert_eq!(strs[3], "/Users/test-home/go/bin/toolx");
2242 assert_eq!(strs[4], "/Users/test-home/.local/bin/toolx");
2243 assert_eq!(strs.len(), 5);
2244 }
2245
2246 #[cfg(unix)]
2247 #[test]
2248 fn well_known_search_paths_skips_home_when_unset() {
2249 let paths = well_known_search_paths("toolx", None);
2250 assert_eq!(paths.len(), 2);
2251 assert!(paths[0].ends_with("opt/homebrew/bin/toolx"));
2252 assert!(paths[1].ends_with("usr/local/bin/toolx"));
2253 }
2254
2255 #[cfg(unix)]
2256 #[test]
2257 fn try_well_known_path_lookup_in_finds_executable_file() {
2258 use std::os::unix::fs::PermissionsExt;
2259 let dir = tempfile::tempdir().unwrap();
2260 let bin_dir = dir.path().join("bin");
2261 fs::create_dir_all(&bin_dir).unwrap();
2262 let tool_path = bin_dir.join("toolx");
2263 fs::write(&tool_path, "#!/bin/sh\necho test").unwrap();
2264 let mut perms = fs::metadata(&tool_path).unwrap().permissions();
2265 perms.set_mode(0o755);
2266 fs::set_permissions(&tool_path, perms).unwrap();
2267
2268 let candidates = vec![
2269 dir.path().join("missing/toolx"),
2270 tool_path.clone(),
2271 dir.path().join("alt/toolx"),
2272 ];
2273 let found = try_well_known_path_lookup_in(&candidates);
2274 assert_eq!(found, Some(tool_path));
2275 }
2276
2277 #[cfg(unix)]
2278 #[test]
2279 fn try_well_known_path_lookup_in_skips_non_executable_file() {
2280 let dir = tempfile::tempdir().unwrap();
2281 let bin_dir = dir.path().join("bin");
2282 fs::create_dir_all(&bin_dir).unwrap();
2283 let tool_path = bin_dir.join("toolx");
2285 fs::write(&tool_path, "not a real tool").unwrap();
2286
2287 let found = try_well_known_path_lookup_in(&std::slice::from_ref(&tool_path));
2288 assert!(found.is_none(), "non-executable file should be skipped");
2289 }
2290
2291 #[cfg(unix)]
2292 #[test]
2293 fn try_well_known_path_lookup_in_skips_directories_and_missing_paths() {
2294 let dir = tempfile::tempdir().unwrap();
2295 let candidates = vec![dir.path().to_path_buf(), dir.path().join("does-not-exist")];
2297 assert!(try_well_known_path_lookup_in(&candidates).is_none());
2298 }
2299
2300 #[cfg(windows)]
2301 #[test]
2302 fn try_well_known_path_lookup_is_noop_on_windows() {
2303 assert!(try_well_known_path_lookup("biome").is_none());
2306 }
2307
2308 #[test]
2311 fn configured_tool_hint_does_not_claim_not_installed() {
2312 let hint = configured_tool_hint("biome", "biome.json");
2313 assert!(
2314 hint.contains("was not found on PATH or in common install locations"),
2315 "hint should explain the PATH miss: got {:?}",
2316 hint
2317 );
2318 assert!(
2319 !hint.contains("but not installed"),
2320 "hint must not claim the tool isn't installed: got {:?}",
2321 hint
2322 );
2323 }
2324
2325 #[test]
2326 fn install_hint_for_go_mentions_path() {
2327 let hint = install_hint("go");
2330 assert!(
2331 hint.contains("PATH"),
2332 "go install hint should mention PATH: got {:?}",
2333 hint
2334 );
2335 }
2336}