1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
//! This module contains common bundling routines and utilities.

use anyhow::{bail, Context, Error};
use path_clean::PathClean;
use std::{
    collections::{HashMap, HashSet},
    fs::File,
    io::{Read, Write},
    path::{Component, Path, PathBuf},
};
use swc_core::{
    atoms::Atom,
    bundler::{Bundler, Hook, Load, ModuleData, ModuleRecord, Resolve},
    common::{
        comments::SingleThreadedComments, errors::Handler, sync::Lrc, FileName,
        Globals, Mark, SourceMap, Span,
    },
    ecma::{
        ast::{KeyValueProp, Module, Program},
        codegen::{
            text_writer::{JsWriter, WriteJs},
            Emitter,
        },
        loader::resolve::Resolution,
        parser::{parse_file_as_module, EsSyntax, Syntax, TsSyntax},
        transforms::{react::react, typescript::typescript},
        visit::FoldWith,
    },
};
use tempfile::NamedTempFile;

/// The file extensions to try when an import is given without an extension
static EXTENSIONS: &[&str] = &["js", "jsx", "ts", "tsx"];

/// Bundle the entry point into a raw module.
///
/// It does not apply AST transforms and ignores default/external dependencies without
/// bundling them.
pub(super) fn bundle_into_raw_module(
    root: &Path,
    target: &Path,
    dependency_map: &HashMap<String, String>,
    globals: &Globals,
    cm: Lrc<SourceMap>,
) -> Result<Module, Error> {
    if !target.exists() {
        bail!("Entry point does not exist: '{}'", target.display());
    }

    // Get the list of external modules not to resolve; this should include default
    // dependencies and (if any) external dependencies obtained from the dependency map
    let external_modules = {
        let mut dependencies = HashSet::from([
            Atom::from("@deskulpt-test/apis"),
            Atom::from("@deskulpt-test/emotion/jsx-runtime"),
            Atom::from("@deskulpt-test/react"),
            Atom::from("@deskulpt-test/ui"),
        ]);
        dependencies.extend(dependency_map.keys().map(|k| Atom::from(k.clone())));
        Vec::from_iter(dependencies)
    };

    // The root of the path resolver will be used to determine whether a resolved import
    // goes beyond the root; the comparison is done via path prefixes so we must be
    // consistent with how SWC resolves paths, see:
    // https://github.com/swc-project/swc/blob/f584ef76d75e86da15d0725ac94be35a88a1c946/crates/swc_bundler/src/bundler/mod.rs#L159-L166
    #[cfg(target_os = "windows")]
    let path_resolver_root = root.canonicalize()?;
    #[cfg(not(target_os = "windows"))]
    let path_resolver_root = root.to_path_buf();

    let mut bundler = Bundler::new(
        globals,
        cm.clone(),
        PathLoader(cm.clone()),
        PathResolver(path_resolver_root),
        // Do not resolve the external modules
        swc_core::bundler::Config { external_modules, ..Default::default() },
        Box::new(NoopHook),
    );

    // SWC bundler requires a map of entries to bundle; we provide a single entry point
    // and expect there to be only one generated bundle; we use the target path as the
    // key for convenience
    let mut entries = HashMap::new();
    entries.insert(
        target.to_string_lossy().to_string(),
        FileName::Real(target.to_path_buf()),
    );

    let mut bundles = bundler.bundle(entries)?;
    if bundles.len() != 1 {
        bail!("Expected a single bundle, got {}", bundles.len());
    }
    Ok(bundles.pop().unwrap().module)
}

/// Emit a module into a buffer.
pub(super) fn emit_module_to_buf<W: Write>(module: Module, cm: Lrc<SourceMap>, buf: W) {
    let wr = JsWriter::new(cm.clone(), "\n", buf, None);
    let mut emitter = Emitter {
        cfg: swc_core::ecma::codegen::Config::default().with_minify(true),
        cm: cm.clone(),
        comments: None,
        wr: Box::new(wr) as Box<dyn WriteJs>,
    };
    emitter.emit_module(&module).unwrap();
}

/// Deskulpt-customized path loader for SWC bundler.
///
/// It is in charge of parsing the source file into a module AST. TypeScript types are
/// stripped off and JSX syntax is transformed during the parsing.
struct PathLoader(Lrc<SourceMap>);

