Path: blob/main/extensions/copilot/test/simulation/diagnosticProviders/tsc.ts
13395 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import * as cp from 'child_process';6import * as fs from 'fs';7import * as path from 'path';8import ts from 'typescript/lib/tsserverlibrary';9import { ITestingServicesAccessor } from '../../../src/platform/test/node/services';10import { TestingCacheSalts } from '../../base/salts';11import { CacheScope } from '../../base/simulationContext';12import { REPO_ROOT } from '../../base/stest';13import { TS_SERVER_DIAGNOSTICS_PROVIDER_CACHE_SALT } from '../../cacheSalt';14import { cleanTempDirWithRetry, createTempDir } from '../stestUtil';15import { IFile, ITSDiagnosticRelatedInformation, ITestDiagnostic } from './diagnosticsProvider';16import { CachingDiagnosticsProvider, setupTemporaryWorkspace } from './utils';1718/**19* Class which finds TS Server diagnostics after compilation of TS files20*/21export class TSServerDiagnosticsProvider extends CachingDiagnosticsProvider {22override readonly id: string;23override readonly cacheSalt = TestingCacheSalts.tscCacheSalt;24override readonly cacheScope = CacheScope.TSC;2526public readonly ignoreImportErrors: boolean;2728constructor(options: { ignoreImportErrors?: boolean } = {}) {29super();30this.ignoreImportErrors = options.ignoreImportErrors ?? false;31this.id = this.ignoreImportErrors ? 'tsc-ignore-import-errors' : 'tsc';32}3334protected override get cacheVersion(): number { return TS_SERVER_DIAGNOSTICS_PROVIDER_CACHE_SALT; }3536protected override async computeDiagnostics(files: IFile[]): Promise<ITestDiagnostic[]> {37if (this.ignoreImportErrors) {38const identifiers = new Set<string>();39for (const file of files) {40addIdentifiersToSet(file.fileContents, identifiers);41}42const filteredIdentifiers = [...withoutKeywords(identifiers)];43files.push({44fileName: 'modules-mock.d.ts',45fileContents: `46declare module '*' {47${filteredIdentifiers.map(i => `export const ${i}: any; export type ${i} = any;`).join('\n\t')}48}49`50});51}5253const workspacePath = await createTempDir();54const filesWithPaths = await setupTemporaryWorkspace(workspacePath, files);5556const packagejson = filesWithPaths.find(file => path.basename(file.fileName) === 'package.json');57if (packagejson) {58try {59await doRunNpmInstall(path.dirname(packagejson.filePath));60} catch (err) {61return files.map(file => ({62file: file.fileName,63startLine: 0,64startCharacter: 0,65endLine: 0,66endCharacter: 0,67code: 'npm-install-failed',68message: `npm install failed: ${err.message}`,69source: 'ts',70relatedInformation: undefined71}));72}73}7475const hasTSConfigFile = filesWithPaths.some(file => path.basename(file.fileName) === 'tsconfig.json');7677if (!hasTSConfigFile) {78const tsconfigPath = path.join(workspacePath, 'tsconfig.json');79let tsConfig: any;80if (this.ignoreImportErrors) {81tsConfig = {82'compilerOptions': {83'target': 'es2021',84'strict': true,85'module': 'commonjs',86'outDir': 'out',87'sourceMap': false,88'useDefineForClassFields': false,89'experimentalDecorators': true,90},91'exclude': [92'node_modules',93'outcome',94'scenarios'95]96};97} else {98tsConfig = {99'compilerOptions': {100'target': 'es2021',101'strict': true,102'module': 'commonjs',103'outDir': 'out',104'sourceMap': true105},106'exclude': [107'node_modules',108'outcome',109'scenarios'110]111};112}113await fs.promises.writeFile(tsconfigPath, JSON.stringify(tsConfig));114}115116try {117let diagnostics = await this.compileFolder(workspacePath, filesWithPaths);118if (this.ignoreImportErrors) {119const errorCodeThisMemberCannotHaveAnOverride = 4113; // "This member cannot have an 'override' modifier because it is not declared in the base class 'any'."120const errorCodeParameterOptionsImplicitlyHasAnAnyType = 7006; // "Parameter 'options' implicitly has an 'any' type."121diagnostics = diagnostics.filter(d => d.code !== errorCodeThisMemberCannotHaveAnOverride && d.code !== errorCodeParameterOptionsImplicitlyHasAnAnyType);122}123return diagnostics;124} finally {125cleanTempDirWithRetry(workspacePath);126}127}128129private compileFolder(workspacePath: string, files: { filePath: string; fileName: string; fileContents: string }[]): Promise<ITestDiagnostic[]> {130return new Promise<ITestDiagnostic[]>((resolve, reject) => {131const results: ITestDiagnostic[] = [];132133const tsserverPath = path.resolve(path.join(REPO_ROOT, 'node_modules/typescript/lib/tsserver.js'));134const tsserver = cp.fork(tsserverPath, {135cwd: workspacePath,136stdio: ['pipe', 'pipe', 'pipe', 'ipc']137});138tsserver.stdin?.setDefaultEncoding('utf8');139tsserver.stdout?.setEncoding('utf8');140141let seq = 1;142const seqToFile = new Map<number, string>();143const writeRequest = (data: any) => {144data.seq = seq++;145const actual = `${JSON.stringify(data)}\r\n`;146tsserver.stdin!.write(actual);147};148149for (const file of files) {150writeRequest({151'type': 'request',152'command': 'open',153'arguments': { 'file': file.filePath }154});155}156for (const file of files) {157seqToFile.set(seq, file.fileName);158writeRequest({159'type': 'request',160'command': 'syntacticDiagnosticsSync',161'arguments': { 'file': file.filePath }162});163}164for (const file of files) {165seqToFile.set(seq, file.fileName);166writeRequest({167'type': 'request',168'command': 'semanticDiagnosticsSync',169'arguments': { 'file': file.filePath }170});171}172tsserver.on('error', reject);173const handleMessage = (msg: ts.server.protocol.Message) => {174if (msg.type !== 'response') {175return;176}177const resp = msg as ts.server.protocol.Response;178if (resp.command !== 'semanticDiagnosticsSync' && resp.command !== 'syntacticDiagnosticsSync') {179return;180}181const kind = resp.command === 'semanticDiagnosticsSync' ? 'semantic' : 'syntactic';182const diagResp = resp as ts.server.protocol.SemanticDiagnosticsSyncResponse | ts.server.protocol.SyntacticDiagnosticsSyncResponse;183for (const diag of diagResp.body ?? []) {184if (typeof diag.start === 'number') {185throw new Error(`TODO: Can't handle DiagnosticWithLinePosition right now`);186}187const regularDiag = diag as ts.server.protocol.Diagnostic;188const _relatedInfo: (ITSDiagnosticRelatedInformation | null)[] = (regularDiag.relatedInformation ?? []).map((ri) => {189if (!ri.span) {190return null;191}192return {193location: {194file: ri.span.file.substring(workspacePath.length + 1),195startLine: ri.span?.start.line - 1,196startCharacter: ri.span?.start.offset - 1,197endLine: ri.span?.end.line - 1,198endCharacter: ri.span?.end.offset - 1,199},200message: ri.message,201code: ri.code202};203});204const relatedInformation = _relatedInfo.filter((x): x is ITSDiagnosticRelatedInformation => !!x);205results.push({206file: seqToFile.get(diagResp.request_seq)!,207startLine: regularDiag.start.line - 1,208startCharacter: regularDiag.start.offset - 1,209endLine: regularDiag.end.line - 1,210endCharacter: regularDiag.end.offset - 1,211message: regularDiag.text,212code: regularDiag.code,213relatedInformation,214source: 'ts',215kind,216});217}218219if (diagResp.request_seq === seq - 1) {220writeRequest({221'type': 'request',222'command': 'exit',223});224tsserver.on('exit', () => {225resolve(results);226});227tsserver.kill();228}229};230231let stdout = '';232const processStdoutData = () => {233do {234const eolIndex = stdout.indexOf('\r\n') ?? stdout.indexOf('\n');235if (eolIndex === -1) {236break;237}238const firstLine = stdout.substring(0, eolIndex);239let body;240if (firstLine.includes('Content-Length')) {241const contentLength = parseInt(firstLine.substring('Content-Length: '.length), 10);242body = stdout.substring(eolIndex + 4, eolIndex + 4 + contentLength);243if (body.length < contentLength) {244// entire body did not arrive yet245break;246}247stdout = stdout.substring(eolIndex + 4 + contentLength);248} else {249// Might come after the body250body = firstLine;251// Hold on to the rest of the stdout for the next iteration252stdout = stdout.substring(eolIndex + 2);253}254255try {256handleMessage(JSON.parse(body));257} catch (ex) {258console.error(ex);259}260} while (true);261};262263tsserver.stdout!.on('data', (chunk) => {264stdout += chunk;265processStdoutData();266});267});268}269}270271function addIdentifiersToSet(content: string, result: Set<string>): void {272const regex = /\b[a-zA-Z_][a-zA-Z0-9_]*\b/g;273let match: RegExpExecArray | null;274while ((match = regex.exec(content)) !== null) {275result.add(match[0]);276}277}278279function withoutKeywords(identifiers: Set<string>): Set<string> {280const keywords = ['class', 'interface', 'function', 'const', 'let', 'var', 'import', 'export', 'from', 'default', 'extends', 'implements', 'new', 'return', 'if', 'else', 'for',281'while', 'do', 'switch', 'case', 'break', 'continue', 'throw', 'try', 'catch', 'finally', 'finally', 'await', 'async', 'await', 'void', 'any', 'number',282'string', 'boolean', 'object', 'null', 'undefined', 'true', 'false', 'this', 'super', 'typeof', 'instanceof', 'in', 'as', 'is', 'delete', 'typeof',283'instanceof', 'in', 'as', 'is', 'delete', 'void', 'never', 'unknown', 'declare', 'namespace', 'module', 'type', 'enum', 'readonly', 'abstract', 'private',284'protected', 'public', 'static', 'readonly', 'abstract', 'private', 'protected', 'public', 'static', 'get', 'set', 'constructor', 'require', 'module', 'exports',285'global', 'window', 'document', 'console', 'process', 'require', 'module', 'exports', 'global', 'window', 'document', 'console', 'process', 'with'];286const keywordsSet = new Set(keywords);287const filteredIdentifiers = new Set<string>();288for (const identifier of identifiers) {289if (!keywordsSet.has(identifier)) {290filteredIdentifiers.add(identifier);291}292}293return filteredIdentifiers;294}295296/**297* Runs `npm install` and proceeds to compile the files in the passed in folder. This is cached and is safe to use in tests.298*/299export async function compileTSWorkspace(accessor: ITestingServicesAccessor, folderPath: string): Promise<ITestDiagnostic[]> {300const files = await readTSFiles(folderPath);301return await new TSServerDiagnosticsProvider().getDiagnostics(accessor, files);302}303304export function doRunNpmInstall(projectRoot: string): Promise<void> {305return new Promise((resolve, reject) => {306cp.exec('npm install', { cwd: projectRoot }, (error, stdout, stderr) => {307if (error) {308return reject(error);309}310return resolve();311});312});313}314315async function readTSFiles(folderPath: string): Promise<IFile[]> {316const allFiles: string[] = [];317await rreaddir(folderPath, allFiles);318return await Promise.all(319allFiles.filter(320file => ['.ts', '.tsx', '.json'].includes(path.extname(file))321).map(async (filePath) => {322const relativeFilePath = path.relative(folderPath, filePath);323const fileContents = await fs.promises.readFile(filePath, 'utf8');324return {325fileName: relativeFilePath,326fileContents327};328})329);330}331332async function rreaddir(folderPath: string, result: string[]): Promise<void> {333const entries = await fs.promises.readdir(folderPath, { withFileTypes: true });334for (const entry of entries) {335const fullPath = path.join(folderPath, entry.name);336if (entry.isDirectory()) {337await rreaddir(fullPath, result);338} else {339result.push(fullPath);340}341}342}343344345