use crate::manifest::walk_filter;
use std::collections::BTreeMap;
use std::path::Path;
use walkdir::WalkDir;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct TsPathsConfig {
pub base: String,
pub paths: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Default)]
pub struct JsWorkspace {
configs: BTreeMap<String, TsPathsConfig>,
packages: BTreeMap<String, String>,
}
impl JsWorkspace {
#[cfg(test)]
pub(crate) fn from_raw(configs: &[(&str, TsPathsConfig)], packages: &[(&str, &str)]) -> Self {
Self {
configs: configs
.iter()
.map(|(dir, config)| ((*dir).to_string(), config.clone()))
.collect(),
packages: packages
.iter()
.map(|(name, dir)| ((*name).to_string(), (*dir).to_string()))
.collect(),
}
}
pub fn is_empty(&self) -> bool {
self.configs.is_empty() && self.packages.is_empty()
}
pub fn discover(root: &Path) -> Self {
let mut out = Self::default();
for entry in WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(walk_filter)
.filter_map(Result::ok)
{
if !entry.file_type().is_file() {
continue;
}
let name = entry.file_name().to_string_lossy();
let is_tsconfig = name == "tsconfig.json";
let is_package = name == "package.json";
if !is_tsconfig && !is_package {
continue;
}
let Some(dir) = relative_dir(root, entry.path()) else {
continue;
};
let Ok(text) = std::fs::read_to_string(entry.path()) else {
continue;
};
if is_tsconfig {
if let Some(config) = parse_tsconfig(&text, &dir) {
out.configs.insert(dir, config);
}
} else if let Some(pkg) = parse_package_name(&text) {
match out.packages.get(&pkg) {
Some(existing) if shallower(existing, &dir) => {}
_ => {
out.packages.insert(pkg, dir);
}
}
}
}
out
}
pub fn alias_targets(&self, importer_dir: &str, specifier: &str) -> Vec<String> {
let Some(config) = self.nearest_config(importer_dir) else {
return Vec::new();
};
let Some((pattern, targets)) = best_pattern(&config.paths, specifier) else {
return Vec::new();
};
let stem = match pattern.split_once('*') {
Some((prefix, suffix)) => &specifier[prefix.len()..specifier.len() - suffix.len()],
None => "",
};
targets
.iter()
.map(|target| join_rel(&config.base, &target.replace('*', stem)))
.collect()
}
pub fn package_targets(&self, specifier: &str) -> Vec<String> {
let mut best: Option<(&String, &String)> = None;
for (name, dir) in &self.packages {
let matches = match specifier.strip_prefix(name.as_str()) {
Some("") => true,
Some(rest) => rest.starts_with('/'),
None => false,
};
if matches && best.is_none_or(|(current, _)| name.len() > current.len()) {
best = Some((name, dir));
}
}
let Some((name, dir)) = best else {
return Vec::new();
};
let rest = specifier[name.len()..].trim_start_matches('/');
if rest.is_empty() {
vec![
dir.clone(),
join_rel(dir, "src"),
join_rel(dir, "src/index"),
]
} else {
vec![join_rel(dir, rest), join_rel(dir, &format!("src/{rest}"))]
}
}
fn nearest_config(&self, importer_dir: &str) -> Option<&TsPathsConfig> {
let mut dir = importer_dir;
loop {
if let Some(config) = self.configs.get(dir) {
return Some(config);
}
match dir.rsplit_once('/') {
Some((parent, _)) => dir = parent,
None if dir.is_empty() => return None,
None => dir = "",
}
}
}
}
fn shallower(a: &str, b: &str) -> bool {
let depth = |s: &str| {
if s.is_empty() {
0
} else {
s.matches('/').count() + 1
}
};
(depth(a), a) < (depth(b), b)
}
fn relative_dir(root: &Path, file: &Path) -> Option<String> {
let rel = file.strip_prefix(root).ok()?;
let parent = rel.parent()?;
Some(parent.to_string_lossy().replace('\\', "/"))
}
fn join_rel(base: &str, rel: &str) -> String {
let mut parts: Vec<&str> = Vec::new();
for part in base.split('/').chain(rel.split('/')) {
match part {
"" | "." => {}
".." => {
parts.pop();
}
value => parts.push(value),
}
}
parts.join("/")
}
fn best_pattern<'a>(
paths: &'a BTreeMap<String, Vec<String>>,
specifier: &str,
) -> Option<(&'a str, &'a Vec<String>)> {
let mut best: Option<(&str, &Vec<String>)> = None;
for (pattern, targets) in paths {
let matches = match pattern.split_once('*') {
None => pattern == specifier,
Some((prefix, suffix)) => {
specifier.len() >= prefix.len() + suffix.len()
&& specifier.starts_with(prefix)
&& specifier.ends_with(suffix)
}
};
if !matches {
continue;
}
if pattern.contains('*') {
let prefix_len = pattern.split_once('*').map_or(0, |(p, _)| p.len());
match best {
Some((current, _)) if !current.contains('*') => {}
Some((current, _)) => {
let current_len = current.split_once('*').map_or(0, |(p, _)| p.len());
if (prefix_len, pattern.as_str()) > (current_len, current) {
best = Some((pattern, targets));
}
}
None => best = Some((pattern, targets)),
}
} else {
return Some((pattern, targets));
}
}
best
}
fn parse_tsconfig(text: &str, dir: &str) -> Option<TsPathsConfig> {
let value: serde_json::Value = serde_json::from_str(&strip_jsonc(text)).ok()?;
let options = value.get("compilerOptions")?;
let base_url = options
.get("baseUrl")
.and_then(|v| v.as_str())
.unwrap_or(".");
let paths_obj = options.get("paths")?.as_object()?;
let mut paths = BTreeMap::new();
for (pattern, targets) in paths_obj {
let list: Vec<String> = targets
.as_array()?
.iter()
.filter_map(|t| t.as_str().map(str::to_string))
.collect();
if !list.is_empty() {
paths.insert(pattern.clone(), list);
}
}
if paths.is_empty() {
return None;
}
Some(TsPathsConfig {
base: join_rel(dir, base_url),
paths,
})
}
fn parse_package_name(text: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(&strip_jsonc(text)).ok()?;
let name = value.get("name")?.as_str()?.trim();
(!name.is_empty()).then(|| name.to_string())
}
fn strip_jsonc(src: &str) -> String {
let mut out = String::with_capacity(src.len());
let chars: Vec<char> = src.chars().collect();
let mut i = 0;
let mut in_string = false;
while i < chars.len() {
let c = chars[i];
if in_string {
out.push(c);
if c == '\\' && i + 1 < chars.len() {
out.push(chars[i + 1]);
i += 2;
continue;
}
if c == '"' {
in_string = false;
}
i += 1;
continue;
}
let next = chars.get(i + 1).copied();
match c {
'"' => {
in_string = true;
out.push('"');
i += 1;
}
'/' if next == Some('/') => {
while i < chars.len() && chars[i] != '\n' {
i += 1;
}
}
'/' if next == Some('*') => {
i += 2;
while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
i += 1;
}
i = (i + 2).min(chars.len());
}
',' => {
let mut j = i + 1;
while j < chars.len() && chars[j].is_whitespace() {
j += 1;
}
if matches!(chars.get(j), Some('}') | Some(']')) {
i += 1;
} else {
out.push(',');
i += 1;
}
}
_ => {
out.push(c);
i += 1;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jsonc_stripper_removes_comments_and_trailing_commas() {
let src = r#"{
// line comment
"compilerOptions": {
/* block
comment */
"baseUrl": ".",
"paths": { "@/*": ["./src/*"], },
},
}"#;
let value: serde_json::Value = serde_json::from_str(&strip_jsonc(src)).expect("parses");
assert_eq!(value["compilerOptions"]["baseUrl"], ".");
assert_eq!(value["compilerOptions"]["paths"]["@/*"][0], "./src/*");
}
#[test]
fn jsonc_stripper_is_string_literal_aware() {
let src = r#"{
"name": "https://example.com/pkg",
"a": "/* not a comment */",
"b": "trailing, }",
"c": "escaped \" quote // still in string",
"d": "unicode ✓ ok"
}"#;
let value: serde_json::Value = serde_json::from_str(&strip_jsonc(src)).expect("parses");
assert_eq!(value["name"], "https://example.com/pkg");
assert_eq!(value["a"], "/* not a comment */");
assert_eq!(value["b"], "trailing, }");
assert_eq!(value["c"], "escaped \" quote // still in string");
assert_eq!(value["d"], "unicode ✓ ok");
}
fn config(base: &str, entries: &[(&str, &[&str])]) -> TsPathsConfig {
TsPathsConfig {
base: base.into(),
paths: entries
.iter()
.map(|(p, ts)| ((*p).to_string(), ts.iter().map(|t| t.to_string()).collect()))
.collect(),
}
}
fn workspace(configs: &[(&str, TsPathsConfig)], packages: &[(&str, &str)]) -> JsWorkspace {
JsWorkspace::from_raw(configs, packages)
}
#[test]
fn alias_uses_the_nearest_ancestor_tsconfig() {
let ws = workspace(
&[
("", config(".", &[("@/*", &["./shared/*"])])),
(
"packages/app",
config("packages/app", &[("@/*", &["./src/*"])]),
),
],
&[],
);
assert_eq!(
ws.alias_targets("packages/app/src/deep", "@/util"),
vec!["packages/app/src/util"]
);
assert_eq!(ws.alias_targets("other/dir", "@/util"), vec!["shared/util"]);
assert!(ws.alias_targets("packages/app/src", "zod").is_empty());
}
#[test]
fn alias_patterns_prefer_exact_then_longest_literal_prefix() {
let ws = workspace(
&[(
"",
config(
".",
&[
("@/*", &["./generic/*"]),
("@/deep/*", &["./specific/*"]),
("@/deep/exact", &["./pinned"]),
],
),
)],
&[],
);
assert_eq!(ws.alias_targets("", "@/deep/exact"), vec!["pinned"]);
assert_eq!(ws.alias_targets("", "@/deep/thing"), vec!["specific/thing"]);
assert_eq!(ws.alias_targets("", "@/other"), vec!["generic/other"]);
}
#[test]
fn alias_keeps_every_target_in_config_order() {
let ws = workspace(
&[("", config(".", &[("@/*", &["./first/*", "./second/*"])]))],
&[],
);
assert_eq!(
ws.alias_targets("", "@/x"),
vec!["first/x", "second/x"],
"TS tries path targets in order; so must we"
);
}
#[test]
fn alias_substitutes_a_suffixed_wildcard() {
let ws = workspace(
&[("", config(".", &[("#db/*.js", &["./src/db/*.ts"])]))],
&[],
);
assert_eq!(
ws.alias_targets("", "#db/schema.js"),
vec!["src/db/schema.ts"]
);
}
#[test]
fn workspace_packages_match_the_longest_name_prefix() {
let ws = workspace(
&[],
&[
("@scope/core", "packages/core"),
("@scope/core-extra", "packages/core-extra"),
],
);
assert_eq!(
ws.package_targets("@scope/core"),
vec![
"packages/core",
"packages/core/src",
"packages/core/src/index"
]
);
assert_eq!(
ws.package_targets("@scope/core/sub/path"),
vec!["packages/core/sub/path", "packages/core/src/sub/path"]
);
assert_eq!(
ws.package_targets("@scope/core-extra/x"),
vec!["packages/core-extra/x", "packages/core-extra/src/x"]
);
assert!(ws.package_targets("@other/thing").is_empty());
assert!(ws.package_targets("@scope/corely").is_empty());
}
#[test]
fn discovery_reads_nested_configs_and_skips_node_modules() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
std::fs::create_dir_all(root.join("packages/app/src")).unwrap();
std::fs::create_dir_all(root.join("node_modules/evil")).unwrap();
std::fs::write(root.join("package.json"), r#"{"name": "root-pkg"}"#).unwrap();
std::fs::write(
root.join("tsconfig.json"),
r#"{ "extends": "@tsconfig/bun" }"#,
)
.unwrap();
std::fs::write(
root.join("packages/app/package.json"),
r#"{"name": "@scope/app"}"#,
)
.unwrap();
std::fs::write(
root.join("packages/app/tsconfig.json"),
"{\n // aliases\n \"compilerOptions\": { \"paths\": { \"@/*\": [\"./src/*\"] } }\n}",
)
.unwrap();
std::fs::write(
root.join("node_modules/evil/package.json"),
r#"{"name": "@scope/app"}"#,
)
.unwrap();
let ws = JsWorkspace::discover(root);
assert_eq!(
ws.packages.get("@scope/app").map(String::as_str),
Some("packages/app"),
"node_modules must not contribute packages"
);
assert_eq!(ws.packages.get("root-pkg").map(String::as_str), Some(""));
assert!(!ws.configs.contains_key(""));
assert_eq!(
ws.alias_targets("packages/app/src", "@/util"),
vec!["packages/app/src/util"]
);
}
#[test]
fn unparseable_config_is_skipped_rather_than_fatal() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("tsconfig.json"), "{ this is not json").unwrap();
std::fs::write(tmp.path().join("package.json"), "}{").unwrap();
let ws = JsWorkspace::discover(tmp.path());
assert!(ws.is_empty());
}
}