use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::project_resolver::{ResolutionError, ResolutionResult};
#[derive(Debug)]
pub struct PathRule {
pub pattern: String,
pub targets: Vec<String>,
regex: regex::Regex,
substitution_template: String,
}
#[derive(Debug)]
#[allow(non_snake_case)]
pub struct PathAliasResolver {
pub baseUrl: Option<String>,
pub rules: Vec<PathRule>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[allow(non_snake_case)]
#[derive(Default)]
pub struct CompilerOptions {
#[serde(rename = "baseUrl")]
pub baseUrl: Option<String>,
#[serde(default)]
pub paths: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[allow(non_snake_case)]
#[derive(Default)]
pub struct TsConfig {
pub extends: Option<String>,
#[serde(default)]
pub compilerOptions: CompilerOptions,
}
pub fn parse_jsonc_tsconfig(content: &str) -> ResolutionResult<TsConfig> {
serde_json5::from_str(content)
.map_err(|e| ResolutionError::invalid_cache(
format!("Failed to parse tsconfig.json: {e}\nSuggestion: Check JSON syntax, comments, and trailing commas")
))
}
pub fn read_tsconfig(path: &Path) -> ResolutionResult<TsConfig> {
let content = std::fs::read_to_string(path)
.map_err(|e| ResolutionError::cache_io(path.to_path_buf(), e))?;
parse_jsonc_tsconfig(&content)
}
pub fn resolve_extends_chain(
base_path: &Path,
visited: &mut std::collections::HashSet<PathBuf>,
) -> ResolutionResult<TsConfig> {
let canonical_path = base_path
.canonicalize()
.map_err(|e| ResolutionError::cache_io(base_path.to_path_buf(), e))?;
if visited.contains(&canonical_path) {
return Err(ResolutionError::invalid_cache(format!(
"Circular extends chain detected: {}\nSuggestion: Remove circular references in tsconfig extends",
canonical_path.display()
)));
}
visited.insert(canonical_path.clone());
let mut config = read_tsconfig(&canonical_path)?;
if let Some(extends_path) = &config.extends {
let parent_path = if Path::new(extends_path).is_absolute() {
PathBuf::from(extends_path)
} else {
canonical_path
.parent()
.ok_or_else(|| {
ResolutionError::invalid_cache(format!(
"Cannot resolve parent directory for: {}",
canonical_path.display()
))
})?
.join(extends_path)
};
let parent_path = if parent_path.extension().is_none() {
parent_path.with_extension("json")
} else {
parent_path
};
let parent_config = resolve_extends_chain(&parent_path, visited)?;
config = merge_tsconfig(parent_config, config);
}
visited.remove(&canonical_path);
Ok(config)
}
fn merge_tsconfig(parent: TsConfig, child: TsConfig) -> TsConfig {
TsConfig {
extends: child.extends,
compilerOptions: CompilerOptions {
baseUrl: child
.compilerOptions
.baseUrl
.or(parent.compilerOptions.baseUrl),
paths: {
let mut merged = parent.compilerOptions.paths;
merged.extend(child.compilerOptions.paths);
merged
},
},
}
}
impl PathRule {
pub fn new(pattern: String, targets: Vec<String>) -> ResolutionResult<Self> {
let regex_pattern = pattern.replace("*", "(.*)");
let regex_pattern = format!(
"^{}$",
regex::escape(®ex_pattern).replace("\\(\\.\\*\\)", "(.*)")
);
let regex = regex::Regex::new(®ex_pattern)
.map_err(|e| ResolutionError::invalid_cache(
format!("Invalid path pattern '{pattern}': {e}\nSuggestion: Check tsconfig.json path patterns for valid syntax")
))?;
let substitution_template = targets
.first()
.ok_or_else(|| {
ResolutionError::invalid_cache(format!(
"Path pattern '{pattern}' has no targets\nSuggestion: Add at least one target path"
))
})?
.replace("*", "$1");
Ok(Self {
pattern,
targets,
regex,
substitution_template,
})
}
pub fn try_resolve(&self, specifier: &str) -> Option<String> {
if let Some(captures) = self.regex.captures(specifier) {
let mut result = self.substitution_template.clone();
if let Some(captured) = captures.get(1) {
result = result.replace("$1", captured.as_str());
}
Some(result)
} else {
None
}
}
}
impl PathAliasResolver {
pub fn from_tsconfig(config: &TsConfig) -> ResolutionResult<Self> {
let mut rules = Vec::new();
let mut paths: Vec<_> = config.compilerOptions.paths.iter().collect();
paths.sort_by_key(|(pattern, _)| {
let wildcard_count = pattern.matches('*').count();
(-(pattern.len() as isize), wildcard_count)
});
for (pattern, targets) in paths {
let rule = PathRule::new(pattern.clone(), targets.clone())?;
rules.push(rule);
}
Ok(Self {
baseUrl: config.compilerOptions.baseUrl.clone(),
rules,
})
}
pub fn resolve_import(&self, specifier: &str) -> Vec<String> {
let mut candidates = Vec::new();
for rule in &self.rules {
if let Some(resolved) = rule.try_resolve(specifier) {
let final_path = if let Some(ref base) = self.baseUrl {
if base == "." {
resolved
} else {
format!("{}/{}", base.trim_end_matches('/'), resolved)
}
} else {
resolved
};
candidates.push(final_path);
}
}
candidates
}
pub fn expand_extensions(&self, path: &str) -> Vec<String> {
let mut expanded = Vec::new();
expanded.push(path.to_string());
for ext in &[".ts", ".tsx", ".d.ts"] {
expanded.push(format!("{path}{ext}"));
}
for ext in &[".ts", ".tsx"] {
expanded.push(format!("{path}/index{ext}"));
}
expanded
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn parse_real_project_root_tsconfig() {
let tsconfig_path = Path::new("tsconfig.json");
if !tsconfig_path.exists() {
println!("Skipping test - no tsconfig.json in project root");
return;
}
let config = read_tsconfig(tsconfig_path).expect("Should parse project root tsconfig.json");
println!("DEBUG: Parsed config: {config:#?}");
assert_eq!(config.compilerOptions.baseUrl, Some(".".to_string()));
assert_eq!(config.compilerOptions.paths.len(), 2);
println!("✓ Parsed project root tsconfig.json:");
println!(" baseUrl: {:?}", config.compilerOptions.baseUrl);
println!(" paths: {:#?}", config.compilerOptions.paths);
}
#[test]
fn parse_tsconfig_with_comments() {
let content = r#"{
// Base configuration
"compilerOptions": {
"baseUrl": "./src", // Source directory
"paths": {
/* Path mappings */
"@utils/*": ["utils/*"], // Utility modules
}
}
}"#;
let config = parse_jsonc_tsconfig(content).expect("Should parse JSONC with comments");
assert_eq!(config.compilerOptions.baseUrl, Some("./src".to_string()));
assert_eq!(config.compilerOptions.paths.len(), 1);
}
#[test]
fn parse_minimal_tsconfig() {
let content = r#"{}"#;
let config = parse_jsonc_tsconfig(content).expect("Should parse empty config");
assert!(config.extends.is_none());
assert!(config.compilerOptions.baseUrl.is_none());
assert!(config.compilerOptions.paths.is_empty());
}
#[test]
fn parse_tsconfig_with_extends() {
let content = r#"{
"extends": "./base.json",
"compilerOptions": {
"baseUrl": "./src"
}
}"#;
let config = parse_jsonc_tsconfig(content).expect("Should parse config with extends");
assert_eq!(config.extends, Some("./base.json".to_string()));
assert_eq!(config.compilerOptions.baseUrl, Some("./src".to_string()));
}
#[test]
fn invalid_json_returns_error() {
let content = r#"{ invalid json }"#;
let result = parse_jsonc_tsconfig(content);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Failed to parse tsconfig.json"));
assert!(error_msg.contains("Suggestion:"));
}
#[test]
fn read_example_tsconfig_from_file() {
let tsconfig_path = Path::new("examples/typescript/tsconfig.json");
if !tsconfig_path.exists() {
println!("Skipping test - no example tsconfig.json");
return;
}
let config = read_tsconfig(tsconfig_path).expect("Should read example tsconfig from file");
assert_eq!(config.compilerOptions.baseUrl, Some("./src".to_string()));
assert_eq!(config.compilerOptions.paths.len(), 3);
assert!(config.compilerOptions.paths.contains_key("@components/*"));
assert!(config.compilerOptions.paths.contains_key("@utils/*"));
assert!(config.compilerOptions.paths.contains_key("@types/*"));
println!("✓ Parsed examples/typescript/tsconfig.json:");
println!(" baseUrl: {:?}", config.compilerOptions.baseUrl);
println!(" paths: {:#?}", config.compilerOptions.paths);
}
#[test]
fn read_nonexistent_file_returns_error() {
let nonexistent = Path::new("/does/not/exist/tsconfig.json");
let result = read_tsconfig(nonexistent);
assert!(result.is_err());
let error = result.unwrap_err();
matches!(error, ResolutionError::CacheIo { .. });
}
#[test]
fn resolve_real_extends_chain() {
let child_path = Path::new("examples/typescript/packages/web/tsconfig.json");
let parent_path = Path::new("examples/typescript/tsconfig.json");
if !child_path.exists() || !parent_path.exists() {
println!("Skipping test - example extends chain files don't exist");
return;
}
let mut visited = std::collections::HashSet::new();
let merged = resolve_extends_chain(child_path, &mut visited)
.expect("Should resolve real extends chain");
assert_eq!(merged.compilerOptions.baseUrl, Some("./src".to_string()));
assert!(merged.compilerOptions.paths.len() >= 2);
assert!(merged.compilerOptions.paths.contains_key("@components/*"));
assert!(merged.compilerOptions.paths.contains_key("@utils/*"));
assert!(merged.compilerOptions.paths.contains_key("@types/*"));
assert!(merged.compilerOptions.paths.contains_key("@web/*"));
assert!(merged.compilerOptions.paths.contains_key("@api/*"));
println!("✓ Resolved real extends chain:");
println!(" baseUrl: {:?}", merged.compilerOptions.baseUrl);
println!(" merged paths: {:#?}", merged.compilerOptions.paths);
}
#[test]
fn merge_tsconfig_child_overrides_parent() {
let parent = TsConfig {
extends: Some("parent.json".to_string()),
compilerOptions: CompilerOptions {
baseUrl: Some("./parent".to_string()),
paths: HashMap::from([
("@parent/*".to_string(), vec!["parent/*".to_string()]),
("@common/*".to_string(), vec!["parent/common/*".to_string()]),
]),
},
};
let child = TsConfig {
extends: Some("child.json".to_string()),
compilerOptions: CompilerOptions {
baseUrl: Some("./child".to_string()),
paths: HashMap::from([
("@child/*".to_string(), vec!["child/*".to_string()]),
("@common/*".to_string(), vec!["child/common/*".to_string()]),
]),
},
};
let merged = merge_tsconfig(parent, child);
assert_eq!(merged.extends, Some("child.json".to_string()));
assert_eq!(merged.compilerOptions.baseUrl, Some("./child".to_string()));
assert_eq!(
merged.compilerOptions.paths.get("@common/*"),
Some(&vec!["child/common/*".to_string()])
);
assert!(merged.compilerOptions.paths.contains_key("@parent/*"));
assert!(merged.compilerOptions.paths.contains_key("@child/*"));
}
#[test]
fn resolve_path_aliases_with_real_tsconfig() {
let tsconfig_path = Path::new("examples/typescript/tsconfig.json");
if !tsconfig_path.exists() {
println!("Skipping test - no example tsconfig.json");
return;
}
let config = read_tsconfig(tsconfig_path).expect("Should read example tsconfig");
let resolver = PathAliasResolver::from_tsconfig(&config).expect("Should create resolver");
println!(
"DEBUG: Created resolver with {} rules",
resolver.rules.len()
);
println!("DEBUG: Base URL: {:?}", resolver.baseUrl);
for rule in &resolver.rules {
println!("DEBUG: Rule: {} → {:?}", rule.pattern, rule.targets);
}
let test_cases = [
("@components/Button", "components/Button"), ("@utils/format", "utils/format"), ("@types/User", "types/User"), ("regular/import", ""), ];
for (import_specifier, expected_path) in test_cases {
let resolved = resolver.resolve_import(import_specifier);
println!("Testing: {import_specifier} → Expected: {expected_path} → Got: {resolved:?}");
if expected_path.is_empty() {
assert!(
resolved.is_empty(),
"Should not resolve regular imports: {import_specifier}"
);
} else {
assert!(
!resolved.is_empty(),
"Should resolve alias: {import_specifier}"
);
let expected_full = format!("./src/{expected_path}");
assert!(
resolved.contains(&expected_full),
"Should contain expected path: {expected_full} in {resolved:?}"
);
}
}
}
#[test]
fn resolve_with_extends_chain_real_files() {
let child_path = Path::new("examples/typescript/packages/web/tsconfig.json");
if !child_path.exists() {
println!("Skipping test - no example extends chain");
return;
}
let mut visited = std::collections::HashSet::new();
let merged_config =
resolve_extends_chain(child_path, &mut visited).expect("Should resolve extends chain");
let resolver = PathAliasResolver::from_tsconfig(&merged_config)
.expect("Should create resolver from merged config");
println!("DEBUG: Merged resolver has {} rules", resolver.rules.len());
let test_cases = [
("@components/Header", "components/Header"), ("@utils/api", "utils/api"), ("@web/Layout", "web/Layout"), ("@api/client", "api/client"), ];
for (import_specifier, expected_path) in test_cases {
let resolved = resolver.resolve_import(import_specifier);
println!(
"Extends test: {import_specifier} → Expected: {expected_path} → Got: {resolved:?}"
);
assert!(
!resolved.is_empty(),
"Should resolve merged alias: {import_specifier}"
);
let expected_full = format!("./src/{expected_path}");
assert!(
resolved.contains(&expected_full),
"Should contain merged path: {expected_full} in {resolved:?}"
);
}
}
#[test]
fn expand_typescript_extensions() {
let resolver = PathAliasResolver {
baseUrl: Some("./src".to_string()),
rules: vec![],
};
let base_path = "components/Button";
let expanded = resolver.expand_extensions(base_path);
println!("Extension expansion: {base_path} → {expanded:?}");
assert!(expanded.contains(&"components/Button".to_string()));
assert!(expanded.contains(&"components/Button.ts".to_string()));
assert!(expanded.contains(&"components/Button.tsx".to_string()));
assert!(expanded.contains(&"components/Button.d.ts".to_string()));
assert!(expanded.contains(&"components/Button/index.ts".to_string()));
assert!(expanded.contains(&"components/Button/index.tsx".to_string()));
println!("✓ Extension expansion working correctly");
}
#[test]
fn detect_circular_extends() {
let temp_dir = TempDir::new().unwrap();
let a_content = r#"{ "extends": "./b.json" }"#;
let b_content = r#"{ "extends": "./a.json" }"#;
let a_path = temp_dir.path().join("a.json");
let b_path = temp_dir.path().join("b.json");
fs::write(&a_path, a_content).unwrap();
fs::write(&b_path, b_content).unwrap();
let mut visited = std::collections::HashSet::new();
let result = resolve_extends_chain(&a_path, &mut visited);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Circular extends chain detected"));
assert!(error_msg.contains("Suggestion:"));
}
}