use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::{Context, Result, bail};
use oxc_allocator::Allocator;
use oxc_parser::{Parser, config::TokensParserConfig};
use oxc_semantic::SemanticBuilder;
use oxc_span::{GetSpan, SourceType};
use oxc_syntax::module_record::{ExportExportName, ExportImportName, ImportImportName};
pub const MODULE_INIT: &str = "<module>";
#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd)]
pub struct SymbolInfo {
pub fingerprint: String,
pub dependencies: BTreeSet<String>,
pub runtime: bool,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ImportedName {
Named(String),
Namespace,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ImportBinding {
pub source: String,
pub imported: ImportedName,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReExport {
pub exported: String,
pub source: String,
pub imported: ImportedName,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ParsedModule {
pub symbols: BTreeMap<String, SymbolInfo>,
pub imports: BTreeMap<String, ImportBinding>,
pub local_exports: BTreeMap<String, String>,
pub re_exports: Vec<ReExport>,
pub star_exports: Vec<String>,
pub module_requests: BTreeSet<String>,
}
pub fn is_source_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|extension| extension.to_str()),
Some("js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts")
) && !path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.ends_with(".test.ts")
|| name.ends_with(".test.tsx")
|| name.ends_with(".spec.ts")
|| name.ends_with(".spec.tsx")
})
}
pub fn parse_module(path: &Path, source: &str) -> Result<ParsedModule> {
let source_type = SourceType::from_path(path)
.with_context(|| format!("unsupported source file {}", path.display()))?;
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, source, source_type)
.with_config(TokensParserConfig)
.parse();
if !parsed.errors.is_empty() {
bail!("failed to parse {}: {}", path.display(), parsed.errors[0]);
}
let module_record = parsed.module_record;
let semantic = SemanticBuilder::new().build(&parsed.program);
if !semantic.errors.is_empty() {
bail!(
"failed semantic analysis for {}: {}",
path.display(),
semantic.errors[0]
);
}
let semantic = semantic.semantic;
let scoping = semantic.scoping();
let root_scope = scoping.root_scope_id();
let mut top_level = Vec::new();
for symbol_id in scoping.symbol_ids() {
if scoping.symbol_scope_id(symbol_id) != root_scope {
continue;
}
let name = scoping.symbol_name(symbol_id).to_string();
let span = semantic.symbol_declaration(symbol_id).kind().span();
let runtime = scoping.symbol_flags(symbol_id).is_value()
&& !scoping.symbol_flags(symbol_id).is_type_import();
top_level.push((symbol_id, name, span, runtime));
}
let runtime_referenced_symbols: BTreeSet<_> = top_level
.iter()
.filter(|(symbol_id, _, _, _)| {
semantic
.symbol_references(*symbol_id)
.any(|reference| !reference.flags().is_type_only())
})
.map(|(_, name, _, _)| name.clone())
.collect();
let mut symbols = BTreeMap::new();
for (_, name, span, runtime) in &top_level {
let fingerprint = source
.get(span.start as usize..span.end as usize)
.unwrap_or_default()
.to_string();
symbols.insert(
name.clone(),
SymbolInfo {
fingerprint,
dependencies: BTreeSet::new(),
runtime: *runtime,
},
);
}
for (dependency_id, dependency_name, _, dependency_runtime) in &top_level {
if !dependency_runtime {
continue;
}
for reference in semantic.symbol_references(*dependency_id) {
if reference.flags().is_type_only() {
continue;
}
let reference_span = semantic.reference_span(reference);
for (_, consumer_name, consumer_span, consumer_runtime) in &top_level {
if !consumer_runtime || consumer_name == dependency_name {
continue;
}
if consumer_span.start <= reference_span.start
&& consumer_span.end >= reference_span.end
&& let Some(consumer) = symbols.get_mut(consumer_name)
{
consumer.dependencies.insert(dependency_name.clone());
}
}
}
}
let mut imports = BTreeMap::new();
for entry in &module_record.import_entries {
if entry.is_type {
continue;
}
let imported = match &entry.import_name {
ImportImportName::Name(name) => ImportedName::Named(name.name.to_string()),
ImportImportName::Default(_) => ImportedName::Named("default".to_string()),
ImportImportName::NamespaceObject => ImportedName::Namespace,
};
imports.insert(
entry.local_name.name.to_string(),
ImportBinding {
source: entry.module_request.name.to_string(),
imported,
},
);
}
let mut local_exports = BTreeMap::new();
for entry in &module_record.local_export_entries {
if entry.is_type {
continue;
}
let Some(local_name) = entry.local_name.name().map(|name| name.to_string()) else {
continue;
};
let Some(exported_name) = export_name(&entry.export_name) else {
continue;
};
local_exports.insert(exported_name, local_name);
}
let mut re_exports = Vec::new();
for entry in &module_record.indirect_export_entries {
if entry.is_type {
continue;
}
let Some(source) = entry
.module_request
.as_ref()
.map(|request| request.name.to_string())
else {
continue;
};
let Some(exported) = export_name(&entry.export_name) else {
continue;
};
let imported = match &entry.import_name {
ExportImportName::Name(name) => ImportedName::Named(name.name.to_string()),
ExportImportName::All => ImportedName::Namespace,
ExportImportName::AllButDefault | ExportImportName::Null => continue,
};
re_exports.push(ReExport {
exported,
source,
imported,
});
}
let star_exports = module_record
.star_export_entries
.iter()
.filter(|entry| !entry.is_type)
.filter_map(|entry| {
entry
.module_request
.as_ref()
.map(|request| request.name.to_string())
})
.collect();
let mut module_requests = BTreeSet::new();
for entry in &module_record.import_entries {
if !entry.is_type && runtime_referenced_symbols.contains(entry.local_name.name.as_str()) {
module_requests.insert(entry.module_request.name.to_string());
}
}
for entry in module_record
.indirect_export_entries
.iter()
.chain(module_record.star_export_entries.iter())
{
if !entry.is_type
&& let Some(request) = &entry.module_request
{
module_requests.insert(request.name.to_string());
}
}
for (name, requests) in &module_record.requested_modules {
let has_side_effect_import = requests.iter().any(|request| {
request.is_import
&& !request.is_type
&& !module_record
.import_entries
.iter()
.any(|entry| entry.statement_span == request.statement_span)
});
if has_side_effect_import {
module_requests.insert(name.to_string());
}
}
for import in &module_record.dynamic_imports {
if let Some(request) =
source.get(import.module_request.start as usize..import.module_request.end as usize)
{
module_requests.insert(request.trim_matches(['\'', '"']).to_string());
}
}
let declaration_spans: Vec<_> = top_level
.iter()
.map(|(_, _, span, _)| (span.start as usize, span.end as usize))
.collect();
let declaration_statement_spans: Vec<_> = parsed
.program
.body
.iter()
.map(GetSpan::span)
.filter(|statement_span| {
declaration_spans
.iter()
.any(|(declaration_start, declaration_end)| {
statement_span.start as usize <= *declaration_start
&& statement_span.end as usize >= *declaration_end
})
})
.collect();
let mut module_fingerprint = String::new();
for token in &parsed.tokens {
let span = token.span();
let belongs_to_declaration = declaration_statement_spans.iter().any(|statement_span| {
statement_span.start <= span.start && statement_span.end >= span.end
});
if !belongs_to_declaration {
module_fingerprint.push_str(
source
.get(span.start as usize..span.end as usize)
.unwrap_or_default(),
);
}
}
for re_export in &re_exports {
module_fingerprint.push_str(&format!(
"reexport:{}:{}:{:?};",
re_export.exported, re_export.source, re_export.imported
));
}
for source in &star_exports {
module_fingerprint.push_str(&format!("star:{source};"));
}
for request in &module_requests {
module_fingerprint.push_str(&format!("request:{request};"));
}
symbols.insert(
MODULE_INIT.to_string(),
SymbolInfo {
fingerprint: module_fingerprint.clone(),
dependencies: BTreeSet::new(),
runtime: true,
},
);
Ok(ParsedModule {
symbols,
imports,
local_exports,
re_exports,
star_exports,
module_requests,
})
}
fn export_name(name: &ExportExportName<'_>) -> Option<String> {
match name {
ExportExportName::Name(name) => Some(name.name.to_string()),
ExportExportName::Default(_) => Some("default".to_string()),
ExportExportName::Null => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_symbol_dependencies_and_runtime_imports() {
let module = parse_module(
Path::new("fixture.ts"),
r#"
import { value, type Shape } from './dependency';
const unused = 1;
export const result = value + 1;
export type Result = Shape;
"#,
)
.unwrap();
assert_eq!(
module.imports["value"].imported,
ImportedName::Named("value".to_string())
);
assert!(!module.imports.contains_key("Shape"));
assert!(module.symbols["result"].dependencies.contains("value"));
assert!(!module.symbols["Result"].runtime);
assert_eq!(module.local_exports["result"], "result");
assert!(!module.local_exports.contains_key("Result"));
}
#[test]
fn records_reexports() {
let module = parse_module(
Path::new("fixture.ts"),
"export { value as renamed } from './dependency'; export * from './other';",
)
.unwrap();
assert_eq!(module.re_exports[0].exported, "renamed");
assert_eq!(module.star_exports, vec!["./other"]);
}
#[test]
fn comments_do_not_change_fingerprints() {
let before = parse_module(
Path::new("fixture.ts"),
"// old wording\nconst schema = { name: 'view' };",
)
.unwrap();
let after = parse_module(
Path::new("fixture.ts"),
"// new wording\nconst schema = { name: 'view' };",
)
.unwrap();
assert_eq!(before.symbols, after.symbols);
}
#[test]
fn type_position_does_not_create_runtime_module_request() {
let module = parse_module(
Path::new("fixture.ts"),
"import { Shape } from './types'; export type Result = Shape;",
)
.unwrap();
assert!(module.module_requests.is_empty());
}
}