diff --git a/src/harness/fourslash.ts b/src/harness/fourslash.ts index 458eaeb2c29..d0a5f35d8ed 100644 --- a/src/harness/fourslash.ts +++ b/src/harness/fourslash.ts @@ -2108,7 +2108,7 @@ namespace FourSlash { * Because codefixes are only applied on the working file, it is unsafe * to apply this more than once (consider a refactoring across files). */ - public verifyRangeAfterCodeFix(expectedText: string, errorCode?: number) { + public verifyRangeAfterCodeFix(expectedText: string, errorCode?: number, includeWhiteSpace?: boolean) { const ranges = this.getRanges(); if (ranges.length !== 1) { this.raiseError("Exactly one range should be specified in the testfile."); @@ -2120,7 +2120,11 @@ namespace FourSlash { const actualText = this.rangeText(ranges[0]); - if (this.removeWhitespace(actualText) !== this.removeWhitespace(expectedText)) { + const result = includeWhiteSpace + ? actualText === expectedText + : this.removeWhitespace(actualText) === this.removeWhitespace(expectedText) + + if (!result) { this.raiseError(`Actual text doesn't match expected text. Actual:\n'${actualText}'\nExpected:\n'${expectedText}'`); } } @@ -3517,8 +3521,8 @@ namespace FourSlashInterface { this.DocCommentTemplate(/*expectedText*/ undefined, /*expectedOffset*/ undefined, /*empty*/ true); } - public rangeAfterCodeFix(expectedText: string, errorCode?: number): void { - this.state.verifyRangeAfterCodeFix(expectedText, errorCode); + public rangeAfterCodeFix(expectedText: string, errorCode?: number, includeWhiteSpace?: boolean): void { + this.state.verifyRangeAfterCodeFix(expectedText, errorCode, includeWhiteSpace); } public importFixAtPosition(expectedTextArray: string[], errorCode?: number): void { diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index 59930fd98ba..ca2db231ef7 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -738,7 +738,7 @@ namespace Harness.LanguageService { // host to answer server queries about files on disk const serverHost = new SessionServerHost(clientHost); const server = new ts.server.Session(serverHost, - { isCancellationRequested: () => false }, + ts.server.nullCancellationToken, /*useOneInferredProject*/ false, /*typingsInstaller*/ undefined, Utils.byteLength, diff --git a/src/harness/unittests/compileOnSave.ts b/src/harness/unittests/compileOnSave.ts index b2c46c5a18c..48558b5c1a3 100644 --- a/src/harness/unittests/compileOnSave.ts +++ b/src/harness/unittests/compileOnSave.ts @@ -4,6 +4,7 @@ namespace ts.projectSystem { import CommandNames = server.CommandNames; + const nullCancellationToken = server.nullCancellationToken; function createTestTypingsInstaller(host: server.ServerHost) { return new TestTypingsInstaller("/a/data/", /*throttleLimit*/5, host); diff --git a/src/harness/unittests/session.ts b/src/harness/unittests/session.ts index 15c43d9e76b..8d306a6099b 100644 --- a/src/harness/unittests/session.ts +++ b/src/harness/unittests/session.ts @@ -27,7 +27,7 @@ namespace ts.server { clearImmediate: noop, createHash: s => s }; - const nullCancellationToken: HostCancellationToken = { isCancellationRequested: () => false }; + const mockLogger: Logger = { close: noop, hasLevel(): boolean { return false; }, diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 40a6c933242..ce8fa7351a2 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -34,10 +34,6 @@ namespace ts.projectSystem { getLogFileName: (): string => undefined }; - export const nullCancellationToken: HostCancellationToken = { - isCancellationRequested: () => false - }; - export const { content: libFileContent } = Harness.getDefaultLibraryFile(Harness.IO); export const libFile: FileOrFolder = { path: "/a/lib/lib.d.ts", @@ -158,17 +154,33 @@ namespace ts.projectSystem { } class TestSession extends server.Session { + private seq = 0; + getProjectService() { return this.projectService; } + + public getSeq() { + return this.seq; + } + + public getNextSeq() { + return this.seq + 1; + } + + public executeCommandSeq(request: Partial) { + this.seq++; + request.seq = this.seq; + request.type = "request"; + return this.executeCommand(request); + } }; - export function createSession(host: server.ServerHost, typingsInstaller?: server.ITypingsInstaller, projectServiceEventHandler?: server.ProjectServiceEventHandler) { + export function createSession(host: server.ServerHost, typingsInstaller?: server.ITypingsInstaller, projectServiceEventHandler?: server.ProjectServiceEventHandler, cancellationToken?: server.ServerCancellationToken) { if (typingsInstaller === undefined) { typingsInstaller = new TestTypingsInstaller("/a/data/", /*throttleLimit*/5, host); } - - return new TestSession(host, nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ projectServiceEventHandler !== undefined, projectServiceEventHandler); + return new TestSession(host, cancellationToken || server.nullCancellationToken, /*useSingleInferredProject*/ false, typingsInstaller, Utils.byteLength, process.hrtime, nullLogger, /*canUseEvents*/ projectServiceEventHandler !== undefined, projectServiceEventHandler); } export interface CreateProjectServiceParameters { @@ -191,7 +203,7 @@ namespace ts.projectSystem { } } export function createProjectService(host: server.ServerHost, parameters: CreateProjectServiceParameters = {}) { - const cancellationToken = parameters.cancellationToken || nullCancellationToken; + const cancellationToken = parameters.cancellationToken || server.nullCancellationToken; const logger = parameters.logger || nullLogger; const useSingleInferredProject = parameters.useSingleInferredProject !== undefined ? parameters.useSingleInferredProject : false; return new TestProjectService(host, logger, cancellationToken, useSingleInferredProject, parameters.typingsInstaller, parameters.eventHandler); @@ -328,6 +340,8 @@ namespace ts.projectSystem { export class TestServerHost implements server.ServerHost { args: string[] = []; + private readonly output: string[] = []; + private fs: ts.FileMap; private getCanonicalFileName: (s: string) => string; private toPath: (f: string) => Path; @@ -477,6 +491,10 @@ namespace ts.projectSystem { this.timeoutCallbacks.invoke(); } + runQueuedImmediateCallbacks() { + this.immediateCallbacks.invoke(); + } + setImmediate(callback: TimeOutCallback, _time: number, ...args: any[]) { return this.immediateCallbacks.register(callback, args); } @@ -509,7 +527,17 @@ namespace ts.projectSystem { this.reloadFS(filesOrFolders); } - write() { } + write(message: string) { + this.output.push(message); + } + + getOutput(): ReadonlyArray { + return this.output; + } + + clearOutput() { + this.output.length = 0; + } readonly readFile = (s: string) => (this.fs.get(this.toPath(s))).content; readonly resolvePath = (s: string) => s; @@ -3131,6 +3159,200 @@ namespace ts.projectSystem { }); }); + describe("cancellationToken", () => { + it("is attached to request", () => { + const f1 = { + path: "/a/b/app.ts", + content: "let xyz = 1;" + }; + const host = createServerHost([f1]); + let expectedRequestId: number; + const cancellationToken: server.ServerCancellationToken = { + isCancellationRequested: () => false, + setRequest: requestId => { + if (expectedRequestId === undefined) { + assert.isTrue(false, "unexpected call") + } + assert.equal(requestId, expectedRequestId); + }, + resetRequest: noop + } + const session = createSession(host, /*typingsInstaller*/ undefined, /*projectServiceEventHandler*/ undefined, cancellationToken); + + expectedRequestId = session.getNextSeq(); + session.executeCommandSeq({ + command: "open", + arguments: { file: f1.path } + }); + + expectedRequestId = session.getNextSeq(); + session.executeCommandSeq({ + command: "geterr", + arguments: { files: [f1.path] } + }); + + expectedRequestId = session.getNextSeq(); + session.executeCommandSeq({ + command: "occurrences", + arguments: { file: f1.path, line: 1, offset: 6 } + }); + + expectedRequestId = 2; + host.runQueuedImmediateCallbacks(); + expectedRequestId = 2; + host.runQueuedImmediateCallbacks(); + }); + + it("Geterr is cancellable", () => { + const f1 = { + path: "/a/app.ts", + content: "let x = 1" + }; + const config = { + path: "/a/tsconfig.json", + content: JSON.stringify({ + compilerOptions: {} + }) + }; + + let requestToCancel = -1; + const cancellationToken: server.ServerCancellationToken = (function(){ + let currentId: number; + return { + setRequest(requestId) { + currentId = requestId; + }, + resetRequest(requestId) { + assert.equal(requestId, currentId, "unexpected request id in cancellation") + currentId = undefined; + }, + isCancellationRequested() { + return requestToCancel === currentId; + } + } + })(); + const host = createServerHost([f1, config]); + const session = createSession(host, /*typingsInstaller*/ undefined, () => {}, cancellationToken); + { + session.executeCommandSeq({ + command: "open", + arguments: { file: f1.path } + }); + // send geterr for missing file + session.executeCommandSeq({ + command: "geterr", + arguments: { files: ["/a/missing"] } + }); + // no files - expect 'completed' event + assert.equal(host.getOutput().length, 1, "expect 1 message"); + verifyRequestCompleted(session.getSeq(), 0); + } + { + const getErrId = session.getNextSeq(); + // send geterr for a valid file + session.executeCommandSeq({ + command: "geterr", + arguments: { files: [f1.path] } + }); + + assert.equal(host.getOutput().length, 0, "expect 0 messages"); + + // run new request + session.executeCommandSeq({ + command: "projectInfo", + arguments: { file: f1.path } + }); + host.clearOutput(); + + // cancel previously issued Geterr + requestToCancel = getErrId; + host.runQueuedTimeoutCallbacks(); + + assert.equal(host.getOutput().length, 1, "expect 1 message"); + verifyRequestCompleted(getErrId, 0); + + requestToCancel = -1; + } + { + const getErrId = session.getNextSeq(); + session.executeCommandSeq({ + command: "geterr", + arguments: { files: [f1.path] } + }); + assert.equal(host.getOutput().length, 0, "expect 0 messages"); + + // run first step + host.runQueuedTimeoutCallbacks(); + assert.equal(host.getOutput().length, 1, "expect 1 messages"); + const e1 = getMessage(0); + assert.equal(e1.event, "syntaxDiag"); + host.clearOutput(); + + requestToCancel = getErrId; + host.runQueuedImmediateCallbacks(); + assert.equal(host.getOutput().length, 1, "expect 1 message"); + verifyRequestCompleted(getErrId, 0); + + requestToCancel = -1; + } + { + const getErrId = session.getNextSeq(); + session.executeCommandSeq({ + command: "geterr", + arguments: { files: [f1.path] } + }); + assert.equal(host.getOutput().length, 0, "expect 0 messages"); + + // run first step + host.runQueuedTimeoutCallbacks(); + assert.equal(host.getOutput().length, 1, "expect 1 messages"); + const e1 = getMessage(0); + assert.equal(e1.event, "syntaxDiag"); + host.clearOutput(); + + host.runQueuedImmediateCallbacks(); + assert.equal(host.getOutput().length, 2, "expect 2 messages"); + const e2 = getMessage(0); + assert.equal(e2.event, "semanticDiag"); + verifyRequestCompleted(getErrId, 1); + + requestToCancel = -1; + } + { + const getErr1 = session.getNextSeq(); + session.executeCommandSeq({ + command: "geterr", + arguments: { files: [f1.path] } + }); + assert.equal(host.getOutput().length, 0, "expect 0 messages"); + // run first step + host.runQueuedTimeoutCallbacks(); + assert.equal(host.getOutput().length, 1, "expect 1 messages"); + const e1 = getMessage(0); + assert.equal(e1.event, "syntaxDiag"); + host.clearOutput(); + + session.executeCommandSeq({ + command: "geterr", + arguments: { files: [f1.path] } + }); + // make sure that getErr1 is completed + verifyRequestCompleted(getErr1, 0); + } + + function verifyRequestCompleted(expectedSeq: number, n: number) { + const event = getMessage(n); + assert.equal(event.event, "requestCompleted"); + assert.equal(event.body.request_seq, expectedSeq, "expectedSeq"); + host.clearOutput(); + } + + function getMessage(n: number) { + return JSON.parse(server.extractMessage(host.getOutput()[n])); + } + }); + }); + describe("maxNodeModuleJsDepth for inferred projects", () => { it("should be set to 2 if the project has js root files", () => { const file1: FileOrFolder = { @@ -3184,5 +3406,4 @@ namespace ts.projectSystem { assert.isUndefined(project.getCompilerOptions().maxNodeModuleJsDepth); }); }); - } \ No newline at end of file diff --git a/src/server/cancellationToken/cancellationToken.ts b/src/server/cancellationToken/cancellationToken.ts index de59f3a1bce..71a31d14016 100644 --- a/src/server/cancellationToken/cancellationToken.ts +++ b/src/server/cancellationToken/cancellationToken.ts @@ -1,14 +1,24 @@ -/// - - -// TODO: extract services types -interface HostCancellationToken { - isCancellationRequested(): boolean; -} +/// import fs = require("fs"); -function createCancellationToken(args: string[]): HostCancellationToken { +interface ServerCancellationToken { + isCancellationRequested(): boolean; + setRequest(requestId: number): void; + resetRequest(requestId: number): void; +} + +function pipeExists(name: string): boolean { + try { + fs.statSync(name); + return true; + } + catch (e) { + return false; + } +} + +function createCancellationToken(args: string[]): ServerCancellationToken { let cancellationPipeName: string; for (let i = 0; i < args.length - 1; i++) { if (args[i] === "--cancellationPipeName") { @@ -17,18 +27,44 @@ function createCancellationToken(args: string[]): HostCancellationToken { } } if (!cancellationPipeName) { - return { isCancellationRequested: () => false }; + return { + isCancellationRequested: () => false, + setRequest: (_requestId: number): void => void 0, + resetRequest: (_requestId: number): void => void 0 + }; } - return { - isCancellationRequested() { - try { - fs.statSync(cancellationPipeName); - return true; - } - catch (e) { - return false; - } + // cancellationPipeName is a string without '*' inside that can optionally end with '*' + // when client wants to signal cancellation it should create a named pipe with name= + // server will synchronously check the presence of the pipe and treat its existance as indicator that current request should be canceled. + // in case if client prefers to use more fine-grained schema than one name for all request it can add '*' to the end of cancelellationPipeName. + // in this case pipe name will be build dynamically as . + if (cancellationPipeName.charAt(cancellationPipeName.length - 1) === "*") { + const namePrefix = cancellationPipeName.slice(0, -1); + if (namePrefix.length === 0 || namePrefix.indexOf("*") >= 0) { + throw new Error("Invalid name for template cancellation pipe: it should have length greater than 2 characters and contain only one '*'."); } - }; + let perRequestPipeName: string; + let currentRequestId: number; + return { + isCancellationRequested: () => perRequestPipeName !== undefined && pipeExists(perRequestPipeName), + setRequest(requestId: number) { + currentRequestId = currentRequestId; + perRequestPipeName = namePrefix + requestId; + }, + resetRequest(requestId: number) { + if (currentRequestId !== requestId) { + throw new Error(`Mismatched request id, expected ${currentRequestId}, actual ${requestId}`); + } + perRequestPipeName = undefined; + } + }; + } + else { + return { + isCancellationRequested: () => pipeExists(cancellationPipeName), + setRequest: (_requestId: number): void => void 0, + resetRequest: (_requestId: number): void => void 0 + }; + } } export = createCancellationToken; \ No newline at end of file diff --git a/src/server/client.ts b/src/server/client.ts index d9e93769bd9..6f784db1682 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -13,6 +13,25 @@ namespace ts.server { findInComments: boolean; } + /* @internal */ + export function extractMessage(message: string) { + // Read the content length + const contentLengthPrefix = "Content-Length: "; + const lines = message.split(/\r?\n/); + Debug.assert(lines.length >= 2, "Malformed response: Expected 3 lines in the response."); + + const contentLengthText = lines[0]; + Debug.assert(contentLengthText.indexOf(contentLengthPrefix) === 0, "Malformed response: Response text did not contain content-length header."); + const contentLength = parseInt(contentLengthText.substring(contentLengthPrefix.length)); + + // Read the body + const responseBody = lines[2]; + + // Verify content length + Debug.assert(responseBody.length + 1 === contentLength, "Malformed response: Content length did not match the response's body length."); + return responseBody; + } + export class SessionClient implements LanguageService { private sequence: number = 0; private lineMaps: ts.Map = ts.createMap(); @@ -84,7 +103,7 @@ namespace ts.server { while (!foundResponseMessage) { lastMessage = this.messages.shift(); Debug.assert(!!lastMessage, "Did not receive any responses."); - const responseBody = processMessage(lastMessage); + const responseBody = extractMessage(lastMessage); try { response = JSON.parse(responseBody); // the server may emit events before emitting the response. We @@ -109,24 +128,6 @@ namespace ts.server { Debug.assert(!!response.body, "Malformed response: Unexpected empty response body."); return response; - - function processMessage(message: string) { - // Read the content length - const contentLengthPrefix = "Content-Length: "; - const lines = message.split("\r\n"); - Debug.assert(lines.length >= 2, "Malformed response: Expected 3 lines in the response."); - - const contentLengthText = lines[0]; - Debug.assert(contentLengthText.indexOf(contentLengthPrefix) === 0, "Malformed response: Response text did not contain content-length header."); - const contentLength = parseInt(contentLengthText.substring(contentLengthPrefix.length)); - - // Read the body - const responseBody = lines[2]; - - // Verify content length - Debug.assert(responseBody.length + 1 === contentLength, "Malformed response: Content length did not match the response's body length."); - return responseBody; - } } openFile(fileName: string, content?: string, scriptKindName?: "TS" | "JS" | "TSX" | "JSX"): void { diff --git a/src/server/protocol.ts b/src/server/protocol.ts index e82eca86ba9..4d583cb66d9 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -1766,6 +1766,20 @@ namespace ts.server.protocol { arguments: GeterrRequestArgs; } + export type RequestCompletedEventName = "requestCompleted"; + + /** + * Event that is sent when server have finished processing request with specified id. + */ + export interface RequestCompletedEvent extends Event { + event: RequestCompletedEventName; + body: RequestCompletedEventBody; + } + + export interface RequestCompletedEventBody { + request_seq: number; + } + /** * Item of diagnostic information found in a DiagnosticEvent message. */ diff --git a/src/server/server.ts b/src/server/server.ts index e689a2fc782..9488d8037bb 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -354,7 +354,7 @@ namespace ts.server { class IOSession extends Session { constructor( host: ServerHost, - cancellationToken: HostCancellationToken, + cancellationToken: ServerCancellationToken, installerEventPort: number, canUseEvents: boolean, useSingleInferredProject: boolean, @@ -593,15 +593,13 @@ namespace ts.server { sys.gc = () => global.gc(); } - let cancellationToken: HostCancellationToken; + let cancellationToken: ServerCancellationToken; try { const factory = require("./cancellationToken"); cancellationToken = factory(sys.args); } catch (e) { - cancellationToken = { - isCancellationRequested: () => false - }; + cancellationToken = nullCancellationToken; }; let eventPort: number; diff --git a/src/server/session.ts b/src/server/session.ts index 5d745fc7965..262ba8da1da 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -8,6 +8,17 @@ namespace ts.server { stack?: string; } + export interface ServerCancellationToken extends HostCancellationToken { + setRequest(requestId: number): void; + resetRequest(requestId: number): void; + } + + export const nullCancellationToken: ServerCancellationToken = { + isCancellationRequested: () => false, + setRequest: () => void 0, + resetRequest: () => void 0 + }; + function hrTimeToMilliseconds(time: number[]): number { const seconds = time[0]; const nanoseconds = time[1]; @@ -193,18 +204,134 @@ namespace ts.server { return `Content-Length: ${1 + len}\r\n\r\n${json}${newLine}`; } + /** + * Allows to schedule next step in multistep operation + */ + interface NextStep { + immediate(action: () => void): void; + delay(ms: number, action: () => void): void; + } + + /** + * External capabilities used by multistep operation + */ + interface MultistepOperationHost { + getCurrentRequestId(): number; + sendRequestCompletedEvent(requestId: number): void; + getServerHost(): ServerHost; + isCancellationRequested(): boolean; + executeWithRequestId(requestId: number, action: () => void): void; + logError(error: Error, message: string): void; + } + + /** + * Represents operation that can schedule its next step to be executed later. + * Scheduling is done via instance of NextStep. If on current step subsequent step was not scheduled - operation is assumed to be completed. + */ + class MultistepOperation { + private requestId: number; + private timerHandle: any; + private immediateId: any; + private completed = true; + private readonly next: NextStep; + + constructor(private readonly operationHost: MultistepOperationHost) { + this.next = { + immediate: action => this.immediate(action), + delay: (ms, action) => this.delay(ms, action) + } + } + + public startNew(action: (next: NextStep) => void) { + this.complete(); + this.requestId = this.operationHost.getCurrentRequestId(); + this.completed = false; + this.executeAction(action); + } + + private complete() { + if (!this.completed) { + if (this.requestId) { + this.operationHost.sendRequestCompletedEvent(this.requestId); + } + this.completed = true; + } + this.setTimerHandle(undefined); + this.setImmediateId(undefined); + } + + private immediate(action: () => void) { + const requestId = this.requestId; + Debug.assert(requestId === this.operationHost.getCurrentRequestId(), "immediate: incorrect request id") + this.setImmediateId(this.operationHost.getServerHost().setImmediate(() => { + this.immediateId = undefined; + this.operationHost.executeWithRequestId(requestId, () => this.executeAction(action)); + })); + } + + private delay(ms: number, action: () => void) { + const requestId = this.requestId; + Debug.assert(requestId === this.operationHost.getCurrentRequestId(), "delay: incorrect request id") + this.setTimerHandle(this.operationHost.getServerHost().setTimeout(() => { + this.timerHandle = undefined; + this.operationHost.executeWithRequestId(requestId, () => this.executeAction(action)); + }, ms)); + } + + private executeAction(action: (next: NextStep) => void) { + let stop = false; + try { + if (this.operationHost.isCancellationRequested()) { + stop = true; + } + else { + action(this.next); + } + } + catch (e) { + stop = true; + // ignore cancellation request + if (!(e instanceof OperationCanceledException)) { + this.operationHost.logError(e, `delayed processing of request ${this.requestId}`); + } + } + if (stop || !this.hasPendingWork()) { + this.complete(); + } + } + + private setTimerHandle(timerHandle: any) {; + if (this.timerHandle !== undefined) { + this.operationHost.getServerHost().clearTimeout(this.timerHandle); + } + this.timerHandle = timerHandle; + } + + private setImmediateId(immediateId: number) { + if (this.immediateId !== undefined) { + this.operationHost.getServerHost().clearImmediate(this.immediateId); + } + this.immediateId = immediateId; + } + + private hasPendingWork() { + return !!this.timerHandle || !!this.immediateId; + } + } + export class Session implements EventSender { private readonly gcTimer: GcTimer; protected projectService: ProjectService; - private errorTimer: any; /*NodeJS.Timer | number*/ - private immediateId: any; private changeSeq = 0; + private currentRequestId: number; + private errorCheck: MultistepOperation; + private eventHander: ProjectServiceEventHandler; constructor( private host: ServerHost, - cancellationToken: HostCancellationToken, + private readonly cancellationToken: ServerCancellationToken, useSingleInferredProject: boolean, protected readonly typingsInstaller: ITypingsInstaller, private byteLength: (buf: string, encoding?: string) => number, @@ -217,17 +344,35 @@ namespace ts.server { ? eventHandler || (event => this.defaultEventHandler(event)) : undefined; + const multistepOperationHost: MultistepOperationHost = { + executeWithRequestId: (requestId, action) => this.executeWithRequestId(requestId, action), + getCurrentRequestId: () => this.currentRequestId, + getServerHost: () => this.host, + logError: (err, cmd) => this.logError(err, cmd), + sendRequestCompletedEvent: requestId => this.sendRequestCompletedEvent(requestId), + isCancellationRequested: () => cancellationToken.isCancellationRequested() + } + this.errorCheck = new MultistepOperation(multistepOperationHost); this.projectService = new ProjectService(host, logger, cancellationToken, useSingleInferredProject, typingsInstaller, this.eventHander); this.gcTimer = new GcTimer(host, /*delay*/ 7000, logger); } + private sendRequestCompletedEvent(requestId: number): void { + const event: protocol.RequestCompletedEvent = { + seq: 0, + type: "event", + event: "requestCompleted", + body: { request_seq: requestId } + }; + this.send(event); + } + private defaultEventHandler(event: ProjectServiceEvent) { switch (event.eventName) { case ContextEvent: const { project, fileName } = event.data; this.projectService.logger.info(`got context event, updating diagnostics for ${fileName}`); - this.updateErrorCheck([{ fileName, project }], this.changeSeq, - (n) => n === this.changeSeq, 100); + this.errorCheck.startNew(next => this.updateErrorCheck(next, [{ fileName, project }], this.changeSeq, (n) => n === this.changeSeq, 100)); break; case ConfigFileDiagEvent: const { triggerFile, configFileName, diagnostics } = event.data; @@ -284,7 +429,7 @@ namespace ts.server { seq: 0, type: "event", event: eventName, - body: info, + body: info }; this.send(ev); } @@ -342,18 +487,11 @@ namespace ts.server { }, ms); } - private updateErrorCheck(checkList: PendingErrorCheck[], seq: number, - matchSeq: (seq: number) => boolean, ms = 1500, followMs = 200, requireOpen = true) { + private updateErrorCheck(next: NextStep, checkList: PendingErrorCheck[], seq: number, matchSeq: (seq: number) => boolean, ms = 1500, followMs = 200, requireOpen = true) { if (followMs > ms) { followMs = ms; } - if (this.errorTimer) { - this.host.clearTimeout(this.errorTimer); - } - if (this.immediateId) { - this.host.clearImmediate(this.immediateId); - this.immediateId = undefined; - } + let index = 0; const checkOne = () => { if (matchSeq(seq)) { @@ -361,21 +499,18 @@ namespace ts.server { index++; if (checkSpec.project.containsFile(checkSpec.fileName, requireOpen)) { this.syntacticCheck(checkSpec.fileName, checkSpec.project); - this.immediateId = this.host.setImmediate(() => { + next.immediate(() => { this.semanticCheck(checkSpec.fileName, checkSpec.project); - this.immediateId = undefined; if (checkList.length > index) { - this.errorTimer = this.host.setTimeout(checkOne, followMs); - } - else { - this.errorTimer = undefined; + next.delay(followMs, checkOne); } }); } } }; + if ((checkList.length > index) && (matchSeq(seq))) { - this.errorTimer = this.host.setTimeout(checkOne, ms); + next.delay(ms, checkOne); } } @@ -1087,7 +1222,7 @@ namespace ts.server { } } - private getDiagnostics(delay: number, fileNames: string[]) { + private getDiagnostics(next: NextStep, delay: number, fileNames: string[]): void { const checkList = fileNames.reduce((accum: PendingErrorCheck[], uncheckedFileName: string) => { const fileName = toNormalizedPath(uncheckedFileName); const project = this.projectService.getDefaultProjectForFile(fileName, /*refreshInferredProjects*/ true); @@ -1098,7 +1233,7 @@ namespace ts.server { }, []); if (checkList.length > 0) { - this.updateErrorCheck(checkList, this.changeSeq, (n) => n === this.changeSeq, delay); + this.updateErrorCheck(next, checkList, this.changeSeq, (n) => n === this.changeSeq, delay); } } @@ -1335,7 +1470,7 @@ namespace ts.server { : spans; } - getDiagnosticsForProject(delay: number, fileName: string) { + private getDiagnosticsForProject(next: NextStep, delay: number, fileName: string): void { const { fileNames, languageServiceDisabled } = this.getProjectInfoWorker(fileName, /*projectFileName*/ undefined, /*needFileNameList*/ true); if (languageServiceDisabled) { return; @@ -1373,7 +1508,7 @@ namespace ts.server { const checkList = fileNamesInProject.map(fileName => ({ fileName, project })); // Project level error analysis runs on background files too, therefore // doesn't require the file to be opened - this.updateErrorCheck(checkList, this.changeSeq, (n) => n == this.changeSeq, delay, 200, /*requireOpen*/ false); + this.updateErrorCheck(next, checkList, this.changeSeq, (n) => n == this.changeSeq, delay, 200, /*requireOpen*/ false); } } @@ -1550,13 +1685,13 @@ namespace ts.server { [CommandNames.SyntacticDiagnosticsSync]: (request: protocol.SyntacticDiagnosticsSyncRequest) => { return this.requiredResponse(this.getSyntacticDiagnosticsSync(request.arguments)); }, - [CommandNames.Geterr]: (request: protocol.Request) => { - const geterrArgs = request.arguments; - return { response: this.getDiagnostics(geterrArgs.delay, geterrArgs.files), responseRequired: false }; + [CommandNames.Geterr]: (request: protocol.GeterrRequest) => { + this.errorCheck.startNew(next => this.getDiagnostics(next, request.arguments.delay, request.arguments.files)); + return this.notRequired(); }, - [CommandNames.GeterrForProject]: (request: protocol.Request) => { - const { file, delay } = request.arguments; - return { response: this.getDiagnosticsForProject(delay, file), responseRequired: false }; + [CommandNames.GeterrForProject]: (request: protocol.GeterrForProjectRequest) => { + this.errorCheck.startNew(next => this.getDiagnosticsForProject(next, request.arguments.delay, request.arguments.file)); + return this.notRequired(); }, [CommandNames.Change]: (request: protocol.ChangeRequest) => { this.change(request.arguments); @@ -1643,10 +1778,32 @@ namespace ts.server { this.handlers.set(command, handler); } + private setCurrentRequest(requestId: number): void { + Debug.assert(this.currentRequestId === undefined); + this.currentRequestId = requestId; + this.cancellationToken.setRequest(requestId); + } + + private resetCurrentRequest(requestId: number): void { + Debug.assert(this.currentRequestId === requestId); + this.currentRequestId = undefined; + this.cancellationToken.resetRequest(requestId); + } + + public executeWithRequestId(requestId: number, f: () => T) { + try { + this.setCurrentRequest(requestId); + return f(); + } + finally { + this.resetCurrentRequest(requestId); + } + } + public executeCommand(request: protocol.Request): { response?: any, responseRequired?: boolean } { const handler = this.handlers.get(request.command); if (handler) { - return handler(request); + return this.executeWithRequestId(request.seq, () => handler(request)); } else { this.logger.msg(`Unrecognized JSON command: ${JSON.stringify(request)}`, Msg.Err); diff --git a/src/services/codefixes/unusedIdentifierFixes.ts b/src/services/codefixes/unusedIdentifierFixes.ts index 6c0bc04eca8..a45a3c26dba 100644 --- a/src/services/codefixes/unusedIdentifierFixes.ts +++ b/src/services/codefixes/unusedIdentifierFixes.ts @@ -150,7 +150,20 @@ namespace ts.codefix { } function createCodeFixToRemoveNode(node: Node) { - return createCodeFix("", node.getStart(), node.getWidth()); + let end = node.getEnd(); + const endCharCode = sourceFile.text.charCodeAt(end); + const afterEndCharCode = sourceFile.text.charCodeAt(end + 1); + if (isLineBreak(endCharCode)) { + end += 1; + } + // in the case of CR LF, you could have two consecutive new line characters for one new line. + // this needs to be differenciated from two LF LF chars that actually mean two new lines. + if (isLineBreak(afterEndCharCode) && endCharCode !== afterEndCharCode) { + end += 1; + } + + const start = node.getStart(); + return createCodeFix("", start, end - start); } function findFirstNonSpaceCharPosStarting(start: number) { diff --git a/src/services/completions.ts b/src/services/completions.ts index df78ab931d1..d5fe54df719 100644 --- a/src/services/completions.ts +++ b/src/services/completions.ts @@ -915,13 +915,29 @@ namespace ts.Completions { } } else if (sourceFile.languageVariant === LanguageVariant.JSX) { - if (kind === SyntaxKind.LessThanToken) { - isRightOfOpenTag = true; - location = contextToken; - } - else if (kind === SyntaxKind.SlashToken && contextToken.parent.kind === SyntaxKind.JsxClosingElement) { - isStartingCloseTag = true; - location = contextToken; + switch (contextToken.parent.kind) { + case SyntaxKind.JsxClosingElement: + if (kind === SyntaxKind.SlashToken) { + isStartingCloseTag = true; + location = contextToken; + } + break; + + case SyntaxKind.BinaryExpression: + if (!((contextToken.parent as BinaryExpression).left.flags & NodeFlags.ThisNodeHasError)) { + // It has a left-hand side, so we're not in an opening JSX tag. + break; + } + // fall through + + case SyntaxKind.JsxSelfClosingElement: + case SyntaxKind.JsxElement: + case SyntaxKind.JsxOpeningElement: + if (kind === SyntaxKind.LessThanToken) { + isRightOfOpenTag = true; + location = contextToken; + } + break; } } } diff --git a/tests/cases/fourslash/fourslash.ts b/tests/cases/fourslash/fourslash.ts index c613204f539..661d7cba6a4 100644 --- a/tests/cases/fourslash/fourslash.ts +++ b/tests/cases/fourslash/fourslash.ts @@ -225,7 +225,7 @@ declare namespace FourSlashInterface { noMatchingBracePositionInCurrentFile(bracePosition: number): void; DocCommentTemplate(expectedText: string, expectedOffset: number, empty?: boolean): void; noDocCommentTemplate(): void; - rangeAfterCodeFix(expectedText: string, errorCode?: number): void; + rangeAfterCodeFix(expectedText: string, errorCode?: number, includeWhiteSpace?: boolean): void; importFixAtPosition(expectedTextArray: string[], errorCode?: number): void; navigationBar(json: any): void; diff --git a/tests/cases/fourslash/tsxCompletionNonTagLessThan.ts b/tests/cases/fourslash/tsxCompletionNonTagLessThan.ts new file mode 100644 index 00000000000..be715438437 --- /dev/null +++ b/tests/cases/fourslash/tsxCompletionNonTagLessThan.ts @@ -0,0 +1,15 @@ +/// + +// @Filename: /a.tsx +////var x: Array