Skip to main content

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}