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::Scss => "scss",
614 LangId::Vue => "vue",
615 LangId::Json => "json",
616 LangId::Scala => "scala",
617 LangId::Java => "java",
618 LangId::Ruby => "ruby",
619 LangId::Kotlin => "kotlin",
620 LangId::Swift => "swift",
621 LangId::Php => "php",
622 LangId::Lua => "lua",
623 LangId::Perl => "perl",
624 LangId::Html => "html",
625 LangId::Markdown => "markdown",
626 LangId::Yaml => "yaml",
627 }
628}
629
630fn has_formatter_support(lang: LangId) -> bool {
631 matches!(
632 lang,
633 LangId::TypeScript
634 | LangId::JavaScript
635 | LangId::Tsx
636 | LangId::Python
637 | LangId::Rust
638 | LangId::Go
639 )
640}
641
642fn has_checker_support(lang: LangId) -> bool {
643 matches!(
644 lang,
645 LangId::TypeScript
646 | LangId::JavaScript
647 | LangId::Tsx
648 | LangId::Python
649 | LangId::Rust
650 | LangId::Go
651 )
652}
653
654fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
655 let project_root = config.project_root.as_deref();
656 if let Some(preferred) = config.formatter.get(lang_key(lang)) {
657 return explicit_formatter_candidate(preferred, file_str);
658 }
659
660 match lang {
661 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
662 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
663 vec![ToolCandidate {
664 tool: "biome".to_string(),
665 source: "biome.json".to_string(),
666 args: vec![
667 "format".to_string(),
668 "--write".to_string(),
669 file_str.to_string(),
670 ],
671 required: true,
672 }]
673 } else if has_project_config(
674 project_root,
675 &[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts"],
676 ) {
677 vec![ToolCandidate {
678 tool: "oxfmt".to_string(),
679 source: "oxfmt config".to_string(),
680 args: vec!["--write".to_string(), file_str.to_string()],
681 required: true,
682 }]
683 } else if has_project_config(
684 project_root,
685 &[
686 ".prettierrc",
687 ".prettierrc.json",
688 ".prettierrc.yml",
689 ".prettierrc.yaml",
690 ".prettierrc.js",
691 ".prettierrc.cjs",
692 ".prettierrc.mjs",
693 ".prettierrc.toml",
694 "prettier.config.js",
695 "prettier.config.cjs",
696 "prettier.config.mjs",
697 ],
698 ) {
699 vec![ToolCandidate {
700 tool: "prettier".to_string(),
701 source: "Prettier config".to_string(),
702 args: vec!["--write".to_string(), file_str.to_string()],
703 required: true,
704 }]
705 } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
706 vec![ToolCandidate {
707 tool: "deno".to_string(),
708 source: "deno.json".to_string(),
709 args: vec!["fmt".to_string(), file_str.to_string()],
710 required: true,
711 }]
712 } else {
713 Vec::new()
714 }
715 }
716 LangId::Python => {
717 if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
718 || has_pyproject_tool(project_root, "ruff")
719 {
720 vec![ToolCandidate {
721 tool: "ruff".to_string(),
722 source: "ruff config".to_string(),
723 args: vec!["format".to_string(), file_str.to_string()],
724 required: true,
725 }]
726 } else if has_pyproject_tool(project_root, "black") {
727 vec![ToolCandidate {
728 tool: "black".to_string(),
729 source: "pyproject.toml".to_string(),
730 args: vec![file_str.to_string()],
731 required: true,
732 }]
733 } else {
734 Vec::new()
735 }
736 }
737 LangId::Rust => {
738 if has_project_config(project_root, &["Cargo.toml"]) {
739 vec![ToolCandidate {
740 tool: "rustfmt".to_string(),
741 source: "Cargo.toml".to_string(),
742 args: vec![file_str.to_string()],
743 required: true,
744 }]
745 } else {
746 Vec::new()
747 }
748 }
749 LangId::Go => {
750 if has_project_config(project_root, &["go.mod"]) {
751 vec![
752 ToolCandidate {
753 tool: "goimports".to_string(),
754 source: "go.mod".to_string(),
755 args: vec!["-w".to_string(), file_str.to_string()],
756 required: false,
757 },
758 ToolCandidate {
759 tool: "gofmt".to_string(),
760 source: "go.mod".to_string(),
761 args: vec!["-w".to_string(), file_str.to_string()],
762 required: true,
763 },
764 ]
765 } else {
766 Vec::new()
767 }
768 }
769 LangId::C
770 | LangId::Cpp
771 | LangId::Zig
772 | LangId::CSharp
773 | LangId::Bash
774 | LangId::Solidity
775 | LangId::Scss
776 | LangId::Vue
777 | LangId::Json
778 | LangId::Scala
779 | LangId::Java
780 | LangId::Ruby
781 | LangId::Kotlin
782 | LangId::Swift
783 | LangId::Php
784 | LangId::Lua
785 | LangId::Perl => Vec::new(),
786 LangId::Html => Vec::new(),
787 LangId::Markdown => Vec::new(),
788 LangId::Yaml => Vec::new(),
789 }
790}
791
792fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
793 let project_root = config.project_root.as_deref();
794 if let Some(preferred) = config.checker.get(lang_key(lang)) {
795 return explicit_checker_candidate(preferred, file_str);
796 }
797
798 match lang {
799 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
800 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
801 vec![ToolCandidate {
802 tool: "biome".to_string(),
803 source: "biome.json".to_string(),
804 args: vec![
805 "check".to_string(),
806 "--reporter=json".to_string(),
807 file_str.to_string(),
808 ],
809 required: true,
810 }]
811 } else if has_project_config(project_root, &["tsconfig.json"]) {
812 vec![ToolCandidate {
813 tool: "tsc".to_string(),
814 source: "tsconfig.json".to_string(),
815 args: vec![
816 "--noEmit".to_string(),
817 "--pretty".to_string(),
818 "false".to_string(),
819 ],
820 required: true,
821 }]
822 } else {
823 Vec::new()
824 }
825 }
826 LangId::Python => {
827 if has_project_config(project_root, &["pyrightconfig.json"])
828 || has_pyproject_tool(project_root, "pyright")
829 {
830 vec![ToolCandidate {
831 tool: "pyright".to_string(),
832 source: "pyright config".to_string(),
833 args: vec!["--outputjson".to_string(), file_str.to_string()],
834 required: true,
835 }]
836 } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
837 || has_pyproject_tool(project_root, "ruff")
838 {
839 vec![ToolCandidate {
840 tool: "ruff".to_string(),
841 source: "ruff config".to_string(),
842 args: vec![
843 "check".to_string(),
844 "--output-format=json".to_string(),
845 file_str.to_string(),
846 ],
847 required: true,
848 }]
849 } else {
850 Vec::new()
851 }
852 }
853 LangId::Rust => {
854 if has_project_config(project_root, &["Cargo.toml"]) {
855 vec![ToolCandidate {
856 tool: "cargo".to_string(),
857 source: "Cargo.toml".to_string(),
858 args: vec!["check".to_string(), "--message-format=json".to_string()],
859 required: true,
860 }]
861 } else {
862 Vec::new()
863 }
864 }
865 LangId::Go => {
866 if has_project_config(project_root, &["go.mod"]) {
867 vec![
868 ToolCandidate {
869 tool: "staticcheck".to_string(),
870 source: "go.mod".to_string(),
871 args: vec!["-f".to_string(), "json".to_string(), file_str.to_string()],
872 required: false,
873 },
874 ToolCandidate {
875 tool: "go".to_string(),
876 source: "go.mod".to_string(),
877 args: vec!["vet".to_string(), file_str.to_string()],
878 required: true,
879 },
880 ]
881 } else {
882 Vec::new()
883 }
884 }
885 LangId::C
886 | LangId::Cpp
887 | LangId::Zig
888 | LangId::CSharp
889 | LangId::Bash
890 | LangId::Solidity
891 | LangId::Scss
892 | LangId::Vue
893 | LangId::Json
894 | LangId::Scala
895 | LangId::Java
896 | LangId::Ruby
897 | LangId::Kotlin
898 | LangId::Swift
899 | LangId::Php
900 | LangId::Lua
901 | LangId::Perl => Vec::new(),
902 LangId::Html => Vec::new(),
903 LangId::Markdown => Vec::new(),
904 LangId::Yaml => Vec::new(),
905 }
906}
907
908fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
909 match name {
910 "none" | "off" | "false" => Vec::new(),
911 "biome" => vec![ToolCandidate {
912 tool: name.to_string(),
913 source: "formatter config".to_string(),
914 args: vec![
915 "format".to_string(),
916 "--write".to_string(),
917 file_str.to_string(),
918 ],
919 required: true,
920 }],
921 "oxfmt" => vec![ToolCandidate {
922 tool: name.to_string(),
923 source: "formatter config".to_string(),
924 args: vec!["--write".to_string(), file_str.to_string()],
925 required: true,
926 }],
927 "prettier" => vec![ToolCandidate {
928 tool: name.to_string(),
929 source: "formatter config".to_string(),
930 args: vec!["--write".to_string(), file_str.to_string()],
931 required: true,
932 }],
933 "deno" => vec![ToolCandidate {
934 tool: name.to_string(),
935 source: "formatter config".to_string(),
936 args: vec!["fmt".to_string(), file_str.to_string()],
937 required: true,
938 }],
939 "ruff" => vec![ToolCandidate {
940 tool: name.to_string(),
941 source: "formatter config".to_string(),
942 args: vec!["format".to_string(), file_str.to_string()],
943 required: true,
944 }],
945 "black" | "rustfmt" => vec![ToolCandidate {
946 tool: name.to_string(),
947 source: "formatter config".to_string(),
948 args: vec![file_str.to_string()],
949 required: true,
950 }],
951 "goimports" | "gofmt" => vec![ToolCandidate {
952 tool: name.to_string(),
953 source: "formatter config".to_string(),
954 args: vec!["-w".to_string(), file_str.to_string()],
955 required: true,
956 }],
957 _ => Vec::new(),
958 }
959}
960
961fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
962 match name {
963 "none" | "off" | "false" => Vec::new(),
964 "tsc" | "tsgo" => vec![ToolCandidate {
965 tool: name.to_string(),
966 source: "checker config".to_string(),
967 args: vec![
968 "--noEmit".to_string(),
969 "--pretty".to_string(),
970 "false".to_string(),
971 ],
972 required: true,
973 }],
974 "cargo" => vec![ToolCandidate {
975 tool: name.to_string(),
976 source: "checker config".to_string(),
977 args: vec!["check".to_string(), "--message-format=json".to_string()],
978 required: true,
979 }],
980 "go" => vec![ToolCandidate {
981 tool: name.to_string(),
982 source: "checker config".to_string(),
983 args: vec!["vet".to_string(), file_str.to_string()],
984 required: true,
985 }],
986 "biome" => vec![ToolCandidate {
987 tool: name.to_string(),
988 source: "checker config".to_string(),
989 args: vec![
990 "check".to_string(),
991 "--reporter=json".to_string(),
992 file_str.to_string(),
993 ],
994 required: true,
995 }],
996 "pyright" => vec![ToolCandidate {
997 tool: name.to_string(),
998 source: "checker config".to_string(),
999 args: vec!["--outputjson".to_string(), file_str.to_string()],
1000 required: true,
1001 }],
1002 "ruff" => vec![ToolCandidate {
1003 tool: name.to_string(),
1004 source: "checker config".to_string(),
1005 args: vec![
1006 "check".to_string(),
1007 "--output-format=json".to_string(),
1008 file_str.to_string(),
1009 ],
1010 required: true,
1011 }],
1012 "staticcheck" => vec![ToolCandidate {
1013 tool: name.to_string(),
1014 source: "checker config".to_string(),
1015 args: vec!["-f".to_string(), "json".to_string(), file_str.to_string()],
1016 required: true,
1017 }],
1018 _ => Vec::new(),
1019 }
1020}
1021
1022fn resolve_tool_candidates(
1023 candidates: Vec<ToolCandidate>,
1024 project_root: Option<&Path>,
1025 require_ruff_format: bool,
1026) -> ToolDetection {
1027 if candidates.is_empty() {
1028 return ToolDetection::NotConfigured;
1029 }
1030
1031 let mut missing_required = None;
1032 for candidate in candidates {
1033 if let Some(command) = resolve_candidate_tool(&candidate, project_root, require_ruff_format)
1034 {
1035 return ToolDetection::Found(command, candidate.args);
1036 }
1037 if candidate.required && missing_required.is_none() {
1038 missing_required = Some(candidate.tool);
1039 }
1040 }
1041
1042 match missing_required {
1043 Some(tool) => ToolDetection::NotInstalled { tool },
1044 None => ToolDetection::NotConfigured,
1045 }
1046}
1047
1048fn checker_command(_candidate: &ToolCandidate, resolved: String) -> String {
1049 resolved
1050}
1051
1052fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
1053 if candidate.tool == "tsc" || candidate.tool == "tsgo" {
1054 vec![
1055 "--noEmit".to_string(),
1056 "--pretty".to_string(),
1057 "false".to_string(),
1058 ]
1059 } else {
1060 candidate.args.clone()
1061 }
1062}
1063
1064fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
1065 let file_str = path.to_string_lossy().to_string();
1066 resolve_tool_candidates(
1067 formatter_candidates(lang, config, &file_str),
1068 config.project_root.as_deref(),
1069 true,
1070 )
1071}
1072
1073fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
1074 let file_str = path.to_string_lossy().to_string();
1075 let candidates = checker_candidates(lang, config, &file_str);
1076 if candidates.is_empty() {
1077 return ToolDetection::NotConfigured;
1078 }
1079
1080 let project_root = config.project_root.as_deref();
1081 let mut missing_required = None;
1082 for candidate in candidates {
1083 if let Some(command) = resolve_candidate_tool(&candidate, project_root, false) {
1084 return ToolDetection::Found(
1085 checker_command(&candidate, command),
1086 checker_args(&candidate),
1087 );
1088 }
1089 if candidate.required && missing_required.is_none() {
1090 missing_required = Some(candidate.tool);
1091 }
1092 }
1093
1094 match missing_required {
1095 Some(tool) => ToolDetection::NotInstalled { tool },
1096 None => ToolDetection::NotConfigured,
1097 }
1098}
1099
1100fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
1101 crate::callgraph::walk_project_files(project_root)
1102 .filter_map(|path| detect_language(&path))
1103 .collect()
1104}
1105
1106fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
1107 let filename = match lang {
1108 LangId::TypeScript => "aft-tool-detection.ts",
1109 LangId::Tsx => "aft-tool-detection.tsx",
1110 LangId::JavaScript => "aft-tool-detection.js",
1111 LangId::Python => "aft-tool-detection.py",
1112 LangId::Rust => "aft_tool_detection.rs",
1113 LangId::Go => "aft_tool_detection.go",
1114 LangId::C => "aft_tool_detection.c",
1115 LangId::Cpp => "aft_tool_detection.cpp",
1116 LangId::Zig => "aft_tool_detection.zig",
1117 LangId::CSharp => "aft_tool_detection.cs",
1118 LangId::Bash => "aft_tool_detection.sh",
1119 LangId::Solidity => "aft_tool_detection.sol",
1120 LangId::Scss => "aft-tool-detection.scss",
1121 LangId::Vue => "aft-tool-detection.vue",
1122 LangId::Json => "aft-tool-detection.json",
1123 LangId::Scala => "aft-tool-detection.scala",
1124 LangId::Java => "aft-tool-detection.java",
1125 LangId::Ruby => "aft-tool-detection.rb",
1126 LangId::Kotlin => "aft-tool-detection.kt",
1127 LangId::Swift => "aft-tool-detection.swift",
1128 LangId::Php => "aft-tool-detection.php",
1129 LangId::Lua => "aft-tool-detection.lua",
1130 LangId::Perl => "aft-tool-detection.pl",
1131 LangId::Html => "aft-tool-detection.html",
1132 LangId::Markdown => "aft-tool-detection.md",
1133 LangId::Yaml => "aft-tool-detection.yaml",
1134 };
1135 project_root.join(filename)
1136}
1137
1138pub(crate) fn install_hint(tool: &str) -> String {
1139 match tool {
1140 "biome" => {
1141 "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
1142 }
1143 "oxfmt" => "Run `npm install -D oxfmt` or install globally.".to_string(),
1144 "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
1145 "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
1146 "tsgo" => {
1147 "Run `npm install -D @typescript/native-preview` or install globally.".to_string()
1148 }
1149 "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
1150 "ruff" => {
1151 "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
1152 }
1153 "black" => {
1154 "Install: `pip install black` or your Python package manager equivalent.".to_string()
1155 }
1156 "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
1157 "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
1158 "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
1159 "go" => if cfg!(windows) {
1160 "Install Go from https://go.dev/dl/. Common install paths:\
1161 C:\\Go\\bin, C:\\Program Files\\Go\\bin. \
1162 GUI-launched editors often don't inherit login-shell PATH."
1163 } else {
1164 "Install Go from https://go.dev/dl/, or — if it's already installed —\
1165 ensure its bin directory is on PATH (Homebrew typically uses\
1166 /opt/homebrew/bin on Apple Silicon, /usr/local/bin on Intel macOS).\
1167 GUI-launched editors often don't inherit login-shell PATH."
1168 }
1169 .to_string(),
1170 "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
1171 "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
1172 "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
1173 "typescript-language-server" => {
1174 "Install: `npm install -g typescript-language-server typescript`".to_string()
1175 }
1176 "deno" => "Install Deno from https://deno.com/.".to_string(),
1177 "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
1178 "staticcheck" => {
1179 "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
1180 }
1181 other => format!("Install `{other}` and ensure it is on PATH."),
1182 }
1183}
1184
1185fn configured_tool_hint(tool: &str, source: &str) -> String {
1186 format!(
1196 "{tool} is configured in {source} but was not found on PATH or in common install locations. {}",
1197 install_hint(tool)
1198 )
1199}
1200
1201fn missing_tool_warning(
1202 kind: &str,
1203 language: &str,
1204 candidate: &ToolCandidate,
1205 project_root: Option<&Path>,
1206 require_ruff_format: bool,
1207) -> Option<MissingTool> {
1208 if !candidate.required
1209 || resolve_candidate_tool(candidate, project_root, require_ruff_format).is_some()
1210 {
1211 return None;
1212 }
1213
1214 Some(MissingTool {
1215 kind: kind.to_string(),
1216 language: language.to_string(),
1217 tool: candidate.tool.clone(),
1218 hint: configured_tool_hint(&candidate.tool, &candidate.source),
1219 })
1220}
1221
1222pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
1224 let languages = languages_in_project(project_root);
1225 let mut warnings = Vec::new();
1226 let mut seen = HashSet::new();
1227
1228 for lang in languages {
1229 let language = lang_key(lang);
1230 let placeholder = placeholder_file_for_language(project_root, lang);
1231 let file_str = placeholder.to_string_lossy().to_string();
1232
1233 for candidate in formatter_candidates(lang, config, &file_str) {
1234 if let Some(warning) = missing_tool_warning(
1235 "formatter_not_installed",
1236 language,
1237 &candidate,
1238 config.project_root.as_deref(),
1239 true,
1240 ) {
1241 if seen.insert((
1242 warning.kind.clone(),
1243 warning.language.clone(),
1244 warning.tool.clone(),
1245 )) {
1246 warnings.push(warning);
1247 }
1248 }
1249 }
1250
1251 for candidate in checker_candidates(lang, config, &file_str) {
1252 if let Some(warning) = missing_tool_warning(
1253 "checker_not_installed",
1254 language,
1255 &candidate,
1256 config.project_root.as_deref(),
1257 false,
1258 ) {
1259 if seen.insert((
1260 warning.kind.clone(),
1261 warning.language.clone(),
1262 warning.tool.clone(),
1263 )) {
1264 warnings.push(warning);
1265 }
1266 }
1267 }
1268 }
1269
1270 warnings.sort_by(|left, right| {
1271 (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1272 });
1273 warnings
1274}
1275
1276pub fn detect_formatter(
1286 path: &Path,
1287 lang: LangId,
1288 config: &Config,
1289) -> Option<(String, Vec<String>)> {
1290 match detect_formatter_for_path(path, lang, config) {
1291 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1292 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1293 }
1294}
1295
1296fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1298 let root = match project_root {
1299 Some(r) => r,
1300 None => return false,
1301 };
1302 filenames.iter().any(|f| root.join(f).exists())
1303}
1304
1305fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1307 let root = match project_root {
1308 Some(r) => r,
1309 None => return false,
1310 };
1311 let pyproject = root.join("pyproject.toml");
1312 if !pyproject.exists() {
1313 return false;
1314 }
1315 match std::fs::read_to_string(&pyproject) {
1316 Ok(content) => {
1317 let pattern = format!("[tool.{}]", tool_name);
1318 content.contains(&pattern)
1319 }
1320 Err(_) => false,
1321 }
1322}
1323
1324fn formatter_excluded_path(stderr: &str) -> bool {
1345 let s = stderr.to_lowercase();
1346 s.contains("no files were processed")
1347 || s.contains("ignored by the configuration")
1348 || s.contains("expected at least one target file")
1349 || s.contains("no files found matching the given patterns")
1350 || s.contains("no files matching the pattern")
1351 || s.contains("no python files found")
1352}
1353
1354pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1375 if !config.format_on_edit {
1377 return (false, Some("no_formatter_configured".to_string()));
1378 }
1379
1380 let lang = match detect_language(path) {
1381 Some(l) => l,
1382 None => {
1383 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1384 return (false, Some("unsupported_language".to_string()));
1385 }
1386 };
1387 if !has_formatter_support(lang) {
1388 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1389 return (false, Some("unsupported_language".to_string()));
1390 }
1391
1392 let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1393 ToolDetection::Found(cmd, args) => (cmd, args),
1394 ToolDetection::NotConfigured => {
1395 log::debug!(
1396 "format: {} (skipped: no_formatter_configured)",
1397 path.display()
1398 );
1399 return (false, Some("no_formatter_configured".to_string()));
1400 }
1401 ToolDetection::NotInstalled { tool } => {
1402 crate::slog_warn!(
1403 "format: {} (skipped: formatter_not_installed: {})",
1404 path.display(),
1405 tool
1406 );
1407 return (false, Some("formatter_not_installed".to_string()));
1408 }
1409 };
1410
1411 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1412
1413 let working_dir = config.project_root.as_deref();
1420
1421 match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1422 Ok(_) => {
1423 crate::slog_info!("format: {} ({})", path.display(), cmd);
1424 (true, None)
1425 }
1426 Err(FormatError::Timeout { .. }) => {
1427 crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1428 (false, Some("timeout".to_string()))
1429 }
1430 Err(FormatError::NotFound { .. }) => {
1431 crate::slog_warn!(
1432 "format: {} (skipped: formatter_not_installed)",
1433 path.display()
1434 );
1435 (false, Some("formatter_not_installed".to_string()))
1436 }
1437 Err(FormatError::Failed { stderr, .. }) => {
1438 if formatter_excluded_path(&stderr) {
1450 crate::slog_info!(
1451 "format: {} (skipped: formatter_excluded_path; stderr: {})",
1452 path.display(),
1453 stderr.lines().next().unwrap_or("").trim()
1454 );
1455 return (false, Some("formatter_excluded_path".to_string()));
1456 }
1457 crate::slog_warn!(
1458 "format: {} (skipped: error: {})",
1459 path.display(),
1460 stderr.lines().next().unwrap_or("unknown").trim()
1461 );
1462 (false, Some("error".to_string()))
1463 }
1464 Err(FormatError::UnsupportedLanguage) => {
1465 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1466 (false, Some("unsupported_language".to_string()))
1467 }
1468 }
1469}
1470
1471pub fn run_external_tool_capture(
1478 command: &str,
1479 args: &[&str],
1480 working_dir: Option<&Path>,
1481 timeout_secs: u32,
1482) -> Result<ExternalToolResult, FormatError> {
1483 let mut cmd = Command::new(command);
1484 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1485
1486 if let Some(dir) = working_dir {
1487 cmd.current_dir(dir);
1488 }
1489
1490 isolate_in_process_group(&mut cmd);
1491
1492 let child = match cmd.spawn() {
1493 Ok(c) => c,
1494 Err(e) if e.kind() == ErrorKind::NotFound => {
1495 return Err(FormatError::NotFound {
1496 tool: command.to_string(),
1497 });
1498 }
1499 Err(e) => {
1500 return Err(FormatError::Failed {
1501 tool: command.to_string(),
1502 stderr: e.to_string(),
1503 });
1504 }
1505 };
1506
1507 let outcome = wait_with_timeout(child, command, timeout_secs)?;
1508 Ok(ExternalToolResult {
1509 stdout: outcome.stdout,
1510 stderr: outcome.stderr,
1511 exit_code: outcome.status.code().unwrap_or(-1),
1512 truncated: outcome.truncated,
1513 })
1514}
1515
1516#[derive(Debug, Clone, serde::Serialize)]
1522pub struct ValidationError {
1523 pub line: u32,
1524 pub column: u32,
1525 pub message: String,
1526 pub severity: String,
1527}
1528
1529pub fn detect_type_checker(
1540 path: &Path,
1541 lang: LangId,
1542 config: &Config,
1543) -> Option<(String, Vec<String>)> {
1544 match detect_checker_for_path(path, lang, config) {
1545 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1546 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1547 }
1548}
1549
1550pub fn parse_checker_output(
1555 stdout: &str,
1556 stderr: &str,
1557 file: &Path,
1558 checker: &str,
1559) -> Vec<ValidationError> {
1560 let checker_name = checker_executable_name(checker);
1561 match checker_name.as_str() {
1562 "npx" | "tsc" | "tsgo" => parse_tsc_output(stdout, stderr, file),
1563 "biome" => parse_biome_output(stdout, stderr, file),
1564 "pyright" => parse_pyright_output(stdout, file),
1565 "ruff" => parse_ruff_output(stdout, stderr, file),
1566 "cargo" => parse_cargo_output(stdout, stderr, file),
1567 "go" => parse_go_vet_output(stderr, file),
1568 "staticcheck" => parse_staticcheck_output(stdout, stderr, file),
1569 _ => Vec::new(),
1570 }
1571}
1572
1573fn checker_executable_name(checker: &str) -> String {
1574 let name = checker
1575 .rsplit(['/', '\\'])
1576 .next()
1577 .filter(|name| !name.is_empty())
1578 .unwrap_or(checker)
1579 .to_ascii_lowercase();
1580
1581 for suffix in [".exe", ".cmd", ".bat", ".ps1"] {
1582 if let Some(stripped) = name.strip_suffix(suffix) {
1583 return stripped.to_string();
1584 }
1585 }
1586
1587 name
1588}
1589
1590fn normalize_path_for_compare(path: &str) -> String {
1591 path.trim_start_matches("file://")
1592 .replace('\\', "/")
1593 .trim_start_matches("./")
1594 .to_string()
1595}
1596
1597fn diagnostic_path_matches(file: &Path, diagnostic_file: &str) -> bool {
1598 if diagnostic_file.is_empty() {
1599 return true;
1600 }
1601
1602 let file_str = normalize_path_for_compare(&file.to_string_lossy());
1603 let diagnostic_str = normalize_path_for_compare(diagnostic_file);
1604 file_str == diagnostic_str
1605 || file_str.ends_with(&diagnostic_str)
1606 || diagnostic_str.ends_with(&file_str)
1607}
1608
1609fn line_column_for_byte_offset(source: &str, offset: usize) -> (u32, u32) {
1610 let mut line = 1u32;
1611 let mut column = 1u32;
1612 for (idx, ch) in source.char_indices() {
1613 if idx >= offset {
1614 break;
1615 }
1616 if ch == '\n' {
1617 line += 1;
1618 column = 1;
1619 } else {
1620 column += 1;
1621 }
1622 }
1623 (line, column)
1624}
1625
1626fn json_string_at<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a str> {
1627 let mut current = value;
1628 for key in path {
1629 current = current.get(*key)?;
1630 }
1631 current.as_str()
1632}
1633
1634fn json_u32_at(value: &serde_json::Value, path: &[&str]) -> Option<u32> {
1635 let mut current = value;
1636 for key in path {
1637 current = current.get(*key)?;
1638 }
1639 current.as_u64().map(|n| n as u32)
1640}
1641
1642fn json_location_path(value: &serde_json::Value) -> Option<&str> {
1643 json_string_at(value, &["location", "path", "file"])
1644 .or_else(|| json_string_at(value, &["location", "path"]))
1645 .or_else(|| json_string_at(value, &["filename"]))
1646 .or_else(|| json_string_at(value, &["file"]))
1647}
1648
1649fn diagnostic_message(value: &serde_json::Value) -> String {
1650 json_string_at(value, &["description"])
1651 .or_else(|| json_string_at(value, &["message"]))
1652 .or_else(|| json_string_at(value, &["text"]))
1653 .or_else(|| json_string_at(value, &["category"]))
1654 .unwrap_or("unknown error")
1655 .to_string()
1656}
1657
1658fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1660 let mut errors = Vec::new();
1661 let file_str = file.to_string_lossy();
1662 let combined = format!("{}{}", stdout, stderr);
1664 for line in combined.lines() {
1665 if let Some((loc, rest)) = line.split_once("): ") {
1668 let file_part = loc.split('(').next().unwrap_or("");
1670 if !file_str.ends_with(file_part)
1671 && !file_part.ends_with(&*file_str)
1672 && file_part != &*file_str
1673 {
1674 continue;
1675 }
1676
1677 let coords = loc.split('(').last().unwrap_or("");
1679 let parts: Vec<&str> = coords.split(',').collect();
1680 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1681 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1682
1683 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1685 ("error".to_string(), msg.to_string())
1686 } else if let Some(msg) = rest.strip_prefix("warning ") {
1687 ("warning".to_string(), msg.to_string())
1688 } else {
1689 ("error".to_string(), rest.to_string())
1690 };
1691
1692 errors.push(ValidationError {
1693 line: line_num,
1694 column: col_num,
1695 message,
1696 severity,
1697 });
1698 }
1699 }
1700 errors
1701}
1702
1703fn parse_biome_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1704 let mut errors = Vec::new();
1705 for output in [stdout, stderr] {
1706 let trimmed = output.trim();
1707 if trimmed.is_empty() {
1708 continue;
1709 }
1710 if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
1711 parse_biome_json_value(&json, file, &mut errors);
1712 }
1713 }
1714 errors
1715}
1716
1717fn parse_biome_json_value(
1718 json: &serde_json::Value,
1719 file: &Path,
1720 errors: &mut Vec<ValidationError>,
1721) {
1722 let diagnostics: Vec<&serde_json::Value> = if let Some(diags) = json
1723 .get("diagnostics")
1724 .and_then(|diagnostics| diagnostics.as_array())
1725 {
1726 diags.iter().collect()
1727 } else if let Some(diags) = json.as_array() {
1728 diags.iter().collect()
1729 } else {
1730 Vec::new()
1731 };
1732
1733 let source = std::fs::read_to_string(file).ok();
1734 for diag in diagnostics {
1735 if let Some(diag_file) = json_location_path(diag) {
1736 if !diagnostic_path_matches(file, diag_file) {
1737 continue;
1738 }
1739 }
1740
1741 let (line, column) = biome_line_column(diag, source.as_deref());
1742 errors.push(ValidationError {
1743 line,
1744 column,
1745 message: diagnostic_message(diag),
1746 severity: diag
1747 .get("severity")
1748 .and_then(|severity| severity.as_str())
1749 .unwrap_or("error")
1750 .to_lowercase(),
1751 });
1752 }
1753}
1754
1755fn biome_line_column(diag: &serde_json::Value, source: Option<&str>) -> (u32, u32) {
1756 if let Some(line) =
1757 json_u32_at(diag, &["location", "line"]).or_else(|| json_u32_at(diag, &["line"]))
1758 {
1759 let column = json_u32_at(diag, &["location", "column"])
1760 .or_else(|| json_u32_at(diag, &["column"]))
1761 .unwrap_or(0);
1762 return (line, column);
1763 }
1764
1765 let offset = diag
1766 .get("location")
1767 .and_then(|location| location.get("span"))
1768 .and_then(|span| span.as_array())
1769 .and_then(|span| span.first())
1770 .and_then(|offset| offset.as_u64())
1771 .map(|offset| offset as usize);
1772
1773 match (source, offset) {
1774 (Some(source), Some(offset)) => line_column_for_byte_offset(source, offset),
1775 _ => (0, 0),
1776 }
1777}
1778
1779fn parse_ruff_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1780 let mut errors = Vec::new();
1781 for output in [stdout, stderr] {
1782 let trimmed = output.trim();
1783 if trimmed.is_empty() {
1784 continue;
1785 }
1786 if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
1787 parse_ruff_json_value(&json, file, &mut errors);
1788 }
1789 }
1790 errors
1791}
1792
1793fn parse_ruff_json_value(json: &serde_json::Value, file: &Path, errors: &mut Vec<ValidationError>) {
1794 let diagnostics: Vec<&serde_json::Value> = if let Some(diags) = json.as_array() {
1795 diags.iter().collect()
1796 } else if let Some(diags) = json.get("diagnostics").and_then(|d| d.as_array()) {
1797 diags.iter().collect()
1798 } else {
1799 Vec::new()
1800 };
1801
1802 for diag in diagnostics {
1803 let diag_file = diag
1804 .get("filename")
1805 .and_then(|filename| filename.as_str())
1806 .unwrap_or("");
1807 if !diagnostic_path_matches(file, diag_file) {
1808 continue;
1809 }
1810
1811 let message = match (
1812 diag.get("code").and_then(|code| code.as_str()),
1813 diag.get("message").and_then(|message| message.as_str()),
1814 ) {
1815 (Some(code), Some(message)) => format!("{code}: {message}"),
1816 (None, Some(message)) => message.to_string(),
1817 (Some(code), None) => code.to_string(),
1818 (None, None) => "unknown error".to_string(),
1819 };
1820
1821 errors.push(ValidationError {
1822 line: json_u32_at(diag, &["location", "row"])
1823 .or_else(|| json_u32_at(diag, &["location", "line"]))
1824 .unwrap_or(0),
1825 column: json_u32_at(diag, &["location", "column"]).unwrap_or(0),
1826 message,
1827 severity: diag
1828 .get("severity")
1829 .and_then(|severity| severity.as_str())
1830 .unwrap_or("error")
1831 .to_lowercase(),
1832 });
1833 }
1834}
1835
1836fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1838 let mut errors = Vec::new();
1839 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1841 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1842 for diag in diags {
1843 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1845 if !diagnostic_path_matches(file, diag_file) {
1846 continue;
1847 }
1848
1849 let line_num = diag
1850 .get("range")
1851 .and_then(|r| r.get("start"))
1852 .and_then(|s| s.get("line"))
1853 .and_then(|l| l.as_u64())
1854 .unwrap_or(0) as u32;
1855 let col_num = diag
1856 .get("range")
1857 .and_then(|r| r.get("start"))
1858 .and_then(|s| s.get("character"))
1859 .and_then(|c| c.as_u64())
1860 .unwrap_or(0) as u32;
1861 let message = diag
1862 .get("message")
1863 .and_then(|m| m.as_str())
1864 .unwrap_or("unknown error")
1865 .to_string();
1866 let severity = diag
1867 .get("severity")
1868 .and_then(|s| s.as_str())
1869 .unwrap_or("error")
1870 .to_lowercase();
1871
1872 errors.push(ValidationError {
1873 line: line_num + 1, column: col_num + 1, message,
1876 severity,
1877 });
1878 }
1879 }
1880 }
1881 errors
1882}
1883
1884fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1886 let mut errors = Vec::new();
1887 let file_str = file.to_string_lossy();
1888
1889 for line in stdout.lines() {
1890 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1891 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1892 continue;
1893 }
1894 let message_obj = match msg.get("message") {
1895 Some(m) => m,
1896 None => continue,
1897 };
1898
1899 let level = message_obj
1900 .get("level")
1901 .and_then(|l| l.as_str())
1902 .unwrap_or("error");
1903
1904 if level != "error" && level != "warning" {
1906 continue;
1907 }
1908
1909 let text = message_obj
1910 .get("message")
1911 .and_then(|m| m.as_str())
1912 .unwrap_or("unknown error")
1913 .to_string();
1914
1915 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1917 for span in spans {
1918 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1919 let is_primary = span
1920 .get("is_primary")
1921 .and_then(|p| p.as_bool())
1922 .unwrap_or(false);
1923
1924 if !is_primary {
1925 continue;
1926 }
1927
1928 if !file_str.ends_with(span_file)
1930 && !span_file.ends_with(&*file_str)
1931 && span_file != &*file_str
1932 {
1933 continue;
1934 }
1935
1936 let line_num =
1937 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1938 let col_num = span
1939 .get("column_start")
1940 .and_then(|c| c.as_u64())
1941 .unwrap_or(0) as u32;
1942
1943 errors.push(ValidationError {
1944 line: line_num,
1945 column: col_num,
1946 message: text.clone(),
1947 severity: level.to_string(),
1948 });
1949 }
1950 }
1951 }
1952 }
1953 errors
1954}
1955
1956fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1958 let mut errors = Vec::new();
1959 let pattern =
1960 regex::Regex::new(r"^(?P<file>.+?):(?P<line>\d+)(?::(?P<col>\d+))?:\s*(?P<message>.*)$")
1961 .expect("valid go vet diagnostic regex");
1962
1963 for line in stderr.lines() {
1964 let Some(captures) = pattern.captures(line) else {
1965 continue;
1966 };
1967
1968 let err_file = captures
1969 .name("file")
1970 .map(|m| m.as_str())
1971 .unwrap_or("")
1972 .trim();
1973 if !diagnostic_path_matches(file, err_file) {
1974 continue;
1975 }
1976
1977 errors.push(ValidationError {
1978 line: captures
1979 .name("line")
1980 .and_then(|m| m.as_str().parse().ok())
1981 .unwrap_or(0),
1982 column: captures
1983 .name("col")
1984 .and_then(|m| m.as_str().parse().ok())
1985 .unwrap_or(0),
1986 message: captures
1987 .name("message")
1988 .map(|m| m.as_str().trim().to_string())
1989 .unwrap_or_else(|| "unknown error".to_string()),
1990 severity: "error".to_string(),
1991 });
1992 }
1993 errors
1994}
1995
1996fn parse_staticcheck_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1997 let combined = format!("{}\n{}", stdout, stderr);
1998 let trimmed = combined.trim();
1999 if trimmed.is_empty() {
2000 return Vec::new();
2001 }
2002
2003 let mut errors = Vec::new();
2004 if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
2005 parse_staticcheck_json_value(&json, file, &mut errors);
2006 return errors;
2007 }
2008
2009 for line in trimmed.lines() {
2010 let line = line.trim();
2011 if line.is_empty() {
2012 continue;
2013 }
2014 if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
2015 parse_staticcheck_json_value(&json, file, &mut errors);
2016 }
2017 }
2018
2019 errors
2020}
2021
2022fn parse_staticcheck_json_value(
2023 json: &serde_json::Value,
2024 file: &Path,
2025 errors: &mut Vec<ValidationError>,
2026) {
2027 if let Some(diags) = json.as_array() {
2028 for diag in diags {
2029 parse_staticcheck_diag(diag, file, errors);
2030 }
2031 } else if let Some(diags) = json.get("diagnostics").and_then(|d| d.as_array()) {
2032 for diag in diags {
2033 parse_staticcheck_diag(diag, file, errors);
2034 }
2035 } else if let Some(diags) = json.get("issues").and_then(|d| d.as_array()) {
2036 for diag in diags {
2037 parse_staticcheck_diag(diag, file, errors);
2038 }
2039 } else {
2040 parse_staticcheck_diag(json, file, errors);
2041 }
2042}
2043
2044fn parse_staticcheck_diag(
2045 diag: &serde_json::Value,
2046 file: &Path,
2047 errors: &mut Vec<ValidationError>,
2048) {
2049 let diag_file = json_string_at(diag, &["location", "file"])
2050 .or_else(|| json_string_at(diag, &["file"]))
2051 .unwrap_or("");
2052 if !diagnostic_path_matches(file, diag_file) {
2053 return;
2054 }
2055
2056 let message = match (
2057 diag.get("code").and_then(|code| code.as_str()),
2058 diag.get("message").and_then(|message| message.as_str()),
2059 ) {
2060 (Some(code), Some(message)) => format!("{code}: {message}"),
2061 (None, Some(message)) => message.to_string(),
2062 (Some(code), None) => code.to_string(),
2063 (None, None) => "unknown error".to_string(),
2064 };
2065
2066 errors.push(ValidationError {
2067 line: json_u32_at(diag, &["location", "line"])
2068 .or_else(|| json_u32_at(diag, &["line"]))
2069 .unwrap_or(0),
2070 column: json_u32_at(diag, &["location", "column"])
2071 .or_else(|| json_u32_at(diag, &["column"]))
2072 .unwrap_or(0),
2073 message,
2074 severity: diag
2075 .get("severity")
2076 .and_then(|severity| severity.as_str())
2077 .unwrap_or("error")
2078 .to_lowercase(),
2079 });
2080}
2081
2082fn output_tail_summary(stdout: &str, stderr: &str, truncated: bool) -> String {
2083 let mut parts = Vec::new();
2084 if let Some(tail) = short_output_tail(stderr) {
2085 parts.push(format!("stderr: {tail}"));
2086 }
2087 if let Some(tail) = short_output_tail(stdout) {
2088 parts.push(format!("stdout: {tail}"));
2089 }
2090 if truncated {
2091 parts.push("output truncated".to_string());
2092 }
2093
2094 if parts.is_empty() {
2095 "no output".to_string()
2096 } else {
2097 parts.join("; ")
2098 }
2099}
2100
2101fn short_output_tail(output: &str) -> Option<String> {
2102 let trimmed = output.trim();
2103 if trimmed.is_empty() {
2104 return None;
2105 }
2106
2107 let mut lines: Vec<&str> = trimmed.lines().rev().take(3).collect();
2108 lines.reverse();
2109 let mut tail = lines.join(" | ");
2110 const MAX_TAIL_CHARS: usize = 500;
2111 if tail.len() > MAX_TAIL_CHARS {
2112 let start = tail.len().saturating_sub(MAX_TAIL_CHARS);
2113 tail = format!("…{}", &tail[start..]);
2114 }
2115 Some(tail)
2116}
2117
2118pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
2127 let lang = match detect_language(path) {
2128 Some(l) => l,
2129 None => {
2130 log::debug!(
2131 "validate: {} (skipped: unsupported_language)",
2132 path.display()
2133 );
2134 return (Vec::new(), Some("unsupported_language".to_string()));
2135 }
2136 };
2137 if !has_checker_support(lang) {
2138 log::debug!(
2139 "validate: {} (skipped: unsupported_language)",
2140 path.display()
2141 );
2142 return (Vec::new(), Some("unsupported_language".to_string()));
2143 }
2144
2145 let (cmd, args) = match detect_checker_for_path(path, lang, config) {
2146 ToolDetection::Found(cmd, args) => (cmd, args),
2147 ToolDetection::NotConfigured => {
2148 log::debug!(
2149 "validate: {} (skipped: no_checker_configured)",
2150 path.display()
2151 );
2152 return (Vec::new(), Some("no_checker_configured".to_string()));
2153 }
2154 ToolDetection::NotInstalled { tool } => {
2155 crate::slog_warn!(
2156 "validate: {} (skipped: checker_not_installed: {})",
2157 path.display(),
2158 tool
2159 );
2160 return (Vec::new(), Some("checker_not_installed".to_string()));
2161 }
2162 };
2163
2164 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
2165
2166 let working_dir = config.project_root.as_deref();
2168
2169 match run_external_tool_capture(
2170 &cmd,
2171 &arg_refs,
2172 working_dir,
2173 config.type_checker_timeout_secs,
2174 ) {
2175 Ok(result) => {
2176 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
2177 if result.exit_code != 0 && errors.is_empty() {
2178 let summary = output_tail_summary(&result.stdout, &result.stderr, result.truncated);
2179 log::debug!(
2180 "validate: {} (skipped: error: checker exited {} with {})",
2181 path.display(),
2182 result.exit_code,
2183 summary
2184 );
2185 return (Vec::new(), Some("error".to_string()));
2186 }
2187 log::debug!(
2188 "validate: {} ({}, {} errors)",
2189 path.display(),
2190 cmd,
2191 errors.len()
2192 );
2193 (errors, None)
2194 }
2195 Err(FormatError::Timeout { .. }) => {
2196 crate::slog_error!("validate: {} (skipped: timeout)", path.display());
2197 (Vec::new(), Some("timeout".to_string()))
2198 }
2199 Err(FormatError::NotFound { .. }) => {
2200 crate::slog_warn!(
2201 "validate: {} (skipped: checker_not_installed)",
2202 path.display()
2203 );
2204 (Vec::new(), Some("checker_not_installed".to_string()))
2205 }
2206 Err(FormatError::Failed { stderr, .. }) => {
2207 log::debug!(
2208 "validate: {} (skipped: error: {})",
2209 path.display(),
2210 stderr.lines().next().unwrap_or("unknown")
2211 );
2212 (Vec::new(), Some("error".to_string()))
2213 }
2214 Err(FormatError::UnsupportedLanguage) => {
2215 log::debug!(
2216 "validate: {} (skipped: unsupported_language)",
2217 path.display()
2218 );
2219 (Vec::new(), Some("unsupported_language".to_string()))
2220 }
2221 }
2222}
2223
2224#[cfg(test)]
2225mod tests {
2226 use super::*;
2227 use std::fs;
2228 use std::io::Write;
2229 use std::sync::{Mutex, MutexGuard, OnceLock};
2230
2231 fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
2238 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2239 let mutex = LOCK.get_or_init(|| Mutex::new(()));
2240 match mutex.lock() {
2243 Ok(guard) => guard,
2244 Err(poisoned) => poisoned.into_inner(),
2245 }
2246 }
2247
2248 #[test]
2249 fn run_external_tool_not_found() {
2250 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
2251 assert!(result.is_err());
2252 match result.unwrap_err() {
2253 FormatError::NotFound { tool } => {
2254 assert_eq!(tool, "__nonexistent_tool_xyz__");
2255 }
2256 other => panic!("expected NotFound, got: {:?}", other),
2257 }
2258 }
2259
2260 #[test]
2261 fn run_external_tool_timeout_kills_subprocess() {
2262 let result = run_external_tool("sleep", &["60"], None, 1);
2264 assert!(result.is_err());
2265 match result.unwrap_err() {
2266 FormatError::Timeout { tool, timeout_secs } => {
2267 assert_eq!(tool, "sleep");
2268 assert_eq!(timeout_secs, 1);
2269 }
2270 other => panic!("expected Timeout, got: {:?}", other),
2271 }
2272 }
2273
2274 #[test]
2275 fn run_external_tool_success() {
2276 let result = run_external_tool("echo", &["hello"], None, 5);
2277 assert!(result.is_ok());
2278 let res = result.unwrap();
2279 assert_eq!(res.exit_code, 0);
2280 assert!(res.stdout.contains("hello"));
2281 }
2282
2283 #[cfg(unix)]
2284 #[test]
2285 fn format_helper_handles_large_stderr_without_deadlock() {
2286 let start = Instant::now();
2287 let result = run_external_tool_capture(
2288 "sh",
2289 &[
2290 "-c",
2291 "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
2292 ],
2293 None,
2294 2,
2295 )
2296 .expect("large stderr command should complete");
2297
2298 assert_eq!(result.exit_code, 0);
2299 assert!(
2300 result.stderr.len() >= 1024 * 1024,
2301 "expected full stderr capture, got {} bytes",
2302 result.stderr.len()
2303 );
2304 assert!(start.elapsed() < Duration::from_secs(2));
2305 }
2306
2307 #[test]
2308 fn run_external_tool_nonzero_exit() {
2309 let result = run_external_tool("false", &[], None, 5);
2311 assert!(result.is_err());
2312 match result.unwrap_err() {
2313 FormatError::Failed { tool, .. } => {
2314 assert_eq!(tool, "false");
2315 }
2316 other => panic!("expected Failed, got: {:?}", other),
2317 }
2318 }
2319
2320 #[test]
2321 fn auto_format_unsupported_language() {
2322 let dir = tempfile::tempdir().unwrap();
2323 let path = dir.path().join("file.txt");
2324 fs::write(&path, "hello").unwrap();
2325
2326 let config = Config::default();
2327 let (formatted, reason) = auto_format(&path, &config);
2328 assert!(!formatted);
2329 assert_eq!(reason.as_deref(), Some("unsupported_language"));
2330 }
2331
2332 #[test]
2333 fn detect_formatter_rust_when_rustfmt_available() {
2334 let dir = tempfile::tempdir().unwrap();
2335 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2336 let path = dir.path().join("test.rs");
2337 let config = Config {
2338 project_root: Some(dir.path().to_path_buf()),
2339 ..Config::default()
2340 };
2341 let result = detect_formatter(&path, LangId::Rust, &config);
2342 if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
2343 let (cmd, args) = result.unwrap();
2344 let stem = std::path::Path::new(&cmd)
2348 .file_stem()
2349 .and_then(|s| s.to_str())
2350 .unwrap_or("");
2351 assert_eq!(stem, "rustfmt", "expected rustfmt, got {cmd}");
2352 assert!(args.iter().any(|a| a.ends_with("test.rs")));
2353 } else {
2354 assert!(result.is_none());
2355 }
2356 }
2357
2358 #[test]
2359 fn detect_formatter_go_mapping() {
2360 let dir = tempfile::tempdir().unwrap();
2361 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2362 let path = dir.path().join("main.go");
2363 let config = Config {
2364 project_root: Some(dir.path().to_path_buf()),
2365 ..Config::default()
2366 };
2367 let result = detect_formatter(&path, LangId::Go, &config);
2368 if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
2369 let (cmd, args) = result.unwrap();
2370 assert_eq!(
2371 std::path::Path::new(&cmd)
2372 .file_stem()
2373 .and_then(|s| s.to_str())
2374 .unwrap_or(""),
2375 "goimports",
2376 "expected goimports, got {cmd}"
2377 );
2378 assert!(args.contains(&"-w".to_string()));
2379 } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
2380 let (cmd, args) = result.unwrap();
2381 assert_eq!(
2382 std::path::Path::new(&cmd)
2383 .file_stem()
2384 .and_then(|s| s.to_str())
2385 .unwrap_or(""),
2386 "gofmt",
2387 "expected gofmt, got {cmd}"
2388 );
2389 assert!(args.contains(&"-w".to_string()));
2390 } else {
2391 assert!(result.is_none());
2392 }
2393 }
2394
2395 #[test]
2396 fn detect_formatter_python_mapping() {
2397 let dir = tempfile::tempdir().unwrap();
2398 fs::write(dir.path().join("ruff.toml"), "").unwrap();
2399 let path = dir.path().join("main.py");
2400 let config = Config {
2401 project_root: Some(dir.path().to_path_buf()),
2402 ..Config::default()
2403 };
2404 let result = detect_formatter(&path, LangId::Python, &config);
2405 if ruff_format_available(config.project_root.as_deref()) {
2406 let (cmd, args) = result.unwrap();
2407 assert_eq!(
2408 std::path::Path::new(&cmd)
2409 .file_stem()
2410 .and_then(|s| s.to_str())
2411 .unwrap_or(""),
2412 "ruff",
2413 "expected ruff, got {cmd}"
2414 );
2415 assert!(args.contains(&"format".to_string()));
2416 } else {
2417 assert!(result.is_none());
2418 }
2419 }
2420
2421 #[test]
2422 fn detect_formatter_no_config_returns_none() {
2423 let path = Path::new("test.ts");
2424 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
2425 assert!(
2426 result.is_none(),
2427 "expected no formatter without project config"
2428 );
2429 }
2430
2431 #[cfg(unix)]
2432 #[test]
2433 fn detect_formatter_oxfmt_config_for_typescript_projects() {
2434 let _guard = tool_cache_test_lock();
2435 clear_tool_cache();
2436 let dir = tempfile::tempdir().unwrap();
2437 fs::write(dir.path().join(".oxfmtrc.json"), "{}\n").unwrap();
2438 let bin_dir = dir.path().join("node_modules").join(".bin");
2439 fs::create_dir_all(&bin_dir).unwrap();
2440 let fake = bin_dir.join("oxfmt");
2441 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2442 use std::os::unix::fs::PermissionsExt;
2443 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2444
2445 let path = dir.path().join("src/app.ts");
2446 let config = Config {
2447 project_root: Some(dir.path().to_path_buf()),
2448 ..Config::default()
2449 };
2450
2451 let (cmd, args) = detect_formatter(&path, LangId::TypeScript, &config).unwrap();
2452 assert!(cmd.ends_with("oxfmt"), "expected oxfmt, got {cmd}");
2453 assert_eq!(args[0], "--write");
2454 assert!(args.iter().any(|arg| arg.ends_with("src/app.ts")));
2455 }
2456
2457 #[cfg(unix)]
2463 #[test]
2464 fn detect_formatter_explicit_override() {
2465 let dir = tempfile::tempdir().unwrap();
2467 let bin_dir = dir.path().join("node_modules").join(".bin");
2468 fs::create_dir_all(&bin_dir).unwrap();
2469 use std::os::unix::fs::PermissionsExt;
2470 let fake = bin_dir.join("biome");
2471 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2472 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2473
2474 let path = Path::new("test.ts");
2475 let mut config = Config {
2476 project_root: Some(dir.path().to_path_buf()),
2477 ..Config::default()
2478 };
2479 config
2480 .formatter
2481 .insert("typescript".to_string(), "biome".to_string());
2482 let result = detect_formatter(path, LangId::TypeScript, &config);
2483 let (cmd, args) = result.unwrap();
2484 assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
2485 assert!(args.contains(&"format".to_string()));
2486 assert!(args.contains(&"--write".to_string()));
2487 }
2488
2489 #[cfg(unix)]
2490 #[test]
2491 fn detect_formatter_explicit_oxfmt_override() {
2492 let _guard = tool_cache_test_lock();
2493 clear_tool_cache();
2494 let dir = tempfile::tempdir().unwrap();
2495 let bin_dir = dir.path().join("node_modules").join(".bin");
2496 fs::create_dir_all(&bin_dir).unwrap();
2497 use std::os::unix::fs::PermissionsExt;
2498 let fake = bin_dir.join("oxfmt");
2499 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2500 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2501
2502 let path = Path::new("test.ts");
2503 let mut config = Config {
2504 project_root: Some(dir.path().to_path_buf()),
2505 ..Config::default()
2506 };
2507 config
2508 .formatter
2509 .insert("typescript".to_string(), "oxfmt".to_string());
2510
2511 let (cmd, args) = detect_formatter(path, LangId::TypeScript, &config).unwrap();
2512 assert!(cmd.contains("oxfmt"), "expected oxfmt in cmd, got: {cmd}");
2513 assert_eq!(args, vec!["--write".to_string(), "test.ts".to_string()]);
2514 }
2515
2516 #[test]
2517 fn resolve_tool_caches_positive_result_until_clear() {
2518 let _guard = tool_cache_test_lock();
2519 clear_tool_cache();
2520 let dir = tempfile::tempdir().unwrap();
2521 let bin_dir = dir.path().join("node_modules").join(".bin");
2522 fs::create_dir_all(&bin_dir).unwrap();
2523 let tool = bin_dir.join("aft-cache-hit-tool");
2524 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2525
2526 let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2527 assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
2528
2529 fs::remove_file(&tool).unwrap();
2530 let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2531 assert_eq!(cached, first);
2532
2533 clear_tool_cache();
2534 assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
2535 }
2536
2537 #[test]
2538 fn resolve_tool_caches_negative_result_until_clear() {
2539 let _guard = tool_cache_test_lock();
2540 clear_tool_cache();
2541 let dir = tempfile::tempdir().unwrap();
2542 let bin_dir = dir.path().join("node_modules").join(".bin");
2543 let tool = bin_dir.join("aft-cache-miss-tool");
2544
2545 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2546
2547 fs::create_dir_all(&bin_dir).unwrap();
2548 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2549 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2550
2551 clear_tool_cache();
2552 assert_eq!(
2553 resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
2554 Some(tool.to_string_lossy().as_ref())
2555 );
2556 }
2557
2558 #[test]
2559 fn auto_format_happy_path_rustfmt() {
2560 if resolve_tool("rustfmt", None).is_none() {
2561 crate::slog_warn!("skipping: rustfmt not available");
2562 return;
2563 }
2564
2565 let dir = tempfile::tempdir().unwrap();
2566 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2567 let path = dir.path().join("test.rs");
2568
2569 let mut f = fs::File::create(&path).unwrap();
2570 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
2571 drop(f);
2572
2573 let config = Config {
2574 project_root: Some(dir.path().to_path_buf()),
2575 ..Config::default()
2576 };
2577 let (formatted, reason) = auto_format(&path, &config);
2578 assert!(formatted, "expected formatting to succeed");
2579 assert!(reason.is_none());
2580
2581 let content = fs::read_to_string(&path).unwrap();
2582 assert!(
2583 !content.contains("fn main"),
2584 "expected rustfmt to fix spacing"
2585 );
2586 }
2587
2588 #[test]
2589 fn formatter_excluded_path_detects_biome_messages() {
2590 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";
2592 assert!(
2593 formatter_excluded_path(stderr),
2594 "expected biome exclusion stderr to be detected"
2595 );
2596 }
2597
2598 #[test]
2599 fn formatter_excluded_path_detects_prettier_messages() {
2600 let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
2603 assert!(
2604 formatter_excluded_path(stderr),
2605 "expected prettier exclusion stderr to be detected"
2606 );
2607 }
2608
2609 #[test]
2610 fn formatter_excluded_path_detects_oxfmt_messages() {
2611 assert!(formatter_excluded_path(
2612 "Expected at least one target file. All matched files may have been excluded by ignore rules."
2613 ));
2614 assert!(formatter_excluded_path(
2615 "No files found matching the given patterns."
2616 ));
2617 }
2618
2619 #[test]
2620 fn formatter_excluded_path_detects_ruff_messages() {
2621 let stderr = "warning: No Python files found under the given path(s).\n";
2623 assert!(
2624 formatter_excluded_path(stderr),
2625 "expected ruff exclusion stderr to be detected"
2626 );
2627 }
2628
2629 #[test]
2630 fn formatter_excluded_path_is_case_insensitive() {
2631 assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
2632 assert!(formatter_excluded_path("Ignored By The Configuration"));
2633 assert!(formatter_excluded_path("EXPECTED AT LEAST ONE TARGET FILE"));
2634 }
2635
2636 #[test]
2637 fn formatter_excluded_path_rejects_real_errors() {
2638 assert!(!formatter_excluded_path(""));
2641 assert!(!formatter_excluded_path("syntax error: unexpected token"));
2642 assert!(!formatter_excluded_path("formatter crashed: out of memory"));
2643 assert!(!formatter_excluded_path(
2644 "permission denied: /readonly/file"
2645 ));
2646 assert!(!formatter_excluded_path(
2647 "biome internal error: please report"
2648 ));
2649 }
2650
2651 #[test]
2652 fn parse_tsc_output_basic() {
2653 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";
2654 let file = Path::new("src/app.ts");
2655 let errors = parse_tsc_output(stdout, "", file);
2656 assert_eq!(errors.len(), 2);
2657 assert_eq!(errors[0].line, 10);
2658 assert_eq!(errors[0].column, 5);
2659 assert_eq!(errors[0].severity, "error");
2660 assert!(errors[0].message.contains("TS2322"));
2661 assert_eq!(errors[1].line, 20);
2662 }
2663
2664 #[test]
2665 fn parse_tsc_output_filters_other_files() {
2666 let stdout =
2667 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
2668 let file = Path::new("src/app.ts");
2669 let errors = parse_tsc_output(stdout, "", file);
2670 assert_eq!(errors.len(), 1);
2671 assert_eq!(errors[0].line, 5);
2672 }
2673
2674 #[test]
2675 fn parse_cargo_output_basic() {
2676 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}]}}"#;
2677 let file = Path::new("src/main.rs");
2678 let errors = parse_cargo_output(json_line, "", file);
2679 assert_eq!(errors.len(), 1);
2680 assert_eq!(errors[0].line, 10);
2681 assert_eq!(errors[0].column, 5);
2682 assert_eq!(errors[0].severity, "error");
2683 assert!(errors[0].message.contains("mismatched types"));
2684 }
2685
2686 #[test]
2687 fn parse_cargo_output_skips_notes() {
2688 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}]}}"#;
2690 let file = Path::new("src/main.rs");
2691 let errors = parse_cargo_output(json_line, "", file);
2692 assert_eq!(errors.len(), 0);
2693 }
2694
2695 #[test]
2696 fn parse_cargo_output_filters_other_files() {
2697 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}]}}"#;
2698 let file = Path::new("src/main.rs");
2699 let errors = parse_cargo_output(json_line, "", file);
2700 assert_eq!(errors.len(), 0);
2701 }
2702
2703 #[test]
2704 fn parse_go_vet_output_basic() {
2705 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2706 let file = Path::new("main.go");
2707 let errors = parse_go_vet_output(stderr, file);
2708 assert_eq!(errors.len(), 2);
2709 assert_eq!(errors[0].line, 10);
2710 assert_eq!(errors[0].column, 5);
2711 assert!(errors[0].message.contains("unreachable code"));
2712 assert_eq!(errors[1].line, 20);
2713 assert_eq!(errors[1].column, 0);
2714 }
2715
2716 #[test]
2717 fn parse_pyright_output_basic() {
2718 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2719 let file = Path::new("test.py");
2720 let errors = parse_pyright_output(stdout, file);
2721 assert_eq!(errors.len(), 1);
2722 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 11);
2724 assert_eq!(errors[0].severity, "error");
2725 assert!(errors[0].message.contains("Type error here"));
2726 }
2727
2728 #[test]
2729 fn validate_full_unsupported_language() {
2730 let dir = tempfile::tempdir().unwrap();
2731 let path = dir.path().join("file.txt");
2732 fs::write(&path, "hello").unwrap();
2733
2734 let config = Config::default();
2735 let (errors, reason) = validate_full(&path, &config);
2736 assert!(errors.is_empty());
2737 assert_eq!(reason.as_deref(), Some("unsupported_language"));
2738 }
2739
2740 #[test]
2741 fn detect_type_checker_rust() {
2742 let dir = tempfile::tempdir().unwrap();
2743 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2744 let path = dir.path().join("src/main.rs");
2745 let config = Config {
2746 project_root: Some(dir.path().to_path_buf()),
2747 ..Config::default()
2748 };
2749 let result = detect_type_checker(&path, LangId::Rust, &config);
2750 if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2751 let (cmd, args) = result.unwrap();
2752 assert_eq!(
2753 std::path::Path::new(&cmd)
2754 .file_stem()
2755 .and_then(|s| s.to_str())
2756 .unwrap_or(""),
2757 "cargo",
2758 "expected cargo, got {cmd}"
2759 );
2760 assert!(args.contains(&"check".to_string()));
2761 } else {
2762 assert!(result.is_none());
2763 }
2764 }
2765
2766 #[test]
2767 fn detect_type_checker_go() {
2768 let dir = tempfile::tempdir().unwrap();
2769 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2770 let path = dir.path().join("main.go");
2771 let config = Config {
2772 project_root: Some(dir.path().to_path_buf()),
2773 ..Config::default()
2774 };
2775 let result = detect_type_checker(&path, LangId::Go, &config);
2776 if resolve_tool("go", config.project_root.as_deref()).is_some() {
2777 let (cmd, _args) = result.unwrap();
2778 let name = checker_executable_name(&cmd);
2780 assert!(
2781 name == "go" || name == "staticcheck",
2782 "expected go or staticcheck, got {cmd}"
2783 );
2784 } else {
2785 assert!(result.is_none());
2786 }
2787 }
2788
2789 #[cfg(unix)]
2790 #[test]
2791 fn detect_type_checker_defaults_to_tsc_for_typescript() {
2792 let _guard = tool_cache_test_lock();
2793 clear_tool_cache();
2794 let dir = tempfile::tempdir().unwrap();
2795 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2796 let bin_dir = dir.path().join("node_modules").join(".bin");
2797 fs::create_dir_all(&bin_dir).unwrap();
2798 use std::os::unix::fs::PermissionsExt;
2799 let fake_tsc = bin_dir.join("tsc");
2800 fs::write(&fake_tsc, "#!/bin/sh\nexit 0").unwrap();
2801 fs::set_permissions(&fake_tsc, fs::Permissions::from_mode(0o755)).unwrap();
2802 let fake_tsgo = bin_dir.join("tsgo");
2803 fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2804 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2805
2806 let path = dir.path().join("src/app.ts");
2807 let config = Config {
2808 project_root: Some(dir.path().to_path_buf()),
2809 ..Config::default()
2810 };
2811
2812 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2813 assert!(cmd.ends_with("tsc"), "expected tsc by default, got: {cmd}");
2814 assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2815 }
2816
2817 #[cfg(unix)]
2818 #[test]
2819 fn detect_type_checker_uses_tsgo_when_explicitly_configured() {
2820 let _guard = tool_cache_test_lock();
2821 clear_tool_cache();
2822 let dir = tempfile::tempdir().unwrap();
2823 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2824 let bin_dir = dir.path().join("node_modules").join(".bin");
2825 fs::create_dir_all(&bin_dir).unwrap();
2826 use std::os::unix::fs::PermissionsExt;
2827 let fake_tsgo = bin_dir.join("tsgo");
2828 fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
2829 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2830
2831 let path = dir.path().join("src/app.ts");
2832 let mut config = Config {
2833 project_root: Some(dir.path().to_path_buf()),
2834 ..Config::default()
2835 };
2836 config
2837 .checker
2838 .insert("typescript".to_string(), "tsgo".to_string());
2839
2840 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
2841 assert!(cmd.ends_with("tsgo"), "expected tsgo, got: {cmd}");
2842 assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
2843 }
2844
2845 #[cfg(unix)]
2846 #[test]
2847 fn validate_full_explicit_tsgo_parses_diagnostics() {
2848 let _guard = tool_cache_test_lock();
2849 clear_tool_cache();
2850 let dir = tempfile::tempdir().unwrap();
2851 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2852 let src_dir = dir.path().join("src");
2853 fs::create_dir_all(&src_dir).unwrap();
2854 let path = src_dir.join("app.ts");
2855 fs::write(&path, "const value: number = 'nope';\n").unwrap();
2856
2857 let bin_dir = dir.path().join("node_modules").join(".bin");
2858 fs::create_dir_all(&bin_dir).unwrap();
2859 use std::os::unix::fs::PermissionsExt;
2860 let fake_tsgo = bin_dir.join("tsgo");
2861 fs::write(
2862 &fake_tsgo,
2863 "#!/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",
2864 )
2865 .unwrap();
2866 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
2867
2868 let mut config = Config {
2869 project_root: Some(dir.path().to_path_buf()),
2870 ..Config::default()
2871 };
2872 config
2873 .checker
2874 .insert("typescript".to_string(), "tsgo".to_string());
2875
2876 let (errors, reason) = validate_full(&path, &config);
2877 assert_eq!(reason, None);
2878 assert_eq!(errors.len(), 1);
2879 assert_eq!(errors[0].line, 1);
2880 assert_eq!(errors[0].column, 23);
2881 assert!(errors[0].message.contains("TS2322"));
2882 }
2883
2884 #[test]
2885 fn run_external_tool_capture_nonzero_not_error() {
2886 let result = run_external_tool_capture("false", &[], None, 5);
2888 assert!(result.is_ok(), "capture should not error on non-zero exit");
2889 assert_eq!(result.unwrap().exit_code, 1);
2890 }
2891
2892 #[test]
2893 fn run_external_tool_capture_not_found() {
2894 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2895 assert!(result.is_err());
2896 match result.unwrap_err() {
2897 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2898 other => panic!("expected NotFound, got: {:?}", other),
2899 }
2900 }
2901
2902 #[cfg(unix)]
2906 #[test]
2907 fn well_known_search_paths_include_homebrew_cargo_go_and_local() {
2908 let home = std::ffi::OsString::from("/Users/test-home");
2909 let paths = well_known_search_paths("toolx", Some(&home));
2910 let strs: Vec<String> = paths
2911 .iter()
2912 .map(|p| p.to_string_lossy().into_owned())
2913 .collect();
2914 assert_eq!(strs[0], "/opt/homebrew/bin/toolx");
2917 assert_eq!(strs[1], "/usr/local/bin/toolx");
2918 assert_eq!(strs[2], "/usr/local/go/bin/toolx");
2919 assert_eq!(strs[3], "/usr/bin/toolx");
2920 assert_eq!(strs[4], "/snap/bin/toolx");
2921 assert_eq!(strs[5], "/Users/test-home/.cargo/bin/toolx");
2922 assert_eq!(strs[6], "/Users/test-home/go/bin/toolx");
2923 assert_eq!(strs[7], "/Users/test-home/.local/bin/toolx");
2924 assert_eq!(strs.len(), 8);
2925 }
2926
2927 #[cfg(unix)]
2928 #[test]
2929 fn well_known_search_paths_skips_home_when_unset() {
2930 let paths = well_known_search_paths("toolx", None);
2931 assert_eq!(paths.len(), 5);
2932 assert!(paths[0].ends_with("opt/homebrew/bin/toolx"));
2933 assert!(paths[1].ends_with("usr/local/bin/toolx"));
2934 assert!(paths[2].ends_with("usr/local/go/bin/toolx"));
2935 assert!(paths[3].ends_with("usr/bin/toolx"));
2936 assert!(paths[4].ends_with("snap/bin/toolx"));
2937 }
2938
2939 #[cfg(unix)]
2940 #[test]
2941 fn try_well_known_path_lookup_in_finds_executable_file() {
2942 use std::os::unix::fs::PermissionsExt;
2943 let dir = tempfile::tempdir().unwrap();
2944 let bin_dir = dir.path().join("bin");
2945 fs::create_dir_all(&bin_dir).unwrap();
2946 let tool_path = bin_dir.join("toolx");
2947 fs::write(&tool_path, "#!/bin/sh\necho test").unwrap();
2948 let mut perms = fs::metadata(&tool_path).unwrap().permissions();
2949 perms.set_mode(0o755);
2950 fs::set_permissions(&tool_path, perms).unwrap();
2951
2952 let candidates = vec![
2953 dir.path().join("missing/toolx"),
2954 tool_path.clone(),
2955 dir.path().join("alt/toolx"),
2956 ];
2957 let found = try_well_known_path_lookup_in(&candidates);
2958 assert_eq!(found, Some(tool_path));
2959 }
2960
2961 #[cfg(unix)]
2962 #[test]
2963 fn try_well_known_path_lookup_in_skips_non_executable_file() {
2964 let dir = tempfile::tempdir().unwrap();
2965 let bin_dir = dir.path().join("bin");
2966 fs::create_dir_all(&bin_dir).unwrap();
2967 let tool_path = bin_dir.join("toolx");
2969 fs::write(&tool_path, "not a real tool").unwrap();
2970
2971 let found = try_well_known_path_lookup_in(&std::slice::from_ref(&tool_path));
2972 assert!(found.is_none(), "non-executable file should be skipped");
2973 }
2974
2975 #[cfg(unix)]
2976 #[test]
2977 fn try_well_known_path_lookup_in_skips_directories_and_missing_paths() {
2978 let dir = tempfile::tempdir().unwrap();
2979 let candidates = vec![dir.path().to_path_buf(), dir.path().join("does-not-exist")];
2981 assert!(try_well_known_path_lookup_in(&candidates).is_none());
2982 }
2983
2984 #[cfg(windows)]
2985 #[test]
2986 fn try_well_known_path_lookup_finds_npm_global_shim() {
2987 let dir = tempfile::tempdir().unwrap();
2988 let npm_bin = dir.path().join("npm");
2989 fs::create_dir_all(&npm_bin).unwrap();
2990 let shim = npm_bin.join("biome.cmd");
2991 fs::write(&shim, "@echo off\n").unwrap();
2992
2993 let saved_disable = std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP");
2994 std::env::remove_var("AFT_DISABLE_WELL_KNOWN_LOOKUP");
2995 let saved_appdata = std::env::var_os("APPDATA");
2996 std::env::set_var("APPDATA", dir.path());
2997
2998 let found = try_well_known_path_lookup("biome");
2999
3000 if let Some(value) = saved_appdata {
3001 std::env::set_var("APPDATA", value);
3002 } else {
3003 std::env::remove_var("APPDATA");
3004 }
3005 if let Some(value) = saved_disable {
3006 std::env::set_var("AFT_DISABLE_WELL_KNOWN_LOOKUP", value);
3007 }
3008
3009 assert_eq!(found.as_deref(), Some(shim.as_path()));
3010 }
3011
3012 #[test]
3015 fn configured_tool_hint_does_not_claim_not_installed() {
3016 let hint = configured_tool_hint("biome", "biome.json");
3017 assert!(
3018 hint.contains("was not found on PATH or in common install locations"),
3019 "hint should explain the PATH miss: got {:?}",
3020 hint
3021 );
3022 assert!(
3023 !hint.contains("but not installed"),
3024 "hint must not claim the tool isn't installed: got {:?}",
3025 hint
3026 );
3027 }
3028
3029 #[test]
3030 fn install_hint_for_go_mentions_path() {
3031 let hint = install_hint("go");
3034 assert!(
3035 hint.contains("PATH"),
3036 "go install hint should mention PATH: got {:?}",
3037 hint
3038 );
3039 }
3040
3041 #[test]
3042 fn read_bounded_to_string_truncates_after_limit() {
3043 let (text, truncated) = read_bounded_to_string(std::io::Cursor::new(b"abcdef"), 4);
3044 assert_eq!(text, "abcd");
3045 assert!(truncated);
3046
3047 let (text, truncated) = read_bounded_to_string(std::io::Cursor::new(b"abc"), 4);
3048 assert_eq!(text, "abc");
3049 assert!(!truncated);
3050 }
3051
3052 #[test]
3053 fn windows_local_node_bin_extensions_follow_pathext_then_defaults() {
3054 let pathext = std::ffi::OsString::from(".EXE;.CMD;.BAT;.CMD");
3055 let extensions = windows_local_node_bin_extensions(Some(&pathext));
3056 assert_eq!(extensions, vec![".exe", ".cmd", ".bat", ".ps1"]);
3057 }
3058
3059 #[test]
3060 fn checker_executable_name_strips_paths_and_windows_extensions() {
3061 assert_eq!(checker_executable_name("/usr/local/bin/ruff"), "ruff");
3062 assert_eq!(checker_executable_name(r"C:\Go\bin\go.exe"), "go");
3063 assert_eq!(
3064 checker_executable_name(r"C:\repo\node_modules\.bin\biome.cmd"),
3065 "biome"
3066 );
3067 }
3068
3069 #[test]
3070 fn parse_biome_output_json_reporter() {
3071 let dir = tempfile::tempdir().unwrap();
3072 let file = dir.path().join("src/app.ts");
3073 fs::create_dir_all(file.parent().unwrap()).unwrap();
3074 fs::write(&file, "const value = 1;\nconsole.log(value);\n").unwrap();
3075 let stdout = serde_json::json!({
3078 "diagnostics": [
3079 {
3080 "severity": "warning",
3081 "description": "Avoid console.log",
3082 "location": {
3083 "path": { "file": file.to_string_lossy() },
3084 "span": [17, 28],
3085 },
3086 },
3087 ],
3088 })
3089 .to_string();
3090
3091 let errors = parse_biome_output(&stdout, "", &file);
3092 assert_eq!(errors.len(), 1);
3093 assert_eq!(errors[0].line, 2);
3094 assert_eq!(errors[0].column, 1);
3095 assert_eq!(errors[0].severity, "warning");
3096 assert!(errors[0].message.contains("Avoid console.log"));
3097 }
3098
3099 #[test]
3100 fn parse_ruff_output_json() {
3101 let stdout = r#"[{"filename":"pkg/main.py","location":{"row":3,"column":5},"code":"F401","message":"`os` imported but unused"}]"#;
3102 let errors = parse_ruff_output(stdout, "", Path::new("pkg/main.py"));
3103 assert_eq!(errors.len(), 1);
3104 assert_eq!(errors[0].line, 3);
3105 assert_eq!(errors[0].column, 5);
3106 assert!(errors[0].message.contains("F401"));
3107 }
3108
3109 #[test]
3110 fn parse_staticcheck_output_json_lines() {
3111 let stdout = r#"{"code":"SA4006","severity":"error","location":{"file":"C:\\repo\\main.go","line":10,"column":5},"message":"value is never used"}"#;
3112 let errors = parse_staticcheck_output(stdout, "", Path::new(r"C:\repo\main.go"));
3113 assert_eq!(errors.len(), 1);
3114 assert_eq!(errors[0].line, 10);
3115 assert_eq!(errors[0].column, 5);
3116 assert!(errors[0].message.contains("SA4006"));
3117 }
3118
3119 #[test]
3120 fn parse_go_vet_output_handles_windows_drive_letters() {
3121 let stderr = r"C:\repo\main.go:10:5: unreachable code
3122C:\repo\other.go:1:1: other file
3123";
3124 let errors = parse_go_vet_output(stderr, Path::new(r"C:\repo\main.go"));
3125 assert_eq!(errors.len(), 1);
3126 assert_eq!(errors[0].line, 10);
3127 assert_eq!(errors[0].column, 5);
3128 assert_eq!(errors[0].message, "unreachable code");
3129 }
3130
3131 #[cfg(unix)]
3132 #[test]
3133 fn detect_type_checker_biome_uses_json_reporter() {
3134 let _guard = tool_cache_test_lock();
3135 clear_tool_cache();
3136 let dir = tempfile::tempdir().unwrap();
3137 fs::write(dir.path().join("biome.json"), "{}\n").unwrap();
3138 let bin_dir = dir.path().join("node_modules").join(".bin");
3139 fs::create_dir_all(&bin_dir).unwrap();
3140 let fake = bin_dir.join("biome");
3141 fs::write(&fake, "#!/bin/sh\necho 1.0.0\n").unwrap();
3142 use std::os::unix::fs::PermissionsExt;
3143 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3144
3145 let path = dir.path().join("src/app.ts");
3146 let config = Config {
3147 project_root: Some(dir.path().to_path_buf()),
3148 ..Config::default()
3149 };
3150
3151 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
3152 assert!(cmd.ends_with("biome"), "expected biome, got: {cmd}");
3153 assert_eq!(args[0], "check");
3154 assert!(args.contains(&"--reporter=json".to_string()));
3155 }
3156
3157 #[cfg(unix)]
3158 #[test]
3159 fn detect_type_checker_ruff_does_not_require_formatter_version() {
3160 let _guard = tool_cache_test_lock();
3161 clear_tool_cache();
3162 let dir = tempfile::tempdir().unwrap();
3163 fs::write(dir.path().join("ruff.toml"), "\n").unwrap();
3164 let bin_dir = dir.path().join("node_modules").join(".bin");
3165 fs::create_dir_all(&bin_dir).unwrap();
3166 let fake = bin_dir.join("ruff");
3167 fs::write(&fake, "#!/bin/sh\necho 'ruff 0.0.1'\n").unwrap();
3168 use std::os::unix::fs::PermissionsExt;
3169 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3170
3171 let path = dir.path().join("main.py");
3172 let config = Config {
3173 project_root: Some(dir.path().to_path_buf()),
3174 ..Config::default()
3175 };
3176
3177 assert!(!ruff_format_available(config.project_root.as_deref()));
3178 let (cmd, args) = detect_type_checker(&path, LangId::Python, &config).unwrap();
3179 assert!(cmd.ends_with("ruff"), "expected ruff checker, got: {cmd}");
3180 assert_eq!(args[0], "check");
3181 assert!(args.contains(&"--output-format=json".to_string()));
3182 }
3183
3184 #[cfg(unix)]
3185 #[test]
3186 fn detect_type_checker_staticcheck_uses_json_reporter() {
3187 let _guard = tool_cache_test_lock();
3188 clear_tool_cache();
3189 let dir = tempfile::tempdir().unwrap();
3190 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21\n").unwrap();
3191 let bin_dir = dir.path().join("node_modules").join(".bin");
3192 fs::create_dir_all(&bin_dir).unwrap();
3193 let fake = bin_dir.join("staticcheck");
3194 fs::write(&fake, "#!/bin/sh\necho staticcheck\n").unwrap();
3195 use std::os::unix::fs::PermissionsExt;
3196 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3197
3198 let path = dir.path().join("main.go");
3199 let config = Config {
3200 project_root: Some(dir.path().to_path_buf()),
3201 ..Config::default()
3202 };
3203
3204 let (cmd, args) = detect_type_checker(&path, LangId::Go, &config).unwrap();
3205 assert!(
3206 cmd.ends_with("staticcheck"),
3207 "expected staticcheck, got: {cmd}"
3208 );
3209 assert_eq!(args[0], "-f");
3210 assert_eq!(args[1], "json");
3211 }
3212
3213 #[cfg(unix)]
3214 #[test]
3215 fn detect_type_checker_uses_resolved_cargo_and_go_paths() {
3216 let _guard = tool_cache_test_lock();
3217 clear_tool_cache();
3218 let dir = tempfile::tempdir().unwrap();
3219 let bin_dir = dir.path().join("node_modules").join(".bin");
3220 fs::create_dir_all(&bin_dir).unwrap();
3221 use std::os::unix::fs::PermissionsExt;
3222 for name in ["cargo", "go"] {
3223 let fake = bin_dir.join(name);
3224 fs::write(&fake, "#!/bin/sh\necho fake\n").unwrap();
3225 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3226 }
3227
3228 fs::write(
3229 dir.path().join("Cargo.toml"),
3230 "[package]\nname = \"test\"\n",
3231 )
3232 .unwrap();
3233 let rust_config = Config {
3234 project_root: Some(dir.path().to_path_buf()),
3235 ..Config::default()
3236 };
3237 let (cargo_cmd, _) =
3238 detect_type_checker(&dir.path().join("src/main.rs"), LangId::Rust, &rust_config)
3239 .unwrap();
3240 assert_eq!(cargo_cmd, bin_dir.join("cargo").to_string_lossy());
3241
3242 fs::remove_file(dir.path().join("Cargo.toml")).unwrap();
3243 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21\n").unwrap();
3244 let mut go_config = Config {
3245 project_root: Some(dir.path().to_path_buf()),
3246 ..Config::default()
3247 };
3248 go_config.checker.insert("go".to_string(), "go".to_string());
3249 let (go_cmd, _) =
3250 detect_type_checker(&dir.path().join("main.go"), LangId::Go, &go_config).unwrap();
3251 assert_eq!(go_cmd, bin_dir.join("go").to_string_lossy());
3252 }
3253}