rspack_sources 0.1.17

Rusty webpack-sources port.
Documentation
use std::{
  borrow::Cow,
  hash::{Hash, Hasher},
};

use crate::{
  helpers::{
    get_generated_source_info, get_map, split_into_lines,
    split_into_potential_tokens, GeneratedInfo, OnChunk, OnName, OnSource,
    StreamChunks,
  },
  source::{Mapping, OriginalLocation},
  MapOptions, Source, SourceMap,
};

/// Represents source code, it will create source map for the source code,
/// but the source map is created by splitting the source code at typical
/// statement borders (`;`, `{`, `}`).
///
/// - [webpack-sources docs](https://github.com/webpack/webpack-sources/#originalsource).
///
/// ```
/// use rspack_sources::{OriginalSource, MapOptions, Source};
///
/// let input = "if (hello()) { world(); hi(); there(); } done();\nif (hello()) { world(); hi(); there(); } done();";
/// let source = OriginalSource::new(input, "file.js");
/// assert_eq!(source.source(), input);
/// assert_eq!(
///   source.map(&MapOptions::default()).unwrap().mappings(),
///   "AAAA,eAAe,SAAS,MAAM,WAAW;AACzC,eAAe,SAAS,MAAM,WAAW",
/// );
/// assert_eq!(
///   source.map(&MapOptions::new(false)).unwrap().mappings(),
///   "AAAA;AACA",
/// );
/// ```
#[derive(Debug, Clone, Eq)]
pub struct OriginalSource {
  value: String,
  name: String,
}

impl OriginalSource {
  /// Create a [OriginalSource].
  pub fn new(value: impl Into<String>, name: impl Into<String>) -> Self {
    Self {
      value: value.into(),
      name: name.into(),
    }
  }
}

impl Source for OriginalSource {
  fn source(&self) -> Cow<str> {
    Cow::Borrowed(&self.value)
  }

  fn buffer(&self) -> Cow<[u8]> {
    Cow::Borrowed(self.value.as_bytes())
  }

  fn size(&self) -> usize {
    self.value.len()
  }

  fn map(&self, options: &MapOptions) -> Option<SourceMap> {
    get_map(self, options)
  }

  fn to_writer(&self, writer: &mut dyn std::io::Write) -> std::io::Result<()> {
    writer.write_all(self.value.as_bytes())
  }
}

impl Hash for OriginalSource {
  fn hash<H: Hasher>(&self, state: &mut H) {
    "OriginalSource".hash(state);
    self.buffer().hash(state);
    self.name.hash(state);
  }
}

impl PartialEq for OriginalSource {
  fn eq(&self, other: &Self) -> bool {
    self.value == other.value && self.name == other.name
  }
}

