Skip to content

Commit 1085a7c

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 1085a7c

File tree

3 files changed

+334
-6
lines changed

3 files changed

+334
-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: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,170 @@ 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 computing which byte ranges of the original source survive
236+
/// in the output and where they appear.
237+
pub fn apply_edits_with_sourcemap(
238+
code: &str,
239+
edits: Vec<Edit>,
240+
filename: &str,
241+
) -> (String, Option<String>) {
242+
// Generate the output using the existing algorithm
243+
let output = apply_edits(code, edits.clone());
244+
245+
// Generate sourcemap by analyzing how edits transform the source
246+
let map = generate_sourcemap_from_edits(code, &output, edits, filename);
247+
(output, Some(map))
248+
}
249+
250+
/// Generate a source map by analyzing how edits transform source positions.
251+
///
252+
/// This works by normalizing and flattening all edits into non-overlapping ranges,
253+
/// then walking through the source to determine which original byte ranges
254+
/// appear in the output and at what positions.
255+
fn generate_sourcemap_from_edits(
256+
source: &str,
257+
_output: &str,
258+
mut edits: Vec<Edit>,
259+
filename: &str,
260+
) -> String {
261+
let mut builder = oxc_sourcemap::SourceMapBuilder::default();
262+
builder.set_source_and_content(filename, source);
263+
264+
if edits.is_empty() {
265+
// Identity mapping
266+
add_line_mappings_for_segment(&mut builder, source, 0, 0, 0, 0);
267+
return builder.into_sourcemap().to_json_string();
268+
}
269+
270+
// Sort edits ascending by start, then by priority
271+
edits.sort_by(|a, b| match a.start.cmp(&b.start) {
272+
std::cmp::Ordering::Equal => a.priority.cmp(&b.priority),
273+
other => other,
274+
});
275+
276+
// Flatten edits into non-overlapping "consumed source ranges" and their replacements.
277+
// The key insight: the old apply_edits processes edits reverse, mutating the string.
278+
// We need to figure out the net effect: which source byte ranges are kept, which are
279+
// replaced, and what text appears in their place.
280+
//
281+
// We merge edits that target the same source position or overlap.
282+
let mut merged: Vec<(u32, u32, String)> = Vec::new(); // (src_start, src_end, replacement)
283+
let code_len = source.len() as u32;
284+
285+
for edit in &edits {
286+
if edit.start > code_len || edit.end > code_len || edit.start > edit.end {
287+
continue;
288+
}
289+
if let Some(last) = merged.last_mut() {
290+
if edit.start <= last.1 {
291+
// Overlapping or adjacent: extend the range and append replacement
292+
last.1 = last.1.max(edit.end);
293+
last.2.push_str(&edit.replacement);
294+
continue;
295+
}
296+
}
297+
merged.push((edit.start, edit.end, edit.replacement.clone()));
298+
}
299+
300+
// Now walk through source with the merged edits to build the sourcemap
301+
let mut src_pos: u32 = 0;
302+
let mut out_line: u32 = 0;
303+
let mut out_col: u32 = 0;
304+
305+
for (edit_start, edit_end, replacement) in &merged {
306+
// Unchanged segment before this edit
307+
if *edit_start > src_pos {
308+
let segment = &source[src_pos as usize..*edit_start as usize];
309+
let (src_line, src_col) = byte_offset_to_line_col(source, src_pos as usize);
310+
add_line_mappings_for_segment(
311+
&mut builder,
312+
segment,
313+
out_line,
314+
out_col,
315+
src_line,
316+
src_col,
317+
);
318+
// Advance output position
319+
for ch in segment.chars() {
320+
if ch == '\n' {
321+
out_line += 1;
322+
out_col = 0;
323+
} else {
324+
out_col += ch.len_utf8() as u32;
325+
}
326+
}
327+
}
328+
329+
// Replacement text (no source mapping)
330+
for ch in replacement.chars() {
331+
if ch == '\n' {
332+
out_line += 1;
333+
out_col = 0;
334+
} else {
335+
out_col += ch.len_utf8() as u32;
336+
}
337+
}
338+
339+
src_pos = *edit_end;
340+
}
341+
342+
// Remaining unchanged source after the last edit
343+
if (src_pos as usize) < source.len() {
344+
let segment = &source[src_pos as usize..];
345+
let (src_line, src_col) = byte_offset_to_line_col(source, src_pos as usize);
346+
add_line_mappings_for_segment(&mut builder, segment, out_line, out_col, src_line, src_col);
347+
}
348+
349+
builder.into_sourcemap().to_json_string()
350+
}
351+
352+
/// Compute line and column for a byte offset in the source string.
353+
fn byte_offset_to_line_col(source: &str, offset: usize) -> (u32, u32) {
354+
let mut line: u32 = 0;
355+
let mut col: u32 = 0;
356+
for (i, ch) in source.char_indices() {
357+
if i >= offset {
358+
break;
359+
}
360+
if ch == '\n' {
361+
line += 1;
362+
col = 0;
363+
} else {
364+
col += ch.len_utf8() as u32;
365+
}
366+
}
367+
(line, col)
368+
}
369+
370+
/// Add source map mappings for an unchanged segment of source code.
371+
///
372+
/// Adds a mapping at the start of the segment and at the beginning of each new line.
373+
fn add_line_mappings_for_segment(
374+
builder: &mut oxc_sourcemap::SourceMapBuilder,
375+
segment: &str,
376+
mut out_line: u32,
377+
mut out_col: u32,
378+
mut src_line: u32,
379+
mut src_col: u32,
380+
) {
381+
// Add mapping at the start of this segment
382+
builder.add_token(out_line, out_col, src_line, src_col, Some(0), None);
383+
384+
for ch in segment.chars() {
385+
if ch == '\n' {
386+
out_line += 1;
387+
out_col = 0;
388+
src_line += 1;
389+
src_col = 0;
390+
// Add mapping at the start of each new line
391+
builder.add_token(out_line, out_col, src_line, src_col, Some(0), None);
392+
}
393+
}
394+
}
395+
232396
#[cfg(test)]
233397
mod tests {
234398
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)