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