Skip to main content

deno_core/
source_map.rs

1// Copyright 2018-2026 the Deno authors. MIT license.
2
3//! This mod provides functions to remap a `JsError` based on a source map.
4
5use std::borrow::Cow;
6use std::collections::HashMap;
7use std::rc::Rc;
8use std::str;
9use std::sync::Arc;
10
11pub use sourcemap::SourceMap;
12
13use crate::ModuleLoader;
14use crate::ModuleName;
15use crate::resolve_url;
16
17#[derive(Debug, PartialEq)]
18pub enum SourceMapApplication {
19  /// No mapping was applied, the location is unchanged.
20  Unchanged,
21  /// Line and column were mapped to a new location.
22  LineAndColumn {
23    line_number: u32,
24    column_number: u32,
25  },
26  /// Line, column and file name were mapped to a new location.
27  LineAndColumnAndFileName {
28    file_name: String,
29    line_number: u32,
30    column_number: u32,
31  },
32}
33
34pub type SourceMapData = Cow<'static, [u8]>;
35
36pub struct SourceMapper {
37  // TODO(bartlomieju): I feel like these two should be cleared when Isolate
38  // reaches "near heap limit" to free up some space. This needs to be confirmed though.
39  maps: HashMap<String, Option<Arc<SourceMap>>>,
40  source_lines: HashMap<(String, i64), Option<String>>,
41
42  loader: Rc<dyn ModuleLoader>,
43
44  ext_source_maps: HashMap<ModuleName, SourceMapData>,
45  source_map_urls: HashMap<ModuleName, String>,
46}
47
48impl SourceMapper {
49  pub fn new(loader: Rc<dyn ModuleLoader>) -> Self {
50    Self {
51      maps: Default::default(),
52      source_lines: Default::default(),
53      ext_source_maps: Default::default(),
54      source_map_urls: Default::default(),
55      loader,
56    }
57  }
58
59  /// Add a source map for particular `ext:` module.
60  pub(crate) fn add_ext_source_map(
61    &mut self,
62    module_name: ModuleName,
63    source_map_data: SourceMapData,
64  ) {
65    self.ext_source_maps.insert(module_name, source_map_data);
66  }
67
68  pub(crate) fn take_ext_source_maps(
69    &mut self,
70  ) -> HashMap<ModuleName, SourceMapData> {
71    std::mem::take(&mut self.ext_source_maps)
72  }
73
74  /// Add a source map extracted from V8 for a module.
75  pub(crate) fn add_source_map(
76    &mut self,
77    module_name: ModuleName,
78    source_map: SourceMap,
79  ) {
80    self
81      .maps
82      .insert(module_name.to_string(), Some(Arc::new(source_map)));
83  }
84
85  pub(crate) fn add_source_map_url(
86    &mut self,
87    module_name: ModuleName,
88    source_map_url: String,
89  ) {
90    self.source_map_urls.insert(module_name, source_map_url);
91  }
92
93  /// Apply a source map to the passed location. If there is no source map for
94  /// this location, or if the location remains unchanged after mapping, the
95  /// changed values are returned.
96  ///
97  /// Line and column numbers are 1-based.
98  pub fn apply_source_map(
99    &mut self,
100    file_name: &str,
101    line_number: u32,
102    column_number: u32,
103  ) -> SourceMapApplication {
104    // Lookup expects 0-based line and column numbers, but ours are 1-based.
105    let line_number = line_number - 1;
106    let column_number = column_number - 1;
107
108    let maybe_source_map =
109      self.maps.entry(file_name.to_owned()).or_insert_with(|| {
110        None
111          // Try ext: source maps (inline)
112          .or_else(|| {
113            SourceMap::from_slice(self.ext_source_maps.get(file_name)?)
114              .ok()
115              .map(Arc::new)
116          })
117          // Try external source maps via ModuleLoader
118          .or_else(|| {
119            // Check if we have an external source map URL for this file
120            let source_map_url = self.source_map_urls.get(file_name)?;
121            // Request the external source map from the loader
122            let source_map_data =
123              self.loader.load_external_source_map(source_map_url)?;
124            SourceMap::from_slice(&source_map_data).ok().map(Arc::new)
125          })
126          // Try loader's inline source maps
127          .or_else(|| {
128            SourceMap::from_slice(&self.loader.get_source_map(file_name)?)
129              .ok()
130              .map(Arc::new)
131          })
132      });
133
134    let Some(source_map) = maybe_source_map.as_ref() else {
135      return SourceMapApplication::Unchanged;
136    };
137
138    let Some(token) = source_map.lookup_token(line_number, column_number)
139    else {
140      return SourceMapApplication::Unchanged;
141    };
142
143    let new_line_number = token.get_src_line() + 1;
144    let new_column_number = token.get_src_col() + 1;
145
146    let new_file_name = match token.get_source() {
147      Some(source_file_name) => {
148        if source_file_name == file_name {
149          None
150        } else {
151          // The `source_file_name` written by tsc in the source map is
152          // sometimes only the basename of the URL, or has unwanted `<`/`>`
153          // around it. Try to parse it as a URL first. If that fails,
154          // try to resolve it as a relative path from the module URL.
155          match resolve_url(source_file_name) {
156            Ok(m) if m.scheme() == "blob" => None,
157            Ok(m) => Some(m.to_string()),
158            Err(_) => resolve_url(file_name)
159              .ok()
160              .and_then(|base_url| base_url.join(source_file_name).ok())
161              .and_then(|resolved| {
162                let resolved_str = resolved.to_string();
163                // Only rewrite file name if the source file actually exists.
164                // This prevents npm packages with source maps pointing to
165                // non-distributed source files from breaking stack traces.
166                match self.loader.source_map_source_exists(&resolved_str) {
167                  Some(true) => Some(resolved_str),
168                  _ => None,
169                }
170              }),
171          }
172        }
173      }
174      None => None,
175    };
176
177    match new_file_name {
178      None => SourceMapApplication::LineAndColumn {
179        line_number: new_line_number,
180        column_number: new_column_number,
181      },
182      Some(file_name) => SourceMapApplication::LineAndColumnAndFileName {
183        file_name,
184        line_number: new_line_number,
185        column_number: new_column_number,
186      },
187    }
188  }
189
190  const MAX_SOURCE_LINE_LENGTH: usize = 150;
191
192  pub fn get_source_line(
193    &mut self,
194    file_name: &str,
195    line_number: i64,
196  ) -> Option<String> {
197    if let Some(maybe_source_line) =
198      self.source_lines.get(&(file_name.to_string(), line_number))
199    {
200      return maybe_source_line.clone();
201    }
202
203    let maybe_source_line = self
204      .loader
205      .get_source_mapped_source_line(file_name, (line_number - 1) as usize)
206      .filter(|s| s.len() <= Self::MAX_SOURCE_LINE_LENGTH);
207
208    // Cache and return
209    self.source_lines.insert(
210      (file_name.to_string(), line_number),
211      maybe_source_line.clone(),
212    );
213    maybe_source_line
214  }
215}
216
217#[cfg(test)]
218mod tests {
219  use url::Url;
220
221  use super::*;
222  use crate::ModuleCodeString;
223  use crate::ModuleLoadReferrer;
224  use crate::ModuleLoadResponse;
225  use crate::ModuleSpecifier;
226  use crate::ResolutionKind;
227  use crate::ascii_str;
228  use crate::error::ModuleLoaderError;
229  use crate::modules::ModuleLoadOptions;
230
231  struct SourceMapLoaderContent {
232    source_map: Option<ModuleCodeString>,
233  }
234
235  #[derive(Default)]
236  pub struct SourceMapLoader {
237    map: HashMap<ModuleSpecifier, SourceMapLoaderContent>,
238    existing_files: std::cell::RefCell<std::collections::HashSet<String>>,
239  }
240
241  impl SourceMapLoader {
242    fn add_existing_file(&self, file_name: &str) {
243      self
244        .existing_files
245        .borrow_mut()
246        .insert(file_name.to_string());
247    }
248  }
249
250  impl ModuleLoader for SourceMapLoader {
251    fn resolve(
252      &self,
253      _specifier: &str,
254      _referrer: &str,
255      _kind: ResolutionKind,
256    ) -> Result<ModuleSpecifier, ModuleLoaderError> {
257      unreachable!()
258    }
259
260    fn load(
261      &self,
262      _module_specifier: &ModuleSpecifier,
263      _maybe_referrer: Option<&ModuleLoadReferrer>,
264      _options: ModuleLoadOptions,
265    ) -> ModuleLoadResponse {
266      unreachable!()
267    }
268
269    fn get_source_map(&self, file_name: &str) -> Option<Cow<'_, [u8]>> {
270      let url = Url::parse(file_name).unwrap();
271      let content = self.map.get(&url)?;
272      content
273        .source_map
274        .as_ref()
275        .map(|s| Cow::Borrowed(s.as_bytes()))
276    }
277
278    fn get_source_mapped_source_line(
279      &self,
280      _file_name: &str,
281      _line_number: usize,
282    ) -> Option<String> {
283      Some("fake source line".to_string())
284    }
285
286    fn source_map_source_exists(&self, source_url: &str) -> Option<bool> {
287      Some(self.existing_files.borrow().contains(source_url))
288    }
289  }
290
291  #[test]
292  fn test_source_mapper() {
293    let mut loader = SourceMapLoader::default();
294    loader.map.insert(
295      Url::parse("file:///b.js").unwrap(),
296      SourceMapLoaderContent { source_map: None },
297    );
298    loader.map.insert(
299      Url::parse("file:///a.ts").unwrap(),
300      SourceMapLoaderContent {
301        source_map: Some(ascii_str!(r#"{"version":3,"sources":["file:///a.ts"],"sourcesContent":["export function a(): string {\n  return \"a\";\n}\n"],"names":[],"mappings":"AAAA,OAAO,SAAS;EACd,OAAO;AACT"}"#).into()),
302      },
303    );
304
305    let mut source_mapper = SourceMapper::new(Rc::new(loader));
306
307    // Non-existent file
308    let application =
309      source_mapper.apply_source_map("file:///doesnt_exist.js", 1, 1);
310    assert_eq!(application, SourceMapApplication::Unchanged);
311
312    // File with no source map
313    let application = source_mapper.apply_source_map("file:///b.js", 1, 1);
314    assert_eq!(application, SourceMapApplication::Unchanged);
315
316    // File with a source map
317    let application = source_mapper.apply_source_map("file:///a.ts", 1, 21);
318    assert_eq!(
319      application,
320      SourceMapApplication::LineAndColumn {
321        line_number: 1,
322        column_number: 17
323      }
324    );
325
326    let line = source_mapper.get_source_line("file:///a.ts", 1).unwrap();
327    assert_eq!(line, "fake source line");
328    // Get again to hit a cache
329    let line = source_mapper.get_source_line("file:///a.ts", 1).unwrap();
330    assert_eq!(line, "fake source line");
331  }
332
333  #[test]
334  fn test_source_map_relative_path_nonexistent_file() {
335    // This is important for npm packages that ship source maps pointing to
336    // source files that aren't distributed.
337    let mut loader = SourceMapLoader::default();
338    loader.map.insert(
339      Url::parse("file:///project/dist/bundle.js").unwrap(),
340      SourceMapLoaderContent {
341        // Source map with relative path "../src/index.ts" that doesn't exist
342        source_map: Some(ascii_str!(r#"{"version":3,"sources":["../src/index.ts"],"sourcesContent":["console.log('hello');\n"],"names":[],"mappings":"AAAA,QAAQ,IAAI"}"#).into()),
343      },
344    );
345
346    let mut source_mapper = SourceMapper::new(Rc::new(loader));
347
348    // The source file "../src/index.ts" resolved to "file:///project/src/index.ts"
349    // doesn't exist, so we should only get line/column mapping without file rename
350    let application =
351      source_mapper.apply_source_map("file:///project/dist/bundle.js", 1, 1);
352    assert_eq!(
353      application,
354      SourceMapApplication::LineAndColumn {
355        line_number: 1,
356        column_number: 1
357      }
358    );
359  }
360
361  #[test]
362  fn test_source_map_relative_path_existing_file() {
363    // Test that relative paths pointing to existing files DO rewrite the file name
364    let mut loader = SourceMapLoader::default();
365    loader.map.insert(
366      Url::parse("file:///project/dist/bundle.js").unwrap(),
367      SourceMapLoaderContent {
368        // Source map with relative path "../src/index.ts"
369        source_map: Some(ascii_str!(r#"{"version":3,"sources":["../src/index.ts"],"sourcesContent":["console.log('hello');\n"],"names":[],"mappings":"AAAA,QAAQ,IAAI"}"#).into()),
370      },
371    );
372    loader.add_existing_file("file:///project/src/index.ts");
373
374    let mut source_mapper = SourceMapper::new(Rc::new(loader));
375
376    let application =
377      source_mapper.apply_source_map("file:///project/dist/bundle.js", 1, 1);
378    assert_eq!(
379      application,
380      SourceMapApplication::LineAndColumnAndFileName {
381        file_name: "file:///project/src/index.ts".to_string(),
382        line_number: 1,
383        column_number: 1
384      }
385    );
386  }
387}