diff --git a/.github/codecov.yml b/.github/codecov.yml index f16d332d04b0..f6edc9849e2e 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -2,3 +2,8 @@ coverage: precision: 0 round: up range: "70...90" + status: + project: off +comment: + layout: "diff, flags" + behavior: default diff --git a/.vscode/settings.json b/.vscode/settings.json index d928b65c7554..a5f575069106 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,5 +22,8 @@ "python.linting.enabled": false, "python.unitTest.promptToConfigure": false, "python.workspaceSymbols.enabled": false, - "python.formatting.provider": "none" + "python.formatting.provider": "none", + "typescript.preferences.quoteStyle": "single", + "javascript.preferences.quoteStyle": "single", + "typescriptHero.imports.stringQuoteStyle": "'" } diff --git a/gulpfile.js b/gulpfile.js index 21e45228c4fb..b5eee666090d 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -704,7 +704,6 @@ function getFilesToProcess(fileList) { * @param {hygieneOptions} options */ function getFileListToProcess(options) { - return []; const mode = options ? options.mode : 'all'; const gulpSrcOptions = { base: '.' }; diff --git a/news/2 Fixes/3346.md b/news/2 Fixes/3346.md new file mode 100644 index 000000000000..b4b57ef8a4af --- /dev/null +++ b/news/2 Fixes/3346.md @@ -0,0 +1 @@ +Ensure extension does not start multiple language servers. diff --git a/package-lock.json b/package-lock.json index 1baa38f4cc1b..d4956cb7c708 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1660,6 +1660,12 @@ "integrity": "sha512-Tt7w/ylBS/OEAlSCwzB0Db1KbxnkycP/1UkQpbvKFYoUuRn4uYsC3xh5TRPrOjTy0i8TIkSz1JdNL4GPVdf3KQ==", "dev": true }, + "@types/stack-trace": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/stack-trace/-/stack-trace-0.0.29.tgz", + "integrity": "sha512-TgfOX+mGY/NyNxJLIbDWrO9DjGoVSW9+aB8H2yy1fy32jsvxijhmyJI9fDFgvz3YP4lvJaq9DzdR/M1bOgVc9g==", + "dev": true + }, "@types/strip-json-comments": { "version": "0.0.30", "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", @@ -4018,6 +4024,12 @@ "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", "dev": true }, + "compare-module-exports": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/compare-module-exports/-/compare-module-exports-2.1.0.tgz", + "integrity": "sha512-3Lc0sTIuX1jmY2K2RrXRJOND6KsRTX2D4v3+eu1PDptsuJZVK4LZc852eZa9I+avj0NrUKlTNgqvccNOH6mbGg==", + "dev": true + }, "component-emitter": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", @@ -10445,6 +10457,12 @@ "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=", "dev": true }, + "lodash.some": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz", + "integrity": "sha1-G7nzFO9ri63tE7VJFpsqlF62jk0=", + "dev": true + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -14530,6 +14548,43 @@ "integrity": "sha1-Jkgr9JFcftn4MAu1y+xI/U/1vGI=", "dev": true }, + "rewiremock": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/rewiremock/-/rewiremock-3.13.0.tgz", + "integrity": "sha512-1MkO4mX4j31GilbMsqdgLNXjmrHo9EUKQFCa82rLye8ltOHnJe0rRaHUSKz2yUClr8l0Qnj1ZTjZHmp6vNTrzQ==", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "compare-module-exports": "^2.1.0", + "lodash.some": "^4.6.0", + "lodash.template": "^4.4.0", + "node-libs-browser": "^2.1.0", + "path-parse": "^1.0.5", + "wipe-node-cache": "^2.1.0", + "wipe-webpack-cache": "^2.1.0" + }, + "dependencies": { + "lodash.template": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.4.0.tgz", + "integrity": "sha1-5zoDhcg1VZF0bgILmWecaQ5o+6A=", + "dev": true, + "requires": { + "lodash._reinterpolate": "~3.0.0", + "lodash.templatesettings": "^4.0.0" + } + }, + "lodash.templatesettings": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-4.1.0.tgz", + "integrity": "sha1-K01OlbpEDZFf8IvImeRVNmZxMxY=", + "dev": true, + "requires": { + "lodash._reinterpolate": "~3.0.0" + } + } + } + }, "right-align": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", @@ -17986,6 +18041,21 @@ "resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.4.tgz", "integrity": "sha1-ugZWKbepJRMOFXeRCM9UCZDpjRs=" }, + "wipe-node-cache": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wipe-node-cache/-/wipe-node-cache-2.1.0.tgz", + "integrity": "sha512-Vdash0WV9Di/GeYW9FJrAZcPjGK4dO7M/Be/sJybguEgcM7As0uwLyvewZYqdlepoh7Rj4ZJKEdo8uX83PeNIw==", + "dev": true + }, + "wipe-webpack-cache": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wipe-webpack-cache/-/wipe-webpack-cache-2.1.0.tgz", + "integrity": "sha512-OXzQMGpA7MnQQ8AG+uMl5mWR2ezy6fw1+DMHY+wzYP1qkF1jrek87psLBmhZEj+er4efO/GD4R8jXWFierobaA==", + "dev": true, + "requires": { + "wipe-node-cache": "^2.1.0" + } + }, "wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index 6496e5dd06b9..cf3c2b0cfb9b 100644 --- a/package.json +++ b/package.json @@ -1542,7 +1542,7 @@ "type": "string", "default": "pipenv", "description": "Path to the pipenv executable to use for activation.", - "scope": "window" + "scope": "resource" }, "python.sortImports.args": { "type": "array", @@ -1929,6 +1929,7 @@ "@types/semver": "^5.5.0", "@types/shortid": "^0.0.29", "@types/sinon": "^4.3.0", + "@types/stack-trace": "0.0.29", "@types/temp": "^0.8.32", "@types/tmp": "0.0.33", "@types/untildify": "^3.0.0", @@ -1987,6 +1988,7 @@ "relative": "^3.0.2", "remap-istanbul": "^0.10.1", "retyped-diff-match-patch-tsd-ambient": "^1.0.0-0", + "rewiremock": "^3.13.0", "shortid": "^2.2.8", "source-map-support": "^0.5.9", "style-loader": "^0.23.1", diff --git a/src/client/activation/activationService.ts b/src/client/activation/activationService.ts index 4888330ba524..d9a7bf023b8c 100644 --- a/src/client/activation/activationService.ts +++ b/src/client/activation/activationService.ts @@ -58,7 +58,7 @@ export class ExtensionActivationService implements IExtensionActivationService, if (!jedi) { const diagnostic = await this.lsNotSupportedDiagnosticService.diagnose(); this.lsNotSupportedDiagnosticService.handle(diagnostic).ignoreErrors(); - if (diagnostic.length){ + if (diagnostic.length) { sendTelemetryEvent(PYTHON_LANGUAGE_SERVER_PLATFORM_NOT_SUPPORTED); jedi = true; } @@ -70,8 +70,13 @@ export class ExtensionActivationService implements IExtensionActivationService, let activator = this.serviceContainer.get(IExtensionActivator, activatorName); this.currentActivator = { jedi, activator }; - const success = await activator.activate(); - if (!success && !jedi) { + try { + await activator.activate(); + return; + } catch (ex) { + if (jedi) { + return; + } //Language server fails, reverting to jedi jedi = true; await this.logStartup(jedi); @@ -84,7 +89,7 @@ export class ExtensionActivationService implements IExtensionActivationService, public dispose() { if (this.currentActivator) { - this.currentActivator.activator.deactivate().ignoreErrors(); + this.currentActivator.activator.dispose(); } } diff --git a/src/client/activation/downloader.ts b/src/client/activation/downloader.ts index 6847f66a974e..4a2ebb1828c9 100644 --- a/src/client/activation/downloader.ts +++ b/src/client/activation/downloader.ts @@ -3,50 +3,42 @@ 'use strict'; +import { inject, injectable, named } from 'inversify'; import * as path from 'path'; import { ProgressLocation, window } from 'vscode'; import { IApplicationShell } from '../common/application/types'; import { STANDARD_OUTPUT_CHANNEL } from '../common/constants'; import { IFileSystem } from '../common/platform/types'; -import { IExtensionContext, IOutputChannel } from '../common/types'; +import { IOutputChannel } from '../common/types'; import { createDeferred } from '../common/utils/async'; import { LanguageService } from '../common/utils/localize'; import { StopWatch } from '../common/utils/stopWatch'; -import { IServiceContainer } from '../ioc/types'; import { sendTelemetryEvent } from '../telemetry'; import { PYTHON_LANGUAGE_SERVER_DOWNLOADED, PYTHON_LANGUAGE_SERVER_ERROR, PYTHON_LANGUAGE_SERVER_EXTRACTED } from '../telemetry/constants'; -import { - IHttpClient, ILanguageServerDownloader, ILanguageServerFolderService, - ILanguageServerPlatformData -} from './types'; +import { IHttpClient, ILanguageServerDownloader, ILanguageServerFolderService, IPlatformData } from './types'; const downloadFileExtension = '.nupkg'; +@injectable() export class LanguageServerDownloader implements ILanguageServerDownloader { - private readonly output: IOutputChannel; - private readonly fs: IFileSystem; - private readonly appShell: IApplicationShell; constructor( - private readonly platformData: ILanguageServerPlatformData, - private readonly engineFolder: string, - private readonly serviceContainer: IServiceContainer + @inject(IPlatformData) private readonly platformData: IPlatformData, + @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly output: IOutputChannel, + @inject(IHttpClient) private readonly httpClient: IHttpClient, + @inject(ILanguageServerFolderService) private readonly lsFolderService: ILanguageServerFolderService, + @inject(IApplicationShell) private readonly appShell: IApplicationShell, + @inject(IFileSystem) private readonly fs: IFileSystem ) { - this.output = this.serviceContainer.get(IOutputChannel, STANDARD_OUTPUT_CHANNEL); - this.fs = this.serviceContainer.get(IFileSystem); - this.appShell = this.serviceContainer.get(IApplicationShell); - } public async getDownloadInfo() { - const lsFolderService = this.serviceContainer.get(ILanguageServerFolderService); - return lsFolderService.getLatestLanguageServerVersion().then(item => item!); + return this.lsFolderService.getLatestLanguageServerVersion().then(item => item!); } - - public async downloadLanguageServer(context: IExtensionContext): Promise { + public async downloadLanguageServer(destinationFolder: string): Promise { const downloadInfo = await this.getDownloadInfo(); const downloadUri = downloadInfo.uri; const lsVersion = downloadInfo.version.raw; @@ -61,7 +53,7 @@ export class LanguageServerDownloader implements ILanguageServerDownloader { this.output.appendLine(err); success = false; this.appShell.showErrorMessage(LanguageService.lsFailedToDownload()); - sendTelemetryEvent(PYTHON_LANGUAGE_SERVER_ERROR, undefined, { error: 'Failed to download (platform)' }); + sendTelemetryEvent(PYTHON_LANGUAGE_SERVER_ERROR, undefined, { error: 'Failed to download (platform)' }, err); throw new Error(err); } finally { sendTelemetryEvent( @@ -73,13 +65,13 @@ export class LanguageServerDownloader implements ILanguageServerDownloader { timer.reset(); try { - await this.unpackArchive(context.extensionPath, localTempFilePath); + await this.unpackArchive(destinationFolder, localTempFilePath); } catch (err) { this.output.appendLine('extraction failed.'); this.output.appendLine(err); success = false; this.appShell.showErrorMessage(LanguageService.lsFailedToExtract()); - sendTelemetryEvent(PYTHON_LANGUAGE_SERVER_ERROR, undefined, { error: 'Failed to extract (platform)' }); + sendTelemetryEvent(PYTHON_LANGUAGE_SERVER_ERROR, undefined, { error: 'Failed to extract (platform)' }, err); throw new Error(err); } finally { sendTelemetryEvent( @@ -107,11 +99,12 @@ export class LanguageServerDownloader implements ILanguageServerDownloader { await window.withProgress({ location: ProgressLocation.Window }, async (progress) => { - const httpClient = this.serviceContainer.get(IHttpClient); - const req = await httpClient.downloadFile(uri); + const req = await this.httpClient.downloadFile(uri); req.on('response', (response) => { if (response.statusCode !== 200) { - throw new Error(`Failed with status ${response.statusCode}, ${response.statusMessage}, Uri ${uri}`); + const error = new Error(`Failed with status ${response.statusCode}, ${response.statusMessage}, Uri ${uri}`); + deferred.reject(error); + throw error; } }); const requestProgress = await import('request-progress'); @@ -139,10 +132,9 @@ export class LanguageServerDownloader implements ILanguageServerDownloader { return tempFile.filePath; } - protected async unpackArchive(extensionPath: string, tempFilePath: string): Promise { + protected async unpackArchive(destinationFolder: string, tempFilePath: string): Promise { this.output.append('Unpacking archive... '); - const installFolder = path.join(extensionPath, this.engineFolder); const deferred = createDeferred(); const title = 'Extracting files... '; @@ -160,10 +152,10 @@ export class LanguageServerDownloader implements ILanguageServerDownloader { let extractedFiles = 0; zip.on('ready', async () => { totalFiles = zip.entriesCount; - if (!await this.fs.directoryExists(installFolder)) { - await this.fs.createDirectory(installFolder); + if (!await this.fs.directoryExists(destinationFolder)) { + await this.fs.createDirectory(destinationFolder); } - zip.extract(null, installFolder, (err) => { + zip.extract(null, destinationFolder, (err) => { if (err) { deferred.reject(err); } else { @@ -181,7 +173,7 @@ export class LanguageServerDownloader implements ILanguageServerDownloader { }); // Set file to executable (nothing happens in Windows, as chmod has no definition there) - const executablePath = path.join(installFolder, this.platformData.getEngineExecutableName()); + const executablePath = path.join(destinationFolder, this.platformData.engineExecutableName); await this.fs.chmod(executablePath, '0764'); // -rwxrw-r-- this.output.appendLine('done.'); diff --git a/src/client/activation/hashVerifier.ts b/src/client/activation/hashVerifier.ts deleted file mode 100644 index e0b405ff5877..000000000000 --- a/src/client/activation/hashVerifier.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import { createHash } from 'crypto'; -import * as fs from 'fs'; -import { createDeferred } from '../common/utils/async'; - -export class HashVerifier { - public async verifyHash(filePath: string, platformString: string, expectedDigest: string): Promise { - const readStream = fs.createReadStream(filePath); - const deferred = createDeferred(); - const hash = createHash('sha512'); - hash.setEncoding('hex'); - readStream - .on('end', () => { - hash.end(); - deferred.resolve(); - }) - .on('error', (err) => { - deferred.reject(`Unable to calculate file hash. Error ${err}`); - }); - - readStream.pipe(hash); - await deferred.promise; - const actual = hash.read() as string; - return expectedDigest === platformString ? true : actual.toLowerCase() === expectedDigest.toLowerCase(); - } -} diff --git a/src/client/activation/interpreterDataService.ts b/src/client/activation/interpreterDataService.ts index 1e082bb171d5..723f2857e2bc 100644 --- a/src/client/activation/interpreterDataService.ts +++ b/src/client/activation/interpreterDataService.ts @@ -3,140 +3,142 @@ import { createHash } from 'crypto'; import * as fs from 'fs'; +import { inject, injectable } from 'inversify'; import * as path from 'path'; -import { ExtensionContext, Uri } from 'vscode'; import { IApplicationShell } from '../common/application/types'; import '../common/extensions'; import { IPlatformService } from '../common/platform/types'; import { IPythonExecutionFactory, IPythonExecutionService } from '../common/process/types'; +import { IExtensionContext, Resource } from '../common/types'; import { createDeferred } from '../common/utils/async'; import { IServiceContainer } from '../ioc/types'; +import { IInterpreterDataService, InterpreterData } from './types'; const DataVersion = 1; - -export class InterpreterData { - constructor( - public readonly dataVersion: number, - // tslint:disable-next-line:no-shadowed-variable - public readonly path: string, - public readonly version: string, - public readonly searchPaths: string, - public readonly hash: string - ) { } +class InterpreterDataCls { + constructor( + public readonly dataVersion: number, + // tslint:disable-next-line:no-shadowed-variable + public readonly path: string, + public readonly version: string, + public readonly searchPaths: string, + public readonly hash: string + ) { } } -export class InterpreterDataService { - constructor( - private readonly context: ExtensionContext, - private readonly serviceContainer: IServiceContainer) { } +@injectable() +export class InterpreterDataService implements IInterpreterDataService { + constructor( + @inject(IExtensionContext) private readonly context: IExtensionContext, + @inject(IServiceContainer) private readonly serviceContainer: IServiceContainer) { } - public async getInterpreterData(resource?: Uri): Promise { - const executionFactory = this.serviceContainer.get(IPythonExecutionFactory); - const execService = await executionFactory.create({ resource }); + public async getInterpreterData(resource: Resource): Promise { + const executionFactory = this.serviceContainer.get(IPythonExecutionFactory); + const execService = await executionFactory.create({ resource }); - const interpreterPath = await execService.getExecutablePath(); - if (interpreterPath.length === 0) { - return; - } + const interpreterPath = await execService.getExecutablePath(); + if (interpreterPath.length === 0) { + return; + } - const cacheKey = `InterpreterData-${interpreterPath}`; - let interpreterData = this.context.globalState.get(cacheKey); - let interpreterChanged = false; - if (interpreterData) { - // Check if interpreter executable changed - if (interpreterData.dataVersion !== DataVersion) { - interpreterChanged = true; - } else { - const currentHash = await this.getInterpreterHash(interpreterPath); - interpreterChanged = currentHash !== interpreterData.hash; - } - } + const cacheKey = `InterpreterData-${interpreterPath}`; + let interpreterData = this.context.globalState.get(cacheKey); + let interpreterChanged = false; + if (interpreterData) { + // Check if interpreter executable changed + if (interpreterData.dataVersion !== DataVersion) { + interpreterChanged = true; + } else { + const currentHash = await this.getInterpreterHash(interpreterPath); + interpreterChanged = currentHash !== interpreterData.hash; + } + } - if (interpreterChanged || !interpreterData) { - interpreterData = await this.getInterpreterDataFromPython(execService, interpreterPath); - this.context.globalState.update(interpreterPath, interpreterData); - } else { - // Make sure we verify that search paths did not change. This must be done - // completely async so we don't delay Python language server startup. - this.verifySearchPaths(interpreterData.searchPaths, interpreterPath, execService); + if (interpreterChanged || !interpreterData) { + interpreterData = await this.getInterpreterDataFromPython(execService, interpreterPath); + this.context.globalState.update(interpreterPath, interpreterData); + } else { + // Make sure we verify that search paths did not change. This must be done + // completely async so we don't delay Python language server startup. + this.verifySearchPaths(interpreterData.searchPaths, interpreterPath, execService); + } + return interpreterData; } - return interpreterData; - } - public getInterpreterHash(interpreterPath: string): Promise { - const platform = this.serviceContainer.get(IPlatformService); - const pythonExecutable = path.join(path.dirname(interpreterPath), platform.isWindows ? 'python.exe' : 'python'); - // Hash mod time and creation time - const deferred = createDeferred(); - fs.lstat(pythonExecutable, (err, stats) => { - if (err) { - deferred.resolve(''); - } else { - const actual = createHash('sha512').update(`${stats.ctime}-${stats.mtime}`).digest('hex'); - deferred.resolve(actual); - } - }); - return deferred.promise; - } - - private async getInterpreterDataFromPython(execService: IPythonExecutionService, interpreterPath: string): Promise { - const result = await execService.exec(['-c', 'import sys; print(sys.version_info)'], {}); - // sys.version_info(major=3, minor=6, micro=6, releaselevel='final', serial=0) - if (!result.stdout) { - throw Error('Unable to determine Python interpreter version and system prefix.'); - } - const output = result.stdout.splitLines({ removeEmptyEntries: true, trim: true }); - if (!output || output.length < 1) { - throw Error('Unable to parse version and and system prefix from the Python interpreter output.'); - } - const majorMatches = output[0].match(/major=(\d*?),/); - const minorMatches = output[0].match(/minor=(\d*?),/); - if (!majorMatches || majorMatches.length < 2 || !minorMatches || minorMatches.length < 2) { - throw Error('Unable to parse interpreter version.'); + public getInterpreterHash(interpreterPath: string): Promise { + const platform = this.serviceContainer.get(IPlatformService); + const pythonExecutable = path.join(path.dirname(interpreterPath), platform.isWindows ? 'python.exe' : 'python'); + // Hash mod time and creation time + const deferred = createDeferred(); + fs.lstat(pythonExecutable, (err, stats) => { + if (err) { + deferred.resolve(''); + } else { + const actual = createHash('sha512').update(`${stats.ctime}-${stats.mtime}`).digest('hex'); + deferred.resolve(actual); + } + }); + return deferred.promise; } - const hash = await this.getInterpreterHash(interpreterPath); - const searchPaths = await this.getSearchPaths(execService); - return new InterpreterData(DataVersion, interpreterPath, `${majorMatches[1]}.${minorMatches[1]}`, searchPaths, hash); - } - private async getSearchPaths(execService: IPythonExecutionService): Promise { - const result = await execService.exec(['-c', 'import sys; import os; print(sys.path + os.getenv("PYTHONPATH", "").split(os.pathsep));'], {}); - if (!result.stdout) { - throw Error('Unable to determine Python interpreter search paths.'); + private async getInterpreterDataFromPython(execService: IPythonExecutionService, interpreterPath: string): Promise { + const result = await execService.exec(['-c', 'import sys; print(sys.version_info)'], {}); + // sys.version_info(major=3, minor=6, micro=6, releaselevel='final', serial=0) + if (!result.stdout) { + throw Error('Unable to determine Python interpreter version and system prefix.'); + } + const output = result.stdout.splitLines({ removeEmptyEntries: true, trim: true }); + if (!output || output.length < 1) { + throw Error('Unable to parse version and and system prefix from the Python interpreter output.'); + } + const majorMatches = output[0].match(/major=(\d*?),/); + const minorMatches = output[0].match(/minor=(\d*?),/); + if (!majorMatches || majorMatches.length < 2 || !minorMatches || minorMatches.length < 2) { + throw Error('Unable to parse interpreter version.'); + } + const hash = await this.getInterpreterHash(interpreterPath); + const searchPaths = await this.getSearchPaths(execService); + return new InterpreterDataCls(DataVersion, interpreterPath, `${majorMatches[1]}.${minorMatches[1]}`, searchPaths, hash); } - // tslint:disable-next-line:no-unnecessary-local-variable - const paths = result.stdout.split(',') - .filter(p => this.isValidPath(p)) - .map(p => this.pathCleanup(p)); - return paths.join(';'); // PTVS uses ; on all platforms - } - private pathCleanup(s: string): string { - s = s.trim(); - if (s[0] === '\'') { - s = s.substr(1); - } - if (s[s.length - 1] === ']') { - s = s.substr(0, s.length - 1); + private async getSearchPaths(execService: IPythonExecutionService): Promise { + const result = await execService.exec(['-c', 'import sys; import os; print(sys.path + os.getenv("PYTHONPATH", "").split(os.pathsep));'], {}); + if (!result.stdout) { + throw Error('Unable to determine Python interpreter search paths.'); + } + // tslint:disable-next-line:no-unnecessary-local-variable + const paths = result.stdout.split(',') + .filter(p => this.isValidPath(p)) + .map(p => this.pathCleanup(p)); + return paths.join(';'); // PTVS uses ; on all platforms } - if (s[s.length - 1] === '\'') { - s = s.substr(0, s.length - 1); + + private pathCleanup(s: string): string { + s = s.trim(); + if (s[0] === '\'') { + s = s.substr(1); + } + if (s[s.length - 1] === ']') { + s = s.substr(0, s.length - 1); + } + if (s[s.length - 1] === '\'') { + s = s.substr(0, s.length - 1); + } + return s; } - return s; - } - private isValidPath(s: string): boolean { - return s.length > 0 && s[0] !== '['; - } + private isValidPath(s: string): boolean { + return s.length > 0 && s[0] !== '['; + } - private verifySearchPaths(currentPaths: string, interpreterPath: string, execService: IPythonExecutionService): void { - this.getSearchPaths(execService) - .then(async paths => { - if (paths !== currentPaths) { - this.context.globalState.update(interpreterPath, undefined); - const appShell = this.serviceContainer.get(IApplicationShell); - await appShell.showWarningMessage('Search paths have changed for this Python interpreter. Please reload the extension to ensure that the IntelliSense works correctly.'); - } - }).ignoreErrors(); - } + private verifySearchPaths(currentPaths: string, interpreterPath: string, execService: IPythonExecutionService): void { + this.getSearchPaths(execService) + .then(async paths => { + if (paths !== currentPaths) { + this.context.globalState.update(interpreterPath, undefined); + const appShell = this.serviceContainer.get(IApplicationShell); + await appShell.showWarningMessage('Search paths have changed for this Python interpreter. Please reload the extension to ensure that the IntelliSense works correctly.'); + } + }).ignoreErrors(); + } } diff --git a/src/client/activation/jedi.ts b/src/client/activation/jedi.ts index f12debb4b0c4..e986e95180d8 100644 --- a/src/client/activation/jedi.ts +++ b/src/client/activation/jedi.ts @@ -33,7 +33,7 @@ export class JediExtensionActivator implements IExtensionActivator { this.documentSelector = PYTHON; } - public async activate(): Promise { + public async activate(): Promise { const context = this.context; const jediFactory = this.jediFactory = new JediFactory(context.asAbsolutePath('.'), this.serviceManager); @@ -76,11 +76,9 @@ export class JediExtensionActivator implements IExtensionActivator { testManagementService.activate() .then(() => testManagementService.activateCodeLenses(symbolProvider)) .catch(ex => this.serviceManager.get(ILogger).logError('Failed to activate Unit Tests', ex)); - - return true; } - public async deactivate(): Promise { + public dispose(): void { if (this.jediFactory) { this.jediFactory.dispose(); } diff --git a/src/client/activation/languageServer/activator.ts b/src/client/activation/languageServer/activator.ts new file mode 100644 index 000000000000..a6cc3904bcbc --- /dev/null +++ b/src/client/activation/languageServer/activator.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import * as path from 'path'; +import { IWorkspaceService } from '../../common/application/types'; +import { traceDecorators } from '../../common/logger'; +import { IFileSystem } from '../../common/platform/types'; +import { IConfigurationService, Resource } from '../../common/types'; +import { EXTENSION_ROOT_DIR } from '../../constants'; +import { IExtensionActivator, ILanguageServerDownloader, ILanguageServerFolderService, ILanguageServerManager } from '../types'; + +/** + * Starts the language server managers per workspaces (currently one for first workspace). + * + * @export + * @class LanguageServerExtensionActivator + * @implements {IExtensionActivator} + */ +@injectable() +export class LanguageServerExtensionActivator implements IExtensionActivator { + constructor(@inject(ILanguageServerManager) private readonly manager: ILanguageServerManager, + @inject(IWorkspaceService) private readonly workspace: IWorkspaceService, + @inject(IFileSystem) private readonly fs: IFileSystem, + @inject(ILanguageServerDownloader) private readonly lsDownloader: ILanguageServerDownloader, + @inject(ILanguageServerFolderService) private readonly languageServerFolderService: ILanguageServerFolderService, + @inject(IConfigurationService) private readonly configurationService: IConfigurationService) { } + @traceDecorators.error('Failed to activate language server') + public async activate(): Promise { + const mainWorkspaceUri = this.workspace.hasWorkspaceFolders ? this.workspace.workspaceFolders![0].uri : undefined; + await this.ensureLanguageServerIsAvailable(mainWorkspaceUri); + await this.manager.start(mainWorkspaceUri); + } + public dispose(): void { + this.manager.dispose(); + } + @traceDecorators.error('Failed to ensure language server is available') + protected async ensureLanguageServerIsAvailable(resource: Resource) { + const settings = this.configurationService.getSettings(resource); + if (!settings.downloadLanguageServer) { + return; + } + const languageServerFolder = await this.languageServerFolderService.getLanguageServerFolderName(); + const languageServerFolderPath = path.join(EXTENSION_ROOT_DIR, languageServerFolder); + const mscorlib = path.join(languageServerFolderPath, 'mscorlib.dll'); + if (!await this.fs.fileExists(mscorlib)) { + await this.lsDownloader.downloadLanguageServer(languageServerFolderPath); + } + } +} diff --git a/src/client/activation/languageServer/analysisOptions.ts b/src/client/activation/languageServer/analysisOptions.ts new file mode 100644 index 000000000000..827bd1ac48d6 --- /dev/null +++ b/src/client/activation/languageServer/analysisOptions.ts @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable, named } from 'inversify'; +import * as path from 'path'; +import { CancellationToken, CompletionContext, ConfigurationChangeEvent, Disposable, Event, EventEmitter, OutputChannel, Position, TextDocument } from 'vscode'; +import { LanguageClientOptions, ProvideCompletionItemsSignature } from 'vscode-languageclient'; +import { IWorkspaceService } from '../../common/application/types'; +import { isTestExecution, PYTHON, PYTHON_LANGUAGE, STANDARD_OUTPUT_CHANNEL } from '../../common/constants'; +import { traceDecorators, traceError } from '../../common/logger'; +import { BANNER_NAME_PROPOSE_LS, IConfigurationService, IExtensionContext, IOutputChannel, IPathUtils, IPythonExtensionBanner, Resource } from '../../common/types'; +import { debounce } from '../../common/utils/decorators'; +import { IEnvironmentVariablesProvider } from '../../common/variables/types'; +import { IInterpreterService } from '../../interpreter/contracts'; +import { IInterpreterDataService, ILanguageServerAnalysisOptions, ILanguageServerFolderService, InterpreterData } from '../types'; + +@injectable() +export class LanguageServerAnalysisOptions implements ILanguageServerAnalysisOptions { + private excludedFiles: string[] = []; + private typeshedPaths: string[] = []; + private disposables: Disposable[] = []; + private interpreterHash: string = ''; + private languageServerFolder: string = ''; + private resource: Resource; + private readonly didChange = new EventEmitter(); + constructor(@inject(IExtensionContext) private readonly context: IExtensionContext, + @inject(IEnvironmentVariablesProvider) private readonly envVarsProvider: IEnvironmentVariablesProvider, + @inject(IConfigurationService) private readonly configuration: IConfigurationService, + @inject(IWorkspaceService) private readonly workspace: IWorkspaceService, + @inject(IPythonExtensionBanner) @named(BANNER_NAME_PROPOSE_LS) private readonly surveyBanner: IPythonExtensionBanner, + @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, + @inject(IInterpreterDataService) private readonly interpreterDataService: IInterpreterDataService, + @inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly output: OutputChannel, + @inject(IPathUtils) private readonly pathUtils: IPathUtils, + @inject(ILanguageServerFolderService) private readonly languageServerFolderService: ILanguageServerFolderService) { + + } + public async initialize(resource: Resource) { + this.resource = resource; + this.languageServerFolder = await this.languageServerFolderService.getLanguageServerFolderName(); + + let disposable = this.workspace.onDidChangeConfiguration(this.onSettingsChangedHandler, this); + this.disposables.push(disposable); + + disposable = this.interpreterService.onDidChangeInterpreter(() => this.onSettingsChangedHandler(), this); + this.disposables.push(disposable); + } + public get onDidChange(): Event { + return this.didChange.event; + } + public dispose(): void { + this.disposables.forEach(d => d.dispose()); + this.didChange.dispose(); + } + @traceDecorators.error('Failed to get analysis options') + public async getAnalysisOptions(): Promise { + const properties = new Map(); + let interpreterData: InterpreterData | undefined; + let pythonPath = ''; + + try { + interpreterData = await this.interpreterDataService.getInterpreterData(this.resource); + } catch (ex) { + traceError('Unable to determine path to the Python interpreter. IntelliSense will be limited.', ex); + } + + this.interpreterHash = interpreterData ? interpreterData.hash : ''; + if (interpreterData) { + pythonPath = path.dirname(interpreterData.path); + // tslint:disable-next-line:no-string-literal + properties['InterpreterPath'] = interpreterData.path; + // tslint:disable-next-line:no-string-literal + properties['Version'] = interpreterData.version; + } + + // tslint:disable-next-line:no-string-literal + properties['DatabasePath'] = path.join(this.context.extensionPath, this.languageServerFolder); + + let searchPaths = interpreterData ? interpreterData.searchPaths.split(path.delimiter) : []; + const settings = this.configuration.getSettings(); + if (settings.autoComplete) { + const extraPaths = settings.autoComplete.extraPaths; + if (extraPaths && extraPaths.length > 0) { + searchPaths.push(...extraPaths); + } + } + const vars = await this.envVarsProvider.getEnvironmentVariables(); + if (vars.PYTHONPATH && vars.PYTHONPATH.length > 0) { + const paths = vars.PYTHONPATH.split(this.pathUtils.delimiter).filter(item => item.trim().length > 0); + searchPaths.push(...paths); + } + // Make sure paths do not contain multiple slashes so file URIs + // in VS Code (Node.js) and in the language server (.NET) match. + // Note: for the language server paths separator is always ; + searchPaths.push(pythonPath); + searchPaths = searchPaths.map(p => path.normalize(p)); + + this.excludedFiles = this.getExcludedFiles(); + this.typeshedPaths = this.getTypeshedPaths(); + + // Options to control the language client + return { + // Register the server for Python documents + documentSelector: PYTHON, + synchronize: { + configurationSection: PYTHON_LANGUAGE + }, + outputChannel: this.output, + initializationOptions: { + interpreter: { + properties + }, + displayOptions: { + preferredFormat: 'markdown', + trimDocumentationLines: false, + maxDocumentationLineLength: 0, + trimDocumentationText: false, + maxDocumentationTextLength: 0 + }, + searchPaths, + typeStubSearchPaths: this.typeshedPaths, + excludeFiles: this.excludedFiles, + testEnvironment: isTestExecution(), + analysisUpdates: true, + traceLogging: true, // Max level, let LS decide through settings actual level of logging. + asyncStartup: true + }, + middleware: { + provideCompletionItem: (document: TextDocument, position: Position, context: CompletionContext, token: CancellationToken, next: ProvideCompletionItemsSignature) => { + this.surveyBanner.showBanner().ignoreErrors(); + return next(document, position, context, token); + } + } + }; + } + protected getExcludedFiles(): string[] { + const list: string[] = ['**/Lib/**', '**/site-packages/**']; + this.getVsCodeExcludeSection('search.exclude', list); + this.getVsCodeExcludeSection('files.exclude', list); + this.getVsCodeExcludeSection('files.watcherExclude', list); + this.getPythonExcludeSection(list); + return list; + } + + protected getVsCodeExcludeSection(setting: string, list: string[]): void { + const states = this.workspace.getConfiguration(setting); + if (states) { + Object.keys(states) + .filter(k => (k.indexOf('*') >= 0 || k.indexOf('/') >= 0) && states[k]) + .forEach(p => list.push(p)); + } + } + protected getPythonExcludeSection(list: string[]): void { + const pythonSettings = this.configuration.getSettings(this.resource); + const paths = pythonSettings && pythonSettings.linting ? pythonSettings.linting.ignorePatterns : undefined; + if (paths && Array.isArray(paths)) { + paths + .filter(p => p && p.length > 0) + .forEach(p => list.push(p)); + } + } + protected getTypeshedPaths(): string[] { + const settings = this.configuration.getSettings(this.resource); + return settings.analysis.typeshedPaths && settings.analysis.typeshedPaths.length > 0 + ? settings.analysis.typeshedPaths + : [path.join(this.context.extensionPath, this.languageServerFolder, 'Typeshed')]; + } + protected async onSettingsChangedHandler(e?: ConfigurationChangeEvent): Promise { + if (e && !e.affectsConfiguration('python', this.resource)) { + return; + } + this.onSettingsChanged().catch(ex => traceError('Failed to detect changes', ex)); + } + @traceDecorators.verbose('Changes in python settings detected in analysis options') + @debounce(1000) + protected async onSettingsChanged(): Promise { + const idata = await this.interpreterDataService.getInterpreterData(this.resource); + if (!idata || idata.hash !== this.interpreterHash) { + this.interpreterHash = idata ? idata.hash : ''; + this.didChange.fire(); + return; + } + + const excludedFiles = this.getExcludedFiles(); + await this.notifyIfValuesHaveChanged(this.excludedFiles, excludedFiles); + + const typeshedPaths = this.getTypeshedPaths(); + await this.notifyIfValuesHaveChanged(this.typeshedPaths, typeshedPaths); + } + + protected async notifyIfValuesHaveChanged(oldArray: string[], newArray: string[]): Promise { + if (newArray.length !== oldArray.length) { + this.didChange.fire(); + return; + } + + for (let i = 0; i < oldArray.length; i += 1) { + if (oldArray[i] !== newArray[i]) { + this.didChange.fire(); + return; + } + } + } +} diff --git a/src/client/activation/languageServer/languageClientFactory.ts b/src/client/activation/languageServer/languageClientFactory.ts new file mode 100644 index 000000000000..d9d74598d92f --- /dev/null +++ b/src/client/activation/languageServer/languageClientFactory.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable, named } from 'inversify'; +import * as path from 'path'; +import { LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient'; +import { EXTENSION_ROOT_DIR, PYTHON_LANGUAGE } from '../../common/constants'; +import { IConfigurationService, Resource } from '../../common/types'; +import { ILanguageClientFactory, ILanguageServerFolderService, IPlatformData, LanguageClientFactory } from '../types'; + +// tslint:disable:no-require-imports no-require-imports no-var-requires max-classes-per-file + +const dotNetCommand = 'dotnet'; +const languageClientName = 'Python Tools'; + +@injectable() +export class BaseLanguageClientFactory implements ILanguageClientFactory { + constructor(@inject(ILanguageClientFactory) @named(LanguageClientFactory.downloaded) private readonly downloadedFactory: ILanguageClientFactory, + @inject(ILanguageClientFactory) @named(LanguageClientFactory.simple) private readonly simpleFactory: ILanguageClientFactory, + @inject(IConfigurationService) private readonly configurationService: IConfigurationService) { } + public async createLanguageClient(resource: Resource, clientOptions: LanguageClientOptions): Promise { + const settings = this.configurationService.getSettings(resource); + const factory = settings.downloadLanguageServer ? this.downloadedFactory : this.simpleFactory; + return factory.createLanguageClient(resource, clientOptions); + } +} + +/** + * Creates a langauge client for use by users of the extension. + * + * @export + * @class DownloadedLanguageClientFactory + * @implements {ILanguageClientFactory} + */ +@injectable() +export class DownloadedLanguageClientFactory implements ILanguageClientFactory { + constructor(@inject(IPlatformData) private readonly platformData: IPlatformData, + @inject(ILanguageServerFolderService) private readonly languageServerFolderService: ILanguageServerFolderService) { } + public async createLanguageClient(_resource: Resource, clientOptions: LanguageClientOptions): Promise { + const languageServerFolder = await this.languageServerFolderService.getLanguageServerFolderName(); + const serverModule = path.join(EXTENSION_ROOT_DIR, languageServerFolder, this.platformData.engineExecutableName); + + const options = { stdio: 'pipe' }; + const serverOptions: ServerOptions = { + run: { command: serverModule, rgs: [], options: options }, + debug: { command: serverModule, args: ['--debug'], options } + }; + const vscodeLanguageClient = require('vscode-languageclient') as typeof import('vscode-languageclient'); + return new vscodeLanguageClient.LanguageClient(PYTHON_LANGUAGE, languageClientName, serverOptions, clientOptions); + } +} + +/** + * Creates a language client factory primarily used for LS development purposes. + * + * @export + * @class SimpleLanguageClientFactory + * @implements {ILanguageClientFactory} + */ +@injectable() +export class SimpleLanguageClientFactory implements ILanguageClientFactory { + constructor(@inject(IPlatformData) private readonly platformData: IPlatformData, + @inject(ILanguageServerFolderService) private readonly languageServerFolderService: ILanguageServerFolderService) { } + public async createLanguageClient(_resource: Resource, clientOptions: LanguageClientOptions): Promise { + const languageServerFolder = await this.languageServerFolderService.getLanguageServerFolderName(); + const commandOptions = { stdio: 'pipe' }; + const serverModule = path.join(EXTENSION_ROOT_DIR, languageServerFolder, this.platformData.engineDllName); + const serverOptions: ServerOptions = { + run: { command: dotNetCommand, args: [serverModule], options: commandOptions }, + debug: { command: dotNetCommand, args: [serverModule, '--debug'], options: commandOptions } + }; + const vscodeLanguageClient = require('vscode-languageclient') as typeof import('vscode-languageclient'); + return new vscodeLanguageClient.LanguageClient(PYTHON_LANGUAGE, languageClientName, serverOptions, clientOptions); + } +} diff --git a/src/client/activation/languageServer/languageServer.ts b/src/client/activation/languageServer/languageServer.ts index 1a19ead9aa5c..ba231cc820eb 100644 --- a/src/client/activation/languageServer/languageServer.ts +++ b/src/client/activation/languageServer/languageServer.ts @@ -3,382 +3,71 @@ 'use strict'; -import { inject, injectable } from 'inversify'; -import * as path from 'path'; -import { - CancellationToken, CompletionContext, OutputChannel, Position, - TextDocument, Uri -} from 'vscode'; -import { - Disposable, LanguageClient, LanguageClientOptions, - ProvideCompletionItemsSignature, ServerOptions -} from 'vscode-languageclient'; -import { - IApplicationShell, ICommandManager, IWorkspaceService -} from '../../common/application/types'; -import { PythonSettings } from '../../common/configSettings'; -// tslint:disable-next-line:ordered-imports -import { isTestExecution, STANDARD_OUTPUT_CHANNEL } from '../../common/constants'; -import { Logger } from '../../common/logger'; -import { IFileSystem } from '../../common/platform/types'; -import { - BANNER_NAME_LS_SURVEY, DeprecatedFeatureInfo, IConfigurationService, - IDisposableRegistry, IExtensionContext, IFeatureDeprecationManager, ILogger, - IOutputChannel, IPathUtils, IPythonExtensionBanner, IPythonSettings -} from '../../common/types'; -import { createDeferred, Deferred } from '../../common/utils/async'; -import { LanguageService } from '../../common/utils/localize'; -import { StopWatch } from '../../common/utils/stopWatch'; -import { IEnvironmentVariablesProvider } from '../../common/variables/types'; -import { IServiceContainer } from '../../ioc/types'; -import { LanguageServerSymbolProvider } from '../../providers/symbolProvider'; -import { sendTelemetryEvent } from '../../telemetry'; -import { - PYTHON_LANGUAGE_SERVER_ENABLED, - PYTHON_LANGUAGE_SERVER_ERROR, - PYTHON_LANGUAGE_SERVER_TELEMETRY -} from '../../telemetry/constants'; -import { IUnitTestManagementService } from '../../unittests/types'; -import { LanguageServerDownloader } from '../downloader'; -import { InterpreterData, InterpreterDataService } from '../interpreterDataService'; +import { inject, injectable, named } from 'inversify'; +import { Disposable, LanguageClient, LanguageClientOptions } from 'vscode-languageclient'; +import { traceDecorators, traceError } from '../../common/logger'; +import { Resource } from '../../common/types'; +import { createDeferred, Deferred, sleep } from '../../common/utils/async'; +import { noop } from '../../common/utils/misc'; +import { captureTelemetry, sendTelemetryEvent } from '../../telemetry'; +import { PYTHON_LANGUAGE_SERVER_ENABLED, PYTHON_LANGUAGE_SERVER_READY, PYTHON_LANGUAGE_SERVER_TELEMETRY } from '../../telemetry/constants'; import { ProgressReporting } from '../progress'; -import { IExtensionActivator, ILanguageServerFolderService, ILanguageServerPlatformData } from '../types'; - -const PYTHON = 'python'; -const dotNetCommand = 'dotnet'; -const languageClientName = 'Python Tools'; -const loadExtensionCommand = 'python._loadLanguageServerExtension'; -const buildSymbolsCmdDeprecatedInfo: DeprecatedFeatureInfo = { - doNotDisplayPromptStateKey: 'SHOW_DEPRECATED_FEATURE_PROMPT_BUILD_WORKSPACE_SYMBOLS', - message: 'The command \'Python: Build Workspace Symbols\' is deprecated when using the Python Language Server. The Python Language Server builds symbols in the workspace in the background.', - moreInfoUrl: 'https://github.com/Microsoft/vscode-python/issues/2267#issuecomment-408996859', - commands: ['python.buildWorkspaceSymbols'] -}; +import { ILanaguageServer as ILanguageServer, ILanguageClientFactory, LanguageClientFactory } from '../types'; @injectable() -export class LanguageServerExtensionActivator implements IExtensionActivator { - protected languageServerFolder!: string; - protected readonly platformData: ILanguageServerPlatformData; - protected readonly context: IExtensionContext; - private readonly configuration: IConfigurationService; - private readonly appShell: IApplicationShell; - private readonly output: OutputChannel; - private readonly fs: IFileSystem; - private readonly sw = new StopWatch(); +export class LanguageServer implements ILanguageServer { private readonly startupCompleted: Deferred; private readonly disposables: Disposable[] = []; - private readonly workspace: IWorkspaceService; - private readonly root: Uri | undefined; - - private languageClient: LanguageClient | undefined; - private interpreterHash: string = ''; - private excludedFiles: string[] = []; - private typeshedPaths: string[] = []; - private loadExtensionArgs: {} | undefined; - private surveyBanner: IPythonExtensionBanner; - private languageServerFolderService: ILanguageServerFolderService; - constructor(@inject(IServiceContainer) private readonly services: IServiceContainer) { - this.context = this.services.get(IExtensionContext); - this.configuration = this.services.get(IConfigurationService); - this.appShell = this.services.get(IApplicationShell); - this.output = this.services.get(IOutputChannel, STANDARD_OUTPUT_CHANNEL); - this.fs = this.services.get(IFileSystem); - this.platformData = this.services.get(ILanguageServerPlatformData); - this.workspace = this.services.get(IWorkspaceService); - this.languageServerFolderService = this.services.get(ILanguageServerFolderService); - const deprecationManager: IFeatureDeprecationManager = - this.services.get(IFeatureDeprecationManager); + private languageClient?: LanguageClient; - // Currently only a single root. Multi-root support is future. - this.root = this.workspace && this.workspace.hasWorkspaceFolders - ? this.workspace.workspaceFolders![0]!.uri : undefined; + constructor(@inject(ILanguageClientFactory) @named(LanguageClientFactory.base) private readonly factory: ILanguageClientFactory) { this.startupCompleted = createDeferred(); - const commandManager = this.services.get(ICommandManager); - - this.disposables.push(commandManager.registerCommand(loadExtensionCommand, - async (args) => { - if (this.languageClient) { - await this.startupCompleted.promise; - this.languageClient.sendRequest('python/loadExtension', args); - } else { - this.loadExtensionArgs = args; - } - } - )); - - deprecationManager.registerDeprecation(buildSymbolsCmdDeprecatedInfo); - - this.surveyBanner = services.get(IPythonExtensionBanner, BANNER_NAME_LS_SURVEY); - - (this.configuration.getSettings() as PythonSettings).addListener('change', this.onSettingsChanged.bind(this)); - } - - public async activate(): Promise { - this.sw.reset(); - this.languageServerFolder = await this.languageServerFolderService.getLanguageServerFolderName(); - const clientOptions = await this.getAnalysisOptions(); - if (!clientOptions) { - return false; - } - this.startupCompleted.promise.then(() => { - const testManagementService = this.services.get(IUnitTestManagementService); - testManagementService.activate() - .then(() => testManagementService.activateCodeLenses(new LanguageServerSymbolProvider(this.languageClient!))) - .catch(ex => this.services.get(ILogger).logError('Failed to activate Unit Tests', ex)); - }).ignoreErrors(); - - return this.startLanguageServer(clientOptions); } - - public async deactivate(): Promise { + @traceDecorators.verbose('Stopping Language Server') + public dispose() { if (this.languageClient) { - // Do not await on this - this.languageClient.stop(); + // Do not await on this. + this.languageClient.stop() + .then(noop, ex => traceError('Stopping language client failed', ex)); + this.languageClient = undefined; } - for (const d of this.disposables) { + while (this.disposables.length > 0) { + const d = this.disposables.shift()!; d.dispose(); } - (this.configuration.getSettings() as PythonSettings).removeListener('change', this.onSettingsChanged.bind(this)); } - protected async startLanguageClient(): Promise { - this.context.subscriptions.push(this.languageClient!.start()); + @traceDecorators.error('Failed to start language server') + @captureTelemetry(PYTHON_LANGUAGE_SERVER_ENABLED, undefined, true) + public async start(resource: Resource, options: LanguageClientOptions): Promise { + this.languageClient = await this.factory.createLanguageClient(resource, options); + this.disposables.push(this.languageClient!.start()); await this.serverReady(); - const disposables = this.services.get(IDisposableRegistry); const progressReporting = new ProgressReporting(this.languageClient!); - disposables.push(progressReporting); + this.disposables.push(progressReporting); + this.languageClient.onTelemetry(telemetryEvent => { + const eventName = telemetryEvent.EventName || PYTHON_LANGUAGE_SERVER_TELEMETRY; + sendTelemetryEvent(eventName, telemetryEvent.Measurements, telemetryEvent.Properties); + }); } - - protected async createSelfContainedLanguageClient(serverModule: string, clientOptions: LanguageClientOptions): Promise { - const options = { stdio: 'pipe' }; - const serverOptions: ServerOptions = { - run: { command: serverModule, rgs: [], options: options }, - debug: { command: serverModule, args: ['--debug'], options } - }; - const vscodeLanaguageClient = await import('vscode-languageclient'); - return new vscodeLanaguageClient.LanguageClient(PYTHON, languageClientName, serverOptions, clientOptions); - } - - protected async startLanguageServer(clientOptions: LanguageClientOptions): Promise { - // Determine if we are running MSIL/Universal via dotnet or self-contained app. - sendTelemetryEvent(PYTHON_LANGUAGE_SERVER_ENABLED); - - const settings = this.configuration.getSettings(); - if (!settings.downloadLanguageServer) { - // Depends on .NET Runtime or SDK. Typically development-only case. - this.languageClient = await this.createSimpleLanguageClient(clientOptions); - await this.startLanguageClient(); - return true; - } - const mscorlib = path.join(this.context.extensionPath, this.languageServerFolder, 'mscorlib.dll'); - if (!await this.fs.fileExists(mscorlib)) { - const downloader = new LanguageServerDownloader(this.platformData, this.languageServerFolder, this.services); - try { - await downloader.downloadLanguageServer(this.context); - } catch (err) { - return false; - } + @traceDecorators.error('Failed to load Language Server extension') + public loadExtension(args?: {}) { + if (!this.languageClient) { + throw new Error('Activation not completed or not invoked'); } - - const serverModule = path.join(this.context.extensionPath, this.languageServerFolder, this.platformData.getEngineExecutableName()); - this.languageClient = await this.createSelfContainedLanguageClient(serverModule, clientOptions); - try { - await this.startLanguageClient(); - this.languageClient.onTelemetry(telemetryEvent => { - const eventName = telemetryEvent.EventName ? telemetryEvent.EventName : PYTHON_LANGUAGE_SERVER_TELEMETRY; - sendTelemetryEvent(eventName, telemetryEvent.Measurements, telemetryEvent.Properties); - }); - return true; - } catch (ex) { - this.appShell.showErrorMessage(LanguageService.lsFailedToStart()); - this.output.appendLine('Language server failed to start.'); - this.output.appendLine(ex); - sendTelemetryEvent(PYTHON_LANGUAGE_SERVER_ERROR, undefined, { error: 'Failed to start (platform)' }); - return false; + if (!this.startupCompleted.completed) { + throw new Error('Activation not completed'); } + this.languageClient.sendRequest('python/loadExtension', args) + .then(noop, ex => traceError('Request python/loadExtension failed', ex)); } - + @captureTelemetry(PYTHON_LANGUAGE_SERVER_READY, undefined, true) private async serverReady(): Promise { - while (!this.languageClient!.initializeResult) { - await new Promise(resolve => setTimeout(resolve, 100)); + while (this.languageClient && !this.languageClient!.initializeResult) { + await sleep(100); } - if (this.loadExtensionArgs) { - this.languageClient!.sendRequest('python/loadExtension', this.loadExtensionArgs); - } - this.startupCompleted.resolve(); } - - private async createSimpleLanguageClient(clientOptions: LanguageClientOptions): Promise { - const commandOptions = { stdio: 'pipe' }; - const serverModule = path.join(this.context.extensionPath, this.languageServerFolder, this.platformData.getEngineDllName()); - const serverOptions: ServerOptions = { - run: { command: dotNetCommand, args: [serverModule], options: commandOptions }, - debug: { command: dotNetCommand, args: [serverModule, '--debug'], options: commandOptions } - }; - const vscodeLanaguageClient = await import('vscode-languageclient'); - return new vscodeLanaguageClient.LanguageClient(PYTHON, languageClientName, serverOptions, clientOptions); - } - // tslint:disable-next-line:member-ordering - public async getAnalysisOptions(): Promise { - const properties = new Map(); - let interpreterData: InterpreterData | undefined; - let pythonPath = ''; - - try { - const interpreterDataService = new InterpreterDataService(this.context, this.services); - interpreterData = await interpreterDataService.getInterpreterData(); - } catch (ex) { - Logger.error('Unable to determine path to the Python interpreter. IntelliSense will be limited.', ex); - } - - this.interpreterHash = interpreterData ? interpreterData.hash : ''; - if (interpreterData) { - pythonPath = path.dirname(interpreterData.path); - // tslint:disable-next-line:no-string-literal - properties['InterpreterPath'] = interpreterData.path; - // tslint:disable-next-line:no-string-literal - properties['Version'] = interpreterData.version; - } - - // tslint:disable-next-line:no-string-literal - properties['DatabasePath'] = path.join(this.context.extensionPath, this.languageServerFolder); - - let searchPaths = interpreterData ? interpreterData.searchPaths.split(path.delimiter) : []; - const settings = this.configuration.getSettings(); - if (settings.autoComplete) { - const extraPaths = settings.autoComplete.extraPaths; - if (extraPaths && extraPaths.length > 0) { - searchPaths.push(...extraPaths); - } - } - const envVarsProvider = this.services.get(IEnvironmentVariablesProvider); - const vars = await envVarsProvider.getEnvironmentVariables(); - if (vars.PYTHONPATH && vars.PYTHONPATH.length > 0) { - const pathUtils = this.services.get(IPathUtils); - const paths = vars.PYTHONPATH.split(pathUtils.delimiter).filter(item => item.trim().length > 0); - searchPaths.push(...paths); - } - // Make sure paths do not contain multiple slashes so file URIs - // in VS Code (Node.js) and in the language server (.NET) match. - // Note: for the language server paths separator is always ; - searchPaths.push(pythonPath); - searchPaths = searchPaths.map(p => path.normalize(p)); - - const selector = [{ language: PYTHON, scheme: 'file' }]; - this.excludedFiles = this.getExcludedFiles(); - this.typeshedPaths = this.getTypeshedPaths(settings); - - // Options to control the language client - return { - // Register the server for Python documents - documentSelector: selector, - synchronize: { - configurationSection: PYTHON - }, - outputChannel: this.output, - initializationOptions: { - interpreter: { - properties - }, - displayOptions: { - preferredFormat: 'markdown', - trimDocumentationLines: false, - maxDocumentationLineLength: 0, - trimDocumentationText: false, - maxDocumentationTextLength: 0 - }, - searchPaths, - typeStubSearchPaths: this.typeshedPaths, - excludeFiles: this.excludedFiles, - testEnvironment: isTestExecution(), - analysisUpdates: true, - traceLogging: true, // Max level, let LS decide through settings actual level of logging. - asyncStartup: true - }, - middleware: { - provideCompletionItem: (document: TextDocument, position: Position, context: CompletionContext, token: CancellationToken, next: ProvideCompletionItemsSignature) => { - if (this.surveyBanner) { - this.surveyBanner.showBanner().ignoreErrors(); - } - return next(document, position, context, token); - } - } - }; - } - - private getExcludedFiles(): string[] { - const list: string[] = ['**/Lib/**', '**/site-packages/**']; - this.getVsCodeExcludeSection('search.exclude', list); - this.getVsCodeExcludeSection('files.exclude', list); - this.getVsCodeExcludeSection('files.watcherExclude', list); - this.getPythonExcludeSection(list); - return list; - } - - private getVsCodeExcludeSection(setting: string, list: string[]): void { - const states = this.workspace.getConfiguration(setting, this.root); - if (states) { - Object.keys(states) - .filter(k => (k.indexOf('*') >= 0 || k.indexOf('/') >= 0) && states[k]) - .forEach(p => list.push(p)); - } - } - - private getPythonExcludeSection(list: string[]): void { - const pythonSettings = this.configuration.getSettings(this.root); - const paths = pythonSettings && pythonSettings.linting ? pythonSettings.linting.ignorePatterns : undefined; - if (paths && Array.isArray(paths)) { - paths - .filter(p => p && p.length > 0) - .forEach(p => list.push(p)); - } - } - - private getTypeshedPaths(settings: IPythonSettings): string[] { - return settings.analysis.typeshedPaths && settings.analysis.typeshedPaths.length > 0 - ? settings.analysis.typeshedPaths - : [path.join(this.context.extensionPath, this.languageServerFolder, 'Typeshed')]; - } - - private async onSettingsChanged(): Promise { - const ids = new InterpreterDataService(this.context, this.services); - const idata = await ids.getInterpreterData(); - if (!idata || idata.hash !== this.interpreterHash) { - this.interpreterHash = idata ? idata.hash : ''; - await this.restartLanguageServer(); - return; - } - - const excludedFiles = this.getExcludedFiles(); - await this.restartLanguageServerIfArrayChanged(this.excludedFiles, excludedFiles); - - const settings = this.configuration.getSettings(); - const typeshedPaths = this.getTypeshedPaths(settings); - await this.restartLanguageServerIfArrayChanged(this.typeshedPaths, typeshedPaths); - } - - private async restartLanguageServerIfArrayChanged(oldArray: string[], newArray: string[]): Promise { - if (newArray.length !== oldArray.length) { - await this.restartLanguageServer(); - return; - } - - for (let i = 0; i < oldArray.length; i += 1) { - if (oldArray[i] !== newArray[i]) { - await this.restartLanguageServer(); - return; - } - } - } - - private async restartLanguageServer(): Promise { - if (!this.context) { - return; - } - await this.deactivate(); - await this.activate(); - } } diff --git a/src/client/activation/languageServer/languageServerHashes.ts b/src/client/activation/languageServer/languageServerHashes.ts deleted file mode 100644 index d8d856064363..000000000000 --- a/src/client/activation/languageServer/languageServerHashes.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -// This file will be replaced by a generated one during the release build -// with actual hashes of the uploaded packages. -// Values are for test purposes only -export const language_server_win_x86_sha512 = 'win-x86'; -export const language_server_win_x64_sha512 = 'win-x64'; -export const language_server_osx_x64_sha512 = 'osx-x64'; -export const language_server_linux_x64_sha512 = 'linux-x64'; diff --git a/src/client/activation/languageServer/manager.ts b/src/client/activation/languageServer/manager.ts new file mode 100644 index 000000000000..a8e53f9818d9 --- /dev/null +++ b/src/client/activation/languageServer/manager.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { inject, injectable } from 'inversify'; +import { ICommandManager } from '../../common/application/types'; +import { traceDecorators } from '../../common/logger'; +import { IDisposable, Resource } from '../../common/types'; +import { debounce } from '../../common/utils/decorators'; +import { IServiceContainer } from '../../ioc/types'; +import { captureTelemetry } from '../../telemetry'; +import { PYTHON_LANGUAGE_SERVER_STARTUP } from '../../telemetry/constants'; +import { ILanaguageServer, ILanguageServerAnalysisOptions, ILanguageServerManager } from '../types'; + +const loadExtensionCommand = 'python._loadLanguageServerExtension'; + +@injectable() +export class LanguageServerManager implements ILanguageServerManager { + protected static loadExtensionArgs?: {}; + private languageServer?: ILanaguageServer; + private resource!: Resource; + private disposables: IDisposable[] = []; + constructor( + @inject(IServiceContainer) private readonly serviceContainer: IServiceContainer, + @inject(ICommandManager) private readonly commandManager: ICommandManager, + @inject(ILanguageServerAnalysisOptions) private readonly analysisOptions: ILanguageServerAnalysisOptions) { } + public dispose() { + if (this.languageServer) { + this.languageServer.dispose(); + } + this.disposables.forEach(d => d.dispose()); + } + @traceDecorators.error('Failed to start Language Server') + public async start(resource: Resource): Promise { + if (this.languageServer) { + throw new Error('Language Server already started'); + } + this.registerCommandHandler(); + this.resource = resource; + this.analysisOptions.onDidChange(this.restartLanguageServer, this, this.disposables); + + await this.analysisOptions.initialize(resource); + await this.startLanguageServer(); + } + protected registerCommandHandler() { + const disposable = this.commandManager.registerCommand(loadExtensionCommand, args => { + LanguageServerManager.loadExtensionArgs = args; + this.loadExtensionIfNecessary(); + }); + this.disposables.push(disposable); + } + protected loadExtensionIfNecessary() { + if (this.languageServer && LanguageServerManager.loadExtensionArgs) { + this.languageServer.loadExtension(LanguageServerManager.loadExtensionArgs); + } + } + @traceDecorators.error('Failed to restart Language Server') + @traceDecorators.verbose('Restarting Language Server') + @debounce(1000) + protected async restartLanguageServer(): Promise { + if (this.languageServer) { + this.languageServer.dispose(); + } + await this.startLanguageServer(); + } + @captureTelemetry(PYTHON_LANGUAGE_SERVER_STARTUP, undefined, true) + @traceDecorators.verbose('Starting Language Server') + protected async startLanguageServer(): Promise { + this.languageServer = this.serviceContainer.get(ILanaguageServer); + const options = await this.analysisOptions!.getAnalysisOptions(); + await this.languageServer.start(this.resource, options); + this.loadExtensionIfNecessary(); + } +} diff --git a/src/client/activation/platformData.ts b/src/client/activation/platformData.ts index 5ba029bdc189..c49ba4442793 100644 --- a/src/client/activation/platformData.ts +++ b/src/client/activation/platformData.ts @@ -3,13 +3,14 @@ import { inject, injectable } from 'inversify'; import { IPlatformService } from '../common/platform/types'; -import { - language_server_linux_x64_sha512, - language_server_osx_x64_sha512, - language_server_win_x64_sha512, - language_server_win_x86_sha512 -} from './languageServer/languageServerHashes'; -import { ILanguageServerPlatformData, PlatformName } from './types'; +import { IPlatformData } from './types'; + +export enum PlatformName { + Windows32Bit = 'win-x86', + Windows64Bit = 'win-x64', + Mac64Bit = 'osx-x64', + Linux64Bit = 'linux-x64' +} export enum PlatformLSExecutables { Windows = 'Microsoft.Python.LanguageServer.exe', @@ -18,11 +19,9 @@ export enum PlatformLSExecutables { } @injectable() -export class LanguageServerPlatformData implements ILanguageServerPlatformData { - constructor( - @inject(IPlatformService) private platform: IPlatformService) { } - - public getPlatformName(): PlatformName { +export class PlatformData implements IPlatformData { + constructor(@inject(IPlatformService) private readonly platform: IPlatformService) { } + public get platformName(): PlatformName { if (this.platform.isWindows) { return this.platform.is64bit ? PlatformName.Windows64Bit : PlatformName.Windows32Bit; } @@ -38,11 +37,11 @@ export class LanguageServerPlatformData implements ILanguageServerPlatformData { throw new Error('Unknown OS platform.'); } - public getEngineDllName(): string { + public get engineDllName(): string { return 'Microsoft.Python.LanguageServer.dll'; } - public getEngineExecutableName(): string { + public get engineExecutableName(): string { if (this.platform.isWindows) { return PlatformLSExecutables.Windows; } else if (this.platform.isLinux) { @@ -53,17 +52,4 @@ export class LanguageServerPlatformData implements ILanguageServerPlatformData { return 'unknown-platform'; } } - - public async getExpectedHash(): Promise { - if (this.platform.isWindows) { - return this.platform.is64bit ? language_server_win_x64_sha512 : language_server_win_x86_sha512; - } - if (this.platform.isMac) { - return language_server_osx_x64_sha512; - } - if (this.platform.isLinux && this.platform.is64bit) { - return language_server_linux_x64_sha512; - } - throw new Error('Unknown platform.'); - } } diff --git a/src/client/activation/serviceRegistry.ts b/src/client/activation/serviceRegistry.ts index 43441c221e0f..96689743f581 100644 --- a/src/client/activation/serviceRegistry.ts +++ b/src/client/activation/serviceRegistry.ts @@ -11,14 +11,20 @@ import { LanguageServerSurveyBanner } from '../languageServices/languageServerSu import { ProposeLanguageServerBanner } from '../languageServices/proposeLanguageServerBanner'; import { ExtensionActivationService } from './activationService'; import { DownloadBetaChannelRule, DownloadDailyChannelRule, DownloadStableChannelRule } from './downloadChannelRules'; +import { LanguageServerDownloader } from './downloader'; +import { InterpreterDataService } from './interpreterDataService'; import { JediExtensionActivator } from './jedi'; -import { LanguageServerExtensionActivator } from './languageServer/languageServer'; +import { LanguageServerExtensionActivator } from './languageServer/activator'; +import { LanguageServerAnalysisOptions } from './languageServer/analysisOptions'; +import { BaseLanguageClientFactory, DownloadedLanguageClientFactory, SimpleLanguageClientFactory } from './languageServer/languageClientFactory'; +import { LanguageServer } from './languageServer/languageServer'; import { LanguageServerCompatibilityService } from './languageServer/languageServerCompatibilityService'; import { LanguageServerFolderService } from './languageServer/languageServerFolderService'; import { BetaLanguageServerPackageRepository, DailyLanguageServerPackageRepository, LanguageServerDownloadChannel, StableLanguageServerPackageRepository } from './languageServer/languageServerPackageRepository'; import { LanguageServerPackageService } from './languageServer/languageServerPackageService'; -import { LanguageServerPlatformData } from './platformData'; -import { ExtensionActivators, IDownloadChannelRule, IExtensionActivationService, IExtensionActivator, ILanguageServerCompatibilityService as ILanagueServerCompatibilityService, ILanguageServerFolderService, ILanguageServerPackageService, ILanguageServerPlatformData } from './types'; +import { LanguageServerManager } from './languageServer/manager'; +import { PlatformData } from './platformData'; +import { ExtensionActivators, IDownloadChannelRule, IExtensionActivationService, IExtensionActivator, IInterpreterDataService, ILanaguageServer, ILanguageClientFactory, ILanguageServerAnalysisOptions, ILanguageServerCompatibilityService as ILanagueServerCompatibilityService, ILanguageServerDownloader, ILanguageServerFolderService, ILanguageServerManager, ILanguageServerPackageService, IPlatformData, LanguageClientFactory } from './types'; export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IExtensionActivationService, ExtensionActivationService); @@ -36,5 +42,13 @@ export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IDownloadChannelRule, DownloadBetaChannelRule, LanguageServerDownloadChannel.beta); serviceManager.addSingleton(IDownloadChannelRule, DownloadStableChannelRule, LanguageServerDownloadChannel.stable); serviceManager.addSingleton(ILanagueServerCompatibilityService, LanguageServerCompatibilityService); - serviceManager.addSingleton(ILanguageServerPlatformData, LanguageServerPlatformData); + serviceManager.addSingleton(ILanguageClientFactory, BaseLanguageClientFactory, LanguageClientFactory.base); + serviceManager.addSingleton(ILanguageClientFactory, DownloadedLanguageClientFactory, LanguageClientFactory.downloaded); + serviceManager.addSingleton(ILanguageClientFactory, SimpleLanguageClientFactory, LanguageClientFactory.simple); + serviceManager.addSingleton(IInterpreterDataService, InterpreterDataService); + serviceManager.addSingleton(ILanguageServerDownloader, LanguageServerDownloader); + serviceManager.addSingleton(IPlatformData, PlatformData); + serviceManager.add(ILanguageServerAnalysisOptions, LanguageServerAnalysisOptions); + serviceManager.add(ILanaguageServer, LanguageServer); + serviceManager.add(ILanguageServerManager, LanguageServerManager); } diff --git a/src/client/activation/types.ts b/src/client/activation/types.ts index 883420e2a7da..905921216088 100644 --- a/src/client/activation/types.ts +++ b/src/client/activation/types.ts @@ -5,62 +5,106 @@ import { Request as RequestResult } from 'request'; import { SemVer } from 'semver'; +import { Event } from 'vscode'; +import { LanguageClient, LanguageClientOptions } from 'vscode-languageclient'; import { NugetPackage } from '../common/nuget/types'; -import { IExtensionContext, LanguageServerDownloadChannels } from '../common/types'; +import { IDisposable, LanguageServerDownloadChannels, Resource } from '../common/types'; export const IExtensionActivationService = Symbol('IExtensionActivationService'); export interface IExtensionActivationService { - activate(): Promise; + activate(): Promise; } export enum ExtensionActivators { - Jedi = 'Jedi', - DotNet = 'DotNet' + Jedi = 'Jedi', + DotNet = 'DotNet' } export const IExtensionActivator = Symbol('IExtensionActivator'); -export interface IExtensionActivator { - activate(): Promise; - deactivate(): Promise; +export interface IExtensionActivator extends IDisposable { + activate(): Promise; } export const IHttpClient = Symbol('IHttpClient'); export interface IHttpClient { - downloadFile(uri: string): Promise; - getJSON(uri: string): Promise; + downloadFile(uri: string): Promise; + getJSON(uri: string): Promise; } export type FolderVersionPair = { path: string; version: SemVer }; export const ILanguageServerFolderService = Symbol('ILanguageServerFolderService'); export interface ILanguageServerFolderService { - getLanguageServerFolderName(): Promise; - getLatestLanguageServerVersion(): Promise; - getCurrentLanguageServerDirectory(): Promise; + getLanguageServerFolderName(): Promise; + getLatestLanguageServerVersion(): Promise; + getCurrentLanguageServerDirectory(): Promise; } export const ILanguageServerDownloader = Symbol('ILanguageServerDownloader'); export interface ILanguageServerDownloader { - getDownloadInfo(): Promise; - downloadLanguageServer(context: IExtensionContext): Promise; + getDownloadInfo(): Promise; + downloadLanguageServer(destinationFolder: string): Promise; } export const ILanguageServerPackageService = Symbol('ILanguageServerPackageService'); export interface ILanguageServerPackageService { - getNugetPackageName(): string; - getLatestNugetPackageVersion(): Promise; - getLanguageServerDownloadChannel(): LanguageServerDownloadChannels; + getNugetPackageName(): string; + getLatestNugetPackageVersion(): Promise; + getLanguageServerDownloadChannel(): LanguageServerDownloadChannels; } export const MajorLanguageServerVersion = Symbol('MajorLanguageServerVersion'); export const IDownloadChannelRule = Symbol('IDownloadChannelRule'); export interface IDownloadChannelRule { - shouldLookForNewLanguageServer(currentFolder?: FolderVersionPair): Promise; + shouldLookForNewLanguageServer(currentFolder?: FolderVersionPair): Promise; } export const ILanguageServerCompatibilityService = Symbol('ILanguageServerCompatibilityService'); export interface ILanguageServerCompatibilityService { - isSupported(): Promise; + isSupported(): Promise; +} +export enum LanguageClientFactory { + base = 'base', + simple = 'simple', + downloaded = 'downloaded' +} +export const ILanguageClientFactory = Symbol('ILanguageClientFactory'); +export interface ILanguageClientFactory { + createLanguageClient(resource: Resource, clientOptions: LanguageClientOptions): Promise; +} +export const ILanguageServerAnalysisOptions = Symbol('ILanguageServerAnalysisOptions'); +export interface ILanguageServerAnalysisOptions extends IDisposable { + readonly onDidChange: Event; + initialize(resource: Resource): Promise; + getAnalysisOptions(): Promise; +} +export const ILanguageServerManager = Symbol('ILanguageServerManager'); +export interface ILanguageServerManager extends IDisposable { + start(resource: Resource): Promise; +} +export const ILanaguageServer = Symbol('ILanaguageServer'); +export interface ILanaguageServer extends IDisposable { + start(resource: Resource, options: LanguageClientOptions): Promise; + /** + * Sends a request to LS so as to load other extensions. + * This is used as a plugin loader mechanism. + * Anyone (such as intellicode) wanting to interact with LS, needs to send this request to LS. + * @param {{}} [args] + * @memberof ILanaguageServer + */ + loadExtension(args?: {}): void; +} +export type InterpreterData = { + readonly dataVersion: number; + readonly path: string; + readonly version: string; + readonly searchPaths: string; + readonly hash: string; +}; + +export const IInterpreterDataService = Symbol('InterpreterDataService'); +export interface IInterpreterDataService { + getInterpreterData(resource: Resource): Promise; } export enum PlatformName { @@ -69,11 +113,9 @@ export enum PlatformName { Mac64Bit = 'osx-x64', Linux64Bit = 'linux-x64' } - -export const ILanguageServerPlatformData = Symbol('ILanguageServerPlatformData'); -export interface ILanguageServerPlatformData { - getPlatformName(): PlatformName; - getEngineDllName(): string; - getEngineExecutableName(): string; - getExpectedHash(): Promise; +export const IPlatformData = Symbol('IPlatformData'); +export interface IPlatformData { + readonly platformName: PlatformName; + readonly engineDllName: string; + readonly engineExecutableName: string; } diff --git a/src/client/common/asyncDisposableRegistry.ts b/src/client/common/asyncDisposableRegistry.ts index fb5ddf2ca66a..1aa60fa2feae 100644 --- a/src/client/common/asyncDisposableRegistry.ts +++ b/src/client/common/asyncDisposableRegistry.ts @@ -2,20 +2,19 @@ // Licensed under the MIT License. 'use strict'; import { injectable } from 'inversify'; - -import { IAsyncDisposableRegistry, IDisposable } from './types'; +import { IAsyncDisposable, IAsyncDisposableRegistry, IDisposable } from './types'; // List of disposables that need to run a promise. @injectable() export class AsyncDisposableRegistry implements IAsyncDisposableRegistry { - private list : IDisposable[] = []; + private list: (IDisposable | IAsyncDisposable)[] = []; public async dispose(): Promise { const promises = this.list.map(l => l.dispose()); await Promise.all(promises); } - public push(disposable: IDisposable | undefined) { + public push(disposable?: IDisposable | IAsyncDisposable) { if (disposable) { this.list.push(disposable); } diff --git a/src/client/common/configSettings.ts b/src/client/common/configSettings.ts index 61e12abbdd7c..3a9865aa8427 100644 --- a/src/client/common/configSettings.ts +++ b/src/client/common/configSettings.ts @@ -26,10 +26,10 @@ import { } from './types'; import { SystemVariables } from './variables/systemVariables'; -// tslint:disable-next-line:no-require-imports no-var-requires +// tslint:disable:no-require-imports no-var-requires const untildify = require('untildify'); +const _debounce = require('lodash/debounce') as typeof import('lodash/debounce'); -// tslint:disable-next-line:completed-docs export class PythonSettings extends EventEmitter implements IPythonSettings { private static pythonSettings: Map = new Map(); public downloadLanguageServer = true; @@ -377,7 +377,7 @@ export class PythonSettings extends EventEmitter implements IPythonSettings { // If workspace config changes, then we could have a cascading effect of on change events. // Let's defer the change notification. - setTimeout(() => this.emit('change'), 1); + _debounce(() => this.emit('change'), 1); }; this.disposables.push(this.InterpreterAutoSelectionService.onDidChangeAutoSelectedInterpreter(onDidChange.bind(this))); this.disposables.push(this.workspace.onDidChangeConfiguration((event: ConfigurationChangeEvent) => { diff --git a/src/client/common/logger.ts b/src/client/common/logger.ts index 3843da84a42c..08ba9ff3eaeb 100644 --- a/src/client/common/logger.ts +++ b/src/client/common/logger.ts @@ -1,6 +1,7 @@ // tslint:disable:no-console import { injectable } from 'inversify'; +import { sendTelemetryEvent } from '../telemetry'; import { skipIfTest } from './helpers'; import { ILogger, LogLevel } from './types'; @@ -137,6 +138,7 @@ function trace(message: string, options: LogOptions = LogOptions.None, logLevel? } if (ex) { new Logger().logError(messagesToLog.join(', '), ex); + sendTelemetryEvent('ERROR', undefined, undefined, ex); } else { new Logger().logInformation(messagesToLog.join(', ')); } diff --git a/src/client/common/types.ts b/src/client/common/types.ts index d56a802d2c5f..b90d9eec5ff5 100644 --- a/src/client/common/types.ts +++ b/src/client/common/types.ts @@ -343,9 +343,7 @@ export interface IBrowserService { export const IPythonExtensionBanner = Symbol('IPythonExtensionBanner'); export interface IPythonExtensionBanner { - enabled: boolean; - shownCount: Promise; - optionLabels: string[]; + readonly enabled: boolean; showBanner(): Promise; } export const BANNER_NAME_LS_SURVEY: string = 'LSSurveyBanner'; @@ -374,16 +372,17 @@ export interface IFeatureDeprecationManager extends Disposable { export const IEditorUtils = Symbol('IEditorUtils'); export interface IEditorUtils { - // getTextEditor(uri: Uri): Promise<{ editor: TextEditor; dispose?(): void }>; getWorkspaceEditsFromPatch(originalContents: string, patch: string, uri: Uri): WorkspaceEdit; } export interface IDisposable { - dispose(): Promise | undefined | void; + dispose(): void | undefined; +} +export interface IAsyncDisposable { + dispose(): Promise; } export const IAsyncDisposableRegistry = Symbol('IAsyncDisposableRegistry'); -export interface IAsyncDisposableRegistry { - dispose(): Promise; - push(disposable: IDisposable); +export interface IAsyncDisposableRegistry extends IAsyncDisposable { + push(disposable: IDisposable | IAsyncDisposable); } diff --git a/src/client/datascience/dataScienceSurveyBanner.ts b/src/client/datascience/dataScienceSurveyBanner.ts index 2ccca9ae9b49..96ae9379723f 100644 --- a/src/client/datascience/dataScienceSurveyBanner.ts +++ b/src/client/datascience/dataScienceSurveyBanner.ts @@ -48,15 +48,6 @@ export class DataScienceSurveyBanner implements IPythonExtensionBanner { } this.isInitialized = true; } - - public get optionLabels(): string[] { - return this.bannerLabels; - } - - public get shownCount(): Promise { - return this.getPythonDSCommandCounter(); - } - public get enabled(): boolean { return this.persistentState.createGlobalPersistentState(DSSurveyStateKeys.ShowBanner, true).value; } diff --git a/src/client/datascience/jupyter/jupyterServer.ts b/src/client/datascience/jupyter/jupyterServer.ts index 9435784b5e21..4162455e2e25 100644 --- a/src/client/datascience/jupyter/jupyterServer.ts +++ b/src/client/datascience/jupyter/jupyterServer.ts @@ -15,7 +15,7 @@ import { CancellationToken } from 'vscode-jsonrpc'; import { IWorkspaceService } from '../../common/application/types'; import { CancellationError } from '../../common/cancellation'; -import { IAsyncDisposableRegistry, IDisposable, IDisposableRegistry, ILogger } from '../../common/types'; +import { IAsyncDisposable, IAsyncDisposableRegistry, IDisposableRegistry, ILogger } from '../../common/types'; import { createDeferred, Deferred, sleep } from '../../common/utils/async'; import * as localize from '../../common/utils/localize'; import { noop } from '../../common/utils/misc'; @@ -108,7 +108,7 @@ class CellSubscriber { // https://www.npmjs.com/package/@jupyterlab/services @injectable() -export class JupyterServer implements INotebookServer, IDisposable { +export class JupyterServer implements INotebookServer, IAsyncDisposable { private session: IJupyterSession | undefined; private connInfo: IConnection | undefined; private workingDir: string | undefined; diff --git a/src/client/datascience/types.ts b/src/client/datascience/types.ts index ba60905249f2..2436c82097e7 100644 --- a/src/client/datascience/types.ts +++ b/src/client/datascience/types.ts @@ -8,7 +8,7 @@ import { Observable } from 'rxjs/Observable'; import { CancellationToken, CodeLens, CodeLensProvider, Disposable, Event, Range, TextDocument, TextEditor } from 'vscode'; import { ICommandManager } from '../common/application/types'; -import { IDisposable } from '../common/types'; +import { IAsyncDisposable, IDisposable } from '../common/types'; import { PythonInterpreter } from '../interpreter/contracts'; // Main interface @@ -62,7 +62,7 @@ export interface IJupyterExecution { } export const IJupyterSession = Symbol('IJupyterSession'); -export interface IJupyterSession extends IDisposable { +export interface IJupyterSession extends IAsyncDisposable { onRestarted: Event; restart() : Promise; interrupt() : Promise; diff --git a/src/client/extension.ts b/src/client/extension.ts index bb9d72540dbb..359e58f54da0 100644 --- a/src/client/extension.ts +++ b/src/client/extension.ts @@ -10,8 +10,6 @@ import { StopWatch } from './common/utils/stopWatch'; // Do not move this line of code (used to measure extension load times). const stopWatch = new StopWatch(); import { Container } from 'inversify'; -import { basename as pathBasename, sep as pathSep } from 'path'; -import * as stackTrace from 'stack-trace'; import { CodeActionKind, debug, @@ -58,7 +56,6 @@ import { import { createDeferred } from './common/utils/async'; import { Common } from './common/utils/localize'; import { registerTypes as variableRegisterTypes } from './common/variables/serviceRegistry'; -import { EXTENSION_ROOT_DIR } from './constants'; import { registerTypes as dataScienceRegisterTypes } from './datascience/serviceRegistry'; import { IDataScience } from './datascience/types'; import { DebuggerTypeName } from './debugger/constants'; @@ -402,34 +399,6 @@ function notifyUser(msg: string) { } } -function sanitizeFilename(filename: string): string { - if (filename.startsWith(EXTENSION_ROOT_DIR + pathSep)) { - filename = `${filename.substring(EXTENSION_ROOT_DIR.length)}`; - } else { - // We don't really care about files outside our extension. - filename = `${pathSep}${pathBasename(filename)}`; - } - return filename; -} - -function getStackTrace(ex: Error): string { - // We aren't showing the error message (ex.message) since it might - // contain PII. - let trace = ''; - for (const frame of stackTrace.parse(ex)) { - let filename = frame.getFileName(); - if (filename) { - filename = sanitizeFilename(filename); - const lineno = frame.getLineNumber(); - const colno = frame.getColumnNumber(); - trace += `\n\tat ${filename}:${lineno}:${colno}`; - } else { - trace += '\n\tat '; - } - } - return trace.trim(); -} - async function sendErrorTelemetry(ex: Error) { try { // tslint:disable-next-line:no-any @@ -441,8 +410,7 @@ async function sendErrorTelemetry(ex: Error) { // ignore } } - props.stackTrace = getStackTrace(ex); - sendTelemetryEvent(EDITOR_LOAD, durations, props); + sendTelemetryEvent(EDITOR_LOAD, durations, props, ex); } catch (exc2) { traceError('sendErrorTelemetry() failed.', exc2); } diff --git a/src/client/interpreter/autoSelection/index.ts b/src/client/interpreter/autoSelection/index.ts index 43fbffa515e7..20c98bbc5c75 100644 --- a/src/client/interpreter/autoSelection/index.ts +++ b/src/client/interpreter/autoSelection/index.ts @@ -62,10 +62,10 @@ export class InterpreterAutoSelectionService implements IInterpreterAutoSelectio winRegInterpreter.setNextRule(systemInterpreter); } public async autoSelectInterpreter(resource: Resource): Promise { - Promise.all(this.rules.map(item => item.autoSelectInterpreter(undefined))).ignoreErrors(); await this.initializeStore(); await this.userDefinedInterpreter.autoSelectInterpreter(resource, this); this.didAutoSelectedInterpreterEmitter.fire(); + Promise.all(this.rules.map(item => item.autoSelectInterpreter(undefined))).ignoreErrors(); } public get onDidChangeAutoSelectedInterpreter(): Event { return this.didAutoSelectedInterpreterEmitter.event; @@ -113,8 +113,6 @@ export class InterpreterAutoSelectionService implements IInterpreterAutoSelectio } this.autoSelectedInterpreterByWorkspace.set(workspaceFolderPath, interpreter); } - - this.didAutoSelectedInterpreterEmitter.fire(); } protected async initializeStore() { if (this.globallyPreferredInterpreter) { diff --git a/src/client/languageServices/languageServerSurveyBanner.ts b/src/client/languageServices/languageServerSurveyBanner.ts index d2156a71e21c..1b4a8060502f 100644 --- a/src/client/languageServices/languageServerSurveyBanner.ts +++ b/src/client/languageServices/languageServerSurveyBanner.ts @@ -62,14 +62,6 @@ export class LanguageServerSurveyBanner implements IPythonExtensionBanner { } } - public get optionLabels(): string[] { - return this.bannerLabels; - } - - public get shownCount(): Promise { - return this.getPythonLSLaunchCounter(); - } - public get enabled(): boolean { return this.persistentState.createGlobalPersistentState(LSSurveyStateKeys.ShowBanner, true).value; } diff --git a/src/client/languageServices/proposeLanguageServerBanner.ts b/src/client/languageServices/proposeLanguageServerBanner.ts index c58c4697a114..e881bbe628ec 100644 --- a/src/client/languageServices/proposeLanguageServerBanner.ts +++ b/src/client/languageServices/proposeLanguageServerBanner.ts @@ -65,15 +65,6 @@ export class ProposeLanguageServerBanner implements IPythonExtensionBanner { return; } } - - public get shownCount(): Promise { - return Promise.resolve(-1); // we don't count this popup banner! - } - - public get optionLabels(): string[] { - return this.bannerLabels; - } - public get enabled(): boolean { return this.persistentState.createGlobalPersistentState(ProposeLSStateKeys.ShowBanner, true).value; } diff --git a/src/client/telemetry/constants.ts b/src/client/telemetry/constants.ts index d05e97805348..61b61107a785 100644 --- a/src/client/telemetry/constants.ts +++ b/src/client/telemetry/constants.ts @@ -45,6 +45,7 @@ export const PYTHON_LANGUAGE_SERVER_EXTRACTED = 'PYTHON_LANGUAGE_SERVER.EXTRACTE export const PYTHON_LANGUAGE_SERVER_DOWNLOADED = 'PYTHON_LANGUAGE_SERVER.DOWNLOADED'; export const PYTHON_LANGUAGE_SERVER_ERROR = 'PYTHON_LANGUAGE_SERVER.ERROR'; export const PYTHON_LANGUAGE_SERVER_STARTUP = 'PYTHON_LANGUAGE_SERVER.STARTUP'; +export const PYTHON_LANGUAGE_SERVER_READY = 'PYTHON_LANGUAGE_SERVER.READY'; export const PYTHON_LANGUAGE_SERVER_PLATFORM_NOT_SUPPORTED = 'PYTHON_LANGUAGE_SERVER.PLATFORM_NOT_SUPPORTED'; export const PYTHON_LANGUAGE_SERVER_PLATFORM_SUPPORTED = 'PYTHON_LANGUAGE_SERVER.PLATFORM_SUPPORTED'; export const PYTHON_LANGUAGE_SERVER_TELEMETRY = 'PYTHON_LANGUAGE_SERVER.EVENT'; diff --git a/src/client/telemetry/index.ts b/src/client/telemetry/index.ts index 58ea3e6a212f..5215fcb3cc30 100644 --- a/src/client/telemetry/index.ts +++ b/src/client/telemetry/index.ts @@ -3,8 +3,10 @@ // tslint:disable:no-reference no-any import-name /// +import { basename as pathBasename, sep as pathSep } from 'path'; +import * as stackTrace from 'stack-trace'; import TelemetryReporter from 'vscode-extension-telemetry'; -import { isTestExecution, PVSC_EXTENSION_ID } from '../common/constants'; +import { EXTENSION_ROOT_DIR, isTestExecution, PVSC_EXTENSION_ID } from '../common/constants'; import { StopWatch } from '../common/utils/stopWatch'; import { TelemetryProperties } from './types'; @@ -27,7 +29,7 @@ function isTelemetrySupported(): boolean { } let telemetryReporter: TelemetryReporter; function getTelemetryReporter() { - if (telemetryReporter) { + if (!isTestExecution() && telemetryReporter) { return telemetryReporter; } const extensionId = PVSC_EXTENSION_ID; @@ -45,7 +47,7 @@ function getTelemetryReporter() { return telemetryReporter = new reporter(extensionId, extensionVersion, aiKey); } -export function sendTelemetryEvent(eventName: string, durationMs?: { [key: string]: number } | number, properties?: TelemetryProperties) { +export function sendTelemetryEvent(eventName: string, durationMs?: { [key: string]: number } | number, properties?: TelemetryProperties, ex?: Error) { if (isTestExecution() || !isTelemetrySupported()) { return; } @@ -65,6 +67,12 @@ export function sendTelemetryEvent(eventName: string, durationMs?: { [key: strin (customProperties as any)[prop] = typeof data[prop] === 'string' ? data[prop] : data[prop].toString(); }); } + if (ex) { + customProperties.stackTrace = getStackTrace(ex); + } + if (ex && eventName !== 'ERROR') { + reporter.sendTelemetryEvent(eventName, properties ? customProperties : undefined, measures); + } reporter.sendTelemetryEvent(eventName, properties ? customProperties : undefined, measures); } @@ -104,7 +112,7 @@ export function captureTelemetry( // tslint:disable-next-line:no-any properties = properties || {}; (properties as any).failed = true; - sendTelemetryEvent(failureEventName ? failureEventName : eventName, stopWatch.elapsedTime, properties); + sendTelemetryEvent(failureEventName ? failureEventName : eventName, stopWatch.elapsedTime, properties, ex); }); } else { sendTelemetryEvent(eventName, stopWatch.elapsedTime, properties); @@ -131,10 +139,62 @@ export function sendTelemetryWhenDone(eventName: string, promise: Promise | // tslint:disable-next-line:promise-function-async }, ex => { // tslint:disable-next-line:no-non-null-assertion - sendTelemetryEvent(eventName, stopWatch!.elapsedTime, properties); + sendTelemetryEvent(eventName, stopWatch!.elapsedTime, properties, ex); return Promise.reject(ex); }); } else { throw new Error('Method is neither a Promise nor a Theneable'); } } + +function sanitizeFilename(filename: string): string { + if (filename.startsWith(EXTENSION_ROOT_DIR)) { + filename = `${filename.substring(EXTENSION_ROOT_DIR.length)}`; + } else { + // We don't really care about files outside our extension. + filename = `${pathSep}${pathBasename(filename)}`; + } + return filename; +} + +function sanitizeName(name: string): string { + if (name.indexOf('/') === -1 && name.indexOf('\\') === -1) { + return name; + } else { + return ''; + } +} + +function getStackTrace(ex: Error): string { + // We aren't showing the error message (ex.message) since it might + // contain PII. + let trace = ''; + for (const frame of stackTrace.parse(ex)) { + let filename = frame.getFileName(); + if (filename) { + filename = sanitizeFilename(filename); + const lineno = frame.getLineNumber(); + const colno = frame.getColumnNumber(); + trace += `\n\tat ${getCallsite(frame)} ${filename}:${lineno}:${colno}`; + } else { + trace += '\n\tat '; + } + } + return trace.trim(); +} + +function getCallsite(frame: stackTrace.StackFrame) { + const parts: string[] = []; + if (typeof frame.getTypeName() === 'string' && frame.getTypeName().length > 0) { + parts.push(frame.getTypeName()); + } + if (typeof frame.getMethodName() === 'string' && frame.getMethodName().length > 0) { + parts.push(frame.getMethodName()); + } + if (typeof frame.getFunctionName() === 'string' && frame.getFunctionName().length > 0) { + if (parts.length !== 2 || parts.join('.') !== frame.getFunctionName()) { + parts.push(frame.getFunctionName()); + } + } + return parts.map(sanitizeName).join('.'); +} diff --git a/src/test/activation/activationService.unit.test.ts b/src/test/activation/activationService.unit.test.ts index 9b5fc5f43047..6ffff3a8aa40 100644 --- a/src/test/activation/activationService.unit.test.ts +++ b/src/test/activation/activationService.unit.test.ts @@ -73,7 +73,7 @@ suite('Activation - ActivationService', () => { async function testActivation(activationService: IExtensionActivationService, activator: TypeMoq.IMock, lsSupported: boolean = true) { activator - .setup(a => a.activate()).returns(() => Promise.resolve(true)) + .setup(a => a.activate()).returns(() => Promise.resolve()) .verifiable(TypeMoq.Times.once()); let activatorName = ExtensionActivators.Jedi; if (lsSupported && !jediIsEnabled) { @@ -82,7 +82,7 @@ suite('Activation - ActivationService', () => { let diagnostics; if (!lsSupported && !jediIsEnabled) { diagnostics = [TypeMoq.It.isAny()]; - } else{ + } else { diagnostics = []; } lsNotSupportedDiagnosticService.setup(l => l.diagnose()).returns(() => Promise.resolve(diagnostics)); @@ -132,7 +132,7 @@ suite('Activation - ActivationService', () => { await testActivation(activationService, activator); activator - .setup(a => a.deactivate()).returns(() => Promise.resolve()) + .setup(a => a.dispose()).returns(() => Promise.resolve()) .verifiable(TypeMoq.Times.once()); activationService.dispose(); @@ -274,7 +274,7 @@ suite('Activation - ActivationService', () => { appShell.verifyAll(); cmdManager.verifyAll(); }); - if (!jediIsEnabled){ + if (!jediIsEnabled) { test('Revert to jedi when LS activation fails', async () => { lanagueServerSupportedService.setup(ls => ls.isSupported()).returns(() => Promise.resolve(true)); pythonSettings.setup(p => p.jediEnabled).returns(() => jediIsEnabled); @@ -289,14 +289,14 @@ suite('Activation - ActivationService', () => { .returns(() => activatorDotNet.object) .verifiable(TypeMoq.Times.once()); activatorDotNet - .setup(a => a.activate()).returns(() => Promise.resolve(false)) + .setup(a => a.activate()).returns(() => Promise.reject(new Error(''))) .verifiable(TypeMoq.Times.once()); serviceContainer .setup(c => c.get(TypeMoq.It.isValue(IExtensionActivator), TypeMoq.It.isValue(ExtensionActivators.Jedi))) .returns(() => activatorJedi.object) .verifiable(TypeMoq.Times.once()); activatorJedi - .setup(a => a.activate()).returns(() => Promise.resolve(true)) + .setup(a => a.activate()).returns(() => Promise.resolve()) .verifiable(TypeMoq.Times.once()); await activationService.activate(); diff --git a/src/test/activation/downloader.unit.test.ts b/src/test/activation/downloader.unit.test.ts index 795fc8db4b60..ca0bd168f3e8 100644 --- a/src/test/activation/downloader.unit.test.ts +++ b/src/test/activation/downloader.unit.test.ts @@ -9,32 +9,22 @@ import { expect } from 'chai'; import { SemVer } from 'semver'; import * as TypeMoq from 'typemoq'; import { LanguageServerDownloader } from '../../client/activation/downloader'; -import { ILanguageServerFolderService, ILanguageServerPlatformData } from '../../client/activation/types'; +import { ILanguageServerFolderService, IPlatformData } from '../../client/activation/types'; import { IApplicationShell } from '../../client/common/application/types'; -import { STANDARD_OUTPUT_CHANNEL } from '../../client/common/constants'; -import { IFileSystem, IPlatformService } from '../../client/common/platform/types'; -import { IExtensionContext, IOutputChannel } from '../../client/common/types'; +import { IFileSystem } from '../../client/common/platform/types'; +import { IOutputChannel } from '../../client/common/types'; import { LanguageService } from '../../client/common/utils/localize'; -import { IServiceContainer } from '../../client/ioc/types'; // tslint:disable-next-line:max-func-body-length suite('Activation - Downloader', () => { let languageServerDownloader: LanguageServerDownloader; - let platformService: TypeMoq.IMock; - let container: TypeMoq.IMock; let folderService: TypeMoq.IMock; setup(() => { - container = TypeMoq.Mock.ofType(); - platformService = TypeMoq.Mock.ofType(); folderService = TypeMoq.Mock.ofType(); - const fs = TypeMoq.Mock.ofType(); - const output = TypeMoq.Mock.ofType(); - const platformData = TypeMoq.Mock.ofType(); - container.setup(a => a.get(TypeMoq.It.isValue(IOutputChannel), TypeMoq.It.isValue(STANDARD_OUTPUT_CHANNEL))).returns(() => output.object); - container.setup(a => a.get(TypeMoq.It.isValue(IFileSystem))).returns(() => fs.object); - container.setup(a => a.get(TypeMoq.It.isValue(ILanguageServerFolderService))).returns(() => folderService.object); - - languageServerDownloader = new LanguageServerDownloader(platformData.object, '', container.object); + languageServerDownloader = new LanguageServerDownloader(undefined as any, + undefined as any, undefined as any, + folderService.object, undefined as any, + undefined as any); }); test('Get download uri', async () => { @@ -52,8 +42,8 @@ suite('Activation - Downloader', () => { suite('Test LanguageServerDownloader.downloadLanguageServer', () => { class LanguageServerDownloaderTest extends LanguageServerDownloader { // tslint:disable-next-line:no-unnecessary-override - public async downloadLanguageServer(context: IExtensionContext): Promise { - return super.downloadLanguageServer(context); + public async downloadLanguageServer(destinationFolder: string): Promise { + return super.downloadLanguageServer(destinationFolder); } protected async downloadFile(_uri: string, _title: string): Promise { throw new Error('kaboom'); @@ -61,8 +51,8 @@ suite('Activation - Downloader', () => { } class LanguageServerExtractorTest extends LanguageServerDownloader { // tslint:disable-next-line:no-unnecessary-override - public async downloadLanguageServer(context: IExtensionContext): Promise { - return super.downloadLanguageServer(context); + public async downloadLanguageServer(destinationFolder: string): Promise { + return super.downloadLanguageServer(destinationFolder); } // tslint:disable-next-line:no-unnecessary-override public async getDownloadInfo() { @@ -80,22 +70,15 @@ suite('Activation - Downloader', () => { let languageServerExtractorTest: LanguageServerExtractorTest; setup(() => { appShell = TypeMoq.Mock.ofType(); - container = TypeMoq.Mock.ofType(); - platformService = TypeMoq.Mock.ofType(); folderService = TypeMoq.Mock.ofType(); const fs = TypeMoq.Mock.ofType(); const output = TypeMoq.Mock.ofType(); - const platformData = TypeMoq.Mock.ofType(); - container.setup(a => a.get(TypeMoq.It.isValue(IOutputChannel), TypeMoq.It.isValue(STANDARD_OUTPUT_CHANNEL))).returns(() => output.object); - container.setup(a => a.get(TypeMoq.It.isValue(IFileSystem))).returns(() => fs.object); - container.setup(a => a.get(TypeMoq.It.isValue(ILanguageServerFolderService))).returns(() => folderService.object); - container.setup(c => c.get(TypeMoq.It.isValue(IApplicationShell))).returns(() => appShell.object); + const platformData = TypeMoq.Mock.ofType(); - languageServerDownloaderTest = new LanguageServerDownloaderTest(platformData.object, '', container.object); - languageServerExtractorTest = new LanguageServerExtractorTest(platformData.object, '', container.object); + languageServerDownloaderTest = new LanguageServerDownloaderTest(platformData.object, output.object, undefined as any, folderService.object, appShell.object, fs.object); + languageServerExtractorTest = new LanguageServerExtractorTest(platformData.object, output.object, undefined as any, folderService.object, appShell.object, fs.object); }); test('Display error message if LS downloading fails', async () => { - const context = TypeMoq.Mock.ofType(); const pkg = { uri: 'xyz', package: 'abc', version: new SemVer('0.0.0') } as any; folderService .setup(f => f.getLatestLanguageServerVersion()) @@ -105,14 +88,13 @@ suite('Activation - Downloader', () => { .returns(() => Promise.resolve(undefined)) .verifiable(TypeMoq.Times.once()); try { - await languageServerDownloaderTest.downloadLanguageServer(context.object); + await languageServerDownloaderTest.downloadLanguageServer(''); } catch (err) { appShell.verifyAll(); } folderService.verifyAll(); }); test('Display error message if LS extraction fails', async () => { - const context = TypeMoq.Mock.ofType(); const pkg = { uri: 'xyz', package: 'abc', version: new SemVer('0.0.0') } as any; folderService .setup(f => f.getLatestLanguageServerVersion()) @@ -122,7 +104,7 @@ suite('Activation - Downloader', () => { .returns(() => Promise.resolve(undefined)) .verifiable(TypeMoq.Times.once()); try { - await languageServerExtractorTest.downloadLanguageServer(context.object); + await languageServerExtractorTest.downloadLanguageServer(''); } catch (err) { appShell.verifyAll(); } diff --git a/src/test/activation/languageServer/activator.unit.test.ts b/src/test/activation/languageServer/activator.unit.test.ts new file mode 100644 index 000000000000..d4461b7f54c7 --- /dev/null +++ b/src/test/activation/languageServer/activator.unit.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import * as path from 'path'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import { Uri } from 'vscode'; +import { LanguageServerDownloader } from '../../../client/activation/downloader'; +import { LanguageServerExtensionActivator } from '../../../client/activation/languageServer/activator'; +import { LanguageServerFolderService } from '../../../client/activation/languageServer/languageServerFolderService'; +import { LanguageServerManager } from '../../../client/activation/languageServer/manager'; +import { IExtensionActivator, ILanguageServerDownloader, ILanguageServerFolderService, ILanguageServerManager } from '../../../client/activation/types'; +import { IWorkspaceService } from '../../../client/common/application/types'; +import { WorkspaceService } from '../../../client/common/application/workspace'; +import { PythonSettings } from '../../../client/common/configSettings'; +import { ConfigurationService } from '../../../client/common/configuration/service'; +import { FileSystem } from '../../../client/common/platform/fileSystem'; +import { IFileSystem } from '../../../client/common/platform/types'; +import { IConfigurationService, IPythonSettings } from '../../../client/common/types'; +import { createDeferred } from '../../../client/common/utils/async'; +import { EXTENSION_ROOT_DIR } from '../../../client/constants'; +import { sleep } from '../../core'; + +// tslint:disable:max-func-body-length + +suite('Language Server - Activator', () => { + let activator: IExtensionActivator; + let workspaceService: IWorkspaceService; + let manager: ILanguageServerManager; + let fs: IFileSystem; + let lsDownloader: ILanguageServerDownloader; + let lsFolderService: ILanguageServerFolderService; + let configuration: IConfigurationService; + let settings: IPythonSettings; + setup(() => { + manager = mock(LanguageServerManager); + workspaceService = mock(WorkspaceService); + fs = mock(FileSystem); + lsDownloader = mock(LanguageServerDownloader); + lsFolderService = mock(LanguageServerFolderService); + configuration = mock(ConfigurationService); + settings = mock(PythonSettings); + when(configuration.getSettings(anything())).thenReturn(instance(settings)); + activator = new LanguageServerExtensionActivator(instance(manager), + instance(workspaceService), instance(fs), + instance(lsDownloader), instance(lsFolderService), + instance(configuration)); + }); + test('Manager must be started without any workspace', async () => { + when(workspaceService.hasWorkspaceFolders).thenReturn(false); + when(manager.start(undefined)).thenResolve(); + when(settings.downloadLanguageServer).thenReturn(false); + + await activator.activate(); + + verify(manager.start(undefined)).once(); + verify(workspaceService.hasWorkspaceFolders).once(); + }); + test('Manager must be disposed', async () => { + activator.dispose(); + + verify(manager.dispose()).once(); + }); + test('Do not download LS if not required', async () => { + when(workspaceService.hasWorkspaceFolders).thenReturn(false); + when(manager.start(undefined)).thenResolve(); + when(settings.downloadLanguageServer).thenReturn(false); + + await activator.activate(); + + verify(manager.start(undefined)).once(); + verify(workspaceService.hasWorkspaceFolders).once(); + verify(lsFolderService.getLanguageServerFolderName()).never(); + verify(lsDownloader.downloadLanguageServer(anything())).never(); + }); + test('Do not download LS if not required', async () => { + const languageServerFolder = 'Some folder name'; + const languageServerFolderPath = path.join(EXTENSION_ROOT_DIR, languageServerFolder); + const mscorlib = path.join(languageServerFolderPath, 'mscorlib.dll'); + + when(workspaceService.hasWorkspaceFolders).thenReturn(false); + when(manager.start(undefined)).thenResolve(); + when(settings.downloadLanguageServer).thenReturn(true); + when(lsFolderService.getLanguageServerFolderName()).thenResolve(languageServerFolder); + when(fs.fileExists(mscorlib)).thenResolve(true); + + await activator.activate(); + + verify(manager.start(undefined)).once(); + verify(workspaceService.hasWorkspaceFolders).once(); + verify(lsFolderService.getLanguageServerFolderName()).once(); + verify(lsDownloader.downloadLanguageServer(anything())).never(); + }); + test('Start language server after downloading', async () => { + const deferred = createDeferred(); + const languageServerFolder = 'Some folder name'; + const languageServerFolderPath = path.join(EXTENSION_ROOT_DIR, languageServerFolder); + const mscorlib = path.join(languageServerFolderPath, 'mscorlib.dll'); + + when(workspaceService.hasWorkspaceFolders).thenReturn(false); + when(manager.start(undefined)).thenResolve(); + when(settings.downloadLanguageServer).thenReturn(true); + when(lsFolderService.getLanguageServerFolderName()).thenResolve(languageServerFolder); + when(fs.fileExists(mscorlib)).thenResolve(false); + when(lsDownloader.downloadLanguageServer(languageServerFolderPath)).thenReturn(deferred.promise); + + const promise = activator.activate(); + await sleep(1); + verify(workspaceService.hasWorkspaceFolders).once(); + verify(lsFolderService.getLanguageServerFolderName()).once(); + verify(lsDownloader.downloadLanguageServer(anything())).once(); + + verify(manager.start(undefined)).never(); + + deferred.resolve(); + await sleep(1); + verify(manager.start(undefined)).once(); + + await promise; + }); + test('Manager must be started with resource for first available workspace', async () => { + const uri = Uri.file(__filename); + when(workspaceService.hasWorkspaceFolders).thenReturn(true); + when(workspaceService.workspaceFolders).thenReturn([{ index: 0, name: '', uri }]); + when(manager.start(uri)).thenResolve(); + when(settings.downloadLanguageServer).thenReturn(false); + + await activator.activate(); + + verify(manager.start(uri)).once(); + verify(workspaceService.hasWorkspaceFolders).once(); + verify(workspaceService.workspaceFolders).once(); + }); + + test('Manager must be disposed', async () => { + activator.dispose(); + + verify(manager.dispose()).once(); + }); +}); diff --git a/src/test/activation/languageServer/analysisOptions.unit.test.ts b/src/test/activation/languageServer/analysisOptions.unit.test.ts new file mode 100644 index 000000000000..d591fd245ea6 --- /dev/null +++ b/src/test/activation/languageServer/analysisOptions.unit.test.ts @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import { instance, mock, verify, when } from 'ts-mockito'; +import * as typemoq from 'typemoq'; +import { ConfigurationChangeEvent, Uri } from 'vscode'; +import { InterpreterDataService } from '../../../client/activation/interpreterDataService'; +import { LanguageServerAnalysisOptions } from '../../../client/activation/languageServer/analysisOptions'; +import { LanguageServerFolderService } from '../../../client/activation/languageServer/languageServerFolderService'; +import { IInterpreterDataService, ILanguageServerFolderService } from '../../../client/activation/types'; +import { IWorkspaceService } from '../../../client/common/application/types'; +import { WorkspaceService } from '../../../client/common/application/workspace'; +import { ConfigurationService } from '../../../client/common/configuration/service'; +import { PathUtils } from '../../../client/common/platform/pathUtils'; +import { IConfigurationService, IDisposable, IExtensionContext, IOutputChannel, IPathUtils, IPythonExtensionBanner } from '../../../client/common/types'; +import { EnvironmentVariablesProvider } from '../../../client/common/variables/environmentVariablesProvider'; +import { IEnvironmentVariablesProvider } from '../../../client/common/variables/types'; +import { IInterpreterService } from '../../../client/interpreter/contracts'; +import { InterpreterService } from '../../../client/interpreter/interpreterService'; +import { ProposeLanguageServerBanner } from '../../../client/languageServices/proposeLanguageServerBanner'; +import { sleep } from '../../core'; + +// tslint:disable:no-unnecessary-override no-any chai-vague-errors no-unused-expression max-func-body-length + +suite('Language Server - Analysis Options', () => { + class TestClass extends LanguageServerAnalysisOptions { + public getExcludedFiles(): string[] { + return super.getExcludedFiles(); + } + public getVsCodeExcludeSection(setting: string, list: string[]): void { + return super.getVsCodeExcludeSection(setting, list); + } + public getPythonExcludeSection(list: string[]): void { + return super.getPythonExcludeSection(list); + } + public getTypeshedPaths(): string[] { + return super.getTypeshedPaths(); + } + public async onSettingsChanged(): Promise { + return super.onSettingsChanged(); + } + public async notifyIfValuesHaveChanged(oldArray: string[], newArray: string[]): Promise { + return super.notifyIfValuesHaveChanged(oldArray, newArray); + } + } + let analysisOptions: TestClass; + let context: typemoq.IMock; + let envVarsProvider: IEnvironmentVariablesProvider; + let configurationService: IConfigurationService; + let workspace: IWorkspaceService; + let surveyBanner: IPythonExtensionBanner; + let interpreterService: IInterpreterService; + let outputChannel: IOutputChannel; + let pathUtils: IPathUtils; + let lsFolderService: ILanguageServerFolderService; + let interpreterDataService: IInterpreterDataService; + setup(() => { + context = typemoq.Mock.ofType(); + envVarsProvider = mock(EnvironmentVariablesProvider); + configurationService = mock(ConfigurationService); + workspace = mock(WorkspaceService); + surveyBanner = mock(ProposeLanguageServerBanner); + interpreterService = mock(InterpreterService); + outputChannel = typemoq.Mock.ofType().object; + pathUtils = mock(PathUtils); + interpreterDataService = mock(InterpreterDataService); + lsFolderService = mock(LanguageServerFolderService); + analysisOptions = new TestClass(context.object, instance(envVarsProvider), + instance(configurationService), + instance(workspace), instance(surveyBanner), + instance(interpreterService), instance(interpreterDataService), outputChannel, + instance(pathUtils), instance(lsFolderService)); + }); + test('Initialize will add event handlers and will dispose them when running dispose', async () => { + const disposable1 = typemoq.Mock.ofType(); + const disposable2 = typemoq.Mock.ofType(); + when(workspace.onDidChangeConfiguration).thenReturn(() => disposable1.object); + when(interpreterService.onDidChangeInterpreter).thenReturn(() => disposable2.object); + + await analysisOptions.initialize(undefined); + + verify(workspace.onDidChangeConfiguration).once(); + verify(interpreterService.onDidChangeInterpreter).once(); + + disposable1.setup(d => d.dispose()).verifiable(typemoq.Times.once()); + disposable2.setup(d => d.dispose()).verifiable(typemoq.Times.once()); + + analysisOptions.dispose(); + + disposable1.verifyAll(); + disposable2.verifyAll(); + }); + test('Changes to settings or interpreter will be debounced', async () => { + const disposable1 = typemoq.Mock.ofType(); + const disposable2 = typemoq.Mock.ofType(); + let configChangedHandler!: Function; + let interpreterChangedHandler!: Function; + when(workspace.onDidChangeConfiguration).thenReturn(cb => { configChangedHandler = cb; return disposable1.object; }); + when(interpreterService.onDidChangeInterpreter).thenReturn(cb => { interpreterChangedHandler = cb; return disposable2.object; }); + let settingsChangedInvokedCount = 0; + when(interpreterDataService.getInterpreterData(undefined)) + .thenCall(() => settingsChangedInvokedCount += 1) + .thenResolve(); + + await analysisOptions.initialize(undefined); + expect(configChangedHandler).to.not.be.undefined; + expect(interpreterChangedHandler).to.not.be.undefined; + + for (let i = 0; i < 100; i += 1) { + configChangedHandler.call(analysisOptions); + interpreterChangedHandler.call(analysisOptions); + } + expect(settingsChangedInvokedCount).to.be.equal(0); + + await sleep(1); + + expect(settingsChangedInvokedCount).to.be.equal(1); + }); + test('If there are no changes then no events will be fired', async () => { + when(interpreterDataService.getInterpreterData(undefined)) + .thenResolve({ hash: '' } as any); + analysisOptions.getExcludedFiles = () => []; + analysisOptions.getTypeshedPaths = () => []; + + let eventFired = false; + analysisOptions.onDidChange(() => eventFired = true); + + await analysisOptions.onSettingsChanged(); + await sleep(1); + + expect(eventFired).to.be.equal(false); + }); + test('Event must be fired if excluded files are different', async () => { + when(interpreterDataService.getInterpreterData(undefined)) + .thenResolve(); + analysisOptions.getExcludedFiles = () => ['1']; + analysisOptions.getTypeshedPaths = () => []; + + let eventFired = false; + analysisOptions.onDidChange(() => eventFired = true); + + await analysisOptions.onSettingsChanged(); + await sleep(1); + + expect(eventFired).to.be.equal(true); + }); + test('Event must be fired if typeshed files are different', async () => { + when(interpreterDataService.getInterpreterData(undefined)) + .thenResolve(); + analysisOptions.getExcludedFiles = () => []; + analysisOptions.getTypeshedPaths = () => ['1']; + + let eventFired = false; + analysisOptions.onDidChange(() => eventFired = true); + + await analysisOptions.onSettingsChanged(); + await sleep(1); + + expect(eventFired).to.be.equal(true); + }); + test('Event must be fired if interpreter info is different', async () => { + when(interpreterDataService.getInterpreterData({ hash: '1234' } as any)) + .thenResolve(); + + let eventFired = false; + analysisOptions.onDidChange(() => eventFired = true); + + await analysisOptions.onSettingsChanged(); + await sleep(1); + + expect(eventFired).to.be.equal(true); + }); + test('Changes to settings will be filtered to current resoruce', async () => { + const uri = Uri.file(__filename); + const disposable1 = typemoq.Mock.ofType(); + const disposable2 = typemoq.Mock.ofType(); + let configChangedHandler!: Function; + let interpreterChangedHandler!: Function; + when(workspace.onDidChangeConfiguration).thenReturn(cb => { configChangedHandler = cb; return disposable1.object; }); + when(interpreterService.onDidChangeInterpreter).thenReturn(cb => { interpreterChangedHandler = cb; return disposable2.object; }); + let settingsChangedInvokedCount = 0; + when(interpreterDataService.getInterpreterData(uri)).thenResolve(); + + analysisOptions.onDidChange(() => settingsChangedInvokedCount += 1); + await analysisOptions.initialize(uri); + expect(configChangedHandler).to.not.be.undefined; + expect(interpreterChangedHandler).to.not.be.undefined; + + settingsChangedInvokedCount = 0; + for (let i = 0; i < 100; i += 1) { + const event = typemoq.Mock.ofType(); + event.setup(e => e.affectsConfiguration(typemoq.It.isValue('python'), typemoq.It.isValue(uri))) + .verifiable(typemoq.Times.once()); + configChangedHandler.call(analysisOptions, event.object); + interpreterChangedHandler.call(analysisOptions); + + event.verifyAll(); + } + expect(settingsChangedInvokedCount).to.be.equal(0); + + await sleep(1); + + expect(settingsChangedInvokedCount).to.be.equal(1); + }); +}); diff --git a/src/test/activation/languageServer/languageClientFactory.unit.test.ts b/src/test/activation/languageServer/languageClientFactory.unit.test.ts new file mode 100644 index 000000000000..133992583ac8 --- /dev/null +++ b/src/test/activation/languageServer/languageClientFactory.unit.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +//tslint:disable:no-require-imports no-require-imports no-var-requires no-any no-unnecessary-class max-func-body-length match-default-export-name + +import { expect } from 'chai'; +import * as path from 'path'; +import rewiremock from 'rewiremock'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; +import * as typemoq from 'typemoq'; +import { Uri } from 'vscode'; +import { LanguageClientOptions, ServerOptions } from 'vscode-languageclient'; +import { BaseLanguageClientFactory, DownloadedLanguageClientFactory, SimpleLanguageClientFactory } from '../../../client/activation/languageServer/languageClientFactory'; +import { LanguageServerFolderService } from '../../../client/activation/languageServer/languageServerFolderService'; +import { PlatformData } from '../../../client/activation/platformData'; +import { PythonSettings } from '../../../client/common/configSettings'; +import { ConfigurationService } from '../../../client/common/configuration/service'; +import { EXTENSION_ROOT_DIR } from '../../../client/common/constants'; +import { IConfigurationService, IPythonSettings } from '../../../client/common/types'; + +const dotNetCommand = 'dotnet'; +const languageClientName = 'Python Tools'; + +suite('Language Server - LanguageClient Factory', () => { + let configurationService: IConfigurationService; + let settings: IPythonSettings; + setup(() => { + configurationService = mock(ConfigurationService); + settings = mock(PythonSettings); + when(configurationService.getSettings(anything())).thenReturn(instance(settings)); + }); + teardown(() => { + rewiremock.disable(); + }); + + test('Download factory is used when required to download the LS', async () => { + const downloadFactory = mock(DownloadedLanguageClientFactory); + const simpleFactory = mock(SimpleLanguageClientFactory); + const factory = new BaseLanguageClientFactory(instance(downloadFactory), instance(simpleFactory), instance(configurationService)); + const uri = Uri.file(__filename); + const options = typemoq.Mock.ofType().object; + when(settings.downloadLanguageServer).thenReturn(true); + + await factory.createLanguageClient(uri, options); + + verify(configurationService.getSettings(uri)).once(); + verify(downloadFactory.createLanguageClient(uri, options)).once(); + verify(simpleFactory.createLanguageClient(uri, options)).never(); + }); + test('Simple factory is used when not required to download the LS', async () => { + const downloadFactory = mock(DownloadedLanguageClientFactory); + const simpleFactory = mock(SimpleLanguageClientFactory); + const factory = new BaseLanguageClientFactory(instance(downloadFactory), instance(simpleFactory), instance(configurationService)); + const uri = Uri.file(__filename); + const options = typemoq.Mock.ofType().object; + when(settings.downloadLanguageServer).thenReturn(false); + + await factory.createLanguageClient(uri, options); + + verify(configurationService.getSettings(uri)).once(); + verify(downloadFactory.createLanguageClient(uri, options)).never(); + verify(simpleFactory.createLanguageClient(uri, options)).once(); + }); + test('Download factory will make use of the language server folder name and client will be created', async () => { + const platformData = mock(PlatformData); + const lsFolderService = mock(LanguageServerFolderService); + const factory = new DownloadedLanguageClientFactory(instance(platformData), instance(lsFolderService)); + const uri = Uri.file(__filename); + const options = typemoq.Mock.ofType().object; + const languageServerFolder = 'some folder name'; + const engineDllName = 'xyz.dll'; + when(lsFolderService.getLanguageServerFolderName()).thenResolve(languageServerFolder); + when(platformData.engineExecutableName).thenReturn(engineDllName); + + const serverModule = path.join(EXTENSION_ROOT_DIR, languageServerFolder, engineDllName); + const expectedServerOptions = { + run: { command: serverModule, rgs: [], options: { stdio: 'pipe' } }, + debug: { command: serverModule, args: ['--debug'], options: { stdio: 'pipe' } } + }; + rewiremock.enable(); + + class MockClass { + constructor(language: string, name: string, serverOptions: ServerOptions, clientOptions: LanguageClientOptions) { + expect(language).to.be.equal('python'); + expect(name).to.be.equal(languageClientName); + expect(clientOptions).to.be.deep.equal(options); + expect(serverOptions).to.be.deep.equal(expectedServerOptions); + } + } + rewiremock('vscode-languageclient').with({ LanguageClient: MockClass }); + + const client = await factory.createLanguageClient(uri, options); + + verify(lsFolderService.getLanguageServerFolderName()).once(); + verify(platformData.engineExecutableName).atLeast(1); + verify(platformData.engineDllName).never(); + verify(platformData.platformName).never(); + expect(client).to.be.instanceOf(MockClass); + }); + test('Simple factory will make use of the language server folder name and client will be created', async () => { + const platformData = mock(PlatformData); + const lsFolderService = mock(LanguageServerFolderService); + const factory = new SimpleLanguageClientFactory(instance(platformData), instance(lsFolderService)); + const uri = Uri.file(__filename); + const options = typemoq.Mock.ofType().object; + const languageServerFolder = 'some folder name'; + const engineDllName = 'xyz.dll'; + when(lsFolderService.getLanguageServerFolderName()).thenResolve(languageServerFolder); + when(platformData.engineDllName).thenReturn(engineDllName); + + const serverModule = path.join(EXTENSION_ROOT_DIR, languageServerFolder, engineDllName); + const expectedServerOptions = { + run: { command: dotNetCommand, args: [serverModule], options: { stdio: 'pipe' } }, + debug: { command: dotNetCommand, args: [serverModule, '--debug'], options: { stdio: 'pipe' } } + }; + rewiremock.enable(); + + class MockClass { + constructor(language: string, name: string, serverOptions: ServerOptions, clientOptions: LanguageClientOptions) { + expect(language).to.be.equal('python'); + expect(name).to.be.equal(languageClientName); + expect(clientOptions).to.be.deep.equal(options); + expect(serverOptions).to.be.deep.equal(expectedServerOptions); + } + } + rewiremock('vscode-languageclient').with({ LanguageClient: MockClass }); + + const client = await factory.createLanguageClient(uri, options); + + verify(lsFolderService.getLanguageServerFolderName()).once(); + verify(platformData.engineExecutableName).never(); + verify(platformData.engineDllName).once(); + verify(platformData.platformName).never(); + expect(client).to.be.instanceOf(MockClass); + }); +}); diff --git a/src/test/activation/languageServer/languageServer.unit.test.ts b/src/test/activation/languageServer/languageServer.unit.test.ts index 025321c8445c..95143827bfe1 100644 --- a/src/test/activation/languageServer/languageServer.unit.test.ts +++ b/src/test/activation/languageServer/languageServer.unit.test.ts @@ -3,146 +3,76 @@ 'use strict'; -// tslint:disable:max-func-body-length - import { expect } from 'chai'; -import * as path from 'path'; -import * as TypeMoq from 'typemoq'; +import { instance, mock, when } from 'ts-mockito'; +import * as typemoq from 'typemoq'; +import { Uri } from 'vscode'; import { LanguageClient, LanguageClientOptions } from 'vscode-languageclient'; -import { LanguageServerExtensionActivator } from '../../../client/activation/languageServer/languageServer'; -import { ILanguageServerPlatformData } from '../../../client/activation/types'; -import { IApplicationShell, ICommandManager, IWorkspaceService } from '../../../client/common/application/types'; -import { IFileSystem, IPlatformService } from '../../../client/common/platform/types'; -import { IConfigurationService, IDisposableRegistry, IExtensionContext, IFeatureDeprecationManager, IOutputChannel, IPathUtils, IPythonSettings } from '../../../client/common/types'; -import { LanguageService } from '../../../client/common/utils/localize'; -import { IEnvironmentVariablesProvider } from '../../../client/common/variables/types'; -import { IServiceContainer } from '../../../client/ioc/types'; +import { BaseLanguageClientFactory } from '../../../client/activation/languageServer/languageClientFactory'; +import { LanguageServer } from '../../../client/activation/languageServer/languageServer'; +import { ILanaguageServer, ILanguageClientFactory } from '../../../client/activation/types'; +import { IDisposable } from '../../../client/common/types'; +import { sleep } from '../../../client/common/utils/async'; -suite('Language Server', () => { - let serviceContainer: TypeMoq.IMock; - let pythonSettings: TypeMoq.IMock; - let appShell: TypeMoq.IMock; - let cmdManager: TypeMoq.IMock; - let workspaceService: TypeMoq.IMock; - let platformService: TypeMoq.IMock; - let platformData: TypeMoq.IMock; - let languageServer: LanguageServerExtensionActivator; - let extensionContext: TypeMoq.IMock; +//tslint:disable:no-require-imports no-require-imports no-var-requires no-any no-unnecessary-class max-func-body-length +suite('Language Server - LanguageServer', () => { + let clientFactory: ILanguageClientFactory; + let server: ILanaguageServer; + let client: typemoq.IMock; setup(() => { - serviceContainer = TypeMoq.Mock.ofType(); - extensionContext = TypeMoq.Mock.ofType(); - extensionContext.setup(ec => ec.extensionPath).returns(() => ''); - appShell = TypeMoq.Mock.ofType(); - workspaceService = TypeMoq.Mock.ofType(); - cmdManager = TypeMoq.Mock.ofType(); - platformService = TypeMoq.Mock.ofType(); - const configService = TypeMoq.Mock.ofType(); - pythonSettings = TypeMoq.Mock.ofType(); - - workspaceService.setup(w => w.hasWorkspaceFolders).returns(() => false); - workspaceService.setup(w => w.workspaceFolders).returns(() => []); - configService.setup(c => c.getSettings(TypeMoq.It.isAny())).returns(() => pythonSettings.object); - - const output = TypeMoq.Mock.ofType(); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IOutputChannel), TypeMoq.It.isAny())).returns(() => output.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IWorkspaceService))).returns(() => workspaceService.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IApplicationShell))).returns(() => appShell.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IDisposableRegistry))).returns(() => []); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IConfigurationService))).returns(() => configService.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(ICommandManager))).returns(() => cmdManager.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IPlatformService))).returns(() => platformService.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IExtensionContext))).returns(() => extensionContext.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IFeatureDeprecationManager))).returns(() => TypeMoq.Mock.ofType().object); - - languageServer = new LanguageServerExtensionActivator(serviceContainer.object); + client = typemoq.Mock.ofType(); + clientFactory = mock(BaseLanguageClientFactory); + server = new LanguageServer(instance(clientFactory)); }); + teardown(() => { + client.setup(c => c.stop()).returns(() => Promise.resolve()); + server.dispose(); + }); + test('Loading extension will throw an error if not activated', () => { + expect(() => server.loadExtension()).throws(); + }); + test('Send telemetry when LS has started and disposes appropriately', async () => { + const loadExtensionArgs = { x: 1 }; + const uri = Uri.file(__filename); + const options = typemoq.Mock.ofType().object; + client.setup(c => (c as any).then).returns(() => undefined); + when(clientFactory.createLanguageClient(uri, options)).thenResolve(client.object); + const startDisposable = typemoq.Mock.ofType(); + client.setup(c => c.stop()).returns(() => Promise.resolve()); + client + .setup(c => c.start()).returns(() => startDisposable.object) + .verifiable(typemoq.Times.once()); + client + .setup(c => c.sendRequest(typemoq.It.isValue('python/loadExtension'), typemoq.It.isValue(loadExtensionArgs))) + .returns(() => Promise.resolve(undefined) as any); - test('Must get PYTHONPATH from env vars provider', async () => { - const pathDelimiter = 'x'; - const pythonPathVar = ['A', 'B', '1']; - const envVarsProvider = TypeMoq.Mock.ofType(); - const pathUtils = TypeMoq.Mock.ofType(); - extensionContext.setup(e => e.extensionPath).returns(() => path.join('a', 'b', 'c')); - pathUtils.setup(p => p.delimiter).returns(() => pathDelimiter); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IEnvironmentVariablesProvider))).returns(() => envVarsProvider.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IPathUtils))).returns(() => pathUtils.object); - envVarsProvider - .setup(p => p.getEnvironmentVariables()) - .returns(() => { return Promise.resolve({ PYTHONPATH: pythonPathVar.join(pathDelimiter) }); }) - .verifiable(TypeMoq.Times.once()); - - // tslint:disable-next-line:no-any - (languageServer as any).languageServerFolder = ''; - const options = await languageServer.getAnalysisOptions(); + expect(() => server.loadExtension(loadExtensionArgs)).throws('Activation not completed or not invoked'); + client.verify(c => c.sendRequest(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.never()); + client + .setup(c => c.initializeResult).returns(() => false as any) + .verifiable(typemoq.Times.once()); - expect(options!).not.to.equal(undefined, 'options cannot be undefined'); - expect(options!.initializationOptions).not.to.equal(undefined, 'initializationOptions cannot be undefined'); - expect(options!.initializationOptions!.searchPaths).to.include.members(pythonPathVar); - }); + const promise = server.start(uri, options); - suite('Test LanguageServerExtensionActivator.startLanguageServer', () => { - class LanguageServerExtensionActivatorTest extends LanguageServerExtensionActivator { - // tslint:disable-next-line:no-unnecessary-override - public async startLanguageServer(clientOptions: LanguageClientOptions): Promise { - this.languageServerFolder = ''; - return super.startLanguageServer(clientOptions); - } - protected async createSelfContainedLanguageClient(serverModule: string, clientOptions: LanguageClientOptions): Promise { - return Promise.resolve(undefined); - } - protected async startLanguageClient(): Promise { - throw new Error('kaboom'); - } - } - let languageServerExtensionActivatorTest: LanguageServerExtensionActivatorTest; - let fs: TypeMoq.IMock; - setup(() => { - serviceContainer = TypeMoq.Mock.ofType(); - extensionContext = TypeMoq.Mock.ofType(); - extensionContext.setup(ec => ec.extensionPath).returns(() => ''); - appShell = TypeMoq.Mock.ofType(); - workspaceService = TypeMoq.Mock.ofType(); - cmdManager = TypeMoq.Mock.ofType(); - platformService = TypeMoq.Mock.ofType(); - platformData = TypeMoq.Mock.ofType(); - platformData.setup(pd => pd.getEngineExecutableName()).returns(() => ''); - const configService = TypeMoq.Mock.ofType(); - pythonSettings = TypeMoq.Mock.ofType(); - fs = TypeMoq.Mock.ofType(); + // Even though server has started request should not yet be sent out. + // Not untill language client has initialized. + expect(() => server.loadExtension(loadExtensionArgs)).throws('Activation not completed'); + client.verify(c => c.sendRequest(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.never()); - workspaceService.setup(w => w.hasWorkspaceFolders).returns(() => false); - workspaceService.setup(w => w.workspaceFolders).returns(() => []); - configService.setup(c => c.getSettings(TypeMoq.It.isAny())).returns(() => pythonSettings.object); + // // Initialize language client and verify that the request was sent out. + client + .setup(c => c.initializeResult).returns(() => true as any) + .verifiable(typemoq.Times.once()); + await sleep(120); + expect(() => server.loadExtension(loadExtensionArgs)).to.not.throw(); + client.verify(c => c.sendRequest(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.once()); + client.verify(c => c.stop(), typemoq.Times.never()); - const output = TypeMoq.Mock.ofType(); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IOutputChannel), TypeMoq.It.isAny())).returns(() => output.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IWorkspaceService))).returns(() => workspaceService.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IApplicationShell))).returns(() => appShell.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IDisposableRegistry))).returns(() => []); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IConfigurationService))).returns(() => configService.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(ICommandManager))).returns(() => cmdManager.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IPlatformService))).returns(() => platformService.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IExtensionContext))).returns(() => extensionContext.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IFileSystem))).returns(() => fs.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(ILanguageServerPlatformData))).returns(() => platformData.object); - serviceContainer.setup(c => c.get(TypeMoq.It.isValue(IFeatureDeprecationManager))).returns(() => TypeMoq.Mock.ofType().object); - languageServerExtensionActivatorTest = new LanguageServerExtensionActivatorTest(serviceContainer.object); - }); + await promise; + server.dispose(); - test('Display error message if LS fails to start', async () => { - const clientOptions = TypeMoq.Mock.ofType(); - fs.setup(a => a.fileExists(TypeMoq.It.isAnyString())) - .returns(() => Promise.resolve(true)) - .verifiable(TypeMoq.Times.once()); - appShell.setup(a => a.showErrorMessage(LanguageService.lsFailedToStart())) - .returns(() => Promise.resolve(undefined)) - .verifiable(TypeMoq.Times.once()); - try { - await languageServerExtensionActivatorTest.startLanguageServer(clientOptions.object); - } catch (err) { - appShell.verifyAll(); - } - }); + client.verify(c => c.stop(), typemoq.Times.once()); + startDisposable.verify(d => d.dispose(), typemoq.Times.once()); }); }); diff --git a/src/test/activation/languageServer/manager.unit.test.ts b/src/test/activation/languageServer/manager.unit.test.ts new file mode 100644 index 000000000000..da4fac9daddf --- /dev/null +++ b/src/test/activation/languageServer/manager.unit.test.ts @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; +import * as typemoq from 'typemoq'; +import { Uri } from 'vscode'; +import { LanguageClientOptions } from 'vscode-languageclient'; +import { LanguageServerAnalysisOptions } from '../../../client/activation/languageServer/analysisOptions'; +import { LanguageServer } from '../../../client/activation/languageServer/languageServer'; +import { LanguageServerManager } from '../../../client/activation/languageServer/manager'; +import { ILanaguageServer, ILanguageServerAnalysisOptions } from '../../../client/activation/types'; +import { CommandManager } from '../../../client/common/application/commandManager'; +import { ICommandManager } from '../../../client/common/application/types'; +import { IDisposable } from '../../../client/common/types'; +import { ServiceContainer } from '../../../client/ioc/container'; +import { IServiceContainer } from '../../../client/ioc/types'; +import { sleep } from '../../core'; + +use(chaiAsPromised); + +// tslint:disable:max-func-body-length no-any chai-vague-errors no-unused-expression + +const loadExtensionCommand = 'python._loadLanguageServerExtension'; + +suite('Language Server - Manager', () => { + class LanguageServerManagerTest extends LanguageServerManager { + public static initializeExtensionArgs(args: {}) { + LanguageServerManager.loadExtensionArgs = args; + } + public clearLoadExtensionArgs() { + LanguageServerManager.loadExtensionArgs = undefined; + } + } + let manager: LanguageServerManagerTest; + let serviceContainer: IServiceContainer; + let analysisOptions: ILanguageServerAnalysisOptions; + let languageServer: ILanaguageServer; + let commandManager: ICommandManager; + let onChangeHandler: Function; + const languageClientOptions = { x: 1 } as any as LanguageClientOptions; + let commandRegistrationDisposable: typemoq.IMock; + setup(() => { + serviceContainer = mock(ServiceContainer); + analysisOptions = mock(LanguageServerAnalysisOptions); + languageServer = mock(LanguageServer); + commandManager = mock(CommandManager); + commandRegistrationDisposable = typemoq.Mock.ofType(); + manager = new LanguageServerManagerTest(instance(serviceContainer), + instance(commandManager), + instance(analysisOptions)); + manager.clearLoadExtensionArgs(); + }); + + [undefined, Uri.file(__filename)].forEach(resource => { + async function startLanguageServer() { + when(commandManager.registerCommand(loadExtensionCommand, anything())) + .thenReturn(commandRegistrationDisposable.object); + + let handlerRegistered = false; + const changeFn = (handler: Function) => { handlerRegistered = true; onChangeHandler = handler; }; + when(analysisOptions.initialize(resource)).thenResolve(); + when(analysisOptions.getAnalysisOptions()).thenResolve(languageClientOptions); + when(analysisOptions.onDidChange).thenReturn(changeFn as any); + when(serviceContainer.get(ILanaguageServer)).thenReturn(instance(languageServer)); + when(languageServer.start(resource, languageClientOptions)).thenResolve(); + + await manager.start(resource); + + verify(analysisOptions.initialize(resource)).once(); + verify(analysisOptions.getAnalysisOptions()).once(); + verify(serviceContainer.get(ILanaguageServer)).once(); + verify(languageServer.start(resource, languageClientOptions)).once(); + expect(handlerRegistered).to.be.true; + verify(languageServer.dispose()).never(); + verify(commandManager.registerCommand(loadExtensionCommand, anything())).once(); + commandRegistrationDisposable.verify(d => d.dispose(), typemoq.Times.never()); + } + test('Start must register handlers and initialize analysis options', async () => { + await startLanguageServer(); + + manager.dispose(); + + verify(languageServer.dispose()).once(); + }); + test('Attempting to start LS will throw an exception', async () => { + await startLanguageServer(); + + await expect(manager.start(resource)).to.eventually.be.rejectedWith('Language Server already started'); + }); + test('Changes in analysis options must restart LS', async () => { + await startLanguageServer(); + + await onChangeHandler.call(manager); + await sleep(1); + + verify(languageServer.dispose()).once(); + + verify(analysisOptions.getAnalysisOptions()).twice(); + verify(serviceContainer.get(ILanaguageServer)).twice(); + verify(languageServer.start(resource, languageClientOptions)).twice(); + }); + test('Changes in analysis options must throttled when restarting LS', async () => { + await startLanguageServer(); + + await onChangeHandler.call(manager); + await onChangeHandler.call(manager); + await onChangeHandler.call(manager); + await onChangeHandler.call(manager); + await Promise.all([onChangeHandler.call(manager), + onChangeHandler.call(manager), + onChangeHandler.call(manager), + onChangeHandler.call(manager)]); + await sleep(1); + + verify(languageServer.dispose()).once(); + + verify(analysisOptions.getAnalysisOptions()).twice(); + verify(serviceContainer.get(ILanaguageServer)).twice(); + verify(languageServer.start(resource, languageClientOptions)).twice(); + }); + test('Multiple changes in analysis options must restart LS twice', async () => { + await startLanguageServer(); + + await onChangeHandler.call(manager); + await onChangeHandler.call(manager); + await onChangeHandler.call(manager); + await onChangeHandler.call(manager); + await Promise.all([onChangeHandler.call(manager), + onChangeHandler.call(manager), + onChangeHandler.call(manager), + onChangeHandler.call(manager)]); + await sleep(1); + + verify(languageServer.dispose()).once(); + + verify(analysisOptions.getAnalysisOptions()).twice(); + verify(serviceContainer.get(ILanaguageServer)).twice(); + verify(languageServer.start(resource, languageClientOptions)).twice(); + + await onChangeHandler.call(manager); + await onChangeHandler.call(manager); + await onChangeHandler.call(manager); + await onChangeHandler.call(manager); + await Promise.all([onChangeHandler.call(manager), + onChangeHandler.call(manager), + onChangeHandler.call(manager), + onChangeHandler.call(manager)]); + await sleep(1); + + verify(languageServer.dispose()).twice(); + + verify(analysisOptions.getAnalysisOptions()).thrice(); + verify(serviceContainer.get(ILanaguageServer)).thrice(); + verify(languageServer.start(resource, languageClientOptions)).thrice(); + }); + test('Must register command handler', async () => { + await startLanguageServer(); + manager.dispose(); + + commandRegistrationDisposable.verify(d => d.dispose(), typemoq.Times.once()); + }); + test('Must load extension when command is sent', async () => { + const args = { x: 1 }; + await startLanguageServer(); + + verify(languageServer.loadExtension(args)).never(); + + const cb = capture(commandManager.registerCommand).first()[1] as Function; + cb.call(manager, args); + + verify(languageServer.loadExtension(args)).once(); + commandRegistrationDisposable.verify(d => d.dispose(), typemoq.Times.never()); + }); + test('Must load extension when command was been sent before starting LS', async () => { + const args = { x: 1 }; + LanguageServerManagerTest.initializeExtensionArgs(args); + + await startLanguageServer(); + + verify(languageServer.loadExtension(args)).once(); + }); + }); +}); diff --git a/src/test/activation/platformData.unit.test.ts b/src/test/activation/platformData.unit.test.ts index a0d3bb9e3b08..0acf76e9b7cb 100644 --- a/src/test/activation/platformData.unit.test.ts +++ b/src/test/activation/platformData.unit.test.ts @@ -4,8 +4,8 @@ // tslint:disable:no-unused-variable import * as assert from 'assert'; import * as TypeMoq from 'typemoq'; -import { LanguageServerPlatformData, PlatformLSExecutables } from '../../client/activation/platformData'; -import { IFileSystem, IPlatformService } from '../../client/common/platform/types'; +import { PlatformData, PlatformLSExecutables } from '../../client/activation/platformData'; +import { IPlatformService } from '../../client/common/platform/types'; const testDataWinMac = [ { isWindows: true, is64Bit: true, expectedName: 'win-x64' }, @@ -38,14 +38,10 @@ suite('Activation - platform data', () => { platformService.setup(x => x.isMac).returns(() => !t.isWindows); platformService.setup(x => x.is64bit).returns(() => t.is64Bit); - const fs = TypeMoq.Mock.ofType(); - const pd = new LanguageServerPlatformData(platformService.object); + const pd = new PlatformData(platformService.object); - const actual = pd.getPlatformName(); + const actual = pd.platformName; assert.equal(actual, t.expectedName, `${actual} does not match ${t.expectedName}`); - - const actualHash = await pd.getExpectedHash(); - assert.equal(actualHash, t.expectedName, `${actual} hash not match ${t.expectedName}`); } }); test('Name and hash (Linux)', async () => { @@ -56,15 +52,10 @@ suite('Activation - platform data', () => { platformService.setup(x => x.isLinux).returns(() => true); platformService.setup(x => x.is64bit).returns(() => true); - const fs = TypeMoq.Mock.ofType(); - fs.setup(x => x.readFile(TypeMoq.It.isAnyString())).returns(() => Promise.resolve(`NAME="name"\nID=${t.name}\nID_LIKE=debian`)); - const pd = new LanguageServerPlatformData(platformService.object); + const pd = new PlatformData(platformService.object); - const actual = pd.getPlatformName(); + const actual = pd.platformName; assert.equal(actual, t.expectedName, `${actual} does not match ${t.expectedName}`); - - const actualHash = await pd.getExpectedHash(); - assert.equal(actual, t.expectedName, `${actual} hash not match ${t.expectedName}`); } }); test('Module name', async () => { @@ -74,10 +65,9 @@ suite('Activation - platform data', () => { platformService.setup(x => x.isLinux).returns(() => t.isLinux); platformService.setup(x => x.isMac).returns(() => t.isMac); - const fs = TypeMoq.Mock.ofType(); - const pd = new LanguageServerPlatformData(platformService.object); + const pd = new PlatformData(platformService.object); - const actual = pd.getEngineExecutableName(); + const actual = pd.engineExecutableName; assert.equal(actual, t.expectedName, `${actual} does not match ${t.expectedName}`); } }); diff --git a/src/test/datascience/dataScienceIocContainer.ts b/src/test/datascience/dataScienceIocContainer.ts index e6745d300a82..648660fbecfe 100644 --- a/src/test/datascience/dataScienceIocContainer.ts +++ b/src/test/datascience/dataScienceIocContainer.ts @@ -7,12 +7,13 @@ import * as path from 'path'; import * as TypeMoq from 'typemoq'; import { Disposable, Event, EventEmitter, FileSystemWatcher, Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; +import { TerminalManager } from '../../client/common/application/terminalManager'; import { IApplicationShell, ICommandManager, IDocumentManager, - IWorkspaceService, ITerminalManager, + IWorkspaceService, } from '../../client/common/application/types'; import { AsyncDisposableRegistry } from '../../client/common/asyncDisposableRegistry'; import { PythonSettings } from '../../client/common/configSettings'; @@ -146,7 +147,6 @@ import { MockAutoSelectionService } from '../mocks/autoSelector'; import { UnitTestIocContainer } from '../unittests/serviceRegistry'; import { MockCommandManager } from './mockCommandManager'; import { MockJupyterManager } from './mockJupyterManager'; -import { TerminalManager } from '../../client/common/application/terminalManager'; export class DataScienceIocContainer extends UnitTestIocContainer { diff --git a/src/test/interpreters/autoSelection/index.unit.test.ts b/src/test/interpreters/autoSelection/index.unit.test.ts index dbfe0eec6b52..146a0c2c0700 100644 --- a/src/test/interpreters/autoSelection/index.unit.test.ts +++ b/src/test/interpreters/autoSelection/index.unit.test.ts @@ -87,9 +87,13 @@ suite('Interpreters - Auto Selection', () => { verify(systemInterpreter.setNextRule(anything())).never(); }); test('Run rules in background', async () => { + let eventFired = false; + autoSelectionService.onDidChangeAutoSelectedInterpreter(() => eventFired = true); autoSelectionService.initializeStore = () => Promise.resolve(); await autoSelectionService.autoSelectInterpreter(undefined); + expect(eventFired).to.deep.equal(true, 'event not fired'); + const allRules = [userDefinedInterpreter, winRegInterpreter, currentPathInterpreter, systemInterpreter, workspaceInterpreter, cachedPaths]; for (const service of allRules) { verify(service.autoSelectInterpreter(undefined)).once(); @@ -100,16 +104,24 @@ suite('Interpreters - Auto Selection', () => { verify(userDefinedInterpreter.autoSelectInterpreter(anything(), autoSelectionService)).once(); }); test('Run userDefineInterpreter as the first rule', async () => { + let eventFired = false; + autoSelectionService.onDidChangeAutoSelectedInterpreter(() => eventFired = true); autoSelectionService.initializeStore = () => Promise.resolve(); + await autoSelectionService.autoSelectInterpreter(undefined); + expect(eventFired).to.deep.equal(true, 'event not fired'); verify(userDefinedInterpreter.autoSelectInterpreter(undefined, autoSelectionService)).once(); }); test('Initialize the store', async () => { let initialize = false; + let eventFired = false; + autoSelectionService.onDidChangeAutoSelectedInterpreter(() => eventFired = true); autoSelectionService.initializeStore = async () => initialize = true as any; + await autoSelectionService.autoSelectInterpreter(undefined); + expect(eventFired).to.deep.equal(true, 'event not fired'); expect(initialize).to.be.equal(true, 'Not invoked'); }); test('Initializing the store would be executed once', async () => { @@ -162,7 +174,7 @@ suite('Interpreters - Auto Selection', () => { verify(state.updateValue(interpreterInfo)).once(); expect(selectedInterpreter).to.deep.equal(interpreterInfo); - expect(eventFired).to.deep.equal(true, 'event not fired'); + expect(eventFired).to.deep.equal(false, 'event fired'); }); test('Do not store global interpreter info in state store when resource is undefined and version is lower than one already in state', async () => { let eventFired = false; @@ -198,7 +210,7 @@ suite('Interpreters - Auto Selection', () => { verify(state.updateValue(anything())).once(); expect(selectedInterpreter).to.deep.equal(interpreterInfo); - expect(eventFired).to.deep.equal(true, 'event fired'); + expect(eventFired).to.deep.equal(false, 'event fired'); }); test('Store global interpreter info in state store', async () => { const pythonPath = 'Hello World'; @@ -227,7 +239,7 @@ suite('Interpreters - Auto Selection', () => { verify(state.updateValue(interpreterInfo)).never(); expect(selectedInterpreter).to.deep.equal(interpreterInfo); - expect(eventFired).to.deep.equal(true, 'event not fired'); + expect(eventFired).to.deep.equal(false, 'event fired'); }); test('Store workspace interpreter info in state store', async () => { const pythonPath = 'Hello World'; diff --git a/src/test/telemetry/index.unit.test.ts b/src/test/telemetry/index.unit.test.ts new file mode 100644 index 000000000000..327597403b2d --- /dev/null +++ b/src/test/telemetry/index.unit.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +//tslint:disable:max-func-body-length match-default-export-name + +import { expect } from 'chai'; +import rewiremock from 'rewiremock'; +import { EXTENSION_ROOT_DIR } from '../../client/constants'; +import { sendTelemetryEvent } from '../../client/telemetry'; + +suite('Telemetry', () => { + const oldValueOfVSC_PYTHON_UNIT_TEST = process.env.VSC_PYTHON_UNIT_TEST; + const oldValueOfVSC_PYTHON_CI_TEST = process.env.VSC_PYTHON_CI_TEST; + setup(() => { + process.env.VSC_PYTHON_UNIT_TEST = undefined; + process.env.VSC_PYTHON_CI_TEST = undefined; + }); + teardown(() => { + process.env.VSC_PYTHON_UNIT_TEST = oldValueOfVSC_PYTHON_UNIT_TEST; + process.env.VSC_PYTHON_CI_TEST = oldValueOfVSC_PYTHON_CI_TEST; + rewiremock.disable(); + }); + + class Reporter { + public static eventName: string; + public static properties: { [key: string]: string }; + public static measures: {}; + public sendTelemetryEvent(eventName: string, properties?: {}, measures?: {}) { + Reporter.eventName = eventName; + Reporter.properties = properties!; + Reporter.measures = measures!; + } + } + test('Send Telemetry', () => { + rewiremock.enable(); + rewiremock('vscode-extension-telemetry').with({ default: Reporter }); + + const eventName = 'Testing'; + const properties = { hello: 'world', foo: 'bar' }; + const measuers = { start: 123, end: 987 }; + + // tslint:disable-next-line:no-any + sendTelemetryEvent(eventName, measuers, properties as any); + + expect(Reporter.eventName).to.equal(eventName); + expect(Reporter.measures).to.deep.equal(measuers); + expect(Reporter.properties).to.deep.equal(properties); + }); + test('Send Telemetry', () => { + rewiremock.enable(); + rewiremock('vscode-extension-telemetry').with({ default: Reporter }); + + const eventName = 'Testing'; + + sendTelemetryEvent(eventName); + + expect(Reporter.eventName).to.equal(eventName); + expect(Reporter.measures).to.equal(undefined, 'Measures should be empty'); + expect(Reporter.properties).to.equal(undefined, 'Properties should be empty'); + }); + test('Send Error Telemetry', () => { + rewiremock.enable(); + const error = new Error('Boo'); + rewiremock('vscode-extension-telemetry').with({ default: Reporter }); + + const eventName = 'Testing'; + const properties = { hello: 'world', foo: 'bar' }; + const measuers = { start: 123, end: 987 }; + + // tslint:disable-next-line:no-any + sendTelemetryEvent(eventName, measuers, properties as any, error); + + const stackTrace = Reporter.properties.stackTrace; + delete Reporter.properties.stackTrace; + + expect(Reporter.eventName).to.equal(eventName); + expect(Reporter.measures).to.deep.equal(measuers); + expect(Reporter.properties).to.deep.equal(properties); + expect(stackTrace).to.be.length.greaterThan(1); + }); + test('Send Error Telemetry', () => { + rewiremock.enable(); + const error = new Error('Boo'); + error.stack = `Error: Boo +at Context.test (${EXTENSION_ROOT_DIR}/src/test/telemetry/index.unit.test.ts:50:23) +at callFn (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runnable.js:372:21) +at Test.Runnable.run (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runnable.js:364:7) +at Runner.runTest (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runner.js:455:10) +at ${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runner.js:573:12 +at next (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runner.js:369:14) +at ${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runner.js:379:7 +at next (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runner.js:303:14) +at ${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runner.js:342:7 +at done (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runnable.js:319:5) +at callFn (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runnable.js:395:7) +at Hook.Runnable.run (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runnable.js:364:7) +at next (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runner.js:317:10) +at Immediate. (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runner.js:347:5) +at runCallback (timers.js:789:20) +at tryOnImmediate (timers.js:751:5) +at processImmediate [as _immediateCallback] (timers.js:722:5)`; + rewiremock('vscode-extension-telemetry').with({ default: Reporter }); + + const eventName = 'Testing'; + const properties = { hello: 'world', foo: 'bar' }; + const measuers = { start: 123, end: 987 }; + + // tslint:disable-next-line:no-any + sendTelemetryEvent(eventName, measuers, properties as any, error); + + const stackTrace = Reporter.properties.stackTrace; + delete Reporter.properties.stackTrace; + + expect(Reporter.eventName).to.equal(eventName); + expect(Reporter.measures).to.deep.equal(measuers); + expect(Reporter.properties).to.deep.equal(properties); + expect(stackTrace).to.be.length.greaterThan(1); + + // tslint:disable-next-line:no-multiline-string + const expectedStack = `at Context.test /src/test/telemetry/index.unit.test.ts:50:23 +\tat callFn /node_modules/mocha/lib/runnable.js:372:21 +\tat Test.Runnable.run /node_modules/mocha/lib/runnable.js:364:7 +\tat Runner.runTest /node_modules/mocha/lib/runner.js:455:10 +\tat /node_modules/mocha/lib/runner.js:573:12 +\tat next /node_modules/mocha/lib/runner.js:369:14 +\tat /node_modules/mocha/lib/runner.js:379:7 +\tat next /node_modules/mocha/lib/runner.js:303:14 +\tat /node_modules/mocha/lib/runner.js:342:7 +\tat done /node_modules/mocha/lib/runnable.js:319:5 +\tat callFn /node_modules/mocha/lib/runnable.js:395:7 +\tat Hook.Runnable.run /node_modules/mocha/lib/runnable.js:364:7 +\tat next /node_modules/mocha/lib/runner.js:317:10 +\tat Immediate /node_modules/mocha/lib/runner.js:347:5 +\tat runCallback /timers.js:789:20 +\tat tryOnImmediate /timers.js:751:5 +\tat processImmediate [as _immediateCallback] /timers.js:722:5`; + + expect(stackTrace).to.be.equal(expectedStack); + }); + test('Ensure non extension file paths are stripped from stack trace', () => { + rewiremock.enable(); + const error = new Error('Boo'); + error.stack = `Error: Boo +at Context.test (${EXTENSION_ROOT_DIR}/src/test/telemetry/index.unit.test.ts:50:23) +at callFn (c:/one/two/user/node_modules/mocha/lib/runnable.js:372:21) +at Test.Runnable.run (/usr/Paul/Homer/desktop/node_modules/mocha/lib/runnable.js:364:7) +at Runner.runTest (\\wow\wee/node_modules/mocha/lib/runner.js:455:10) +at Immediate. (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runner.js:347:5)`; + rewiremock('vscode-extension-telemetry').with({ default: Reporter }); + + const eventName = 'Testing'; + const properties = { hello: 'world', foo: 'bar' }; + const measuers = { start: 123, end: 987 }; + + // tslint:disable-next-line:no-any + sendTelemetryEvent(eventName, measuers, properties as any, error); + + const stackTrace = Reporter.properties.stackTrace; + delete Reporter.properties.stackTrace; + + expect(Reporter.eventName).to.equal(eventName); + expect(Reporter.measures).to.deep.equal(measuers); + expect(Reporter.properties).to.deep.equal(properties); + expect(stackTrace).to.be.length.greaterThan(1); + + // tslint:disable-next-line:no-multiline-string + const expectedStack = `at Context.test /src/test/telemetry/index.unit.test.ts:50:23 +\tat callFn /runnable.js:372:21 +\tat Test.Runnable.run /runnable.js:364:7 +\tat Runner.runTest /runner.js:455:10 +\tat Immediate /node_modules/mocha/lib/runner.js:347:5`; + + expect(stackTrace).to.be.equal(expectedStack); + }); + test('Ensure non function names containing file names (unlikely, but for sake of completeness) are stripped from stack trace', () => { + rewiremock.enable(); + const error = new Error('Boo'); + error.stack = `Error: Boo +at Context.test (${EXTENSION_ROOT_DIR}/src/test/telemetry/index.unit.test.ts:50:23) +at callFn (c:/one/two/user/node_modules/mocha/lib/runnable.js:372:21) +at Test./usr/Paul/Homer/desktop/node_modules/mocha/lib/runnable.run (/usr/Paul/Homer/desktop/node_modules/mocha/lib/runnable.js:364:7) +at Runner.runTest (\\wow\wee/node_modules/mocha/lib/runner.js:455:10) +at Immediate. (${EXTENSION_ROOT_DIR}/node_modules/mocha/lib/runner.js:347:5)`; + rewiremock('vscode-extension-telemetry').with({ default: Reporter }); + + const eventName = 'Testing'; + const properties = { hello: 'world', foo: 'bar' }; + const measuers = { start: 123, end: 987 }; + + // tslint:disable-next-line:no-any + sendTelemetryEvent(eventName, measuers, properties as any, error); + + const stackTrace = Reporter.properties.stackTrace; + delete Reporter.properties.stackTrace; + + expect(Reporter.eventName).to.equal(eventName); + expect(Reporter.measures).to.deep.equal(measuers); + expect(Reporter.properties).to.deep.equal(properties); + expect(stackTrace).to.be.length.greaterThan(1); + + // tslint:disable-next-line:no-multiline-string + const expectedStack = `at Context.test /src/test/telemetry/index.unit.test.ts:50:23 +\tat callFn /runnable.js:372:21 +\tat .run /runnable.js:364:7 +\tat Runner.runTest /runner.js:455:10 +\tat Immediate /node_modules/mocha/lib/runner.js:347:5`; + + expect(stackTrace).to.be.equal(expectedStack); + }); +});