Skip to main content

rspack_core/utils/
extract_source_map.rs

1/*
2  MIT License http://www.opensource.org/licenses/mit-license.php
3  Author Natsu @xiaoxiaojx
4*/
5
6use std::{borrow::Cow, path::PathBuf, sync::Arc};
7
8use cow_utils::CowUtils;
9use futures::stream::{FuturesOrdered, StreamExt};
10use once_cell::sync::Lazy;
11use regex::Regex;
12use rspack_fs::ReadableFileSystem;
13use rspack_paths::{AssertUtf8, Utf8Path, Utf8PathBuf};
14use rspack_sources::SourceMap;
15use rspack_util::{base64, node_path::NodePath};
16use rustc_hash::FxHashSet;
17
18/// Source map extractor result
19#[derive(Debug)]
20pub struct ExtractSourceMapResult {
21  pub source: String,
22  pub source_map: Option<SourceMap<'static>>,
23  pub file_dependencies: Option<FxHashSet<PathBuf>>,
24}
25
26/// Source mapping URL information
27#[derive(Debug)]
28pub struct SourceMappingURL {
29  pub source_mapping_url: String,
30  pub replacement_string: String,
31}
32
33static VALID_PROTOCOL_PATTERN: Lazy<Regex> =
34  Lazy::new(|| Regex::new(r"^[a-z][a-z0-9+.-]*:").expect("Invalid regex pattern"));
35static SOURCE_MAPPING_URL_REGEX: Lazy<Regex> = Lazy::new(|| {
36  Regex::new(r#"(?:/\*(?:\s*\r?\n(?://)?)?(?:\s*[#@]\s*sourceMappingURL\s*=\s*([^\s'"]*)\s*)\s*\*/|//(?:\s*[#@]\s*sourceMappingURL\s*=\s*([^\s'"]*)\s*))\s*"#).expect("Invalid regex pattern")
37});
38static URI_REGEX: Lazy<Regex> = Lazy::new(|| {
39  Regex::new(r"^data:([^;,]+)?((?:;[^;,]+)*?)(?:;(base64)?)?,(.*)$").expect("Invalid regex pattern")
40});
41
42/// Extract source mapping URL from code comments
43pub fn get_source_mapping_url(code: &str) -> SourceMappingURL {
44  // Use captures_iter to find the last match, avoiding split and collect overhead
45  let mut match_result = None;
46  let mut replacement_string = String::new();
47
48  // Find the last match from the end
49  if let Some(captures) = SOURCE_MAPPING_URL_REGEX.captures_iter(code).last() {
50    match_result = captures
51      .get(1)
52      .or_else(|| captures.get(2))
53      .map(|m| m.as_str());
54
55    // Get the complete match string for replacement
56    replacement_string = captures.get(0).map_or("", |m| m.as_str()).to_string();
57  }
58
59  let source_mapping_url = match_result.unwrap_or("").to_string();
60
61  SourceMappingURL {
62    source_mapping_url: if !source_mapping_url.is_empty() {
63      urlencoding::decode(&source_mapping_url)
64        .unwrap_or(Cow::Borrowed(&source_mapping_url))
65        .to_string()
66    } else {
67      source_mapping_url
68    },
69    replacement_string,
70  }
71}
72
73/// Check if value is a URL
74fn is_url(value: &str) -> bool {
75  VALID_PROTOCOL_PATTERN.is_match(value) && !Utf8Path::new(value).node_is_absolute_win32()
76}
77
78fn is_absolute(path: &Utf8Path) -> bool {
79  path.node_is_absolute_posix() || path.node_is_absolute_win32()
80}
81
82/// Decode data URI
83fn decode_data_uri(uri: &str) -> Option<String> {
84  // data URL scheme: "data:text/javascript;charset=utf-8;base64,some-string"
85  // http://www.ietf.org/rfc/rfc2397.txt
86  let captures = URI_REGEX.captures(uri)?;
87  let is_base64 = captures.get(3).is_some();
88  let body = captures.get(4)?.as_str();
89
90  if is_base64 {
91    return base64::decode_to_vec(body)
92      .ok()
93      .and_then(|bytes| String::from_utf8(bytes).ok());
94  }
95
96  // CSS allows to use `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" style="stroke: rgb(223,224,225); stroke-width: 2px; fill: none; stroke-dasharray: 6px 3px" /></svg>`
97  // so we return original body if we can't `decodeURIComponent`
98  match urlencoding::decode(body) {
99    Ok(decoded) => Some(decoded.to_string()),
100    Err(_) => Some(body.to_string()),
101  }
102}
103
104/// Fetch source content from data URL
105fn fetch_from_data_url(source_url: &str) -> Result<String, String> {
106  if let Some(content) = decode_data_uri(source_url) {
107    Ok(content)
108  } else {
109    Err(format!(
110      "Failed to parse source map from \"data\" URL: {source_url}"
111    ))
112  }
113}
114
115/// Get absolute path for source file using Node.js logic
116fn get_absolute_path(context: &Utf8Path, request: &str, source_root: Option<&str>) -> Utf8PathBuf {
117  let path = if let Some(source_root) = source_root {
118    let source_root_path = Utf8Path::new(source_root);
119    if is_absolute(source_root_path) {
120      source_root_path.join(request)
121    } else {
122      context.join(source_root).join(request)
123    }
124  } else {
125    context.join(request)
126  };
127  path.node_normalize()
128}
129
130/// Fetch source content from file system
131async fn fetch_from_filesystem(
132  fs: &Arc<dyn ReadableFileSystem>,
133  source_url: &str,
134) -> Result<(String, Option<String>), String> {
135  if is_url(source_url) {
136    return Ok((source_url.to_string(), None));
137  }
138
139  let path = PathBuf::from(source_url);
140  fs.read_to_string(&path.assert_utf8())
141    .await
142    .map(|content| (source_url.to_string(), Some(content)))
143    .map_err(|err| format!("Failed to parse source map from '{source_url}' file: {err}"))
144}
145
146/// Fetch from multiple possible file paths
147async fn fetch_paths_from_filesystem(
148  fs: &Arc<dyn ReadableFileSystem>,
149  possible_requests: &[String],
150  mut errors_accumulator: String,
151) -> Result<(String, Option<String>), String> {
152  if possible_requests.is_empty() {
153    return Err(errors_accumulator);
154  }
155
156  // Use iteration instead of recursion to avoid Box::pin and dynamic dispatch overhead
157  for (i, request) in possible_requests.iter().enumerate() {
158    match fetch_from_filesystem(fs, request).await {
159      Ok(result) => return Ok(result),
160      Err(error) => {
161        if i > 0 {
162          errors_accumulator.push_str("\n\n");
163        }
164        errors_accumulator.push_str(&error);
165      }
166    }
167  }
168
169  Err(errors_accumulator)
170}
171
172/// Fetch source content from URL
173async fn fetch_from_url(
174  fs: &Arc<dyn ReadableFileSystem>,
175  context: &Utf8Path,
176  url: &str,
177  source_root: Option<&str>,
178  skip_reading: bool,
179) -> Result<(String, Option<String>), String> {
180  // 1. It's an absolute url and it is not `windows` path like `C:\dir\file`
181  if is_url(url) {
182    if url.starts_with("data:") {
183      if skip_reading {
184        return Ok((String::new(), None));
185      }
186
187      let source_content = fetch_from_data_url(url)?;
188      return Ok((String::new(), Some(source_content)));
189    }
190
191    if skip_reading {
192      return Ok((url.to_string(), None));
193    }
194
195    if url.starts_with("file:") {
196      // Handle file:// URLs
197      let path_from_url = url.strip_prefix("file://").unwrap_or(url);
198      let source_url = PathBuf::from(path_from_url).to_string_lossy().into_owned();
199      return fetch_from_filesystem(fs, &source_url).await;
200    }
201
202    return Err(format!(
203      "Failed to parse source map: '{url}' URL is not supported"
204    ));
205  }
206
207  // 2. It's a scheme-relative
208  if url.starts_with("//") {
209    return Err(format!(
210      "Failed to parse source map: '{url}' URL is not supported"
211    ));
212  }
213
214  // 3. Absolute path
215  if is_absolute(Utf8Path::new(url)) {
216    let source_url = Utf8Path::new(url).node_normalize().to_string();
217
218    if !skip_reading {
219      let mut possible_requests = Vec::with_capacity(2);
220      possible_requests.push(source_url.clone());
221
222      if let Some(stripped) = url.strip_prefix('/') {
223        let absolute_path = get_absolute_path(context, stripped, source_root);
224        possible_requests.push(absolute_path.to_string());
225      }
226
227      return fetch_paths_from_filesystem(fs, &possible_requests, String::new()).await;
228    }
229
230    return Ok((source_url, None));
231  }
232
233  // 4. Relative path
234  let source_url = get_absolute_path(context, url, source_root);
235  let source_url_str = source_url.to_string();
236
237  if !skip_reading {
238    let (_, content) = fetch_from_filesystem(fs, &source_url_str).await?;
239    return Ok((source_url_str, content));
240  }
241
242  Ok((source_url_str, None))
243}
244
245/// Extract source map from code content
246pub async fn extract_source_map(
247  fs: Arc<dyn ReadableFileSystem>,
248  input: &str,
249  resource_path: &str,
250) -> Result<ExtractSourceMapResult, String> {
251  let SourceMappingURL {
252    source_mapping_url,
253    replacement_string,
254  } = get_source_mapping_url(input);
255
256  if source_mapping_url.is_empty() {
257    return Ok(ExtractSourceMapResult {
258      source: input.to_string(),
259      source_map: None,
260      file_dependencies: None,
261    });
262  }
263
264  let base_context = Utf8Path::new(resource_path)
265    .parent()
266    .ok_or_else(|| "Invalid resource path".to_string())?;
267
268  let (source_url, source_content) =
269    fetch_from_url(&fs, base_context, &source_mapping_url, None, false).await?;
270
271  let content = match source_content.as_deref() {
272    Some(c) => c.trim_start_matches(")]}'"),
273    None => {
274      return Ok(ExtractSourceMapResult {
275        source: input.to_string(),
276        source_map: None,
277        file_dependencies: if source_url.is_empty() {
278          None
279        } else {
280          let mut set = FxHashSet::default();
281          set.insert(PathBuf::from(source_url));
282          Some(set)
283        },
284      });
285    }
286  };
287
288  // Create SourceMap directly from JSON
289  let mut source_map = SourceMap::from_json(content.to_string())
290    .map_err(|e| format!("Failed to parse source map: {e}"))?;
291
292  let context = if !source_url.is_empty() {
293    Utf8Path::new(&source_url).parent().unwrap_or(base_context)
294  } else {
295    base_context
296  };
297
298  let mut resolved_sources = Vec::new();
299  let mut file_dependencies = if source_url.is_empty() {
300    None
301  } else {
302    let mut set = FxHashSet::default();
303    set.insert(PathBuf::from(&source_url));
304    Some(set)
305  };
306
307  // Get sources from SourceMap and take ownership
308  let sources = source_map
309    .sources()
310    .iter()
311    .map(|source| source.to_string())
312    .collect::<Vec<_>>();
313  let source_root = source_map.source_root().map(|s| s.to_string());
314
315  // Pre-collect all source content to avoid borrowing issues
316  let source_contents: Vec<Option<String>> = (0..sources.len())
317    .map(|i| source_map.get_source_content(i).map(|s| s.to_string()))
318    .collect();
319
320  // Process sources in parallel using FuturesOrdered to maintain order
321  let mut futures = FuturesOrdered::new();
322
323  // Use zip to consume both vectors without extra cloning
324  for (source, original_content) in sources.into_iter().zip(source_contents) {
325    let skip_reading = original_content.is_some();
326    let source_root = source_root.clone();
327    let context = context.to_path_buf();
328
329    let fs = fs.clone();
330    futures.push_back(async move {
331      let result =
332        fetch_from_url(&fs, &context, &source, source_root.as_deref(), skip_reading).await;
333      (original_content, skip_reading, result)
334    });
335  }
336
337  // Collect results in order
338  while let Some((original_content, skip_reading, result)) = futures.next().await {
339    let (source_url_result, source_content_result) = result?;
340
341    let final_content = if skip_reading {
342      original_content
343    } else {
344      source_content_result
345    };
346
347    if !skip_reading && !source_url_result.is_empty() && !is_url(&source_url_result) {
348      if let Some(ref mut deps) = file_dependencies {
349        deps.insert(PathBuf::from(&source_url_result));
350      } else {
351        let mut set = FxHashSet::default();
352        set.insert(PathBuf::from(&source_url_result));
353        file_dependencies = Some(set);
354      }
355    }
356
357    resolved_sources.push((source_url_result, final_content));
358  }
359
360  // Build the final SourceMap using setter methods - consume resolved_sources to avoid cloning
361  let (sources_vec, sources_content_vec): (Vec<String>, Vec<Cow<'_, str>>) = resolved_sources
362    .into_iter()
363    .map(|(url, content)| (url, Cow::Owned(content.unwrap_or_default())))
364    .unzip();
365
366  source_map.set_sources(sources_vec);
367  source_map.set_sources_content(sources_content_vec);
368
369  // Remove source_root as per original logic
370  source_map.set_source_root(None);
371
372  // Optimize string replacement to avoid unnecessary cloning
373  let new_source = if replacement_string.is_empty() {
374    input.to_string()
375  } else {
376    input.cow_replace(&replacement_string, "").into_owned()
377  };
378
379  Ok(ExtractSourceMapResult {
380    source: new_source,
381    source_map: Some(source_map),
382    file_dependencies,
383  })
384}
385
386#[cfg(test)]
387mod tests {
388  use super::*;
389
390  #[test]
391  fn test_get_source_mapping_url() {
392    // Test cases based on expected results from extractSourceMap.unittest.js.snap
393    let test_cases = vec![
394      (
395        "/*#sourceMappingURL=absolute-sourceRoot-source-map.map*/",
396        "absolute-sourceRoot-source-map.map",
397      ),
398      (
399        "/*  #sourceMappingURL=absolute-sourceRoot-source-map.map  */",
400        "absolute-sourceRoot-source-map.map",
401      ),
402      (
403        "//#sourceMappingURL=absolute-sourceRoot-source-map.map",
404        "absolute-sourceRoot-source-map.map",
405      ),
406      (
407        "//@sourceMappingURL=absolute-sourceRoot-source-map.map",
408        "absolute-sourceRoot-source-map.map",
409      ),
410      (
411        " //  #sourceMappingURL=absolute-sourceRoot-source-map.map",
412        "absolute-sourceRoot-source-map.map",
413      ),
414      (
415        " //  #  sourceMappingURL  =   absolute-sourceRoot-source-map.map  ",
416        "absolute-sourceRoot-source-map.map",
417      ),
418      (
419        "// #sourceMappingURL = http://hello.com/external-source-map2.map",
420        "http://hello.com/external-source-map2.map",
421      ),
422      (
423        "// #sourceMappingURL = //hello.com/external-source-map2.map",
424        "//hello.com/external-source-map2.map",
425      ),
426      (
427        "// @sourceMappingURL=data:application/source-map;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5saW5lLXNvdXJjZS1tYXAuanMiLCJzb3VyY2VzIjpbImlubGluZS1zb3VyY2UtbWFwLnR4dCJdLCJzb3VyY2VzQ29udGVudCI6WyJ3aXRoIFNvdXJjZU1hcCJdLCJtYXBwaW5ncyI6IkFBQUEifQ==",
428        "data:application/source-map;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5saW5lLXNvdXJjZS1tYXAuanMiLCJzb3VyY2VzIjpbImlubGluZS1zb3VyY2UtbWFwLnR4dCJdLCJzb3VyY2VzQ29udGVudCI6WyJ3aXRoIFNvdXJjZU1hcCJdLCJtYXBwaW5ncyI6IkFBQUEifQ==",
429      ),
430      (
431        r#"
432        with SourceMap
433
434        // #sourceMappingURL = /sample-source-map.map
435        // comment
436        "#,
437        "/sample-source-map.map",
438      ),
439      (
440        r#"
441        with SourceMap
442        // #sourceMappingURL = /sample-source-map-1.map
443        // #sourceMappingURL = /sample-source-map-2.map
444        // #sourceMappingURL = /sample-source-map-last.map
445        // comment
446        "#,
447        "/sample-source-map-last.map",
448      ),
449      // JavaScript code snippet with variable reference, expected to return empty string
450      (
451        r#""
452        /*# sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap))))+" */";"#,
453        "",
454      ),
455      // JavaScript code snippet, expected to truncate at first variable reference
456      (
457        r#"// # sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap))))+"'"#,
458        "data:application/json;base64,",
459      ),
460      // JavaScript code snippet with variable reference, expected to return empty string
461      (
462        r#"anInvalidDirective = "
463/*# sourceMappingURL=data:application/json;base64,"+btoa(unescape(encodeURIComponent(JSON.stringify(sourceMap))))+" */";"#,
464        "",
465      ),
466    ];
467
468    for (input, expected) in test_cases {
469      let result = get_source_mapping_url(input);
470      assert_eq!(
471        result.source_mapping_url, expected,
472        "Failed for input: {input}"
473      );
474    }
475  }
476
477  #[test]
478  fn test_get_source_mapping_url_empty_cases() {
479    // Test cases without sourceMappingURL
480    let cases = vec![
481      "const foo = 'bar';",
482      "// This is a regular comment",
483      "/* Multi-line\n   comment\n   without sourceMappingURL */",
484      "",
485    ];
486
487    for case in cases {
488      let result = get_source_mapping_url(case);
489      assert!(result.source_mapping_url.is_empty());
490      assert!(result.replacement_string.is_empty());
491    }
492  }
493  #[tokio::test]
494  async fn test_extract_source_map_normalization() {
495    let context = Utf8Path::new("/context");
496
497    // Test get_absolute_path normalization
498    let path = get_absolute_path(context, "../foo.js", None);
499    assert_eq!(path.as_str(), "/foo.js");
500
501    let path = get_absolute_path(context, "a/../../b/c.js", None);
502    assert_eq!(path.as_str(), "/b/c.js");
503
504    // Test fetch_from_url normalization for absolute paths
505    let fs: Arc<dyn ReadableFileSystem> = Arc::new(rspack_fs::MemoryFileSystem::default());
506
507    let (path, _) = fetch_from_url(&fs, context, "/a/b/../c.js", None, true)
508      .await
509      .unwrap();
510    assert_eq!(path, "/a/c.js");
511  }
512}