Skip to content

Commit 2aedad9

Browse files
Brooooooklynclaude
andcommitted
fix: populate source map in transformAngularFile when sourcemap option is enabled
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 2aedad9

File tree

3 files changed

+344
-6
lines changed

3 files changed

+344
-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: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,180 @@ 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. Find each unchanged segment in the actual output and generate mappings.
331+
// Unchanged segments appear in the same order in the output, so we scan forward.
332+
let mut output_search_pos: usize = 0;
333+
for (src_start, src_end) in &unchanged {
334+
let segment = &source[*src_start as usize..*src_end as usize];
335+
if segment.is_empty() {
336+
continue;
337+
}
338+
// Find this exact segment in the output, searching forward from last match
339+
if let Some(found_offset) = output[output_search_pos..].find(segment) {
340+
let output_byte_pos = output_search_pos + found_offset;
341+
let (src_line, src_col) = byte_offset_to_line_col_utf16(source, *src_start as usize);
342+
let (out_line, out_col) = byte_offset_to_line_col_utf16(output, output_byte_pos);
343+
add_line_mappings_for_segment(
344+
&mut builder,
345+
segment,
346+
out_line,
347+
out_col,
348+
src_line,
349+
src_col,
350+
);
351+
output_search_pos = output_byte_pos + segment.len();
352+
}
353+
}
354+
355+
builder.into_sourcemap().to_json_string()
356+
}
357+
358+
/// Compute line and column (UTF-16 code units) for a byte offset in a string.
359+
///
360+
/// Source map columns must be in UTF-16 code units per the spec and `oxc_sourcemap`
361+
/// convention. For ASCII this equals byte offset; for multi-byte characters
362+
/// (e.g., `ɵ` U+0275 = 2 UTF-8 bytes but 1 UTF-16 code unit) the values differ.
363+
fn byte_offset_to_line_col_utf16(source: &str, offset: usize) -> (u32, u32) {
364+
let mut line: u32 = 0;
365+
let mut col: u32 = 0;
366+
for (i, ch) in source.char_indices() {
367+
if i >= offset {
368+
break;
369+
}
370+
if ch == '\n' {
371+
line += 1;
372+
col = 0;
373+
} else {
374+
col += ch.len_utf16() as u32;
375+
}
376+
}
377+
(line, col)
378+
}
379+
380+
/// Add source map mappings for an unchanged segment of source code.
381+
///
382+
/// Adds a mapping at the start of the segment and at the beginning of each new line.
383+
fn add_line_mappings_for_segment(
384+
builder: &mut oxc_sourcemap::SourceMapBuilder,
385+
segment: &str,
386+
mut out_line: u32,
387+
mut out_col: u32,
388+
mut src_line: u32,
389+
mut src_col: u32,
390+
) {
391+
// Add mapping at the start of this segment
392+
builder.add_token(out_line, out_col, src_line, src_col, Some(0), None);
393+
394+
for ch in segment.chars() {
395+
if ch == '\n' {
396+
out_line += 1;
397+
out_col = 0;
398+
src_line += 1;
399+
src_col = 0;
400+
// Add mapping at the start of each new line
401+
builder.add_token(out_line, out_col, src_line, src_col, Some(0), None);
402+
}
403+
}
404+
}
405+
232406
#[cfg(test)]
233407
mod tests {
234408
use super::*;

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6356,3 +6356,150 @@ export class TestComponent {
63566356

63576357
insta::assert_snapshot!("jit_union_type_ctor_params", result.code);
63586358
}
6359+
6360+
// =========================================================================
6361+
// Source map tests
6362+
// =========================================================================
6363+
6364+
#[test]
6365+
fn test_sourcemap_aot_mode() {
6366+
// Issue #99: transformAngularFile should return a source map when sourcemap: true
6367+
let allocator = Allocator::default();
6368+
let source = r#"import { Component } from '@angular/core';
6369+
6370+
@Component({
6371+
selector: 'app-test',
6372+
template: '<h1>Hello World</h1>',
6373+
standalone: true,
6374+
})
6375+
export class TestComponent {
6376+
}
6377+
"#;
6378+
6379+
let options = ComponentTransformOptions { sourcemap: true, ..Default::default() };
6380+
6381+
let result = transform_angular_file(&allocator, "app.component.ts", source, &options, None);
6382+
6383+
assert!(
6384+
result.map.is_some(),
6385+
"AOT mode should return a source map when sourcemap: true, but map was None"
6386+
);
6387+
6388+
let map = result.map.unwrap();
6389+
// Verify it's valid JSON
6390+
assert!(
6391+
map.starts_with('{'),
6392+
"Source map should be valid JSON, got: {}",
6393+
&map[..50.min(map.len())]
6394+
);
6395+
// Verify it contains expected sourcemap fields
6396+
assert!(map.contains("\"version\":3"), "Source map should have version 3");
6397+
assert!(map.contains("\"mappings\""), "Source map should have mappings");
6398+
assert!(map.contains("app.component.ts"), "Source map should reference the source file");
6399+
}
6400+
6401+
#[test]
6402+
fn test_sourcemap_jit_mode() {
6403+
// Issue #99: JIT mode should also return a source map when sourcemap: true
6404+
let allocator = Allocator::default();
6405+
let source = r#"import { Component } from '@angular/core';
6406+
6407+
@Component({
6408+
selector: 'app-test',
6409+
template: '<h1>Hello World</h1>',
6410+
standalone: true,
6411+
})
6412+
export class TestComponent {
6413+
}
6414+
"#;
6415+
6416+
let options = ComponentTransformOptions { sourcemap: true, jit: true, ..Default::default() };
6417+
6418+
let result = transform_angular_file(&allocator, "app.component.ts", source, &options, None);
6419+
6420+
assert!(
6421+
result.map.is_some(),
6422+
"JIT mode should return a source map when sourcemap: true, but map was None"
6423+
);
6424+
6425+
let map = result.map.unwrap();
6426+
assert!(map.starts_with('{'), "Source map should be valid JSON");
6427+
assert!(map.contains("\"version\":3"), "Source map should have version 3");
6428+
}
6429+
6430+
#[test]
6431+
fn test_sourcemap_disabled_by_default() {
6432+
// When sourcemap is false (default), map should be None
6433+
let allocator = Allocator::default();
6434+
let source = r#"import { Component } from '@angular/core';
6435+
6436+
@Component({
6437+
selector: 'app-test',
6438+
template: '<h1>Hello</h1>',
6439+
standalone: true,
6440+
})
6441+
export class TestComponent {
6442+
}
6443+
"#;
6444+
6445+
let result = transform_angular_file(
6446+
&allocator,
6447+
"app.component.ts",
6448+
source,
6449+
&ComponentTransformOptions::default(),
6450+
None,
6451+
);
6452+
6453+
assert!(result.map.is_none(), "Source map should be None when sourcemap option is false");
6454+
}
6455+
6456+
#[test]
6457+
fn test_sourcemap_with_external_template() {
6458+
// Source map should work with resolved external templates
6459+
let allocator = Allocator::default();
6460+
let source = r#"import { Component } from '@angular/core';
6461+
6462+
@Component({
6463+
selector: 'app-test',
6464+
templateUrl: './app.html',
6465+
standalone: true,
6466+
})
6467+
export class TestComponent {
6468+
}
6469+
"#;
6470+
6471+
let mut templates = std::collections::HashMap::new();
6472+
templates.insert("./app.html".to_string(), "<h1>Hello World</h1>".to_string());
6473+
let resolved = ResolvedResources { templates, styles: std::collections::HashMap::new() };
6474+
6475+
let options = ComponentTransformOptions { sourcemap: true, ..Default::default() };
6476+
6477+
let result =
6478+
transform_angular_file(&allocator, "app.component.ts", source, &options, Some(&resolved));
6479+
6480+
assert!(
6481+
result.map.is_some(),
6482+
"AOT with external template should return a source map when sourcemap: true"
6483+
);
6484+
}
6485+
6486+
#[test]
6487+
fn test_sourcemap_no_angular_classes() {
6488+
// A file with no Angular classes should still return a source map if requested
6489+
let allocator = Allocator::default();
6490+
let source = r#"export class PlainService {
6491+
getData() { return 42; }
6492+
}
6493+
"#;
6494+
6495+
let options = ComponentTransformOptions { sourcemap: true, ..Default::default() };
6496+
6497+
let result = transform_angular_file(&allocator, "plain.ts", source, &options, None);
6498+
6499+
// Even for files with no Angular components, if sourcemap is requested,
6500+
// a trivial identity source map should be returned
6501+
assert!(
6502+
result.map.is_some(),
6503+
"Should return a source map even for files with no Angular classes when sourcemap: true"
6504+
);
6505+
}

0 commit comments

Comments
 (0)