use std::collections::{HashMap, HashSet};
use crate::config::FormatterConfig;
use crate::external_tools_common::{
find_missing_commands, log_warning_once, missing_commands_warning_message,
};
#[derive(Debug)]
pub enum FormatterError {
SpawnFailed(String),
NonZeroExit { code: i32, stderr: String },
Timeout,
IoError(std::io::Error),
}
impl std::fmt::Display for FormatterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SpawnFailed(cmd) => write!(f, "failed to spawn formatter: {}", cmd),
Self::NonZeroExit { code, stderr } => {
write!(f, "formatter exited with code {}: {}", code, stderr)
}
Self::Timeout => write!(f, "formatter timed out"),
Self::IoError(e) => write!(f, "formatter I/O error: {}", e),
}
}
}
impl std::error::Error for FormatterError {}
impl From<std::io::Error> for FormatterError {
fn from(e: std::io::Error) -> Self {
Self::IoError(e)
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn find_missing_formatter_commands(
formatters: &HashMap<String, Vec<FormatterConfig>>,
) -> HashSet<String> {
find_missing_commands(
formatters
.values()
.flat_map(|configs| configs.iter().map(|cfg| cfg.cmd.as_str())),
)
}
#[cfg(target_arch = "wasm32")]
pub fn find_missing_formatter_commands(
_formatters: &HashMap<String, Vec<FormatterConfig>>,
) -> HashSet<String> {
HashSet::new()
}
pub fn log_missing_formatter_commands(missing: &HashSet<String>) {
let Some(message) = missing_formatter_warning_message(missing) else {
return;
};
log_warning_once(&message);
}
pub fn resolve_stdin_args(args: &[String], language: &str) -> Vec<String> {
let virtual_filename = virtual_filename_for_language(language);
args.iter()
.map(|arg| arg.replace("{}", &virtual_filename))
.collect()
}
pub(crate) fn temp_file_extension_for_language(language: &str) -> &'static str {
match normalized_language(language).as_str() {
"javascript" | "js" => "js",
"typescript" | "ts" => "ts",
"jsx" => "jsx",
"tsx" => "tsx",
"json" => "json",
"jsonc" => "jsonc",
"yaml" | "yml" => "yaml",
"markdown" | "md" | "qmd" | "rmd" => "md",
"css" => "css",
"scss" => "scss",
"less" => "less",
"html" => "html",
"vue" => "vue",
"svelte" => "svelte",
"graphql" | "gql" => "graphql",
"r" => "r",
_ => "txt",
}
}
fn virtual_filename_for_language(language: &str) -> String {
format!("stdin.{}", temp_file_extension_for_language(language))
}
fn normalized_language(language: &str) -> String {
language.trim().to_ascii_lowercase().replace('_', "-")
}
fn missing_formatter_warning_message(missing: &HashSet<String>) -> Option<String> {
missing_commands_warning_message(missing, "formatter", "formatting")
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::{
find_missing_formatter_commands, missing_formatter_warning_message, resolve_stdin_args,
temp_file_extension_for_language,
};
use crate::config::FormatterConfig;
use std::collections::{HashMap, HashSet};
#[test]
fn reports_missing_commands_once() {
let mut formatters = HashMap::new();
formatters.insert(
"python".to_string(),
vec![
FormatterConfig {
cmd: "definitely-not-a-real-formatter-123".to_string(),
args: vec![],
enabled: true,
stdin: true,
},
FormatterConfig {
cmd: "definitely-not-a-real-formatter-123".to_string(),
args: vec![],
enabled: true,
stdin: true,
},
],
);
let missing = find_missing_formatter_commands(&formatters);
assert_eq!(missing.len(), 1);
assert!(missing.contains("definitely-not-a-real-formatter-123"));
}
#[test]
fn skips_empty_commands() {
let mut formatters = HashMap::new();
formatters.insert(
"python".to_string(),
vec![FormatterConfig {
cmd: " ".to_string(),
args: vec![],
enabled: true,
stdin: true,
}],
);
let missing = find_missing_formatter_commands(&formatters);
assert!(missing.is_empty());
}
#[test]
fn warning_message_sorts_and_deduplicates_commands() {
let missing = HashSet::from([
"black".to_string(),
"rustfmt".to_string(),
"black".to_string(),
]);
let message = missing_formatter_warning_message(&missing).expect("message expected");
assert_eq!(
message,
"External formatter command(s) not found: black, rustfmt. Configured external formatting for these tools will be skipped."
);
}
#[test]
fn resolve_stdin_args_replaces_placeholder_with_language_filename() {
let args = vec!["--stdin-filepath".to_string(), "{}".to_string()];
let resolved = resolve_stdin_args(&args, "typescript");
assert_eq!(
resolved,
vec!["--stdin-filepath".to_string(), "stdin.ts".to_string()]
);
}
#[test]
fn resolve_stdin_args_falls_back_for_unknown_language() {
let args = vec!["--stdin-filepath".to_string(), "{}".to_string()];
let resolved = resolve_stdin_args(&args, "unknownlang");
assert_eq!(
resolved,
vec!["--stdin-filepath".to_string(), "stdin.txt".to_string()]
);
}
#[test]
fn temp_file_extension_is_language_aware() {
assert_eq!(temp_file_extension_for_language("r"), "r");
assert_eq!(temp_file_extension_for_language("TypeScript"), "ts");
assert_eq!(temp_file_extension_for_language("unknownlang"), "txt");
}
}