use std::path::Path;
use oxc_allocator::Allocator;
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::{GetSpan, SourceType};
use oxc_syntax::module_record::{ExportExportName, ImportImportName};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedRef {
pub def_start: u32,
pub def_end: u32,
pub use_start: u32,
pub use_end: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JsImport {
pub local: String,
pub specifier: String,
pub imported: Option<String>,
pub is_type: bool,
pub local_start: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JsExport {
pub name: String,
pub name_start: u32,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct JsAnalysis {
pub resolved: Vec<ResolvedRef>,
pub imports: Vec<JsImport>,
pub exports: Vec<JsExport>,
pub had_errors: bool,
}
pub fn source_type_for_path(path: &Path) -> Option<SourceType> {
SourceType::from_path(path).ok()
}
pub fn analyze(source: &str, source_type: SourceType) -> JsAnalysis {
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, source, source_type).parse();
let semantic_ret = SemanticBuilder::new().with_build_nodes(true).build(&parsed.program);
let semantic = semantic_ret.semantic;
let scoping = semantic.scoping();
let nodes = semantic.nodes();
let mut resolved = Vec::new();
for symbol_id in scoping.symbol_ids() {
let def_span = scoping.symbol_span(symbol_id);
for reference in scoping.get_resolved_references(symbol_id) {
let use_span = nodes.kind(reference.node_id()).span();
resolved.push(ResolvedRef {
def_start: def_span.start,
def_end: def_span.end,
use_start: use_span.start,
use_end: use_span.end,
});
}
}
let imports = parsed
.module_record
.import_entries
.iter()
.filter_map(|entry| {
let imported = match &entry.import_name {
ImportImportName::Name(ns) => Some(ns.name.as_str().to_string()),
ImportImportName::Default(_) => None,
ImportImportName::NamespaceObject => return None,
};
Some(JsImport {
local: entry.local_name.name.as_str().to_string(),
specifier: entry.module_request.name.as_str().to_string(),
imported,
is_type: entry.is_type,
local_start: entry.local_name.span.start,
})
})
.collect();
let exports = parsed
.module_record
.local_export_entries
.iter()
.filter_map(|entry| match &entry.export_name {
ExportExportName::Name(ns) => Some(JsExport {
name: ns.name.as_str().to_string(),
name_start: ns.span.start,
}),
_ => None,
})
.collect();
JsAnalysis {
resolved,
imports,
exports,
had_errors: parsed.panicked || !parsed.diagnostics.is_empty(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(source: &str) -> JsAnalysis {
analyze(source, SourceType::ts())
}
#[test]
fn resolves_call_to_its_definition() {
let src = "function greet(name) {\n return name;\n}\ngreet(\"hi\");\n";
let a = ts(src);
assert!(!a.had_errors);
let greet_def = src.find("function greet").unwrap() + "function ".len();
let greet_call = src.rfind("greet(").unwrap();
let call_ref = a
.resolved
.iter()
.find(|r| r.use_start as usize == greet_call)
.expect("greet call must resolve");
assert_eq!(
call_ref.def_start as usize, greet_def,
"greet call binds to the function def"
);
}
#[test]
fn shadowing_resolves_to_inner_binding() {
let src = "const x = 1;\nfunction f() {\n const x = 2;\n return x;\n}\n";
let a = ts(src);
let outer_x = src.find("const x = 1").unwrap() + "const ".len();
let inner_x = src.find("const x = 2").unwrap() + "const ".len();
let use_x = src.rfind("return x").unwrap() + "return ".len();
let r = a
.resolved
.iter()
.find(|r| r.use_start as usize == use_x)
.expect("inner x use must resolve");
assert_eq!(r.def_start as usize, inner_x, "must bind to inner x, not outer");
assert_ne!(r.def_start as usize, outer_x);
}
#[test]
fn extracts_named_and_default_imports() {
let src = "import def, { foo, bar as baz } from './mod';\nimport * as ns from 'pkg';\nimport type { T } from './t';\n";
let a = analyze(src, SourceType::ts());
let by_local = |l: &str| a.imports.iter().find(|i| i.local == l).cloned();
let foo = by_local("foo").expect("named import foo");
assert_eq!(foo.specifier, "./mod");
assert_eq!(foo.imported.as_deref(), Some("foo"));
assert!(!foo.is_type, "runtime import must have is_type=false");
let t = by_local("T").expect("type-only import T");
assert!(t.is_type, "`import type` must set is_type=true");
let baz = by_local("baz").expect("aliased import baz");
assert_eq!(
baz.imported.as_deref(),
Some("bar"),
"aliased import keeps the source name"
);
let def = by_local("def").expect("default import");
assert_eq!(def.imported, None, "default import has no source name");
assert!(
by_local("ns").is_none(),
"namespace import is dropped — no single export site to stitch"
);
}
#[test]
fn extracts_named_exports() {
let src = "export function alpha() {}\nexport const beta = 1;\n";
let a = analyze(src, SourceType::mjs());
let mut names: Vec<&str> = a.exports.iter().map(|e| e.name.as_str()).collect();
names.sort_unstable();
assert_eq!(names, ["alpha", "beta"], "exactly alpha and beta must be exported");
}
#[test]
fn free_identifier_is_not_resolved() {
let src = "function f() {\n return fetch('/x');\n}\n";
let a = ts(src);
let fetch_use = src.find("fetch(").unwrap() as u32;
assert!(
!a.resolved.iter().any(|r| r.use_start == fetch_use),
"global `fetch` must not resolve to an in-file def"
);
}
}