use std::fmt::Write;
use crate::SourceMap;
pub struct SourcemapVisualizer<'a, 'sm> {
code: &'a str,
sourcemap: &'a SourceMap<'sm>,
}
impl<'a, 'sm> SourcemapVisualizer<'a, 'sm> {
pub fn new(code: &'a str, sourcemap: &'a SourceMap<'sm>) -> Self {
Self { code, sourcemap }
}
pub fn get_url(&self) -> String {
let result = self.sourcemap.to_json_string();
let s = format!("{}\0{}{}\0{}", self.code.len(), self.code, result.len(), result);
let hash = base64_simd::STANDARD.encode_to_string(s);
format!("https://evanw.github.io/source-map-visualization/#{hash}")
}
pub fn get_text(&self) -> String {
let source_contents = &self.sourcemap.source_contents;
if source_contents.is_empty() {
return "[no source contents]\n".to_owned();
}
let source_contents_lines_map: Vec<Option<Vec<Vec<u16>>>> = source_contents
.iter()
.map(|content| content.as_deref().map(Self::generate_line_utf16_tables))
.collect();
let output_lines = Self::generate_line_utf16_tables(self.code);
let tokens = &self.sourcemap.tokens;
let mut s = String::new();
let mut last_source = None;
for (i, t) in tokens.iter().enumerate() {
let Some(source_id) = t.get_source_id() else {
continue;
};
let Some(source) = self.sourcemap.get_source(source_id) else { continue };
let Some(source_lines) =
source_contents_lines_map.get(source_id as usize).and_then(Option::as_ref)
else {
continue;
};
if last_source != Some(source) {
writeln!(s, "- {source}").unwrap();
last_source = Some(source);
}
let dst_invalid = t.dst_line as usize >= output_lines.len()
|| (t.dst_col as usize) >= output_lines[t.dst_line as usize].len();
let src_invalid = t.src_line as usize >= source_lines.len()
|| (t.src_col as usize) >= source_lines[t.src_line as usize].len();
if dst_invalid || src_invalid {
writeln!(
s,
"({}:{}){} --> ({}:{}){}",
t.src_line,
t.src_col,
if src_invalid { " [invalid]" } else { "" },
t.dst_line,
t.dst_col,
if dst_invalid { " [invalid]" } else { "" },
)
.unwrap();
continue;
}
let dst_end_col = match tokens.get(i + 1) {
Some(t2) if t2.dst_line == t.dst_line => t2.dst_col,
_ => output_lines[t.dst_line as usize].len() as u32,
};
let src_end_col = tokens[i + 1..]
.iter()
.take_while(|t2| t2.get_source_id() == Some(source_id) && t2.src_line == t.src_line)
.find(|t2| t2.src_col > t.src_col)
.map_or(source_lines[t.src_line as usize].len() as u32, |t2| t2.src_col);
writeln!(
s,
"({}:{}) {:?} --> ({}:{}) {:?}",
t.src_line,
t.src_col,
Self::str_slice_by_token(source_lines, t.src_line, t.src_col, src_end_col),
t.dst_line,
t.dst_col,
Self::str_slice_by_token(&output_lines, t.dst_line, t.dst_col, dst_end_col)
)
.unwrap();
}
s
}
fn generate_line_utf16_tables(content: &str) -> Vec<Vec<u16>> {
let mut tables = vec![];
let mut line_byte_offset = 0;
let bytes = content.as_bytes();
for (i, ch) in content.char_indices() {
if matches!(ch, '\r' | '\n' | '\u{2028}' | '\u{2029}') {
if ch == '\r' && bytes.get(i + 1) == Some(&b'\n') {
continue;
}
let end = i + ch.len_utf8();
tables.push(content[line_byte_offset..end].encode_utf16().collect());
line_byte_offset = end;
}
}
tables.push(content[line_byte_offset..].encode_utf16().collect());
tables
}
fn str_slice_by_token(buff: &[Vec<u16>], line: u32, start: u32, end: u32) -> String {
let start = start as usize;
let end = end as usize;
let s = &buff[line as usize];
String::from_utf16_lossy(&s[start.min(end).min(s.len())..start.max(end).min(s.len())])
.replace('\r', "")
}
}
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use super::*;
use crate::Token;
#[test]
fn get_url() {
let sm =
SourceMap::from_json_string(r#"{"version":3,"sources":[],"names":[],"mappings":""}"#)
.unwrap();
let url = SourcemapVisualizer::new("code", &sm).get_url();
assert!(url.starts_with("https://evanw.github.io/source-map-visualization/#"));
}
#[test]
fn no_source_contents() {
let sm = SourceMap::from_json_string(
r#"{"version":3,"sources":["a.js"],"names":[],"mappings":"AAAA"}"#,
)
.unwrap();
assert_eq!(SourcemapVisualizer::new("a", &sm).get_text(), "[no source contents]\n");
}
#[test]
fn skips_tokens_without_resolvable_source() {
let sm = SourceMap::new(
None,
vec![],
None,
vec![Cow::Borrowed("a.js"), Cow::Borrowed("b.js")],
vec![Some(Cow::Borrowed("hello\n")), None],
vec![
Token::new(0, 0, 0, 0, None, None),
Token::new(0, 1, 0, 0, Some(1), None),
Token::new(0, 2, 0, 0, Some(0), None),
]
.into_boxed_slice(),
None,
);
let text = SourcemapVisualizer::new("hello\n", &sm).get_text();
assert!(text.contains("- a.js"), "{text}");
assert!(!text.contains("- b.js"), "{text}");
}
#[test]
fn handles_crlf_line_endings() {
let sm = SourceMap::new(
None,
vec![],
None,
vec![Cow::Borrowed("a.js")],
vec![Some(Cow::Borrowed("aa\r\nbb\r\n"))],
vec![Token::new(0, 0, 0, 0, Some(0), None), Token::new(1, 0, 1, 0, Some(0), None)]
.into_boxed_slice(),
None,
);
let text = SourcemapVisualizer::new("aa\r\nbb\r\n", &sm).get_text();
assert!(text.contains("- a.js"), "{text}");
}
#[test]
fn handles_unicode_line_separators() {
for separator in ['\u{2028}', '\u{2029}'] {
let code = format!("é{separator}😀");
let sm = SourceMap::new(
None,
vec![],
None,
vec!["a.js".into()],
vec![Some(code.as_str().into())],
vec![Token::new(0, 0, 0, 0, Some(0), None), Token::new(1, 0, 1, 0, Some(0), None)]
.into_boxed_slice(),
None,
);
let first_line = format!("é{separator}");
assert_eq!(
SourcemapVisualizer::new(&code, &sm).get_text(),
format!(
"- a.js\n(0:0) {first_line:?} --> (0:0) {first_line:?}\n(1:0) \"😀\" --> (1:0) \"😀\"\n"
)
);
}
}
#[test]
fn handles_columns_inside_surrogate_pairs() {
let sm = SourceMap::new(
None,
vec![],
None,
vec!["a.js".into()],
vec![Some("😀x".into())],
vec![Token::new(0, 0, 0, 0, Some(0), None), Token::new(0, 1, 0, 1, Some(0), None)]
.into_boxed_slice(),
None,
);
assert_eq!(
SourcemapVisualizer::new("😀x", &sm).get_text(),
"- a.js\n(0:0) \"�\" --> (0:0) \"�\"\n(0:1) \"�x\" --> (0:1) \"�x\"\n"
);
}
#[test]
fn skips_token_with_out_of_range_source() {
let sm = SourceMap::new(
None,
vec![],
None,
vec![Cow::Borrowed("a.js")],
vec![Some(Cow::Borrowed("aa\n"))],
vec![Token::new(0, 0, 0, 0, Some(5), None)].into_boxed_slice(),
None,
);
assert_eq!(SourcemapVisualizer::new("aa\n", &sm).get_text(), "");
}
}