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