Enhance source map handling in NLS plugin and related components

- Introduced adjustments for source maps in the NLS plugin to ensure accurate mapping after placeholder replacements.
- Implemented deferred processing for source maps to handle edits more effectively, preserving unmapped segments.
- Updated tests to validate column mappings and ensure correctness in both minified and non-minified builds.
- Improved documentation to reflect changes in source map generation and adjustments.
This commit is contained in:
Johannes
2026-03-03 08:51:36 +01:00
parent 73b0506c6b
commit ccbe5ab074
7 changed files with 453 additions and 110 deletions

View File

@@ -237,6 +237,8 @@ function runTsGoTypeCheck(): Promise<void> {
}
const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`;
const useCdnSourceMapsForPackagingTasks = !!process.env['CI'];
const stripSourceMapsInPackagingTasks = !!process.env['CI'];
const minifyVSCodeTask = task.define('minify-vscode', task.series(
bundleVSCodeTask,
util.rimraf('out-vscode-min'),
@@ -349,8 +351,11 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d
const extensions = gulp.src(['.build/extensions/**', ...platformSpecificBuiltInExtensionsExclusions], { base: '.build', dot: true });
const sourceFilterPattern = stripSourceMapsInPackagingTasks
? ['**', '!**/*.{js,css}.map']
: ['**'];
const sources = es.merge(src, extensions)
.pipe(filter(['**', '!**/*.{js,css}.map'], { dot: true }));
.pipe(filter(sourceFilterPattern, { dot: true }));
let version = packageJson.version;
const quality = (product as { quality?: string }).quality;
@@ -420,8 +425,13 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d
const productionDependencies = getProductionDependencies(root);
const dependenciesSrc = productionDependencies.map(d => path.relative(root, d)).map(d => [`${d}/**`, `!${d}/**/{test,tests}/**`]).flat().concat('!**/*.mk');
const depFilterPattern = ['**', `!**/${config.version}/**`, '!**/bin/darwin-arm64-87/**', '!**/package-lock.json', '!**/yarn.lock'];
if (stripSourceMapsInPackagingTasks) {
depFilterPattern.push('!**/*.{js,css}.map');
}
const deps = gulp.src(dependenciesSrc, { base: '.', dot: true })
.pipe(filter(['**', `!**/${config.version}/**`, '!**/bin/darwin-arm64-87/**', '!**/package-lock.json', '!**/yarn.lock', '!**/*.{js,css}.map']))
.pipe(filter(depFilterPattern))
.pipe(util.cleanNodeModules(path.join(import.meta.dirname, '.moduleignore')))
.pipe(util.cleanNodeModules(path.join(import.meta.dirname, `.moduleignore.${process.platform}`)))
.pipe(jsFilter)
@@ -696,7 +706,13 @@ BUILD_TARGETS.forEach(buildTarget => {
if (useEsbuildTranspile) {
const esbuildBundleTask = task.define(
`esbuild-bundle${dashed(platform)}${dashed(arch)}${dashed(minified)}`,
() => runEsbuildBundle(sourceFolderName, !!minified, true, 'desktop', minified ? `${sourceMappingURLBase}/core` : undefined)
() => runEsbuildBundle(
sourceFolderName,
!!minified,
true,
'desktop',
minified && useCdnSourceMapsForPackagingTasks ? `${sourceMappingURLBase}/core` : undefined
)
);
vscodeTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series(
copyCodiconsTask,

View File

@@ -897,6 +897,13 @@ ${tslib}`,
const mangleStats: { file: string; result: ConvertPrivateFieldsResult }[] = [];
// Map from JS file path to pre-mangle content + edits, for source map adjustment
const mangleEdits = new Map<string, { preMangleCode: string; edits: readonly import('./private-to-property.ts').TextEdit[] }>();
// Map from JS file path to pre-NLS content + edits, for source map adjustment
const nlsEdits = new Map<string, { preNLSCode: string; edits: readonly import('./private-to-property.ts').TextEdit[] }>();
// Defer .map files until all .js files are processed, because esbuild may
// emit the .map file in a different build result than the .js file (e.g.
// code-split chunks), and we need the NLS/mangle edits from the .js pass
// to be available when adjusting the .map.
const deferredMaps: { path: string; text: string; contents: Uint8Array }[] = [];
for (const { result } of buildResults) {
if (!result.outputFiles) {
continue;
@@ -925,7 +932,12 @@ ${tslib}`,
// Apply NLS post-processing if enabled (JS only)
if (file.path.endsWith('.js') && doNls && indexMap.size > 0) {
content = postProcessNLS(content, indexMap, preserveEnglish);
const preNLSCode = content;
const nlsResult = postProcessNLS(content, indexMap, preserveEnglish);
content = nlsResult.code;
if (nlsResult.edits.length > 0) {
nlsEdits.set(file.path, { preNLSCode, edits: nlsResult.edits });
}
}
// Rewrite sourceMappingURL to CDN URL if configured
@@ -943,16 +955,8 @@ ${tslib}`,
await fs.promises.writeFile(file.path, content);
} else if (file.path.endsWith('.map')) {
// Source maps may need adjustment if private fields were mangled
const jsPath = file.path.replace(/\.map$/, '');
const editInfo = mangleEdits.get(jsPath);
if (editInfo) {
const mapJson = JSON.parse(file.text);
const adjusted = adjustSourceMap(mapJson, editInfo.preMangleCode, editInfo.edits);
await fs.promises.writeFile(file.path, JSON.stringify(adjusted));
} else {
await fs.promises.writeFile(file.path, file.contents);
}
// Defer .map processing until all .js files have been handled
deferredMaps.push({ path: file.path, text: file.text, contents: file.contents });
} else {
// Write other files (assets, etc.) as-is
await fs.promises.writeFile(file.path, file.contents);
@@ -961,6 +965,27 @@ ${tslib}`,
bundled++;
}
// Second pass: process deferred .map files now that all mangle/NLS edits
// have been collected from .js processing above.
for (const mapFile of deferredMaps) {
const jsPath = mapFile.path.replace(/\.map$/, '');
const mangle = mangleEdits.get(jsPath);
const nls = nlsEdits.get(jsPath);
if (mangle || nls) {
let mapJson = JSON.parse(mapFile.text);
if (mangle) {
mapJson = adjustSourceMap(mapJson, mangle.preMangleCode, mangle.edits);
}
if (nls) {
mapJson = adjustSourceMap(mapJson, nls.preNLSCode, nls.edits);
}
await fs.promises.writeFile(mapFile.path, JSON.stringify(mapJson));
} else {
await fs.promises.writeFile(mapFile.path, mapFile.contents);
}
}
// Log mangle-privates stats
if (doManglePrivates && mangleStats.length > 0) {
let totalClasses = 0, totalFields = 0, totalEdits = 0, totalElapsed = 0;

View File

@@ -12,6 +12,7 @@ import {
analyzeLocalizeCalls,
parseLocalizeKeyOrValue
} from '../lib/nls-analysis.ts';
import type { TextEdit } from './private-to-property.ts';
// ============================================================================
// Types
@@ -148,12 +149,13 @@ export async function finalizeNLS(
/**
* Post-processes a JavaScript file to replace NLS placeholders with indices.
* Returns the transformed code and the edits applied (for source map adjustment).
*/
export function postProcessNLS(
content: string,
indexMap: Map<string, number>,
preserveEnglish: boolean
): string {
): { code: string; edits: readonly TextEdit[] } {
return replaceInOutput(content, indexMap, preserveEnglish);
}
@@ -244,7 +246,7 @@ function generateNLSSourceMap(
const generator = new SourceMapGenerator();
generator.setSourceContent(filePath, originalSource);
const lineCount = originalSource.split('\n').length;
const lines = originalSource.split('\n');
// Group edits by line
const editsByLine = new Map<number, NLSEdit[]>();
@@ -257,7 +259,7 @@ function generateNLSSourceMap(
arr.push(edit);
}
for (let line = 0; line < lineCount; line++) {
for (let line = 0; line < lines.length; line++) {
const smLine = line + 1; // source maps use 1-based lines
// Always map start of line
@@ -273,7 +275,8 @@ function generateNLSSourceMap(
let cumulativeShift = 0;
for (const edit of lineEdits) {
for (let i = 0; i < lineEdits.length; i++) {
const edit = lineEdits[i];
const origLen = edit.endCol - edit.startCol;
// Map start of edit: the replacement begins at the same original position
@@ -285,12 +288,20 @@ function generateNLSSourceMap(
cumulativeShift += edit.newLength - origLen;
// Map content after edit: columns resume with the shift applied
generator.addMapping({
generated: { line: smLine, column: edit.endCol + cumulativeShift },
original: { line: smLine, column: edit.endCol },
source: filePath,
});
// Source maps don't interpolate columns — each query resolves to the
// last segment with generatedColumn <= queryColumn. A single mapping
// at edit-end would cause every subsequent column on this line to
// collapse to that one original position. Add per-column identity
// mappings from edit-end to the next edit (or end of line) so that
// esbuild's source-map composition preserves fine-grained accuracy.
const nextBound = i + 1 < lineEdits.length ? lineEdits[i + 1].startCol : lines[line].length;
for (let origCol = edit.endCol; origCol < nextBound; origCol++) {
generator.addMapping({
generated: { line: smLine, column: origCol + cumulativeShift },
original: { line: smLine, column: origCol },
source: filePath,
});
}
}
}
}
@@ -302,17 +313,19 @@ function replaceInOutput(
content: string,
indexMap: Map<string, number>,
preserveEnglish: boolean
): string {
// Replace all placeholders in a single pass using regex
// Two types of placeholders:
// - %%NLS:moduleId#key%% for localize() - message replaced with null
// - %%NLS2:moduleId#key%% for localize2() - message preserved
// Note: esbuild may use single or double quotes, so we handle both
): { code: string; edits: readonly TextEdit[] } {
// Collect all matches first, then apply from back to front so that byte
// offsets remain valid. Each match becomes a TextEdit in terms of the
// ORIGINAL content offsets, which is what adjustSourceMap expects.
interface PendingEdit { start: number; end: number; replacement: string }
const pending: PendingEdit[] = [];
if (preserveEnglish) {
// Just replace the placeholder with the index (both NLS and NLS2)
return content.replace(/["']%%NLS2?:([^%]+)%%["']/g, (match, inner) => {
// Try NLS first, then NLS2
const re = /["']%%NLS2?:([^%]+)%%["']/g;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const inner = m[1];
let placeholder = `%%NLS:${inner}%%`;
let index = indexMap.get(placeholder);
if (index === undefined) {
@@ -320,45 +333,60 @@ function replaceInOutput(
index = indexMap.get(placeholder);
}
if (index !== undefined) {
return String(index);
pending.push({ start: m.index, end: m.index + m[0].length, replacement: String(index) });
}
// Placeholder not found in map, leave as-is (shouldn't happen)
return match;
});
}
} else {
// For NLS (localize): replace placeholder with index AND replace message with null
// For NLS2 (localize2): replace placeholder with index, keep message
// Note: Use (?:[^"\\]|\\.)* to properly handle escaped quotes like \" or \\
// Note: esbuild may use single or double quotes, so we handle both
// First handle NLS (localize) - replace both key and message
content = content.replace(
/["']%%NLS:([^%]+)%%["'](\s*,\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g,
(match, inner, comma) => {
const placeholder = `%%NLS:${inner}%%`;
const index = indexMap.get(placeholder);
if (index !== undefined) {
return `${index}${comma}null`;
}
return match;
// NLS (localize): replace placeholder with index AND replace message with null
const reNLS = /["']%%NLS:([^%]+)%%["'](\s*,\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g;
let m: RegExpExecArray | null;
while ((m = reNLS.exec(content)) !== null) {
const inner = m[1];
const comma = m[2];
const placeholder = `%%NLS:${inner}%%`;
const index = indexMap.get(placeholder);
if (index !== undefined) {
pending.push({ start: m.index, end: m.index + m[0].length, replacement: `${index}${comma}null` });
}
);
}
// Then handle NLS2 (localize2) - replace only key, keep message
content = content.replace(
/["']%%NLS2:([^%]+)%%["']/g,
(match, inner) => {
const placeholder = `%%NLS2:${inner}%%`;
const index = indexMap.get(placeholder);
if (index !== undefined) {
return String(index);
}
return match;
// NLS2 (localize2): replace only key, keep message
const reNLS2 = /["']%%NLS2:([^%]+)%%["']/g;
while ((m = reNLS2.exec(content)) !== null) {
const inner = m[1];
const placeholder = `%%NLS2:${inner}%%`;
const index = indexMap.get(placeholder);
if (index !== undefined) {
pending.push({ start: m.index, end: m.index + m[0].length, replacement: String(index) });
}
);
return content;
}
}
if (pending.length === 0) {
return { code: content, edits: [] };
}
// Sort by offset ascending, then apply back-to-front to keep offsets valid
pending.sort((a, b) => a.start - b.start);
// Build TextEdit[] (in original-content coordinates) and apply edits
const edits: TextEdit[] = [];
for (const p of pending) {
edits.push({ start: p.start, end: p.end, newText: p.replacement });
}
// Apply edits using forward-scanning parts array — O(N+K) instead of
// O(N*K) from repeated substring concatenation on large strings.
const parts: string[] = [];
let lastEnd = 0;
for (const p of pending) {
parts.push(content.substring(lastEnd, p.start));
parts.push(p.replacement);
lastEnd = p.end;
}
parts.push(content.substring(lastEnd));
return { code: parts.join(''), edits };
}
// ============================================================================

View File

@@ -220,15 +220,53 @@ export function adjustSourceMap(
return sourceMapJson;
}
// Build a line-offset table for the original code to convert byte offsets to line/column
const lineStarts: number[] = [0];
for (let i = 0; i < originalCode.length; i++) {
if (originalCode.charCodeAt(i) === 10 /* \n */) {
lineStarts.push(i + 1);
}
// Build line-offset tables for the original code and the code after edits.
// When edits span newlines (e.g. NLS replacing a multi-line template literal
// with `null`), subsequent lines shift up and columns change. We handle this
// by converting each mapping's old generated (line, col) to a byte offset,
// adjusting the offset for the edits, then converting back to (line, col) in
// the post-edit coordinate system.
const oldLineStarts = buildLineStarts(originalCode);
const newLineStarts = buildLineStartsAfterEdits(originalCode, edits);
// Precompute cumulative byte-shift after each edit for binary search
const n = edits.length;
const editStarts: number[] = new Array(n);
const editEnds: number[] = new Array(n);
const cumShifts: number[] = new Array(n); // cumulative shift *after* edit[i]
let cumShift = 0;
for (let i = 0; i < n; i++) {
editStarts[i] = edits[i].start;
editEnds[i] = edits[i].end;
cumShift += edits[i].newText.length - (edits[i].end - edits[i].start);
cumShifts[i] = cumShift;
}
function offsetToLineCol(offset: number): { line: number; col: number } {
function adjustOffset(oldOff: number): number {
// Binary search: find last edit with start <= oldOff
let lo = 0, hi = n - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (editStarts[mid] <= oldOff) {
lo = mid + 1;
} else {
hi = mid - 1;
}
}
// hi = index of last edit where start <= oldOff, or -1 if none
if (hi < 0) {
return oldOff;
}
if (oldOff < editEnds[hi]) {
// Inside edit range — clamp to edit start in new coordinates
const prevShift = hi > 0 ? cumShifts[hi - 1] : 0;
return editStarts[hi] + prevShift;
}
return oldOff + cumShifts[hi];
}
function offsetToLineCol(lineStarts: readonly number[], offset: number): { line: number; col: number } {
let lo = 0, hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
@@ -241,23 +279,9 @@ export function adjustSourceMap(
return { line: lo, col: offset - lineStarts[lo] };
}
// Convert edits from byte offsets to per-line column shifts
interface LineEdit { col: number; origLen: number; newLen: number }
const editsByLine = new Map<number, LineEdit[]>();
for (const edit of edits) {
const pos = offsetToLineCol(edit.start);
const origLen = edit.end - edit.start;
let arr = editsByLine.get(pos.line);
if (!arr) {
arr = [];
editsByLine.set(pos.line, arr);
}
arr.push({ col: pos.col, origLen, newLen: edit.newText.length });
}
// Use source-map library to read, adjust, and write
const consumer = new SourceMapConsumer(sourceMapJson);
const generator = new SourceMapGenerator({ file: sourceMapJson.file });
const generator = new SourceMapGenerator({ file: sourceMapJson.file, sourceRoot: sourceMapJson.sourceRoot });
// Copy sourcesContent
for (let i = 0; i < sourceMapJson.sources.length; i++) {
@@ -267,15 +291,19 @@ export function adjustSourceMap(
}
}
// Walk every mapping, adjust the generated column, and add to the new generator
// Walk every mapping, convert old generated position → byte offset → adjust → new position
consumer.eachMapping(mapping => {
const lineEdits = editsByLine.get(mapping.generatedLine - 1); // 0-based for our data
const adjustedCol = adjustColumn(mapping.generatedColumn, lineEdits);
const oldLine0 = mapping.generatedLine - 1; // 0-based
const oldOff = (oldLine0 < oldLineStarts.length
? oldLineStarts[oldLine0]
: oldLineStarts[oldLineStarts.length - 1]) + mapping.generatedColumn;
const newOff = adjustOffset(oldOff);
const newPos = offsetToLineCol(newLineStarts, newOff);
// Some mappings may be unmapped (no original position/source) - skip those.
if (mapping.source !== null && mapping.originalLine !== null && mapping.originalColumn !== null) {
const newMapping: Mapping = {
generated: { line: mapping.generatedLine, column: adjustedCol },
generated: { line: newPos.line + 1, column: newPos.col },
original: { line: mapping.originalLine, column: mapping.originalColumn },
source: mapping.source,
};
@@ -283,25 +311,82 @@ export function adjustSourceMap(
newMapping.name = mapping.name;
}
generator.addMapping(newMapping);
} else {
// Preserve unmapped segments (generated-only mappings with no original
// position). These create essential "gaps" that prevent
// originalPositionFor() from wrongly interpolating between distant
// valid mappings on the same line in minified output.
// eslint-disable-next-line local/code-no-dangerous-type-assertions
generator.addMapping({
generated: { line: newPos.line + 1, column: newPos.col },
} as Mapping);
}
});
return JSON.parse(generator.toString());
}
function adjustColumn(col: number, lineEdits: { col: number; origLen: number; newLen: number }[] | undefined): number {
if (!lineEdits) {
return col;
}
let shift = 0;
for (const edit of lineEdits) {
if (edit.col + edit.origLen <= col) {
shift += edit.newLen - edit.origLen;
} else if (edit.col < col) {
return edit.col + shift;
} else {
function buildLineStarts(text: string): number[] {
const starts: number[] = [0];
let pos = 0;
while (true) {
const nl = text.indexOf('\n', pos);
if (nl === -1) {
break;
}
starts.push(nl + 1);
pos = nl + 1;
}
return col + shift;
return starts;
}
/**
* Compute line starts for the code that results from applying `edits` to
* `originalCode`, without materialising the full new string.
*/
function buildLineStartsAfterEdits(originalCode: string, edits: readonly TextEdit[]): number[] {
const starts: number[] = [0];
let oldPos = 0;
let newPos = 0;
for (const edit of edits) {
// Scan unchanged region [oldPos, edit.start) for newlines
let from = oldPos;
while (true) {
const nl = originalCode.indexOf('\n', from);
if (nl === -1 || nl >= edit.start) {
break;
}
starts.push(newPos + (nl - oldPos) + 1);
from = nl + 1;
}
newPos += edit.start - oldPos;
// Scan replacement text for newlines
let replFrom = 0;
while (true) {
const nl = edit.newText.indexOf('\n', replFrom);
if (nl === -1) {
break;
}
starts.push(newPos + nl + 1);
replFrom = nl + 1;
}
newPos += edit.newText.length;
oldPos = edit.end;
}
// Scan remaining unchanged text after last edit
let from = oldPos;
while (true) {
const nl = originalCode.indexOf('\n', from);
if (nl === -1) {
break;
}
starts.push(newPos + (nl - oldPos) + 1);
from = nl + 1;
}
return starts;
}

View File

@@ -10,6 +10,7 @@ import * as fs from 'fs';
import * as os from 'os';
import { type RawSourceMap, SourceMapConsumer } from 'source-map';
import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from '../nls-plugin.ts';
import { adjustSourceMap } from '../private-to-property.ts';
// analyzeLocalizeCalls requires the import path to end with `/nls`
const NLS_STUB = [
@@ -36,7 +37,7 @@ interface BundleResult {
async function bundleWithNLS(
files: Record<string, string>,
entryPoint: string,
opts?: { postProcess?: boolean }
opts?: { postProcess?: boolean; minify?: boolean }
): Promise<BundleResult> {
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'nls-sm-test-'));
const srcDir = path.join(tmpDir, 'src');
@@ -64,6 +65,7 @@ async function bundleWithNLS(
packages: 'external',
sourcemap: 'linked',
sourcesContent: true,
minify: opts?.minify ?? false,
write: false,
plugins: [
nlsPlugin({ baseDir: srcDir, collector }),
@@ -91,7 +93,16 @@ async function bundleWithNLS(
// Optionally apply NLS post-processing (replaces placeholders with indices)
if (opts?.postProcess) {
const nlsResult = await finalizeNLS(collector, outDir);
jsContent = postProcessNLS(jsContent, nlsResult.indexMap, false);
const preNLSCode = jsContent;
const nlsProcessed = postProcessNLS(jsContent, nlsResult.indexMap, false);
jsContent = nlsProcessed.code;
// Adjust source map for NLS edits
if (nlsProcessed.edits.length > 0) {
const mapJson = JSON.parse(mapContent);
const adjusted = adjustSourceMap(mapJson, preNLSCode, nlsProcessed.edits);
mapContent = JSON.stringify(adjusted);
}
}
assert.ok(jsContent, 'Expected JS output');
@@ -370,4 +381,82 @@ suite('NLS plugin source maps', () => {
cleanup();
}
});
test('post-processed NLS - column mappings correct after placeholder replacement', async () => {
// NLS placeholders like "%%NLS:test/drift#k%%" are much longer than their
// replacements (e.g. "0"). Without source map adjustment the columns for
// tokens AFTER the replacement drift by the cumulative length delta.
const source = [
'import { localize } from "../vs/nls";', // 1
'export const a = localize("k1", "Alpha"); export const MARKER = "FINDME";', // 2
].join('\n');
const { js, map, cleanup } = await bundleWithNLS(
{ 'test/drift.ts': source },
'test/drift.ts',
{ postProcess: true }
);
try {
assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced');
const bundleLine = findLine(js, 'FINDME');
const bundleCol = findColumn(js, '"FINDME"');
const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });
assert.ok(pos.source, 'Should have source');
assert.strictEqual(pos.line, 2, 'Should map to line 2');
const originalCol = findColumn(source, '"FINDME"');
const columnDrift = Math.abs(pos.column! - originalCol);
assert.ok(columnDrift <= 20,
`Column drift after NLS post-processing should be small. ` +
`Expected ~${originalCol}, got ${pos.column} (drift: ${columnDrift}). ` +
`Large drift means postProcessNLS edits were not applied to the source map.`);
} finally {
cleanup();
}
});
test('minified bundle with NLS - end-to-end column mapping', async () => {
// With minification, the entire output is (roughly) on one line.
// Multiple NLS replacements compound their column shifts. A function
// defined after several localize() calls must still map correctly.
const source = [
'import { localize } from "../vs/nls";', // 1
'', // 2
'export const a = localize("k1", "Alpha message");', // 3
'export const b = localize("k2", "Bravo message that is quite long");', // 4
'export const c = localize("k3", "Charlie");', // 5
'export const d = localize("k4", "Delta is the fourth letter");', // 6
'', // 7
'export function computeResult(x: number): number {', // 8
'\treturn x * 42;', // 9
'}', // 10
].join('\n');
const { js, map, cleanup } = await bundleWithNLS(
{ 'test/minified.ts': source },
'test/minified.ts',
{ postProcess: true, minify: true }
);
try {
assert.ok(!js.includes('%%NLS:'), 'Placeholders should be replaced');
// Find the computeResult function in the minified output.
// esbuild minifies `x * 42` and may rename the parameter, so
// search for `*42` which survives both minification and renaming.
const needle = '*42';
const bundleLine = findLine(js, needle);
const bundleCol = findColumn(js, needle);
const pos = map.originalPositionFor({ line: bundleLine, column: bundleCol });
assert.ok(pos.source, 'Should have source for minified mapping');
assert.strictEqual(pos.line, 9,
`Should map "*42" back to line 9. Got line ${pos.line}.`);
} finally {
cleanup();
}
});
});

View File

@@ -439,6 +439,41 @@ suite('adjustSourceMap', () => {
assert.strictEqual(pos.column, origGetValueCol, 'getValue column should match original');
});
test('multi-line edit: removing newlines shifts subsequent lines up', () => {
// Simulates the NLS scenario: a template literal with embedded newlines
// is replaced with `null`, collapsing 3 lines into 1.
const code = [
'var a = "hello";', // line 0 (0-based)
'var b = `line1', // line 1
'line2', // line 2
'line3`;', // line 3
'var c = "world";', // line 4
].join('\n');
const map = createIdentitySourceMap(code, 'test.js');
// Replace the template literal `line1\nline2\nline3` with `null`
// (keeps `var b = ` and `;` intact)
const tplStart = code.indexOf('`line1');
const tplEnd = code.indexOf('line3`') + 'line3`'.length;
const edits = [{ start: tplStart, end: tplEnd, newText: 'null' }];
const result = adjustSourceMap(map, code, edits);
const consumer = new SourceMapConsumer(result);
// After edit, code is:
// "var a = \"hello\";\nvar b = null;\nvar c = \"world\";"
// "var c" was on line 5 (1-based), now on line 3 (1-based) since 2 newlines removed
// 'var c' at original line 5, col 0 should now map at generated line 3
const pos = consumer.originalPositionFor({ line: 3, column: 0 });
assert.strictEqual(pos.line, 5, 'var c should map to original line 5');
assert.strictEqual(pos.column, 0, 'var c column should be 0');
// 'var a' on line 1 should be unaffected
const posA = consumer.originalPositionFor({ line: 1, column: 0 });
assert.strictEqual(posA.line, 1, 'var a should still map to original line 1');
});
test('brand check: #field in obj -> string replacement adjusts map', () => {
const code = 'class C { #x; check(o) { return #x in o; } }';
const map = createIdentitySourceMap(code, 'test.js');

View File

@@ -222,13 +222,13 @@ Two categories of corruption:
2. **`--source-map-base-url` option** - Rewrites `sourceMappingURL` comments to point to CDN URLs.
3. **NLS plugin inline source maps** (`nls-plugin.ts`) - The `onLoad` handler now generates an inline source map (`//# sourceMappingURL=data:...`) mapping from NLS-transformed source back to original. esbuild composes this with its own bundle source map. `SourceMapGenerator.setSourceContent` embeds the original source so `sourcesContent` in the final `.map` has the real TypeScript. Tests in `test/nls-sourcemap.test.ts`.
3. **NLS plugin inline source maps** (`nls-plugin.ts`) - The `onLoad` handler generates an inline source map (`//# sourceMappingURL=data:...`) mapping from NLS-transformed source back to original. esbuild composes this with its own bundle source map. `SourceMapGenerator.setSourceContent` embeds the original source so `sourcesContent` in the final `.map` has the real TypeScript. `generateNLSSourceMap` adds per-column identity mappings after each edit on a line so that esbuild's source-map composition preserves fine-grained column accuracy (source maps don't interpolate columns — they use binary search, so a single boundary mapping would collapse all subsequent columns to the edit-end position). Tests in `test/nls-sourcemap.test.ts`.
4. **`convertPrivateFields` source map adjustment** (`private-to-property.ts`) - `convertPrivateFields` returns its sorted edits as `TextEdit[]`. `adjustSourceMap()` uses `SourceMapConsumer` to walk every mapping, adjusts generated columns based on cumulative edit shifts per line, and rebuilds with `SourceMapGenerator`. The post-processing loop in `index.ts` saves pre-mangle content + edits per JS file, then applies `adjustSourceMap` to the corresponding `.map`. Tests in `test/private-to-property.test.ts`.
### Not Yet Fixed
5. **`postProcessNLS` source map adjustment** (`nls-plugin.ts`, `index.ts`) — `postProcessNLS` now returns `{ code, edits }` where `edits` is a `TextEdit[]` tracking each replacement's byte offset. The bundle loop in `index.ts` chains `adjustSourceMap` calls: first for mangle edits, then for NLS edits, so both transforms are accurately reflected in the final `.map` file. Tests in `test/nls-sourcemap.test.ts`.
**`postProcessNLS` column drift** - Replaces NLS placeholders with short indices in bundled output without updating `.map` files. Shifts columns but never lines, so line-level debugging and crash reporting work correctly. Fixing would require tracking replacement offsets through regex matches and adjusting the source map, similar to `adjustSourceMap`.
6. **`adjustSourceMap` unmapped segment preservation** (`private-to-property.ts`) — Previously, `adjustSourceMap()` silently dropped mappings where `source === null`. These unmapped segments create essential "gaps" that prevent `originalPositionFor()` from wrongly interpolating between distant valid mappings on the same minified line. Now emits them as generated-only mappings. Also preserves `sourceRoot` from the input map.
### Key Technical Details
@@ -241,6 +241,71 @@ Two categories of corruption:
**Plugin interaction:** Both the NLS plugin and `fileContentMapperPlugin` register `onLoad({ filter: /\.ts$/ })`. In esbuild, the first `onLoad` to return non-`undefined` wins. The NLS plugin is `unshift`ed (runs first), so files with NLS calls skip `fileContentMapperPlugin`. This is safe in practice since `product.ts` (which has `BUILD->INSERT_PRODUCT_CONFIGURATION`) has no localize calls.
### Still Broken — Full Production Build (`npm run gulp vscode-min`)
**Symptom:** Source maps are totally broken in the minified production build. E.g. a breakpoint at `src/vs/editor/browser/editorExtensions.ts` line 308 resolves to `src/vs/editor/common/cursor/cursorMoveCommands.ts` line 732 — a completely different file. This is **cross-file** mapping corruption, not just column drift.
**Status of unit tests:** The fixes above pass in isolated unit tests (small 12 file bundles via `esbuild.build` with `minify: true`). The tests verify column drift ≤ 20 and correct line mapping for single-file bundles with NLS. **183 tests pass, 0 failing.** But the full production build bundles hundreds of files into huge minified outputs (e.g. `workbench.desktop.main.js` at ~15 MB) and the source maps break at that scale.
**Suspected root causes (need investigation):**
1. **`generateNLSSourceMap` per-column identity mappings may overwhelm esbuild's source-map composition.** The fix added one mapping per column from edit-end to end-of-line (or next edit). For a long TypeScript line with a `localize()` call near the beginning, this generates hundreds of identity mappings per line. Across hundreds of files, the inline source maps embedded in `onLoad` responses may be extremely large. esbuild must compose these with its own source maps during bundling — it may hit limits, silently drop mappings, or produce incorrect composed maps at this scale. **Mitigation to try:** Instead of per-column mappings, use sparser "checkpoint" mappings (e.g., every N characters) or rely only on boundary mappings and accept some column drift within the NLS-transformed region. The old boundary-only approach was wrong (collapsed all downstream columns), but per-column may be the other extreme.
2. **`adjustSourceMap` may corrupt source indices in large minified bundles.** In a minified bundle, the entire output is on one or very few lines. `adjustSourceMap()` walks every mapping via `SourceMapConsumer.eachMapping()` and adjusts `generatedColumn` using `adjustColumn()`. But when thousands of mappings all share `generatedLine: 1` and there are hundreds of NLS edits on that same line, there may be sorting/ordering bugs: `eachMapping()` returns mappings in generated order by default, but `adjustColumn()` binary-searches through edits sorted by column. If edits cover regions that interleave with mappings from different source files, the cumulative shift calculation might produce wrong columns that then resolve to wrong source files.
3. **Chained `adjustSourceMap` calls (mangle → NLS) may compound errors.** After the first `adjustSourceMap` for mangle edits, the source map's generated columns are updated. The second call for NLS edits uses `nlsEdits` which were computed against `preNLSCode` — but `preNLSCode` is the post-mangle JS, which is what the first `adjustSourceMap` maps from. This chaining _should_ be correct, but needs verification at scale with a real minified bundle.
4. **The `source-map` v0.6.1 library may have precision issues with very large VLQ-encoded maps.** The bundled outputs have source maps with hundreds of thousands of mappings. The library is old (2017) and there may be numerical precision or sorting issues with very large maps. Consider testing with `source-map` v0.7+ or the Rust-based `@aspect-build/source-map`.
5. **Alternative approach: skip per-column NLS plugin mappings, fix only `postProcessNLS`.** The NLS plugin `onLoad` replaces `"key"` with `"%%NLS:longPlaceholder%%"` — a length change that only affects columns on affected lines. The subsequent `postProcessNLS` then replaces the long placeholder with a short index. If the `adjustSourceMap` for `postProcessNLS` is correct, it should compensate for both expansions (plugin expansion + post-process contraction). We might not need per-column mappings in `generateNLSSourceMap` at all — just the boundary mapping. The column will drift in the intermediate representation but `adjustSourceMap` for NLS should fix it. **This hypothesis needs testing.**
6. **Alternative approach: do NLS replacement purely in post-processing.** Skip the `onLoad` two-phase approach (placeholder insertion + post-processing replacement) entirely. Instead, run `postProcessNLS` as a single post-processing step that directly replaces `localize("key", "message")``localize(0, null)` in the bundled JS output, with proper source-map adjustment via `adjustSourceMap`. This avoids both the inline source map composition complexity and the two-step replacement. The downside is that post-processing must parse/regex-match real `localize()` calls (not easy placeholders), which is more fragile.
**Summary of fixes applied vs status:**
| Bug | Fix | Unit test | Production |
|-----|-----|-----------|------------|
| `generateNLSSourceMap` only had boundary mappings → columns collapsed | Added per-column identity mappings after each edit | Pass (drift: 0) | **Broken** — may overwhelm esbuild composition at scale |
| `postProcessNLS` didn't track edits for source map adjustment | Returns `{ code, edits }`, chained in `index.ts` | Pass | **Broken**`adjustSourceMap` may corrupt source indices on huge single-line minified output |
| `adjustSourceMap` dropped unmapped segments | Preserves generated-only mappings + `sourceRoot` | Pass (no regressions) | **Broken** — same cross-file mapping issue |
**Files involved:**
- `build/next/nls-plugin.ts``generateNLSSourceMap()` (per-column mappings), `postProcessNLS()` (returns edits), `replaceInOutput()` (regex replacement)
- `build/next/private-to-property.ts``adjustSourceMap()` (column adjustment)
- `build/next/index.ts` — bundle post-processing loop (lines ~899975), chains adjustSourceMap calls
- `build/next/test/nls-sourcemap.test.ts` — unit tests (pass but don't cover production-scale bundles)
**How to reproduce:**
```bash
npm run gulp vscode-min
# Open out-vscode-min/ in a debugger, set breakpoints in editor files
# Observe breakpoints resolve to wrong files
```
**How to debug further:**
```bash
# 1. Build with just --nls (no mangle) to isolate NLS from mangle issues
npx tsx build/next/index.ts bundle --nls --minify --target desktop --out out-debug
# 2. Build with just --mangle-privates (no NLS) to isolate mangle issues
npx tsx build/next/index.ts bundle --mangle-privates --minify --target desktop --out out-debug
# 3. Build with neither (baseline — does esbuild's own map work?)
npx tsx build/next/index.ts bundle --minify --target desktop --out out-debug
# 4. Compare .map files across the three builds to find where mappings diverge
# 5. Validate a specific mapping in the large bundle:
node -e "
const {SourceMapConsumer} = require('source-map');
const fs = require('fs');
const map = JSON.parse(fs.readFileSync('./out-debug/vs/workbench/workbench.desktop.main.js.map','utf8'));
const c = new SourceMapConsumer(map);
// Look up a known position and see which source file it resolves to
console.log(c.originalPositionFor({line: 1, column: XXXX}));
"
```
---
## Self-hosting Setup