impl Load for PathLoader {
    fn load(&self, file: &FileName) -> Result<ModuleData, Error> {
        let path = match file {
            FileName::Real(path) => path,
            _ => unreachable!(),
        };
        let fm = self.0.load_file(path)?;

        let syntax = match path.extension() {
            Some(ext) if ext == "ts" || ext == "tsx" => {
                Syntax::Typescript(TsSyntax { tsx: true, ..Default::default() })
            },
            _ => Syntax::Es(EsSyntax { jsx: true, ..Default::default() }),
        };

        // Parse the file as a module
        match parse_file_as_module(&fm, syntax, Default::default(), None, &mut vec![]) {
            Ok(module) => {
                let unresolved_mark = Mark::new();
                let top_level_mark = Mark::new();

                // Strip off TypeScript types
                let mut ts_transform = typescript::typescript(
                    Default::default(),
                    unresolved_mark,
                    top_level_mark,
                );

                // We use the automatic JSX transform (in contrast to the classic
                // transform) here so that there is no need to bring anything into scope
                // just for syntax which could be unused; to enable the `css` prop from
                // Emotion, we specify the import source to be `@deskulpt-test/emotion`,
                // so that the JSX runtime utilities will be automatically imported from
                // `@deskulpt-test/emotion/jsx-runtime`
                let mut jsx_transform = react::<SingleThreadedComments>(
                    self.0.clone(),
                    None,
                    swc_core::ecma::transforms::react::Options {
                        runtime: Some(
                            swc_core::ecma::transforms::react::Runtime::Automatic,
                        ),
                        import_source: Some("@deskulpt-test/emotion".to_string()),
                        ..Default::default()
                    },
                    top_level_mark,
                    unresolved_mark,
                );

                match Program::Module(module)
                    .fold_with(&mut ts_transform)
                    .fold_with(&mut jsx_transform)
                    .module()
                {
                    Some(module) => {
                        Ok(ModuleData { fm, module, helpers: Default::default() })
                    },
                    None => bail!("Failed to parse the file as a module"),
                }
            },
            Err(err) => {
                // The error handler requires a destination for the emitter writer that
                // implements `Write`; a buffer implements `Write` but its borrowed
                // reference does not, causing the handler to take ownership of the
                // buffer, making us unable to read from it later (and the buffer is
                // made private in the handler); the workaround is to use a temporary
                // file and access its content later by its path (we require the file to
                // live only for a short time so this is relatively safe)
                let mut err_msg = String::new();
                {
                    let context = format!(
                        "Parsing error occurred but failed to emit the formatted error \
                        analysis; falling back to raw version: {err:?}"
                    );
                    let buffer = NamedTempFile::new().context(context.clone())?;
                    let buffer_path = buffer.path().to_path_buf();
                    let handler = Handler::with_emitter_writer(
                        Box::new(buffer),
                        Some(self.0.clone()),
                    );
                    err.into_diagnostic(&handler).emit();
                    File::open(buffer_path)
                        .context(context.clone())?
                        .read_to_string(&mut err_msg)
                        .context(context.clone())?;
                }
                bail!(err_msg);
            },
        }
    }
}

/// The Deskulpt-customized path resolver for SWC bundler.
///
/// It is in charge of resolving the module specifiers in the import statements. Note
/// that module specifiers that are ignored in the first place will not go through this
/// resolver at all.
///
/// This path resolver intends to resolve the following types of imports:
///
/// - Extension-less relative paths, e.g., `import foo from "./foo"`
/// - Relative paths, e.g., `import foo from "./foo.js"`
///
/// It is not designed to resolve the following types of imports:
///
/// - Absolute path imports, e.g., `import foo from "/foo"`
/// - URL imports, e.g., `import foo from "https://example.com/foo"`
/// - Node resolution imports, e.g., `import globals from "globals"`
/// - Relative imports that go beyond the root
struct PathResolver(PathBuf);

impl PathResolver {
    /// Helper function for resolving a path by treating it as a file.
    ///
    /// If `path` refers to a file then it is directly returned. Otherwise, `path` with
    /// each extension in [`EXTENSIONS`] is tried in order.
    fn resolve_as_file(&self, path: &Path) -> Result<PathBuf, Error> {
        if path.is_file() {
            // Early return if `path` is directly a file
            return Ok(path.to_path_buf());
        }

        if let Some(name) = path.file_name() {
            let mut ext_path = path.to_path_buf();
            let name = name.to_string_lossy();

            // Try all extensions we support for importing
            for ext in EXTENSIONS {
                ext_path.set_file_name(format!("{name}.{ext}"));
                if ext_path.is_file() {
                    return Ok(ext_path);
                }
            }
        }
        bail!("File resolution failed")
    }

    /// Helper function for the [`Resolve`] trait.
    ///
    /// Note that errors emitted here do not need to provide information about `base`
    /// and `module_specifier` because the call to this function should have already
    /// been wrapped in an SWC context that provides this information.
    fn resolve_filename(
        &self,
        base: &FileName,
        module_specifier: &str,
    ) -> Result<FileName, Error> {
        let base = match base {
            FileName::Real(v) => v,
            _ => bail!("Invalid base for resolution: '{base}'"),
        };

        // Determine the base directory (or `base` itself if already a directory)
        let base_dir = if base.is_file() {
            // If failed to get the parent directory then use the cwd
            base.parent().unwrap_or_else(|| Path::new("."))
        } else {
            base
        };

        let spec_path = Path::new(module_specifier);
        // Absolute paths are not supported
        if spec_path.is_absolute() {
            bail!("Absolute imports are not supported; use relative imports instead");
        }

        // If not absolute, then it should be either relative, a node module, or a URL;
        // we support only relative import among these types
        let mut components = spec_path.components();
        if let Some(Component::CurDir | Component::ParentDir) = components.next() {
            let path = base_dir.join(module_specifier).clean();

            // Try to resolve by treating `path` as a file first, otherwise try by
            // looking for an `index` file under `path` as a directory
            let resolved_path = self
                .resolve_as_file(&path)
                .or_else(|_| self.resolve_as_file(&path.join("index")))?;

            // Reject if the resolved path goes beyond the root
            if !resolved_path.starts_with(&self.0) {
                bail!(
                    "Relative imports should not go beyond the root '{}'",
                    self.0.display(),
                );
            }
            return Ok(FileName::Real(resolved_path));
        }

        bail!(
            "node_modules imports should be explicitly included in package.json to \
            avoid being bundled at runtime; URL imports are not supported, one should \
            vendor its source to local and use a relative import instead"
        )
    }
}

impl Resolve for PathResolver {
    fn resolve(
        &self,
        base: &FileName,
        module_specifier: &str,
    ) -> Result<Resolution, Error> {
        self.resolve_filename(base, module_specifier)
            .map(|filename| Resolution { filename, slug: None })
    }
}

/// A no-op hook for SWC bundler.
struct NoopHook;

impl Hook for NoopHook {
    fn get_import_meta_props(
        &self,
        _: Span,
        _: &ModuleRecord,
    ) -> Result<Vec<KeyValueProp>, Error> {
        // XXX: figure out a better way than panicking
        unimplemented!();
    }
}