use std::path::{Component, Path, PathBuf};
use rustc_hash::FxHashMap;
use boa_gc::GcRefCell;
use boa_parser::Source;
use crate::script::Script;
use crate::{
js_string, object::JsObject, realm::Realm, vm::ActiveRunnable, Context, JsError, JsNativeError,
JsResult, JsString,
};
use super::Module;
pub fn resolve_module_specifier(
base: Option<&Path>,
specifier: &JsString,
referrer: Option<&Path>,
_context: &mut Context,
) -> JsResult<PathBuf> {
let base_path = base.map_or_else(|| PathBuf::from(""), PathBuf::from);
let referrer_dir = referrer.and_then(|p| p.parent());
let specifier = specifier.to_std_string_escaped();
#[cfg(target_family = "windows")]
let specifier = specifier.replace("/", "\\");
let short_path = Path::new(&specifier);
let is_relative = short_path.starts_with(".") || short_path.starts_with("..");
let long_path = if is_relative {
if let Some(r_path) = referrer_dir {
base_path.join(r_path).join(short_path)
} else {
return Err(JsError::from_opaque(
js_string!("relative path without referrer").into(),
));
}
} else {
base_path.join(&specifier)
};
if long_path.is_relative() && base.is_some() {
return Err(JsError::from_opaque(
js_string!("resolved path is relative").into(),
));
}
let path = long_path
.components()
.filter(|c| c != &Component::CurDir || c == &Component::Normal("".as_ref()))
.try_fold(PathBuf::new(), |mut acc, c| {
if c == Component::ParentDir {
if acc.as_os_str().is_empty() {
return Err(JsError::from_opaque(
js_string!("path is outside the module root").into(),
));
}
acc.pop();
} else {
acc.push(c);
}
Ok(acc)
})?;
if path.starts_with(&base_path) {
Ok(path)
} else {
Err(JsError::from_opaque(
js_string!("path is outside the module root").into(),
))
}
}
#[derive(Debug, Clone)]
pub enum Referrer {
Module(Module),
Realm(Realm),
Script(Script),
}
impl Referrer {
#[must_use]
pub fn path(&self) -> Option<&Path> {
match self {
Self::Module(module) => module.path(),
Self::Realm(_) => None,
Self::Script(script) => script.path(),
}
}
}
impl From<ActiveRunnable> for Referrer {
fn from(value: ActiveRunnable) -> Self {
match value {
ActiveRunnable::Script(script) => Self::Script(script),
ActiveRunnable::Module(module) => Self::Module(module),
}
}
}
pub trait ModuleLoader {
#[allow(clippy::type_complexity)]
fn load_imported_module(
&self,
referrer: Referrer,
specifier: JsString,
finish_load: Box<dyn FnOnce(JsResult<Module>, &mut Context)>,
context: &mut Context,
);
fn register_module(&self, _specifier: JsString, _module: Module) {}
fn get_module(&self, _specifier: JsString) -> Option<Module> {
None
}
fn init_import_meta(&self, _import_meta: &JsObject, _module: &Module, _context: &mut Context) {}
}
#[derive(Debug, Clone, Copy)]
pub struct IdleModuleLoader;
impl ModuleLoader for IdleModuleLoader {
fn load_imported_module(
&self,
_referrer: Referrer,
_specifier: JsString,
finish_load: Box<dyn FnOnce(JsResult<Module>, &mut Context)>,
context: &mut Context,
) {
finish_load(
Err(JsNativeError::typ()
.with_message("module resolution is disabled for this context")
.into()),
context,
);
}
}
#[derive(Debug)]
pub struct SimpleModuleLoader {
root: PathBuf,
module_map: GcRefCell<FxHashMap<PathBuf, Module>>,
}
impl SimpleModuleLoader {
pub fn new<P: AsRef<Path>>(root: P) -> JsResult<Self> {
if cfg!(target_family = "wasm") {
return Err(JsNativeError::typ()
.with_message("cannot resolve a relative path in WASM targets")
.into());
}
let root = root.as_ref();
let absolute = root.canonicalize().map_err(|e| {
JsNativeError::typ()
.with_message(format!("could not set module root `{}`", root.display()))
.with_cause(JsError::from_opaque(js_string!(e.to_string()).into()))
})?;
Ok(Self {
root: absolute,
module_map: GcRefCell::default(),
})
}
#[inline]
pub fn insert(&self, path: PathBuf, module: Module) {
self.module_map.borrow_mut().insert(path, module);
}
#[inline]
pub fn get(&self, path: &Path) -> Option<Module> {
self.module_map.borrow().get(path).cloned()
}
}
impl ModuleLoader for SimpleModuleLoader {
fn load_imported_module(
&self,
referrer: Referrer,
specifier: JsString,
finish_load: Box<dyn FnOnce(JsResult<Module>, &mut Context)>,
context: &mut Context,
) {
let result = (|| {
let short_path = specifier.to_std_string_escaped();
let path =
resolve_module_specifier(Some(&self.root), &specifier, referrer.path(), context)?;
if let Some(module) = self.get(&path) {
return Ok(module);
}
let source = Source::from_filepath(&path).map_err(|err| {
JsNativeError::typ()
.with_message(format!("could not open file `{short_path}`"))
.with_cause(JsError::from_opaque(js_string!(err.to_string()).into()))
})?;
let module = Module::parse(source, None, context).map_err(|err| {
JsNativeError::syntax()
.with_message(format!("could not parse module `{short_path}`"))
.with_cause(err)
})?;
self.insert(path, module.clone());
Ok(module)
})();
finish_load(result, context);
}
fn register_module(&self, specifier: JsString, module: Module) {
let path = PathBuf::from(specifier.to_std_string_escaped());
self.insert(path, module);
}
fn get_module(&self, specifier: JsString) -> Option<Module> {
let path = specifier.to_std_string_escaped();
self.get(Path::new(&path))
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use test_case::test_case;
use super::*;
#[rustfmt::skip]
#[cfg(target_family = "unix")]
#[test_case(Some("/hello/ref.js"), "a.js", Ok("/base/a.js"))]
#[test_case(Some("/base/ref.js"), "./b.js", Ok("/base/b.js"))]
#[test_case(Some("/base/other/ref.js"), "./c.js", Ok("/base/other/c.js"))]
#[test_case(Some("/base/other/ref.js"), "../d.js", Ok("/base/d.js"))]
#[test_case(Some("/base/ref.js"), "e.js", Ok("/base/e.js"))]
#[test_case(Some("/base/ref.js"), "./f.js", Ok("/base/f.js"))]
#[test_case(Some("./ref.js"), "./g.js", Ok("/base/g.js"))]
#[test_case(Some("./other/ref.js"), "./other/h.js", Ok("/base/other/other/h.js"))]
#[test_case(Some("./other/ref.js"), "./other/../h1.js", Ok("/base/other/h1.js"))]
#[test_case(Some("./other/ref.js"), "./../h2.js", Ok("/base/h2.js"))]
#[test_case(None, "./i.js", Err(()))]
#[test_case(None, "j.js", Ok("/base/j.js"))]
#[test_case(None, "other/k.js", Ok("/base/other/k.js"))]
#[test_case(None, "other/../../l.js", Err(()))]
#[test_case(Some("/base/ref.js"), "other/../../m.js", Err(()))]
#[test_case(None, "../n.js", Err(()))]
fn resolve_test(ref_path: Option<&str>, spec: &str, expected: Result<&str, ()>) {
let base = PathBuf::from("/base");
let mut context = Context::default();
let spec = js_string!(spec);
let ref_path = ref_path.map(PathBuf::from);
let actual = resolve_module_specifier(
Some(&base),
&spec,
ref_path.as_deref(),
&mut context,
);
assert_eq!(actual.map_err(|_| ()), expected.map(PathBuf::from));
}
#[rustfmt::skip]
#[cfg(target_family = "unix")]
#[test_case(Some("hello/ref.js"), "a.js", Ok("a.js"))]
#[test_case(Some("base/ref.js"), "./b.js", Ok("base/b.js"))]
#[test_case(Some("base/other/ref.js"), "./c.js", Ok("base/other/c.js"))]
#[test_case(Some("base/other/ref.js"), "../d.js", Ok("base/d.js"))]
#[test_case(Some("base/ref.js"), "e.js", Ok("e.js"))]
#[test_case(Some("base/ref.js"), "./f.js", Ok("base/f.js"))]
#[test_case(Some("./ref.js"), "./g.js", Ok("g.js"))]
#[test_case(Some("./other/ref.js"), "./other/h.js", Ok("other/other/h.js"))]
#[test_case(Some("./other/ref.js"), "./other/../h1.js", Ok("other/h1.js"))]
#[test_case(Some("./other/ref.js"), "./../h2.js", Ok("h2.js"))]
#[test_case(None, "./i.js", Err(()))]
#[test_case(None, "j.js", Ok("j.js"))]
#[test_case(None, "other/k.js", Ok("other/k.js"))]
#[test_case(None, "other/../../l.js", Err(()))]
#[test_case(Some("/base/ref.js"), "other/../../m.js", Err(()))]
#[test_case(None, "../n.js", Err(()))]
fn resolve_test_no_base(ref_path: Option<&str>, spec: &str, expected: Result<&str, ()>) {
let mut context = Context::default();
let spec = js_string!(spec);
let ref_path = ref_path.map(PathBuf::from);
let actual = resolve_module_specifier(
None,
&spec,
ref_path.as_deref(),
&mut context,
);
assert_eq!(actual.map_err(|_| ()), expected.map(PathBuf::from));
}
#[rustfmt::skip]
#[cfg(target_family = "windows")]
#[test_case(Some("a:\\hello\\ref.js"), "a.js", Ok("a:\\base\\a.js"))]
#[test_case(Some("a:\\base\\ref.js"), "./b.js", Ok("a:\\base\\b.js"))]
#[test_case(Some("a:\\base\\other\\ref.js"), "./c.js", Ok("a:\\base\\other\\c.js"))]
#[test_case(Some("a:\\base\\other\\ref.js"), "../d.js", Ok("a:\\base\\d.js"))]
#[test_case(Some("a:\\base\\ref.js"), "e.js", Ok("a:\\base\\e.js"))]
#[test_case(Some("a:\\base\\ref.js"), "./f.js", Ok("a:\\base\\f.js"))]
#[test_case(Some(".\\ref.js"), "./g.js", Ok("a:\\base\\g.js"))]
#[test_case(Some(".\\other\\ref.js"), "./other/h.js", Ok("a:\\base\\other\\other\\h.js"))]
#[test_case(Some(".\\other\\ref.js"), "./other/../h1.js", Ok("a:\\base\\other\\h1.js"))]
#[test_case(Some(".\\other\\ref.js"), "./../h2.js", Ok("a:\\base\\h2.js"))]
#[test_case(None, "./i.js", Err(()))]
#[test_case(None, "j.js", Ok("a:\\base\\j.js"))]
#[test_case(None, "other/k.js", Ok("a:\\base\\other\\k.js"))]
#[test_case(None, "other/../../l.js", Err(()))]
#[test_case(Some("\\base\\ref.js"), "other/../../m.js", Err(()))]
#[test_case(None, "../n.js", Err(()))]
fn resolve_test(ref_path: Option<&str>, spec: &str, expected: Result<&str, ()>) {
let base = PathBuf::from("a:\\base");
let mut context = Context::default();
let spec = js_string!(spec);
let ref_path = ref_path.map(PathBuf::from);
let actual = resolve_module_specifier(
Some(&base),
&spec,
ref_path.as_deref(),
&mut context,
);
assert_eq!(actual.map_err(|_| ()), expected.map(PathBuf::from));
}
}