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 JsConfig {
pub extends: Option<String>,
#[serde(default)]
pub compilerOptions: CompilerOptions,
}
pub fn parse_jsonc_jsconfig(content: &str) -> ResolutionResult<JsConfig> {
serde_json5::from_str(content).map_err(|e| {
ResolutionError::invalid_cache(format!(
"Failed to parse jsconfig.json: {e}\nSuggestion: Check JSON syntax, comments, and trailing commas"
))
})
}
pub fn read_jsconfig(path: &Path) -> ResolutionResult<JsConfig> {
let content = std::fs::read_to_string(path)
.map_err(|e| ResolutionError::cache_io(path.to_path_buf(), e))?;
parse_jsonc_jsconfig(&content)
}
pub fn resolve_extends_chain(
base_path: &Path,
visited: &mut std::collections::HashSet<PathBuf>,
) -> ResolutionResult<JsConfig> {
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 jsconfig extends",
canonical_path.display()
)));
}
visited.insert(canonical_path.clone());
let mut config = read_jsconfig(&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_jsconfig(parent_config, config);
}
visited.remove(&canonical_path);
Ok(config)
}
fn merge_jsconfig(parent: JsConfig, child: JsConfig) -> JsConfig {
JsConfig {
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 jsconfig.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_jsconfig(config: &JsConfig) -> 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 &[".js", ".jsx", ".mjs", ".cjs"] {
expanded.push(format!("{path}{ext}"));
}
for ext in &[".js", ".jsx"] {
expanded.push(format!("{path}/index{ext}"));
}
expanded
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn parse_jsconfig_with_comments() {
let content = r#"{
// Base configuration
"compilerOptions": {
"baseUrl": "./src", // Source directory
"paths": {
/* Path mappings */
"@utils/*": ["utils/*"], // Utility modules
}
}
}"#;
let config = parse_jsonc_jsconfig(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_jsconfig() {
let content = r#"{}"#;
let config = parse_jsonc_jsconfig(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_jsconfig_with_extends() {
let content = r#"{
"extends": "./base.json",
"compilerOptions": {
"baseUrl": "./src"
}
}"#;
let config = parse_jsonc_jsconfig(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_jsconfig(content);
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("Failed to parse jsconfig.json"));
assert!(error_msg.contains("Suggestion:"));
}
#[test]
fn merge_jsconfig_child_overrides_parent() {
let parent = JsConfig {
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 = JsConfig {
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_jsconfig(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 path_rule_resolves_wildcards() {
let rule = PathRule::new(
"@components/*".to_string(),
vec!["src/components/*".to_string()],
)
.expect("Should create rule");
let result = rule.try_resolve("@components/Button");
assert_eq!(result, Some("src/components/Button".to_string()));
let result = rule.try_resolve("@components/forms/Input");
assert_eq!(result, Some("src/components/forms/Input".to_string()));
let result = rule.try_resolve("@utils/format");
assert!(result.is_none());
}
#[test]
fn path_alias_resolver_with_base_url() {
let config = JsConfig {
extends: None,
compilerOptions: CompilerOptions {
baseUrl: Some("./src".to_string()),
paths: HashMap::from([("@/*".to_string(), vec!["*".to_string()])]),
},
};
let resolver = PathAliasResolver::from_jsconfig(&config).expect("Should create resolver");
let resolved = resolver.resolve_import("@/components/Button");
assert_eq!(resolved, vec!["./src/components/Button".to_string()]);
}
#[test]
fn expand_javascript_extensions() {
let resolver = PathAliasResolver {
baseUrl: Some("./src".to_string()),
rules: vec![],
};
let base_path = "components/Button";
let expanded = resolver.expand_extensions(base_path);
assert!(expanded.contains(&"components/Button".to_string()));
assert!(expanded.contains(&"components/Button.js".to_string()));
assert!(expanded.contains(&"components/Button.jsx".to_string()));
assert!(expanded.contains(&"components/Button.mjs".to_string()));
assert!(expanded.contains(&"components/Button/index.js".to_string()));
assert!(expanded.contains(&"components/Button/index.jsx".to_string()));
}
#[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:"));
}
}