use anyhow::{Context, Result};
use oxc_allocator::Allocator;
use oxc_ast::ast::*;
use oxc_parser::Parser;
use oxc_span::{GetSpan, SourceType};
use semver_analyzer_core::ApiSurface as CoreApiSurface;
use semver_analyzer_core::Symbol as CoreSymbol;
use semver_analyzer_core::{
AccessorKind, Parameter, Signature, SymbolKind, TypeParameter, Visibility,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::TsSymbolData;
type Symbol = CoreSymbol<TsSymbolData>;
type ApiSurface = CoreApiSurface<TsSymbolData>;
#[derive(Default)]
pub struct OxcExtractor;
impl OxcExtractor {
pub fn new() -> Self {
Self
}
pub fn extract_from_dir(&self, dir: &Path) -> Result<ApiSurface> {
let all_files = find_dts_files(dir)?;
let reachability = filter_to_reachable(&all_files, dir);
let files = &reachability.files;
let mut global_imports = scan_types_packages(dir);
let types_count = global_imports.len();
let mut file_sources: Vec<(PathBuf, String)> = Vec::new();
for file_path in files {
let source = std::fs::read_to_string(file_path)
.with_context(|| format!("Failed to read {}", file_path.display()))?;
let file_imports = collect_imports_from_source(&source);
global_imports.merge_namespaces_from(&file_imports);
file_sources.push((file_path.clone(), source));
}
if !global_imports.is_empty() {
let from_files = global_imports.len() - types_count;
if types_count > 0 || from_files > 0 {
tracing::debug!(
from_types = types_count,
from_files = from_files,
"Global import map built"
);
}
}
let mut symbols = Vec::new();
for (file_path, source) in &file_sources {
let relative = file_path.strip_prefix(dir).unwrap_or(file_path);
let mapped = remap_dist_to_src(relative);
symbols.extend(self.extract_from_source_with_globals(source, &mapped, &global_imports));
}
let mut pkg_name_cache: HashMap<String, String> = HashMap::new();
for sym in &symbols {
let path_str = sym.file.to_string_lossy();
let parts: Vec<&str> = path_str.split('/').collect();
if parts.len() >= 2 && parts[0] == "packages" {
let dir_name = parts[1].to_string();
if let std::collections::hash_map::Entry::Vacant(e) = pkg_name_cache.entry(dir_name)
{
let pkg_json_path = dir.join("packages").join(e.key()).join("package.json");
let npm_name = std::fs::read_to_string(&pkg_json_path)
.ok()
.and_then(|content| {
serde_json::from_str::<serde_json::Value>(&content).ok()
})
.and_then(|v| v.get("name")?.as_str().map(|s| s.to_string()));
if let Some(name) = npm_name {
e.insert(name);
}
}
}
}
for sym in &mut symbols {
let path_str = sym.file.to_string_lossy();
let parts: Vec<&str> = path_str.split('/').collect();
if parts.len() >= 2 && parts[0] == "packages" {
if let Some(npm_name) = pkg_name_cache.get(parts[1]) {
sym.package = Some(npm_name.clone());
}
}
}
if !reachability.provenance.is_empty() {
set_import_paths(&mut symbols, &file_sources, &reachability.provenance, dir);
}
populate_rendered_components(&mut symbols, dir);
Ok(ApiSurface { symbols })
}
pub fn extract_from_source(&self, source: &str, file_path: &Path) -> Vec<Symbol> {
self.extract_from_source_with_globals(source, file_path, &crate::canon::ImportMap::new())
}
fn extract_from_source_with_globals(
&self,
source: &str,
file_path: &Path,
global_imports: &crate::canon::ImportMap,
) -> Vec<Symbol> {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source, SourceType::d_ts()).parse();
if !ret.errors.is_empty() {
for err in &ret.errors {
tracing::warn!(file = %file_path.display(), error = %err, "Parse error in .d.ts file");
}
}
let mut imports = collect_imports(source, &ret.program.body);
imports.merge_all_from(global_imports);
let line_offsets = compute_line_offsets(source);
let mut symbols = Vec::new();
for stmt in &ret.program.body {
extract_statement(
source,
stmt,
file_path,
&line_offsets,
&imports,
&mut symbols,
);
}
symbols
}
}
impl OxcExtractor {
pub fn extract_at_ref(
&self,
repo: &Path,
git_ref: &str,
build_command: Option<&str>,
degradation: Option<&semver_analyzer_core::diagnostics::DegradationTracker>,
) -> Result<ApiSurface> {
use crate::worktree::{ExtractionWarning, WorktreeGuard};
use semver_analyzer_core::error::DiagnoseWithTip;
let guard = WorktreeGuard::new(repo, git_ref, build_command).diagnose()?;
if let Some(tracker) = degradation {
for warning in guard.warnings() {
match warning {
ExtractionWarning::PartialTscBuildFailed {
succeeded, failed, ..
} => {
tracker.record(
"TD",
format!(
"tsc partially succeeded ({} packages ok, {} failed) \
and project build also failed at ref {}",
succeeded, failed, git_ref
),
"API surface may be incomplete — some package \
declarations could not be generated",
);
}
ExtractionWarning::TscFailedBuildSucceeded { .. } => {
tracker.record(
"TD",
format!("tsc failed at ref {}, fell back to project build", git_ref),
"API surface was extracted via project build — \
coverage should be complete",
);
}
}
}
}
self.extract_from_dir(guard.path())
}
}
fn remap_dist_to_src(path: &Path) -> PathBuf {
let path_str = path.to_string_lossy();
let dist_segments = [
"/dist/esm/",
"/dist/js/",
"/dist/cjs/",
"/dist/mjs/",
"/dist/es/",
"/dist/commonjs/",
"/dist/lib/",
];
for segment in &dist_segments {
if let Some(pos) = path_str.find(segment) {
let before = &path_str[..pos];
let after = &path_str[pos + segment.len()..];
return PathBuf::from(format!("{}/src/{}", before, after));
}
}
if let Some(pos) = path_str.find("/dist/") {
let before = &path_str[..pos];
let after = &path_str[pos + "/dist/".len()..];
return PathBuf::from(format!("{}/src/{}", before, after));
}
path.to_path_buf()
}
fn extract_css_style_tokens(source: &str) -> Vec<String> {
static RE: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r"styles\.([a-zA-Z][a-zA-Z0-9]+)").unwrap());
let mut tokens: Vec<String> = Vec::new();
for cap in RE.captures_iter(source) {
let token = &cap[1];
if token == "modifiers" {
continue;
}
if !tokens.contains(&token.to_string()) {
tokens.push(token.to_string());
}
}
tokens
}
fn populate_rendered_components(symbols: &mut [Symbol], worktree_dir: &Path) {
use std::collections::HashMap;
let mut cache: HashMap<PathBuf, (Vec<String>, Vec<String>)> = HashMap::new();
let mut enriched = 0u32;
for sym in symbols.iter_mut() {
if !matches!(
sym.kind,
SymbolKind::Variable | SymbolKind::Function | SymbolKind::Constant
) {
continue;
}
if !sym.name.starts_with(|c: char| c.is_ascii_uppercase()) {
continue;
}
let dts_path = sym.file.to_string_lossy();
let tsx_relative = if dts_path.ends_with(".d.ts") {
PathBuf::from(dts_path.trim_end_matches(".d.ts").to_owned() + ".tsx")
} else {
continue;
};
if let Some((rendered, css_tokens)) = cache.get(&tsx_relative) {
if !rendered.is_empty() {
sym.language_data.rendered_components = rendered.clone();
enriched += 1;
}
if !css_tokens.is_empty() {
sym.language_data.css = css_tokens.clone();
}
continue;
}
let tsx_abs = worktree_dir.join(&tsx_relative);
let (rendered, css_tokens) = match std::fs::read_to_string(&tsx_abs) {
Ok(source) => (
crate::jsx_diff::extract_rendered_components_from_source(&source),
extract_css_style_tokens(&source),
),
Err(_) => (Vec::new(), Vec::new()),
};
if !rendered.is_empty() {
sym.language_data.rendered_components = rendered.clone();
enriched += 1;
}
if !css_tokens.is_empty() {
sym.language_data.css = css_tokens.clone();
}
cache.insert(tsx_relative, (rendered, css_tokens));
}
if enriched > 0 {
tracing::info!(
enriched_symbols = enriched,
tsx_files_parsed = cache.values().filter(|(v, _)| !v.is_empty()).count(),
"Populated rendered_components from .tsx source files"
);
}
}
fn find_dts_files(dir: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
find_dts_recursive(dir, &mut files)?;
let excluded = find_redundant_dist_dirs(&files);
if !excluded.is_empty() {
let before = files.len();
files.retain(|f| !excluded.iter().any(|dir| f.starts_with(dir)));
let removed = before - files.len();
if removed > 0 {
tracing::debug!(
removed_count = removed,
"Deduplicated build outputs: removed redundant .d.ts files"
);
}
}
files.sort(); Ok(files)
}
struct ReachabilityResult {
files: Vec<PathBuf>,
provenance: std::collections::HashMap<PathBuf, Vec<PathBuf>>,
}
fn filter_to_reachable(files: &[PathBuf], _base_dir: &Path) -> ReachabilityResult {
use std::collections::{HashMap, HashSet, VecDeque};
let index_files: Vec<&PathBuf> = files
.iter()
.filter(|f| f.file_name().map(|n| n == "index.d.ts").unwrap_or(false))
.collect();
if index_files.is_empty() {
return ReachabilityResult {
files: files.to_vec(),
provenance: HashMap::new(),
};
}
let file_set: HashSet<PathBuf> = files.iter().cloned().collect();
let mut provenance: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
for entry_point in &index_files {
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut queue: VecDeque<PathBuf> = VecDeque::new();
let ep = entry_point.to_path_buf();
visited.insert(ep.clone());
queue.push_back(ep.clone());
while let Some(file) = queue.pop_front() {
provenance.entry(file.clone()).or_default().push(ep.clone());
let source = match std::fs::read_to_string(&file) {
Ok(s) => s,
Err(_) => continue,
};
let parent_dir = file.parent().unwrap_or(std::path::Path::new("."));
for line in source.lines() {
let trimmed = line.trim();
if let Some(from_path) = extract_export_from_path(trimmed) {
let resolved = resolve_dts_path(parent_dir, &from_path, &file_set);
if let Some(resolved) = resolved {
if visited.insert(resolved.clone()) {
queue.push_back(resolved);
}
}
}
}
}
}
let original_count = files.len();
let filtered: Vec<PathBuf> = files
.iter()
.filter(|f| provenance.contains_key(*f))
.cloned()
.collect();
let excluded = original_count - filtered.len();
if excluded > 0 {
tracing::debug!(
reachable = filtered.len(),
total = original_count,
excluded = excluded,
"Entry-point filter applied to .d.ts files"
);
}
ReachabilityResult {
files: filtered,
provenance,
}
}
fn extract_export_from_path(line: &str) -> Option<String> {
if !line.starts_with("export") {
return None;
}
let from_idx = line.find(" from ")?;
let after_from = &line[from_idx + 6..];
let quote_char = after_from.chars().next()?;
if quote_char != '\'' && quote_char != '"' {
return None;
}
let rest = &after_from[1..];
let end_idx = rest.find(quote_char)?;
let path = &rest[..end_idx];
if path.starts_with('.') {
Some(path.to_string())
} else {
None
}
}
fn resolve_dts_path(
parent_dir: &Path,
from_path: &str,
file_set: &std::collections::HashSet<PathBuf>,
) -> Option<PathBuf> {
let base = parent_dir.join(from_path);
let with_dts = base.with_extension("d.ts");
if file_set.contains(&with_dts) {
return Some(with_dts);
}
let with_index = base.join("index.d.ts");
if file_set.contains(&with_index) {
return Some(with_index);
}
if file_set.contains(&base) {
return Some(base);
}
if let Some(without_js) = from_path.strip_suffix(".js") {
let with_dts = parent_dir.join(without_js).with_extension("d.ts");
if file_set.contains(&with_dts) {
return Some(with_dts);
}
let with_index = parent_dir.join(without_js).join("index.d.ts");
if file_set.contains(&with_index) {
return Some(with_index);
}
}
None
}
fn set_import_paths(
symbols: &mut [Symbol],
file_sources: &[(PathBuf, String)],
provenance: &std::collections::HashMap<PathBuf, Vec<PathBuf>>,
base_dir: &Path,
) {
use std::collections::HashMap;
let mut remap_to_original: HashMap<PathBuf, PathBuf> = HashMap::new();
for (file_path, _) in file_sources {
let relative = file_path.strip_prefix(base_dir).unwrap_or(file_path);
let mapped = remap_dist_to_src(relative);
remap_to_original.insert(mapped, file_path.clone());
}
for sym in symbols.iter_mut() {
let original_path = match remap_to_original.get(&sym.file) {
Some(p) => p,
None => continue,
};
let entry_points = match provenance.get(original_path) {
Some(eps) => eps,
None => continue,
};
if entry_points.is_empty() {
continue;
}
let mut shortest_subpath: Option<String> = None;
let mut is_root = false;
for ep in entry_points {
let ep_relative = ep.strip_prefix(base_dir).unwrap_or(ep);
match entry_point_subpath(ep_relative) {
None => {
is_root = true;
break;
}
Some(subpath) => {
if shortest_subpath
.as_ref()
.is_none_or(|s| subpath.len() < s.len())
{
shortest_subpath = Some(subpath);
}
}
}
}
if is_root {
continue;
}
if let (Some(ref pkg), Some(ref subpath)) = (&sym.package, &shortest_subpath) {
sym.import_path = Some(format!("{}/{}", pkg, subpath));
tracing::trace!(
symbol = %sym.name,
import_path = %sym.import_path.as_deref().unwrap_or("?"),
"Symbol import path set from subpath entry point"
);
}
}
}
fn entry_point_subpath(entry_point_relative: &Path) -> Option<String> {
let path_str = entry_point_relative.to_string_lossy();
let dist_segments = [
"/dist/esm/",
"/dist/js/",
"/dist/cjs/",
"/dist/mjs/",
"/dist/es/",
"/dist/commonjs/",
"/dist/lib/",
"/dist/",
"/src/",
];
for segment in &dist_segments {
if let Some(pos) = path_str.find(segment) {
let after = &path_str[pos + segment.len()..];
let subpath = after
.strip_suffix("index.d.ts")
.unwrap_or(after)
.trim_end_matches('/');
if subpath.is_empty() {
return None; }
return Some(subpath.to_string());
}
}
None
}
const DIST_VARIANT_PRIORITY: &[&str] = &["esm", "mjs", "es", "js", "cjs", "commonjs", "lib"];
fn find_redundant_dist_dirs(files: &[PathBuf]) -> Vec<PathBuf> {
use std::collections::{HashMap, HashSet};
let mut dist_variants: HashMap<PathBuf, HashSet<String>> = HashMap::new();
for file in files {
let components: Vec<_> = file.components().collect();
for (i, comp) in components.iter().enumerate() {
if comp.as_os_str() == "dist" && i + 1 < components.len() {
let dist_parent: PathBuf = components[..=i].iter().collect();
let variant = components[i + 1].as_os_str().to_string_lossy().to_string();
if DIST_VARIANT_PRIORITY.contains(&variant.as_str()) {
dist_variants
.entry(dist_parent)
.or_default()
.insert(variant);
}
break;
}
}
}
let mut exclude_dirs = Vec::new();
for (dist_dir, variants) in &dist_variants {
if variants.len() <= 1 {
continue;
}
if let Some(best) = DIST_VARIANT_PRIORITY
.iter()
.find(|p| variants.contains(**p))
{
for variant in variants {
if variant != *best {
exclude_dirs.push(dist_dir.join(variant));
}
}
}
}
exclude_dirs
}
fn find_dts_recursive(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
let entries = std::fs::read_dir(dir)
.with_context(|| format!("Failed to read directory {}", dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name == "node_modules" || name.starts_with('.') {
continue;
}
if path.is_dir() {
find_dts_recursive(&path, files)?;
} else if name.ends_with(".d.ts") {
files.push(path);
}
}
Ok(())
}
fn compute_line_offsets(source: &str) -> Vec<u32> {
let mut offsets = vec![0u32]; for (i, b) in source.bytes().enumerate() {
if b == b'\n' {
offsets.push((i + 1) as u32);
}
}
offsets
}
fn offset_to_line(offsets: &[u32], offset: u32) -> usize {
match offsets.binary_search(&offset) {
Ok(i) => i + 1,
Err(i) => i, }
}
fn span_text(source: &str, span: oxc_span::Span) -> &str {
&source[span.start as usize..span.end as usize]
}
fn type_annotation_str(
source: &str,
imports: &crate::canon::ImportMap,
ta: &TSTypeAnnotation,
) -> String {
let raw = span_text(source, ta.type_annotation.span());
let import_ref = if imports.is_empty() {
None
} else {
Some(imports)
};
crate::canon::canonicalize_type_with_imports(raw, import_ref).unwrap_or_else(|| raw.to_string())
}
fn collect_imports(source: &str, stmts: &[Statement]) -> crate::canon::ImportMap {
use oxc_ast::ast::ImportDeclarationSpecifier;
let mut map = crate::canon::ImportMap::new();
collect_reference_directives(source, &mut map);
for stmt in stmts {
let decl = match stmt {
Statement::ImportDeclaration(d) => d,
_ => continue,
};
let module = decl.source.value.as_str();
if let Some(specifiers) = &decl.specifiers {
for spec in specifiers {
match spec {
ImportDeclarationSpecifier::ImportDefaultSpecifier(default) => {
map.add_default(&default.local.name, module);
}
ImportDeclarationSpecifier::ImportNamespaceSpecifier(ns) => {
map.add_namespace(&ns.local.name, module);
}
ImportDeclarationSpecifier::ImportSpecifier(named) => {
let original = module_export_name_str(&named.imported);
map.add_named(&named.local.name, &original, module);
}
}
}
}
}
map
}
fn collect_reference_directives(source: &str, map: &mut crate::canon::ImportMap) {
let known_namespaces: &[(&str, &str)] = &[
("react", "React"),
("react-dom", "ReactDOM"),
("node", "NodeJS"),
];
for line in source.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("///") {
if !trimmed.is_empty() && !trimmed.starts_with("//") {
break;
}
continue;
}
if let Some(start) = trimmed.find("types=\"") {
let rest = &trimmed[start + 7..];
if let Some(end) = rest.find('"') {
let package = &rest[..end];
if let Some((_, ns)) = known_namespaces.iter().find(|(pkg, _)| *pkg == package) {
map.add_namespace(ns, package);
} else {
let ns_name = capitalize_first(package);
if !ns_name.is_empty() {
map.add_namespace(&ns_name, package);
}
}
}
}
}
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
}
fn collect_imports_from_source(source: &str) -> crate::canon::ImportMap {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source, SourceType::d_ts()).parse();
collect_imports(source, &ret.program.body)
}
fn scan_types_packages(dir: &Path) -> crate::canon::ImportMap {
let mut map = crate::canon::ImportMap::new();
let types_dir = find_types_dir(dir);
let types_dir = match types_dir {
Some(d) => d,
None => return map,
};
let entries = match std::fs::read_dir(&types_dir) {
Ok(e) => e,
Err(_) => return map,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let pkg_name = entry.file_name().to_string_lossy().to_string();
let entry_file = resolve_types_entry(&path);
let entry_file = match entry_file {
Some(f) => f,
None => continue,
};
let source = match std::fs::read_to_string(&entry_file) {
Ok(s) => s,
Err(_) => continue,
};
if let Some(ns_name) = find_export_as_namespace(&source) {
map.add_namespace(&ns_name, &pkg_name);
continue;
}
if !has_module_syntax(&source) {
for ns_name in find_declare_namespaces(&source) {
map.add_namespace(&ns_name, &pkg_name);
}
}
}
map
}
fn find_types_dir(dir: &Path) -> Option<PathBuf> {
let mut current = dir.to_path_buf();
loop {
let candidate = current.join("node_modules/@types");
if candidate.is_dir() {
return Some(candidate);
}
if !current.pop() {
return None;
}
}
}
fn resolve_types_entry(pkg_dir: &Path) -> Option<PathBuf> {
let pkg_json = pkg_dir.join("package.json");
if let Ok(content) = std::fs::read_to_string(&pkg_json) {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
for field in &["types", "typings"] {
if let Some(entry) = json.get(field).and_then(|v| v.as_str()) {
if !entry.is_empty() {
let path = pkg_dir.join(entry);
if path.exists() {
return Some(path);
}
}
}
}
}
}
let index = pkg_dir.join("index.d.ts");
if index.exists() {
Some(index)
} else {
None
}
}
fn find_export_as_namespace(source: &str) -> Option<String> {
for line in source.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("export as namespace ") {
let name = rest.trim_end_matches(';').trim();
if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Some(name.to_string());
}
}
}
None
}
fn has_module_syntax(source: &str) -> bool {
for line in source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') {
continue;
}
if trimmed.starts_with("import ") || trimmed.starts_with("export ") {
return true;
}
}
false
}
fn find_declare_namespaces(source: &str) -> Vec<String> {
let mut namespaces = Vec::new();
for line in source.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("declare namespace ") {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !name.is_empty() {
namespaces.push(name);
}
}
}
namespaces
}
fn qualified_name(file: &Path, parts: &[&str]) -> String {
let stem = file
.with_extension("") .with_extension("") .to_string_lossy()
.replace('\\', "/");
let mut qn = stem;
for part in parts {
qn.push('.');
qn.push_str(part);
}
qn
}
fn extract_statement(
source: &str,
stmt: &Statement,
file: &Path,
line_offsets: &[u32],
imports: &crate::canon::ImportMap,
symbols: &mut Vec<Symbol>,
) {
match stmt {
Statement::ExportNamedDeclaration(export) => {
if let Some(decl) = &export.declaration {
extract_declaration(
source,
imports,
decl,
file,
line_offsets,
Visibility::Exported,
symbols,
);
}
for spec in &export.specifiers {
let local_name = module_export_name_str(&spec.local);
let exported_name = module_export_name_str(&spec.exported);
let source_module = export.source.as_ref().map(|s| s.value.to_string());
let line = offset_to_line(line_offsets, spec.span.start);
let mut sym = Symbol::new(
exported_name.clone(),
qualified_name(file, &[&exported_name]),
SymbolKind::Variable, Visibility::Exported,
file,
line,
);
if let Some(src) = source_module {
sym.type_dependencies
.push(format!("reexport:{}:{}", src, local_name));
}
symbols.push(sym);
}
}
Statement::ExportDefaultDeclaration(export) => {
extract_default_export(source, imports, export, file, line_offsets, symbols);
}
Statement::ExportAllDeclaration(export) => {
let line = offset_to_line(line_offsets, export.span.start);
let source_module = export.source.value.to_string();
if let Some(exported) = &export.exported {
let name = module_export_name_str(exported);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[&name]),
SymbolKind::Namespace,
Visibility::Exported,
file,
line,
);
sym.type_dependencies
.push(format!("reexport-all-as:{}", source_module));
symbols.push(sym);
} else {
let mut sym = Symbol::new(
"*".to_string(),
qualified_name(file, &["*"]),
SymbolKind::Namespace,
Visibility::Exported,
file,
line,
);
sym.type_dependencies
.push(format!("reexport-all:{}", source_module));
symbols.push(sym);
}
}
_ => {} }
}
fn extract_declaration(
source: &str,
imports: &crate::canon::ImportMap,
decl: &Declaration,
file: &Path,
line_offsets: &[u32],
visibility: Visibility,
symbols: &mut Vec<Symbol>,
) {
match decl {
Declaration::FunctionDeclaration(func) => {
if let Some(sym) =
extract_function(source, imports, func, file, line_offsets, visibility)
{
symbols.push(sym);
}
}
Declaration::ClassDeclaration(cls) => {
if let Some(sym) = extract_class(source, imports, cls, file, line_offsets, visibility) {
symbols.push(sym);
}
}
Declaration::TSInterfaceDeclaration(iface) => {
symbols.push(extract_interface(
source,
imports,
iface,
file,
line_offsets,
visibility,
));
}
Declaration::TSTypeAliasDeclaration(alias) => {
symbols.push(extract_type_alias(
source,
imports,
alias,
file,
line_offsets,
visibility,
));
}
Declaration::TSEnumDeclaration(enum_decl) => {
symbols.push(extract_enum(
source,
imports,
enum_decl,
file,
line_offsets,
visibility,
));
}
Declaration::VariableDeclaration(var) => {
extract_variables(
source,
imports,
var,
file,
line_offsets,
visibility,
symbols,
);
}
Declaration::TSModuleDeclaration(ns) => {
extract_namespace(source, imports, ns, file, line_offsets, visibility, symbols);
}
_ => {} }
}
fn extract_default_export(
source: &str,
imports: &crate::canon::ImportMap,
export: &ExportDefaultDeclaration,
file: &Path,
line_offsets: &[u32],
symbols: &mut Vec<Symbol>,
) {
let line = offset_to_line(line_offsets, export.span.start);
match &export.declaration {
ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
let name = func
.id
.as_ref()
.map(|id| id.name.to_string())
.unwrap_or_else(|| "default".to_string());
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[&name]),
SymbolKind::Function,
Visibility::Exported,
file,
line,
);
sym.signature = Some(extract_signature(source, imports, func));
sym.type_dependencies = collect_function_type_deps(source, func);
symbols.push(sym);
}
ExportDefaultDeclarationKind::ClassDeclaration(cls) => {
if let Some(mut sym) = extract_class(
source,
imports,
cls,
file,
line_offsets,
Visibility::Exported,
) {
if sym.name == "<anonymous>" {
sym.name = "default".to_string();
sym.qualified_name = qualified_name(file, &["default"]);
}
symbols.push(sym);
}
}
ExportDefaultDeclarationKind::TSInterfaceDeclaration(iface) => {
symbols.push(extract_interface(
source,
imports,
iface,
file,
line_offsets,
Visibility::Exported,
));
}
_ => {
let sym = Symbol::new(
"default",
qualified_name(file, &["default"]),
SymbolKind::Variable,
Visibility::Exported,
file,
line,
);
symbols.push(sym);
}
}
}
fn extract_function(
source: &str,
imports: &crate::canon::ImportMap,
func: &Function,
file: &Path,
line_offsets: &[u32],
visibility: Visibility,
) -> Option<Symbol> {
let name = func.id.as_ref()?.name.to_string();
let line = offset_to_line(line_offsets, func.span.start);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[&name]),
SymbolKind::Function,
visibility,
file,
line,
);
sym.signature = Some(extract_signature(source, imports, func));
sym.type_dependencies = collect_function_type_deps(source, func);
Some(sym)
}
fn extract_signature(
source: &str,
imports: &crate::canon::ImportMap,
func: &Function,
) -> Signature {
let parameters = extract_params(source, imports, &func.params);
let return_type = func
.return_type
.as_ref()
.map(|ta| type_annotation_str(source, imports, ta));
let type_parameters = func
.type_parameters
.as_ref()
.map(|tp| extract_type_parameters(source, tp))
.unwrap_or_default();
Signature {
parameters,
return_type,
type_parameters,
is_async: func.r#async,
}
}
fn extract_class(
source: &str,
imports: &crate::canon::ImportMap,
cls: &Class,
file: &Path,
line_offsets: &[u32],
visibility: Visibility,
) -> Option<Symbol> {
let name = cls
.id
.as_ref()
.map(|id| id.name.to_string())
.unwrap_or_else(|| "<anonymous>".to_string());
let line = offset_to_line(line_offsets, cls.span.start);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[&name]),
SymbolKind::Class,
visibility,
file,
line,
);
if let Some(super_class) = &cls.super_class {
sym.extends = Some(span_text(source, super_class.span()).to_string());
}
sym.implements = cls
.implements
.iter()
.map(|imp| {
span_text(source, imp.expression.span()).to_string()
})
.collect();
sym.is_abstract = cls.r#abstract;
if let Some(tp) = &cls.type_parameters {
for param in &tp.params {
if let Some(constraint) = ¶m.constraint {
collect_type_deps_from_ts_type(source, constraint, &mut sym.type_dependencies);
}
}
}
for element in &cls.body.body {
if let Some(member) =
extract_class_element(source, imports, element, file, &name, line_offsets)
{
sym.members.push(member);
}
}
Some(sym)
}
fn extract_class_element(
source: &str,
imports: &crate::canon::ImportMap,
element: &ClassElement,
file: &Path,
class_name: &str,
line_offsets: &[u32],
) -> Option<Symbol> {
match element {
ClassElement::MethodDefinition(method) => {
extract_method_definition(source, imports, method, file, class_name, line_offsets)
}
ClassElement::PropertyDefinition(prop) => {
extract_property_definition(source, imports, prop, file, class_name, line_offsets)
}
ClassElement::AccessorProperty(prop) => {
extract_accessor_property(source, imports, prop, file, class_name, line_offsets)
}
ClassElement::TSIndexSignature(idx) => {
let line = offset_to_line(line_offsets, idx.span.start);
let mut sym = Symbol::new(
"[index]",
qualified_name(file, &[class_name, "[index]"]),
SymbolKind::Property,
ts_accessibility_to_visibility(None),
file,
line,
);
sym.is_readonly = idx.readonly;
sym.type_dependencies
.extend(collect_type_deps_from_annotation(
source,
&idx.type_annotation,
));
Some(sym)
}
ClassElement::StaticBlock(_) => None, }
}
fn extract_method_definition(
source: &str,
imports: &crate::canon::ImportMap,
method: &MethodDefinition,
file: &Path,
class_name: &str,
line_offsets: &[u32],
) -> Option<Symbol> {
let name = property_key_name(&method.key)?;
let line = offset_to_line(line_offsets, method.span.start);
if matches!(method.accessibility, Some(TSAccessibility::Private)) {
return None;
}
let (kind, accessor_kind) = match method.kind {
MethodDefinitionKind::Constructor => (SymbolKind::Constructor, None),
MethodDefinitionKind::Method => (SymbolKind::Method, None),
MethodDefinitionKind::Get => (SymbolKind::GetAccessor, Some(AccessorKind::Get)),
MethodDefinitionKind::Set => (SymbolKind::SetAccessor, Some(AccessorKind::Set)),
};
let visibility = ts_accessibility_to_visibility(method.accessibility);
let is_abstract = matches!(
method.r#type,
MethodDefinitionType::TSAbstractMethodDefinition
);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[class_name, &name]),
kind,
visibility,
file,
line,
);
sym.is_abstract = is_abstract;
sym.is_static = method.r#static;
sym.accessor_kind = accessor_kind;
sym.signature = Some(extract_signature(source, imports, &method.value));
sym.type_dependencies = collect_function_type_deps(source, &method.value);
Some(sym)
}
fn extract_property_definition(
source: &str,
imports: &crate::canon::ImportMap,
prop: &PropertyDefinition,
file: &Path,
class_name: &str,
line_offsets: &[u32],
) -> Option<Symbol> {
let name = property_key_name(&prop.key)?;
let line = offset_to_line(line_offsets, prop.span.start);
if matches!(prop.accessibility, Some(TSAccessibility::Private)) {
return None;
}
let visibility = ts_accessibility_to_visibility(prop.accessibility);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[class_name, &name]),
SymbolKind::Property,
visibility,
file,
line,
);
sym.is_readonly = prop.readonly;
sym.is_static = prop.r#static;
sym.is_abstract = matches!(
prop.r#type,
PropertyDefinitionType::TSAbstractPropertyDefinition
);
if let Some(ta) = &prop.type_annotation {
sym.type_dependencies = collect_type_deps_from_annotation(source, ta);
}
if let Some(ta) = &prop.type_annotation {
sym.signature = Some(Signature {
parameters: Vec::new(),
return_type: Some(type_annotation_str(source, imports, ta)),
type_parameters: Vec::new(),
is_async: false,
});
}
Some(sym)
}
fn extract_accessor_property(
source: &str,
imports: &crate::canon::ImportMap,
prop: &AccessorProperty,
file: &Path,
class_name: &str,
line_offsets: &[u32],
) -> Option<Symbol> {
let name = property_key_name(&prop.key)?;
let line = offset_to_line(line_offsets, prop.span.start);
let visibility = ts_accessibility_to_visibility(prop.accessibility);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[class_name, &name]),
SymbolKind::Property,
visibility,
file,
line,
);
sym.is_static = prop.r#static;
sym.accessor_kind = Some(AccessorKind::Get);
if let Some(ta) = &prop.type_annotation {
sym.type_dependencies = collect_type_deps_from_annotation(source, ta);
sym.signature = Some(Signature {
parameters: Vec::new(),
return_type: Some(type_annotation_str(source, imports, ta)),
type_parameters: Vec::new(),
is_async: false,
});
}
Some(sym)
}
fn extract_interface(
source: &str,
imports: &crate::canon::ImportMap,
iface: &TSInterfaceDeclaration,
file: &Path,
line_offsets: &[u32],
visibility: Visibility,
) -> Symbol {
let name = iface.id.name.to_string();
let line = offset_to_line(line_offsets, iface.span.start);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[&name]),
SymbolKind::Interface,
visibility,
file,
line,
);
if !iface.extends.is_empty() {
let names: Vec<String> = iface
.extends
.iter()
.map(|ext| span_text(source, ext.span).to_string())
.collect();
if let Some(first) = names.first() {
sym.extends = Some(first.clone());
}
if names.len() > 1 {
sym.implements = names[1..].to_vec();
}
}
if let Some(tp) = &iface.type_parameters {
for param in &tp.params {
if let Some(constraint) = ¶m.constraint {
collect_type_deps_from_ts_type(source, constraint, &mut sym.type_dependencies);
}
}
}
for member in &iface.body.body {
if let Some(member_sym) =
extract_ts_signature(source, imports, member, file, &name, line_offsets)
{
sym.members.push(member_sym);
}
}
sym
}
fn extract_ts_signature(
source: &str,
imports: &crate::canon::ImportMap,
sig: &TSSignature,
file: &Path,
parent_name: &str,
line_offsets: &[u32],
) -> Option<Symbol> {
match sig {
TSSignature::TSPropertySignature(prop) => {
let name = property_key_name(&prop.key)?;
let line = offset_to_line(line_offsets, prop.span.start);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[parent_name, &name]),
SymbolKind::Property,
Visibility::Public,
file,
line,
);
sym.is_readonly = prop.readonly;
if let Some(ta) = &prop.type_annotation {
sym.type_dependencies = collect_type_deps_from_annotation(source, ta);
sym.signature = Some(Signature {
parameters: Vec::new(),
return_type: Some(type_annotation_str(source, imports, ta)),
type_parameters: Vec::new(),
is_async: false,
});
}
Some(sym)
}
TSSignature::TSMethodSignature(method) => {
let name = property_key_name(&method.key)?;
let line = offset_to_line(line_offsets, method.span.start);
let kind = match method.kind {
TSMethodSignatureKind::Method => SymbolKind::Method,
TSMethodSignatureKind::Get => SymbolKind::GetAccessor,
TSMethodSignatureKind::Set => SymbolKind::SetAccessor,
};
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[parent_name, &name]),
kind,
Visibility::Public,
file,
line,
);
let parameters = extract_params(source, imports, &method.params);
let return_type = method
.return_type
.as_ref()
.map(|ta| type_annotation_str(source, imports, ta));
let type_parameters = method
.type_parameters
.as_ref()
.map(|tp| extract_type_parameters(source, tp))
.unwrap_or_default();
sym.signature = Some(Signature {
parameters,
return_type,
type_parameters,
is_async: false,
});
let mut deps = Vec::new();
for param in &method.params.items {
if let Some(ta) = ¶m.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, &mut deps);
}
}
if let Some(ta) = &method.return_type {
collect_type_deps_from_ts_type(source, &ta.type_annotation, &mut deps);
}
deps.sort();
deps.dedup();
sym.type_dependencies = deps;
Some(sym)
}
TSSignature::TSIndexSignature(idx) => {
let line = offset_to_line(line_offsets, idx.span.start);
let mut sym = Symbol::new(
"[index]",
qualified_name(file, &[parent_name, "[index]"]),
SymbolKind::Property,
Visibility::Public,
file,
line,
);
sym.is_readonly = idx.readonly;
sym.type_dependencies = collect_type_deps_from_annotation(source, &idx.type_annotation);
Some(sym)
}
TSSignature::TSCallSignatureDeclaration(call) => {
let line = offset_to_line(line_offsets, call.span.start);
let parameters = extract_params(source, imports, &call.params);
let return_type = call
.return_type
.as_ref()
.map(|ta| type_annotation_str(source, imports, ta));
let type_parameters = call
.type_parameters
.as_ref()
.map(|tp| extract_type_parameters(source, tp))
.unwrap_or_default();
let mut sym = Symbol::new(
"(call)",
qualified_name(file, &[parent_name, "(call)"]),
SymbolKind::Method,
Visibility::Public,
file,
line,
);
sym.signature = Some(Signature {
parameters,
return_type,
type_parameters,
is_async: false,
});
Some(sym)
}
TSSignature::TSConstructSignatureDeclaration(ctor) => {
let line = offset_to_line(line_offsets, ctor.span.start);
let parameters = extract_params(source, imports, &ctor.params);
let return_type = ctor
.return_type
.as_ref()
.map(|ta| type_annotation_str(source, imports, ta));
let type_parameters = ctor
.type_parameters
.as_ref()
.map(|tp| extract_type_parameters(source, tp))
.unwrap_or_default();
let mut sym = Symbol::new(
"new",
qualified_name(file, &[parent_name, "new"]),
SymbolKind::Constructor,
Visibility::Public,
file,
line,
);
sym.signature = Some(Signature {
parameters,
return_type,
type_parameters,
is_async: false,
});
Some(sym)
}
}
}
fn extract_type_alias(
source: &str,
_imports: &crate::canon::ImportMap,
alias: &TSTypeAliasDeclaration,
file: &Path,
line_offsets: &[u32],
visibility: Visibility,
) -> Symbol {
let name = alias.id.name.to_string();
let line = offset_to_line(line_offsets, alias.span.start);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[&name]),
SymbolKind::TypeAlias,
visibility,
file,
line,
);
let type_str = span_text(source, alias.type_annotation.span()).to_string();
sym.signature = Some(Signature {
parameters: Vec::new(),
return_type: Some(type_str),
type_parameters: alias
.type_parameters
.as_ref()
.map(|tp| extract_type_parameters(source, tp))
.unwrap_or_default(),
is_async: false,
});
collect_type_deps_from_ts_type(source, &alias.type_annotation, &mut sym.type_dependencies);
if let Some(tp) = &alias.type_parameters {
for param in &tp.params {
if let Some(constraint) = ¶m.constraint {
collect_type_deps_from_ts_type(source, constraint, &mut sym.type_dependencies);
}
if let Some(default) = ¶m.default {
collect_type_deps_from_ts_type(source, default, &mut sym.type_dependencies);
}
}
}
sym.type_dependencies.sort();
sym.type_dependencies.dedup();
sym
}
fn extract_enum(
source: &str,
_imports: &crate::canon::ImportMap,
enum_decl: &TSEnumDeclaration,
file: &Path,
line_offsets: &[u32],
visibility: Visibility,
) -> Symbol {
let name = enum_decl.id.name.to_string();
let line = offset_to_line(line_offsets, enum_decl.span.start);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[&name]),
SymbolKind::Enum,
visibility,
file,
line,
);
for member in &enum_decl.body.members {
let member_name = enum_member_name(&member.id);
let member_line = offset_to_line(line_offsets, member.span.start);
let mut member_sym = Symbol::new(
member_name.clone(),
qualified_name(file, &[&name, &member_name]),
SymbolKind::EnumMember,
Visibility::Public,
file,
member_line,
);
if let Some(init) = &member.initializer {
member_sym.signature = Some(Signature {
parameters: Vec::new(),
return_type: Some(span_text(source, init.span()).to_string()),
type_parameters: Vec::new(),
is_async: false,
});
}
sym.members.push(member_sym);
}
sym
}
fn enum_member_name(name: &TSEnumMemberName) -> String {
match name {
TSEnumMemberName::Identifier(id) => id.name.to_string(),
TSEnumMemberName::String(s) => s.value.to_string(),
TSEnumMemberName::ComputedString(s) => s.value.to_string(),
TSEnumMemberName::ComputedTemplateString(t) => {
t.quasis
.first()
.map(|q| q.value.raw.to_string())
.unwrap_or_else(|| "<template>".to_string())
}
}
}
fn extract_variables(
source: &str,
imports: &crate::canon::ImportMap,
var: &VariableDeclaration,
file: &Path,
line_offsets: &[u32],
visibility: Visibility,
symbols: &mut Vec<Symbol>,
) {
let kind = match var.kind {
VariableDeclarationKind::Const => SymbolKind::Constant,
_ => SymbolKind::Variable,
};
for declarator in &var.declarations {
match &declarator.id {
BindingPattern::BindingIdentifier(id) => {
let name = id.name.to_string();
let line = offset_to_line(line_offsets, declarator.span.start);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[&name]),
kind,
visibility,
file,
line,
);
sym.is_readonly = matches!(var.kind, VariableDeclarationKind::Const);
if let Some(ta) = &declarator.type_annotation {
sym.type_dependencies = collect_type_deps_from_annotation(source, ta);
sym.signature = Some(Signature {
parameters: Vec::new(),
return_type: Some(type_annotation_str(source, imports, ta)),
type_parameters: Vec::new(),
is_async: false,
});
}
symbols.push(sym);
}
_ => {
}
}
}
}
fn extract_namespace(
source: &str,
imports: &crate::canon::ImportMap,
ns: &TSModuleDeclaration,
file: &Path,
line_offsets: &[u32],
visibility: Visibility,
symbols: &mut Vec<Symbol>,
) {
let name = match &ns.id {
TSModuleDeclarationName::Identifier(id) => id.name.to_string(),
TSModuleDeclarationName::StringLiteral(s) => s.value.to_string(),
};
let line = offset_to_line(line_offsets, ns.span.start);
let mut sym = Symbol::new(
name.clone(),
qualified_name(file, &[&name]),
SymbolKind::Namespace,
visibility,
file,
line,
);
if let Some(body) = &ns.body {
match body {
TSModuleDeclarationBody::TSModuleBlock(block) => {
for stmt in &block.body {
extract_namespace_statement(
source,
imports,
stmt,
file,
&name,
line_offsets,
&mut sym.members,
);
}
}
TSModuleDeclarationBody::TSModuleDeclaration(inner_ns) => {
let mut inner_symbols = Vec::new();
extract_namespace(
source,
imports,
inner_ns,
file,
line_offsets,
visibility,
&mut inner_symbols,
);
for s in inner_symbols {
sym.members.push(s);
}
}
}
}
symbols.push(sym);
}
fn extract_namespace_statement(
source: &str,
imports: &crate::canon::ImportMap,
stmt: &Statement,
file: &Path,
ns_name: &str,
line_offsets: &[u32],
members: &mut Vec<Symbol>,
) {
match stmt {
Statement::ExportNamedDeclaration(export) => {
if let Some(decl) = &export.declaration {
let mut ns_symbols = Vec::new();
extract_declaration(
source,
imports,
decl,
file,
line_offsets,
Visibility::Exported,
&mut ns_symbols,
);
for mut s in ns_symbols {
s.qualified_name = qualified_name(file, &[ns_name, &s.name]);
members.push(s);
}
}
}
Statement::FunctionDeclaration(func) => {
if let Some(mut sym) = extract_function(
source,
imports,
func,
file,
line_offsets,
Visibility::Public,
) {
sym.qualified_name = qualified_name(file, &[ns_name, &sym.name]);
members.push(sym);
}
}
Statement::ClassDeclaration(cls) => {
if let Some(mut sym) =
extract_class(source, imports, cls, file, line_offsets, Visibility::Public)
{
sym.qualified_name = qualified_name(file, &[ns_name, &sym.name]);
members.push(sym);
}
}
Statement::TSInterfaceDeclaration(iface) => {
let mut sym = extract_interface(
source,
imports,
iface,
file,
line_offsets,
Visibility::Public,
);
sym.qualified_name = qualified_name(file, &[ns_name, &sym.name]);
members.push(sym);
}
Statement::TSTypeAliasDeclaration(alias) => {
let mut sym = extract_type_alias(
source,
imports,
alias,
file,
line_offsets,
Visibility::Public,
);
sym.qualified_name = qualified_name(file, &[ns_name, &sym.name]);
members.push(sym);
}
Statement::TSEnumDeclaration(enum_decl) => {
let mut sym = extract_enum(
source,
imports,
enum_decl,
file,
line_offsets,
Visibility::Public,
);
sym.qualified_name = qualified_name(file, &[ns_name, &sym.name]);
members.push(sym);
}
Statement::VariableDeclaration(var) => {
let mut var_symbols = Vec::new();
extract_variables(
source,
imports,
var,
file,
line_offsets,
Visibility::Public,
&mut var_symbols,
);
for mut s in var_symbols {
s.qualified_name = qualified_name(file, &[ns_name, &s.name]);
members.push(s);
}
}
Statement::TSModuleDeclaration(inner_ns) => {
let mut ns_symbols = Vec::new();
extract_namespace(
source,
imports,
inner_ns,
file,
line_offsets,
Visibility::Public,
&mut ns_symbols,
);
for s in ns_symbols {
members.push(s);
}
}
_ => {}
}
}
fn extract_params(
source: &str,
imports: &crate::canon::ImportMap,
params: &FormalParameters,
) -> Vec<Parameter> {
let mut result: Vec<Parameter> = params
.items
.iter()
.map(|p| extract_single_param(source, imports, p))
.collect();
if let Some(rest) = ¶ms.rest {
let name = binding_rest_name(&rest.rest);
let type_annotation = rest
.type_annotation
.as_ref()
.map(|ta| type_annotation_str(source, imports, ta));
result.push(Parameter {
name,
type_annotation,
optional: false,
has_default: false,
default_value: None,
is_variadic: true,
});
}
result
}
fn extract_single_param(
source: &str,
imports: &crate::canon::ImportMap,
param: &FormalParameter,
) -> Parameter {
let name = binding_pattern_name(¶m.pattern);
let type_annotation = param
.type_annotation
.as_ref()
.map(|ta| type_annotation_str(source, imports, ta));
let has_default = param.initializer.is_some();
let default_value = param
.initializer
.as_ref()
.map(|init| span_text(source, init.span()).to_string());
Parameter {
name,
type_annotation,
optional: param.optional || has_default,
has_default,
default_value,
is_variadic: false,
}
}
fn binding_pattern_name(pattern: &BindingPattern) -> String {
match pattern {
BindingPattern::BindingIdentifier(id) => id.name.to_string(),
BindingPattern::ObjectPattern(_) => "<destructured>".to_string(),
BindingPattern::ArrayPattern(_) => "<destructured>".to_string(),
BindingPattern::AssignmentPattern(assign) => binding_pattern_name(&assign.left),
}
}
fn binding_rest_name(rest: &BindingRestElement) -> String {
binding_pattern_name(&rest.argument)
}
fn extract_type_parameters(source: &str, tp: &TSTypeParameterDeclaration) -> Vec<TypeParameter> {
tp.params
.iter()
.map(|p| TypeParameter {
name: p.name.to_string(),
constraint: p
.constraint
.as_ref()
.map(|c| span_text(source, c.span()).to_string()),
default: p
.default
.as_ref()
.map(|d| span_text(source, d.span()).to_string()),
})
.collect()
}
fn collect_function_type_deps(source: &str, func: &Function) -> Vec<String> {
let mut deps = Vec::new();
for param in &func.params.items {
if let Some(ta) = ¶m.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, &mut deps);
}
}
if let Some(rest) = &func.params.rest {
if let Some(ta) = &rest.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, &mut deps);
}
}
if let Some(ta) = &func.return_type {
collect_type_deps_from_ts_type(source, &ta.type_annotation, &mut deps);
}
if let Some(tp) = &func.type_parameters {
for param in &tp.params {
if let Some(constraint) = ¶m.constraint {
collect_type_deps_from_ts_type(source, constraint, &mut deps);
}
if let Some(default) = ¶m.default {
collect_type_deps_from_ts_type(source, default, &mut deps);
}
}
}
deps.sort();
deps.dedup();
deps
}
fn collect_type_deps_from_annotation(source: &str, ta: &TSTypeAnnotation) -> Vec<String> {
let mut deps = Vec::new();
collect_type_deps_from_ts_type(source, &ta.type_annotation, &mut deps);
deps.sort();
deps.dedup();
deps
}
fn collect_type_deps_from_ts_type(source: &str, ts_type: &TSType, deps: &mut Vec<String>) {
match ts_type {
TSType::TSTypeReference(r) => {
let name = ts_type_name_str(&r.type_name);
deps.push(name);
if let Some(type_args) = &r.type_arguments {
for arg in &type_args.params {
collect_type_deps_from_ts_type(source, arg, deps);
}
}
}
TSType::TSUnionType(u) => {
for t in &u.types {
collect_type_deps_from_ts_type(source, t, deps);
}
}
TSType::TSIntersectionType(i) => {
for t in &i.types {
collect_type_deps_from_ts_type(source, t, deps);
}
}
TSType::TSArrayType(a) => {
collect_type_deps_from_ts_type(source, &a.element_type, deps);
}
TSType::TSTupleType(t) => {
for elem in &t.element_types {
collect_type_deps_from_tuple_element(source, elem, deps);
}
}
TSType::TSTypeLiteral(lit) => {
for member in &lit.members {
match member {
TSSignature::TSPropertySignature(prop) => {
if let Some(ta) = &prop.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
TSSignature::TSMethodSignature(method) => {
for param in &method.params.items {
if let Some(ta) = ¶m.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
if let Some(ta) = &method.return_type {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
TSSignature::TSIndexSignature(idx) => {
collect_type_deps_from_ts_type(
source,
&idx.type_annotation.type_annotation,
deps,
);
}
TSSignature::TSCallSignatureDeclaration(call) => {
for param in &call.params.items {
if let Some(ta) = ¶m.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
if let Some(ta) = &call.return_type {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
TSSignature::TSConstructSignatureDeclaration(ctor) => {
for param in &ctor.params.items {
if let Some(ta) = ¶m.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
if let Some(ta) = &ctor.return_type {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
}
}
}
TSType::TSFunctionType(f) => {
for param in &f.params.items {
if let Some(ta) = ¶m.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
collect_type_deps_from_ts_type(source, &f.return_type.type_annotation, deps);
}
TSType::TSConstructorType(c) => {
for param in &c.params.items {
if let Some(ta) = ¶m.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
collect_type_deps_from_ts_type(source, &c.return_type.type_annotation, deps);
}
TSType::TSConditionalType(c) => {
collect_type_deps_from_ts_type(source, &c.check_type, deps);
collect_type_deps_from_ts_type(source, &c.extends_type, deps);
collect_type_deps_from_ts_type(source, &c.true_type, deps);
collect_type_deps_from_ts_type(source, &c.false_type, deps);
}
TSType::TSMappedType(m) => {
collect_type_deps_from_ts_type(source, &m.constraint, deps);
if let Some(ta) = &m.type_annotation {
collect_type_deps_from_ts_type(source, ta, deps);
}
if let Some(name_type) = &m.name_type {
collect_type_deps_from_ts_type(source, name_type, deps);
}
}
TSType::TSIndexedAccessType(idx) => {
collect_type_deps_from_ts_type(source, &idx.object_type, deps);
collect_type_deps_from_ts_type(source, &idx.index_type, deps);
}
TSType::TSTypeOperatorType(op) => {
collect_type_deps_from_ts_type(source, &op.type_annotation, deps);
}
TSType::TSParenthesizedType(p) => {
collect_type_deps_from_ts_type(source, &p.type_annotation, deps);
}
TSType::TSInferType(infer) => {
if let Some(constraint) = &infer.type_parameter.constraint {
collect_type_deps_from_ts_type(source, constraint, deps);
}
}
TSType::TSTemplateLiteralType(tpl) => {
for t in &tpl.types {
collect_type_deps_from_ts_type(source, t, deps);
}
}
TSType::TSTypeQuery(q) => {
match &q.expr_name {
TSTypeQueryExprName::IdentifierReference(id) => {
deps.push(id.name.to_string());
}
TSTypeQueryExprName::QualifiedName(qn) => {
deps.push(span_text(source, qn.span()).to_string());
}
TSTypeQueryExprName::TSImportType(import) => {
if let Some(qualifier) = &import.qualifier {
deps.push(import_type_qualifier_str(source, qualifier));
}
}
TSTypeQueryExprName::ThisExpression(_) => {
}
}
}
TSType::TSImportType(import) => {
if let Some(qualifier) = &import.qualifier {
deps.push(import_type_qualifier_str(source, qualifier));
}
if let Some(type_args) = &import.type_arguments {
for arg in &type_args.params {
collect_type_deps_from_ts_type(source, arg, deps);
}
}
}
TSType::TSTypePredicate(pred) => {
if let Some(ta) = &pred.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
TSType::TSAnyKeyword(_)
| TSType::TSBigIntKeyword(_)
| TSType::TSBooleanKeyword(_)
| TSType::TSNeverKeyword(_)
| TSType::TSNullKeyword(_)
| TSType::TSNumberKeyword(_)
| TSType::TSObjectKeyword(_)
| TSType::TSStringKeyword(_)
| TSType::TSSymbolKeyword(_)
| TSType::TSUndefinedKeyword(_)
| TSType::TSUnknownKeyword(_)
| TSType::TSVoidKeyword(_)
| TSType::TSIntrinsicKeyword(_)
| TSType::TSThisType(_)
| TSType::TSLiteralType(_) => {}
TSType::TSNamedTupleMember(named) => {
collect_type_deps_from_tuple_element(source, &named.element_type, deps);
}
TSType::JSDocNullableType(jsdoc) => {
collect_type_deps_from_ts_type(source, &jsdoc.type_annotation, deps);
}
TSType::JSDocNonNullableType(jsdoc) => {
collect_type_deps_from_ts_type(source, &jsdoc.type_annotation, deps);
}
TSType::JSDocUnknownType(_) => {}
}
}
fn collect_type_deps_from_tuple_element(
source: &str,
elem: &TSTupleElement,
deps: &mut Vec<String>,
) {
match elem {
TSTupleElement::TSOptionalType(opt) => {
collect_type_deps_from_ts_type(source, &opt.type_annotation, deps);
}
TSTupleElement::TSRestType(rest) => {
collect_type_deps_from_ts_type(source, &rest.type_annotation, deps);
}
TSTupleElement::TSNamedTupleMember(named) => {
collect_type_deps_from_tuple_element(source, &named.element_type, deps);
}
TSTupleElement::TSTypeReference(r) => {
let name = ts_type_name_str(&r.type_name);
deps.push(name);
if let Some(type_args) = &r.type_arguments {
for arg in &type_args.params {
collect_type_deps_from_ts_type(source, arg, deps);
}
}
}
TSTupleElement::TSUnionType(u) => {
for t in &u.types {
collect_type_deps_from_ts_type(source, t, deps);
}
}
TSTupleElement::TSIntersectionType(i) => {
for t in &i.types {
collect_type_deps_from_ts_type(source, t, deps);
}
}
TSTupleElement::TSArrayType(a) => {
collect_type_deps_from_ts_type(source, &a.element_type, deps);
}
TSTupleElement::TSFunctionType(f) => {
for param in &f.params.items {
if let Some(ta) = ¶m.type_annotation {
collect_type_deps_from_ts_type(source, &ta.type_annotation, deps);
}
}
collect_type_deps_from_ts_type(source, &f.return_type.type_annotation, deps);
}
_ => {}
}
}
fn property_key_name(key: &PropertyKey) -> Option<String> {
match key {
PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
PropertyKey::PrivateIdentifier(id) => Some(format!("#{}", id.name)),
_ => None,
}
}
fn ts_type_name_str(name: &TSTypeName) -> String {
match name {
TSTypeName::IdentifierReference(id) => id.name.to_string(),
TSTypeName::QualifiedName(qn) => {
let left = ts_type_name_str(&qn.left);
format!("{}.{}", left, qn.right.name)
}
TSTypeName::ThisExpression(_) => "this".to_string(),
}
}
fn import_type_qualifier_str(source: &str, qualifier: &TSImportTypeQualifier) -> String {
span_text(source, qualifier.span()).to_string()
}
fn ts_accessibility_to_visibility(accessibility: Option<TSAccessibility>) -> Visibility {
match accessibility {
Some(TSAccessibility::Private) => Visibility::Private,
Some(TSAccessibility::Protected) => Visibility::Internal, Some(TSAccessibility::Public) | None => Visibility::Public,
}
}
fn module_export_name_str(name: &ModuleExportName) -> String {
match name {
ModuleExportName::IdentifierName(id) => id.name.to_string(),
ModuleExportName::IdentifierReference(id) => id.name.to_string(),
ModuleExportName::StringLiteral(s) => s.value.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn extract(source: &str) -> Vec<Symbol> {
let extractor = OxcExtractor::new();
extractor.extract_from_source(source, Path::new("test.d.ts"))
}
fn find_symbol<'a>(symbols: &'a [Symbol], name: &str) -> &'a Symbol {
symbols.iter().find(|s| s.name == name).unwrap_or_else(|| {
panic!(
"Symbol '{}' not found in {:?}",
name,
symbols.iter().map(|s| &s.name).collect::<Vec<_>>()
)
})
}
#[test]
fn extract_simple_function() {
let symbols = extract("export declare function greet(name: string): void;");
assert_eq!(symbols.len(), 1);
let sym = &symbols[0];
assert_eq!(sym.name, "greet");
assert_eq!(sym.kind, SymbolKind::Function);
assert_eq!(sym.visibility, Visibility::Exported);
let sig = sym.signature.as_ref().unwrap();
assert_eq!(sig.parameters.len(), 1);
assert_eq!(sig.parameters[0].name, "name");
assert_eq!(sig.parameters[0].type_annotation.as_deref(), Some("string"));
assert!(!sig.parameters[0].optional);
assert_eq!(sig.return_type.as_deref(), Some("void"));
assert!(!sig.is_async);
}
#[test]
fn extract_function_with_optional_params() {
let symbols = extract(
"export declare function create(name: string, age?: number, active?: boolean): void;",
);
let sig = symbols[0].signature.as_ref().unwrap();
assert_eq!(sig.parameters.len(), 3);
assert!(!sig.parameters[0].optional);
assert!(sig.parameters[1].optional);
assert!(sig.parameters[2].optional);
}
#[test]
fn extract_function_with_rest_params() {
let symbols =
extract("export declare function log(msg: string, ...args: unknown[]): void;");
let sig = symbols[0].signature.as_ref().unwrap();
assert_eq!(sig.parameters.len(), 2);
assert!(!sig.parameters[0].is_variadic);
assert!(sig.parameters[1].is_variadic);
assert_eq!(sig.parameters[1].name, "args");
assert_eq!(
sig.parameters[1].type_annotation.as_deref(),
Some("unknown[]")
);
}
#[test]
fn extract_async_function() {
let symbols = extract("export declare function fetchData(url: string): Promise<Response>;");
let sig = symbols[0].signature.as_ref().unwrap();
assert_eq!(sig.return_type.as_deref(), Some("Promise<Response>"));
}
#[test]
fn extract_generic_function() {
let symbols = extract(
"export declare function identity<T extends Serializable, U = unknown>(input: T, fallback: U): T | U;",
);
let sig = symbols[0].signature.as_ref().unwrap();
assert_eq!(sig.type_parameters.len(), 2);
assert_eq!(sig.type_parameters[0].name, "T");
assert_eq!(
sig.type_parameters[0].constraint.as_deref(),
Some("Serializable")
);
assert!(sig.type_parameters[0].default.is_none());
assert_eq!(sig.type_parameters[1].name, "U");
assert!(sig.type_parameters[1].constraint.is_none());
assert_eq!(sig.type_parameters[1].default.as_deref(), Some("unknown"));
assert!(symbols[0]
.type_dependencies
.contains(&"Serializable".to_string()));
}
#[test]
fn extract_function_type_dependencies() {
let symbols =
extract("export declare function createUser(opts: UserOptions): Promise<User>;");
let deps = &symbols[0].type_dependencies;
assert!(deps.contains(&"UserOptions".to_string()));
assert!(deps.contains(&"Promise".to_string()));
assert!(deps.contains(&"User".to_string()));
}
#[test]
fn extract_simple_class() {
let symbols = extract(
r#"
export declare class UserService extends BaseService implements Serializable {
readonly name: string;
constructor(name: string);
getUser(id: string): Promise<User>;
static create(): UserService;
}
"#,
);
let cls = find_symbol(&symbols, "UserService");
assert_eq!(cls.kind, SymbolKind::Class);
assert_eq!(cls.extends.as_deref(), Some("BaseService"));
assert_eq!(cls.implements, vec!["Serializable"]);
assert!(!cls.is_abstract);
assert!(cls.members.len() >= 4);
let name_prop = find_symbol(&cls.members, "name");
assert_eq!(name_prop.kind, SymbolKind::Property);
assert!(name_prop.is_readonly);
let ctor = find_symbol(&cls.members, "constructor");
assert_eq!(ctor.kind, SymbolKind::Constructor);
let get_user = find_symbol(&cls.members, "getUser");
assert_eq!(get_user.kind, SymbolKind::Method);
assert!(!get_user.is_static);
let create = find_symbol(&cls.members, "create");
assert!(create.is_static);
}
#[test]
fn extract_abstract_class() {
let symbols = extract(
r#"
export declare abstract class Validator {
abstract validate(): boolean;
protected helper(): void;
}
"#,
);
let cls = find_symbol(&symbols, "Validator");
assert!(cls.is_abstract);
let validate = find_symbol(&cls.members, "validate");
assert!(validate.is_abstract);
let helper = find_symbol(&cls.members, "helper");
assert_eq!(helper.visibility, Visibility::Internal);
}
#[test]
fn extract_class_skips_private_members() {
let symbols = extract(
r#"
export declare class MyClass {
private _internal: string;
private doStuff(): void;
public visible: number;
}
"#,
);
let cls = find_symbol(&symbols, "MyClass");
assert!(cls.members.iter().all(|m| m.name != "_internal"));
assert!(cls.members.iter().all(|m| m.name != "doStuff"));
assert!(cls.members.iter().any(|m| m.name == "visible"));
}
#[test]
fn extract_class_accessors() {
let symbols = extract(
r#"
export declare class Widget {
get count(): number;
set count(value: number);
}
"#,
);
let cls = find_symbol(&symbols, "Widget");
let getters: Vec<_> = cls.members.iter().filter(|m| m.name == "count").collect();
assert_eq!(getters.len(), 2);
assert!(getters.iter().any(|m| m.kind == SymbolKind::GetAccessor));
assert!(getters.iter().any(|m| m.kind == SymbolKind::SetAccessor));
}
#[test]
fn extract_interface() {
let symbols = extract(
r#"
export interface UserOptions {
name: string;
age?: number;
readonly id: string;
greet(msg: string): void;
}
"#,
);
let iface = find_symbol(&symbols, "UserOptions");
assert_eq!(iface.kind, SymbolKind::Interface);
let name = find_symbol(&iface.members, "name");
assert_eq!(name.kind, SymbolKind::Property);
assert!(!name.is_readonly);
let id = find_symbol(&iface.members, "id");
assert!(id.is_readonly);
let greet = find_symbol(&iface.members, "greet");
assert_eq!(greet.kind, SymbolKind::Method);
let sig = greet.signature.as_ref().unwrap();
assert_eq!(sig.parameters.len(), 1);
assert_eq!(sig.return_type.as_deref(), Some("void"));
}
#[test]
fn extract_interface_extends() {
let symbols = extract(
r#"
export interface Admin extends User, Permissions {
level: number;
}
"#,
);
let iface = find_symbol(&symbols, "Admin");
assert_eq!(iface.extends.as_deref(), Some("User"));
assert_eq!(iface.implements, vec!["Permissions"]);
}
#[test]
fn extract_interface_with_index_signature() {
let symbols = extract(
r#"
export interface Dictionary {
[key: string]: unknown;
}
"#,
);
let iface = find_symbol(&symbols, "Dictionary");
let idx = find_symbol(&iface.members, "[index]");
assert_eq!(idx.kind, SymbolKind::Property);
}
#[test]
fn extract_type_alias() {
let symbols = extract("export type UserId = string | number;");
let alias = find_symbol(&symbols, "UserId");
assert_eq!(alias.kind, SymbolKind::TypeAlias);
let sig = alias.signature.as_ref().unwrap();
assert_eq!(sig.return_type.as_deref(), Some("string | number"));
}
#[test]
fn extract_generic_type_alias() {
let symbols = extract(
"export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };",
);
let alias = find_symbol(&symbols, "Result");
let sig = alias.signature.as_ref().unwrap();
assert_eq!(sig.type_parameters.len(), 2);
assert_eq!(sig.type_parameters[0].name, "T");
assert_eq!(sig.type_parameters[1].name, "E");
assert_eq!(sig.type_parameters[1].default.as_deref(), Some("Error"));
assert!(alias.type_dependencies.contains(&"Error".to_string()));
}
#[test]
fn extract_enum() {
let symbols = extract(
r#"
export declare enum Color {
Red = 0,
Green = 1,
Blue = 2
}
"#,
);
let e = find_symbol(&symbols, "Color");
assert_eq!(e.kind, SymbolKind::Enum);
assert_eq!(e.members.len(), 3);
let red = find_symbol(&e.members, "Red");
assert_eq!(red.kind, SymbolKind::EnumMember);
assert_eq!(
red.signature.as_ref().unwrap().return_type.as_deref(),
Some("0")
);
}
#[test]
fn extract_string_enum() {
let symbols = extract(
r#"
export declare enum Direction {
Up = "UP",
Down = "DOWN"
}
"#,
);
let e = find_symbol(&symbols, "Direction");
let up = find_symbol(&e.members, "Up");
assert_eq!(
up.signature.as_ref().unwrap().return_type.as_deref(),
Some("\"UP\"")
);
}
#[test]
fn extract_const() {
let symbols = extract("export declare const API_VERSION: string;");
let sym = find_symbol(&symbols, "API_VERSION");
assert_eq!(sym.kind, SymbolKind::Constant);
assert!(sym.is_readonly);
assert_eq!(
sym.signature.as_ref().unwrap().return_type.as_deref(),
Some("string")
);
}
#[test]
fn extract_let_variable() {
let symbols = extract("export declare let counter: number;");
let sym = find_symbol(&symbols, "counter");
assert_eq!(sym.kind, SymbolKind::Variable);
assert!(!sym.is_readonly);
}
#[test]
fn extract_namespace() {
let symbols = extract(
r#"
export declare namespace Utils {
function helper(x: number): string;
const VERSION: string;
}
"#,
);
let ns = find_symbol(&symbols, "Utils");
assert_eq!(ns.kind, SymbolKind::Namespace);
let helper = find_symbol(&ns.members, "helper");
assert_eq!(helper.kind, SymbolKind::Function);
let version = find_symbol(&ns.members, "VERSION");
assert_eq!(version.kind, SymbolKind::Constant);
}
#[test]
fn extract_named_reexports() {
let symbols = extract("export { Foo, Bar as Baz } from './other';");
assert_eq!(symbols.len(), 2);
let foo = find_symbol(&symbols, "Foo");
assert!(foo
.type_dependencies
.iter()
.any(|d| d.contains("reexport:./other:Foo")));
let baz = find_symbol(&symbols, "Baz");
assert!(baz
.type_dependencies
.iter()
.any(|d| d.contains("reexport:./other:Bar")));
}
#[test]
fn extract_star_reexport() {
let symbols = extract("export * from './utils';");
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name, "*");
assert!(symbols[0]
.type_dependencies
.iter()
.any(|d| d.contains("reexport-all:./utils")));
}
#[test]
fn extract_star_as_reexport() {
let symbols = extract("export * as utils from './utils';");
let ns = find_symbol(&symbols, "utils");
assert_eq!(ns.kind, SymbolKind::Namespace);
assert!(ns
.type_dependencies
.iter()
.any(|d| d.contains("reexport-all-as:./utils")));
}
#[test]
fn extract_default_function() {
let symbols = extract("export default function main(): void;");
let sym = find_symbol(&symbols, "main");
assert_eq!(sym.kind, SymbolKind::Function);
assert_eq!(sym.visibility, Visibility::Exported);
}
#[test]
fn qualified_name_structure() {
let symbols = extract("export declare function greet(): void;");
assert_eq!(symbols[0].qualified_name, "test.greet");
}
#[test]
fn qualified_name_for_class_member() {
let symbols = extract(
r#"
export declare class Foo {
bar(): void;
}
"#,
);
let cls = find_symbol(&symbols, "Foo");
let bar = find_symbol(&cls.members, "bar");
assert_eq!(bar.qualified_name, "test.Foo.bar");
}
#[test]
fn correct_line_numbers() {
let symbols = extract(
r#"export declare function first(): void;
export declare function second(): void;
export declare function third(): void;
"#,
);
assert_eq!(find_symbol(&symbols, "first").line, 1);
assert_eq!(find_symbol(&symbols, "second").line, 2);
assert_eq!(find_symbol(&symbols, "third").line, 3);
}
#[test]
fn type_deps_from_complex_types() {
let symbols = extract(
"export declare function process(data: Map<string, Array<Item>>): Result<Output, AppError>;",
);
let deps = &symbols[0].type_dependencies;
assert!(deps.contains(&"Map".to_string()));
assert!(deps.contains(&"Array".to_string()));
assert!(deps.contains(&"Item".to_string()));
assert!(deps.contains(&"Result".to_string()));
assert!(deps.contains(&"Output".to_string()));
assert!(deps.contains(&"AppError".to_string()));
}
#[test]
fn type_deps_skip_builtins() {
let symbols =
extract("export declare function basic(a: string, b: number, c: boolean): void;");
assert!(symbols[0].type_dependencies.is_empty());
}
#[test]
fn find_dts_files_skips_node_modules() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join("node_modules/pkg")).unwrap();
std::fs::create_dir_all(root.join(".hidden")).unwrap();
std::fs::write(root.join("src/api.d.ts"), "").unwrap();
std::fs::write(root.join("src/utils.d.ts"), "").unwrap();
std::fs::write(root.join("node_modules/pkg/index.d.ts"), "").unwrap();
std::fs::write(root.join(".hidden/secret.d.ts"), "").unwrap();
let files = find_dts_files(root).unwrap();
assert_eq!(files.len(), 2);
assert!(files.iter().any(|f| f.ends_with("api.d.ts")));
assert!(files.iter().any(|f| f.ends_with("utils.d.ts")));
}
#[test]
fn dedup_esm_and_js_keeps_esm() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("packages/react-core/dist/esm")).unwrap();
std::fs::create_dir_all(root.join("packages/react-core/dist/js")).unwrap();
std::fs::write(root.join("packages/react-core/dist/esm/Alert.d.ts"), "").unwrap();
std::fs::write(root.join("packages/react-core/dist/js/Alert.d.ts"), "").unwrap();
let files = find_dts_files(root).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].to_string_lossy().contains("dist/esm/"));
}
#[test]
fn dedup_esm_and_cjs_keeps_esm() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("dist/esm/components")).unwrap();
std::fs::create_dir_all(root.join("dist/cjs/components")).unwrap();
std::fs::write(root.join("dist/esm/components/Foo.d.ts"), "").unwrap();
std::fs::write(root.join("dist/cjs/components/Foo.d.ts"), "").unwrap();
std::fs::write(root.join("dist/esm/index.d.ts"), "").unwrap();
std::fs::write(root.join("dist/cjs/index.d.ts"), "").unwrap();
let files = find_dts_files(root).unwrap();
assert_eq!(files.len(), 2);
assert!(files
.iter()
.all(|f| f.to_string_lossy().contains("dist/esm/")));
}
#[test]
fn dedup_js_and_cjs_keeps_js() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("dist/js")).unwrap();
std::fs::create_dir_all(root.join("dist/cjs")).unwrap();
std::fs::write(root.join("dist/js/index.d.ts"), "").unwrap();
std::fs::write(root.join("dist/cjs/index.d.ts"), "").unwrap();
let files = find_dts_files(root).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].to_string_lossy().contains("dist/js/"));
}
#[test]
fn dedup_single_variant_not_filtered() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("dist/js")).unwrap();
std::fs::write(root.join("dist/js/index.d.ts"), "").unwrap();
let files = find_dts_files(root).unwrap();
assert_eq!(files.len(), 1);
}
#[test]
fn dedup_non_dist_files_preserved() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("dist/esm")).unwrap();
std::fs::create_dir_all(root.join("dist/js")).unwrap();
std::fs::create_dir_all(root.join("css/components")).unwrap();
std::fs::write(root.join("dist/esm/index.d.ts"), "").unwrap();
std::fs::write(root.join("dist/js/index.d.ts"), "").unwrap();
std::fs::write(root.join("css/components/button.d.ts"), "").unwrap();
let files = find_dts_files(root).unwrap();
assert_eq!(files.len(), 2); assert!(files
.iter()
.any(|f| f.to_string_lossy().contains("dist/esm/")));
assert!(files.iter().any(|f| f.to_string_lossy().contains("css/")));
}
#[test]
fn dedup_multiple_packages_independent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for pkg in &["pkg-a", "pkg-b"] {
std::fs::create_dir_all(root.join(format!("packages/{}/dist/esm", pkg))).unwrap();
std::fs::create_dir_all(root.join(format!("packages/{}/dist/js", pkg))).unwrap();
std::fs::write(
root.join(format!("packages/{}/dist/esm/index.d.ts", pkg)),
"",
)
.unwrap();
std::fs::write(
root.join(format!("packages/{}/dist/js/index.d.ts", pkg)),
"",
)
.unwrap();
}
let files = find_dts_files(root).unwrap();
assert_eq!(files.len(), 2); assert!(files
.iter()
.all(|f| f.to_string_lossy().contains("dist/esm/")));
}
#[test]
fn dedup_unknown_variant_dirs_ignored() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("dist/types")).unwrap();
std::fs::create_dir_all(root.join("dist/esm")).unwrap();
std::fs::write(root.join("dist/types/index.d.ts"), "").unwrap();
std::fs::write(root.join("dist/esm/index.d.ts"), "").unwrap();
let files = find_dts_files(root).unwrap();
assert_eq!(files.len(), 2);
}
#[test]
fn find_redundant_dist_dirs_empty() {
let files: Vec<PathBuf> = vec![];
assert!(find_redundant_dist_dirs(&files).is_empty());
}
#[test]
fn extract_empty_file() {
let symbols = extract("");
assert!(symbols.is_empty());
}
#[test]
fn extract_non_exported_declarations_are_skipped() {
let symbols = extract(
r#"
declare function internal(): void;
declare class InternalClass {}
"#,
);
assert!(symbols.is_empty());
}
#[test]
fn extract_multiple_declarations() {
let symbols = extract(
r#"
export declare function foo(): void;
export interface Bar { x: number; }
export type Baz = string;
export declare const QUX: boolean;
export declare enum Status { Active = 0, Inactive = 1 }
"#,
);
assert_eq!(symbols.len(), 5);
assert!(symbols
.iter()
.any(|s| s.name == "foo" && s.kind == SymbolKind::Function));
assert!(symbols
.iter()
.any(|s| s.name == "Bar" && s.kind == SymbolKind::Interface));
assert!(symbols
.iter()
.any(|s| s.name == "Baz" && s.kind == SymbolKind::TypeAlias));
assert!(symbols
.iter()
.any(|s| s.name == "QUX" && s.kind == SymbolKind::Constant));
assert!(symbols
.iter()
.any(|s| s.name == "Status" && s.kind == SymbolKind::Enum));
}
#[test]
fn extract_class_with_type_params() {
let symbols = extract(
r#"
export declare class Container<T extends Serializable> {
value: T;
get(): T;
}
"#,
);
let cls = find_symbol(&symbols, "Container");
assert!(cls.type_dependencies.contains(&"Serializable".to_string()));
}
#[test]
fn extract_interface_call_and_construct_signatures() {
let symbols = extract(
r#"
export interface Factory {
(): Widget;
new (config: Config): Widget;
}
"#,
);
let iface = find_symbol(&symbols, "Factory");
assert!(iface.members.iter().any(|m| m.name == "(call)"));
assert!(iface.members.iter().any(|m| m.name == "new"));
}
#[test]
fn line_offset_computation() {
let offsets = compute_line_offsets("abc\ndef\nghi");
assert_eq!(offsets, vec![0, 4, 8]);
assert_eq!(offset_to_line(&offsets, 0), 1); assert_eq!(offset_to_line(&offsets, 3), 1); assert_eq!(offset_to_line(&offsets, 4), 2); assert_eq!(offset_to_line(&offsets, 8), 3); }
#[test]
fn find_export_as_namespace_found() {
let source = r#"
export = React;
export as namespace React;
declare namespace React {
type ReactNode = string;
}
"#;
assert_eq!(find_export_as_namespace(source), Some("React".to_string()));
}
#[test]
fn find_export_as_namespace_not_found() {
let source = r#"
export declare function foo(): void;
export interface Bar {}
"#;
assert_eq!(find_export_as_namespace(source), None);
}
#[test]
fn find_export_as_namespace_with_semicolon() {
let source = "export as namespace MyLib;";
assert_eq!(find_export_as_namespace(source), Some("MyLib".to_string()));
}
#[test]
fn find_declare_namespaces_finds_global() {
let source = r#"
declare namespace NodeJS {
interface Process {}
}
declare var process: NodeJS.Process;
"#;
let ns = find_declare_namespaces(source);
assert_eq!(ns, vec!["NodeJS".to_string()]);
}
#[test]
fn find_declare_namespaces_multiple() {
let source = r#"
declare namespace NodeJS { }
declare namespace Buffer { }
"#;
let ns = find_declare_namespaces(source);
assert_eq!(ns.len(), 2);
assert!(ns.contains(&"NodeJS".to_string()));
assert!(ns.contains(&"Buffer".to_string()));
}
#[test]
fn has_module_syntax_detects_import() {
assert!(has_module_syntax("import React from 'react';"));
assert!(has_module_syntax("export declare function foo(): void;"));
}
#[test]
fn has_module_syntax_false_for_ambient() {
let source = r#"
// A comment
declare namespace NodeJS { }
declare var process: NodeJS.Process;
"#;
assert!(!has_module_syntax(source));
}
#[test]
fn scan_types_packages_from_temp_dir() {
let dir = tempfile::tempdir().unwrap();
let types_dir = dir.path().join("node_modules/@types/react");
std::fs::create_dir_all(&types_dir).unwrap();
std::fs::write(
types_dir.join("package.json"),
r#"{"types": "index.d.ts", "main": ""}"#,
)
.unwrap();
std::fs::write(
types_dir.join("index.d.ts"),
r#"
export = React;
export as namespace React;
declare namespace React {
type ReactNode = string | number;
}
"#,
)
.unwrap();
let map = scan_types_packages(dir.path());
assert!(map.is_namespace_or_default("React"));
}
#[test]
fn scan_types_packages_ambient_namespace() {
let dir = tempfile::tempdir().unwrap();
let types_dir = dir.path().join("node_modules/@types/node");
std::fs::create_dir_all(&types_dir).unwrap();
std::fs::write(
types_dir.join("package.json"),
r#"{"types": "globals.d.ts"}"#,
)
.unwrap();
std::fs::write(
types_dir.join("globals.d.ts"),
r#"
declare namespace NodeJS {
interface Process { }
}
declare var process: NodeJS.Process;
"#,
)
.unwrap();
let map = scan_types_packages(dir.path());
assert!(map.is_namespace_or_default("NodeJS"));
}
#[test]
fn scan_types_packages_walks_up_for_node_modules() {
let dir = tempfile::tempdir().unwrap();
let types_dir = dir.path().join("node_modules/@types/react");
std::fs::create_dir_all(&types_dir).unwrap();
let subdir = dir.path().join("packages/react-core");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(types_dir.join("package.json"), r#"{"types": "index.d.ts"}"#).unwrap();
std::fs::write(
types_dir.join("index.d.ts"),
"export as namespace React;\ndeclare namespace React { }",
)
.unwrap();
let map = scan_types_packages(&subdir);
assert!(
map.is_namespace_or_default("React"),
"Should find @types/react by walking up from subdirectory"
);
}
#[test]
fn scan_types_packages_ignores_module_files() {
let dir = tempfile::tempdir().unwrap();
let types_dir = dir.path().join("node_modules/@types/somepkg");
std::fs::create_dir_all(&types_dir).unwrap();
std::fs::write(types_dir.join("package.json"), r#"{"types": "index.d.ts"}"#).unwrap();
std::fs::write(
types_dir.join("index.d.ts"),
r#"
import { Dependency } from 'other';
declare namespace Internal { }
export declare function foo(): void;
"#,
)
.unwrap();
let map = scan_types_packages(dir.path());
assert!(!map.is_namespace_or_default("Internal"));
}
#[test]
fn collect_imports_from_source_finds_namespace() {
let source = r#"
import * as React from 'react';
export declare const Foo: React.FunctionComponent<{}>;
"#;
let map = collect_imports_from_source(source);
assert!(map.is_namespace_or_default("React"));
}
#[test]
fn global_import_map_resolves_unimported_namespace() {
let source = r#"
import type { JSX } from 'react';
export interface CardBodyProps {
children?: React.ReactNode;
component?: keyof JSX.IntrinsicElements;
}
export declare const CardBody: React.FunctionComponent<CardBodyProps>;
"#;
let extractor = OxcExtractor::new();
let symbols_no_globals = extractor.extract_from_source(source, Path::new("CardBody.d.ts"));
let body_no_globals = find_symbol(&symbols_no_globals, "CardBody");
let sig_no = body_no_globals.signature.as_ref().unwrap();
assert!(
sig_no.return_type.as_ref().unwrap().contains("React."),
"Without globals, React. prefix should remain: {:?}",
sig_no.return_type
);
let mut global = crate::canon::ImportMap::new();
global.add_namespace("React", "react");
let symbols_with_globals =
extractor.extract_from_source_with_globals(source, Path::new("CardBody.d.ts"), &global);
let body_with_globals = find_symbol(&symbols_with_globals, "CardBody");
let sig_yes = body_with_globals.signature.as_ref().unwrap();
assert!(
!sig_yes.return_type.as_ref().unwrap().contains("React."),
"With globals, React. prefix should be stripped: {:?}",
sig_yes.return_type
);
}
#[test]
fn two_pass_merges_namespace_imports_across_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("FileA.d.ts"),
r#"
import * as React from 'react';
export declare const Foo: React.FunctionComponent<{}>;
"#,
)
.unwrap();
std::fs::write(
dir.path().join("FileB.d.ts"),
r#"
export interface Props {
children?: React.ReactNode;
}
"#,
)
.unwrap();
let extractor = OxcExtractor::new();
let surface = extractor.extract_from_dir(dir.path()).unwrap();
let props = surface
.symbols
.iter()
.find(|s| s.name == "Props")
.expect("Props interface should be extracted");
let children = props
.members
.iter()
.find(|m| m.name == "children")
.expect("children member should exist");
let sig = children.signature.as_ref().unwrap();
assert_eq!(
sig.return_type.as_deref(),
Some("ReactNode"),
"React. prefix should be stripped via two-pass import map merge"
);
}
#[test]
fn two_pass_with_types_packages() {
let dir = tempfile::tempdir().unwrap();
let types_dir = dir.path().join("node_modules/@types/react");
std::fs::create_dir_all(&types_dir).unwrap();
std::fs::write(types_dir.join("package.json"), r#"{"types": "index.d.ts"}"#).unwrap();
std::fs::write(
types_dir.join("index.d.ts"),
"export = React;\nexport as namespace React;\ndeclare namespace React { type ReactNode = string; }",
)
.unwrap();
std::fs::write(
dir.path().join("Component.d.ts"),
r#"
export interface MyProps {
label: React.ReactNode;
}
"#,
)
.unwrap();
let extractor = OxcExtractor::new();
let surface = extractor.extract_from_dir(dir.path()).unwrap();
let props = surface
.symbols
.iter()
.find(|s| s.name == "MyProps")
.expect("MyProps should be extracted");
let label = props
.members
.iter()
.find(|m| m.name == "label")
.expect("label member should exist");
let sig = label.signature.as_ref().unwrap();
assert_eq!(
sig.return_type.as_deref(),
Some("ReactNode"),
"React.ReactNode should be canonicalized to ReactNode via @types scan"
);
}
#[test]
fn local_import_takes_priority_over_global() {
let source = r#"
import MyReact from 'custom-react';
export declare const Foo: MyReact.Component;
"#;
let mut global = crate::canon::ImportMap::new();
global.add_namespace("MyReact", "different-module");
let extractor = OxcExtractor::new();
let symbols =
extractor.extract_from_source_with_globals(source, Path::new("test.d.ts"), &global);
let foo = find_symbol(&symbols, "Foo");
let sig = foo.signature.as_ref().unwrap();
assert_eq!(
sig.return_type.as_deref(),
Some("Component"),
"Local import should take priority; MyReact. should be stripped"
);
}
#[test]
fn import_map_merge_namespaces_only() {
let mut base = crate::canon::ImportMap::new();
base.add_namespace("React", "react");
let mut other = crate::canon::ImportMap::new();
other.add_namespace("Lodash", "lodash");
other.add_named("useState", "useState", "react");
base.merge_namespaces_from(&other);
assert!(base.is_namespace_or_default("Lodash"));
assert!(base.named_import_module("useState").is_none());
}
#[test]
fn import_map_merge_does_not_overwrite() {
let mut base = crate::canon::ImportMap::new();
base.add_namespace("React", "custom-react");
let mut other = crate::canon::ImportMap::new();
other.add_namespace("React", "react");
base.merge_all_from(&other);
assert_eq!(base.module_for("React"), Some("custom-react"));
}
#[test]
fn remap_dist_esm_to_src() {
assert_eq!(
remap_dist_to_src(Path::new(
"packages/react-core/dist/esm/components/Button/Button.d.ts"
)),
PathBuf::from("packages/react-core/src/components/Button/Button.d.ts")
);
}
#[test]
fn remap_dist_js_to_src() {
assert_eq!(
remap_dist_to_src(Path::new(
"packages/react-core/dist/js/components/Card/Card.d.ts"
)),
PathBuf::from("packages/react-core/src/components/Card/Card.d.ts")
);
}
#[test]
fn remap_dist_cjs_to_src() {
assert_eq!(
remap_dist_to_src(Path::new("packages/react-core/dist/cjs/index.d.ts")),
PathBuf::from("packages/react-core/src/index.d.ts")
);
}
#[test]
fn remap_bare_dist_to_src() {
assert_eq!(
remap_dist_to_src(Path::new("packages/react-core/dist/components/Button.d.ts")),
PathBuf::from("packages/react-core/src/components/Button.d.ts")
);
}
#[test]
fn remap_no_dist_unchanged() {
let path = Path::new("packages/react-core/src/components/Button/Button.d.ts");
assert_eq!(remap_dist_to_src(path), path.to_path_buf());
}
#[test]
fn remap_preserves_deprecated_subpath() {
assert_eq!(
remap_dist_to_src(Path::new(
"packages/react-core/dist/esm/deprecated/components/Chip/Chip.d.ts"
)),
PathBuf::from("packages/react-core/src/deprecated/components/Chip/Chip.d.ts")
);
}
#[test]
fn populate_rendered_components_from_tsx_files() {
use std::fs;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let base = tmp.path();
let comp_dir = base.join("src/components/Dropdown");
fs::create_dir_all(&comp_dir).unwrap();
fs::write(
comp_dir.join("Dropdown.tsx"),
r#"
import React from 'react';
export const Dropdown: React.FC = ({ children }) => {
return (
<div className="dropdown">
<DropdownToggle />
<DropdownMenu>
{children}
</DropdownMenu>
</div>
);
};
"#,
)
.unwrap();
let sym = Symbol::new(
"Dropdown",
"src/components/Dropdown/Dropdown.Dropdown",
SymbolKind::Variable,
Visibility::Exported,
PathBuf::from("src/components/Dropdown/Dropdown.d.ts"),
1,
);
let type_sym = Symbol::new(
"DropdownProps",
"src/components/Dropdown/Dropdown.DropdownProps",
SymbolKind::Interface,
Visibility::Exported,
PathBuf::from("src/components/Dropdown/Dropdown.d.ts"),
5,
);
let const_sym = Symbol::new(
"defaultDropdownWidth",
"src/components/Dropdown/Dropdown.defaultDropdownWidth",
SymbolKind::Variable,
Visibility::Exported,
PathBuf::from("src/components/Dropdown/Dropdown.d.ts"),
10,
);
let mut symbols = vec![sym.clone(), type_sym, const_sym];
populate_rendered_components(&mut symbols, base);
assert!(
!symbols[0].language_data.rendered_components.is_empty(),
"Dropdown should have rendered_components"
);
assert!(
symbols[0]
.language_data
.rendered_components
.contains(&"DropdownToggle".to_string()),
"should contain DropdownToggle"
);
assert!(
symbols[0]
.language_data
.rendered_components
.contains(&"DropdownMenu".to_string()),
"should contain DropdownMenu"
);
assert!(symbols[1].language_data.rendered_components.is_empty());
assert!(symbols[2].language_data.rendered_components.is_empty());
}
#[test]
fn populate_rendered_components_missing_tsx_file() {
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let sym = Symbol::new(
"Missing",
"src/components/Missing/Missing.Missing",
SymbolKind::Variable,
Visibility::Exported,
PathBuf::from("src/components/Missing/Missing.d.ts"),
1,
);
let mut symbols = vec![sym];
populate_rendered_components(&mut symbols, tmp.path());
assert!(symbols[0].language_data.rendered_components.is_empty());
}
#[test]
fn populate_rendered_components_caches_per_file() {
use std::fs;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let comp_dir = tmp.path().join("src/components/Modal");
fs::create_dir_all(&comp_dir).unwrap();
fs::write(
comp_dir.join("Modal.tsx"),
r#"
export const Modal = () => {
return <ModalBody />;
};
"#,
)
.unwrap();
let sym1 = Symbol::new(
"Modal",
"src/components/Modal/Modal.Modal",
SymbolKind::Function,
Visibility::Exported,
PathBuf::from("src/components/Modal/Modal.d.ts"),
1,
);
let sym2 = Symbol::new(
"ModalVariant",
"src/components/Modal/Modal.ModalVariant",
SymbolKind::Variable,
Visibility::Exported,
PathBuf::from("src/components/Modal/Modal.d.ts"),
5,
);
let mut symbols = vec![sym1, sym2];
populate_rendered_components(&mut symbols, tmp.path());
assert_eq!(
symbols[0].language_data.rendered_components,
symbols[1].language_data.rendered_components
);
assert!(symbols[0]
.language_data
.rendered_components
.contains(&"ModalBody".to_string()));
}
}