#![allow(
clippy::print_stdout,
clippy::print_stderr,
reason = "CLI commands intentionally write user-facing output"
)]
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use srcmap_codec::{decode, encode};
use srcmap_remapping::{ConcatBuilder, remap};
use srcmap_sourcemap::utils::resolve_source_map_url;
use srcmap_sourcemap::{Bias, SourceMap, SourceMappingUrl, parse_source_mapping_url};
#[derive(Parser)]
#[command(
name = "srcmap",
about = "Inspect, validate, compose, and manipulate source maps",
version
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Info {
file: PathBuf,
#[arg(long)]
json: bool,
},
Validate {
file: PathBuf,
#[arg(long)]
json: bool,
},
Lookup {
file: PathBuf,
line: u32,
column: u32,
#[arg(long, default_value = "glb")]
bias: String,
#[arg(long, default_value = "0")]
context: u32,
#[arg(long)]
json: bool,
},
Resolve {
file: PathBuf,
#[arg(long)]
source: String,
line: u32,
column: u32,
#[arg(long, default_value = "lub")]
bias: String,
#[arg(long)]
json: bool,
},
Decode {
mappings: Option<String>,
#[arg(long)]
compact: bool,
},
Encode {
file: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Mappings {
file: PathBuf,
#[arg(long)]
source: Option<String>,
#[arg(long, default_value = "50")]
limit: usize,
#[arg(long, default_value = "0")]
offset: usize,
#[arg(long)]
json: bool,
},
Concat {
files: Vec<PathBuf>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
file_name: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
dry_run: bool,
},
Remap {
file: PathBuf,
#[arg(long)]
dir: Option<PathBuf>,
#[arg(long = "upstream", value_name = "SOURCE=PATH")]
upstreams: Vec<String>,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(long)]
dry_run: bool,
},
Symbolicate {
file: PathBuf,
#[arg(long)]
dir: Option<PathBuf>,
#[arg(long = "map", value_name = "SOURCE=PATH")]
maps: Vec<String>,
#[arg(long)]
json: bool,
},
Scopes {
file: PathBuf,
#[arg(long)]
json: bool,
},
Fetch {
url: String,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Sources {
file: PathBuf,
#[arg(long)]
extract: bool,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Schema,
}
struct CliError {
message: String,
code: &'static str,
}
impl CliError {
fn new(code: &'static str, message: impl Into<String>) -> Self {
Self { message: message.into(), code }
}
fn io(message: impl Into<String>) -> Self {
Self::new("IO_ERROR", message)
}
fn parse(message: impl Into<String>) -> Self {
Self::new("PARSE_ERROR", message)
}
fn not_found(message: impl Into<String>) -> Self {
Self::new("NOT_FOUND", message)
}
fn validation(message: impl Into<String>) -> Self {
Self::new("VALIDATION_ERROR", message)
}
fn path_traversal(message: impl Into<String>) -> Self {
Self::new("PATH_TRAVERSAL", message)
}
fn invalid_input(message: impl Into<String>) -> Self {
Self::new("INVALID_INPUT", message)
}
fn fetch_error(message: impl Into<String>) -> Self {
Self::new("FETCH_ERROR", message)
}
fn to_json(&self) -> String {
serde_json::to_string_pretty(&serde_json::json!({
"error": self.message,
"code": self.code,
}))
.unwrap()
}
}
fn reject_control_chars(input: &str, label: &str) -> Result<(), CliError> {
for (i, byte) in input.bytes().enumerate() {
if byte < 0x20 && byte != b'\n' && byte != b'\r' && byte != b'\t' {
return Err(CliError::invalid_input(format!(
"{label} contains control character 0x{byte:02x} at offset {i}"
)));
}
}
Ok(())
}
fn validate_safe_path(path: &Path, sandbox: &Path) -> Result<PathBuf, CliError> {
let canonical = path
.canonicalize()
.map_err(|e| CliError::io(format!("failed to resolve path {}: {e}", path.display())))?;
let sandbox_canonical = sandbox.canonicalize().map_err(|e| {
CliError::io(format!("failed to resolve sandbox {}: {e}", sandbox.display()))
})?;
if !canonical.starts_with(&sandbox_canonical) {
return Err(CliError::path_traversal(format!(
"path {} escapes sandbox directory {}",
path.display(),
sandbox.display()
)));
}
Ok(canonical)
}
fn validate_output_path(path: &Path) -> Result<(), CliError> {
let cwd = std::env::current_dir().map_err(|e| CliError::io(format!("cannot get cwd: {e}")))?;
if let Some(parent) = path.parent() {
if parent.as_os_str().is_empty() {
return Ok(());
}
let parent_canonical = parent.canonicalize().map_err(|e| {
CliError::io(format!(
"output parent directory {} does not exist: {e}",
parent.display()
))
})?;
let cwd_canonical =
cwd.canonicalize().map_err(|e| CliError::io(format!("failed to resolve cwd: {e}")))?;
if !parent_canonical.starts_with(&cwd_canonical) {
return Err(CliError::path_traversal(format!(
"output path {} escapes current working directory",
path.display()
)));
}
}
Ok(())
}
fn validate_source_name(source: &str) -> Result<(), CliError> {
reject_control_chars(source, "source name")?;
if source.contains("..") {
return Err(CliError::path_traversal(format!(
"source name contains path traversal: {source}"
)));
}
if source.contains('?') || source.contains('#') {
return Err(CliError::invalid_input(format!(
"source name contains invalid characters (? or #): {source}"
)));
}
if source.contains('%') {
return Err(CliError::invalid_input(format!(
"source name contains percent-encoding: {source}"
)));
}
Ok(())
}
fn read_file_or_stdin(path: &PathBuf) -> Result<String, CliError> {
if path.as_os_str() == "-" {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::io(format!("failed to read stdin: {e}")))?;
Ok(buf)
} else {
fs::read_to_string(path)
.map_err(|e| CliError::io(format!("failed to read {}: {e}", path.display())))
}
}
fn parse_source_map(path: &PathBuf) -> Result<(SourceMap, String), CliError> {
let json = read_file_or_stdin(path)?;
let sm = SourceMap::from_json(&json)
.map_err(|e| CliError::parse(format!("invalid source map: {e}")))?;
Ok((sm, json))
}
fn write_output(output: &Option<PathBuf>, content: &str) -> Result<(), CliError> {
match output {
Some(path) => {
validate_output_path(path)?;
fs::write(path, content)
.map_err(|e| CliError::io(format!("failed to write {}: {e}", path.display())))
}
None => {
println!("{content}");
Ok(())
}
}
}
fn http_get(url: &str) -> Result<String, CliError> {
let response = ureq::get(url)
.set("User-Agent", concat!("srcmap-cli/", env!("CARGO_PKG_VERSION")))
.call()
.map_err(|e| CliError::fetch_error(format!("failed to fetch {url}: {e}")))?;
let status = response.status();
if status != 200 {
return Err(CliError::fetch_error(format!("HTTP {status} for {url}")));
}
response
.into_string()
.map_err(|e| CliError::fetch_error(format!("failed to read response body from {url}: {e}")))
}
fn url_filename(url: &str) -> String {
let path = url.split('?').next().unwrap_or(url);
let path = path.split('#').next().unwrap_or(path);
let name = path.rsplit('/').next().unwrap_or("bundle.js");
if name.is_empty() { "bundle.js".to_string() } else { name.to_string() }
}
fn sanitize_source_path(source: &str) -> Result<PathBuf, CliError> {
let stripped = source
.strip_prefix("webpack:///")
.or_else(|| source.strip_prefix("webpack://"))
.or_else(|| source.strip_prefix("file:///"))
.or_else(|| source.strip_prefix("file://"))
.unwrap_or(source);
let stripped = stripped.trim_start_matches('/');
if stripped.is_empty() {
return Err(CliError::invalid_input("empty source name after stripping prefixes"));
}
let mut components = Vec::new();
for component in Path::new(stripped).components() {
match component {
std::path::Component::ParentDir => {
if !components.is_empty() {
components.pop();
}
}
std::path::Component::CurDir => {}
_ => {
components.push(component);
}
}
}
if components.is_empty() {
return Err(CliError::invalid_input(format!(
"source name resolves to empty path: {source}"
)));
}
let path: PathBuf = components.iter().collect();
Ok(path)
}
fn format_size(bytes: usize) -> String {
if bytes < 1024 {
format!("{bytes} B")
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
fn cmd_info(file: &PathBuf, json: bool) -> Result<(), CliError> {
let (sm, raw) = parse_source_map(file)?;
if json {
let has_content = sm.sources_content.iter().filter(|c| c.is_some()).count();
let content_size: usize =
sm.sources_content.iter().filter_map(|c| c.as_ref()).map(|s| s.len()).sum();
let obj = serde_json::json!({
"file": sm.file,
"sourceRoot": sm.source_root,
"sources": sm.sources.len(),
"names": sm.names.len(),
"mappings": sm.mapping_count(),
"rangeMappings": sm.range_mapping_count(),
"lines": sm.line_count(),
"sourcesWithContent": has_content,
"totalContentSize": content_size,
"fileSize": raw.len(),
"ignoreList": sm.ignore_list,
"debugId": sm.debug_id,
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
if let Some(ref f) = sm.file {
println!("File: {f}");
}
if let Some(ref root) = sm.source_root {
println!("Source root: {root}");
}
println!("Sources: {}", sm.sources.len());
println!("Names: {}", sm.names.len());
println!("Mappings: {}", sm.mapping_count());
if sm.has_range_mappings() {
println!(" Range: {} range mappings", sm.range_mapping_count());
}
println!("Lines: {}", sm.line_count());
println!("File size: {}", format_size(raw.len()));
let has_content = sm.sources_content.iter().filter(|c| c.is_some()).count();
if has_content > 0 {
let content_size: usize =
sm.sources_content.iter().filter_map(|c| c.as_ref()).map(|s| s.len()).sum();
println!(
"Content: {has_content}/{} sources ({})",
sm.sources.len(),
format_size(content_size)
);
}
if let Some(ref id) = sm.debug_id {
println!("Debug ID: {id}");
}
if !sm.ignore_list.is_empty() {
println!("Ignore list: {} sources", sm.ignore_list.len());
}
println!();
println!("Sources:");
for (i, s) in sm.sources.iter().enumerate() {
let ignored = if sm.ignore_list.contains(&(i as u32)) { " (ignored)" } else { "" };
let content = match sm.sources_content.get(i) {
Some(Some(c)) => format!(" [{}]", format_size(c.len())),
_ => String::new(),
};
println!(" {i}: {s}{content}{ignored}");
}
if !sm.names.is_empty() {
println!();
println!("Names ({}):", sm.names.len());
let display_count = sm.names.len().min(20);
for name in &sm.names[..display_count] {
println!(" {name}");
}
if sm.names.len() > 20 {
println!(" ... and {} more", sm.names.len() - 20);
}
}
}
Ok(())
}
fn cmd_validate(file: &PathBuf, json: bool) -> Result<bool, CliError> {
let raw = read_file_or_stdin(file)?;
match SourceMap::from_json(&raw) {
Ok(sm) => {
if json {
let obj = serde_json::json!({
"valid": true,
"version": 3,
"sources": sm.sources.len(),
"names": sm.names.len(),
"mappings": sm.mapping_count(),
"lines": sm.line_count(),
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
println!("Valid source map v3");
println!(
" {} sources, {} names, {} mappings across {} lines",
sm.sources.len(),
sm.names.len(),
sm.mapping_count(),
sm.line_count()
);
}
Ok(true)
}
Err(e) => {
if json {
let obj = serde_json::json!({
"valid": false,
"error": format!("{e}"),
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
eprintln!("Invalid source map: {e}");
}
Ok(false)
}
}
}
fn parse_bias(s: &str) -> Result<Bias, CliError> {
match s {
"glb" | "greatest-lower-bound" => Ok(Bias::GreatestLowerBound),
"lub" | "least-upper-bound" => Ok(Bias::LeastUpperBound),
_ => {
Err(CliError::invalid_input(format!("invalid bias: {s} (expected \"glb\" or \"lub\")")))
}
}
}
fn cmd_lookup(
file: &PathBuf,
line: u32,
column: u32,
bias: &str,
context: u32,
json: bool,
) -> Result<(), CliError> {
let b = parse_bias(bias)?;
let (sm, _) = parse_source_map(file)?;
match sm.original_position_for_with_bias(line, column, b) {
Some(loc) => {
let source = sm.source(loc.source);
let name = loc.name.map(|n| sm.name(n).to_string());
let context_lines = if context > 0 {
sm.sources_content.get(loc.source as usize).and_then(|c| c.as_ref()).map(
|content| {
let lines: Vec<&str> = content.lines().collect();
let target = loc.line as usize;
let start = target.saturating_sub(context as usize);
let end = (target + context as usize + 1).min(lines.len());
(start, target, lines[start..end].to_vec())
},
)
} else {
None
};
if json {
let mut obj = serde_json::json!({
"source": source,
"line": loc.line,
"column": loc.column,
"name": name,
});
if let Some((start, target, ref lines)) = context_lines {
let ctx: Vec<serde_json::Value> = lines
.iter()
.enumerate()
.map(|(i, text)| {
let line_num = start + i;
serde_json::json!({
"line": line_num,
"text": text,
"highlight": line_num == target,
})
})
.collect();
obj["context"] = serde_json::json!(ctx);
}
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
println!("{source}:{line}:{col}", line = loc.line, col = loc.column);
if let Some(name) = name {
println!(" name: {name}");
}
if let Some((start, target, ref lines)) = context_lines {
println!();
let gutter_width = format!("{}", start + lines.len()).len();
for (i, text) in lines.iter().enumerate() {
let line_num = start + i;
let marker = if line_num == target { ">" } else { " " };
println!(
"{marker} {line_num:>gutter_width$} | {text}",
gutter_width = gutter_width
);
}
}
}
}
None => {
return Err(CliError::not_found(format!("no mapping found for {line}:{column}")));
}
}
Ok(())
}
fn cmd_resolve(
file: &PathBuf,
source: &str,
line: u32,
column: u32,
bias: &str,
json: bool,
) -> Result<(), CliError> {
let b = parse_bias(bias)?;
reject_control_chars(source, "source")?;
let (sm, _) = parse_source_map(file)?;
match sm.generated_position_for_with_bias(source, line, column, b) {
Some(loc) => {
if json {
let obj = serde_json::json!({
"line": loc.line,
"column": loc.column,
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
println!("{}:{}", loc.line, loc.column);
}
}
None => {
return Err(CliError::not_found(format!(
"no mapping found for {source}:{line}:{column}"
)));
}
}
Ok(())
}
fn cmd_decode(mappings: Option<String>, compact: bool) -> Result<(), CliError> {
let input = match mappings {
Some(m) => m,
None => {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::io(format!("failed to read stdin: {e}")))?;
buf.trim().to_string()
}
};
reject_control_chars(&input, "mappings input")?;
let decoded = decode(&input).map_err(|e| CliError::parse(format!("decode error: {e}")))?;
let as_vecs: Vec<Vec<Vec<i64>>> = decoded
.into_iter()
.map(|line| line.into_iter().map(|seg| seg.to_vec()).collect())
.collect();
let json = if compact {
serde_json::to_string(&as_vecs)
} else {
serde_json::to_string_pretty(&as_vecs)
}
.map_err(|e| CliError::parse(format!("JSON serialization error: {e}")))?;
println!("{json}");
Ok(())
}
fn cmd_encode(file: Option<PathBuf>, json: bool) -> Result<(), CliError> {
let input = match file {
Some(path) => read_file_or_stdin(&path)?,
None => {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::io(format!("failed to read stdin: {e}")))?;
buf
}
};
let raw: Vec<Vec<Vec<i64>>> =
serde_json::from_str(&input).map_err(|e| CliError::parse(format!("invalid JSON: {e}")))?;
let decoded: srcmap_codec::SourceMapMappings = raw
.into_iter()
.map(|line| line.into_iter().map(srcmap_codec::Segment::from).collect())
.collect();
let encoded = encode(&decoded);
if json {
let obj = serde_json::json!({ "vlq": encoded });
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
println!("{encoded}");
}
Ok(())
}
fn cmd_mappings(
file: &PathBuf,
source_filter: &Option<String>,
limit: usize,
offset: usize,
json: bool,
) -> Result<(), CliError> {
if let Some(name) = source_filter {
reject_control_chars(name, "source filter")?;
}
let (sm, _) = parse_source_map(file)?;
let all = sm.all_mappings();
let total = if let Some(source_name) = source_filter {
let source_idx = sm
.source_index(source_name)
.ok_or_else(|| CliError::not_found(format!("source not found: {source_name}")))?;
all.iter().filter(|m| m.source == source_idx).count()
} else {
sm.mapping_count()
};
let filtered: Vec<_> = if let Some(source_name) = source_filter {
let source_idx = sm
.source_index(source_name)
.ok_or_else(|| CliError::not_found(format!("source not found: {source_name}")))?;
all.iter().filter(|m| m.source == source_idx).skip(offset).take(limit).collect()
} else {
all.iter().skip(offset).take(limit).collect()
};
if json {
let entries: Vec<serde_json::Value> = filtered
.iter()
.map(|m| {
let source =
if m.source != u32::MAX { Some(sm.source(m.source).to_string()) } else { None };
let name =
if m.name != u32::MAX { Some(sm.name(m.name).to_string()) } else { None };
serde_json::json!({
"generatedLine": m.generated_line,
"generatedColumn": m.generated_column,
"source": source,
"originalLine": m.original_line,
"originalColumn": m.original_column,
"name": name,
"isRangeMapping": m.is_range_mapping,
})
})
.collect();
let obj = serde_json::json!({
"mappings": entries,
"total": total,
"offset": offset,
"limit": limit,
"hasMore": offset + filtered.len() < total,
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
println!(
"{:<8} {:<8} {:<30} {:<8} {:<8} {:<6} name",
"gen.ln", "gen.col", "source", "orig.ln", "orig.col", "range"
);
println!("{:-<86}", "");
for m in &filtered {
let source = if m.source != u32::MAX { sm.source(m.source) } else { "-" };
let name = if m.name != u32::MAX { sm.name(m.name) } else { "" };
let range_marker = if m.is_range_mapping { "R" } else { "" };
println!(
"{:<8} {:<8} {:<30} {:<8} {:<8} {:<6} {}",
m.generated_line,
m.generated_column,
source,
m.original_line,
m.original_column,
range_marker,
name
);
}
if offset + limit < total {
println!();
println!(
"Showing {}-{} of {total}. Use --offset and --limit to paginate.",
offset,
offset + filtered.len()
);
}
}
Ok(())
}
fn cmd_concat(
files: &[PathBuf],
output: &Option<PathBuf>,
file_name: Option<String>,
json: bool,
dry_run: bool,
) -> Result<(), CliError> {
if files.is_empty() {
return Err(CliError::validation("at least one source map file is required"));
}
if let Some(path) = output {
validate_output_path(path)?;
}
let mut builder = ConcatBuilder::new(file_name);
let mut line_offset: u32 = 0;
let mut file_stats: Vec<serde_json::Value> = Vec::new();
for path in files {
let (sm, _) = parse_source_map(path)?;
let lines = sm.line_count() as u32;
let sources = sm.sources.len();
let mappings = sm.mapping_count();
builder.add_map(&sm, line_offset);
file_stats.push(serde_json::json!({
"file": path.display().to_string(),
"sources": sources,
"mappings": mappings,
"lines": lines,
"lineOffset": line_offset,
}));
line_offset += lines;
}
let map_json = builder.to_json();
let result = SourceMap::from_json(&map_json)
.map_err(|e| CliError::parse(format!("failed to parse generated map: {e}")))?;
if dry_run {
if json {
let obj = serde_json::json!({
"dryRun": true,
"inputFiles": file_stats,
"result": {
"sources": result.sources.len(),
"mappings": result.mapping_count(),
"lines": result.line_count(),
"fileSize": map_json.len(),
},
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
eprintln!(
"Dry run: would concatenate {} files → {} sources, {} mappings, {} lines ({})",
files.len(),
result.sources.len(),
result.mapping_count(),
result.line_count(),
format_size(map_json.len()),
);
}
return Ok(());
}
if json {
let obj = serde_json::json!({
"inputFiles": file_stats,
"result": {
"sources": result.sources.len(),
"mappings": result.mapping_count(),
"lines": result.line_count(),
"fileSize": map_json.len(),
},
"sourceMap": serde_json::from_str::<serde_json::Value>(&map_json).unwrap(),
});
write_output(output, &serde_json::to_string_pretty(&obj).unwrap())?;
} else {
write_output(output, &map_json)?;
if output.is_some() {
eprintln!(
"Concatenated {} files: {} sources, {} mappings, {} lines",
files.len(),
result.sources.len(),
result.mapping_count(),
result.line_count()
);
}
}
Ok(())
}
fn cmd_remap(
file: &PathBuf,
dir: &Option<PathBuf>,
upstreams: &[String],
output: &Option<PathBuf>,
json: bool,
dry_run: bool,
) -> Result<(), CliError> {
if let Some(path) = output {
validate_output_path(path)?;
}
let (outer, _) = parse_source_map(file)?;
let cwd = std::env::current_dir().map_err(|e| CliError::io(format!("cannot get cwd: {e}")))?;
let safe_dir = if let Some(d) = dir { Some(validate_safe_path(d, &cwd)?) } else { None };
let mut upstream_paths: std::collections::HashMap<String, PathBuf> =
std::collections::HashMap::new();
for entry in upstreams {
let (source, path) = entry.split_once('=').ok_or_else(|| {
CliError::validation(format!("invalid upstream format: {entry} (expected SOURCE=PATH)"))
})?;
reject_control_chars(source, "upstream source")?;
upstream_paths.insert(source.to_string(), PathBuf::from(path));
}
let found_upstreams: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
let skipped_sources: std::cell::RefCell<Vec<String>> = std::cell::RefCell::new(Vec::new());
let result = remap(&outer, |source| {
if let Some(path) = upstream_paths.get(source) {
let content = fs::read_to_string(path).ok()?;
let sm = SourceMap::from_json(&content).ok()?;
found_upstreams.borrow_mut().push(source.to_string());
return Some(sm);
}
if validate_source_name(source).is_err() {
skipped_sources.borrow_mut().push(source.to_string());
return None;
}
if let Some(ref search_dir) = safe_dir {
let map_path = search_dir.join(format!("{source}.map"));
if let Ok(canonical) = map_path.canonicalize()
&& canonical.starts_with(search_dir)
&& let Ok(content) = fs::read_to_string(&canonical)
&& let Ok(sm) = SourceMap::from_json(&content)
{
found_upstreams.borrow_mut().push(source.to_string());
return Some(sm);
}
let source_path = Path::new(source);
if let Some(stem) = source_path.file_stem() {
let map_name = format!("{}.map", stem.to_string_lossy());
let map_path = search_dir.join(map_name);
if let Ok(canonical) = map_path.canonicalize()
&& canonical.starts_with(search_dir)
&& let Ok(content) = fs::read_to_string(&canonical)
&& let Ok(sm) = SourceMap::from_json(&content)
{
found_upstreams.borrow_mut().push(source.to_string());
return Some(sm);
}
}
}
None
});
let found = found_upstreams.into_inner();
let skipped = skipped_sources.into_inner();
if dry_run {
if json {
let obj = serde_json::json!({
"dryRun": true,
"outerSources": outer.sources.len(),
"upstreamMapsFound": found,
"skippedSources": skipped,
"result": {
"sources": result.sources.len(),
"mappings": result.mapping_count(),
"lines": result.line_count(),
},
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
eprintln!(
"Dry run: would remap {} sources → {} upstream maps found",
outer.sources.len(),
found.len(),
);
if !skipped.is_empty() {
eprintln!(" Skipped (invalid source names): {}", skipped.join(", "));
}
eprintln!(
" Result: {} sources, {} mappings, {} lines",
result.sources.len(),
result.mapping_count(),
result.line_count(),
);
}
return Ok(());
}
let map_json = result.to_json();
if json {
let obj = serde_json::json!({
"upstreamMapsFound": found,
"skippedSources": skipped,
"result": {
"sources": result.sources.len(),
"mappings": result.mapping_count(),
"lines": result.line_count(),
"fileSize": map_json.len(),
},
"sourceMap": serde_json::from_str::<serde_json::Value>(&map_json).unwrap(),
});
write_output(output, &serde_json::to_string_pretty(&obj).unwrap())?;
} else {
write_output(output, &map_json)?;
if output.is_some() {
eprintln!(
"Remapped: {} sources, {} mappings, {} lines",
result.sources.len(),
result.mapping_count(),
result.line_count()
);
}
}
Ok(())
}
fn cmd_symbolicate(
file: &PathBuf,
dir: &Option<PathBuf>,
maps: &[String],
json: bool,
) -> Result<(), CliError> {
let stack_input = read_file_or_stdin(file)?;
let cwd = std::env::current_dir().map_err(|e| CliError::io(format!("cannot get cwd: {e}")))?;
let safe_dir = if let Some(d) = dir { Some(validate_safe_path(d, &cwd)?) } else { None };
let mut explicit_maps: std::collections::HashMap<String, SourceMap> =
std::collections::HashMap::new();
for entry in maps {
let (source, path_str) = entry.split_once('=').ok_or_else(|| {
CliError::validation(format!("invalid map format: {entry} (expected SOURCE=PATH)"))
})?;
let path = PathBuf::from(path_str);
let content = fs::read_to_string(&path)
.map_err(|e| CliError::io(format!("failed to read {}: {e}", path.display())))?;
let sm = SourceMap::from_json(&content)
.map_err(|e| CliError::parse(format!("invalid source map {}: {e}", path.display())))?;
explicit_maps.insert(source.to_string(), sm);
}
let result = srcmap_symbolicate::symbolicate(&stack_input, |source| {
if let Some(sm) = explicit_maps.get(source) {
return Some(sm.clone());
}
if let Some(ref search_dir) = safe_dir {
let map_path = search_dir.join(format!("{source}.map"));
if let Ok(canonical) = map_path.canonicalize()
&& canonical.starts_with(search_dir)
&& let Ok(content) = fs::read_to_string(&canonical)
&& let Ok(sm) = SourceMap::from_json(&content)
{
return Some(sm);
}
}
None
});
if json {
println!("{}", srcmap_symbolicate::to_json(&result));
} else {
print!("{result}");
}
Ok(())
}
fn format_scope_tree(scope: &srcmap_scopes::OriginalScope, indent: usize) {
let pad = " ".repeat(indent);
let kind = scope.kind.as_deref().unwrap_or("?");
let name = scope.name.as_deref().map(|n| format!(" \"{n}\"")).unwrap_or_default();
let frame = if scope.is_stack_frame { " [frame]" } else { "" };
println!(
"{pad}{kind}{name}{frame} {}:{}-{}:{}",
scope.start.line, scope.start.column, scope.end.line, scope.end.column
);
if !scope.variables.is_empty() {
println!("{pad} vars: {}", scope.variables.join(", "));
}
for child in &scope.children {
format_scope_tree(child, indent + 1);
}
}
fn format_range_tree(range: &srcmap_scopes::GeneratedRange, sources: &[String], indent: usize) {
let pad = " ".repeat(indent);
let frame = if range.is_stack_frame { " [frame]" } else { "" };
let hidden = if range.is_hidden { " [hidden]" } else { "" };
println!(
"{pad}{}:{}-{}:{}{frame}{hidden}",
range.start.line, range.start.column, range.end.line, range.end.column
);
if let Some(def) = range.definition {
println!("{pad} definition: scope #{def}");
}
if let Some(ref cs) = range.call_site {
let source = sources.get(cs.source_index as usize).map(|s| s.as_str()).unwrap_or("?");
println!("{pad} call site: {source}:{}:{}", cs.line, cs.column);
}
for binding in &range.bindings {
match binding {
srcmap_scopes::Binding::Expression(expr) => {
println!("{pad} binding: {expr}");
}
srcmap_scopes::Binding::Unavailable => {
println!("{pad} binding: <unavailable>");
}
srcmap_scopes::Binding::SubRanges(subs) => {
for sub in subs {
let expr = sub.expression.as_deref().unwrap_or("<unavailable>");
println!("{pad} binding: {expr} (from {}:{})", sub.from.line, sub.from.column);
}
}
}
}
for child in &range.children {
format_range_tree(child, sources, indent + 1);
}
}
fn scope_to_json(scope: &srcmap_scopes::OriginalScope) -> serde_json::Value {
serde_json::json!({
"start": { "line": scope.start.line, "column": scope.start.column },
"end": { "line": scope.end.line, "column": scope.end.column },
"kind": scope.kind,
"name": scope.name,
"isStackFrame": scope.is_stack_frame,
"variables": scope.variables,
"children": scope.children.iter().map(scope_to_json).collect::<Vec<_>>(),
})
}
fn range_to_json(range: &srcmap_scopes::GeneratedRange, sources: &[String]) -> serde_json::Value {
let bindings: Vec<serde_json::Value> = range
.bindings
.iter()
.map(|b| match b {
srcmap_scopes::Binding::Expression(expr) => {
serde_json::json!({ "expression": expr })
}
srcmap_scopes::Binding::Unavailable => {
serde_json::json!({ "unavailable": true })
}
srcmap_scopes::Binding::SubRanges(subs) => {
let entries: Vec<serde_json::Value> = subs
.iter()
.map(|s| {
serde_json::json!({
"expression": s.expression,
"from": { "line": s.from.line, "column": s.from.column },
})
})
.collect();
serde_json::json!({ "subRanges": entries })
}
})
.collect();
let call_site = range.call_site.as_ref().map(|cs| {
let source = sources.get(cs.source_index as usize).map(|s| s.as_str()).unwrap_or("?");
serde_json::json!({
"source": source,
"line": cs.line,
"column": cs.column,
})
});
serde_json::json!({
"start": { "line": range.start.line, "column": range.start.column },
"end": { "line": range.end.line, "column": range.end.column },
"isStackFrame": range.is_stack_frame,
"isHidden": range.is_hidden,
"definition": range.definition,
"callSite": call_site,
"bindings": bindings,
"children": range.children.iter().map(|c| range_to_json(c, sources)).collect::<Vec<_>>(),
})
}
fn cmd_scopes(file: &PathBuf, json: bool) -> Result<(), CliError> {
let (sm, _) = parse_source_map(file)?;
let scopes = sm
.scopes
.as_ref()
.ok_or_else(|| CliError::not_found("source map does not contain scopes data"))?;
if json {
let original: Vec<serde_json::Value> = scopes
.scopes
.iter()
.enumerate()
.filter_map(|(i, s)| {
s.as_ref().map(|scope| {
let source = sm.sources.get(i).map(|s| s.as_str()).unwrap_or("?");
serde_json::json!({
"source": source,
"scope": scope_to_json(scope),
})
})
})
.collect();
let ranges: Vec<serde_json::Value> =
scopes.ranges.iter().map(|r| range_to_json(r, &sm.sources)).collect();
let obj = serde_json::json!({
"originalScopes": original,
"generatedRanges": ranges,
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
let scope_count: usize = scopes.scopes.iter().filter(|s| s.is_some()).count();
println!("Original scopes ({scope_count} sources with scopes):");
for (i, scope) in scopes.scopes.iter().enumerate() {
if let Some(scope) = scope {
let source = sm.sources.get(i).map(|s| s.as_str()).unwrap_or("?");
println!();
println!(" [{i}] {source}:");
format_scope_tree(scope, 2);
}
}
println!();
println!("Generated ranges ({}):", scopes.ranges.len());
for range in &scopes.ranges {
println!();
format_range_tree(range, &sm.sources, 1);
}
}
Ok(())
}
fn cmd_fetch(url: &str, output: &Option<PathBuf>, json: bool) -> Result<(), CliError> {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(CliError::invalid_input("URL must start with http:// or https://"));
}
let output_dir = match output {
Some(dir) => dir.clone(),
None => {
std::env::current_dir().map_err(|e| CliError::io(format!("cannot get cwd: {e}")))?
}
};
if !output_dir.exists() {
fs::create_dir_all(&output_dir).map_err(|e| {
CliError::io(format!("failed to create output directory {}: {e}", output_dir.display()))
})?;
}
eprintln!("Fetching {url}...");
let bundle_body = http_get(url)?;
let bundle_filename = url_filename(url);
let bundle_path = output_dir.join(&bundle_filename);
fs::write(&bundle_path, &bundle_body)
.map_err(|e| CliError::io(format!("failed to write {}: {e}", bundle_path.display())))?;
eprintln!(" Saved {} ({})", bundle_path.display(), format_size(bundle_body.len()));
let map_result = match parse_source_mapping_url(&bundle_body) {
Some(SourceMappingUrl::Inline(decoded_json)) => {
let map_filename = format!("{bundle_filename}.map");
let map_path = output_dir.join(&map_filename);
fs::write(&map_path, &decoded_json).map_err(|e| {
CliError::io(format!("failed to write {}: {e}", map_path.display()))
})?;
eprintln!(
" Saved {} (inline, {})",
map_path.display(),
format_size(decoded_json.len())
);
Some((map_path.display().to_string(), decoded_json.len(), "inline".to_string()))
}
Some(SourceMappingUrl::External(ref map_ref)) => {
let map_url = resolve_source_map_url(url, map_ref).ok_or_else(|| {
CliError::fetch_error(format!("could not resolve source map URL: {map_ref}"))
})?;
eprintln!("Fetching {map_url}...");
let map_body = http_get(&map_url)?;
let map_filename = url_filename(&map_url);
let map_path = output_dir.join(&map_filename);
fs::write(&map_path, &map_body).map_err(|e| {
CliError::io(format!("failed to write {}: {e}", map_path.display()))
})?;
eprintln!(" Saved {} ({})", map_path.display(), format_size(map_body.len()));
Some((map_path.display().to_string(), map_body.len(), map_url))
}
None => {
let map_url = format!("{url}.map");
eprintln!("No sourceMappingURL found, trying {map_url}...");
match http_get(&map_url) {
Ok(map_body) => {
let map_filename = format!("{bundle_filename}.map");
let map_path = output_dir.join(&map_filename);
fs::write(&map_path, &map_body).map_err(|e| {
CliError::io(format!("failed to write {}: {e}", map_path.display()))
})?;
eprintln!(
" Saved {} (convention, {})",
map_path.display(),
format_size(map_body.len())
);
Some((map_path.display().to_string(), map_body.len(), map_url))
}
Err(_) => {
eprintln!(" No source map found");
None
}
}
}
};
if json {
let obj = serde_json::json!({
"bundle": {
"url": url,
"file": bundle_path.display().to_string(),
"size": bundle_body.len(),
},
"sourceMap": map_result.as_ref().map(|(path, size, source_url)| {
serde_json::json!({
"url": source_url,
"file": path,
"size": size,
})
}),
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else if map_result.is_none() {
println!("Fetched {} (no source map found)", bundle_path.display());
} else {
println!("Done");
}
Ok(())
}
fn cmd_sources(
file: &PathBuf,
extract: bool,
output: &Option<PathBuf>,
json: bool,
) -> Result<(), CliError> {
let (sm, _) = parse_source_map(file)?;
if extract {
let output_dir = match output {
Some(dir) => dir.clone(),
None => {
std::env::current_dir().map_err(|e| CliError::io(format!("cannot get cwd: {e}")))?
}
};
let mut extracted = Vec::new();
let mut skipped = Vec::new();
for (i, source_name) in sm.sources.iter().enumerate() {
let content = match sm.sources_content.get(i).and_then(|c| c.as_ref()) {
Some(c) => c,
None => {
skipped.push(source_name.clone());
continue;
}
};
let dest_path = match sanitize_source_path(source_name) {
Ok(p) => output_dir.join(p),
Err(_) => {
skipped.push(source_name.clone());
continue;
}
};
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
CliError::io(format!("failed to create directory {}: {e}", parent.display()))
})?;
}
fs::write(&dest_path, content).map_err(|e| {
CliError::io(format!("failed to write {}: {e}", dest_path.display()))
})?;
extracted.push(serde_json::json!({
"source": source_name,
"file": dest_path.display().to_string(),
"size": content.len(),
}));
}
if json {
let obj = serde_json::json!({
"extracted": extracted,
"skipped": skipped,
"total": sm.sources.len(),
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
println!(
"Extracted {}/{} sources to {}",
extracted.len(),
sm.sources.len(),
output_dir.display()
);
for entry in &extracted {
let source = entry["source"].as_str().unwrap();
let size = entry["size"].as_u64().unwrap() as usize;
println!(" {source} [{}]", format_size(size));
}
if !skipped.is_empty() {
println!("Skipped {} sources without content", skipped.len());
}
}
} else {
let entries: Vec<serde_json::Value> = sm
.sources
.iter()
.enumerate()
.map(|(i, source)| {
let content_size =
sm.sources_content.get(i).and_then(|c| c.as_ref()).map(|c| c.len());
let ignored = sm.ignore_list.contains(&(i as u32));
serde_json::json!({
"index": i,
"source": source,
"hasContent": content_size.is_some(),
"contentSize": content_size,
"ignored": ignored,
})
})
.collect();
if json {
let obj = serde_json::json!({
"sources": entries,
"total": sm.sources.len(),
"withContent": entries.iter().filter(|e| e["hasContent"].as_bool() == Some(true)).count(),
});
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
} else {
let with_content =
entries.iter().filter(|e| e["hasContent"].as_bool() == Some(true)).count();
println!("Sources ({}, {} with content):", sm.sources.len(), with_content);
for entry in &entries {
let idx = entry["index"].as_u64().unwrap();
let source = entry["source"].as_str().unwrap();
let size_str = match entry["contentSize"].as_u64() {
Some(size) => format!(" [{}]", format_size(size as usize)),
None => " [no content]".to_string(),
};
let ignored =
if entry["ignored"].as_bool() == Some(true) { " (ignored)" } else { "" };
println!(" {idx}: {source}{size_str}{ignored}");
}
}
}
Ok(())
}
fn cmd_schema() -> Result<(), CliError> {
let schema = serde_json::json!({
"name": "srcmap",
"version": env!("CARGO_PKG_VERSION"),
"description": "Inspect, validate, compose, and manipulate source maps",
"note": "All line and column values are 0-based (ECMA-426 spec)",
"globalFlags": {
"--json": "Available on most commands. Outputs structured JSON instead of human-readable text. Errors are also returned as JSON when this flag is active.",
},
"commands": [
{
"name": "info",
"description": "Show source map metadata and statistics",
"args": [
{"name": "file", "type": "path", "required": true, "description": "Source map file (use `-` for stdin)"}
],
"flags": {
"--json": {"type": "bool", "default": false, "description": "Output as JSON"}
}
},
{
"name": "validate",
"description": "Validate a source map file and report structure",
"args": [
{"name": "file", "type": "path", "required": true, "description": "Source map file (use `-` for stdin)"}
],
"flags": {
"--json": {"type": "bool", "default": false, "description": "Output as JSON"}
},
"exitCodes": {"0": "valid", "1": "invalid or error"}
},
{
"name": "lookup",
"description": "Find original position for a generated position (forward mapping)",
"args": [
{"name": "file", "type": "path", "required": true, "description": "Source map file"},
{"name": "line", "type": "u32", "required": true, "description": "Generated line (0-based)"},
{"name": "column", "type": "u32", "required": true, "description": "Generated column (0-based)"}
],
"flags": {
"--bias": {"type": "string", "default": "glb", "description": "Search bias: glb (greatest lower bound) or lub (least upper bound)"},
"--context": {"type": "u32", "default": 0, "description": "Number of context lines to show around the matched position"},
"--json": {"type": "bool", "default": false, "description": "Output as JSON"}
}
},
{
"name": "resolve",
"description": "Find generated position for an original position (reverse mapping)",
"args": [
{"name": "file", "type": "path", "required": true, "description": "Source map file"},
{"name": "line", "type": "u32", "required": true, "description": "Original line (0-based)"},
{"name": "column", "type": "u32", "required": true, "description": "Original column (0-based)"}
],
"flags": {
"--source": {"type": "string", "required": true, "description": "Source filename to look up"},
"--bias": {"type": "string", "default": "lub", "description": "Search bias: lub (least upper bound) or glb (greatest lower bound)"},
"--json": {"type": "bool", "default": false, "description": "Output as JSON"}
}
},
{
"name": "decode",
"description": "Decode a VLQ mappings string to JSON array",
"args": [
{"name": "mappings", "type": "string", "required": false, "description": "VLQ-encoded mappings string (reads stdin if omitted)"}
],
"flags": {
"--compact": {"type": "bool", "default": false, "description": "Output as compact single-line JSON"}
}
},
{
"name": "encode",
"description": "Encode decoded mappings JSON back to a VLQ string",
"args": [
{"name": "file", "type": "path", "required": false, "description": "JSON file with decoded mappings (reads stdin if omitted)"}
],
"flags": {
"--json": {"type": "bool", "default": false, "description": "Wrap result in JSON object {\"vlq\": \"...\"}"}
}
},
{
"name": "mappings",
"description": "List all mappings in a source map with pagination",
"args": [
{"name": "file", "type": "path", "required": true, "description": "Source map file"}
],
"flags": {
"--source": {"type": "string", "required": false, "description": "Filter by source filename"},
"--limit": {"type": "usize", "default": 50, "description": "Maximum number of mappings to show"},
"--offset": {"type": "usize", "default": 0, "description": "Skip first N mappings"},
"--json": {"type": "bool", "default": false, "description": "Output as JSON with pagination metadata"}
}
},
{
"name": "concat",
"description": "Concatenate multiple source maps into one (mutating)",
"args": [
{"name": "files", "type": "path[]", "required": true, "description": "Source map files to concatenate (in order)"}
],
"flags": {
"-o, --output": {"type": "path", "required": false, "description": "Output file (stdout if omitted)"},
"--file_name": {"type": "string", "required": false, "description": "Output filename to embed in the map"},
"--json": {"type": "bool", "default": false, "description": "Output as JSON with metadata"},
"--dry-run": {"type": "bool", "default": false, "description": "Validate and preview result without writing"}
}
},
{
"name": "remap",
"description": "Compose/remap source maps through a transform chain (mutating)",
"args": [
{"name": "file", "type": "path", "required": true, "description": "Outer (final transform) source map"}
],
"flags": {
"--dir": {"type": "path", "required": false, "description": "Directory to search for upstream source maps"},
"--upstream": {"type": "string[]", "required": false, "description": "Explicit upstream mappings (SOURCE=PATH pairs, repeatable)"},
"-o, --output": {"type": "path", "required": false, "description": "Output file (stdout if omitted)"},
"--json": {"type": "bool", "default": false, "description": "Output as JSON with metadata"},
"--dry-run": {"type": "bool", "default": false, "description": "Validate and preview result without writing"}
}
},
{
"name": "symbolicate",
"description": "Symbolicate a stack trace using source maps",
"args": [
{"name": "file", "type": "path", "required": true, "description": "File containing the stack trace (use `-` for stdin)"}
],
"flags": {
"--dir": {"type": "path", "required": false, "description": "Directory to search for source maps"},
"--map": {"type": "string[]", "required": false, "description": "Explicit source map files (SOURCE=PATH pairs, repeatable)"},
"--json": {"type": "bool", "default": false, "description": "Output as JSON"}
}
},
{
"name": "scopes",
"description": "Inspect ECMA-426 scopes and variable bindings in a source map",
"args": [
{"name": "file", "type": "path", "required": true, "description": "Source map file"}
],
"flags": {
"--json": {"type": "bool", "default": false, "description": "Output as JSON"}
}
},
{
"name": "fetch",
"description": "Fetch a JavaScript/CSS bundle and its source map from a URL",
"args": [
{"name": "url", "type": "string", "required": true, "description": "URL of the JavaScript or CSS file"}
],
"flags": {
"-o, --output": {"type": "path", "required": false, "description": "Output directory (default: current directory)"},
"--json": {"type": "bool", "default": false, "description": "Output as JSON"}
}
},
{
"name": "sources",
"description": "List or extract original sources embedded in a source map",
"args": [
{"name": "file", "type": "path", "required": true, "description": "Source map file"}
],
"flags": {
"--extract": {"type": "bool", "default": false, "description": "Extract sourcesContent to files on disk"},
"-o, --output": {"type": "path", "required": false, "description": "Output directory for extracted files (default: current directory)"},
"--json": {"type": "bool", "default": false, "description": "Output as JSON"}
}
},
{
"name": "schema",
"description": "Describe all commands and their arguments as JSON (this output)",
"args": [],
"flags": {}
}
]
});
println!("{}", serde_json::to_string_pretty(&schema).unwrap());
Ok(())
}
fn main() -> ExitCode {
let cli = Cli::parse();
let json_mode = matches!(
&cli.command,
Command::Info { json: true, .. }
| Command::Validate { json: true, .. }
| Command::Lookup { json: true, .. }
| Command::Resolve { json: true, .. }
| Command::Encode { json: true, .. }
| Command::Mappings { json: true, .. }
| Command::Concat { json: true, .. }
| Command::Remap { json: true, .. }
| Command::Symbolicate { json: true, .. }
| Command::Scopes { json: true, .. }
| Command::Fetch { json: true, .. }
| Command::Sources { json: true, .. }
);
let result = match &cli.command {
Command::Info { file, json } => cmd_info(file, *json),
Command::Validate { file, json } => match cmd_validate(file, *json) {
Ok(true) => Ok(()),
Ok(false) => return ExitCode::FAILURE,
Err(e) => Err(e),
},
Command::Lookup { file, line, column, bias, context, json } => {
cmd_lookup(file, *line, *column, bias, *context, *json)
}
Command::Resolve { file, source, line, column, bias, json } => {
cmd_resolve(file, source, *line, *column, bias, *json)
}
Command::Decode { mappings, compact } => cmd_decode(mappings.clone(), *compact),
Command::Encode { file, json } => cmd_encode(file.clone(), *json),
Command::Mappings { file, source, limit, offset, json } => {
cmd_mappings(file, source, *limit, *offset, *json)
}
Command::Concat { files, output, file_name, json, dry_run } => {
cmd_concat(files, output, file_name.clone(), *json, *dry_run)
}
Command::Remap { file, dir, upstreams, output, json, dry_run } => {
cmd_remap(file, dir, upstreams, output, *json, *dry_run)
}
Command::Symbolicate { file, dir, maps, json } => cmd_symbolicate(file, dir, maps, *json),
Command::Scopes { file, json } => cmd_scopes(file, *json),
Command::Fetch { url, output, json } => cmd_fetch(url, output, *json),
Command::Sources { file, extract, output, json } => {
cmd_sources(file, *extract, output, *json)
}
Command::Schema => cmd_schema(),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
if json_mode {
eprintln!("{}", e.to_json());
} else {
eprintln!("error: {}", e.message);
}
ExitCode::FAILURE
}
}
}