Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/build/next/private-to-property.ts
13379 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as ts from 'typescript';
7
import { type RawSourceMap, type Mapping, SourceMapConsumer, SourceMapGenerator } from 'source-map';
8
9
/**
10
* Converts native ES private fields (`#foo`) into regular JavaScript properties with short,
11
* globally unique names (e.g., `$a`, `$b`). This achieves two goals:
12
*
13
* 1. **Performance**: Native private fields are slower than regular properties in V8.
14
* 2. **Mangling**: Short replacement names reduce bundle size.
15
*
16
* ## Why not simply strip `#`?
17
*
18
* - **Inheritance collision**: If `class B extends A` and both declare `#x`, stripping `#`
19
* yields `x` on both - collision on child instances.
20
* - **Public property shadowing**: `class Foo extends Error { static #name = ... }` - stripping
21
* `#` produces `name` which shadows `Error.name`.
22
*
23
* ## Strategy: Globally unique names with `$` prefix
24
*
25
* Each (class, privateFieldName) pair gets a unique name from a global counter: `$a`, `$b`, ...
26
* This guarantees no inheritance collision and no shadowing of public properties.
27
*
28
* ## Why this is safe with syntax-only analysis
29
*
30
* Native `#` fields are **lexically scoped** to their declaring class body. Every declaration
31
* and every usage site is syntactically inside the class body. A single AST walk is sufficient
32
* to find all sites - no cross-file analysis or type checker needed.
33
*/
34
35
// Short name generator: $a, $b, ..., $z, $A, ..., $Z, $aa, $ab, ...
36
const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
37
38
function generateShortName(index: number): string {
39
let name = '';
40
do {
41
name = CHARS[index % CHARS.length] + name;
42
index = Math.floor(index / CHARS.length) - 1;
43
} while (index >= 0);
44
return '$' + name;
45
}
46
47
interface Edit {
48
start: number;
49
end: number;
50
newText: string;
51
}
52
53
// Private name → replacement name per class (identified by position in file)
54
type ClassScope = Map<string, string>;
55
56
export interface TextEdit {
57
readonly start: number;
58
readonly end: number;
59
readonly newText: string;
60
}
61
62
export interface ConvertPrivateFieldsResult {
63
readonly code: string;
64
readonly classCount: number;
65
readonly fieldCount: number;
66
readonly editCount: number;
67
readonly elapsed: number;
68
/** Sorted edits applied to the original code, for source map adjustment. */
69
readonly edits: readonly TextEdit[];
70
}
71
72
/**
73
* Converts all native `#` private fields/methods in the given JavaScript source to regular
74
* properties with short, globally unique names.
75
*
76
* @param code The JavaScript source code (typically a bundled output file).
77
* @param filename Used for TypeScript parser diagnostics only.
78
* @returns The transformed source code with `#` fields replaced, plus stats.
79
*/
80
export function convertPrivateFields(code: string, filename: string): ConvertPrivateFieldsResult {
81
const t1 = Date.now();
82
// Quick bail-out: if there are no `#` characters, nothing to do
83
if (!code.includes('#')) {
84
return { code, classCount: 0, fieldCount: 0, editCount: 0, elapsed: Date.now() - t1, edits: [] };
85
}
86
87
const sourceFile = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, false, ts.ScriptKind.JS);
88
89
// Global counter for unique name generation
90
let nameCounter = 0;
91
let fieldCount = 0;
92
let classCount = 0;
93
94
// Collect all edits
95
const edits: Edit[] = [];
96
97
// Class stack for resolving private names in nested classes.
98
// When a PrivateIdentifier is encountered, we search from innermost to outermost
99
// class scope - matching JS lexical resolution semantics.
100
const classStack: ClassScope[] = [];
101
102
visit(sourceFile);
103
104
if (edits.length === 0) {
105
return { code, classCount: 0, fieldCount: 0, editCount: 0, elapsed: Date.now() - t1, edits: [] };
106
}
107
108
// Apply edits using substring concatenation (O(N+K), not O(N*K) like char-array splice)
109
edits.sort((a, b) => a.start - b.start);
110
const parts: string[] = [];
111
let lastEnd = 0;
112
for (const edit of edits) {
113
parts.push(code.substring(lastEnd, edit.start));
114
parts.push(edit.newText);
115
lastEnd = edit.end;
116
}
117
parts.push(code.substring(lastEnd));
118
return { code: parts.join(''), classCount, fieldCount: fieldCount, editCount: edits.length, elapsed: Date.now() - t1, edits };
119
120
// --- AST walking ---
121
122
function visit(node: ts.Node): void {
123
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
124
visitClass(node);
125
return;
126
}
127
ts.forEachChild(node, visit);
128
}
129
130
function visitClass(node: ts.ClassDeclaration | ts.ClassExpression): void {
131
// 1) Collect public member names so generated names don't collide
132
const publicNames = new Set<string>();
133
for (const member of node.members) {
134
if (!member.name) {
135
continue;
136
}
137
if (ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) {
138
publicNames.add(member.name.text);
139
continue;
140
}
141
if (ts.isComputedPropertyName(member.name) && ts.isStringLiteral(member.name.expression)) {
142
publicNames.add(member.name.expression.text);
143
}
144
}
145
146
// 2) Collect all private field/method/accessor declarations in THIS class,
147
// skipping generated names that collide with existing public members.
148
const scope: ClassScope = new Map();
149
for (const member of node.members) {
150
if (member.name && ts.isPrivateIdentifier(member.name)) {
151
const name = member.name.text;
152
if (!scope.has(name)) {
153
let shortName: string;
154
do {
155
shortName = generateShortName(nameCounter++);
156
} while (publicNames.has(shortName));
157
scope.set(name, shortName);
158
fieldCount++;
159
}
160
}
161
}
162
163
if (scope.size > 0) {
164
classCount++;
165
}
166
167
// 3) Walk heritage clauses BEFORE pushing this class's scope.
168
// The `extends` expression is evaluated in the enclosing lexical scope,
169
// so any private-field references there belong to an outer class.
170
const walkInClass = createWalkInClass(node);
171
for (const clause of node.heritageClauses ?? []) {
172
ts.forEachChild(clause, walkInClass);
173
}
174
175
// 4) Now push the scope and walk the class members
176
classStack.push(scope);
177
for (const member of node.members) {
178
ts.forEachChild(member, walkInClass);
179
}
180
classStack.pop();
181
}
182
183
function createWalkInClass(classNode: ts.ClassDeclaration | ts.ClassExpression) {
184
return function walkInClass(child: ts.Node): void {
185
// Nested class: process independently with its own scope
186
if ((ts.isClassDeclaration(child) || ts.isClassExpression(child)) && child !== classNode) {
187
visitClass(child);
188
return;
189
}
190
191
// Handle `#field in expr` (ergonomic brand check) - needs string literal replacement
192
if (ts.isBinaryExpression(child) &&
193
child.operatorToken.kind === ts.SyntaxKind.InKeyword &&
194
ts.isPrivateIdentifier(child.left)) {
195
const resolved = resolvePrivateName(child.left.text);
196
if (resolved !== undefined) {
197
edits.push({
198
start: child.left.getStart(sourceFile),
199
end: child.left.getEnd(),
200
newText: `'${resolved}'`
201
});
202
}
203
// Still need to walk the right-hand side for any private field usages
204
ts.forEachChild(child.right, walkInClass);
205
return;
206
}
207
208
// Normal PrivateIdentifier usage (declaration, property access, method call)
209
if (ts.isPrivateIdentifier(child)) {
210
const resolved = resolvePrivateName(child.text);
211
if (resolved !== undefined) {
212
const start = child.getStart(sourceFile);
213
edits.push({
214
start,
215
end: child.getEnd(),
216
// In minified code, `async#run()` has no space before `#`.
217
// The `#` naturally starts a new token, but `$` does not —
218
// `async$a` would fuse into one identifier. Insert a space
219
// when the preceding character is an identifier character.
220
newText: (start > 0 && isIdentifierChar(code.charCodeAt(start - 1))) ? ' ' + resolved : resolved
221
});
222
}
223
return;
224
}
225
226
ts.forEachChild(child, walkInClass);
227
};
228
}
229
230
function resolvePrivateName(name: string): string | undefined {
231
// Walk from innermost to outermost class scope (matches JS lexical resolution)
232
for (let i = classStack.length - 1; i >= 0; i--) {
233
const resolved = classStack[i].get(name);
234
if (resolved !== undefined) {
235
return resolved;
236
}
237
}
238
return undefined;
239
}
240
}
241
242
function isIdentifierChar(ch: number): boolean {
243
// a-z, A-Z, 0-9, _, $
244
return (ch >= 97 && ch <= 122) || (ch >= 65 && ch <= 90) || (ch >= 48 && ch <= 57) || ch === 95 || ch === 36;
245
}
246
247
/**
248
* Adjusts a source map to account for text edits applied to the generated JS.
249
*
250
* Each edit replaced a span `[start, end)` in the original generated JS with `newText`.
251
* This shifts all subsequent columns on the same line. The source map's generated
252
* columns are updated so they still point to the correct original positions.
253
*
254
* @param sourceMapJson The parsed source map JSON object.
255
* @param originalCode The original generated JS (before edits were applied).
256
* @param edits The sorted edits that were applied.
257
* @returns A new source map JSON object with adjusted generated columns.
258
*/
259
export function adjustSourceMap(
260
sourceMapJson: RawSourceMap,
261
originalCode: string,
262
edits: readonly TextEdit[]
263
): RawSourceMap {
264
if (edits.length === 0) {
265
return sourceMapJson;
266
}
267
268
// Build line-offset tables for the original code and the code after edits.
269
// When edits span newlines (e.g. NLS replacing a multi-line template literal
270
// with `null`), subsequent lines shift up and columns change. We handle this
271
// by converting each mapping's old generated (line, col) to a byte offset,
272
// adjusting the offset for the edits, then converting back to (line, col) in
273
// the post-edit coordinate system.
274
275
const oldLineStarts = buildLineStarts(originalCode);
276
const newLineStarts = buildLineStartsAfterEdits(originalCode, edits);
277
278
// Precompute cumulative byte-shift after each edit for binary search
279
const n = edits.length;
280
const editStarts: number[] = new Array(n);
281
const editEnds: number[] = new Array(n);
282
const cumShifts: number[] = new Array(n); // cumulative shift *after* edit[i]
283
let cumShift = 0;
284
for (let i = 0; i < n; i++) {
285
editStarts[i] = edits[i].start;
286
editEnds[i] = edits[i].end;
287
cumShift += edits[i].newText.length - (edits[i].end - edits[i].start);
288
cumShifts[i] = cumShift;
289
}
290
291
function adjustOffset(oldOff: number): number {
292
// Binary search: find last edit with start <= oldOff
293
let lo = 0, hi = n - 1;
294
while (lo <= hi) {
295
const mid = (lo + hi) >> 1;
296
if (editStarts[mid] <= oldOff) {
297
lo = mid + 1;
298
} else {
299
hi = mid - 1;
300
}
301
}
302
// hi = index of last edit where start <= oldOff, or -1 if none
303
if (hi < 0) {
304
return oldOff;
305
}
306
if (oldOff < editEnds[hi]) {
307
// Inside edit range — clamp to edit start in new coordinates
308
const prevShift = hi > 0 ? cumShifts[hi - 1] : 0;
309
return editStarts[hi] + prevShift;
310
}
311
return oldOff + cumShifts[hi];
312
}
313
314
function offsetToLineCol(lineStarts: readonly number[], offset: number): { line: number; col: number } {
315
let lo = 0, hi = lineStarts.length - 1;
316
while (lo < hi) {
317
const mid = (lo + hi + 1) >> 1;
318
if (lineStarts[mid] <= offset) {
319
lo = mid;
320
} else {
321
hi = mid - 1;
322
}
323
}
324
return { line: lo, col: offset - lineStarts[lo] };
325
}
326
327
// Use source-map library to read, adjust, and write
328
const consumer = new SourceMapConsumer(sourceMapJson);
329
const generator = new SourceMapGenerator({ file: sourceMapJson.file, sourceRoot: sourceMapJson.sourceRoot });
330
331
// Copy sourcesContent
332
for (let i = 0; i < sourceMapJson.sources.length; i++) {
333
const content = sourceMapJson.sourcesContent?.[i];
334
if (content !== null && content !== undefined) {
335
generator.setSourceContent(sourceMapJson.sources[i], content);
336
}
337
}
338
339
// Walk every mapping, convert old generated position → byte offset → adjust → new position
340
consumer.eachMapping(mapping => {
341
const oldLine0 = mapping.generatedLine - 1; // 0-based
342
const oldOff = (oldLine0 < oldLineStarts.length
343
? oldLineStarts[oldLine0]
344
: oldLineStarts[oldLineStarts.length - 1]) + mapping.generatedColumn;
345
346
const newOff = adjustOffset(oldOff);
347
const newPos = offsetToLineCol(newLineStarts, newOff);
348
349
if (mapping.source !== null && mapping.originalLine !== null && mapping.originalColumn !== null) {
350
const newMapping: Mapping = {
351
generated: { line: newPos.line + 1, column: newPos.col },
352
original: { line: mapping.originalLine, column: mapping.originalColumn },
353
source: mapping.source,
354
};
355
if (mapping.name !== null) {
356
newMapping.name = mapping.name;
357
}
358
generator.addMapping(newMapping);
359
} else {
360
// Preserve unmapped segments (generated-only mappings with no original
361
// position). These create essential "gaps" that prevent
362
// originalPositionFor() from wrongly interpolating between distant
363
// valid mappings on the same line in minified output.
364
// eslint-disable-next-line local/code-no-dangerous-type-assertions
365
generator.addMapping({
366
generated: { line: newPos.line + 1, column: newPos.col },
367
} as Mapping);
368
}
369
});
370
371
return JSON.parse(generator.toString());
372
}
373
374
function buildLineStarts(text: string): number[] {
375
const starts: number[] = [0];
376
let pos = 0;
377
while (true) {
378
const nl = text.indexOf('\n', pos);
379
if (nl === -1) {
380
break;
381
}
382
starts.push(nl + 1);
383
pos = nl + 1;
384
}
385
return starts;
386
}
387
388
/**
389
* Compute line starts for the code that results from applying `edits` to
390
* `originalCode`, without materialising the full new string.
391
*/
392
function buildLineStartsAfterEdits(originalCode: string, edits: readonly TextEdit[]): number[] {
393
const starts: number[] = [0];
394
let oldPos = 0;
395
let newPos = 0;
396
397
for (const edit of edits) {
398
// Scan unchanged region [oldPos, edit.start) for newlines
399
let from = oldPos;
400
while (true) {
401
const nl = originalCode.indexOf('\n', from);
402
if (nl === -1 || nl >= edit.start) {
403
break;
404
}
405
starts.push(newPos + (nl - oldPos) + 1);
406
from = nl + 1;
407
}
408
newPos += edit.start - oldPos;
409
410
// Scan replacement text for newlines
411
let replFrom = 0;
412
while (true) {
413
const nl = edit.newText.indexOf('\n', replFrom);
414
if (nl === -1) {
415
break;
416
}
417
starts.push(newPos + nl + 1);
418
replFrom = nl + 1;
419
}
420
newPos += edit.newText.length;
421
422
oldPos = edit.end;
423
}
424
425
// Scan remaining unchanged text after last edit
426
let from = oldPos;
427
while (true) {
428
const nl = originalCode.indexOf('\n', from);
429
if (nl === -1) {
430
break;
431
}
432
starts.push(newPos + (nl - oldPos) + 1);
433
from = nl + 1;
434
}
435
436
return starts;
437
}
438
439