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