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