Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use oxc_parser::Parser;
use oxc_span::{Atom, GetSpan, SourceType, Span};
use rustc_hash::FxHashMap;

use crate::optimizer::{Edit, apply_edits};
use crate::optimizer::{Edit, apply_edits, apply_edits_with_sourcemap};

#[cfg(feature = "cross_file_elision")]
use super::cross_file_elision::CrossFileAnalyzer;
Expand Down Expand Up @@ -1126,7 +1126,7 @@ fn transform_angular_file_jit(
allocator: &Allocator,
path: &str,
source: &str,
_options: &TransformOptions,
options: &TransformOptions,
) -> TransformResult {
let mut result = TransformResult::new();

Expand Down Expand Up @@ -1213,7 +1213,13 @@ fn transform_angular_file_jit(

if jit_classes.is_empty() {
// No Angular classes found, return source as-is
result.code = source.to_string();
if options.sourcemap {
let (code, map) = apply_edits_with_sourcemap(source, vec![], path);
result.code = code;
result.map = map;
} else {
result.code = source.to_string();
}
return result;
}

Expand Down Expand Up @@ -1359,7 +1365,13 @@ fn transform_angular_file_jit(
}

// Apply all edits
result.code = apply_edits(source, edits);
if options.sourcemap {
let (code, map) = apply_edits_with_sourcemap(source, edits, path);
result.code = code;
result.map = map;
} else {
result.code = apply_edits(source, edits);
}

result
}
Expand Down Expand Up @@ -2125,8 +2137,13 @@ pub fn transform_angular_file(
}

// Apply all edits in one pass
result.code = apply_edits(source, edits);
result.map = None;
if options.sourcemap {
let (code, map) = apply_edits_with_sourcemap(source, edits, path);
result.code = code;
result.map = map;
} else {
result.code = apply_edits(source, edits);
}

result
}
Expand Down
193 changes: 193 additions & 0 deletions crates/oxc_angular_compiler/src/optimizer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,199 @@ pub fn apply_edits(code: &str, mut edits: Vec<Edit>) -> String {
result
}

/// Apply edits to source code and generate a source map.
///
/// Uses the same edit-application algorithm as `apply_edits`, then generates
/// a source map by finding where unchanged source segments appear in the
/// actual output — guaranteeing the sourcemap is consistent with the output
/// regardless of edit ordering.
pub fn apply_edits_with_sourcemap(
code: &str,
edits: Vec<Edit>,
filename: &str,
) -> (String, Option<String>) {
// Generate the output using the existing algorithm
let output = apply_edits(code, edits.clone());

// Generate sourcemap by finding unchanged source segments in the actual output
let map = generate_sourcemap_from_edits(code, &output, edits, filename);
(output, Some(map))
}

