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