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