use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const RUNTIME_MODULE: &str = "@oxc-project/runtime";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SpecifierKind {
Static,
Dynamic,
OxcRuntime,
}
#[derive(Clone, Debug)]
pub struct ModuleImport {
pub specifier: String,
pub kind: SpecifierKind,
}
impl ModuleImport {
#[cfg_attr(not(feature = "typescript"), allow(dead_code))]
fn new(specifier: String, dynamic: bool) -> Self {
let kind = if dynamic {
SpecifierKind::Dynamic
} else if is_runtime_import(&specifier) {
SpecifierKind::OxcRuntime
} else {
SpecifierKind::Static
};
Self { specifier, kind }
}
}
#[derive(Clone, Debug, Default)]
pub struct ModuleGraph {
nodes: BTreeMap<PathBuf, Vec<ModuleImport>>,
}
impl ModuleGraph {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, path: impl Into<PathBuf>, imports: Vec<ModuleImport>) {
self.nodes.insert(path.into(), imports);
}
pub fn uses_runtime_helpers(&self) -> bool {
self.nodes
.values()
.flatten()
.any(|i| i.kind == SpecifierKind::OxcRuntime)
}
pub fn unresolved(&self, importmap: &crate::importmap::Importmap) -> Vec<(String, String)> {
let mut out = Vec::new();
for (path, imports) in &self.nodes {
for import in imports {
if classify(&import.specifier) == SpecifierClass::Bare
&& !importmap.resolves(&import.specifier)
{
out.push((path.display().to_string(), import.specifier.clone()));
}
}
}
out
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SpecifierClass {
Url,
Relative,
Bare,
}
pub fn classify(spec: &str) -> SpecifierClass {
if url::Url::parse(spec).is_ok() {
SpecifierClass::Url
} else if spec.starts_with('/') || spec.starts_with("./") || spec.starts_with("../") {
SpecifierClass::Relative
} else {
SpecifierClass::Bare
}
}
fn is_runtime_import(spec: &str) -> bool {
spec == RUNTIME_MODULE || spec.starts_with(&format!("{RUNTIME_MODULE}/"))
}
#[derive(Debug)]
pub struct SourceImports {
pub imports: Vec<ModuleImport>,
pub parsed: bool,
}
pub fn imports_from_source(
js: &str,
module_only: bool,
) -> std::result::Result<SourceImports, String> {
#[cfg(feature = "typescript")]
return imports_from_source_ast(js, module_only);
#[cfg(not(feature = "typescript"))]
{
let _ = (js, module_only);
Ok(SourceImports {
imports: Vec::new(),
parsed: false,
})
}
}
pub(crate) fn is_emitted_js(ext: &str) -> bool {
["js", "mjs"].iter().any(|e| ext.eq_ignore_ascii_case(e))
}
pub(crate) fn imports_for_emitted_js(
source: &str,
ext: &str,
rel: &Path,
) -> crate::Result<Option<Vec<ModuleImport>>> {
if !is_emitted_js(ext) {
return Ok(None);
}
let module_only = ext.eq_ignore_ascii_case("mjs");
let read = imports_from_source(source, module_only).map_err(|reason| {
crate::Error::Build(format!("web-modules: {}: {reason}", rel.display()))
})?;
if !read.parsed {
crate::static_files::build_warning(&format!(
"web-modules: {}: was not parsed as a module or classic script; \
its imports are not validated",
rel.display()
));
}
Ok(Some(read.imports))
}
#[cfg(feature = "typescript")]
fn imports_from_source_ast(
js: &str,
module_only: bool,
) -> std::result::Result<SourceImports, String> {
use oxc_allocator::Allocator;
use oxc_parser::Parser;
use oxc_span::SourceType;
let mut imports = Vec::new();
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, js, SourceType::mjs()).parse();
if !parsed.diagnostics.has_errors() {
static_from_program(&parsed.program, &mut imports);
dynamic_from_program(&parsed.program, &mut imports);
return Ok(SourceImports {
imports,
parsed: true,
});
}
if module_only {
let first = parsed
.diagnostics
.iter()
.next()
.map(|d| format!("{d:?}"))
.unwrap_or_default();
return Err(format!("does not parse as an ES module: {first}"));
}
let script = Parser::new(&allocator, js, SourceType::script()).parse();
if !script.diagnostics.has_errors() {
dynamic_from_program(&script.program, &mut imports);
return Ok(SourceImports {
imports,
parsed: true,
});
}
Ok(SourceImports {
imports: Vec::new(),
parsed: false,
})
}
#[cfg(feature = "typescript")]
pub fn static_from_program(program: &oxc_ast::ast::Program, imports: &mut Vec<ModuleImport>) {
use oxc_ast::ast::Statement;
for stmt in &program.body {
let source = match stmt {
Statement::ImportDeclaration(decl) => Some(decl.source.value.as_str()),
Statement::ExportAllDeclaration(decl) => Some(decl.source.value.as_str()),
Statement::ExportNamedDeclaration(decl) => {
decl.source.as_ref().map(|s| s.value.as_str())
}
_ => None,
};
if let Some(spec) = source {
imports.push(ModuleImport::new(spec.to_string(), false));
}
}
}
#[cfg(feature = "typescript")]
pub fn dynamic_from_program(program: &oxc_ast::ast::Program, imports: &mut Vec<ModuleImport>) {
use oxc_ast_visit::Visit;
DynamicImports { imports }.visit_program(program);
}
#[cfg(feature = "typescript")]
struct DynamicImports<'i> {
imports: &'i mut Vec<ModuleImport>,
}
#[cfg(feature = "typescript")]
impl<'a> oxc_ast_visit::Visit<'a> for DynamicImports<'_> {
fn visit_import_expression(&mut self, expr: &oxc_ast::ast::ImportExpression<'a>) {
use oxc_ast::ast::Expression;
match &expr.source {
Expression::StringLiteral(spec) => self
.imports
.push(ModuleImport::new(spec.value.as_str().to_string(), true)),
Expression::TemplateLiteral(tpl) if tpl.is_no_substitution_template() => {
if let Some(quasi) = tpl.single_quasi() {
self.imports
.push(ModuleImport::new(quasi.as_str().to_string(), true));
}
}
_ => {}
}
oxc_ast_visit::walk::walk_import_expression(self, expr);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn specs(imports: &[ModuleImport]) -> Vec<&str> {
imports.iter().map(|i| i.specifier.as_str()).collect()
}
#[test]
fn classify_follows_url_grammar() {
use SpecifierClass::*;
for (spec, want) in [
("lit", Bare),
("@oxc-project/runtime/helpers/decorate", Bare),
(".hidden", Bare), ("1nope:x", Bare), ("no scheme:x", Bare), ("./local.js", Relative),
("../up.js", Relative),
("/x.js", Relative),
("//host/mod.js", Relative),
("user:pass@example.com/mod.js", Url),
("https://h/y.js", Url),
("data:text/javascript,0", Url),
("blob:https://origin/uuid", Url),
("about:blank", Url),
("node:fs", Url),
("file:///x.js", Url),
("custom+scheme.v1:thing", Url),
] {
assert_eq!(classify(spec), want, "classify({spec:?})");
}
}
#[test]
fn unresolved_skips_url_scheme_specifiers() {
let mut graph = ModuleGraph::new();
graph.insert(
"app.js",
vec![
ModuleImport::new("node:fs".into(), false),
ModuleImport::new("blob:https://origin/uuid".into(), true),
ModuleImport::new("about:blank".into(), false),
ModuleImport::new("missing-package".into(), false),
],
);
let unresolved = graph.unresolved(&crate::importmap::Importmap::new());
assert_eq!(
unresolved.len(),
1,
"only the bare specifier is unresolved; got {unresolved:?}"
);
assert_eq!(unresolved[0].1, "missing-package");
}
#[test]
fn unresolved_ignores_traversing_relative_and_absolute_specifiers() {
for spec in [
"../../../../etc/passwd",
"./../secret",
"/etc/shadow",
"//host/x.js",
] {
assert_eq!(
classify(spec),
SpecifierClass::Relative,
"classify({spec:?})"
);
}
let mut graph = ModuleGraph::new();
graph.insert(
"app.js",
vec![
ModuleImport::new("../../../../etc/passwd".into(), false),
ModuleImport::new("/etc/shadow".into(), true),
ModuleImport::new("missing-package".into(), false),
],
);
let unresolved = graph.unresolved(&crate::importmap::Importmap::new());
assert_eq!(
unresolved.len(),
1,
"traversing paths are not resolution candidates; got {unresolved:?}"
);
assert_eq!(unresolved[0].1, "missing-package");
}
#[cfg(feature = "typescript")]
#[test]
fn source_imports_static_dynamic_and_runtime_kinds() {
let js = "import { a } from \"lit\";\n\
import _d from \"@oxc-project/runtime/helpers/decorate\";\n\
import \"./local.js\";\n\
const m = import(\"bootstrap\");";
let imports = imports_from_source(js, false).unwrap().imports;
let found = specs(&imports);
for want in [
"lit",
"@oxc-project/runtime/helpers/decorate",
"./local.js",
"bootstrap",
] {
assert!(found.contains(&want), "missing {want:?} in {found:?}");
}
let kind = |s: &str| imports.iter().find(|i| i.specifier == s).unwrap().kind;
assert_eq!(kind("lit"), SpecifierKind::Static);
assert_eq!(
kind("@oxc-project/runtime/helpers/decorate"),
SpecifierKind::OxcRuntime
);
assert_eq!(kind("bootstrap"), SpecifierKind::Dynamic);
}
#[cfg(not(feature = "typescript"))]
#[test]
fn no_parser_reports_unparsed_and_no_imports() {
let read = imports_from_source("import { a } from \"lit\";", true).unwrap();
assert!(read.imports.is_empty());
assert!(!read.parsed);
}
#[cfg(feature = "typescript")]
#[test]
fn finds_minified_space_less_forms() {
let js = "import\"@oxc-project/runtime/helpers/decorate\";\
import{a as b}from\"lit\";\
export{x}from\"bootstrap\";\
const m=import(\"lit-html\");";
let imports = imports_from_source(js, false).unwrap().imports;
let found = specs(&imports);
for want in [
"@oxc-project/runtime/helpers/decorate",
"lit",
"bootstrap",
"lit-html",
] {
assert!(found.contains(&want), "missing {want:?} in {found:?}");
}
}
#[cfg(feature = "typescript")]
#[test]
fn ignores_imports_in_comments_and_strings() {
let js = "// Satisfies `import nodeCrypto from \"crypto\"` in the browser.\n\
const msg = 'import \"nope\" failed';\n\
export default {};";
let imports = imports_from_source(js, false).unwrap().imports;
let found = specs(&imports);
assert!(
!found.contains(&"crypto") && !found.contains(&"nope"),
"comment/string text must not register as imports; got {found:?}"
);
}
#[cfg(feature = "typescript")]
#[test]
fn finds_nested_and_minified_dynamic_import_from_ast() {
let js =
"document.addEventListener(\"click\",()=>{import(\"./lazy.js\").then(m=>m.run())});\
const load=(n)=>import(n);";
let imports = imports_from_source(js, false).unwrap().imports;
let dynamic: Vec<&str> = imports
.iter()
.filter(|i| i.kind == SpecifierKind::Dynamic)
.map(|i| i.specifier.as_str())
.collect();
assert_eq!(dynamic, ["./lazy.js"], "got {dynamic:?}");
}
#[test]
fn graph_flags_unresolved_but_agrees_on_resolved() {
let mut graph = ModuleGraph::new();
graph.insert(
"app.js",
vec![
ModuleImport::new("lit".into(), false),
ModuleImport::new("@oxc-project/runtime/helpers/decorate".into(), false),
ModuleImport::new("./local.js".into(), false),
],
);
assert!(graph.uses_runtime_helpers());
let mut map = crate::importmap::Importmap::new();
map.insert("lit", "/web_modules/lit/index.js");
let unresolved = graph.unresolved(&map);
assert_eq!(unresolved.len(), 1, "got {unresolved:?}");
assert_eq!(unresolved[0].0, "app.js");
assert!(unresolved[0].1.starts_with("@oxc-project/runtime"));
}
#[test]
fn graph_without_helpers_needs_no_runtime() {
let mut graph = ModuleGraph::new();
graph.insert("app.js", vec![ModuleImport::new("lit".into(), false)]);
assert!(!graph.uses_runtime_helpers());
}
#[test]
fn insert_replaces_same_path_last_write_wins() {
let mut graph = ModuleGraph::new();
graph.insert(
"app.js",
vec![
ModuleImport::new("missing-package".into(), false),
ModuleImport::new("@oxc-project/runtime/helpers/decorate".into(), false),
],
);
graph.insert("other.js", vec![ModuleImport::new("lit".into(), false)]);
graph.insert("app.js", vec![]);
assert!(
!graph.uses_runtime_helpers(),
"the replaced record's helper must not trigger vendoring"
);
let unresolved = graph.unresolved(&crate::importmap::Importmap::new());
assert_eq!(
unresolved,
vec![("other.js".to_string(), "lit".to_string())],
"only the shipped files' imports count; got {unresolved:?}"
);
}
#[cfg(feature = "typescript")]
#[test]
fn unparsable_source_contributes_no_imports_and_reports_it() {
let js = "import { broken from \"lit\";";
let read = imports_from_source(js, false).unwrap();
assert!(read.imports.is_empty());
assert!(!read.parsed, "both goals failed, so nothing is known");
}
#[cfg(feature = "typescript")]
#[test]
fn classic_script_dynamic_import_is_captured() {
let js = "var await = 1;\nimport(\"missing-package\");";
let read = imports_from_source(js, false).unwrap();
assert!(read.parsed, "the classic-script goal parses this");
assert_eq!(read.imports.len(), 1, "got {:?}", read.imports);
assert_eq!(read.imports[0].specifier, "missing-package");
assert_eq!(read.imports[0].kind, SpecifierKind::Dynamic);
}
#[cfg(feature = "typescript")]
#[test]
fn module_only_source_fails_on_module_syntax_error() {
let err = imports_from_source("var await = 1;", true).unwrap_err();
assert!(err.contains("does not parse as an ES module"), "got: {err}");
}
#[cfg(feature = "typescript")]
#[test]
fn no_substitution_template_dynamic_imports_are_captured() {
let js = "const a = import(`lit`);\n\
const b = import(`./local.js`);\n\
const c = (name) => import(`pkg/${name}`);";
let imports = imports_from_source(js, false).unwrap().imports;
let dynamic: Vec<&str> = imports
.iter()
.filter(|i| i.kind == SpecifierKind::Dynamic)
.map(|i| i.specifier.as_str())
.collect();
assert_eq!(dynamic, ["lit", "./local.js"], "got {dynamic:?}");
}
}