use std::{borrow::Cow, sync::Arc};
use rspack_error::Result;
use rspack_sources::{
MapOptions, Mapping, ObjectPool, OriginalLocation, Source, SourceMap, SourceMapSource,
SourceMapSourceOptions, encode_mappings,
};
use rspack_util::source_map::SourceMapKind;
use rustc_hash::{FxHashMap, FxHashSet};
use swc_core::{
common::{
BytePos, FileName, LineCol, SourceFile, SourceMap as SwcSourceMap,
comments::Comments,
source_map::{MultiByteChar, SmallPos},
sync::Lrc,
},
ecma::{
ast::{EsVersion, Ident, Program as SwcProgram},
atoms::Atom,
codegen::{
self, Emitter, Node,
text_writer::{self, WriteJs},
},
visit::{Visit, VisitWith, noop_visit_type},
},
};
use super::{JavaScriptCompiler, TransformOutput};
#[derive(Default, Clone, Debug)]
pub struct SourceMapConfig {
pub source_map_kind: SourceMapKind,
pub names: FxHashMap<BytePos, Atom>,
}
impl SourceMapConfig {
pub fn enabled(&self) -> bool {
self.source_map_kind.source_map()
}
fn file_name_to_source(&self, f: &FileName) -> String {
let f = f.to_string();
if f.starts_with('<') && f.ends_with('>') {
f[1..f.len() - 1].to_string()
} else {
f
}
}
fn inline_sources_content(&self, _: &FileName) -> bool {
self.source_map_kind.source_map()
}
fn emit_columns(&self, _f: &FileName) -> bool {
self.source_map_kind.emit_columns()
}
fn name_for_bytepos(&self, pos: BytePos) -> Option<&str> {
self.names.get(&pos).map(|v| &**v)
}
fn skip(&self, f: &FileName) -> bool {
matches!(f, FileName::Internal(..))
}
fn ignore_list(&self, f: &FileName) -> bool {
matches!(f, FileName::Anon | FileName::Internal(..))
}
}
pub struct PrintOptions<'a> {
pub source_len: u32,
pub source_map: Arc<SwcSourceMap>,
pub target: EsVersion,
pub source_map_config: SourceMapConfig,
pub input_source_map: Option<SourceMap<'static>>,
pub minify: bool,
pub comments: Option<&'a dyn Comments>,
pub preamble: &'a str,
pub ascii_only: bool,
pub inline_script: bool,
}
impl JavaScriptCompiler {
pub fn print(&self, node: &SwcProgram, options: PrintOptions<'_>) -> Result<TransformOutput> {
let PrintOptions {
source_len,
source_map,
target,
mut source_map_config,
input_source_map,
minify,
comments,
preamble,
ascii_only,
inline_script,
} = options;
let mut src_map_buf = vec![];
if source_map_config.enabled() {
let mut v = IdentCollector {
names: Default::default(),
};
node.visit_with(&mut v);
source_map_config.names = v.names;
}
let src = {
let mut buf = Vec::with_capacity(source_len as usize);
{
let mut w = text_writer::JsWriter::new(
source_map.clone(),
"\n",
&mut buf,
source_map_config.enabled().then_some(&mut src_map_buf),
);
w.preamble(preamble)?;
let mut wr = Box::new(w) as Box<dyn WriteJs>;
if minify {
wr = Box::new(text_writer::omit_trailing_semi(wr));
}
let mut emitter = Emitter {
cfg: codegen::Config::default()
.with_minify(minify)
.with_target(target)
.with_ascii_only(ascii_only)
.with_inline_script(inline_script),
comments,
cm: source_map.clone(),
wr,
};
node.emit_with(&mut emitter)?;
}
unsafe { String::from_utf8_unchecked(buf) }
};
let map = if source_map_config.enabled() {
build_rspack_source_map(
&source_map,
&src_map_buf,
input_source_map,
&source_map_config,
&src,
)
} else {
None
};
Ok(TransformOutput {
code: src,
map,
diagnostics: Default::default(),
})
}
}
fn build_rspack_source_map(
swc_cm: &SwcSourceMap,
mappings: &[(BytePos, LineCol)],
input_source_map: Option<SourceMap<'static>>,
config: &SourceMapConfig,
generated_code: &str,
) -> Option<SourceMap<'static>> {
let mut builder = RspackSourceMapBuilder::default();
let mut cur_file: Option<Lrc<SourceFile>> = None;
let mut cur_src_id = 0u32;
let mut prev_dst_line = u32::MAX;
let mut position_converter = SourceMapPositionConverter::default();
for (raw_pos, lc) in mappings {
let pos = *raw_pos;
if pos.is_reserved_for_comments() {
continue;
}
if lc.line == 0 && lc.col == 0 && pos.is_dummy() {
continue;
}
if pos == BytePos(u32::MAX) {
builder.add_mapping(Mapping {
generated_line: lc.line + 1,
generated_column: lc.col,
original: Some(OriginalLocation {
source_index: cur_src_id,
original_line: 1,
original_column: 0,
name_index: None,
}),
});
continue;
}
let file = match cur_file {
Some(ref file) if file.start_pos <= *raw_pos && *raw_pos < file.end_pos => file,
_ => {
let Some(file) = swc_cm.try_lookup_source_file(*raw_pos).ok().flatten() else {
continue;
};
if config.skip(&file.name) {
continue;
}
cur_src_id = builder.add_source(
Cow::Owned(config.file_name_to_source(&file.name)),
config
.inline_sources_content(&file.name)
.then(|| Cow::Owned(file.src.to_string())),
);
if input_source_map.is_none() && config.ignore_list(&file.name) {
builder.add_to_ignore_list(cur_src_id);
}
cur_file = Some(file.clone());
cur_file.as_ref().expect("source file was just set")
}
};
if config.skip(&file.name) {
continue;
}
if !config.emit_columns(&file.name) && lc.line == prev_dst_line {
continue;
}
let Some((original_line, original_column)) = position_converter.convert(file, pos) else {
continue;
};
let name_index = config
.name_for_bytepos(pos)
.map(|name| builder.add_name(name));
builder.add_mapping(Mapping {
generated_line: lc.line + 1,
generated_column: lc.col,
original: Some(OriginalLocation {
source_index: cur_src_id,
original_line,
original_column,
name_index,
}),
});
prev_dst_line = lc.line;
}
let intermediate_map = builder.into_source_map();
if let Some(input_source_map) = input_source_map {
let name = intermediate_map
.get_source(0)
.map(ToString::to_string)
.unwrap_or_default();
Arc::new(SourceMapSource::new(SourceMapSourceOptions {
value: generated_code.to_string(),
name,
source_map: intermediate_map,
original_source: None,
inner_source_map: Some(input_source_map),
remove_original_source: true,
}))
.map_static(
&ObjectPool::default(),
&MapOptions::new(config.source_map_kind.emit_columns()),
)
} else {
Some(intermediate_map)
}
}
#[derive(Default)]
struct RspackSourceMapBuilder<'a> {
mappings: Vec<Mapping>,
sources_content: Vec<Cow<'a, str>>,
source_indices: FxHashMap<Cow<'a, str>, u32>,
name_indices: FxHashMap<Cow<'a, str>, u32>,
ignore_list: FxHashSet<u32>,
}
impl<'a> RspackSourceMapBuilder<'a> {
fn add_source(&mut self, source: Cow<'a, str>, source_content: Option<Cow<'a, str>>) -> u32 {
if let Some(index) = self.source_indices.get(source.as_ref()) {
return *index;
}
let index = self.source_indices.len() as u32;
self
.sources_content
.push(source_content.unwrap_or(Cow::Borrowed("")));
self.source_indices.insert(source, index);
index
}
fn add_to_ignore_list(&mut self, source_index: u32) {
self.ignore_list.insert(source_index);
}
fn add_name(&mut self, name: &'a str) -> u32 {
if let Some(index) = self.name_indices.get(name) {
return *index;
}
let index = self.name_indices.len() as u32;
self.name_indices.insert(Cow::Borrowed(name), index);
index
}
fn add_mapping(&mut self, mapping: Mapping) {
self.mappings.push(mapping);
}
fn into_source_map(self) -> SourceMap<'static> {
let mut source_map = SourceMap::new(
encode_mappings(self.mappings.into_iter()),
ordered_cows(self.source_indices),
self
.sources_content
.into_iter()
.map(|source_content| Cow::Owned(source_content.into_owned()))
.collect(),
ordered_cows(self.name_indices),
);
if !self.ignore_list.is_empty() {
let mut ignore_list = self.ignore_list.into_iter().collect::<Vec<_>>();
ignore_list.sort_unstable();
source_map.set_ignore_list(Some(Cow::Owned(ignore_list)));
}
source_map
}
}
fn ordered_cows(entries: FxHashMap<Cow<'_, str>, u32>) -> Vec<Cow<'static, str>> {
let mut ordered = vec![Cow::Borrowed(""); entries.len()];
for (value, index) in entries {
ordered[index as usize] = Cow::Owned(value.into_owned());
}
ordered
}
#[derive(Default)]
struct SourceMapPositionConverter {
state: Option<SourceMapPositionState>,
}
struct SourceMapPositionState {
file_start: BytePos,
line: u32,
line_start: BytePos,
line_end: BytePos,
pos: BytePos,
utf8_to_utf16_diff: u32,
line_utf8_to_utf16_diff: u32,
multibyte_index: usize,
}
impl SourceMapPositionConverter {
fn convert(&mut self, file: &SourceFile, pos: BytePos) -> Option<(u32, u32)> {
let (line, line_start, line_end) = match &self.state {
Some(state)
if state.file_start == file.start_pos
&& state.line_start <= pos
&& pos < state.line_end =>
{
(state.line, state.line_start, state.line_end)
}
_ => {
let line = file.lookup_line(pos)?;
let (line_start, line_end) = file.line_bounds(line);
debug_assert!(
u32::try_from(line).is_ok(),
"{}: line index {line} exceeds u32::MAX",
file.name,
);
let line = line as u32;
(line, line_start, line_end)
}
};
debug_assert!(
line_start <= pos && pos <= line_end,
"{}: bpos = {:?}; line bounds = {:?}..{:?};",
file.name,
pos,
line_start,
line_end,
);
let source_pos = pos.to_u32().checked_sub(file.start_pos.to_u32())? as usize;
if !file.src.is_char_boundary(source_pos) {
return None;
}
let multibyte_chars = &file.analyze().multibyte_chars;
let (mut state_pos, mut utf8_to_utf16_diff, mut multibyte_index) = match &self.state {
Some(state) if state.file_start == file.start_pos => {
(state.pos, state.utf8_to_utf16_diff, state.multibyte_index)
}
_ => (file.start_pos, 0, 0),
};
let line_utf8_to_utf16_diff = match &self.state {
Some(state) if state.file_start == file.start_pos && state.line_start == line_start => {
state.line_utf8_to_utf16_diff
}
_ => {
Self::move_to(
multibyte_chars,
line_start,
&mut state_pos,
&mut utf8_to_utf16_diff,
&mut multibyte_index,
)?;
utf8_to_utf16_diff
}
};
Self::move_to(
multibyte_chars,
pos,
&mut state_pos,
&mut utf8_to_utf16_diff,
&mut multibyte_index,
)?;
let byte_column = pos.to_u32().checked_sub(line_start.to_u32())?;
let column =
byte_column.checked_sub(utf8_to_utf16_diff.checked_sub(line_utf8_to_utf16_diff)?)?;
self.state = Some(SourceMapPositionState {
file_start: file.start_pos,
line,
line_start,
line_end,
pos,
utf8_to_utf16_diff,
line_utf8_to_utf16_diff,
multibyte_index,
});
Some((line + 1, column))
}
fn move_to(
multibyte_chars: &[MultiByteChar],
target: BytePos,
pos: &mut BytePos,
utf8_to_utf16_diff: &mut u32,
multibyte_index: &mut usize,
) -> Option<()> {
if *pos <= target {
while let Some(multibyte_char) = multibyte_chars.get(*multibyte_index) {
if multibyte_char.pos >= target {
break;
}
*utf8_to_utf16_diff =
utf8_to_utf16_diff.checked_add(multibyte_char.byte_to_char_diff() as u32)?;
*multibyte_index += 1;
}
} else {
while let Some(index) = multibyte_index.checked_sub(1) {
let multibyte_char = &multibyte_chars[index];
if multibyte_char.pos < target {
break;
}
*utf8_to_utf16_diff =
utf8_to_utf16_diff.checked_sub(multibyte_char.byte_to_char_diff() as u32)?;
*multibyte_index = index;
}
}
*pos = target;
Some(())
}
}
#[cfg(feature = "codspeed")]
#[doc(hidden)]
pub fn benchmark_source_map_position_conversion(file: &SourceFile, positions: &[BytePos]) -> u64 {
let mut converter = SourceMapPositionConverter::default();
positions.iter().fold(0, |checksum, &pos| {
let (line, column) = converter
.convert(file, pos)
.expect("benchmark positions should be valid");
checksum.wrapping_add((u64::from(line) << 32) | u64::from(column))
})
}
struct IdentCollector {
pub names: FxHashMap<BytePos, Atom>,
}
impl Visit for IdentCollector {
noop_visit_type!();
fn visit_ident(&mut self, ident: &Ident) {
self.names.insert(ident.span.lo, ident.sym.clone());
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use rspack_sources::{Mapping, OriginalLocation, SourceMap, encode_mappings};
use rspack_util::source_map::SourceMapKind;
use swc_core::common::{BytePos, FileName, LineCol, SourceMap as SwcSourceMap};
use super::{SourceMapConfig, build_rspack_source_map};
#[test]
fn composes_input_source_map_in_cheap_mode() {
let swc_cm = Arc::new(SwcSourceMap::default());
let source = "\n\n\nconsole.log(user.name);\n";
let file = swc_cm.new_source_file(Arc::new(FileName::Custom("input.js".into())), source);
let line_four = BytePos(file.start_pos.0 + source.find("console").expect("source line") as u32);
let input_source_map = SourceMap::from_json(
r#"{
"version": 3,
"file": "input.js",
"sources": ["original.vue"],
"sourcesContent": ["<template>\n <p>{{ user.name }}</p>\n</template>\n"],
"names": [],
"mappings": ";;;UACA"
}"#
.to_string(),
)
.expect("valid input source map");
let source_map = build_rspack_source_map(
&swc_cm,
&[(line_four, LineCol { line: 0, col: 0 })],
Some(input_source_map),
&SourceMapConfig {
source_map_kind: SourceMapKind::SourceMap.with_cheap(true),
names: Default::default(),
},
"console.log(user.name);\n",
)
.expect("composed source map");
let mappings = source_map.decoded_mappings().collect::<Vec<_>>();
assert_eq!(source_map.get_source(0), Some("original.vue"));
assert_eq!(
mappings[0]
.original
.as_ref()
.map(|original| original.original_line),
Some(2)
);
}
#[test]
fn preserves_outer_names_when_input_source_map_has_no_names() {
let swc_cm = Arc::new(SwcSourceMap::default());
let source = "const user = value;\n";
let file = swc_cm.new_source_file(Arc::new(FileName::Custom("input.js".into())), source);
let user_column = source.find("user").expect("identifier column") as u32;
let user_pos = BytePos(file.start_pos.0 + user_column);
let input_source_map = SourceMap::new(
encode_mappings(
[Mapping {
generated_line: 1,
generated_column: user_column,
original: Some(OriginalLocation {
source_index: 0,
original_line: 1,
original_column: user_column,
name_index: None,
}),
}]
.into_iter(),
),
vec!["original.js".into()],
vec![source.into()],
vec![],
);
let source_map = build_rspack_source_map(
&swc_cm,
&[(
user_pos,
LineCol {
line: 0,
col: user_column,
},
)],
Some(input_source_map),
&SourceMapConfig {
source_map_kind: SourceMapKind::SourceMap,
names: [(user_pos, "user".into())].into_iter().collect(),
},
source,
)
.expect("composed source map");
let mappings = source_map.decoded_mappings().collect::<Vec<_>>();
let name_index = mappings[0]
.original
.as_ref()
.and_then(|original| original.name_index)
.expect("outer name should be preserved");
assert_eq!(source_map.get_name(name_index as usize), Some("user"));
}
#[test]
fn keeps_input_source_map_gaps_unmapped() {
let swc_cm = Arc::new(SwcSourceMap::default());
let source = "const a = 1; const b = 2;\n";
let file = swc_cm.new_source_file(Arc::new(FileName::Custom("input.js".into())), source);
let a_pos = BytePos(file.start_pos.0 + source.find("a").expect("a column") as u32);
let b_column = source.find("b").expect("b column") as u32;
let b_pos = BytePos(file.start_pos.0 + b_column);
let input_source_map = SourceMap::new(
encode_mappings(
[
Mapping {
generated_line: 1,
generated_column: 0,
original: Some(OriginalLocation {
source_index: 0,
original_line: 1,
original_column: 0,
name_index: None,
}),
},
Mapping {
generated_line: 1,
generated_column: b_column,
original: None,
},
]
.into_iter(),
),
vec!["original.js".into()],
vec![source.into()],
vec![],
);
let source_map = build_rspack_source_map(
&swc_cm,
&[
(a_pos, LineCol { line: 0, col: 0 }),
(
b_pos,
LineCol {
line: 0,
col: b_column,
},
),
],
Some(input_source_map),
&SourceMapConfig {
source_map_kind: SourceMapKind::SourceMap,
names: Default::default(),
},
source,
)
.expect("composed source map");
assert_eq!(source_map.sources(), &["original.js"]);
let mappings = source_map.decoded_mappings().collect::<Vec<_>>();
assert!(mappings[0].original.is_some());
assert!(mappings[1].original.is_none());
}
#[test]
fn preserves_absolute_input_sources_under_source_root() {
let swc_cm = Arc::new(SwcSourceMap::default());
let source = "const first = second;\n";
let file = swc_cm.new_source_file(Arc::new(FileName::Custom("input.js".into())), source);
let first_column = source.find("first").expect("first column") as u32;
let second_column = source.find("second").expect("second column") as u32;
let first_pos = BytePos(file.start_pos.0 + first_column);
let second_pos = BytePos(file.start_pos.0 + second_column);
let mut input_source_map = SourceMap::new(
encode_mappings(
[
Mapping {
generated_line: 1,
generated_column: first_column,
original: Some(OriginalLocation {
source_index: 0,
original_line: 1,
original_column: 0,
name_index: None,
}),
},
Mapping {
generated_line: 1,
generated_column: second_column,
original: Some(OriginalLocation {
source_index: 1,
original_line: 1,
original_column: 0,
name_index: None,
}),
},
]
.into_iter(),
),
vec!["/src/app.js".into(), "https://example.com/vendor.js".into()],
vec![
"const first = second;\n".into(),
"export const second = 1;\n".into(),
],
vec![],
);
input_source_map.set_source_root(Some("/root".into()));
let source_map = build_rspack_source_map(
&swc_cm,
&[
(
first_pos,
LineCol {
line: 0,
col: first_column,
},
),
(
second_pos,
LineCol {
line: 0,
col: second_column,
},
),
],
Some(input_source_map),
&SourceMapConfig {
source_map_kind: SourceMapKind::SourceMap,
names: Default::default(),
},
source,
)
.expect("composed source map");
assert_eq!(source_map.get_source(0), Some("/src/app.js"));
assert_eq!(
source_map.get_source(1),
Some("https://example.com/vendor.js")
);
}
}