use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use streaming_iterator::StreamingIterator;
use tree_sitter::{Node, Query, QueryCursor};
use super::{cached_query, TypeScriptExtractor};
const PRODUCTION_FUNCTION_QUERY: &str = include_str!("../queries/production_function.scm");
static PRODUCTION_FUNCTION_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
const IMPORT_MAPPING_QUERY: &str = include_str!("../queries/import_mapping.scm");
static IMPORT_MAPPING_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
const RE_EXPORT_QUERY: &str = include_str!("../queries/re_export.scm");
static RE_EXPORT_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
const EXPORTED_SYMBOL_QUERY: &str = include_str!("../queries/exported_symbol.scm");
static EXPORTED_SYMBOL_QUERY_CACHE: OnceLock<Query> = OnceLock::new();
const MAX_BARREL_DEPTH: usize = 3;
#[derive(Debug, Clone, PartialEq)]
pub struct ProductionFunction {
pub name: String,
pub file: String,
pub line: usize,
pub class_name: Option<String>,
pub is_exported: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Route {
pub http_method: String,
pub path: String,
pub handler_name: String,
pub class_name: String,
pub file: String,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DecoratorInfo {
pub name: String,
pub arguments: Vec<String>,
pub target_name: String,
pub class_name: String,
pub file: String,
pub line: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FileMapping {
pub production_file: String,
pub test_files: Vec<String>,
pub strategy: MappingStrategy,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MappingStrategy {
FileNameConvention,
ImportTracing,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ImportMapping {
pub symbol_name: String,
pub module_specifier: String,
pub file: String,
pub line: usize,
pub symbols: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BarrelReExport {
pub symbols: Vec<String>,
pub from_specifier: String,
pub wildcard: bool,
}
const HTTP_METHODS: &[&str] = &["Get", "Post", "Put", "Patch", "Delete", "Head", "Options"];
const GAP_RELEVANT_DECORATORS: &[&str] = &[
"UseGuards",
"UsePipes",
"IsEmail",
"IsNotEmpty",
"MinLength",
"MaxLength",
"IsOptional",
"IsString",
"IsNumber",
"IsInt",
"IsBoolean",
"IsDate",
"IsEnum",
"IsArray",
"ValidateNested",
"Min",
"Max",
"Matches",
"IsUrl",
"IsUUID",
];
impl TypeScriptExtractor {
pub fn map_test_files(
&self,
production_files: &[String],
test_files: &[String],
) -> Vec<FileMapping> {
let mut tests_by_key: HashMap<(String, String), Vec<String>> = HashMap::new();
for test_file in test_files {
let Some(stem) = test_stem(test_file) else {
continue;
};
let directory = Path::new(test_file)
.parent()
.map(|parent| parent.to_string_lossy().into_owned())
.unwrap_or_default();
tests_by_key
.entry((directory, stem.to_string()))
.or_default()
.push(test_file.clone());
}
production_files
.iter()
.map(|production_file| {
let test_matches = production_stem(production_file)
.and_then(|stem| {
let directory = Path::new(production_file)
.parent()
.map(|parent| parent.to_string_lossy().into_owned())
.unwrap_or_default();
tests_by_key.get(&(directory, stem.to_string())).cloned()
})
.unwrap_or_default();
FileMapping {
production_file: production_file.clone(),
test_files: test_matches,
strategy: MappingStrategy::FileNameConvention,
}
})
.collect()
}
pub fn extract_routes(&self, source: &str, file_path: &str) -> Vec<Route> {
let mut parser = Self::parser();
let tree = match parser.parse(source, None) {
Some(t) => t,
None => return Vec::new(),
};
let source_bytes = source.as_bytes();
let mut routes = Vec::new();
for node in iter_children(tree.root_node()) {
let (container, class_node) = match node.kind() {
"export_statement" => {
let cls = node
.named_children(&mut node.walk())
.find(|c| c.kind() == "class_declaration");
match cls {
Some(c) => (node, c),
None => continue,
}
}
"class_declaration" => (node, node),
_ => continue,
};
let (base_path, class_name) =
match extract_controller_info(container, class_node, source_bytes) {
Some(info) => info,
None => continue,
};
let class_body = match class_node.child_by_field_name("body") {
Some(b) => b,
None => continue,
};
let mut decorator_acc: Vec<Node> = Vec::new();
for child in iter_children(class_body) {
match child.kind() {
"decorator" => decorator_acc.push(child),
"method_definition" => {
let handler_name = child
.child_by_field_name("name")
.and_then(|n| n.utf8_text(source_bytes).ok())
.unwrap_or("")
.to_string();
let line = child.start_position().row + 1;
for dec in &decorator_acc {
if let Some((dec_name, dec_arg)) =
extract_decorator_call(*dec, source_bytes)
{
if HTTP_METHODS.contains(&dec_name.as_str()) {
let sub_path = dec_arg.unwrap_or_default();
routes.push(Route {
http_method: dec_name.to_uppercase(),
path: normalize_path(&base_path, &sub_path),
handler_name: handler_name.clone(),
class_name: class_name.clone(),
file: file_path.to_string(),
line,
});
}
}
}
decorator_acc.clear();
}
_ => {}
}
}
}
routes
}
pub fn extract_decorators(&self, source: &str, file_path: &str) -> Vec<DecoratorInfo> {
let mut parser = Self::parser();
let tree = match parser.parse(source, None) {
Some(t) => t,
None => return Vec::new(),
};
let source_bytes = source.as_bytes();
let mut decorators = Vec::new();
for node in iter_children(tree.root_node()) {
let (container, class_node) = match node.kind() {
"export_statement" => {
let cls = node
.named_children(&mut node.walk())
.find(|c| c.kind() == "class_declaration");
match cls {
Some(c) => (node, c),
None => continue,
}
}
"class_declaration" => (node, node),
_ => continue,
};
let class_name = class_node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(source_bytes).ok())
.unwrap_or("")
.to_string();
let class_level_decorators: Vec<Node> = find_decorators_on_node(container, class_node);
collect_gap_decorators(
&class_level_decorators,
&class_name, &class_name,
file_path,
source_bytes,
&mut decorators,
);
let class_body = match class_node.child_by_field_name("body") {
Some(b) => b,
None => continue,
};
let mut decorator_acc: Vec<Node> = Vec::new();
for child in iter_children(class_body) {
match child.kind() {
"decorator" => decorator_acc.push(child),
"method_definition" => {
let method_name = child
.child_by_field_name("name")
.and_then(|n| n.utf8_text(source_bytes).ok())
.unwrap_or("")
.to_string();
collect_gap_decorators(
&decorator_acc,
&method_name,
&class_name,
file_path,
source_bytes,
&mut decorators,
);
decorator_acc.clear();
}
"public_field_definition" => {
let field_name = child
.child_by_field_name("name")
.and_then(|n| n.utf8_text(source_bytes).ok())
.unwrap_or("")
.to_string();
let field_decorators: Vec<Node> = iter_children(child)
.filter(|c| c.kind() == "decorator")
.collect();
collect_gap_decorators(
&field_decorators,
&field_name,
&class_name,
file_path,
source_bytes,
&mut decorators,
);
decorator_acc.clear();
}
_ => {}
}
}
}
decorators
}
pub fn extract_production_functions(
&self,
source: &str,
file_path: &str,
) -> Vec<ProductionFunction> {
let mut parser = Self::parser();
let tree = match parser.parse(source, None) {
Some(t) => t,
None => return Vec::new(),
};
let query = cached_query(&PRODUCTION_FUNCTION_QUERY_CACHE, PRODUCTION_FUNCTION_QUERY);
let mut cursor = QueryCursor::new();
let source_bytes = source.as_bytes();
let idx_name = query
.capture_index_for_name("name")
.expect("@name capture not found in production_function.scm");
let idx_exported_function = query
.capture_index_for_name("exported_function")
.expect("@exported_function capture not found");
let idx_function = query
.capture_index_for_name("function")
.expect("@function capture not found");
let idx_method = query
.capture_index_for_name("method")
.expect("@method capture not found");
let idx_exported_arrow = query
.capture_index_for_name("exported_arrow")
.expect("@exported_arrow capture not found");
let idx_arrow = query
.capture_index_for_name("arrow")
.expect("@arrow capture not found");
let mut dedup: HashMap<(usize, String), ProductionFunction> = HashMap::new();
let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
while let Some(m) = matches.next() {
let name_node = match m.captures.iter().find(|c| c.index == idx_name) {
Some(c) => c.node,
None => continue,
};
let name = name_node.utf8_text(source_bytes).unwrap_or("").to_string();
let line = name_node.start_position().row + 1;
let (is_exported, class_name) = if m
.captures
.iter()
.any(|c| c.index == idx_exported_function || c.index == idx_exported_arrow)
{
(true, None)
} else if m
.captures
.iter()
.any(|c| c.index == idx_function || c.index == idx_arrow)
{
(false, None)
} else if let Some(c) = m.captures.iter().find(|c| c.index == idx_method) {
let (cname, exported) = find_class_info(c.node, source_bytes);
(exported, cname)
} else {
continue;
};
dedup
.entry((line, name.clone()))
.and_modify(|existing| {
if is_exported {
existing.is_exported = true;
}
})
.or_insert(ProductionFunction {
name,
file: file_path.to_string(),
line,
class_name,
is_exported,
});
}
let mut results: Vec<ProductionFunction> = dedup.into_values().collect();
results.sort_by_key(|f| f.line);
results
}
}
fn iter_children(node: Node) -> impl Iterator<Item = Node> {
(0..node.child_count()).filter_map(move |i| node.child(i))
}
fn extract_controller_info(
container: Node,
class_node: Node,
source: &[u8],
) -> Option<(String, String)> {
let class_name = class_node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(source).ok())?
.to_string();
for search_node in [container, class_node] {
for i in 0..search_node.child_count() {
let child = match search_node.child(i) {
Some(c) => c,
None => continue,
};
if child.kind() != "decorator" {
continue;
}
if let Some((name, arg)) = extract_decorator_call(child, source) {
if name == "Controller" {
let base_path = arg.unwrap_or_default();
return Some((base_path, class_name));
}
}
}
}
None
}
fn collect_gap_decorators(
decorator_acc: &[Node],
target_name: &str,
class_name: &str,
file_path: &str,
source: &[u8],
output: &mut Vec<DecoratorInfo>,
) {
for dec in decorator_acc {
if let Some((dec_name, _)) = extract_decorator_call(*dec, source) {
if GAP_RELEVANT_DECORATORS.contains(&dec_name.as_str()) {
let args = extract_decorator_args(*dec, source);
output.push(DecoratorInfo {
name: dec_name,
arguments: args,
target_name: target_name.to_string(),
class_name: class_name.to_string(),
file: file_path.to_string(),
line: dec.start_position().row + 1,
});
}
}
}
}
fn extract_decorator_call(decorator_node: Node, source: &[u8]) -> Option<(String, Option<String>)> {
for i in 0..decorator_node.child_count() {
let child = match decorator_node.child(i) {
Some(c) => c,
None => continue,
};
match child.kind() {
"call_expression" => {
let func_node = child.child_by_field_name("function")?;
let name = func_node.utf8_text(source).ok()?.to_string();
let args_node = child.child_by_field_name("arguments")?;
if args_node.named_child_count() == 0 {
return Some((name, None));
}
let first_string = find_first_string_arg(args_node, source);
if first_string.is_some() {
return Some((name, first_string));
}
return Some((name, Some("<dynamic>".to_string())));
}
"identifier" => {
let name = child.utf8_text(source).ok()?.to_string();
return Some((name, None));
}
_ => {}
}
}
None
}
fn extract_decorator_args(decorator_node: Node, source: &[u8]) -> Vec<String> {
let mut args = Vec::new();
for i in 0..decorator_node.child_count() {
let child = match decorator_node.child(i) {
Some(c) => c,
None => continue,
};
if child.kind() == "call_expression" {
if let Some(args_node) = child.child_by_field_name("arguments") {
for j in 0..args_node.named_child_count() {
if let Some(arg) = args_node.named_child(j) {
if let Ok(text) = arg.utf8_text(source) {
args.push(text.to_string());
}
}
}
}
}
}
args
}
fn find_first_string_arg(args_node: Node, source: &[u8]) -> Option<String> {
for i in 0..args_node.named_child_count() {
let arg = args_node.named_child(i)?;
if arg.kind() == "string" {
let text = arg.utf8_text(source).ok()?;
let stripped = text.trim_matches(|c| c == '\'' || c == '"');
if !stripped.is_empty() {
return Some(stripped.to_string());
}
}
}
None
}
fn normalize_path(base: &str, sub: &str) -> String {
let base = base.trim_matches('/');
let sub = sub.trim_matches('/');
match (base.is_empty(), sub.is_empty()) {
(true, true) => "/".to_string(),
(true, false) => format!("/{sub}"),
(false, true) => format!("/{base}"),
(false, false) => format!("/{base}/{sub}"),
}
}
fn find_decorators_on_node<'a>(container: Node<'a>, class_node: Node<'a>) -> Vec<Node<'a>> {
let mut result = Vec::new();
for search_node in [container, class_node] {
for i in 0..search_node.child_count() {
if let Some(child) = search_node.child(i) {
if child.kind() == "decorator" {
result.push(child);
}
}
}
}
result
}
fn find_class_info(method_node: Node, source: &[u8]) -> (Option<String>, bool) {
let mut current = method_node.parent();
while let Some(node) = current {
if node.kind() == "class_body" {
if let Some(class_node) = node.parent() {
let class_kind = class_node.kind();
if class_kind == "class_declaration"
|| class_kind == "class"
|| class_kind == "abstract_class_declaration"
{
let class_name = class_node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(source).ok())
.map(|s| s.to_string());
let is_exported = class_node
.parent()
.is_some_and(|p| p.kind() == "export_statement");
return (class_name, is_exported);
}
}
}
current = node.parent();
}
(None, false)
}
fn is_type_only_import(symbol_node: Node) -> bool {
let parent = symbol_node.parent();
if let Some(p) = parent {
if p.kind() == "import_specifier" {
for i in 0..p.child_count() {
if let Some(child) = p.child(i) {
if child.kind() == "type" {
return true;
}
}
}
}
}
let mut current = Some(symbol_node);
while let Some(node) = current {
if node.kind() == "import_statement" {
for i in 0..node.child_count() {
if let Some(child) = node.child(i) {
if child.kind() == "type" {
return true;
}
}
}
break;
}
current = node.parent();
}
false
}
impl TypeScriptExtractor {
pub fn extract_imports(&self, source: &str, file_path: &str) -> Vec<ImportMapping> {
let mut parser = Self::parser();
let tree = match parser.parse(source, None) {
Some(t) => t,
None => return Vec::new(),
};
let source_bytes = source.as_bytes();
let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
let symbol_idx = query.capture_index_for_name("symbol_name").unwrap();
let specifier_idx = query.capture_index_for_name("module_specifier").unwrap();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
let mut result = Vec::new();
while let Some(m) = matches.next() {
let mut symbol_node = None;
let mut symbol = None;
let mut specifier = None;
let mut symbol_line = 0usize;
for cap in m.captures {
if cap.index == symbol_idx {
symbol_node = Some(cap.node);
symbol = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
symbol_line = cap.node.start_position().row + 1;
} else if cap.index == specifier_idx {
specifier = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
}
}
if let (Some(sym), Some(spec)) = (symbol, specifier) {
if !spec.starts_with("./") && !spec.starts_with("../") {
continue;
}
if let Some(snode) = symbol_node {
if is_type_only_import(snode) {
continue;
}
}
result.push(ImportMapping {
symbol_name: sym.to_string(),
module_specifier: spec.to_string(),
file: file_path.to_string(),
line: symbol_line,
symbols: Vec::new(),
});
}
}
let specifier_to_symbols: HashMap<String, Vec<String>> =
result.iter().fold(HashMap::new(), |mut acc, im| {
acc.entry(im.module_specifier.clone())
.or_default()
.push(im.symbol_name.clone());
acc
});
for im in &mut result {
im.symbols = specifier_to_symbols
.get(&im.module_specifier)
.cloned()
.unwrap_or_default();
}
result
}
pub fn extract_all_import_specifiers(&self, source: &str) -> Vec<(String, Vec<String>)> {
let mut parser = Self::parser();
let tree = match parser.parse(source, None) {
Some(t) => t,
None => return Vec::new(),
};
let source_bytes = source.as_bytes();
let query = cached_query(&IMPORT_MAPPING_QUERY_CACHE, IMPORT_MAPPING_QUERY);
let symbol_idx = query.capture_index_for_name("symbol_name").unwrap();
let specifier_idx = query.capture_index_for_name("module_specifier").unwrap();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
let mut specifier_symbols: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
while let Some(m) = matches.next() {
let mut symbol_node = None;
let mut symbol = None;
let mut specifier = None;
for cap in m.captures {
if cap.index == symbol_idx {
symbol_node = Some(cap.node);
symbol = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
} else if cap.index == specifier_idx {
specifier = Some(cap.node.utf8_text(source_bytes).unwrap_or(""));
}
}
if let (Some(sym), Some(spec)) = (symbol, specifier) {
if spec.starts_with("./") || spec.starts_with("../") {
continue;
}
if let Some(snode) = symbol_node {
if is_type_only_import(snode) {
continue;
}
}
specifier_symbols
.entry(spec.to_string())
.or_default()
.push(sym.to_string());
}
}
specifier_symbols.into_iter().collect()
}
pub fn extract_barrel_re_exports(&self, source: &str, _file_path: &str) -> Vec<BarrelReExport> {
let mut parser = Self::parser();
let tree = match parser.parse(source, None) {
Some(t) => t,
None => return Vec::new(),
};
let source_bytes = source.as_bytes();
let query = cached_query(&RE_EXPORT_QUERY_CACHE, RE_EXPORT_QUERY);
let symbol_idx = query.capture_index_for_name("symbol_name");
let wildcard_idx = query.capture_index_for_name("wildcard");
let specifier_idx = query
.capture_index_for_name("from_specifier")
.expect("@from_specifier capture not found in re_export.scm");
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
struct ReExportEntry {
symbols: Vec<String>,
wildcard: bool,
}
let mut grouped: HashMap<String, ReExportEntry> = HashMap::new();
while let Some(m) = matches.next() {
let mut from_spec = None;
let mut sym_name = None;
let mut is_wildcard = false;
for cap in m.captures {
if wildcard_idx == Some(cap.index) {
is_wildcard = true;
} else if cap.index == specifier_idx {
from_spec = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
} else if symbol_idx == Some(cap.index) {
sym_name = Some(cap.node.utf8_text(source_bytes).unwrap_or("").to_string());
}
}
let Some(spec) = from_spec else { continue };
let entry = grouped.entry(spec).or_insert(ReExportEntry {
symbols: Vec::new(),
wildcard: false,
});
if is_wildcard {
entry.wildcard = true;
}
if let Some(sym) = sym_name {
if !sym.is_empty() && !entry.symbols.contains(&sym) {
entry.symbols.push(sym);
}
}
}
grouped
.into_iter()
.map(|(from_spec, entry)| BarrelReExport {
symbols: entry.symbols,
from_specifier: from_spec,
wildcard: entry.wildcard,
})
.collect()
}
pub fn map_test_files_with_imports(
&self,
production_files: &[String],
test_sources: &HashMap<String, String>,
scan_root: &Path,
) -> Vec<FileMapping> {
let test_file_list: Vec<String> = test_sources.keys().cloned().collect();
let mut mappings = self.map_test_files(production_files, &test_file_list);
let canonical_root = match scan_root.canonicalize() {
Ok(r) => r,
Err(_) => return mappings,
};
let mut canonical_to_idx: HashMap<String, usize> = HashMap::new();
for (idx, prod) in production_files.iter().enumerate() {
if let Ok(canonical) = Path::new(prod).canonicalize() {
canonical_to_idx.insert(canonical.to_string_lossy().into_owned(), idx);
}
}
let layer1_matched: std::collections::HashSet<String> = mappings
.iter()
.flat_map(|m| m.test_files.iter().cloned())
.collect();
let tsconfig_paths =
crate::tsconfig::discover_tsconfig(&canonical_root).and_then(|tsconfig_path| {
let content = std::fs::read_to_string(&tsconfig_path)
.map_err(|e| {
eprintln!("[exspec] warning: failed to read tsconfig: {e}");
})
.ok()?;
let tsconfig_dir = tsconfig_path.parent().unwrap_or(&canonical_root);
crate::tsconfig::TsconfigPaths::from_str(&content, tsconfig_dir)
.or_else(|| {
eprintln!("[exspec] warning: failed to parse tsconfig paths, alias resolution disabled");
None
})
});
for (test_file, source) in test_sources {
let imports = self.extract_imports(source, test_file);
let from_file = Path::new(test_file);
let mut matched_indices = std::collections::HashSet::new();
let collect_matches = |resolved: &str,
symbols: &[String],
indices: &mut HashSet<usize>| {
if is_barrel_file(resolved) {
let barrel_path = PathBuf::from(resolved);
let resolved_files =
resolve_barrel_exports(&barrel_path, symbols, &canonical_root);
for prod in resolved_files {
let prod_str = prod.to_string_lossy().into_owned();
if !is_non_sut_helper(&prod_str, canonical_to_idx.contains_key(&prod_str)) {
if let Some(&idx) = canonical_to_idx.get(&prod_str) {
indices.insert(idx);
}
}
}
} else if !is_non_sut_helper(resolved, canonical_to_idx.contains_key(resolved)) {
if let Some(&idx) = canonical_to_idx.get(resolved) {
indices.insert(idx);
}
}
};
for import in &imports {
if let Some(resolved) =
resolve_import_path(&import.module_specifier, from_file, &canonical_root)
{
collect_matches(&resolved, &import.symbols, &mut matched_indices);
}
}
if let Some(ref tc_paths) = tsconfig_paths {
let alias_imports = self.extract_all_import_specifiers(source);
for (specifier, symbols) in &alias_imports {
let Some(alias_base) = tc_paths.resolve_alias(specifier) else {
continue;
};
if let Some(resolved) =
resolve_absolute_base_to_file(&alias_base, &canonical_root)
{
collect_matches(&resolved, symbols, &mut matched_indices);
}
}
}
for idx in matched_indices {
if !mappings[idx].test_files.contains(test_file) {
mappings[idx].test_files.push(test_file.clone());
}
}
}
for mapping in &mut mappings {
let has_layer1 = mapping
.test_files
.iter()
.any(|t| layer1_matched.contains(t));
if !has_layer1 && !mapping.test_files.is_empty() {
mapping.strategy = MappingStrategy::ImportTracing;
}
}
mappings
}
}
pub fn resolve_import_path(
module_specifier: &str,
from_file: &Path,
scan_root: &Path,
) -> Option<String> {
let base_dir_raw = from_file.parent()?;
let base_dir = base_dir_raw
.canonicalize()
.unwrap_or_else(|_| base_dir_raw.to_path_buf());
let raw_path = base_dir.join(module_specifier);
let canonical_root = scan_root.canonicalize().ok()?;
resolve_absolute_base_to_file(&raw_path, &canonical_root)
}
fn resolve_absolute_base_to_file(base: &Path, canonical_root: &Path) -> Option<String> {
const TS_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx"];
let has_known_ext = base
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| TS_EXTENSIONS.contains(&e));
let candidates: Vec<PathBuf> = if has_known_ext {
vec![base.to_path_buf()]
} else {
let base_str = base.as_os_str().to_string_lossy();
TS_EXTENSIONS
.iter()
.map(|ext| PathBuf::from(format!("{base_str}.{ext}")))
.collect()
};
for candidate in &candidates {
if let Ok(canonical) = candidate.canonicalize() {
if canonical.starts_with(canonical_root) {
return Some(canonical.to_string_lossy().into_owned());
}
}
}
if !has_known_ext {
let base_str = base.as_os_str().to_string_lossy();
let index_candidates = [
PathBuf::from(format!("{base_str}/index.ts")),
PathBuf::from(format!("{base_str}/index.tsx")),
];
for candidate in &index_candidates {
if let Ok(canonical) = candidate.canonicalize() {
if canonical.starts_with(canonical_root) {
return Some(canonical.to_string_lossy().into_owned());
}
}
}
}
None
}
fn is_type_definition_file(file_path: &str) -> bool {
let Some(file_name) = Path::new(file_path).file_name().and_then(|f| f.to_str()) else {
return false;
};
if let Some(stem) = Path::new(file_name).file_stem().and_then(|s| s.to_str()) {
for suffix in &[".enum", ".interface", ".exception"] {
if stem.ends_with(suffix) && stem != &suffix[1..] {
return true;
}
}
}
false
}
fn is_non_sut_helper(file_path: &str, is_known_production: bool) -> bool {
if file_path
.split('/')
.any(|seg| seg == "test" || seg == "__tests__")
{
return true;
}
let Some(file_name) = Path::new(file_path).file_name().and_then(|f| f.to_str()) else {
return false;
};
if matches!(
file_name,
"constants.ts"
| "constants.js"
| "constants.tsx"
| "constants.jsx"
| "index.ts"
| "index.js"
| "index.tsx"
| "index.jsx"
) {
return true;
}
if !is_known_production && is_type_definition_file(file_path) {
return true;
}
false
}
fn is_barrel_file(path: &str) -> bool {
let file_name = Path::new(path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("");
file_name == "index.ts" || file_name == "index.tsx"
}
fn file_exports_any_symbol(file_path: &Path, symbols: &[String]) -> bool {
if symbols.is_empty() {
return true;
}
let source = match std::fs::read_to_string(file_path) {
Ok(s) => s,
Err(_) => return false,
};
let mut parser = TypeScriptExtractor::parser();
let tree = match parser.parse(&source, None) {
Some(t) => t,
None => return false,
};
let query = cached_query(&EXPORTED_SYMBOL_QUERY_CACHE, EXPORTED_SYMBOL_QUERY);
let symbol_idx = query
.capture_index_for_name("symbol_name")
.expect("@symbol_name capture not found in exported_symbol.scm");
let mut cursor = QueryCursor::new();
let source_bytes = source.as_bytes();
let mut matches = cursor.matches(query, tree.root_node(), source_bytes);
while let Some(m) = matches.next() {
for cap in m.captures {
if cap.index == symbol_idx {
let name = cap.node.utf8_text(source_bytes).unwrap_or("");
if symbols.iter().any(|s| s == name) {
return true;
}
}
}
}
false
}
pub fn resolve_barrel_exports(
barrel_path: &Path,
symbols: &[String],
scan_root: &Path,
) -> Vec<PathBuf> {
let canonical_root = match scan_root.canonicalize() {
Ok(r) => r,
Err(_) => return Vec::new(),
};
let extractor = crate::TypeScriptExtractor::new();
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut results: Vec<PathBuf> = Vec::new();
resolve_barrel_exports_inner(
barrel_path,
symbols,
scan_root,
&canonical_root,
&extractor,
&mut visited,
0,
&mut results,
);
results
}
#[allow(clippy::too_many_arguments)]
fn resolve_barrel_exports_inner(
barrel_path: &Path,
symbols: &[String],
scan_root: &Path,
canonical_root: &Path,
extractor: &crate::TypeScriptExtractor,
visited: &mut HashSet<PathBuf>,
depth: usize,
results: &mut Vec<PathBuf>,
) {
if depth >= MAX_BARREL_DEPTH {
return;
}
let canonical_barrel = match barrel_path.canonicalize() {
Ok(p) => p,
Err(_) => return,
};
if !visited.insert(canonical_barrel) {
return;
}
let source = match std::fs::read_to_string(barrel_path) {
Ok(s) => s,
Err(_) => return,
};
let re_exports = extractor.extract_barrel_re_exports(&source, &barrel_path.to_string_lossy());
for re_export in &re_exports {
if !re_export.wildcard {
let has_match =
symbols.is_empty() || symbols.iter().any(|s| re_export.symbols.contains(s));
if !has_match {
continue;
}
}
if let Some(resolved_str) =
resolve_import_path(&re_export.from_specifier, barrel_path, scan_root)
{
if is_barrel_file(&resolved_str) {
resolve_barrel_exports_inner(
&PathBuf::from(&resolved_str),
symbols,
scan_root,
canonical_root,
extractor,
visited,
depth + 1,
results,
);
} else if !is_non_sut_helper(&resolved_str, false) {
if !symbols.is_empty()
&& re_export.wildcard
&& !file_exports_any_symbol(Path::new(&resolved_str), symbols)
{
continue;
}
if let Ok(canonical) = PathBuf::from(&resolved_str).canonicalize() {
if canonical.starts_with(canonical_root) && !results.contains(&canonical) {
results.push(canonical);
}
}
}
}
}
}
fn production_stem(path: &str) -> Option<&str> {
Path::new(path).file_stem()?.to_str()
}
fn test_stem(path: &str) -> Option<&str> {
let stem = Path::new(path).file_stem()?.to_str()?;
stem.strip_suffix(".spec")
.or_else(|| stem.strip_suffix(".test"))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> String {
let path = format!(
"{}/tests/fixtures/typescript/observe/{}",
env!("CARGO_MANIFEST_DIR").replace("/crates/lang-typescript", ""),
name
);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}"))
}
#[test]
fn exported_functions_extracted() {
let source = fixture("exported_functions.ts");
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
let exported: Vec<&ProductionFunction> = funcs.iter().filter(|f| f.is_exported).collect();
let names: Vec<&str> = exported.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
assert!(
names.contains(&"findById"),
"expected findById in {names:?}"
);
}
#[test]
fn non_exported_function_has_flag_false() {
let source = fixture("exported_functions.ts");
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
let helper = funcs.iter().find(|f| f.name == "internalHelper");
assert!(helper.is_some(), "expected internalHelper to be extracted");
assert!(!helper.unwrap().is_exported);
}
#[test]
fn class_methods_with_class_name() {
let source = fixture("class_methods.ts");
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions(&source, "class_methods.ts");
let controller_methods: Vec<&ProductionFunction> = funcs
.iter()
.filter(|f| f.class_name.as_deref() == Some("UsersController"))
.collect();
let names: Vec<&str> = controller_methods.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
assert!(names.contains(&"create"), "expected create in {names:?}");
assert!(
names.contains(&"validate"),
"expected validate in {names:?}"
);
}
#[test]
fn exported_class_is_exported() {
let source = fixture("class_methods.ts");
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions(&source, "class_methods.ts");
let controller_methods: Vec<&ProductionFunction> = funcs
.iter()
.filter(|f| f.class_name.as_deref() == Some("UsersController"))
.collect();
assert!(
controller_methods.iter().all(|f| f.is_exported),
"all UsersController methods should be exported"
);
let internal_methods: Vec<&ProductionFunction> = funcs
.iter()
.filter(|f| f.class_name.as_deref() == Some("InternalService"))
.collect();
assert!(
!internal_methods.is_empty(),
"expected InternalService methods"
);
assert!(
internal_methods.iter().all(|f| !f.is_exported),
"all InternalService methods should not be exported"
);
}
#[test]
fn arrow_exports_extracted() {
let source = fixture("arrow_exports.ts");
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions(&source, "arrow_exports.ts");
let exported: Vec<&ProductionFunction> = funcs.iter().filter(|f| f.is_exported).collect();
let names: Vec<&str> = exported.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
assert!(
names.contains(&"findById"),
"expected findById in {names:?}"
);
}
#[test]
fn non_exported_arrow_flag_false() {
let source = fixture("arrow_exports.ts");
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions(&source, "arrow_exports.ts");
let internal = funcs.iter().find(|f| f.name == "internalFn");
assert!(internal.is_some(), "expected internalFn to be extracted");
assert!(!internal.unwrap().is_exported);
}
#[test]
fn mixed_file_all_types() {
let source = fixture("mixed.ts");
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions(&source, "mixed.ts");
let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"getUser"), "expected getUser in {names:?}");
assert!(
names.contains(&"createUser"),
"expected createUser in {names:?}"
);
assert!(
names.contains(&"formatName"),
"expected formatName in {names:?}"
);
assert!(
names.contains(&"validateInput"),
"expected validateInput in {names:?}"
);
let get_user = funcs.iter().find(|f| f.name == "getUser").unwrap();
assert!(get_user.is_exported);
let format_name = funcs.iter().find(|f| f.name == "formatName").unwrap();
assert!(!format_name.is_exported);
let find_all = funcs
.iter()
.find(|f| f.name == "findAll" && f.class_name.is_some())
.unwrap();
assert_eq!(find_all.class_name.as_deref(), Some("UserService"));
assert!(find_all.is_exported);
let transform = funcs.iter().find(|f| f.name == "transform").unwrap();
assert_eq!(transform.class_name.as_deref(), Some("PrivateHelper"));
assert!(!transform.is_exported);
}
#[test]
fn decorated_methods_extracted() {
let source = fixture("nestjs_controller.ts");
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions(&source, "nestjs_controller.ts");
let names: Vec<&str> = funcs.iter().map(|f| f.name.as_str()).collect();
assert!(names.contains(&"findAll"), "expected findAll in {names:?}");
assert!(names.contains(&"create"), "expected create in {names:?}");
assert!(names.contains(&"remove"), "expected remove in {names:?}");
for func in &funcs {
assert_eq!(func.class_name.as_deref(), Some("UsersController"));
assert!(func.is_exported);
}
}
#[test]
fn line_numbers_correct() {
let source = fixture("exported_functions.ts");
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions(&source, "exported_functions.ts");
let find_all = funcs.iter().find(|f| f.name == "findAll").unwrap();
assert_eq!(find_all.line, 1, "findAll should be on line 1");
let find_by_id = funcs.iter().find(|f| f.name == "findById").unwrap();
assert_eq!(find_by_id.line, 5, "findById should be on line 5");
let helper = funcs.iter().find(|f| f.name == "internalHelper").unwrap();
assert_eq!(helper.line, 9, "internalHelper should be on line 9");
}
#[test]
fn empty_source_returns_empty() {
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions("", "empty.ts");
assert!(funcs.is_empty());
}
#[test]
fn basic_controller_routes() {
let source = fixture("nestjs_controller.ts");
let extractor = TypeScriptExtractor::new();
let routes = extractor.extract_routes(&source, "nestjs_controller.ts");
assert_eq!(routes.len(), 3, "expected 3 routes, got {routes:?}");
let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
assert!(methods.contains(&"GET"), "expected GET in {methods:?}");
assert!(methods.contains(&"POST"), "expected POST in {methods:?}");
assert!(
methods.contains(&"DELETE"),
"expected DELETE in {methods:?}"
);
let get_route = routes.iter().find(|r| r.http_method == "GET").unwrap();
assert_eq!(get_route.path, "/users");
let delete_route = routes.iter().find(|r| r.http_method == "DELETE").unwrap();
assert_eq!(delete_route.path, "/users/:id");
}
#[test]
fn route_path_combination() {
let source = fixture("nestjs_routes_advanced.ts");
let extractor = TypeScriptExtractor::new();
let routes = extractor.extract_routes(&source, "nestjs_routes_advanced.ts");
let active = routes
.iter()
.find(|r| r.handler_name == "findActive")
.unwrap();
assert_eq!(active.http_method, "GET");
assert_eq!(active.path, "/api/v1/users/active");
}
#[test]
fn controller_no_path() {
let source = fixture("nestjs_empty_controller.ts");
let extractor = TypeScriptExtractor::new();
let routes = extractor.extract_routes(&source, "nestjs_empty_controller.ts");
assert_eq!(routes.len(), 1, "expected 1 route, got {routes:?}");
assert_eq!(routes[0].http_method, "GET");
assert_eq!(routes[0].path, "/health");
}
#[test]
fn method_without_route_decorator() {
let source = fixture("nestjs_empty_controller.ts");
let extractor = TypeScriptExtractor::new();
let routes = extractor.extract_routes(&source, "nestjs_empty_controller.ts");
let helper = routes.iter().find(|r| r.handler_name == "helperMethod");
assert!(helper.is_none(), "helperMethod should not be a route");
}
#[test]
fn all_http_methods() {
let source = fixture("nestjs_routes_advanced.ts");
let extractor = TypeScriptExtractor::new();
let routes = extractor.extract_routes(&source, "nestjs_routes_advanced.ts");
assert_eq!(routes.len(), 9, "expected 9 routes, got {routes:?}");
let methods: Vec<&str> = routes.iter().map(|r| r.http_method.as_str()).collect();
assert!(methods.contains(&"GET"));
assert!(methods.contains(&"POST"));
assert!(methods.contains(&"PUT"));
assert!(methods.contains(&"PATCH"));
assert!(methods.contains(&"DELETE"));
assert!(methods.contains(&"HEAD"));
assert!(methods.contains(&"OPTIONS"));
}
#[test]
fn use_guards_decorator() {
let source = fixture("nestjs_guards_pipes.ts");
let extractor = TypeScriptExtractor::new();
let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
let guards: Vec<&DecoratorInfo> = decorators
.iter()
.filter(|d| d.name == "UseGuards")
.collect();
assert!(!guards.is_empty(), "expected UseGuards decorators");
let auth_guard = guards
.iter()
.find(|d| d.arguments.contains(&"AuthGuard".to_string()));
assert!(auth_guard.is_some(), "expected AuthGuard argument");
}
#[test]
fn multiple_decorators_on_method() {
let source = fixture("nestjs_controller.ts");
let extractor = TypeScriptExtractor::new();
let decorators = extractor.extract_decorators(&source, "nestjs_controller.ts");
let names: Vec<&str> = decorators.iter().map(|d| d.name.as_str()).collect();
assert!(
names.contains(&"UseGuards"),
"expected UseGuards in {names:?}"
);
assert!(
!names.contains(&"Delete"),
"Delete should not be in decorators"
);
}
#[test]
fn class_validator_on_dto() {
let source = fixture("nestjs_dto_validation.ts");
let extractor = TypeScriptExtractor::new();
let decorators = extractor.extract_decorators(&source, "nestjs_dto_validation.ts");
let names: Vec<&str> = decorators.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"IsEmail"), "expected IsEmail in {names:?}");
assert!(
names.contains(&"IsNotEmpty"),
"expected IsNotEmpty in {names:?}"
);
}
#[test]
fn use_pipes_decorator() {
let source = fixture("nestjs_guards_pipes.ts");
let extractor = TypeScriptExtractor::new();
let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
let pipes: Vec<&DecoratorInfo> =
decorators.iter().filter(|d| d.name == "UsePipes").collect();
assert!(!pipes.is_empty(), "expected UsePipes decorators");
assert!(pipes[0].arguments.contains(&"ValidationPipe".to_string()));
}
#[test]
fn empty_source_returns_empty_routes_and_decorators() {
let extractor = TypeScriptExtractor::new();
let routes = extractor.extract_routes("", "empty.ts");
let decorators = extractor.extract_decorators("", "empty.ts");
assert!(routes.is_empty());
assert!(decorators.is_empty());
}
#[test]
fn non_nestjs_class_ignored() {
let source = fixture("class_methods.ts");
let extractor = TypeScriptExtractor::new();
let routes = extractor.extract_routes(&source, "class_methods.ts");
assert!(routes.is_empty(), "expected no routes from plain class");
}
#[test]
fn route_handler_and_class_name() {
let source = fixture("nestjs_controller.ts");
let extractor = TypeScriptExtractor::new();
let routes = extractor.extract_routes(&source, "nestjs_controller.ts");
let handlers: Vec<&str> = routes.iter().map(|r| r.handler_name.as_str()).collect();
assert!(handlers.contains(&"findAll"));
assert!(handlers.contains(&"create"));
assert!(handlers.contains(&"remove"));
for route in &routes {
assert_eq!(route.class_name, "UsersController");
}
}
#[test]
fn class_level_use_guards() {
let source = fixture("nestjs_guards_pipes.ts");
let extractor = TypeScriptExtractor::new();
let decorators = extractor.extract_decorators(&source, "nestjs_guards_pipes.ts");
let class_guards: Vec<&DecoratorInfo> = decorators
.iter()
.filter(|d| {
d.name == "UseGuards"
&& d.target_name == "ProtectedController"
&& d.class_name == "ProtectedController"
})
.collect();
assert!(
!class_guards.is_empty(),
"expected class-level UseGuards, got {decorators:?}"
);
assert!(class_guards[0]
.arguments
.contains(&"JwtAuthGuard".to_string()));
}
#[test]
fn dynamic_controller_path() {
let source = fixture("nestjs_dynamic_routes.ts");
let extractor = TypeScriptExtractor::new();
let routes = extractor.extract_routes(&source, "nestjs_dynamic_routes.ts");
assert_eq!(routes.len(), 1);
assert!(
routes[0].path.contains("<dynamic>"),
"expected <dynamic> in path, got {:?}",
routes[0].path
);
}
#[test]
fn abstract_class_methods_extracted() {
let source = fixture("abstract_class.ts");
let extractor = TypeScriptExtractor::new();
let funcs = extractor.extract_production_functions(&source, "abstract_class.ts");
let validate = funcs.iter().find(|f| f.name == "validate");
assert!(validate.is_some(), "expected validate to be extracted");
let validate = validate.unwrap();
assert_eq!(validate.class_name.as_deref(), Some("BaseService"));
assert!(validate.is_exported);
let process = funcs.iter().find(|f| f.name == "process");
assert!(process.is_some(), "expected process to be extracted");
let process = process.unwrap();
assert_eq!(process.class_name.as_deref(), Some("InternalBase"));
assert!(!process.is_exported);
}
#[test]
fn basic_spec_mapping() {
let extractor = TypeScriptExtractor::new();
let production_files = vec!["src/users.service.ts".to_string()];
let test_files = vec!["src/users.service.spec.ts".to_string()];
let mappings = extractor.map_test_files(&production_files, &test_files);
assert_eq!(
mappings,
vec![FileMapping {
production_file: "src/users.service.ts".to_string(),
test_files: vec!["src/users.service.spec.ts".to_string()],
strategy: MappingStrategy::FileNameConvention,
}]
);
}
#[test]
fn test_suffix_mapping() {
let extractor = TypeScriptExtractor::new();
let production_files = vec!["src/utils.ts".to_string()];
let test_files = vec!["src/utils.test.ts".to_string()];
let mappings = extractor.map_test_files(&production_files, &test_files);
assert_eq!(
mappings[0].test_files,
vec!["src/utils.test.ts".to_string()]
);
}
#[test]
fn multiple_test_files() {
let extractor = TypeScriptExtractor::new();
let production_files = vec!["src/app.ts".to_string()];
let test_files = vec!["src/app.spec.ts".to_string(), "src/app.test.ts".to_string()];
let mappings = extractor.map_test_files(&production_files, &test_files);
assert_eq!(
mappings[0].test_files,
vec!["src/app.spec.ts".to_string(), "src/app.test.ts".to_string()]
);
}
#[test]
fn nestjs_controller() {
let extractor = TypeScriptExtractor::new();
let production_files = vec!["src/users/users.controller.ts".to_string()];
let test_files = vec!["src/users/users.controller.spec.ts".to_string()];
let mappings = extractor.map_test_files(&production_files, &test_files);
assert_eq!(
mappings[0].test_files,
vec!["src/users/users.controller.spec.ts".to_string()]
);
}
#[test]
fn no_matching_test() {
let extractor = TypeScriptExtractor::new();
let production_files = vec!["src/orphan.ts".to_string()];
let test_files = vec!["src/other.spec.ts".to_string()];
let mappings = extractor.map_test_files(&production_files, &test_files);
assert_eq!(mappings[0].test_files, Vec::<String>::new());
}
#[test]
fn different_directory_no_match() {
let extractor = TypeScriptExtractor::new();
let production_files = vec!["src/users.ts".to_string()];
let test_files = vec!["test/users.spec.ts".to_string()];
let mappings = extractor.map_test_files(&production_files, &test_files);
assert_eq!(mappings[0].test_files, Vec::<String>::new());
}
#[test]
fn empty_input() {
let extractor = TypeScriptExtractor::new();
let mappings = extractor.map_test_files(&[], &[]);
assert!(mappings.is_empty());
}
#[test]
fn tsx_files() {
let extractor = TypeScriptExtractor::new();
let production_files = vec!["src/App.tsx".to_string()];
let test_files = vec!["src/App.test.tsx".to_string()];
let mappings = extractor.map_test_files(&production_files, &test_files);
assert_eq!(mappings[0].test_files, vec!["src/App.test.tsx".to_string()]);
}
#[test]
fn unmatched_test_ignored() {
let extractor = TypeScriptExtractor::new();
let production_files = vec!["src/a.ts".to_string()];
let test_files = vec!["src/a.spec.ts".to_string(), "src/b.spec.ts".to_string()];
let mappings = extractor.map_test_files(&production_files, &test_files);
assert_eq!(mappings.len(), 1);
assert_eq!(mappings[0].test_files, vec!["src/a.spec.ts".to_string()]);
}
#[test]
fn stem_extraction() {
assert_eq!(
production_stem("src/users.service.ts"),
Some("users.service")
);
assert_eq!(production_stem("src/App.tsx"), Some("App"));
assert_eq!(
test_stem("src/users.service.spec.ts"),
Some("users.service")
);
assert_eq!(test_stem("src/utils.test.ts"), Some("utils"));
assert_eq!(test_stem("src/App.test.tsx"), Some("App"));
assert_eq!(test_stem("src/invalid.ts"), None);
}
#[test]
fn im1_named_import_symbol_and_specifier() {
let source = fixture("import_named.ts");
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(&source, "import_named.ts");
let found = imports.iter().find(|i| i.symbol_name == "UsersController");
assert!(
found.is_some(),
"expected UsersController in imports: {imports:?}"
);
assert_eq!(
found.unwrap().module_specifier,
"./users.controller",
"wrong specifier"
);
}
#[test]
fn im2_multiple_named_imports() {
let source = fixture("import_mixed.ts");
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(&source, "import_mixed.ts");
let from_module: Vec<&ImportMapping> = imports
.iter()
.filter(|i| i.module_specifier == "./module")
.collect();
let symbols: Vec<&str> = from_module.iter().map(|i| i.symbol_name.as_str()).collect();
assert!(symbols.contains(&"A"), "expected A in symbols: {symbols:?}");
assert!(symbols.contains(&"B"), "expected B in symbols: {symbols:?}");
assert!(
from_module.len() >= 2,
"expected at least 2 imports from ./module, got {from_module:?}"
);
}
#[test]
fn im3_alias_import_original_name() {
let source = fixture("import_mixed.ts");
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(&source, "import_mixed.ts");
let a_count = imports.iter().filter(|i| i.symbol_name == "A").count();
assert!(
a_count >= 1,
"expected at least one import with symbol_name 'A', got: {imports:?}"
);
}
#[test]
fn im4_default_import() {
let source = fixture("import_default.ts");
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(&source, "import_default.ts");
assert_eq!(imports.len(), 1, "expected 1 import, got {imports:?}");
assert_eq!(imports[0].symbol_name, "UsersController");
assert_eq!(imports[0].module_specifier, "./users.controller");
}
#[test]
fn im5_npm_package_excluded() {
let source = "import { Test } from '@nestjs/testing';";
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(source, "test.ts");
assert!(imports.is_empty(), "expected empty vec, got {imports:?}");
}
#[test]
fn im6_relative_parent_path() {
let source = fixture("import_named.ts");
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(&source, "import_named.ts");
let found = imports
.iter()
.find(|i| i.module_specifier == "../services/s.service");
assert!(
found.is_some(),
"expected ../services/s.service in imports: {imports:?}"
);
assert_eq!(found.unwrap().symbol_name, "S");
}
#[test]
fn im7_empty_source_returns_empty() {
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports("", "empty.ts");
assert!(imports.is_empty());
}
#[test]
fn im8_namespace_import() {
let source = fixture("import_namespace.ts");
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(&source, "import_namespace.ts");
let found = imports.iter().find(|i| i.symbol_name == "UsersController");
assert!(
found.is_some(),
"expected UsersController in imports: {imports:?}"
);
assert_eq!(found.unwrap().module_specifier, "./users.controller");
let helpers = imports.iter().find(|i| i.symbol_name == "helpers");
assert!(
helpers.is_some(),
"expected helpers in imports: {imports:?}"
);
assert_eq!(helpers.unwrap().module_specifier, "../utils/helpers");
let express = imports.iter().find(|i| i.symbol_name == "express");
assert!(
express.is_none(),
"npm package should be excluded: {imports:?}"
);
}
#[test]
fn im9_type_only_import_excluded() {
let source = fixture("import_type_only.ts");
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(&source, "import_type_only.ts");
let user_service = imports.iter().find(|i| i.symbol_name == "UserService");
assert!(
user_service.is_none(),
"type-only import should be excluded: {imports:?}"
);
let create_dto = imports.iter().find(|i| i.symbol_name == "CreateUserDto");
assert!(
create_dto.is_none(),
"inline type modifier import should be excluded: {imports:?}"
);
let controller = imports.iter().find(|i| i.symbol_name == "UsersController");
assert!(
controller.is_some(),
"normal import should remain: {imports:?}"
);
assert_eq!(controller.unwrap().module_specifier, "./users.controller");
}
#[test]
fn rp1_resolve_ts_without_extension() {
use std::io::Write as IoWrite;
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let target = src_dir.join("users.controller.ts");
std::fs::File::create(&target).unwrap();
let from_file = src_dir.join("users.controller.spec.ts");
let result = resolve_import_path("./users.controller", &from_file, dir.path());
assert!(
result.is_some(),
"expected Some for existing .ts file, got None"
);
let resolved = result.unwrap();
assert!(
resolved.ends_with("users.controller.ts"),
"expected path ending with users.controller.ts, got {resolved}"
);
}
#[test]
fn rp2_resolve_ts_with_extension() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let target = src_dir.join("users.controller.ts");
std::fs::File::create(&target).unwrap();
let from_file = src_dir.join("users.controller.spec.ts");
let result = resolve_import_path("./users.controller.ts", &from_file, dir.path());
assert!(
result.is_some(),
"expected Some for existing file with explicit .ts extension"
);
}
#[test]
fn rp3_nonexistent_file_returns_none() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let from_file = src_dir.join("some.spec.ts");
let result = resolve_import_path("./nonexistent", &from_file, dir.path());
assert!(result.is_none(), "expected None for nonexistent file");
}
#[test]
fn rp4_outside_scan_root_returns_none() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let from_file = src_dir.join("some.spec.ts");
let result = resolve_import_path("../../outside", &from_file, dir.path());
assert!(result.is_none(), "expected None for path outside scan_root");
}
#[test]
fn rp5_resolve_tsx_without_extension() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let target = src_dir.join("App.tsx");
std::fs::File::create(&target).unwrap();
let from_file = src_dir.join("App.test.tsx");
let result = resolve_import_path("./App", &from_file, dir.path());
assert!(
result.is_some(),
"expected Some for existing .tsx file, got None"
);
let resolved = result.unwrap();
assert!(
resolved.ends_with("App.tsx"),
"expected path ending with App.tsx, got {resolved}"
);
}
#[test]
fn mt1_layer1_and_layer2_both_matched() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let prod_path = src_dir.join("users.controller.ts");
std::fs::File::create(&prod_path).unwrap();
let layer1_test = src_dir.join("users.controller.spec.ts");
let layer1_source = r#"// Layer 1 spec
describe('UsersController', () => {});
"#;
let layer2_test = test_dir.join("users.controller.spec.ts");
let layer2_source = format!(
"import {{ UsersController }} from '../src/users.controller';\ndescribe('cross', () => {{}});\n"
);
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
layer1_test.to_string_lossy().into_owned(),
layer1_source.to_string(),
);
test_sources.insert(
layer2_test.to_string_lossy().into_owned(),
layer2_source.to_string(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
assert_eq!(mappings.len(), 1, "expected 1 FileMapping");
let mapping = &mappings[0];
assert!(
mapping
.test_files
.contains(&layer1_test.to_string_lossy().into_owned()),
"expected Layer 1 test in mapping, got {:?}",
mapping.test_files
);
assert!(
mapping
.test_files
.contains(&layer2_test.to_string_lossy().into_owned()),
"expected Layer 2 test in mapping, got {:?}",
mapping.test_files
);
}
#[test]
fn mt2_cross_directory_import_tracing() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src").join("services");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let prod_path = src_dir.join("user.service.ts");
std::fs::File::create(&prod_path).unwrap();
let test_path = test_dir.join("user.service.spec.ts");
let test_source = format!(
"import {{ UserService }} from '../src/services/user.service';\ndescribe('cross', () => {{}});\n"
);
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
assert_eq!(mappings.len(), 1);
let mapping = &mappings[0];
assert!(
mapping
.test_files
.contains(&test_path.to_string_lossy().into_owned()),
"expected test in mapping via ImportTracing, got {:?}",
mapping.test_files
);
assert_eq!(
mapping.strategy,
MappingStrategy::ImportTracing,
"expected ImportTracing strategy"
);
}
#[test]
fn mt3_npm_only_import_not_matched() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let prod_path = src_dir.join("users.controller.ts");
std::fs::File::create(&prod_path).unwrap();
let test_path = test_dir.join("something.spec.ts");
let test_source =
"import { Test } from '@nestjs/testing';\ndescribe('npm', () => {});\n".to_string();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
assert_eq!(mappings.len(), 1);
assert!(
mappings[0].test_files.is_empty(),
"expected no test files for npm-only import, got {:?}",
mappings[0].test_files
);
}
#[test]
fn mt4_one_test_imports_multiple_productions() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let prod_a = src_dir.join("a.service.ts");
let prod_b = src_dir.join("b.service.ts");
std::fs::File::create(&prod_a).unwrap();
std::fs::File::create(&prod_b).unwrap();
let test_path = test_dir.join("ab.spec.ts");
let test_source = format!(
"import {{ A }} from '../src/a.service';\nimport {{ B }} from '../src/b.service';\ndescribe('ab', () => {{}});\n"
);
let production_files = vec![
prod_a.to_string_lossy().into_owned(),
prod_b.to_string_lossy().into_owned(),
];
let mut test_sources = HashMap::new();
test_sources.insert(test_path.to_string_lossy().into_owned(), test_source);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
assert_eq!(mappings.len(), 2, "expected 2 FileMappings (A and B)");
for mapping in &mappings {
assert!(
mapping
.test_files
.contains(&test_path.to_string_lossy().into_owned()),
"expected ab.spec.ts mapped to {}, got {:?}",
mapping.production_file,
mapping.test_files
);
}
}
#[test]
fn is_non_sut_helper_constants_ts() {
assert!(is_non_sut_helper("src/constants.ts", false));
}
#[test]
fn is_non_sut_helper_index_ts() {
assert!(is_non_sut_helper("src/index.ts", false));
}
#[test]
fn is_non_sut_helper_extension_variants() {
assert!(is_non_sut_helper("src/constants.js", false));
assert!(is_non_sut_helper("src/constants.tsx", false));
assert!(is_non_sut_helper("src/constants.jsx", false));
assert!(is_non_sut_helper("src/index.js", false));
assert!(is_non_sut_helper("src/index.tsx", false));
assert!(is_non_sut_helper("src/index.jsx", false));
}
#[test]
fn is_non_sut_helper_rejects_non_helpers() {
assert!(!is_non_sut_helper("src/my-constants.ts", false));
assert!(!is_non_sut_helper("src/service.ts", false));
assert!(!is_non_sut_helper("src/app.constants.ts", false));
assert!(!is_non_sut_helper("src/constants-v2.ts", false));
}
#[test]
fn is_non_sut_helper_rejects_directory_name() {
assert!(!is_non_sut_helper("constants/app.ts", false));
assert!(!is_non_sut_helper("index/service.ts", false));
}
#[test]
fn is_non_sut_helper_enum_ts() {
let path = "src/enums/request-method.enum.ts";
assert!(is_non_sut_helper(path, false));
}
#[test]
fn is_non_sut_helper_interface_ts() {
let path = "src/interfaces/middleware-configuration.interface.ts";
assert!(is_non_sut_helper(path, false));
}
#[test]
fn is_non_sut_helper_exception_ts() {
let path = "src/errors/unknown-module.exception.ts";
assert!(is_non_sut_helper(path, false));
}
#[test]
fn is_non_sut_helper_test_path() {
let path = "packages/core/test/utils/string.cleaner.ts";
assert!(is_non_sut_helper(path, false));
assert!(is_non_sut_helper(
"packages/core/__tests__/utils/helper.ts",
false
));
assert!(!is_non_sut_helper(
"/home/user/projects/contest/src/service.ts",
false
));
assert!(!is_non_sut_helper("src/latest/foo.ts", false));
}
#[test]
fn is_non_sut_helper_rejects_plain_filename() {
assert!(!is_non_sut_helper("src/enum.ts", false));
assert!(!is_non_sut_helper("src/interface.ts", false));
assert!(!is_non_sut_helper("src/exception.ts", false));
}
#[test]
fn is_non_sut_helper_enum_interface_extension_variants() {
assert!(is_non_sut_helper("src/foo.enum.js", false));
assert!(is_non_sut_helper("src/bar.interface.tsx", false));
}
#[test]
fn is_type_definition_file_enum() {
assert!(is_type_definition_file("src/foo.enum.ts"));
}
#[test]
fn is_type_definition_file_interface() {
assert!(is_type_definition_file("src/bar.interface.ts"));
}
#[test]
fn is_type_definition_file_exception() {
assert!(is_type_definition_file("src/baz.exception.ts"));
}
#[test]
fn is_type_definition_file_service() {
assert!(!is_type_definition_file("src/service.ts"));
}
#[test]
fn is_type_definition_file_constants() {
assert!(!is_type_definition_file("src/constants.ts"));
}
#[test]
fn is_non_sut_helper_production_enum_bypassed() {
assert!(!is_non_sut_helper("src/foo.enum.ts", true));
}
#[test]
fn is_non_sut_helper_unknown_enum_filtered() {
assert!(is_non_sut_helper("src/foo.enum.ts", false));
}
#[test]
fn is_non_sut_helper_constants_always_filtered() {
assert!(is_non_sut_helper("src/constants.ts", true));
}
#[test]
fn barrel_01_resolve_directory_to_index_ts() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let decorators_dir = dir.path().join("decorators");
std::fs::create_dir_all(&decorators_dir).unwrap();
std::fs::File::create(decorators_dir.join("index.ts")).unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let from_file = src_dir.join("some.spec.ts");
let result = resolve_import_path("../decorators", &from_file, dir.path());
assert!(
result.is_some(),
"expected Some for directory with index.ts, got None"
);
let resolved = result.unwrap();
assert!(
resolved.ends_with("decorators/index.ts"),
"expected path ending with decorators/index.ts, got {resolved}"
);
}
#[test]
fn barrel_02_re_export_named_capture() {
let source = "export { Foo } from './foo';";
let extractor = TypeScriptExtractor::new();
let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
assert_eq!(
re_exports.len(),
1,
"expected 1 re-export, got {re_exports:?}"
);
let re = &re_exports[0];
assert_eq!(re.symbols, vec!["Foo".to_string()]);
assert_eq!(re.from_specifier, "./foo");
assert!(!re.wildcard);
}
#[test]
fn barrel_03_re_export_wildcard_capture() {
let source = "export * from './foo';";
let extractor = TypeScriptExtractor::new();
let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
assert_eq!(
re_exports.len(),
1,
"expected 1 re-export, got {re_exports:?}"
);
let re = &re_exports[0];
assert!(re.wildcard, "expected wildcard=true");
assert_eq!(re.from_specifier, "./foo");
}
#[test]
fn barrel_04_resolve_barrel_exports_one_hop() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let index_path = dir.path().join("index.ts");
std::fs::write(&index_path, "export { Foo } from './foo';").unwrap();
let foo_path = dir.path().join("foo.ts");
std::fs::File::create(&foo_path).unwrap();
let result = resolve_barrel_exports(&index_path, &["Foo".to_string()], dir.path());
assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
assert!(
result[0].ends_with("foo.ts"),
"expected foo.ts, got {:?}",
result[0]
);
}
#[test]
fn barrel_05_resolve_barrel_exports_two_hops() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let index_path = dir.path().join("index.ts");
std::fs::write(&index_path, "export * from './core';").unwrap();
let core_dir = dir.path().join("core");
std::fs::create_dir_all(&core_dir).unwrap();
std::fs::write(core_dir.join("index.ts"), "export { Foo } from './foo';").unwrap();
let foo_path = core_dir.join("foo.ts");
std::fs::File::create(&foo_path).unwrap();
let result = resolve_barrel_exports(&index_path, &["Foo".to_string()], dir.path());
assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
assert!(
result[0].ends_with("foo.ts"),
"expected foo.ts, got {:?}",
result[0]
);
}
#[test]
fn barrel_06_circular_barrel_no_infinite_loop() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let a_dir = dir.path().join("a");
let b_dir = dir.path().join("b");
std::fs::create_dir_all(&a_dir).unwrap();
std::fs::create_dir_all(&b_dir).unwrap();
std::fs::write(a_dir.join("index.ts"), "export * from '../b';").unwrap();
std::fs::write(b_dir.join("index.ts"), "export * from '../a';").unwrap();
let a_index = a_dir.join("index.ts");
let result = resolve_barrel_exports(&a_index, &["Foo".to_string()], dir.path());
assert!(
result.is_empty(),
"expected empty result for circular barrel, got {result:?}"
);
}
#[test]
fn barrel_07_layer2_barrel_import_matches_production() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let decorators_dir = src_dir.join("decorators");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&decorators_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let prod_path = src_dir.join("foo.service.ts");
std::fs::File::create(&prod_path).unwrap();
std::fs::write(
decorators_dir.join("index.ts"),
"export { Foo } from '../foo.service';",
)
.unwrap();
let test_path = test_dir.join("foo.spec.ts");
std::fs::write(
&test_path,
"import { Foo } from '../src/decorators';\ndescribe('foo', () => {});",
)
.unwrap();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
std::fs::read_to_string(&test_path).unwrap(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
assert_eq!(mappings.len(), 1, "expected 1 FileMapping");
assert!(
mappings[0]
.test_files
.contains(&test_path.to_string_lossy().into_owned()),
"expected foo.spec.ts mapped via barrel, got {:?}",
mappings[0].test_files
);
}
#[test]
fn barrel_08_non_sut_filter_applied_after_barrel_resolution() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let prod_path = src_dir.join("user.service.ts");
std::fs::File::create(&prod_path).unwrap();
std::fs::write(
src_dir.join("index.ts"),
"export { SOME_CONST } from './constants';",
)
.unwrap();
std::fs::File::create(src_dir.join("constants.ts")).unwrap();
let test_path = test_dir.join("barrel_const.spec.ts");
std::fs::write(
&test_path,
"import { SOME_CONST } from '../src';\ndescribe('const', () => {});",
)
.unwrap();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
std::fs::read_to_string(&test_path).unwrap(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
assert_eq!(
mappings.len(),
1,
"expected 1 FileMapping for user.service.ts"
);
assert!(
mappings[0].test_files.is_empty(),
"constants.ts should be filtered out, but got {:?}",
mappings[0].test_files
);
}
#[test]
fn barrel_09_extract_imports_retains_symbols() {
let source = "import { Foo, Bar } from './module';";
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(source, "test.ts");
let from_module: Vec<&ImportMapping> = imports
.iter()
.filter(|i| i.module_specifier == "./module")
.collect();
let names: Vec<&str> = from_module.iter().map(|i| i.symbol_name.as_str()).collect();
assert!(names.contains(&"Foo"), "expected Foo in symbols: {names:?}");
assert!(names.contains(&"Bar"), "expected Bar in symbols: {names:?}");
let grouped = imports
.iter()
.filter(|i| i.module_specifier == "./module")
.fold(Vec::<String>::new(), |mut acc, i| {
acc.push(i.symbol_name.clone());
acc
});
assert_eq!(
grouped.len(),
2,
"expected 2 symbols from ./module, got {grouped:?}"
);
let first_import = imports
.iter()
.find(|i| i.module_specifier == "./module")
.expect("expected at least one import from ./module");
let symbols = &first_import.symbols;
assert!(
symbols.contains(&"Foo".to_string()),
"symbols should contain Foo, got {symbols:?}"
);
assert!(
symbols.contains(&"Bar".to_string()),
"symbols should contain Bar, got {symbols:?}"
);
assert_eq!(
symbols.len(),
2,
"expected exactly 2 symbols, got {symbols:?}"
);
}
#[test]
fn barrel_10_wildcard_barrel_symbol_filter() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let core_dir = dir.path().join("core");
std::fs::create_dir_all(&core_dir).unwrap();
std::fs::write(dir.path().join("index.ts"), "export * from './core';").unwrap();
std::fs::write(
core_dir.join("index.ts"),
"export * from './foo';\nexport * from './bar';",
)
.unwrap();
std::fs::write(core_dir.join("foo.ts"), "export function Foo() {}").unwrap();
std::fs::write(core_dir.join("bar.ts"), "export function Bar() {}").unwrap();
let result = resolve_barrel_exports(
&dir.path().join("index.ts"),
&["Foo".to_string()],
dir.path(),
);
assert_eq!(result.len(), 1, "expected 1 resolved file, got {result:?}");
assert!(
result[0].ends_with("foo.ts"),
"expected foo.ts, got {:?}",
result[0]
);
}
#[test]
fn barrel_11_wildcard_barrel_empty_symbols_match_all() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let core_dir = dir.path().join("core");
std::fs::create_dir_all(&core_dir).unwrap();
std::fs::write(dir.path().join("index.ts"), "export * from './core';").unwrap();
std::fs::write(
core_dir.join("index.ts"),
"export * from './foo';\nexport * from './bar';",
)
.unwrap();
std::fs::write(core_dir.join("foo.ts"), "export function Foo() {}").unwrap();
std::fs::write(core_dir.join("bar.ts"), "export function Bar() {}").unwrap();
let result = resolve_barrel_exports(&dir.path().join("index.ts"), &[], dir.path());
assert_eq!(result.len(), 2, "expected 2 resolved files, got {result:?}");
}
#[test]
fn boundary_b1_ns_reexport_not_captured() {
let source = "export * as Validators from './validators';";
let extractor = TypeScriptExtractor::new();
let re_exports = extractor.extract_barrel_re_exports(source, "index.ts");
assert!(
re_exports.is_empty(),
"expected empty re_exports for namespace export, got {:?}",
re_exports
);
}
#[test]
fn boundary_b1_ns_reexport_mapping_miss() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let validators_dir = dir.path().join("validators");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&validators_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let prod_path = validators_dir.join("foo.service.ts");
std::fs::File::create(&prod_path).unwrap();
std::fs::write(
dir.path().join("index.ts"),
"export * as Validators from './validators';",
)
.unwrap();
std::fs::write(
validators_dir.join("index.ts"),
"export { FooService } from './foo.service';",
)
.unwrap();
let test_path = test_dir.join("foo.spec.ts");
std::fs::write(
&test_path,
"import { Validators } from '../index';\ndescribe('FooService', () => {});",
)
.unwrap();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
std::fs::read_to_string(&test_path).unwrap(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
let all_test_files: Vec<&String> =
mappings.iter().flat_map(|m| m.test_files.iter()).collect();
assert!(
all_test_files.is_empty(),
"expected no test_files mapped (FN: namespace re-export not resolved), got {:?}",
all_test_files
);
}
#[test]
fn boundary_b2_non_relative_import_skipped() {
let source = "import { Injectable } from '@nestjs/common';";
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(source, "app.service.ts");
assert!(
imports.is_empty(),
"expected empty imports for non-relative path, got {:?}",
imports
);
}
#[test]
fn boundary_b2_cross_pkg_barrel_unresolvable() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let core_src = dir.path().join("packages").join("core").join("src");
let core_test = dir.path().join("packages").join("core").join("test");
let common_src = dir.path().join("packages").join("common").join("src");
std::fs::create_dir_all(&core_src).unwrap();
std::fs::create_dir_all(&core_test).unwrap();
std::fs::create_dir_all(&common_src).unwrap();
let prod_path = core_src.join("foo.service.ts");
std::fs::File::create(&prod_path).unwrap();
let common_path = common_src.join("foo.ts");
std::fs::File::create(&common_path).unwrap();
let test_path = core_test.join("foo.spec.ts");
std::fs::write(
&test_path,
"import { Foo } from '@org/common';\ndescribe('Foo', () => {});",
)
.unwrap();
let scan_root = dir.path().join("packages").join("core");
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
std::fs::read_to_string(&test_path).unwrap(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
let all_test_files: Vec<&String> =
mappings.iter().flat_map(|m| m.test_files.iter()).collect();
assert!(
all_test_files.is_empty(),
"expected no test_files mapped (FN: cross-package import not resolved), got {:?}",
all_test_files
);
}
#[test]
fn boundary_b3_tsconfig_alias_not_resolved() {
let source = "import { FooService } from '@app/services/foo.service';";
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(source, "app.module.ts");
assert!(
imports.is_empty(),
"expected empty imports for tsconfig alias, got {:?}",
imports
);
}
#[test]
fn boundary_b4_enum_primary_target_filtered() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let prod_path = src_dir.join("route-paramtypes.enum.ts");
std::fs::File::create(&prod_path).unwrap();
let test_path = test_dir.join("route.spec.ts");
std::fs::write(
&test_path,
"import { RouteParamtypes } from '../src/route-paramtypes.enum';\ndescribe('Route', () => {});",
)
.unwrap();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
std::fs::read_to_string(&test_path).unwrap(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
let enum_mapping = mappings
.iter()
.find(|m| m.production_file.ends_with("route-paramtypes.enum.ts"));
assert!(
enum_mapping.is_some(),
"expected mapping for route-paramtypes.enum.ts"
);
let enum_mapping = enum_mapping.unwrap();
assert!(
!enum_mapping.test_files.is_empty(),
"expected test_files for route-paramtypes.enum.ts (production file), got empty"
);
}
#[test]
fn boundary_b4_interface_primary_target_filtered() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let prod_path = src_dir.join("user.interface.ts");
std::fs::File::create(&prod_path).unwrap();
let test_path = test_dir.join("user.spec.ts");
std::fs::write(
&test_path,
"import { User } from '../src/user.interface';\ndescribe('User', () => {});",
)
.unwrap();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
std::fs::read_to_string(&test_path).unwrap(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
let iface_mapping = mappings
.iter()
.find(|m| m.production_file.ends_with("user.interface.ts"));
assert!(
iface_mapping.is_some(),
"expected mapping for user.interface.ts"
);
let iface_mapping = iface_mapping.unwrap();
assert!(
!iface_mapping.test_files.is_empty(),
"expected test_files for user.interface.ts (production file), got empty"
);
}
#[test]
fn boundary_b5_dynamic_import_not_extracted() {
let source = fixture("import_dynamic.ts");
let extractor = TypeScriptExtractor::new();
let imports = extractor.extract_imports(&source, "import_dynamic.ts");
assert!(
imports.is_empty(),
"expected empty imports for dynamic import(), got {:?}",
imports
);
}
#[test]
fn test_observe_tsconfig_alias_basic() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let tsconfig = dir.path().join("tsconfig.json");
std::fs::write(
&tsconfig,
r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
)
.unwrap();
let prod_path = src_dir.join("foo.service.ts");
std::fs::File::create(&prod_path).unwrap();
let test_path = test_dir.join("foo.service.spec.ts");
let test_source =
"import { FooService } from '@app/foo.service';\ndescribe('FooService', () => {});\n";
std::fs::write(&test_path, test_source).unwrap();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
test_source.to_string(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
let mapping = mappings
.iter()
.find(|m| m.production_file.contains("foo.service.ts"))
.expect("expected mapping for foo.service.ts");
assert!(
mapping
.test_files
.contains(&test_path.to_string_lossy().into_owned()),
"expected foo.service.spec.ts in mapping via alias, got {:?}",
mapping.test_files
);
}
#[test]
fn test_observe_no_tsconfig_alias_ignored() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
let prod_path = src_dir.join("foo.service.ts");
std::fs::File::create(&prod_path).unwrap();
let test_path = test_dir.join("foo.service.spec.ts");
let test_source =
"import { FooService } from '@app/foo.service';\ndescribe('FooService', () => {});\n";
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
test_source.to_string(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
let all_test_files: Vec<&String> =
mappings.iter().flat_map(|m| m.test_files.iter()).collect();
assert!(
all_test_files.is_empty(),
"expected no test_files when tsconfig absent, got {:?}",
all_test_files
);
}
#[test]
fn test_observe_tsconfig_alias_barrel() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let services_dir = src_dir.join("services");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&services_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
std::fs::write(
dir.path().join("tsconfig.json"),
r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
)
.unwrap();
let prod_path = src_dir.join("bar.service.ts");
std::fs::File::create(&prod_path).unwrap();
std::fs::write(
services_dir.join("index.ts"),
"export { BarService } from '../bar.service';\n",
)
.unwrap();
let test_path = test_dir.join("bar.service.spec.ts");
let test_source =
"import { BarService } from '@app/services';\ndescribe('BarService', () => {});\n";
std::fs::write(&test_path, test_source).unwrap();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
test_source.to_string(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
let mapping = mappings
.iter()
.find(|m| m.production_file.contains("bar.service.ts"))
.expect("expected mapping for bar.service.ts");
assert!(
mapping
.test_files
.contains(&test_path.to_string_lossy().into_owned()),
"expected bar.service.spec.ts mapped via alias+barrel, got {:?}",
mapping.test_files
);
}
#[test]
fn test_observe_tsconfig_alias_mixed() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
std::fs::write(
dir.path().join("tsconfig.json"),
r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
)
.unwrap();
let foo_path = src_dir.join("foo.service.ts");
let bar_path = src_dir.join("bar.service.ts");
std::fs::File::create(&foo_path).unwrap();
std::fs::File::create(&bar_path).unwrap();
let test_path = test_dir.join("mixed.spec.ts");
let test_source = "\
import { FooService } from '@app/foo.service';
import { BarService } from '../src/bar.service';
describe('Mixed', () => {});
";
std::fs::write(&test_path, test_source).unwrap();
let production_files = vec![
foo_path.to_string_lossy().into_owned(),
bar_path.to_string_lossy().into_owned(),
];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
test_source.to_string(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
let foo_mapping = mappings
.iter()
.find(|m| m.production_file.contains("foo.service.ts"))
.expect("expected mapping for foo.service.ts");
assert!(
foo_mapping
.test_files
.contains(&test_path.to_string_lossy().into_owned()),
"expected mixed.spec.ts in foo mapping, got {:?}",
foo_mapping.test_files
);
let bar_mapping = mappings
.iter()
.find(|m| m.production_file.contains("bar.service.ts"))
.expect("expected mapping for bar.service.ts");
assert!(
bar_mapping
.test_files
.contains(&test_path.to_string_lossy().into_owned()),
"expected mixed.spec.ts in bar mapping, got {:?}",
bar_mapping.test_files
);
}
#[test]
fn test_observe_tsconfig_alias_helper_filtered() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
std::fs::write(
dir.path().join("tsconfig.json"),
r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
)
.unwrap();
let prod_path = src_dir.join("constants.ts");
std::fs::File::create(&prod_path).unwrap();
let test_path = test_dir.join("constants.spec.ts");
let test_source =
"import { APP_NAME } from '@app/constants';\ndescribe('Constants', () => {});\n";
std::fs::write(&test_path, test_source).unwrap();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
test_source.to_string(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
let all_test_files: Vec<&String> =
mappings.iter().flat_map(|m| m.test_files.iter()).collect();
assert!(
all_test_files.is_empty(),
"expected constants.ts filtered by is_non_sut_helper, got {:?}",
all_test_files
);
}
#[test]
fn test_observe_tsconfig_alias_nonexistent() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
std::fs::write(
dir.path().join("tsconfig.json"),
r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
)
.unwrap();
let prod_path = src_dir.join("foo.service.ts");
std::fs::File::create(&prod_path).unwrap();
let test_path = test_dir.join("nonexistent.spec.ts");
let test_source =
"import { Missing } from '@app/nonexistent';\ndescribe('Nonexistent', () => {});\n";
std::fs::write(&test_path, test_source).unwrap();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
test_source.to_string(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
let all_test_files: Vec<&String> =
mappings.iter().flat_map(|m| m.test_files.iter()).collect();
assert!(
all_test_files.is_empty(),
"expected no mapping for alias to nonexistent file, got {:?}",
all_test_files
);
}
#[test]
fn boundary_b3_tsconfig_alias_resolved() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let src_dir = dir.path().join("src");
let services_dir = src_dir.join("services");
let test_dir = dir.path().join("test");
std::fs::create_dir_all(&services_dir).unwrap();
std::fs::create_dir_all(&test_dir).unwrap();
std::fs::write(
dir.path().join("tsconfig.json"),
r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#,
)
.unwrap();
let prod_path = services_dir.join("foo.service.ts");
std::fs::File::create(&prod_path).unwrap();
let test_path = test_dir.join("foo.service.spec.ts");
let test_source = "import { FooService } from '@app/services/foo.service';\ndescribe('FooService', () => {});\n";
std::fs::write(&test_path, test_source).unwrap();
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
test_source.to_string(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, dir.path());
let mapping = mappings
.iter()
.find(|m| m.production_file.contains("foo.service.ts"))
.expect("expected FileMapping for foo.service.ts");
assert!(
mapping
.test_files
.contains(&test_path.to_string_lossy().into_owned()),
"expected tsconfig alias to be resolved (B3 fix), got {:?}",
mapping.test_files
);
}
#[test]
fn boundary_b6_import_outside_scan_root() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let core_src = dir.path().join("packages").join("core").join("src");
let core_test = dir.path().join("packages").join("core").join("test");
let common_src = dir.path().join("packages").join("common").join("src");
std::fs::create_dir_all(&core_src).unwrap();
std::fs::create_dir_all(&core_test).unwrap();
std::fs::create_dir_all(&common_src).unwrap();
let prod_path = core_src.join("foo.service.ts");
std::fs::File::create(&prod_path).unwrap();
let shared_path = common_src.join("shared.ts");
std::fs::File::create(&shared_path).unwrap();
let test_path = core_test.join("foo.spec.ts");
std::fs::write(
&test_path,
"import { Shared } from '../../common/src/shared';\ndescribe('Foo', () => {});",
)
.unwrap();
let scan_root = dir.path().join("packages").join("core");
let production_files = vec![prod_path.to_string_lossy().into_owned()];
let mut test_sources = HashMap::new();
test_sources.insert(
test_path.to_string_lossy().into_owned(),
std::fs::read_to_string(&test_path).unwrap(),
);
let extractor = TypeScriptExtractor::new();
let mappings =
extractor.map_test_files_with_imports(&production_files, &test_sources, &scan_root);
let all_test_files: Vec<&String> =
mappings.iter().flat_map(|m| m.test_files.iter()).collect();
assert!(
all_test_files.is_empty(),
"expected no test_files (import target outside scan_root), got {:?}",
all_test_files
);
}
}