diff --git a/scripts/buildProtocol.ts b/scripts/buildProtocol.ts index c2ac33c83fc19..899ab700bf372 100644 --- a/scripts/buildProtocol.ts +++ b/scripts/buildProtocol.ts @@ -51,22 +51,25 @@ class DeclarationsWalker { return this.processType((type).typeArguments[0]); } else { - for (const decl of s.getDeclarations()) { - const sourceFile = decl.getSourceFile(); - if (sourceFile === this.protocolFile || path.basename(sourceFile.fileName) === "lib.d.ts") { - return; - } - if (decl.kind === ts.SyntaxKind.EnumDeclaration && !isStringEnum(decl as ts.EnumDeclaration)) { - this.removedTypes.push(type); - return; - } - else { - // splice declaration in final d.ts file - let text = decl.getFullText(); - this.text += `${text}\n`; - // recursively pull all dependencies into result dts file + const declarations = s.getDeclarations(); + if (declarations) { + for (const decl of declarations) { + const sourceFile = decl.getSourceFile(); + if (sourceFile === this.protocolFile || path.basename(sourceFile.fileName) === "lib.d.ts") { + return; + } + if (decl.kind === ts.SyntaxKind.EnumDeclaration && !isStringEnum(decl as ts.EnumDeclaration)) { + this.removedTypes.push(type); + return; + } + else { + // splice declaration in final d.ts file + let text = decl.getFullText(); + this.text += `${text}\n`; + // recursively pull all dependencies into result dts file - this.visitTypeNodes(decl); + this.visitTypeNodes(decl); + } } } } diff --git a/src/compiler/core.ts b/src/compiler/core.ts index 542435b84f901..bc28965b7c5da 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -1861,7 +1861,7 @@ namespace ts { return i < 0 ? path : path.substring(i + 1); } - export function combinePaths(path1: string, path2: string) { + export function combinePaths(path1: string, path2: string): string { if (!(path1 && path1.length)) return path2; if (!(path2 && path2.length)) return path1; if (getRootLength(path2) !== 0) return path2; diff --git a/src/compiler/moduleNameResolver.ts b/src/compiler/moduleNameResolver.ts index a0e045cf0f259..b0633ef4d5a26 100644 --- a/src/compiler/moduleNameResolver.ts +++ b/src/compiler/moduleNameResolver.ts @@ -1005,7 +1005,8 @@ namespace ts { return withPackageId(packageId, pathAndExtension); } - function getPackageName(moduleName: string): { packageName: string, rest: string } { + /* @internal */ + export function getPackageName(moduleName: string): { packageName: string, rest: string } { let idx = moduleName.indexOf(directorySeparator); if (moduleName[0] === "@") { idx = moduleName.indexOf(directorySeparator, idx + 1); @@ -1063,18 +1064,27 @@ namespace ts { const mangledScopedPackageSeparator = "__"; /** For a scoped package, we must look in `@types/foo__bar` instead of `@types/@foo/bar`. */ - function mangleScopedPackage(moduleName: string, state: ModuleResolutionState): string { - if (startsWith(moduleName, "@")) { - const replaceSlash = moduleName.replace(ts.directorySeparator, mangledScopedPackageSeparator); - if (replaceSlash !== moduleName) { - const mangled = replaceSlash.slice(1); // Take off the "@" - if (state.traceEnabled) { - trace(state.host, Diagnostics.Scoped_package_detected_looking_in_0, mangled); - } - return mangled; + function mangleScopedPackage(packageName: string, state: ModuleResolutionState): string { + const mangled = getMangledNameForScopedPackage(packageName); + if (state.traceEnabled && mangled !== packageName) { + trace(state.host, Diagnostics.Scoped_package_detected_looking_in_0, mangled); + } + return mangled; + } + + /* @internal */ + export function getTypesPackageName(packageName: string): string { + return `@types/${getMangledNameForScopedPackage(packageName)}`; + } + + function getMangledNameForScopedPackage(packageName: string): string { + if (startsWith(packageName, "@")) { + const replaceSlash = packageName.replace(ts.directorySeparator, mangledScopedPackageSeparator); + if (replaceSlash !== packageName) { + return replaceSlash.slice(1); // Take off the "@" } } - return moduleName; + return packageName; } /* @internal */ diff --git a/src/harness/fourslash.ts b/src/harness/fourslash.ts index 493b0187b57ad..9ac2aac764529 100644 --- a/src/harness/fourslash.ts +++ b/src/harness/fourslash.ts @@ -953,6 +953,10 @@ namespace FourSlash { return this.getChecker().getSymbolsInScope(node, ts.SymbolFlags.Value | ts.SymbolFlags.Type | ts.SymbolFlags.Namespace); } + public setTypesRegistry(map: ts.MapLike): void { + this.languageServiceAdapterHost.typesRegistry = ts.createMapFromTemplate(map); + } + public verifyTypeOfSymbolAtLocation(range: Range, symbol: ts.Symbol, expected: string): void { const node = this.goToAndGetNode(range); const checker = this.getChecker(); @@ -2777,16 +2781,26 @@ Actual: ${stringify(fullActual)}`); } } - public verifyCodeFixAvailable(negative: boolean) { - const codeFix = this.getCodeFixActions(this.activeFile.fileName); + public verifyCodeFixAvailable(negative: boolean, info: FourSlashInterface.VerifyCodeFixAvailableOptions[] | undefined) { + const codeFixes = this.getCodeFixActions(this.activeFile.fileName); - if (negative && codeFix.length) { - this.raiseError(`verifyCodeFixAvailable failed - expected no fixes but found one.`); + if (negative) { + if (codeFixes.length) { + this.raiseError(`verifyCodeFixAvailable failed - expected no fixes but found one.`); + } + return; } - if (!(negative || codeFix.length)) { + if (!codeFixes.length) { this.raiseError(`verifyCodeFixAvailable failed - expected code fixes but none found.`); } + if (info) { + assert.equal(info.length, codeFixes.length); + ts.zipWith(codeFixes, info, (fix, info) => { + assert.equal(fix.description, info.description); + this.assertObjectsEqual(fix.commands, info.commands); + }); + } } public verifyApplicableRefactorAvailableAtMarker(negative: boolean, markerName: string) { @@ -2830,6 +2844,14 @@ Actual: ${stringify(fullActual)}`); } } + public verifyRefactor({ name, actionName, refactors }: FourSlashInterface.VerifyRefactorOptions) { + const selection = this.getSelection(); + + const actualRefactors = (this.languageService.getApplicableRefactors(this.activeFile.fileName, selection) || ts.emptyArray) + .filter(r => r.name === name && r.actions.some(a => a.name === actionName)); + this.assertObjectsEqual(actualRefactors, refactors); + } + public verifyApplicableRefactorAvailableForRange(negative: boolean) { const ranges = this.getRanges(); if (!(ranges && ranges.length === 1)) { @@ -3614,6 +3636,10 @@ namespace FourSlashInterface { public symbolsInScope(range: FourSlash.Range): ts.Symbol[] { return this.state.symbolsInScope(range); } + + public setTypesRegistry(map: ts.MapLike): void { + this.state.setTypesRegistry(map); + } } export class GoTo { @@ -3789,8 +3815,8 @@ namespace FourSlashInterface { this.state.verifyCodeFix(options); } - public codeFixAvailable() { - this.state.verifyCodeFixAvailable(this.negative); + public codeFixAvailable(options?: VerifyCodeFixAvailableOptions[]) { + this.state.verifyCodeFixAvailable(this.negative, options); } public applicableRefactorAvailableAtMarker(markerName: string) { @@ -3801,6 +3827,10 @@ namespace FourSlashInterface { this.state.verifyApplicableRefactorAvailableForRange(this.negative); } + public refactor(options: VerifyRefactorOptions) { + this.state.verifyRefactor(options); + } + public refactorAvailable(name: string, actionName?: string) { this.state.verifyRefactorAvailable(this.negative, name, actionName); } @@ -4449,6 +4479,17 @@ namespace FourSlashInterface { index?: number; } + export interface VerifyCodeFixAvailableOptions { + description: string; + commands?: ts.CodeActionCommand[]; + } + + export interface VerifyRefactorOptions { + name: string; + actionName: string; + refactors: ts.ApplicableRefactorInfo[]; + } + export interface VerifyCompletionActionOptions extends NewContentOptions { name: string; description: string; diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index 527824ee145aa..a12554addfe6f 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -123,6 +123,7 @@ namespace Harness.LanguageService { } export class LanguageServiceAdapterHost { + public typesRegistry: ts.Map | undefined; protected virtualFileSystem: Utils.VirtualFileSystem = new Utils.VirtualFileSystem(virtualFileSystemRoot, /*useCaseSensitiveFilenames*/false); constructor(protected cancellationToken = DefaultHostCancellationToken.Instance, @@ -182,6 +183,11 @@ namespace Harness.LanguageService { /// Native adapter class NativeLanguageServiceHost extends LanguageServiceAdapterHost implements ts.LanguageServiceHost { + isKnownTypesPackageName(name: string): boolean { + return this.typesRegistry && this.typesRegistry.has(name); + } + installPackage = ts.notImplemented; + getCompilationSettings() { return this.settings; } getCancellationToken() { return this.cancellationToken; } getDirectories(path: string): string[] { @@ -493,6 +499,7 @@ namespace Harness.LanguageService { getCodeFixesAtPosition(): ts.CodeAction[] { throw new Error("Not supported on the shim."); } + applyCodeActionCommand = ts.notImplemented; getCodeFixDiagnostics(): ts.Diagnostic[] { throw new Error("Not supported on the shim."); } diff --git a/src/harness/unittests/compileOnSave.ts b/src/harness/unittests/compileOnSave.ts index fdec5b192ee83..7be6ab5b323ec 100644 --- a/src/harness/unittests/compileOnSave.ts +++ b/src/harness/unittests/compileOnSave.ts @@ -12,7 +12,7 @@ namespace ts.projectSystem { describe("CompileOnSave affected list", () => { function sendAffectedFileRequestAndCheckResult(session: server.Session, request: server.protocol.Request, expectedFileList: { projectFileName: string, files: FileOrFolder[] }[]) { - const response: server.protocol.CompileOnSaveAffectedFileListSingleProject[] = session.executeCommand(request).response; + const response = session.executeCommand(request).response as server.protocol.CompileOnSaveAffectedFileListSingleProject[]; const actualResult = response.sort((list1, list2) => compareStrings(list1.projectFileName, list2.projectFileName)); expectedFileList = expectedFileList.sort((list1, list2) => compareStrings(list1.projectFileName, list2.projectFileName)); diff --git a/src/harness/unittests/extractTestHelpers.ts b/src/harness/unittests/extractTestHelpers.ts index 1b51cdadc2785..49c2c1d327704 100644 --- a/src/harness/unittests/extractTestHelpers.ts +++ b/src/harness/unittests/extractTestHelpers.ts @@ -97,6 +97,14 @@ namespace ts { return rulesProvider; } + const notImplementedHost: LanguageServiceHost = { + getCompilationSettings: notImplemented, + getScriptFileNames: notImplemented, + getScriptVersion: notImplemented, + getScriptSnapshot: notImplemented, + getDefaultLibFileName: notImplemented, + }; + export function testExtractSymbol(caption: string, text: string, baselineFolder: string, description: DiagnosticMessage) { const t = extractTest(text); const selectionRange = t.ranges.get("selection"); @@ -125,6 +133,7 @@ namespace ts { file: sourceFile, startPosition: selectionRange.start, endPosition: selectionRange.end, + host: notImplementedHost, rulesProvider: getRuleProvider() }; const rangeToExtract = refactor.extractSymbol.getRangeToExtract(sourceFile, createTextSpanFromBounds(selectionRange.start, selectionRange.end)); @@ -188,6 +197,7 @@ namespace ts { file: sourceFile, startPosition: selectionRange.start, endPosition: selectionRange.end, + host: notImplementedHost, rulesProvider: getRuleProvider() }; const rangeToExtract = refactor.extractSymbol.getRangeToExtract(sourceFile, createTextSpanFromBounds(selectionRange.start, selectionRange.end)); diff --git a/src/harness/unittests/projectErrors.ts b/src/harness/unittests/projectErrors.ts index d72168383c170..dae465a3ef333 100644 --- a/src/harness/unittests/projectErrors.ts +++ b/src/harness/unittests/projectErrors.ts @@ -57,7 +57,7 @@ namespace ts.projectSystem { }); checkNumberOfProjects(projectService, { externalProjects: 1 }); - const diags = session.executeCommand(compilerOptionsRequest).response; + const diags = session.executeCommand(compilerOptionsRequest).response as server.protocol.DiagnosticWithLinePosition[]; // only file1 exists - expect error checkDiagnosticsWithLinePos(diags, ["File '/a/b/applib.ts' not found."]); } @@ -65,7 +65,7 @@ namespace ts.projectSystem { { // only file2 exists - expect error checkNumberOfProjects(projectService, { externalProjects: 1 }); - const diags = session.executeCommand(compilerOptionsRequest).response; + const diags = session.executeCommand(compilerOptionsRequest).response as server.protocol.DiagnosticWithLinePosition[]; checkDiagnosticsWithLinePos(diags, ["File '/a/b/app.ts' not found."]); } @@ -73,7 +73,7 @@ namespace ts.projectSystem { { // both files exist - expect no errors checkNumberOfProjects(projectService, { externalProjects: 1 }); - const diags = session.executeCommand(compilerOptionsRequest).response; + const diags = session.executeCommand(compilerOptionsRequest).response as server.protocol.DiagnosticWithLinePosition[]; checkDiagnosticsWithLinePos(diags, []); } }); @@ -103,13 +103,13 @@ namespace ts.projectSystem { seq: 2, arguments: { projectFileName: project.getProjectName() } }; - let diags = session.executeCommand(compilerOptionsRequest).response; + let diags = session.executeCommand(compilerOptionsRequest).response as server.protocol.DiagnosticWithLinePosition[]; checkDiagnosticsWithLinePos(diags, ["File '/a/b/applib.ts' not found."]); host.reloadFS([file1, file2, config, libFile]); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - diags = session.executeCommand(compilerOptionsRequest).response; + diags = session.executeCommand(compilerOptionsRequest).response as server.protocol.DiagnosticWithLinePosition[]; checkDiagnosticsWithLinePos(diags, []); }); diff --git a/src/harness/unittests/session.ts b/src/harness/unittests/session.ts index 3b5efc2d6defb..fbef9cb455841 100644 --- a/src/harness/unittests/session.ts +++ b/src/harness/unittests/session.ts @@ -315,7 +315,7 @@ namespace ts.server { item: false }; const command = "newhandle"; - const result = { + const result: ts.server.HandlerResponse = { response: respBody, responseRequired: true }; @@ -332,7 +332,7 @@ namespace ts.server { const respBody = { item: false }; - const resp = { + const resp: ts.server.HandlerResponse = { response: respBody, responseRequired: true }; @@ -372,7 +372,7 @@ namespace ts.server { }; const command = "test"; - session.output(body, command); + session.output(body, command, /*reqSeq*/ 0); expect(lastSent).to.deep.equal({ seq: 0, @@ -475,7 +475,7 @@ namespace ts.server { }; const command = "test"; - session.output(body, command); + session.output(body, command, /*reqSeq*/ 0); expect(session.lastSent).to.deep.equal({ seq: 0, @@ -542,7 +542,7 @@ namespace ts.server { handleRequest(msg: protocol.Request) { let response: protocol.Response; try { - ({ response } = this.executeCommand(msg)); + response = this.executeCommand(msg).response as protocol.Response; } catch (e) { this.output(undefined, msg.command, msg.seq, e.toString()); diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 4929cbfbaa517..0d18c7ba586f0 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -70,6 +70,9 @@ namespace ts.projectSystem { protected postExecActions: PostExecAction[] = []; + isKnownTypesPackageName = notImplemented; + installPackage = notImplemented; + executePendingCommands() { const actionsToRun = this.postExecActions; this.postExecActions = []; @@ -761,7 +764,7 @@ namespace ts.projectSystem { ); // Two errors: CommonFile2 not found and cannot find name y - let diags: server.protocol.Diagnostic[] = session.executeCommand(getErrRequest).response; + let diags = session.executeCommand(getErrRequest).response as server.protocol.Diagnostic[]; verifyDiagnostics(diags, [ { diagnosticMessage: Diagnostics.Cannot_find_name_0, errorTextArguments: ["y"] }, { diagnosticMessage: Diagnostics.File_0_not_found, errorTextArguments: [commonFile2.path] } @@ -773,7 +776,7 @@ namespace ts.projectSystem { assert.strictEqual(projectService.inferredProjects[0], project, "Inferred project should be same"); checkProjectRootFiles(project, [file1.path]); checkProjectActualFiles(project, [file1.path, libFile.path, commonFile2.path]); - diags = session.executeCommand(getErrRequest).response; + diags = session.executeCommand(getErrRequest).response as server.protocol.Diagnostic[]; verifyNoDiagnostics(diags); }); @@ -2603,11 +2606,11 @@ namespace ts.projectSystem { // Try to find some interface type defined in lib.d.ts const libTypeNavToRequest = makeSessionRequest(CommandNames.Navto, { searchValue: "Document", file: file1.path, projectFileName: configFile.path }); - const items: protocol.NavtoItem[] = session.executeCommand(libTypeNavToRequest).response; + const items = session.executeCommand(libTypeNavToRequest).response as protocol.NavtoItem[]; assert.isFalse(containsNavToItem(items, "Document", "interface"), `Found lib.d.ts symbol in JavaScript project nav to request result.`); const localFunctionNavToRequst = makeSessionRequest(CommandNames.Navto, { searchValue: "foo", file: file1.path, projectFileName: configFile.path }); - const items2: protocol.NavtoItem[] = session.executeCommand(localFunctionNavToRequst).response; + const items2 = session.executeCommand(localFunctionNavToRequst).response as protocol.NavtoItem[]; assert.isTrue(containsNavToItem(items2, "foo", "function"), `Cannot find function symbol "foo".`); }); }); @@ -3062,7 +3065,7 @@ namespace ts.projectSystem { server.CommandNames.SemanticDiagnosticsSync, { file: file1.path } ); - let diags: server.protocol.Diagnostic[] = session.executeCommand(getErrRequest).response; + let diags = session.executeCommand(getErrRequest).response as server.protocol.Diagnostic[]; verifyNoDiagnostics(diags); const moduleFileOldPath = moduleFile.path; @@ -3070,7 +3073,7 @@ namespace ts.projectSystem { moduleFile.path = moduleFileNewPath; host.reloadFS([moduleFile, file1]); host.runQueuedTimeoutCallbacks(); - diags = session.executeCommand(getErrRequest).response; + diags = session.executeCommand(getErrRequest).response as server.protocol.Diagnostic[]; verifyDiagnostics(diags, [ { diagnosticMessage: Diagnostics.Cannot_find_module_0, errorTextArguments: ["./moduleFile"] } ]); @@ -3088,7 +3091,7 @@ namespace ts.projectSystem { session.executeCommand(changeRequest); host.runQueuedTimeoutCallbacks(); - diags = session.executeCommand(getErrRequest).response; + diags = session.executeCommand(getErrRequest).response as server.protocol.Diagnostic[]; verifyNoDiagnostics(diags); }); @@ -3113,7 +3116,7 @@ namespace ts.projectSystem { server.CommandNames.SemanticDiagnosticsSync, { file: file1.path } ); - let diags: server.protocol.Diagnostic[] = session.executeCommand(getErrRequest).response; + let diags = session.executeCommand(getErrRequest).response as server.protocol.Diagnostic[]; verifyNoDiagnostics(diags); const moduleFileOldPath = moduleFile.path; @@ -3121,7 +3124,7 @@ namespace ts.projectSystem { moduleFile.path = moduleFileNewPath; host.reloadFS([moduleFile, file1, configFile]); host.runQueuedTimeoutCallbacks(); - diags = session.executeCommand(getErrRequest).response; + diags = session.executeCommand(getErrRequest).response as server.protocol.Diagnostic[]; verifyDiagnostics(diags, [ { diagnosticMessage: Diagnostics.Cannot_find_module_0, errorTextArguments: ["./moduleFile"] } ]); @@ -3129,7 +3132,7 @@ namespace ts.projectSystem { moduleFile.path = moduleFileOldPath; host.reloadFS([moduleFile, file1, configFile]); host.runQueuedTimeoutCallbacks(); - diags = session.executeCommand(getErrRequest).response; + diags = session.executeCommand(getErrRequest).response as server.protocol.Diagnostic[]; verifyNoDiagnostics(diags); }); @@ -3196,7 +3199,7 @@ namespace ts.projectSystem { server.CommandNames.SemanticDiagnosticsSync, { file: file1.path } ); - let diags: server.protocol.Diagnostic[] = session.executeCommand(getErrRequest).response; + let diags = session.executeCommand(getErrRequest).response as server.protocol.Diagnostic[]; verifyDiagnostics(diags, [ { diagnosticMessage: Diagnostics.Cannot_find_module_0, errorTextArguments: ["./moduleFile"] } ]); @@ -3212,7 +3215,7 @@ namespace ts.projectSystem { session.executeCommand(changeRequest); // Recheck - diags = session.executeCommand(getErrRequest).response; + diags = session.executeCommand(getErrRequest).response as server.protocol.Diagnostic[]; verifyNoDiagnostics(diags); }); }); @@ -3912,7 +3915,7 @@ namespace ts.projectSystem { command: server.CommandNames.CompilerOptionsDiagnosticsFull, seq: 2, arguments: { projectFileName: projectName } - }).response; + }).response as ReadonlyArray; assert.isTrue(diags.length === 0); session.executeCommand({ @@ -3926,7 +3929,7 @@ namespace ts.projectSystem { command: server.CommandNames.CompilerOptionsDiagnosticsFull, seq: 4, arguments: { projectFileName: projectName } - }).response; + }).response as ReadonlyArray; assert.isTrue(diagsAfterUpdate.length === 0); }); @@ -3953,7 +3956,7 @@ namespace ts.projectSystem { command: server.CommandNames.CompilerOptionsDiagnosticsFull, seq: 2, arguments: { projectFileName } - }).response; + }).response as ReadonlyArray; assert.isTrue(diags.length === 0); session.executeCommand({ @@ -3971,7 +3974,7 @@ namespace ts.projectSystem { command: server.CommandNames.CompilerOptionsDiagnosticsFull, seq: 4, arguments: { projectFileName } - }).response; + }).response as ReadonlyArray; assert.isTrue(diagsAfterUpdate.length === 0); }); }); @@ -4452,7 +4455,7 @@ namespace ts.projectSystem { command: server.CommandNames.SemanticDiagnosticsSync, seq: 2, arguments: { file: configFile.path, projectFileName: projectName, includeLinePosition: true } - }).response; + }).response as ReadonlyArray; assert.isTrue(diags.length === 2); configFile.content = configFileContentWithoutCommentLine; @@ -4463,7 +4466,7 @@ namespace ts.projectSystem { command: server.CommandNames.SemanticDiagnosticsSync, seq: 2, arguments: { file: configFile.path, projectFileName: projectName, includeLinePosition: true } - }).response; + }).response as ReadonlyArray; assert.isTrue(diagsAfterEdit.length === 2); verifyDiagnostic(diags[0], diagsAfterEdit[0]); @@ -4855,7 +4858,7 @@ namespace ts.projectSystem { line: undefined, offset: undefined }); - const { response } = session.executeCommand(getDefinitionRequest); + const response = session.executeCommand(getDefinitionRequest).response as server.protocol.FileSpan[]; assert.equal(response[0].file, moduleFile.path, "Should go to definition of vessel: response: " + JSON.stringify(response)); callsTrackingHost.verifyNoHostCalls(); diff --git a/src/harness/unittests/typingsInstaller.ts b/src/harness/unittests/typingsInstaller.ts index e644f8730107b..fb1a7a26a7ae0 100644 --- a/src/harness/unittests/typingsInstaller.ts +++ b/src/harness/unittests/typingsInstaller.ts @@ -4,6 +4,8 @@ namespace ts.projectSystem { import TI = server.typingsInstaller; + import validatePackageName = JsTyping.validatePackageName; + import PackageNameValidationResult = JsTyping.PackageNameValidationResult; interface InstallerParams { globalTypingsCacheLocation?: string; @@ -266,7 +268,7 @@ namespace ts.projectSystem { }; const host = createServerHost([file1]); let enqueueIsCalled = false; - const installer = new (class extends Installer { + const installer: Installer = new (class extends Installer { constructor() { super(host, { typesRegistry: createTypesRegistry("jquery") }); } @@ -983,21 +985,21 @@ namespace ts.projectSystem { for (let i = 0; i < 8; i++) { packageName += packageName; } - assert.equal(TI.validatePackageName(packageName), TI.PackageNameValidationResult.NameTooLong); + assert.equal(validatePackageName(packageName), PackageNameValidationResult.NameTooLong); }); it("name cannot start with dot", () => { - assert.equal(TI.validatePackageName(".foo"), TI.PackageNameValidationResult.NameStartsWithDot); + assert.equal(validatePackageName(".foo"), PackageNameValidationResult.NameStartsWithDot); }); it("name cannot start with underscore", () => { - assert.equal(TI.validatePackageName("_foo"), TI.PackageNameValidationResult.NameStartsWithUnderscore); + assert.equal(validatePackageName("_foo"), PackageNameValidationResult.NameStartsWithUnderscore); }); it("scoped packages not supported", () => { - assert.equal(TI.validatePackageName("@scope/bar"), TI.PackageNameValidationResult.ScopedPackagesNotSupported); + assert.equal(validatePackageName("@scope/bar"), PackageNameValidationResult.ScopedPackagesNotSupported); }); it("non URI safe characters are not supported", () => { - assert.equal(TI.validatePackageName(" scope "), TI.PackageNameValidationResult.NameContainsNonURISafeCharacters); - assert.equal(TI.validatePackageName("; say ‘Hello from TypeScript!’ #"), TI.PackageNameValidationResult.NameContainsNonURISafeCharacters); - assert.equal(TI.validatePackageName("a/b/c"), TI.PackageNameValidationResult.NameContainsNonURISafeCharacters); + assert.equal(validatePackageName(" scope "), PackageNameValidationResult.NameContainsNonURISafeCharacters); + assert.equal(validatePackageName("; say ‘Hello from TypeScript!’ #"), PackageNameValidationResult.NameContainsNonURISafeCharacters); + assert.equal(validatePackageName("a/b/c"), PackageNameValidationResult.NameContainsNonURISafeCharacters); }); }); @@ -1250,7 +1252,7 @@ namespace ts.projectSystem { const host = createServerHost([f1, packageFile]); let beginEvent: server.BeginInstallTypes; let endEvent: server.EndInstallTypes; - const installer = new (class extends Installer { + const installer: Installer = new (class extends Installer { constructor() { super(host, { globalTypingsCacheLocation: cachePath, typesRegistry: createTypesRegistry("commander") }); } diff --git a/src/server/client.ts b/src/server/client.ts index 39e30848e3e52..2781d7cf20ae5 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -14,7 +14,7 @@ namespace ts.server { } /* @internal */ - export function extractMessage(message: string) { + export function extractMessage(message: string): string { // Read the content length const contentLengthPrefix = "Content-Length: "; const lines = message.split(/\r?\n/); @@ -542,6 +542,8 @@ namespace ts.server { return response.body.map(entry => this.convertCodeActions(entry, file)); } + applyCodeActionCommand = notImplemented; + private createFileLocationOrRangeRequestArgs(positionOrRange: number | TextRange, fileName: string): protocol.FileLocationOrRangeRequestArgs { return typeof positionOrRange === "number" ? this.createFileLocationRequestArgs(fileName, positionOrRange) diff --git a/src/server/project.ts b/src/server/project.ts index 9c3fab63d2306..725ce725c3881 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -242,6 +242,16 @@ namespace ts.server { this.markAsDirty(); } + isKnownTypesPackageName(name: string): boolean { + return this.typingsCache.isKnownTypesPackageName(name); + } + installPackage(options: InstallPackageOptions): PromiseLike { + return this.typingsCache.installPackage({ ...options, projectRootPath: this.toPath(this.currentDirectory) }); + } + private get typingsCache(): TypingsCache { + return this.projectService.typingsCache; + } + // Method of LanguageServiceHost getCompilationSettings() { return this.compilerOptions; diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 7b9e9fe80969a..35e9f20d05825 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -94,6 +94,7 @@ namespace ts.server.protocol { BreakpointStatement = "breakpointStatement", CompilerOptionsForInferredProjects = "compilerOptionsForInferredProjects", GetCodeFixes = "getCodeFixes", + ApplyCodeActionCommand = "applyCodeActionCommand", /* @internal */ GetCodeFixesFull = "getCodeFixes-full", GetSupportedCodeFixes = "getSupportedCodeFixes", @@ -125,6 +126,8 @@ namespace ts.server.protocol { * Client-initiated request message */ export interface Request extends Message { + type: "request"; + /** * The command to execute */ @@ -147,6 +150,8 @@ namespace ts.server.protocol { * Server-initiated event message */ export interface Event extends Message { + type: "event"; + /** * Name of event */ @@ -162,6 +167,8 @@ namespace ts.server.protocol { * Response by server to client request message. */ export interface Response extends Message { + type: "response"; + /** * Sequence number of the request message. */ @@ -178,7 +185,8 @@ namespace ts.server.protocol { command: string; /** - * Contains error message if success === false. + * If success === false, this should always be provided. + * Otherwise, may (or may not) contain a success message. */ message?: string; @@ -520,6 +528,14 @@ namespace ts.server.protocol { arguments: CodeFixRequestArgs; } + export interface ApplyCodeActionCommandRequest extends Request { + command: CommandTypes.ApplyCodeActionCommand; + arguments: ApplyCodeActionCommandRequestArgs; + } + + // All we need is the `success` and `message` fields of Response. + export interface ApplyCodeActionCommandResponse extends Response {} + export interface FileRangeRequestArgs extends FileRequestArgs { /** * The line number for the request (1-based). @@ -564,6 +580,10 @@ namespace ts.server.protocol { errorCodes?: number[]; } + export interface ApplyCodeActionCommandRequestArgs extends FileRequestArgs { + command: {}; + } + /** * Response for GetCodeFixes request. */ @@ -1541,6 +1561,8 @@ namespace ts.server.protocol { description: string; /** Text changes to apply to each file as part of the code action */ changes: FileCodeEdits[]; + /** A command is an opaque object that should be passed to `ApplyCodeActionCommandRequestArgs` without modification. */ + commands?: {}[]; } /** diff --git a/src/server/server.ts b/src/server/server.ts index 17905ece33ade..decf614cf8ea7 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -250,6 +250,9 @@ namespace ts.server { private activeRequestCount = 0; private requestQueue: QueuedOperation[] = []; private requestMap = createMap(); // Maps operation ID to newest requestQueue entry with that ID + /** We will lazily request the types registry on the first call to `isKnownTypesPackageName` and store it in `typesRegistryCache`. */ + private requestedRegistry: boolean; + private typesRegistryCache: Map | undefined; // This number is essentially arbitrary. Processing more than one typings request // at a time makes sense, but having too many in the pipe results in a hang @@ -258,7 +261,7 @@ namespace ts.server { // buffer, but we have yet to find a way to retrieve that value. private static readonly maxActiveRequestCount = 10; private static readonly requestDelayMillis = 100; - + private packageInstalledPromise: { resolve(value: ApplyCodeActionCommandResult): void, reject(reason: any): void }; constructor( private readonly telemetryEnabled: boolean, @@ -278,6 +281,31 @@ namespace ts.server { } } + isKnownTypesPackageName(name: string): boolean { + // We want to avoid looking this up in the registry as that is expensive. So first check that it's actually an NPM package. + const validationResult = JsTyping.validatePackageName(name); + if (validationResult !== JsTyping.PackageNameValidationResult.Ok) { + return false; + } + + if (this.requestedRegistry) { + return !!this.typesRegistryCache && this.typesRegistryCache.has(name); + } + + this.requestedRegistry = true; + this.send({ kind: "typesRegistry" }); + return false; + } + + installPackage(options: InstallPackageOptionsWithProjectRootPath): PromiseLike { + const rq: InstallPackageRequest = { kind: "installPackage", ...options }; + this.send(rq); + Debug.assert(this.packageInstalledPromise === undefined); + return new Promise((resolve, reject) => { + this.packageInstalledPromise = { resolve, reject }; + }); + } + private reportInstallerProcessId() { if (this.installerPidReported) { return; @@ -343,7 +371,11 @@ namespace ts.server { } onProjectClosed(p: Project): void { - this.installer.send({ projectName: p.getProjectName(), kind: "closeProject" }); + this.send({ projectName: p.getProjectName(), kind: "closeProject" }); + } + + private send(rq: TypingInstallerRequestUnion): void { + this.installer.send(rq); } enqueueInstallTypingsRequest(project: Project, typeAcquisition: TypeAcquisition, unresolvedImports: SortedReadonlyArray): void { @@ -359,7 +391,7 @@ namespace ts.server { if (this.logger.hasLevel(LogLevel.verbose)) { this.logger.info(`Sending request:${stringifyIndented(request)}`); } - this.installer.send(request); + this.send(request); }; const queuedRequest: QueuedOperation = { operationId, operation }; @@ -375,12 +407,26 @@ namespace ts.server { } } - private handleMessage(response: SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes | InitializationFailedResponse) { + private handleMessage(response: TypesRegistryResponse | PackageInstalledResponse | SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes | InitializationFailedResponse) { if (this.logger.hasLevel(LogLevel.verbose)) { this.logger.info(`Received response:${stringifyIndented(response)}`); } switch (response.kind) { + case EventTypesRegistry: + this.typesRegistryCache = ts.createMapFromTemplate(response.typesRegistry); + break; + case EventPackageInstalled: { + const { success, message } = response; + if (success) { + this.packageInstalledPromise.resolve({ successMessage: message }); + } + else { + this.packageInstalledPromise.reject(message); + } + this.packageInstalledPromise = undefined; + break; + } case EventInitializationFailed: { if (!this.eventSender) { diff --git a/src/server/session.ts b/src/server/session.ts index 017784530d5bf..7725a699ee273 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -411,19 +411,27 @@ namespace ts.server { this.send(ev); } - public output(info: any, cmdName: string, reqSeq = 0, errorMsg?: string) { + // For backwards-compatibility only. + public output(info: any, cmdName: string, reqSeq?: number, errorMsg?: string): void { + this.doOutput(info, cmdName, reqSeq, /*success*/ !errorMsg, errorMsg); + } + + private doOutput(info: {} | undefined, cmdName: string, reqSeq: number, success: boolean, message?: string): void { const res: protocol.Response = { seq: 0, type: "response", command: cmdName, request_seq: reqSeq, - success: !errorMsg, + success, }; - if (!errorMsg) { + if (success) { res.body = info; } else { - res.message = errorMsg; + Debug.assert(info === undefined); + } + if (message) { + res.message = message; } this.send(res); } @@ -1307,7 +1315,7 @@ namespace ts.server { this.changeSeq++; // make sure no changes happen before this one is finished if (project.reloadScript(file, tempFileName)) { - this.output(undefined, CommandNames.Reload, reqSeq); + this.doOutput(/*info*/ undefined, CommandNames.Reload, reqSeq, /*success*/ true); } } @@ -1545,6 +1553,15 @@ namespace ts.server { } } + private applyCodeActionCommand(commandName: string, requestSeq: number, args: protocol.ApplyCodeActionCommandRequestArgs): void { + const { file, project } = this.getFileAndProject(args); + const output = (success: boolean, message: string) => this.doOutput({}, commandName, requestSeq, success, message); + const command = args.command as CodeActionCommand; // They should be sending back the command we sent them. + project.getLanguageService().applyCodeActionCommand(file, command).then( + ({ successMessage }) => { output(/*success*/ true, successMessage); }, + error => { output(/*success*/ false, error); }); + } + private getStartAndEndPosition(args: protocol.FileRangeRequestArgs, scriptInfo: ScriptInfo) { let startPosition: number = undefined, endPosition: number = undefined; if (args.startPosition !== undefined) { @@ -1567,14 +1584,12 @@ namespace ts.server { return { startPosition, endPosition }; } - private mapCodeAction(codeAction: CodeAction, scriptInfo: ScriptInfo): protocol.CodeAction { - return { - description: codeAction.description, - changes: codeAction.changes.map(change => ({ - fileName: change.fileName, - textChanges: change.textChanges.map(textChange => this.convertTextChangeToCodeEdit(textChange, scriptInfo)) - })) - }; + private mapCodeAction({ description, changes: unmappedChanges, commands }: CodeAction, scriptInfo: ScriptInfo): protocol.CodeAction { + const changes = unmappedChanges.map(change => ({ + fileName: change.fileName, + textChanges: change.textChanges.map(textChange => this.convertTextChangeToCodeEdit(textChange, scriptInfo)) + })); + return { description, changes, commands }; } private mapTextChangesToCodeEdits(project: Project, textChanges: FileTextChanges): protocol.FileCodeEdits { @@ -1660,15 +1675,15 @@ namespace ts.server { exit() { } - private notRequired() { + private notRequired(): HandlerResponse { return { responseRequired: false }; } - private requiredResponse(response: any) { + private requiredResponse(response: {}): HandlerResponse { return { response, responseRequired: true }; } - private handlers = createMapFromTemplate<(request: protocol.Request) => { response?: any, responseRequired?: boolean }>({ + private handlers = createMapFromTemplate<(request: protocol.Request) => HandlerResponse>({ [CommandNames.OpenExternalProject]: (request: protocol.OpenExternalProjectRequest) => { this.projectService.openExternalProject(request.arguments, /*suppressRefreshOfInferredProjects*/ false); // TODO: report errors @@ -1846,7 +1861,7 @@ namespace ts.server { }, [CommandNames.Configure]: (request: protocol.ConfigureRequest) => { this.projectService.setHostConfiguration(request.arguments); - this.output(undefined, CommandNames.Configure, request.seq); + this.doOutput(/*info*/ undefined, CommandNames.Configure, request.seq, /*success*/ true); return this.notRequired(); }, [CommandNames.Reload]: (request: protocol.ReloadRequest) => { @@ -1913,6 +1928,10 @@ namespace ts.server { [CommandNames.GetCodeFixesFull]: (request: protocol.CodeFixRequest) => { return this.requiredResponse(this.getCodeFixes(request.arguments, /*simplifiedResult*/ false)); }, + [CommandNames.ApplyCodeActionCommand]: (request: protocol.ApplyCodeActionCommandRequest) => { + this.applyCodeActionCommand(request.command, request.seq, request.arguments); + return this.notRequired(); // Response will come asynchronously. + }, [CommandNames.GetSupportedCodeFixes]: () => { return this.requiredResponse(this.getSupportedCodeFixes()); }, @@ -1927,7 +1946,7 @@ namespace ts.server { } }); - public addProtocolHandler(command: string, handler: (request: protocol.Request) => { response?: any, responseRequired: boolean }) { + public addProtocolHandler(command: string, handler: (request: protocol.Request) => HandlerResponse) { if (this.handlers.has(command)) { throw new Error(`Protocol handler already exists for command "${command}"`); } @@ -1956,14 +1975,14 @@ namespace ts.server { } } - public executeCommand(request: protocol.Request): { response?: any, responseRequired?: boolean } { + public executeCommand(request: protocol.Request): HandlerResponse { const handler = this.handlers.get(request.command); if (handler) { return this.executeWithRequestId(request.seq, () => handler(request)); } else { this.logger.msg(`Unrecognized JSON command:${stringifyIndented(request)}`, Msg.Err); - this.output(undefined, CommandNames.Unknown, request.seq, `Unrecognized JSON command: ${request.command}`); + this.doOutput(/*info*/ undefined, CommandNames.Unknown, request.seq, /*success*/ false, `Unrecognized JSON command: ${request.command}`); return { responseRequired: false }; } } @@ -1994,25 +2013,31 @@ namespace ts.server { } if (response) { - this.output(response, request.command, request.seq); + this.doOutput(response, request.command, request.seq, /*success*/ true); } else if (responseRequired) { - this.output(undefined, request.command, request.seq, "No content available."); + this.doOutput(/*info*/ undefined, request.command, request.seq, /*success*/ false, "No content available."); } } catch (err) { if (err instanceof OperationCanceledException) { // Handle cancellation exceptions - this.output({ canceled: true }, request.command, request.seq); + this.doOutput({ canceled: true }, request.command, request.seq, /*success*/ true); return; } this.logError(err, message); - this.output( - undefined, + this.doOutput( + /*info*/ undefined, request ? request.command : CommandNames.Unknown, request ? request.seq : 0, + /*success*/ false, "Error processing request. " + (err).message + "\n" + (err).stack); } } } + + export interface HandlerResponse { + response?: {}; + responseRequired?: boolean; + } } diff --git a/src/server/shared.ts b/src/server/shared.ts index 66f739a5b974a..a8a122c3327ee 100644 --- a/src/server/shared.ts +++ b/src/server/shared.ts @@ -3,6 +3,8 @@ namespace ts.server { export const ActionSet: ActionSet = "action::set"; export const ActionInvalidate: ActionInvalidate = "action::invalidate"; + export const EventTypesRegistry: EventTypesRegistry = "event::typesRegistry"; + export const EventPackageInstalled: EventPackageInstalled = "event::packageInstalled"; export const EventBeginInstallTypes: EventBeginInstallTypes = "event::beginInstallTypes"; export const EventEndInstallTypes: EventEndInstallTypes = "event::endInstallTypes"; export const EventInitializationFailed: EventInitializationFailed = "event::initializationFailed"; diff --git a/src/server/types.ts b/src/server/types.ts index 4fc4356a4a97f..af5e121278eff 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -28,12 +28,14 @@ declare namespace ts.server { " __sortedArrayBrand": any; } - export interface TypingInstallerRequest { + export interface TypingInstallerRequestWithProjectName { readonly projectName: string; - readonly kind: "discover" | "closeProject"; } - export interface DiscoverTypings extends TypingInstallerRequest { + /* @internal */ + export type TypingInstallerRequestUnion = DiscoverTypings | CloseProject | TypesRegistryRequest | InstallPackageRequest; + + export interface DiscoverTypings extends TypingInstallerRequestWithProjectName { readonly fileNames: string[]; readonly projectRootPath: Path; readonly compilerOptions: CompilerOptions; @@ -43,18 +45,46 @@ declare namespace ts.server { readonly kind: "discover"; } - export interface CloseProject extends TypingInstallerRequest { + export interface CloseProject extends TypingInstallerRequestWithProjectName { readonly kind: "closeProject"; } + export interface TypesRegistryRequest { + readonly kind: "typesRegistry"; + } + + export interface InstallPackageRequest { + readonly kind: "installPackage"; + readonly fileName: Path; + readonly packageName: string; + readonly projectRootPath: Path; + } + export type ActionSet = "action::set"; export type ActionInvalidate = "action::invalidate"; + export type EventTypesRegistry = "event::typesRegistry"; + export type EventPackageInstalled = "event::packageInstalled"; export type EventBeginInstallTypes = "event::beginInstallTypes"; export type EventEndInstallTypes = "event::endInstallTypes"; export type EventInitializationFailed = "event::initializationFailed"; export interface TypingInstallerResponse { - readonly kind: ActionSet | ActionInvalidate | EventBeginInstallTypes | EventEndInstallTypes | EventInitializationFailed; + readonly kind: ActionSet | ActionInvalidate | EventTypesRegistry | EventPackageInstalled | EventBeginInstallTypes | EventEndInstallTypes | EventInitializationFailed; + } + /* @internal */ + export type TypingInstallerResponseUnion = SetTypings | InvalidateCachedTypings | TypesRegistryResponse | PackageInstalledResponse | InstallTypes | InitializationFailedResponse; + + /* @internal */ + export interface TypesRegistryResponse extends TypingInstallerResponse { + readonly kind: EventTypesRegistry; + readonly typesRegistry: MapLike; + } + + /* @internal */ + export interface PackageInstalledResponse extends TypingInstallerResponse { + readonly kind: EventPackageInstalled; + readonly success: boolean; + readonly message: string; } export interface InitializationFailedResponse extends TypingInstallerResponse { diff --git a/src/server/typingsCache.ts b/src/server/typingsCache.ts index 207824616a980..ddcf85063fdda 100644 --- a/src/server/typingsCache.ts +++ b/src/server/typingsCache.ts @@ -1,7 +1,13 @@ /// namespace ts.server { + export interface InstallPackageOptionsWithProjectRootPath extends InstallPackageOptions { + projectRootPath: Path; + } + export interface ITypingsInstaller { + isKnownTypesPackageName(name: string): boolean; + installPackage(options: InstallPackageOptionsWithProjectRootPath): PromiseLike; enqueueInstallTypingsRequest(p: Project, typeAcquisition: TypeAcquisition, unresolvedImports: SortedReadonlyArray): void; attach(projectService: ProjectService): void; onProjectClosed(p: Project): void; @@ -9,6 +15,9 @@ namespace ts.server { } export const nullTypingsInstaller: ITypingsInstaller = { + isKnownTypesPackageName: returnFalse, + // Should never be called because we never provide a types registry. + installPackage: notImplemented, enqueueInstallTypingsRequest: noop, attach: noop, onProjectClosed: noop, @@ -77,6 +86,14 @@ namespace ts.server { constructor(private readonly installer: ITypingsInstaller) { } + isKnownTypesPackageName(name: string): boolean { + return this.installer.isKnownTypesPackageName(name); + } + + installPackage(options: InstallPackageOptionsWithProjectRootPath): PromiseLike { + return this.installer.installPackage(options); + } + getTypingsForProject(project: Project, unresolvedImports: SortedReadonlyArray, forceRefresh: boolean): SortedReadonlyArray { const typeAcquisition = project.getTypeAcquisition(); diff --git a/src/server/typingsInstaller/nodeTypingsInstaller.ts b/src/server/typingsInstaller/nodeTypingsInstaller.ts index d32dd2a4b5f76..2a1036010a733 100644 --- a/src/server/typingsInstaller/nodeTypingsInstaller.ts +++ b/src/server/typingsInstaller/nodeTypingsInstaller.ts @@ -53,7 +53,7 @@ namespace ts.server.typingsInstaller { } try { const content = JSON.parse(host.readFile(typesRegistryFilePath)); - return createMapFromTemplate(content.entries); + return createMapFromTemplate(content.entries); } catch (e) { if (log.isEnabled()) { @@ -79,7 +79,7 @@ namespace ts.server.typingsInstaller { private readonly npmPath: string; readonly typesRegistry: Map; - private delayedInitializationError: InitializationFailedResponse; + private delayedInitializationError: InitializationFailedResponse | undefined; constructor(globalTypingsCacheLocation: string, typingSafeListLocation: string, typesMapLocation: string, npmLocation: string | undefined, throttleLimit: number, log: Log) { super( @@ -127,7 +127,7 @@ namespace ts.server.typingsInstaller { } listen() { - process.on("message", (req: DiscoverTypings | CloseProject) => { + process.on("message", (req: TypingInstallerRequestUnion) => { if (this.delayedInitializationError) { // report initializationFailed error this.sendResponse(this.delayedInitializationError); @@ -139,11 +139,39 @@ namespace ts.server.typingsInstaller { break; case "closeProject": this.closeProject(req); + break; + case "typesRegistry": { + const typesRegistry: { [key: string]: void } = {}; + this.typesRegistry.forEach((value, key) => { + typesRegistry[key] = value; + }); + const response: TypesRegistryResponse = { kind: EventTypesRegistry, typesRegistry }; + this.sendResponse(response); + break; + } + case "installPackage": { + const { fileName, packageName, projectRootPath } = req; + const cwd = getDirectoryOfPackageJson(fileName, this.installTypingHost) || projectRootPath; + if (cwd) { + this.installWorker(-1, [packageName], cwd, success => { + const message = success ? `Package ${packageName} installed.` : `There was an error installing ${packageName}.`; + const response: PackageInstalledResponse = { kind: EventPackageInstalled, success, message }; + this.sendResponse(response); + }); + } + else { + const response: PackageInstalledResponse = { kind: EventPackageInstalled, success: false, message: "Could not determine a project root path." }; + this.sendResponse(response); + } + break; + } + default: + Debug.assertNever(req); } }); } - protected sendResponse(response: SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes | InitializationFailedResponse) { + protected sendResponse(response: TypingInstallerResponseUnion) { if (this.log.isEnabled()) { this.log.writeLine(`Sending response:\n ${JSON.stringify(response)}`); } @@ -153,11 +181,11 @@ namespace ts.server.typingsInstaller { } } - protected installWorker(requestId: number, args: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void { + protected installWorker(requestId: number, packageNames: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void { if (this.log.isEnabled()) { - this.log.writeLine(`#${requestId} with arguments'${JSON.stringify(args)}'.`); + this.log.writeLine(`#${requestId} with arguments'${JSON.stringify(packageNames)}'.`); } - const command = `${this.npmPath} install --ignore-scripts ${args.join(" ")} --save-dev --user-agent="typesInstaller/${version}"`; + const command = `${this.npmPath} install --ignore-scripts ${packageNames.join(" ")} --save-dev --user-agent="typesInstaller/${version}"`; const start = Date.now(); const hasError = this.execSyncAndLog(command, { cwd }); if (this.log.isEnabled()) { @@ -186,6 +214,14 @@ namespace ts.server.typingsInstaller { } } + function getDirectoryOfPackageJson(fileName: string, host: InstallTypingHost): string | undefined { + return forEachAncestorDirectory(getDirectoryPath(fileName), directory => { + if (host.fileExists(combinePaths(directory, "package.json"))) { + return directory; + } + }); + } + const logFilePath = findArgument(server.Arguments.LogFile); const globalTypingsCacheLocation = findArgument(server.Arguments.GlobalCacheLocation); const typingSafeListLocation = findArgument(server.Arguments.TypingSafeListLocation); diff --git a/src/server/typingsInstaller/typingsInstaller.ts b/src/server/typingsInstaller/typingsInstaller.ts index 3eae0755747b7..26e7781b4406e 100644 --- a/src/server/typingsInstaller/typingsInstaller.ts +++ b/src/server/typingsInstaller/typingsInstaller.ts @@ -32,50 +32,11 @@ namespace ts.server.typingsInstaller { } } - export enum PackageNameValidationResult { - Ok, - ScopedPackagesNotSupported, - EmptyName, - NameTooLong, - NameStartsWithDot, - NameStartsWithUnderscore, - NameContainsNonURISafeCharacters - } - - - export const MaxPackageNameLength = 214; - /** - * Validates package name using rules defined at https://docs.npmjs.com/files/package.json - */ - export function validatePackageName(packageName: string): PackageNameValidationResult { - if (!packageName) { - return PackageNameValidationResult.EmptyName; - } - if (packageName.length > MaxPackageNameLength) { - return PackageNameValidationResult.NameTooLong; - } - if (packageName.charCodeAt(0) === CharacterCodes.dot) { - return PackageNameValidationResult.NameStartsWithDot; - } - if (packageName.charCodeAt(0) === CharacterCodes._) { - return PackageNameValidationResult.NameStartsWithUnderscore; - } - // check if name is scope package like: starts with @ and has one '/' in the middle - // scoped packages are not currently supported - // TODO: when support will be added we'll need to split and check both scope and package name - if (/^@[^/]+\/[^/]+$/.test(packageName)) { - return PackageNameValidationResult.ScopedPackagesNotSupported; - } - if (encodeURIComponent(packageName) !== packageName) { - return PackageNameValidationResult.NameContainsNonURISafeCharacters; - } - return PackageNameValidationResult.Ok; - } export type RequestCompletedAction = (success: boolean) => void; interface PendingRequest { requestId: number; - args: string[]; + packageNames: string[]; cwd: string; onRequestCompleted: RequestCompletedAction; } @@ -255,8 +216,8 @@ namespace ts.server.typingsInstaller { if (this.missingTypingsSet.get(typing) || this.packageNameToTypingLocation.get(typing)) { continue; } - const validationResult = validatePackageName(typing); - if (validationResult === PackageNameValidationResult.Ok) { + const validationResult = JsTyping.validatePackageName(typing); + if (validationResult === JsTyping.PackageNameValidationResult.Ok) { if (this.typesRegistry.has(typing)) { result.push(typing); } @@ -270,26 +231,7 @@ namespace ts.server.typingsInstaller { // add typing name to missing set so we won't process it again this.missingTypingsSet.set(typing, true); if (this.log.isEnabled()) { - switch (validationResult) { - case PackageNameValidationResult.EmptyName: - this.log.writeLine(`Package name '${typing}' cannot be empty`); - break; - case PackageNameValidationResult.NameTooLong: - this.log.writeLine(`Package name '${typing}' should be less than ${MaxPackageNameLength} characters`); - break; - case PackageNameValidationResult.NameStartsWithDot: - this.log.writeLine(`Package name '${typing}' cannot start with '.'`); - break; - case PackageNameValidationResult.NameStartsWithUnderscore: - this.log.writeLine(`Package name '${typing}' cannot start with '_'`); - break; - case PackageNameValidationResult.ScopedPackagesNotSupported: - this.log.writeLine(`Package '${typing}' is scoped and currently is not supported`); - break; - case PackageNameValidationResult.NameContainsNonURISafeCharacters: - this.log.writeLine(`Package name '${typing}' contains non URI safe characters`); - break; - } + this.log.writeLine(JsTyping.renderPackageNameValidationFailure(validationResult, typing)); } } } @@ -430,8 +372,8 @@ namespace ts.server.typingsInstaller { }; } - private installTypingsAsync(requestId: number, args: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void { - this.pendingRunRequests.unshift({ requestId, args, cwd, onRequestCompleted }); + private installTypingsAsync(requestId: number, packageNames: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void { + this.pendingRunRequests.unshift({ requestId, packageNames, cwd, onRequestCompleted }); this.executeWithThrottling(); } @@ -439,7 +381,7 @@ namespace ts.server.typingsInstaller { while (this.inFlightRequestCount < this.throttleLimit && this.pendingRunRequests.length) { this.inFlightRequestCount++; const request = this.pendingRunRequests.pop(); - this.installWorker(request.requestId, request.args, request.cwd, ok => { + this.installWorker(request.requestId, request.packageNames, request.cwd, ok => { this.inFlightRequestCount--; request.onRequestCompleted(ok); this.executeWithThrottling(); @@ -447,7 +389,7 @@ namespace ts.server.typingsInstaller { } } - protected abstract installWorker(requestId: number, args: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void; + protected abstract installWorker(requestId: number, packageNames: string[], cwd: string, onRequestCompleted: RequestCompletedAction): void; protected abstract sendResponse(response: SetTypings | InvalidateCachedTypings | BeginInstallTypes | EndInstallTypes): void; } diff --git a/src/services/codefixes/fixCannotFindModule.ts b/src/services/codefixes/fixCannotFindModule.ts new file mode 100644 index 0000000000000..b7fde9d917ef9 --- /dev/null +++ b/src/services/codefixes/fixCannotFindModule.ts @@ -0,0 +1,35 @@ +/* @internal */ +namespace ts.codefix { + registerCodeFix({ + errorCodes: [ + Diagnostics.Cannot_find_module_0.code, + Diagnostics.Could_not_find_a_declaration_file_for_module_0_1_implicitly_has_an_any_type.code, + ], + getCodeActions: context => { + const { sourceFile, span: { start } } = context; + const token = getTokenAtPosition(sourceFile, start, /*includeJsDocComment*/ false); + if (!isStringLiteral(token)) { + throw Debug.fail(); // These errors should only happen on the module name. + } + + const action = tryGetCodeActionForInstallPackageTypes(context.host, token.text); + return action && [action]; + }, + }); + + export function tryGetCodeActionForInstallPackageTypes(host: LanguageServiceHost, moduleName: string): CodeAction | undefined { + const { packageName } = getPackageName(moduleName); + + if (!host.isKnownTypesPackageName(packageName)) { + // If !registry, registry not available yet, can't do anything. + return undefined; + } + + const typesPackageName = getTypesPackageName(packageName); + return { + description: `Install '${typesPackageName}'`, + changes: [], + commands: [{ type: "install package", packageName: typesPackageName }], + }; + } +} diff --git a/src/services/codefixes/fixes.ts b/src/services/codefixes/fixes.ts index 7ee0aaa679973..5b1ee8ec8572c 100644 --- a/src/services/codefixes/fixes.ts +++ b/src/services/codefixes/fixes.ts @@ -3,6 +3,7 @@ /// /// /// +/// /// /// /// diff --git a/src/services/jsTyping.ts b/src/services/jsTyping.ts index f1859a90b98fa..572858dd2fd0d 100644 --- a/src/services/jsTyping.ts +++ b/src/services/jsTyping.ts @@ -246,4 +246,65 @@ namespace ts.JsTyping { } } + + export const enum PackageNameValidationResult { + Ok, + ScopedPackagesNotSupported, + EmptyName, + NameTooLong, + NameStartsWithDot, + NameStartsWithUnderscore, + NameContainsNonURISafeCharacters + } + + const MaxPackageNameLength = 214; + + /** + * Validates package name using rules defined at https://docs.npmjs.com/files/package.json + */ + export function validatePackageName(packageName: string): PackageNameValidationResult { + if (!packageName) { + return PackageNameValidationResult.EmptyName; + } + if (packageName.length > MaxPackageNameLength) { + return PackageNameValidationResult.NameTooLong; + } + if (packageName.charCodeAt(0) === CharacterCodes.dot) { + return PackageNameValidationResult.NameStartsWithDot; + } + if (packageName.charCodeAt(0) === CharacterCodes._) { + return PackageNameValidationResult.NameStartsWithUnderscore; + } + // check if name is scope package like: starts with @ and has one '/' in the middle + // scoped packages are not currently supported + // TODO: when support will be added we'll need to split and check both scope and package name + if (/^@[^/]+\/[^/]+$/.test(packageName)) { + return PackageNameValidationResult.ScopedPackagesNotSupported; + } + if (encodeURIComponent(packageName) !== packageName) { + return PackageNameValidationResult.NameContainsNonURISafeCharacters; + } + return PackageNameValidationResult.Ok; + } + + export function renderPackageNameValidationFailure(result: PackageNameValidationResult, typing: string): string { + switch (result) { + case PackageNameValidationResult.EmptyName: + return `Package name '${typing}' cannot be empty`; + case PackageNameValidationResult.NameTooLong: + return `Package name '${typing}' should be less than ${MaxPackageNameLength} characters`; + case PackageNameValidationResult.NameStartsWithDot: + return `Package name '${typing}' cannot start with '.'`; + case PackageNameValidationResult.NameStartsWithUnderscore: + return `Package name '${typing}' cannot start with '_'`; + case PackageNameValidationResult.ScopedPackagesNotSupported: + return `Package '${typing}' is scoped and currently is not supported`; + case PackageNameValidationResult.NameContainsNonURISafeCharacters: + return `Package name '${typing}' contains non URI safe characters`; + case PackageNameValidationResult.Ok: + throw Debug.fail(); // Shouldn't have called this. + default: + Debug.assertNever(result); + } + } } diff --git a/src/services/refactorProvider.ts b/src/services/refactorProvider.ts index b338882e6db68..e0a1924793976 100644 --- a/src/services/refactorProvider.ts +++ b/src/services/refactorProvider.ts @@ -19,6 +19,7 @@ namespace ts { startPosition: number; endPosition?: number; program: Program; + host: LanguageServiceHost; cancellationToken?: CancellationToken; } diff --git a/src/services/refactors/installTypesForPackage.ts b/src/services/refactors/installTypesForPackage.ts new file mode 100644 index 0000000000000..ded4a1d47afaf --- /dev/null +++ b/src/services/refactors/installTypesForPackage.ts @@ -0,0 +1,63 @@ +/* @internal */ +namespace ts.refactor.installTypesForPackage { + const actionName = "install"; + + const installTypesForPackage: Refactor = { + name: "Install missing types package", + description: "Install missing types package", + getEditsForAction, + getAvailableActions, + }; + + registerRefactor(installTypesForPackage); + + function getAvailableActions(context: RefactorContext): ApplicableRefactorInfo[] | undefined { + if (context.program.getCompilerOptions().noImplicitAny) { + // Then it will be available via `fixCannotFindModule`. + return undefined; + } + + const action = getAction(context); + return action && [ + { + name: installTypesForPackage.name, + description: installTypesForPackage.description, + actions: [ + { + description: action.description, + name: actionName, + }, + ], + }, + ]; + } + + function getEditsForAction(context: RefactorContext, _actionName: string): RefactorEditInfo | undefined { + Debug.assertEqual(actionName, _actionName); + const action = getAction(context)!; // Should be defined if we said there was an action available. + return { + edits: [], + renameFilename: undefined, + renameLocation: undefined, + commands: action.commands, + }; + } + + function getAction(context: RefactorContext): CodeAction | undefined { + const { file, startPosition } = context; + const node = getTokenAtPosition(file, startPosition, /*includeJsDocComment*/ false); + if (isStringLiteral(node) && isModuleIdentifier(node) && getResolvedModule(file, node.text) === undefined) { + return codefix.tryGetCodeActionForInstallPackageTypes(context.host, node.text); + } + } + + function isModuleIdentifier(node: StringLiteral): boolean { + switch (node.parent.kind) { + case SyntaxKind.ImportDeclaration: + case SyntaxKind.ExternalModuleReference: + return true; + default: + return false; + } + } +} \ No newline at end of file diff --git a/src/services/refactors/refactors.ts b/src/services/refactors/refactors.ts index 21aeda7ae5278..f4b56422a89bd 100644 --- a/src/services/refactors/refactors.ts +++ b/src/services/refactors/refactors.ts @@ -1,3 +1,4 @@ /// /// /// +/// diff --git a/src/services/services.ts b/src/services/services.ts index 0399ca1658e9d..a5c9c1d353a18 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -1763,6 +1763,19 @@ namespace ts { }); } + function applyCodeActionCommand(fileName: Path, action: CodeActionCommand): PromiseLike { + fileName = toPath(fileName, currentDirectory, getCanonicalFileName); + switch (action.type) { + case "install package": + return host.installPackage + ? host.installPackage({ fileName, packageName: action.packageName }) + : Promise.reject("Host does not implement `installPackage`"); + default: + Debug.fail(); + // TODO: Debug.assertNever(action); will only work if there is more than one type. + } + } + function getDocCommentTemplateAtPosition(fileName: string, position: number): TextInsertion { return JsDoc.getDocCommentTemplateAtPosition(getNewLineOrDefaultFromHost(host), syntaxTreeCache.getCurrentSourceFile(fileName), position); } @@ -1972,8 +1985,9 @@ namespace ts { endPosition, program: getProgram(), newLineCharacter: formatOptions ? formatOptions.newLineCharacter : host.getNewLine(), + host, rulesProvider: getRuleProvider(formatOptions), - cancellationToken + cancellationToken, }; } @@ -2035,6 +2049,7 @@ namespace ts { isValidBraceCompletionAtPosition, getSpanOfEnclosingComment, getCodeFixesAtPosition, + applyCodeActionCommand, getEmitOutput, getNonBoundSourceFile, getSourceFile, diff --git a/src/services/types.ts b/src/services/types.ts index 7bf4a909a7a34..7ec559be92283 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -145,10 +145,15 @@ namespace ts { isCancellationRequested(): boolean; } + export interface InstallPackageOptions { + fileName: Path; + packageName: string; + } + // // Public interface of the host of a language service instance. // - export interface LanguageServiceHost { + export interface LanguageServiceHost extends GetEffectiveTypeRootsHost { getCompilationSettings(): CompilerOptions; getNewLine?(): string; getProjectVersion?(): string; @@ -158,7 +163,6 @@ namespace ts { getScriptSnapshot(fileName: string): IScriptSnapshot | undefined; getLocalizedDiagnosticMessages?(): any; getCancellationToken?(): HostCancellationToken; - getCurrentDirectory(): string; getDefaultLibFileName(options: CompilerOptions): string; log?(s: string): void; trace?(s: string): void; @@ -187,7 +191,6 @@ namespace ts { resolveTypeReferenceDirectives?(typeDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[]; /* @internal */ hasInvalidatedResolution?: HasInvalidatedResolution; /* @internal */ hasChangedAutomaticTypeDirectiveNames?: boolean; - directoryExists?(directoryName: string): boolean; /* * getDirectories is also required for full import and type reference completions. Without it defined, certain @@ -199,6 +202,9 @@ namespace ts { * Gets a set of custom transformers to use during emit. */ getCustomTransformers?(): CustomTransformers | undefined; + + isKnownTypesPackageName?(name: string): boolean; + installPackage?(options: InstallPackageOptions): PromiseLike; } // @@ -276,6 +282,7 @@ namespace ts { getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): TextSpan; getCodeFixesAtPosition(fileName: string, start: number, end: number, errorCodes: number[], formatOptions: FormatCodeSettings): CodeAction[]; + applyCodeActionCommand(fileName: string, action: CodeActionCommand): PromiseLike; getApplicableRefactors(fileName: string, positionOrRaneg: number | TextRange): ApplicableRefactorInfo[]; getEditsForRefactor(fileName: string, formatOptions: FormatCodeSettings, positionOrRange: number | TextRange, refactorName: string, actionName: string): RefactorEditInfo | undefined; @@ -295,6 +302,10 @@ namespace ts { dispose(): void; } + export interface ApplyCodeActionCommandResult { + successMessage: string; + } + export interface Classifications { spans: number[]; endOfLineState: EndOfLineState; @@ -367,6 +378,20 @@ namespace ts { description: string; /** Text changes to apply to each file as part of the code action */ changes: FileTextChanges[]; + /** + * If the user accepts the code fix, the editor should send the action back in a `applyAction` request. + * This allows the language service to have side effects (e.g. installing dependencies) upon a code fix. + */ + commands?: CodeActionCommand[]; + } + + // Publicly, this type is just `{}`. Internally it is a union of all the actions we use. + // See `commands?: {}[]` in protocol.ts + export type CodeActionCommand = InstallPackageAction; + + export interface InstallPackageAction { + /* @internal */ type: "install package"; + /* @internal */ packageName: string; } /** @@ -420,6 +445,7 @@ namespace ts { edits: FileTextChanges[]; renameFilename: string | undefined; renameLocation: number | undefined; + commands?: CodeActionCommand[]; } export interface TextInsertion { diff --git a/src/services/utilities.ts b/src/services/utilities.ts index 716a4188db25b..2b116eb1cd05c 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -1,3 +1,12 @@ +/* @internal */ // Don't expose that we use this +// Based on lib.es6.d.ts +interface PromiseConstructor { + new (executor: (resolve: (value?: T | PromiseLike) => void, reject: (reason?: any) => void) => void): Promise; + reject(reason: any): Promise; +} +/* @internal */ +declare var Promise: PromiseConstructor; + // These utilities are common to multiple language service features. /* @internal */ namespace ts { diff --git a/tests/baselines/reference/api/tsserverlibrary.d.ts b/tests/baselines/reference/api/tsserverlibrary.d.ts index 56c7fa5f98c44..15eca3da329f4 100644 --- a/tests/baselines/reference/api/tsserverlibrary.d.ts +++ b/tests/baselines/reference/api/tsserverlibrary.d.ts @@ -3869,7 +3869,11 @@ declare namespace ts { interface HostCancellationToken { isCancellationRequested(): boolean; } - interface LanguageServiceHost { + interface InstallPackageOptions { + fileName: Path; + packageName: string; + } + interface LanguageServiceHost extends GetEffectiveTypeRootsHost { getCompilationSettings(): CompilerOptions; getNewLine?(): string; getProjectVersion?(): string; @@ -3879,7 +3883,6 @@ declare namespace ts { getScriptSnapshot(fileName: string): IScriptSnapshot | undefined; getLocalizedDiagnosticMessages?(): any; getCancellationToken?(): HostCancellationToken; - getCurrentDirectory(): string; getDefaultLibFileName(options: CompilerOptions): string; log?(s: string): void; trace?(s: string): void; @@ -3891,12 +3894,13 @@ declare namespace ts { getTypeRootsVersion?(): number; resolveModuleNames?(moduleNames: string[], containingFile: string, reusedNames?: string[]): ResolvedModule[]; resolveTypeReferenceDirectives?(typeDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[]; - directoryExists?(directoryName: string): boolean; getDirectories?(directoryName: string): string[]; /** * Gets a set of custom transformers to use during emit. */ getCustomTransformers?(): CustomTransformers | undefined; + isKnownTypesPackageName?(name: string): boolean; + installPackage?(options: InstallPackageOptions): PromiseLike; } interface LanguageService { cleanupSemanticCache(): void; @@ -3944,6 +3948,7 @@ declare namespace ts { isValidBraceCompletionAtPosition(fileName: string, position: number, openingBrace: number): boolean; getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): TextSpan; getCodeFixesAtPosition(fileName: string, start: number, end: number, errorCodes: number[], formatOptions: FormatCodeSettings): CodeAction[]; + applyCodeActionCommand(fileName: string, action: CodeActionCommand): PromiseLike; getApplicableRefactors(fileName: string, positionOrRaneg: number | TextRange): ApplicableRefactorInfo[]; getEditsForRefactor(fileName: string, formatOptions: FormatCodeSettings, positionOrRange: number | TextRange, refactorName: string, actionName: string): RefactorEditInfo | undefined; getEmitOutput(fileName: string, emitOnlyDtsFiles?: boolean): EmitOutput; @@ -3951,6 +3956,9 @@ declare namespace ts { getProgram(): Program; dispose(): void; } + interface ApplyCodeActionCommandResult { + successMessage: string; + } interface Classifications { spans: number[]; endOfLineState: EndOfLineState; @@ -4015,6 +4023,14 @@ declare namespace ts { description: string; /** Text changes to apply to each file as part of the code action */ changes: FileTextChanges[]; + /** + * If the user accepts the code fix, the editor should send the action back in a `applyAction` request. + * This allows the language service to have side effects (e.g. installing dependencies) upon a code fix. + */ + commands?: CodeActionCommand[]; + } + type CodeActionCommand = InstallPackageAction; + interface InstallPackageAction { } /** * A set of one or more available refactoring actions, grouped under a parent refactoring. @@ -4063,6 +4079,7 @@ declare namespace ts { edits: FileTextChanges[]; renameFilename: string | undefined; renameLocation: number | undefined; + commands?: CodeActionCommand[]; } interface TextInsertion { newText: string; @@ -4633,11 +4650,10 @@ declare namespace ts.server { interface SortedReadonlyArray extends ReadonlyArray { " __sortedArrayBrand": any; } - interface TypingInstallerRequest { + interface TypingInstallerRequestWithProjectName { readonly projectName: string; - readonly kind: "discover" | "closeProject"; } - interface DiscoverTypings extends TypingInstallerRequest { + interface DiscoverTypings extends TypingInstallerRequestWithProjectName { readonly fileNames: string[]; readonly projectRootPath: Path; readonly compilerOptions: CompilerOptions; @@ -4646,16 +4662,27 @@ declare namespace ts.server { readonly cachePath?: string; readonly kind: "discover"; } - interface CloseProject extends TypingInstallerRequest { + interface CloseProject extends TypingInstallerRequestWithProjectName { readonly kind: "closeProject"; } + interface TypesRegistryRequest { + readonly kind: "typesRegistry"; + } + interface InstallPackageRequest { + readonly kind: "installPackage"; + readonly fileName: Path; + readonly packageName: string; + readonly projectRootPath: Path; + } type ActionSet = "action::set"; type ActionInvalidate = "action::invalidate"; + type EventTypesRegistry = "event::typesRegistry"; + type EventPackageInstalled = "event::packageInstalled"; type EventBeginInstallTypes = "event::beginInstallTypes"; type EventEndInstallTypes = "event::endInstallTypes"; type EventInitializationFailed = "event::initializationFailed"; interface TypingInstallerResponse { - readonly kind: ActionSet | ActionInvalidate | EventBeginInstallTypes | EventEndInstallTypes | EventInitializationFailed; + readonly kind: ActionSet | ActionInvalidate | EventTypesRegistry | EventPackageInstalled | EventBeginInstallTypes | EventEndInstallTypes | EventInitializationFailed; } interface InitializationFailedResponse extends TypingInstallerResponse { readonly kind: EventInitializationFailed; @@ -4691,6 +4718,8 @@ declare namespace ts.server { declare namespace ts.server { const ActionSet: ActionSet; const ActionInvalidate: ActionInvalidate; + const EventTypesRegistry: EventTypesRegistry; + const EventPackageInstalled: EventPackageInstalled; const EventBeginInstallTypes: EventBeginInstallTypes; const EventEndInstallTypes: EventEndInstallTypes; const EventInitializationFailed: EventInitializationFailed; @@ -4828,6 +4857,7 @@ declare namespace ts.server.protocol { DocCommentTemplate = "docCommentTemplate", CompilerOptionsForInferredProjects = "compilerOptionsForInferredProjects", GetCodeFixes = "getCodeFixes", + ApplyCodeActionCommand = "applyCodeActionCommand", GetSupportedCodeFixes = "getSupportedCodeFixes", GetApplicableRefactors = "getApplicableRefactors", GetEditsForRefactor = "getEditsForRefactor", @@ -4849,6 +4879,7 @@ declare namespace ts.server.protocol { * Client-initiated request message */ interface Request extends Message { + type: "request"; /** * The command to execute */ @@ -4868,6 +4899,7 @@ declare namespace ts.server.protocol { * Server-initiated event message */ interface Event extends Message { + type: "event"; /** * Name of event */ @@ -4881,6 +4913,7 @@ declare namespace ts.server.protocol { * Response by server to client request message. */ interface Response extends Message { + type: "response"; /** * Sequence number of the request message. */ @@ -4894,7 +4927,8 @@ declare namespace ts.server.protocol { */ command: string; /** - * Contains error message if success === false. + * If success === false, this should always be provided. + * Otherwise, may (or may not) contain a success message. */ message?: string; /** @@ -5170,6 +5204,12 @@ declare namespace ts.server.protocol { command: CommandTypes.GetCodeFixes; arguments: CodeFixRequestArgs; } + interface ApplyCodeActionCommandRequest extends Request { + command: CommandTypes.ApplyCodeActionCommand; + arguments: ApplyCodeActionCommandRequestArgs; + } + interface ApplyCodeActionCommandResponse extends Response { + } interface FileRangeRequestArgs extends FileRequestArgs { /** * The line number for the request (1-based). @@ -5197,6 +5237,9 @@ declare namespace ts.server.protocol { */ errorCodes?: number[]; } + interface ApplyCodeActionCommandRequestArgs extends FileRequestArgs { + command: {}; + } /** * Response for GetCodeFixes request. */ @@ -5935,6 +5978,8 @@ declare namespace ts.server.protocol { description: string; /** Text changes to apply to each file as part of the code action */ changes: FileCodeEdits[]; + /** A command is an opaque object that should be passed to `ApplyCodeActionCommandRequestArgs` without modification. */ + commands?: {}[]; } /** * Format and format on key response message. @@ -6852,6 +6897,7 @@ declare namespace ts.server { send(msg: protocol.Message): void; event(info: T, eventName: string): void; output(info: any, cmdName: string, reqSeq?: number, errorMsg?: string): void; + private doOutput(info, cmdName, reqSeq, success, message?); private semanticCheck(file, project); private syntacticCheck(file, project); private updateErrorCheck(next, checkList, ms, requireOpen?); @@ -6927,8 +6973,9 @@ declare namespace ts.server { private getApplicableRefactors(args); private getEditsForRefactor(args, simplifiedResult); private getCodeFixes(args, simplifiedResult); + private applyCodeActionCommand(commandName, requestSeq, args); private getStartAndEndPosition(args, scriptInfo); - private mapCodeAction(codeAction, scriptInfo); + private mapCodeAction({description, changes: unmappedChanges, commands}, scriptInfo); private mapTextChangesToCodeEdits(project, textChanges); private convertTextChangeToCodeEdit(change, scriptInfo); private getBraceMatching(args, simplifiedResult); @@ -6938,19 +6985,17 @@ declare namespace ts.server { private notRequired(); private requiredResponse(response); private handlers; - addProtocolHandler(command: string, handler: (request: protocol.Request) => { - response?: any; - responseRequired: boolean; - }): void; + addProtocolHandler(command: string, handler: (request: protocol.Request) => HandlerResponse): void; private setCurrentRequest(requestId); private resetCurrentRequest(requestId); executeWithRequestId(requestId: number, f: () => T): T; - executeCommand(request: protocol.Request): { - response?: any; - responseRequired?: boolean; - }; + executeCommand(request: protocol.Request): HandlerResponse; onMessage(message: string): void; } + interface HandlerResponse { + response?: {}; + responseRequired?: boolean; + } } declare namespace ts.server { class ScriptInfo { @@ -7015,7 +7060,12 @@ declare namespace ts { function updateWatchingWildcardDirectories(existingWatchedForWildcards: Map, wildcardDirectories: Map, watchDirectory: (directory: string, flags: WatchDirectoryFlags) => FileWatcher): void; } declare namespace ts.server { + interface InstallPackageOptionsWithProjectRootPath extends InstallPackageOptions { + projectRootPath: Path; + } interface ITypingsInstaller { + isKnownTypesPackageName(name: string): boolean; + installPackage(options: InstallPackageOptionsWithProjectRootPath): PromiseLike; enqueueInstallTypingsRequest(p: Project, typeAcquisition: TypeAcquisition, unresolvedImports: SortedReadonlyArray): void; attach(projectService: ProjectService): void; onProjectClosed(p: Project): void; @@ -7026,6 +7076,8 @@ declare namespace ts.server { private readonly installer; private readonly perProjectCache; constructor(installer: ITypingsInstaller); + isKnownTypesPackageName(name: string): boolean; + installPackage(options: InstallPackageOptionsWithProjectRootPath): PromiseLike; getTypingsForProject(project: Project, unresolvedImports: SortedReadonlyArray, forceRefresh: boolean): SortedReadonlyArray; updateTypingsForProject(projectName: string, compilerOptions: CompilerOptions, typeAcquisition: TypeAcquisition, unresolvedImports: SortedReadonlyArray, newTypings: string[]): void; deleteTypingsForProject(projectName: string): void; @@ -7120,6 +7172,9 @@ declare namespace ts.server { isJsOnlyProject(): boolean; getCachedUnresolvedImportsPerFile_TestOnly(): UnresolvedImportsMap; static resolveModule(moduleName: string, initialDir: string, host: ServerHost, log: (message: string) => void): {}; + isKnownTypesPackageName(name: string): boolean; + installPackage(options: InstallPackageOptions): PromiseLike; + private readonly typingsCache; getCompilationSettings(): CompilerOptions; getCompilerOptions(): CompilerOptions; getNewLine(): string; diff --git a/tests/baselines/reference/api/typescript.d.ts b/tests/baselines/reference/api/typescript.d.ts index 2668a25bcfcd2..f3180b9d1069c 100644 --- a/tests/baselines/reference/api/typescript.d.ts +++ b/tests/baselines/reference/api/typescript.d.ts @@ -3869,7 +3869,11 @@ declare namespace ts { interface HostCancellationToken { isCancellationRequested(): boolean; } - interface LanguageServiceHost { + interface InstallPackageOptions { + fileName: Path; + packageName: string; + } + interface LanguageServiceHost extends GetEffectiveTypeRootsHost { getCompilationSettings(): CompilerOptions; getNewLine?(): string; getProjectVersion?(): string; @@ -3879,7 +3883,6 @@ declare namespace ts { getScriptSnapshot(fileName: string): IScriptSnapshot | undefined; getLocalizedDiagnosticMessages?(): any; getCancellationToken?(): HostCancellationToken; - getCurrentDirectory(): string; getDefaultLibFileName(options: CompilerOptions): string; log?(s: string): void; trace?(s: string): void; @@ -3891,12 +3894,13 @@ declare namespace ts { getTypeRootsVersion?(): number; resolveModuleNames?(moduleNames: string[], containingFile: string, reusedNames?: string[]): ResolvedModule[]; resolveTypeReferenceDirectives?(typeDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[]; - directoryExists?(directoryName: string): boolean; getDirectories?(directoryName: string): string[]; /** * Gets a set of custom transformers to use during emit. */ getCustomTransformers?(): CustomTransformers | undefined; + isKnownTypesPackageName?(name: string): boolean; + installPackage?(options: InstallPackageOptions): PromiseLike; } interface LanguageService { cleanupSemanticCache(): void; @@ -3944,6 +3948,7 @@ declare namespace ts { isValidBraceCompletionAtPosition(fileName: string, position: number, openingBrace: number): boolean; getSpanOfEnclosingComment(fileName: string, position: number, onlyMultiLine: boolean): TextSpan; getCodeFixesAtPosition(fileName: string, start: number, end: number, errorCodes: number[], formatOptions: FormatCodeSettings): CodeAction[]; + applyCodeActionCommand(fileName: string, action: CodeActionCommand): PromiseLike; getApplicableRefactors(fileName: string, positionOrRaneg: number | TextRange): ApplicableRefactorInfo[]; getEditsForRefactor(fileName: string, formatOptions: FormatCodeSettings, positionOrRange: number | TextRange, refactorName: string, actionName: string): RefactorEditInfo | undefined; getEmitOutput(fileName: string, emitOnlyDtsFiles?: boolean): EmitOutput; @@ -3951,6 +3956,9 @@ declare namespace ts { getProgram(): Program; dispose(): void; } + interface ApplyCodeActionCommandResult { + successMessage: string; + } interface Classifications { spans: number[]; endOfLineState: EndOfLineState; @@ -4015,6 +4023,14 @@ declare namespace ts { description: string; /** Text changes to apply to each file as part of the code action */ changes: FileTextChanges[]; + /** + * If the user accepts the code fix, the editor should send the action back in a `applyAction` request. + * This allows the language service to have side effects (e.g. installing dependencies) upon a code fix. + */ + commands?: CodeActionCommand[]; + } + type CodeActionCommand = InstallPackageAction; + interface InstallPackageAction { } /** * A set of one or more available refactoring actions, grouped under a parent refactoring. @@ -4063,6 +4079,7 @@ declare namespace ts { edits: FileTextChanges[]; renameFilename: string | undefined; renameLocation: number | undefined; + commands?: CodeActionCommand[]; } interface TextInsertion { newText: string; diff --git a/tests/cases/fourslash/codeFixCannotFindModule.ts b/tests/cases/fourslash/codeFixCannotFindModule.ts new file mode 100644 index 0000000000000..96e5c201644d1 --- /dev/null +++ b/tests/cases/fourslash/codeFixCannotFindModule.ts @@ -0,0 +1,15 @@ +/// + +////import * as abs from "abs"; + +test.setTypesRegistry({ + "abs": undefined, +}); + +verify.codeFixAvailable([{ + description: "Install '@types/abs'", + commands: [{ + type: "install package", + packageName: "@types/abs", + }], +}]); diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index e1d9607de8aaf..a7c54b5dda649 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -118,6 +118,7 @@ declare namespace FourSlashInterface { rangesByText(): ts.Map; markerByName(s: string): Marker; symbolsInScope(range: Range): any[]; + setTypesRegistry(map: { [key: string]: void }): void; } class goTo { marker(name?: string | Marker): void; @@ -169,12 +170,17 @@ declare namespace FourSlashInterface { errorCode?: number, index?: number, }); - codeFixAvailable(): void; + codeFixAvailable(options: Array<{ description: string, actions: Array<{ type: string, data: {} }> }>): void; applicableRefactorAvailableAtMarker(markerName: string): void; codeFixDiagnosticsAvailableAtMarkers(markerNames: string[], diagnosticCode?: number): void; applicableRefactorAvailableForRange(): void; - refactorAvailable(name: string, actionName?: string); + refactorAvailable(name: string, actionName?: string): void; + refactor(options: { + name: string; + actionName: string; + refactors: any[]; + }): void; } class verify extends verifyNegatable { assertHasRanges(ranges: Range[]): void; @@ -277,6 +283,7 @@ declare namespace FourSlashInterface { fileAfterApplyingRefactorAtMarker(markerName: string, expectedContent: string, refactorNameToApply: string, actionName: string, formattingOptions?: FormatCodeOptions): void; rangeIs(expectedText: string, includeWhiteSpace?: boolean): void; fileAfterApplyingRefactorAtMarker(markerName: string, expectedContent: string, refactorNameToApply: string, formattingOptions?: FormatCodeOptions): void; + getAndApplyCodeFix(errorCode?: number, index?: number): void; importFixAtPosition(expectedTextArray: string[], errorCode?: number): void; navigationBar(json: any, options?: { checkSpans?: boolean }): void; diff --git a/tests/cases/fourslash/refactorInstallTypesForPackage.ts b/tests/cases/fourslash/refactorInstallTypesForPackage.ts new file mode 100644 index 0000000000000..edcdc2ba6e0c9 --- /dev/null +++ b/tests/cases/fourslash/refactorInstallTypesForPackage.ts @@ -0,0 +1,25 @@ +/// + +////import * as abs from "/*a*/abs/subModule/*b*/"; + +test.setTypesRegistry({ + "abs": undefined, +}); + +goTo.select("a", "b"); +verify.refactor({ + name: "Install missing types package", + actionName: "install", + refactors: [ + { + name: "Install missing types package", + description: "Install missing types package", + actions: [ + { + description: "Install '@types/abs'", + name: "install", + } + ] + } + ], +}); diff --git a/tests/cases/fourslash/refactorInstallTypesForPackage_importEquals.ts b/tests/cases/fourslash/refactorInstallTypesForPackage_importEquals.ts new file mode 100644 index 0000000000000..18793e4b353dd --- /dev/null +++ b/tests/cases/fourslash/refactorInstallTypesForPackage_importEquals.ts @@ -0,0 +1,25 @@ +/// + +////import abs = require("/*a*/abs/subModule/*b*/"); + +test.setTypesRegistry({ + "abs": undefined, +}); + +goTo.select("a", "b"); +verify.refactor({ + name: "Install missing types package", + actionName: "install", + refactors: [ + { + name: "Install missing types package", + description: "Install missing types package", + actions: [ + { + description: "Install '@types/abs'", + name: "install", + } + ] + } + ], +});