use std::{
borrow::Cow,
cmp::Reverse,
fmt::Debug,
hash::BuildHasherDefault,
path::{Path, PathBuf},
sync::Arc,
};
use compact_str::CompactString;
use indexmap::IndexMap;
use rustc_hash::FxHasher;
use serde::Deserialize;
use crate::{TsconfigReferences, path::PathUtil, replace_bom_with_whitespace};
const TEMPLATE_VARIABLE: &str = "${configDir}";
const GLOB_ALL_PATTERN: &str = "**/*";
pub type CompilerOptionsPathsMap = IndexMap<String, Vec<PathBuf>, BuildHasherDefault<FxHasher>>;
#[derive(Clone, Debug, Deserialize)]
pub struct ProjectReference {
pub path: PathBuf,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TsConfig {
#[serde(skip)]
pub root: bool,
#[serde(skip)]
should_build: bool,
#[serde(skip)]
pub path: PathBuf,
#[serde(default)]
pub files: Option<Vec<PathBuf>>,
#[serde(default)]
pub include: Option<Vec<PathBuf>>,
#[serde(default)]
pub exclude: Option<Vec<PathBuf>>,
#[serde(default)]
pub extends: Option<ExtendsField>,
#[serde(default)]
pub compiler_options: CompilerOptions,
#[serde(default)]
pub references: Vec<ProjectReference>,
#[serde(skip)]
pub references_resolved: Vec<Arc<Self>>,
}
impl TsConfig {
pub fn parse(root: bool, path: &Path, json: String) -> Result<Self, serde_json::Error> {
let mut json = json.into_bytes();
replace_bom_with_whitespace(&mut json);
_ = json_strip_comments::strip_slice(&mut json);
let mut tsconfig: Self = if json.iter().all(u8::is_ascii_whitespace) {
Self::default()
} else {
serde_json::from_slice(&json)?
};
tsconfig.root = root;
tsconfig.path = path.to_path_buf();
tsconfig.compiler_options.paths_base =
tsconfig.compiler_options.base_url.as_ref().map_or_else(
|| tsconfig.directory().to_path_buf(),
|base_url| {
if base_url.to_string_lossy().starts_with(TEMPLATE_VARIABLE) {
base_url.clone()
} else {
tsconfig.directory().normalize_with(base_url)
}
},
);
Ok(tsconfig)
}
#[must_use]
pub fn root(&self) -> bool {
self.root
}
#[must_use]
pub fn should_build(&self) -> bool {
self.should_build
}
pub fn set_should_build(&mut self, should_build: bool) {
self.should_build = should_build;
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn directory(&self) -> &Path {
debug_assert!(self.path.file_name().is_some());
self.path.parent().unwrap()
}
pub(crate) fn extends(&self) -> impl Iterator<Item = &str> {
let specifiers = match &self.extends {
Some(ExtendsField::Single(specifier)) => {
vec![specifier.as_str()]
}
Some(ExtendsField::Multiple(specifiers)) => {
specifiers.iter().map(String::as_str).collect()
}
None => Vec::new(),
};
specifiers.into_iter()
}
pub(crate) fn load_references(&mut self, references: TsconfigReferences) -> bool {
match references {
TsconfigReferences::Disabled => {
self.references.drain(..);
}
TsconfigReferences::Auto => {}
}
!self.references.is_empty()
}
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
pub(crate) fn extend_tsconfig(&mut self, tsconfig: &Self) {
if self.files.is_none()
&& let Some(files) = &tsconfig.files
{
self.files = Some(files.clone());
}
if self.include.is_none()
&& let Some(include) = &tsconfig.include
{
self.include = Some(include.clone());
}
if self.exclude.is_none()
&& let Some(exclude) = &tsconfig.exclude
{
self.exclude = Some(exclude.clone());
}
let compiler_options = &mut self.compiler_options;
if compiler_options.base_url.is_none() {
compiler_options.base_url.clone_from(&tsconfig.compiler_options.base_url);
if tsconfig.compiler_options.base_url.is_some() {
compiler_options.paths_base.clone_from(&tsconfig.compiler_options.paths_base);
}
}
if compiler_options.paths.is_none() {
if compiler_options.base_url.is_none() && tsconfig.compiler_options.base_url.is_none() {
compiler_options.paths_base.clone_from(&tsconfig.compiler_options.paths_base);
}
compiler_options.paths.clone_from(&tsconfig.compiler_options.paths);
}
if compiler_options.experimental_decorators.is_none()
&& let Some(experimental_decorators) =
&tsconfig.compiler_options.experimental_decorators
{
compiler_options.experimental_decorators = Some(*experimental_decorators);
}
if compiler_options.emit_decorator_metadata.is_none()
&& let Some(emit_decorator_metadata) =
&tsconfig.compiler_options.emit_decorator_metadata
{
compiler_options.emit_decorator_metadata = Some(*emit_decorator_metadata);
}
if compiler_options.use_define_for_class_fields.is_none()
&& let Some(use_define_for_class_fields) =
&tsconfig.compiler_options.use_define_for_class_fields
{
compiler_options.use_define_for_class_fields = Some(*use_define_for_class_fields);
}
if compiler_options.rewrite_relative_import_extensions.is_none()
&& let Some(rewrite_relative_import_extensions) =
&tsconfig.compiler_options.rewrite_relative_import_extensions
{
compiler_options.rewrite_relative_import_extensions =
Some(*rewrite_relative_import_extensions);
}
if compiler_options.jsx.is_none()
&& let Some(jsx) = &tsconfig.compiler_options.jsx
{
compiler_options.jsx = Some(jsx.clone());
}
if compiler_options.jsx_factory.is_none()
&& let Some(jsx_factory) = &tsconfig.compiler_options.jsx_factory
{
compiler_options.jsx_factory = Some(jsx_factory.clone());
}
if compiler_options.jsx_fragment_factory.is_none()
&& let Some(jsx_fragment_factory) = &tsconfig.compiler_options.jsx_fragment_factory
{
compiler_options.jsx_fragment_factory = Some(jsx_fragment_factory.clone());
}
if compiler_options.jsx_import_source.is_none()
&& let Some(jsx_import_source) = &tsconfig.compiler_options.jsx_import_source
{
compiler_options.jsx_import_source = Some(jsx_import_source.clone());
}
if compiler_options.verbatim_module_syntax.is_none()
&& let Some(verbatim_module_syntax) = &tsconfig.compiler_options.verbatim_module_syntax
{
compiler_options.verbatim_module_syntax = Some(*verbatim_module_syntax);
}
if compiler_options.preserve_value_imports.is_none()
&& let Some(preserve_value_imports) = &tsconfig.compiler_options.preserve_value_imports
{
compiler_options.preserve_value_imports = Some(*preserve_value_imports);
}
if compiler_options.imports_not_used_as_values.is_none()
&& let Some(imports_not_used_as_values) =
&tsconfig.compiler_options.imports_not_used_as_values
{
compiler_options.imports_not_used_as_values = Some(imports_not_used_as_values.clone());
}
if compiler_options.target.is_none()
&& let Some(target) = &tsconfig.compiler_options.target
{
compiler_options.target = Some(target.clone());
}
if compiler_options.module.is_none()
&& let Some(module) = &tsconfig.compiler_options.module
{
compiler_options.module = Some(module.clone());
}
if compiler_options.allow_js.is_none()
&& let Some(allow_js) = &tsconfig.compiler_options.allow_js
{
compiler_options.allow_js = Some(*allow_js);
}
if compiler_options.root_dirs.is_none()
&& let Some(root_dirs) = &tsconfig.compiler_options.root_dirs
{
compiler_options.root_dirs = Some(root_dirs.clone());
}
}
#[must_use]
pub(crate) fn build(mut self) -> Self {
if !self.should_build {
return self;
}
let config_dir = self.directory().to_path_buf();
if let Some(files) = self.files.take() {
self.files = Some(files.into_iter().map(|p| self.adjust_path(p)).collect());
}
if let Some(includes) = self.include.take() {
self.include = Some(includes.into_iter().map(|p| self.adjust_path(p)).collect());
}
if let Some(excludes) = self.exclude.take() {
self.exclude = Some(excludes.into_iter().map(|p| self.adjust_path(p)).collect());
}
if let Some(base_url) = &self.compiler_options.base_url {
self.compiler_options.base_url = Some(self.adjust_path(base_url.clone()));
}
if let Some(stripped_path) =
self.compiler_options.paths_base.to_string_lossy().strip_prefix(TEMPLATE_VARIABLE)
{
self.compiler_options.paths_base =
config_dir.join(stripped_path.trim_start_matches('/'));
}
if let Some(root_dirs) = &mut self.compiler_options.root_dirs {
for root_dir in root_dirs.iter_mut() {
*root_dir = config_dir.normalize_with(&root_dir);
}
}
if let Some(paths_map) = &mut self.compiler_options.paths {
for paths in paths_map.values_mut() {
for path in paths {
*path = if let Some(stripped_path) =
path.to_string_lossy().strip_prefix(TEMPLATE_VARIABLE)
{
config_dir.join(stripped_path.trim_start_matches('/'))
} else {
self.compiler_options.paths_base.normalize_with(&path)
};
}
}
self.compiler_options.compiled_paths =
Some(Arc::new(CompiledTsconfigPaths::new(paths_map)));
} else {
self.compiler_options.compiled_paths = None;
}
self
}
#[expect(clippy::option_if_let_else)]
fn adjust_path(&self, path: PathBuf) -> PathBuf {
if let Some(stripped) = path.to_string_lossy().strip_prefix(TEMPLATE_VARIABLE) {
self.directory().join(stripped.trim_start_matches('/'))
} else {
self.directory().normalize_with(path)
}
}
#[must_use]
pub(crate) fn resolve_references_then_self_paths(
&self,
path: &Path,
specifier: &str,
) -> Vec<PathBuf> {
for tsconfig in &self.references_resolved {
if path.starts_with(&tsconfig.compiler_options.paths_base) {
return tsconfig.resolve_path_alias(specifier);
}
}
self.resolve_path_alias(specifier)
}
#[must_use]
pub(crate) fn resolve_path_alias(&self, specifier: &str) -> Vec<PathBuf> {
if specifier.starts_with('.') {
return Vec::new();
}
let compiler_options = &self.compiler_options;
let Some(paths_map) = &compiler_options.paths else {
return vec![];
};
if let Some(paths) = paths_map.get(specifier) {
return paths.clone();
}
if let Some(compiled_paths) = &compiler_options.compiled_paths
&& let Some(paths) = compiled_paths.resolve(specifier)
{
return paths;
}
Vec::new()
}
pub(crate) fn resolve_base_url(&self, specifier: &str) -> Option<PathBuf> {
self.compiler_options
.base_url
.is_some()
.then(|| self.compiler_options.paths_base.normalize_with(specifier))
}
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompilerOptions {
pub base_url: Option<PathBuf>,
pub paths: Option<CompilerOptionsPathsMap>,
#[serde(skip)]
compiled_paths: Option<Arc<CompiledTsconfigPaths>>,
#[serde(skip)]
pub(crate) paths_base: PathBuf,
pub experimental_decorators: Option<bool>,
pub emit_decorator_metadata: Option<bool>,
pub use_define_for_class_fields: Option<bool>,
pub rewrite_relative_import_extensions: Option<bool>,
pub jsx: Option<String>,
pub jsx_factory: Option<String>,
pub jsx_fragment_factory: Option<String>,
pub jsx_import_source: Option<String>,
pub verbatim_module_syntax: Option<bool>,
pub preserve_value_imports: Option<bool>,
pub imports_not_used_as_values: Option<String>,
pub target: Option<String>,
pub module: Option<String>,
pub allow_js: Option<bool>,
pub root_dirs: Option<Vec<PathBuf>>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
#[serde(untagged)]
pub enum ExtendsField {
Single(String),
Multiple(Vec<String>),
}
#[derive(Clone, Copy)]
enum GlobPattern<'a> {
Pattern(&'a [PathBuf]),
All,
}
impl TsConfig {
pub(crate) fn resolve_tsconfig_solution(tsconfig: Arc<Self>, path: &Path) -> Arc<Self> {
if !tsconfig.references_resolved.is_empty()
&& tsconfig.is_file_extension_allowed_in_tsconfig(path)
&& !tsconfig.is_file_included_in_tsconfig(path)
&& let Some(solution_tsconfig) = tsconfig
.references_resolved
.iter()
.find(|referenced| referenced.is_file_included_in_tsconfig(path))
.map(Arc::clone)
{
return solution_tsconfig;
}
tsconfig
}
fn is_file_included_in_tsconfig(&self, path: &Path) -> bool {
if self.files.as_ref().is_some_and(|files| files.iter().any(|file| Path::new(file) == path))
{
return true;
}
let is_included = self.include.as_ref().map_or_else(
|| {
if self.files.is_some() {
false
} else {
self.is_glob_matches(path, GlobPattern::All)
}
},
|include_patterns| self.is_glob_matches(path, GlobPattern::Pattern(include_patterns)),
);
if is_included {
return self.exclude.as_ref().is_none_or(|exclude_patterns| {
!self.is_glob_matches(path, GlobPattern::Pattern(exclude_patterns))
});
}
false
}
fn is_glob_matches(&self, path: &Path, pattern: GlobPattern) -> bool {
let path_str = path.to_string_lossy().replace('\\', "/");
match pattern {
GlobPattern::All => self.is_glob_match(GLOB_ALL_PATTERN, path, &path_str),
GlobPattern::Pattern(patterns) => patterns.iter().any(|pattern| {
let pattern = pattern.to_string_lossy().replace('\\', "/");
self.is_glob_match(pattern.as_ref(), path, &path_str)
}),
}
}
fn is_glob_match(&self, pattern: &str, path: &Path, path_str: &str) -> bool {
if pattern == path_str {
return true;
}
if pattern == GLOB_ALL_PATTERN {
return true;
}
let after_last_slash = pattern.rsplit('/').next().unwrap_or(pattern);
let needs_implicit_glob = !after_last_slash.contains(['.', '*', '?']);
let pattern = if needs_implicit_glob {
Cow::Owned(format!(
"{pattern}{}",
if pattern.ends_with('/') { "**/*" } else { "/**/*" }
))
} else {
Cow::Borrowed(pattern)
};
if pattern.ends_with('*') && !self.is_file_extension_allowed_in_tsconfig(path) {
return false;
}
fast_glob::glob_match(pattern.as_ref(), path_str)
}
fn is_file_extension_allowed_in_tsconfig(&self, path: &Path) -> bool {
const TS_EXTENSIONS: [&str; 4] = ["ts", "tsx", "mts", "cts"];
const JS_EXTENSIONS: [&str; 4] = ["js", "jsx", "mjs", "cjs"];
let allow_js = self.compiler_options.allow_js.is_some_and(|b| b);
path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| {
TS_EXTENSIONS.contains(&ext)
|| if allow_js { JS_EXTENSIONS.contains(&ext) } else { false }
})
}
}
#[derive(Clone, Debug, Default)]
struct CompiledTsconfigPaths {
wildcard_patterns: Vec<CompiledTsconfigPathPattern>,
}
#[derive(Clone, Debug)]
struct CompiledTsconfigPathPattern {
prefix: CompactString,
suffix: CompactString,
prefix_len: usize,
suffix_len: usize,
targets: Vec<CompiledTsconfigPathTarget>,
}
#[derive(Clone, Debug)]
enum CompiledTsconfigPathTarget {
Static(PathBuf),
Wildcard { prefix: CompactString, suffix: CompactString },
}
impl CompiledTsconfigPaths {
fn new(paths_map: &CompilerOptionsPathsMap) -> Self {
let mut wildcard_patterns = paths_map
.iter()
.filter_map(|(key, paths)| {
let (prefix, suffix) = key.split_once('*')?;
let targets = paths
.iter()
.map(|path| {
let path_str = path.to_string_lossy();
path_str.split_once('*').map_or_else(
|| CompiledTsconfigPathTarget::Static(path.clone()),
|(target_prefix, target_suffix)| CompiledTsconfigPathTarget::Wildcard {
prefix: CompactString::new(target_prefix),
suffix: CompactString::new(target_suffix),
},
)
})
.collect::<Vec<_>>();
Some(CompiledTsconfigPathPattern {
prefix: CompactString::new(prefix),
suffix: CompactString::new(suffix),
prefix_len: prefix.len(),
suffix_len: suffix.len(),
targets,
})
})
.collect::<Vec<_>>();
wildcard_patterns.sort_by_key(|pattern| Reverse(pattern.prefix_len));
Self { wildcard_patterns }
}
fn resolve(&self, specifier: &str) -> Option<Vec<PathBuf>> {
self.wildcard_patterns.iter().find_map(|pattern| {
if !specifier.starts_with(pattern.prefix.as_str())
|| !specifier.ends_with(pattern.suffix.as_str())
|| specifier.len() < pattern.prefix_len + pattern.suffix_len
{
return None;
}
let wildcard = &specifier[pattern.prefix_len..specifier.len() - pattern.suffix_len];
Some(pattern.targets.iter().map(|target| target.resolve(wildcard)).collect())
})
}
}
impl CompiledTsconfigPathTarget {
fn resolve(&self, wildcard: &str) -> PathBuf {
match self {
Self::Static(path) => path.clone(),
Self::Wildcard { prefix, suffix } => {
let mut resolved =
String::with_capacity(prefix.len() + wildcard.len() + suffix.len());
resolved.push_str(prefix.as_str());
resolved.push_str(wildcard);
resolved.push_str(suffix.as_str());
PathBuf::from(resolved)
}
}
}
}