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