debug_scopes/debug_scopes.rs
1//! Debug scopes example: model a function inlined into generated code.
2//!
3//! Demonstrates the ECMA-426 "Scopes" proposal for source maps.
4//! This proposal lets debuggers reconstruct original scope trees, variable
5//! bindings, and inlined function call sites from minified/bundled output.
6//!
7//! Scenario: a TypeScript function `add(a, b)` is inlined by the bundler.
8//!
9//! Original source (math.ts, source index 0):
10//! ```text
11//! // Line 0: (module-level code)
12//! // Line 1: function add(a: number, b: number): number {
13//! // Line 2: return a + b;
14//! // Line 3: }
15//! // Line 4:
16//! // Line 5: const result = add(10, 32);
17//! ```
18//!
19//! Generated code (bundle.js):
20//! ```text
21//! // Line 0: (module wrapper open)
22//! // Line 1: var _a = 10;
23//! // Line 2: var _b = 32;
24//! // Line 3: var result = _a + _b;
25//! // Line 4: (module wrapper close)
26//! ```
27//!
28//! The bundler inlined `add(10, 32)` — lines 1-3 in the output correspond
29//! to the body of the original `add` function. A debugger uses the scope
30//! info to show that `_a` is really `a` and `_b` is really `b`, and that
31//! the code was inlined from a call at line 5, column 14.
32//!
33//! Run with: cargo run -p srcmap-scopes --example debug_scopes
34
35use srcmap_scopes::{
36 Binding, CallSite, GeneratedRange, OriginalScope, Position, ScopeInfo, decode_scopes,
37 encode_scopes,
38};
39
40fn main() {
41 // -----------------------------------------------------------------------
42 // 1. Build the original scope tree (what the author wrote)
43 // -----------------------------------------------------------------------
44 //
45 // ECMA-426 original scopes form a tree per source file. Each scope has:
46 // - start/end positions (0-based line and column)
47 // - an optional name (for named functions, classes, etc.)
48 // - an optional kind ("global", "module", "function", "block", "class")
49 // - is_stack_frame: true for function-like scopes that appear in call stacks
50 // - variables: names declared in this scope (parameters, let/const/var)
51 // - children: nested scopes
52 //
53 // Scopes are indexed by a pre-order traversal counter ("definition index"):
54 // - definition 0: the module scope (root)
55 // - definition 1: the `add` function scope (first child)
56
57 let add_scope = OriginalScope {
58 start: Position { line: 1, column: 0 },
59 end: Position { line: 3, column: 1 },
60 name: Some("add".to_string()),
61 kind: Some("function".to_string()),
62 is_stack_frame: true,
63 variables: vec!["a".to_string(), "b".to_string()],
64 children: vec![],
65 };
66
67 let module_scope = OriginalScope {
68 start: Position { line: 0, column: 0 },
69 end: Position {
70 line: 5,
71 column: 27,
72 },
73 name: None,
74 kind: Some("module".to_string()),
75 is_stack_frame: false,
76 variables: vec!["result".to_string()],
77 children: vec![add_scope],
78 };
79
80 // -----------------------------------------------------------------------
81 // 2. Build the generated ranges (what the bundler produced)
82 // -----------------------------------------------------------------------
83 //
84 // Generated ranges describe regions of the output code and how they map
85 // back to original scopes. Key fields:
86 //
87 // - definition: index into the pre-order list of all original scopes,
88 // linking this range to its corresponding original scope
89 // - call_site: if this range is an inlined function body, the location
90 // in original source where the call happened
91 // - bindings: one entry per variable in the referenced original scope,
92 // telling the debugger what JS expression to evaluate for each variable
93 // - is_stack_frame: true if this range should appear in synthetic stacks
94 // - is_hidden: true if the debugger should skip over this range entirely
95
96 let inlined_range = GeneratedRange {
97 start: Position { line: 1, column: 0 },
98 end: Position {
99 line: 3,
100 column: 22,
101 },
102 is_stack_frame: true,
103 is_hidden: false,
104 // definition=1 points to the `add` function scope (pre-order index 1)
105 definition: Some(1),
106 // The call site is where `add(10, 32)` was called in the original source.
107 // The debugger uses this to reconstruct a synthetic call stack:
108 // add @ math.ts:2:2 (current position in the inlined body)
109 // <module> @ math.ts:5:14 (the call site)
110 call_site: Some(CallSite {
111 source_index: 0,
112 line: 5,
113 column: 14,
114 }),
115 // Bindings map the original scope's variables to generated expressions.
116 // The `add` scope has variables ["a", "b"] (in that order), so:
117 // bindings[0] = Expression("_a") → original `a` is `_a` in generated code
118 // bindings[1] = Expression("_b") → original `b` is `_b` in generated code
119 bindings: vec![
120 Binding::Expression("_a".to_string()),
121 Binding::Expression("_b".to_string()),
122 ],
123 children: vec![],
124 };
125
126 let wrapper_range = GeneratedRange {
127 start: Position { line: 0, column: 0 },
128 end: Position { line: 4, column: 1 },
129 is_stack_frame: false,
130 is_hidden: false,
131 // definition=0 points to the module scope (pre-order index 0)
132 definition: Some(0),
133 call_site: None,
134 // The module scope has variables ["result"], and in the generated code
135 // the variable keeps its name, so we bind it to "result".
136 bindings: vec![Binding::Expression("result".to_string())],
137 children: vec![inlined_range],
138 };
139
140 // -----------------------------------------------------------------------
141 // 3. Assemble ScopeInfo and encode
142 // -----------------------------------------------------------------------
143 //
144 // ScopeInfo combines original scope trees (one per source file) with the
145 // generated ranges. The `scopes` vec is indexed by source index — None
146 // means no scope info for that source file.
147
148 let scope_info = ScopeInfo {
149 scopes: vec![Some(module_scope)],
150 ranges: vec![wrapper_range],
151 };
152
153 // Encoding produces a compact VLQ string (stored in the source map's
154 // "scopes" field) and populates the names array with any new name strings.
155 let mut names: Vec<String> = vec![];
156 let encoded = encode_scopes(&scope_info, &mut names);
157
158 println!("=== ECMA-426 Scopes Roundtrip ===\n");
159 println!("Encoded scopes: {encoded:?}");
160 println!("Names array: {names:?}\n");
161
162 assert!(!encoded.is_empty(), "encoded string must not be empty");
163 assert!(
164 !names.is_empty(),
165 "names array must contain scope/variable names"
166 );
167
168 // -----------------------------------------------------------------------
169 // 4. Decode back and verify roundtrip
170 // -----------------------------------------------------------------------
171 //
172 // decode_scopes takes the encoded string, the names array, and the number
173 // of source files (so it knows how many original scope trees to expect).
174
175 let decoded = decode_scopes(&encoded, &names, 1).expect("decoding must succeed");
176
177 // Verify the original scope tree roundtrips correctly
178 assert_eq!(
179 decoded.scopes.len(),
180 1,
181 "must have exactly one source file's scopes"
182 );
183
184 let root_scope = decoded.scopes[0]
185 .as_ref()
186 .expect("source 0 must have scope info");
187
188 assert_eq!(root_scope.kind.as_deref(), Some("module"));
189 assert!(
190 !root_scope.is_stack_frame,
191 "module scope is not a stack frame"
192 );
193 assert_eq!(root_scope.variables, vec!["result"]);
194 assert_eq!(root_scope.start, Position { line: 0, column: 0 });
195 assert_eq!(
196 root_scope.end,
197 Position {
198 line: 5,
199 column: 27
200 }
201 );
202
203 println!("Original scope tree (source 0):");
204 println!(
205 " Root: kind={:?}, variables={:?}",
206 root_scope.kind, root_scope.variables
207 );
208
209 assert_eq!(root_scope.children.len(), 1, "module has one child scope");
210
211 let func_scope = &root_scope.children[0];
212 assert_eq!(func_scope.name.as_deref(), Some("add"));
213 assert_eq!(func_scope.kind.as_deref(), Some("function"));
214 assert!(func_scope.is_stack_frame, "function scope is a stack frame");
215 assert_eq!(func_scope.variables, vec!["a", "b"]);
216 assert_eq!(func_scope.start, Position { line: 1, column: 0 });
217 assert_eq!(func_scope.end, Position { line: 3, column: 1 });
218
219 println!(
220 " Child: name={:?}, kind={:?}, variables={:?}",
221 func_scope.name, func_scope.kind, func_scope.variables
222 );
223
224 // Verify the generated ranges roundtrip correctly
225 assert_eq!(
226 decoded.ranges.len(),
227 1,
228 "must have one top-level generated range"
229 );
230
231 let wrapper = &decoded.ranges[0];
232 assert_eq!(wrapper.definition, Some(0));
233 assert!(!wrapper.is_stack_frame);
234 assert!(!wrapper.is_hidden);
235 assert!(wrapper.call_site.is_none());
236 assert_eq!(
237 wrapper.bindings,
238 vec![Binding::Expression("result".to_string())]
239 );
240
241 println!("\nGenerated ranges:");
242 println!(
243 " Wrapper: lines {}-{}, definition={:?}, bindings={:?}",
244 wrapper.start.line, wrapper.end.line, wrapper.definition, wrapper.bindings
245 );
246
247 assert_eq!(wrapper.children.len(), 1, "wrapper has one child range");
248
249 let inlined = &wrapper.children[0];
250 assert_eq!(inlined.definition, Some(1));
251 assert!(inlined.is_stack_frame, "inlined range is a stack frame");
252 assert!(!inlined.is_hidden);
253 assert_eq!(
254 inlined.call_site,
255 Some(CallSite {
256 source_index: 0,
257 line: 5,
258 column: 14,
259 })
260 );
261 assert_eq!(
262 inlined.bindings,
263 vec![
264 Binding::Expression("_a".to_string()),
265 Binding::Expression("_b".to_string()),
266 ]
267 );
268
269 println!(
270 " Inlined: lines {}-{}, definition={:?}, call_site={:?}, bindings={:?}",
271 inlined.start.line,
272 inlined.end.line,
273 inlined.definition,
274 inlined.call_site,
275 inlined.bindings
276 );
277
278 // Full structural equality check
279 assert_eq!(
280 decoded, scope_info,
281 "decoded scope info must match the original"
282 );
283
284 println!("\nRoundtrip verified: decoded structure matches original.\n");
285
286 // -----------------------------------------------------------------------
287 // 5. Look up original scopes by definition index
288 // -----------------------------------------------------------------------
289 //
290 // A debugger hits a breakpoint in generated code and finds a generated
291 // range with definition=1. It needs to find the corresponding original
292 // scope to know the function name, parameter names, etc.
293
294 println!("--- Definition index lookups ---\n");
295
296 let scope_0 = decoded
297 .original_scope_for_definition(0)
298 .expect("definition 0 must exist");
299 assert_eq!(scope_0.kind.as_deref(), Some("module"));
300 println!(
301 " definition 0: kind={:?}, name={:?}",
302 scope_0.kind, scope_0.name
303 );
304
305 let scope_1 = decoded
306 .original_scope_for_definition(1)
307 .expect("definition 1 must exist");
308 assert_eq!(scope_1.name.as_deref(), Some("add"));
309 assert_eq!(scope_1.variables, vec!["a", "b"]);
310 println!(
311 " definition 1: kind={:?}, name={:?}",
312 scope_1.kind, scope_1.name
313 );
314
315 // Out-of-bounds definition index returns None
316 assert!(
317 decoded.original_scope_for_definition(99).is_none(),
318 "non-existent definition must return None"
319 );
320 println!(" definition 99: None (out of bounds)");
321
322 // -----------------------------------------------------------------------
323 // 6. Demonstrate the Unavailable binding variant
324 // -----------------------------------------------------------------------
325 //
326 // Sometimes a variable is optimized out entirely. The debugger should
327 // show it as "unavailable" rather than silently omitting it.
328
329 println!("\n--- Unavailable binding ---\n");
330
331 let optimized_info = ScopeInfo {
332 scopes: vec![Some(OriginalScope {
333 start: Position { line: 0, column: 0 },
334 end: Position { line: 3, column: 1 },
335 name: Some("compute".to_string()),
336 kind: Some("function".to_string()),
337 is_stack_frame: true,
338 variables: vec!["x".to_string(), "y".to_string()],
339 children: vec![],
340 })],
341 ranges: vec![GeneratedRange {
342 start: Position { line: 0, column: 0 },
343 end: Position { line: 1, column: 0 },
344 is_stack_frame: true,
345 is_hidden: false,
346 definition: Some(0),
347 call_site: None,
348 // x is available as "_x", but y was optimized out
349 bindings: vec![Binding::Expression("_x".to_string()), Binding::Unavailable],
350 children: vec![],
351 }],
352 };
353
354 let mut opt_names: Vec<String> = vec![];
355 let opt_encoded = encode_scopes(&optimized_info, &mut opt_names);
356 let opt_decoded = decode_scopes(&opt_encoded, &opt_names, 1).expect("decoding must succeed");
357
358 assert_eq!(opt_decoded, optimized_info);
359 println!(" Bindings: {:?}", opt_decoded.ranges[0].bindings);
360 println!(" Variable 'x' -> Expression(\"_x\"), variable 'y' -> Unavailable");
361
362 // -----------------------------------------------------------------------
363 // 7. Error handling for invalid input
364 // -----------------------------------------------------------------------
365 //
366 // decode_scopes returns ScopesError for malformed input. This is useful
367 // for tools that validate source maps.
368
369 println!("\n--- Error handling ---\n");
370
371 // Empty encoded string is valid (no scopes, no ranges)
372 let empty_result = decode_scopes("", &[], 0);
373 assert!(empty_result.is_ok(), "empty input is valid");
374 println!(" Empty input: ok (no scopes, no ranges)");
375
376 // Invalid VLQ data: 'z' is not a valid base64 character for VLQ
377 let bad_vlq = decode_scopes("!!!", &[], 1);
378 assert!(bad_vlq.is_err(), "invalid VLQ must produce an error");
379 println!(" Invalid VLQ (\"!!!\"): {}", bad_vlq.unwrap_err());
380
381 println!("\nAll assertions passed.");
382}