use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathAliasMap {
pub aliases: HashMap<String, Vec<String>>,
pub base_url: Option<String>,
pub config_dir: PathBuf,
}
#[derive(Debug, Deserialize)]
struct CompilerOptions {
#[serde(rename = "baseUrl")]
base_url: Option<String>,
paths: Option<HashMap<String, Vec<String>>>,
}
#[derive(Debug, Deserialize)]
struct TsConfig {
#[serde(rename = "compilerOptions")]
compiler_options: Option<CompilerOptions>,
}
impl PathAliasMap {
pub fn from_file(tsconfig_path: impl AsRef<Path>) -> Result<Self> {
let tsconfig_path = tsconfig_path.as_ref();
let content = std::fs::read_to_string(tsconfig_path)
.with_context(|| format!("Failed to read tsconfig.json: {}", tsconfig_path.display()))?;
let config: TsConfig = json5::from_str(&content)
.with_context(|| format!("Failed to parse tsconfig.json: {}", tsconfig_path.display()))?;
let config_dir = tsconfig_path.parent()
.ok_or_else(|| anyhow::anyhow!("Invalid tsconfig.json path"))?
.to_path_buf();
let compiler_options = config.compiler_options.unwrap_or_else(|| {
CompilerOptions {
base_url: None,
paths: None,
}
});
Ok(Self {
aliases: compiler_options.paths.unwrap_or_default(),
base_url: compiler_options.base_url,
config_dir,
})
}
pub fn find_nearest_tsconfig(source_file: &Path) -> Option<PathBuf> {
let mut current_dir = source_file.parent()?;
loop {
let tsconfig_path = current_dir.join("tsconfig.json");
if tsconfig_path.exists() {
return Some(tsconfig_path);
}
let nuxt_tsconfig = current_dir.join(".nuxt/tsconfig.json");
if nuxt_tsconfig.exists() {
return Some(nuxt_tsconfig);
}
current_dir = current_dir.parent()?;
}
}
pub fn resolve_alias(&self, import_path: &str) -> Option<String> {
log::debug!(" resolve_alias: trying to match '{}' against {} aliases", import_path, self.aliases.len());
for (alias_pattern, target_paths) in &self.aliases {
log::trace!(" Checking alias pattern: {} => {:?}", alias_pattern, target_paths);
if alias_pattern.ends_with("/*") {
let alias_prefix = alias_pattern.trim_end_matches("/*");
if import_path.starts_with(alias_prefix) {
let suffix = import_path.strip_prefix(alias_prefix).unwrap_or("");
if let Some(target_pattern) = target_paths.first() {
let resolved = if target_pattern.ends_with("/*") {
let target_prefix = target_pattern.trim_end_matches("/*");
format!("{}{}", target_prefix, suffix)
} else {
let clean_suffix = suffix.trim_start_matches('/');
if clean_suffix.is_empty() {
target_pattern.to_string()
} else {
format!("{}/{}", target_pattern, clean_suffix)
}
};
log::trace!("Resolved alias {} + {} => {}", alias_pattern, import_path, resolved);
return Some(resolved);
}
}
} else {
if import_path == alias_pattern {
if let Some(target) = target_paths.first() {
log::trace!("Resolved exact alias {} => {}", alias_pattern, target);
return Some(target.clone());
}
}
}
}
None
}
pub fn resolve_relative_to_config(&self, path: &str) -> PathBuf {
let base = if let Some(ref base_url) = self.base_url {
self.config_dir.join(base_url)
} else {
self.config_dir.clone()
};
let joined = base.join(path);
let normalized = joined.components()
.fold(PathBuf::new(), |mut acc, component| {
match component {
std::path::Component::CurDir => acc, std::path::Component::ParentDir => {
acc.pop(); acc
}
_ => {
acc.push(component);
acc
}
}
});
normalized
}
}
pub fn parse_all_tsconfigs(root: &Path) -> Result<std::collections::HashMap<PathBuf, PathAliasMap>> {
use std::collections::HashMap;
use ignore::WalkBuilder;
log::debug!("Starting tsconfig discovery in {}", root.display());
let mut tsconfigs = HashMap::new();
let mut file_count = 0;
for entry in WalkBuilder::new(root)
.follow_links(false)
.build()
.filter_map(|e| e.ok())
{
let path = entry.path();
if path.file_name().and_then(|n| n.to_str()) == Some("tsconfig.json") {
file_count += 1;
log::debug!("Found tsconfig.json file #{}: {}", file_count, path.display());
match PathAliasMap::from_file(path) {
Ok(alias_map) => {
let config_dir = alias_map.config_dir.clone();
log::debug!(" Parsed successfully: base_url={:?}, {} aliases",
alias_map.base_url,
alias_map.aliases.len());
tsconfigs.insert(config_dir, alias_map);
}
Err(e) => {
log::warn!("Failed to parse tsconfig.json at {}: {}", path.display(), e);
}
}
}
}
log::debug!("Tsconfig discovery complete: found {} files, parsed {} successfully", file_count, tsconfigs.len());
Ok(tsconfigs)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_parse_tsconfig_with_paths() {
let temp = TempDir::new().unwrap();
let tsconfig_path = temp.path().join("tsconfig.json");
let tsconfig_content = r#"{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"],
"@packages/*": ["../../packages/*"]
}
}
}"#;
fs::write(&tsconfig_path, tsconfig_content).unwrap();
let alias_map = PathAliasMap::from_file(&tsconfig_path).unwrap();
assert_eq!(alias_map.base_url, Some(".".to_string()));
assert_eq!(alias_map.aliases.len(), 2);
assert!(alias_map.aliases.contains_key("~/*"));
assert!(alias_map.aliases.contains_key("@packages/*"));
}
#[test]
fn test_resolve_wildcard_alias() {
let temp = TempDir::new().unwrap();
let alias_map = PathAliasMap {
aliases: HashMap::from([
("@packages/*".to_string(), vec!["../../packages/*".to_string()]),
]),
base_url: Some(".".to_string()),
config_dir: temp.path().to_path_buf(),
};
let resolved = alias_map.resolve_alias("@packages/ui/stores/auth");
assert_eq!(resolved, Some("../../packages/ui/stores/auth".to_string()));
}
#[test]
fn test_resolve_exact_alias() {
let temp = TempDir::new().unwrap();
let alias_map = PathAliasMap {
aliases: HashMap::from([
("~".to_string(), vec!["./src".to_string()]),
]),
base_url: None,
config_dir: temp.path().to_path_buf(),
};
let resolved = alias_map.resolve_alias("~");
assert_eq!(resolved, Some("./src".to_string()));
}
#[test]
fn test_no_match() {
let temp = TempDir::new().unwrap();
let alias_map = PathAliasMap {
aliases: HashMap::from([
("@packages/*".to_string(), vec!["../../packages/*".to_string()]),
]),
base_url: None,
config_dir: temp.path().to_path_buf(),
};
let resolved = alias_map.resolve_alias("./relative/path");
assert_eq!(resolved, None);
}
#[test]
fn test_find_nearest_tsconfig() {
let temp = TempDir::new().unwrap();
let src_dir = temp.path().join("src");
let components_dir = src_dir.join("components");
fs::create_dir_all(&components_dir).unwrap();
let tsconfig_path = temp.path().join("tsconfig.json");
fs::write(&tsconfig_path, "{}").unwrap();
let source_file = components_dir.join("Button.tsx");
fs::write(&source_file, "export const Button = () => {}").unwrap();
let found = PathAliasMap::find_nearest_tsconfig(&source_file);
assert_eq!(found, Some(tsconfig_path));
}
#[test]
fn test_resolve_relative_to_config() {
let temp = TempDir::new().unwrap();
let alias_map = PathAliasMap {
aliases: HashMap::new(),
base_url: Some("src".to_string()),
config_dir: temp.path().to_path_buf(),
};
let resolved = alias_map.resolve_relative_to_config("utils/helper.ts");
assert_eq!(resolved, temp.path().join("src/utils/helper.ts"));
}
}