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