Skip to content

Commit 826d807

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 826d807

File tree

3 files changed

+325
-6
lines changed

3 files changed

+325
-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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,161 @@ 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 source byte ranges consumed by edits (deletions/replacements only).
276+
// Pure insertions (start == end) don't consume source bytes.
277+
let mut deleted_ranges: Vec<(u32, u32)> = edits
278+
.iter()
279+
.filter(|e| {
280+
e.start < e.end && e.start <= source.len() as u32 && e.end <= source.len() as u32
281+
})
282+
.map(|e| (e.start, e.end))
283+
.collect();
284+
deleted_ranges.sort_by_key(|r| r.0);
285+
286+
// Merge overlapping/adjacent deleted ranges
287+
let mut merged_deleted: Vec<(u32, u32)> = Vec::new();
288+
for (s, e) in deleted_ranges {
289+
if let Some(last) = merged_deleted.last_mut() {
290+
if s <= last.1 {
291+
last.1 = last.1.max(e);
292+
continue;
293+
}
294+
}
295+
merged_deleted.push((s, e));
296+
}
297+
298+
// 2. Compute unchanged source ranges (complement of deleted ranges)
299+
let mut unchanged: Vec<(u32, u32)> = Vec::new();
300+
let mut pos: u32 = 0;
301+
for (del_start, del_end) in &merged_deleted {
302+
if *del_start > pos {
303+
unchanged.push((pos, *del_start));
304+
}
305+
pos = *del_end;
306+
}
307+
if pos < source.len() as u32 {
308+
unchanged.push((pos, source.len() as u32));
309+
}
310+
311+
// 3. Find each unchanged segment in the actual output and generate mappings.
312+
// Unchanged segments appear in the same order in the output, so we scan forward.
313+
let mut output_search_pos: usize = 0;
314+
for (src_start, src_end) in &unchanged {
315+
let segment = &source[*src_start as usize..*src_end as usize];
316+
if segment.is_empty() {
317+
continue;
318+
}
319+
// Find this exact segment in the output, searching forward from last match
320+
if let Some(found_offset) = output[output_search_pos..].find(segment) {
321+
let output_byte_pos = output_search_pos + found_offset;
322+
let (src_line, src_col) = byte_offset_to_line_col_utf16(source, *src_start as usize);
323+
let (out_line, out_col) = byte_offset_to_line_col_utf16(output, output_byte_pos);
324+
add_line_mappings_for_segment(
325+
&mut builder,
326+
segment,
327+
out_line,
328+
out_col,
329+
src_line,
330+
src_col,
331+
);
332+
output_search_pos = output_byte_pos + segment.len();
333+
}
334+
}
335+
336+
builder.into_sourcemap().to_json_string()
337+
}
338+
339+
/// Compute line and column (UTF-16 code units) for a byte offset in a string.
340+
///
341+
/// Source map columns must be in UTF-16 code units per the spec and `oxc_sourcemap`
342+
/// convention. For ASCII this equals byte offset; for multi-byte characters
343+
/// (e.g., `ɵ` U+0275 = 2 UTF-8 bytes but 1 UTF-16 code unit) the values differ.
344+
fn byte_offset_to_line_col_utf16(source: &str, offset: usize) -> (u32, u32) {
345+
let mut line: u32 = 0;
346+
let mut col: u32 = 0;
347+
for (i, ch) in source.char_indices() {
348+
if i >= offset {
349+
break;
350+
}
351+
if ch == '\n' {
352+
line += 1;
353+
col = 0;
354+
} else {
355+
col += ch.len_utf16() as u32;
356+
}
357+
}
358+
(line, col)
359+
}
360+
361+
/// Add source map mappings for an unchanged segment of source code.
362+
///
363+
/// Adds a mapping at the start of the segment and at the beginning of each new line.
364+
fn add_line_mappings_for_segment(
365+
builder: &mut oxc_sourcemap::SourceMapBuilder,
366+
segment: &str,
367+
mut out_line: u32,
368+
mut out_col: u32,
369+
mut src_line: u32,
370+
mut src_col: u32,
371+
) {
372+
// Add mapping at the start of this segment
373+
builder.add_token(out_line, out_col, src_line, src_col, Some(0), None);
374+
375+
for ch in segment.chars() {
376+
if ch == '\n' {
377+
out_line += 1;
378+
out_col = 0;
379+
src_line += 1;
380+
src_col = 0;
381+
// Add mapping at the start of each new line
382+
builder.add_token(out_line, out_col, src_line, src_col, Some(0), None);
383+
}
384+
}
385+
}
386+
232387
#[cfg(test)]
233388
mod tests {
234389
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)