swc_ecma_transforms_module/
path.rs

1use std::{
2    borrow::Cow,
3    env::current_dir,
4    fs::canonicalize,
5    io,
6    path::{Component, Path, PathBuf},
7    sync::Arc,
8};
9
10use anyhow::{anyhow, Context, Error};
11use path_clean::PathClean;
12use pathdiff::diff_paths;
13use swc_atoms::Atom;
14use swc_common::{FileName, Mark, Span, SyntaxContext, DUMMY_SP};
15use swc_ecma_ast::*;
16use swc_ecma_loader::resolve::{Resolution, Resolve};
17use swc_ecma_utils::{quote_ident, ExprFactory};
18use tracing::{debug, info, warn, Level};
19
20#[derive(Default)]
21pub enum Resolver {
22    Real {
23        base: FileName,
24        resolver: Arc<dyn ImportResolver>,
25    },
26    #[default]
27    Default,
28}
29
30impl Resolver {
31    pub(crate) fn resolve(&self, src: Atom) -> Atom {
32        match self {
33            Self::Real { resolver, base } => resolver
34                .resolve_import(base, &src)
35                .with_context(|| format!("failed to resolve import `{}`", src))
36                .unwrap(),
37            Self::Default => src,
38        }
39    }
40
41    pub(crate) fn make_require_call(
42        &self,
43        unresolved_mark: Mark,
44        src: Atom,
45        src_span: Span,
46    ) -> Expr {
47        let src = self.resolve(src);
48
49        CallExpr {
50            span: DUMMY_SP,
51            callee: quote_ident!(
52                SyntaxContext::empty().apply_mark(unresolved_mark),
53                "require"
54            )
55            .as_callee(),
56            args: vec![Lit::Str(Str {
57                span: src_span,
58                raw: None,
59                value: src,
60            })
61            .as_arg()],
62            ..Default::default()
63        }
64        .into()
65    }
66}
67
68pub trait ImportResolver {
69    /// Resolves `target` as a string usable by the modules pass.
70    ///
71    /// The returned string will be used as a module specifier.
72    fn resolve_import(&self, base: &FileName, module_specifier: &str) -> Result<Atom, Error>;
73}
74
75/// [ImportResolver] implementation which just uses original source.
76#[derive(Debug, Clone, Copy, Default)]
77pub struct NoopImportResolver;
78
79impl ImportResolver for NoopImportResolver {
80    fn resolve_import(&self, _: &FileName, module_specifier: &str) -> Result<Atom, Error> {
81        Ok(module_specifier.into())
82    }
83}
84
85/// [ImportResolver] implementation for node.js
86///
87/// Supports [FileName::Real] and [FileName::Anon] for `base`, [FileName::Real]
88/// and [FileName::Custom] for `target`. ([FileName::Custom] is used for core
89/// modules)
90#[derive(Debug, Clone, Default)]
91pub struct NodeImportResolver<R>
92where
93    R: Resolve,
94{
95    resolver: R,
96    config: Config,
97}
98
99#[derive(Debug, Clone)]
100pub struct Config {
101    pub base_dir: Option<PathBuf>,
102    pub resolve_fully: bool,
103    pub file_extension: String,
104}
105
106impl Default for Config {
107    fn default() -> Config {
108        Config {
109            file_extension: crate::util::Config::default_js_ext(),
110            resolve_fully: bool::default(),
111            base_dir: Option::default(),
112        }
113    }
114}
115
116impl<R> NodeImportResolver<R>
117where
118    R: Resolve,
119{
120    pub fn with_config(resolver: R, config: Config) -> Self {
121        #[cfg(not(all(target_arch = "wasm32", not(target_os = "wasi"))))]
122        if let Some(base_dir) = &config.base_dir {
123            assert!(
124                base_dir.is_absolute(),
125                "base_dir(`{}`) must be absolute. Please ensure that `jsc.baseUrl` is specified \
126                 correctly. This cannot be deduced by SWC itself because SWC is a transpiler and \
127                 it does not try to resolve project details. In other words, SWC does not know \
128                 which directory should be used as a base directory. It can be deduced if \
129                 `.swcrc` is used, but if not, there are many candidates. e.g. the directory \
130                 containing `package.json`, or the current working directory. Because of that, \
131                 the caller (typically the developer of the JavaScript package) should specify \
132                 it. If you see this error, please report an issue to the package author.",
133                base_dir.display()
134            );
135        }
136
137        Self { resolver, config }
138    }
139}
140
141impl<R> NodeImportResolver<R>
142where
143    R: Resolve,
144{
145    fn to_specifier(&self, mut target_path: PathBuf, orig_filename: Option<&str>) -> Atom {
146        debug!(
147            "Creating a specifier for `{}` with original filename `{:?}`",
148            target_path.display(),
149            orig_filename
150        );
151
152        if let Some(orig_filename) = orig_filename {
153            let is_resolved_as_index = if let Some(stem) = target_path.file_stem() {
154                stem == "index"
155            } else {
156                false
157            };
158
159            let is_resolved_as_non_js = if let Some(ext) = target_path.extension() {
160                ext.to_string_lossy() != self.config.file_extension
161            } else {
162                false
163            };
164
165            let is_resolved_as_js = if let Some(ext) = target_path.extension() {
166                ext.to_string_lossy() == self.config.file_extension
167            } else {
168                false
169            };
170
171            let is_exact = if let Some(filename) = target_path.file_name() {
172                filename == orig_filename
173            } else {
174                false
175            };
176
177            let file_stem_matches = if let Some(stem) = target_path.file_stem() {
178                stem == orig_filename
179            } else {
180                false
181            };
182
183            if self.config.resolve_fully && is_resolved_as_js {
184            } else if orig_filename == "index" {
185                // Import: `./foo/index`
186                // Resolved: `./foo/index.js`
187
188                if self.config.resolve_fully {
189                    target_path.set_file_name(format!("index.{}", self.config.file_extension));
190                } else {
191                    target_path.set_file_name("index");
192                }
193            } else if is_resolved_as_index
194                && is_resolved_as_js
195                && orig_filename != format!("index.{}", self.config.file_extension)
196            {
197                // Import: `./foo`
198                // Resolved: `./foo/index.js`
199
200                target_path.pop();
201            } else if is_resolved_as_non_js && self.config.resolve_fully && file_stem_matches {
202                target_path.set_extension(self.config.file_extension.clone());
203            } else if !is_resolved_as_js && !is_resolved_as_index && !is_exact {
204                target_path.set_file_name(orig_filename);
205            } else if is_resolved_as_non_js && is_exact {
206                if let Some(ext) = Path::new(orig_filename).extension() {
207                    target_path.set_extension(ext);
208                } else {
209                    target_path.set_extension(self.config.file_extension.clone());
210                }
211            } else if self.config.resolve_fully && is_resolved_as_non_js {
212                target_path.set_extension(self.config.file_extension.clone());
213            } else if is_resolved_as_non_js && is_resolved_as_index {
214                if orig_filename == "index" {
215                    target_path.set_extension("");
216                } else {
217                    target_path.pop();
218                }
219            }
220        } else {
221            target_path.set_extension("");
222        }
223
224        if cfg!(target_os = "windows") {
225            target_path.display().to_string().replace('\\', "/").into()
226        } else {
227            target_path.display().to_string().into()
228        }
229    }
230
231    fn try_resolve_import(&self, base: &FileName, module_specifier: &str) -> Result<Atom, Error> {
232        let _tracing = if cfg!(debug_assertions) {
233            Some(
234                tracing::span!(
235                    Level::ERROR,
236                    "resolve_import",
237                    base = tracing::field::display(base),
238                    module_specifier = tracing::field::display(module_specifier),
239                )
240                .entered(),
241            )
242        } else {
243            None
244        };
245
246        let orig_slug = module_specifier.split('/').last();
247
248        let target = self.resolver.resolve(base, module_specifier);
249        let mut target = match target {
250            Ok(v) => v,
251            Err(err) => {
252                warn!("import rewriter: failed to resolve: {}", err);
253                return Ok(module_specifier.into());
254            }
255        };
256
257        // Bazel uses symlink
258        //
259        // https://github.com/swc-project/swc/issues/8265
260        if let FileName::Real(resolved) = &target.filename {
261            if let Ok(orig) = canonicalize(resolved) {
262                target.filename = FileName::Real(orig);
263            }
264        }
265
266        let Resolution {
267            filename: target,
268            slug,
269        } = target;
270        let slug = slug.as_deref().or(orig_slug);
271
272        info!("Resolved as {target:?} with slug = {slug:?}");
273
274        let mut target = match target {
275            FileName::Real(v) => v,
276            FileName::Custom(s) => return Ok(self.to_specifier(s.into(), slug)),
277            _ => {
278                unreachable!(
279                    "Node path provider does not support using `{:?}` as a target file name",
280                    target
281                )
282            }
283        };
284        let mut base = match base {
285            FileName::Real(v) => Cow::Borrowed(
286                v.parent()
287                    .ok_or_else(|| anyhow!("failed to get parent of {:?}", v))?,
288            ),
289            FileName::Anon => match &self.config.base_dir {
290                Some(v) => Cow::Borrowed(&**v),
291                None => {
292                    if cfg!(target_arch = "wasm32") {
293                        panic!("Please specify `filename`")
294                    } else {
295                        Cow::Owned(current_dir().expect("failed to get current directory"))
296                    }
297                }
298            },
299            _ => {
300                unreachable!(
301                    "Node path provider does not support using `{:?}` as a base file name",
302                    base
303                )
304            }
305        };
306
307        if base.is_absolute() != target.is_absolute() {
308            base = Cow::Owned(absolute_path(self.config.base_dir.as_deref(), &base)?);
309            target = absolute_path(self.config.base_dir.as_deref(), &target)?;
310        }
311
312        debug!(
313            "Comparing values (after normalizing absoluteness)\nbase={}\ntarget={}",
314            base.display(),
315            target.display()
316        );
317
318        let rel_path = diff_paths(&target, &*base);
319
320        let rel_path = match rel_path {
321            Some(v) => v,
322            None => return Ok(self.to_specifier(target, slug)),
323        };
324
325        debug!("Relative path: {}", rel_path.display());
326
327        {
328            // Check for `node_modules`.
329
330            for component in rel_path.components() {
331                match component {
332                    Component::Prefix(_) => {}
333                    Component::RootDir => {}
334                    Component::CurDir => {}
335                    Component::ParentDir => {}
336                    Component::Normal(c) => {
337                        if c == "node_modules" {
338                            return Ok(module_specifier.into());
339                        }
340                    }
341                }
342            }
343        }
344
345        let s = rel_path.to_string_lossy();
346        let s = if s.starts_with('.') || s.starts_with('/') || rel_path.is_absolute() {
347            s
348        } else {
349            Cow::Owned(format!("./{}", s))
350        };
351
352        Ok(self.to_specifier(s.into_owned().into(), slug))
353    }
354}
355
356impl<R> ImportResolver for NodeImportResolver<R>
357where
358    R: Resolve,
359{
360    fn resolve_import(&self, base: &FileName, module_specifier: &str) -> Result<Atom, Error> {
361        self.try_resolve_import(base, module_specifier)
362            .or_else(|err| {
363                warn!("Failed to resolve import: {}", err);
364                Ok(module_specifier.into())
365            })
366    }
367}
368
369macro_rules! impl_ref {
370    ($P:ident, $T:ty) => {
371        impl<$P> ImportResolver for $T
372        where
373            $P: ImportResolver,
374        {
375            fn resolve_import(&self, base: &FileName, target: &str) -> Result<Atom, Error> {
376                (**self).resolve_import(base, target)
377            }
378        }
379    };
380}
381
382impl_ref!(P, &'_ P);
383impl_ref!(P, Box<P>);
384impl_ref!(P, Arc<P>);
385
386fn absolute_path(base_dir: Option<&Path>, path: &Path) -> io::Result<PathBuf> {
387    let absolute_path = if path.is_absolute() {
388        path.to_path_buf()
389    } else {
390        match base_dir {
391            Some(base_dir) => base_dir.join(path),
392            None => current_dir()?.join(path),
393        }
394    }
395    .clean();
396
397    Ok(absolute_path)
398}