mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-23 01:58:53 +01:00
For #269213 This adds a new eslint rule for `as any` and `<any>({... })`. We'd like to remove almost all of these, however right now the first goal is to prevent them in new code. That's why with this first PR I simply add `eslint-disable` comments for all breaks Trying to get this change in soon after branching off for release to hopefully minimize disruption during debt week work
529 lines
17 KiB
TypeScript
529 lines
17 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import type * as ts from 'typescript';
|
|
import lazy from 'lazy.js';
|
|
import { duplex, through } from 'event-stream';
|
|
import File from 'vinyl';
|
|
import sm from 'source-map';
|
|
import path from 'path';
|
|
import sort from 'gulp-sort';
|
|
|
|
type FileSourceMap = File & { sourceMap: sm.RawSourceMap };
|
|
|
|
enum CollectStepResult {
|
|
Yes,
|
|
YesAndRecurse,
|
|
No,
|
|
NoAndRecurse
|
|
}
|
|
|
|
function collect(ts: typeof import('typescript'), node: ts.Node, fn: (node: ts.Node) => CollectStepResult): ts.Node[] {
|
|
const result: ts.Node[] = [];
|
|
|
|
function loop(node: ts.Node) {
|
|
const stepResult = fn(node);
|
|
|
|
if (stepResult === CollectStepResult.Yes || stepResult === CollectStepResult.YesAndRecurse) {
|
|
result.push(node);
|
|
}
|
|
|
|
if (stepResult === CollectStepResult.YesAndRecurse || stepResult === CollectStepResult.NoAndRecurse) {
|
|
ts.forEachChild(node, loop);
|
|
}
|
|
}
|
|
|
|
loop(node);
|
|
return result;
|
|
}
|
|
|
|
function clone<T extends object>(object: T): T {
|
|
// eslint-disable-next-line local/code-no-any-casts
|
|
const result = {} as any as T;
|
|
for (const id in object) {
|
|
result[id] = object[id];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Returns a stream containing the patched JavaScript and source maps.
|
|
*/
|
|
export function nls(options: { preserveEnglish: boolean }): NodeJS.ReadWriteStream {
|
|
let base: string;
|
|
const input = through();
|
|
const output = input
|
|
.pipe(sort()) // IMPORTANT: to ensure stable NLS metadata generation, we must sort the files because NLS messages are globally extracted and indexed across all files
|
|
.pipe(through(function (f: FileSourceMap) {
|
|
if (!f.sourceMap) {
|
|
return this.emit('error', new Error(`File ${f.relative} does not have sourcemaps.`));
|
|
}
|
|
|
|
let source = f.sourceMap.sources[0];
|
|
if (!source) {
|
|
return this.emit('error', new Error(`File ${f.relative} does not have a source in the source map.`));
|
|
}
|
|
|
|
const root = f.sourceMap.sourceRoot;
|
|
if (root) {
|
|
source = path.join(root, source);
|
|
}
|
|
|
|
const typescript = f.sourceMap.sourcesContent![0];
|
|
if (!typescript) {
|
|
return this.emit('error', new Error(`File ${f.relative} does not have the original content in the source map.`));
|
|
}
|
|
|
|
base = f.base;
|
|
this.emit('data', _nls.patchFile(f, typescript, options));
|
|
}, function () {
|
|
for (const file of [
|
|
new File({
|
|
contents: Buffer.from(JSON.stringify({
|
|
keys: _nls.moduleToNLSKeys,
|
|
messages: _nls.moduleToNLSMessages,
|
|
}, null, '\t')),
|
|
base,
|
|
path: `${base}/nls.metadata.json`
|
|
}),
|
|
new File({
|
|
contents: Buffer.from(JSON.stringify(_nls.allNLSMessages)),
|
|
base,
|
|
path: `${base}/nls.messages.json`
|
|
}),
|
|
new File({
|
|
contents: Buffer.from(JSON.stringify(_nls.allNLSModulesAndKeys)),
|
|
base,
|
|
path: `${base}/nls.keys.json`
|
|
}),
|
|
new File({
|
|
contents: Buffer.from(`/*---------------------------------------------------------
|
|
* Copyright (C) Microsoft Corporation. All rights reserved.
|
|
*--------------------------------------------------------*/
|
|
globalThis._VSCODE_NLS_MESSAGES=${JSON.stringify(_nls.allNLSMessages)};`),
|
|
base,
|
|
path: `${base}/nls.messages.js`
|
|
})
|
|
]) {
|
|
this.emit('data', file);
|
|
}
|
|
|
|
this.emit('end');
|
|
}));
|
|
|
|
return duplex(input, output);
|
|
}
|
|
|
|
function isImportNode(ts: typeof import('typescript'), node: ts.Node): boolean {
|
|
return node.kind === ts.SyntaxKind.ImportDeclaration || node.kind === ts.SyntaxKind.ImportEqualsDeclaration;
|
|
}
|
|
|
|
module _nls {
|
|
|
|
export const moduleToNLSKeys: { [name: string /* module ID */]: ILocalizeKey[] /* keys */ } = {};
|
|
export const moduleToNLSMessages: { [name: string /* module ID */]: string[] /* messages */ } = {};
|
|
export const allNLSMessages: string[] = [];
|
|
export const allNLSModulesAndKeys: Array<[string /* module ID */, string[] /* keys */]> = [];
|
|
let allNLSMessagesIndex = 0;
|
|
|
|
type ILocalizeKey = string | { key: string }; // key might contain metadata for translators and then is not just a string
|
|
|
|
interface INlsPatchResult {
|
|
javascript: string;
|
|
sourcemap: sm.RawSourceMap;
|
|
nlsMessages?: string[];
|
|
nlsKeys?: ILocalizeKey[];
|
|
}
|
|
|
|
interface ISpan {
|
|
start: ts.LineAndCharacter;
|
|
end: ts.LineAndCharacter;
|
|
}
|
|
|
|
interface ILocalizeCall {
|
|
keySpan: ISpan;
|
|
key: string;
|
|
valueSpan: ISpan;
|
|
value: string;
|
|
}
|
|
|
|
interface ILocalizeAnalysisResult {
|
|
localizeCalls: ILocalizeCall[];
|
|
}
|
|
|
|
interface IPatch {
|
|
span: ISpan;
|
|
content: string;
|
|
}
|
|
|
|
function fileFrom(file: File, contents: string, path: string = file.path) {
|
|
return new File({
|
|
contents: Buffer.from(contents),
|
|
base: file.base,
|
|
cwd: file.cwd,
|
|
path: path
|
|
});
|
|
}
|
|
|
|
function mappedPositionFrom(source: string, lc: ts.LineAndCharacter): sm.MappedPosition {
|
|
return { source, line: lc.line + 1, column: lc.character };
|
|
}
|
|
|
|
function lcFrom(position: sm.Position): ts.LineAndCharacter {
|
|
return { line: position.line - 1, character: position.column };
|
|
}
|
|
|
|
class SingleFileServiceHost implements ts.LanguageServiceHost {
|
|
|
|
private file: ts.IScriptSnapshot;
|
|
private lib: ts.IScriptSnapshot;
|
|
|
|
constructor(ts: typeof import('typescript'), private options: ts.CompilerOptions, private filename: string, contents: string) {
|
|
this.file = ts.ScriptSnapshot.fromString(contents);
|
|
this.lib = ts.ScriptSnapshot.fromString('');
|
|
}
|
|
|
|
getCompilationSettings = () => this.options;
|
|
getScriptFileNames = () => [this.filename];
|
|
getScriptVersion = () => '1';
|
|
getScriptSnapshot = (name: string) => name === this.filename ? this.file : this.lib;
|
|
getCurrentDirectory = () => '';
|
|
getDefaultLibFileName = () => 'lib.d.ts';
|
|
|
|
readFile(path: string, _encoding?: string): string | undefined {
|
|
if (path === this.filename) {
|
|
return this.file.getText(0, this.file.getLength());
|
|
}
|
|
return undefined;
|
|
}
|
|
fileExists(path: string): boolean {
|
|
return path === this.filename;
|
|
}
|
|
}
|
|
|
|
function isCallExpressionWithinTextSpanCollectStep(ts: typeof import('typescript'), textSpan: ts.TextSpan, node: ts.Node): CollectStepResult {
|
|
if (!ts.textSpanContainsTextSpan({ start: node.pos, length: node.end - node.pos }, textSpan)) {
|
|
return CollectStepResult.No;
|
|
}
|
|
|
|
return node.kind === ts.SyntaxKind.CallExpression ? CollectStepResult.YesAndRecurse : CollectStepResult.NoAndRecurse;
|
|
}
|
|
|
|
function analyze(
|
|
ts: typeof import('typescript'),
|
|
contents: string,
|
|
functionName: 'localize' | 'localize2',
|
|
options: ts.CompilerOptions = {}
|
|
): ILocalizeAnalysisResult {
|
|
const filename = 'file.ts';
|
|
const serviceHost = new SingleFileServiceHost(ts, Object.assign(clone(options), { noResolve: true }), filename, contents);
|
|
const service = ts.createLanguageService(serviceHost);
|
|
const sourceFile = ts.createSourceFile(filename, contents, ts.ScriptTarget.ES5, true);
|
|
|
|
// all imports
|
|
const imports = lazy(collect(ts, sourceFile, n => isImportNode(ts, n) ? CollectStepResult.YesAndRecurse : CollectStepResult.NoAndRecurse));
|
|
|
|
// import nls = require('vs/nls');
|
|
const importEqualsDeclarations = imports
|
|
.filter(n => n.kind === ts.SyntaxKind.ImportEqualsDeclaration)
|
|
.map(n => <ts.ImportEqualsDeclaration>n)
|
|
.filter(d => d.moduleReference.kind === ts.SyntaxKind.ExternalModuleReference)
|
|
.filter(d => (<ts.ExternalModuleReference>d.moduleReference).expression.getText().endsWith(`/nls.js'`));
|
|
|
|
// import ... from 'vs/nls';
|
|
const importDeclarations = imports
|
|
.filter(n => n.kind === ts.SyntaxKind.ImportDeclaration)
|
|
.map(n => <ts.ImportDeclaration>n)
|
|
.filter(d => d.moduleSpecifier.kind === ts.SyntaxKind.StringLiteral)
|
|
.filter(d => d.moduleSpecifier.getText().endsWith(`/nls.js'`))
|
|
.filter(d => !!d.importClause && !!d.importClause.namedBindings);
|
|
|
|
// `nls.localize(...)` calls
|
|
const nlsLocalizeCallExpressions = importDeclarations
|
|
.filter(d => !!(d.importClause && d.importClause.namedBindings && d.importClause.namedBindings.kind === ts.SyntaxKind.NamespaceImport))
|
|
.map(d => (<ts.NamespaceImport>d.importClause!.namedBindings).name)
|
|
.concat(importEqualsDeclarations.map(d => d.name))
|
|
|
|
// find read-only references to `nls`
|
|
.map(n => service.getReferencesAtPosition(filename, n.pos + 1) ?? [])
|
|
.flatten()
|
|
.filter(r => !r.isWriteAccess)
|
|
|
|
// find the deepest call expressions AST nodes that contain those references
|
|
.map(r => collect(ts, sourceFile, n => isCallExpressionWithinTextSpanCollectStep(ts, r.textSpan, n)))
|
|
.map(a => lazy(a).last())
|
|
.filter(n => !!n)
|
|
.map(n => <ts.CallExpression>n)
|
|
|
|
// only `localize` calls
|
|
.filter(n => n.expression.kind === ts.SyntaxKind.PropertyAccessExpression && (<ts.PropertyAccessExpression>n.expression).name.getText() === functionName);
|
|
|
|
// `localize` named imports
|
|
const allLocalizeImportDeclarations = importDeclarations
|
|
.filter(d => !!(d.importClause && d.importClause.namedBindings && d.importClause.namedBindings.kind === ts.SyntaxKind.NamedImports))
|
|
.map(d => ([] as any[]).concat((<ts.NamedImports>d.importClause!.namedBindings!).elements))
|
|
.flatten();
|
|
|
|
// `localize` read-only references
|
|
const localizeReferences = allLocalizeImportDeclarations
|
|
.filter(d => d.name.getText() === functionName)
|
|
.map(n => service.getReferencesAtPosition(filename, n.pos + 1) ?? [])
|
|
.flatten()
|
|
.filter(r => !r.isWriteAccess);
|
|
|
|
// custom named `localize` read-only references
|
|
const namedLocalizeReferences = allLocalizeImportDeclarations
|
|
.filter(d => d.propertyName && d.propertyName.getText() === functionName)
|
|
.map(n => service.getReferencesAtPosition(filename, n.name.pos + 1) ?? [])
|
|
.flatten()
|
|
.filter(r => !r.isWriteAccess);
|
|
|
|
// find the deepest call expressions AST nodes that contain those references
|
|
const localizeCallExpressions = localizeReferences
|
|
.concat(namedLocalizeReferences)
|
|
.map(r => collect(ts, sourceFile, n => isCallExpressionWithinTextSpanCollectStep(ts, r.textSpan, n)))
|
|
.map(a => lazy(a).last())
|
|
.filter(n => !!n)
|
|
.map(n => <ts.CallExpression>n);
|
|
|
|
// collect everything
|
|
const localizeCalls = nlsLocalizeCallExpressions
|
|
.concat(localizeCallExpressions)
|
|
.map(e => e.arguments)
|
|
.filter(a => a.length > 1)
|
|
.sort((a, b) => a[0].getStart() - b[0].getStart())
|
|
.map<ILocalizeCall>(a => ({
|
|
keySpan: { start: ts.getLineAndCharacterOfPosition(sourceFile, a[0].getStart()), end: ts.getLineAndCharacterOfPosition(sourceFile, a[0].getEnd()) },
|
|
key: a[0].getText(),
|
|
valueSpan: { start: ts.getLineAndCharacterOfPosition(sourceFile, a[1].getStart()), end: ts.getLineAndCharacterOfPosition(sourceFile, a[1].getEnd()) },
|
|
value: a[1].getText()
|
|
}));
|
|
|
|
return {
|
|
localizeCalls: localizeCalls.toArray()
|
|
};
|
|
}
|
|
|
|
class TextModel {
|
|
|
|
private lines: string[];
|
|
private lineEndings: string[];
|
|
|
|
constructor(contents: string) {
|
|
const regex = /\r\n|\r|\n/g;
|
|
let index = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
this.lines = [];
|
|
this.lineEndings = [];
|
|
|
|
while (match = regex.exec(contents)) {
|
|
this.lines.push(contents.substring(index, match.index));
|
|
this.lineEndings.push(match[0]);
|
|
index = regex.lastIndex;
|
|
}
|
|
|
|
if (contents.length > 0) {
|
|
this.lines.push(contents.substring(index, contents.length));
|
|
this.lineEndings.push('');
|
|
}
|
|
}
|
|
|
|
public get(index: number): string {
|
|
return this.lines[index];
|
|
}
|
|
|
|
public set(index: number, line: string): void {
|
|
this.lines[index] = line;
|
|
}
|
|
|
|
public get lineCount(): number {
|
|
return this.lines.length;
|
|
}
|
|
|
|
/**
|
|
* Applies patch(es) to the model.
|
|
* Multiple patches must be ordered.
|
|
* Does not support patches spanning multiple lines.
|
|
*/
|
|
public apply(patch: IPatch): void {
|
|
const startLineNumber = patch.span.start.line;
|
|
const endLineNumber = patch.span.end.line;
|
|
|
|
const startLine = this.lines[startLineNumber] || '';
|
|
const endLine = this.lines[endLineNumber] || '';
|
|
|
|
this.lines[startLineNumber] = [
|
|
startLine.substring(0, patch.span.start.character),
|
|
patch.content,
|
|
endLine.substring(patch.span.end.character)
|
|
].join('');
|
|
|
|
for (let i = startLineNumber + 1; i <= endLineNumber; i++) {
|
|
this.lines[i] = '';
|
|
}
|
|
}
|
|
|
|
public toString(): string {
|
|
return lazy(this.lines).zip(this.lineEndings)
|
|
.flatten().toArray().join('');
|
|
}
|
|
}
|
|
|
|
function patchJavascript(patches: IPatch[], contents: string): string {
|
|
const model = new TextModel(contents);
|
|
|
|
// patch the localize calls
|
|
lazy(patches).reverse().each(p => model.apply(p));
|
|
|
|
return model.toString();
|
|
}
|
|
|
|
function patchSourcemap(patches: IPatch[], rsm: sm.RawSourceMap, smc: sm.SourceMapConsumer): sm.RawSourceMap {
|
|
const smg = new sm.SourceMapGenerator({
|
|
file: rsm.file,
|
|
sourceRoot: rsm.sourceRoot
|
|
});
|
|
|
|
patches = patches.reverse();
|
|
let currentLine = -1;
|
|
let currentLineDiff = 0;
|
|
let source: string | null = null;
|
|
|
|
smc.eachMapping(m => {
|
|
const patch = patches[patches.length - 1];
|
|
const original = { line: m.originalLine, column: m.originalColumn };
|
|
const generated = { line: m.generatedLine, column: m.generatedColumn };
|
|
|
|
if (currentLine !== generated.line) {
|
|
currentLineDiff = 0;
|
|
}
|
|
|
|
currentLine = generated.line;
|
|
generated.column += currentLineDiff;
|
|
|
|
if (patch && m.generatedLine - 1 === patch.span.end.line && m.generatedColumn === patch.span.end.character) {
|
|
const originalLength = patch.span.end.character - patch.span.start.character;
|
|
const modifiedLength = patch.content.length;
|
|
const lengthDiff = modifiedLength - originalLength;
|
|
currentLineDiff += lengthDiff;
|
|
generated.column += lengthDiff;
|
|
|
|
patches.pop();
|
|
}
|
|
|
|
source = rsm.sourceRoot ? path.relative(rsm.sourceRoot, m.source) : m.source;
|
|
source = source.replace(/\\/g, '/');
|
|
smg.addMapping({ source, name: m.name, original, generated });
|
|
}, null, sm.SourceMapConsumer.GENERATED_ORDER);
|
|
|
|
if (source) {
|
|
smg.setSourceContent(source, smc.sourceContentFor(source));
|
|
}
|
|
|
|
return JSON.parse(smg.toString());
|
|
}
|
|
|
|
function parseLocalizeKeyOrValue(sourceExpression: string) {
|
|
// sourceValue can be "foo", 'foo', `foo` or { .... }
|
|
// in its evalulated form
|
|
// we want to return either the string or the object
|
|
// eslint-disable-next-line no-eval
|
|
return eval(`(${sourceExpression})`);
|
|
}
|
|
|
|
function patch(ts: typeof import('typescript'), typescript: string, javascript: string, sourcemap: sm.RawSourceMap, options: { preserveEnglish: boolean }): INlsPatchResult {
|
|
const { localizeCalls } = analyze(ts, typescript, 'localize');
|
|
const { localizeCalls: localize2Calls } = analyze(ts, typescript, 'localize2');
|
|
|
|
if (localizeCalls.length === 0 && localize2Calls.length === 0) {
|
|
return { javascript, sourcemap };
|
|
}
|
|
|
|
const nlsKeys = localizeCalls.map(lc => parseLocalizeKeyOrValue(lc.key)).concat(localize2Calls.map(lc => parseLocalizeKeyOrValue(lc.key)));
|
|
const nlsMessages = localizeCalls.map(lc => parseLocalizeKeyOrValue(lc.value)).concat(localize2Calls.map(lc => parseLocalizeKeyOrValue(lc.value)));
|
|
const smc = new sm.SourceMapConsumer(sourcemap);
|
|
const positionFrom = mappedPositionFrom.bind(null, sourcemap.sources[0]);
|
|
|
|
// build patches
|
|
const toPatch = (c: { range: ISpan; content: string }): IPatch => {
|
|
const start = lcFrom(smc.generatedPositionFor(positionFrom(c.range.start)));
|
|
const end = lcFrom(smc.generatedPositionFor(positionFrom(c.range.end)));
|
|
return { span: { start, end }, content: c.content };
|
|
};
|
|
|
|
const localizePatches = lazy(localizeCalls)
|
|
.map(lc => (
|
|
options.preserveEnglish ? [
|
|
{ range: lc.keySpan, content: `${allNLSMessagesIndex++}` } // localize('key', "message") => localize(<index>, "message")
|
|
] : [
|
|
{ range: lc.keySpan, content: `${allNLSMessagesIndex++}` }, // localize('key', "message") => localize(<index>, null)
|
|
{ range: lc.valueSpan, content: 'null' }
|
|
]))
|
|
.flatten()
|
|
.map(toPatch);
|
|
|
|
const localize2Patches = lazy(localize2Calls)
|
|
.map(lc => (
|
|
{ range: lc.keySpan, content: `${allNLSMessagesIndex++}` } // localize2('key', "message") => localize(<index>, "message")
|
|
))
|
|
.map(toPatch);
|
|
|
|
// Sort patches by their start position
|
|
const patches = localizePatches.concat(localize2Patches).toArray().sort((a, b) => {
|
|
if (a.span.start.line < b.span.start.line) {
|
|
return -1;
|
|
} else if (a.span.start.line > b.span.start.line) {
|
|
return 1;
|
|
} else if (a.span.start.character < b.span.start.character) {
|
|
return -1;
|
|
} else if (a.span.start.character > b.span.start.character) {
|
|
return 1;
|
|
} else {
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
javascript = patchJavascript(patches, javascript);
|
|
|
|
sourcemap = patchSourcemap(patches, sourcemap, smc);
|
|
|
|
return { javascript, sourcemap, nlsKeys, nlsMessages };
|
|
}
|
|
|
|
export function patchFile(javascriptFile: File, typescript: string, options: { preserveEnglish: boolean }): File {
|
|
const ts = require('typescript') as typeof import('typescript');
|
|
// hack?
|
|
const moduleId = javascriptFile.relative
|
|
.replace(/\.js$/, '')
|
|
.replace(/\\/g, '/');
|
|
|
|
const { javascript, sourcemap, nlsKeys, nlsMessages } = patch(
|
|
ts,
|
|
typescript,
|
|
javascriptFile.contents!.toString(),
|
|
// eslint-disable-next-line local/code-no-any-casts
|
|
(<any>javascriptFile).sourceMap,
|
|
options
|
|
);
|
|
|
|
const result = fileFrom(javascriptFile, javascript);
|
|
// eslint-disable-next-line local/code-no-any-casts
|
|
(<any>result).sourceMap = sourcemap;
|
|
|
|
if (nlsKeys) {
|
|
moduleToNLSKeys[moduleId] = nlsKeys;
|
|
allNLSModulesAndKeys.push([moduleId, nlsKeys.map(nlsKey => typeof nlsKey === 'string' ? nlsKey : nlsKey.key)]);
|
|
}
|
|
|
|
if (nlsMessages) {
|
|
moduleToNLSMessages[moduleId] = nlsMessages;
|
|
allNLSMessages.push(...nlsMessages);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|