impl StreamChunks for OriginalSource {
  fn stream_chunks(
    &self,
    options: &MapOptions,
    on_chunk: OnChunk,
    on_source: OnSource,
    _on_name: OnName,
  ) -> crate::helpers::GeneratedInfo {
    on_source(0, &self.name, Some(&self.value));
    if options.columns {
      // With column info we need to read all lines and split them
      let mut line = 1;
      let mut column = 0;
      for token in split_into_potential_tokens(&self.value) {
        let is_end_of_line = token.ends_with('\n');
        if is_end_of_line && token.len() == 1 {
          if !options.final_source {
            on_chunk(
              Some(token),
              Mapping {
                generated_line: line,
                generated_column: column,
                original: None,
              },
            );
          }
        } else {
          on_chunk(
            (!options.final_source).then_some(token),
            Mapping {
              generated_line: line,
              generated_column: column,
              original: Some(OriginalLocation {
                source_index: 0,
                original_line: line,
                original_column: column,
                name_index: None,
              }),
            },
          );
        }
        if is_end_of_line {
          line += 1;
          column = 0;
        } else {
          column += token.len() as u32;
        }
      }
      GeneratedInfo {
        generated_line: line,
        generated_column: column,
      }
    } else if options.final_source {
      // Without column info and with final source we only
      // need meta info to generate mapping
      let result = get_generated_source_info(&self.value);
      if result.generated_column == 0 {
        for line in 1..result.generated_line {
          on_chunk(
            None,
            Mapping {
              generated_line: line,
              generated_column: 0,
              original: Some(OriginalLocation {
                source_index: 0,
                original_line: line,
                original_column: 0,
                name_index: None,
              }),
            },
          );
        }
      } else {
        for line in 1..=result.generated_line {
          on_chunk(
            None,
            Mapping {
              generated_line: line,
              generated_column: 0,
              original: Some(OriginalLocation {
                source_index: 0,
                original_line: line,
                original_column: 0,
                name_index: None,
              }),
            },
          );
        }
      }
      result
    } else {
      // Without column info, but also without final source
      // we need to split source by lines
      let mut line = 1;
      let mut last_line = None;
      for l in split_into_lines(&self.value) {
        on_chunk(
          (!options.final_source).then_some(l),
          Mapping {
            generated_line: line,
            generated_column: 0,
            original: Some(OriginalLocation {
              source_index: 0,
              original_line: line,
              original_column: 0,
              name_index: None,
            }),
          },
        );
        line += 1;
        last_line = Some(l);
      }
      if let Some(last_line) = last_line && !last_line.ends_with('\n') {
        GeneratedInfo {
          generated_line: line,
          generated_column: last_line.len() as u32,
        }
      } else {
        GeneratedInfo {
          generated_line: line + 1,
          generated_column: 0,
        }
      }
    }
  }
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn should_handle_multiline_string() {
    let source = OriginalSource::new("Line1\n\nLine3\n", "file.js");
    let result_text = source.source();
    let result_map = source.map(&MapOptions::default()).unwrap();
    let result_list_map = source.map(&MapOptions::new(false)).unwrap();

    assert_eq!(result_text, "Line1\n\nLine3\n");
    assert_eq!(result_map.sources(), &["file.js".to_string()]);
    assert_eq!(result_list_map.sources(), ["file.js".to_string()]);
    assert_eq!(
      result_map.sources_content(),
      ["Line1\n\nLine3\n".to_string()],
    );
    assert_eq!(
      result_list_map.sources_content(),
      ["Line1\n\nLine3\n".to_string()],
    );
    assert_eq!(result_map.mappings(), "AAAA;;AAEA");
    assert_eq!(result_list_map.mappings(), "AAAA;AACA;AACA");
  }

  #[test]
  fn should_handle_empty_string() {
    let source = OriginalSource::new("", "file.js");
    let result_text = source.source();
    let result_map = source.map(&MapOptions::default());
    let result_list_map = source.map(&MapOptions::new(false));

    assert_eq!(result_text, "");
    assert!(result_map.is_none());
    assert!(result_list_map.is_none());
  }

  #[test]
  fn should_omit_mappings_for_columns_with_node() {
    let source = OriginalSource::new("Line1\n\nLine3\n", "file.js");
    let result_map = source.map(&MapOptions::new(false)).unwrap();
    assert_eq!(result_map.mappings(), "AAAA;AACA;AACA");
  }

  #[test]
  fn should_return_the_correct_size_for_binary_files() {
    let source = OriginalSource::new(
      String::from_utf8(vec![0; 256]).unwrap(),
      "file.wasm",
    );
    assert_eq!(source.size(), 256);
  }

  #[test]
  fn should_return_the_correct_size_for_unicode_files() {
    let source = OriginalSource::new("😋", "file.js");
    assert_eq!(source.size(), 4);
  }

  #[test]
  fn should_split_code_into_statements() {
    let input = "if (hello()) { world(); hi(); there(); } done();\nif (hello()) { world(); hi(); there(); } done();";
    let source = OriginalSource::new(input, "file.js");
    assert_eq!(source.source(), input);
    assert_eq!(
      source.map(&MapOptions::default()).unwrap().mappings(),
      "AAAA,eAAe,SAAS,MAAM,WAAW;AACzC,eAAe,SAAS,MAAM,WAAW",
    );
    assert_eq!(
      source.map(&MapOptions::new(false)).unwrap().mappings(),
      "AAAA;AACA",
    );
  }
}