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}
24
25struct SubprocessOutcome {
26 stdout: String,
27 stderr: String,
28 status: ExitStatus,
29}
30
31#[derive(Debug)]
33pub enum FormatError {
34 NotFound { tool: String },
36 Timeout { tool: String, timeout_secs: u32 },
38 Failed { tool: String, stderr: String },
40 UnsupportedLanguage,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
46pub struct MissingTool {
47 pub kind: String,
48 pub language: String,
49 pub tool: String,
50 pub hint: String,
51}
52
53#[derive(Debug, Clone)]
54struct ToolCandidate {
55 tool: String,
56 source: String,
57 args: Vec<String>,
58 required: bool,
59}
60
61#[derive(Debug, Clone)]
62enum ToolDetection {
63 Found(String, Vec<String>),
64 NotConfigured,
65 NotInstalled { tool: String },
66}
67
68impl std::fmt::Display for FormatError {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 FormatError::NotFound { tool } => write!(f, "formatter not found: {}", tool),
72 FormatError::Timeout { tool, timeout_secs } => {
73 write!(f, "formatter '{}' timed out after {}s", tool, timeout_secs)
74 }
75 FormatError::Failed { tool, stderr } => {
76 write!(f, "formatter '{}' failed: {}", tool, stderr)
77 }
78 FormatError::UnsupportedLanguage => write!(f, "unsupported language for formatting"),
79 }
80 }
81}
82
83#[cfg(unix)]
90fn isolate_in_process_group(cmd: &mut Command) {
91 use std::os::unix::process::CommandExt;
92 unsafe {
94 cmd.pre_exec(|| {
95 if libc::setsid() == -1 {
96 return Err(std::io::Error::last_os_error());
97 }
98 Ok(())
99 });
100 }
101}
102
103#[cfg(not(unix))]
104fn isolate_in_process_group(_cmd: &mut Command) {
105 }
109
110#[cfg(unix)]
113fn kill_process_tree(child: &mut Child) {
114 let pid = child.id() as i32;
115 if pid > 0 {
116 unsafe {
119 libc::killpg(pid, libc::SIGKILL);
120 }
121 }
122 let _ = child.kill();
123}
124
125#[cfg(not(unix))]
126fn kill_process_tree(child: &mut Child) {
127 let _ = child.kill();
128}
129
130pub fn run_external_tool(
136 command: &str,
137 args: &[&str],
138 working_dir: Option<&Path>,
139 timeout_secs: u32,
140) -> Result<ExternalToolResult, FormatError> {
141 let mut cmd = Command::new(command);
142 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
143
144 if let Some(dir) = working_dir {
145 cmd.current_dir(dir);
146 }
147
148 isolate_in_process_group(&mut cmd);
149
150 let child = match cmd.spawn() {
151 Ok(c) => c,
152 Err(e) if e.kind() == ErrorKind::NotFound => {
153 return Err(FormatError::NotFound {
154 tool: command.to_string(),
155 });
156 }
157 Err(e) => {
158 return Err(FormatError::Failed {
159 tool: command.to_string(),
160 stderr: e.to_string(),
161 });
162 }
163 };
164
165 let outcome = wait_with_timeout(child, command, timeout_secs)?;
166 let exit_code = outcome.status.code().unwrap_or(-1);
167 if exit_code != 0 {
168 return Err(FormatError::Failed {
169 tool: command.to_string(),
170 stderr: outcome.stderr,
171 });
172 }
173
174 Ok(ExternalToolResult {
175 stdout: outcome.stdout,
176 stderr: outcome.stderr,
177 exit_code,
178 })
179}
180
181fn wait_with_timeout(
182 mut child: Child,
183 command: &str,
184 timeout_secs: u32,
185) -> Result<SubprocessOutcome, FormatError> {
186 let mut stdout_pipe = child.stdout.take().expect("piped stdout");
187 let mut stderr_pipe = child.stderr.take().expect("piped stderr");
188 let stdout_thread = thread::spawn(move || {
189 let mut buf = String::new();
190 let _ = stdout_pipe.read_to_string(&mut buf);
191 buf
192 });
193 let stderr_thread = thread::spawn(move || {
194 let mut buf = String::new();
195 let _ = stderr_pipe.read_to_string(&mut buf);
196 buf
197 });
198 let deadline = Instant::now() + Duration::from_secs(timeout_secs as u64);
199
200 loop {
201 match child.try_wait() {
202 Ok(Some(status)) => {
203 let stdout = stdout_thread.join().unwrap_or_default();
204 let stderr = stderr_thread.join().unwrap_or_default();
205 return Ok(SubprocessOutcome {
206 stdout,
207 stderr,
208 status,
209 });
210 }
211 Ok(None) => {
212 if Instant::now() >= deadline {
213 kill_process_tree(&mut child);
214 let _ = child.wait();
215 return Err(FormatError::Timeout {
220 tool: command.to_string(),
221 timeout_secs,
222 });
223 }
224 thread::sleep(Duration::from_millis(50));
225 }
226 Err(e) => {
227 kill_process_tree(&mut child);
228 let _ = child.wait();
229 return Err(FormatError::Failed {
231 tool: command.to_string(),
232 stderr: format!("try_wait error: {}", e),
233 });
234 }
235 }
236 }
237}
238
239const TOOL_CACHE_TTL: Duration = Duration::from_secs(60);
241
242#[derive(Debug, Clone, PartialEq, Eq, Hash)]
243struct ToolCacheKey {
244 command: String,
245 project_root: PathBuf,
246}
247
248static TOOL_RESOLUTION_CACHE: std::sync::LazyLock<
249 Mutex<HashMap<ToolCacheKey, (Option<PathBuf>, Instant)>>,
250> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
251
252static TOOL_AVAILABILITY_CACHE: std::sync::LazyLock<Mutex<HashMap<String, (bool, Instant)>>> =
253 std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
254
255fn tool_cache_key(command: &str, project_root: Option<&Path>) -> ToolCacheKey {
256 ToolCacheKey {
257 command: command.to_string(),
258 project_root: project_root.map(Path::to_path_buf).unwrap_or_default(),
259 }
260}
261
262fn availability_cache_key(command: &str, project_root: Option<&Path>) -> String {
263 let root = project_root
264 .map(|path| path.to_string_lossy())
265 .unwrap_or_default();
266 format!("{}\0{}", command, root)
267}
268
269pub fn clear_tool_cache() {
270 if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
271 cache.clear();
272 }
273 if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
274 cache.clear();
275 }
276}
277
278fn resolve_tool(command: &str, project_root: Option<&Path>) -> Option<String> {
281 let key = tool_cache_key(command, project_root);
282 if let Ok(cache) = TOOL_RESOLUTION_CACHE.lock() {
283 if let Some((resolved, checked_at)) = cache.get(&key) {
284 if checked_at.elapsed() < TOOL_CACHE_TTL {
285 return resolved
286 .as_ref()
287 .map(|path| path.to_string_lossy().to_string());
288 }
289 }
290 }
291
292 let resolved = resolve_tool_uncached(command, project_root);
293 if let Ok(mut cache) = TOOL_RESOLUTION_CACHE.lock() {
294 cache.insert(key, (resolved.clone(), Instant::now()));
295 }
296 resolved.map(|path| path.to_string_lossy().to_string())
297}
298
299fn resolve_tool_uncached(command: &str, project_root: Option<&Path>) -> Option<PathBuf> {
300 if let Some(root) = project_root {
302 let local_bin = root.join("node_modules").join(".bin").join(command);
303 if local_bin.exists() {
304 return Some(local_bin);
305 }
306 }
307
308 match Command::new(command)
310 .arg("--version")
311 .stdin(Stdio::null())
312 .stdout(Stdio::null())
313 .stderr(Stdio::null())
314 .spawn()
315 {
316 Ok(mut child) => {
317 let start = Instant::now();
318 let timeout = Duration::from_secs(2);
319 loop {
320 match child.try_wait() {
321 Ok(Some(status)) => {
322 return if status.success() {
323 Some(PathBuf::from(command))
324 } else {
325 None
326 };
327 }
328 Ok(None) if start.elapsed() > timeout => {
329 let _ = child.kill();
330 let _ = child.wait();
331 return None;
332 }
333 Ok(None) => thread::sleep(Duration::from_millis(50)),
334 Err(_) => return None,
335 }
336 }
337 }
338 Err(_) => None,
339 }
340}
341
342fn ruff_format_available(project_root: Option<&Path>) -> bool {
349 let key = availability_cache_key("ruff-format", project_root);
350 if let Ok(cache) = TOOL_AVAILABILITY_CACHE.lock() {
351 if let Some((available, checked_at)) = cache.get(&key) {
352 if checked_at.elapsed() < TOOL_CACHE_TTL {
353 return *available;
354 }
355 }
356 }
357
358 let result = ruff_format_available_uncached(project_root);
359 if let Ok(mut cache) = TOOL_AVAILABILITY_CACHE.lock() {
360 cache.insert(key, (result, Instant::now()));
361 }
362 result
363}
364
365fn ruff_format_available_uncached(project_root: Option<&Path>) -> bool {
366 let command = match resolve_tool("ruff", project_root) {
367 Some(command) => command,
368 None => return false,
369 };
370 let output = match Command::new(&command)
371 .arg("--version")
372 .stdout(Stdio::piped())
373 .stderr(Stdio::null())
374 .output()
375 {
376 Ok(o) => o,
377 Err(_) => return false,
378 };
379
380 let version_str = String::from_utf8_lossy(&output.stdout);
381 let version_part = version_str
383 .trim()
384 .strip_prefix("ruff ")
385 .unwrap_or(version_str.trim());
386
387 let parts: Vec<&str> = version_part.split('.').collect();
388 if parts.len() < 3 {
389 return false;
390 }
391
392 let major: u32 = match parts[0].parse() {
393 Ok(v) => v,
394 Err(_) => return false,
395 };
396 let minor: u32 = match parts[1].parse() {
397 Ok(v) => v,
398 Err(_) => return false,
399 };
400 let patch: u32 = match parts[2].parse() {
401 Ok(v) => v,
402 Err(_) => return false,
403 };
404
405 (major, minor, patch) >= (0, 1, 2)
407}
408
409fn resolve_candidate_tool(
410 candidate: &ToolCandidate,
411 project_root: Option<&Path>,
412) -> Option<String> {
413 if candidate.tool == "ruff" && !ruff_format_available(project_root) {
414 return None;
415 }
416
417 resolve_tool(&candidate.tool, project_root)
418}
419
420fn lang_key(lang: LangId) -> &'static str {
421 match lang {
422 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => "typescript",
423 LangId::Python => "python",
424 LangId::Rust => "rust",
425 LangId::Go => "go",
426 LangId::C => "c",
427 LangId::Cpp => "cpp",
428 LangId::Zig => "zig",
429 LangId::CSharp => "csharp",
430 LangId::Bash => "bash",
431 LangId::Solidity => "solidity",
432 LangId::Vue => "vue",
433 LangId::Json => "json",
434 LangId::Scala => "scala",
435 LangId::Html => "html",
436 LangId::Markdown => "markdown",
437 }
438}
439
440fn has_formatter_support(lang: LangId) -> bool {
441 matches!(
442 lang,
443 LangId::TypeScript
444 | LangId::JavaScript
445 | LangId::Tsx
446 | LangId::Python
447 | LangId::Rust
448 | LangId::Go
449 )
450}
451
452fn has_checker_support(lang: LangId) -> bool {
453 matches!(
454 lang,
455 LangId::TypeScript
456 | LangId::JavaScript
457 | LangId::Tsx
458 | LangId::Python
459 | LangId::Rust
460 | LangId::Go
461 )
462}
463
464fn formatter_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
465 let project_root = config.project_root.as_deref();
466 if let Some(preferred) = config.formatter.get(lang_key(lang)) {
467 return explicit_formatter_candidate(preferred, file_str);
468 }
469
470 match lang {
471 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
472 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
473 vec![ToolCandidate {
474 tool: "biome".to_string(),
475 source: "biome.json".to_string(),
476 args: vec![
477 "format".to_string(),
478 "--write".to_string(),
479 file_str.to_string(),
480 ],
481 required: true,
482 }]
483 } else if has_project_config(
484 project_root,
485 &[
486 ".prettierrc",
487 ".prettierrc.json",
488 ".prettierrc.yml",
489 ".prettierrc.yaml",
490 ".prettierrc.js",
491 ".prettierrc.cjs",
492 ".prettierrc.mjs",
493 ".prettierrc.toml",
494 "prettier.config.js",
495 "prettier.config.cjs",
496 "prettier.config.mjs",
497 ],
498 ) {
499 vec![ToolCandidate {
500 tool: "prettier".to_string(),
501 source: "Prettier config".to_string(),
502 args: vec!["--write".to_string(), file_str.to_string()],
503 required: true,
504 }]
505 } else if has_project_config(project_root, &["deno.json", "deno.jsonc"]) {
506 vec![ToolCandidate {
507 tool: "deno".to_string(),
508 source: "deno.json".to_string(),
509 args: vec!["fmt".to_string(), file_str.to_string()],
510 required: true,
511 }]
512 } else {
513 Vec::new()
514 }
515 }
516 LangId::Python => {
517 if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
518 || has_pyproject_tool(project_root, "ruff")
519 {
520 vec![ToolCandidate {
521 tool: "ruff".to_string(),
522 source: "ruff config".to_string(),
523 args: vec!["format".to_string(), file_str.to_string()],
524 required: true,
525 }]
526 } else if has_pyproject_tool(project_root, "black") {
527 vec![ToolCandidate {
528 tool: "black".to_string(),
529 source: "pyproject.toml".to_string(),
530 args: vec![file_str.to_string()],
531 required: true,
532 }]
533 } else {
534 Vec::new()
535 }
536 }
537 LangId::Rust => {
538 if has_project_config(project_root, &["Cargo.toml"]) {
539 vec![ToolCandidate {
540 tool: "rustfmt".to_string(),
541 source: "Cargo.toml".to_string(),
542 args: vec![file_str.to_string()],
543 required: true,
544 }]
545 } else {
546 Vec::new()
547 }
548 }
549 LangId::Go => {
550 if has_project_config(project_root, &["go.mod"]) {
551 vec![
552 ToolCandidate {
553 tool: "goimports".to_string(),
554 source: "go.mod".to_string(),
555 args: vec!["-w".to_string(), file_str.to_string()],
556 required: false,
557 },
558 ToolCandidate {
559 tool: "gofmt".to_string(),
560 source: "go.mod".to_string(),
561 args: vec!["-w".to_string(), file_str.to_string()],
562 required: true,
563 },
564 ]
565 } else {
566 Vec::new()
567 }
568 }
569 LangId::C
570 | LangId::Cpp
571 | LangId::Zig
572 | LangId::CSharp
573 | LangId::Bash
574 | LangId::Solidity
575 | LangId::Vue
576 | LangId::Json
577 | LangId::Scala => Vec::new(),
578 LangId::Html => Vec::new(),
579 LangId::Markdown => Vec::new(),
580 }
581}
582
583fn checker_candidates(lang: LangId, config: &Config, file_str: &str) -> Vec<ToolCandidate> {
584 let project_root = config.project_root.as_deref();
585 if let Some(preferred) = config.checker.get(lang_key(lang)) {
586 return explicit_checker_candidate(preferred, file_str);
587 }
588
589 match lang {
590 LangId::TypeScript | LangId::JavaScript | LangId::Tsx => {
591 if has_project_config(project_root, &["biome.json", "biome.jsonc"]) {
592 vec![ToolCandidate {
593 tool: "biome".to_string(),
594 source: "biome.json".to_string(),
595 args: vec!["check".to_string(), file_str.to_string()],
596 required: true,
597 }]
598 } else if has_project_config(project_root, &["tsconfig.json"]) {
599 vec![ToolCandidate {
600 tool: "tsc".to_string(),
601 source: "tsconfig.json".to_string(),
602 args: vec![
603 "--noEmit".to_string(),
604 "--pretty".to_string(),
605 "false".to_string(),
606 ],
607 required: true,
608 }]
609 } else {
610 Vec::new()
611 }
612 }
613 LangId::Python => {
614 if has_project_config(project_root, &["pyrightconfig.json"])
615 || has_pyproject_tool(project_root, "pyright")
616 {
617 vec![ToolCandidate {
618 tool: "pyright".to_string(),
619 source: "pyright config".to_string(),
620 args: vec!["--outputjson".to_string(), file_str.to_string()],
621 required: true,
622 }]
623 } else if has_project_config(project_root, &["ruff.toml", ".ruff.toml"])
624 || has_pyproject_tool(project_root, "ruff")
625 {
626 vec![ToolCandidate {
627 tool: "ruff".to_string(),
628 source: "ruff config".to_string(),
629 args: vec![
630 "check".to_string(),
631 "--output-format=json".to_string(),
632 file_str.to_string(),
633 ],
634 required: true,
635 }]
636 } else {
637 Vec::new()
638 }
639 }
640 LangId::Rust => {
641 if has_project_config(project_root, &["Cargo.toml"]) {
642 vec![ToolCandidate {
643 tool: "cargo".to_string(),
644 source: "Cargo.toml".to_string(),
645 args: vec!["check".to_string(), "--message-format=json".to_string()],
646 required: true,
647 }]
648 } else {
649 Vec::new()
650 }
651 }
652 LangId::Go => {
653 if has_project_config(project_root, &["go.mod"]) {
654 vec![
655 ToolCandidate {
656 tool: "staticcheck".to_string(),
657 source: "go.mod".to_string(),
658 args: vec![file_str.to_string()],
659 required: false,
660 },
661 ToolCandidate {
662 tool: "go".to_string(),
663 source: "go.mod".to_string(),
664 args: vec!["vet".to_string(), file_str.to_string()],
665 required: true,
666 },
667 ]
668 } else {
669 Vec::new()
670 }
671 }
672 LangId::C
673 | LangId::Cpp
674 | LangId::Zig
675 | LangId::CSharp
676 | LangId::Bash
677 | LangId::Solidity
678 | LangId::Vue
679 | LangId::Json
680 | LangId::Scala => Vec::new(),
681 LangId::Html => Vec::new(),
682 LangId::Markdown => Vec::new(),
683 }
684}
685
686fn explicit_formatter_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
687 match name {
688 "none" | "off" | "false" => Vec::new(),
689 "biome" => vec![ToolCandidate {
690 tool: name.to_string(),
691 source: "formatter config".to_string(),
692 args: vec![
693 "format".to_string(),
694 "--write".to_string(),
695 file_str.to_string(),
696 ],
697 required: true,
698 }],
699 "prettier" => vec![ToolCandidate {
700 tool: name.to_string(),
701 source: "formatter config".to_string(),
702 args: vec!["--write".to_string(), file_str.to_string()],
703 required: true,
704 }],
705 "deno" => vec![ToolCandidate {
706 tool: name.to_string(),
707 source: "formatter config".to_string(),
708 args: vec!["fmt".to_string(), file_str.to_string()],
709 required: true,
710 }],
711 "ruff" => vec![ToolCandidate {
712 tool: name.to_string(),
713 source: "formatter config".to_string(),
714 args: vec!["format".to_string(), file_str.to_string()],
715 required: true,
716 }],
717 "black" | "rustfmt" => vec![ToolCandidate {
718 tool: name.to_string(),
719 source: "formatter config".to_string(),
720 args: vec![file_str.to_string()],
721 required: true,
722 }],
723 "goimports" | "gofmt" => vec![ToolCandidate {
724 tool: name.to_string(),
725 source: "formatter config".to_string(),
726 args: vec!["-w".to_string(), file_str.to_string()],
727 required: true,
728 }],
729 _ => Vec::new(),
730 }
731}
732
733fn explicit_checker_candidate(name: &str, file_str: &str) -> Vec<ToolCandidate> {
734 match name {
735 "none" | "off" | "false" => Vec::new(),
736 "tsc" => vec![ToolCandidate {
737 tool: name.to_string(),
738 source: "checker config".to_string(),
739 args: vec![
740 "--noEmit".to_string(),
741 "--pretty".to_string(),
742 "false".to_string(),
743 ],
744 required: true,
745 }],
746 "cargo" => vec![ToolCandidate {
747 tool: name.to_string(),
748 source: "checker config".to_string(),
749 args: vec!["check".to_string(), "--message-format=json".to_string()],
750 required: true,
751 }],
752 "go" => vec![ToolCandidate {
753 tool: name.to_string(),
754 source: "checker config".to_string(),
755 args: vec!["vet".to_string(), file_str.to_string()],
756 required: true,
757 }],
758 "biome" => vec![ToolCandidate {
759 tool: name.to_string(),
760 source: "checker config".to_string(),
761 args: vec!["check".to_string(), file_str.to_string()],
762 required: true,
763 }],
764 "pyright" => vec![ToolCandidate {
765 tool: name.to_string(),
766 source: "checker config".to_string(),
767 args: vec!["--outputjson".to_string(), file_str.to_string()],
768 required: true,
769 }],
770 "ruff" => vec![ToolCandidate {
771 tool: name.to_string(),
772 source: "checker config".to_string(),
773 args: vec![
774 "check".to_string(),
775 "--output-format=json".to_string(),
776 file_str.to_string(),
777 ],
778 required: true,
779 }],
780 "staticcheck" => vec![ToolCandidate {
781 tool: name.to_string(),
782 source: "checker config".to_string(),
783 args: vec![file_str.to_string()],
784 required: true,
785 }],
786 _ => Vec::new(),
787 }
788}
789
790fn resolve_tool_candidates(
791 candidates: Vec<ToolCandidate>,
792 project_root: Option<&Path>,
793) -> ToolDetection {
794 if candidates.is_empty() {
795 return ToolDetection::NotConfigured;
796 }
797
798 let mut missing_required = None;
799 for candidate in candidates {
800 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
801 return ToolDetection::Found(command, candidate.args);
802 }
803 if candidate.required && missing_required.is_none() {
804 missing_required = Some(candidate.tool);
805 }
806 }
807
808 match missing_required {
809 Some(tool) => ToolDetection::NotInstalled { tool },
810 None => ToolDetection::NotConfigured,
811 }
812}
813
814fn checker_command(candidate: &ToolCandidate, resolved: String) -> String {
815 match candidate.tool.as_str() {
816 "tsc" => resolved,
817 "cargo" => "cargo".to_string(),
818 "go" => "go".to_string(),
819 _ => resolved,
820 }
821}
822
823fn checker_args(candidate: &ToolCandidate) -> Vec<String> {
824 if candidate.tool == "tsc" {
825 vec![
826 "--noEmit".to_string(),
827 "--pretty".to_string(),
828 "false".to_string(),
829 ]
830 } else {
831 candidate.args.clone()
832 }
833}
834
835fn detect_formatter_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
836 let file_str = path.to_string_lossy().to_string();
837 resolve_tool_candidates(
838 formatter_candidates(lang, config, &file_str),
839 config.project_root.as_deref(),
840 )
841}
842
843fn detect_checker_for_path(path: &Path, lang: LangId, config: &Config) -> ToolDetection {
844 let file_str = path.to_string_lossy().to_string();
845 let candidates = checker_candidates(lang, config, &file_str);
846 if candidates.is_empty() {
847 return ToolDetection::NotConfigured;
848 }
849
850 let project_root = config.project_root.as_deref();
851 let mut missing_required = None;
852 for candidate in candidates {
853 if let Some(command) = resolve_candidate_tool(&candidate, project_root) {
854 return ToolDetection::Found(
855 checker_command(&candidate, command),
856 checker_args(&candidate),
857 );
858 }
859 if candidate.required && missing_required.is_none() {
860 missing_required = Some(candidate.tool);
861 }
862 }
863
864 match missing_required {
865 Some(tool) => ToolDetection::NotInstalled { tool },
866 None => ToolDetection::NotConfigured,
867 }
868}
869
870fn languages_in_project(project_root: &Path) -> HashSet<LangId> {
871 crate::callgraph::walk_project_files(project_root)
872 .filter_map(|path| detect_language(&path))
873 .collect()
874}
875
876fn placeholder_file_for_language(project_root: &Path, lang: LangId) -> PathBuf {
877 let filename = match lang {
878 LangId::TypeScript => "aft-tool-detection.ts",
879 LangId::Tsx => "aft-tool-detection.tsx",
880 LangId::JavaScript => "aft-tool-detection.js",
881 LangId::Python => "aft-tool-detection.py",
882 LangId::Rust => "aft_tool_detection.rs",
883 LangId::Go => "aft_tool_detection.go",
884 LangId::C => "aft_tool_detection.c",
885 LangId::Cpp => "aft_tool_detection.cpp",
886 LangId::Zig => "aft_tool_detection.zig",
887 LangId::CSharp => "aft_tool_detection.cs",
888 LangId::Bash => "aft_tool_detection.sh",
889 LangId::Solidity => "aft_tool_detection.sol",
890 LangId::Vue => "aft-tool-detection.vue",
891 LangId::Json => "aft-tool-detection.json",
892 LangId::Scala => "aft-tool-detection.scala",
893 LangId::Html => "aft-tool-detection.html",
894 LangId::Markdown => "aft-tool-detection.md",
895 };
896 project_root.join(filename)
897}
898
899pub(crate) fn install_hint(tool: &str) -> String {
900 match tool {
901 "biome" => {
902 "Run `bun add -d --workspace-root @biomejs/biome` or install globally.".to_string()
903 }
904 "prettier" => "Run `npm install -D prettier` or install globally.".to_string(),
905 "tsc" => "Run `npm install -D typescript` or install globally.".to_string(),
906 "pyright" | "pyright-langserver" => "Install: `npm install -g pyright`".to_string(),
907 "ruff" => {
908 "Install: `pip install ruff` or your Python package manager equivalent.".to_string()
909 }
910 "black" => {
911 "Install: `pip install black` or your Python package manager equivalent.".to_string()
912 }
913 "rustfmt" => "Install: `rustup component add rustfmt`".to_string(),
914 "rust-analyzer" => "Install: `rustup component add rust-analyzer`".to_string(),
915 "cargo" => "Install Rust from https://rustup.rs/.".to_string(),
916 "go" => "Install Go from https://go.dev/dl/.".to_string(),
917 "gopls" => "Install: `go install golang.org/x/tools/gopls@latest`".to_string(),
918 "bash-language-server" => "Install: `npm install -g bash-language-server`".to_string(),
919 "yaml-language-server" => "Install: `npm install -g yaml-language-server`".to_string(),
920 "typescript-language-server" => {
921 "Install: `npm install -g typescript-language-server typescript`".to_string()
922 }
923 "deno" => "Install Deno from https://deno.com/.".to_string(),
924 "goimports" => "Install: `go install golang.org/x/tools/cmd/goimports@latest`".to_string(),
925 "staticcheck" => {
926 "Install: `go install honnef.co/go/tools/cmd/staticcheck@latest`".to_string()
927 }
928 other => format!("Install `{other}` and ensure it is on PATH."),
929 }
930}
931
932fn configured_tool_hint(tool: &str, source: &str) -> String {
933 format!(
934 "{tool} is configured in {source} but not installed. {}",
935 install_hint(tool)
936 )
937}
938
939fn missing_tool_warning(
940 kind: &str,
941 language: &str,
942 candidate: &ToolCandidate,
943 project_root: Option<&Path>,
944) -> Option<MissingTool> {
945 if !candidate.required || resolve_candidate_tool(candidate, project_root).is_some() {
946 return None;
947 }
948
949 Some(MissingTool {
950 kind: kind.to_string(),
951 language: language.to_string(),
952 tool: candidate.tool.clone(),
953 hint: configured_tool_hint(&candidate.tool, &candidate.source),
954 })
955}
956
957pub fn detect_missing_tools(project_root: &Path, config: &Config) -> Vec<MissingTool> {
959 let languages = languages_in_project(project_root);
960 let mut warnings = Vec::new();
961 let mut seen = HashSet::new();
962
963 for lang in languages {
964 let language = lang_key(lang);
965 let placeholder = placeholder_file_for_language(project_root, lang);
966 let file_str = placeholder.to_string_lossy().to_string();
967
968 for candidate in formatter_candidates(lang, config, &file_str) {
969 if let Some(warning) = missing_tool_warning(
970 "formatter_not_installed",
971 language,
972 &candidate,
973 config.project_root.as_deref(),
974 ) {
975 if seen.insert((
976 warning.kind.clone(),
977 warning.language.clone(),
978 warning.tool.clone(),
979 )) {
980 warnings.push(warning);
981 }
982 }
983 }
984
985 for candidate in checker_candidates(lang, config, &file_str) {
986 if let Some(warning) = missing_tool_warning(
987 "checker_not_installed",
988 language,
989 &candidate,
990 config.project_root.as_deref(),
991 ) {
992 if seen.insert((
993 warning.kind.clone(),
994 warning.language.clone(),
995 warning.tool.clone(),
996 )) {
997 warnings.push(warning);
998 }
999 }
1000 }
1001 }
1002
1003 warnings.sort_by(|left, right| {
1004 (&left.kind, &left.language, &left.tool).cmp(&(&right.kind, &right.language, &right.tool))
1005 });
1006 warnings
1007}
1008
1009pub fn detect_formatter(
1019 path: &Path,
1020 lang: LangId,
1021 config: &Config,
1022) -> Option<(String, Vec<String>)> {
1023 match detect_formatter_for_path(path, lang, config) {
1024 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1025 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1026 }
1027}
1028
1029fn has_project_config(project_root: Option<&Path>, filenames: &[&str]) -> bool {
1031 let root = match project_root {
1032 Some(r) => r,
1033 None => return false,
1034 };
1035 filenames.iter().any(|f| root.join(f).exists())
1036}
1037
1038fn has_pyproject_tool(project_root: Option<&Path>, tool_name: &str) -> bool {
1040 let root = match project_root {
1041 Some(r) => r,
1042 None => return false,
1043 };
1044 let pyproject = root.join("pyproject.toml");
1045 if !pyproject.exists() {
1046 return false;
1047 }
1048 match std::fs::read_to_string(&pyproject) {
1049 Ok(content) => {
1050 let pattern = format!("[tool.{}]", tool_name);
1051 content.contains(&pattern)
1052 }
1053 Err(_) => false,
1054 }
1055}
1056
1057fn formatter_excluded_path(stderr: &str) -> bool {
1076 let s = stderr.to_lowercase();
1077 s.contains("no files were processed")
1078 || s.contains("ignored by the configuration")
1079 || s.contains("no files matching the pattern")
1080 || s.contains("no python files found")
1081}
1082
1083pub fn auto_format(path: &Path, config: &Config) -> (bool, Option<String>) {
1104 if !config.format_on_edit {
1106 return (false, Some("no_formatter_configured".to_string()));
1107 }
1108
1109 let lang = match detect_language(path) {
1110 Some(l) => l,
1111 None => {
1112 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1113 return (false, Some("unsupported_language".to_string()));
1114 }
1115 };
1116 if !has_formatter_support(lang) {
1117 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1118 return (false, Some("unsupported_language".to_string()));
1119 }
1120
1121 let (cmd, args) = match detect_formatter_for_path(path, lang, config) {
1122 ToolDetection::Found(cmd, args) => (cmd, args),
1123 ToolDetection::NotConfigured => {
1124 log::debug!(
1125 "format: {} (skipped: no_formatter_configured)",
1126 path.display()
1127 );
1128 return (false, Some("no_formatter_configured".to_string()));
1129 }
1130 ToolDetection::NotInstalled { tool } => {
1131 crate::slog_warn!(
1132 "format: {} (skipped: formatter_not_installed: {})",
1133 path.display(),
1134 tool
1135 );
1136 return (false, Some("formatter_not_installed".to_string()));
1137 }
1138 };
1139
1140 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1141
1142 let working_dir = config.project_root.as_deref();
1149
1150 match run_external_tool(&cmd, &arg_refs, working_dir, config.formatter_timeout_secs) {
1151 Ok(_) => {
1152 crate::slog_info!("format: {} ({})", path.display(), cmd);
1153 (true, None)
1154 }
1155 Err(FormatError::Timeout { .. }) => {
1156 crate::slog_warn!("format: {} (skipped: timeout)", path.display());
1157 (false, Some("timeout".to_string()))
1158 }
1159 Err(FormatError::NotFound { .. }) => {
1160 crate::slog_warn!(
1161 "format: {} (skipped: formatter_not_installed)",
1162 path.display()
1163 );
1164 (false, Some("formatter_not_installed".to_string()))
1165 }
1166 Err(FormatError::Failed { stderr, .. }) => {
1167 if formatter_excluded_path(&stderr) {
1179 crate::slog_info!(
1180 "format: {} (skipped: formatter_excluded_path; stderr: {})",
1181 path.display(),
1182 stderr.lines().next().unwrap_or("").trim()
1183 );
1184 return (false, Some("formatter_excluded_path".to_string()));
1185 }
1186 crate::slog_warn!(
1187 "format: {} (skipped: error: {})",
1188 path.display(),
1189 stderr.lines().next().unwrap_or("unknown").trim()
1190 );
1191 (false, Some("error".to_string()))
1192 }
1193 Err(FormatError::UnsupportedLanguage) => {
1194 log::debug!("format: {} (skipped: unsupported_language)", path.display());
1195 (false, Some("unsupported_language".to_string()))
1196 }
1197 }
1198}
1199
1200pub fn run_external_tool_capture(
1207 command: &str,
1208 args: &[&str],
1209 working_dir: Option<&Path>,
1210 timeout_secs: u32,
1211) -> Result<ExternalToolResult, FormatError> {
1212 let mut cmd = Command::new(command);
1213 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
1214
1215 if let Some(dir) = working_dir {
1216 cmd.current_dir(dir);
1217 }
1218
1219 isolate_in_process_group(&mut cmd);
1220
1221 let child = match cmd.spawn() {
1222 Ok(c) => c,
1223 Err(e) if e.kind() == ErrorKind::NotFound => {
1224 return Err(FormatError::NotFound {
1225 tool: command.to_string(),
1226 });
1227 }
1228 Err(e) => {
1229 return Err(FormatError::Failed {
1230 tool: command.to_string(),
1231 stderr: e.to_string(),
1232 });
1233 }
1234 };
1235
1236 let outcome = wait_with_timeout(child, command, timeout_secs)?;
1237 Ok(ExternalToolResult {
1238 stdout: outcome.stdout,
1239 stderr: outcome.stderr,
1240 exit_code: outcome.status.code().unwrap_or(-1),
1241 })
1242}
1243
1244#[derive(Debug, Clone, serde::Serialize)]
1250pub struct ValidationError {
1251 pub line: u32,
1252 pub column: u32,
1253 pub message: String,
1254 pub severity: String,
1255}
1256
1257pub fn detect_type_checker(
1268 path: &Path,
1269 lang: LangId,
1270 config: &Config,
1271) -> Option<(String, Vec<String>)> {
1272 match detect_checker_for_path(path, lang, config) {
1273 ToolDetection::Found(cmd, args) => Some((cmd, args)),
1274 ToolDetection::NotConfigured | ToolDetection::NotInstalled { .. } => None,
1275 }
1276}
1277
1278pub fn parse_checker_output(
1283 stdout: &str,
1284 stderr: &str,
1285 file: &Path,
1286 checker: &str,
1287) -> Vec<ValidationError> {
1288 let checker_name = Path::new(checker)
1289 .file_name()
1290 .and_then(|name| name.to_str())
1291 .unwrap_or(checker);
1292 match checker_name {
1293 "npx" | "tsc" => parse_tsc_output(stdout, stderr, file),
1294 "pyright" => parse_pyright_output(stdout, file),
1295 "cargo" => parse_cargo_output(stdout, stderr, file),
1296 "go" => parse_go_vet_output(stderr, file),
1297 _ => Vec::new(),
1298 }
1299}
1300
1301fn parse_tsc_output(stdout: &str, stderr: &str, file: &Path) -> Vec<ValidationError> {
1303 let mut errors = Vec::new();
1304 let file_str = file.to_string_lossy();
1305 let combined = format!("{}{}", stdout, stderr);
1307 for line in combined.lines() {
1308 if let Some((loc, rest)) = line.split_once("): ") {
1311 let file_part = loc.split('(').next().unwrap_or("");
1313 if !file_str.ends_with(file_part)
1314 && !file_part.ends_with(&*file_str)
1315 && file_part != &*file_str
1316 {
1317 continue;
1318 }
1319
1320 let coords = loc.split('(').last().unwrap_or("");
1322 let parts: Vec<&str> = coords.split(',').collect();
1323 let line_num: u32 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
1324 let col_num: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
1325
1326 let (severity, message) = if let Some(msg) = rest.strip_prefix("error ") {
1328 ("error".to_string(), msg.to_string())
1329 } else if let Some(msg) = rest.strip_prefix("warning ") {
1330 ("warning".to_string(), msg.to_string())
1331 } else {
1332 ("error".to_string(), rest.to_string())
1333 };
1334
1335 errors.push(ValidationError {
1336 line: line_num,
1337 column: col_num,
1338 message,
1339 severity,
1340 });
1341 }
1342 }
1343 errors
1344}
1345
1346fn parse_pyright_output(stdout: &str, file: &Path) -> Vec<ValidationError> {
1348 let mut errors = Vec::new();
1349 let file_str = file.to_string_lossy();
1350
1351 if let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout) {
1353 if let Some(diags) = json.get("generalDiagnostics").and_then(|d| d.as_array()) {
1354 for diag in diags {
1355 let diag_file = diag.get("file").and_then(|f| f.as_str()).unwrap_or("");
1357 if !diag_file.is_empty()
1358 && !file_str.ends_with(diag_file)
1359 && !diag_file.ends_with(&*file_str)
1360 && diag_file != &*file_str
1361 {
1362 continue;
1363 }
1364
1365 let line_num = diag
1366 .get("range")
1367 .and_then(|r| r.get("start"))
1368 .and_then(|s| s.get("line"))
1369 .and_then(|l| l.as_u64())
1370 .unwrap_or(0) as u32;
1371 let col_num = diag
1372 .get("range")
1373 .and_then(|r| r.get("start"))
1374 .and_then(|s| s.get("character"))
1375 .and_then(|c| c.as_u64())
1376 .unwrap_or(0) as u32;
1377 let message = diag
1378 .get("message")
1379 .and_then(|m| m.as_str())
1380 .unwrap_or("unknown error")
1381 .to_string();
1382 let severity = diag
1383 .get("severity")
1384 .and_then(|s| s.as_str())
1385 .unwrap_or("error")
1386 .to_lowercase();
1387
1388 errors.push(ValidationError {
1389 line: line_num + 1, column: col_num,
1391 message,
1392 severity,
1393 });
1394 }
1395 }
1396 }
1397 errors
1398}
1399
1400fn parse_cargo_output(stdout: &str, _stderr: &str, file: &Path) -> Vec<ValidationError> {
1402 let mut errors = Vec::new();
1403 let file_str = file.to_string_lossy();
1404
1405 for line in stdout.lines() {
1406 if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line) {
1407 if msg.get("reason").and_then(|r| r.as_str()) != Some("compiler-message") {
1408 continue;
1409 }
1410 let message_obj = match msg.get("message") {
1411 Some(m) => m,
1412 None => continue,
1413 };
1414
1415 let level = message_obj
1416 .get("level")
1417 .and_then(|l| l.as_str())
1418 .unwrap_or("error");
1419
1420 if level != "error" && level != "warning" {
1422 continue;
1423 }
1424
1425 let text = message_obj
1426 .get("message")
1427 .and_then(|m| m.as_str())
1428 .unwrap_or("unknown error")
1429 .to_string();
1430
1431 if let Some(spans) = message_obj.get("spans").and_then(|s| s.as_array()) {
1433 for span in spans {
1434 let span_file = span.get("file_name").and_then(|f| f.as_str()).unwrap_or("");
1435 let is_primary = span
1436 .get("is_primary")
1437 .and_then(|p| p.as_bool())
1438 .unwrap_or(false);
1439
1440 if !is_primary {
1441 continue;
1442 }
1443
1444 if !file_str.ends_with(span_file)
1446 && !span_file.ends_with(&*file_str)
1447 && span_file != &*file_str
1448 {
1449 continue;
1450 }
1451
1452 let line_num =
1453 span.get("line_start").and_then(|l| l.as_u64()).unwrap_or(0) as u32;
1454 let col_num = span
1455 .get("column_start")
1456 .and_then(|c| c.as_u64())
1457 .unwrap_or(0) as u32;
1458
1459 errors.push(ValidationError {
1460 line: line_num,
1461 column: col_num,
1462 message: text.clone(),
1463 severity: level.to_string(),
1464 });
1465 }
1466 }
1467 }
1468 }
1469 errors
1470}
1471
1472fn parse_go_vet_output(stderr: &str, file: &Path) -> Vec<ValidationError> {
1474 let mut errors = Vec::new();
1475 let file_str = file.to_string_lossy();
1476
1477 for line in stderr.lines() {
1478 let parts: Vec<&str> = line.splitn(4, ':').collect();
1480 if parts.len() < 3 {
1481 continue;
1482 }
1483
1484 let err_file = parts[0].trim();
1485 if !file_str.ends_with(err_file)
1486 && !err_file.ends_with(&*file_str)
1487 && err_file != &*file_str
1488 {
1489 continue;
1490 }
1491
1492 let line_num: u32 = parts[1].trim().parse().unwrap_or(0);
1493 let (col_num, message) = if parts.len() >= 4 {
1494 if let Ok(col) = parts[2].trim().parse::<u32>() {
1495 (col, parts[3].trim().to_string())
1496 } else {
1497 (0, format!("{}:{}", parts[2].trim(), parts[3].trim()))
1499 }
1500 } else {
1501 (0, parts[2].trim().to_string())
1502 };
1503
1504 errors.push(ValidationError {
1505 line: line_num,
1506 column: col_num,
1507 message,
1508 severity: "error".to_string(),
1509 });
1510 }
1511 errors
1512}
1513
1514pub fn validate_full(path: &Path, config: &Config) -> (Vec<ValidationError>, Option<String>) {
1523 let lang = match detect_language(path) {
1524 Some(l) => l,
1525 None => {
1526 log::debug!(
1527 "validate: {} (skipped: unsupported_language)",
1528 path.display()
1529 );
1530 return (Vec::new(), Some("unsupported_language".to_string()));
1531 }
1532 };
1533 if !has_checker_support(lang) {
1534 log::debug!(
1535 "validate: {} (skipped: unsupported_language)",
1536 path.display()
1537 );
1538 return (Vec::new(), Some("unsupported_language".to_string()));
1539 }
1540
1541 let (cmd, args) = match detect_checker_for_path(path, lang, config) {
1542 ToolDetection::Found(cmd, args) => (cmd, args),
1543 ToolDetection::NotConfigured => {
1544 log::debug!(
1545 "validate: {} (skipped: no_checker_configured)",
1546 path.display()
1547 );
1548 return (Vec::new(), Some("no_checker_configured".to_string()));
1549 }
1550 ToolDetection::NotInstalled { tool } => {
1551 crate::slog_warn!(
1552 "validate: {} (skipped: checker_not_installed: {})",
1553 path.display(),
1554 tool
1555 );
1556 return (Vec::new(), Some("checker_not_installed".to_string()));
1557 }
1558 };
1559
1560 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1561
1562 let working_dir = config.project_root.as_deref();
1564
1565 match run_external_tool_capture(
1566 &cmd,
1567 &arg_refs,
1568 working_dir,
1569 config.type_checker_timeout_secs,
1570 ) {
1571 Ok(result) => {
1572 let errors = parse_checker_output(&result.stdout, &result.stderr, path, &cmd);
1573 log::debug!(
1574 "validate: {} ({}, {} errors)",
1575 path.display(),
1576 cmd,
1577 errors.len()
1578 );
1579 (errors, None)
1580 }
1581 Err(FormatError::Timeout { .. }) => {
1582 crate::slog_error!("validate: {} (skipped: timeout)", path.display());
1583 (Vec::new(), Some("timeout".to_string()))
1584 }
1585 Err(FormatError::NotFound { .. }) => {
1586 crate::slog_warn!(
1587 "validate: {} (skipped: checker_not_installed)",
1588 path.display()
1589 );
1590 (Vec::new(), Some("checker_not_installed".to_string()))
1591 }
1592 Err(FormatError::Failed { stderr, .. }) => {
1593 log::debug!(
1594 "validate: {} (skipped: error: {})",
1595 path.display(),
1596 stderr.lines().next().unwrap_or("unknown")
1597 );
1598 (Vec::new(), Some("error".to_string()))
1599 }
1600 Err(FormatError::UnsupportedLanguage) => {
1601 log::debug!(
1602 "validate: {} (skipped: unsupported_language)",
1603 path.display()
1604 );
1605 (Vec::new(), Some("unsupported_language".to_string()))
1606 }
1607 }
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612 use super::*;
1613 use std::fs;
1614 use std::io::Write;
1615 use std::sync::{Mutex, MutexGuard, OnceLock};
1616
1617 fn tool_cache_test_lock() -> MutexGuard<'static, ()> {
1624 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1625 let mutex = LOCK.get_or_init(|| Mutex::new(()));
1626 match mutex.lock() {
1629 Ok(guard) => guard,
1630 Err(poisoned) => poisoned.into_inner(),
1631 }
1632 }
1633
1634 #[test]
1635 fn run_external_tool_not_found() {
1636 let result = run_external_tool("__nonexistent_tool_xyz__", &[], None, 5);
1637 assert!(result.is_err());
1638 match result.unwrap_err() {
1639 FormatError::NotFound { tool } => {
1640 assert_eq!(tool, "__nonexistent_tool_xyz__");
1641 }
1642 other => panic!("expected NotFound, got: {:?}", other),
1643 }
1644 }
1645
1646 #[test]
1647 fn run_external_tool_timeout_kills_subprocess() {
1648 let result = run_external_tool("sleep", &["60"], None, 1);
1650 assert!(result.is_err());
1651 match result.unwrap_err() {
1652 FormatError::Timeout { tool, timeout_secs } => {
1653 assert_eq!(tool, "sleep");
1654 assert_eq!(timeout_secs, 1);
1655 }
1656 other => panic!("expected Timeout, got: {:?}", other),
1657 }
1658 }
1659
1660 #[test]
1661 fn run_external_tool_success() {
1662 let result = run_external_tool("echo", &["hello"], None, 5);
1663 assert!(result.is_ok());
1664 let res = result.unwrap();
1665 assert_eq!(res.exit_code, 0);
1666 assert!(res.stdout.contains("hello"));
1667 }
1668
1669 #[cfg(unix)]
1670 #[test]
1671 fn format_helper_handles_large_stderr_without_deadlock() {
1672 let start = Instant::now();
1673 let result = run_external_tool_capture(
1674 "sh",
1675 &[
1676 "-c",
1677 "i=0; while [ $i -lt 1024 ]; do printf '%1024s\\n' x >&2; i=$((i+1)); done",
1678 ],
1679 None,
1680 2,
1681 )
1682 .expect("large stderr command should complete");
1683
1684 assert_eq!(result.exit_code, 0);
1685 assert!(
1686 result.stderr.len() >= 1024 * 1024,
1687 "expected full stderr capture, got {} bytes",
1688 result.stderr.len()
1689 );
1690 assert!(start.elapsed() < Duration::from_secs(2));
1691 }
1692
1693 #[test]
1694 fn run_external_tool_nonzero_exit() {
1695 let result = run_external_tool("false", &[], None, 5);
1697 assert!(result.is_err());
1698 match result.unwrap_err() {
1699 FormatError::Failed { tool, .. } => {
1700 assert_eq!(tool, "false");
1701 }
1702 other => panic!("expected Failed, got: {:?}", other),
1703 }
1704 }
1705
1706 #[test]
1707 fn auto_format_unsupported_language() {
1708 let dir = tempfile::tempdir().unwrap();
1709 let path = dir.path().join("file.txt");
1710 fs::write(&path, "hello").unwrap();
1711
1712 let config = Config::default();
1713 let (formatted, reason) = auto_format(&path, &config);
1714 assert!(!formatted);
1715 assert_eq!(reason.as_deref(), Some("unsupported_language"));
1716 }
1717
1718 #[test]
1719 fn detect_formatter_rust_when_rustfmt_available() {
1720 let dir = tempfile::tempdir().unwrap();
1721 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1722 let path = dir.path().join("test.rs");
1723 let config = Config {
1724 project_root: Some(dir.path().to_path_buf()),
1725 ..Config::default()
1726 };
1727 let result = detect_formatter(&path, LangId::Rust, &config);
1728 if resolve_tool("rustfmt", config.project_root.as_deref()).is_some() {
1729 let (cmd, args) = result.unwrap();
1730 assert_eq!(cmd, "rustfmt");
1731 assert!(args.iter().any(|a| a.ends_with("test.rs")));
1732 } else {
1733 assert!(result.is_none());
1734 }
1735 }
1736
1737 #[test]
1738 fn detect_formatter_go_mapping() {
1739 let dir = tempfile::tempdir().unwrap();
1740 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
1741 let path = dir.path().join("main.go");
1742 let config = Config {
1743 project_root: Some(dir.path().to_path_buf()),
1744 ..Config::default()
1745 };
1746 let result = detect_formatter(&path, LangId::Go, &config);
1747 if resolve_tool("goimports", config.project_root.as_deref()).is_some() {
1748 let (cmd, args) = result.unwrap();
1749 assert_eq!(cmd, "goimports");
1750 assert!(args.contains(&"-w".to_string()));
1751 } else if resolve_tool("gofmt", config.project_root.as_deref()).is_some() {
1752 let (cmd, args) = result.unwrap();
1753 assert_eq!(cmd, "gofmt");
1754 assert!(args.contains(&"-w".to_string()));
1755 } else {
1756 assert!(result.is_none());
1757 }
1758 }
1759
1760 #[test]
1761 fn detect_formatter_python_mapping() {
1762 let dir = tempfile::tempdir().unwrap();
1763 fs::write(dir.path().join("ruff.toml"), "").unwrap();
1764 let path = dir.path().join("main.py");
1765 let config = Config {
1766 project_root: Some(dir.path().to_path_buf()),
1767 ..Config::default()
1768 };
1769 let result = detect_formatter(&path, LangId::Python, &config);
1770 if ruff_format_available(config.project_root.as_deref()) {
1771 let (cmd, args) = result.unwrap();
1772 assert_eq!(cmd, "ruff");
1773 assert!(args.contains(&"format".to_string()));
1774 } else {
1775 assert!(result.is_none());
1776 }
1777 }
1778
1779 #[test]
1780 fn detect_formatter_no_config_returns_none() {
1781 let path = Path::new("test.ts");
1782 let result = detect_formatter(path, LangId::TypeScript, &Config::default());
1783 assert!(
1784 result.is_none(),
1785 "expected no formatter without project config"
1786 );
1787 }
1788
1789 #[cfg(unix)]
1795 #[test]
1796 fn detect_formatter_explicit_override() {
1797 let dir = tempfile::tempdir().unwrap();
1799 let bin_dir = dir.path().join("node_modules").join(".bin");
1800 fs::create_dir_all(&bin_dir).unwrap();
1801 use std::os::unix::fs::PermissionsExt;
1802 let fake = bin_dir.join("biome");
1803 fs::write(&fake, "#!/bin/sh\necho 1.0.0").unwrap();
1804 fs::set_permissions(&fake, fs::Permissions::from_mode(0o755)).unwrap();
1805
1806 let path = Path::new("test.ts");
1807 let mut config = Config {
1808 project_root: Some(dir.path().to_path_buf()),
1809 ..Config::default()
1810 };
1811 config
1812 .formatter
1813 .insert("typescript".to_string(), "biome".to_string());
1814 let result = detect_formatter(path, LangId::TypeScript, &config);
1815 let (cmd, args) = result.unwrap();
1816 assert!(cmd.contains("biome"), "expected biome in cmd, got: {}", cmd);
1817 assert!(args.contains(&"format".to_string()));
1818 assert!(args.contains(&"--write".to_string()));
1819 }
1820
1821 #[test]
1822 fn resolve_tool_caches_positive_result_until_clear() {
1823 let _guard = tool_cache_test_lock();
1824 clear_tool_cache();
1825 let dir = tempfile::tempdir().unwrap();
1826 let bin_dir = dir.path().join("node_modules").join(".bin");
1827 fs::create_dir_all(&bin_dir).unwrap();
1828 let tool = bin_dir.join("aft-cache-hit-tool");
1829 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1830
1831 let first = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1832 assert_eq!(first.as_deref(), Some(tool.to_string_lossy().as_ref()));
1833
1834 fs::remove_file(&tool).unwrap();
1835 let cached = resolve_tool("aft-cache-hit-tool", Some(dir.path()));
1836 assert_eq!(cached, first);
1837
1838 clear_tool_cache();
1839 assert!(resolve_tool("aft-cache-hit-tool", Some(dir.path())).is_none());
1840 }
1841
1842 #[test]
1843 fn resolve_tool_caches_negative_result_until_clear() {
1844 let _guard = tool_cache_test_lock();
1845 clear_tool_cache();
1846 let dir = tempfile::tempdir().unwrap();
1847 let bin_dir = dir.path().join("node_modules").join(".bin");
1848 let tool = bin_dir.join("aft-cache-miss-tool");
1849
1850 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1851
1852 fs::create_dir_all(&bin_dir).unwrap();
1853 fs::write(&tool, "#!/bin/sh\necho cached").unwrap();
1854 assert!(resolve_tool("aft-cache-miss-tool", Some(dir.path())).is_none());
1855
1856 clear_tool_cache();
1857 assert_eq!(
1858 resolve_tool("aft-cache-miss-tool", Some(dir.path())).as_deref(),
1859 Some(tool.to_string_lossy().as_ref())
1860 );
1861 }
1862
1863 #[test]
1864 fn auto_format_happy_path_rustfmt() {
1865 if resolve_tool("rustfmt", None).is_none() {
1866 crate::slog_warn!("skipping: rustfmt not available");
1867 return;
1868 }
1869
1870 let dir = tempfile::tempdir().unwrap();
1871 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
1872 let path = dir.path().join("test.rs");
1873
1874 let mut f = fs::File::create(&path).unwrap();
1875 writeln!(f, "fn main() {{ println!(\"hello\"); }}").unwrap();
1876 drop(f);
1877
1878 let config = Config {
1879 project_root: Some(dir.path().to_path_buf()),
1880 ..Config::default()
1881 };
1882 let (formatted, reason) = auto_format(&path, &config);
1883 assert!(formatted, "expected formatting to succeed");
1884 assert!(reason.is_none());
1885
1886 let content = fs::read_to_string(&path).unwrap();
1887 assert!(
1888 !content.contains("fn main"),
1889 "expected rustfmt to fix spacing"
1890 );
1891 }
1892
1893 #[test]
1894 fn formatter_excluded_path_detects_biome_messages() {
1895 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";
1897 assert!(
1898 formatter_excluded_path(stderr),
1899 "expected biome exclusion stderr to be detected"
1900 );
1901 }
1902
1903 #[test]
1904 fn formatter_excluded_path_detects_prettier_messages() {
1905 let stderr = "[error] No files matching the pattern were found: \"src/scratch.ts\".\n";
1908 assert!(
1909 formatter_excluded_path(stderr),
1910 "expected prettier exclusion stderr to be detected"
1911 );
1912 }
1913
1914 #[test]
1915 fn formatter_excluded_path_detects_ruff_messages() {
1916 let stderr = "warning: No Python files found under the given path(s).\n";
1918 assert!(
1919 formatter_excluded_path(stderr),
1920 "expected ruff exclusion stderr to be detected"
1921 );
1922 }
1923
1924 #[test]
1925 fn formatter_excluded_path_is_case_insensitive() {
1926 assert!(formatter_excluded_path("NO FILES WERE PROCESSED"));
1927 assert!(formatter_excluded_path("Ignored By The Configuration"));
1928 }
1929
1930 #[test]
1931 fn formatter_excluded_path_rejects_real_errors() {
1932 assert!(!formatter_excluded_path(""));
1935 assert!(!formatter_excluded_path("syntax error: unexpected token"));
1936 assert!(!formatter_excluded_path("formatter crashed: out of memory"));
1937 assert!(!formatter_excluded_path(
1938 "permission denied: /readonly/file"
1939 ));
1940 assert!(!formatter_excluded_path(
1941 "biome internal error: please report"
1942 ));
1943 }
1944
1945 #[test]
1946 fn parse_tsc_output_basic() {
1947 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";
1948 let file = Path::new("src/app.ts");
1949 let errors = parse_tsc_output(stdout, "", file);
1950 assert_eq!(errors.len(), 2);
1951 assert_eq!(errors[0].line, 10);
1952 assert_eq!(errors[0].column, 5);
1953 assert_eq!(errors[0].severity, "error");
1954 assert!(errors[0].message.contains("TS2322"));
1955 assert_eq!(errors[1].line, 20);
1956 }
1957
1958 #[test]
1959 fn parse_tsc_output_filters_other_files() {
1960 let stdout =
1961 "other.ts(1,1): error TS2322: wrong file\nsrc/app.ts(5,3): error TS1234: our file\n";
1962 let file = Path::new("src/app.ts");
1963 let errors = parse_tsc_output(stdout, "", file);
1964 assert_eq!(errors.len(), 1);
1965 assert_eq!(errors[0].line, 5);
1966 }
1967
1968 #[test]
1969 fn parse_cargo_output_basic() {
1970 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}]}}"#;
1971 let file = Path::new("src/main.rs");
1972 let errors = parse_cargo_output(json_line, "", file);
1973 assert_eq!(errors.len(), 1);
1974 assert_eq!(errors[0].line, 10);
1975 assert_eq!(errors[0].column, 5);
1976 assert_eq!(errors[0].severity, "error");
1977 assert!(errors[0].message.contains("mismatched types"));
1978 }
1979
1980 #[test]
1981 fn parse_cargo_output_skips_notes() {
1982 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}]}}"#;
1984 let file = Path::new("src/main.rs");
1985 let errors = parse_cargo_output(json_line, "", file);
1986 assert_eq!(errors.len(), 0);
1987 }
1988
1989 #[test]
1990 fn parse_cargo_output_filters_other_files() {
1991 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}]}}"#;
1992 let file = Path::new("src/main.rs");
1993 let errors = parse_cargo_output(json_line, "", file);
1994 assert_eq!(errors.len(), 0);
1995 }
1996
1997 #[test]
1998 fn parse_go_vet_output_basic() {
1999 let stderr = "main.go:10:5: unreachable code\nmain.go:20: another issue\n";
2000 let file = Path::new("main.go");
2001 let errors = parse_go_vet_output(stderr, file);
2002 assert_eq!(errors.len(), 2);
2003 assert_eq!(errors[0].line, 10);
2004 assert_eq!(errors[0].column, 5);
2005 assert!(errors[0].message.contains("unreachable code"));
2006 assert_eq!(errors[1].line, 20);
2007 assert_eq!(errors[1].column, 0);
2008 }
2009
2010 #[test]
2011 fn parse_pyright_output_basic() {
2012 let stdout = r#"{"generalDiagnostics":[{"file":"test.py","range":{"start":{"line":4,"character":10}},"message":"Type error here","severity":"error"}]}"#;
2013 let file = Path::new("test.py");
2014 let errors = parse_pyright_output(stdout, file);
2015 assert_eq!(errors.len(), 1);
2016 assert_eq!(errors[0].line, 5); assert_eq!(errors[0].column, 10);
2018 assert_eq!(errors[0].severity, "error");
2019 assert!(errors[0].message.contains("Type error here"));
2020 }
2021
2022 #[test]
2023 fn validate_full_unsupported_language() {
2024 let dir = tempfile::tempdir().unwrap();
2025 let path = dir.path().join("file.txt");
2026 fs::write(&path, "hello").unwrap();
2027
2028 let config = Config::default();
2029 let (errors, reason) = validate_full(&path, &config);
2030 assert!(errors.is_empty());
2031 assert_eq!(reason.as_deref(), Some("unsupported_language"));
2032 }
2033
2034 #[test]
2035 fn detect_type_checker_rust() {
2036 let dir = tempfile::tempdir().unwrap();
2037 fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"test\"").unwrap();
2038 let path = dir.path().join("src/main.rs");
2039 let config = Config {
2040 project_root: Some(dir.path().to_path_buf()),
2041 ..Config::default()
2042 };
2043 let result = detect_type_checker(&path, LangId::Rust, &config);
2044 if resolve_tool("cargo", config.project_root.as_deref()).is_some() {
2045 let (cmd, args) = result.unwrap();
2046 assert_eq!(cmd, "cargo");
2047 assert!(args.contains(&"check".to_string()));
2048 } else {
2049 assert!(result.is_none());
2050 }
2051 }
2052
2053 #[test]
2054 fn detect_type_checker_go() {
2055 let dir = tempfile::tempdir().unwrap();
2056 fs::write(dir.path().join("go.mod"), "module test\ngo 1.21").unwrap();
2057 let path = dir.path().join("main.go");
2058 let config = Config {
2059 project_root: Some(dir.path().to_path_buf()),
2060 ..Config::default()
2061 };
2062 let result = detect_type_checker(&path, LangId::Go, &config);
2063 if resolve_tool("go", config.project_root.as_deref()).is_some() {
2064 let (cmd, _args) = result.unwrap();
2065 assert!(cmd == "go" || cmd == "staticcheck");
2067 } else {
2068 assert!(result.is_none());
2069 }
2070 }
2071 #[test]
2072 fn run_external_tool_capture_nonzero_not_error() {
2073 let result = run_external_tool_capture("false", &[], None, 5);
2075 assert!(result.is_ok(), "capture should not error on non-zero exit");
2076 assert_eq!(result.unwrap().exit_code, 1);
2077 }
2078
2079 #[test]
2080 fn run_external_tool_capture_not_found() {
2081 let result = run_external_tool_capture("__nonexistent_xyz__", &[], None, 5);
2082 assert!(result.is_err());
2083 match result.unwrap_err() {
2084 FormatError::NotFound { tool } => assert_eq!(tool, "__nonexistent_xyz__"),
2085 other => panic!("expected NotFound, got: {:?}", other),
2086 }
2087 }
2088}