bynk_ts/source_map.rs
1//! Source-map construction (debugging track; ADR 0103). Relocated from
2//! `bynk-emit/src/emitter/source_map.rs` unchanged (P7.5, #1307, Decision B)
3//! — `bynk-emit`'s current lowering keeps calling this exact API, from its
4//! new crate path, for as long as it still splices handler/test-case bodies
5//! through local buffers. [`crate::printer::print`] uses the same type a
6//! second, simpler way: no [`SourceMapBuilder::merge`] call, since the
7//! printer owns one buffer for the whole [`crate::TsProgram`] and never
8//! splices — `record`ing directly from each statement's own span as it
9//! writes is what closes R7.4 ("the source map is produced by the printer
10//! from `TsNode.span`. No phase before the printer records an offset")
11//! structurally, for that path, from this slice.
12//!
13//! It records **checkpoints** — `(byte offset into the generated buffer,
14//! source id, source span)` triples — at statement, match-arm, and
15//! declaration boundaries, and resolves them into a source-map v3 document
16//! once the buffer is complete.
17//!
18//! The design is line-level and statement-anchored (ADR 0103 D1). A
19//! checkpoint marks "every generated line from here until the next
20//! checkpoint maps to this source span" — the nearest-enclosing-statement
21//! rule (D2).
22//!
23//! **v0.70 — spliced bodies and multiple sources.** Free-function bodies
24//! lower straight into the main buffer, so their byte offsets are correct.
25//! Service / agent handler bodies and test-case bodies lower into a *local*
26//! buffer that is later spliced into the module, so their offsets must be
27//! **rebased at the splice**. [`SourceMapBuilder::merge`] does that,
28//! line-anchored so it is correct for both verbatim (`push_str`) and
29//! line-by-line (indent-prepended) splices — both preserve line structure. A
30//! test module aggregates several `.bynk` files, so the builder carries a
31//! list of `sources` and each checkpoint a source id.
32
33use bynk_syntax::span::{LineIndex, Span};
34
35/// Escape a string for interpolation into a JSON string literal. Vendored
36/// from `bynk_project::json_string` (P7.5, #1307, Decision A) rather than
37/// depending on the whole of `bynk-project` for one ~20-line, dependency-free
38/// function — this crate depends on `bynk-syntax` only (`src/lib.rs`'s own
39/// module doc).
40fn json_string(s: &str) -> String {
41 let mut out = String::with_capacity(s.len() + 2);
42 out.push('"');
43 for c in s.chars() {
44 match c {
45 '"' => out.push_str("\\\""),
46 '\\' => out.push_str("\\\\"),
47 '\n' => out.push_str("\\n"),
48 '\r' => out.push_str("\\r"),
49 '\t' => out.push_str("\\t"),
50 // The rest of C0 has no short escape; `\u00xx` is the only form.
51 c if (c as u32) < 0x20 => {
52 use std::fmt::Write as _;
53 let _ = write!(out, "\\u{:04x}", c as u32);
54 }
55 c => out.push(c),
56 }
57 }
58 out.push('"');
59 out
60}
61
62/// Accumulates source-map checkpoints during emission. Lives behind a
63/// `RefCell` on `bynk-emit`'s `LowerCtx` so the deep lowering chain and the
64/// declaration loop can both record without fighting the borrow checker. A
65/// *sub*-builder (one per spliced body) records against its local buffer,
66/// then is [`merge`](Self::merge)d into the module builder at the splice
67/// offset.
68#[derive(Debug, Default, Clone)]
69pub struct SourceMapBuilder {
70 /// `(name, text)` per source file referenced by this map. Index 0 is the
71 /// primary source (the file being emitted); test modules add more.
72 sources: Vec<(String, String)>,
73 /// `(generated byte offset, source id, source span)`, in record order —
74 /// which is *not* guaranteed sorted once `merge` injects a spliced
75 /// body's checkpoints, so `to_v3` sorts by generated line.
76 checkpoints: Vec<(usize, usize, Span)>,
77}
78
79impl SourceMapBuilder {
80 pub fn new() -> Self {
81 Self::default()
82 }
83
84 /// Register a source file, returning its id (index into `sources`).
85 /// Idempotent by name, so a test module that registers the same `.bynk`
86 /// file for several cases reuses one id. The first registered source is
87 /// the primary one (`record` targets it).
88 pub fn add_source(&mut self, name: impl Into<String>, text: impl Into<String>) -> usize {
89 let name = name.into();
90 if let Some(i) = self.sources.iter().position(|(n, _)| *n == name) {
91 return i;
92 }
93 self.sources.push((name, text.into()));
94 self.sources.len() - 1
95 }
96
97 /// Record that generated text from `byte_offset` onward originates at
98 /// `span` in the **primary** source (id 0), until the next checkpoint.
99 /// Callers pass `out.len()` before appending the statement's text.
100 /// Sub-builders use this; `merge` re-tags the source id.
101 pub fn record(&mut self, byte_offset: usize, span: Span) {
102 if let Some(last) = self.checkpoints.last_mut()
103 && last.0 == byte_offset
104 && last.1 == 0
105 {
106 last.2 = span;
107 return;
108 }
109 self.checkpoints.push((byte_offset, 0, span));
110 }
111
112 /// Shift every recorded checkpoint's generated-buffer offset forward by
113 /// `delta` bytes. For a sub-builder whose checkpoints were recorded
114 /// against a local buffer's *pre*-splice contents, call this right after
115 /// text is inserted before byte 0 of that buffer (e.g. `emit_service`'s
116 /// subscriber-filter/schema-gate prologue, #1363) so the checkpoints
117 /// stay correct against the buffer's new contents before [`merge`](Self::merge) reads
118 /// them — `merge` resolves each checkpoint's line against `sub_text` as
119 /// handed to it, which has no way to know a prologue was spliced in
120 /// ahead of the recorded offsets.
121 pub fn shift_checkpoints(&mut self, delta: usize) {
122 for checkpoint in &mut self.checkpoints {
123 checkpoint.0 += delta;
124 }
125 }
126
127 /// Merge a sub-builder's body checkpoints into this module builder,
128 /// rebased to the spliced body's position and re-tagged to `source_id`
129 /// (v0.70).
130 ///
131 /// `sub_text` is the body's local buffer; `out` is the module buffer
132 /// *after* the body has been spliced into it; `base_byte` is
133 /// `out.len()` captured *before* the splice (where the body's first
134 /// line starts in `out`). The merge is **line-anchored**: a checkpoint
135 /// on body line *k* maps to module line `base_line + k`, so it is
136 /// correct whether the splice appended the body verbatim (handlers) or
137 /// rebuilt it line-by-line with indentation (tests) — both preserve
138 /// line structure. Column is irrelevant: maps are line-level.
139 pub fn merge(
140 &mut self,
141 sub: &SourceMapBuilder,
142 sub_text: &str,
143 out: &str,
144 base_byte: usize,
145 source_id: usize,
146 ) {
147 let sub_starts = generated_line_starts(sub_text);
148 let out_starts = generated_line_starts(out);
149 let base_line = line_of_offset(&out_starts, base_byte);
150 for &(off, _sub_src, span) in &sub.checkpoints {
151 let body_line = line_of_offset(&sub_starts, off);
152 let out_line = base_line + body_line;
153 if out_line < out_starts.len() {
154 self.checkpoints
155 .push((out_starts[out_line], source_id, span));
156 }
157 }
158 }
159
160 /// Serialise to a source-map v3 JSON document over the registered
161 /// `sources`. `generated` is the finished buffer; `generated_file` is
162 /// the `file` field. Returns `None` when nothing resolves (no sources,
163 /// or no in-range checkpoint).
164 pub fn to_v3(&self, generated: &str, generated_file: &str) -> Option<String> {
165 if self.sources.is_empty() {
166 return None;
167 }
168 let line_starts = generated_line_starts(generated);
169 // One line index per source, built once: a checkpoint resolves per
170 // emitted line, so `line_col`'s scan-from-0 would be O(checkpoints ×
171 // source size) (#732). Binary-search each lookup instead.
172 let source_indexes: Vec<LineIndex> = self
173 .sources
174 .iter()
175 .map(|(_, t)| LineIndex::new(t))
176 .collect();
177 // Resolve each checkpoint to (generated line, source id, source line, col),
178 // all 0-based. Drop any whose span falls outside its source's text (a
179 // defensive guard for multi-file aggregation).
180 let mut resolved: Vec<(usize, usize, usize, usize)> = Vec::new();
181 for &(offset, src, span) in &self.checkpoints {
182 let Some((_, text)) = self.sources.get(src) else {
183 continue;
184 };
185 if span.start > text.len() {
186 continue;
187 }
188 let gen_line = line_of_offset(&line_starts, offset);
189 let (sl, sc) = source_indexes[src].line_col(text, span.start);
190 resolved.push((gen_line, src, sl - 1, sc - 1));
191 }
192 if resolved.is_empty() {
193 return None;
194 }
195 // Stable sort by generated line: `merge` injects body checkpoints out of
196 // record order, and the forward pass below needs ascending lines. For ties
197 // the later-recorded (more specific) checkpoint stays last and wins.
198 resolved.sort_by_key(|r| r.0);
199
200 let total_lines = line_starts.len();
201 let first_line = resolved[0].0;
202
203 let mut mappings = String::new();
204 for _ in 0..first_line {
205 mappings.push(';');
206 }
207 // Previous-segment state for VLQ delta encoding (source id, source line,
208 // source column persist across lines; generated column is always 0 here).
209 let (mut prev_src, mut prev_sl, mut prev_sc) = (0i64, 0i64, 0i64);
210 let mut ri = 0usize;
211 for line in first_line..total_lines {
212 if line > first_line {
213 mappings.push(';');
214 }
215 while ri + 1 < resolved.len() && resolved[ri + 1].0 <= line {
216 ri += 1;
217 }
218 let (_, src, sl, sc) = resolved[ri];
219 let (src, sl, sc) = (src as i64, sl as i64, sc as i64);
220 vlq_encode(0, &mut mappings); // generated column 0
221 vlq_encode(src - prev_src, &mut mappings);
222 vlq_encode(sl - prev_sl, &mut mappings);
223 vlq_encode(sc - prev_sc, &mut mappings);
224 prev_src = src;
225 prev_sl = sl;
226 prev_sc = sc;
227 }
228
229 let names = self
230 .sources
231 .iter()
232 .map(|(n, _)| json_string(n))
233 .collect::<Vec<_>>()
234 .join(",");
235 let contents = self
236 .sources
237 .iter()
238 .map(|(_, t)| json_string(t))
239 .collect::<Vec<_>>()
240 .join(",");
241 Some(format!(
242 "{{\"version\":3,\"file\":{},\"sources\":[{}],\"sourcesContent\":[{}],\"names\":[],\"mappings\":{}}}",
243 json_string(generated_file),
244 names,
245 contents,
246 json_string(&mappings),
247 ))
248 }
249}
250
251/// Byte offset of the start of each generated line (line 0 starts at 0).
252fn generated_line_starts(s: &str) -> Vec<usize> {
253 let mut starts = vec![0usize];
254 for (i, b) in s.bytes().enumerate() {
255 if b == b'\n' {
256 starts.push(i + 1);
257 }
258 }
259 starts
260}
261
262/// 0-based generated line containing `offset`, by binary search over line starts.
263fn line_of_offset(line_starts: &[usize], offset: usize) -> usize {
264 match line_starts.binary_search(&offset) {
265 Ok(i) => i,
266 Err(i) => i - 1,
267 }
268}
269
270/// Base64 VLQ-encode a single signed value, appending to `out`.
271fn vlq_encode(value: i64, out: &mut String) {
272 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
273 let mut v = if value < 0 {
274 ((-value) as u64) << 1 | 1
275 } else {
276 (value as u64) << 1
277 };
278 loop {
279 let mut digit = (v & 0b11111) as usize;
280 v >>= 5;
281 if v != 0 {
282 digit |= 0b100000;
283 }
284 out.push(B64[digit] as char);
285 if v == 0 {
286 break;
287 }
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 fn build(generated: &str, b: &SourceMapBuilder) -> String {
296 b.to_v3(generated, "x.ts").unwrap()
297 }
298
299 /// Pull the `mappings` string back out of the hand-built JSON document.
300 fn extract_mappings(json: &str) -> String {
301 let key = "\"mappings\":\"";
302 let start = json.find(key).unwrap() + key.len();
303 let rest = &json[start..];
304 rest[..rest.find('"').unwrap()].to_string()
305 }
306
307 /// Decode each generated line's `(source_id, source_line)` (0-based), for the
308 /// single-segment-per-line maps this builder emits.
309 fn decode(mappings: &str) -> Vec<Option<(i64, i64)>> {
310 const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
311 let dec = |s: &str| -> Vec<i64> {
312 let mut out = Vec::new();
313 let (mut shift, mut acc) = (0i64, 0i64);
314 for &c in s.as_bytes() {
315 let d = B64.iter().position(|&b| b == c).unwrap() as i64;
316 acc += (d & 0b11111) << shift;
317 if d & 0b100000 != 0 {
318 shift += 5;
319 } else {
320 out.push(if acc & 1 == 1 { -(acc >> 1) } else { acc >> 1 });
321 shift = 0;
322 acc = 0;
323 }
324 }
325 out
326 };
327 let (mut src, mut sl) = (0i64, 0i64);
328 let mut lines = Vec::new();
329 for seg in mappings.split(';') {
330 if seg.is_empty() {
331 lines.push(None);
332 continue;
333 }
334 let f = dec(seg); // [genCol, srcIdxDelta, srcLineDelta, srcCol]
335 src += f[1];
336 sl += f[2];
337 lines.push(Some((src, sl)));
338 }
339 lines
340 }
341
342 #[test]
343 fn vlq_round_numbers() {
344 let mut s = String::new();
345 for (v, want) in [(0, "A"), (1, "C"), (-1, "D"), (16, "gB")] {
346 s.clear();
347 vlq_encode(v, &mut s);
348 assert_eq!(s, want);
349 }
350 }
351
352 #[test]
353 fn single_source_maps_each_line_to_nearest_checkpoint() {
354 let source = "header\nstmtA\nstmtB\n";
355 let off_a = source.find("stmtA").unwrap();
356 let off_b = source.find("stmtB").unwrap();
357 let generated = "g0\ng1\ng2\ng3\n";
358 let g2 = generated.find("g2").unwrap();
359
360 let mut b = SourceMapBuilder::new();
361 b.add_source("x.bynk", source);
362 b.record(0, Span::new(off_a, off_a + 5)); // gen line 0 -> src line 1 (0-based)
363 b.record(g2, Span::new(off_b, off_b + 5)); // gen line 2 -> src line 2
364
365 let json = build(generated, &b);
366 assert!(json.contains("\"sources\":[\"x.bynk\"]"));
367 let lines = decode(&extract_mappings(&json));
368 // All from source 0; lines 1,1,2,2,(trailing 2).
369 assert_eq!(
370 lines,
371 vec![
372 Some((0, 1)),
373 Some((0, 1)),
374 Some((0, 2)),
375 Some((0, 2)),
376 Some((0, 2))
377 ]
378 );
379 }
380
381 #[test]
382 fn merge_line_anchors_a_spliced_body_under_a_second_source() {
383 // Module buffer: a declaration line from source 0, then a spliced body
384 // (two lines) from source 1, indent-prepended (line-by-line splice).
385 let mod_src = "service api\n";
386 let body_src = " let a = 1\n let b = 2\n"; // the .bynk body, source 1
387 let off_a = body_src.find("let a").unwrap();
388 let off_b = body_src.find("let b").unwrap();
389
390 let mut module = SourceMapBuilder::new();
391 let s0 = module.add_source("mod.bynk", mod_src);
392 assert_eq!(s0, 0);
393 // The module's own declaration checkpoint (source 0, line 1).
394 module.record(0, Span::new(0, 7));
395
396 // Lower the body into a local buffer with a sub-builder.
397 let sub_text = "const a = 1;\nconst b = 2;\n";
398 let mut sub = SourceMapBuilder::new();
399 sub.record(0, Span::new(off_a, off_a + 5)); // body line 0 -> body src line 0
400 let bl1 = sub_text.find("const b").unwrap();
401 sub.record(bl1, Span::new(off_b, off_b + 5)); // body line 1 -> body src line 1
402
403 // Splice the body into the module buffer line-by-line with indentation
404 // (mirrors the real test-body splice), capturing base before the splice.
405 let mut out = String::from("export const api = {};\n"); // gen line 0
406 let base = out.len();
407 for line in sub_text.lines() {
408 out.push_str(" ");
409 out.push_str(line);
410 out.push('\n');
411 }
412 // gen line 1 <- body line 0, gen line 2 <- body line 1.
413 let s1 = module.add_source("body.bynk", body_src);
414 module.merge(&sub, sub_text, &out, base, s1);
415
416 let json = module.to_v3(&out, "mod.ts").unwrap();
417 assert!(json.contains("\"sources\":[\"mod.bynk\",\"body.bynk\"]"));
418 let lines = decode(&extract_mappings(&json));
419 // gen 0 -> (src0, line0); gen 1 -> (src1, line0); gen 2 -> (src1, line1).
420 assert_eq!(lines[0], Some((0, 0)));
421 assert_eq!(lines[1], Some((1, 0)));
422 assert_eq!(lines[2], Some((1, 1)));
423 }
424
425 #[test]
426 fn shift_checkpoints_keeps_a_prepended_prologue_from_shifting_body_lines() {
427 // Mirrors `emit_service`'s own sequence (#1363): a sub-builder records
428 // checkpoints against the body's pre-insert text, a one-line prologue
429 // is then prepended to the *body buffer itself* (not `sub`'s view of
430 // it), and `merge` is finally called with the post-insert buffer.
431 let body_src = " let a = 1\n let b = 2\n"; // the .bynk body
432 let off_a = body_src.find("let a").unwrap();
433 let off_b = body_src.find("let b").unwrap();
434
435 let mut sub = SourceMapBuilder::new();
436 let mut body_out = "const a = 1;\nconst b = 2;\n".to_string();
437 sub.record(0, Span::new(off_a, off_a + 5)); // body line 0 -> body src line 0
438 let bl1 = body_out.find("const b").unwrap();
439 sub.record(bl1, Span::new(off_b, off_b + 5)); // body line 1 -> body src line 1
440
441 // The prologue insert `emit_service` does, plus the rebase this fix adds.
442 let prologue = "if (!(guard)) return undefined;\n";
443 body_out.insert_str(0, prologue);
444 sub.shift_checkpoints(prologue.len());
445
446 let mut module = SourceMapBuilder::new();
447 let s0 = module.add_source("body.bynk", body_src);
448 let mut out = String::from("export const api = {\n"); // gen line 0
449 let base = out.len();
450 out.push_str(&body_out); // verbatim splice, as emit_service does
451 out.push_str("};\n");
452 module.merge(&sub, &body_out, &out, base, s0);
453
454 let json = module.to_v3(&out, "mod.ts").unwrap();
455 let lines = decode(&extract_mappings(&json));
456 // gen 1 is the prologue line (still tagged from the nearest-preceding
457 // checkpoint); gen 2 is `const a = 1;` -> src line 0; gen 3 is
458 // `const b = 2;` -> src line 1 — not shifted onto each other's lines.
459 assert_eq!(
460 lines[2],
461 Some((0, 0)),
462 "body's own first statement keeps its real source line"
463 );
464 assert_eq!(
465 lines[3],
466 Some((0, 1)),
467 "body's own second statement keeps its real source line"
468 );
469 }
470
471 #[test]
472 fn empty_builder_serialises_to_none() {
473 assert!(SourceMapBuilder::new().to_v3("x\n", "x.ts").is_none());
474 }
475
476 #[test]
477 fn json_string_escapes_control_characters_and_quotes() {
478 assert_eq!(json_string("a\"b"), "\"a\\\"b\"");
479 assert_eq!(json_string("a\\b"), "\"a\\\\b\"");
480 assert_eq!(json_string("a\nb"), "\"a\\nb\"");
481 assert_eq!(json_string("a\u{1}b"), "\"a\\u0001b\"");
482 assert_eq!(json_string("plain"), "\"plain\"");
483 }
484}