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 for dir in crate::tool_path::well_known_windows_bin_dirs(
496 std::env::var_os("USERPROFILE").as_deref(),
497 ) {
498 if let Some(found) = crate::tool_path::probe_tool_in_dir(&dir, command) {
499 return Some(found);
500 }
501 }
502 return None;
503 }
504 let candidates = well_known_search_paths(command, std::env::var_os("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::Zig => "zig",
665 LangId::CSharp => "csharp",
666 LangId::Bash => "bash",
667 LangId::Solidity => "solidity",
668 LangId::Scss => "scss",
669 LangId::Vue => "vue",
670 LangId::Json => "json",
671 LangId::Scala => "scala",
672 LangId::Java => "java",
673 LangId::Ruby => "ruby",
674 LangId::Kotlin => "kotlin",
675 LangId::Swift => "swift",
676 LangId::Php => "php",
677 LangId::Lua => "lua",
678 LangId::Perl => "perl",
679 LangId::Html => "html",
680 LangId::Markdown => "markdown",
681 LangId::Yaml => "yaml",
682 LangId::Pascal => "pascal",
683 LangId::R => "r",
684 LangId::Groovy => "groovy",
685 LangId::ObjC => "objc",
686 }
687}
688
689fn has_formatter_support(lang: LangId) -> bool {
690 matches!(
691 lang,
692 LangId::TypeScript
693 | LangId::JavaScript
694 | LangId::Tsx
695 | LangId::Python
696 | LangId::Rust
697 | LangId::Go
698 )
699}
700
701fn has_checker_support(lang: LangId) -> bool {
702 matches!(
703 lang,
704 LangId::TypeScript
705 | LangId::JavaScript
706 | LangId::Tsx
707 | LangId::Python
708 | LangId::Rust
709 | LangId::Go
710 )
711}
712
713fn nearest_package_manifest(file: &Path) -> Option<PathBuf> {
714 let mut directory = file.parent();
715 while let Some(current) = directory {
716 let manifest = current.join("Cargo.toml");
717 if let Ok(source) = std::fs::read_to_string(&manifest) {
718 if let Ok(value) = toml::from_str::<toml::Value>(&source) {
719 if value.get("package").is_some() {
720 return Some(manifest);
721 }
722 }
723 }
724 directory = current.parent();
725 }
726 None
727}
728
729fn cargo_package_edition(manifest: &Path) -> Option<CargoPackageEdition> {
730 let source = std::fs::read_to_string(manifest).ok()?;
731 let value: toml::Value = toml::from_str(&source).ok()?;
732 let edition = value.get("package")?.get("edition")?;
733
734 if let Some(edition) = edition.as_str() {
735 return Some(CargoPackageEdition::Explicit(edition.to_string()));
736 }
737
738 edition
739 .get("workspace")
740 .and_then(toml::Value::as_bool)
741 .filter(|workspace| *workspace)
742 .map(|_| CargoPackageEdition::WorkspaceInherited)
743}
744
745fn workspace_package_edition(manifest: &Path) -> Option<String> {
746 let mut directory = manifest.parent();
747 while let Some(current) = directory {
748 let workspace_manifest = current.join("Cargo.toml");
749 if let Ok(source) = std::fs::read_to_string(&workspace_manifest) {
750 if let Ok(value) = toml::from_str::<toml::Value>(&source) {
751 if let Some(workspace) = value.get("workspace") {
752 return workspace
753 .get("package")
754 .and_then(|package| package.get("edition"))
755 .and_then(toml::Value::as_str)
756 .map(str::to_string);
757 }
758 }
759 }
760 directory = current.parent();
761 }
762 None
763}
764
765fn rustfmt_args(file: &Path) -> Vec<String> {
771 let manifest = nearest_package_manifest(file);
772 let mut args = match manifest.as_deref().and_then(cargo_package_edition) {
773 Some(CargoPackageEdition::Explicit(edition)) => vec!["--edition".to_string(), edition],
774 Some(CargoPackageEdition::WorkspaceInherited) => manifest
775 .as_deref()
776 .and_then(workspace_package_edition)
777 .map(|edition| vec!["--edition".to_string(), edition])
778 .unwrap_or_default(),
779 None => Vec::new(),
780 };
781 args.push(file.to_string_lossy().into_owned());
782 args
783}
784
785fn formatter_candidates(lang: LangId, config: &Config, path: &Path) -> Vec<ToolCandidate> {
786 let project_root = config.project_root.as_deref();
787 let file_str = path.to_string_lossy();
788 if let Some(preferred) = config.formatter.get(lang_key(lang)) {
789 return explicit_formatter_candidate(preferred, &file_str);
790 }
791
792 match lang {
793 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
794 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
795 vec![ToolCandidate {
796 tool: "biome".to_string(),
797 source: "biome.json".to_string(),
798 args: vec![
799 "format".to_string(),
800 "--write".to_string(),
801 file_str.to_string(),
802 ],
803 required: true,
804 }]
805 } else if has_project_config(
806 project_root,
807 &[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts"],
808 ) {
809 vec![ToolCandidate {
810 tool: "oxfmt".to_string(),
811 source: "oxfmt config".to_string(),
812 args: vec!["--write".to_string(), file_str.to_string()],
813 required: true,
814 }]
815 } else if has_project_config(
816 project_root,
817 &[
818 ".prettierrc",
819 ".prettierrc.json",
820 ".prettierrc.yml",
821 ".prettierrc.yaml",
822 ".prettierrc.js",
823 ".prettierrc.cjs",
824 ".prettierrc.mjs",
825 ".prettierrc.toml",
826 "prettier.config.js",
827 "prettier.config.cjs",
828 "prettier.config.mjs",
829 ],
830 ) {
831 vec![ToolCandidate {
832 tool: "prettier".to_string(),
833 source: "Prettier config".to_string(),
834 args: vec!["--write".to_string(), file_str.to_string()],
835 required: true,
836 }]
837 } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
838 vec![ToolCandidate {
839 tool: "deno".to_string(),
840 source: "deno.json".to_string(),
841 args: vec!["fmt".to_string(), file_str.to_string()],
842 required: true,
843 }]
844 } else {
845 Vec::new()
846 }
847 }
848 LangId::Python => {
849 if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
850 || has_pyproject_tool(project_root, "ruff")
851 {
852 vec![ToolCandidate {
853 tool: "ruff".to_string(),
854 source: "ruff config".to_string(),
855 args: vec!["format".to_string(), file_str.to_string()],
856 required: true,
857 }]
858 } else if has_pyproject_tool(project_root, "black") {
859 vec![ToolCandidate {
860 tool: "black".to_string(),
861 source: "pyproject.toml".to_string(),
862 args: vec![file_str.to_string()],
863 required: true,
864 }]
865 } else {
866 Vec::new()
867 }
868 }
869 LangId::Rust => {
870 if has_project_config(project_root, &["Cargo.toml"]) {
871 vec![ToolCandidate {
872 tool: "rustfmt".to_string(),
873 source: "Cargo.toml".to_string(),
874 args: rustfmt_args(path),
875 required: true,
876 }]
877 } else {
878 Vec::new()
879 }
880 }
881 LangId::Go => {
882 if has_project_config(project_root, &["go.mod"]) {
883 vec![
884 ToolCandidate {
885 tool: "goimports".to_string(),
886 source: "go.mod".to_string(),
887 args: vec!["-w".to_string(), file_str.to_string()],
888 required: false,
889 },
890 ToolCandidate {
891 tool: "gofmt".to_string(),
892 source: "go.mod".to_string(),
893 args: vec!["-w".to_string(), file_str.to_string()],
894 required: true,
895 },
896 ]
897 } else {
898 Vec::new()
899 }
900 }
901 LangId::C
902 | LangId::Cpp
903 | LangId::Zig
904 | LangId::CSharp
905 | LangId::Bash
906 | LangId::Solidity
907 | LangId::Scss
908 | LangId::Vue
909 | LangId::Json
910 | LangId::Scala
911 | LangId::Java
912 | LangId::Ruby
913 | LangId::Kotlin
914 | LangId::Swift
915 | LangId::Php
916 | LangId::Lua
917 | LangId::Perl
918 | LangId::Pascal
919 | LangId::R
920 | LangId::Groovy
921 | LangId::ObjC => Vec::new(),
922 LangId::Html => Vec::new(),
923 LangId::Markdown => Vec::new(),
924 LangId::Yaml => Vec::new(),
925 }
926}
927
928fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
929 let project_root = config.project_root.as_deref();
930 if let Some(preferred) = config.checker.get(lang_key(lang)) {
931 return explicit_checker_candidate(preferred, file_str);
932 }
933
934 match lang {
935 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
936 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
937 vec![ToolCandidate {
938 tool: "biome".to_string(),
939 source: "biome.json".to_string(),
940 args: vec![
941 "check".to_string(),
942 "--reporter=json".to_string(),
943 file_str.to_string(),
944 ],
945 required: true,
946 }]
947 } else if has_project_config(project_root, &["tsconfig.json"]) {
948 vec![ToolCandidate {
949 tool: "tsc".to_string(),
950 source: "tsconfig.json".to_string(),
951 args: vec![
952 "--noEmit".to_string(),
953 "--pretty".to_string(),
954 "false".to_string(),
955 ],
956 required: true,
957 }]
958 } else {
959 Vec::new()
960 }
961 }
962 LangId::Python => {
963 if has_project_config(project_root, &["pyrightconfig.json"])
964 || has_pyproject_tool(project_root, "pyright")
965 {
966 vec![ToolCandidate {
967 tool: "pyright".to_string(),
968 source: "pyright config".to_string(),
969 args: vec!["--outputjson".to_string(), file_str.to_string()],
970 required: true,
971 }]
972 } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
973 || has_pyproject_tool(project_root, "ruff")
974 {
975 vec![ToolCandidate {
976 tool: "ruff".to_string(),
977 source: "ruff config".to_string(),
978 args: vec![
979 "check".to_string(),
980 "--output-format=json".to_string(),
981 file_str.to_string(),
982 ],
983 required: true,
984 }]
985 } else {
986 Vec::new()
987 }
988 }
989 LangId::Rust => {
990 if has_project_config(project_root, &["Cargo.toml"]) {
991 vec![ToolCandidate {
992 tool: "cargo".to_string(),
993 source: "Cargo.toml".to_string(),
994 args: vec!["check".to_string(), "--message-format=json".to_string()],
995 required: true,
996 }]
997 } else {
998 Vec::new()
999 }
1000 }
1001 LangId::Go => {
1002 if has_project_config(project_root, &["go.mod"]) {
1003 vec![
1004 ToolCandidate {
1005 tool: "staticcheck".to_string(),
1006 source: "go.mod".to_string(),
1007 args: vec!["-f".to_string(), "json".to_string(), file_str.to_string()],
1008 required: false,
1009 },
1010 ToolCandidate {
1011 tool: "go".to_string(),
1012 source: "go.mod".to_string(),
1013 args: vec!["vet".to_string(), file_str.to_string()],
1014 required: true,
1015 },
1016 ]
1017 } else {
1018 Vec::new()
1019 }
1020 }
1021 LangId::C
1022 | LangId::Cpp
1023 | LangId::Zig
1024 | LangId::CSharp
1025 | LangId::Bash
1026 | LangId::Solidity
1027 | LangId::Scss
1028 | LangId::Vue
1029 | LangId::Json
1030 | LangId::Scala
1031 | LangId::Java
1032 | LangId::Ruby
1033 | LangId::Kotlin
1034 | LangId::Swift
1035 | LangId::Php
1036 | LangId::Lua
1037 | LangId::Perl
1038 | LangId::Pascal
1039 | LangId::R
1040 | LangId::Groovy
1041 | LangId::ObjC => Vec::new(),
1042 LangId::Html => Vec::new(),
1043 LangId::Markdown => Vec::new(),
1044 LangId::Yaml => Vec::new(),
1045 }
1046}
1047
1048fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
1049 match name {
1050 "none" | "off" | "false" => Vec::new(),
1051 "biome" => vec![ToolCandidate {
1052 tool: name.to_string(),
1053 source: "formatter config".to_string(),
1054 args: vec![
1055 "format".to_string(),
1056 "--write".to_string(),
1057 file_str.to_string(),
1058 ],
1059 required: true,
1060 }],
1061 "oxfmt" => vec![ToolCandidate {
1062 tool: name.to_string(),
1063 source: "formatter config".to_string(),
1064 args: vec!["--write".to_string(), file_str.to_string()],
1065 required: true,
1066 }],
1067 "prettier" => vec![ToolCandidate {
1068 tool: name.to_string(),
1069 source: "formatter config".to_string(),
1070 args: vec!["--write".to_string(), file_str.to_string()],
1071 required: true,
1072 }],
1073 "deno" => vec![ToolCandidate {
1074 tool: name.to_string(),
1075 source: "formatter config".to_string(),
1076 args: vec!["fmt".to_string(), file_str.to_string()],
1077 required: true,
1078 }],
1079 "ruff" => vec![ToolCandidate {
1080 tool: name.to_string(),
1081 source: "formatter config".to_string(),
1082 args: vec!["format".to_string(), file_str.to_string()],
1083 required: true,
1084 }],
1085 "black" | "rustfmt" => vec![ToolCandidate {
1086 tool: name.to_string(),
1087 source: "formatter config".to_string(),
1088 args: vec![file_str.to_string()],
1089 required: true,
1090 }],
1091 "goimports" | "gofmt" => vec![ToolCandidate {
1092 tool: name.to_string(),
1093 source: "formatter config".to_string(),
1094 args: vec!["-w".to_string(), file_str.to_string()],
1095 required: true,
1096 }],
1097 _ => Vec::new(),
1098 }
1099}
1100
1101fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
1102 match name {
1103 "none" | "off" | "false" => Vec::new(),
1104 "tsc" | "tsgo" => vec![ToolCandidate {
1105 tool: name.to_string(),
1106 source: "checker config".to_string(),
1107 args: vec![
1108 "--noEmit".to_string(),
1109 "--pretty".to_string(),
1110 "false".to_string(),
1111 ],
1112 required: true,
1113 }],
1114 "cargo" => vec![ToolCandidate {
1115 tool: name.to_string(),
1116 source: "checker config".to_string(),
1117 args: vec!["check".to_string(), "--message-format=json".to_string()],
1118 required: true,
1119 }],
1120 "go" => vec![ToolCandidate {
1121 tool: name.to_string(),
1122 source: "checker config".to_string(),
1123 args: vec!["vet".to_string(), file_str.to_string()],
1124 required: true,
1125 }],
1126 "biome" => vec![ToolCandidate {
1127 tool: name.to_string(),
1128 source: "checker config".to_string(),
1129 args: vec![
1130 "check".to_string(),
1131 "--reporter=json".to_string(),
1132 file_str.to_string(),
1133 ],
1134 required: true,
1135 }],
1136 "pyright" => vec![ToolCandidate {
1137 tool: name.to_string(),
1138 source: "checker config".to_string(),
1139 args: vec!["--outputjson".to_string(), file_str.to_string()],
1140 required: true,
1141 }],
1142 "ruff" => vec![ToolCandidate {
1143 tool: name.to_string(),
1144 source: "checker config".to_string(),
1145 args: vec![
1146 "check".to_string(),
1147 "--output-format=json".to_string(),
1148 file_str.to_string(),
1149 ],
1150 required: true,
1151 }],
1152 "staticcheck" => vec![ToolCandidate {
1153 tool: name.to_string(),
1154 source: "checker config".to_string(),
1155 args: vec!["-f".to_string(), "json".to_string(), file_str.to_string()],
1156 required: true,
1157 }],
1158 _ => Vec::new(),
1159 }
1160}
1161
1162fn resolve_tool_candidates(
1163 candidates: Vec<ToolCandidate>,
1164 project_root: Option<&Path>,
1165) -> ToolDetection {
1166 if candidates.is_empty() {
1167 return ToolDetection::NotConfigured;
1168 }
1169
1170 let mut missing_required = None;
1171 for candidate in candidates {
1172 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
1173 return ToolDetection::Found {
1174 tool: candidate.tool,
1175 command,
1176 args: candidate.args,
1177 };
1178 }
1179 if candidate.required && missing_required.is_none() {
1180 missing_required = Some(candidate.tool);
1181 }
1182 }
1183
1184 match missing_required {
1185 Some(tool) => ToolDetection::NotInstalled { tool },
1186 None => ToolDetection::NotConfigured,
1187 }
1188}
1189
1190fn checker_command(_candidate: &ToolCandidate, resolved: String) -> String {
1191 resolved
1192}
1193
1194fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
1195 if candidate.tool == "tsc" || candidate.tool == "tsgo" {
1196 vec![
1197 "--noEmit".to_string(),
1198 "--pretty".to_string(),
1199 "false".to_string(),
1200 ]
1201 } else {
1202 candidate.args.clone()
1203 }
1204}
1205
1206fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
1207 resolve_tool_candidates(
1208 formatter_candidates(lang, config, path),
1209 config.project_root.as_deref(),
1210 )
1211}
1212
1213fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
1214 let file_str = path.to_string_lossy().to_string();
1215 let candidates = checker_candidates(lang, config, &file_str);
1216 if candidates.is_empty() {
1217 return ToolDetection::NotConfigured;
1218 }
1219
1220 let project_root = config.project_root.as_deref();
1221 let mut missing_required = None;
1222 for candidate in candidates {
1223 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
1224 let command = checker_command(&candidate, command);
1225 let args = checker_args(&candidate);
1226 return ToolDetection::Found {
1227 tool: candidate.tool,
1228 command,
1229 args,
1230 };
1231 }
1232 if candidate.required && missing_required.is_none() {
1233 missing_required = Some(candidate.tool);
1234 }
1235 }
1236
1237 match missing_required {
1238 Some(tool) => ToolDetection::NotInstalled { tool },
1239 None => ToolDetection::NotConfigured,
1240 }
1241}
1242
1243fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
1244 crate::callgraph::walk_project_files(project_root)
1245 .filter_map(|path| detect_language(&path))
1246 .collect()
1247}
1248
1249fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
1250 let filename = match lang {
1251 LangId::TypeScript => "aft-tool-detection.ts",
1252 LangId::Tsx => "aft-tool-detection.tsx",
1253 LangId::JavaScript => "aft-tool-detection.js",
1254 LangId::Python => "aft-tool-detection.py",
1255 LangId::Rust => "aft_tool_detection.rs",
1256 LangId::Go => "aft_tool_detection.go",
1257 LangId::C => "aft_tool_detection.c",
1258 LangId::Cpp => "aft_tool_detection.cpp",
1259 LangId::Zig => "aft_tool_detection.zig",
1260 LangId::CSharp => "aft_tool_detection.cs",
1261 LangId::Bash => "aft_tool_detection.sh",
1262 LangId::Solidity => "aft_tool_detection.sol",
1263 LangId::Scss => "aft-tool-detection.scss",
1264 LangId::Vue => "aft-tool-detection.vue",
1265 LangId::Json => "aft-tool-detection.json",
1266 LangId::Scala => "aft-tool-detection.scala",
1267 LangId::Java => "aft-tool-detection.java",
1268 LangId::Ruby => "aft-tool-detection.rb",
1269 LangId::Kotlin => "aft-tool-detection.kt",
1270 LangId::Swift => "aft-tool-detection.swift",
1271 LangId::Php => "aft-tool-detection.php",
1272 LangId::Lua => "aft-tool-detection.lua",
1273 LangId::Perl => "aft-tool-detection.pl",
1274 LangId::Html => "aft-tool-detection.html",
1275 LangId::Markdown => "aft-tool-detection.md",
1276 LangId::Yaml => "aft-tool-detection.yaml",
1277 LangId::Pascal => "aft-tool-detection.pas",
1278 LangId::R => "aft-tool-detection.R",
1279 LangId::Groovy => "aft-tool-detection.groovy",
1280 LangId::ObjC => "aft-tool-detection.m",
1281 };
1282 project_root.join(filename)
1283}
1284
1285pub(crate) fn install_hint(tool: &str) -> String {
1286 match tool {
1287 "biome" => {
1288 "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
1289 }
1290 "oxfmt" => "Run `npm install -D oxfmt` or install globally.".to_string(),
1291 "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
1292 "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
1293 "tsgo" => {
1294 "Run `npm install -D @typescript/native-preview` or install globally.".to_string()
1295 }
1296 "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
1297 "ruff" => {
1298 "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
1299 }
1300 "black" => {
1301 "Install: `pip install black` or your Python package manager equivalent.".to_string()
1302 }
1303 "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
1304 "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
1305 "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
1306 "go" => if cfg!(windows) {
1307 "Install Go from https://go.dev/dl/. Common install paths:\
1308 C:\\Go\\bin, C:\\Program Files\\Go\\bin. \
1309 GUI-launched editors often don't inherit login-shell PATH."
1310 } else {
1311 "Install Go from https://go.dev/dl/, or — if it's already installed —\
1312 ensure its bin directory is on PATH (Homebrew typically uses\
1313 /opt/homebrew/bin on Apple Silicon, /usr/local/bin on Intel macOS).\
1314 GUI-launched editors often don't inherit login-shell PATH."
1315 }
1316 .to_string(),
1317 "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
1318 "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
1319 "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
1320 "typescript-language-server" => {
1321 "Install: `npm install -g typescript-language-server typescript`".to_string()
1322 }
1323 "deno" => "Install Deno from https://deno.com/.".to_string(),
1324 "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
1325 "staticcheck" => {
1326 "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
1327 }
1328 other => format!("Install `{other}` and ensure it is on PATH."),
1329 }
1330}
1331
1332fn configured_tool_hint(tool: &str, source: &str) -> String {
1333 format!(
1343 "{tool} is configured in {source} but was not found on PATH or in common install locations. {}",
1344 install_hint(tool)
1345 )
1346}
1347
1348fn missing_tool_warning(
1349 kind: &str,
1350 language: &str,
1351 candidate: &ToolCandidate,
1352 project_root: Option<&Path>,
1353) -> Option<MissingTool> {
1354 if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
1355 return None;
1356 }
1357
1358 Some(MissingTool {
1359 kind: kind.to_string(),
1360 language: language.to_string(),
1361 tool: candidate.tool.clone(),
1362 hint: configured_tool_hint(&candidate.tool, &candidate.source),
1363 })
1364}
1365
1366pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
1368 let languages = languages_in_project(project_root);
1369 let mut warnings = Vec::new();
1370 let mut seen = HashSet::new();
1371
1372 for lang in languages {
1373 let language = lang_key(lang);
1374 let placeholder = placeholder_file_for_language(project_root, lang);
1375 let file_str = placeholder.to_string_lossy().to_string();
1376 for candidate in formatter_candidates(lang, config, &placeholder) {
1377 if let Some(warning) = missing_tool_warning(
1378 "formatter_not_installed",
1379 language,
1380 &candidate,
1381 config.project_root.as_deref(),
1382 ) {
1383 if seen.insert((
1384 warning.kind.clone(),
1385 warning.language.clone(),
1386 warning.tool.clone(),
1387 )) {
1388 warnings.push(warning);
1389 }
1390 }
1391 }
1392
1393 for candidate in checker_candidates(lang, config, &file_str) {
1394 if let Some(warning) = missing_tool_warning(
1395 "checker_not_installed",
1396 language,
1397 &candidate,
1398 config.project_root.as_deref(),
1399 ) {
1400 if seen.insert((
1401 warning.kind.clone(),
1402 warning.language.clone(),
1403 warning.tool.clone(),
1404 )) {
1405 warnings.push(warning);
1406 }
1407 }
1408 }
1409 }
1410
1411 warnings.sort_by(|left, right| {
1412 (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1413 });
1414 warnings
1415}
1416
1417pub fn detect_formatter(
1427 path: &Path,
1428 lang: LangId,
1429 config: &Config,
1430) -> Option<(String, Vec<String>)> {
1431 match detect_formatter_for_path(path, lang, config) {
1432 ToolDetection::Found { command, args, .. } => Some((command, args)),
1433 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1434 }
1435}
1436
1437fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1439 let root = match project_root {
1440 Some(r) => r,
1441 None => return false,
1442 };
1443 filenames.iter().any(|f| root.join(f).exists())
1444}
1445
1446fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1448 let root = match project_root {
1449 Some(r) => r,
1450 None => return false,
1451 };
1452 let pyproject = root.join("pyproject.toml");
1453 if !pyproject.exists() {
1454 return false;
1455 }
1456 match std::fs::read_to_string(&pyproject) {
1457 Ok(content) => {
1458 let pattern = format!("[tool.{}]", tool_name);
1459 content.contains(&pattern)
1460 }
1461 Err(_) => false,
1462 }
1463}
1464
1465fn formatter_excluded_path(stderr: &str) -> bool {
1486 let s = stderr.to_lowercase();
1487 s.contains("no files were processed")
1488 || s.contains("ignored by the configuration")
1489 || s.contains("expected at least one target file")
1490 || s.contains("no files found matching the given patterns")
1491 || s.contains("no files matching the pattern")
1492 || s.contains("no python files found")
1493}
1494
1495pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1516 if !config.format_on_edit {
1518 return (false, Some("no_formatter_configured".to_string()));
1519 }
1520
1521 let lang = match detect_language(path) {
1522 Some(l) => l,
1523 None => {
1524 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1525 return (false, Some("unsupported_language".to_string()));
1526 }
1527 };
1528 if !has_formatter_support(lang) {
1529 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1530 return (false, Some("unsupported_language".to_string()));
1531 }
1532
1533 let (formatter_tool, cmd, args) = match detect_formatter_for_path(path, lang, config) {
1534 ToolDetection::Found {
1535 tool,
1536 command,
1537 args,
1538 } => (tool, command, args),
1539 ToolDetection::NotConfigured => {
1540 log::debug!(
1541 "format: {} (skipped: no_formatter_configured)",
1542 path.display()
1543 );
1544 return (false, Some("no_formatter_configured".to_string()));
1545 }
1546 ToolDetection::NotInstalled { tool } => {
1547 crate::slog_warn!(
1548 "format: {} (skipped: formatter_not_installed: {})",
1549 path.display(),
1550 tool
1551 );
1552 return (false, Some("formatter_not_installed".to_string()));
1553 }
1554 };
1555
1556 if formatter_tool == "ruff" && !ruff_format_available(config.project_root.as_deref()) {
1561 crate::slog_warn!(
1562 "format: {} (skipped: formatter_not_installed: ruff; version gate requires >= 0.1.2)",
1563 path.display()
1564 );
1565 return (false, Some("formatter_not_installed".to_string()));
1566 }
1567
1568 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1569
1570 let working_dir = config.project_root.as_deref();
1577
1578 match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1579 Ok(_) => {
1580 crate::slog_info!("format: {} ({})", path.display(), cmd);
1581 (true, None)
1582 }
1583 Err(FormatError::Timeout { .. }) => {
1584 crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1585 (false, Some("timeout".to_string()))
1586 }
1587 Err(FormatError::NotFound { .. }) => {
1588 crate::slog_warn!(
1589 "format: {} (skipped: formatter_not_installed)",
1590 path.display()
1591 );
1592 (false, Some("formatter_not_installed".to_string()))
1593 }
1594 Err(FormatError::Failed { stderr, .. }) => {
1595 if formatter_excluded_path(&stderr) {
1607 crate::slog_info!(
1608 "format: {} (skipped: formatter_excluded_path; stderr: {})",
1609 path.display(),
1610 stderr.lines().next().unwrap_or("").trim()
1611 );
1612 return (false, Some("formatter_excluded_path".to_string()));
1613 }
1614 crate::slog_warn!(
1615 "format: {} (skipped: error: {})",
1616 path.display(),
1617 stderr.lines().next().unwrap_or("unknown").trim()
1618 );
1619 (false, Some("error".to_string()))
1620 }
1621 Err(FormatError::UnsupportedLanguage) => {
1622 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1623 (false, Some("unsupported_language".to_string()))
1624 }
1625 }
1626}
1627
1628pub fn run_external_tool_capture(
1635 command: &str,
1636 args: &[&str],
1637 working_dir: Option<&Path>,
1638 timeout_secs: u32,
1639) -> Result<ExternalToolResult, FormatError> {
1640 let mut cmd = external_tool_command(command, args)?;
1641 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
1642
1643 if let Some(dir) = working_dir {
1644 cmd.current_dir(dir);
1645 }
1646
1647 isolate_in_process_group(&mut cmd);
1648
1649 let child = match cmd.spawn() {
1650 Ok(c) => c,
1651 Err(e) if e.kind() == ErrorKind::NotFound => {
1652 return Err(FormatError::NotFound {
1653 tool: command.to_string(),
1654 });
1655 }
1656 Err(e) => {
1657 return Err(FormatError::Failed {
1658 tool: command.to_string(),
1659 stderr: e.to_string(),
1660 });
1661 }
1662 };
1663
1664 let outcome = wait_with_timeout(child, command, timeout_secs)?;
1665 Ok(ExternalToolResult {
1666 stdout: outcome.stdout,
1667 stderr: outcome.stderr,
1668 exit_code: outcome.status.code().unwrap_or(-1),
1669 truncated: outcome.truncated,
1670 })
1671}
1672
1673#[derive(Debug, Clone, serde::Serialize)]
1679pub struct ValidationError {
1680 pub line: u32,
1681 pub column: u32,
1682 pub message: String,
1683 pub severity: String,
1684}
1685
1686pub fn detect_type_checker(
1697 path: &Path,
1698 lang: LangId,
1699 config: &Config,
1700) -> Option<(String, Vec<String>)> {
1701 match detect_checker_for_path(path, lang, config) {
1702 ToolDetection::Found { command, args, .. } => Some((command, args)),
1703 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1704 }
1705}
1706
1707pub fn parse_checker_output(
1712 stdout: &str,
1713 stderr: &str,
1714 file: &Path,
1715 checker: &str,
1716) -> Vec<ValidationError> {
1717 let checker_name = checker_executable_name(checker);
1718 match checker_name.as_str() {
1719 "npx" | "tsc" | "tsgo" => parse_tsc_output(stdout, stderr, file),
1720 "biome" => parse_biome_output(stdout, stderr, file),
1721 "pyright" => parse_pyright_output(stdout, file),
1722 "ruff" => parse_ruff_output(stdout, stderr, file),
1723 "cargo" => parse_cargo_output(stdout, stderr, file),
1724 "go" => parse_go_vet_output(stderr, file),
1725 "staticcheck" => parse_staticcheck_output(stdout, stderr, file),
1726 _ => Vec::new(),
1727 }
1728}
1729
1730fn checker_executable_name(checker: &str) -> String {
1731 let name = checker
1732 .rsplit(['/', '\\'])
1733 .next()
1734 .filter(|name| !name.is_empty())
1735 .unwrap_or(checker)
1736 .to_ascii_lowercase();
1737
1738 for suffix in [".exe", ".cmd", ".bat", ".ps1"] {
1739 if let Some(stripped) = name.strip_suffix(suffix) {
1740 return stripped.to_string();
1741 }
1742 }
1743
1744 name
1745}
1746
1747fn normalize_path_for_compare(path: &str) -> String {
1748 path.trim_start_matches("file://")
1749 .replace('\\', "/")
1750 .trim_start_matches("./")
1751 .to_string()
1752}
1753
1754fn diagnostic_path_matches(file: &Path, diagnostic_file: &str) -> bool {
1755 if diagnostic_file.is_empty() {
1756 return true;
1757 }
1758
1759 let file_str = normalize_path_for_compare(&file.to_string_lossy());
1760 let diagnostic_str = normalize_path_for_compare(diagnostic_file);
1761 file_str == diagnostic_str
1762 || file_str.ends_with(&diagnostic_str)
1763 || diagnostic_str.ends_with(&file_str)
1764}
1765
1766fn line_column_for_byte_offset(source: &str, offset: usize) -> (u32, u32) {
1767 let mut line = 1u32;
1768 let mut column = 1u32;
1769 for (idx, ch) in source.char_indices() {
1770 if idx >= offset {
1771 break;
1772 }
1773 if ch == '\n' {
1774 line += 1;
1775 column = 1;
1776 } else {
1777 column += 1;
1778 }
1779 }
1780 (line, column)
1781}
1782
1783fn json_string_at<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a str> {
1784 let mut current = value;
1785 for key in path {
1786 current = current.get(*key)?;
1787 }
1788 current.as_str()
1789}
1790
1791fn json_u32_at(value: &serde_json::Value, path: &[&str]) -> Option<u32> {
1792 let mut current = value;
1793 for key in path {
1794 current = current.get(*key)?;
1795 }
1796 current.as_u64().map(|n| n as u32)
1797}
1798
1799fn json_location_path(value: &serde_json::Value) -> Option<&str> {
1800 json_string_at(value, &["location", "path", "file"])
1801 .or_else(|| json_string_at(value, &["location", "path"]))
1802 .or_else(|| json_string_at(value, &["filename"]))
1803 .or_else(|| json_string_at(value, &["file"]))
1804}
1805
1806fn diagnostic_message(value: &serde_json::Value) -> String {
1807 json_string_at(value, &["description"])
1808 .or_else(|| json_string_at(value, &["message"]))
1809 .or_else(|| json_string_at(value, &["text"]))
1810 .or_else(|| json_string_at(value, &["category"]))
1811 .unwrap_or("unknown error")
1812 .to_string()
1813}
1814
1815fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1817 let mut errors = Vec::new();
1818 let file_str = file.to_string_lossy();
1819 let combined = format!("{}{}", stdout, stderr);
1821 for line in combined.lines() {
1822 if let Some((loc, rest)) = line.split_once("): ") {
1825 let file_part = loc.split('(').next().unwrap_or("");
1827 if !file_str.ends_with(file_part)
1828 && !file_part.ends_with(&*file_str)
1829 && file_part != &*file_str
1830 {
1831 continue;
1832 }
1833
1834 let coords = loc.split('(').last().unwrap_or("");
1836 let parts: Vec<&str> = coords.split(',').collect();
1837 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1838 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1839
1840 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1842 ("error".to_string(), msg.to_string())
1843 } else if let Some(msg) = rest.strip_prefix("warning ") {
1844 ("warning".to_string(), msg.to_string())
1845 } else {
1846 ("error".to_string(), rest.to_string())
1847 };
1848
1849 errors.push(ValidationError {
1850 line: line_num,
1851 column: col_num,
1852 message,
1853 severity,
1854 });
1855 }
1856 }
1857 errors
1858}
1859
1860fn parse_biome_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1861 let mut errors = Vec::new();
1862 for output in [stdout, stderr] {
1863 let trimmed = output.trim();
1864 if trimmed.is_empty() {
1865 continue;
1866 }
1867 if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
1868 parse_biome_json_value(&json, file, &mut errors);
1869 }
1870 }
1871 errors
1872}
1873
1874fn parse_biome_json_value(
1875 json: &serde_json::Value,
1876 file: &Path,
1877 errors: &mut Vec<ValidationError>,
1878) {
1879 let diagnostics: Vec<&serde_json::Value> = if let Some(diags) = json
1880 .get("diagnostics")
1881 .and_then(|diagnostics| diagnostics.as_array())
1882 {
1883 diags.iter().collect()
1884 } else if let Some(diags) = json.as_array() {
1885 diags.iter().collect()
1886 } else {
1887 Vec::new()
1888 };
1889
1890 let source = std::fs::read_to_string(file).ok();
1891 for diag in diagnostics {
1892 if let Some(diag_file) = json_location_path(diag) {
1893 if !diagnostic_path_matches(file, diag_file) {
1894 continue;
1895 }
1896 }
1897
1898 let (line, column) = biome_line_column(diag, source.as_deref());
1899 errors.push(ValidationError {
1900 line,
1901 column,
1902 message: diagnostic_message(diag),
1903 severity: diag
1904 .get("severity")
1905 .and_then(|severity| severity.as_str())
1906 .unwrap_or("error")
1907 .to_lowercase(),
1908 });
1909 }
1910}
1911
1912fn biome_line_column(diag: &serde_json::Value, source: Option<&str>) -> (u32, u32) {
1913 if let Some(line) =
1914 json_u32_at(diag, &["location", "line"]).or_else(|| json_u32_at(diag, &["line"]))
1915 {
1916 let column = json_u32_at(diag, &["location", "column"])
1917 .or_else(|| json_u32_at(diag, &["column"]))
1918 .unwrap_or(0);
1919 return (line, column);
1920 }
1921
1922 let offset = diag
1923 .get("location")
1924 .and_then(|location| location.get("span"))
1925 .and_then(|span| span.as_array())
1926 .and_then(|span| span.first())
1927 .and_then(|offset| offset.as_u64())
1928 .map(|offset| offset as usize);
1929
1930 match (source, offset) {
1931 (Some(source), Some(offset)) => line_column_for_byte_offset(source, offset),
1932 _ => (0, 0),
1933 }
1934}
1935
1936fn parse_ruff_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1937 let mut errors = Vec::new();
1938 for output in [stdout, stderr] {
1939 let trimmed = output.trim();
1940 if trimmed.is_empty() {
1941 continue;
1942 }
1943 if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
1944 parse_ruff_json_value(&json, file, &mut errors);
1945 }
1946 }
1947 errors
1948}
1949
1950fn parse_ruff_json_value(json: &serde_json::Value, file: &Path, errors: &mut Vec<ValidationError>) {
1951 let diagnostics: Vec<&serde_json::Value> = if let Some(diags) = json.as_array() {
1952 diags.iter().collect()
1953 } else if let Some(diags) = json.get("diagnostics").and_then(|d| d.as_array()) {
1954 diags.iter().collect()
1955 } else {
1956 Vec::new()
1957 };
1958
1959 for diag in diagnostics {
1960 let diag_file = diag
1961 .get("filename")
1962 .and_then(|filename| filename.as_str())
1963 .unwrap_or("");
1964 if !diagnostic_path_matches(file, diag_file) {
1965 continue;
1966 }
1967
1968 let message = match (
1969 diag.get("code").and_then(|code| code.as_str()),
1970 diag.get("message").and_then(|message| message.as_str()),
1971 ) {
1972 (Some(code), Some(message)) => format!("{code}: {message}"),
1973 (None, Some(message)) => message.to_string(),
1974 (Some(code), None) => code.to_string(),
1975 (None, None) => "unknown error".to_string(),
1976 };
1977
1978 errors.push(ValidationError {
1979 line: json_u32_at(diag, &["location", "row"])
1980 .or_else(|| json_u32_at(diag, &["location", "line"]))
1981 .unwrap_or(0),
1982 column: json_u32_at(diag, &["location", "column"]).unwrap_or(0),
1983 message,
1984 severity: diag
1985 .get("severity")
1986 .and_then(|severity| severity.as_str())
1987 .unwrap_or("error")
1988 .to_lowercase(),
1989 });
1990 }
1991}
1992
1993fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1995 let mut errors = Vec::new();
1996 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1998 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1999 for diag in diags {
2000 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
2002 if !diagnostic_path_matches(file, diag_file) {
2003 continue;
2004 }
2005
2006 let line_num = diag
2007 .get("range")
2008 .and_then(|r| r.get("start"))
2009 .and_then(|s| s.get("line"))
2010 .and_then(|l| l.as_u64())
2011 .unwrap_or(0) as u32;
2012 let col_num = diag
2013 .get("range")
2014 .and_then(|r| r.get("start"))
2015 .and_then(|s| s.get("character"))
2016 .and_then(|c| c.as_u64())
2017 .unwrap_or(0) as u32;
2018 let message = diag
2019 .get("message")
2020 .and_then(|m| m.as_str())
2021 .unwrap_or("unknown error")
2022 .to_string();
2023 let severity = diag
2024 .get("severity")
2025 .and_then(|s| s.as_str())
2026 .unwrap_or("error")
2027 .to_lowercase();
2028
2029 errors.push(ValidationError {
2030 line: line_num + 1, column: col_num + 1, message,
2033 severity,
2034 });
2035 }
2036 }
2037 }
2038 errors
2039}
2040
2041fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
2043 let mut errors = Vec::new();
2044 let file_str = file.to_string_lossy();
2045
2046 for line in stdout.lines() {
2047 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
2048 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
2049 continue;
2050 }
2051 let message_obj = match msg.get("message") {
2052 Some(m) => m,
2053 None => continue,
2054 };
2055
2056 let level = message_obj
2057 .get("level")
2058 .and_then(|l| l.as_str())
2059 .unwrap_or("error");
2060
2061 if level != "error" && level != "warning" {
2063 continue;
2064 }
2065
2066 let text = message_obj
2067 .get("message")
2068 .and_then(|m| m.as_str())
2069 .unwrap_or("unknown error")
2070 .to_string();
2071
2072 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
2074 for span in spans {
2075 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
2076 let is_primary = span
2077 .get("is_primary")
2078 .and_then(|p| p.as_bool())
2079 .unwrap_or(false);
2080
2081 if !is_primary {
2082 continue;
2083 }
2084
2085 if !file_str.ends_with(span_file)
2087 && !span_file.ends_with(&*file_str)
2088 && span_file != &*file_str
2089 {
2090 continue;
2091 }
2092
2093 let line_num =
2094 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
2095 let col_num = span
2096 .get("column_start")
2097 .and_then(|c| c.as_u64())
2098 .unwrap_or(0) as u32;
2099
2100 errors.push(ValidationError {
2101 line: line_num,
2102 column: col_num,
2103 message: text.clone(),
2104 severity: level.to_string(),
2105 });
2106 }
2107 }
2108 }
2109 }
2110 errors
2111}
2112
2113fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
2115 let mut errors = Vec::new();
2116 let pattern =
2117 regex::Regex::new(r"^(?P<file>.+?):(?P<line>\d+)(?::(?P<col>\d+))?:\s*(?P<message>.*)$")
2118 .expect("valid go vet diagnostic regex");
2119
2120 for line in stderr.lines() {
2121 let Some(captures) = pattern.captures(line) else {
2122 continue;
2123 };
2124
2125 let err_file = captures
2126 .name("file")
2127 .map(|m| m.as_str())
2128 .unwrap_or("")
2129 .trim();
2130 if !diagnostic_path_matches(file, err_file) {
2131 continue;
2132 }
2133
2134 errors.push(ValidationError {
2135 line: captures
2136 .name("line")
2137 .and_then(|m| m.as_str().parse().ok())
2138 .unwrap_or(0),
2139 column: captures
2140 .name("col")
2141 .and_then(|m| m.as_str().parse().ok())
2142 .unwrap_or(0),
2143 message: captures
2144 .name("message")
2145 .map(|m| m.as_str().trim().to_string())
2146 .unwrap_or_else(|| "unknown error".to_string()),
2147 severity: "error".to_string(),
2148 });
2149 }
2150 errors
2151}
2152
2153fn parse_staticcheck_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
2154 let combined = format!("{}\n{}", stdout, stderr);
2155 let trimmed = combined.trim();
2156 if trimmed.is_empty() {
2157 return Vec::new();
2158 }
2159
2160 let mut errors = Vec::new();
2161 if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
2162 parse_staticcheck_json_value(&json, file, &mut errors);
2163 return errors;
2164 }
2165
2166 for line in trimmed.lines() {
2167 let line = line.trim();
2168 if line.is_empty() {
2169 continue;
2170 }
2171 if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
2172 parse_staticcheck_json_value(&json, file, &mut errors);
2173 }
2174 }
2175
2176 errors
2177}
2178
2179fn parse_staticcheck_json_value(
2180 json: &serde_json::Value,
2181 file: &Path,
2182 errors: &mut Vec<ValidationError>,
2183) {
2184 if let Some(diags) = json.as_array() {
2185 for diag in diags {
2186 parse_staticcheck_diag(diag, file, errors);
2187 }
2188 } else if let Some(diags) = json.get("diagnostics").and_then(|d| d.as_array()) {
2189 for diag in diags {
2190 parse_staticcheck_diag(diag, file, errors);
2191 }
2192 } else if let Some(diags) = json.get("issues").and_then(|d| d.as_array()) {
2193 for diag in diags {
2194 parse_staticcheck_diag(diag, file, errors);
2195 }
2196 } else {
2197 parse_staticcheck_diag(json, file, errors);
2198 }
2199}
2200
2201fn parse_staticcheck_diag(
2202 diag: &serde_json::Value,
2203 file: &Path,
2204 errors: &mut Vec<ValidationError>,
2205) {
2206 let diag_file = json_string_at(diag, &["location", "file"])
2207 .or_else(|| json_string_at(diag, &["file"]))
2208 .unwrap_or("");
2209 if !diagnostic_path_matches(file, diag_file) {
2210 return;
2211 }
2212
2213 let message = match (
2214 diag.get("code").and_then(|code| code.as_str()),
2215 diag.get("message").and_then(|message| message.as_str()),
2216 ) {
2217 (Some(code), Some(message)) => format!("{code}: {message}"),
2218 (None, Some(message)) => message.to_string(),
2219 (Some(code), None) => code.to_string(),
2220 (None, None) => "unknown error".to_string(),
2221 };
2222
2223 errors.push(ValidationError {
2224 line: json_u32_at(diag, &["location", "line"])
2225 .or_else(|| json_u32_at(diag, &["line"]))
2226 .unwrap_or(0),
2227 column: json_u32_at(diag, &["location", "column"])
2228 .or_else(|| json_u32_at(diag, &["column"]))
2229 .unwrap_or(0),
2230 message,
2231 severity: diag
2232 .get("severity")
2233 .and_then(|severity| severity.as_str())
2234 .unwrap_or("error")
2235 .to_lowercase(),
2236 });
2237}
2238
2239fn output_tail_summary(stdout: &str, stderr: &str, truncated: bool) -> String {
2240 let mut parts = Vec::new();
2241 if let Some(tail) = short_output_tail(stderr) {
2242 parts.push(format!("stderr: {tail}"));
2243 }
2244 if let Some(tail) = short_output_tail(stdout) {
2245 parts.push(format!("stdout: {tail}"));
2246 }
2247 if truncated {
2248 parts.push("output truncated".to_string());
2249 }
2250
2251 if parts.is_empty() {
2252 "no output".to_string()
2253 } else {
2254 parts.join("; ")
2255 }
2256}
2257
2258fn short_output_tail(output: &str) -> Option<String> {
2259 let trimmed = output.trim();
2260 if trimmed.is_empty() {
2261 return None;
2262 }
2263
2264 let mut lines: Vec<&str> = trimmed.lines().rev().take(3).collect();
2265 lines.reverse();
2266 let mut tail = lines.join(" | ");
2267 const MAX_TAIL_CHARS: usize = 500;
2268 if tail.len() > MAX_TAIL_CHARS {
2269 let start = tail.len().saturating_sub(MAX_TAIL_CHARS);
2270 tail = format!("…{}", &tail[start..]);
2271 }
2272 Some(tail)
2273}
2274
2275pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
2284 let lang = match detect_language(path) {
2285 Some(l) => l,
2286 None => {
2287 log::debug!(
2288 "validate: {} (skipped: unsupported_language)",
2289 path.display()
2290 );
2291 return (Vec::new(), Some("unsupported_language".to_string()));
2292 }
2293 };
2294 if !has_checker_support(lang) {
2295 log::debug!(
2296 "validate: {} (skipped: unsupported_language)",
2297 path.display()
2298 );
2299 return (Vec::new(), Some("unsupported_language".to_string()));
2300 }
2301
2302 let (cmd, args) = match detect_checker_for_path(path, lang, config) {
2303 ToolDetection::Found { command, args, .. } => (command, args),
2304 ToolDetection::NotConfigured => {
2305 log::debug!(
2306 "validate: {} (skipped: no_checker_configured)",
2307 path.display()
2308 );
2309 return (Vec::new(), Some("no_checker_configured".to_string()));
2310 }
2311 ToolDetection::NotInstalled { tool } => {
2312 crate::slog_warn!(
2313 "validate: {} (skipped: checker_not_installed: {})",
2314 path.display(),
2315 tool
2316 );
2317 return (Vec::new(), Some("checker_not_installed".to_string()));
2318 }
2319 };
2320
2321 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
2322
2323 let working_dir = config.project_root.as_deref();
2325
2326 match run_external_tool_capture(
2327 &cmd,
2328 &arg_refs,
2329 working_dir,
2330 config.type_checker_timeout_secs,
2331 ) {
2332 Ok(result) => {
2333 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
2334 if result.exit_code != 0 && errors.is_empty() {
2335 let summary = output_tail_summary(&result.stdout, &result.stderr, result.truncated);
2336 log::debug!(
2337 "validate: {} (skipped: error: checker exited {} with {})",
2338 path.display(),
2339 result.exit_code,
2340 summary
2341 );
2342 return (Vec::new(), Some("error".to_string()));
2343 }
2344 log::debug!(
2345 "validate: {} ({}, {} errors)",
2346 path.display(),
2347 cmd,
2348 errors.len()
2349 );
2350 (errors, None)
2351 }
2352 Err(FormatError::Timeout { .. }) => {
2353 crate::slog_error!("validate: {} (skipped: timeout)", path.display());
2354 (Vec::new(), Some("timeout".to_string()))
2355 }
2356 Err(FormatError::NotFound { .. }) => {
2357 crate::slog_warn!(
2358 "validate: {} (skipped: checker_not_installed)",
2359 path.display()
2360 );
2361 (Vec::new(), Some("checker_not_installed".to_string()))
2362 }
2363 Err(FormatError::Failed { stderr, .. }) => {
2364 log::debug!(
2365 "validate: {} (skipped: error: {})",
2366 path.display(),
2367 stderr.lines().next().unwrap_or("unknown")
2368 );
2369 (Vec::new(), Some("error".to_string()))
2370 }
2371 Err(FormatError::UnsupportedLanguage) => {
2372 log::debug!(
2373 "validate: {} (skipped: unsupported_language)",
2374 path.display()
2375 );
2376 (Vec::new(), Some("unsupported_language".to_string()))
2377 }
2378 }
2379}
2380
2381#[cfg(test)]
2382mod tests {
2383 use super::*;
2384 use std::fs;
2385 use std::io::Write;
2386 use std::sync::{Mutex, MutexGuard, OnceLock};
2387
2388 fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
2395 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2396 let mutex = LOCK.get_or_init(|| Mutex::new(()));
2397 match mutex.lock() {
2400 Ok(guard) => guard,
2401 Err(poisoned) => poisoned.into_inner(),
2402 }
2403 }
2404
2405 #[test]
2406 fn run_external_tool_not_found() {
2407 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
2408 assert!(result.is_err());
2409 match result.unwrap_err() {
2410 FormatError::NotFound { tool } => {
2411 assert_eq!(tool, "__nonexistent_tool_xyz__");
2412 }
2413 other => panic!("expected NotFound, got: {:?}", other),
2414 }
2415 }
2416
2417 #[test]
2418 fn run_external_tool_timeout_kills_subprocess() {
2419 let (tool, args): (&str, &[&str]) = if cfg!(windows) {
2422 ("ping", &["-n", "60", "127.0.0.1"])
2423 } else {
2424 ("sleep", &["60"])
2425 };
2426 let result = run_external_tool(tool, args, None, 1);
2427 assert!(result.is_err());
2428 match result.unwrap_err() {
2429 FormatError::Timeout { tool, timeout_secs } => {
2430 assert_eq!(tool, if cfg!(windows) { "ping" } else { "sleep" });
2431 assert_eq!(timeout_secs, 1);
2432 }
2433 other => panic!("expected Timeout, got: {:?}", other),
2434 }
2435 }
2436
2437 #[test]
2438 fn run_external_tool_success() {
2439 let (tool, args): (&str, &[&str]) = if cfg!(windows) {
2441 ("cmd", &["/C", "echo hello"])
2442 } else {
2443 ("echo", &["hello"])
2444 };
2445 let result = run_external_tool(tool, args, None, 5);
2446 assert!(result.is_ok());
2447 let res = result.unwrap();
2448 assert_eq!(res.exit_code, 0);
2449 assert!(res.stdout.contains("hello"));
2450 }
2451
2452 #[cfg(windows)]
2453 #[test]
2454 fn run_external_tool_invokes_npm_style_batch_shim() {
2455 let temp = tempfile::tempdir().unwrap();
2456 let shim = temp.path().join("formatter shim.cmd");
2457 std::fs::write(&shim, "@echo off\r\necho %~1\r\n").unwrap();
2458
2459 let result =
2460 run_external_tool(shim.to_str().unwrap(), &["--stdin-filepath"], None, 5).unwrap();
2461
2462 assert_eq!(result.stdout.trim(), "--stdin-filepath");
2463 }
2464
2465 #[cfg(unix)]
2466 #[test]
2467 fn format_helper_handles_large_stderr_without_deadlock() {
2468 let start = Instant::now();
2469 let result = run_external_tool_capture(
2470 "sh",
2471 &[
2472 "-c",
2473 "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
2474 ],
2475 None,
2476 2,
2477 )
2478 .expect("large stderr command should complete");
2479
2480 assert_eq!(result.exit_code, 0);
2481 assert!(
2482 result.stderr.len() >= 1024 * 1024,
2483 "expected full stderr capture, got {} bytes",
2484 result.stderr.len()
2485 );
2486 assert!(start.elapsed() < Duration::from_secs(2));
2487 }
2488
2489 #[test]
2490 fn run_external_tool_nonzero_exit() {
2491 let (tool, args): (&str, &[&str]) = if cfg!(windows) {
2493 ("cmd", &["/C", "exit /b 1"])
2494 } else {
2495 ("false", &[])
2496 };
2497 let result = run_external_tool(tool, args, None, 5);
2498 assert!(result.is_err());
2499 match result.unwrap_err() {
2500 FormatError::Failed { tool, .. } => {
2501 assert_eq!(tool, if cfg!(windows) { "cmd" } else { "false" });
2502 }
2503 other => panic!("expected Failed, got: {:?}", other),
2504 }
2505 }
2506
2507 #[test]
2508 fn auto_format_unsupported_language() {
2509 let dir = tempfile::tempdir().unwrap();
2510 let path = dir.path().join("file.txt");
2511 fs::write(&path, "hello").unwrap();
2512
2513 let config = Config {
2516 format_on_edit: true,
2517 ..Config::default()
2518 };
2519 let (formatted, reason) = auto_format(&path, &config);
2520 assert!(!formatted);
2521 assert_eq!(reason.as_deref(), Some("unsupported_language"));
2522 }
2523
2524 #[test]
2525 fn detect_formatter_rust_when_rustfmt_available() {
2526 let dir = tempfile::tempdir().unwrap();
2527 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2528 let path = dir.path().join("test.rs");
2529 let config = Config {
2530 project_root: Some(dir.path().to_path_buf()),
2531 ..Config::default()
2532 };
2533 let result = detect_formatter(&path, LangId::Rust, &config);
2534 if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
2535 let (cmd, args) = result.unwrap();
2536 let stem = std::path::Path::new(&cmd)
2540 .file_stem()
2541 .and_then(|s| s.to_str())
2542 .unwrap_or("");
2543 assert_eq!(stem, "rustfmt", "expected rustfmt, got {cmd}");
2544 assert!(args.iter().any(|a| a.ends_with("test.rs")));
2545 } else {
2546 assert!(result.is_none());
2547 }
2548 }
2549
2550 #[test]
2551 fn detect_formatter_go_mapping() {
2552 let dir = tempfile::tempdir().unwrap();
2553 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2554 let path = dir.path().join("main.go");
2555 let config = Config {
2556 project_root: Some(dir.path().to_path_buf()),
2557 ..Config::default()
2558 };
2559 let result = detect_formatter(&path, LangId::Go, &config);
2560 if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
2561 let (cmd, args) = result.unwrap();
2562 assert_eq!(
2563 std::path::Path::new(&cmd)
2564 .file_stem()
2565 .and_then(|s| s.to_str())
2566 .unwrap_or(""),
2567 "goimports",
2568 "expected goimports, got {cmd}"
2569 );
2570 assert!(args.contains(&"-w".to_string()));
2571 } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
2572 let (cmd, args) = result.unwrap();
2573 assert_eq!(
2574 std::path::Path::new(&cmd)
2575 .file_stem()
2576 .and_then(|s| s.to_str())
2577 .unwrap_or(""),
2578 "gofmt",
2579 "expected gofmt, got {cmd}"
2580 );
2581 assert!(args.contains(&"-w".to_string()));
2582 } else {
2583 assert!(result.is_none());
2584 }
2585 }
2586
2587 #[test]
2588 fn detect_formatter_python_mapping() {
2589 let dir = tempfile::tempdir().unwrap();
2590 fs::write(dir.path().join("ruff.toml"), "").unwrap();
2591 let path = dir.path().join("main.py");
2592 let config = Config {
2593 project_root: Some(dir.path().to_path_buf()),
2594 ..Config::default()
2595 };
2596 let result = detect_formatter(&path, LangId::Python, &config);
2597 if resolve_tool("ruff", config.project_root.as_deref()).is_some() {
2598 let (cmd, args) = result.unwrap();
2599 assert_eq!(
2600 std::path::Path::new(&cmd)
2601 .file_stem()
2602 .and_then(|s| s.to_str())
2603 .unwrap_or(""),
2604 "ruff",
2605 "expected ruff, got {cmd}"
2606 );
2607 assert!(args.contains(&"format".to_string()));
2608 } else {
2609 assert!(result.is_none());
2610 }
2611 }
2612
2613 #[test]
2614 fn detect_formatter_no_config_returns_none() {
2615 let path = Path::new("test.ts");
2616 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
2617 assert!(
2618 result.is_none(),
2619 "expected no formatter without project config"
2620 );
2621 }
2622
2623 #[cfg(unix)]
2624 #[test]
2625 fn detect_formatter_oxfmt_config_for_typescript_projects() {
2626 let _guard = tool_cache_test_lock();
2627 clear_tool_cache();
2628 let dir = tempfile::tempdir().unwrap();
2629 fs::write(dir.path().join(".oxfmtrc.json"), "{}\n").unwrap();
2630 let bin_dir = dir.path().join("node_modules").join(".bin");
2631 fs::create_dir_all(&bin_dir).unwrap();
2632 let fake = bin_dir.join("oxfmt");
2633 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2634 use std::os::unix::fs::PermissionsExt;
2635 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2636
2637 let path = dir.path().join("src/app.ts");
2638 let config = Config {
2639 project_root: Some(dir.path().to_path_buf()),
2640 ..Config::default()
2641 };
2642
2643 let (cmd, args) = detect_formatter(&path, LangId::TypeScript, &config).unwrap();
2644 assert!(cmd.ends_with("oxfmt"), "expected oxfmt, got {cmd}");
2645 assert_eq!(args[0], "--write");
2646 assert!(args.iter().any(|arg| arg.ends_with("src/app.ts")));
2647 }
2648
2649 #[cfg(unix)]
2655 #[test]
2656 fn detect_formatter_explicit_override() {
2657 let dir = tempfile::tempdir().unwrap();
2659 let bin_dir = dir.path().join("node_modules").join(".bin");
2660 fs::create_dir_all(&bin_dir).unwrap();
2661 use std::os::unix::fs::PermissionsExt;
2662 let fake = bin_dir.join("biome");
2663 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2664 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2665
2666 let path = Path::new("test.ts");
2667 let mut config = Config {
2668 project_root: Some(dir.path().to_path_buf()),
2669 ..Config::default()
2670 };
2671 config
2672 .formatter
2673 .insert("typescript".to_string(), "biome".to_string());
2674 let result = detect_formatter(path, LangId::TypeScript, &config);
2675 let (cmd, args) = result.unwrap();
2676 assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
2677 assert!(args.contains(&"format".to_string()));
2678 assert!(args.contains(&"--write".to_string()));
2679 }
2680
2681 #[cfg(unix)]
2682 #[test]
2683 fn detect_formatter_explicit_oxfmt_override() {
2684 let _guard = tool_cache_test_lock();
2685 clear_tool_cache();
2686 let dir = tempfile::tempdir().unwrap();
2687 let bin_dir = dir.path().join("node_modules").join(".bin");
2688 fs::create_dir_all(&bin_dir).unwrap();
2689 use std::os::unix::fs::PermissionsExt;
2690 let fake = bin_dir.join("oxfmt");
2691 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
2692 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
2693
2694 let path = Path::new("test.ts");
2695 let mut config = Config {
2696 project_root: Some(dir.path().to_path_buf()),
2697 ..Config::default()
2698 };
2699 config
2700 .formatter
2701 .insert("typescript".to_string(), "oxfmt".to_string());
2702
2703 let (cmd, args) = detect_formatter(path, LangId::TypeScript, &config).unwrap();
2704 assert!(cmd.contains("oxfmt"), "expected oxfmt in cmd, got: {cmd}");
2705 assert_eq!(args, vec!["--write".to_string(), "test.ts".to_string()]);
2706 }
2707
2708 #[test]
2709 fn resolve_tool_caches_positive_result_until_clear() {
2710 let _guard = tool_cache_test_lock();
2711 clear_tool_cache();
2712 let dir = tempfile::tempdir().unwrap();
2713 let bin_dir = dir.path().join("node_modules").join(".bin");
2714 fs::create_dir_all(&bin_dir).unwrap();
2715 let tool = bin_dir.join("aft-cache-hit-tool");
2716 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2717
2718 let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2719 assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
2720
2721 fs::remove_file(&tool).unwrap();
2722 let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
2723 assert_eq!(cached, first);
2724
2725 clear_tool_cache();
2726 assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
2727 }
2728
2729 #[test]
2730 fn resolve_tool_caches_negative_result_until_clear() {
2731 let _guard = tool_cache_test_lock();
2732 clear_tool_cache();
2733 let dir = tempfile::tempdir().unwrap();
2734 let bin_dir = dir.path().join("node_modules").join(".bin");
2735 let tool = bin_dir.join("aft-cache-miss-tool");
2736
2737 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2738
2739 fs::create_dir_all(&bin_dir).unwrap();
2740 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
2741 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
2742
2743 clear_tool_cache();
2744 assert_eq!(
2745 resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
2746 Some(tool.to_string_lossy().as_ref())
2747 );
2748 }
2749
2750 #[test]
2751 fn auto_format_happy_path_rustfmt() {
2752 if resolve_tool("rustfmt", None).is_none() {
2753 crate::slog_warn!("skipping: rustfmt not available");
2754 return;
2755 }
2756
2757 let dir = tempfile::tempdir().unwrap();
2758 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2759 let path = dir.path().join("test.rs");
2760
2761 let mut f = fs::File::create(&path).unwrap();
2762 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
2763 drop(f);
2764
2765 let config = Config {
2766 project_root: Some(dir.path().to_path_buf()),
2767 format_on_edit: true,
2768 ..Config::default()
2769 };
2770 let (formatted, reason) = auto_format(&path, &config);
2771 assert!(
2774 formatted,
2775 "expected formatting to succeed (reason: {reason:?})"
2776 );
2777 assert!(reason.is_none(), "unexpected skip reason: {reason:?}");
2778
2779 let content = fs::read_to_string(&path).unwrap();
2780 assert!(
2781 !content.contains("fn main"),
2782 "expected rustfmt to fix spacing"
2783 );
2784 }
2785
2786 #[test]
2787 fn formatter_excluded_path_detects_biome_messages() {
2788 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";
2790 assert!(
2791 formatter_excluded_path(stderr),
2792 "expected biome exclusion stderr to be detected"
2793 );
2794 }
2795
2796 #[test]
2797 fn formatter_excluded_path_detects_prettier_messages() {
2798 let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
2801 assert!(
2802 formatter_excluded_path(stderr),
2803 "expected prettier exclusion stderr to be detected"
2804 );
2805 }
2806
2807 #[test]
2808 fn formatter_excluded_path_detects_oxfmt_messages() {
2809 assert!(formatter_excluded_path(
2810 "Expected at least one target file. All matched files may have been excluded by ignore rules."
2811 ));
2812 assert!(formatter_excluded_path(
2813 "No files found matching the given patterns."
2814 ));
2815 }
2816
2817 #[test]
2818 fn formatter_excluded_path_detects_ruff_messages() {
2819 let stderr = "warning: No Python files found under the given path(s).\n";
2821 assert!(
2822 formatter_excluded_path(stderr),
2823 "expected ruff exclusion stderr to be detected"
2824 );
2825 }
2826
2827 #[test]
2828 fn formatter_excluded_path_is_case_insensitive() {
2829 assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
2830 assert!(formatter_excluded_path("Ignored By The Configuration"));
2831 assert!(formatter_excluded_path("EXPECTED AT LEAST ONE TARGET FILE"));
2832 }
2833
2834 #[test]
2835 fn formatter_excluded_path_rejects_real_errors() {
2836 assert!(!formatter_excluded_path(""));
2839 assert!(!formatter_excluded_path("syntax error: unexpected token"));
2840 assert!(!formatter_excluded_path("formatter crashed: out of memory"));
2841 assert!(!formatter_excluded_path(
2842 "permission denied: /readonly/file"
2843 ));
2844 assert!(!formatter_excluded_path(
2845 "biome internal error: please report"
2846 ));
2847 }
2848
2849 #[test]
2850 fn parse_tsc_output_basic() {
2851 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";
2852 let file = Path::new("src/app.ts");
2853 let errors = parse_tsc_output(stdout, "", file);
2854 assert_eq!(errors.len(), 2);
2855 assert_eq!(errors[0].line, 10);
2856 assert_eq!(errors[0].column, 5);
2857 assert_eq!(errors[0].severity, "error");
2858 assert!(errors[0].message.contains("TS2322"));
2859 assert_eq!(errors[1].line, 20);
2860 }
2861
2862 #[test]
2863 fn parse_tsc_output_filters_other_files() {
2864 let stdout =
2865 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
2866 let file = Path::new("src/app.ts");
2867 let errors = parse_tsc_output(stdout, "", file);
2868 assert_eq!(errors.len(), 1);
2869 assert_eq!(errors[0].line, 5);
2870 }
2871
2872 #[test]
2873 fn parse_cargo_output_basic() {
2874 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}]}}"#;
2875 let file = Path::new("src/main.rs");
2876 let errors = parse_cargo_output(json_line, "", file);
2877 assert_eq!(errors.len(), 1);
2878 assert_eq!(errors[0].line, 10);
2879 assert_eq!(errors[0].column, 5);
2880 assert_eq!(errors[0].severity, "error");
2881 assert!(errors[0].message.contains("mismatched types"));
2882 }
2883
2884 #[test]
2885 fn parse_cargo_output_skips_notes() {
2886 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}]}}"#;
2888 let file = Path::new("src/main.rs");
2889 let errors = parse_cargo_output(json_line, "", file);
2890 assert_eq!(errors.len(), 0);
2891 }
2892
2893 #[test]
2894 fn parse_cargo_output_filters_other_files() {
2895 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}]}}"#;
2896 let file = Path::new("src/main.rs");
2897 let errors = parse_cargo_output(json_line, "", file);
2898 assert_eq!(errors.len(), 0);
2899 }
2900
2901 #[test]
2902 fn parse_go_vet_output_basic() {
2903 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2904 let file = Path::new("main.go");
2905 let errors = parse_go_vet_output(stderr, file);
2906 assert_eq!(errors.len(), 2);
2907 assert_eq!(errors[0].line, 10);
2908 assert_eq!(errors[0].column, 5);
2909 assert!(errors[0].message.contains("unreachable code"));
2910 assert_eq!(errors[1].line, 20);
2911 assert_eq!(errors[1].column, 0);
2912 }
2913
2914 #[test]
2915 fn parse_pyright_output_basic() {
2916 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2917 let file = Path::new("test.py");
2918 let errors = parse_pyright_output(stdout, file);
2919 assert_eq!(errors.len(), 1);
2920 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 11);
2922 assert_eq!(errors[0].severity, "error");
2923 assert!(errors[0].message.contains("Type error here"));
2924 }
2925
2926 #[test]
2927 fn validate_full_unsupported_language() {
2928 let dir = tempfile::tempdir().unwrap();
2929 let path = dir.path().join("file.txt");
2930 fs::write(&path, "hello").unwrap();
2931
2932 let config = Config::default();
2933 let (errors, reason) = validate_full(&path, &config);
2934 assert!(errors.is_empty());
2935 assert_eq!(reason.as_deref(), Some("unsupported_language"));
2936 }
2937
2938 #[test]
2939 fn detect_type_checker_rust() {
2940 let dir = tempfile::tempdir().unwrap();
2941 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2942 let path = dir.path().join("src/main.rs");
2943 let config = Config {
2944 project_root: Some(dir.path().to_path_buf()),
2945 ..Config::default()
2946 };
2947 let result = detect_type_checker(&path, LangId::Rust, &config);
2948 if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2949 let (cmd, args) = result.unwrap();
2950 assert_eq!(
2951 std::path::Path::new(&cmd)
2952 .file_stem()
2953 .and_then(|s| s.to_str())
2954 .unwrap_or(""),
2955 "cargo",
2956 "expected cargo, got {cmd}"
2957 );
2958 assert!(args.contains(&"check".to_string()));
2959 } else {
2960 assert!(result.is_none());
2961 }
2962 }
2963
2964 #[test]
2965 fn detect_type_checker_go() {
2966 let dir = tempfile::tempdir().unwrap();
2967 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2968 let path = dir.path().join("main.go");
2969 let config = Config {
2970 project_root: Some(dir.path().to_path_buf()),
2971 ..Config::default()
2972 };
2973 let result = detect_type_checker(&path, LangId::Go, &config);
2974 if resolve_tool("go", config.project_root.as_deref()).is_some() {
2975 let (cmd, _args) = result.unwrap();
2976 let name = checker_executable_name(&cmd);
2978 assert!(
2979 name == "go" || name == "staticcheck",
2980 "expected go or staticcheck, got {cmd}"
2981 );
2982 } else {
2983 assert!(result.is_none());
2984 }
2985 }
2986
2987 #[cfg(unix)]
2988 #[test]
2989 fn detect_type_checker_defaults_to_tsc_for_typescript() {
2990 let _guard = tool_cache_test_lock();
2991 clear_tool_cache();
2992 let dir = tempfile::tempdir().unwrap();
2993 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
2994 let bin_dir = dir.path().join("node_modules").join(".bin");
2995 fs::create_dir_all(&bin_dir).unwrap();
2996 use std::os::unix::fs::PermissionsExt;
2997 let fake_tsc = bin_dir.join("tsc");
2998 fs::write(&fake_tsc, "#!/bin/sh\nexit 0").unwrap();
2999 fs::set_permissions(&fake_tsc, fs::Permissions::from_mode(0o755)).unwrap();
3000 let fake_tsgo = bin_dir.join("tsgo");
3001 fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
3002 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
3003
3004 let path = dir.path().join("src/app.ts");
3005 let config = Config {
3006 project_root: Some(dir.path().to_path_buf()),
3007 ..Config::default()
3008 };
3009
3010 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
3011 assert!(cmd.ends_with("tsc"), "expected tsc by default, got: {cmd}");
3012 assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
3013 }
3014
3015 #[cfg(unix)]
3016 #[test]
3017 fn detect_type_checker_uses_tsgo_when_explicitly_configured() {
3018 let _guard = tool_cache_test_lock();
3019 clear_tool_cache();
3020 let dir = tempfile::tempdir().unwrap();
3021 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
3022 let bin_dir = dir.path().join("node_modules").join(".bin");
3023 fs::create_dir_all(&bin_dir).unwrap();
3024 use std::os::unix::fs::PermissionsExt;
3025 let fake_tsgo = bin_dir.join("tsgo");
3026 fs::write(&fake_tsgo, "#!/bin/sh\nexit 0").unwrap();
3027 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
3028
3029 let path = dir.path().join("src/app.ts");
3030 let mut config = Config {
3031 project_root: Some(dir.path().to_path_buf()),
3032 ..Config::default()
3033 };
3034 config
3035 .checker
3036 .insert("typescript".to_string(), "tsgo".to_string());
3037
3038 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
3039 assert!(cmd.ends_with("tsgo"), "expected tsgo, got: {cmd}");
3040 assert_eq!(args, vec!["--noEmit", "--pretty", "false"]);
3041 }
3042
3043 #[cfg(unix)]
3044 #[test]
3045 fn validate_full_explicit_tsgo_parses_diagnostics() {
3046 let _guard = tool_cache_test_lock();
3047 clear_tool_cache();
3048 let dir = tempfile::tempdir().unwrap();
3049 fs::write(dir.path().join("tsconfig.json"), "{}").unwrap();
3050 let src_dir = dir.path().join("src");
3051 fs::create_dir_all(&src_dir).unwrap();
3052 let path = src_dir.join("app.ts");
3053 fs::write(&path, "const value: number = 'nope';\n").unwrap();
3054
3055 let bin_dir = dir.path().join("node_modules").join(".bin");
3056 fs::create_dir_all(&bin_dir).unwrap();
3057 use std::os::unix::fs::PermissionsExt;
3058 let fake_tsgo = bin_dir.join("tsgo");
3059 fs::write(
3060 &fake_tsgo,
3061 "#!/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",
3062 )
3063 .unwrap();
3064 fs::set_permissions(&fake_tsgo, fs::Permissions::from_mode(0o755)).unwrap();
3065 let _ = std::process::Command::new(&fake_tsgo)
3069 .stdout(std::process::Stdio::null())
3070 .stderr(std::process::Stdio::null())
3071 .status();
3072
3073 let mut config = Config {
3074 project_root: Some(dir.path().to_path_buf()),
3075 ..Config::default()
3076 };
3077 config
3078 .checker
3079 .insert("typescript".to_string(), "tsgo".to_string());
3080
3081 let (errors, reason) = validate_full(&path, &config);
3082 assert_eq!(reason, None);
3083 assert_eq!(errors.len(), 1);
3084 assert_eq!(errors[0].line, 1);
3085 assert_eq!(errors[0].column, 23);
3086 assert!(errors[0].message.contains("TS2322"));
3087 }
3088
3089 #[test]
3090 fn run_external_tool_capture_nonzero_not_error() {
3091 let (tool, args): (&str, &[&str]) = if cfg!(windows) {
3093 ("cmd", &["/C", "exit /b 1"])
3094 } else {
3095 ("false", &[])
3096 };
3097 let result = run_external_tool_capture(tool, args, None, 5);
3098 assert!(result.is_ok(), "capture should not error on non-zero exit");
3099 assert_eq!(result.unwrap().exit_code, 1);
3100 }
3101
3102 #[test]
3103 fn run_external_tool_capture_not_found() {
3104 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
3105 assert!(result.is_err());
3106 match result.unwrap_err() {
3107 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
3108 other => panic!("expected NotFound, got: {:?}", other),
3109 }
3110 }
3111
3112 #[cfg(unix)]
3116 #[test]
3117 fn well_known_search_paths_include_homebrew_cargo_go_and_local() {
3118 let home = std::ffi::OsString::from("/Users/test-home");
3119 let paths = well_known_search_paths("toolx", Some(&home));
3120 let strs: Vec<String> = paths
3121 .iter()
3122 .map(|p| p.to_string_lossy().into_owned())
3123 .collect();
3124 assert_eq!(strs[0], "/opt/homebrew/bin/toolx");
3127 assert_eq!(strs[1], "/usr/local/bin/toolx");
3128 assert_eq!(strs[2], "/usr/local/go/bin/toolx");
3129 assert_eq!(strs[3], "/usr/bin/toolx");
3130 assert_eq!(strs[4], "/snap/bin/toolx");
3131 assert_eq!(strs[5], "/Users/test-home/.cargo/bin/toolx");
3132 assert_eq!(strs[6], "/Users/test-home/go/bin/toolx");
3133 assert_eq!(strs[7], "/Users/test-home/.local/bin/toolx");
3134 assert_eq!(strs.len(), 8);
3135 }
3136
3137 #[cfg(unix)]
3138 #[test]
3139 fn well_known_search_paths_skips_home_when_unset() {
3140 let paths = well_known_search_paths("toolx", None);
3141 assert_eq!(paths.len(), 5);
3142 assert!(paths[0].ends_with("opt/homebrew/bin/toolx"));
3143 assert!(paths[1].ends_with("usr/local/bin/toolx"));
3144 assert!(paths[2].ends_with("usr/local/go/bin/toolx"));
3145 assert!(paths[3].ends_with("usr/bin/toolx"));
3146 assert!(paths[4].ends_with("snap/bin/toolx"));
3147 }
3148
3149 #[cfg(unix)]
3150 #[test]
3151 fn try_well_known_path_lookup_in_finds_executable_file() {
3152 use std::os::unix::fs::PermissionsExt;
3153 let dir = tempfile::tempdir().unwrap();
3154 let bin_dir = dir.path().join("bin");
3155 fs::create_dir_all(&bin_dir).unwrap();
3156 let tool_path = bin_dir.join("toolx");
3157 fs::write(&tool_path, "#!/bin/sh\necho test").unwrap();
3158 let mut perms = fs::metadata(&tool_path).unwrap().permissions();
3159 perms.set_mode(0o755);
3160 fs::set_permissions(&tool_path, perms).unwrap();
3161
3162 let candidates = vec![
3163 dir.path().join("missing/toolx"),
3164 tool_path.clone(),
3165 dir.path().join("alt/toolx"),
3166 ];
3167 let found = try_well_known_path_lookup_in(&candidates);
3168 assert_eq!(found, Some(tool_path));
3169 }
3170
3171 #[cfg(unix)]
3172 #[test]
3173 fn try_well_known_path_lookup_in_skips_non_executable_file() {
3174 let dir = tempfile::tempdir().unwrap();
3175 let bin_dir = dir.path().join("bin");
3176 fs::create_dir_all(&bin_dir).unwrap();
3177 let tool_path = bin_dir.join("toolx");
3179 fs::write(&tool_path, "not a real tool").unwrap();
3180
3181 let found = try_well_known_path_lookup_in(&std::slice::from_ref(&tool_path));
3182 assert!(found.is_none(), "non-executable file should be skipped");
3183 }
3184
3185 #[cfg(unix)]
3186 #[test]
3187 fn try_well_known_path_lookup_in_skips_directories_and_missing_paths() {
3188 let dir = tempfile::tempdir().unwrap();
3189 let candidates = vec![dir.path().to_path_buf(), dir.path().join("does-not-exist")];
3191 assert!(try_well_known_path_lookup_in(&candidates).is_none());
3192 }
3193
3194 #[cfg(windows)]
3195 #[test]
3196 fn try_well_known_path_lookup_finds_npm_global_shim() {
3197 let dir = tempfile::tempdir().unwrap();
3198 let npm_bin = dir.path().join("npm");
3199 fs::create_dir_all(&npm_bin).unwrap();
3200 let shim = npm_bin.join("biome.cmd");
3201 fs::write(&shim, "@echo off\n").unwrap();
3202
3203 let saved_disable = std::env::var_os("AFT_DISABLE_WELL_KNOWN_LOOKUP");
3204 std::env::remove_var("AFT_DISABLE_WELL_KNOWN_LOOKUP");
3205 let saved_appdata = std::env::var_os("APPDATA");
3206 std::env::set_var("APPDATA", dir.path());
3207
3208 let found = try_well_known_path_lookup("biome");
3209
3210 if let Some(value) = saved_appdata {
3211 std::env::set_var("APPDATA", value);
3212 } else {
3213 std::env::remove_var("APPDATA");
3214 }
3215 if let Some(value) = saved_disable {
3216 std::env::set_var("AFT_DISABLE_WELL_KNOWN_LOOKUP", value);
3217 }
3218
3219 assert_eq!(found.as_deref(), Some(shim.as_path()));
3220 }
3221
3222 #[test]
3225 fn configured_tool_hint_does_not_claim_not_installed() {
3226 let hint = configured_tool_hint("biome", "biome.json");
3227 assert!(
3228 hint.contains("was not found on PATH or in common install locations"),
3229 "hint should explain the PATH miss: got {:?}",
3230 hint
3231 );
3232 assert!(
3233 !hint.contains("but not installed"),
3234 "hint must not claim the tool isn't installed: got {:?}",
3235 hint
3236 );
3237 }
3238
3239 #[test]
3240 fn install_hint_for_go_mentions_path() {
3241 let hint = install_hint("go");
3244 assert!(
3245 hint.contains("PATH"),
3246 "go install hint should mention PATH: got {:?}",
3247 hint
3248 );
3249 }
3250
3251 #[test]
3252 fn read_bounded_to_string_truncates_after_limit() {
3253 let (text, truncated) = read_bounded_to_string(std::io::Cursor::new(b"abcdef"), 4);
3254 assert_eq!(text, "abcd");
3255 assert!(truncated);
3256
3257 let (text, truncated) = read_bounded_to_string(std::io::Cursor::new(b"abc"), 4);
3258 assert_eq!(text, "abc");
3259 assert!(!truncated);
3260 }
3261
3262 #[test]
3263 fn windows_local_node_bin_extensions_follow_pathext_then_defaults() {
3264 let pathext = std::ffi::OsString::from(".EXE;.CMD;.BAT;.CMD");
3265 let extensions = windows_local_node_bin_extensions(Some(&pathext));
3266 assert_eq!(extensions, vec![".exe", ".cmd", ".bat", ".ps1"]);
3267 }
3268
3269 #[test]
3270 fn checker_executable_name_strips_paths_and_windows_extensions() {
3271 assert_eq!(checker_executable_name("/usr/local/bin/ruff"), "ruff");
3272 assert_eq!(checker_executable_name(r"C:\Go\bin\go.exe"), "go");
3273 assert_eq!(
3274 checker_executable_name(r"C:\repo\node_modules\.bin\biome.cmd"),
3275 "biome"
3276 );
3277 }
3278
3279 #[test]
3280 fn parse_biome_output_json_reporter() {
3281 let dir = tempfile::tempdir().unwrap();
3282 let file = dir.path().join("src/app.ts");
3283 fs::create_dir_all(file.parent().unwrap()).unwrap();
3284 fs::write(&file, "const value = 1;\nconsole.log(value);\n").unwrap();
3285 let stdout = serde_json::json!({
3288 "diagnostics": [
3289 {
3290 "severity": "warning",
3291 "description": "Avoid console.log",
3292 "location": {
3293 "path": { "file": file.to_string_lossy() },
3294 "span": [17, 28],
3295 },
3296 },
3297 ],
3298 })
3299 .to_string();
3300
3301 let errors = parse_biome_output(&stdout, "", &file);
3302 assert_eq!(errors.len(), 1);
3303 assert_eq!(errors[0].line, 2);
3304 assert_eq!(errors[0].column, 1);
3305 assert_eq!(errors[0].severity, "warning");
3306 assert!(errors[0].message.contains("Avoid console.log"));
3307 }
3308
3309 #[test]
3310 fn parse_ruff_output_json() {
3311 let stdout = r#"[{"filename":"pkg/main.py","location":{"row":3,"column":5},"code":"F401","message":"`os` imported but unused"}]"#;
3312 let errors = parse_ruff_output(stdout, "", Path::new("pkg/main.py"));
3313 assert_eq!(errors.len(), 1);
3314 assert_eq!(errors[0].line, 3);
3315 assert_eq!(errors[0].column, 5);
3316 assert!(errors[0].message.contains("F401"));
3317 }
3318
3319 #[test]
3320 fn parse_staticcheck_output_json_lines() {
3321 let stdout = r#"{"code":"SA4006","severity":"error","location":{"file":"C:\\repo\\main.go","line":10,"column":5},"message":"value is never used"}"#;
3322 let errors = parse_staticcheck_output(stdout, "", Path::new(r"C:\repo\main.go"));
3323 assert_eq!(errors.len(), 1);
3324 assert_eq!(errors[0].line, 10);
3325 assert_eq!(errors[0].column, 5);
3326 assert!(errors[0].message.contains("SA4006"));
3327 }
3328
3329 #[test]
3330 fn parse_go_vet_output_handles_windows_drive_letters() {
3331 let stderr = r"C:\repo\main.go:10:5: unreachable code
3332C:\repo\other.go:1:1: other file
3333";
3334 let errors = parse_go_vet_output(stderr, 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_eq!(errors[0].message, "unreachable code");
3339 }
3340
3341 #[cfg(unix)]
3342 #[test]
3343 fn detect_type_checker_biome_uses_json_reporter() {
3344 let _guard = tool_cache_test_lock();
3345 clear_tool_cache();
3346 let dir = tempfile::tempdir().unwrap();
3347 fs::write(dir.path().join("biome.json"), "{}\n").unwrap();
3348 let bin_dir = dir.path().join("node_modules").join(".bin");
3349 fs::create_dir_all(&bin_dir).unwrap();
3350 let fake = bin_dir.join("biome");
3351 fs::write(&fake, "#!/bin/sh\necho 1.0.0\n").unwrap();
3352 use std::os::unix::fs::PermissionsExt;
3353 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3354
3355 let path = dir.path().join("src/app.ts");
3356 let config = Config {
3357 project_root: Some(dir.path().to_path_buf()),
3358 ..Config::default()
3359 };
3360
3361 let (cmd, args) = detect_type_checker(&path, LangId::TypeScript, &config).unwrap();
3362 assert!(cmd.ends_with("biome"), "expected biome, got: {cmd}");
3363 assert_eq!(args[0], "check");
3364 assert!(args.contains(&"--reporter=json".to_string()));
3365 }
3366
3367 #[cfg(unix)]
3368 #[test]
3369 fn detect_type_checker_ruff_does_not_require_formatter_version() {
3370 let _guard = tool_cache_test_lock();
3371 clear_tool_cache();
3372 let dir = tempfile::tempdir().unwrap();
3373 fs::write(dir.path().join("ruff.toml"), "\n").unwrap();
3374 let bin_dir = dir.path().join("node_modules").join(".bin");
3375 fs::create_dir_all(&bin_dir).unwrap();
3376 let fake = bin_dir.join("ruff");
3377 fs::write(&fake, "#!/bin/sh\necho 'ruff 0.0.1'\n").unwrap();
3378 use std::os::unix::fs::PermissionsExt;
3379 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3380
3381 let path = dir.path().join("main.py");
3382 let config = Config {
3383 project_root: Some(dir.path().to_path_buf()),
3384 ..Config::default()
3385 };
3386
3387 assert!(!ruff_format_available(config.project_root.as_deref()));
3388 let (cmd, args) = detect_type_checker(&path, LangId::Python, &config).unwrap();
3389 assert!(cmd.ends_with("ruff"), "expected ruff checker, got: {cmd}");
3390 assert_eq!(args[0], "check");
3391 assert!(args.contains(&"--output-format=json".to_string()));
3392 }
3393
3394 #[cfg(unix)]
3395 #[test]
3396 fn detect_type_checker_staticcheck_uses_json_reporter() {
3397 let _guard = tool_cache_test_lock();
3398 clear_tool_cache();
3399 let dir = tempfile::tempdir().unwrap();
3400 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21\n").unwrap();
3401 let bin_dir = dir.path().join("node_modules").join(".bin");
3402 fs::create_dir_all(&bin_dir).unwrap();
3403 let fake = bin_dir.join("staticcheck");
3404 fs::write(&fake, "#!/bin/sh\necho staticcheck\n").unwrap();
3405 use std::os::unix::fs::PermissionsExt;
3406 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3407
3408 let path = dir.path().join("main.go");
3409 let config = Config {
3410 project_root: Some(dir.path().to_path_buf()),
3411 ..Config::default()
3412 };
3413
3414 let (cmd, args) = detect_type_checker(&path, LangId::Go, &config).unwrap();
3415 assert!(
3416 cmd.ends_with("staticcheck"),
3417 "expected staticcheck, got: {cmd}"
3418 );
3419 assert_eq!(args[0], "-f");
3420 assert_eq!(args[1], "json");
3421 }
3422
3423 #[cfg(unix)]
3424 #[test]
3425 fn detect_type_checker_uses_resolved_cargo_and_go_paths() {
3426 let _guard = tool_cache_test_lock();
3427 clear_tool_cache();
3428 let dir = tempfile::tempdir().unwrap();
3429 let bin_dir = dir.path().join("node_modules").join(".bin");
3430 fs::create_dir_all(&bin_dir).unwrap();
3431 use std::os::unix::fs::PermissionsExt;
3432 for name in ["cargo", "go"] {
3433 let fake = bin_dir.join(name);
3434 fs::write(&fake, "#!/bin/sh\necho fake\n").unwrap();
3435 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
3436 }
3437
3438 fs::write(
3439 dir.path().join("Cargo.toml"),
3440 "[package]\nname = \"test\"\n",
3441 )
3442 .unwrap();
3443 let rust_config = Config {
3444 project_root: Some(dir.path().to_path_buf()),
3445 ..Config::default()
3446 };
3447 let (cargo_cmd, _) =
3448 detect_type_checker(&dir.path().join("src/main.rs"), LangId::Rust, &rust_config)
3449 .unwrap();
3450 assert_eq!(cargo_cmd, bin_dir.join("cargo").to_string_lossy());
3451
3452 fs::remove_file(dir.path().join("Cargo.toml")).unwrap();
3453 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21\n").unwrap();
3454 let mut go_config = Config {
3455 project_root: Some(dir.path().to_path_buf()),
3456 ..Config::default()
3457 };
3458 go_config.checker.insert("go".to_string(), "go".to_string());
3459 let (go_cmd, _) =
3460 detect_type_checker(&dir.path().join("main.go"), LangId::Go, &go_config).unwrap();
3461 assert_eq!(go_cmd, bin_dir.join("go").to_string_lossy());
3462 }
3463}