use std::borrow::Cow; use std::path::{Path, PathBuf}; use indexmap::IndexMap; use serde::Deserialize; use swc_common::FileName; use thiserror::Error; #[derive(Debug)] pub struct TsConfigPathResolver { /// The base path from which relative paths are resolved. base: PathBuf, base_filename: FileName, /// The parsed paths, sorted by descending prefix length (before any '*' wildcard). paths: Vec, } #[derive(Error, Debug)] pub enum TsConfigError { #[cfg(not(target_arch = "wasm32"))] #[error("reading {0} failed")] Read(PathBuf, #[source] std::io::Error), #[error("parsing {0} failed")] Parse(PathBuf, #[source] serde_json::Error), #[error("{0} has no parent directory")] NoParent(PathBuf), } impl TsConfigPathResolver { #[cfg(not(target_arch = "wasm32"))] pub fn from_file(tsconfig_path: &Path) -> Result { let tsconfig_dir = tsconfig_path .parent() .ok_or_else(|| TsConfigError::NoParent(tsconfig_path.to_path_buf()))?; let tsconfig = std::fs::read_to_string(tsconfig_path) .map_err(|e| TsConfigError::Read(tsconfig_path.to_path_buf(), e))?; Self::from_str(tsconfig_dir, &tsconfig) } /// Parse a tsconfig from a JSON/JSONC string. Works in both native and WASM. pub fn from_str(tsconfig_dir: &Path, content: &str) -> Result { let content = strip_jsonc_comments(content, false); let tsconfig: TSConfig = serde_json::from_str(&content) .map_err(|e| TsConfigError::Parse(tsconfig_dir.to_path_buf(), e))?; Ok(Self::from_config(tsconfig_dir, tsconfig)) } pub fn from_config(tsconfig_dir: &Path, tsconfig: TSConfig) -> Self { let base = tsconfig .compiler_options .base_url .map(|p| tsconfig_dir.join(p)) .unwrap_or(tsconfig_dir.to_path_buf()); let mut paths: Vec = tsconfig .compiler_options .paths .into_iter() .map(|(key, val)| { let key = PathVal::from(key); let values = val.into_iter().map(PathVal::from).collect(); PathEntry { key, values } }) .collect(); // Sort the paths by descending prefix length. paths.sort_by(|a, b| { let a = a.key.prefix_len(); let b = b.key.prefix_len(); b.cmp(&a) }); let base_filename = FileName::Real(base.clone()); Self { base, base_filename, paths, } } pub fn base(&self) -> &FileName { &self.base_filename } /// Resolve a tsconfig path alias, checking file existence via the provided function. pub fn resolve_with_checker( &self, import: &str, exists: impl Fn(&Path) -> bool, ) -> Option> { for entry in &self.paths { match &entry.key { PathVal::Exact(key) if import == key => { for val in &entry.values { if let PathVal::Exact(val) = val { for candidate in file_candidates(self.base.join(val)) { if exists(&candidate) { return Some(Cow::Borrowed(val)); } } } } } PathVal::Wildcard { prefix, suffix } if import.starts_with(prefix) && import.ends_with(suffix) => { let wildcard = &import[prefix.len()..import.len() - suffix.len()]; for val in &entry.values { match val { PathVal::Wildcard { prefix, suffix } => { let mut rel_path = String::with_capacity( prefix.len() + suffix.len() + wildcard.len() + 2, ); rel_path.push_str(prefix); rel_path.push_str(wildcard); rel_path.push_str(suffix); for candidate in file_candidates(self.base.join(&rel_path)) { if exists(&candidate) { return Some(Cow::Owned(rel_path)); } } } PathVal::Exact(val) => { for candidate in file_candidates(self.base.join(val)) { if exists(&candidate) { return Some(Cow::Borrowed(val)); } } } } } } _ => {} } } None } /// Resolve a tsconfig path alias using filesystem `.exists()` checks. #[cfg(not(target_arch = "wasm32"))] pub fn resolve(&self, import: &str) -> Option> { self.resolve_with_checker(import, |p| p.exists()) } } #[derive(Debug)] struct PathEntry { key: PathVal, values: Vec, } /// Represents a path, possibly with a wildcard. #[derive(Debug)] enum PathVal { /// The path is an exact path. Exact(String), /// The path is a wildcard path, with the given prefix and suffix surrounding the '*'. Wildcard { prefix: String, suffix: String }, } impl PathVal { /// Returns the length of the prefix, or of the full path if it is an exact path. fn prefix_len(&self) -> usize { match self { PathVal::Exact(s) => s.len(), PathVal::Wildcard { prefix, .. } => prefix.len(), } } } impl From for PathVal { fn from(s: String) -> Self { match s.split_once('*') { Some((prefix, suffix)) => PathVal::Wildcard { prefix: prefix.to_string(), suffix: suffix.to_string(), }, None => PathVal::Exact(s), } } } #[derive(Deserialize)] pub struct TSConfig { #[serde(default, rename = "compilerOptions")] pub compiler_options: CompilerOptions, } #[derive(Deserialize, Default)] pub struct CompilerOptions { #[serde(rename = "baseUrl")] pub base_url: Option, #[serde(default)] pub paths: IndexMap>, } /// Takes a string of jsonc content and returns a comment free version /// which should parse fine as regular json. /// Nested block comments are supported. /// preserve_locations will replace most comments with spaces, so that JSON parsing /// errors should point to the right location. /// /// From https://github.com/serde-rs/json/issues/168#issuecomment-761831843. fn strip_jsonc_comments(jsonc_input: &str, preserve_locations: bool) -> String { let mut json_output = String::new(); let mut block_comment_depth: u8 = 0; let mut is_in_string: bool = false; // Comments cannot be in strings for line in jsonc_input.split('\n') { let mut last_char: Option = None; for cur_char in line.chars() { // Check whether we're in a string if block_comment_depth == 0 && last_char != Some('\\') && cur_char == '"' { is_in_string = !is_in_string; } // Check for line comment start if !is_in_string && last_char == Some('/') && cur_char == '/' { last_char = None; if preserve_locations { json_output.push_str(" "); } break; // Stop outputting or parsing this line } // Check for block comment start if !is_in_string && last_char == Some('/') && cur_char == '*' { block_comment_depth += 1; last_char = None; if preserve_locations { json_output.push_str(" "); } // Check for block comment end } else if !is_in_string && last_char == Some('*') && cur_char == '/' { block_comment_depth = block_comment_depth.saturating_sub(1); last_char = None; if preserve_locations { json_output.push_str(" "); } // Output last char if not in any block comment } else { if block_comment_depth == 0 { if let Some(last_char) = last_char { json_output.push(last_char); } } else if preserve_locations { json_output.push(' '); } last_char = Some(cur_char); } } // Add last char and newline if not in any block comment if let Some(last_char) = last_char { if block_comment_depth == 0 { json_output.push(last_char); } else if preserve_locations { json_output.push(' '); } } // Remove trailing whitespace from line while json_output.ends_with(' ') { json_output.pop(); } json_output.push('\n'); } json_output } struct PathResolveIterator { base: PathBuf, base_ext: Option, candidates: &'static [&'static str], idx: usize, } impl Iterator for PathResolveIterator { type Item = PathBuf; fn next(&mut self) -> Option { if self.idx < self.candidates.len() { let candidate = self.candidates[self.idx]; self.idx += 1; if candidate.is_empty() { Some(self.base.clone()) } else if let Some(base_ext) = &self.base_ext { // Combine the candidate extension with the base extension. let ext = format!("{base_ext}.{candidate}"); Some(self.base.with_extension(ext)) } else { Some(self.base.with_extension(candidate)) } } else { None } } } fn file_candidates(base: PathBuf) -> impl Iterator { let base_ext = base .extension() .and_then(|s| s.to_str()) .map(|s| s.to_string()); let candidates: &'static [&'static str] = match base_ext.as_deref() { Some("js") => &["ts", "tsx", "d.ts", "js", "jsx"], Some("mjs") => &["mts", "d.mts", "mjs"], Some("cjs") => &["cts", "d.cts", "cjs"], // If we don't have an extension, or it's not recognized, try all extensions. _ => &[ "ts", "tsx", "d.ts", "js", "jsx", "mts", "d.mts", "mjs", "cts", "d.cts", "cjs", "", ], }; PathResolveIterator { base, candidates, base_ext, idx: 0, } }