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 pub truncated: bool,
24}
25
26struct SubprocessOutcome {
27 stdout: String,
28 stderr: String,
29 status: ExitStatus,
30 truncated: bool,
31}
32
33#[derive(Debug)]
35pub enum FormatError {
36 NotFound { tool: String },
38 Timeout { tool: String, timeout_secs: u32 },
40 Failed { tool: String, stderr: String },
42 UnsupportedLanguage,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
48pub struct MissingTool {
49 pub kind: String,
50 pub language: String,
51 pub tool: String,
52 pub hint: String,
53}
54
55#[derive(Debug, Clone)]
56struct ToolCandidate {
57 tool: String,
58 source: String,
59 args: Vec<String>,
60 required: bool,
61}
62
63#[derive(Debug, Clone)]
64enum ToolDetection {
65 Found(String, Vec<String>),
66 NotConfigured,
67 NotInstalled { tool: String },
68}
69
70impl std::fmt::Display for FormatError {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
74 FormatError::Timeout { tool, timeout_secs } => {
75 write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
76 }
77 FormatError::Failed { tool, stderr } => {
78 write!(f, "formatter '{}' failed: {}", tool, stderr)
79 }
80 FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
81 }
82 }
83}
84
85#[cfg(unix)]
92fn isolate_in_process_group(cmd: &mut Command) {
93 use std::os::unix::process::CommandExt;
94 unsafe {
96 cmd.pre_exec(|| {
97 if libc::setsid() == -1 {
98 return Err(std::io::Error::last_os_error());
99 }
100 Ok(())
101 });
102 }
103}
104
105#[cfg(not(unix))]
106fn isolate_in_process_group(_cmd: &mut Command) {
107 }
110
111#[cfg(unix)]
114fn kill_process_tree(child: &mut Child) {
115 let pid = child.id() as i32;
116 if pid > 0 {
117 unsafe {
120 libc::killpg(pid, libc::SIGKILL);
121 }
122 }
123 let _ = child.kill();
124}
125
126#[cfg(windows)]
127fn kill_process_tree(child: &mut Child) {
128 let pid = child.id().to_string();
129 let _ = Command::new("taskkill")
130 .args(["/PID", pid.as_str(), "/T", "/F"])
131 .stdin(Stdio::null())
132 .stdout(Stdio::null())
133 .stderr(Stdio::null())
134 .status();
135 let _ = child.kill();
136}
137
138#[cfg(not(any(unix, windows)))]
139fn kill_process_tree(child: &mut Child) {
140 let _ = child.kill();
141}
142
143pub fn run_external_tool(
149 command: &str,
150 args: &[&str],
151 working_dir: Option<&Path>,
152 timeout_secs: u32,
153) -> Result<ExternalToolResult, FormatError> {
154 let mut cmd = Command::new(command);
155 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
156
157 if let Some(dir) = working_dir {
158 cmd.current_dir(dir);
159 }
160
161 isolate_in_process_group(&mut cmd);
162
163 let child = match cmd.spawn() {
164 Ok(c) => c,
165 Err(e) if e.kind() == ErrorKind::NotFound => {
166 return Err(FormatError::NotFound {
167 tool: command.to_string(),
168 });
169 }
170 Err(e) => {
171 return Err(FormatError::Failed {
172 tool: command.to_string(),
173 stderr: e.to_string(),
174 });
175 }
176 };
177
178 let outcome = wait_with_timeout(child, command, timeout_secs)?;
179 let exit_code = outcome.status.code().unwrap_or(-1);
180 if exit_code != 0 {
181 return Err(FormatError::Failed {
182 tool: command.to_string(),
183 stderr: outcome.stderr,
184 });
185 }
186
187 Ok(ExternalToolResult {
188 stdout: outcome.stdout,
189 stderr: outcome.stderr,
190 exit_code,
191 truncated: outcome.truncated,
192 })
193}
194
195const MAX_CAPTURE_BYTES: usize = 16 * 1024 * 1024;
196
197fn wait_with_timeout(
198 mut child: Child,
199 command: &str,
200 timeout_secs: u32,
201) -> Result<SubprocessOutcome, FormatError> {
202 let stdout_pipe = child.stdout.take().expect("piped stdout");
203 let stderr_pipe = child.stderr.take().expect("piped stderr");
204 let stdout_thread =
205 thread::spawn(move || read_bounded_to_string(stdout_pipe, MAX_CAPTURE_BYTES));
206 let stderr_thread =
207 thread::spawn(move || read_bounded_to_string(stderr_pipe, MAX_CAPTURE_BYTES));
208 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
209
210 loop {
211 match child.try_wait() {
212 Ok(Some(status)) => {
213 let (stdout, stdout_truncated) = stdout_thread.join().unwrap_or_default();
214 let (stderr, stderr_truncated) = stderr_thread.join().unwrap_or_default();
215 return Ok(SubprocessOutcome {
216 stdout,
217 stderr,
218 status,
219 truncated: stdout_truncated || stderr_truncated,
220 });
221 }
222 Ok(None) => {
223 if Instant::now() >= deadline {
224 kill_process_tree(&mut child);
225 let _ = child.wait();
226 return Err(FormatError::Timeout {
231 tool: command.to_string(),
232 timeout_secs,
233 });
234 }
235 thread::sleep(Duration::from_millis(50));
236 }
237 Err(e) => {
238 kill_process_tree(&mut child);
239 let _ = child.wait();
240 return Err(FormatError::Failed {
242 tool: command.to_string(),
243 stderr: format!("try_wait error: {}", e),
244 });
245 }
246 }
247 }
248}
249
250fn read_bounded_to_string<R: Read>(mut reader: R, limit: usize) -> (String, bool) {
251 let mut bytes = Vec::with_capacity(limit.min(8192));
252 let mut scratch = [0u8; 8192];
253 let mut truncated = false;
254
255 loop {
256 let read = match reader.read(&mut scratch) {
257 Ok(0) => break,
258 Ok(read) => read,
259 Err(_) => break,
260 };
261
262 let remaining = limit.saturating_sub(bytes.len());
263 if remaining > 0 {
264 let keep = remaining.min(read);
265 bytes.extend_from_slice(&scratch[..keep]);
266 if keep < read {
267 truncated = true;
268 }
269 } else {
270 truncated = true;
271 }
272 }
273
274 (String::from_utf8_lossy(&bytes).into_owned(), truncated)
275}
276
277const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
279
280#[derive(Debug, Clone, PartialEq, Eq, Hash)]
281struct ToolCacheKey {
282 command: String,
283 project_root: PathBuf,
284}
285
286static TOOL_RESOLUTION_CACHE: std::sync::LazyLock<
287 Mutex<HashMap<ToolCacheKey, (Option<PathBuf>, Instant)>>,
288> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
289
290static TOOL_AVAILABILITY_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
291 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
292
293fn tool_cache_key(command: &str, project_root: Option<&Path>) -> ToolCacheKey {
294 ToolCacheKey {
295 command: command.to_string(),
296 project_root: project_root.map(Path::to_path_buf).unwrap_or_default(),
297 }
298}
299
300fn availability_cache_key(command: &str, project_root: Option<&Path>) -> String {
301 let root = project_root
302 .map(|path| path.to_string_lossy())
303 .unwrap_or_default();
304 format!("{}\0{}", command, root)
305}
306
307pub fn clear_tool_cache() {
308 if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
309 cache.clear();
310 }
311 if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
312 cache.clear();
313 }
314}
315
316fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
319 let key = tool_cache_key(command, project_root);
320 if let Ok(cache) = TOOL_RESOLUTION_CACHE.lock() {
321 if let Some((resolved, checked_at)) = cache.get(&key) {
322 if checked_at.elapsed() < TOOL_CACHE_TTL {
323 return resolved
324 .as_ref()
325 .map(|path| path.to_string_lossy().to_string());
326 }
327 }
328 }
329
330 let resolved = resolve_tool_uncached(command, project_root);
331 if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
332 cache.insert(key, (resolved.clone(), Instant::now()));
333 }
334 resolved.map(|path| path.to_string_lossy().to_string())
335}
336
337pub(crate) fn resolve_tool_uncached(command: &str, project_root: Option<&Path>) -> Option<PathBuf> {
338 if let Some(root) = project_root {
342 let local_bin_dir = root.join("node_modules").join(".bin");
343 for local_bin in local_node_bin_candidates(&local_bin_dir, command) {
344 if local_bin.exists() {
345 return Some(local_bin);
346 }
347 }
348 }
349
350 if let Some(path) = crate::tool_path::resolve_on_path(command) {
352 return Some(path);
353 }
354
355 try_well_known_path_lookup(command)
362}
363
364fn local_node_bin_candidates(bin_dir: &Path, command: &str) -> Vec<PathBuf> {
365 #[cfg(windows)]
366 {
367 let command_path = Path::new(command);
368 if command_path.extension().is_some() {
369 return vec![bin_dir.join(command)];
370 }
371
372 let mut candidates = vec![bin_dir.join(command)];
373 candidates.extend(
374 windows_local_node_bin_extensions(std::env::var_os("PATHEXT").as_deref())
375 .into_iter()
376 .map(|ext| bin_dir.join(format!("{command}{ext}"))),
377 );
378 candidates
379 }
380
381 #[cfg(not(windows))]
382 {
383 vec![bin_dir.join(command)]
384 }
385}
386
387#[cfg(any(windows, test))]
388fn windows_local_node_bin_extensions(pathext: Option<&std::ffi::OsStr>) -> Vec<String> {
389 const DEFAULT_ORDER: [&str; 4] = [".cmd", ".exe", ".bat", ".ps1"];
390 let allowed: HashSet<&str> = DEFAULT_ORDER.into_iter().collect();
391
392 let mut ordered = Vec::new();
393 if let Some(pathext) = pathext.and_then(|value| value.to_str()) {
394 for ext in pathext.split(';') {
395 let normalized = ext.trim().to_ascii_lowercase();
396 if allowed.contains(normalized.as_str()) && !ordered.contains(&normalized) {
397 ordered.push(normalized);
398 }
399 }
400 }
401
402 for ext in DEFAULT_ORDER {
403 if !ordered.iter().any(|existing| existing == ext) {
404 ordered.push(ext.to_string());
405 }
406 }
407
408 ordered
409}
410
411fn try_well_known_path_lookup(command: &str) -> Option<PathBuf> {
430 if std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP").is_some() {
435 return None;
436 }
437 if cfg!(windows) {
438 for dir in crate::tool_path::well_known_windows_bin_dirs(
439 std::env::var_os("USERPROFILE").as_deref(),
440 ) {
441 if let Some(found) = crate::tool_path::probe_tool_in_dir(&dir, command) {
442 return Some(found);
443 }
444 }
445 return None;
446 }
447 let candidates = well_known_search_paths(command, std::env::var_os("HOME").as_deref());
448 try_well_known_path_lookup_in(&candidates)
449}
450
451fn well_known_search_paths(command: &str, home: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
455 let mut candidates: Vec<PathBuf> = Vec::with_capacity(8);
456 candidates.push(PathBuf::from("/opt/homebrew/bin").join(command));
457 candidates.push(PathBuf::from("/usr/local/bin").join(command));
458 candidates.push(PathBuf::from("/usr/local/go/bin").join(command));
463 candidates.push(PathBuf::from("/usr/bin").join(command));
464 candidates.push(PathBuf::from("/snap/bin").join(command));
465 if let Some(home) = home {
466 let home_path = PathBuf::from(home);
467 candidates.push(home_path.join(".cargo/bin").join(command));
468 candidates.push(home_path.join("go/bin").join(command));
469 candidates.push(home_path.join(".local/bin").join(command));
470 }
471 candidates
472}
473
474fn try_well_known_path_lookup_in(candidates: &[PathBuf]) -> Option<PathBuf> {
488 for candidate in candidates {
489 if let Ok(metadata) = std::fs::metadata(candidate) {
490 if metadata.is_file() && is_executable(&metadata) {
491 return Some(candidate.clone());
492 }
493 }
494 }
495 None
496}
497
498#[cfg(unix)]
499fn is_executable(metadata: &std::fs::Metadata) -> bool {
500 use std::os::unix::fs::PermissionsExt;
501 metadata.permissions().mode() & 0o111 != 0
502}
503
504#[cfg(not(unix))]
505fn is_executable(_metadata: &std::fs::Metadata) -> bool {
506 true
511}
512
513pub(crate) fn tool_available_for_missing_warning(tool: &str, project_root: Option<&Path>) -> bool {
521 if tool == "ruff" {
522 return resolve_tool_uncached("ruff", project_root).is_some()
523 && ruff_format_available(project_root);
524 }
525 resolve_tool_uncached(tool, project_root).is_some()
526}
527
528fn ruff_format_available(project_root: Option<&Path>) -> bool {
529 let key = availability_cache_key("ruff-format", project_root);
530 if let Ok(cache) = TOOL_AVAILABILITY_CACHE.lock() {
531 if let Some((available, checked_at)) = cache.get(&key) {
532 if checked_at.elapsed() < TOOL_CACHE_TTL {
533 return *available;
534 }
535 }
536 }
537
538 let result = ruff_format_available_uncached(project_root);
539 if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
540 cache.insert(key, (result, Instant::now()));
541 }
542 result
543}
544
545fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
546 let command = match resolve_tool("ruff", project_root) {
547 Some(command) => command,
548 None => return false,
549 };
550 let output = match Command::new(&command)
551 .arg("--version")
552 .stdout(Stdio::piped())
553 .stderr(Stdio::null())
554 .output()
555 {
556 Ok(o) => o,
557 Err(_) => return false,
558 };
559
560 let version_str = String::from_utf8_lossy(&output.stdout);
561 let version_part = version_str
563 .trim()
564 .strip_prefix("ruff ")
565 .unwrap_or(version_str.trim());
566
567 let parts: Vec<&str> = version_part.split('.').collect();
568 if parts.len() < 3 {
569 return false;
570 }
571
572 let major: u32 = match parts[0].parse() {
573 Ok(v) => v,
574 Err(_) => return false,
575 };
576 let minor: u32 = match parts[1].parse() {
577 Ok(v) => v,
578 Err(_) => return false,
579 };
580 let patch: u32 = match parts[2].parse() {
581 Ok(v) => v,
582 Err(_) => return false,
583 };
584
585 (major, minor, patch) >= (0, 1, 2)
587}
588
589fn resolve_candidate_tool(
590 candidate: &ToolCandidate,
591 project_root: Option<&Path>,
592 require_ruff_format: bool,
593) -> Option<String> {
594 if require_ruff_format && candidate.tool == "ruff" && !ruff_format_available(project_root) {
595 return None;
596 }
597
598 resolve_tool(&candidate.tool, project_root)
599}
600
601fn lang_key(lang: LangId) -> &'static str {
602 match lang {
603 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
604 LangId::Python => "python",
605 LangId::Rust => "rust",
606 LangId::Go => "go",
607 LangId::C => "c",
608 LangId::Cpp => "cpp",
609 LangId::Zig => "zig",
610 LangId::CSharp => "csharp",
611 LangId::Bash => "bash",
612 LangId::Solidity => "solidity",
613 LangId::Vue => "vue",
614 LangId::Json => "json",
615 LangId::Scala => "scala",
616 LangId::Java => "java",
617 LangId::Ruby => "ruby",
618 LangId::Kotlin => "kotlin",
619 LangId::Swift => "swift",
620 LangId::Php => "php",
621 LangId::Lua => "lua",
622 LangId::Perl => "perl",
623 LangId::Html => "html",
624 LangId::Markdown => "markdown",
625 LangId::Yaml => "yaml",
626 }
627}
628
629fn has_formatter_support(lang: LangId) -> bool {
630 matches!(
631 lang,
632 LangId::TypeScript
633 | LangId::JavaScript
634 | LangId::Tsx
635 | LangId::Python
636 | LangId::Rust
637 | LangId::Go
638 )
639}
640
641fn has_checker_support(lang: LangId) -> bool {
642 matches!(
643 lang,
644 LangId::TypeScript
645 | LangId::JavaScript
646 | LangId::Tsx
647 | LangId::Python
648 | LangId::Rust
649 | LangId::Go
650 )
651}
652
653fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
654 let project_root = config.project_root.as_deref();
655 if let Some(preferred) = config.formatter.get(lang_key(lang)) {
656 return explicit_formatter_candidate(preferred, file_str);
657 }
658
659 match lang {
660 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
661 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
662 vec![ToolCandidate {
663 tool: "biome".to_string(),
664 source: "biome.json".to_string(),
665 args: vec![
666 "format".to_string(),
667 "--write".to_string(),
668 file_str.to_string(),
669 ],
670 required: true,
671 }]
672 } else if has_project_config(
673 project_root,
674 &[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts"],
675 ) {
676 vec![ToolCandidate {
677 tool: "oxfmt".to_string(),
678 source: "oxfmt config".to_string(),
679 args: vec!["--write".to_string(), file_str.to_string()],
680 required: true,
681 }]
682 } else if has_project_config(
683 project_root,
684 &[
685 ".prettierrc",
686 ".prettierrc.json",
687 ".prettierrc.yml",
688 ".prettierrc.yaml",
689 ".prettierrc.js",
690 ".prettierrc.cjs",
691 ".prettierrc.mjs",
692 ".prettierrc.toml",
693 "prettier.config.js",
694 "prettier.config.cjs",
695 "prettier.config.mjs",
696 ],
697 ) {
698 vec![ToolCandidate {
699 tool: "prettier".to_string(),
700 source: "Prettier config".to_string(),
701 args: vec!["--write".to_string(), file_str.to_string()],
702 required: true,
703 }]
704 } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
705 vec![ToolCandidate {
706 tool: "deno".to_string(),
707 source: "deno.json".to_string(),
708 args: vec!["fmt".to_string(), file_str.to_string()],
709 required: true,
710 }]
711 } else {
712 Vec::new()
713 }
714 }
715 LangId::Python => {
716 if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
717 || has_pyproject_tool(project_root, "ruff")
718 {
719 vec![ToolCandidate {
720 tool: "ruff".to_string(),
721 source: "ruff config".to_string(),
722 args: vec!["format".to_string(), file_str.to_string()],
723 required: true,
724 }]
725 } else if has_pyproject_tool(project_root, "black") {
726 vec![ToolCandidate {
727 tool: "black".to_string(),
728 source: "pyproject.toml".to_string(),
729 args: vec![file_str.to_string()],
730 required: true,
731 }]
732 } else {
733 Vec::new()
734 }
735 }
736 LangId::Rust => {
737 if has_project_config(project_root, &["Cargo.toml"]) {
738 vec![ToolCandidate {
739 tool: "rustfmt".to_string(),
740 source: "Cargo.toml".to_string(),
741 args: vec![file_str.to_string()],
742 required: true,
743 }]
744 } else {
745 Vec::new()
746 }
747 }
748 LangId::Go => {
749 if has_project_config(project_root, &["go.mod"]) {
750 vec![
751 ToolCandidate {
752 tool: "goimports".to_string(),
753 source: "go.mod".to_string(),
754 args: vec!["-w".to_string(), file_str.to_string()],
755 required: false,
756 },
757 ToolCandidate {
758 tool: "gofmt".to_string(),
759 source: "go.mod".to_string(),
760 args: vec!["-w".to_string(), file_str.to_string()],
761 required: true,
762 },
763 ]
764 } else {
765 Vec::new()
766 }
767 }
768 LangId::C
769 | LangId::Cpp
770 | LangId::Zig
771 | LangId::CSharp
772 | LangId::Bash
773 | LangId::Solidity
774 | LangId::Vue
775 | LangId::Json
776 | LangId::Scala
777 | LangId::Java
778 | LangId::Ruby
779 | LangId::Kotlin
780 | LangId::Swift
781 | LangId::Php
782 | LangId::Lua
783 | LangId::Perl => Vec::new(),
784 LangId::Html => Vec::new(),
785 LangId::Markdown => Vec::new(),
786 LangId::Yaml => Vec::new(),
787 }
788}
789
790fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
791 let project_root = config.project_root.as_deref();
792 if let Some(preferred) = config.checker.get(lang_key(lang)) {
793 return explicit_checker_candidate(preferred, file_str);
794 }
795
796 match lang {
797 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
798 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
799 vec![ToolCandidate {
800 tool: "biome".to_string(),
801 source: "biome.json".to_string(),
802 args: vec![
803 "check".to_string(),
804 "--reporter=json".to_string(),
805 file_str.to_string(),
806 ],
807 required: true,
808 }]
809 } else if has_project_config(project_root, &["tsconfig.json"]) {
810 vec![ToolCandidate {
811 tool: "tsc".to_string(),
812 source: "tsconfig.json".to_string(),
813 args: vec![
814 "--noEmit".to_string(),
815 "--pretty".to_string(),
816 "false".to_string(),
817 ],
818 required: true,
819 }]
820 } else {
821 Vec::new()
822 }
823 }
824 LangId::Python => {
825 if has_project_config(project_root, &["pyrightconfig.json"])
826 || has_pyproject_tool(project_root, "pyright")
827 {
828 vec![ToolCandidate {
829 tool: "pyright".to_string(),
830 source: "pyright config".to_string(),
831 args: vec!["--outputjson".to_string(), file_str.to_string()],
832 required: true,
833 }]
834 } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
835 || has_pyproject_tool(project_root, "ruff")
836 {
837 vec![ToolCandidate {
838 tool: "ruff".to_string(),
839 source: "ruff config".to_string(),
840 args: vec![
841 "check".to_string(),
842 "--output-format=json".to_string(),
843 file_str.to_string(),
844 ],
845 required: true,
846 }]
847 } else {
848 Vec::new()
849 }
850 }
851 LangId::Rust => {
852 if has_project_config(project_root, &["Cargo.toml"]) {
853 vec![ToolCandidate {
854 tool: "cargo".to_string(),
855 source: "Cargo.toml".to_string(),
856 args: vec!["check".to_string(), "--message-format=json".to_string()],
857 required: true,
858 }]
859 } else {
860 Vec::new()
861 }
862 }
863 LangId::Go => {
864 if has_project_config(project_root, &["go.mod"]) {
865 vec![
866 ToolCandidate {
867 tool: "staticcheck".to_string(),
868 source: "go.mod".to_string(),
869 args: vec!["-f".to_string(), "json".to_string(), file_str.to_string()],
870 required: false,
871 },
872 ToolCandidate {
873 tool: "go".to_string(),
874 source: "go.mod".to_string(),
875 args: vec!["vet".to_string(), file_str.to_string()],
876 required: true,
877 },
878 ]
879 } else {
880 Vec::new()
881 }
882 }
883 LangId::C
884 | LangId::Cpp
885 | LangId::Zig
886 | LangId::CSharp
887 | LangId::Bash
888 | LangId::Solidity
889 | LangId::Vue
890 | LangId::Json
891 | LangId::Scala
892 | LangId::Java
893 | LangId::Ruby
894 | LangId::Kotlin
895 | LangId::Swift
896 | LangId::Php
897 | LangId::Lua
898 | LangId::Perl => Vec::new(),
899 LangId::Html => Vec::new(),
900 LangId::Markdown => Vec::new(),
901 LangId::Yaml => Vec::new(),
902 }
903}
904
905fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
906 match name {
907 "none" | "off" | "false" => Vec::new(),
908 "biome" => vec![ToolCandidate {
909 tool: name.to_string(),
910 source: "formatter config".to_string(),
911 args: vec![
912 "format".to_string(),
913 "--write".to_string(),
914 file_str.to_string(),
915 ],
916 required: true,
917 }],
918 "oxfmt" => vec![ToolCandidate {
919 tool: name.to_string(),
920 source: "formatter config".to_string(),
921 args: vec!["--write".to_string(), file_str.to_string()],
922 required: true,
923 }],
924 "prettier" => vec![ToolCandidate {
925 tool: name.to_string(),
926 source: "formatter config".to_string(),
927 args: vec!["--write".to_string(), file_str.to_string()],
928 required: true,
929 }],
930 "deno" => vec![ToolCandidate {
931 tool: name.to_string(),
932 source: "formatter config".to_string(),
933 args: vec!["fmt".to_string(), file_str.to_string()],
934 required: true,
935 }],
936 "ruff" => vec![ToolCandidate {
937 tool: name.to_string(),
938 source: "formatter config".to_string(),
939 args: vec!["format".to_string(), file_str.to_string()],
940 required: true,
941 }],
942 "black" | "rustfmt" => vec![ToolCandidate {
943 tool: name.to_string(),
944 source: "formatter config".to_string(),
945 args: vec![file_str.to_string()],
946 required: true,
947 }],
948 "goimports" | "gofmt" => vec![ToolCandidate {
949 tool: name.to_string(),
950 source: "formatter config".to_string(),
951 args: vec!["-w".to_string(), file_str.to_string()],
952 required: true,
953 }],
954 _ => Vec::new(),
955 }
956}
957
958fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
959 match name {
960 "none" | "off" | "false" => Vec::new(),
961 "tsc" | "tsgo" => vec![ToolCandidate {
962 tool: name.to_string(),
963 source: "checker config".to_string(),
964 args: vec![
965 "--noEmit".to_string(),
966 "--pretty".to_string(),
967 "false".to_string(),
968 ],
969 required: true,
970 }],
971 "cargo" => vec![ToolCandidate {
972 tool: name.to_string(),
973 source: "checker config".to_string(),
974 args: vec!["check".to_string(), "--message-format=json".to_string()],
975 required: true,
976 }],
977 "go" => vec![ToolCandidate {
978 tool: name.to_string(),
979 source: "checker config".to_string(),
980 args: vec!["vet".to_string(), file_str.to_string()],
981 required: true,
982 }],
983 "biome" => vec![ToolCandidate {
984 tool: name.to_string(),
985 source: "checker config".to_string(),
986 args: vec![
987 "check".to_string(),
988 "--reporter=json".to_string(),
989 file_str.to_string(),
990 ],
991 required: true,
992 }],
993 "pyright" => vec![ToolCandidate {
994 tool: name.to_string(),
995 source: "checker config".to_string(),
996 args: vec!["--outputjson".to_string(), file_str.to_string()],
997 required: true,
998 }],
999 "ruff" => vec![ToolCandidate {
1000 tool: name.to_string(),
1001 source: "checker config".to_string(),
1002 args: vec![
1003 "check".to_string(),
1004 "--output-format=json".to_string(),
1005 file_str.to_string(),
1006 ],
1007 required: true,
1008 }],
1009 "staticcheck" => vec![ToolCandidate {
1010 tool: name.to_string(),
1011 source: "checker config".to_string(),
1012 args: vec!["-f".to_string(), "json".to_string(), file_str.to_string()],
1013 required: true,
1014 }],
1015 _ => Vec::new(),
1016 }
1017}
1018
1019fn resolve_tool_candidates(
1020 candidates: Vec<ToolCandidate>,
1021 project_root: Option<&Path>,
1022 require_ruff_format: bool,
1023) -> ToolDetection {
1024 if candidates.is_empty() {
1025 return ToolDetection::NotConfigured;
1026 }
1027
1028 let mut missing_required = None;
1029 for candidate in candidates {
1030 if let Some(command) = resolve_candidate_tool(&candidate, project_root, require_ruff_format)
1031 {
1032 return ToolDetection::Found(command, candidate.args);
1033 }
1034 if candidate.required && missing_required.is_none() {
1035 missing_required = Some(candidate.tool);
1036 }
1037 }
1038
1039 match missing_required {
1040 Some(tool) => ToolDetection::NotInstalled { tool },
1041 None => ToolDetection::NotConfigured,
1042 }
1043}
1044
1045fn checker_command(_candidate: &ToolCandidate, resolved: String) -> String {
1046 resolved
1047}
1048
1049fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
1050 if candidate.tool == "tsc" || candidate.tool == "tsgo" {
1051 vec![
1052 "--noEmit".to_string(),
1053 "--pretty".to_string(),
1054 "false".to_string(),
1055 ]
1056 } else {
1057 candidate.args.clone()
1058 }
1059}
1060
1061fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
1062 let file_str = path.to_string_lossy().to_string();
1063 resolve_tool_candidates(
1064 formatter_candidates(lang, config, &file_str),
1065 config.project_root.as_deref(),
1066 true,
1067 )
1068}
1069
1070fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
1071 let file_str = path.to_string_lossy().to_string();
1072 let candidates = checker_candidates(lang, config, &file_str);
1073 if candidates.is_empty() {
1074 return ToolDetection::NotConfigured;
1075 }
1076
1077 let project_root = config.project_root.as_deref();
1078 let mut missing_required = None;
1079 for candidate in candidates {
1080 if let Some(command) = resolve_candidate_tool(&candidate, project_root, false) {
1081 return ToolDetection::Found(
1082 checker_command(&candidate, command),
1083 checker_args(&candidate),
1084 );
1085 }
1086 if candidate.required && missing_required.is_none() {
1087 missing_required = Some(candidate.tool);
1088 }
1089 }
1090
1091 match missing_required {
1092 Some(tool) => ToolDetection::NotInstalled { tool },
1093 None => ToolDetection::NotConfigured,
1094 }
1095}
1096
1097fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
1098 crate::callgraph::walk_project_files(project_root)
1099 .filter_map(|path| detect_language(&path))
1100 .collect()
1101}
1102
1103fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
1104 let filename = match lang {
1105 LangId::TypeScript => "aft-tool-detection.ts",
1106 LangId::Tsx => "aft-tool-detection.tsx",
1107 LangId::JavaScript => "aft-tool-detection.js",
1108 LangId::Python => "aft-tool-detection.py",
1109 LangId::Rust => "aft_tool_detection.rs",
1110 LangId::Go => "aft_tool_detection.go",
1111 LangId::C => "aft_tool_detection.c",
1112 LangId::Cpp => "aft_tool_detection.cpp",
1113 LangId::Zig => "aft_tool_detection.zig",
1114 LangId::CSharp => "aft_tool_detection.cs",
1115 LangId::Bash => "aft_tool_detection.sh",
1116 LangId::Solidity => "aft_tool_detection.sol",
1117 LangId::Vue => "aft-tool-detection.vue",
1118 LangId::Json => "aft-tool-detection.json",
1119 LangId::Scala => "aft-tool-detection.scala",
1120 LangId::Java => "aft-tool-detection.java",
1121 LangId::Ruby => "aft-tool-detection.rb",
1122 LangId::Kotlin => "aft-tool-detection.kt",
1123 LangId::Swift => "aft-tool-detection.swift",
1124 LangId::Php => "aft-tool-detection.php",
1125 LangId::Lua => "aft-tool-detection.lua",
1126 LangId::Perl => "aft-tool-detection.pl",
1127 LangId::Html => "aft-tool-detection.html",
1128 LangId::Markdown => "aft-tool-detection.md",
1129 LangId::Yaml => "aft-tool-detection.yaml",
1130 };
1131 project_root.join(filename)
1132}
1133
1134pub(crate) fn install_hint(tool: &str) -> String {
1135 match tool {
1136 "biome" => {
1137 "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
1138 }
1139 "oxfmt" => "Run `npm install -D oxfmt` or install globally.".to_string(),
1140 "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
1141 "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
1142 "tsgo" => {
1143 "Run `npm install -D @typescript/native-preview` or install globally.".to_string()
1144 }
1145 "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
1146 "ruff" => {
1147 "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
1148 }
1149 "black" => {
1150 "Install: `pip install black` or your Python package manager equivalent.".to_string()
1151 }
1152 "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
1153 "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
1154 "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
1155 "go" => if cfg!(windows) {
1156 "Install Go from https://go.dev/dl/. Common install paths:\
1157 C:\\Go\\bin, C:\\Program Files\\Go\\bin. \
1158 GUI-launched editors often don't inherit login-shell PATH."
1159 } else {
1160 "Install Go from https://go.dev/dl/, or — if it's already installed —\
1161 ensure its bin directory is on PATH (Homebrew typically uses\
1162 /opt/homebrew/bin on Apple Silicon, /usr/local/bin on Intel macOS).\
1163 GUI-launched editors often don't inherit login-shell PATH."
1164 }
1165 .to_string(),
1166 "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
1167 "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
1168 "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
1169 "typescript-language-server" => {
1170 "Install: `npm install -g typescript-language-server typescript`".to_string()
1171 }
1172 "deno" => "Install Deno from https://deno.com/.".to_string(),
1173 "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
1174 "staticcheck" => {
1175 "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
1176 }
1177 other => format!("Install `{other}` and ensure it is on PATH."),
1178 }
1179}
1180
1181fn configured_tool_hint(tool: &str, source: &str) -> String {
1182 format!(
1192 "{tool} is configured in {source} but was not found on PATH or in common install locations. {}",
1193 install_hint(tool)
1194 )
1195}
1196
1197fn missing_tool_warning(
1198 kind: &str,
1199 language: &str,
1200 candidate: &ToolCandidate,
1201 project_root: Option<&Path>,
1202 require_ruff_format: bool,
1203) -> Option<MissingTool> {
1204 if !candidate.required
1205 || resolve_candidate_tool(candidate, project_root, require_ruff_format).is_some()
1206 {
1207 return None;
1208 }
1209
1210 Some(MissingTool {
1211 kind: kind.to_string(),
1212 language: language.to_string(),
1213 tool: candidate.tool.clone(),
1214 hint: configured_tool_hint(&candidate.tool, &candidate.source),
1215 })
1216}
1217
1218pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
1220 let languages = languages_in_project(project_root);
1221 let mut warnings = Vec::new();
1222 let mut seen = HashSet::new();
1223
1224 for lang in languages {
1225 let language = lang_key(lang);
1226 let placeholder = placeholder_file_for_language(project_root, lang);
1227 let file_str = placeholder.to_string_lossy().to_string();
1228
1229 for candidate in formatter_candidates(lang, config, &file_str) {
1230 if let Some(warning) = missing_tool_warning(
1231 "formatter_not_installed",
1232 language,
1233 &candidate,
1234 config.project_root.as_deref(),
1235 true,
1236 ) {
1237 if seen.insert((
1238 warning.kind.clone(),
1239 warning.language.clone(),
1240 warning.tool.clone(),
1241 )) {
1242 warnings.push(warning);
1243 }
1244 }
1245 }
1246
1247 for candidate in checker_candidates(lang, config, &file_str) {
1248 if let Some(warning) = missing_tool_warning(
1249 "checker_not_installed",
1250 language,
1251 &candidate,
1252 config.project_root.as_deref(),
1253 false,
1254 ) {
1255 if seen.insert((
1256 warning.kind.clone(),
1257 warning.language.clone(),
1258 warning.tool.clone(),
1259 )) {
1260 warnings.push(warning);
1261 }
1262 }
1263 }
1264 }
1265
1266 warnings.sort_by(|left, right| {
1267 (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1268 });
1269 warnings
1270}
1271
1272pub fn detect_formatter(
1282 path: &Path,
1283 lang: LangId,
1284 config: &Config,
1285) -> Option<(String, Vec<String>)> {
1286 match detect_formatter_for_path(path, lang, config) {
1287 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1288 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1289 }
1290}
1291
1292fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1294 let root = match project_root {
1295 Some(r) => r,
1296 None => return false,
1297 };
1298 filenames.iter().any(|f| root.join(f).exists())
1299}
1300
1301fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1303 let root = match project_root {
1304 Some(r) => r,
1305 None => return false,
1306 };
1307 let pyproject = root.join("pyproject.toml");
1308 if !pyproject.exists() {
1309 return false;
1310 }
1311 match std::fs::read_to_string(&pyproject) {
1312 Ok(content) => {
1313 let pattern = format!("[tool.{}]", tool_name);
1314 content.contains(&pattern)
1315 }
1316 Err(_) => false,
1317 }
1318}
1319
1320fn formatter_excluded_path(stderr: &str) -> bool {
1341 let s = stderr.to_lowercase();
1342 s.contains("no files were processed")
1343 || s.contains("ignored by the configuration")
1344 || s.contains("expected at least one target file")
1345 || s.contains("no files found matching the given patterns")
1346 || s.contains("no files matching the pattern")
1347 || s.contains("no python files found")
1348}
1349
1350pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1371 if !config.format_on_edit {
1373 return (false, Some("no_formatter_configured".to_string()));
1374 }
1375
1376 let lang = match detect_language(path) {
1377 Some(l) => l,
1378 None => {
1379 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1380 return (false, Some("unsupported_language".to_string()));
1381 }
1382 };
1383 if !has_formatter_support(lang) {
1384 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1385 return (false, Some("unsupported_language".to_string()));
1386 }
1387
1388 let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1389 ToolDetection::Found(cmd, args) => (cmd, args),
1390 ToolDetection::NotConfigured => {
1391 log::debug!(
1392 "format: {} (skipped: no_formatter_configured)",
1393 path.display()
1394 );
1395 return (false, Some("no_formatter_configured".to_string()));
1396 }
1397 ToolDetection::NotInstalled { tool } => {
1398 crate::slog_warn!(
1399 "format: {} (skipped: formatter_not_installed: {})",
1400 path.display(),
1401 tool
1402 );
1403 return (false, Some("formatter_not_installed".to_string()));
1404 }
1405 };
1406
1407 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1408
1409 let working_dir = config.project_root.as_deref();
1416
1417 match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1418 Ok(_) => {
1419 crate::slog_info!("format: {} ({})", path.display(), cmd);
1420 (true, None)
1421 }
1422 Err(FormatError::Timeout { .. }) => {
1423 crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1424 (false, Some("timeout".to_string()))
1425 }
1426 Err(FormatError::NotFound { .. }) => {
1427 crate::slog_warn!(
1428 "format: {} (skipped: formatter_not_installed)",
1429 path.display()
1430 );
1431 (false, Some("formatter_not_installed".to_string()))
1432 }
1433 Err(FormatError::Failed { stderr, .. }) => {
1434 if formatter_excluded_path(&stderr) {
1446 crate::slog_info!(
1447 "format: {} (skipped: formatter_excluded_path; stderr: {})",
1448 path.display(),
1449 stderr.lines().next().unwrap_or("").trim()
1450 );
1451 return (false, Some("formatter_excluded_path".to_string()));
1452 }
1453 crate::slog_warn!(
1454 "format: {} (skipped: error: {})",
1455 path.display(),
1456 stderr.lines().next().unwrap_or("unknown").trim()
1457 );
1458 (false, Some("error".to_string()))
1459 }
1460 Err(FormatError::UnsupportedLanguage) => {
1461 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1462 (false, Some("unsupported_language".to_string()))
1463 }
1464 }
1465}
1466
1467pub fn run_external_tool_capture(
1474 command: &str,
1475 args: &[&str],
1476 working_dir: Option<&Path>,
1477 timeout_secs: u32,
1478) -> Result<ExternalToolResult, FormatError> {
1479 let mut cmd = Command::new(command);
1480 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1481
1482 if let Some(dir) = working_dir {
1483 cmd.current_dir(dir);
1484 }
1485
1486 isolate_in_process_group(&mut cmd);
1487
1488 let child = match cmd.spawn() {
1489 Ok(c) => c,
1490 Err(e) if e.kind() == ErrorKind::NotFound => {
1491 return Err(FormatError::NotFound {
1492 tool: command.to_string(),
1493 });
1494 }
1495 Err(e) => {
1496 return Err(FormatError::Failed {
1497 tool: command.to_string(),
1498 stderr: e.to_string(),
1499 });
1500 }
1501 };
1502
1503 let outcome = wait_with_timeout(child, command, timeout_secs)?;
1504 Ok(ExternalToolResult {
1505 stdout: outcome.stdout,
1506 stderr: outcome.stderr,
1507 exit_code: outcome.status.code().unwrap_or(-1),
1508 truncated: outcome.truncated,
1509 })
1510}
1511
1512#[derive(Debug, Clone, serde::Serialize)]
1518pub struct ValidationError {
1519 pub line: u32,
1520 pub column: u32,
1521 pub message: String,
1522 pub severity: String,
1523}
1524
1525pub fn detect_type_checker(
1536 path: &Path,
1537 lang: LangId,
1538 config: &Config,
1539) -> Option<(String, Vec<String>)> {
1540 match detect_checker_for_path(path, lang, config) {
1541 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1542 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1543 }
1544}
1545
1546pub fn parse_checker_output(
1551 stdout: &str,
1552 stderr: &str,
1553 file: &Path,
1554 checker: &str,
1555) -> Vec<ValidationError> {
1556 let checker_name = checker_executable_name(checker);
1557 match checker_name.as_str() {
1558 "npx" | "tsc" | "tsgo" => parse_tsc_output(stdout, stderr, file),
1559 "biome" => parse_biome_output(stdout, stderr, file),
1560 "pyright" => parse_pyright_output(stdout, file),
1561 "ruff" => parse_ruff_output(stdout, stderr, file),
1562 "cargo" => parse_cargo_output(stdout, stderr, file),
1563 "go" => parse_go_vet_output(stderr, file),
1564 "staticcheck" => parse_staticcheck_output(stdout, stderr, file),
1565 _ => Vec::new(),
1566 }
1567}
1568
1569fn checker_executable_name(checker: &str) -> String {
1570 let name = checker
1571 .rsplit(['/', '\\'])
1572 .next()
1573 .filter(|name| !name.is_empty())
1574 .unwrap_or(checker)
1575 .to_ascii_lowercase();
1576
1577 for suffix in [".exe", ".cmd", ".bat", ".ps1"] {
1578 if let Some(stripped) = name.strip_suffix(suffix) {
1579 return stripped.to_string();
1580 }
1581 }
1582
1583 name
1584}
1585
1586fn normalize_path_for_compare(path: &str) -> String {
1587 path.trim_start_matches("file://")
1588 .replace('\\', "/")
1589 .trim_start_matches("./")
1590 .to_string()
1591}
1592
1593fn diagnostic_path_matches(file: &Path, diagnostic_file: &str) -> bool {
1594 if diagnostic_file.is_empty() {
1595 return true;
1596 }
1597
1598 let file_str = normalize_path_for_compare(&file.to_string_lossy());
1599 let diagnostic_str = normalize_path_for_compare(diagnostic_file);
1600 file_str == diagnostic_str
1601 || file_str.ends_with(&diagnostic_str)
1602 || diagnostic_str.ends_with(&file_str)
1603}
1604
1605fn line_column_for_byte_offset(source: &str, offset: usize) -> (u32, u32) {
1606 let mut line = 1u32;
1607 let mut column = 1u32;
1608 for (idx, ch) in source.char_indices() {
1609 if idx >= offset {
1610 break;
1611 }
1612 if ch == '\n' {
1613 line += 1;
1614 column = 1;
1615 } else {
1616 column += 1;
1617 }
1618 }
1619 (line, column)
1620}
1621
1622fn json_string_at<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a str> {
1623 let mut current = value;
1624 for key in path {
1625 current = current.get(*key)?;
1626 }
1627 current.as_str()
1628}
1629
1630fn json_u32_at(value: &serde_json::Value, path: &[&str]) -> Option<u32> {
1631 let mut current = value;
1632 for key in path {
1633 current = current.get(*key)?;
1634 }
1635 current.as_u64().map(|n| n as u32)
1636}
1637
1638fn json_location_path(value: &serde_json::Value) -> Option<&str> {
1639 json_string_at(value, &["location", "path", "file"])
1640 .or_else(|| json_string_at(value, &["location", "path"]))
1641 .or_else(|| json_string_at(value, &["filename"]))
1642 .or_else(|| json_string_at(value, &["file"]))
1643}
1644
1645fn diagnostic_message(value: &serde_json::Value) -> String {
1646 json_string_at(value, &["description"])
1647 .or_else(|| json_string_at(value, &["message"]))
1648 .or_else(|| json_string_at(value, &["text"]))
1649 .or_else(|| json_string_at(value, &["category"]))
1650 .unwrap_or("unknown error")
1651 .to_string()
1652}
1653
1654fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1656 let mut errors = Vec::new();
1657 let file_str = file.to_string_lossy();
1658 let combined = format!("{}{}", stdout, stderr);
1660 for line in combined.lines() {
1661 if let Some((loc, rest)) = line.split_once("): ") {
1664 let file_part = loc.split('(').next().unwrap_or("");
1666 if !file_str.ends_with(file_part)
1667 && !file_part.ends_with(&*file_str)
1668 && file_part != &*file_str
1669 {
1670 continue;
1671 }
1672
1673 let coords = loc.split('(').last().unwrap_or("");
1675 let parts: Vec<&str> = coords.split(',').collect();
1676 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1677 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1678
1679 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1681 ("error".to_string(), msg.to_string())
1682 } else if let Some(msg) = rest.strip_prefix("warning ") {
1683 ("warning".to_string(), msg.to_string())
1684 } else {
1685 ("error".to_string(), rest.to_string())
1686 };
1687
1688 errors.push(ValidationError {
1689 line: line_num,
1690 column: col_num,
1691 message,
1692 severity,
1693 });
1694 }
1695 }
1696 errors
1697}
1698
1699fn parse_biome_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1700 let mut errors = Vec::new();
1701 for output in [stdout, stderr] {
1702 let trimmed = output.trim();
1703 if trimmed.is_empty() {
1704 continue;
1705 }
1706 if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
1707 parse_biome_json_value(&json, file, &mut errors);
1708 }
1709 }
1710 errors
1711}
1712
1713fn parse_biome_json_value(
1714 json: &serde_json::Value,
1715 file: &Path,
1716 errors: &mut Vec<ValidationError>,
1717) {
1718 let diagnostics: Vec<&serde_json::Value> = if let Some(diags) = json
1719 .get("diagnostics")
1720 .and_then(|diagnostics| diagnostics.as_array())
1721 {
1722 diags.iter().collect()
1723 } else if let Some(diags) = json.as_array() {
1724 diags.iter().collect()
1725 } else {
1726 Vec::new()
1727 };
1728
1729 let source = std::fs::read_to_string(file).ok();
1730 for diag in diagnostics {
1731 if let Some(diag_file) = json_location_path(diag) {
1732 if !diagnostic_path_matches(file, diag_file) {
1733 continue;
1734 }
1735 }
1736
1737 let (line, column) = biome_line_column(diag, source.as_deref());
1738 errors.push(ValidationError {
1739 line,
1740 column,
1741 message: diagnostic_message(diag),
1742 severity: diag
1743 .get("severity")
1744 .and_then(|severity| severity.as_str())
1745 .unwrap_or("error")
1746 .to_lowercase(),
1747 });
1748 }
1749}
1750
1751fn biome_line_column(diag: &serde_json::Value, source: Option<&str>) -> (u32, u32) {
1752 if let Some(line) =
1753 json_u32_at(diag, &["location", "line"]).or_else(|| json_u32_at(diag, &["line"]))
1754 {
1755 let column = json_u32_at(diag, &["location", "column"])
1756 .or_else(|| json_u32_at(diag, &["column"]))
1757 .unwrap_or(0);
1758 return (line, column);
1759 }
1760
1761 let offset = diag
1762 .get("location")
1763 .and_then(|location| location.get("span"))
1764 .and_then(|span| span.as_array())
1765 .and_then(|span| span.first())
1766 .and_then(|offset| offset.as_u64())
1767 .map(|offset| offset as usize);
1768
1769 match (source, offset) {
1770 (Some(source), Some(offset)) => line_column_for_byte_offset(source, offset),
1771 _ => (0, 0),
1772 }
1773}
1774
1775fn parse_ruff_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1776 let mut errors = Vec::new();
1777 for output in [stdout, stderr] {
1778 let trimmed = output.trim();
1779 if trimmed.is_empty() {
1780 continue;
1781 }
1782 if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
1783 parse_ruff_json_value(&json, file, &mut errors);
1784 }
1785 }
1786 errors
1787}
1788
1789fn parse_ruff_json_value(json: &serde_json::Value, file: &Path, errors: &mut Vec<ValidationError>) {
1790 let diagnostics: Vec<&serde_json::Value> = if let Some(diags) = json.as_array() {
1791 diags.iter().collect()
1792 } else if let Some(diags) = json.get("diagnostics").and_then(|d| d.as_array()) {
1793 diags.iter().collect()
1794 } else {
1795 Vec::new()
1796 };
1797
1798 for diag in diagnostics {
1799 let diag_file = diag
1800 .get("filename")
1801 .and_then(|filename| filename.as_str())
1802 .unwrap_or("");
1803 if !diagnostic_path_matches(file, diag_file) {
1804 continue;
1805 }
1806
1807 let message = match (
1808 diag.get("code").and_then(|code| code.as_str()),
1809 diag.get("message").and_then(|message| message.as_str()),
1810 ) {
1811 (Some(code), Some(message)) => format!("{code}: {message}"),
1812 (None, Some(message)) => message.to_string(),
1813 (Some(code), None) => code.to_string(),
1814 (None, None) => "unknown error".to_string(),
1815 };
1816
1817 errors.push(ValidationError {
1818 line: json_u32_at(diag, &["location", "row"])
1819 .or_else(|| json_u32_at(diag, &["location", "line"]))
1820 .unwrap_or(0),
1821 column: json_u32_at(diag, &["location", "column"]).unwrap_or(0),
1822 message,
1823 severity: diag
1824 .get("severity")
1825 .and_then(|severity| severity.as_str())
1826 .unwrap_or("error")
1827 .to_lowercase(),
1828 });
1829 }
1830}
1831
1832fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1834 let mut errors = Vec::new();
1835 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1837 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1838 for diag in diags {
1839 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1841 if !diagnostic_path_matches(file, diag_file) {
1842 continue;
1843 }
1844
1845 let line_num = diag
1846 .get("range")
1847 .and_then(|r| r.get("start"))
1848 .and_then(|s| s.get("line"))
1849 .and_then(|l| l.as_u64())
1850 .unwrap_or(0) as u32;
1851 let col_num = diag
1852 .get("range")
1853 .and_then(|r| r.get("start"))
1854 .and_then(|s| s.get("character"))
1855 .and_then(|c| c.as_u64())
1856 .unwrap_or(0) as u32;
1857 let message = diag
1858 .get("message")
1859 .and_then(|m| m.as_str())
1860 .unwrap_or("unknown error")
1861 .to_string();
1862 let severity = diag
1863 .get("severity")
1864 .and_then(|s| s.as_str())
1865 .unwrap_or("error")
1866 .to_lowercase();
1867
1868 errors.push(ValidationError {
1869 line: line_num + 1, column: col_num + 1, message,
1872 severity,
1873 });
1874 }
1875 }
1876 }
1877 errors
1878}
1879
1880fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1882 let mut errors = Vec::new();
1883 let file_str = file.to_string_lossy();
1884
1885 for line in stdout.lines() {
1886 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1887 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1888 continue;
1889 }
1890 let message_obj = match msg.get("message") {
1891 Some(m) => m,
1892 None => continue,
1893 };
1894
1895 let level = message_obj
1896 .get("level")
1897 .and_then(|l| l.as_str())
1898 .unwrap_or("error");
1899
1900 if level != "error" && level != "warning" {
1902 continue;
1903 }
1904
1905 let text = message_obj
1906 .get("message")
1907 .and_then(|m| m.as_str())
1908 .unwrap_or("unknown error")
1909 .to_string();
1910
1911 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1913 for span in spans {
1914 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1915 let is_primary = span
1916 .get("is_primary")
1917 .and_then(|p| p.as_bool())
1918 .unwrap_or(false);
1919
1920 if !is_primary {
1921 continue;
1922 }
1923
1924 if !file_str.ends_with(span_file)
1926 && !span_file.ends_with(&*file_str)
1927 && span_file != &*file_str
1928 {
1929 continue;
1930 }
1931
1932 let line_num =
1933 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1934 let col_num = span
1935 .get("column_start")
1936 .and_then(|c| c.as_u64())
1937 .unwrap_or(0) as u32;
1938
1939 errors.push(ValidationError {
1940 line: line_num,
1941 column: col_num,
1942 message: text.clone(),
1943 severity: level.to_string(),
1944 });
1945 }
1946 }
1947 }
1948 }
1949 errors
1950}
1951
1952fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1954 let mut errors = Vec::new();
1955 let pattern =
1956 regex::Regex::new(r"^(?P<file>.+?):(?P<line>\d+)(?::(?P<col>\d+))?:\s*(?P<message>.*)$")
1957 .expect("valid go vet diagnostic regex");
1958
1959 for line in stderr.lines() {
1960 let Some(captures) = pattern.captures(line) else {
1961 continue;
1962 };
1963
1964 let err_file = captures
1965 .name("file")
1966 .map(|m| m.as_str())
1967 .unwrap_or("")
1968 .trim();
1969 if !diagnostic_path_matches(file, err_file) {
1970 continue;
1971 }
1972
1973 errors.push(ValidationError {
1974 line: captures
1975 .name("line")
1976 .and_then(|m| m.as_str().parse().ok())
1977 .unwrap_or(0),
1978 column: captures
1979 .name("col")
1980 .and_then(|m| m.as_str().parse().ok())
1981 .unwrap_or(0),
1982 message: captures
1983 .name("message")
1984 .map(|m| m.as_str().trim().to_string())
1985 .unwrap_or_else(|| "unknown error".to_string()),
1986 severity: "error".to_string(),
1987 });
1988 }
1989 errors
1990}
1991
1992fn parse_staticcheck_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1993 let combined = format!("{}\n{}", stdout, stderr);
1994 let trimmed = combined.trim();
1995 if trimmed.is_empty() {
1996 return Vec::new();
1997 }
1998
1999 let mut errors = Vec::new();
2000 if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
2001 parse_staticcheck_json_value(&json, file, &mut errors);
2002 return errors;
2003 }
2004
2005 for line in trimmed.lines() {
2006 let line = line.trim();
2007 if line.is_empty() {
2008 continue;
2009 }
2010 if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
2011 parse_staticcheck_json_value(&json, file, &mut errors);
2012 }
2013 }
2014
2015 errors
2016}
2017
2018fn parse_staticcheck_json_value(
2019 json: &serde_json::Value,
2020 file: &Path,
2021 errors: &mut Vec<ValidationError>,
2022) {
2023 if let Some(diags) = json.as_array() {
2024 for diag in diags {
2025 parse_staticcheck_diag(diag, file, errors);
2026 }
2027 } else if let Some(diags) = json.get("diagnostics").and_then(|d| d.as_array()) {
2028 for diag in diags {
2029 parse_staticcheck_diag(diag, file, errors);
2030 }
2031 } else if let Some(diags) = json.get("issues").and_then(|d| d.as_array()) {
2032 for diag in diags {
2033 parse_staticcheck_diag(diag, file, errors);
2034 }
2035 } else {
2036 parse_staticcheck_diag(json, file, errors);
2037 }
2038}
2039
2040fn parse_staticcheck_diag(
2041 diag: &serde_json::Value,
2042 file: &Path,
2043 errors: &mut Vec<ValidationError>,
2044) {
2045 let diag_file = json_string_at(diag, &["location", "file"])
2046 .or_else(|| json_string_at(diag, &["file"]))
2047 .unwrap_or("");
2048 if !diagnostic_path_matches(file, diag_file) {
2049 return;
2050 }
2051
2052 let message = match (
2053 diag.get("code").and_then(|code| code.as_str()),
2054 diag.get("message").and_then(|message| message.as_str()),
2055 ) {
2056 (Some(code), Some(message)) => format!("{code}: {message}"),
2057 (None, Some(message)) => message.to_string(),
2058 (Some(code), None) => code.to_string(),
2059 (None, None) => "unknown error".to_string(),
2060 };
2061
2062 errors.push(ValidationError {
2063 line: json_u32_at(diag, &["location", "line"])
2064 .or_else(|| json_u32_at(diag, &["line"]))
2065 .unwrap_or(0),
2066 column: json_u32_at(diag, &["location", "column"])
2067 .or_else(|| json_u32_at(diag, &["column"]))
2068 .unwrap_or(0),
2069 message,
2070 severity: diag
2071 .get("severity")
2072 .and_then(|severity| severity.as_str())
2073 .unwrap_or("error")
2074 .to_lowercase(),
2075 });
2076}
2077
2078fn output_tail_summary(stdout: &str, stderr: &str, truncated: bool) -> String {
2079 let mut parts = Vec::new();
2080 if let Some(tail) = short_output_tail(stderr) {
2081 parts.push(format!("stderr: {tail}"));
2082 }
2083 if let Some(tail) = short_output_tail(stdout) {
2084 parts.push(format!("stdout: {tail}"));
2085 }
2086 if truncated {
2087 parts.push("output truncated".to_string());
2088 }
2089
2090 if parts.is_empty() {
2091 "no output".to_string()
2092 } else {
2093 parts.join("; ")
2094 }
2095}
2096
2097fn short_output_tail(output: &str) -> Option<String> {
2098 let trimmed = output.trim();
2099 if trimmed.is_empty() {
2100 return None;
2101 }
2102
2103 let mut lines: Vec<&str> = trimmed.lines().rev().take(3).collect();
2104 lines.reverse();
2105 let mut tail = lines.join(" | ");
2106 const MAX_TAIL_CHARS: usize = 500;
2107 if tail.len() > MAX_TAIL_CHARS {
2108 let start = tail.len().saturating_sub(MAX_TAIL_CHARS);
2109 tail = format!("…{}", &tail[start..]);
2110 }
2111 Some(tail)
2112}
2113
2114pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
2123 let lang = match detect_language(path) {
2124 Some(l) => l,
2125 None => {
2126 log::debug!(
2127 "validate: {} (skipped: unsupported_language)",
2128 path.display()
2129 );
2130 return (Vec::new(), Some("unsupported_language".to_string()));
2131 }
2132 };
2133 if !has_checker_support(lang) {
2134 log::debug!(
2135 "validate: {} (skipped: unsupported_language)",
2136 path.display()
2137 );
2138 return (Vec::new(), Some("unsupported_language".to_string()));
2139 }
2140
2141 let (cmd, args) = match detect_checker_for_path(path, lang, config) {
2142 ToolDetection::Found(cmd, args) => (cmd, args),
2143 ToolDetection::NotConfigured => {
2144 log::debug!(
2145 "validate: {} (skipped: no_checker_configured)",
2146 path.display()
2147 );
2148 return (Vec::new(), Some("no_checker_configured".to_string()));
2149 }
2150 ToolDetection::NotInstalled { tool } => {
2151 crate::slog_warn!(
2152 "validate: {} (skipped: checker_not_installed: {})",
2153 path.display(),
2154 tool
2155 );
2156 return (Vec::new(), Some("checker_not_installed".to_string()));
2157 }
2158 };
2159
2160 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
2161
2162 let working_dir = config.project_root.as_deref();
2164
2165 match run_external_tool_capture(
2166 &cmd,
2167 &arg_refs,
2168 working_dir,
2169 config.type_checker_timeout_secs,
2170 ) {
2171 Ok(result) => {
2172 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
2173 if result.exit_code != 0 && errors.is_empty() {
2174 let summary = output_tail_summary(&result.stdout, &result.stderr, result.truncated);
2175 log::debug!(
2176 "validate: {} (skipped: error: checker exited {} with {})",
2177 path.display(),
2178 result.exit_code,
2179 summary
2180 );
2181 return (Vec::new(), Some("error".to_string()));
2182 }
2183 log::debug!(
2184 "validate: {} ({}, {} errors)",
2185 path.display(),
2186 cmd,
2187 errors.len()
2188 );
2189 (errors, None)
2190 }
2191 Err(FormatError::Timeout { .. }) => {
2192 crate::slog_error!("validate: {} (skipped: timeout)", path.display());
2193 (Vec::new(), Some("timeout".to_string()))
2194 }
2195 Err(FormatError::NotFound { .. }) => {
2196 crate::slog_warn!(
2197 "validate: {} (skipped: checker_not_installed)",
2198 path.display()
2199 );
2200 (Vec::new(), Some("checker_not_installed".to_string()))
2201 }
2202 Err(FormatError::Failed { stderr, .. }) => {
2203 log::debug!(
2204 "validate: {} (skipped: error: {})",
2205 path.display(),
2206 stderr.lines().next().unwrap_or("unknown")
2207 );
2208 (Vec::new(), Some("error".to_string()))
2209 }
2210 Err(FormatError::UnsupportedLanguage) => {
2211 log::debug!(
2212 "validate: {} (skipped: unsupported_language)",
2213 path.display()
2214 );
2215 (Vec::new(), Some("unsupported_language".to_string()))
2216 }
2217 }
2218}
2219
2220#[cfg(test)]
2221mod tests {
2222 use super::*;
2223 use std::fs;
2224 use std::io::Write;
2225 use std::sync::{Mutex, MutexGuard, OnceLock};
2226
2227 fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
2234 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2235 let mutex = LOCK.get_or_init(|| Mutex::new(()));
2236 match mutex.lock() {
2239 Ok(guard) => guard,
2240 Err(poisoned) => poisoned.into_inner(),
2241 }
2242 }
2243
2244 #[test]
2245 fn run_external_tool_not_found() {
2246 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
2247 assert!(result.is_err());
2248 match result.unwrap_err() {
2249 FormatError::NotFound { tool } => {
2250 assert_eq!(tool, "__nonexistent_tool_xyz__");
2251 }
2252 other => panic!("expected NotFound, got: {:?}", other),
2253 }
2254 }
2255
2256 #[test]
2257 fn run_external_tool_timeout_kills_subprocess() {
2258 let result = run_external_tool("sleep", &["60"], None, 1);
2260 assert!(result.is_err());
2261 match result.unwrap_err() {
2262 FormatError::Timeout { tool, timeout_secs } => {
2263 assert_eq!(tool, "sleep");
2264 assert_eq!(timeout_secs, 1);
2265 }
2266 other => panic!("expected Timeout, got: {:?}", other),
2267 }
2268 }
2269
2270 #[test]
2271 fn run_external_tool_success() {
2272 let result = run_external_tool("echo", &["hello"], None, 5);
2273 assert!(result.is_ok());
2274 let res = result.unwrap();
2275 assert_eq!(res.exit_code, 0);
2276 assert!(res.stdout.contains("hello"));
2277 }
2278
2279 #[cfg(unix)]
2280 #[test]
2281 fn format_helper_handles_large_stderr_without_deadlock() {
2282 let start = Instant::now();
2283 let result = run_external_tool_capture(
2284 "sh",
2285 &[
2286 "-c",
2287 "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
2288 ],
2289 None,
2290 2,
2291 )
2292 .expect("large stderr command should complete");
2293
2294 assert_eq!(result.exit_code, 0);
2295 assert!(
2296 result.stderr.len() >= 1024 * 1024,
2297 "expected full stderr capture, got {} bytes",
2298 result.stderr.len()
2299 );
2300 assert!(start.elapsed() < Duration::from_secs(2));
2301 }
2302
2303 #[test]
2304 fn run_external_tool_nonzero_exit() {
2305 let result = run_external_tool("false", &[], None, 5);
2307 assert!(result.is_err());
2308 match result.unwrap_err() {
2309 FormatError::Failed { tool, .. } => {
2310 assert_eq!(tool, "false");
2311 }
2312 other => panic!("expected Failed, got: {:?}", other),
2313 }
2314 }
2315
2316 #[test]
2317 fn auto_format_unsupported_language() {
2318 let dir = tempfile::tempdir().unwrap();
2319 let path = dir.path().join("file.txt");
2320 fs::write(&path, "hello").unwrap();
2321
2322 let config = Config::default();
2323 let (formatted, reason) = auto_format(&path, &config);
2324 assert!(!formatted);
2325 assert_eq!(reason.as_deref(), Some("unsupported_language"));
2326 }
2327
2328 #[test]
2329 fn detect_formatter_rust_when_rustfmt_available() {
2330 let dir = tempfile::tempdir().unwrap();
2331 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2332 let path = dir.path().join("test.rs");
2333 let config = Config {
2334 project_root: Some(dir.path().to_path_buf()),
2335 ..Config::default()
2336 };
2337 let result = detect_formatter(&path, LangId::Rust, &config);
2338 if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
2339 let (cmd, args) = result.unwrap();
2340 let stem = std::path::Path::new(&cmd)
2344 .file_stem()
2345 .and_then(|s| s.to_str())
2346 .unwrap_or("");
2347 assert_eq!(stem, "rustfmt", "expected rustfmt, got {cmd}");
2348 assert!(args.iter().any(|a| a.ends_with("test.rs")));
2349 } else {
2350 assert!(result.is_none());
2351 }
2352 }
2353
2354 #[test]
2355 fn detect_formatter_go_mapping() {
2356 let dir = tempfile::tempdir().unwrap();
2357 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2358 let path = dir.path().join("main.go");
2359 let config = Config {
2360 project_root: Some(dir.path().to_path_buf()),
2361 ..Config::default()
2362 };
2363 let result = detect_formatter(&path, LangId::Go, &config);
2364 if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
2365 let (cmd, args) = result.unwrap();
2366 assert_eq!(
2367 std::path::Path::new(&cmd)
2368 .file_stem()
2369 .and_then(|s| s.to_str())
2370 .unwrap_or(""),
2371 "goimports",
2372 "expected goimports, got {cmd}"
2373 );
2374 assert!(args.contains(&"-w".to_string()));
2375 } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
2376 let (cmd, args) = result.unwrap();
2377 assert_eq!(
2378 std::path::Path::new(&cmd)
2379 .file_stem()
2380 .and_then(|s| s.to_str())
2381 .unwrap_or(""),
2382 "gofmt",
2383 "expected gofmt, got {cmd}"
2384 );
2385 assert!(args.contains(&"-w".to_string()));
2386 } else {
2387 assert!(result.is_none());
2388 }
2389 }
2390
2391 #[test]
2392 fn detect_formatter_python_mapping() {
2393 let dir = tempfile::tempdir().unwrap();
2394 fs::write(dir.path().join("ruff.toml"), "").unwrap();
2395 let path = dir.path().join("main.py");
2396 let config = Config {
2397 project_root: Some(dir.path().to_path_buf()),
2398 ..Config::default()
2399 };
2400 let result = detect_formatter(&path, LangId::Python, &config);
2401 if ruff_format_available(config.project_root.as_deref()) {
2402 let (cmd, args) = result.unwrap();
2403 assert_eq!(
2404 std::path::Path::new(&cmd)
2405 .file_stem()
2406 .and_then(|s| s.to_str())
2407 .unwrap_or(""),
2408 "ruff",
2409 "expected ruff, got {cmd}"
2410 );
2411 assert!(args.contains(&"format".to_string()));
2412 } else {
2413 assert!(result.is_none());
2414 }
2415 }
2416
2417 #[test]
2418 fn detect_formatter_no_config_returns_none() {
2419 let path = Path::new("test.ts");
2420 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
2421 assert!(
2422 result.is_none(),
2423 "expected no formatter without project config"
2424 );
2425 }
2426
2427 #[cfg(unix)]
2428 #[test]
2429 fn detect_formatter_oxfmt_config_for_typescript_projects() {
2430 let _guard = tool_cache_test_lock();
2431 clear_tool_cache();
2432 let dir = tempfile::tempdir().unwrap();
2433 fs::write(dir.path().join(".oxfmtrc.json"), "{}\n").unwrap();
2434 let bin_dir = dir.path().join("node_modules").join(".bin");
2435 fs::create_dir_all(&bin_dir).unwrap();
2436 let fake = bin_dir.join("oxfmt");
2437 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2438 use std::os::unix::fs::PermissionsExt;
2439 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2440
2441 let path = dir.path().join("src/app.ts");
2442 let config = Config {
2443 project_root: Some(dir.path().to_path_buf()),
2444 ..Config::default()
2445 };
2446
2447 let (cmd, args) = detect_formatter(&path, LangId::TypeScript, &config).unwrap();
2448 assert!(cmd.ends_with("oxfmt"), "expected oxfmt, got {cmd}");
2449 assert_eq!(args[0], "--write");
2450 assert!(args.iter().any(|arg| arg.ends_with("src/app.ts")));
2451 }
2452
2453 #[cfg(unix)]
2459 #[test]
2460 fn detect_formatter_explicit_override() {
2461 let dir = tempfile::tempdir().unwrap();
2463 let bin_dir = dir.path().join("node_modules").join(".bin");
2464 fs::create_dir_all(&bin_dir).unwrap();
2465 use std::os::unix::fs::PermissionsExt;
2466 let fake = bin_dir.join("biome");
2467 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2468 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2469
2470 let path = Path::new("test.ts");
2471 let mut config = Config {
2472 project_root: Some(dir.path().to_path_buf()),
2473 ..Config::default()
2474 };
2475 config
2476 .formatter
2477 .insert("typescript".to_string(), "biome".to_string());
2478 let result = detect_formatter(path, LangId::TypeScript, &config);
2479 let (cmd, args) = result.unwrap();
2480 assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
2481 assert!(args.contains(&"format".to_string()));
2482 assert!(args.contains(&"--write".to_string()));
2483 }
2484
2485 #[cfg(unix)]
2486 #[test]
2487 fn detect_formatter_explicit_oxfmt_override() {
2488 let _guard = tool_cache_test_lock();
2489 clear_tool_cache();
2490 let dir = tempfile::tempdir().unwrap();
2491 let bin_dir = dir.path().join("node_modules").join(".bin");
2492 fs::create_dir_all(&bin_dir).unwrap();
2493 use std::os::unix::fs::PermissionsExt;
2494 let fake = bin_dir.join("oxfmt");
2495 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2496 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2497
2498 let path = Path::new("test.ts");
2499 let mut config = Config {
2500 project_root: Some(dir.path().to_path_buf()),
2501 ..Config::default()
2502 };
2503 config
2504 .formatter
2505 .insert("typescript".to_string(), "oxfmt".to_string());
2506
2507 let (cmd, args) = detect_formatter(path, LangId::TypeScript, &config).unwrap();
2508 assert!(cmd.contains("oxfmt"), "expected oxfmt in cmd, got: {cmd}");
2509 assert_eq!(args, vec!["--write".to_string(), "test.ts".to_string()]);
2510 }
2511
2512 #[test]
2513 fn resolve_tool_caches_positive_result_until_clear() {
2514 let _guard = tool_cache_test_lock();
2515 clear_tool_cache();
2516 let dir = tempfile::tempdir().unwrap();
2517 let bin_dir = dir.path().join("node_modules").join(".bin");
2518 fs::create_dir_all(&bin_dir).unwrap();
2519 let tool = bin_dir.join("aft-cache-hit-tool");
2520 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2521
2522 let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2523 assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
2524
2525 fs::remove_file(&tool).unwrap();
2526 let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2527 assert_eq!(cached, first);
2528
2529 clear_tool_cache();
2530 assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
2531 }
2532
2533 #[test]
2534 fn resolve_tool_caches_negative_result_until_clear() {
2535 let _guard = tool_cache_test_lock();
2536 clear_tool_cache();
2537 let dir = tempfile::tempdir().unwrap();
2538 let bin_dir = dir.path().join("node_modules").join(".bin");
2539 let tool = bin_dir.join("aft-cache-miss-tool");
2540
2541 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2542
2543 fs::create_dir_all(&bin_dir).unwrap();
2544 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2545 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2546
2547 clear_tool_cache();
2548 assert_eq!(
2549 resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
2550 Some(tool.to_string_lossy().as_ref())
2551 );
2552 }
2553
2554 #[test]
2555 fn auto_format_happy_path_rustfmt() {
2556 if resolve_tool("rustfmt", None).is_none() {
2557 crate::slog_warn!("skipping: rustfmt not available");
2558 return;
2559 }
2560
2561 let dir = tempfile::tempdir().unwrap();
2562 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2563 let path = dir.path().join("test.rs");
2564
2565 let mut f = fs::File::create(&path).unwrap();
2566 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
2567 drop(f);
2568
2569 let config = Config {
2570 project_root: Some(dir.path().to_path_buf()),
2571 ..Config::default()
2572 };
2573 let (formatted, reason) = auto_format(&path, &config);
2574 assert!(formatted, "expected formatting to succeed");
2575 assert!(reason.is_none());
2576
2577 let content = fs::read_to_string(&path).unwrap();
2578 assert!(
2579 !content.contains("fn main"),
2580 "expected rustfmt to fix spacing"
2581 );
2582 }
2583
2584 #[test]
2585 fn formatter_excluded_path_detects_biome_messages() {
2586 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";
2588 assert!(
2589 formatter_excluded_path(stderr),
2590 "expected biome exclusion stderr to be detected"
2591 );
2592 }
2593
2594 #[test]
2595 fn formatter_excluded_path_detects_prettier_messages() {
2596 let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
2599 assert!(
2600 formatter_excluded_path(stderr),
2601 "expected prettier exclusion stderr to be detected"
2602 );
2603 }
2604
2605 #[test]
2606 fn formatter_excluded_path_detects_oxfmt_messages() {
2607 assert!(formatter_excluded_path(
2608 "Expected at least one target file. All matched files may have been excluded by ignore rules."
2609 ));
2610 assert!(formatter_excluded_path(
2611 "No files found matching the given patterns."
2612 ));
2613 }
2614
2615 #[test]
2616 fn formatter_excluded_path_detects_ruff_messages() {
2617 let stderr = "warning: No Python files found under the given path(s).\n";
2619 assert!(
2620 formatter_excluded_path(stderr),
2621 "expected ruff exclusion stderr to be detected"
2622 );
2623 }
2624
2625 #[test]
2626 fn formatter_excluded_path_is_case_insensitive() {
2627 assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
2628 assert!(formatter_excluded_path("Ignored By The Configuration"));
2629 assert!(formatter_excluded_path("EXPECTED AT LEAST ONE TARGET FILE"));
2630 }
2631
2632 #[test]
2633 fn formatter_excluded_path_rejects_real_errors() {
2634 assert!(!formatter_excluded_path(""));
2637 assert!(!formatter_excluded_path("syntax error: unexpected token"));
2638 assert!(!formatter_excluded_path("formatter crashed: out of memory"));
2639 assert!(!formatter_excluded_path(
2640 "permission denied: /readonly/file"
2641 ));
2642 assert!(!formatter_excluded_path(
2643 "biome internal error: please report"
2644 ));
2645 }
2646
2647 #[test]
2648 fn parse_tsc_output_basic() {
2649 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";
2650 let file = Path::new("src/app.ts");
2651 let errors = parse_tsc_output(stdout, "", file);
2652 assert_eq!(errors.len(), 2);
2653 assert_eq!(errors[0].line, 10);
2654 assert_eq!(errors[0].column, 5);
2655 assert_eq!(errors[0].severity, "error");
2656 assert!(errors[0].message.contains("TS2322"));
2657 assert_eq!(errors[1].line, 20);
2658 }
2659
2660 #[test]
2661 fn parse_tsc_output_filters_other_files() {
2662 let stdout =
2663 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
2664 let file = Path::new("src/app.ts");
2665 let errors = parse_tsc_output(stdout, "", file);
2666 assert_eq!(errors.len(), 1);
2667 assert_eq!(errors[0].line, 5);
2668 }
2669
2670 #[test]
2671 fn parse_cargo_output_basic() {
2672 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}]}}"#;
2673 let file = Path::new("src/main.rs");
2674 let errors = parse_cargo_output(json_line, "", file);
2675 assert_eq!(errors.len(), 1);
2676 assert_eq!(errors[0].line, 10);
2677 assert_eq!(errors[0].column, 5);
2678 assert_eq!(errors[0].severity, "error");
2679 assert!(errors[0].message.contains("mismatched types"));
2680 }
2681
2682 #[test]
2683 fn parse_cargo_output_skips_notes() {
2684 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}]}}"#;
2686 let file = Path::new("src/main.rs");
2687 let errors = parse_cargo_output(json_line, "", file);
2688 assert_eq!(errors.len(), 0);
2689 }
2690
2691 #[test]
2692 fn parse_cargo_output_filters_other_files() {
2693 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}]}}"#;
2694 let file = Path::new("src/main.rs");
2695 let errors = parse_cargo_output(json_line, "", file);
2696 assert_eq!(errors.len(), 0);
2697 }
2698
2699 #[test]
2700 fn parse_go_vet_output_basic() {
2701 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2702 let file = Path::new("main.go");
2703 let errors = parse_go_vet_output(stderr, file);
2704 assert_eq!(errors.len(), 2);
2705 assert_eq!(errors[0].line, 10);
2706 assert_eq!(errors[0].column, 5);
2707 assert!(errors[0].message.contains("unreachable code"));
2708 assert_eq!(errors[1].line, 20);
2709 assert_eq!(errors[1].column, 0);
2710 }
2711
2712 #[test]
2713 fn parse_pyright_output_basic() {
2714 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2715 let file = Path::new("test.py");
2716 let errors = parse_pyright_output(stdout, file);
2717 assert_eq!(errors.len(), 1);
2718 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 11);
2720 assert_eq!(errors[0].severity, "error");
2721 assert!(errors[0].message.contains("Type error here"));
2722 }
2723
2724 #[test]
2725 fn validate_full_unsupported_language() {
2726 let dir = tempfile::tempdir().unwrap();
2727 let path = dir.path().join("file.txt");
2728 fs::write(&path, "hello").unwrap();
2729
2730 let config = Config::default();
2731 let (errors, reason) = validate_full(&path, &config);
2732 assert!(errors.is_empty());
2733 assert_eq!(reason.as_deref(), Some("unsupported_language"));
2734 }
2735
2736 #[test]
2737 fn detect_type_checker_rust() {
2738 let dir = tempfile::tempdir().unwrap();
2739 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2740 let path = dir.path().join("src/main.rs");
2741 let config = Config {
2742 project_root: Some(dir.path().to_path_buf()),
2743 ..Config::default()
2744 };
2745 let result = detect_type_checker(&path, LangId::Rust, &config);
2746 if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2747 let (cmd, args) = result.unwrap();
2748 assert_eq!(
2749 std::path::Path::new(&cmd)
2750 .file_stem()
2751 .and_then(|s| s.to_str())
2752 .unwrap_or(""),
2753 "cargo",
2754 "expected cargo, got {cmd}"
2755 );
2756 assert!(args.contains(&"check".to_string()));
2757 } else {
2758 assert!(result.is_none());
2759 }
2760 }
2761
2762 #[test]
2763 fn detect_type_checker_go() {
2764 let dir = tempfile::tempdir().unwrap();
2765 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2766 let path = dir.path().join("main.go");
2767 let config = Config {
2768 project_root: Some(dir.path().to_path_buf()),
2769 ..Config::default()
2770 };
2771 let result = detect_type_checker(&path, LangId::Go, &config);
2772 if resolve_tool("go", config.project_root.as_deref()).is_some() {
2773 let (cmd, _args) = result.unwrap();
2774 let name = checker_executable_name(&cmd);
2776 assert!(
2777 name == "go" || name == "staticcheck",
2778 "expected go or staticcheck, got {cmd}"
2779 );
2780 } else {
2781 assert!(result.is_none());
2782 }
2783 }
2784
2785 #[cfg(unix)]
2786 #[test]
2787 fn detect_type_checker_defaults_to_tsc_for_typescript() {
2788 let _guard = tool_cache_test_lock();
2789 clear_tool_cache();
2790 let dir = tempfile::tempdir().unwrap();
2791 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2792 let bin_dir = dir.path().join("node_modules").join(".bin");
2793 fs::create_dir_all(&bin_dir).unwrap();
2794 use std::os::unix::fs::PermissionsExt;
2795 let fake_tsc = bin_dir.join("tsc");
2796 fs::write(&fake_tsc, "#!/bin/sh\nexit 0").unwrap();
2797 fs::set_permissions(&fake_tsc, fs::Permissions::from_mode(0o755)).unwrap();
2798 let fake_tsgo = bin_dir.join("tsgo");
2799 fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2800 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2801
2802 let path = dir.path().join("src/app.ts");
2803 let config = Config {
2804 project_root: Some(dir.path().to_path_buf()),
2805 ..Config::default()
2806 };
2807
2808 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2809 assert!(cmd.ends_with("tsc"), "expected tsc by default, got: {cmd}");
2810 assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2811 }
2812
2813 #[cfg(unix)]
2814 #[test]
2815 fn detect_type_checker_uses_tsgo_when_explicitly_configured() {
2816 let _guard = tool_cache_test_lock();
2817 clear_tool_cache();
2818 let dir = tempfile::tempdir().unwrap();
2819 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2820 let bin_dir = dir.path().join("node_modules").join(".bin");
2821 fs::create_dir_all(&bin_dir).unwrap();
2822 use std::os::unix::fs::PermissionsExt;
2823 let fake_tsgo = bin_dir.join("tsgo");
2824 fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2825 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2826
2827 let path = dir.path().join("src/app.ts");
2828 let mut config = Config {
2829 project_root: Some(dir.path().to_path_buf()),
2830 ..Config::default()
2831 };
2832 config
2833 .checker
2834 .insert("typescript".to_string(), "tsgo".to_string());
2835
2836 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2837 assert!(cmd.ends_with("tsgo"), "expected tsgo, got: {cmd}");
2838 assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2839 }
2840
2841 #[cfg(unix)]
2842 #[test]
2843 fn validate_full_explicit_tsgo_parses_diagnostics() {
2844 let _guard = tool_cache_test_lock();
2845 clear_tool_cache();
2846 let dir = tempfile::tempdir().unwrap();
2847 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2848 let src_dir = dir.path().join("src");
2849 fs::create_dir_all(&src_dir).unwrap();
2850 let path = src_dir.join("app.ts");
2851 fs::write(&path, "const value: number = 'nope';\n").unwrap();
2852
2853 let bin_dir = dir.path().join("node_modules").join(".bin");
2854 fs::create_dir_all(&bin_dir).unwrap();
2855 use std::os::unix::fs::PermissionsExt;
2856 let fake_tsgo = bin_dir.join("tsgo");
2857 fs::write(
2858 &fake_tsgo,
2859 "#!/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",
2860 )
2861 .unwrap();
2862 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2863
2864 let mut config = Config {
2865 project_root: Some(dir.path().to_path_buf()),
2866 ..Config::default()
2867 };
2868 config
2869 .checker
2870 .insert("typescript".to_string(), "tsgo".to_string());
2871
2872 let (errors, reason) = validate_full(&path, &config);
2873 assert_eq!(reason, None);
2874 assert_eq!(errors.len(), 1);
2875 assert_eq!(errors[0].line, 1);
2876 assert_eq!(errors[0].column, 23);
2877 assert!(errors[0].message.contains("TS2322"));
2878 }
2879
2880 #[test]
2881 fn run_external_tool_capture_nonzero_not_error() {
2882 let result = run_external_tool_capture("false", &[], None, 5);
2884 assert!(result.is_ok(), "capture should not error on non-zero exit");
2885 assert_eq!(result.unwrap().exit_code, 1);
2886 }
2887
2888 #[test]
2889 fn run_external_tool_capture_not_found() {
2890 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2891 assert!(result.is_err());
2892 match result.unwrap_err() {
2893 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2894 other => panic!("expected NotFound, got: {:?}", other),
2895 }
2896 }
2897
2898 #[cfg(unix)]
2902 #[test]
2903 fn well_known_search_paths_include_homebrew_cargo_go_and_local() {
2904 let home = std::ffi::OsString::from("/Users/test-home");
2905 let paths = well_known_search_paths("toolx", Some(&home));
2906 let strs: Vec<String> = paths
2907 .iter()
2908 .map(|p| p.to_string_lossy().into_owned())
2909 .collect();
2910 assert_eq!(strs[0], "/opt/homebrew/bin/toolx");
2913 assert_eq!(strs[1], "/usr/local/bin/toolx");
2914 assert_eq!(strs[2], "/usr/local/go/bin/toolx");
2915 assert_eq!(strs[3], "/usr/bin/toolx");
2916 assert_eq!(strs[4], "/snap/bin/toolx");
2917 assert_eq!(strs[5], "/Users/test-home/.cargo/bin/toolx");
2918 assert_eq!(strs[6], "/Users/test-home/go/bin/toolx");
2919 assert_eq!(strs[7], "/Users/test-home/.local/bin/toolx");
2920 assert_eq!(strs.len(), 8);
2921 }
2922
2923 #[cfg(unix)]
2924 #[test]
2925 fn well_known_search_paths_skips_home_when_unset() {
2926 let paths = well_known_search_paths("toolx", None);
2927 assert_eq!(paths.len(), 5);
2928 assert!(paths[0].ends_with("opt/homebrew/bin/toolx"));
2929 assert!(paths[1].ends_with("usr/local/bin/toolx"));
2930 assert!(paths[2].ends_with("usr/local/go/bin/toolx"));
2931 assert!(paths[3].ends_with("usr/bin/toolx"));
2932 assert!(paths[4].ends_with("snap/bin/toolx"));
2933 }
2934
2935 #[cfg(unix)]
2936 #[test]
2937 fn try_well_known_path_lookup_in_finds_executable_file() {
2938 use std::os::unix::fs::PermissionsExt;
2939 let dir = tempfile::tempdir().unwrap();
2940 let bin_dir = dir.path().join("bin");
2941 fs::create_dir_all(&bin_dir).unwrap();
2942 let tool_path = bin_dir.join("toolx");
2943 fs::write(&tool_path, "#!/bin/sh\necho test").unwrap();
2944 let mut perms = fs::metadata(&tool_path).unwrap().permissions();
2945 perms.set_mode(0o755);
2946 fs::set_permissions(&tool_path, perms).unwrap();
2947
2948 let candidates = vec![
2949 dir.path().join("missing/toolx"),
2950 tool_path.clone(),
2951 dir.path().join("alt/toolx"),
2952 ];
2953 let found = try_well_known_path_lookup_in(&candidates);
2954 assert_eq!(found, Some(tool_path));
2955 }
2956
2957 #[cfg(unix)]
2958 #[test]
2959 fn try_well_known_path_lookup_in_skips_non_executable_file() {
2960 let dir = tempfile::tempdir().unwrap();
2961 let bin_dir = dir.path().join("bin");
2962 fs::create_dir_all(&bin_dir).unwrap();
2963 let tool_path = bin_dir.join("toolx");
2965 fs::write(&tool_path, "not a real tool").unwrap();
2966
2967 let found = try_well_known_path_lookup_in(&std::slice::from_ref(&tool_path));
2968 assert!(found.is_none(), "non-executable file should be skipped");
2969 }
2970
2971 #[cfg(unix)]
2972 #[test]
2973 fn try_well_known_path_lookup_in_skips_directories_and_missing_paths() {
2974 let dir = tempfile::tempdir().unwrap();
2975 let candidates = vec![dir.path().to_path_buf(), dir.path().join("does-not-exist")];
2977 assert!(try_well_known_path_lookup_in(&candidates).is_none());
2978 }
2979
2980 #[cfg(windows)]
2981 #[test]
2982 fn try_well_known_path_lookup_finds_npm_global_shim() {
2983 let dir = tempfile::tempdir().unwrap();
2984 let npm_bin = dir.path().join("npm");
2985 fs::create_dir_all(&npm_bin).unwrap();
2986 let shim = npm_bin.join("biome.cmd");
2987 fs::write(&shim, "@echo off\n").unwrap();
2988
2989 let saved_disable = std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP");
2990 std::env::remove_var("AFT_DISABLE_WELL_KNOWN_LOOKUP");
2991 let saved_appdata = std::env::var_os("APPDATA");
2992 std::env::set_var("APPDATA", dir.path());
2993
2994 let found = try_well_known_path_lookup("biome");
2995
2996 if let Some(value) = saved_appdata {
2997 std::env::set_var("APPDATA", value);
2998 } else {
2999 std::env::remove_var("APPDATA");
3000 }
3001 if let Some(value) = saved_disable {
3002 std::env::set_var("AFT_DISABLE_WELL_KNOWN_LOOKUP", value);
3003 }
3004
3005 assert_eq!(found.as_deref(), Some(shim.as_path()));
3006 }
3007
3008 #[test]
3011 fn configured_tool_hint_does_not_claim_not_installed() {
3012 let hint = configured_tool_hint("biome", "biome.json");
3013 assert!(
3014 hint.contains("was not found on PATH or in common install locations"),
3015 "hint should explain the PATH miss: got {:?}",
3016 hint
3017 );
3018 assert!(
3019 !hint.contains("but not installed"),
3020 "hint must not claim the tool isn't installed: got {:?}",
3021 hint
3022 );
3023 }
3024
3025 #[test]
3026 fn install_hint_for_go_mentions_path() {
3027 let hint = install_hint("go");
3030 assert!(
3031 hint.contains("PATH"),
3032 "go install hint should mention PATH: got {:?}",
3033 hint
3034 );
3035 }
3036
3037 #[test]
3038 fn read_bounded_to_string_truncates_after_limit() {
3039 let (text, truncated) = read_bounded_to_string(std::io::Cursor::new(b"abcdef"), 4);
3040 assert_eq!(text, "abcd");
3041 assert!(truncated);
3042
3043 let (text, truncated) = read_bounded_to_string(std::io::Cursor::new(b"abc"), 4);
3044 assert_eq!(text, "abc");
3045 assert!(!truncated);
3046 }
3047
3048 #[test]
3049 fn windows_local_node_bin_extensions_follow_pathext_then_defaults() {
3050 let pathext = std::ffi::OsString::from(".EXE;.CMD;.BAT;.CMD");
3051 let extensions = windows_local_node_bin_extensions(Some(&pathext));
3052 assert_eq!(extensions, vec![".exe", ".cmd", ".bat", ".ps1"]);
3053 }
3054
3055 #[test]
3056 fn checker_executable_name_strips_paths_and_windows_extensions() {
3057 assert_eq!(checker_executable_name("/usr/local/bin/ruff"), "ruff");
3058 assert_eq!(checker_executable_name(r"C:\Go\bin\go.exe"), "go");
3059 assert_eq!(
3060 checker_executable_name(r"C:\repo\node_modules\.bin\biome.cmd"),
3061 "biome"
3062 );
3063 }
3064
3065 #[test]
3066 fn parse_biome_output_json_reporter() {
3067 let dir = tempfile::tempdir().unwrap();
3068 let file = dir.path().join("src/app.ts");
3069 fs::create_dir_all(file.parent().unwrap()).unwrap();
3070 fs::write(&file, "const value = 1;\nconsole.log(value);\n").unwrap();
3071 let stdout = serde_json::json!({
3074 "diagnostics": [
3075 {
3076 "severity": "warning",
3077 "description": "Avoid console.log",
3078 "location": {
3079 "path": { "file": file.to_string_lossy() },
3080 "span": [17, 28],
3081 },
3082 },
3083 ],
3084 })
3085 .to_string();
3086
3087 let errors = parse_biome_output(&stdout, "", &file);
3088 assert_eq!(errors.len(), 1);
3089 assert_eq!(errors[0].line, 2);
3090 assert_eq!(errors[0].column, 1);
3091 assert_eq!(errors[0].severity, "warning");
3092 assert!(errors[0].message.contains("Avoid console.log"));
3093 }
3094
3095 #[test]
3096 fn parse_ruff_output_json() {
3097 let stdout = r#"[{"filename":"pkg/main.py","location":{"row":3,"column":5},"code":"F401","message":"`os` imported but unused"}]"#;
3098 let errors = parse_ruff_output(stdout, "", Path::new("pkg/main.py"));
3099 assert_eq!(errors.len(), 1);
3100 assert_eq!(errors[0].line, 3);
3101 assert_eq!(errors[0].column, 5);
3102 assert!(errors[0].message.contains("F401"));
3103 }
3104
3105 #[test]
3106 fn parse_staticcheck_output_json_lines() {
3107 let stdout = r#"{"code":"SA4006","severity":"error","location":{"file":"C:\\repo\\main.go","line":10,"column":5},"message":"value is never used"}"#;
3108 let errors = parse_staticcheck_output(stdout, "", Path::new(r"C:\repo\main.go"));
3109 assert_eq!(errors.len(), 1);
3110 assert_eq!(errors[0].line, 10);
3111 assert_eq!(errors[0].column, 5);
3112 assert!(errors[0].message.contains("SA4006"));
3113 }
3114
3115 #[test]
3116 fn parse_go_vet_output_handles_windows_drive_letters() {
3117 let stderr = r"C:\repo\main.go:10:5: unreachable code
3118C:\repo\other.go:1:1: other file
3119";
3120 let errors = parse_go_vet_output(stderr, Path::new(r"C:\repo\main.go"));
3121 assert_eq!(errors.len(), 1);
3122 assert_eq!(errors[0].line, 10);
3123 assert_eq!(errors[0].column, 5);
3124 assert_eq!(errors[0].message, "unreachable code");
3125 }
3126
3127 #[cfg(unix)]
3128 #[test]
3129 fn detect_type_checker_biome_uses_json_reporter() {
3130 let _guard = tool_cache_test_lock();
3131 clear_tool_cache();
3132 let dir = tempfile::tempdir().unwrap();
3133 fs::write(dir.path().join("biome.json"), "{}\n").unwrap();
3134 let bin_dir = dir.path().join("node_modules").join(".bin");
3135 fs::create_dir_all(&bin_dir).unwrap();
3136 let fake = bin_dir.join("biome");
3137 fs::write(&fake, "#!/bin/sh\necho 1.0.0\n").unwrap();
3138 use std::os::unix::fs::PermissionsExt;
3139 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3140
3141 let path = dir.path().join("src/app.ts");
3142 let config = Config {
3143 project_root: Some(dir.path().to_path_buf()),
3144 ..Config::default()
3145 };
3146
3147 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
3148 assert!(cmd.ends_with("biome"), "expected biome, got: {cmd}");
3149 assert_eq!(args[0], "check");
3150 assert!(args.contains(&"--reporter=json".to_string()));
3151 }
3152
3153 #[cfg(unix)]
3154 #[test]
3155 fn detect_type_checker_ruff_does_not_require_formatter_version() {
3156 let _guard = tool_cache_test_lock();
3157 clear_tool_cache();
3158 let dir = tempfile::tempdir().unwrap();
3159 fs::write(dir.path().join("ruff.toml"), "\n").unwrap();
3160 let bin_dir = dir.path().join("node_modules").join(".bin");
3161 fs::create_dir_all(&bin_dir).unwrap();
3162 let fake = bin_dir.join("ruff");
3163 fs::write(&fake, "#!/bin/sh\necho 'ruff 0.0.1'\n").unwrap();
3164 use std::os::unix::fs::PermissionsExt;
3165 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3166
3167 let path = dir.path().join("main.py");
3168 let config = Config {
3169 project_root: Some(dir.path().to_path_buf()),
3170 ..Config::default()
3171 };
3172
3173 assert!(!ruff_format_available(config.project_root.as_deref()));
3174 let (cmd, args) = detect_type_checker(&path, LangId::Python, &config).unwrap();
3175 assert!(cmd.ends_with("ruff"), "expected ruff checker, got: {cmd}");
3176 assert_eq!(args[0], "check");
3177 assert!(args.contains(&"--output-format=json".to_string()));
3178 }
3179
3180 #[cfg(unix)]
3181 #[test]
3182 fn detect_type_checker_staticcheck_uses_json_reporter() {
3183 let _guard = tool_cache_test_lock();
3184 clear_tool_cache();
3185 let dir = tempfile::tempdir().unwrap();
3186 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21\n").unwrap();
3187 let bin_dir = dir.path().join("node_modules").join(".bin");
3188 fs::create_dir_all(&bin_dir).unwrap();
3189 let fake = bin_dir.join("staticcheck");
3190 fs::write(&fake, "#!/bin/sh\necho staticcheck\n").unwrap();
3191 use std::os::unix::fs::PermissionsExt;
3192 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3193
3194 let path = dir.path().join("main.go");
3195 let config = Config {
3196 project_root: Some(dir.path().to_path_buf()),
3197 ..Config::default()
3198 };
3199
3200 let (cmd, args) = detect_type_checker(&path, LangId::Go, &config).unwrap();
3201 assert!(
3202 cmd.ends_with("staticcheck"),
3203 "expected staticcheck, got: {cmd}"
3204 );
3205 assert_eq!(args[0], "-f");
3206 assert_eq!(args[1], "json");
3207 }
3208
3209 #[cfg(unix)]
3210 #[test]
3211 fn detect_type_checker_uses_resolved_cargo_and_go_paths() {
3212 let _guard = tool_cache_test_lock();
3213 clear_tool_cache();
3214 let dir = tempfile::tempdir().unwrap();
3215 let bin_dir = dir.path().join("node_modules").join(".bin");
3216 fs::create_dir_all(&bin_dir).unwrap();
3217 use std::os::unix::fs::PermissionsExt;
3218 for name in ["cargo", "go"] {
3219 let fake = bin_dir.join(name);
3220 fs::write(&fake, "#!/bin/sh\necho fake\n").unwrap();
3221 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3222 }
3223
3224 fs::write(
3225 dir.path().join("Cargo.toml"),
3226 "[package]\nname = \"test\"\n",
3227 )
3228 .unwrap();
3229 let rust_config = Config {
3230 project_root: Some(dir.path().to_path_buf()),
3231 ..Config::default()
3232 };
3233 let (cargo_cmd, _) =
3234 detect_type_checker(&dir.path().join("src/main.rs"), LangId::Rust, &rust_config)
3235 .unwrap();
3236 assert_eq!(cargo_cmd, bin_dir.join("cargo").to_string_lossy());
3237
3238 fs::remove_file(dir.path().join("Cargo.toml")).unwrap();
3239 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21\n").unwrap();
3240 let mut go_config = Config {
3241 project_root: Some(dir.path().to_path_buf()),
3242 ..Config::default()
3243 };
3244 go_config.checker.insert("go".to_string(), "go".to_string());
3245 let (go_cmd, _) =
3246 detect_type_checker(&dir.path().join("main.go"), LangId::Go, &go_config).unwrap();
3247 assert_eq!(go_cmd, bin_dir.join("go").to_string_lossy());
3248 }
3249}