/// Generate a source map by finding unchanged source segments in the actual output.
///
/// Instead of independently modeling how edits transform positions (which could
/// diverge from `apply_edits`'s reverse-order mutating algorithm), this function:
/// 1. Computes which source byte ranges are untouched by any edit
/// 2. Locates each unchanged segment in the actual output string
/// 3. Generates identity mappings for those segments
///
/// This guarantees the sourcemap is always consistent with the actual output.
fn generate_sourcemap_from_edits(
source: &str,
output: &str,
edits: Vec<Edit>,
filename: &str,
) -> String {
let mut builder = oxc_sourcemap::SourceMapBuilder::default();
builder.set_source_and_content(filename, source);

if edits.is_empty() {
// Identity mapping — every line maps 1:1
add_line_mappings_for_segment(&mut builder, source, 0, 0, 0, 0);
return builder.into_sourcemap().to_json_string();
}

// 1. Collect all edit boundary positions.
// Every edit start/end position is a point where the output may differ from
// the source. We need to split unchanged ranges at ALL edit positions —
// including pure insertions (start == end) — because insertions embed new
// text within what would otherwise be a contiguous source segment, breaking
// `find(segment)` in step 3.
let code_len = source.len() as u32;
let mut boundary_points: Vec<u32> = Vec::new();
let mut deleted_ranges: Vec<(u32, u32)> = Vec::new();

for edit in &edits {
if edit.start > code_len || edit.end > code_len || edit.start > edit.end {
continue;
}
boundary_points.push(edit.start);
boundary_points.push(edit.end);
if edit.start < edit.end {
deleted_ranges.push((edit.start, edit.end));
}
}

boundary_points.push(0);
boundary_points.push(code_len);
boundary_points.sort_unstable();
boundary_points.dedup();

// Merge overlapping deleted ranges for quick overlap checks
deleted_ranges.sort_by_key(|r| r.0);
let mut merged_deleted: Vec<(u32, u32)> = Vec::new();
for (s, e) in deleted_ranges {
if let Some(last) = merged_deleted.last_mut() {
if s <= last.1 {
last.1 = last.1.max(e);
continue;
}
}
merged_deleted.push((s, e));
}

// 2. Compute unchanged source sub-ranges.
// A sub-range [boundary[i], boundary[i+1]) is unchanged if it doesn't
// overlap with any deletion range.
let mut unchanged: Vec<(u32, u32)> = Vec::new();
for window in boundary_points.windows(2) {
let (start, end) = (window[0], window[1]);
if start >= end {
continue;
}
// Check if this sub-range overlaps with any merged deletion
let overlaps = merged_deleted.iter().any(|(del_s, del_e)| start < *del_e && end > *del_s);
if !overlaps {
unchanged.push((start, end));
}
}

// 3. Compute the output byte offset for each unchanged segment and generate mappings.
// Instead of using string search (which can false-match replacement text for
// short segments like `}`), we compute the exact output position using the
// edit shift formula:
// output_pos(S) = S + Σ (replacement.len() - (end - start))
// for all edits where end <= S
// This is exact for non-overlapping edits.

// Precompute edit shifts sorted by end position for efficient prefix-sum lookup
let mut edit_shifts: Vec<(u32, i64)> = edits
.iter()
.filter(|e| e.start <= code_len && e.end <= code_len && e.start <= e.end)
.map(|e| (e.end, e.replacement.len() as i64 - (e.end as i64 - e.start as i64)))
.collect();
edit_shifts.sort_by_key(|(end, _)| *end);

for (src_start, src_end) in &unchanged {
let segment = &source[*src_start as usize..*src_end as usize];
if segment.is_empty() {
continue;
}
// Compute output byte offset: src_start + net shift from all edits ending at or before src_start
let net_shift: i64 = edit_shifts
.iter()
.take_while(|(end, _)| *end <= *src_start)
.map(|(_, shift)| shift)
.sum();
let output_byte_pos = (*src_start as i64 + net_shift) as usize;

debug_assert!(
output_byte_pos + segment.len() <= output.len()
&& &output[output_byte_pos..output_byte_pos + segment.len()] == segment,
"Sourcemap: computed output position {output_byte_pos} does not match \
segment {:?} (src {}..{})",
&segment[..segment.len().min(20)],
src_start,
src_end,
);

let (src_line, src_col) = byte_offset_to_line_col_utf16(source, *src_start as usize);
let (out_line, out_col) = byte_offset_to_line_col_utf16(output, output_byte_pos);
add_line_mappings_for_segment(&mut builder, segment, out_line, out_col, src_line, src_col);
}

builder.into_sourcemap().to_json_string()
}

/// Compute line and column (UTF-16 code units) for a byte offset in a string.
///
/// Source map columns must be in UTF-16 code units per the spec and `oxc_sourcemap`
/// convention. For ASCII this equals byte offset; for multi-byte characters
/// (e.g., `ɵ` U+0275 = 2 UTF-8 bytes but 1 UTF-16 code unit) the values differ.
fn byte_offset_to_line_col_utf16(source: &str, offset: usize) -> (u32, u32) {
let mut line: u32 = 0;
let mut col: u32 = 0;
for (i, ch) in source.char_indices() {
if i >= offset {
break;
}
if ch == '\n' {
line += 1;
col = 0;
} else {
col += ch.len_utf16() as u32;
}
}
(line, col)
}

/// Add source map mappings for an unchanged segment of source code.
///
/// Adds a mapping at the start of the segment and at the beginning of each new line.
fn add_line_mappings_for_segment(
builder: &mut oxc_sourcemap::SourceMapBuilder,
segment: &str,
mut out_line: u32,
mut out_col: u32,
mut src_line: u32,
mut src_col: u32,
) {
// Add mapping at the start of this segment
builder.add_token(out_line, out_col, src_line, src_col, Some(0), None);

for ch in segment.chars() {
if ch == '\n' {
out_line += 1;
out_col = 0;
src_line += 1;
src_col = 0;
// Add mapping at the start of each new line
builder.add_token(out_line, out_col, src_line, src_col, Some(0), None);
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading
Loading