Skip to main content

rolldown_sourcemap/
lib.rs

1mod source;
2mod source_joiner;
3
4use std::borrow::Cow;
5
6use oxc_sourcemap::Token;
7
8pub use oxc_sourcemap::{JSONSourceMap, OwnedSourceMap, SourceMapBuilder, SourcemapVisualizer};
9pub use source_joiner::SourceJoiner;
10
11/// Rolldown always stores and produces owned sourcemaps, so we alias the
12/// lifetime-parameterized `oxc_sourcemap::SourceMap` to its `'static` form.
13pub type SourceMap = oxc_sourcemap::SourceMap<'static>;
14
15pub use crate::source::{Source, SourceMapSource};
16
17/// Strips the first `lines` destination lines from the sourcemap, decrementing all remaining
18/// destination line numbers accordingly. Used to re-anchor a sourcemap after removing a
19/// prefix (e.g. a shebang line) from the generated code.
20pub fn adjust_sourcemap_dst_lines(sourcemap: SourceMap, lines: u32) -> SourceMap {
21  if lines == 0 {
22    return sourcemap;
23  }
24
25  let tokens: Box<[Token]> = sourcemap
26    .get_tokens()
27    .filter(|t| t.get_dst_line() >= lines)
28    .map(|token| {
29      Token::new(
30        token.get_dst_line() - lines,
31        token.get_dst_col(),
32        token.get_src_line(),
33        token.get_src_col(),
34        token.get_source_id(),
35        token.get_name_id(),
36      )
37    })
38    .collect();
39
40  SourceMap::new(
41    sourcemap.get_file().map(|f| Cow::Owned(f.to_owned())),
42    sourcemap.get_names().map(|n| Cow::Owned(n.to_owned())).collect(),
43    sourcemap.get_source_root().map(|s| Cow::Owned(s.to_owned())),
44    sourcemap.get_sources().map(|s| Cow::Owned(s.to_owned())).collect(),
45    sourcemap.get_source_contents().map(|c| c.map(|s| Cow::Owned(s.to_owned()))).collect(),
46    tokens,
47    None,
48  )
49}
50
51/// Builds an empty sourcemap with no tokens, sources, names, or contents.
52pub fn empty_sourcemap() -> SourceMap {
53  SourceMap::new(None, vec![], None, vec![], vec![], Box::new([]), None)
54}
55
56// <https://github.com/rollup/rollup/blob/master/src/utils/collapseSourcemaps.ts>
57pub fn collapse_sourcemaps(sourcemap_chain: &[&SourceMap]) -> SourceMap {
58  debug_assert!(sourcemap_chain.len() > 1);
59  if sourcemap_chain.len() == 1 {
60    // If there's only one sourcemap, return it as is.
61    return sourcemap_chain[0].clone();
62  }
63
64  let last_map = sourcemap_chain.last().expect("sourcemap_chain should not be empty");
65  let first_map = sourcemap_chain.first().expect("sourcemap_chain should not be empty");
66  let chain_without_last = &sourcemap_chain[..sourcemap_chain.len() - 1];
67
68  // Pre-compute lookup tables in reverse order so we avoid reversing on every token lookup.
69  let sourcemap_and_lookup_table: Vec<_> = chain_without_last
70    .iter()
71    .rev()
72    .map(|sourcemap| (*sourcemap, sourcemap.generate_lookup_table()))
73    .collect();
74
75  let tokens: Box<[Token]> = last_map
76    .get_source_view_tokens()
77    .filter_map(|token| {
78      let original_token =
79        sourcemap_and_lookup_table.iter().try_fold(token, |token, (sourcemap, lookup_table)| {
80          sourcemap.lookup_source_view_token(
81            lookup_table,
82            token.get_src_line(),
83            token.get_src_col(),
84          )
85        });
86      original_token.map(|original_token| {
87        Token::new(
88          token.get_dst_line(),
89          token.get_dst_col(),
90          original_token.get_src_line(),
91          original_token.get_src_col(),
92          original_token.get_source_id(),
93          original_token.get_name_id(),
94        )
95      })
96    })
97    .collect();
98
99  SourceMap::new(
100    None,
101    first_map.get_names().map(|n| Cow::Owned(n.to_owned())).collect(),
102    None,
103    first_map.get_sources().map(|s| Cow::Owned(s.to_owned())).collect(),
104    first_map.get_source_contents().map(|x| x.map(|s| Cow::Owned(s.to_owned()))).collect(),
105    tokens,
106    None,
107  )
108}
109
110#[test]
111fn test_collapse_sourcemaps() {
112  use crate::{SourceJoiner, SourceMapSource, collapse_sourcemaps};
113  use oxc::{
114    allocator::Allocator,
115    codegen::{Codegen, CodegenOptions, CodegenReturn, CommentOptions},
116    parser::Parser,
117    span::SourceType,
118  };
119  use oxc_sourcemap::SourcemapVisualizer;
120
121  let allocator = Allocator::default();
122
123  let mut source_joiner = SourceJoiner::default();
124
125  let filename = "foo.js".to_string();
126  let source_text = "const foo = 1; console.log(foo);\n".to_string();
127  let source_type = SourceType::from_path(&filename).unwrap();
128  let ret1 = Parser::new(&allocator, &source_text, source_type).parse();
129  let CodegenReturn { map, code, .. } = Codegen::new()
130    .with_options(CodegenOptions {
131      comments: CommentOptions { normal: false, ..CommentOptions::default() },
132      source_map_path: Some(filename.into()),
133      ..CodegenOptions::default()
134    })
135    .build(&ret1.program);
136  source_joiner.append_source(SourceMapSource::new(code, map.unwrap().into_owned()));
137
138  let filename = "bar.js".to_string();
139  let source_text = "const bar = 2; console.log(bar);\n".to_string();
140  let ret2: oxc::parser::ParserReturn = Parser::new(&allocator, &source_text, source_type).parse();
141  let CodegenReturn { map, code, .. } = Codegen::new()
142    .with_options(CodegenOptions {
143      source_map_path: Some(filename.into()),
144      ..CodegenOptions::default()
145    })
146    .build(&ret2.program);
147  source_joiner.append_source(SourceMapSource::new(code, map.unwrap().into_owned()));
148
149  let (source_text, source_map) = source_joiner.join();
150
151  let mut sourcemap_chain = vec![];
152
153  sourcemap_chain.push(source_map.as_ref().unwrap());
154
155  let filename = "chunk.js".to_string();
156  let ret3 = Parser::new(&allocator, &source_text, source_type).parse();
157  let CodegenReturn { map, code, .. } = Codegen::new()
158    .with_options(CodegenOptions {
159      comments: CommentOptions { normal: false, ..CommentOptions::default() },
160      source_map_path: Some(filename.into()),
161      ..CodegenOptions::default()
162    })
163    .build(&ret3.program);
164  let map = map.unwrap().into_owned();
165  sourcemap_chain.push(&map);
166
167  let map = collapse_sourcemaps(&sourcemap_chain);
168  assert_eq!(
169    SourcemapVisualizer::new(&code, &map).get_text(),
170    r#"- foo.js
171(0:0) "const " --> (0:0) "const "
172(0:6) "foo = " --> (0:6) "foo = "
173(0:12) "1; " --> (0:12) "1;\n"
174(0:15) "console." --> (1:0) "console."
175(0:23) "log(" --> (1:8) "log("
176(0:27) "foo" --> (1:12) "foo"
177(0:30) ");\n" --> (1:15) ");\n"
178- bar.js
179(0:0) "const " --> (2:0) "const "
180(0:6) "bar = " --> (2:6) "bar = "
181(0:12) "2; " --> (2:12) "2;\n"
182(0:15) "console." --> (3:0) "console."
183(0:23) "log(" --> (3:8) "log("
184(0:27) "bar" --> (3:12) "bar"
185(0:30) ");\n" --> (3:15) ");\n"
186"#
187  );
188}
189
190/// Test for https://github.com/rollup/rollup/issues/5955
191#[test]
192fn test_collapse_sourcemaps_with_coarse_segments() {
193  use oxc_sourcemap::SourceMap;
194
195  fn get_loc(mut pos: usize, code: &str) -> (u32, u32) {
196    for (line_idx, line) in code.lines().enumerate() {
197      if pos <= line.len() {
198        #[expect(clippy::cast_possible_truncation)]
199        return (line_idx as u32, pos as u32);
200      }
201      pos -= line.len() + 1; // +1 for newline
202    }
203    panic!("position out of bounds");
204  }
205
206  let original_code = "import { useEffect } from 'react';
207
208export function App() {
209  useEffect(() => {
210    console.log('ReplayAnalyze');
211  }, []);
212
213  return <div>{'.'}</div>;
214}
215";
216  let transformed_code = r#"import{jsx}from"react/jsx-runtime";import{useEffect}from"react";export function App(){return useEffect((()=>{console.log("ReplayAnalyze")}),[]),jsx("div",{children:"."})}"#;
217
218  // spellchecker:off
219  let esbuild_map_json = r#"{
220    "version": 3,
221    "sources": ["<stdin>"],
222    "sourcesContent": ["import { useEffect } from 'react';\n\nexport function App() {\n  useEffect(() => {\n    console.log('ReplayAnalyze');\n  }, []);\n\n  return <div>{'.'}</div>;\n}\n"],
223    "mappings": "AAOS;AAPT,SAAS,iBAAiB;AAEnB,gBAAS,MAAM;AACpB,YAAU,MAAM;AACd,YAAQ,IAAI,eAAe;AAAA,EAC7B,GAAG,CAAC,CAAC;AAEL,SAAO,oBAAC,SAAK,eAAI;AACnB;",
224    "names": []
225  }"#;
226  // spellchecker:on
227  let esbuild_map = SourceMap::from_json_string(esbuild_map_json).unwrap();
228
229  // spellchecker:off
230  let terser_map_json = r#"{
231    "version": 3,
232    "names": ["jsx", "useEffect", "App", "console", "log", "children"],
233    "sources": ["0"],
234    "sourcesContent": ["import { jsx } from \"react/jsx-runtime\";\nimport { useEffect } from \"react\";\nexport function App() {\n  useEffect(() => {\n    console.log(\"ReplayAnalyze\");\n  }, []);\n  return /* @__PURE__ */ jsx(\"div\", { children: \".\" });\n}\n"],
235    "mappings": "OAASA,QAAW,2BACXC,cAAiB,eACnB,SAASC,MAId,OAHAD,WAAU,KACRE,QAAQC,IAAI,gBAAgB,GAC3B,IACoBJ,IAAI,MAAO,CAAEK,SAAU,KAChD"
236  }"#;
237  // spellchecker:on
238  let terser_map = SourceMap::from_json_string(terser_map_json).unwrap();
239
240  let collapsed = collapse_sourcemaps(&[&esbuild_map, &terser_map]);
241  let collapsed_lookup_table = collapsed.generate_lookup_table();
242
243  let generated_loc = get_loc(transformed_code.find("return").unwrap(), transformed_code);
244  let original_loc = collapsed
245    .lookup_source_view_token(&collapsed_lookup_table, generated_loc.0, generated_loc.1)
246    .map(|token| (token.get_src_line(), token.get_src_col()));
247  assert_eq!(
248    original_loc,
249    Some(get_loc(original_code.find("return").unwrap(), original_code)),
250    "collapsed sourcemap should map 'return' in transformed code back to original source"
251  );
252}