tsparser/src/tsconfig.rs
Rama main · réplica de lectura del repositorio original.
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<PathEntry>,
}
#[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<Self, TsConfigError> {
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<Self, TsConfigError> {
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<PathEntry> = 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<Cow<'_, str>> {
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<Cow<'_, str>> {
self.resolve_with_checker(import, |p| p.exists())
}
}
#[derive(Debug)]
struct PathEntry {
key: PathVal,
values: Vec<PathVal>,
}
/// 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<String> 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<PathBuf>,
#[serde(default)]
pub paths: IndexMap<String, Vec<String>>,
}
/// 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<char> = 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<String>,
candidates: &'static [&'static str],
idx: usize,
}
impl Iterator for PathResolveIterator {
type Item = PathBuf;
fn next(&mut self) -> Option<Self::Item> {
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<Item = PathBuf> {
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,
}
}