use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use petgraph::graph::NodeIndex;
use tracing::warn;
#[derive(Debug, Clone)]
pub struct TsConfigPaths {
pub base: PathBuf,
pub mappings: Vec<(String, Vec<String>)>,
}
pub(crate) fn strip_jsonc(input: &str) -> String {
let without_comments = {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let (mut in_str, mut in_block, mut in_line) = (false, false, false);
while let Some(c) = chars.next() {
if in_line {
if c == '\n' {
out.push('\n');
in_line = false;
}
continue;
}
if in_block {
if c == '*' && chars.peek() == Some(&'/') {
chars.next();
in_block = false;
}
continue;
}
if in_str {
out.push(c);
if c == '\\' {
if let Some(e) = chars.next() {
out.push(e);
}
} else if c == '"' {
in_str = false;
}
continue;
}
match c {
'"' => {
in_str = true;
out.push(c);
}
'/' => match chars.peek() {
Some('/') => {
chars.next();
in_line = true;
}
Some('*') => {
chars.next();
in_block = true;
}
_ => out.push(c),
},
_ => out.push(c),
}
}
out
};
let chars: Vec<char> = without_comments.chars().collect();
let mut out = String::with_capacity(without_comments.len());
let mut i = 0;
while i < chars.len() {
if chars[i] == ',' {
let mut j = i + 1;
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
i += 1;
continue;
}
}
out.push(chars[i]);
i += 1;
}
out
}
fn normalize_rel(p: PathBuf) -> PathBuf {
let mut out = PathBuf::new();
for comp in p.components() {
if let Component::Normal(n) = comp {
out.push(n);
}
}
out
}
pub fn parse_tsconfig_content(content: &str, tsconfig_rel_dir: &Path) -> Option<TsConfigPaths> {
let value: serde_json::Value = match serde_json::from_str(&strip_jsonc(content)) {
Ok(v) => v,
Err(e) => {
warn!(
"tsconfig.json unparseable after comment-strip: {}; alias resolution disabled",
e
);
return None;
}
};
let opts = value.get("compilerOptions")?;
let base_url = opts.get("baseUrl").and_then(|v| v.as_str());
let base = if let Some(url) = base_url {
normalize_rel(tsconfig_rel_dir.join(url))
} else {
normalize_rel(tsconfig_rel_dir.to_path_buf())
};
let paths_obj = match opts.get("paths").and_then(|v| v.as_object()) {
Some(obj) => obj,
None => {
return Some(TsConfigPaths {
base,
mappings: vec![],
})
}
};
let mut mappings = Vec::new();
for (key, val) in paths_obj {
if key.matches('*').count() > 1 {
warn!("tsconfig.json: pattern '{}' has >1 '*'; skipping", key);
continue;
}
let targets: Vec<String> = val
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
mappings.push((key.clone(), targets));
}
Some(TsConfigPaths { base, mappings })
}
fn match_alias(pattern: &str, specifier: &str) -> Option<String> {
match pattern.find('*') {
None => (specifier == pattern).then(String::new),
Some(star) => {
let (pre, suf) = (&pattern[..star], &pattern[star + 1..]);
if specifier.starts_with(pre)
&& specifier.ends_with(suf)
&& specifier.len() >= pre.len() + suf.len()
{
Some(specifier[pre.len()..specifier.len() - suf.len()].to_string())
} else {
None
}
}
}
}
fn apply_capture(target: &str, capture: &str) -> String {
match target.find('*') {
None => target.to_string(),
Some(s) => format!("{}{}{}", &target[..s], capture, &target[s + 1..]),
}
}
pub(crate) fn resolve_tsconfig_alias(
specifier: &str,
paths: &TsConfigPaths,
path_to_node: &BTreeMap<PathBuf, NodeIndex>,
) -> Vec<NodeIndex> {
for (pattern, targets) in &paths.mappings {
let Some(capture) = match_alias(pattern, specifier) else {
continue;
};
for target in targets {
let sub = apply_capture(target, &capture);
let rem = sub.strip_prefix("./").unwrap_or(&sub);
let node = crate::resolve::try_path(&paths.base, rem, "typescript", path_to_node)
.or_else(|| crate::resolve::try_path(&paths.base, rem, "javascript", path_to_node));
if let Some(ni) = node {
return vec![ni];
}
}
}
vec![]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_line_comment() {
let v: serde_json::Value =
serde_json::from_str(&strip_jsonc("{ \"a\": 1 // c\n}")).unwrap();
assert_eq!(v["a"], 1);
}
#[test]
fn strip_block_comment() {
let v: serde_json::Value =
serde_json::from_str(&strip_jsonc(r#"{"a": /* c */ 2}"#)).unwrap();
assert_eq!(v["a"], 2);
}
#[test]
fn preserve_url_in_string() {
let v: serde_json::Value =
serde_json::from_str(&strip_jsonc(r#"{"u":"http://x.com"}"#)).unwrap();
assert_eq!(v["u"], "http://x.com");
}
#[test]
fn trailing_comma_object() {
let v: serde_json::Value = serde_json::from_str(&strip_jsonc(r#"{"a":1,}"#)).unwrap();
assert_eq!(v["a"], 1);
}
#[test]
fn trailing_comma_array() {
let v: serde_json::Value = serde_json::from_str(&strip_jsonc(r#"[1,2,]"#)).unwrap();
assert_eq!(v, serde_json::json!([1, 2]));
}
#[test]
fn match_exact() {
assert_eq!(match_alias("~lib", "~lib"), Some(String::new()));
assert_eq!(match_alias("~lib", "~other"), None);
}
#[test]
fn match_wildcard_prefix() {
assert_eq!(match_alias("@/*", "@/lib/foo"), Some("lib/foo".to_string()));
assert_eq!(match_alias("@/*", "other"), None);
}
#[test]
fn match_prefix_suffix() {
assert_eq!(
match_alias("#int/*.t", "#int/foo.t"),
Some("foo".to_string())
);
}
#[test]
fn apply_no_star() {
assert_eq!(apply_capture("./index.ts", ""), "./index.ts");
}
#[test]
fn apply_star() {
assert_eq!(apply_capture("./*", "lib/foo"), "./lib/foo");
}
}