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