Skip to content

Commit ce744b4

Browse files
Brooooooklynclaude
andauthored
fix: populate source map in transformAngularFile when sourcemap option is enabled (#100)
The edit-based transform pipeline never generated source maps, leaving `TransformResult.map` as `None` even when `sourcemap: true`. Add `apply_edits_with_sourcemap` that produces a source map by analyzing which original byte ranges survive in the output, and wire it into the AOT, JIT, and no-Angular-classes code paths. - Close #99 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7cd9633 commit ce744b4

File tree

3 files changed

+363
-6
lines changed

3 files changed

+363
-6
lines changed

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use oxc_parser::Parser;
1515
use oxc_span::{Atom, GetSpan, SourceType, Span};
1616
use rustc_hash::FxHashMap;
1717

18-
use crate::optimizer::{Edit, apply_edits};
18+
use crate::optimizer::{Edit, apply_edits, apply_edits_with_sourcemap};
1919

2020
#[cfg(feature = "cross_file_elision")]
2121
use super::cross_file_elision::CrossFileAnalyzer;
@@ -1126,7 +1126,7 @@ fn transform_angular_file_jit(
11261126
allocator: &Allocator,
11271127
path: &str,
11281128
source: &str,
1129-
_options: &TransformOptions,
1129+
options: &TransformOptions,
11301130
) -> TransformResult {
11311131
let mut result = TransformResult::new();
11321132

@@ -1213,7 +1213,13 @@ fn transform_angular_file_jit(
12131213

12141214
if jit_classes.is_empty() {
12151215
// No Angular classes found, return source as-is
1216-
result.code = source.to_string();
1216+
if options.sourcemap {
1217+
let (code, map) = apply_edits_with_sourcemap(source, vec![], path);
1218+
result.code = code;
1219+
result.map = map;
1220+
} else {
1221+
result.code = source.to_string();
1222+
}
12171223
return result;
12181224
}
12191225

@@ -1359,7 +1365,13 @@ fn transform_angular_file_jit(
13591365
}
13601366

13611367
// Apply all edits
1362-
result.code = apply_edits(source, edits);
1368+
if options.sourcemap {
1369+
let (code, map) = apply_edits_with_sourcemap(source, edits, path);
1370+
result.code = code;
1371+
result.map = map;
1372+
} else {
1373+
result.code = apply_edits(source, edits);
1374+
}
13631375

13641376
result
13651377
}
@@ -2125,8 +2137,13 @@ pub fn transform_angular_file(
21252137
}
21262138

21272139
// Apply all edits in one pass
2128-
result.code = apply_edits(source, edits);
2129-
result.map = None;
2140+
if options.sourcemap {
2141+
let (code, map) = apply_edits_with_sourcemap(source, edits, path);
2142+
result.code = code;
2143+
result.map = map;
2144+
} else {
2145+
result.code = apply_edits(source, edits);
2146+
}
21302147

21312148
result
21322149
}

crates/oxc_angular_compiler/src/optimizer/mod.rs

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,199 @@ pub fn apply_edits(code: &str, mut edits: Vec<Edit>) -> String {
229229
result
230230
}
231231

232+
/// Apply edits to source code and generate a source map.
233+
///
234+
/// Uses the same edit-application algorithm as `apply_edits`, then generates
235+
/// a source map by finding where unchanged source segments appear in the
236+
/// actual output — guaranteeing the sourcemap is consistent with the output
237+
/// regardless of edit ordering.
238+
pub fn apply_edits_with_sourcemap(
239+
code: &str,
240+
edits: Vec<Edit>,
241+
filename: &str,
242+
) -> (String, Option<String>) {
243+
// Generate the output using the existing algorithm
244+
let output = apply_edits(code, edits.clone());
245+
246+
// Generate sourcemap by finding unchanged source segments in the actual output
247+
let map = generate_sourcemap_from_edits(code, &output, edits, filename);
248+
(output, Some(map))
249+
}
250+
251+
/// Generate a source map by finding unchanged source segments in the actual output.
252+
///
253+
/// Instead of independently modeling how edits transform positions (which could
254+
/// diverge from `apply_edits`'s reverse-order mutating algorithm), this function:
255+
/// 1. Computes which source byte ranges are untouched by any edit
256+
/// 2. Locates each unchanged segment in the actual output string
257+
/// 3. Generates identity mappings for those segments
258+
///
259+
/// This guarantees the sourcemap is always consistent with the actual output.
260+
fn generate_sourcemap_from_edits(
261+
source: &str,
262+
output: &str,
263+
edits: Vec<Edit>,
264+
filename: &str,
265+
) -> String {
266+
let mut builder = oxc_sourcemap::SourceMapBuilder::default();
267+
builder.set_source_and_content(filename, source);
268+
269+
if edits.is_empty() {
270+
// Identity mapping — every line maps 1:1
271+
add_line_mappings_for_segment(&mut builder, source, 0, 0, 0, 0);
272+
return builder.into_sourcemap().to_json_string();
273+
}
274+
275+
// 1. Collect all edit boundary positions.
276+
// Every edit start/end position is a point where the output may differ from
277+
// the source. We need to split unchanged ranges at ALL edit positions —
278+
// including pure insertions (start == end) — because insertions embed new
279+
// text within what would otherwise be a contiguous source segment, breaking
280+
// `find(segment)` in step 3.
281+
let code_len = source.len() as u32;
282+
let mut boundary_points: Vec<u32> = Vec::new();
283+
let mut deleted_ranges: Vec<(u32, u32)> = Vec::new();
284+
285+
for edit in &edits {
286+
if edit.start > code_len || edit.end > code_len || edit.start > edit.end {
287+
continue;
288+
}
289+
boundary_points.push(edit.start);
290+
boundary_points.push(edit.end);
291+
if edit.start < edit.end {
292+
deleted_ranges.push((edit.start, edit.end));
293+
}
294+
}
295+
296+
boundary_points.push(0);
297+
boundary_points.push(code_len);
298+
boundary_points.sort_unstable();
299+
boundary_points.dedup();
300+
301+
// Merge overlapping deleted ranges for quick overlap checks
302+
deleted_ranges.sort_by_key(|r| r.0);
303+
let mut merged_deleted: Vec<(u32, u32)> = Vec::new();
304+
for (s, e) in deleted_ranges {
305+
if let Some(last) = merged_deleted.last_mut() {
306+
if s <= last.1 {
307+
last.1 = last.1.max(e);
308+
continue;
309+
}
310+
}
311+
merged_deleted.push((s, e));
312+
}
313+
314+
// 2. Compute unchanged source sub-ranges.
315+
// A sub-range [boundary[i], boundary[i+1]) is unchanged if it doesn't
316+
// overlap with any deletion range.
317+
let mut unchanged: Vec<(u32, u32)> = Vec::new();
318+
for window in boundary_points.windows(2) {
319+
let (start, end) = (window[0], window[1]);
320+
if start >= end {
321+
continue;
322+
}
323+
// Check if this sub-range overlaps with any merged deletion
324+
let overlaps = merged_deleted.iter().any(|(del_s, del_e)| start < *del_e && end > *del_s);
325+
if !overlaps {
326+
unchanged.push((start, end));
327+
}
328+
}
329+
330+
// 3. Compute the output byte offset for each unchanged segment and generate mappings.
331+
// Instead of using string search (which can false-match replacement text for
332+
// short segments like `}`), we compute the exact output position using the
333+
// edit shift formula:
334+
// output_pos(S) = S + Σ (replacement.len() - (end - start))
335+
// for all edits where end <= S
336+
// This is exact for non-overlapping edits.
337+
338+
// Precompute edit shifts sorted by end position for efficient prefix-sum lookup
339+
let mut edit_shifts: Vec<(u32, i64)> = edits
340+
.iter()
341+
.filter(|e| e.start <= code_len && e.end <= code_len && e.start <= e.end)
342+
.map(|e| (e.end, e.replacement.len() as i64 - (e.end as i64 - e.start as i64)))
343+
.collect();
344+
edit_shifts.sort_by_key(|(end, _)| *end);
345+
346+
for (src_start, src_end) in &unchanged {
347+
let segment = &source[*src_start as usize..*src_end as usize];
348+
if segment.is_empty() {
349+
continue;
350+
}
351+
// Compute output byte offset: src_start + net shift from all edits ending at or before src_start
352+
let net_shift: i64 = edit_shifts
353+
.iter()
354+
.take_while(|(end, _)| *end <= *src_start)
355+
.map(|(_, shift)| shift)
356+
.sum();
357+
let output_byte_pos = (*src_start as i64 + net_shift) as usize;
358+
359+
debug_assert!(
360+
output_byte_pos + segment.len() <= output.len()
361+
&& &output[output_byte_pos..output_byte_pos + segment.len()] == segment,
362+
"Sourcemap: computed output position {output_byte_pos} does not match \
363+
segment {:?} (src {}..{})",
364+
&segment[..segment.len().min(20)],
365+
src_start,
366+
src_end,
367+
);
368+
369+
let (src_line, src_col) = byte_offset_to_line_col_utf16(source, *src_start as usize);
370+
let (out_line, out_col) = byte_offset_to_line_col_utf16(output, output_byte_pos);
371+
add_line_mappings_for_segment(&mut builder, segment, out_line, out_col, src_line, src_col);
372+
}
373+
374+
builder.into_sourcemap().to_json_string()
375+
}
376+
377+
/// Compute line and column (UTF-16 code units) for a byte offset in a string.
378+
///
379+
/// Source map columns must be in UTF-16 code units per the spec and `oxc_sourcemap`
380+
/// convention. For ASCII this equals byte offset; for multi-byte characters
381+
/// (e.g., `ɵ` U+0275 = 2 UTF-8 bytes but 1 UTF-16 code unit) the values differ.
382+
fn byte_offset_to_line_col_utf16(source: &str, offset: usize) -> (u32, u32) {
383+
let mut line: u32 = 0;
384+
let mut col: u32 = 0;
385+
for (i, ch) in source.char_indices() {
386+
if i >= offset {
387+
break;
388+
}
389+
if ch == '\n' {
390+
line += 1;
391+
col = 0;
392+
} else {
393+
col += ch.len_utf16() as u32;
394+
}
395+
}
396+
(line, col)
397+
}
398+
399+
/// Add source map mappings for an unchanged segment of source code.
400+
///
401+
/// Adds a mapping at the start of the segment and at the beginning of each new line.
402+
fn add_line_mappings_for_segment(
403+
builder: &mut oxc_sourcemap::SourceMapBuilder,
404+
segment: &str,
405+
mut out_line: u32,
406+
mut out_col: u32,
407+
mut src_line: u32,
408+
mut src_col: u32,
409+
) {
410+
// Add mapping at the start of this segment
411+
builder.add_token(out_line, out_col, src_line, src_col, Some(0), None);
412+
413+
for ch in segment.chars() {
414+
if ch == '\n' {
415+
out_line += 1;
416+
out_col = 0;
417+
src_line += 1;
418+
src_col = 0;
419+
// Add mapping at the start of each new line
420+
builder.add_token(out_line, out_col, src_line, src_col, Some(0), None);
421+
}
422+
}
423+
}
424+
232425
#[cfg(test)]
233426
mod tests {
234427
use super::*;

0 commit comments

Comments
 (0)