Skip to content

Commit 2385a22

Browse files
authored
feat: emit ECMA-426 source map scopes behind experimental flag (#11582)
## Summary Implements ECMA-426 source map scope emission behind a new opt-in flag: - Adds `jsc.experimental.emitSourceMapScopes` (default `false`) - Emits `scopes` metadata in output source maps when enabled - Includes both original scope trees and generated range trees - Includes variable lists and generated range bindings (`G`) - Keeps behavior unchanged unless explicitly enabled ## What changed ### Config and API wiring - Added `emit_source_map_scopes` to SWC config/build pipeline - Added `emit_source_map_scopes` to compiler-base `PrintArgs` - Added `emitSourceMapScopes?: boolean` to `packages/types` ### Codegen scope collection - Extended `WriteJs` with scope hooks: - `start_scope` - `end_scope` - `add_scope_variable` - Added forwarding in wrappers (`Box<W>`, `&mut W`, omit-trailing-semi writer) - Added scope model (`ScopeKind`, `BindingStorage`, `ScopeRecord`, `ScopeBindingRecord`) - Implemented collection in `JsWriter` with: - nested scope stack tracking - hoisted vs lexical placement - per-scope variable dedup by name ### Emitter instrumentation - Instrumented module/script roots, function-like scopes, block scopes, and catch scopes - Collected bindings for params, var/let/const declarations, function/class declarations - Added helper module for pattern/parameter binding extraction ### Source map integration - Added `SourceMapGenConfig::for_each_additional_name` hook - Pre-registers additional scope-related names before mappings are encoded - Added scope encoder in compiler-base (`source_map_scopes.rs`) to encode ECMA-426 tags (`B/C/D/E/F/G`) - `scopes` is only emitted when: - source maps are enabled - `emitSourceMapScopes` is enabled - no input sourcemap composition (`orig.is_none()`) ## Tests Added/updated tests for: - codegen writer scope collection and wrapper forwarding - compiler-base scope encoder behavior - compiler-base print integration (enabled/disabled/orig composition) - swc e2e source-map test for this issue (`issue_11424_emit_source_map_scopes_opt_in`) ## Verification run - `git submodule update --init --recursive` - `cargo fmt --all` - `cargo clippy --all --all-targets -- -D warnings` - `cargo test -p swc_ecma_codegen` - `cargo test -p swc_compiler_base` - `cargo test -p swc_sourcemap` - `cargo test -p swc` Note: `cargo test -p swc` still has pre-existing environment-dependent failures in `tests/source_map.rs` where Node cannot resolve `sourcemap-validator` in this environment. The new `issue_11424_emit_source_map_scopes_opt_in` test passes. Fixes #11424
1 parent e037843 commit 2385a22

19 files changed

Lines changed: 1998 additions & 34 deletions

File tree

.changeset/big-owls-remember.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
swc_sourcemap: major
3+
---
4+
5+
feat: emit ECMA-426 source map scopes behind experimental flag

crates/swc/src/config/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,7 @@ impl Options {
976976
emit_assert_for_import_attributes: experimental
977977
.emit_assert_for_import_attributes
978978
.into_bool(),
979+
emit_source_map_scopes: experimental.emit_source_map_scopes.into_bool(),
979980
codegen_inline_script,
980981
emit_isolated_dts: experimental.emit_isolated_dts.into_bool(),
981982
unresolved_mark,
@@ -1295,6 +1296,7 @@ pub struct BuiltInput<P: Pass> {
12951296

12961297
pub output: JscOutputConfig,
12971298
pub emit_assert_for_import_attributes: bool,
1299+
pub emit_source_map_scopes: bool,
12981300
pub codegen_inline_script: bool,
12991301

13001302
pub emit_isolated_dts: bool,
@@ -1331,6 +1333,7 @@ where
13311333
emit_source_map_columns: self.emit_source_map_columns,
13321334
output: self.output,
13331335
emit_assert_for_import_attributes: self.emit_assert_for_import_attributes,
1336+
emit_source_map_scopes: self.emit_source_map_scopes,
13341337
codegen_inline_script: self.codegen_inline_script,
13351338
emit_isolated_dts: self.emit_isolated_dts,
13361339
unresolved_mark: self.unresolved_mark,
@@ -1433,6 +1436,9 @@ pub struct JscExperimental {
14331436

14341437
#[serde(default)]
14351438
pub emit_assert_for_import_attributes: BoolConfig<false>,
1439+
1440+
#[serde(default)]
1441+
pub emit_source_map_scopes: BoolConfig<false>,
14361442
/// Location where swc may stores its intermediate cache.
14371443
/// Currently this is only being used for wasm plugin's bytecache.
14381444
/// Path should be absolute directory, which will be created if not exist.

crates/swc/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,7 @@ impl Compiler {
925925
orig,
926926
comments: Some(&comments),
927927
emit_source_map_columns: opts.emit_source_map_columns,
928+
emit_source_map_scopes: false,
928929
preamble: &opts.format.preamble,
929930
codegen_config: swc_ecma_codegen::Config::default()
930931
.with_target(target)
@@ -1083,6 +1084,7 @@ impl Compiler {
10831084
orig,
10841085
comments: config.comments.as_ref().map(|v| v as _),
10851086
emit_source_map_columns: config.emit_source_map_columns,
1087+
emit_source_map_scopes: config.emit_source_map_scopes,
10861088
preamble: &config.output.preamble,
10871089
codegen_config: swc_ecma_codegen::Config::default()
10881090
.with_target(config.target)

crates/swc/tests/source_map.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use std::{
1212
use anyhow::{Context, Error};
1313
use swc::{
1414
config::{
15-
Config, InputSourceMap, IsModule, JscConfig, ModuleConfig, Options, SourceMapsConfig,
15+
Config, InputSourceMap, IsModule, JscConfig, JscExperimental, ModuleConfig, Options,
16+
SourceMapsConfig,
1617
},
1718
Compiler,
1819
};
@@ -441,6 +442,83 @@ fn should_work_with_emit_source_map_columns() {
441442
});
442443
}
443444

445+
#[test]
446+
fn issue_11424_emit_source_map_scopes_opt_in() {
447+
Tester::new().print_errors(|cm, handler| {
448+
let c = Compiler::new(cm.clone());
449+
let path = canonicalize("tests/srcmap/issue-11424-scopes/input.js")
450+
.expect("failed to canonicalize fixture");
451+
let fm = cm.load_file(&path).expect("failed to load fixture");
452+
453+
let output = c
454+
.process_js_file(
455+
fm,
456+
&handler,
457+
&Options {
458+
swcrc: false,
459+
source_maps: Some(SourceMapsConfig::Bool(true)),
460+
config: Config {
461+
inline_sources_content: true.into(),
462+
jsc: JscConfig {
463+
experimental: JscExperimental {
464+
emit_source_map_scopes: true.into(),
465+
..Default::default()
466+
},
467+
..Default::default()
468+
},
469+
..Default::default()
470+
},
471+
..Default::default()
472+
},
473+
)
474+
.expect("failed to process fixture");
475+
476+
let map_text = output.map.expect("source map should be emitted");
477+
let map = swc_sourcemap::SourceMap::from_slice(map_text.as_bytes())
478+
.expect("failed to deserialize sourcemap");
479+
let scopes = map.get_scopes().expect("scopes should be emitted");
480+
assert!(!scopes.is_empty());
481+
assert!(scopes.contains('B'));
482+
assert!(scopes.contains('E'));
483+
assert!(scopes.contains('G'));
484+
485+
Ok(())
486+
});
487+
}
488+
489+
#[test]
490+
fn issue_11424_emit_source_map_scopes_default_off() {
491+
Tester::new().print_errors(|cm, handler| {
492+
let c = Compiler::new(cm.clone());
493+
let path = canonicalize("tests/srcmap/issue-11424-scopes/input.js")
494+
.expect("failed to canonicalize fixture");
495+
let fm = cm.load_file(&path).expect("failed to load fixture");
496+
497+
let output = c
498+
.process_js_file(
499+
fm,
500+
&handler,
501+
&Options {
502+
swcrc: false,
503+
source_maps: Some(SourceMapsConfig::Bool(true)),
504+
config: Config {
505+
inline_sources_content: true.into(),
506+
..Default::default()
507+
},
508+
..Default::default()
509+
},
510+
)
511+
.expect("failed to process fixture");
512+
513+
let map_text = output.map.expect("source map should be emitted");
514+
let map = swc_sourcemap::SourceMap::from_slice(map_text.as_bytes())
515+
.expect("failed to deserialize sourcemap");
516+
assert!(map.get_scopes().is_none());
517+
518+
Ok(())
519+
});
520+
}
521+
444522
#[test]
445523
fn issue_4578() {
446524
file(
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export function outer(param) {
2+
var hoisted = param;
3+
let lexical = hoisted + 1;
4+
5+
function inner(innerParam) {
6+
const nested = innerParam + lexical;
7+
8+
if (nested > 0) {
9+
let blockVar = nested;
10+
return blockVar;
11+
}
12+
13+
return hoisted;
14+
}
15+
16+
return inner(lexical);
17+
}

crates/swc_common/src/source_map.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,14 @@ pub fn build_source_map(
12931293
config: &impl SourceMapGenConfig,
12941294
) -> swc_sourcemap::SourceMap {
12951295
let mut builder = SourceMapBuilder::new(None);
1296+
if orig.is_none() {
1297+
config.for_each_additional_name(&mut |name| {
1298+
builder.add_name(unsafe {
1299+
// Safety: `name` is `&str`, so it's valid UTF-8.
1300+
BytesStr::from_utf8_slice_unchecked(name.as_bytes())
1301+
});
1302+
});
1303+
}
12961304

12971305
let mut src_id = 0u32;
12981306

@@ -1507,6 +1515,11 @@ pub trait SourceMapGenConfig {
15071515
None
15081516
}
15091517

1518+
/// Iterates over additional names that should be inserted into
1519+
/// `SourceMap.names` even if they are not directly referenced by mapping
1520+
/// tokens.
1521+
fn for_each_additional_name(&self, _op: &mut dyn FnMut(&str)) {}
1522+
15101523
/// You can override this to control `sourceContents`.
15111524
fn inline_sources_content(&self, f: &FileName) -> bool {
15121525
!matches!(

crates/swc_compiler_base/src/lib.rs

Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ use swc_common::{
2020
};
2121
use swc_config::{file_pattern::FilePattern, is_module::IsModule, types::BoolOr};
2222
use swc_ecma_ast::{EsVersion, Ident, IdentName, Program};
23-
use swc_ecma_codegen::{text_writer::WriteJs, Emitter, Node};
23+
use swc_ecma_codegen::{
24+
text_writer::{ScopeRecord, WriteJs},
25+
Emitter, Node,
26+
};
2427
use swc_ecma_minifier::js::JsMinifyCommentOption;
2528
use swc_ecma_parser::{
2629
parse_file_as_commonjs, parse_file_as_module, parse_file_as_program, parse_file_as_script,
@@ -29,6 +32,8 @@ use swc_ecma_parser::{
2932
use swc_ecma_visit::{noop_visit_type, Visit, VisitWith};
3033
use swc_timer::timer;
3134

35+
mod source_map_scopes;
36+
3237
#[cfg(feature = "node")]
3338
#[napi_derive::napi(object)]
3439
#[derive(Debug, Serialize)]
@@ -123,6 +128,7 @@ pub struct PrintArgs<'a> {
123128
pub orig: Option<swc_sourcemap::SourceMap>,
124129
pub comments: Option<&'a dyn Comments>,
125130
pub emit_source_map_columns: bool,
131+
pub emit_source_map_scopes: bool,
126132
pub preamble: &'a str,
127133
pub codegen_config: swc_ecma_codegen::Config,
128134
pub output: Option<FxHashMap<String, String>>,
@@ -144,6 +150,7 @@ impl Default for PrintArgs<'_> {
144150
orig: None,
145151
comments: None,
146152
emit_source_map_columns: false,
153+
emit_source_map_scopes: false,
147154
preamble: "",
148155
codegen_config: Default::default(),
149156
output: None,
@@ -176,6 +183,7 @@ pub fn print<T>(
176183
orig,
177184
comments,
178185
emit_source_map_columns,
186+
emit_source_map_scopes,
179187
preamble,
180188
codegen_config,
181189
output,
@@ -189,11 +197,13 @@ where
189197
let _timer = timer!("Compiler::print");
190198

191199
let mut src_map_buf = Vec::new();
200+
let should_emit_scope_map = source_map.enabled() && emit_source_map_scopes && orig.is_none();
201+
let mut scope_buf = should_emit_scope_map.then(Vec::<ScopeRecord>::new);
192202

193203
let mut src = {
194204
let mut buf = std::vec::Vec::new();
195205
{
196-
let mut w = swc_ecma_codegen::text_writer::JsWriter::new(
206+
let mut w = swc_ecma_codegen::text_writer::JsWriter::new_with_scopes(
197207
cm.clone(),
198208
"\n",
199209
&mut buf,
@@ -202,6 +212,7 @@ where
202212
} else {
203213
None
204214
},
215+
scope_buf.as_mut(),
205216
);
206217
w.preamble(preamble).unwrap();
207218
let mut wr = Box::new(w) as Box<dyn WriteJs>;
@@ -233,6 +244,11 @@ where
233244
panic!("The module contains only dummy spans\n{src}");
234245
}
235246

247+
let additional_scope_names = scope_buf
248+
.as_deref()
249+
.map(source_map_scopes::collect_additional_names)
250+
.unwrap_or_default();
251+
236252
let mut map = if source_map.enabled() {
237253
Some(cm.build_source_map(
238254
&src_map_buf,
@@ -241,6 +257,7 @@ where
241257
source_file_name,
242258
output_path: output_path.as_deref(),
243259
names: source_map_names,
260+
additional_names: &additional_scope_names,
244261
inline_sources_content,
245262
emit_columns: emit_source_map_columns,
246263
ignore_list: source_map_ignore_list,
@@ -251,6 +268,19 @@ where
251268
};
252269

253270
if let Some(map) = &mut map {
271+
if should_emit_scope_map {
272+
if let Some(scope_buf) = scope_buf.as_deref() {
273+
let encoded_scopes =
274+
source_map_scopes::encode_scopes(scope_buf, &cm, map, |file_name| {
275+
map_file_name_to_source(source_file_name, output_path.as_deref(), file_name)
276+
});
277+
278+
if let Some(scopes) = encoded_scopes {
279+
map.set_scopes(Some(scopes));
280+
}
281+
}
282+
}
283+
254284
if let Some(source_root) = source_root {
255285
map.set_source_root(Some(BytesStr::from_str_slice(source_root)))
256286
}
@@ -306,6 +336,7 @@ struct SwcSourceMapConfig<'a> {
306336
output_path: Option<&'a Path>,
307337

308338
names: &'a FxHashMap<BytePos, Atom>,
339+
additional_names: &'a [String],
309340

310341
inline_sources_content: bool,
311342

@@ -314,38 +345,52 @@ struct SwcSourceMapConfig<'a> {
314345
ignore_list: Option<FilePattern>,
315346
}
316347

317-
impl SourceMapGenConfig for SwcSourceMapConfig<'_> {
318-
fn file_name_to_source(&self, f: &FileName) -> String {
319-
if let Some(file_name) = self.source_file_name {
320-
return file_name.to_string();
321-
}
348+
fn map_file_name_to_source(
349+
source_file_name: Option<&str>,
350+
output_path: Option<&Path>,
351+
f: &FileName,
352+
) -> String {
353+
if let Some(file_name) = source_file_name {
354+
return file_name.to_string();
355+
}
322356

323-
let Some(base_path) = self.output_path.as_ref().and_then(|v| v.parent()) else {
324-
return f.to_string();
325-
};
326-
let target = match f {
327-
FileName::Real(v) => v,
328-
_ => return f.to_string(),
329-
};
357+
let Some(base_path) = output_path.and_then(|v| v.parent()) else {
358+
return f.to_string();
359+
};
360+
let target = match f {
361+
FileName::Real(v) => v,
362+
_ => return f.to_string(),
363+
};
330364

331-
let rel = pathdiff::diff_paths(target, base_path);
332-
match rel {
333-
Some(v) => {
334-
let s = v.to_string_lossy().to_string();
335-
if cfg!(target_os = "windows") {
336-
s.replace('\\', "/")
337-
} else {
338-
s
339-
}
365+
let rel = pathdiff::diff_paths(target, base_path);
366+
match rel {
367+
Some(v) => {
368+
let s = v.to_string_lossy().to_string();
369+
if cfg!(target_os = "windows") {
370+
s.replace('\\', "/")
371+
} else {
372+
s
340373
}
341-
None => f.to_string(),
342374
}
375+
None => f.to_string(),
376+
}
377+
}
378+
379+
impl SourceMapGenConfig for SwcSourceMapConfig<'_> {
380+
fn file_name_to_source(&self, f: &FileName) -> String {
381+
map_file_name_to_source(self.source_file_name, self.output_path, f)
343382
}
344383

345384
fn name_for_bytepos(&self, pos: BytePos) -> Option<&str> {
346385
self.names.get(&pos).map(|v| &**v)
347386
}
348387

388+
fn for_each_additional_name(&self, op: &mut dyn FnMut(&str)) {
389+
for name in self.additional_names {
390+
op(name);
391+
}
392+
}
393+
349394
fn inline_sources_content(&self, _: &FileName) -> bool {
350395
self.inline_sources_content
351396
}

0 commit comments

Comments
 (0)