diff --git a/.gitignore b/.gitignore index f3befcb98957..ebcf58dd5285 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,4 @@ ptvsd*.log pydevd*.log nodeLanguageServer/** nodeLanguageServer.*/** - +bundledLanguageServer/** diff --git a/.vscodeignore b/.vscodeignore index fb4a694b35ea..cb9182d58ca6 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -58,6 +58,8 @@ webpack.datascience-*.config.js .vscode test/** languageServer/** languageServer.*/** +nodeLanguageServer/** +nodeLanguageServer.*/** bin/** build/** BuildOutput/** diff --git a/src/client/activation/common/downloader.ts b/src/client/activation/common/downloader.ts index 6f304bea24f7..c724175ace04 100644 --- a/src/client/activation/common/downloader.ts +++ b/src/client/activation/common/downloader.ts @@ -14,6 +14,7 @@ import { createDeferred } from '../../common/utils/async'; import { Common, LanguageService } from '../../common/utils/localize'; import { StopWatch } from '../../common/utils/stopWatch'; import { IServiceContainer } from '../../ioc/types'; +import { traceError } from '../../logging'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { @@ -58,6 +59,12 @@ export class LanguageServerDownloader implements ILanguageServerDownloader { } public async downloadLanguageServer(destinationFolder: string, resource: Resource): Promise { + if (this.lsFolderService.isBundled()) { + // Sanity check; a bundled LS should never be downloaded. + traceError('Attempted to download bundled language server'); + return; + } + const [downloadUri, lsVersion, lsName] = await this.getDownloadInfo(resource); const timer: StopWatch = new StopWatch(); let success: boolean = true; diff --git a/src/client/activation/common/languageServerFolderService.ts b/src/client/activation/common/languageServerFolderService.ts index 3aa99f8e20e4..b71faa9945cb 100644 --- a/src/client/activation/common/languageServerFolderService.ts +++ b/src/client/activation/common/languageServerFolderService.ts @@ -26,6 +26,10 @@ export abstract class LanguageServerFolderService implements ILanguageServerFold @unmanaged() protected readonly languageServerFolder: string ) {} + public isBundled(): boolean { + return false; + } + @traceDecorators.verbose('Get language server folder name') public async getLanguageServerFolderName(resource: Resource): Promise { const currentFolder = await this.getCurrentLanguageServerDirectory(); diff --git a/src/client/activation/languageServer/languageServerFolderService.ts b/src/client/activation/languageServer/languageServerFolderService.ts index 03ce4e88d1b5..bf541ab4690f 100644 --- a/src/client/activation/languageServer/languageServerFolderService.ts +++ b/src/client/activation/languageServer/languageServerFolderService.ts @@ -10,7 +10,7 @@ import { LanguageServerFolderService } from '../common/languageServerFolderServi import { DotNetLanguageServerFolder } from '../types'; // Must match languageServerVersion* keys in package.json -const DotNetLanguageServerMinVersionKey = 'languageServerVersion'; +export const DotNetLanguageServerMinVersionKey = 'languageServerVersion'; @injectable() export class DotNetLanguageServerFolderService extends LanguageServerFolderService { diff --git a/src/client/activation/node/languageServerFolderService.ts b/src/client/activation/node/languageServerFolderService.ts index 8da206ac55c9..a2a15eb7908f 100644 --- a/src/client/activation/node/languageServerFolderService.ts +++ b/src/client/activation/node/languageServerFolderService.ts @@ -4,13 +4,25 @@ 'use strict'; import { inject, injectable } from 'inversify'; +import * as semver from 'semver'; +import { IApplicationEnvironment, IWorkspaceService } from '../../common/application/types'; +import { NugetPackage } from '../../common/nuget/types'; +import { IConfigurationService, Resource } from '../../common/types'; import { IServiceContainer } from '../../ioc/types'; +import { traceWarning } from '../../logging'; import { LanguageServerFolderService } from '../common/languageServerFolderService'; -import { NodeLanguageServerFolder } from '../types'; +import { + BundledLanguageServerFolder, + FolderVersionPair, + ILanguageServerFolderService, + NodeLanguageServerFolder +} from '../types'; -@injectable() -export class NodeLanguageServerFolderService extends LanguageServerFolderService { - constructor(@inject(IServiceContainer) serviceContainer: IServiceContainer) { +// Must match languageServerVersion* keys in package.json +export const NodeLanguageServerVersionKey = 'languageServerVersionV2'; + +class FallbackNodeLanguageServerFolderService extends LanguageServerFolderService { + constructor(serviceContainer: IServiceContainer) { super(serviceContainer, NodeLanguageServerFolder); } @@ -18,3 +30,63 @@ export class NodeLanguageServerFolderService extends LanguageServerFolderService return '0.0.0'; } } + +@injectable() +export class NodeLanguageServerFolderService implements ILanguageServerFolderService { + private readonly _bundledVersion: semver.SemVer | undefined; + private readonly fallback: FallbackNodeLanguageServerFolderService; + + constructor( + @inject(IServiceContainer) serviceContainer: IServiceContainer, + @inject(IConfigurationService) configService: IConfigurationService, + @inject(IWorkspaceService) workspaceService: IWorkspaceService, + @inject(IApplicationEnvironment) appEnv: IApplicationEnvironment + ) { + this.fallback = new FallbackNodeLanguageServerFolderService(serviceContainer); + + // downloadLanguageServer is a bit of a misnomer; if false then this indicates that a local + // development copy should be run instead of a "real" build, telemetry discarded, etc. + // So, we require it to be true, even though in the bundled case no real download happens. + if ( + configService.getSettings().downloadLanguageServer && + !workspaceService.getConfiguration('python').get('packageName') + ) { + const ver = appEnv.packageJson[NodeLanguageServerVersionKey] as string; + this._bundledVersion = semver.parse(ver) || undefined; + if (this._bundledVersion === undefined) { + traceWarning( + `invalid language server version ${ver} in package.json (${NodeLanguageServerVersionKey})` + ); + } + } + } + + public get bundledVersion(): semver.SemVer | undefined { + return this._bundledVersion; + } + + public isBundled(): boolean { + return this._bundledVersion !== undefined; + } + + public async getLanguageServerFolderName(resource: Resource): Promise { + if (this._bundledVersion) { + return BundledLanguageServerFolder; + } + return this.fallback.getLanguageServerFolderName(resource); + } + + public async getLatestLanguageServerVersion(resource: Resource): Promise { + if (this._bundledVersion) { + return undefined; + } + return this.fallback.getLatestLanguageServerVersion(resource); + } + + public async getCurrentLanguageServerDirectory(): Promise { + if (this._bundledVersion) { + return { path: BundledLanguageServerFolder, version: this._bundledVersion }; + } + return this.fallback.getCurrentLanguageServerDirectory(); + } +} diff --git a/src/client/activation/node/languageServerProxy.ts b/src/client/activation/node/languageServerProxy.ts index ef1ecbb08310..b5db195c625d 100644 --- a/src/client/activation/node/languageServerProxy.ts +++ b/src/client/activation/node/languageServerProxy.ts @@ -79,8 +79,8 @@ export class NodeLanguageServerProxy implements ILanguageServerProxy { options: LanguageClientOptions ): Promise { if (!this.languageClient) { - const lsVersion = await this.folderService.getLatestLanguageServerVersion(resource); - this.lsVersion = lsVersion?.version.format(); + const directory = await this.folderService.getCurrentLanguageServerDirectory(); + this.lsVersion = directory?.version.format(); this.cancellationStrategy = new FileBasedCancellationStrategy(); options.connectionOptions = { cancellationStrategy: this.cancellationStrategy }; diff --git a/src/client/activation/types.ts b/src/client/activation/types.ts index 6b53c01f60e0..7cd251a86c6d 100644 --- a/src/client/activation/types.ts +++ b/src/client/activation/types.ts @@ -72,6 +72,7 @@ export enum LanguageServerType { export const DotNetLanguageServerFolder = 'languageServer'; export const NodeLanguageServerFolder = 'nodeLanguageServer'; +export const BundledLanguageServerFolder = 'bundledLanguageServer'; // tslint:disable-next-line: interface-name export interface DocumentHandler { @@ -116,6 +117,7 @@ export interface ILanguageServerFolderService { getLanguageServerFolderName(resource: Resource): Promise; getLatestLanguageServerVersion(resource: Resource): Promise; getCurrentLanguageServerDirectory(): Promise; + isBundled(): boolean; } export const ILanguageServerDownloader = Symbol('ILanguageServerDownloader'); diff --git a/src/test/activation/languageServer/downloader.unit.test.ts b/src/test/activation/languageServer/downloader.unit.test.ts index 19f00f1dc689..ac2a4e8b4ab3 100644 --- a/src/test/activation/languageServer/downloader.unit.test.ts +++ b/src/test/activation/languageServer/downloader.unit.test.ts @@ -260,12 +260,29 @@ suite('Language Server Activation - Downloader', () => { throw failure; } } + class LanguageServeBundledTest extends LanguageServerDownloader { + // tslint:disable-next-line:no-unnecessary-override + public async downloadLanguageServer(destinationFolder: string, res?: Resource): Promise { + return super.downloadLanguageServer(destinationFolder, res); + } + // tslint:disable-next-line:no-unnecessary-override + public async getDownloadInfo(_res?: Resource): Promise { + throw failure; + } + public async downloadFile(): Promise { + throw failure; + } + protected async unpackArchive(_extensionPath: string, _tempFilePath: string): Promise { + throw failure; + } + } let output: TypeMoq.IMock; let appShell: TypeMoq.IMock; let fs: TypeMoq.IMock; let platformData: TypeMoq.IMock; let languageServerDownloaderTest: LanguageServerDownloaderTest; let languageServerExtractorTest: LanguageServerExtractorTest; + let languageServerBundledTest: LanguageServeBundledTest; setup(() => { appShell = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); folderService = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); @@ -293,8 +310,18 @@ suite('Language Server Activation - Downloader', () => { workspaceService.object, undefined as any ); + languageServerBundledTest = new LanguageServeBundledTest( + lsOutputChannel.object, + undefined as any, + folderService.object, + appShell.object, + fs.object, + workspaceService.object, + undefined as any + ); }); test('Display error message if LS downloading fails', async () => { + folderService.setup((f) => f.isBundled()).returns(() => false); const pkg = makePkgInfo('ls', 'xyz'); folderService.setup((f) => f.getLatestLanguageServerVersion(resource)).returns(() => Promise.resolve(pkg)); output.setup((o) => o.appendLine(LanguageService.downloadFailedOutputMessage())); @@ -318,6 +345,7 @@ suite('Language Server Activation - Downloader', () => { platformData.verifyAll(); }); test('Display error message if LS extraction fails', async () => { + folderService.setup((f) => f.isBundled()).returns(() => false); const pkg = makePkgInfo('ls', 'xyz'); folderService.setup((f) => f.getLatestLanguageServerVersion(resource)).returns(() => Promise.resolve(pkg)); output.setup((o) => o.appendLine(LanguageService.extractionFailedOutputMessage())); @@ -340,6 +368,17 @@ suite('Language Server Activation - Downloader', () => { fs.verifyAll(); platformData.verifyAll(); }); + test('No download if bundled', async () => { + folderService.setup((f) => f.isBundled()).returns(() => true); + + await languageServerBundledTest.downloadLanguageServer('', resource); + + folderService.verifyAll(); + output.verifyAll(); + appShell.verifyAll(); + fs.verifyAll(); + platformData.verifyAll(); + }); }); }); diff --git a/src/test/activation/languageServer/languageServerFolderService.unit.test.ts b/src/test/activation/languageServer/languageServerFolderService.unit.test.ts index a653bc4e58ec..8fc410d0f886 100644 --- a/src/test/activation/languageServer/languageServerFolderService.unit.test.ts +++ b/src/test/activation/languageServer/languageServerFolderService.unit.test.ts @@ -267,4 +267,15 @@ suite('Language Server Folder Service', () => { assert.deepEqual(result, expectedLSDirectory); }); }); + + suite('Method isBundled()', () => { + setup(() => { + serviceContainer = TypeMoq.Mock.ofType(); + languageServerFolderService = new DotNetLanguageServerFolderService(serviceContainer.object); + }); + + test('isBundled is false', () => { + expect(languageServerFolderService.isBundled()).to.be.equal(false, 'isBundled should be false'); + }); + }); }); diff --git a/src/test/activation/languageServer/languageServerPackageService.test.ts b/src/test/activation/languageServer/languageServerPackageService.test.ts index 9defd3ef9b03..e401d524e428 100644 --- a/src/test/activation/languageServer/languageServerPackageService.test.ts +++ b/src/test/activation/languageServer/languageServerPackageService.test.ts @@ -8,6 +8,7 @@ import { expect } from 'chai'; import * as typeMoq from 'typemoq'; import { WorkspaceConfiguration } from 'vscode'; +import { DotNetLanguageServerMinVersionKey } from '../../../client/activation/languageServer/languageServerFolderService'; import { DotNetLanguageServerPackageService } from '../../../client/activation/languageServer/languageServerPackageService'; import { IApplicationEnvironment, IWorkspaceService } from '../../../client/common/application/types'; import { AzureBlobStoreNugetRepository } from '../../../client/common/nuget/azureBlobStoreNugetRepository'; @@ -44,7 +45,7 @@ suite('Language Server Package Service', () => { ); serviceContainer.setup((c) => c.get(typeMoq.It.isValue(INugetRepository))).returns(() => nugetRepo); const appEnv = typeMoq.Mock.ofType(); - const packageJson = { languageServerVersion: '0.0.1' }; + const packageJson = { [DotNetLanguageServerMinVersionKey]: '0.0.1' }; appEnv.setup((e) => e.packageJson).returns(() => packageJson); const platform = typeMoq.Mock.ofType(); const lsPackageService = new DotNetLanguageServerPackageService( diff --git a/src/test/activation/languageServer/languageServerPackageService.unit.test.ts b/src/test/activation/languageServer/languageServerPackageService.unit.test.ts index ab3055be776c..358b48f63c41 100644 --- a/src/test/activation/languageServer/languageServerPackageService.unit.test.ts +++ b/src/test/activation/languageServer/languageServerPackageService.unit.test.ts @@ -12,6 +12,7 @@ import { azureCDNBlobStorageAccount, LanguageServerDownloadChannel } from '../../../client/activation/common/packageRepository'; +import { DotNetLanguageServerMinVersionKey } from '../../../client/activation/languageServer/languageServerFolderService'; import { DotNetLanguageServerPackageService } from '../../../client/activation/languageServer/languageServerPackageService'; import { PlatformName } from '../../../client/activation/types'; import { IApplicationEnvironment } from '../../../client/common/application/types'; @@ -41,7 +42,7 @@ suite('Language Server - Package Service', () => { lsPackageService.getLanguageServerDownloadChannel = () => 'stable'; }); function setMinVersionOfLs(version: string) { - const packageJson = { languageServerVersion: version }; + const packageJson = { [DotNetLanguageServerMinVersionKey]: version }; appVersion.setup((e) => e.packageJson).returns(() => packageJson); } [true, false].forEach((is64Bit) => { diff --git a/src/test/activation/node/languageServerFolderService.unit.test.ts b/src/test/activation/node/languageServerFolderService.unit.test.ts new file mode 100644 index 000000000000..0573fb681fd9 --- /dev/null +++ b/src/test/activation/node/languageServerFolderService.unit.test.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { assert, expect } from 'chai'; +import * as TypeMoq from 'typemoq'; +import { Uri, WorkspaceConfiguration } from 'vscode'; +import { + NodeLanguageServerFolderService, + NodeLanguageServerVersionKey +} from '../../../client/activation/node/languageServerFolderService'; +import { BundledLanguageServerFolder } from '../../../client/activation/types'; +import { IApplicationEnvironment, IWorkspaceService } from '../../../client/common/application/types'; +import { IConfigurationService, IPythonSettings } from '../../../client/common/types'; +import { IServiceContainer } from '../../../client/ioc/types'; + +// tslint:disable:max-func-body-length + +suite('Node Language Server Folder Service', () => { + const resource = Uri.parse('a'); + const version = '0.0.1-test'; + + let serviceContainer: TypeMoq.IMock; + let pythonSettings: TypeMoq.IMock; + let configService: TypeMoq.IMock; + let workspaceConfiguration: TypeMoq.IMock; + let workspaceService: TypeMoq.IMock; + let appEnvironment: TypeMoq.IMock; + + setup(() => { + serviceContainer = TypeMoq.Mock.ofType(); + configService = TypeMoq.Mock.ofType(); + pythonSettings = TypeMoq.Mock.ofType(); + configService.setup((c) => c.getSettings(undefined)).returns(() => pythonSettings.object); + workspaceConfiguration = TypeMoq.Mock.ofType(); + workspaceService = TypeMoq.Mock.ofType(); + workspaceService + .setup((ws) => ws.getConfiguration('python', TypeMoq.It.isAny())) + .returns(() => workspaceConfiguration.object); + appEnvironment = TypeMoq.Mock.ofType(); + }); + + test('With packageName set', () => { + pythonSettings.setup((p) => p.downloadLanguageServer).returns(() => true); + appEnvironment.setup((e) => e.packageJson).returns(() => ({ [NodeLanguageServerVersionKey]: version })); + workspaceConfiguration.setup((wc) => wc.get('packageName')).returns(() => 'somePackageName'); + + const folderService = new NodeLanguageServerFolderService( + serviceContainer.object, + configService.object, + workspaceService.object, + appEnvironment.object + ); + + expect(folderService.bundledVersion).to.be.equal(undefined, 'expected bundledVersion to be undefined'); + expect(folderService.isBundled()).to.be.equal(false, 'isBundled should be false'); + }); + + test('Invalid version', () => { + pythonSettings.setup((p) => p.downloadLanguageServer).returns(() => true); + appEnvironment.setup((e) => e.packageJson).returns(() => ({ [NodeLanguageServerVersionKey]: 'fakeversion' })); + workspaceConfiguration.setup((wc) => wc.get('packageName')).returns(() => undefined); + + const folderService = new NodeLanguageServerFolderService( + serviceContainer.object, + configService.object, + workspaceService.object, + appEnvironment.object + ); + + expect(folderService.bundledVersion).to.be.equal(undefined, 'expected bundledVersion to be undefined'); + expect(folderService.isBundled()).to.be.equal(false, 'isBundled should be false'); + }); + + test('downloadLanguageServer set to false', () => { + pythonSettings.setup((p) => p.downloadLanguageServer).returns(() => false); + appEnvironment.setup((e) => e.packageJson).returns(() => ({ [NodeLanguageServerVersionKey]: 'fakeversion' })); + workspaceConfiguration.setup((wc) => wc.get('packageName')).returns(() => undefined); + + const folderService = new NodeLanguageServerFolderService( + serviceContainer.object, + configService.object, + workspaceService.object, + appEnvironment.object + ); + + expect(folderService.bundledVersion).to.be.equal(undefined, 'expected bundledVersion to be undefined'); + expect(folderService.isBundled()).to.be.equal(false, 'isBundled should be false'); + }); + + suite('Valid configuration', () => { + let folderService: NodeLanguageServerFolderService; + + setup(() => { + pythonSettings.setup((p) => p.downloadLanguageServer).returns(() => true); + appEnvironment.setup((e) => e.packageJson).returns(() => ({ [NodeLanguageServerVersionKey]: version })); + workspaceConfiguration.setup((wc) => wc.get('packageName')).returns(() => undefined); + folderService = new NodeLanguageServerFolderService( + serviceContainer.object, + configService.object, + workspaceService.object, + appEnvironment.object + ); + }); + + test('isBundled is true', () => { + expect(folderService.isBundled()).to.be.equal(true, 'isBundled should be true'); + }); + + test('Parsed version is correct', () => { + expect(folderService.bundledVersion!.format()).to.be.equal(version); + }); + + test('getLanguageServerFolderName', async () => { + const folderName = await folderService.getLanguageServerFolderName(resource); + expect(folderName).to.be.equal(BundledLanguageServerFolder); + }); + + test('getLatestLanguageServerVersion', async () => { + const pkg = await folderService.getLatestLanguageServerVersion(resource); + expect(pkg).to.equal(undefined, 'expected latest version to be undefined'); + }); + + test('Method getCurrentLanguageServerDirectory()', async () => { + const dir = await folderService.getCurrentLanguageServerDirectory(); + assert(dir); + expect(dir!.path).to.equal(BundledLanguageServerFolder); + expect(dir!.version.format()).to.be.equal(version); + }); + }); +}); diff --git a/src/test/common/nuget/azureBobStoreRepository.functional.test.ts b/src/test/common/nuget/azureBobStoreRepository.functional.test.ts index 48a58c988e43..2afb1b94b680 100644 --- a/src/test/common/nuget/azureBobStoreRepository.functional.test.ts +++ b/src/test/common/nuget/azureBobStoreRepository.functional.test.ts @@ -7,6 +7,7 @@ import { expect } from 'chai'; import { SemVer } from 'semver'; import * as typeMoq from 'typemoq'; import { WorkspaceConfiguration } from 'vscode'; +import { DotNetLanguageServerMinVersionKey } from '../../../client/activation/languageServer/languageServerFolderService'; import { DotNetLanguageServerPackageService } from '../../../client/activation/languageServer/languageServerPackageService'; import { IApplicationEnvironment, IWorkspaceService } from '../../../client/common/application/types'; import { AzureBlobStoreNugetRepository } from '../../../client/common/nuget/azureBlobStoreNugetRepository'; @@ -53,7 +54,7 @@ suite('Nuget Azure Storage Repository', () => { // tslint:disable-next-line:no-invalid-this this.timeout(15000); const platformService = new PlatformService(); - const packageJson = { languageServerVersion: '0.0.1' }; + const packageJson = { [DotNetLanguageServerMinVersionKey]: '0.0.1' }; const appEnv = typeMoq.Mock.ofType(); appEnv.setup((e) => e.packageJson).returns(() => packageJson); const lsPackageService = new DotNetLanguageServerPackageService(