use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Default)]
pub struct TsConfig {
pub base_url: Option<PathBuf>,
pub paths: HashMap<String, Vec<PathBuf>>,
}
#[derive(Deserialize)]
struct RawConfig {
#[serde(rename = "compilerOptions")]
compiler_options: Option<CompilerOptions>,
}
#[derive(Deserialize)]
struct CompilerOptions {
#[serde(rename = "baseUrl")]
base_url: Option<String>,
paths: Option<HashMap<String, Vec<String>>>,
}
impl TsConfig {
#[must_use]
pub fn load(root: &Path) -> Option<Self> {
let candidates = ["tsconfig.json", "jsconfig.json"];
candidates.iter().find_map(|name| Self::parse_file(&root.join(name), root))
}
fn parse_file(path: &Path, root: &Path) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
Self::parse_content(&content, root)
}
fn parse_content(content: &str, root: &Path) -> Option<Self> {
let clean = strip_json_comments(content);
let raw: RawConfig = serde_json::from_str(&clean).ok()?;
let opts = raw.compiler_options?;
let base_url = opts.base_url.map(|b| root.join(&b));
let base_for_paths = base_url.as_deref().unwrap_or(root);
let paths = opts.paths.map_or_else(HashMap::new, |p| {
p.into_iter()
.map(|(pattern, targets)| {
let resolved = targets.into_iter().map(|t| base_for_paths.join(&t)).collect();
(pattern, resolved)
})
.collect()
});
Some(Self { base_url, paths })
}
#[must_use]
pub fn resolve(&self, import: &str) -> Option<PathBuf> {
self.resolve_alias(import).or_else(|| self.resolve_base_url(import))
}
fn resolve_alias(&self, import: &str) -> Option<PathBuf> {
self.paths
.iter()
.find_map(|(pattern, targets)| try_resolve_pattern(pattern, targets, import))
}
fn resolve_base_url(&self, import: &str) -> Option<PathBuf> {
find_ts_file(&self.base_url.as_ref()?.join(import))
}
}
fn try_resolve_pattern(pattern: &str, targets: &[PathBuf], import: &str) -> Option<PathBuf> {
let matched = match_pattern(pattern, import)?;
targets.iter().find_map(|t| expand_and_find(t, matched))
}
fn match_pattern<'a>(pattern: &str, import: &'a str) -> Option<&'a str> {
match pattern.strip_suffix('*') {
Some(prefix) => import.strip_prefix(prefix),
None if pattern == import => Some(""),
None => None,
}
}
fn expand_and_find(target: &Path, matched: &str) -> Option<PathBuf> {
let target_str = target.to_string_lossy();
let resolved = if target_str.contains('*') {
PathBuf::from(target_str.replace('*', matched))
} else {
target.to_path_buf()
};
find_ts_file(&resolved)
}
fn find_ts_file(path: &Path) -> Option<PathBuf> {
if path.is_file() { return Some(path.to_path_buf()); }
for ext in &["ts", "tsx", "js", "jsx", "json", "d.ts"] {
let with_ext = path.with_extension(ext);
if with_ext.is_file() { return Some(with_ext); }
}
find_ts_index(path)
}
fn find_ts_index(path: &Path) -> Option<PathBuf> {
if !path.is_dir() { return None; }
for ext in &["ts", "tsx", "js", "jsx"] {
let index = path.join(format!("index.{ext}"));
if index.is_file() { return Some(index); }
}
None
}
fn strip_json_comments(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
while let Some(c) = chars.next() {
if in_string {
result.push(c);
in_string = handle_string_char(c, &mut chars, &mut result);
continue;
}
match c {
'"' => { in_string = true; result.push(c); }
'/' => handle_slash(&mut chars, &mut result),
_ => result.push(c),
}
}
result
}
fn handle_string_char(c: char, chars: &mut std::iter::Peekable<std::str::Chars>, result: &mut String) -> bool {
if c == '\\' {
if let Some(&next) = chars.peek() {
result.push(next);
chars.next();
}
return true;
}
c != '"'
}
fn handle_slash(chars: &mut std::iter::Peekable<std::str::Chars>, result: &mut String) {
match chars.peek() {
Some(&'/') => skip_line_comment(chars, result),
Some(&'*') => skip_block_comment(chars),
_ => result.push('/'),
}
}
fn skip_line_comment(chars: &mut std::iter::Peekable<std::str::Chars>, result: &mut String) {
for ch in chars.by_ref() {
if ch == '\n' { result.push('\n'); break; }
}
}
fn skip_block_comment(chars: &mut std::iter::Peekable<std::str::Chars>) {
chars.next(); while let Some(ch) = chars.next() {
if ch == '*' && chars.peek() == Some(&'/') {
chars.next();
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_comments() {
let input = r#"{ // comment
"baseUrl": "." /* inline */ }"#;
let clean = strip_json_comments(input);
assert!(!clean.contains("//"));
assert!(!clean.contains("/*"));
assert!(clean.contains("baseUrl"));
}
#[test]
fn test_match_pattern() {
assert_eq!(match_pattern("@/*", "@/components/Button"), Some("components/Button"));
assert_eq!(match_pattern("@/*", "react"), None);
assert_eq!(match_pattern("utils", "utils"), Some(""));
}
}