diff --git a/.editorconfig b/.editorconfig index e778670c5..6f58a8831 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,11 +4,10 @@ insert_final_newline = true end_of_line = lf charset = utf-8 trim_trailing_whitespace = true -indent_style = tab +indent_style = space indent_size = 4 [*.{json,yml}] -indent_style = space indent_size = 2 [*.md] diff --git a/package.json b/package.json index 6e611685f..a9912c6e6 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "clean": "rimraf lib", "cover": "nyc --silent --all --require source-map-support/register mocha --timeout 7000 --slow 2000 lib/test/**/*.js", "test": "mocha --require source-map-support/register --timeout 7000 --slow 2000 lib/test/**/*.js", - "lint": "tslint -t msbuild -c tslint.json 'src/**/*.ts'", + "lint": "tslint -c tslint.json -p .", "build": "tsc", "watch": "tsc -w", "semantic-release": "semantic-release pre && npm publish && semantic-release post", @@ -56,16 +56,18 @@ "vscode-languageserver-types": "^3.0.3" }, "devDependencies": { + "@sourcegraph/tsconfig": "^1.0.0", + "@sourcegraph/tslint-config": "^2.0.1", "@types/chai": "^4.0.0", "@types/chai-as-promised": "^7.1.0", "@types/chalk": "^0.4.31", "@types/glob": "^5.0.30", - "@types/lodash": "^4.14.74", + "@types/lodash": "^4.14.76", "@types/mocha": "^2.2.41", "@types/mz": "^0.0.31", "@types/node": "^7.0.32", - "@types/rimraf": "^2.0.2", "@types/object-hash": "^0.5.29", + "@types/rimraf": "^2.0.2", "@types/sinon": "^2.3.5", "@types/temp": "^0.8.29", "commitizen": "^2.9.6", @@ -74,11 +76,12 @@ "mocha": "^3.2.0", "nyc": "^11.0.2", "rimraf": "^2.6.1", - "sinon": "^4.0.0", "semantic-release": "^8.0.0", + "sinon": "^4.0.0", "source-map-support": "^0.4.11", "temp": "^0.8.3", - "tslint": "^5.0.0", + "tslint": "^5.7.0", + "tslint-language-service": "^0.9.6", "validate-commit-msg": "^2.12.2" }, "bin": { diff --git a/src/ast.ts b/src/ast.ts index f17555e84..ef9e6d21c 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -1,5 +1,5 @@ -import * as ts from 'typescript'; +import * as ts from 'typescript' /** * Returns a Generator that walks most of the AST (the part that matters for gathering all references) and emits Nodes @@ -7,11 +7,11 @@ import * as ts from 'typescript'; * TODO is this function worth it? */ export function *walkMostAST(node: ts.Node): IterableIterator { - yield node; - const children = node.getChildren(); - for (const child of children) { - if (child) { - yield* walkMostAST(child); - } - } + yield node + const children = node.getChildren() + for (const child of children) { + if (child) { + yield* walkMostAST(child) + } + } } diff --git a/src/connection.ts b/src/connection.ts index dcfd67cba..dd5b0093c 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,49 +1,49 @@ -import { Observable, Subscription, Symbol } from '@reactivex/rxjs'; -import { EventEmitter } from 'events'; -import { applyReducer, Operation } from 'fast-json-patch'; -import { camelCase, omit } from 'lodash'; -import { FORMAT_TEXT_MAP, SpanContext, Tracer } from 'opentracing'; -import { inspect } from 'util'; -import { ErrorCodes, Message, StreamMessageReader as VSCodeStreamMessageReader, StreamMessageWriter as VSCodeStreamMessageWriter } from 'vscode-jsonrpc'; -import { isNotificationMessage, isRequestMessage, isResponseMessage, NotificationMessage, RequestMessage, ResponseMessage } from 'vscode-jsonrpc/lib/messages'; -import { Logger, NoopLogger } from './logging'; -import { InitializeParams, PartialResultParams } from './request-type'; -import { TypeScriptService } from './typescript-service'; +import { Observable, Subscription, Symbol } from '@reactivex/rxjs' +import { EventEmitter } from 'events' +import { applyReducer, Operation } from 'fast-json-patch' +import { camelCase, omit } from 'lodash' +import { FORMAT_TEXT_MAP, SpanContext, Tracer } from 'opentracing' +import { inspect } from 'util' +import { ErrorCodes, Message, StreamMessageReader as VSCodeStreamMessageReader, StreamMessageWriter as VSCodeStreamMessageWriter } from 'vscode-jsonrpc' +import { isNotificationMessage, isRequestMessage, isResponseMessage, NotificationMessage, RequestMessage, ResponseMessage } from 'vscode-jsonrpc/lib/messages' +import { Logger, NoopLogger } from './logging' +import { InitializeParams, PartialResultParams } from './request-type' +import { TypeScriptService } from './typescript-service' /** * Interface for JSON RPC messages with tracing metadata */ export interface HasMeta { - meta: { [key: string]: any }; + meta: { [key: string]: any } } /** * Returns true if the passed argument has a meta field */ function hasMeta(candidate: any): candidate is HasMeta { - return typeof candidate === 'object' && candidate !== null && typeof candidate.meta === 'object' && candidate.meta !== null; + return typeof candidate === 'object' && candidate !== null && typeof candidate.meta === 'object' && candidate.meta !== null } /** * Returns true if the passed argument is an object with a `.then()` method */ function isPromiseLike(candidate: any): candidate is PromiseLike { - return typeof candidate === 'object' && candidate !== null && typeof candidate.then === 'function'; + return typeof candidate === 'object' && candidate !== null && typeof candidate.then === 'function' } /** * Returns true if the passed argument is an object with a `[Symbol.observable]` method */ function isObservable(candidate: any): candidate is Observable { - return typeof candidate === 'object' && candidate !== null && typeof candidate[Symbol.observable] === 'function'; + return typeof candidate === 'object' && candidate !== null && typeof candidate[Symbol.observable] === 'function' } export interface MessageLogOptions { - /** Logger to use */ - logger?: Logger; + /** Logger to use */ + logger?: Logger - /** Whether to log all messages */ - logMessages?: boolean; + /** Whether to log all messages */ + logMessages?: boolean } /** @@ -52,50 +52,50 @@ export interface MessageLogOptions { */ export class MessageEmitter extends EventEmitter { - constructor(input: NodeJS.ReadableStream, options: MessageLogOptions = {}) { - super(); - const reader = new VSCodeStreamMessageReader(input); - // Forward events - reader.listen(msg => { - this.emit('message', msg); - }); - reader.onError(err => { - this.emit('error', err); - }); - reader.onClose(() => { - this.emit('close'); - }); - this.setMaxListeners(Infinity); - // Register message listener to log messages if configured - if (options.logMessages && options.logger) { - const logger = options.logger; - this.on('message', message => { - logger.log('-->', message); - }); - } - } + constructor(input: NodeJS.ReadableStream, options: MessageLogOptions = {}) { + super() + const reader = new VSCodeStreamMessageReader(input) + // Forward events + reader.listen(msg => { + this.emit('message', msg) + }) + reader.onError(err => { + this.emit('error', err) + }) + reader.onClose(() => { + this.emit('close') + }) + this.setMaxListeners(Infinity) + // Register message listener to log messages if configured + if (options.logMessages && options.logger) { + const logger = options.logger + this.on('message', message => { + logger.log('-->', message) + }) + } + } - /** Emitted when a new JSON RPC message was received on the input stream */ - on(event: 'message', listener: (message: Message) => void): this; - /** Emitted when the underlying input stream emitted an error */ - on(event: 'error', listener: (error: Error) => void): this; - /** Emitted when the underlying input stream was closed */ - on(event: 'close', listener: () => void): this; - /* istanbul ignore next */ - on(event: string, listener: (arg?: any) => void): this { - return super.on(event, listener); - } + /** Emitted when a new JSON RPC message was received on the input stream */ + public on(event: 'message', listener: (message: Message) => void): this + /** Emitted when the underlying input stream emitted an error */ + public on(event: 'error', listener: (error: Error) => void): this + /** Emitted when the underlying input stream was closed */ + public on(event: 'close', listener: () => void): this + /* istanbul ignore next */ + public on(event: string, listener: (arg?: any) => void): this { + return super.on(event, listener) + } - /** Emitted when a new JSON RPC message was received on the input stream */ - once(event: 'message', listener: (message: Message) => void): this; - /** Emitted when the underlying input stream emitted an error */ - once(event: 'error', listener: (error: Error) => void): this; - /** Emitted when the underlying input stream was closed */ - once(event: 'close', listener: () => void): this; - /* istanbul ignore next */ - once(event: string, listener: (arg?: any) => void): this { - return super.on(event, listener); - } + /** Emitted when a new JSON RPC message was received on the input stream */ + public once(event: 'message', listener: (message: Message) => void): this + /** Emitted when the underlying input stream emitted an error */ + public once(event: 'error', listener: (error: Error) => void): this + /** Emitted when the underlying input stream was closed */ + public once(event: 'close', listener: () => void): this + /* istanbul ignore next */ + public once(event: string, listener: (arg?: any) => void): this { + return super.on(event, listener) + } } /** @@ -105,40 +105,40 @@ export class MessageEmitter extends EventEmitter { */ export class MessageWriter { - private logger: Logger; - private logMessages: boolean; - private vscodeWriter: VSCodeStreamMessageWriter; + private logger: Logger + private logMessages: boolean + private vscodeWriter: VSCodeStreamMessageWriter - /** - * @param output The output stream to write to (e.g. STDOUT or a socket) - * @param options - */ - constructor(output: NodeJS.WritableStream, options: MessageLogOptions = {}) { - this.vscodeWriter = new VSCodeStreamMessageWriter(output); - this.logger = options.logger || new NoopLogger(); - this.logMessages = !!options.logMessages; - } + /** + * @param output The output stream to write to (e.g. STDOUT or a socket) + * @param options + */ + constructor(output: NodeJS.WritableStream, options: MessageLogOptions = {}) { + this.vscodeWriter = new VSCodeStreamMessageWriter(output) + this.logger = options.logger || new NoopLogger() + this.logMessages = !!options.logMessages + } - /** - * Writes a JSON RPC message to the output stream. - * Logs it if configured - * - * @param message A complete JSON RPC message object - */ - write(message: RequestMessage | NotificationMessage | ResponseMessage): void { - if (this.logMessages) { - this.logger.log('<--', message); - } - this.vscodeWriter.write(message); - } + /** + * Writes a JSON RPC message to the output stream. + * Logs it if configured + * + * @param message A complete JSON RPC message object + */ + public write(message: RequestMessage | NotificationMessage | ResponseMessage): void { + if (this.logMessages) { + this.logger.log('<--', message) + } + this.vscodeWriter.write(message) + } } export interface RegisterLanguageHandlerOptions { - logger?: Logger; + logger?: Logger - /** An opentracing-compatible tracer */ - tracer?: Tracer; + /** An opentracing-compatible tracer */ + tracer?: Tracer } /** @@ -148,192 +148,197 @@ export interface RegisterLanguageHandlerOptions { * @param messageWriter MessageWriter to write to * @param handler TypeScriptService object that contains methods for all methods to be handled */ -export function registerLanguageHandler(messageEmitter: MessageEmitter, messageWriter: MessageWriter, handler: TypeScriptService, options: RegisterLanguageHandlerOptions = {}): void { +export function registerLanguageHandler( + messageEmitter: MessageEmitter, + messageWriter: MessageWriter, + handler: TypeScriptService, + options: RegisterLanguageHandlerOptions = {} +): void { - const logger = options.logger || new NoopLogger(); - const tracer = options.tracer || new Tracer(); + const logger = options.logger || new NoopLogger() + const tracer = options.tracer || new Tracer() - /** Tracks Subscriptions for results to unsubscribe them on $/cancelRequest */ - const subscriptions = new Map(); + /** Tracks Subscriptions for results to unsubscribe them on $/cancelRequest */ + const subscriptions = new Map() - /** - * Whether the handler is in an initialized state. - * `initialize` sets this to true, `shutdown` to false. - * Used to determine whether a manual `shutdown` call is needed on error/close - */ - let initialized = false; + /** + * Whether the handler is in an initialized state. + * `initialize` sets this to true, `shutdown` to false. + * Used to determine whether a manual `shutdown` call is needed on error/close + */ + let initialized = false - /** Whether the client supports streaming with $/partialResult */ - let streaming = false; + /** Whether the client supports streaming with $/partialResult */ + let streaming = false - messageEmitter.on('message', async message => { - // Ignore responses - if (isResponseMessage(message)) { - return; - } - if (!isRequestMessage(message) && !isNotificationMessage(message)) { - logger.error('Received invalid message:', message); - return; - } - switch (message.method) { - case 'initialize': - initialized = true; - streaming = !!(message.params as InitializeParams).capabilities.streaming; - break; - case 'shutdown': - initialized = false; - break; - case 'exit': - // Ignore exit notification, it's not the responsibility of the TypeScriptService to handle it, - // but the TCP / STDIO server which needs to close the socket or kill the process - for (const subscription of subscriptions.values()) { - subscription.unsubscribe(); - } - return; - case '$/cancelRequest': - // Cancel another request by unsubscribing from the Observable - const subscription = subscriptions.get(message.params.id); - if (!subscription) { - logger.warn(`$/cancelRequest for unknown request ID ${message.params.id}`); - return; - } - subscription.unsubscribe(); - subscriptions.delete(message.params.id); - messageWriter.write({ - jsonrpc: '2.0', - id: message.params.id, - error: { - message: 'Request cancelled', - code: ErrorCodes.RequestCancelled - } - }); - return; - } - const method = camelCase(message.method); - let context: SpanContext | undefined; - // If message is request and has tracing metadata, extract the span context - if (isRequestMessage(message) && hasMeta(message)) { - context = tracer.extract(FORMAT_TEXT_MAP, message.meta) || undefined; - } - const span = tracer.startSpan('Handle ' + message.method, { childOf: context }); - span.setTag('params', inspect(message.params)); - if (typeof (handler as any)[method] !== 'function') { - // Method not implemented - if (isRequestMessage(message)) { - messageWriter.write({ - jsonrpc: '2.0', - id: message.id, - error: { - code: ErrorCodes.MethodNotFound, - message: `Method ${method} not implemented` - } - }); - } else { - logger.warn(`Method ${method} not implemented`); - } - return; - } - // Call handler method with params and span - let observable: Observable; - try { - // Convert return value to Observable - const returnValue = (handler as any)[method](message.params, span); - if (isObservable(returnValue)) { - observable = returnValue; - } else if (isPromiseLike(returnValue)) { - observable = Observable.from(returnValue); - } else { - observable = Observable.of(returnValue); - } - } catch (err) { - observable = Observable.throw(err); - } - if (isRequestMessage(message)) { - const subscription = observable - .do(patch => { - if (streaming) { - span.log({ event: 'partialResult', patch }); - // Send $/partialResult for partial result patches only if client supports it - messageWriter.write({ - jsonrpc: '2.0', - method: '$/partialResult', - params: { - id: message.id, - patch: [patch] - } as PartialResultParams - }); - } - }) - // Build up final result for BC - // TODO send null if client declared streaming capability - .reduce(applyReducer, null) - .finally(() => { - // Finish span - span.finish(); - // Delete subscription from Map - // Make sure to not run this before subscription.set() was called - // (in case the Observable is synchronous) - process.nextTick(() => { - subscriptions.delete(message.id); - }); - }) - .subscribe(result => { - // Send final result - messageWriter.write({ - jsonrpc: '2.0', - id: message.id, - result - }); - }, err => { - // Set error on span - span.setTag('error', true); - span.log({ 'event': 'error', 'error.object': err, 'message': err.message, 'stack': err.stack }); - // Log error - logger.error(`Handler for ${message.method} failed:`, err, '\nMessage:', message); - // Send error response - messageWriter.write({ - jsonrpc: '2.0', - id: message.id, - error: { - message: err.message + '', - code: typeof err.code === 'number' ? err.code : ErrorCodes.UnknownErrorCode, - data: omit(err, ['message', 'code']) - } - }); - }); - // Save subscription for $/cancelRequest - subscriptions.set(message.id, subscription); - } else { - // For notifications, still subscribe and log potential error - observable.subscribe(undefined, err => { - logger.error(`Handle ${method}:`, err); - }); - } - }); + messageEmitter.on('message', async message => { + // Ignore responses + if (isResponseMessage(message)) { + return + } + if (!isRequestMessage(message) && !isNotificationMessage(message)) { + logger.error('Received invalid message:', message) + return + } + switch (message.method) { + case 'initialize': + initialized = true + streaming = !!(message.params as InitializeParams).capabilities.streaming + break + case 'shutdown': + initialized = false + break + case 'exit': + // Ignore exit notification, it's not the responsibility of the TypeScriptService to handle it, + // but the TCP / STDIO server which needs to close the socket or kill the process + for (const subscription of subscriptions.values()) { + subscription.unsubscribe() + } + return + case '$/cancelRequest': + // Cancel another request by unsubscribing from the Observable + const subscription = subscriptions.get(message.params.id) + if (!subscription) { + logger.warn(`$/cancelRequest for unknown request ID ${message.params.id}`) + return + } + subscription.unsubscribe() + subscriptions.delete(message.params.id) + messageWriter.write({ + jsonrpc: '2.0', + id: message.params.id, + error: { + message: 'Request cancelled', + code: ErrorCodes.RequestCancelled + } + }) + return + } + const method = camelCase(message.method) + let context: SpanContext | undefined + // If message is request and has tracing metadata, extract the span context + if (isRequestMessage(message) && hasMeta(message)) { + context = tracer.extract(FORMAT_TEXT_MAP, message.meta) || undefined + } + const span = tracer.startSpan('Handle ' + message.method, { childOf: context }) + span.setTag('params', inspect(message.params)) + if (typeof (handler as any)[method] !== 'function') { + // Method not implemented + if (isRequestMessage(message)) { + messageWriter.write({ + jsonrpc: '2.0', + id: message.id, + error: { + code: ErrorCodes.MethodNotFound, + message: `Method ${method} not implemented` + } + }) + } else { + logger.warn(`Method ${method} not implemented`) + } + return + } + // Call handler method with params and span + let observable: Observable + try { + // Convert return value to Observable + const returnValue = (handler as any)[method](message.params, span) + if (isObservable(returnValue)) { + observable = returnValue + } else if (isPromiseLike(returnValue)) { + observable = Observable.from(returnValue) + } else { + observable = Observable.of(returnValue) + } + } catch (err) { + observable = Observable.throw(err) + } + if (isRequestMessage(message)) { + const subscription = observable + .do(patch => { + if (streaming) { + span.log({ event: 'partialResult', patch }) + // Send $/partialResult for partial result patches only if client supports it + messageWriter.write({ + jsonrpc: '2.0', + method: '$/partialResult', + params: { + id: message.id, + patch: [patch] + } as PartialResultParams + }) + } + }) + // Build up final result for BC + // TODO send null if client declared streaming capability + .reduce(applyReducer, null) + .finally(() => { + // Finish span + span.finish() + // Delete subscription from Map + // Make sure to not run this before subscription.set() was called + // (in case the Observable is synchronous) + process.nextTick(() => { + subscriptions.delete(message.id) + }) + }) + .subscribe(result => { + // Send final result + messageWriter.write({ + jsonrpc: '2.0', + id: message.id, + result + }) + }, err => { + // Set error on span + span.setTag('error', true) + span.log({ 'event': 'error', 'error.object': err, 'message': err.message, 'stack': err.stack }) + // Log error + logger.error(`Handler for ${message.method} failed:`, err, '\nMessage:', message) + // Send error response + messageWriter.write({ + jsonrpc: '2.0', + id: message.id, + error: { + message: err.message + '', + code: typeof err.code === 'number' ? err.code : ErrorCodes.UnknownErrorCode, + data: omit(err, ['message', 'code']) + } + }) + }) + // Save subscription for $/cancelRequest + subscriptions.set(message.id, subscription) + } else { + // For notifications, still subscribe and log potential error + observable.subscribe(undefined, err => { + logger.error(`Handle ${method}:`, err) + }) + } + }) - // On stream close, shutdown handler if it was initialized - messageEmitter.once('close', () => { - // Cancel all outstanding requests - for (const subscription of subscriptions.values()) { - subscription.unsubscribe(); - } - if (initialized) { - initialized = false; - logger.error('Stream was closed without shutdown notification'); - handler.shutdown(); - } - }); + // On stream close, shutdown handler if it was initialized + messageEmitter.once('close', () => { + // Cancel all outstanding requests + for (const subscription of subscriptions.values()) { + subscription.unsubscribe() + } + if (initialized) { + initialized = false + logger.error('Stream was closed without shutdown notification') + handler.shutdown() + } + }) - // On stream error, shutdown handler if it was initialized - messageEmitter.once('error', err => { - // Cancel all outstanding requests - for (const subscription of subscriptions.values()) { - subscription.unsubscribe(); - } - if (initialized) { - initialized = false; - logger.error('Stream:', err); - handler.shutdown(); - } - }); + // On stream error, shutdown handler if it was initialized + messageEmitter.once('error', err => { + // Cancel all outstanding requests + for (const subscription of subscriptions.values()) { + subscription.unsubscribe() + } + if (initialized) { + initialized = false + logger.error('Stream:', err) + handler.shutdown() + } + }) } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index bf0397262..1b5ca0c69 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -1,25 +1,25 @@ -import * as ts from 'typescript'; -import { Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver'; +import * as ts from 'typescript' +import { Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver' /** * Converts a TypeScript Diagnostic to an LSP Diagnostic */ export function convertTsDiagnostic(diagnostic: ts.Diagnostic): Diagnostic { - const text = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'); - let range: Range = { start: {character: 0, line: 0}, end: {character: 0, line: 0} }; - if (diagnostic.file && diagnostic.start && diagnostic.length) { - range = { - start: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start), - end: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start + diagnostic.length) - }; - } - return { - range, - message: text, - severity: convertDiagnosticCategory(diagnostic.category), - code: diagnostic.code, - source: diagnostic.source || 'ts' - }; + const text = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n') + let range: Range = { start: {character: 0, line: 0}, end: {character: 0, line: 0} } + if (diagnostic.file && diagnostic.start && diagnostic.length) { + range = { + start: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start), + end: diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start + diagnostic.length) + } + } + return { + range, + message: text, + severity: convertDiagnosticCategory(diagnostic.category), + code: diagnostic.code, + source: diagnostic.source || 'ts' + } } /** @@ -28,13 +28,13 @@ export function convertTsDiagnostic(diagnostic: ts.Diagnostic): Diagnostic { * @param category The Typescript DiagnosticCategory */ function convertDiagnosticCategory(category: ts.DiagnosticCategory): DiagnosticSeverity { - switch (category) { - case ts.DiagnosticCategory.Error: - return DiagnosticSeverity.Error; - case ts.DiagnosticCategory.Warning: - return DiagnosticSeverity.Warning; - case ts.DiagnosticCategory.Message: - return DiagnosticSeverity.Information; - // unmapped: DiagnosticSeverity.Hint - } + switch (category) { + case ts.DiagnosticCategory.Error: + return DiagnosticSeverity.Error + case ts.DiagnosticCategory.Warning: + return DiagnosticSeverity.Warning + case ts.DiagnosticCategory.Message: + return DiagnosticSeverity.Information + // unmapped: DiagnosticSeverity.Hint + } } diff --git a/src/disposable.ts b/src/disposable.ts index 8633d684a..0e7bed7c3 100644 --- a/src/disposable.ts +++ b/src/disposable.ts @@ -3,10 +3,10 @@ * Interface for objects to perform actions if they are not needed anymore */ export interface Disposable { - /** - * Disposes the object. Example actions: - * - Cancelling all pending operations this object started - * - Unsubscribing all listeners this object added - */ - dispose(): void; + /** + * Disposes the object. Example actions: + * - Cancelling all pending operations this object started + * - Unsubscribing all listeners this object added + */ + dispose(): void } diff --git a/src/fs.ts b/src/fs.ts index e2bddfe13..6392f0dd9 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,99 +1,101 @@ -import { Observable } from '@reactivex/rxjs'; -import { Glob } from 'glob'; -import * as fs from 'mz/fs'; -import { Span } from 'opentracing'; -import Semaphore from 'semaphore-async-await'; -import { LanguageClient } from './lang-handler'; -import { InMemoryFileSystem } from './memfs'; -import { traceObservable } from './tracing'; -import { normalizeUri, uri2path } from './util'; +import { Observable } from '@reactivex/rxjs' +import { Glob } from 'glob' +import * as fs from 'mz/fs' +import { Span } from 'opentracing' +import Semaphore from 'semaphore-async-await' +import { LanguageClient } from './lang-handler' +import { InMemoryFileSystem } from './memfs' +import { traceObservable } from './tracing' +import { normalizeUri, uri2path } from './util' export interface FileSystem { - /** - * Returns all files in the workspace under base - * - * @param base A URI under which to search, resolved relative to the rootUri - * @return An Observable that emits URIs - */ - getWorkspaceFiles(base?: string, childOf?: Span): Observable; - - /** - * Returns the content of a text document - * - * @param uri The URI of the text document, resolved relative to the rootUri - * @return An Observable that emits the text document content - */ - getTextDocumentContent(uri: string, childOf?: Span): Observable; + /** + * Returns all files in the workspace under base + * + * @param base A URI under which to search, resolved relative to the rootUri + * @return An Observable that emits URIs + */ + getWorkspaceFiles(base?: string, childOf?: Span): Observable + + /** + * Returns the content of a text document + * + * @param uri The URI of the text document, resolved relative to the rootUri + * @return An Observable that emits the text document content + */ + getTextDocumentContent(uri: string, childOf?: Span): Observable } export class RemoteFileSystem implements FileSystem { - constructor(private client: LanguageClient) {} - - /** - * The files request is sent from the server to the client to request a list of all files in the workspace or inside the directory of the base parameter, if given. - * A language server can use the result to index files by filtering and doing a content request for each text document of interest. - */ - getWorkspaceFiles(base?: string, childOf = new Span()): Observable { - return this.client.workspaceXfiles({ base }, childOf) - .mergeMap(textDocuments => textDocuments) - .map(textDocument => normalizeUri(textDocument.uri)); - } - - /** - * The content request is sent from the server to the client to request the current content of any text document. This allows language servers to operate without accessing the file system directly. - */ - getTextDocumentContent(uri: string, childOf = new Span()): Observable { - return this.client.textDocumentXcontent({ textDocument: { uri } }, childOf) - .map(textDocument => textDocument.text); - } + constructor(private client: LanguageClient) {} + + /** + * The files request is sent from the server to the client to request a list of all files in the workspace or inside the directory of the base parameter, if given. + * A language server can use the result to index files by filtering and doing a content request for each text document of interest. + */ + public getWorkspaceFiles(base?: string, childOf = new Span()): Observable { + return this.client.workspaceXfiles({ base }, childOf) + .mergeMap(textDocuments => textDocuments) + .map(textDocument => normalizeUri(textDocument.uri)) + } + + /** + * The content request is sent from the server to the client to request the current content of + * any text document. This allows language servers to operate without accessing the file system + * directly. + */ + public getTextDocumentContent(uri: string, childOf = new Span()): Observable { + return this.client.textDocumentXcontent({ textDocument: { uri } }, childOf) + .map(textDocument => textDocument.text) + } } export class LocalFileSystem implements FileSystem { - /** - * @param rootUri The root URI that is used if `base` is not specified - */ - constructor(private rootUri: string) {} - - /** - * Converts the URI to an absolute path on the local disk - */ - protected resolveUriToPath(uri: string): string { - return uri2path(uri); - } - - getWorkspaceFiles(base = this.rootUri): Observable { - if (!base.endsWith('/')) { - base += '/'; - } - const cwd = this.resolveUriToPath(base); - return new Observable(subscriber => { - const globber = new Glob('*', { - cwd, - nodir: true, - matchBase: true, - follow: true - }); - globber.on('match', (file: string) => { - subscriber.next(normalizeUri(base + file)); - }); - globber.on('error', (err: any) => { - subscriber.error(err); - }); - globber.on('end', () => { - subscriber.complete(); - }); - return () => { - globber.abort(); - }; - }); - } - - getTextDocumentContent(uri: string): Observable { - const filePath = this.resolveUriToPath(uri); - return Observable.fromPromise(fs.readFile(filePath, 'utf8')); - } + /** + * @param rootUri The root URI that is used if `base` is not specified + */ + constructor(private rootUri: string) {} + + /** + * Converts the URI to an absolute path on the local disk + */ + protected resolveUriToPath(uri: string): string { + return uri2path(uri) + } + + public getWorkspaceFiles(base = this.rootUri): Observable { + if (!base.endsWith('/')) { + base += '/' + } + const cwd = this.resolveUriToPath(base) + return new Observable(subscriber => { + const globber = new Glob('*', { + cwd, + nodir: true, + matchBase: true, + follow: true + }) + globber.on('match', (file: string) => { + subscriber.next(normalizeUri(base + file)) + }) + globber.on('error', (err: any) => { + subscriber.error(err) + }) + globber.on('end', () => { + subscriber.complete() + }) + return () => { + globber.abort() + } + }) + } + + public getTextDocumentContent(uri: string): Observable { + const filePath = this.resolveUriToPath(uri) + return Observable.fromPromise(fs.readFile(filePath, 'utf8')) + } } /** @@ -103,110 +105,110 @@ export class LocalFileSystem implements FileSystem { */ export class FileSystemUpdater { - /** - * Observable for a pending or completed structure fetch - */ - private structureFetch?: Observable; - - /** - * Map from URI to Observable of pending or completed content fetch - */ - private fetches = new Map>(); - - /** - * Limits concurrent fetches to not fetch thousands of files in parallel - */ - private concurrencyLimit = new Semaphore(100); - - constructor(private remoteFs: FileSystem, private inMemoryFs: InMemoryFileSystem) {} - - /** - * Fetches the file content for the given URI and adds the content to the in-memory file system - * - * @param uri URI of the file to fetch - * @param childOf A parent span for tracing - * @return Observable that completes when the fetch is finished - */ - fetch(uri: string, childOf = new Span()): Observable { - // Limit concurrent fetches - const observable = Observable.fromPromise(this.concurrencyLimit.wait()) - .mergeMap(() => this.remoteFs.getTextDocumentContent(uri)) - .do(content => { - this.concurrencyLimit.signal(); - this.inMemoryFs.add(uri, content); - }, err => { - this.fetches.delete(uri); - }) - .ignoreElements() - .publishReplay() - .refCount() as Observable; - this.fetches.set(uri, observable); - return observable; - } - - /** - * Returns a promise that is resolved when the given URI has been fetched (at least once) to the in-memory file system. - * This function cannot be cancelled because multiple callers get the result of the same operation. - * - * @param uri URI of the file to ensure - * @param childOf An OpenTracing span for tracing - * @return Observable that completes when the file was fetched - */ - ensure(uri: string, childOf = new Span()): Observable { - return traceObservable('Ensure content', childOf, span => { - span.addTags({ uri }); - return this.fetches.get(uri) || this.fetch(uri, span); - }); - } - - /** - * Fetches the file/directory structure for the given directory from the remote file system and saves it in the in-memory file system - * - * @param childOf A parent span for tracing - */ - fetchStructure(childOf = new Span()): Observable { - const observable = traceObservable('Fetch workspace structure', childOf, span => - this.remoteFs.getWorkspaceFiles(undefined, span) - .do(uri => { - this.inMemoryFs.add(uri); - }, err => { - this.structureFetch = undefined; - }) - .ignoreElements() - .publishReplay() - .refCount() as Observable - ); - this.structureFetch = observable; - return observable; - } - - /** - * Returns a promise that is resolved as soon as the file/directory structure for the given directory has been synced - * from the remote file system to the in-memory file system (at least once) - * - * @param span An OpenTracing span for tracing - */ - ensureStructure(childOf = new Span()): Observable { - return traceObservable('Ensure structure', childOf, span => { - return this.structureFetch || this.fetchStructure(span); - }); - } - - /** - * Invalidates the content fetch cache of a file. - * The next call to `ensure` will do a refetch. - * - * @param uri URI of the file that changed - */ - invalidate(uri: string): void { - this.fetches.delete(uri); - } - - /** - * Invalidates the structure fetch cache. - * The next call to `ensureStructure` will do a refetch. - */ - invalidateStructure(): void { - this.structureFetch = undefined; - } + /** + * Observable for a pending or completed structure fetch + */ + private structureFetch?: Observable + + /** + * Map from URI to Observable of pending or completed content fetch + */ + private fetches = new Map>() + + /** + * Limits concurrent fetches to not fetch thousands of files in parallel + */ + private concurrencyLimit = new Semaphore(100) + + constructor(private remoteFs: FileSystem, private inMemoryFs: InMemoryFileSystem) {} + + /** + * Fetches the file content for the given URI and adds the content to the in-memory file system + * + * @param uri URI of the file to fetch + * @param childOf A parent span for tracing + * @return Observable that completes when the fetch is finished + */ + public fetch(uri: string, childOf = new Span()): Observable { + // Limit concurrent fetches + const observable = Observable.fromPromise(this.concurrencyLimit.wait()) + .mergeMap(() => this.remoteFs.getTextDocumentContent(uri)) + .do(content => { + this.concurrencyLimit.signal() + this.inMemoryFs.add(uri, content) + }, err => { + this.fetches.delete(uri) + }) + .ignoreElements() + .publishReplay() + .refCount() as Observable + this.fetches.set(uri, observable) + return observable + } + + /** + * Returns a promise that is resolved when the given URI has been fetched (at least once) to the in-memory file system. + * This function cannot be cancelled because multiple callers get the result of the same operation. + * + * @param uri URI of the file to ensure + * @param childOf An OpenTracing span for tracing + * @return Observable that completes when the file was fetched + */ + public ensure(uri: string, childOf = new Span()): Observable { + return traceObservable('Ensure content', childOf, span => { + span.addTags({ uri }) + return this.fetches.get(uri) || this.fetch(uri, span) + }) + } + + /** + * Fetches the file/directory structure for the given directory from the remote file system and saves it in the in-memory file system + * + * @param childOf A parent span for tracing + */ + public fetchStructure(childOf = new Span()): Observable { + const observable = traceObservable('Fetch workspace structure', childOf, span => + this.remoteFs.getWorkspaceFiles(undefined, span) + .do(uri => { + this.inMemoryFs.add(uri) + }, err => { + this.structureFetch = undefined + }) + .ignoreElements() + .publishReplay() + .refCount() as Observable + ) + this.structureFetch = observable + return observable + } + + /** + * Returns a promise that is resolved as soon as the file/directory structure for the given directory has been synced + * from the remote file system to the in-memory file system (at least once) + * + * @param span An OpenTracing span for tracing + */ + public ensureStructure(childOf = new Span()): Observable { + return traceObservable('Ensure structure', childOf, span => { + return this.structureFetch || this.fetchStructure(span) + }) + } + + /** + * Invalidates the content fetch cache of a file. + * The next call to `ensure` will do a refetch. + * + * @param uri URI of the file that changed + */ + public invalidate(uri: string): void { + this.fetches.delete(uri) + } + + /** + * Invalidates the structure fetch cache. + * The next call to `ensureStructure` will do a refetch. + */ + public invalidateStructure(): void { + this.structureFetch = undefined + } } diff --git a/src/lang-handler.ts b/src/lang-handler.ts index c257b91f2..1dd17823a 100644 --- a/src/lang-handler.ts +++ b/src/lang-handler.ts @@ -1,71 +1,71 @@ -import { Observable } from '@reactivex/rxjs'; -import { FORMAT_TEXT_MAP, Span } from 'opentracing'; -import { inspect } from 'util'; -import { isResponseMessage, Message, NotificationMessage, RequestMessage, ResponseMessage } from 'vscode-jsonrpc/lib/messages'; +import { Observable } from '@reactivex/rxjs' +import { FORMAT_TEXT_MAP, Span } from 'opentracing' +import { inspect } from 'util' +import { isResponseMessage, Message, NotificationMessage, RequestMessage, ResponseMessage } from 'vscode-jsonrpc/lib/messages' import { - ApplyWorkspaceEditParams, - ApplyWorkspaceEditResponse, - LogMessageParams, - PublishDiagnosticsParams, - TextDocumentIdentifier, - TextDocumentItem -} from 'vscode-languageserver'; -import { HasMeta } from './connection'; -import { MessageEmitter, MessageWriter } from './connection'; + ApplyWorkspaceEditParams, + ApplyWorkspaceEditResponse, + LogMessageParams, + PublishDiagnosticsParams, + TextDocumentIdentifier, + TextDocumentItem +} from 'vscode-languageserver' +import { HasMeta } from './connection' +import { MessageEmitter, MessageWriter } from './connection' import { - CacheGetParams, - CacheSetParams, - TextDocumentContentParams, - WorkspaceFilesParams -} from './request-type'; -import { traceObservable } from './tracing'; + CacheGetParams, + CacheSetParams, + TextDocumentContentParams, + WorkspaceFilesParams +} from './request-type' +import { traceObservable } from './tracing' export interface LanguageClient { - /** - * The content request is sent from the server to the client to request the current content of - * any text document. This allows language servers to operate without accessing the file system - * directly. - */ - textDocumentXcontent(params: TextDocumentContentParams, childOf?: Span): Observable; - - /** - * The files request is sent from the server to the client to request a list of all files in the - * workspace or inside the directory of the `base` parameter, if given. - */ - workspaceXfiles(params: WorkspaceFilesParams, childOf?: Span): Observable; - - /** - * The log message notification is sent from the server to the client to ask - * the client to log a particular message. - */ - windowLogMessage(params: LogMessageParams): void; - - /** - * The cache get request is sent from the server to the client to request the value of a cache - * item identified by the provided key. - */ - xcacheGet(params: CacheGetParams, childOf?: Span): Observable; - - /** - * The cache set notification is sent from the server to the client to set the value of a cache - * item identified by the provided key. This is a intentionally notification and not a request - * because the server is not supposed to act differently if the cache set failed. - */ - xcacheSet(params: CacheSetParams): void; - - /** - * Diagnostics are sent from the server to the client to notify the user of errors/warnings - * in a source file - * @param params The diagnostics to send to the client - */ - textDocumentPublishDiagnostics(params: PublishDiagnosticsParams): void; - - /** - * Requests a set of text changes to be applied to documents in the workspace - * Can occur as as a result of rename or executeCommand (code action). - * @param params The edits to apply to the workspace - */ - workspaceApplyEdit(params: ApplyWorkspaceEditParams, childOf?: Span): Observable; + /** + * The content request is sent from the server to the client to request the current content of + * any text document. This allows language servers to operate without accessing the file system + * directly. + */ + textDocumentXcontent(params: TextDocumentContentParams, childOf?: Span): Observable + + /** + * The files request is sent from the server to the client to request a list of all files in the + * workspace or inside the directory of the `base` parameter, if given. + */ + workspaceXfiles(params: WorkspaceFilesParams, childOf?: Span): Observable + + /** + * The log message notification is sent from the server to the client to ask + * the client to log a particular message. + */ + windowLogMessage(params: LogMessageParams): void + + /** + * The cache get request is sent from the server to the client to request the value of a cache + * item identified by the provided key. + */ + xcacheGet(params: CacheGetParams, childOf?: Span): Observable + + /** + * The cache set notification is sent from the server to the client to set the value of a cache + * item identified by the provided key. This is a intentionally notification and not a request + * because the server is not supposed to act differently if the cache set failed. + */ + xcacheSet(params: CacheSetParams): void + + /** + * Diagnostics are sent from the server to the client to notify the user of errors/warnings + * in a source file + * @param params The diagnostics to send to the client + */ + textDocumentPublishDiagnostics(params: PublishDiagnosticsParams): void + + /** + * Requests a set of text changes to be applied to documents in the workspace + * Can occur as as a result of rename or executeCommand (code action). + * @param params The edits to apply to the workspace + */ + workspaceApplyEdit(params: ApplyWorkspaceEditParams, childOf?: Span): Observable } /** @@ -74,130 +74,130 @@ export interface LanguageClient { */ export class RemoteLanguageClient { - /** The next request ID to use */ - private idCounter = 1; - - /** - * @param input MessageEmitter to listen on for responses - * @param output MessageWriter to write requests/notifications to - */ - constructor(private input: MessageEmitter, private output: MessageWriter) {} - - /** - * Sends a Request - * - * @param method The method to call - * @param params The params to pass to the method - * @return Emits the value of the result field or the error - */ - private request(method: string, params: any[] | { [attr: string]: any }, childOf = new Span()): Observable { - return traceObservable(`Request ${method}`, childOf, span => { - span.setTag('params', inspect(params)); - return new Observable(subscriber => { - // Generate a request ID - const id = this.idCounter++; - const message: RequestMessage & HasMeta = { jsonrpc: '2.0', method, id, params, meta: {} }; - childOf.tracer().inject(span, FORMAT_TEXT_MAP, message.meta); - // Send request - this.output.write(message); - let receivedResponse = false; - // Subscribe to message events - const messageSub = Observable.fromEvent(this.input, 'message') - // Find response message with the correct ID - .filter(msg => isResponseMessage(msg) && msg.id === id) - .take(1) - // Emit result or error - .map((msg: ResponseMessage): any => { - receivedResponse = true; - if (msg.error) { - throw Object.assign(new Error(msg.error.message), msg.error); - } - return msg.result; - }) - // Forward events to subscriber - .subscribe(subscriber); - // Handler for unsubscribe() - return () => { - // Unsubscribe message event subscription (removes listener) - messageSub.unsubscribe(); - if (!receivedResponse) { - // Send LSP $/cancelRequest to client - this.notify('$/cancelRequest', { id }); - } - }; - }); - }); - } - - /** - * Sends a Notification - * - * @param method The method to notify - * @param params The params to pass to the method - */ - private notify(method: string, params: any[] | { [attr: string]: any }): void { - const message: NotificationMessage = { jsonrpc: '2.0', method, params }; - this.output.write(message); - } - - /** - * The content request is sent from the server to the client to request the current content of - * any text document. This allows language servers to operate without accessing the file system - * directly. - */ - textDocumentXcontent(params: TextDocumentContentParams, childOf = new Span()): Observable { - return this.request('textDocument/xcontent', params, childOf); - } - - /** - * The files request is sent from the server to the client to request a list of all files in the - * workspace or inside the directory of the `base` parameter, if given. - */ - workspaceXfiles(params: WorkspaceFilesParams, childOf = new Span()): Observable { - return this.request('workspace/xfiles', params, childOf); - } - - /** - * The log message notification is sent from the server to the client to ask - * the client to log a particular message. - */ - windowLogMessage(params: LogMessageParams): void { - this.notify('window/logMessage', params); - } - - /** - * The cache get request is sent from the server to the client to request the value of a cache - * item identified by the provided key. - */ - xcacheGet(params: CacheGetParams, childOf = new Span()): Observable { - return this.request('xcache/get', params, childOf); - } - - /** - * The cache set notification is sent from the server to the client to set the value of a cache - * item identified by the provided key. This is a intentionally notification and not a request - * because the server is not supposed to act differently if the cache set failed. - */ - xcacheSet(params: CacheSetParams): void { - this.notify('xcache/set', params); - } - - /** - * Diagnostics are sent from the server to the client to notify the user of errors/warnings - * in a source file - * @param params The diagnostics to send to the client - */ - textDocumentPublishDiagnostics(params: PublishDiagnosticsParams): void { - this.notify('textDocument/publishDiagnostics', params); - } - - /** - * The workspace/applyEdit request is sent from the server to the client to modify resource on - * the client side. - * - * @param params The edits to apply. - */ - workspaceApplyEdit(params: ApplyWorkspaceEditParams, childOf = new Span()): Observable { - return this.request('workspace/applyEdit', params, childOf); - } + /** The next request ID to use */ + private idCounter = 1 + + /** + * @param input MessageEmitter to listen on for responses + * @param output MessageWriter to write requests/notifications to + */ + constructor(private input: MessageEmitter, private output: MessageWriter) {} + + /** + * Sends a Request + * + * @param method The method to call + * @param params The params to pass to the method + * @return Emits the value of the result field or the error + */ + private request(method: string, params: any[] | { [attr: string]: any }, childOf = new Span()): Observable { + return traceObservable(`Request ${method}`, childOf, span => { + span.setTag('params', inspect(params)) + return new Observable(subscriber => { + // Generate a request ID + const id = this.idCounter++ + const message: RequestMessage & HasMeta = { jsonrpc: '2.0', method, id, params, meta: {} } + childOf.tracer().inject(span, FORMAT_TEXT_MAP, message.meta) + // Send request + this.output.write(message) + let receivedResponse = false + // Subscribe to message events + const messageSub = Observable.fromEvent(this.input, 'message') + // Find response message with the correct ID + .filter(msg => isResponseMessage(msg) && msg.id === id) + .take(1) + // Emit result or error + .map((msg: ResponseMessage): any => { + receivedResponse = true + if (msg.error) { + throw Object.assign(new Error(msg.error.message), msg.error) + } + return msg.result + }) + // Forward events to subscriber + .subscribe(subscriber) + // Handler for unsubscribe() + return () => { + // Unsubscribe message event subscription (removes listener) + messageSub.unsubscribe() + if (!receivedResponse) { + // Send LSP $/cancelRequest to client + this.notify('$/cancelRequest', { id }) + } + } + }) + }) + } + + /** + * Sends a Notification + * + * @param method The method to notify + * @param params The params to pass to the method + */ + private notify(method: string, params: any[] | { [attr: string]: any }): void { + const message: NotificationMessage = { jsonrpc: '2.0', method, params } + this.output.write(message) + } + + /** + * The content request is sent from the server to the client to request the current content of + * any text document. This allows language servers to operate without accessing the file system + * directly. + */ + public textDocumentXcontent(params: TextDocumentContentParams, childOf = new Span()): Observable { + return this.request('textDocument/xcontent', params, childOf) + } + + /** + * The files request is sent from the server to the client to request a list of all files in the + * workspace or inside the directory of the `base` parameter, if given. + */ + public workspaceXfiles(params: WorkspaceFilesParams, childOf = new Span()): Observable { + return this.request('workspace/xfiles', params, childOf) + } + + /** + * The log message notification is sent from the server to the client to ask + * the client to log a particular message. + */ + public windowLogMessage(params: LogMessageParams): void { + this.notify('window/logMessage', params) + } + + /** + * The cache get request is sent from the server to the client to request the value of a cache + * item identified by the provided key. + */ + public xcacheGet(params: CacheGetParams, childOf = new Span()): Observable { + return this.request('xcache/get', params, childOf) + } + + /** + * The cache set notification is sent from the server to the client to set the value of a cache + * item identified by the provided key. This is a intentionally notification and not a request + * because the server is not supposed to act differently if the cache set failed. + */ + public xcacheSet(params: CacheSetParams): void { + this.notify('xcache/set', params) + } + + /** + * Diagnostics are sent from the server to the client to notify the user of errors/warnings + * in a source file + * @param params The diagnostics to send to the client + */ + public textDocumentPublishDiagnostics(params: PublishDiagnosticsParams): void { + this.notify('textDocument/publishDiagnostics', params) + } + + /** + * The workspace/applyEdit request is sent from the server to the client to modify resource on + * the client side. + * + * @param params The edits to apply. + */ + public workspaceApplyEdit(params: ApplyWorkspaceEditParams, childOf = new Span()): Observable { + return this.request('workspace/applyEdit', params, childOf) + } } diff --git a/src/language-server-stdio.ts b/src/language-server-stdio.ts index a7cefbab1..88a6eeee3 100644 --- a/src/language-server-stdio.ts +++ b/src/language-server-stdio.ts @@ -1,45 +1,45 @@ #!/usr/bin/env node -import { Tracer } from 'opentracing'; -import { isNotificationMessage } from 'vscode-jsonrpc/lib/messages'; -import { MessageEmitter, MessageLogOptions, MessageWriter, registerLanguageHandler, RegisterLanguageHandlerOptions } from './connection'; -import { RemoteLanguageClient } from './lang-handler'; -import { FileLogger, StderrLogger } from './logging'; -import { TypeScriptService, TypeScriptServiceOptions } from './typescript-service'; +import { Tracer } from 'opentracing' +import { isNotificationMessage } from 'vscode-jsonrpc/lib/messages' +import { MessageEmitter, MessageLogOptions, MessageWriter, registerLanguageHandler, RegisterLanguageHandlerOptions } from './connection' +import { RemoteLanguageClient } from './lang-handler' +import { FileLogger, StderrLogger } from './logging' +import { TypeScriptService, TypeScriptServiceOptions } from './typescript-service' -const packageJson = require('../package.json'); -const program = require('commander'); -const { initTracer } = require('jaeger-client'); +const packageJson = require('../package.json') +const program = require('commander') +const { initTracer } = require('jaeger-client') program - .version(packageJson.version) - .option('-s, --strict', 'enables strict mode') - .option('-t, --trace', 'print all requests and responses') - .option('-l, --logfile [file]', 'log to this file') - .option('-j, --enable-jaeger', 'enable OpenTracing through Jaeger') - .parse(process.argv); + .version(packageJson.version) + .option('-s, --strict', 'enables strict mode') + .option('-t, --trace', 'print all requests and responses') + .option('-l, --logfile [file]', 'log to this file') + .option('-j, --enable-jaeger', 'enable OpenTracing through Jaeger') + .parse(process.argv) -const logger = program.logfile ? new FileLogger(program.logfile) : new StderrLogger(); -const tracer = program.enableJaeger ? initTracer({ serviceName: 'javascript-typescript-langserver', sampler: { type: 'const', param: 1 } }) : new Tracer(); +const logger = program.logfile ? new FileLogger(program.logfile) : new StderrLogger() +const tracer = program.enableJaeger ? initTracer({ serviceName: 'javascript-typescript-langserver', sampler: { type: 'const', param: 1 } }) : new Tracer() const options: TypeScriptServiceOptions & MessageLogOptions & RegisterLanguageHandlerOptions = { - strict: program.strict, - logMessages: program.trace, - logger, - tracer -}; + strict: program.strict, + logMessages: program.trace, + logger, + tracer +} -const messageEmitter = new MessageEmitter(process.stdin, options); -const messageWriter = new MessageWriter(process.stdout, options); -const remoteClient = new RemoteLanguageClient(messageEmitter, messageWriter); -const service = new TypeScriptService(remoteClient, options); +const messageEmitter = new MessageEmitter(process.stdin, options) +const messageWriter = new MessageWriter(process.stdout, options) +const remoteClient = new RemoteLanguageClient(messageEmitter, messageWriter) +const service = new TypeScriptService(remoteClient, options) // Add an exit notification handler to kill the process messageEmitter.on('message', message => { - if (isNotificationMessage(message) && message.method === 'exit') { - logger.log(`Exit notification`); - process.exit(0); - } -}); + if (isNotificationMessage(message) && message.method === 'exit') { + logger.log(`Exit notification`) + process.exit(0) + } +}) -registerLanguageHandler(messageEmitter, messageWriter, service, options); +registerLanguageHandler(messageEmitter, messageWriter, service, options) diff --git a/src/language-server.ts b/src/language-server.ts index 83f28d119..06aa6b5a8 100644 --- a/src/language-server.ts +++ b/src/language-server.ts @@ -1,33 +1,33 @@ #!/usr/bin/env node -import { Tracer } from 'opentracing'; -import { FileLogger, StdioLogger } from './logging'; -import { serve, ServeOptions } from './server'; -import { TypeScriptService, TypeScriptServiceOptions } from './typescript-service'; -const program = require('commander'); -const packageJson = require('../package.json'); -const { initTracer } = require('jaeger-client'); +import { Tracer } from 'opentracing' +import { FileLogger, StdioLogger } from './logging' +import { serve, ServeOptions } from './server' +import { TypeScriptService, TypeScriptServiceOptions } from './typescript-service' +const program = require('commander') +const packageJson = require('../package.json') +const { initTracer } = require('jaeger-client') -const defaultLspPort = 2089; -const numCPUs = require('os').cpus().length; +const defaultLspPort = 2089 +const numCPUs = require('os').cpus().length program - .version(packageJson.version) - .option('-s, --strict', 'enabled strict mode') - .option('-p, --port [port]', 'specifies LSP port to use (' + defaultLspPort + ')', parseInt) - .option('-c, --cluster [num]', 'number of concurrent cluster workers (defaults to number of CPUs, ' + numCPUs + ')', parseInt) - .option('-t, --trace', 'print all requests and responses') - .option('-l, --logfile [file]', 'log to this file') - .option('-j, --enable-jaeger', 'enable OpenTracing through Jaeger') - .parse(process.argv); + .version(packageJson.version) + .option('-s, --strict', 'enabled strict mode') + .option('-p, --port [port]', 'specifies LSP port to use (' + defaultLspPort + ')', parseInt) + .option('-c, --cluster [num]', 'number of concurrent cluster workers (defaults to number of CPUs, ' + numCPUs + ')', parseInt) + .option('-t, --trace', 'print all requests and responses') + .option('-l, --logfile [file]', 'log to this file') + .option('-j, --enable-jaeger', 'enable OpenTracing through Jaeger') + .parse(process.argv) const options: ServeOptions & TypeScriptServiceOptions = { - clusterSize: program.cluster || numCPUs, - lspPort: program.port || defaultLspPort, - strict: program.strict, - logMessages: program.trace, - logger: program.logfile ? new FileLogger(program.logfile) : new StdioLogger(), - tracer: program.enableJaeger ? initTracer({ serviceName: 'javascript-typescript-langserver', sampler: { type: 'const', param: 1 } }) : new Tracer() -}; + clusterSize: program.cluster || numCPUs, + lspPort: program.port || defaultLspPort, + strict: program.strict, + logMessages: program.trace, + logger: program.logfile ? new FileLogger(program.logfile) : new StdioLogger(), + tracer: program.enableJaeger ? initTracer({ serviceName: 'javascript-typescript-langserver', sampler: { type: 'const', param: 1 } }) : new Tracer() +} -serve(options, client => new TypeScriptService(client, options)); +serve(options, client => new TypeScriptService(client, options)) diff --git a/src/logging.ts b/src/logging.ts index d540aa51a..abe0288a8 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -1,22 +1,22 @@ -import * as chalk from 'chalk'; -import * as fs from 'fs'; -import { inspect } from 'util'; -import { MessageType } from 'vscode-languageserver'; -import { LanguageClient } from './lang-handler'; +import * as chalk from 'chalk' +import * as fs from 'fs' +import { inspect } from 'util' +import { MessageType } from 'vscode-languageserver' +import { LanguageClient } from './lang-handler' export interface Logger { - log(...values: any[]): void; - info(...values: any[]): void; - warn(...values: any[]): void; - error(...values: any[]): void; + log(...values: any[]): void + info(...values: any[]): void + warn(...values: any[]): void + error(...values: any[]): void } /** * Formats values to a message by pretty-printing objects */ function format(values: any[]): string { - return values.map(value => typeof value === 'string' ? value : inspect(value, {depth: Infinity})).join(' '); + return values.map(value => typeof value === 'string' ? value : inspect(value, {depth: Infinity})).join(' ') } /** @@ -24,111 +24,111 @@ function format(values: any[]): string { */ export class LSPLogger implements Logger { - /** - * @param client The client to send window/logMessage notifications to - */ - constructor(private client: LanguageClient) {} - - log(...values: any[]): void { - try { - this.client.windowLogMessage({ type: MessageType.Log, message: format(values) }); - } catch (err) { - // ignore - } - } - - info(...values: any[]): void { - try { - this.client.windowLogMessage({ type: MessageType.Info, message: format(values) }); - } catch (err) { - // ignore - } - } - - warn(...values: any[]): void { - try { - this.client.windowLogMessage({ type: MessageType.Warning, message: format(values) }); - } catch (err) { - // ignore - } - } - - error(...values: any[]): void { - try { - this.client.windowLogMessage({ type: MessageType.Error, message: format(values) }); - } catch (err) { - // ignore - } - } + /** + * @param client The client to send window/logMessage notifications to + */ + constructor(private client: LanguageClient) {} + + public log(...values: any[]): void { + try { + this.client.windowLogMessage({ type: MessageType.Log, message: format(values) }) + } catch (err) { + // ignore + } + } + + public info(...values: any[]): void { + try { + this.client.windowLogMessage({ type: MessageType.Info, message: format(values) }) + } catch (err) { + // ignore + } + } + + public warn(...values: any[]): void { + try { + this.client.windowLogMessage({ type: MessageType.Warning, message: format(values) }) + } catch (err) { + // ignore + } + } + + public error(...values: any[]): void { + try { + this.client.windowLogMessage({ type: MessageType.Error, message: format(values) }) + } catch (err) { + // ignore + } + } } /** * Logging implementation that writes to an arbitrary NodeJS stream */ export class StreamLogger { - constructor(private outStream: NodeJS.WritableStream, private errStream: NodeJS.WritableStream) {} - log(...values: any[]): void { - try { - this.outStream.write(chalk.grey('DEBUG ' + format(values) + '\n')); - } catch (err) { - // ignore - } - } - - info(...values: any[]): void { - try { - this.outStream.write(chalk.bgCyan('INFO') + ' ' + format(values) + '\n'); - } catch (err) { - // ignore - } - } - - warn(...values: any[]): void { - try { - this.errStream.write(chalk.bgYellow('WARN') + ' ' + format(values) + '\n'); - } catch (err) { - // ignore - } - } - - error(...values: any[]): void { - try { - this.errStream.write(chalk.bgRed('ERROR') + ' ' + format(values) + '\n'); - } catch (err) { - // ignore - } - } + constructor(private outStream: NodeJS.WritableStream, private errStream: NodeJS.WritableStream) {} + public log(...values: any[]): void { + try { + this.outStream.write(chalk.grey('DEBUG ' + format(values) + '\n')) + } catch (err) { + // ignore + } + } + + public info(...values: any[]): void { + try { + this.outStream.write(chalk.bgCyan('INFO') + ' ' + format(values) + '\n') + } catch (err) { + // ignore + } + } + + public warn(...values: any[]): void { + try { + this.errStream.write(chalk.bgYellow('WARN') + ' ' + format(values) + '\n') + } catch (err) { + // ignore + } + } + + public error(...values: any[]): void { + try { + this.errStream.write(chalk.bgRed('ERROR') + ' ' + format(values) + '\n') + } catch (err) { + // ignore + } + } } /** * Logger implementation that logs to STDOUT and STDERR depending on level */ export class StdioLogger extends StreamLogger { - constructor() { - super(process.stdout, process.stderr); - } + constructor() { + super(process.stdout, process.stderr) + } } /** * Logger implementation that logs only to STDERR */ export class StderrLogger extends StreamLogger { - constructor() { - super(process.stderr, process.stderr); - } + constructor() { + super(process.stderr, process.stderr) + } } /** * Logger implementation that logs to a file */ export class FileLogger extends StreamLogger { - /** - * @param file Path to the logfile - */ - constructor(file: string) { - const stream = fs.createWriteStream(file); - super(stream, stream); - } + /** + * @param file Path to the logfile + */ + constructor(file: string) { + const stream = fs.createWriteStream(file) + super(stream, stream) + } } /** @@ -136,42 +136,42 @@ export class FileLogger extends StreamLogger { */ export class PrefixedLogger { - constructor(private logger: Logger, private prefix: string) {} + constructor(private logger: Logger, private prefix: string) {} - log(...values: any[]): void { - this.logger.log(`[${this.prefix}] ${format(values)}`); - } + public log(...values: any[]): void { + this.logger.log(`[${this.prefix}] ${format(values)}`) + } - info(...values: any[]): void { - this.logger.info(`[${this.prefix}] ${format(values)}`); - } + public info(...values: any[]): void { + this.logger.info(`[${this.prefix}] ${format(values)}`) + } - warn(...values: any[]): void { - this.logger.warn(`[${this.prefix}] ${format(values)}`); - } + public warn(...values: any[]): void { + this.logger.warn(`[${this.prefix}] ${format(values)}`) + } - error(...values: any[]): void { - this.logger.error(`[${this.prefix}] ${format(values)}`); - } + public error(...values: any[]): void { + this.logger.error(`[${this.prefix}] ${format(values)}`) + } } /** * Logger implementation that does nothing */ export class NoopLogger { - log(...values: any[]): void { - // empty - } + public log(...values: any[]): void { + // empty + } - info(...values: any[]): void { - // empty - } + public info(...values: any[]): void { + // empty + } - warn(...values: any[]): void { - // empty - } + public warn(...values: any[]): void { + // empty + } - error(...values: any[]): void { - // empty - } + public error(...values: any[]): void { + // empty + } } diff --git a/src/match-files.ts b/src/match-files.ts index 6d38ba38d..ba41295d8 100644 --- a/src/match-files.ts +++ b/src/match-files.ts @@ -5,533 +5,533 @@ /* tslint:disable */ export interface FileSystemEntries { - files: string[]; - directories: string[]; + files: string[]; + directories: string[]; } export function matchFiles(path: string, extensions: string[], excludes: string[], includes: string[], useCaseSensitiveFileNames: boolean, currentDirectory: string, getFileSystemEntries: (path: string) => FileSystemEntries): string[] { - path = normalizePath(path); - currentDirectory = normalizePath(currentDirectory); - - const patterns = getFileMatcherPatterns(path, extensions, excludes, includes, useCaseSensitiveFileNames, currentDirectory); - - const regexFlag = useCaseSensitiveFileNames ? '' : 'i'; - - const includeFileRegex = patterns.includeFilePattern && new RegExp(patterns.includeFilePattern, regexFlag); - - const includeDirectoryRegex = patterns.includeDirectoryPattern && new RegExp(patterns.includeDirectoryPattern, regexFlag); - const excludeRegex = patterns.excludePattern && new RegExp(patterns.excludePattern, regexFlag); - - const result: string[] = []; - for (const basePath of patterns.basePaths) { - visitDirectory(basePath, combinePaths(currentDirectory, basePath)); - } - return result; - - function visitDirectory(path: string, absolutePath: string) { - const { files, directories } = getFileSystemEntries(path); - - for (const current of files) { - const name = combinePaths(path, current); - const absoluteName = combinePaths(absolutePath, current); - if ((!extensions || fileExtensionIsAny(name, extensions)) && - (!includeFileRegex || includeFileRegex.test(absoluteName)) && - (!excludeRegex || !excludeRegex.test(absoluteName))) { - result.push(name); - } - } - - for (const current of directories) { - const name = combinePaths(path, current); - const absoluteName = combinePaths(absolutePath, current); - if ((!includeDirectoryRegex || includeDirectoryRegex.test(absoluteName)) && - (!excludeRegex || !excludeRegex.test(absoluteName))) { - visitDirectory(name, absoluteName); - } - } - } + path = normalizePath(path); + currentDirectory = normalizePath(currentDirectory); + + const patterns = getFileMatcherPatterns(path, extensions, excludes, includes, useCaseSensitiveFileNames, currentDirectory); + + const regexFlag = useCaseSensitiveFileNames ? '' : 'i'; + + const includeFileRegex = patterns.includeFilePattern && new RegExp(patterns.includeFilePattern, regexFlag); + + const includeDirectoryRegex = patterns.includeDirectoryPattern && new RegExp(patterns.includeDirectoryPattern, regexFlag); + const excludeRegex = patterns.excludePattern && new RegExp(patterns.excludePattern, regexFlag); + + const result: string[] = []; + for (const basePath of patterns.basePaths) { + visitDirectory(basePath, combinePaths(currentDirectory, basePath)); + } + return result; + + function visitDirectory(path: string, absolutePath: string) { + const { files, directories } = getFileSystemEntries(path); + + for (const current of files) { + const name = combinePaths(path, current); + const absoluteName = combinePaths(absolutePath, current); + if ((!extensions || fileExtensionIsAny(name, extensions)) && + (!includeFileRegex || includeFileRegex.test(absoluteName)) && + (!excludeRegex || !excludeRegex.test(absoluteName))) { + result.push(name); + } + } + + for (const current of directories) { + const name = combinePaths(path, current); + const absoluteName = combinePaths(absolutePath, current); + if ((!includeDirectoryRegex || includeDirectoryRegex.test(absoluteName)) && + (!excludeRegex || !excludeRegex.test(absoluteName))) { + visitDirectory(name, absoluteName); + } + } + } } const directorySeparator = '/'; export function combinePaths(path1: string, path2: string) { - if (!(path1 && path1.length)) return path2; - if (!(path2 && path2.length)) return path1; - if (getRootLength(path2) !== 0) return path2; - if (path1.charAt(path1.length - 1) === directorySeparator) return path1 + path2; - return path1 + directorySeparator + path2; + if (!(path1 && path1.length)) return path2; + if (!(path2 && path2.length)) return path1; + if (getRootLength(path2) !== 0) return path2; + if (path1.charAt(path1.length - 1) === directorySeparator) return path1 + path2; + return path1 + directorySeparator + path2; } function normalizePath(path: string): string { - path = normalizeSlashes(path); - const rootLength = getRootLength(path); - const root = path.substr(0, rootLength); - const normalized = getNormalizedParts(path, rootLength); - if (normalized.length) { - const joinedParts = root + normalized.join(directorySeparator); - return pathEndsWithDirectorySeparator(path) ? joinedParts + directorySeparator : joinedParts; - } - else { - return root; - } + path = normalizeSlashes(path); + const rootLength = getRootLength(path); + const root = path.substr(0, rootLength); + const normalized = getNormalizedParts(path, rootLength); + if (normalized.length) { + const joinedParts = root + normalized.join(directorySeparator); + return pathEndsWithDirectorySeparator(path) ? joinedParts + directorySeparator : joinedParts; + } + else { + return root; + } } function getFileMatcherPatterns(path: string, extensions: string[], excludes: string[], includes: string[], useCaseSensitiveFileNames: boolean, currentDirectory: string): FileMatcherPatterns { - path = normalizePath(path); - currentDirectory = normalizePath(currentDirectory); - const absolutePath = combinePaths(currentDirectory, path); - - return { - includeFilePattern: getRegularExpressionForWildcard(includes, absolutePath, 'files') || '', - includeDirectoryPattern: getRegularExpressionForWildcard(includes, absolutePath, 'directories') || '', - excludePattern: getRegularExpressionForWildcard(excludes, absolutePath, 'exclude') || '', - basePaths: getBasePaths(path, includes, useCaseSensitiveFileNames) || [], - }; + path = normalizePath(path); + currentDirectory = normalizePath(currentDirectory); + const absolutePath = combinePaths(currentDirectory, path); + + return { + includeFilePattern: getRegularExpressionForWildcard(includes, absolutePath, 'files') || '', + includeDirectoryPattern: getRegularExpressionForWildcard(includes, absolutePath, 'directories') || '', + excludePattern: getRegularExpressionForWildcard(excludes, absolutePath, 'exclude') || '', + basePaths: getBasePaths(path, includes, useCaseSensitiveFileNames) || [], + }; } function fileExtensionIs(path: string, extension: string): boolean { - return path.length > extension.length && endsWith(path, extension); + return path.length > extension.length && endsWith(path, extension); } function fileExtensionIsAny(path: string, extensions: string[]): boolean { - for (const extension of extensions) { - if (fileExtensionIs(path, extension)) { - return true; - } - } + for (const extension of extensions) { + if (fileExtensionIs(path, extension)) { + return true; + } + } - return false; + return false; } function getRegularExpressionForWildcard(specs: string[], basePath: string, usage: 'files' | 'directories' | 'exclude') { - if (specs === undefined || specs.length === 0) { - return undefined; - } + if (specs === undefined || specs.length === 0) { + return undefined; + } - const replaceWildcardCharacter = usage === 'files' ? replaceWildCardCharacterFiles : replaceWildCardCharacterOther; - const singleAsteriskRegexFragment = usage === 'files' ? singleAsteriskRegexFragmentFiles : singleAsteriskRegexFragmentOther; + const replaceWildcardCharacter = usage === 'files' ? replaceWildCardCharacterFiles : replaceWildCardCharacterOther; + const singleAsteriskRegexFragment = usage === 'files' ? singleAsteriskRegexFragmentFiles : singleAsteriskRegexFragmentOther; /** * Regex for the ** wildcard. Matches any number of subdirectories. When used for including * files or directories, does not match subdirectories that start with a . character */ - const doubleAsteriskRegexFragment = usage === 'exclude' ? '(/.+?)?' : '(/[^/.][^/]*)*?'; - - let pattern = ''; - let hasWrittenSubpattern = false; - spec: for (const spec of specs) { - if (!spec) { - continue; - } - - let subpattern = ''; - let hasRecursiveDirectoryWildcard = false; - let hasWrittenComponent = false; - const components = getNormalizedPathComponents(spec, basePath); - if (usage !== 'exclude' && components[components.length - 1] === '**') { - continue spec; - } - - // getNormalizedPathComponents includes the separator for the root component. - // We need to remove to create our regex correctly. - components[0] = removeTrailingDirectorySeparator(components[0]); - - let optionalCount = 0; - for (let component of components) { - if (component === '**') { - if (hasRecursiveDirectoryWildcard) { - continue spec; - } - - subpattern += doubleAsteriskRegexFragment; - hasRecursiveDirectoryWildcard = true; - hasWrittenComponent = true; - } - else { - if (usage === 'directories') { - subpattern += '('; - optionalCount++; - } - - if (hasWrittenComponent) { - subpattern += directorySeparator; - } - - if (usage !== 'exclude') { - // The * and ? wildcards should not match directories or files that start with . if they - // appear first in a component. Dotted directories and files can be included explicitly - // like so: **/.*/.* - if (component.charCodeAt(0) === CharacterCodes.asterisk) { - subpattern += '([^./]' + singleAsteriskRegexFragment + ')?'; - component = component.substr(1); - } - else if (component.charCodeAt(0) === CharacterCodes.question) { - subpattern += '[^./]'; - component = component.substr(1); - } - } - - subpattern += component.replace(reservedCharacterPattern, replaceWildcardCharacter); - hasWrittenComponent = true; - } - } - - while (optionalCount > 0) { - subpattern += ')?'; - optionalCount--; - } - - if (hasWrittenSubpattern) { - pattern += '|'; - } - - pattern += '(' + subpattern + ')'; - hasWrittenSubpattern = true; - } - - if (!pattern) { - return undefined; - } - - return '^(' + pattern + (usage === 'exclude' ? ')($|/)' : ')$'); + const doubleAsteriskRegexFragment = usage === 'exclude' ? '(/.+?)?' : '(/[^/.][^/]*)*?'; + + let pattern = ''; + let hasWrittenSubpattern = false; + spec: for (const spec of specs) { + if (!spec) { + continue; + } + + let subpattern = ''; + let hasRecursiveDirectoryWildcard = false; + let hasWrittenComponent = false; + const components = getNormalizedPathComponents(spec, basePath); + if (usage !== 'exclude' && components[components.length - 1] === '**') { + continue spec; + } + + // getNormalizedPathComponents includes the separator for the root component. + // We need to remove to create our regex correctly. + components[0] = removeTrailingDirectorySeparator(components[0]); + + let optionalCount = 0; + for (let component of components) { + if (component === '**') { + if (hasRecursiveDirectoryWildcard) { + continue spec; + } + + subpattern += doubleAsteriskRegexFragment; + hasRecursiveDirectoryWildcard = true; + hasWrittenComponent = true; + } + else { + if (usage === 'directories') { + subpattern += '('; + optionalCount++; + } + + if (hasWrittenComponent) { + subpattern += directorySeparator; + } + + if (usage !== 'exclude') { + // The * and ? wildcards should not match directories or files that start with . if they + // appear first in a component. Dotted directories and files can be included explicitly + // like so: **/.*/.* + if (component.charCodeAt(0) === CharacterCodes.asterisk) { + subpattern += '([^./]' + singleAsteriskRegexFragment + ')?'; + component = component.substr(1); + } + else if (component.charCodeAt(0) === CharacterCodes.question) { + subpattern += '[^./]'; + component = component.substr(1); + } + } + + subpattern += component.replace(reservedCharacterPattern, replaceWildcardCharacter); + hasWrittenComponent = true; + } + } + + while (optionalCount > 0) { + subpattern += ')?'; + optionalCount--; + } + + if (hasWrittenSubpattern) { + pattern += '|'; + } + + pattern += '(' + subpattern + ')'; + hasWrittenSubpattern = true; + } + + if (!pattern) { + return undefined; + } + + return '^(' + pattern + (usage === 'exclude' ? ')($|/)' : ')$'); } function getRootLength(path: string): number { - if (path.charCodeAt(0) === CharacterCodes.slash) { - if (path.charCodeAt(1) !== CharacterCodes.slash) return 1; - const p1 = path.indexOf('/', 2); - if (p1 < 0) return 2; - const p2 = path.indexOf('/', p1 + 1); - if (p2 < 0) return p1 + 1; - return p2 + 1; - } - if (path.charCodeAt(1) === CharacterCodes.colon) { - if (path.charCodeAt(2) === CharacterCodes.slash) return 3; - return 2; - } - // Per RFC 1738 'file' URI schema has the shape file:/// - // if is omitted then it is assumed that host value is 'localhost', - // however slash after the omitted is not removed. - // file:///folder1/file1 - this is a correct URI - // file://folder2/file2 - this is an incorrect URI - if (path.lastIndexOf('file:///', 0) === 0) { - return 'file:///'.length; - } - const idx = path.indexOf('://'); - if (idx !== -1) { - return idx + '://'.length; - } - return 0; + if (path.charCodeAt(0) === CharacterCodes.slash) { + if (path.charCodeAt(1) !== CharacterCodes.slash) return 1; + const p1 = path.indexOf('/', 2); + if (p1 < 0) return 2; + const p2 = path.indexOf('/', p1 + 1); + if (p2 < 0) return p1 + 1; + return p2 + 1; + } + if (path.charCodeAt(1) === CharacterCodes.colon) { + if (path.charCodeAt(2) === CharacterCodes.slash) return 3; + return 2; + } + // Per RFC 1738 'file' URI schema has the shape file:/// + // if is omitted then it is assumed that host value is 'localhost', + // however slash after the omitted is not removed. + // file:///folder1/file1 - this is a correct URI + // file://folder2/file2 - this is an incorrect URI + if (path.lastIndexOf('file:///', 0) === 0) { + return 'file:///'.length; + } + const idx = path.indexOf('://'); + if (idx !== -1) { + return idx + '://'.length; + } + return 0; } function getNormalizedParts(normalizedSlashedPath: string, rootLength: number): string[] { - const parts = normalizedSlashedPath.substr(rootLength).split(directorySeparator); - const normalized: string[] = []; - for (const part of parts) { - if (part !== '.') { - if (part === '..' && normalized.length > 0 && lastOrUndefined(normalized) !== '..') { - normalized.pop(); - } - else { - // A part may be an empty string (which is 'falsy') if the path had consecutive slashes, - // e.g. "path//file.ts". Drop these before re-joining the parts. - if (part) { - normalized.push(part); - } - } - } - } - - return normalized; + const parts = normalizedSlashedPath.substr(rootLength).split(directorySeparator); + const normalized: string[] = []; + for (const part of parts) { + if (part !== '.') { + if (part === '..' && normalized.length > 0 && lastOrUndefined(normalized) !== '..') { + normalized.pop(); + } + else { + // A part may be an empty string (which is 'falsy') if the path had consecutive slashes, + // e.g. "path//file.ts". Drop these before re-joining the parts. + if (part) { + normalized.push(part); + } + } + } + } + + return normalized; } function pathEndsWithDirectorySeparator(path: string): boolean { - return path.charCodeAt(path.length - 1) === directorySeparatorCharCode; + return path.charCodeAt(path.length - 1) === directorySeparatorCharCode; } function replaceWildCardCharacterFiles(match: string) { - return replaceWildcardCharacter(match, singleAsteriskRegexFragmentFiles); + return replaceWildcardCharacter(match, singleAsteriskRegexFragmentFiles); } function replaceWildCardCharacterOther(match: string) { - return replaceWildcardCharacter(match, singleAsteriskRegexFragmentOther); + return replaceWildcardCharacter(match, singleAsteriskRegexFragmentOther); } function replaceWildcardCharacter(match: string, singleAsteriskRegexFragment: string) { - return match === '*' ? singleAsteriskRegexFragment : match === '?' ? '[^/]' : '\\' + match; + return match === '*' ? singleAsteriskRegexFragment : match === '?' ? '[^/]' : '\\' + match; } function getBasePaths(path: string, includes: string[], useCaseSensitiveFileNames: boolean) { - // Storage for our results in the form of literal paths (e.g. the paths as written by the user). - const basePaths: string[] = [path]; - if (includes) { - // Storage for literal base paths amongst the include patterns. - const includeBasePaths: string[] = []; - for (const include of includes) { - // We also need to check the relative paths by converting them to absolute and normalizing - // in case they escape the base path (e.g "..\somedirectory") - const absolute: string = isRootedDiskPath(include) ? include : normalizePath(combinePaths(path, include)); - - const wildcardOffset = indexOfAnyCharCode(absolute, wildcardCharCodes); - const includeBasePath = wildcardOffset < 0 - ? removeTrailingDirectorySeparator(getDirectoryPath(absolute)) - : absolute.substring(0, absolute.lastIndexOf(directorySeparator, wildcardOffset)); - - // Append the literal and canonical candidate base paths. - includeBasePaths.push(includeBasePath); - } - - // Sort the offsets array using either the literal or canonical path representations. - includeBasePaths.sort(useCaseSensitiveFileNames ? compareStrings : compareStringsCaseInsensitive); - - // Iterate over each include base path and include unique base paths that are not a - // subpath of an existing base path - include: for (let i = 0; i < includeBasePaths.length; i++) { - const includeBasePath = includeBasePaths[i]; - for (let j = 0; j < basePaths.length; j++) { - if (containsPath(basePaths[j], includeBasePath, path, !useCaseSensitiveFileNames)) { - continue include; - } - } - - basePaths.push(includeBasePath); - } - } - - return basePaths; + // Storage for our results in the form of literal paths (e.g. the paths as written by the user). + const basePaths: string[] = [path]; + if (includes) { + // Storage for literal base paths amongst the include patterns. + const includeBasePaths: string[] = []; + for (const include of includes) { + // We also need to check the relative paths by converting them to absolute and normalizing + // in case they escape the base path (e.g "..\somedirectory") + const absolute: string = isRootedDiskPath(include) ? include : normalizePath(combinePaths(path, include)); + + const wildcardOffset = indexOfAnyCharCode(absolute, wildcardCharCodes); + const includeBasePath = wildcardOffset < 0 + ? removeTrailingDirectorySeparator(getDirectoryPath(absolute)) + : absolute.substring(0, absolute.lastIndexOf(directorySeparator, wildcardOffset)); + + // Append the literal and canonical candidate base paths. + includeBasePaths.push(includeBasePath); + } + + // Sort the offsets array using either the literal or canonical path representations. + includeBasePaths.sort(useCaseSensitiveFileNames ? compareStrings : compareStringsCaseInsensitive); + + // Iterate over each include base path and include unique base paths that are not a + // subpath of an existing base path + include: for (let i = 0; i < includeBasePaths.length; i++) { + const includeBasePath = includeBasePaths[i]; + for (let j = 0; j < basePaths.length; j++) { + if (containsPath(basePaths[j], includeBasePath, path, !useCaseSensitiveFileNames)) { + continue include; + } + } + + basePaths.push(includeBasePath); + } + } + + return basePaths; } function endsWith(str: string, suffix: string): boolean { - const expectedPos = str.length - suffix.length; - return expectedPos >= 0 && str.indexOf(suffix, expectedPos) === expectedPos; + const expectedPos = str.length - suffix.length; + return expectedPos >= 0 && str.indexOf(suffix, expectedPos) === expectedPos; } function compareStrings(a: string, b: string, ignoreCase?: boolean): Comparison { - if (a === b) return Comparison.EqualTo; - if (a === undefined) return Comparison.LessThan; - if (b === undefined) return Comparison.GreaterThan; - if (ignoreCase) { - if (String.prototype.localeCompare) { - const result = a.localeCompare(b, /*locales*/ undefined, { usage: 'sort', sensitivity: 'accent' }); - return result < 0 ? Comparison.LessThan : result > 0 ? Comparison.GreaterThan : Comparison.EqualTo; - } - - a = a.toUpperCase(); - b = b.toUpperCase(); - if (a === b) return Comparison.EqualTo; - } - - return a < b ? Comparison.LessThan : Comparison.GreaterThan; + if (a === b) return Comparison.EqualTo; + if (a === undefined) return Comparison.LessThan; + if (b === undefined) return Comparison.GreaterThan; + if (ignoreCase) { + if (String.prototype.localeCompare) { + const result = a.localeCompare(b, /*locales*/ undefined, { usage: 'sort', sensitivity: 'accent' }); + return result < 0 ? Comparison.LessThan : result > 0 ? Comparison.GreaterThan : Comparison.EqualTo; + } + + a = a.toUpperCase(); + b = b.toUpperCase(); + if (a === b) return Comparison.EqualTo; + } + + return a < b ? Comparison.LessThan : Comparison.GreaterThan; } function compareStringsCaseInsensitive(a: string, b: string) { - return compareStrings(a, b, /*ignoreCase*/ true); + return compareStrings(a, b, /*ignoreCase*/ true); } const singleAsteriskRegexFragmentFiles = '([^./]|(\\.(?!min\\.js$))?)*'; const singleAsteriskRegexFragmentOther = '[^/]*'; function getNormalizedPathComponents(path: string, currentDirectory: string) { - path = normalizeSlashes(path); - let rootLength = getRootLength(path); - if (rootLength === 0) { - // If the path is not rooted it is relative to current directory - path = combinePaths(normalizeSlashes(currentDirectory), path); - rootLength = getRootLength(path); - } - - return normalizedPathComponents(path, rootLength); + path = normalizeSlashes(path); + let rootLength = getRootLength(path); + if (rootLength === 0) { + // If the path is not rooted it is relative to current directory + path = combinePaths(normalizeSlashes(currentDirectory), path); + rootLength = getRootLength(path); + } + + return normalizedPathComponents(path, rootLength); } function normalizedPathComponents(path: string, rootLength: number) { - const normalizedParts = getNormalizedParts(path, rootLength); - return [path.substr(0, rootLength)].concat(normalizedParts); + const normalizedParts = getNormalizedParts(path, rootLength); + return [path.substr(0, rootLength)].concat(normalizedParts); } function containsPath(parent: string, child: string, currentDirectory: string, ignoreCase?: boolean) { - if (parent === undefined || child === undefined) return false; - if (parent === child) return true; - parent = removeTrailingDirectorySeparator(parent); - child = removeTrailingDirectorySeparator(child); - if (parent === child) return true; - const parentComponents = getNormalizedPathComponents(parent, currentDirectory); - const childComponents = getNormalizedPathComponents(child, currentDirectory); - if (childComponents.length < parentComponents.length) { - return false; - } - - for (let i = 0; i < parentComponents.length; i++) { - const result = compareStrings(parentComponents[i], childComponents[i], ignoreCase); - if (result !== Comparison.EqualTo) { - return false; - } - } - - return true; + if (parent === undefined || child === undefined) return false; + if (parent === child) return true; + parent = removeTrailingDirectorySeparator(parent); + child = removeTrailingDirectorySeparator(child); + if (parent === child) return true; + const parentComponents = getNormalizedPathComponents(parent, currentDirectory); + const childComponents = getNormalizedPathComponents(child, currentDirectory); + if (childComponents.length < parentComponents.length) { + return false; + } + + for (let i = 0; i < parentComponents.length; i++) { + const result = compareStrings(parentComponents[i], childComponents[i], ignoreCase); + if (result !== Comparison.EqualTo) { + return false; + } + } + + return true; } function removeTrailingDirectorySeparator(path: string) { - if (path.charAt(path.length - 1) === directorySeparator) { - return path.substr(0, path.length - 1); - } + if (path.charAt(path.length - 1) === directorySeparator) { + return path.substr(0, path.length - 1); + } - return path; + return path; } function lastOrUndefined(array: T[]): T | void { - return array && array.length > 0 - ? array[array.length - 1] - : undefined; + return array && array.length > 0 + ? array[array.length - 1] + : undefined; } interface FileMatcherPatterns { - includeFilePattern: string; - includeDirectoryPattern: string; - excludePattern: string; - basePaths: string[]; + includeFilePattern: string; + includeDirectoryPattern: string; + excludePattern: string; + basePaths: string[]; } const enum Comparison { - LessThan = -1, - EqualTo = 0, - GreaterThan = 1, + LessThan = -1, + EqualTo = 0, + GreaterThan = 1, } const enum CharacterCodes { - nullCharacter = 0, - maxAsciiCharacter = 0x7F, - - lineFeed = 0x0A, // \n - carriageReturn = 0x0D, // \r - lineSeparator = 0x2028, - paragraphSeparator = 0x2029, - nextLine = 0x0085, - - // Unicode 3.0 space characters - space = 0x0020, // " " - nonBreakingSpace = 0x00A0, // - enQuad = 0x2000, - emQuad = 0x2001, - enSpace = 0x2002, - emSpace = 0x2003, - threePerEmSpace = 0x2004, - fourPerEmSpace = 0x2005, - sixPerEmSpace = 0x2006, - figureSpace = 0x2007, - punctuationSpace = 0x2008, - thinSpace = 0x2009, - hairSpace = 0x200A, - zeroWidthSpace = 0x200B, - narrowNoBreakSpace = 0x202F, - ideographicSpace = 0x3000, - mathematicalSpace = 0x205F, - ogham = 0x1680, - - _ = 0x5F, - $ = 0x24, - - _0 = 0x30, - _1 = 0x31, - _2 = 0x32, - _3 = 0x33, - _4 = 0x34, - _5 = 0x35, - _6 = 0x36, - _7 = 0x37, - _8 = 0x38, - _9 = 0x39, - - a = 0x61, - b = 0x62, - c = 0x63, - d = 0x64, - e = 0x65, - f = 0x66, - g = 0x67, - h = 0x68, - i = 0x69, - j = 0x6A, - k = 0x6B, - l = 0x6C, - m = 0x6D, - n = 0x6E, - o = 0x6F, - p = 0x70, - q = 0x71, - r = 0x72, - s = 0x73, - t = 0x74, - u = 0x75, - v = 0x76, - w = 0x77, - x = 0x78, - y = 0x79, - z = 0x7A, - - A = 0x41, - B = 0x42, - C = 0x43, - D = 0x44, - E = 0x45, - F = 0x46, - G = 0x47, - H = 0x48, - I = 0x49, - J = 0x4A, - K = 0x4B, - L = 0x4C, - M = 0x4D, - N = 0x4E, - O = 0x4F, - P = 0x50, - Q = 0x51, - R = 0x52, - S = 0x53, - T = 0x54, - U = 0x55, - V = 0x56, - W = 0x57, - X = 0x58, - Y = 0x59, - Z = 0x5a, - - ampersand = 0x26, // & - asterisk = 0x2A, // * - at = 0x40, // @ - backslash = 0x5C, // \ - backtick = 0x60, // ` - bar = 0x7C, // | - caret = 0x5E, // ^ - closeBrace = 0x7D, // } - closeBracket = 0x5D, // ] - closeParen = 0x29, // ) - colon = 0x3A, // : - comma = 0x2C, // , - dot = 0x2E, // . - doubleQuote = 0x22, // " - equals = 0x3D, // = - exclamation = 0x21, // ! - greaterThan = 0x3E, // > - hash = 0x23, // # - lessThan = 0x3C, // < - minus = 0x2D, // - - openBrace = 0x7B, // { - openBracket = 0x5B, // [ - openParen = 0x28, // ( - percent = 0x25, // % - plus = 0x2B, // + - question = 0x3F, // ? - semicolon = 0x3B, // ; - singleQuote = 0x27, // ' - slash = 0x2F, // / - tilde = 0x7E, // ~ - - backspace = 0x08, // \b - formFeed = 0x0C, // \f - byteOrderMark = 0xFEFF, - tab = 0x09, // \t - verticalTab = 0x0B, // \v + nullCharacter = 0, + maxAsciiCharacter = 0x7F, + + lineFeed = 0x0A, // \n + carriageReturn = 0x0D, // \r + lineSeparator = 0x2028, + paragraphSeparator = 0x2029, + nextLine = 0x0085, + + // Unicode 3.0 space characters + space = 0x0020, // " " + nonBreakingSpace = 0x00A0, // + enQuad = 0x2000, + emQuad = 0x2001, + enSpace = 0x2002, + emSpace = 0x2003, + threePerEmSpace = 0x2004, + fourPerEmSpace = 0x2005, + sixPerEmSpace = 0x2006, + figureSpace = 0x2007, + punctuationSpace = 0x2008, + thinSpace = 0x2009, + hairSpace = 0x200A, + zeroWidthSpace = 0x200B, + narrowNoBreakSpace = 0x202F, + ideographicSpace = 0x3000, + mathematicalSpace = 0x205F, + ogham = 0x1680, + + _ = 0x5F, + $ = 0x24, + + _0 = 0x30, + _1 = 0x31, + _2 = 0x32, + _3 = 0x33, + _4 = 0x34, + _5 = 0x35, + _6 = 0x36, + _7 = 0x37, + _8 = 0x38, + _9 = 0x39, + + a = 0x61, + b = 0x62, + c = 0x63, + d = 0x64, + e = 0x65, + f = 0x66, + g = 0x67, + h = 0x68, + i = 0x69, + j = 0x6A, + k = 0x6B, + l = 0x6C, + m = 0x6D, + n = 0x6E, + o = 0x6F, + p = 0x70, + q = 0x71, + r = 0x72, + s = 0x73, + t = 0x74, + u = 0x75, + v = 0x76, + w = 0x77, + x = 0x78, + y = 0x79, + z = 0x7A, + + A = 0x41, + B = 0x42, + C = 0x43, + D = 0x44, + E = 0x45, + F = 0x46, + G = 0x47, + H = 0x48, + I = 0x49, + J = 0x4A, + K = 0x4B, + L = 0x4C, + M = 0x4D, + N = 0x4E, + O = 0x4F, + P = 0x50, + Q = 0x51, + R = 0x52, + S = 0x53, + T = 0x54, + U = 0x55, + V = 0x56, + W = 0x57, + X = 0x58, + Y = 0x59, + Z = 0x5a, + + ampersand = 0x26, // & + asterisk = 0x2A, // * + at = 0x40, // @ + backslash = 0x5C, // \ + backtick = 0x60, // ` + bar = 0x7C, // | + caret = 0x5E, // ^ + closeBrace = 0x7D, // } + closeBracket = 0x5D, // ] + closeParen = 0x29, // ) + colon = 0x3A, // : + comma = 0x2C, // , + dot = 0x2E, // . + doubleQuote = 0x22, // " + equals = 0x3D, // = + exclamation = 0x21, // ! + greaterThan = 0x3E, // > + hash = 0x23, // # + lessThan = 0x3C, // < + minus = 0x2D, // - + openBrace = 0x7B, // { + openBracket = 0x5B, // [ + openParen = 0x28, // ( + percent = 0x25, // % + plus = 0x2B, // + + question = 0x3F, // ? + semicolon = 0x3B, // ; + singleQuote = 0x27, // ' + slash = 0x2F, // / + tilde = 0x7E, // ~ + + backspace = 0x08, // \b + formFeed = 0x0C, // \f + byteOrderMark = 0xFEFF, + tab = 0x09, // \t + verticalTab = 0x0B, // \v } const reservedCharacterPattern = /[^\w\s\/]/g; @@ -539,35 +539,35 @@ const reservedCharacterPattern = /[^\w\s\/]/g; const directorySeparatorCharCode = CharacterCodes.slash; function isRootedDiskPath(path: string) { - return getRootLength(path) !== 0; + return getRootLength(path) !== 0; } function indexOfAnyCharCode(text: string, charCodes: number[], start?: number): number { - for (let i = start || 0, len = text.length; i < len; i++) { - if (contains(charCodes, text.charCodeAt(i))) { - return i; - } - } - return -1; + for (let i = start || 0, len = text.length; i < len; i++) { + if (contains(charCodes, text.charCodeAt(i))) { + return i; + } + } + return -1; } const wildcardCharCodes = [CharacterCodes.asterisk, CharacterCodes.question]; function getDirectoryPath(path: string): any { - return path.substr(0, Math.max(getRootLength(path), path.lastIndexOf(directorySeparator))); + return path.substr(0, Math.max(getRootLength(path), path.lastIndexOf(directorySeparator))); } function contains(array: T[], value: T): boolean { - if (array) { - for (const v of array) { - if (v === value) { - return true; - } - } - } - return false; + if (array) { + for (const v of array) { + if (v === value) { + return true; + } + } + } + return false; } function normalizeSlashes(path: string): string { - return path.replace(/\\/g, '/'); + return path.replace(/\\/g, '/'); } diff --git a/src/memfs.ts b/src/memfs.ts index 283e7ce30..4fe7cb7e3 100644 --- a/src/memfs.ts +++ b/src/memfs.ts @@ -1,17 +1,22 @@ -import { EventEmitter } from 'events'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as ts from 'typescript'; -import { Logger, NoopLogger } from './logging'; -import { FileSystemEntries, matchFiles } from './match-files'; -import { path2uri, toUnixPath, uri2path } from './util'; +import { EventEmitter } from 'events' +import * as fs from 'fs' +import * as path from 'path' +import * as ts from 'typescript' +import { Logger, NoopLogger } from './logging' +import { FileSystemEntries, matchFiles } from './match-files' +import { path2uri, toUnixPath, uri2path } from './util' + +/** + * TypeScript library files fetched from the local file system (bundled TS) + */ +export const typeScriptLibraries: Map = new Map() /** * In-memory file cache node which represents either a folder or a file */ export interface FileSystemNode { - file: boolean; - children: Map; + file: boolean + children: Map } /** @@ -19,250 +24,246 @@ export interface FileSystemNode { */ export class InMemoryFileSystem extends EventEmitter implements ts.ParseConfigHost, ts.ModuleResolutionHost { - /** - * Contains a Map of all URIs that exist in the workspace, optionally with a content. - * File contents for URIs in it do not neccessarily have to be fetched already. - */ - private files = new Map(); + /** + * Contains a Map of all URIs that exist in the workspace, optionally with a content. + * File contents for URIs in it do not neccessarily have to be fetched already. + */ + private files = new Map() - /** - * Map (URI -> string content) of temporary files made while user modifies local file(s) - */ - overlay: Map; + /** + * Map (URI -> string content) of temporary files made while user modifies local file(s) + */ + public overlay: Map - /** - * Should we take into account register when performing a file name match or not. On Windows when using local file system, file names are case-insensitive - */ - useCaseSensitiveFileNames: boolean; + /** + * Should we take into account register when performing a file name match or not. On Windows when using local file system, file names are case-insensitive + */ + public useCaseSensitiveFileNames: boolean - /** - * Root path - */ - path: string; + /** + * Root path + */ + public path: string - /** - * File tree root - */ - rootNode: FileSystemNode; + /** + * File tree root + */ + public rootNode: FileSystemNode - constructor(path: string, private logger: Logger = new NoopLogger()) { - super(); - this.path = path; - this.overlay = new Map(); - this.rootNode = { file: false, children: new Map() }; - } + constructor(path: string, private logger: Logger = new NoopLogger()) { + super() + this.path = path + this.overlay = new Map() + this.rootNode = { file: false, children: new Map() } + } - /** Emitted when a file was added */ - on(event: 'add', listener: (uri: string, content?: string) => void): this { - return super.on(event, listener); - } + /** Emitted when a file was added */ + public on(event: 'add', listener: (uri: string, content?: string) => void): this { + return super.on(event, listener) + } - /** - * Returns an IterableIterator for all URIs known to exist in the workspace (content loaded or not) - */ - uris(): IterableIterator { - return this.files.keys(); - } + /** + * Returns an IterableIterator for all URIs known to exist in the workspace (content loaded or not) + */ + public uris(): IterableIterator { + return this.files.keys() + } - /** - * Adds a file to the local cache - * - * @param uri The URI of the file - * @param content The optional content - */ - add(uri: string, content?: string): void { - // Make sure not to override existing content with undefined - if (content !== undefined || !this.files.has(uri)) { - this.files.set(uri, content); - } - // Add to directory tree - // TODO: convert this to use URIs. - const filePath = uri2path(uri); - const components = filePath.split(/[\/\\]/).filter(c => c); - let node = this.rootNode; - for (const [i, component] of components.entries()) { - const n = node.children.get(component); - if (!n) { - if (i < components.length - 1) { - const n = { file: false, children: new Map() }; - node.children.set(component, n); - node = n; - } else { - node.children.set(component, { file: true, children: new Map() }); - } - } else { - node = n; - } - } - this.emit('add', uri, content); - } + /** + * Adds a file to the local cache + * + * @param uri The URI of the file + * @param content The optional content + */ + public add(uri: string, content?: string): void { + // Make sure not to override existing content with undefined + if (content !== undefined || !this.files.has(uri)) { + this.files.set(uri, content) + } + // Add to directory tree + // TODO: convert this to use URIs. + const filePath = uri2path(uri) + const components = filePath.split(/[\/\\]/).filter(c => c) + let node = this.rootNode + for (const [i, component] of components.entries()) { + const n = node.children.get(component) + if (!n) { + if (i < components.length - 1) { + const n = { file: false, children: new Map() } + node.children.set(component, n) + node = n + } else { + node.children.set(component, { file: true, children: new Map() }) + } + } else { + node = n + } + } + this.emit('add', uri, content) + } - /** - * Returns true if the given file is known to exist in the workspace (content loaded or not) - * - * @param uri URI to a file - */ - has(uri: string): boolean { - return this.files.has(uri) || this.fileExists(uri2path(uri)); - } + /** + * Returns true if the given file is known to exist in the workspace (content loaded or not) + * + * @param uri URI to a file + */ + public has(uri: string): boolean { + return this.files.has(uri) || this.fileExists(uri2path(uri)) + } - /** - * Returns the file content for the given URI. - * Will throw an Error if no available in-memory. - * Use FileSystemUpdater.ensure() to ensure that the file is available. - */ - getContent(uri: string): string { - let content = this.overlay.get(uri); - if (content === undefined) { - content = this.files.get(uri); - } - if (content === undefined) { - content = typeScriptLibraries.get(uri2path(uri)); - } - if (content === undefined) { - throw new Error(`Content of ${uri} is not available in memory`); - } - return content; - } + /** + * Returns the file content for the given URI. + * Will throw an Error if no available in-memory. + * Use FileSystemUpdater.ensure() to ensure that the file is available. + */ + public getContent(uri: string): string { + let content = this.overlay.get(uri) + if (content === undefined) { + content = this.files.get(uri) + } + if (content === undefined) { + content = typeScriptLibraries.get(uri2path(uri)) + } + if (content === undefined) { + throw new Error(`Content of ${uri} is not available in memory`) + } + return content + } - /** - * Tells if a file denoted by the given name exists in the workspace (does not have to be loaded) - * - * @param path File path or URI (both absolute or relative file paths are accepted) - */ - fileExists(path: string): boolean { - const uri = path2uri(path); - return this.overlay.has(uri) || this.files.has(uri) || typeScriptLibraries.has(path); - } + /** + * Tells if a file denoted by the given name exists in the workspace (does not have to be loaded) + * + * @param path File path or URI (both absolute or relative file paths are accepted) + */ + public fileExists(path: string): boolean { + const uri = path2uri(path) + return this.overlay.has(uri) || this.files.has(uri) || typeScriptLibraries.has(path) + } - /** - * @param path file path (both absolute or relative file paths are accepted) - * @return file's content in the following order (overlay then cache). - * If there is no such file, returns empty string to match expected signature - */ - readFile(path: string): string { - const content = this.readFileIfExists(path); - if (content === undefined) { - this.logger.warn(`readFile ${path} requested by TypeScript but content not available`); - return ''; - } - return content; - } + /** + * @param path file path (both absolute or relative file paths are accepted) + * @return file's content in the following order (overlay then cache). + * If there is no such file, returns empty string to match expected signature + */ + public readFile(path: string): string { + const content = this.readFileIfExists(path) + if (content === undefined) { + this.logger.warn(`readFile ${path} requested by TypeScript but content not available`) + return '' + } + return content + } - /** - * @param path file path (both absolute or relative file paths are accepted) - * @return file's content in the following order (overlay then cache). - * If there is no such file, returns undefined - */ - private readFileIfExists(path: string): string | undefined { - const uri = path2uri(path); - let content = this.overlay.get(uri); - if (content !== undefined) { - return content; - } + /** + * @param path file path (both absolute or relative file paths are accepted) + * @return file's content in the following order (overlay then cache). + * If there is no such file, returns undefined + */ + private readFileIfExists(path: string): string | undefined { + const uri = path2uri(path) + let content = this.overlay.get(uri) + if (content !== undefined) { + return content + } - // TODO This assumes that the URI was a file:// URL. - // In reality it could be anything, and the first URI matching the path should be used. - // With the current Map, the search would be O(n), it would require a tree to get O(log(n)) - content = this.files.get(uri); - if (content !== undefined) { - return content; - } + // TODO This assumes that the URI was a file:// URL. + // In reality it could be anything, and the first URI matching the path should be used. + // With the current Map, the search would be O(n), it would require a tree to get O(log(n)) + content = this.files.get(uri) + if (content !== undefined) { + return content + } - return typeScriptLibraries.get(path); - } + return typeScriptLibraries.get(path) + } - /** - * Invalidates temporary content denoted by the given URI - * @param uri file's URI - */ - didClose(uri: string) { - this.overlay.delete(uri); - } + /** + * Invalidates temporary content denoted by the given URI + * @param uri file's URI + */ + public didClose(uri: string): void { + this.overlay.delete(uri) + } - /** - * Adds temporary content denoted by the given URI - * @param uri file's URI - */ - didSave(uri: string) { - const content = this.overlay.get(uri); - if (content !== undefined) { - this.add(uri, content); - } - } + /** + * Adds temporary content denoted by the given URI + * @param uri file's URI + */ + public didSave(uri: string): void { + const content = this.overlay.get(uri) + if (content !== undefined) { + this.add(uri, content) + } + } - /** - * Updates temporary content denoted by the given URI - * @param uri file's URI - */ - didChange(uri: string, text: string) { - this.overlay.set(uri, text); - } + /** + * Updates temporary content denoted by the given URI + * @param uri file's URI + */ + public didChange(uri: string, text: string): void { + this.overlay.set(uri, text) + } - /** - * Called by TS service to scan virtual directory when TS service looks for source files that belong to a project - */ - readDirectory(rootDir: string, extensions: string[], excludes: string[], includes: string[]): string[] { - return matchFiles(rootDir, - extensions, - excludes, - includes, - true, - this.path, - p => this.getFileSystemEntries(p)); - } + /** + * Called by TS service to scan virtual directory when TS service looks for source files that belong to a project + */ + public readDirectory(rootDir: string, extensions: string[], excludes: string[], includes: string[]): string[] { + return matchFiles(rootDir, + extensions, + excludes, + includes, + true, + this.path, + p => this.getFileSystemEntries(p) + ) + } - /** - * Called by TS service to scan virtual directory when TS service looks for source files that belong to a project - */ - getFileSystemEntries(path: string): FileSystemEntries { - const ret: { files: string[], directories: string[] } = { files: [], directories: [] }; - let node = this.rootNode; - const components = path.split('/').filter(c => c); - if (components.length !== 1 || components[0]) { - for (const component of components) { - const n = node.children.get(component); - if (!n) { - return ret; - } - node = n; - } - } - node.children.forEach((value, name) => { - if (value.file) { - ret.files.push(name); - } else { - ret.directories.push(name); - } - }); - return ret; - } + /** + * Called by TS service to scan virtual directory when TS service looks for source files that belong to a project + */ + public getFileSystemEntries(path: string): FileSystemEntries { + const ret: { files: string[], directories: string[] } = { files: [], directories: [] } + let node = this.rootNode + const components = path.split('/').filter(c => c) + if (components.length !== 1 || components[0]) { + for (const component of components) { + const n = node.children.get(component) + if (!n) { + return ret + } + node = n + } + } + for (const [name, value] of node.children.entries()) { + if (value.file) { + ret.files.push(name) + } else { + ret.directories.push(name) + } + } + return ret + } - trace(message: string) { - this.logger.log(message); - } + public trace(message: string): void { + this.logger.log(message) + } } -/** - * TypeScript library files fetched from the local file system (bundled TS) - */ -export const typeScriptLibraries: Map = new Map(); - /** * Fetching TypeScript library files from local file system */ -const libPath = path.dirname(ts.getDefaultLibFilePath({ target: ts.ScriptTarget.ES2015 })); -fs.readdirSync(libPath).forEach(file => { - const fullPath = path.join(libPath, file); - if (fs.statSync(fullPath).isFile()) { - typeScriptLibraries.set(toUnixPath(fullPath), fs.readFileSync(fullPath).toString()); - } -}); +const libPath = path.dirname(ts.getDefaultLibFilePath({ target: ts.ScriptTarget.ES2015 })) +for (const file of fs.readdirSync(libPath)) { + const fullPath = path.join(libPath, file) + if (fs.statSync(fullPath).isFile()) { + typeScriptLibraries.set(toUnixPath(fullPath), fs.readFileSync(fullPath).toString()) + } +} /** * @param path file path * @return true if given file belongs to bundled TypeScript libraries */ export function isTypeScriptLibrary(path: string): boolean { - return typeScriptLibraries.has(toUnixPath(path)); + return typeScriptLibraries.has(toUnixPath(path)) } diff --git a/src/packages.ts b/src/packages.ts index ac11bc812..1062ca3af 100644 --- a/src/packages.ts +++ b/src/packages.ts @@ -1,35 +1,35 @@ -import { Observable, Subscription } from '@reactivex/rxjs'; -import { EventEmitter } from 'events'; -import { Span } from 'opentracing'; -import * as path from 'path'; -import * as url from 'url'; -import { Disposable } from './disposable'; -import { FileSystemUpdater } from './fs'; -import { Logger, NoopLogger } from './logging'; -import { InMemoryFileSystem } from './memfs'; -import { traceObservable } from './tracing'; +import { Observable, Subscription } from '@reactivex/rxjs' +import { EventEmitter } from 'events' +import { Span } from 'opentracing' +import * as path from 'path' +import * as url from 'url' +import { Disposable } from './disposable' +import { FileSystemUpdater } from './fs' +import { Logger, NoopLogger } from './logging' +import { InMemoryFileSystem } from './memfs' +import { traceObservable } from './tracing' /** * Schema of a package.json file */ export interface PackageJson { - name?: string; - version?: string; - typings?: string; - repository?: string | { type: string, url: string }; - dependencies?: { - [packageName: string]: string; - }; - devDependencies?: { - [packageName: string]: string; - }; - peerDependencies?: { - [packageName: string]: string; - }; - optionalDependencies?: { - [packageName: string]: string; - }; + name?: string + version?: string + typings?: string + repository?: string | { type: string, url: string } + dependencies?: { + [packageName: string]: string; + } + devDependencies?: { + [packageName: string]: string; + } + peerDependencies?: { + [packageName: string]: string; + } + optionalDependencies?: { + [packageName: string]: string; + } } /** @@ -39,14 +39,14 @@ export interface PackageJson { * /foo/node_modules/bar/node_modules/(baz)/index.d.ts * /foo/node_modules/(@types/bar)/index.ts */ -const NODE_MODULES_PACKAGE_NAME_REGEXP = /.*\/node_modules\/((?:@[^\/]+\/)?[^\/]+)\/.*$/; +const NODE_MODULES_PACKAGE_NAME_REGEXP = /.*\/node_modules\/((?:@[^\/]+\/)?[^\/]+)\/.*$/ /** * Returns the name of a package that a file is contained in */ export function extractNodeModulesPackageName(uri: string): string | undefined { - const match = decodeURIComponent(url.parse(uri).pathname || '').match(NODE_MODULES_PACKAGE_NAME_REGEXP); - return match ? match[1] : undefined; + const match = decodeURIComponent(url.parse(uri).pathname || '').match(NODE_MODULES_PACKAGE_NAME_REGEXP) + return match ? match[1] : undefined } /** @@ -56,156 +56,156 @@ export function extractNodeModulesPackageName(uri: string): string | undefined { * /foo/types/bar/node_modules/(baz)/index.d.ts * /foo/types/(@types/bar)/index.ts */ -const DEFINITELY_TYPED_PACKAGE_NAME_REGEXP = /\/types\/((?:@[^\/]+\/)?[^\/]+)\/.*$/; +const DEFINITELY_TYPED_PACKAGE_NAME_REGEXP = /\/types\/((?:@[^\/]+\/)?[^\/]+)\/.*$/ /** * Returns the name of a package that a file in DefinitelyTyped defines. * E.g. `file:///foo/types/node/index.d.ts` -> `@types/node` */ export function extractDefinitelyTypedPackageName(uri: string): string | undefined { - const match = decodeURIComponent(url.parse(uri).pathname || '').match(DEFINITELY_TYPED_PACKAGE_NAME_REGEXP); - return match ? '@types/' + match[1] : undefined; + const match = decodeURIComponent(url.parse(uri).pathname || '').match(DEFINITELY_TYPED_PACKAGE_NAME_REGEXP) + return match ? '@types/' + match[1] : undefined } export class PackageManager extends EventEmitter implements Disposable { - /** - * Map of package.json URIs _defined_ in the workspace to optional content. - * Does not include package.jsons of dependencies. - * Updated as new package.jsons are discovered. - */ - private packages = new Map(); - - /** - * The URI of the root package.json, if any. - * Updated as new package.jsons are discovered. - */ - public rootPackageJsonUri: string | undefined; - - /** - * Subscriptions to unsubscribe from on object disposal - */ - private subscriptions = new Subscription(); - - constructor( - private updater: FileSystemUpdater, - private inMemoryFileSystem: InMemoryFileSystem, - private logger: Logger = new NoopLogger() - ) { - super(); - let rootPackageJsonLevel = Infinity; - // Find locations of package.jsons _not_ inside node_modules - this.subscriptions.add( - Observable.fromEvent<[string, string]>(this.inMemoryFileSystem, 'add', Array.of) - .subscribe(([uri, content]) => { - const parts = url.parse(uri); - if (!parts.pathname || !parts.pathname.endsWith('/package.json') || parts.pathname.includes('/node_modules/')) { - return; - } - let parsed: PackageJson | undefined; - if (content) { - try { - parsed = JSON.parse(content); - } catch (err) { - logger.error(`Error parsing package.json:`, err); - } - } - // Don't override existing content with undefined - if (parsed || !this.packages.get(uri)) { - this.packages.set(uri, parsed); - this.logger.log(`Found package ${uri}`); - this.emit('parsed', uri, parsed); - } - // If the current root package.json is further nested than this one, replace it - const level = parts.pathname.split('/').length; - if (level < rootPackageJsonLevel) { - this.rootPackageJsonUri = uri; - rootPackageJsonLevel = level; - } - }) - ); - } - - dispose(): void { - this.subscriptions.unsubscribe(); - } - - /** Emitted when a new package.json was found and parsed */ - on(event: 'parsed', listener: (uri: string, packageJson: PackageJson) => void): this; - on(event: string, listener: (...args: any[]) => void): this { - return super.on(event, listener); - } - - /** - * Returns an Iterable for all package.jsons in the workspace - */ - packageJsonUris(): IterableIterator { - return this.packages.keys(); - } - - /** - * Gets the content of the closest package.json known to to the DependencyManager in the ancestors of a URI - * - * @return Observable that emits a single PackageJson or never - */ - getClosestPackageJson(uri: string, span = new Span()): Observable { - return this.updater.ensureStructure() - .concat(Observable.defer(() => { - const packageJsonUri = this.getClosestPackageJsonUri(uri); - if (!packageJsonUri) { - return Observable.empty(); - } - return this.getPackageJson(packageJsonUri, span); - })); - } - - /** - * Returns the parsed package.json of the passed URI - * - * @param uri URI of the package.json - * @return Observable that emits a single PackageJson or never - */ - getPackageJson(uri: string, childOf = new Span()): Observable { - return traceObservable('Get package.json', childOf, span => { - span.addTags({ uri }); - if (uri.includes('/node_modules/')) { - return Observable.throw(new Error(`Not an own package.json: ${uri}`)); - } - let packageJson = this.packages.get(uri); - if (packageJson) { - return Observable.of(packageJson); - } - return this.updater.ensure(uri, span) - .concat(Observable.defer(() => { - packageJson = this.packages.get(uri)!; - if (!packageJson) { - return Observable.throw(new Error(`Expected ${uri} to be registered in PackageManager`)); - } - return Observable.of(packageJson); - })); - }); - } - - /** - * Walks the parent directories of a given URI to find the first package.json that is known to the InMemoryFileSystem - * - * @param uri URI of a file or directory in the workspace - * @return The found package.json or undefined if none found - */ - getClosestPackageJsonUri(uri: string): string | undefined { - const parts: url.UrlObject = url.parse(uri); - while (true) { - if (!parts.pathname) { - return undefined; - } - const packageJsonUri = url.format({ ...parts, pathname: path.posix.join(parts.pathname, 'package.json') }); - if (this.packages.has(packageJsonUri)) { - return packageJsonUri; - } - if (parts.pathname === '/') { - return undefined; - } - parts.pathname = path.posix.dirname(parts.pathname); - } - } + /** + * Map of package.json URIs _defined_ in the workspace to optional content. + * Does not include package.jsons of dependencies. + * Updated as new package.jsons are discovered. + */ + private packages = new Map() + + /** + * The URI of the root package.json, if any. + * Updated as new package.jsons are discovered. + */ + public rootPackageJsonUri: string | undefined + + /** + * Subscriptions to unsubscribe from on object disposal + */ + private subscriptions = new Subscription() + + constructor( + private updater: FileSystemUpdater, + private inMemoryFileSystem: InMemoryFileSystem, + private logger: Logger = new NoopLogger() + ) { + super() + let rootPackageJsonLevel = Infinity + // Find locations of package.jsons _not_ inside node_modules + this.subscriptions.add( + Observable.fromEvent<[string, string]>(this.inMemoryFileSystem, 'add', Array.of) + .subscribe(([uri, content]) => { + const parts = url.parse(uri) + if (!parts.pathname || !parts.pathname.endsWith('/package.json') || parts.pathname.includes('/node_modules/')) { + return + } + let parsed: PackageJson | undefined + if (content) { + try { + parsed = JSON.parse(content) + } catch (err) { + logger.error(`Error parsing package.json:`, err) + } + } + // Don't override existing content with undefined + if (parsed || !this.packages.get(uri)) { + this.packages.set(uri, parsed) + this.logger.log(`Found package ${uri}`) + this.emit('parsed', uri, parsed) + } + // If the current root package.json is further nested than this one, replace it + const level = parts.pathname.split('/').length + if (level < rootPackageJsonLevel) { + this.rootPackageJsonUri = uri + rootPackageJsonLevel = level + } + }) + ) + } + + public dispose(): void { + this.subscriptions.unsubscribe() + } + + /** Emitted when a new package.json was found and parsed */ + public on(event: 'parsed', listener: (uri: string, packageJson: PackageJson) => void): this + public on(event: string, listener: (...args: any[]) => void): this { + return super.on(event, listener) + } + + /** + * Returns an Iterable for all package.jsons in the workspace + */ + public packageJsonUris(): IterableIterator { + return this.packages.keys() + } + + /** + * Gets the content of the closest package.json known to to the DependencyManager in the ancestors of a URI + * + * @return Observable that emits a single PackageJson or never + */ + public getClosestPackageJson(uri: string, span = new Span()): Observable { + return this.updater.ensureStructure() + .concat(Observable.defer(() => { + const packageJsonUri = this.getClosestPackageJsonUri(uri) + if (!packageJsonUri) { + return Observable.empty() + } + return this.getPackageJson(packageJsonUri, span) + })) + } + + /** + * Returns the parsed package.json of the passed URI + * + * @param uri URI of the package.json + * @return Observable that emits a single PackageJson or never + */ + public getPackageJson(uri: string, childOf = new Span()): Observable { + return traceObservable('Get package.json', childOf, span => { + span.addTags({ uri }) + if (uri.includes('/node_modules/')) { + return Observable.throw(new Error(`Not an own package.json: ${uri}`)) + } + let packageJson = this.packages.get(uri) + if (packageJson) { + return Observable.of(packageJson) + } + return this.updater.ensure(uri, span) + .concat(Observable.defer(() => { + packageJson = this.packages.get(uri)! + if (!packageJson) { + return Observable.throw(new Error(`Expected ${uri} to be registered in PackageManager`)) + } + return Observable.of(packageJson) + })) + }) + } + + /** + * Walks the parent directories of a given URI to find the first package.json that is known to the InMemoryFileSystem + * + * @param uri URI of a file or directory in the workspace + * @return The found package.json or undefined if none found + */ + public getClosestPackageJsonUri(uri: string): string | undefined { + const parts: url.UrlObject = url.parse(uri) + while (true) { + if (!parts.pathname) { + return undefined + } + const packageJsonUri = url.format({ ...parts, pathname: path.posix.join(parts.pathname, 'package.json') }) + if (this.packages.has(packageJsonUri)) { + return packageJsonUri + } + if (parts.pathname === '/') { + return undefined + } + parts.pathname = path.posix.dirname(parts.pathname) + } + } } diff --git a/src/plugins.ts b/src/plugins.ts index 4221a18f0..43498e09c 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -1,10 +1,10 @@ -import * as fs from 'mz/fs'; -import * as path from 'path'; -import * as ts from 'typescript'; -import { Logger, NoopLogger } from './logging'; -import { combinePaths } from './match-files'; -import { PluginSettings } from './request-type'; -import { toUnixPath } from './util'; +import * as fs from 'mz/fs' +import * as path from 'path' +import * as ts from 'typescript' +import { Logger, NoopLogger } from './logging' +import { combinePaths } from './match-files' +import { PluginSettings } from './request-type' +import { toUnixPath } from './util' // Based on types and logic from TypeScript server/project.ts @ // https://github.com/Microsoft/TypeScript/blob/711e890e59e10aa05a43cb938474a3d9c2270429/src/server/project.ts @@ -13,184 +13,184 @@ import { toUnixPath } from './util'; * A plugin exports an initialization function, injected with * the current typescript instance */ -export type PluginModuleFactory = (mod: { typescript: typeof ts }) => PluginModule; +export type PluginModuleFactory = (mod: { typescript: typeof ts }) => PluginModule -export type EnableProxyFunc = (pluginModuleFactory: PluginModuleFactory, pluginConfigEntry: ts.PluginImport) => void; +export type EnableProxyFunc = (pluginModuleFactory: PluginModuleFactory, pluginConfigEntry: ts.PluginImport) => void /** * A plugin presents this API when initialized */ export interface PluginModule { - create(createInfo: PluginCreateInfo): ts.LanguageService; - getExternalFiles?(proj: Project): string[]; + create(createInfo: PluginCreateInfo): ts.LanguageService + getExternalFiles?(proj: Project): string[] } /** * All of tsserver's environment exposed to plugins */ export interface PluginCreateInfo { - project: Project; - languageService: ts.LanguageService; - languageServiceHost: ts.LanguageServiceHost; - serverHost: ServerHost; - config: any; + project: Project + languageService: ts.LanguageService + languageServiceHost: ts.LanguageServiceHost + serverHost: ServerHost + config: any } /** * The portion of tsserver's Project API exposed to plugins */ export interface Project { - projectService: { - logger: Logger; - }; + projectService: { + logger: Logger; + } +} + +/** + * A local filesystem-based ModuleResolutionHost for plugin loading. + */ +export class LocalModuleResolutionHost implements ts.ModuleResolutionHost { + public fileExists(fileName: string): boolean { + return fs.existsSync(fileName) + } + public readFile(fileName: string): string { + return fs.readFileSync(fileName, 'utf8') + } } /** * The portion of tsserver's ServerHost API exposed to plugins */ -export type ServerHost = object; +export type ServerHost = object /** * The result of a node require: a module or an error. */ -type RequireResult = { module: {}, error: undefined } | { module: undefined, error: {} }; +type RequireResult = { module: {}, error: undefined } | { module: undefined, error: {} } export class PluginLoader { - private allowLocalPluginLoads: boolean = false; - private globalPlugins: string[] = []; - private pluginProbeLocations: string[] = []; - - constructor( - private rootFilePath: string, - private fs: ts.ModuleResolutionHost, - pluginSettings?: PluginSettings, - private logger = new NoopLogger(), - private resolutionHost = new LocalModuleResolutionHost(), - private requireModule: (moduleName: string) => any = require) { - if (pluginSettings) { - this.allowLocalPluginLoads = pluginSettings.allowLocalPluginLoads || false; - this.globalPlugins = pluginSettings.globalPlugins || []; - this.pluginProbeLocations = pluginSettings.pluginProbeLocations || []; - } - } - - public loadPlugins(options: ts.CompilerOptions, applyProxy: EnableProxyFunc) { - // Search our peer node_modules, then any globally-specified probe paths - // ../../.. to walk from X/node_modules/javascript-typescript-langserver/lib/project-manager.js to X/node_modules/ - const searchPaths = [combinePaths(__filename, '../../..'), ...this.pluginProbeLocations]; - - // Corresponds to --allowLocalPluginLoads, opt-in to avoid remote code execution. - if (this.allowLocalPluginLoads) { - const local = this.rootFilePath; - this.logger.info(`Local plugin loading enabled; adding ${local} to search paths`); - searchPaths.unshift(local); - } - - let pluginImports: ts.PluginImport[] = []; - if (options.plugins) { - pluginImports = options.plugins as ts.PluginImport[]; - } - - // Enable tsconfig-specified plugins - if (options.plugins) { - for (const pluginConfigEntry of pluginImports) { - this.enablePlugin(pluginConfigEntry, searchPaths, applyProxy); - } - } - - if (this.globalPlugins) { - // Enable global plugins with synthetic configuration entries - for (const globalPluginName of this.globalPlugins) { - // Skip already-locally-loaded plugins - if (!pluginImports || pluginImports.some(p => p.name === globalPluginName)) { - continue; - } - - // Provide global: true so plugins can detect why they can't find their config - this.enablePlugin({ name: globalPluginName, global: true } as ts.PluginImport, searchPaths, applyProxy); - } - } - } - - /** - * Tries to load and enable a single plugin - * @param pluginConfigEntry - * @param searchPaths - */ - private enablePlugin(pluginConfigEntry: ts.PluginImport, searchPaths: string[], enableProxy: EnableProxyFunc) { - for (const searchPath of searchPaths) { - const resolvedModule = this.resolveModule(pluginConfigEntry.name, searchPath) as PluginModuleFactory; - if (resolvedModule) { - enableProxy(resolvedModule, pluginConfigEntry); - return; - } - } - this.logger.error(`Couldn't find ${pluginConfigEntry.name} anywhere in paths: ${searchPaths.join(',')}`); - } - - /** - * Load a plugin using a node require - * @param moduleName - * @param initialDir - */ - private resolveModule(moduleName: string, initialDir: string): {} | undefined { - const resolvedPath = toUnixPath(path.resolve(combinePaths(initialDir, 'node_modules'))); - this.logger.info(`Loading ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`); - const result = this.requirePlugin(resolvedPath, moduleName); - if (result.error) { - this.logger.error(`Failed to load module: ${JSON.stringify(result.error)}`); - return undefined; - } - return result.module; - } - - /** - * Resolves a loads a plugin function relative to initialDir - * @param initialDir - * @param moduleName - */ - private requirePlugin(initialDir: string, moduleName: string): RequireResult { - try { - const modulePath = this.resolveJavaScriptModule(moduleName, initialDir, this.fs); - return { module: this.requireModule(modulePath), error: undefined }; - } catch (error) { - return { module: undefined, error }; - } - } - - /** - * Expose resolution logic to allow us to use Node module resolution logic from arbitrary locations. - * No way to do this with `require()`: https://github.com/nodejs/node/issues/5963 - * Throws an error if the module can't be resolved. - * stolen from moduleNameResolver.ts because marked as internal - */ - private resolveJavaScriptModule(moduleName: string, initialDir: string, host: ts.ModuleResolutionHost): string { - // TODO: this should set jsOnly=true to the internal resolver, but this parameter is not exposed on a public api. - const result = - ts.nodeModuleNameResolver( - moduleName, - initialDir.replace('\\', '/') + '/package.json', /* containingFile */ - { moduleResolution: ts.ModuleResolutionKind.NodeJs, allowJs: true }, - this.resolutionHost, - undefined - ); - if (!result.resolvedModule) { - // this.logger.error(result.failedLookupLocations); - throw new Error(`Could not resolve JS module ${moduleName} starting at ${initialDir}.`); - } - return result.resolvedModule.resolvedFileName; - } -} - -/** - * A local filesystem-based ModuleResolutionHost for plugin loading. - */ -export class LocalModuleResolutionHost implements ts.ModuleResolutionHost { - fileExists(fileName: string): boolean { - return fs.existsSync(fileName); - } - readFile(fileName: string): string { - return fs.readFileSync(fileName, 'utf8'); - } + private allowLocalPluginLoads = false + private globalPlugins: string[] = [] + private pluginProbeLocations: string[] = [] + + constructor( + private rootFilePath: string, + private fs: ts.ModuleResolutionHost, + pluginSettings?: PluginSettings, + private logger = new NoopLogger(), + private resolutionHost = new LocalModuleResolutionHost(), + private requireModule: (moduleName: string) => any = require) { + if (pluginSettings) { + this.allowLocalPluginLoads = pluginSettings.allowLocalPluginLoads || false + this.globalPlugins = pluginSettings.globalPlugins || [] + this.pluginProbeLocations = pluginSettings.pluginProbeLocations || [] + } + } + + public loadPlugins(options: ts.CompilerOptions, applyProxy: EnableProxyFunc): void { + // Search our peer node_modules, then any globally-specified probe paths + // ../../.. to walk from X/node_modules/javascript-typescript-langserver/lib/project-manager.js to X/node_modules/ + const searchPaths = [combinePaths(__filename, '../../..'), ...this.pluginProbeLocations] + + // Corresponds to --allowLocalPluginLoads, opt-in to avoid remote code execution. + if (this.allowLocalPluginLoads) { + const local = this.rootFilePath + this.logger.info(`Local plugin loading enabled; adding ${local} to search paths`) + searchPaths.unshift(local) + } + + let pluginImports: ts.PluginImport[] = [] + if (options.plugins) { + pluginImports = options.plugins as ts.PluginImport[] + } + + // Enable tsconfig-specified plugins + if (options.plugins) { + for (const pluginConfigEntry of pluginImports) { + this.enablePlugin(pluginConfigEntry, searchPaths, applyProxy) + } + } + + if (this.globalPlugins) { + // Enable global plugins with synthetic configuration entries + for (const globalPluginName of this.globalPlugins) { + // Skip already-locally-loaded plugins + if (!pluginImports || pluginImports.some(p => p.name === globalPluginName)) { + continue + } + + // Provide global: true so plugins can detect why they can't find their config + this.enablePlugin({ name: globalPluginName, global: true } as ts.PluginImport, searchPaths, applyProxy) + } + } + } + + /** + * Tries to load and enable a single plugin + * @param pluginConfigEntry + * @param searchPaths + */ + private enablePlugin(pluginConfigEntry: ts.PluginImport, searchPaths: string[], enableProxy: EnableProxyFunc): void { + for (const searchPath of searchPaths) { + const resolvedModule = this.resolveModule(pluginConfigEntry.name, searchPath) as PluginModuleFactory + if (resolvedModule) { + enableProxy(resolvedModule, pluginConfigEntry) + return + } + } + this.logger.error(`Couldn't find ${pluginConfigEntry.name} anywhere in paths: ${searchPaths.join(',')}`) + } + + /** + * Load a plugin using a node require + * @param moduleName + * @param initialDir + */ + private resolveModule(moduleName: string, initialDir: string): {} | undefined { + const resolvedPath = toUnixPath(path.resolve(combinePaths(initialDir, 'node_modules'))) + this.logger.info(`Loading ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`) + const result = this.requirePlugin(resolvedPath, moduleName) + if (result.error) { + this.logger.error(`Failed to load module: ${JSON.stringify(result.error)}`) + return undefined + } + return result.module + } + + /** + * Resolves a loads a plugin function relative to initialDir + * @param initialDir + * @param moduleName + */ + private requirePlugin(initialDir: string, moduleName: string): RequireResult { + try { + const modulePath = this.resolveJavaScriptModule(moduleName, initialDir, this.fs) + return { module: this.requireModule(modulePath), error: undefined } + } catch (error) { + return { module: undefined, error } + } + } + + /** + * Expose resolution logic to allow us to use Node module resolution logic from arbitrary locations. + * No way to do this with `require()`: https://github.com/nodejs/node/issues/5963 + * Throws an error if the module can't be resolved. + * stolen from moduleNameResolver.ts because marked as internal + */ + private resolveJavaScriptModule(moduleName: string, initialDir: string, host: ts.ModuleResolutionHost): string { + // TODO: this should set jsOnly=true to the internal resolver, but this parameter is not exposed on a public api. + const result = + ts.nodeModuleNameResolver( + moduleName, + initialDir.replace('\\', '/') + '/package.json', /* containingFile */ + { moduleResolution: ts.ModuleResolutionKind.NodeJs, allowJs: true }, + this.resolutionHost, + undefined + ) + if (!result.resolvedModule) { + // this.logger.error(result.failedLookupLocations); + throw new Error(`Could not resolve JS module ${moduleName} starting at ${initialDir}.`) + } + return result.resolvedModule.resolvedFileName + } } diff --git a/src/project-manager.ts b/src/project-manager.ts index dd5dbf680..8de21e064 100644 --- a/src/project-manager.ts +++ b/src/project-manager.ts @@ -1,592 +1,28 @@ -import { Observable, Subscription } from '@reactivex/rxjs'; -import { SelectorMethodSignature } from '@reactivex/rxjs/dist/cjs/observable/FromEventObservable'; -import iterate from 'iterare'; -import { noop } from 'lodash'; -import { Span } from 'opentracing'; -import * as path from 'path'; -import * as ts from 'typescript'; -import { Disposable } from './disposable'; -import { FileSystemUpdater } from './fs'; -import { Logger, NoopLogger } from './logging'; -import { InMemoryFileSystem } from './memfs'; -import { PluginCreateInfo, PluginLoader, PluginModuleFactory } from './plugins'; -import { PluginSettings } from './request-type'; -import { traceObservable, traceSync } from './tracing'; +import { Observable, Subscription } from '@reactivex/rxjs' +import { SelectorMethodSignature } from '@reactivex/rxjs/dist/cjs/observable/FromEventObservable' +import iterate from 'iterare' +import { noop } from 'lodash' +import { Span } from 'opentracing' +import * as path from 'path' +import * as ts from 'typescript' +import { Disposable } from './disposable' +import { FileSystemUpdater } from './fs' +import { Logger, NoopLogger } from './logging' +import { InMemoryFileSystem } from './memfs' +import { PluginCreateInfo, PluginLoader, PluginModuleFactory } from './plugins' +import { PluginSettings } from './request-type' +import { traceObservable, traceSync } from './tracing' import { - isConfigFile, - isDeclarationFile, - isGlobalTSFile, - isJSTSFile, - isPackageJsonFile, - observableFromIterable, - path2uri, - toUnixPath, - uri2path -} from './util'; - -export type ConfigType = 'js' | 'ts'; - -/** - * ProjectManager translates VFS files to one or many projects denoted by [tj]config.json. - * It uses either local or remote file system to fetch directory tree and files from and then - * makes one or more LanguageService objects. By default all LanguageService objects contain no files, - * they are added on demand - current file for hover or definition, project's files for references and - * all files from all projects for workspace symbols. - */ -export class ProjectManager implements Disposable { - - /** - * Root path with slashes - */ - private rootPath: string; - - /** - * (Workspace subtree (folder) -> TS or JS configuration) mapping. - * Configuration settings for a source file A are located in the closest parent folder of A. - * Map keys are relative (to workspace root) paths - */ - private configs = { - js: new Map(), - ts: new Map() - }; - - /** - * Local side of file content provider which keeps cache of fetched files - */ - private inMemoryFs: InMemoryFileSystem; - - /** - * File system updater that takes care of updating the in-memory file system - */ - private updater: FileSystemUpdater; - - /** - * URI -> version map. Every time file content is about to change or changed (didChange/didOpen/...), we are incrementing it's version - * signalling that file is changed and file's user must invalidate cached and requery file content - */ - private versions: Map; - - /** - * Enables module resolution tracing by TS compiler - */ - private traceModuleResolution: boolean; - - /** - * Flag indicating that we fetched module struture (tsconfig.json, jsconfig.json, package.json files) from the remote file system. - * Without having this information we won't be able to split workspace to sub-projects - */ - private ensuredModuleStructure?: Observable; - - /** - * Observable that completes when extra dependencies pointed to by tsconfig.json have been loaded. - */ - private ensuredConfigDependencies?: Observable; - - /** - * Observable that completes when `ensureAllFiles` completed - */ - private ensuredAllFiles?: Observable; - - /** - * Observable that completes when `ensureOwnFiles` completed - */ - private ensuredOwnFiles?: Observable; - - /** - * A URI Map from file to files referenced by the file, so files only need to be pre-processed once - */ - private referencedFiles = new Map>(); - - /** - * Tracks all Subscriptions that are done in the lifetime of this object to dispose on `dispose()` - */ - private subscriptions = new Subscription(); - - /** - * Options passed to the language server at startup - */ - private pluginSettings?: PluginSettings; - - /** - * @param rootPath root path as passed to `initialize` - * @param inMemoryFileSystem File system that keeps structure and contents in memory - * @param strict indicates if we are working in strict mode (VFS) or with a local file system - * @param traceModuleResolution allows to enable module resolution tracing (done by TS compiler) - */ - constructor( - rootPath: string, - inMemoryFileSystem: InMemoryFileSystem, - updater: FileSystemUpdater, - traceModuleResolution?: boolean, - pluginSettings?: PluginSettings, - protected logger: Logger = new NoopLogger() - ) { - this.rootPath = rootPath; - this.updater = updater; - this.inMemoryFs = inMemoryFileSystem; - this.versions = new Map(); - this.pluginSettings = pluginSettings; - this.traceModuleResolution = traceModuleResolution || false; - - // Share DocumentRegistry between all ProjectConfigurations - const documentRegistry = ts.createDocumentRegistry(); - - // Create catch-all fallback configs in case there are no tsconfig.json files - // They are removed once at least one tsconfig.json is found - const trimmedRootPath = this.rootPath.replace(/\/+$/, ''); - const fallbackConfigs: {js?: ProjectConfiguration, ts?: ProjectConfiguration} = {}; - for (const configType of ['js', 'ts'] as ConfigType[]) { - const configs = this.configs[configType]; - const tsConfig: any = { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - allowNonTsExtensions: false, - allowJs: configType === 'js' - }, - include: { js: ['**/*.js', '**/*.jsx'], ts: ['**/*.ts', '**/*.tsx'] }[configType] - }; - const config = new ProjectConfiguration( - this.inMemoryFs, - documentRegistry, - trimmedRootPath, - this.versions, - '', - tsConfig, - this.traceModuleResolution, - this.pluginSettings, - this.logger - ); - configs.set(trimmedRootPath, config); - fallbackConfigs[configType] = config; - } - - // Whenever a file with content is added to the InMemoryFileSystem, check if it's a tsconfig.json and add a new ProjectConfiguration - this.subscriptions.add( - Observable.fromEvent(inMemoryFileSystem, 'add', Array.of as SelectorMethodSignature<[string, string]>) - .filter(([uri, content]) => !!content && /\/[tj]sconfig\.json/.test(uri) && !uri.includes('/node_modules/')) - .subscribe(([uri, content]) => { - const filePath = uri2path(uri); - let dir = toUnixPath(filePath); - const pos = dir.lastIndexOf('/'); - if (pos <= 0) { - dir = ''; - } else { - dir = dir.substring(0, pos); - } - const configType = this.getConfigurationType(filePath); - const configs = this.configs[configType]; - configs.set(dir, new ProjectConfiguration( - this.inMemoryFs, - documentRegistry, - dir, - this.versions, - filePath, - undefined, - this.traceModuleResolution, - this.pluginSettings, - this.logger - )); - // Remove catch-all config (if exists) - if (configs.get(trimmedRootPath) === fallbackConfigs[configType]) { - configs.delete(trimmedRootPath); - } - }) - ); - } - - /** - * Disposes the object (removes all registered listeners) - */ - dispose(): void { - this.subscriptions.unsubscribe(); - } - - /** - * @return root path (as passed to `initialize`) - */ - getRemoteRoot(): string { - return this.rootPath; - } - - /** - * @return local side of file content provider which keeps cached copies of fethed files - */ - getFs(): InMemoryFileSystem { - return this.inMemoryFs; - } - - /** - * @param filePath file path (both absolute or relative file paths are accepted) - * @return true if there is a fetched file with a given path - */ - hasFile(filePath: string) { - return this.inMemoryFs.fileExists(filePath); - } - - /** - * @return all sub-projects we have identified for a given workspace. - * Sub-project is mainly a folder which contains tsconfig.json, jsconfig.json, package.json, - * or a root folder which serves as a fallback - */ - configurations(): IterableIterator { - return iterate(this.configs.js.values()).concat(this.configs.ts.values()); - } - - /** - * Ensures that the module structure of the project exists in memory. - * TypeScript/JavaScript module structure is determined by [jt]sconfig.json, - * filesystem layout, global*.d.ts and package.json files. - * Then creates new ProjectConfigurations, resets existing and invalidates file references. - */ - ensureModuleStructure(childOf = new Span()): Observable { - return traceObservable('Ensure module structure', childOf, span => { - if (!this.ensuredModuleStructure) { - this.ensuredModuleStructure = this.updater.ensureStructure() - // Ensure content of all all global .d.ts, [tj]sconfig.json, package.json files - .concat(Observable.defer(() => observableFromIterable(this.inMemoryFs.uris()))) - .filter(uri => isGlobalTSFile(uri) || isConfigFile(uri) || isPackageJsonFile(uri)) - .mergeMap(uri => this.updater.ensure(uri)) - .do(noop, err => { - this.ensuredModuleStructure = undefined; - }, () => { - // Reset all compilation state - // TODO ze incremental compilation instead - for (const config of this.configurations()) { - config.reset(); - } - // Require re-processing of file references - this.invalidateReferencedFiles(); - }) - .publishReplay() - .refCount() as Observable; - } - return this.ensuredModuleStructure; - }); - } - - /** - * Invalidates caches for `ensureModuleStructure`, `ensureAllFiles` and `insureOwnFiles` - */ - invalidateModuleStructure(): void { - this.ensuredModuleStructure = undefined; - this.ensuredConfigDependencies = undefined; - this.ensuredAllFiles = undefined; - this.ensuredOwnFiles = undefined; - } - - /** - * Ensures all files not in node_modules were fetched. - * This includes all js/ts files, tsconfig files and package.json files. - * Invalidates project configurations after execution - */ - ensureOwnFiles(childOf = new Span()): Observable { - return traceObservable('Ensure own files', childOf, span => { - if (!this.ensuredOwnFiles) { - this.ensuredOwnFiles = this.updater.ensureStructure(span) - .concat(Observable.defer(() => observableFromIterable(this.inMemoryFs.uris()))) - .filter(uri => !uri.includes('/node_modules/') && isJSTSFile(uri) || isConfigFile(uri) || isPackageJsonFile(uri)) - .mergeMap(uri => this.updater.ensure(uri)) - .do(noop, err => { - this.ensuredOwnFiles = undefined; - }) - .publishReplay() - .refCount() as Observable; - } - return this.ensuredOwnFiles; - }); - } - - /** - * Ensures all files were fetched from the remote file system. - * Invalidates project configurations after execution - */ - ensureAllFiles(childOf = new Span()): Observable { - return traceObservable('Ensure all files', childOf, span => { - if (!this.ensuredAllFiles) { - this.ensuredAllFiles = this.updater.ensureStructure(span) - .concat(Observable.defer(() => observableFromIterable(this.inMemoryFs.uris()))) - .filter(uri => isJSTSFile(uri) || isConfigFile(uri) || isPackageJsonFile(uri)) - .mergeMap(uri => this.updater.ensure(uri)) - .do(noop, err => { - this.ensuredAllFiles = undefined; - }) - .publishReplay() - .refCount() as Observable; - } - return this.ensuredAllFiles; - }); - } - - /** - * Recursively collects file(s) dependencies up to given level. - * Dependencies are extracted by TS compiler from import and reference statements - * - * Dependencies include: - * - all the configuration files - * - files referenced by the given file - * - files included by the given file - * - * The return values of this method are not cached, but those of the file fetching and file processing are. - * - * @param uri File to process - * @param maxDepth Stop collecting when reached given recursion level - * @param ignore Tracks visited files to prevent cycles - * @param childOf OpenTracing parent span for tracing - * @return Observable of file URIs ensured - */ - ensureReferencedFiles(uri: string, maxDepth = 30, ignore = new Set(), childOf = new Span()): Observable { - return traceObservable('Ensure referenced files', childOf, span => { - span.addTags({ uri, maxDepth }); - ignore.add(uri); - return this.ensureModuleStructure(span) - .concat(Observable.defer(() => this.ensureConfigDependencies())) - // If max depth was reached, don't go any further - .concat(Observable.defer(() => maxDepth === 0 ? Observable.empty() : this.resolveReferencedFiles(uri))) - // Prevent cycles - .filter(referencedUri => !ignore.has(referencedUri)) - // Call method recursively with one less dep level - .mergeMap(referencedUri => - this.ensureReferencedFiles(referencedUri, maxDepth - 1, ignore) - // Continue even if an import wasn't found - .catch(err => { - this.logger.error(`Error resolving file references for ${uri}:`, err); - return []; - }) - ); - }); - } - - /** - * Determines if a tsconfig/jsconfig needs additional declaration files loaded. - * @param filePath - */ - isConfigDependency(filePath: string): boolean { - for (const config of this.configurations()) { - config.ensureConfigFile(); - if (config.isExpectedDeclarationFile(filePath)) { - return true; - } - } - return false; - } - - /** - * Loads files determined by tsconfig to be needed into the file system - */ - ensureConfigDependencies(childOf = new Span()): Observable { - return traceObservable('Ensure config dependencies', childOf, span => { - if (!this.ensuredConfigDependencies) { - this.ensuredConfigDependencies = observableFromIterable(this.inMemoryFs.uris()) - .filter(uri => this.isConfigDependency(uri2path(uri))) - .mergeMap(uri => this.updater.ensure(uri)) - .do(noop, err => { - this.ensuredConfigDependencies = undefined; - }) - .publishReplay() - .refCount() as Observable; - } - return this.ensuredConfigDependencies; - }); - } - - /** - * Invalidates a cache entry for `resolveReferencedFiles` (e.g. because the file changed) - * - * @param uri The URI that referenced files should be invalidated for. If not given, all entries are invalidated - */ - invalidateReferencedFiles(uri?: string): void { - if (uri) { - this.referencedFiles.delete(uri); - } else { - this.referencedFiles.clear(); - } - } - - /** - * Returns the files that are referenced from a given file. - * If the file has already been processed, returns a cached value. - * - * @param uri URI of the file to process - * @return URIs of files referenced by the file - */ - private resolveReferencedFiles(uri: string, span = new Span()): Observable { - let observable = this.referencedFiles.get(uri); - if (observable) { - return observable; - } - observable = this.updater.ensure(uri) - .concat(Observable.defer(() => { - const referencingFilePath = uri2path(uri); - const config = this.getConfiguration(referencingFilePath); - config.ensureBasicFiles(span); - const contents = this.inMemoryFs.getContent(uri); - const info = ts.preProcessFile(contents, true, true); - const compilerOpt = config.getHost().getCompilationSettings(); - const pathResolver = referencingFilePath.includes('\\') ? path.win32 : path.posix; - // Iterate imported files - return Observable.merge( - // References with `import` - Observable.from(info.importedFiles) - .map(importedFile => ts.resolveModuleName(importedFile.fileName, toUnixPath(referencingFilePath), compilerOpt, this.inMemoryFs)) - // false means we didn't find a file defining the module. It could still - // exist as an ambient module, which is why we fetch global*.d.ts files. - .filter(resolved => !!(resolved && resolved.resolvedModule)) - .map(resolved => resolved.resolvedModule!.resolvedFileName), - // References with `` - Observable.from(info.referencedFiles) - // Resolve triple slash references relative to current file instead of using - // module resolution host because it behaves differently in "nodejs" mode - .map(referencedFile => pathResolver.resolve(this.rootPath, pathResolver.dirname(referencingFilePath), toUnixPath(referencedFile.fileName))), - // References with `` - Observable.from(info.typeReferenceDirectives) - .map(typeReferenceDirective => ts.resolveTypeReferenceDirective(typeReferenceDirective.fileName, referencingFilePath, compilerOpt, this.inMemoryFs)) - .filter(resolved => !!(resolved && resolved.resolvedTypeReferenceDirective && resolved.resolvedTypeReferenceDirective.resolvedFileName)) - .map(resolved => resolved.resolvedTypeReferenceDirective!.resolvedFileName!) - ); - })) - // Use same scheme, slashes, host for referenced URI as input file - .map(filePath => path2uri(filePath)) - // Don't cache errors - .do(noop, err => { - this.referencedFiles.delete(uri); - }) - // Make sure all subscribers get the same values - .publishReplay() - .refCount(); - this.referencedFiles.set(uri, observable); - return observable; - } - - /** - * @param filePath source file path, absolute - * @return project configuration for a given source file. Climbs directory tree up to workspace root if needed - */ - getConfiguration(filePath: string, configType: ConfigType = this.getConfigurationType(filePath)): ProjectConfiguration { - const config = this.getConfigurationIfExists(filePath, configType); - if (!config) { - throw new Error(`TypeScript config file for ${filePath} not found`); - } - return config; - } - - /** - * @param filePath source file path, absolute - * @return closest configuration for a given file path or undefined if there is no such configuration - */ - getConfigurationIfExists(filePath: string, configType = this.getConfigurationType(filePath)): ProjectConfiguration | undefined { - let dir = toUnixPath(filePath); - let config: ProjectConfiguration | undefined; - const configs = this.configs[configType]; - if (!configs) { - return undefined; - } - const rootPath = this.rootPath.replace(/\/+$/, ''); - while (dir && dir !== rootPath) { - config = configs.get(dir); - if (config) { - return config; - } - const pos = dir.lastIndexOf('/'); - if (pos <= 0) { - dir = ''; - } else { - dir = dir.substring(0, pos); - } - } - return configs.get(rootPath); - } - - /** - * Returns the ProjectConfiguration a file belongs to - */ - getParentConfiguration(uri: string, configType?: ConfigType): ProjectConfiguration | undefined { - return this.getConfigurationIfExists(uri2path(uri), configType); - } - - /** - * Returns all ProjectConfigurations contained in the given directory or one of its childrens - * - * @param uri URI of a directory - */ - getChildConfigurations(uri: string): IterableIterator { - const pathPrefix = uri2path(uri); - return iterate(this.configs.ts).concat(this.configs.js) - .filter(([folderPath, config]) => folderPath.startsWith(pathPrefix)) - .map(([folderPath, config]) => config); - } - - /** - * Called when file was opened by client. Current implementation - * does not differenciates open and change events - * @param uri file's URI - * @param text file's content - */ - didOpen(uri: string, text: string) { - this.didChange(uri, text); - } - - /** - * Called when file was closed by client. Current implementation invalidates compiled version - * @param uri file's URI - */ - didClose(uri: string, span = new Span()) { - const filePath = uri2path(uri); - this.inMemoryFs.didClose(uri); - let version = this.versions.get(uri) || 0; - this.versions.set(uri, ++version); - const config = this.getConfigurationIfExists(filePath); - if (!config) { - return; - } - config.ensureConfigFile(span); - config.getHost().incProjectVersion(); - } - - /** - * Called when file was changed by client. Current implementation invalidates compiled version - * @param uri file's URI - * @param text file's content - */ - didChange(uri: string, text: string, span = new Span()) { - const filePath = uri2path(uri); - this.inMemoryFs.didChange(uri, text); - let version = this.versions.get(uri) || 0; - this.versions.set(uri, ++version); - const config = this.getConfigurationIfExists(filePath); - if (!config) { - return; - } - config.ensureConfigFile(span); - config.ensureSourceFile(filePath); - config.getHost().incProjectVersion(); - } - - /** - * Called when file was saved by client - * @param uri file's URI - */ - didSave(uri: string) { - this.inMemoryFs.didSave(uri); - } - - /** - * @param filePath path to source (or config) file - * @return configuration type to use for a given file - */ - private getConfigurationType(filePath: string): ConfigType { - const name = path.posix.basename(filePath); - if (name === 'tsconfig.json') { - return 'ts'; - } else if (name === 'jsconfig.json') { - return 'js'; - } - const extension = path.posix.extname(filePath); - if (extension === '.js' || extension === '.jsx') { - return 'js'; - } - return 'ts'; - } -} + isConfigFile, + isDeclarationFile, + isGlobalTSFile, + isJSTSFile, + isPackageJsonFile, + observableFromIterable, + path2uri, + toUnixPath, + uri2path +} from './util' /** * Implementaton of LanguageServiceHost that works with in-memory file system. @@ -596,142 +32,142 @@ export class ProjectManager implements Disposable { */ export class InMemoryLanguageServiceHost implements ts.LanguageServiceHost { - complete: boolean; - - /** - * Root path - */ - private rootPath: string; - - /** - * Compiler options to use when parsing/analyzing source files. - * We are extracting them from tsconfig.json or jsconfig.json - */ - private options: ts.CompilerOptions; - - /** - * Local file cache where we looking for file content - */ - private fs: InMemoryFileSystem; - - /** - * Current list of files that were implicitly added to project - * (every time when we need to extract data from a file that we haven't touched yet). - * Each item is a relative file path - */ - private filePaths: string[]; - - /** - * Current project version. When something significant is changed, incrementing it to signal TS compiler that - * files should be updated and cached data should be invalidated - */ - private projectVersion: number; - - /** - * Tracks individual files versions to invalidate TS compiler data when single file is changed. Keys are URIs - */ - private versions: Map; - - constructor(rootPath: string, options: ts.CompilerOptions, fs: InMemoryFileSystem, versions: Map, private logger: Logger = new NoopLogger()) { - this.rootPath = rootPath; - this.options = options; - this.fs = fs; - this.versions = versions; - this.projectVersion = 1; - this.filePaths = []; - } - - /** - * TypeScript uses this method (when present) to compare project's version - * with the last known one to decide if internal data should be synchronized - */ - getProjectVersion(): string { - return '' + this.projectVersion; - } - - getNewLine(): string { - // Although this is optional, language service was sending edits with carriage returns if not specified. - // TODO: combine with the FormatOptions defaults. - return '\n'; - } - - /** - * Incrementing current project version, telling TS compiler to invalidate internal data - */ - incProjectVersion() { - this.projectVersion++; - } - - getCompilationSettings(): ts.CompilerOptions { - return this.options; - } - - getScriptFileNames(): string[] { - return this.filePaths; - } - - /** - * Adds a file and increments project version, used in conjunction with getProjectVersion() - * which may be called by TypeScript to check if internal data is up to date - * - * @param filePath relative file path - */ - addFile(filePath: string) { - this.filePaths.push(filePath); - this.incProjectVersion(); - } - - /** - * @param fileName absolute file path - */ - getScriptVersion(filePath: string): string { - const uri = path2uri(filePath); - let version = this.versions.get(uri); - if (!version) { - version = 1; - this.versions.set(uri, version); - } - return '' + version; - } - - /** - * @param filePath absolute file path - */ - getScriptSnapshot(filePath: string): ts.IScriptSnapshot | undefined { - const exists = this.fs.fileExists(filePath); - if (!exists) { - return undefined; - } - return ts.ScriptSnapshot.fromString(this.fs.readFile(filePath)); - } - - getCurrentDirectory(): string { - return this.rootPath; - } - - getDefaultLibFileName(options: ts.CompilerOptions): string { - return toUnixPath(ts.getDefaultLibFilePath(options)); - } - - trace(message: string) { - // empty - } - - log(message: string) { - // empty - } - - error(message: string) { - this.logger.error(message); - } - - readFile(path: string, encoding?: string): string { - return this.fs.readFile(path); - } - - fileExists(path: string): boolean { - return this.fs.fileExists(path); - } + public complete: boolean + + /** + * Root path + */ + private rootPath: string + + /** + * Compiler options to use when parsing/analyzing source files. + * We are extracting them from tsconfig.json or jsconfig.json + */ + private options: ts.CompilerOptions + + /** + * Local file cache where we looking for file content + */ + private fs: InMemoryFileSystem + + /** + * Current list of files that were implicitly added to project + * (every time when we need to extract data from a file that we haven't touched yet). + * Each item is a relative file path + */ + private filePaths: string[] + + /** + * Current project version. When something significant is changed, incrementing it to signal TS compiler that + * files should be updated and cached data should be invalidated + */ + private projectVersion: number + + /** + * Tracks individual files versions to invalidate TS compiler data when single file is changed. Keys are URIs + */ + private versions: Map + + constructor(rootPath: string, options: ts.CompilerOptions, fs: InMemoryFileSystem, versions: Map, private logger: Logger = new NoopLogger()) { + this.rootPath = rootPath + this.options = options + this.fs = fs + this.versions = versions + this.projectVersion = 1 + this.filePaths = [] + } + + /** + * TypeScript uses this method (when present) to compare project's version + * with the last known one to decide if internal data should be synchronized + */ + public getProjectVersion(): string { + return '' + this.projectVersion + } + + public getNewLine(): string { + // Although this is optional, language service was sending edits with carriage returns if not specified. + // TODO: combine with the FormatOptions defaults. + return '\n' + } + + /** + * Incrementing current project version, telling TS compiler to invalidate internal data + */ + public incProjectVersion(): void { + this.projectVersion++ + } + + public getCompilationSettings(): ts.CompilerOptions { + return this.options + } + + public getScriptFileNames(): string[] { + return this.filePaths + } + + /** + * Adds a file and increments project version, used in conjunction with getProjectVersion() + * which may be called by TypeScript to check if internal data is up to date + * + * @param filePath relative file path + */ + public addFile(filePath: string): void { + this.filePaths.push(filePath) + this.incProjectVersion() + } + + /** + * @param fileName absolute file path + */ + public getScriptVersion(filePath: string): string { + const uri = path2uri(filePath) + let version = this.versions.get(uri) + if (!version) { + version = 1 + this.versions.set(uri, version) + } + return '' + version + } + + /** + * @param filePath absolute file path + */ + public getScriptSnapshot(filePath: string): ts.IScriptSnapshot | undefined { + const exists = this.fs.fileExists(filePath) + if (!exists) { + return undefined + } + return ts.ScriptSnapshot.fromString(this.fs.readFile(filePath)) + } + + public getCurrentDirectory(): string { + return this.rootPath + } + + public getDefaultLibFileName(options: ts.CompilerOptions): string { + return toUnixPath(ts.getDefaultLibFilePath(options)) + } + + public trace(message: string): void { + // empty + } + + public log(message: string): void { + // empty + } + + public error(message: string): void { + this.logger.error(message) + } + + public readFile(path: string, encoding?: string): string { + return this.fs.readFile(path) + } + + public fileExists(path: string): boolean { + return this.fs.fileExists(path) + } } /** @@ -750,297 +186,859 @@ export class InMemoryLanguageServiceHost implements ts.LanguageServiceHost { */ export class ProjectConfiguration { - private service?: ts.LanguageService; - - /** - * Object TS service will use to fetch content of source files - */ - private host?: InMemoryLanguageServiceHost; - - /** - * Local file cache - */ - private fs: InMemoryFileSystem; - - /** - * Relative path to configuration file (tsconfig.json/jsconfig.json) - */ - configFilePath: string; - - /** - * Configuration JSON object. May be used when there is no real configuration file to parse and use - */ - private configContent: any; - - /** - * Relative source file path (relative) -> version associations - */ - private versions: Map; - - /** - * Enables module resolution tracing (done by TS service) - */ - private traceModuleResolution: boolean; - - /** - * Root file path, relative to workspace hierarchy root - */ - private rootFilePath: string; - - /** - * List of files that project consist of (based on tsconfig includes/excludes and wildcards). - * Each item is a relative file path - */ - private expectedFilePaths = new Set(); - - /** - * List of resolved extra root directories to allow global type declaration files to be loaded from. - */ - private typeRoots: string[]; - - /** - * @param fs file system to use - * @param documentRegistry Shared DocumentRegistry that manages SourceFile objects - * @param rootFilePath root file path, absolute - * @param configFilePath configuration file path, absolute - * @param configContent optional configuration content to use instead of reading configuration file) - */ - constructor( - fs: InMemoryFileSystem, - private documentRegistry: ts.DocumentRegistry, - rootFilePath: string, - versions: Map, - configFilePath: string, - configContent?: any, - traceModuleResolution?: boolean, - private pluginSettings?: PluginSettings, - private logger: Logger = new NoopLogger() - ) { - this.fs = fs; - this.configFilePath = configFilePath; - this.configContent = configContent; - this.versions = versions; - this.traceModuleResolution = traceModuleResolution || false; - this.rootFilePath = rootFilePath; - } - - /** - * reset resets a ProjectConfiguration to its state immediately - * after construction. It should be called whenever the underlying - * local filesystem (fs) has changed, and so the - * ProjectConfiguration can no longer assume its state reflects - * that of the underlying files. - */ - reset(): void { - this.initialized = false; - this.ensuredBasicFiles = false; - this.ensuredAllFiles = false; - this.service = undefined; - this.host = undefined; - this.expectedFilePaths = new Set(); - } - - /** - * @return language service object - */ - getService(): ts.LanguageService { - if (!this.service) { - throw new Error('project is uninitialized'); - } - return this.service; - } - - /** - * Tells TS service to recompile program (if needed) based on current list of files and compilation options. - * TS service relies on information provided by language servide host to see if there were any changes in - * the whole project or in some files - * - * @return program object (cached result of parsing and typechecking done by TS service) - */ - getProgram(childOf = new Span()): ts.Program | undefined { - return traceSync('Get program', childOf, span => this.getService().getProgram()); - } - - /** - * @return language service host that TS service uses to read the data - */ - getHost(): InMemoryLanguageServiceHost { - if (!this.host) { - throw new Error('project is uninitialized'); - } - return this.host; - } - - private initialized = false; - - /** - * Initializes (sub)project by parsing configuration and making proper internal objects - */ - private init(span = new Span()): void { - if (this.initialized) { - return; - } - let configObject; - if (!this.configContent) { - const jsonConfig = ts.parseConfigFileTextToJson(this.configFilePath, this.fs.readFile(this.configFilePath)); - if (jsonConfig.error) { - this.logger.error('Cannot parse ' + this.configFilePath + ': ' + jsonConfig.error.messageText); - throw new Error('Cannot parse ' + this.configFilePath + ': ' + jsonConfig.error.messageText); - } - configObject = jsonConfig.config; - } else { - configObject = this.configContent; - } - let dir = toUnixPath(this.configFilePath); - const pos = dir.lastIndexOf('/'); - if (pos <= 0) { - dir = ''; - } else { - dir = dir.substring(0, pos); - } - const base = dir || this.fs.path; - const configParseResult = ts.parseJsonConfigFileContent(configObject, this.fs, base); - this.expectedFilePaths = new Set(configParseResult.fileNames); - - const options = configParseResult.options; - const pathResolver = /^[a-z]:\//i.test(base) ? path.win32 : path.posix; - this.typeRoots = options.typeRoots ? - options.typeRoots.map((r: string) => pathResolver.resolve(this.rootFilePath, r)) : - []; - - if (/(^|\/)jsconfig\.json$/.test(this.configFilePath)) { - options.allowJs = true; - } - if (this.traceModuleResolution) { - options.traceResolution = true; - } - this.host = new InMemoryLanguageServiceHost( - this.fs.path, - options, - this.fs, - this.versions, - this.logger - ); - this.service = ts.createLanguageService(this.host, this.documentRegistry); - const pluginLoader = new PluginLoader(this.rootFilePath, this.fs, this.pluginSettings, this.logger); - pluginLoader.loadPlugins(options, (factory, config) => this.wrapService(factory, config)); - this.initialized = true; - } - - /** - * Replaces the LanguageService with an instance wrapped by the plugin - * @param pluginModuleFactory function to create the module - * @param configEntry extra settings from tsconfig to pass to the plugin module - */ - private wrapService(pluginModuleFactory: PluginModuleFactory, configEntry: ts.PluginImport) { - try { - if (typeof pluginModuleFactory !== 'function') { - this.logger.info(`Skipped loading plugin ${configEntry.name} because it didn't expose a proper factory function`); - return; - } - - const info: PluginCreateInfo = { - config: configEntry, - project: { projectService: { logger: this.logger }}, // TODO: may need more support - languageService: this.getService(), - languageServiceHost: this.getHost(), - serverHost: {} // TODO: may need an adapter - }; - - const pluginModule = pluginModuleFactory({ typescript: ts }); - this.service = pluginModule.create(info); - } catch (e) { - this.logger.error(`Plugin activation failed: ${e}`); - } - } - - /** - * Ensures we are ready to process files from a given sub-project - */ - ensureConfigFile(span = new Span()): void { - this.init(span); - } - - private ensuredBasicFiles = false; - - /** - * Determines if a fileName is a declaration file within expected files or type roots - * @param fileName - */ - public isExpectedDeclarationFile(fileName: string) { - return isDeclarationFile(fileName) && - (this.expectedFilePaths.has(toUnixPath(fileName)) || - this.typeRoots.some(root => fileName.startsWith(root))); - } - - /** - * Ensures we added basic files (global TS files, dependencies, declarations) - */ - ensureBasicFiles(span = new Span()): void { - if (this.ensuredBasicFiles) { - return; - } - - this.init(span); - - const program = this.getProgram(span); - if (!program) { - return; - } - - // Add all global declaration files from the workspace and all declarations from the project - for (const uri of this.fs.uris()) { - const fileName = uri2path(uri); - if (isGlobalTSFile(fileName) || - this.isExpectedDeclarationFile(fileName)) { - const sourceFile = program.getSourceFile(fileName); - if (!sourceFile) { - this.getHost().addFile(fileName); - } - } - } - this.ensuredBasicFiles = true; - } - - private ensuredAllFiles = false; - - /** - * Ensures a single file is available to the LanguageServiceHost - * @param filePath - */ - ensureSourceFile(filePath: string, span = new Span()): void { - const program = this.getProgram(span); - if (!program) { - return; - } - const sourceFile = program.getSourceFile(filePath); - if (!sourceFile) { - this.getHost().addFile(filePath); - } - } - - /** - * Ensures we added all project's source file (as were defined in tsconfig.json) - */ - ensureAllFiles(span = new Span()): void { - if (this.ensuredAllFiles) { - return; - } - this.init(span); - if (this.getHost().complete) { - return; - } - const program = this.getProgram(span); - if (!program) { - return; - } - for (const fileName of this.expectedFilePaths) { - const sourceFile = program.getSourceFile(fileName); - if (!sourceFile) { - this.getHost().addFile(fileName); - } - } - this.getHost().complete = true; - this.ensuredAllFiles = true; - } + private service?: ts.LanguageService + + /** + * Object TS service will use to fetch content of source files + */ + private host?: InMemoryLanguageServiceHost + + /** + * Local file cache + */ + private fs: InMemoryFileSystem + + /** + * Relative path to configuration file (tsconfig.json/jsconfig.json) + */ + public configFilePath: string + + /** + * Configuration JSON object. May be used when there is no real configuration file to parse and use + */ + private configContent: any + + /** + * Relative source file path (relative) -> version associations + */ + private versions: Map + + /** + * Enables module resolution tracing (done by TS service) + */ + private traceModuleResolution: boolean + + /** + * Root file path, relative to workspace hierarchy root + */ + private rootFilePath: string + + /** + * List of files that project consist of (based on tsconfig includes/excludes and wildcards). + * Each item is a relative file path + */ + private expectedFilePaths = new Set() + + /** + * List of resolved extra root directories to allow global type declaration files to be loaded from. + */ + private typeRoots: string[] + + private initialized = false + private ensuredAllFiles = false + private ensuredBasicFiles = false + + /** + * @param fs file system to use + * @param documentRegistry Shared DocumentRegistry that manages SourceFile objects + * @param rootFilePath root file path, absolute + * @param configFilePath configuration file path, absolute + * @param configContent optional configuration content to use instead of reading configuration file) + */ + constructor( + fs: InMemoryFileSystem, + private documentRegistry: ts.DocumentRegistry, + rootFilePath: string, + versions: Map, + configFilePath: string, + configContent?: any, + traceModuleResolution?: boolean, + private pluginSettings?: PluginSettings, + private logger: Logger = new NoopLogger() + ) { + this.fs = fs + this.configFilePath = configFilePath + this.configContent = configContent + this.versions = versions + this.traceModuleResolution = traceModuleResolution || false + this.rootFilePath = rootFilePath + } + + /** + * reset resets a ProjectConfiguration to its state immediately + * after construction. It should be called whenever the underlying + * local filesystem (fs) has changed, and so the + * ProjectConfiguration can no longer assume its state reflects + * that of the underlying files. + */ + public reset(): void { + this.initialized = false + this.ensuredBasicFiles = false + this.ensuredAllFiles = false + this.service = undefined + this.host = undefined + this.expectedFilePaths = new Set() + } + + /** + * @return language service object + */ + public getService(): ts.LanguageService { + if (!this.service) { + throw new Error('project is uninitialized') + } + return this.service + } + + /** + * Tells TS service to recompile program (if needed) based on current list of files and compilation options. + * TS service relies on information provided by language servide host to see if there were any changes in + * the whole project or in some files + * + * @return program object (cached result of parsing and typechecking done by TS service) + */ + public getProgram(childOf = new Span()): ts.Program | undefined { + return traceSync('Get program', childOf, span => this.getService().getProgram()) + } + + /** + * @return language service host that TS service uses to read the data + */ + public getHost(): InMemoryLanguageServiceHost { + if (!this.host) { + throw new Error('project is uninitialized') + } + return this.host + } + + /** + * Initializes (sub)project by parsing configuration and making proper internal objects + */ + private init(span = new Span()): void { + if (this.initialized) { + return + } + let configObject + if (!this.configContent) { + const jsonConfig = ts.parseConfigFileTextToJson(this.configFilePath, this.fs.readFile(this.configFilePath)) + if (jsonConfig.error) { + this.logger.error('Cannot parse ' + this.configFilePath + ': ' + jsonConfig.error.messageText) + throw new Error('Cannot parse ' + this.configFilePath + ': ' + jsonConfig.error.messageText) + } + configObject = jsonConfig.config + } else { + configObject = this.configContent + } + let dir = toUnixPath(this.configFilePath) + const pos = dir.lastIndexOf('/') + if (pos <= 0) { + dir = '' + } else { + dir = dir.substring(0, pos) + } + const base = dir || this.fs.path + const configParseResult = ts.parseJsonConfigFileContent(configObject, this.fs, base) + this.expectedFilePaths = new Set(configParseResult.fileNames) + + const options = configParseResult.options + const pathResolver = /^[a-z]:\//i.test(base) ? path.win32 : path.posix + this.typeRoots = options.typeRoots ? + options.typeRoots.map((r: string) => pathResolver.resolve(this.rootFilePath, r)) : + [] + + if (/(^|\/)jsconfig\.json$/.test(this.configFilePath)) { + options.allowJs = true + } + if (this.traceModuleResolution) { + options.traceResolution = true + } + this.host = new InMemoryLanguageServiceHost( + this.fs.path, + options, + this.fs, + this.versions, + this.logger + ) + this.service = ts.createLanguageService(this.host, this.documentRegistry) + const pluginLoader = new PluginLoader(this.rootFilePath, this.fs, this.pluginSettings, this.logger) + pluginLoader.loadPlugins(options, (factory, config) => this.wrapService(factory, config)) + this.initialized = true + } + + /** + * Replaces the LanguageService with an instance wrapped by the plugin + * @param pluginModuleFactory function to create the module + * @param configEntry extra settings from tsconfig to pass to the plugin module + */ + private wrapService(pluginModuleFactory: PluginModuleFactory, configEntry: ts.PluginImport): void { + try { + if (typeof pluginModuleFactory !== 'function') { + this.logger.info(`Skipped loading plugin ${configEntry.name} because it didn't expose a proper factory function`) + return + } + + const info: PluginCreateInfo = { + config: configEntry, + project: { projectService: { logger: this.logger }}, // TODO: may need more support + languageService: this.getService(), + languageServiceHost: this.getHost(), + serverHost: {} // TODO: may need an adapter + } + + const pluginModule = pluginModuleFactory({ typescript: ts }) + this.service = pluginModule.create(info) + } catch (e) { + this.logger.error(`Plugin activation failed: ${e}`) + } + } + + /** + * Ensures we are ready to process files from a given sub-project + */ + public ensureConfigFile(span = new Span()): void { + this.init(span) + } + + /** + * Determines if a fileName is a declaration file within expected files or type roots + * @param fileName + */ + public isExpectedDeclarationFile(fileName: string): boolean { + return isDeclarationFile(fileName) && + (this.expectedFilePaths.has(toUnixPath(fileName)) || + this.typeRoots.some(root => fileName.startsWith(root))) + } + + /** + * Ensures we added basic files (global TS files, dependencies, declarations) + */ + public ensureBasicFiles(span = new Span()): void { + if (this.ensuredBasicFiles) { + return + } + + this.init(span) + + const program = this.getProgram(span) + if (!program) { + return + } + + // Add all global declaration files from the workspace and all declarations from the project + for (const uri of this.fs.uris()) { + const fileName = uri2path(uri) + if (isGlobalTSFile(fileName) || + this.isExpectedDeclarationFile(fileName)) { + const sourceFile = program.getSourceFile(fileName) + if (!sourceFile) { + this.getHost().addFile(fileName) + } + } + } + this.ensuredBasicFiles = true + } + + /** + * Ensures a single file is available to the LanguageServiceHost + * @param filePath + */ + public ensureSourceFile(filePath: string, span = new Span()): void { + const program = this.getProgram(span) + if (!program) { + return + } + const sourceFile = program.getSourceFile(filePath) + if (!sourceFile) { + this.getHost().addFile(filePath) + } + } + + /** + * Ensures we added all project's source file (as were defined in tsconfig.json) + */ + public ensureAllFiles(span = new Span()): void { + if (this.ensuredAllFiles) { + return + } + this.init(span) + if (this.getHost().complete) { + return + } + const program = this.getProgram(span) + if (!program) { + return + } + for (const fileName of this.expectedFilePaths) { + const sourceFile = program.getSourceFile(fileName) + if (!sourceFile) { + this.getHost().addFile(fileName) + } + } + this.getHost().complete = true + this.ensuredAllFiles = true + } +} + +export type ConfigType = 'js' | 'ts' + +/** + * ProjectManager translates VFS files to one or many projects denoted by [tj]config.json. + * It uses either local or remote file system to fetch directory tree and files from and then + * makes one or more LanguageService objects. By default all LanguageService objects contain no files, + * they are added on demand - current file for hover or definition, project's files for references and + * all files from all projects for workspace symbols. + */ +export class ProjectManager implements Disposable { + + /** + * Root path with slashes + */ + private rootPath: string + + /** + * (Workspace subtree (folder) -> TS or JS configuration) mapping. + * Configuration settings for a source file A are located in the closest parent folder of A. + * Map keys are relative (to workspace root) paths + */ + private configs = { + js: new Map(), + ts: new Map() + } + + /** + * Local side of file content provider which keeps cache of fetched files + */ + private inMemoryFs: InMemoryFileSystem + + /** + * File system updater that takes care of updating the in-memory file system + */ + private updater: FileSystemUpdater + + /** + * URI -> version map. Every time file content is about to change or changed (didChange/didOpen/...), we are incrementing it's version + * signalling that file is changed and file's user must invalidate cached and requery file content + */ + private versions: Map + + /** + * Enables module resolution tracing by TS compiler + */ + private traceModuleResolution: boolean + + /** + * Flag indicating that we fetched module struture (tsconfig.json, jsconfig.json, package.json files) from the remote file system. + * Without having this information we won't be able to split workspace to sub-projects + */ + private ensuredModuleStructure?: Observable + + /** + * Observable that completes when extra dependencies pointed to by tsconfig.json have been loaded. + */ + private ensuredConfigDependencies?: Observable + + /** + * Observable that completes when `ensureAllFiles` completed + */ + private ensuredAllFiles?: Observable + + /** + * Observable that completes when `ensureOwnFiles` completed + */ + private ensuredOwnFiles?: Observable + + /** + * A URI Map from file to files referenced by the file, so files only need to be pre-processed once + */ + private referencedFiles = new Map>() + + /** + * Tracks all Subscriptions that are done in the lifetime of this object to dispose on `dispose()` + */ + private subscriptions = new Subscription() + + /** + * Options passed to the language server at startup + */ + private pluginSettings?: PluginSettings + + /** + * @param rootPath root path as passed to `initialize` + * @param inMemoryFileSystem File system that keeps structure and contents in memory + * @param strict indicates if we are working in strict mode (VFS) or with a local file system + * @param traceModuleResolution allows to enable module resolution tracing (done by TS compiler) + */ + constructor( + rootPath: string, + inMemoryFileSystem: InMemoryFileSystem, + updater: FileSystemUpdater, + traceModuleResolution?: boolean, + pluginSettings?: PluginSettings, + protected logger: Logger = new NoopLogger() + ) { + this.rootPath = rootPath + this.updater = updater + this.inMemoryFs = inMemoryFileSystem + this.versions = new Map() + this.pluginSettings = pluginSettings + this.traceModuleResolution = traceModuleResolution || false + + // Share DocumentRegistry between all ProjectConfigurations + const documentRegistry = ts.createDocumentRegistry() + + // Create catch-all fallback configs in case there are no tsconfig.json files + // They are removed once at least one tsconfig.json is found + const trimmedRootPath = this.rootPath.replace(/\/+$/, '') + const fallbackConfigs: {js?: ProjectConfiguration, ts?: ProjectConfiguration} = {} + for (const configType of ['js', 'ts'] as ConfigType[]) { + const configs = this.configs[configType] + const tsConfig: any = { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + allowNonTsExtensions: false, + allowJs: configType === 'js' + }, + include: { js: ['**/*.js', '**/*.jsx'], ts: ['**/*.ts', '**/*.tsx'] }[configType] + } + const config = new ProjectConfiguration( + this.inMemoryFs, + documentRegistry, + trimmedRootPath, + this.versions, + '', + tsConfig, + this.traceModuleResolution, + this.pluginSettings, + this.logger + ) + configs.set(trimmedRootPath, config) + fallbackConfigs[configType] = config + } + + // Whenever a file with content is added to the InMemoryFileSystem, check if it's a tsconfig.json and add a new ProjectConfiguration + this.subscriptions.add( + Observable.fromEvent(inMemoryFileSystem, 'add', Array.of as SelectorMethodSignature<[string, string]>) + .filter(([uri, content]) => !!content && /\/[tj]sconfig\.json/.test(uri) && !uri.includes('/node_modules/')) + .subscribe(([uri, content]) => { + const filePath = uri2path(uri) + let dir = toUnixPath(filePath) + const pos = dir.lastIndexOf('/') + if (pos <= 0) { + dir = '' + } else { + dir = dir.substring(0, pos) + } + const configType = this.getConfigurationType(filePath) + const configs = this.configs[configType] + configs.set(dir, new ProjectConfiguration( + this.inMemoryFs, + documentRegistry, + dir, + this.versions, + filePath, + undefined, + this.traceModuleResolution, + this.pluginSettings, + this.logger + )) + // Remove catch-all config (if exists) + if (configs.get(trimmedRootPath) === fallbackConfigs[configType]) { + configs.delete(trimmedRootPath) + } + }) + ) + } + + /** + * Disposes the object (removes all registered listeners) + */ + public dispose(): void { + this.subscriptions.unsubscribe() + } + + /** + * @return root path (as passed to `initialize`) + */ + public getRemoteRoot(): string { + return this.rootPath + } + + /** + * @return local side of file content provider which keeps cached copies of fethed files + */ + public getFs(): InMemoryFileSystem { + return this.inMemoryFs + } + + /** + * @param filePath file path (both absolute or relative file paths are accepted) + * @return true if there is a fetched file with a given path + */ + public hasFile(filePath: string): boolean { + return this.inMemoryFs.fileExists(filePath) + } + + /** + * @return all sub-projects we have identified for a given workspace. + * Sub-project is mainly a folder which contains tsconfig.json, jsconfig.json, package.json, + * or a root folder which serves as a fallback + */ + public configurations(): IterableIterator { + return iterate(this.configs.js.values()).concat(this.configs.ts.values()) + } + + /** + * Ensures that the module structure of the project exists in memory. + * TypeScript/JavaScript module structure is determined by [jt]sconfig.json, + * filesystem layout, global*.d.ts and package.json files. + * Then creates new ProjectConfigurations, resets existing and invalidates file references. + */ + public ensureModuleStructure(childOf = new Span()): Observable { + return traceObservable('Ensure module structure', childOf, span => { + if (!this.ensuredModuleStructure) { + this.ensuredModuleStructure = this.updater.ensureStructure() + // Ensure content of all all global .d.ts, [tj]sconfig.json, package.json files + .concat(Observable.defer(() => observableFromIterable(this.inMemoryFs.uris()))) + .filter(uri => isGlobalTSFile(uri) || isConfigFile(uri) || isPackageJsonFile(uri)) + .mergeMap(uri => this.updater.ensure(uri)) + .do(noop, err => { + this.ensuredModuleStructure = undefined + }, () => { + // Reset all compilation state + // TODO ze incremental compilation instead + for (const config of this.configurations()) { + config.reset() + } + // Require re-processing of file references + this.invalidateReferencedFiles() + }) + .publishReplay() + .refCount() as Observable + } + return this.ensuredModuleStructure + }) + } + + /** + * Invalidates caches for `ensureModuleStructure`, `ensureAllFiles` and `insureOwnFiles` + */ + public invalidateModuleStructure(): void { + this.ensuredModuleStructure = undefined + this.ensuredConfigDependencies = undefined + this.ensuredAllFiles = undefined + this.ensuredOwnFiles = undefined + } + + /** + * Ensures all files not in node_modules were fetched. + * This includes all js/ts files, tsconfig files and package.json files. + * Invalidates project configurations after execution + */ + public ensureOwnFiles(childOf = new Span()): Observable { + return traceObservable('Ensure own files', childOf, span => { + if (!this.ensuredOwnFiles) { + this.ensuredOwnFiles = this.updater.ensureStructure(span) + .concat(Observable.defer(() => observableFromIterable(this.inMemoryFs.uris()))) + .filter(uri => !uri.includes('/node_modules/') && isJSTSFile(uri) || isConfigFile(uri) || isPackageJsonFile(uri)) + .mergeMap(uri => this.updater.ensure(uri)) + .do(noop, err => { + this.ensuredOwnFiles = undefined + }) + .publishReplay() + .refCount() as Observable + } + return this.ensuredOwnFiles + }) + } + + /** + * Ensures all files were fetched from the remote file system. + * Invalidates project configurations after execution + */ + public ensureAllFiles(childOf = new Span()): Observable { + return traceObservable('Ensure all files', childOf, span => { + if (!this.ensuredAllFiles) { + this.ensuredAllFiles = this.updater.ensureStructure(span) + .concat(Observable.defer(() => observableFromIterable(this.inMemoryFs.uris()))) + .filter(uri => isJSTSFile(uri) || isConfigFile(uri) || isPackageJsonFile(uri)) + .mergeMap(uri => this.updater.ensure(uri)) + .do(noop, err => { + this.ensuredAllFiles = undefined + }) + .publishReplay() + .refCount() as Observable + } + return this.ensuredAllFiles + }) + } + + /** + * Recursively collects file(s) dependencies up to given level. + * Dependencies are extracted by TS compiler from import and reference statements + * + * Dependencies include: + * - all the configuration files + * - files referenced by the given file + * - files included by the given file + * + * The return values of this method are not cached, but those of the file fetching and file processing are. + * + * @param uri File to process + * @param maxDepth Stop collecting when reached given recursion level + * @param ignore Tracks visited files to prevent cycles + * @param childOf OpenTracing parent span for tracing + * @return Observable of file URIs ensured + */ + public ensureReferencedFiles(uri: string, maxDepth = 30, ignore = new Set(), childOf = new Span()): Observable { + return traceObservable('Ensure referenced files', childOf, span => { + span.addTags({ uri, maxDepth }) + ignore.add(uri) + return this.ensureModuleStructure(span) + .concat(Observable.defer(() => this.ensureConfigDependencies())) + // If max depth was reached, don't go any further + .concat(Observable.defer(() => maxDepth === 0 ? Observable.empty() : this.resolveReferencedFiles(uri))) + // Prevent cycles + .filter(referencedUri => !ignore.has(referencedUri)) + // Call method recursively with one less dep level + .mergeMap(referencedUri => + this.ensureReferencedFiles(referencedUri, maxDepth - 1, ignore) + // Continue even if an import wasn't found + .catch(err => { + this.logger.error(`Error resolving file references for ${uri}:`, err) + return [] + }) + ) + }) + } + + /** + * Determines if a tsconfig/jsconfig needs additional declaration files loaded. + * @param filePath + */ + public isConfigDependency(filePath: string): boolean { + for (const config of this.configurations()) { + config.ensureConfigFile() + if (config.isExpectedDeclarationFile(filePath)) { + return true + } + } + return false + } + + /** + * Loads files determined by tsconfig to be needed into the file system + */ + public ensureConfigDependencies(childOf = new Span()): Observable { + return traceObservable('Ensure config dependencies', childOf, span => { + if (!this.ensuredConfigDependencies) { + this.ensuredConfigDependencies = observableFromIterable(this.inMemoryFs.uris()) + .filter(uri => this.isConfigDependency(uri2path(uri))) + .mergeMap(uri => this.updater.ensure(uri)) + .do(noop, err => { + this.ensuredConfigDependencies = undefined + }) + .publishReplay() + .refCount() as Observable + } + return this.ensuredConfigDependencies + }) + } + + /** + * Invalidates a cache entry for `resolveReferencedFiles` (e.g. because the file changed) + * + * @param uri The URI that referenced files should be invalidated for. If not given, all entries are invalidated + */ + public invalidateReferencedFiles(uri?: string): void { + if (uri) { + this.referencedFiles.delete(uri) + } else { + this.referencedFiles.clear() + } + } + + /** + * Returns the files that are referenced from a given file. + * If the file has already been processed, returns a cached value. + * + * @param uri URI of the file to process + * @return URIs of files referenced by the file + */ + private resolveReferencedFiles(uri: string, span = new Span()): Observable { + let observable = this.referencedFiles.get(uri) + if (observable) { + return observable + } + observable = this.updater.ensure(uri) + .concat(Observable.defer(() => { + const referencingFilePath = uri2path(uri) + const config = this.getConfiguration(referencingFilePath) + config.ensureBasicFiles(span) + const contents = this.inMemoryFs.getContent(uri) + const info = ts.preProcessFile(contents, true, true) + const compilerOpt = config.getHost().getCompilationSettings() + const pathResolver = referencingFilePath.includes('\\') ? path.win32 : path.posix + // Iterate imported files + return Observable.merge( + // References with `import` + Observable.from(info.importedFiles) + .map(importedFile => ts.resolveModuleName(importedFile.fileName, toUnixPath(referencingFilePath), compilerOpt, this.inMemoryFs)) + // false means we didn't find a file defining the module. It could still + // exist as an ambient module, which is why we fetch global*.d.ts files. + .filter(resolved => !!(resolved && resolved.resolvedModule)) + .map(resolved => resolved.resolvedModule!.resolvedFileName), + // References with `` + Observable.from(info.referencedFiles) + // Resolve triple slash references relative to current file instead of using + // module resolution host because it behaves differently in "nodejs" mode + .map(referencedFile => pathResolver.resolve(this.rootPath, pathResolver.dirname(referencingFilePath), toUnixPath(referencedFile.fileName))), + // References with `` + Observable.from(info.typeReferenceDirectives) + .map(typeReferenceDirective => ts.resolveTypeReferenceDirective(typeReferenceDirective.fileName, referencingFilePath, compilerOpt, this.inMemoryFs)) + .filter(resolved => !!(resolved && resolved.resolvedTypeReferenceDirective && resolved.resolvedTypeReferenceDirective.resolvedFileName)) + .map(resolved => resolved.resolvedTypeReferenceDirective!.resolvedFileName!) + ) + })) + // Use same scheme, slashes, host for referenced URI as input file + .map(path2uri) + // Don't cache errors + .do(noop, err => { + this.referencedFiles.delete(uri) + }) + // Make sure all subscribers get the same values + .publishReplay() + .refCount() + this.referencedFiles.set(uri, observable) + return observable + } + + /** + * @param filePath source file path, absolute + * @return project configuration for a given source file. Climbs directory tree up to workspace root if needed + */ + public getConfiguration(filePath: string, configType: ConfigType = this.getConfigurationType(filePath)): ProjectConfiguration { + const config = this.getConfigurationIfExists(filePath, configType) + if (!config) { + throw new Error(`TypeScript config file for ${filePath} not found`) + } + return config + } + + /** + * @param filePath source file path, absolute + * @return closest configuration for a given file path or undefined if there is no such configuration + */ + public getConfigurationIfExists(filePath: string, configType = this.getConfigurationType(filePath)): ProjectConfiguration | undefined { + let dir = toUnixPath(filePath) + let config: ProjectConfiguration | undefined + const configs = this.configs[configType] + if (!configs) { + return undefined + } + const rootPath = this.rootPath.replace(/\/+$/, '') + while (dir && dir !== rootPath) { + config = configs.get(dir) + if (config) { + return config + } + const pos = dir.lastIndexOf('/') + if (pos <= 0) { + dir = '' + } else { + dir = dir.substring(0, pos) + } + } + return configs.get(rootPath) + } + + /** + * Returns the ProjectConfiguration a file belongs to + */ + public getParentConfiguration(uri: string, configType?: ConfigType): ProjectConfiguration | undefined { + return this.getConfigurationIfExists(uri2path(uri), configType) + } + + /** + * Returns all ProjectConfigurations contained in the given directory or one of its childrens + * + * @param uri URI of a directory + */ + public getChildConfigurations(uri: string): IterableIterator { + const pathPrefix = uri2path(uri) + return iterate(this.configs.ts).concat(this.configs.js) + .filter(([folderPath, config]) => folderPath.startsWith(pathPrefix)) + .map(([folderPath, config]) => config) + } + + /** + * Called when file was opened by client. Current implementation + * does not differenciates open and change events + * @param uri file's URI + * @param text file's content + */ + public didOpen(uri: string, text: string): void { + this.didChange(uri, text) + } + + /** + * Called when file was closed by client. Current implementation invalidates compiled version + * @param uri file's URI + */ + public didClose(uri: string, span = new Span()): void { + const filePath = uri2path(uri) + this.inMemoryFs.didClose(uri) + let version = this.versions.get(uri) || 0 + this.versions.set(uri, ++version) + const config = this.getConfigurationIfExists(filePath) + if (!config) { + return + } + config.ensureConfigFile(span) + config.getHost().incProjectVersion() + } + + /** + * Called when file was changed by client. Current implementation invalidates compiled version + * @param uri file's URI + * @param text file's content + */ + public didChange(uri: string, text: string, span = new Span()): void { + const filePath = uri2path(uri) + this.inMemoryFs.didChange(uri, text) + let version = this.versions.get(uri) || 0 + this.versions.set(uri, ++version) + const config = this.getConfigurationIfExists(filePath) + if (!config) { + return + } + config.ensureConfigFile(span) + config.ensureSourceFile(filePath) + config.getHost().incProjectVersion() + } + + /** + * Called when file was saved by client + * @param uri file's URI + */ + public didSave(uri: string): void { + this.inMemoryFs.didSave(uri) + } + + /** + * @param filePath path to source (or config) file + * @return configuration type to use for a given file + */ + private getConfigurationType(filePath: string): ConfigType { + const name = path.posix.basename(filePath) + if (name === 'tsconfig.json') { + return 'ts' + } else if (name === 'jsconfig.json') { + return 'js' + } + const extension = path.posix.extname(filePath) + if (extension === '.js' || extension === '.jsx') { + return 'js' + } + return 'ts' + } } diff --git a/src/request-type.ts b/src/request-type.ts index e2f8dd435..541cbdc3a 100644 --- a/src/request-type.ts +++ b/src/request-type.ts @@ -1,74 +1,74 @@ -import { Operation } from 'fast-json-patch'; -import * as vscode from 'vscode-languageserver'; +import { Operation } from 'fast-json-patch' +import * as vscode from 'vscode-languageserver' export interface InitializeParams extends vscode.InitializeParams { - capabilities: ClientCapabilities; + capabilities: ClientCapabilities } /** * Settings to enable plugin loading */ export interface PluginSettings { - allowLocalPluginLoads: boolean; - globalPlugins: string[]; - pluginProbeLocations: string[]; + allowLocalPluginLoads: boolean + globalPlugins: string[] + pluginProbeLocations: string[] } export interface ClientCapabilities extends vscode.ClientCapabilities { - /** - * The client provides support for workspace/xfiles. - */ - xfilesProvider?: boolean; - - /** - * The client provides support for textDocument/xcontent. - */ - xcontentProvider?: boolean; - - /** - * The client provides support for cache/get and cache/set methods - */ - xcacheProvider?: boolean; - - /** - * The client supports receiving the result solely through $/partialResult notifications for requests from the client to the server. - */ - streaming?: boolean; + /** + * The client provides support for workspace/xfiles. + */ + xfilesProvider?: boolean + + /** + * The client provides support for textDocument/xcontent. + */ + xcontentProvider?: boolean + + /** + * The client provides support for cache/get and cache/set methods + */ + xcacheProvider?: boolean + + /** + * The client supports receiving the result solely through $/partialResult notifications for requests from the client to the server. + */ + streaming?: boolean } export interface ServerCapabilities extends vscode.ServerCapabilities { - xworkspaceReferencesProvider?: boolean; - xdefinitionProvider?: boolean; - xdependenciesProvider?: boolean; - xpackagesProvider?: boolean; - - /** - * The server supports receiving results solely through $/partialResult notifications for requests from the server to the client. - */ - streaming?: boolean; + xworkspaceReferencesProvider?: boolean + xdefinitionProvider?: boolean + xdependenciesProvider?: boolean + xpackagesProvider?: boolean + + /** + * The server supports receiving results solely through $/partialResult notifications for requests from the server to the client. + */ + streaming?: boolean } export interface InitializeResult extends vscode.InitializeResult { - capabilities: ServerCapabilities; + capabilities: ServerCapabilities } export interface TextDocumentContentParams { - /** - * The text document to receive the content for. - */ - textDocument: vscode.TextDocumentIdentifier; + /** + * The text document to receive the content for. + */ + textDocument: vscode.TextDocumentIdentifier } export interface WorkspaceFilesParams { - /** - * The URI of a directory to search. - * Can be relative to the rootPath. - * If not given, defaults to rootPath. - */ - base?: string; + /** + * The URI of a directory to search. + * Can be relative to the rootPath. + * If not given, defaults to rootPath. + */ + base?: string } /** @@ -81,38 +81,38 @@ export interface WorkspaceFilesParams { */ export interface SymbolDescriptor { - /** - * The kind of the symbol as a ts.ScriptElementKind - */ - kind: string; - - /** - * The name of the symbol as returned from TS - */ - name: string; - - /** - * The kind of the symbol the symbol is contained in, as a ts.ScriptElementKind. - * Is an empty string if the symbol has no container. - */ - containerKind: string; - - /** - * The name of the symbol the symbol is contained in, as returned from TS. - * Is an empty string if the symbol has no container. - */ - containerName: string; - - /** - * The file path of the file where the symbol is defined in, relative to the workspace rootPath. - */ - filePath: string; - - /** - * A PackageDescriptor describing the package this symbol belongs to. - * Is `undefined` if the symbol does not belong to a package. - */ - package?: PackageDescriptor; + /** + * The kind of the symbol as a ts.ScriptElementKind + */ + kind: string + + /** + * The name of the symbol as returned from TS + */ + name: string + + /** + * The kind of the symbol the symbol is contained in, as a ts.ScriptElementKind. + * Is an empty string if the symbol has no container. + */ + containerKind: string + + /** + * The name of the symbol the symbol is contained in, as returned from TS. + * Is an empty string if the symbol has no container. + */ + containerName: string + + /** + * The file path of the file where the symbol is defined in, relative to the workspace rootPath. + */ + filePath: string + + /** + * A PackageDescriptor describing the package this symbol belongs to. + * Is `undefined` if the symbol does not belong to a package. + */ + package?: PackageDescriptor } /* @@ -121,15 +121,15 @@ export interface SymbolDescriptor { * If both properties are set, the requirements are AND'd. */ export interface WorkspaceSymbolParams { - /** - * A non-empty query string. - */ - query?: string; - - /** - * A set of properties that describe the symbol to look up. - */ - symbol?: Partial; + /** + * A non-empty query string. + */ + query?: string + + /** + * A set of properties that describe the symbol to look up. + */ + symbol?: Partial } /* @@ -139,30 +139,30 @@ export interface WorkspaceSymbolParams { */ export interface WorkspaceReferenceParams { - /** - * Metadata about the symbol that is being searched for. - */ - query: Partial; - - /** - * Hints provides optional hints about where the language server should look in order to find - * the symbol (this is an optimization). It is up to the language server to define the schema of - * this object. - */ - hints?: DependencyHints; + /** + * Metadata about the symbol that is being searched for. + */ + query: Partial + + /** + * Hints provides optional hints about where the language server should look in order to find + * the symbol (this is an optimization). It is up to the language server to define the schema of + * this object. + */ + hints?: DependencyHints } export interface SymbolLocationInformation { - /** - * The location where the symbol is defined, if any - */ - location?: vscode.Location; + /** + * The location where the symbol is defined, if any + */ + location?: vscode.Location - /** - * Metadata about the symbol that can be used to identify or locate its definition. - */ - symbol: SymbolDescriptor; + /** + * Metadata about the symbol that can be used to identify or locate its definition. + */ + symbol: SymbolDescriptor } /** @@ -170,35 +170,35 @@ export interface SymbolLocationInformation { * interfaces, etc. */ export interface ReferenceInformation { - /** - * The location in the workspace where the `symbol` is referenced. - */ - reference: vscode.Location; - - /** - * Metadata about the symbol that can be used to identify or locate its definition. - */ - symbol: SymbolDescriptor; + /** + * The location in the workspace where the `symbol` is referenced. + */ + reference: vscode.Location + + /** + * Metadata about the symbol that can be used to identify or locate its definition. + */ + symbol: SymbolDescriptor } export interface PackageInformation { - package: PackageDescriptor; - dependencies: DependencyReference[]; + package: PackageDescriptor + dependencies: DependencyReference[] } export interface PackageDescriptor { - name: string; - version?: string; - repoURL?: string; + name: string + version?: string + repoURL?: string } export interface DependencyHints { - dependeePackageName?: string; + dependeePackageName?: string } export interface DependencyReference { - attributes: PackageDescriptor; - hints: DependencyHints; + attributes: PackageDescriptor + hints: DependencyHints } /** @@ -207,10 +207,10 @@ export interface DependencyReference { */ export interface CacheGetParams { - /** - * The key that identifies the cache item - */ - key: string; + /** + * The key that identifies the cache item + */ + key: string } /** @@ -220,53 +220,53 @@ export interface CacheGetParams { */ export interface CacheSetParams { - /** - * The key that identifies the cache item - */ - key: string; + /** + * The key that identifies the cache item + */ + key: string - /** - * The value that should be saved - */ - value: any; + /** + * The value that should be saved + */ + value: any } export interface PartialResultParams { - /** - * The request id to provide parts of the result for - */ - id: number | string; - - /** - * A JSON Patch that represents updates to the partial result as specified in RFC6902 - * https://tools.ietf.org/html/rfc6902 - */ - patch: Operation[]; + /** + * The request id to provide parts of the result for + */ + id: number | string + + /** + * A JSON Patch that represents updates to the partial result as specified in RFC6902 + * https://tools.ietf.org/html/rfc6902 + */ + patch: Operation[] } /** * Restriction on vscode's CompletionItem interface */ export interface CompletionItem extends vscode.CompletionItem { - data?: CompletionItemData; + data?: CompletionItemData } /** * The necessary fields for a completion item details to be resolved by typescript */ export interface CompletionItemData { - /** - * The document from which the completion was requested - */ - uri: string; - - /** - * The offset into the document at which the completion was requested - */ - offset: number; - - /** - * The name field from typescript's returned completion entry - */ - entryName: string; + /** + * The document from which the completion was requested + */ + uri: string + + /** + * The offset into the document at which the completion was requested + */ + offset: number + + /** + * The name field from typescript's returned completion entry + */ + entryName: string } diff --git a/src/server.ts b/src/server.ts index ef53f41d3..535c8287a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,23 +1,23 @@ -import * as cluster from 'cluster'; -import * as net from 'net'; -import { Tracer } from 'opentracing'; -import { isNotificationMessage } from 'vscode-jsonrpc/lib/messages'; -import { MessageEmitter, MessageLogOptions, MessageWriter, registerLanguageHandler } from './connection'; -import { RemoteLanguageClient } from './lang-handler'; -import { Logger, PrefixedLogger, StdioLogger } from './logging'; -import { TypeScriptService } from './typescript-service'; +import * as cluster from 'cluster' +import * as net from 'net' +import { Tracer } from 'opentracing' +import { isNotificationMessage } from 'vscode-jsonrpc/lib/messages' +import { MessageEmitter, MessageLogOptions, MessageWriter, registerLanguageHandler } from './connection' +import { RemoteLanguageClient } from './lang-handler' +import { Logger, PrefixedLogger, StdioLogger } from './logging' +import { TypeScriptService } from './typescript-service' /** Options to `serve()` */ export interface ServeOptions extends MessageLogOptions { - /** Amount of workers to spawn */ - clusterSize: number; + /** Amount of workers to spawn */ + clusterSize: number - /** Port to listen on for TCP LSP connections */ - lspPort: number; + /** Port to listen on for TCP LSP connections */ + lspPort: number - /** An OpenTracing-compatible Tracer */ - tracer?: Tracer; + /** An OpenTracing-compatible Tracer */ + tracer?: Tracer } /** @@ -26,7 +26,7 @@ export interface ServeOptions extends MessageLogOptions { * @param logger An optional logger to wrap, e.g. to write to a logfile. Defaults to STDIO */ export function createClusterLogger(logger = new StdioLogger()): Logger { - return new PrefixedLogger(logger, cluster.isMaster ? 'master' : `wrkr ${cluster.worker.id}`); + return new PrefixedLogger(logger, cluster.isMaster ? 'master' : `wrkr ${cluster.worker.id}`) } /** @@ -37,43 +37,43 @@ export function createClusterLogger(logger = new StdioLogger()): Logger { * @param createLangHandler Factory function that is called for each new connection */ export function serve(options: ServeOptions, createLangHandler = (remoteClient: RemoteLanguageClient) => new TypeScriptService(remoteClient)): void { - const logger = options.logger || createClusterLogger(); - if (options.clusterSize > 1 && cluster.isMaster) { - logger.log(`Spawning ${options.clusterSize} workers`); - cluster.on('online', worker => { - logger.log(`Worker ${worker.id} (PID ${worker.process.pid}) online`); - }); - cluster.on('exit', (worker, code, signal) => { - logger.error(`Worker ${worker.id} (PID ${worker.process.pid}) exited from signal ${signal} with code ${code}, restarting`); - cluster.fork(); - }); - for (let i = 0; i < options.clusterSize; ++i) { - cluster.fork(); - } - } else { - let counter = 1; - const server = net.createServer(socket => { - const id = counter++; - logger.log(`Connection ${id} accepted`); + const logger = options.logger || createClusterLogger() + if (options.clusterSize > 1 && cluster.isMaster) { + logger.log(`Spawning ${options.clusterSize} workers`) + cluster.on('online', worker => { + logger.log(`Worker ${worker.id} (PID ${worker.process.pid}) online`) + }) + cluster.on('exit', (worker, code, signal) => { + logger.error(`Worker ${worker.id} (PID ${worker.process.pid}) exited from signal ${signal} with code ${code}, restarting`) + cluster.fork() + }) + for (let i = 0; i < options.clusterSize; ++i) { + cluster.fork() + } + } else { + let counter = 1 + const server = net.createServer(socket => { + const id = counter++ + logger.log(`Connection ${id} accepted`) - const messageEmitter = new MessageEmitter(socket as NodeJS.ReadableStream, options); - const messageWriter = new MessageWriter(socket, options); - const remoteClient = new RemoteLanguageClient(messageEmitter, messageWriter); + const messageEmitter = new MessageEmitter(socket as NodeJS.ReadableStream, options) + const messageWriter = new MessageWriter(socket, options) + const remoteClient = new RemoteLanguageClient(messageEmitter, messageWriter) - // Add exit notification handler to close the socket on exit - messageEmitter.on('message', message => { - if (isNotificationMessage(message) && message.method === 'exit') { - socket.end(); - socket.destroy(); - logger.log(`Connection ${id} closed (exit notification)`); - } - }); + // Add exit notification handler to close the socket on exit + messageEmitter.on('message', message => { + if (isNotificationMessage(message) && message.method === 'exit') { + socket.end() + socket.destroy() + logger.log(`Connection ${id} closed (exit notification)`) + } + }) - registerLanguageHandler(messageEmitter, messageWriter, createLangHandler(remoteClient), options); - }); + registerLanguageHandler(messageEmitter, messageWriter, createLangHandler(remoteClient), options) + }) - server.listen(options.lspPort, () => { - logger.info(`Listening for incoming LSP connections on ${options.lspPort}`); - }); - } + server.listen(options.lspPort, () => { + logger.info(`Listening for incoming LSP connections on ${options.lspPort}`) + }) + } } diff --git a/src/symbols.ts b/src/symbols.ts index 9da3c103e..31bb70fbb 100644 --- a/src/symbols.ts +++ b/src/symbols.ts @@ -1,34 +1,34 @@ -import * as ts from 'typescript'; -import { SymbolInformation, SymbolKind } from 'vscode-languageserver-types'; -import { isTypeScriptLibrary } from './memfs'; -import { SymbolDescriptor } from './request-type'; -import { path2uri, toUnixPath } from './util'; +import * as ts from 'typescript' +import { SymbolInformation, SymbolKind } from 'vscode-languageserver-types' +import { isTypeScriptLibrary } from './memfs' +import { SymbolDescriptor } from './request-type' +import { path2uri, toUnixPath } from './util' /** * Returns a SymbolDescriptor for a ts.DefinitionInfo */ export function definitionInfoToSymbolDescriptor(info: ts.DefinitionInfo, rootPath: string): SymbolDescriptor { - const rootUnixPath = toUnixPath(rootPath); - const symbolDescriptor: SymbolDescriptor = { - kind: info.kind || '', - name: info.name || '', - containerKind: info.containerKind || '', - containerName: info.containerName || '', - filePath: info.fileName - }; - // If the symbol is an external module representing a file, set name to the file path - if (info.kind === ts.ScriptElementKind.moduleElement && info.name && /[\\\/]/.test(info.name)) { - symbolDescriptor.name = '"' + info.fileName.replace(/(?:\.d)?\.tsx?$/, '') + '"'; - } - // If the symbol itself is not a module and there is no containerKind - // then the container is an external module named by the file name (without file extension) - if (info.kind !== ts.ScriptElementKind.moduleElement && !info.containerKind && !info.containerName) { - symbolDescriptor.containerName = '"' + info.fileName.replace(/(?:\.d)?\.tsx?$/, '') + '"'; - symbolDescriptor.containerKind = ts.ScriptElementKind.moduleElement; - } - normalizeSymbolDescriptorPaths(symbolDescriptor, rootUnixPath); - return symbolDescriptor; + const rootUnixPath = toUnixPath(rootPath) + const symbolDescriptor: SymbolDescriptor = { + kind: info.kind || '', + name: info.name || '', + containerKind: info.containerKind || '', + containerName: info.containerName || '', + filePath: info.fileName + } + // If the symbol is an external module representing a file, set name to the file path + if (info.kind === ts.ScriptElementKind.moduleElement && info.name && /[\\\/]/.test(info.name)) { + symbolDescriptor.name = '"' + info.fileName.replace(/(?:\.d)?\.tsx?$/, '') + '"' + } + // If the symbol itself is not a module and there is no containerKind + // then the container is an external module named by the file name (without file extension) + if (info.kind !== ts.ScriptElementKind.moduleElement && !info.containerKind && !info.containerName) { + symbolDescriptor.containerName = '"' + info.fileName.replace(/(?:\.d)?\.tsx?$/, '') + '"' + symbolDescriptor.containerKind = ts.ScriptElementKind.moduleElement + } + normalizeSymbolDescriptorPaths(symbolDescriptor, rootUnixPath) + return symbolDescriptor } /** @@ -36,10 +36,10 @@ export function definitionInfoToSymbolDescriptor(info: ts.DefinitionInfo, rootPa * returns git://github.com/Microsoft/TypeScript URL, otherwise returns file:// one */ export function locationUri(filePath: string): string { - if (isTypeScriptLibrary(filePath)) { - return 'git://github.com/Microsoft/TypeScript?v' + ts.version + '#lib/' + filePath.split(/[\/\\]/g).pop(); - } - return path2uri(filePath); + if (isTypeScriptLibrary(filePath)) { + return 'git://github.com/Microsoft/TypeScript?v' + ts.version + '#lib/' + filePath.split(/[\/\\]/g).pop() + } + return path2uri(filePath) } /** @@ -48,160 +48,160 @@ export function locationUri(filePath: string): string { * @param rootPath The workspace rootPath to remove from symbol names and containerNames */ export function navigateToItemToSymbolInformation(item: ts.NavigateToItem, program: ts.Program, rootPath: string): SymbolInformation { - const sourceFile = program.getSourceFile(item.fileName); - if (!sourceFile) { - throw new Error(`Source file ${item.fileName} does not exist`); - } - const symbolInformation: SymbolInformation = { - name: item.name ? item.name.replace(rootPath, '') : '', - kind: stringtoSymbolKind(item.kind), - location: { - uri: locationUri(sourceFile.fileName), - range: { - start: ts.getLineAndCharacterOfPosition(sourceFile, item.textSpan.start), - end: ts.getLineAndCharacterOfPosition(sourceFile, item.textSpan.start + item.textSpan.length) - } - } - }; - if (item.containerName) { - symbolInformation.containerName = item.containerName.replace(rootPath, ''); - } - return symbolInformation; + const sourceFile = program.getSourceFile(item.fileName) + if (!sourceFile) { + throw new Error(`Source file ${item.fileName} does not exist`) + } + const symbolInformation: SymbolInformation = { + name: item.name ? item.name.replace(rootPath, '') : '', + kind: stringtoSymbolKind(item.kind), + location: { + uri: locationUri(sourceFile.fileName), + range: { + start: ts.getLineAndCharacterOfPosition(sourceFile, item.textSpan.start), + end: ts.getLineAndCharacterOfPosition(sourceFile, item.textSpan.start + item.textSpan.length) + } + } + } + if (item.containerName) { + symbolInformation.containerName = item.containerName.replace(rootPath, '') + } + return symbolInformation } /** * Returns an LSP SymbolKind for a TypeScript ScriptElementKind */ export function stringtoSymbolKind(kind: string): SymbolKind { - switch (kind) { - case 'module': return SymbolKind.Module; - case 'class': return SymbolKind.Class; - case 'local class': return SymbolKind.Class; - case 'interface': return SymbolKind.Interface; - case 'enum': return SymbolKind.Enum; - case 'enum member': return SymbolKind.Constant; - case 'var': return SymbolKind.Variable; - case 'local var': return SymbolKind.Variable; - case 'function': return SymbolKind.Function; - case 'local function': return SymbolKind.Function; - case 'method': return SymbolKind.Method; - case 'getter': return SymbolKind.Method; - case 'setter': return SymbolKind.Method; - case 'property': return SymbolKind.Property; - case 'constructor': return SymbolKind.Constructor; - case 'parameter': return SymbolKind.Variable; - case 'type parameter': return SymbolKind.Variable; - case 'alias': return SymbolKind.Variable; - case 'let': return SymbolKind.Variable; - case 'const': return SymbolKind.Constant; - case 'JSX attribute': return SymbolKind.Property; - // case 'script' - // case 'keyword' - // case 'type' - // case 'call' - // case 'index' - // case 'construct' - // case 'primitive type' - // case 'label' - // case 'directory' - // case 'external module name' - // case 'external module name' - default: return SymbolKind.Variable; - } + switch (kind) { + case 'module': return SymbolKind.Module + case 'class': return SymbolKind.Class + case 'local class': return SymbolKind.Class + case 'interface': return SymbolKind.Interface + case 'enum': return SymbolKind.Enum + case 'enum member': return SymbolKind.Constant + case 'var': return SymbolKind.Variable + case 'local var': return SymbolKind.Variable + case 'function': return SymbolKind.Function + case 'local function': return SymbolKind.Function + case 'method': return SymbolKind.Method + case 'getter': return SymbolKind.Method + case 'setter': return SymbolKind.Method + case 'property': return SymbolKind.Property + case 'constructor': return SymbolKind.Constructor + case 'parameter': return SymbolKind.Variable + case 'type parameter': return SymbolKind.Variable + case 'alias': return SymbolKind.Variable + case 'let': return SymbolKind.Variable + case 'const': return SymbolKind.Constant + case 'JSX attribute': return SymbolKind.Property + // case 'script' + // case 'keyword' + // case 'type' + // case 'call' + // case 'index' + // case 'construct' + // case 'primitive type' + // case 'label' + // case 'directory' + // case 'external module name' + // case 'external module name' + default: return SymbolKind.Variable + } } /** * Returns an LSP SymbolInformation for a TypeScript NavigationTree node */ export function navigationTreeToSymbolInformation(tree: ts.NavigationTree, parent: ts.NavigationTree | undefined, sourceFile: ts.SourceFile, rootPath: string): SymbolInformation { - const span = tree.spans[0]; - if (!span) { - throw new Error('NavigationTree has no TextSpan'); - } - const symbolInformation: SymbolInformation = { - name: tree.text ? tree.text.replace(rootPath, '') : '', - kind: stringtoSymbolKind(tree.kind), - location: { - uri: locationUri(sourceFile.fileName), - range: { - start: ts.getLineAndCharacterOfPosition(sourceFile, span.start), - end: ts.getLineAndCharacterOfPosition(sourceFile, span.start + span.length) - } - } - }; - if (parent && navigationTreeIsSymbol(parent) && parent.text) { - symbolInformation.containerName = parent.text.replace(rootPath, ''); - } - return symbolInformation; + const span = tree.spans[0] + if (!span) { + throw new Error('NavigationTree has no TextSpan') + } + const symbolInformation: SymbolInformation = { + name: tree.text ? tree.text.replace(rootPath, '') : '', + kind: stringtoSymbolKind(tree.kind), + location: { + uri: locationUri(sourceFile.fileName), + range: { + start: ts.getLineAndCharacterOfPosition(sourceFile, span.start), + end: ts.getLineAndCharacterOfPosition(sourceFile, span.start + span.length) + } + } + } + if (parent && navigationTreeIsSymbol(parent) && parent.text) { + symbolInformation.containerName = parent.text.replace(rootPath, '') + } + return symbolInformation } /** * Returns a SymbolDescriptor for a TypeScript NavigationTree node */ export function navigationTreeToSymbolDescriptor(tree: ts.NavigationTree, parent: ts.NavigationTree | undefined, filePath: string, rootPath: string): SymbolDescriptor { - const symbolDescriptor: SymbolDescriptor = { - kind: tree.kind, - name: tree.text ? tree.text.replace(rootPath, '') : '', - containerKind: '', - containerName: '', - filePath - }; - if (parent && navigationTreeIsSymbol(parent)) { - symbolDescriptor.containerKind = parent.kind; - symbolDescriptor.containerName = parent.text; - } - // If the symbol is an external module representing a file, set name to the file path - if (tree.kind === ts.ScriptElementKind.moduleElement && !tree.text) { - symbolDescriptor.name = '"' + filePath.replace(/(?:\.d)?\.tsx?$/, '') + '"'; - } - // If the symbol itself is not a module and there is no containerKind - // then the container is an external module named by the file name (without file extension) - if (symbolDescriptor.kind !== ts.ScriptElementKind.moduleElement && !symbolDescriptor.containerKind) { - if (!symbolDescriptor.containerName) { - symbolDescriptor.containerName = '"' + filePath.replace(/(?:\.d)?\.tsx?$/, '') + '"'; - } - symbolDescriptor.containerKind = ts.ScriptElementKind.moduleElement; - } - normalizeSymbolDescriptorPaths(symbolDescriptor, rootPath); - return symbolDescriptor; + const symbolDescriptor: SymbolDescriptor = { + kind: tree.kind, + name: tree.text ? tree.text.replace(rootPath, '') : '', + containerKind: '', + containerName: '', + filePath + } + if (parent && navigationTreeIsSymbol(parent)) { + symbolDescriptor.containerKind = parent.kind + symbolDescriptor.containerName = parent.text + } + // If the symbol is an external module representing a file, set name to the file path + if (tree.kind === ts.ScriptElementKind.moduleElement && !tree.text) { + symbolDescriptor.name = '"' + filePath.replace(/(?:\.d)?\.tsx?$/, '') + '"' + } + // If the symbol itself is not a module and there is no containerKind + // then the container is an external module named by the file name (without file extension) + if (symbolDescriptor.kind !== ts.ScriptElementKind.moduleElement && !symbolDescriptor.containerKind) { + if (!symbolDescriptor.containerName) { + symbolDescriptor.containerName = '"' + filePath.replace(/(?:\.d)?\.tsx?$/, '') + '"' + } + symbolDescriptor.containerKind = ts.ScriptElementKind.moduleElement + } + normalizeSymbolDescriptorPaths(symbolDescriptor, rootPath) + return symbolDescriptor } /** * Walks a NaviationTree and emits items with a node and its parent node (if exists) */ export function *walkNavigationTree(tree: ts.NavigationTree, parent?: ts.NavigationTree): IterableIterator<{ tree: ts.NavigationTree, parent?: ts.NavigationTree }> { - yield { tree, parent }; - for (const childItem of tree.childItems || []) { - yield* walkNavigationTree(childItem, tree); - } + yield { tree, parent } + for (const childItem of tree.childItems || []) { + yield* walkNavigationTree(childItem, tree) + } } /** * Returns true if the NavigationTree node describes a proper symbol and not a e.g. a category like `` */ export function navigationTreeIsSymbol(tree: ts.NavigationTree): boolean { - // Categories start with (, [, or < - if (/^[<\(\[]/.test(tree.text)) { - return false; - } - // Magic words - if (['default', 'constructor', 'new()'].indexOf(tree.text) >= 0) { - return false; - } - return true; + // Categories start with (, [, or < + if (/^[<\(\[]/.test(tree.text)) { + return false + } + // Magic words + if (['default', 'constructor', 'new()'].indexOf(tree.text) >= 0) { + return false + } + return true } /** * Makes paths relative to the passed rootPath and strips `node_modules` out of paths */ -function normalizeSymbolDescriptorPaths(symbol: SymbolDescriptor, rootPath: string) { - for (const key of ['name', 'containerName', 'filePath'] as ['name', 'containerName', 'filePath']) { - // Make all paths that may occur in module names relative to the workspace rootPath - symbol[key] = symbol[key].replace(rootPath, ''); - // Remove node_modules part from a module name - // The SymbolDescriptor will be used in the defining repo, where the symbol file path will never contain node_modules - // It may contain the package name though if the repo is a monorepo with multiple packages - const regExp = /[^"]*node_modules\//; - symbol[key] = symbol[key].replace(regExp, ''); - } +function normalizeSymbolDescriptorPaths(symbol: SymbolDescriptor, rootPath: string): void { + for (const key of ['name', 'containerName', 'filePath'] as ['name', 'containerName', 'filePath']) { + // Make all paths that may occur in module names relative to the workspace rootPath + symbol[key] = symbol[key].replace(rootPath, '') + // Remove node_modules part from a module name + // The SymbolDescriptor will be used in the defining repo, where the symbol file path will never contain node_modules + // It may contain the package name though if the repo is a monorepo with multiple packages + const regExp = /[^"]*node_modules\// + symbol[key] = symbol[key].replace(regExp, '') + } } diff --git a/src/test/connection.test.ts b/src/test/connection.test.ts index fc082f8c1..a50f81384 100644 --- a/src/test/connection.test.ts +++ b/src/test/connection.test.ts @@ -1,354 +1,354 @@ -import { Observable, Subject } from '@reactivex/rxjs'; -import * as assert from 'assert'; -import { EventEmitter } from 'events'; -import { Operation } from 'fast-json-patch'; -import { Span } from 'opentracing'; -import * as sinon from 'sinon'; -import { PassThrough } from 'stream'; -import { ErrorCodes } from 'vscode-jsonrpc'; -import { MessageEmitter, MessageWriter, registerLanguageHandler } from '../connection'; -import { NoopLogger } from '../logging'; -import { TypeScriptService } from '../typescript-service'; +import { Observable, Subject } from '@reactivex/rxjs' +import * as assert from 'assert' +import { EventEmitter } from 'events' +import { Operation } from 'fast-json-patch' +import { Span } from 'opentracing' +import * as sinon from 'sinon' +import { PassThrough } from 'stream' +import { ErrorCodes } from 'vscode-jsonrpc' +import { MessageEmitter, MessageWriter, registerLanguageHandler } from '../connection' +import { NoopLogger } from '../logging' +import { TypeScriptService } from '../typescript-service' describe('connection', () => { - describe('registerLanguageHandler()', () => { - it('should return MethodNotFound error when the method does not exist on handler', async () => { - const handler: TypeScriptService = Object.create(TypeScriptService.prototype); - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any as MessageWriter, handler as TypeScriptService); - const params = [1, 1]; - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'whatever', params }); - sinon.assert.calledOnce(writer.write); - sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, error: { code: ErrorCodes.MethodNotFound } })); - }); - it('should return MethodNotFound error when the method is prefixed with an underscore', async () => { - const handler = { _privateMethod: sinon.spy() }; - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any); - const params = [1, 1]; - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }); - sinon.assert.notCalled(handler._privateMethod); - sinon.assert.calledOnce(writer.write); - sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, error: { code: ErrorCodes.MethodNotFound } })); - }); - it('should call a handler on request and send the result of the returned Promise', async () => { - const handler: { [K in keyof TypeScriptService]: TypeScriptService[K] & sinon.SinonStub } = sinon.createStubInstance(TypeScriptService); - handler.initialize.returns(Promise.resolve({ op: 'add', path: '', value: { capabilities: {} }})); - handler.textDocumentHover.returns(Promise.resolve(2)); - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any); - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'initialize', params: { capabilities: {} } }); - await new Promise(resolve => setTimeout(resolve, 0)); - sinon.assert.calledOnce(handler.initialize); - sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, result: { capabilities: {} } })); - }); - it('should ignore exit notifications', async () => { - const handler = { - exit: sinon.spy() - }; - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any); - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'exit' }); - sinon.assert.notCalled(handler.exit); - sinon.assert.notCalled(writer.write); - }); - it('should ignore responses', async () => { - const handler = { - whatever: sinon.spy() - }; - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any); - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'whatever', result: 123 }); - sinon.assert.notCalled(handler.whatever); - }); - it('should log invalid messages', async () => { - const handler = { - whatever: sinon.spy() - }; - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - const logger = new NoopLogger() as NoopLogger & { error: sinon.SinonStub }; - sinon.stub(logger, 'error'); - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any, { logger }); - emitter.emit('message', { jsonrpc: '2.0', id: 1 }); - sinon.assert.calledOnce(logger.error); - }); - it('should call a handler on request and send the result of the returned Observable', async () => { - const handler: TypeScriptService = Object.create(TypeScriptService.prototype); - const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(Observable.of( - { op: 'add', path: '', value: [] }, - { op: 'add', path: '/-', value: 123 } - )); - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService); - const params = [1, 1]; - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }); - sinon.assert.calledOnce(hoverStub); - sinon.assert.calledWithExactly(hoverStub, params, sinon.match.instanceOf(Span)); - sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, result: [123] })); - }); - it('should call a handler on request and send the thrown error of the returned Observable', async () => { - const handler: TypeScriptService = Object.create(TypeScriptService.prototype); - const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(Observable.throw(Object.assign(new Error('Something happened'), { - code: ErrorCodes.serverErrorStart, - whatever: 123 - }))); - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService); - const params = [1, 1]; - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }); - sinon.assert.calledOnce(hoverStub); - sinon.assert.calledWithExactly(hoverStub, params, sinon.match.instanceOf(Span)); - sinon.assert.calledOnce(writer.write); - sinon.assert.calledWithExactly(writer.write, sinon.match({ - jsonrpc: '2.0', - id: 1, - error: { - message: 'Something happened', - code: ErrorCodes.serverErrorStart, - data: { whatever: 123 } - } - })); - }); - it('should call a handler on request and send the returned synchronous value', async () => { - const handler: TypeScriptService = Object.create(TypeScriptService.prototype); - const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(Observable.of({ op: 'add', path: '', value: 2 })); - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService); - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params: [1, 2] }); - sinon.assert.calledOnce(hoverStub); - sinon.assert.calledWithExactly(hoverStub, [1, 2], sinon.match.instanceOf(Span)); - sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, result: 2 })); - }); - it('should call a handler on request and send the result of the returned Observable', async () => { - const handler: TypeScriptService = Object.create(TypeScriptService.prototype); - const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(Observable.of({ op: 'add', path: '', value: 2 })); - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService); - const params = [1, 1]; - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }); - sinon.assert.calledOnce(hoverStub); - sinon.assert.calledWithExactly(hoverStub, params, sinon.match.instanceOf(Span)); - sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, result: 2 })); - }); - it('should unsubscribe from the returned Observable when $/cancelRequest was sent and return a RequestCancelled error', async () => { - const handler: TypeScriptService = Object.create(TypeScriptService.prototype); - const unsubscribeHandler = sinon.spy(); - const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(new Observable(subscriber => unsubscribeHandler)); - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService); - const params = [1, 1]; - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }); - sinon.assert.calledOnce(hoverStub); - sinon.assert.calledWithExactly(hoverStub, params, sinon.match.instanceOf(Span)); - emitter.emit('message', { jsonrpc: '2.0', method: '$/cancelRequest', params: { id: 1 } }); - sinon.assert.calledOnce(unsubscribeHandler); - sinon.assert.calledOnce(writer.write); - sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, error: { code: ErrorCodes.RequestCancelled } })); - }); - it('should unsubscribe from the returned Observable when the connection was closed', async () => { - const handler: TypeScriptService = Object.create(TypeScriptService.prototype); - const unsubscribeHandler = sinon.spy(); - const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(new Observable(subscriber => unsubscribeHandler)); - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService); - const params = [1, 1]; - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }); - sinon.assert.calledOnce(hoverStub); - emitter.emit('close'); - sinon.assert.calledOnce(unsubscribeHandler); - }); - it('should unsubscribe from the returned Observable on exit notification', async () => { - const handler: TypeScriptService = Object.create(TypeScriptService.prototype); - const unsubscribeHandler = sinon.spy(); - const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(new Observable(subscriber => unsubscribeHandler)); - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService); - const params = [1, 1]; - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }); - sinon.assert.calledOnce(hoverStub); - emitter.emit('message', { jsonrpc: '2.0', method: 'exit' }); - sinon.assert.calledOnce(unsubscribeHandler); - }); - for (const event of ['close', 'error']) { - it(`should call shutdown on ${event} if the service was initialized`, async () => { - const handler = { - initialize: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: { capabilities: {} }})), - shutdown: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: null })) - }; - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any); - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'initialize', params: { capabilities: {} } }); + describe('registerLanguageHandler()', () => { + it('should return MethodNotFound error when the method does not exist on handler', async () => { + const handler: TypeScriptService = Object.create(TypeScriptService.prototype) + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any as MessageWriter, handler as TypeScriptService) + const params = [1, 1] + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'whatever', params }) + sinon.assert.calledOnce(writer.write) + sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, error: { code: ErrorCodes.MethodNotFound } })) + }) + it('should return MethodNotFound error when the method is prefixed with an underscore', async () => { + const handler = { _privateMethod: sinon.spy() } + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any) + const params = [1, 1] + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }) + sinon.assert.notCalled(handler._privateMethod) + sinon.assert.calledOnce(writer.write) + sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, error: { code: ErrorCodes.MethodNotFound } })) + }) + it('should call a handler on request and send the result of the returned Promise', async () => { + const handler: { [K in keyof TypeScriptService]: TypeScriptService[K] & sinon.SinonStub } = sinon.createStubInstance(TypeScriptService) + handler.initialize.returns(Promise.resolve({ op: 'add', path: '', value: { capabilities: {} }})) + handler.textDocumentHover.returns(Promise.resolve(2)) + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any) + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'initialize', params: { capabilities: {} } }) + await new Promise(resolve => setTimeout(resolve, 0)) + sinon.assert.calledOnce(handler.initialize) + sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, result: { capabilities: {} } })) + }) + it('should ignore exit notifications', async () => { + const handler = { + exit: sinon.spy() + } + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any) + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'exit' }) + sinon.assert.notCalled(handler.exit) + sinon.assert.notCalled(writer.write) + }) + it('should ignore responses', async () => { + const handler = { + whatever: sinon.spy() + } + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any) + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'whatever', result: 123 }) + sinon.assert.notCalled(handler.whatever) + }) + it('should log invalid messages', async () => { + const handler = { + whatever: sinon.spy() + } + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + const logger = new NoopLogger() as NoopLogger & { error: sinon.SinonStub } + sinon.stub(logger, 'error') + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any, { logger }) + emitter.emit('message', { jsonrpc: '2.0', id: 1 }) + sinon.assert.calledOnce(logger.error) + }) + it('should call a handler on request and send the result of the returned Observable', async () => { + const handler: TypeScriptService = Object.create(TypeScriptService.prototype) + const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(Observable.of( + { op: 'add', path: '', value: [] }, + { op: 'add', path: '/-', value: 123 } + )) + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService) + const params = [1, 1] + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }) + sinon.assert.calledOnce(hoverStub) + sinon.assert.calledWithExactly(hoverStub, params, sinon.match.instanceOf(Span)) + sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, result: [123] })) + }) + it('should call a handler on request and send the thrown error of the returned Observable', async () => { + const handler: TypeScriptService = Object.create(TypeScriptService.prototype) + const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(Observable.throw(Object.assign(new Error('Something happened'), { + code: ErrorCodes.serverErrorStart, + whatever: 123 + }))) + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService) + const params = [1, 1] + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }) + sinon.assert.calledOnce(hoverStub) + sinon.assert.calledWithExactly(hoverStub, params, sinon.match.instanceOf(Span)) + sinon.assert.calledOnce(writer.write) + sinon.assert.calledWithExactly(writer.write, sinon.match({ + jsonrpc: '2.0', + id: 1, + error: { + message: 'Something happened', + code: ErrorCodes.serverErrorStart, + data: { whatever: 123 } + } + })) + }) + it('should call a handler on request and send the returned synchronous value', async () => { + const handler: TypeScriptService = Object.create(TypeScriptService.prototype) + const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(Observable.of({ op: 'add', path: '', value: 2 })) + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService) + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params: [1, 2] }) + sinon.assert.calledOnce(hoverStub) + sinon.assert.calledWithExactly(hoverStub, [1, 2], sinon.match.instanceOf(Span)) + sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, result: 2 })) + }) + it('should call a handler on request and send the result of the returned Observable', async () => { + const handler: TypeScriptService = Object.create(TypeScriptService.prototype) + const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(Observable.of({ op: 'add', path: '', value: 2 })) + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService) + const params = [1, 1] + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }) + sinon.assert.calledOnce(hoverStub) + sinon.assert.calledWithExactly(hoverStub, params, sinon.match.instanceOf(Span)) + sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, result: 2 })) + }) + it('should unsubscribe from the returned Observable when $/cancelRequest was sent and return a RequestCancelled error', async () => { + const handler: TypeScriptService = Object.create(TypeScriptService.prototype) + const unsubscribeHandler = sinon.spy() + const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(new Observable(subscriber => unsubscribeHandler)) + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService) + const params = [1, 1] + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }) + sinon.assert.calledOnce(hoverStub) + sinon.assert.calledWithExactly(hoverStub, params, sinon.match.instanceOf(Span)) + emitter.emit('message', { jsonrpc: '2.0', method: '$/cancelRequest', params: { id: 1 } }) + sinon.assert.calledOnce(unsubscribeHandler) + sinon.assert.calledOnce(writer.write) + sinon.assert.calledWithExactly(writer.write, sinon.match({ jsonrpc: '2.0', id: 1, error: { code: ErrorCodes.RequestCancelled } })) + }) + it('should unsubscribe from the returned Observable when the connection was closed', async () => { + const handler: TypeScriptService = Object.create(TypeScriptService.prototype) + const unsubscribeHandler = sinon.spy() + const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(new Observable(subscriber => unsubscribeHandler)) + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService) + const params = [1, 1] + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }) + sinon.assert.calledOnce(hoverStub) + emitter.emit('close') + sinon.assert.calledOnce(unsubscribeHandler) + }) + it('should unsubscribe from the returned Observable on exit notification', async () => { + const handler: TypeScriptService = Object.create(TypeScriptService.prototype) + const unsubscribeHandler = sinon.spy() + const hoverStub = sinon.stub(handler, 'textDocumentHover').returns(new Observable(subscriber => unsubscribeHandler)) + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as TypeScriptService) + const params = [1, 1] + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'textDocument/hover', params }) + sinon.assert.calledOnce(hoverStub) + emitter.emit('message', { jsonrpc: '2.0', method: 'exit' }) + sinon.assert.calledOnce(unsubscribeHandler) + }) + for (const event of ['close', 'error']) { + it(`should call shutdown on ${event} if the service was initialized`, async () => { + const handler = { + initialize: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: { capabilities: {} }})), + shutdown: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: null })) + } + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any) + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'initialize', params: { capabilities: {} } }) - sinon.assert.calledOnce(handler.initialize); - emitter.emit(event); - sinon.assert.calledOnce(handler.shutdown); - }); - it(`should not call shutdown on ${event} if the service was not initialized`, async () => { - const handler = { - initialize: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: { capabilities: {} }})), - shutdown: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: null })) - }; - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any); - emitter.emit(event); - sinon.assert.notCalled(handler.shutdown); - }); - it(`should not call shutdown again on ${event} if shutdown was already called`, async () => { - const handler = { - initialize: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: { capabilities: {} }})), - shutdown: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: null })) - }; - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any); - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'shutdown', params: {} }); + sinon.assert.calledOnce(handler.initialize) + emitter.emit(event) + sinon.assert.calledOnce(handler.shutdown) + }) + it(`should not call shutdown on ${event} if the service was not initialized`, async () => { + const handler = { + initialize: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: { capabilities: {} }})), + shutdown: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: null })) + } + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any) + emitter.emit(event) + sinon.assert.notCalled(handler.shutdown) + }) + it(`should not call shutdown again on ${event} if shutdown was already called`, async () => { + const handler = { + initialize: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: { capabilities: {} }})), + shutdown: sinon.stub().returns(Observable.of({ op: 'add', path: '', value: null })) + } + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any) + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'shutdown', params: {} }) - sinon.assert.calledOnce(handler.shutdown); - emitter.emit(event); - sinon.assert.calledOnce(handler.shutdown); - }); - } - describe('Client with streaming support', () => { - it('should call a handler on request and send partial results of the returned Observable', async () => { - const handler: { [K in keyof TypeScriptService]: TypeScriptService[K] & sinon.SinonStub } = sinon.createStubInstance(TypeScriptService); - handler.initialize.returns(Observable.of({ op: 'add', path: '', value: { capabilities: { streaming: true }}})); + sinon.assert.calledOnce(handler.shutdown) + emitter.emit(event) + sinon.assert.calledOnce(handler.shutdown) + }) + } + describe('Client with streaming support', () => { + it('should call a handler on request and send partial results of the returned Observable', async () => { + const handler: { [K in keyof TypeScriptService]: TypeScriptService[K] & sinon.SinonStub } = sinon.createStubInstance(TypeScriptService) + handler.initialize.returns(Observable.of({ op: 'add', path: '', value: { capabilities: { streaming: true }}})) - const hoverSubject = new Subject(); - handler.textDocumentHover.returns(hoverSubject); + const hoverSubject = new Subject() + handler.textDocumentHover.returns(hoverSubject) - const emitter = new EventEmitter(); - const writer = { - write: sinon.spy() - }; + const emitter = new EventEmitter() + const writer = { + write: sinon.spy() + } - registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any); + registerLanguageHandler(emitter as MessageEmitter, writer as any, handler as any) - // Send initialize - emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'initialize', params: { capabilities: { streaming: true }}}); - assert.deepEqual(writer.write.args[0], [{ - jsonrpc: '2.0', - method: '$/partialResult', - params: { - id: 1, - patch: [{ op: 'add', path: '', value: { capabilities: { streaming: true }}}] - } - }], 'Expected to send partial result for initialize'); - assert.deepEqual(writer.write.args[1], [{ - jsonrpc: '2.0', - id: 1, - result: { capabilities: { streaming: true } } - }], 'Expected to send final result for initialize'); + // Send initialize + emitter.emit('message', { jsonrpc: '2.0', id: 1, method: 'initialize', params: { capabilities: { streaming: true }}}) + assert.deepEqual(writer.write.args[0], [{ + jsonrpc: '2.0', + method: '$/partialResult', + params: { + id: 1, + patch: [{ op: 'add', path: '', value: { capabilities: { streaming: true }}}] + } + }], 'Expected to send partial result for initialize') + assert.deepEqual(writer.write.args[1], [{ + jsonrpc: '2.0', + id: 1, + result: { capabilities: { streaming: true } } + }], 'Expected to send final result for initialize') - // Send hover - emitter.emit('message', { jsonrpc: '2.0', id: 2, method: 'textDocument/hover', params: [1, 2] }); - sinon.assert.calledOnce(handler.textDocumentHover); + // Send hover + emitter.emit('message', { jsonrpc: '2.0', id: 2, method: 'textDocument/hover', params: [1, 2] }) + sinon.assert.calledOnce(handler.textDocumentHover) - // Simulate initializing JSON Patch Operation - hoverSubject.next({ op: 'add', path: '', value: [] }); - assert.deepEqual(writer.write.args[2], [{ - jsonrpc: '2.0', - method: '$/partialResult', - params: { id: 2, patch: [{ op: 'add', path: '', value: [] }] } - }], 'Expected to send partial result that initializes array'); + // Simulate initializing JSON Patch Operation + hoverSubject.next({ op: 'add', path: '', value: [] }) + assert.deepEqual(writer.write.args[2], [{ + jsonrpc: '2.0', + method: '$/partialResult', + params: { id: 2, patch: [{ op: 'add', path: '', value: [] }] } + }], 'Expected to send partial result that initializes array') - // Simulate streamed value - hoverSubject.next({ op: 'add', path: '/-', value: 123 }); - assert.deepEqual(writer.write.args[3], [{ - jsonrpc: '2.0', - method: '$/partialResult', - params: { id: 2, patch: [{ op: 'add', path: '/-', value: 123 }] } - }], 'Expected to send partial result that adds 123 to array'); + // Simulate streamed value + hoverSubject.next({ op: 'add', path: '/-', value: 123 }) + assert.deepEqual(writer.write.args[3], [{ + jsonrpc: '2.0', + method: '$/partialResult', + params: { id: 2, patch: [{ op: 'add', path: '/-', value: 123 }] } + }], 'Expected to send partial result that adds 123 to array') - // Complete Subject to trigger final response - hoverSubject.complete(); - assert.deepEqual(writer.write.args[4], [{ - jsonrpc: '2.0', - id: 2, - result: [123] - }], 'Expected to send final result [123]'); - }); - }); - }); - describe('MessageEmitter', () => { - it('should log messages if enabled', async () => { - const logger = new NoopLogger() as NoopLogger & { log: sinon.SinonStub }; - sinon.stub(logger, 'log'); - const emitter = new MessageEmitter(new PassThrough(), { logMessages: true, logger }); - emitter.emit('message', { jsonrpc: '2.0', method: 'whatever' }); - sinon.assert.calledOnce(logger.log); - sinon.assert.calledWith(logger.log, '-->'); - }); - it('should not log messages if disabled', async () => { - const logger = new NoopLogger() as NoopLogger & { log: sinon.SinonStub }; - sinon.stub(logger, 'log'); - const emitter = new MessageEmitter(new PassThrough(), { logMessages: false, logger }); - emitter.emit('message', { jsonrpc: '2.0', method: 'whatever' }); - sinon.assert.notCalled(logger.log); - }); - }); - describe('MessageWriter', () => { - it('should log messages if enabled', async () => { - const logger = new NoopLogger() as NoopLogger & { log: sinon.SinonStub }; - sinon.stub(logger, 'log'); - const writer = new MessageWriter(new PassThrough(), { logMessages: true, logger }); - writer.write({ jsonrpc: '2.0', method: 'whatever' }); - sinon.assert.calledOnce(logger.log); - sinon.assert.calledWith(logger.log, '<--'); - }); - it('should not log messages if disabled', async () => { - const logger = new NoopLogger() as NoopLogger & { log: sinon.SinonStub }; - sinon.stub(logger, 'log'); - const writer = new MessageWriter(new PassThrough(), { logMessages: false, logger }); - writer.write({ jsonrpc: '2.0', method: 'whatever' }); - sinon.assert.notCalled(logger.log); - }); - }); -}); + // Complete Subject to trigger final response + hoverSubject.complete() + assert.deepEqual(writer.write.args[4], [{ + jsonrpc: '2.0', + id: 2, + result: [123] + }], 'Expected to send final result [123]') + }) + }) + }) + describe('MessageEmitter', () => { + it('should log messages if enabled', async () => { + const logger = new NoopLogger() as NoopLogger & { log: sinon.SinonStub } + sinon.stub(logger, 'log') + const emitter = new MessageEmitter(new PassThrough(), { logMessages: true, logger }) + emitter.emit('message', { jsonrpc: '2.0', method: 'whatever' }) + sinon.assert.calledOnce(logger.log) + sinon.assert.calledWith(logger.log, '-->') + }) + it('should not log messages if disabled', async () => { + const logger = new NoopLogger() as NoopLogger & { log: sinon.SinonStub } + sinon.stub(logger, 'log') + const emitter = new MessageEmitter(new PassThrough(), { logMessages: false, logger }) + emitter.emit('message', { jsonrpc: '2.0', method: 'whatever' }) + sinon.assert.notCalled(logger.log) + }) + }) + describe('MessageWriter', () => { + it('should log messages if enabled', async () => { + const logger = new NoopLogger() as NoopLogger & { log: sinon.SinonStub } + sinon.stub(logger, 'log') + const writer = new MessageWriter(new PassThrough(), { logMessages: true, logger }) + writer.write({ jsonrpc: '2.0', method: 'whatever' }) + sinon.assert.calledOnce(logger.log) + sinon.assert.calledWith(logger.log, '<--') + }) + it('should not log messages if disabled', async () => { + const logger = new NoopLogger() as NoopLogger & { log: sinon.SinonStub } + sinon.stub(logger, 'log') + const writer = new MessageWriter(new PassThrough(), { logMessages: false, logger }) + writer.write({ jsonrpc: '2.0', method: 'whatever' }) + sinon.assert.notCalled(logger.log) + }) + }) +}) diff --git a/src/test/fs-helpers.ts b/src/test/fs-helpers.ts index 188d559db..d3cad58ac 100644 --- a/src/test/fs-helpers.ts +++ b/src/test/fs-helpers.ts @@ -1,24 +1,24 @@ -import { Observable } from '@reactivex/rxjs'; -import { FileSystem } from '../fs'; -import { observableFromIterable } from '../util'; +import { Observable } from '@reactivex/rxjs' +import { FileSystem } from '../fs' +import { observableFromIterable } from '../util' /** * Map-based file system that holds map (URI -> content) */ export class MapFileSystem implements FileSystem { - constructor(private files: Map) { } + constructor(private files: Map) { } - getWorkspaceFiles(base?: string): Observable { - return observableFromIterable(this.files.keys()) - .filter(path => !base || path.startsWith(base)); - } + public getWorkspaceFiles(base?: string): Observable { + return observableFromIterable(this.files.keys()) + .filter(path => !base || path.startsWith(base)) + } - getTextDocumentContent(uri: string): Observable { - const ret = this.files.get(uri); - if (ret === undefined) { - return Observable.throw(new Error(`Attempt to read not-existent file ${uri}`)); - } - return Observable.of(ret); - } + public getTextDocumentContent(uri: string): Observable { + const ret = this.files.get(uri) + if (ret === undefined) { + return Observable.throw(new Error(`Attempt to read not-existent file ${uri}`)) + } + return Observable.of(ret) + } } diff --git a/src/test/fs.test.ts b/src/test/fs.test.ts index fe8945456..b2ea61c3a 100644 --- a/src/test/fs.test.ts +++ b/src/test/fs.test.ts @@ -1,79 +1,79 @@ -import * as chai from 'chai'; -import chaiAsPromised = require('chai-as-promised'); -import * as fs from 'mz/fs'; -import * as path from 'path'; -import * as rimraf from 'rimraf'; -import * as temp from 'temp'; -import { LocalFileSystem } from '../fs'; -import { path2uri } from '../util'; -chai.use(chaiAsPromised); -const assert = chai.assert; +import * as chai from 'chai' +import chaiAsPromised = require('chai-as-promised') +import * as fs from 'mz/fs' +import * as path from 'path' +import * as rimraf from 'rimraf' +import * as temp from 'temp' +import { LocalFileSystem } from '../fs' +import { path2uri } from '../util' +chai.use(chaiAsPromised) +const assert = chai.assert describe('fs.ts', () => { - describe('LocalFileSystem', () => { - let temporaryDir: string; - let fileSystem: LocalFileSystem; - let rootUri: string; + describe('LocalFileSystem', () => { + let temporaryDir: string + let fileSystem: LocalFileSystem + let rootUri: string - before(async () => { - temporaryDir = await new Promise((resolve, reject) => { - temp.mkdir('local-fs', (err: Error, dirPath: string) => err ? reject(err) : resolve(dirPath)); - }); + before(async () => { + temporaryDir = await new Promise((resolve, reject) => { + temp.mkdir('local-fs', (err: Error, dirPath: string) => err ? reject(err) : resolve(dirPath)) + }) - // global packages contains a package - const globalPackagesDir = path.join(temporaryDir, 'node_modules'); - await fs.mkdir(globalPackagesDir); - const somePackageDir = path.join(globalPackagesDir, 'some_package'); - await fs.mkdir(somePackageDir); - await fs.mkdir(path.join(somePackageDir, 'src')); - await fs.writeFile(path.join(somePackageDir, 'src', 'function.ts'), 'foo'); + // global packages contains a package + const globalPackagesDir = path.join(temporaryDir, 'node_modules') + await fs.mkdir(globalPackagesDir) + const somePackageDir = path.join(globalPackagesDir, 'some_package') + await fs.mkdir(somePackageDir) + await fs.mkdir(path.join(somePackageDir, 'src')) + await fs.writeFile(path.join(somePackageDir, 'src', 'function.ts'), 'foo') - // the project dir - const projectDir = path.join(temporaryDir, 'project'); - rootUri = path2uri(projectDir) + '/'; - await fs.mkdir(projectDir); - await fs.mkdir(path.join(projectDir, 'foo')); - await fs.mkdir(path.join(projectDir, '@types')); - await fs.mkdir(path.join(projectDir, '@types', 'diff')); - await fs.mkdir(path.join(projectDir, 'node_modules')); - await fs.writeFile(path.join(projectDir, 'tweedledee'), 'hi'); - await fs.writeFile(path.join(projectDir, 'tweedledum'), 'bye'); - await fs.writeFile(path.join(projectDir, 'foo', 'bar.ts'), 'baz'); - await fs.writeFile(path.join(projectDir, '@types', 'diff', 'index.d.ts'), 'baz'); + // the project dir + const projectDir = path.join(temporaryDir, 'project') + rootUri = path2uri(projectDir) + '/' + await fs.mkdir(projectDir) + await fs.mkdir(path.join(projectDir, 'foo')) + await fs.mkdir(path.join(projectDir, '@types')) + await fs.mkdir(path.join(projectDir, '@types', 'diff')) + await fs.mkdir(path.join(projectDir, 'node_modules')) + await fs.writeFile(path.join(projectDir, 'tweedledee'), 'hi') + await fs.writeFile(path.join(projectDir, 'tweedledum'), 'bye') + await fs.writeFile(path.join(projectDir, 'foo', 'bar.ts'), 'baz') + await fs.writeFile(path.join(projectDir, '@types', 'diff', 'index.d.ts'), 'baz') - // global package is symlinked into project using npm link - await fs.symlink(somePackageDir, path.join(projectDir, 'node_modules', 'some_package'), 'junction'); - fileSystem = new LocalFileSystem(rootUri); - }); - after(async () => { - await new Promise((resolve, reject) => { - rimraf(temporaryDir, err => err ? reject(err) : resolve()); - }); - }); + // global package is symlinked into project using npm link + await fs.symlink(somePackageDir, path.join(projectDir, 'node_modules', 'some_package'), 'junction') + fileSystem = new LocalFileSystem(rootUri) + }) + after(async () => { + await new Promise((resolve, reject) => { + rimraf(temporaryDir, err => err ? reject(err) : resolve()) + }) + }) - describe('getWorkspaceFiles()', () => { - it('should return all files in the workspace', async () => { - const files = await fileSystem.getWorkspaceFiles().toArray().toPromise(); - assert.sameMembers(files, [ - rootUri + 'tweedledee', - rootUri + 'tweedledum', - rootUri + 'foo/bar.ts', - rootUri + '%40types/diff/index.d.ts', - rootUri + 'node_modules/some_package/src/function.ts' - ]); - }); - it('should return all files under specific root', async () => { - const files = await fileSystem.getWorkspaceFiles(rootUri + 'foo').toArray().toPromise(); - assert.sameMembers(files, [ - rootUri + 'foo/bar.ts' - ]); - }); - }); - describe('getTextDocumentContent()', () => { - it('should read files denoted by absolute URI', async () => { - const content = await fileSystem.getTextDocumentContent(rootUri + 'tweedledee').toPromise(); - assert.equal(content, 'hi'); - }); - }); - }); -}); + describe('getWorkspaceFiles()', () => { + it('should return all files in the workspace', async () => { + const files = await fileSystem.getWorkspaceFiles().toArray().toPromise() + assert.sameMembers(files, [ + rootUri + 'tweedledee', + rootUri + 'tweedledum', + rootUri + 'foo/bar.ts', + rootUri + '%40types/diff/index.d.ts', + rootUri + 'node_modules/some_package/src/function.ts' + ]) + }) + it('should return all files under specific root', async () => { + const files = await fileSystem.getWorkspaceFiles(rootUri + 'foo').toArray().toPromise() + assert.sameMembers(files, [ + rootUri + 'foo/bar.ts' + ]) + }) + }) + describe('getTextDocumentContent()', () => { + it('should read files denoted by absolute URI', async () => { + const content = await fileSystem.getTextDocumentContent(rootUri + 'tweedledee').toPromise() + assert.equal(content, 'hi') + }) + }) + }) +}) diff --git a/src/test/memfs.test.ts b/src/test/memfs.test.ts index ed5554fd4..c5ed629bb 100644 --- a/src/test/memfs.test.ts +++ b/src/test/memfs.test.ts @@ -1,49 +1,49 @@ -import * as chai from 'chai'; -import chaiAsPromised = require('chai-as-promised'); -import iterate from 'iterare'; -import * as sinon from 'sinon'; -import { InMemoryFileSystem, typeScriptLibraries } from '../memfs'; -import { uri2path } from '../util'; -chai.use(chaiAsPromised); -const assert = chai.assert; +import * as chai from 'chai' +import chaiAsPromised = require('chai-as-promised') +import iterate from 'iterare' +import * as sinon from 'sinon' +import { InMemoryFileSystem, typeScriptLibraries } from '../memfs' +import { uri2path } from '../util' +chai.use(chaiAsPromised) +const assert = chai.assert describe('memfs.ts', () => { - describe('InMemoryFileSystem', () => { - describe('add()', () => { - it('should add just a URI and emit an event', () => { - const listener = sinon.spy(); - const fs = new InMemoryFileSystem('/'); - fs.on('add', listener); - fs.add('file:///foo/bar.txt'); - sinon.assert.calledOnce(listener); - sinon.assert.calledWithExactly(listener, 'file:///foo/bar.txt', undefined); - }); - it('should add content for a URI and emit an event', () => { - const listener = sinon.spy(); - const fs = new InMemoryFileSystem('/'); - fs.on('add', listener); - fs.add('file:///foo/bar.txt', 'hello world'); - sinon.assert.calledOnce(listener); - sinon.assert.calledWithExactly(listener, 'file:///foo/bar.txt', 'hello world'); - }); - }); - describe('uris()', () => { - it('should hide TypeScript library files', async () => { - const fs = new InMemoryFileSystem('/'); - assert.isFalse(iterate(fs.uris()).some(uri => typeScriptLibraries.has(uri2path(uri)))); - }); - }); - describe('fileExists()', () => { - it('should expose TypeScript library files', async () => { - const fs = new InMemoryFileSystem('/'); - assert.isTrue(iterate(typeScriptLibraries.keys()).every(path => fs.fileExists(path))); - }); - }); - describe('readFile()', () => { - it('should expose TypeScript library files', async () => { - const fs = new InMemoryFileSystem('/'); - assert.isTrue(iterate(typeScriptLibraries.keys()).every(path => !!fs.readFile(path))); - }); - }); - }); -}); + describe('InMemoryFileSystem', () => { + describe('add()', () => { + it('should add just a URI and emit an event', () => { + const listener = sinon.spy() + const fs = new InMemoryFileSystem('/') + fs.on('add', listener) + fs.add('file:///foo/bar.txt') + sinon.assert.calledOnce(listener) + sinon.assert.calledWithExactly(listener, 'file:///foo/bar.txt', undefined) + }) + it('should add content for a URI and emit an event', () => { + const listener = sinon.spy() + const fs = new InMemoryFileSystem('/') + fs.on('add', listener) + fs.add('file:///foo/bar.txt', 'hello world') + sinon.assert.calledOnce(listener) + sinon.assert.calledWithExactly(listener, 'file:///foo/bar.txt', 'hello world') + }) + }) + describe('uris()', () => { + it('should hide TypeScript library files', async () => { + const fs = new InMemoryFileSystem('/') + assert.isFalse(iterate(fs.uris()).some(uri => typeScriptLibraries.has(uri2path(uri)))) + }) + }) + describe('fileExists()', () => { + it('should expose TypeScript library files', async () => { + const fs = new InMemoryFileSystem('/') + assert.isTrue(iterate(typeScriptLibraries.keys()).every(path => fs.fileExists(path))) + }) + }) + describe('readFile()', () => { + it('should expose TypeScript library files', async () => { + const fs = new InMemoryFileSystem('/') + assert.isTrue(iterate(typeScriptLibraries.keys()).every(path => !!fs.readFile(path))) + }) + }) + }) +}) diff --git a/src/test/packages.test.ts b/src/test/packages.test.ts index c0551abbe..a4f76e99f 100644 --- a/src/test/packages.test.ts +++ b/src/test/packages.test.ts @@ -1,56 +1,56 @@ -import * as assert from 'assert'; -import * as sinon from 'sinon'; -import { FileSystemUpdater } from '../fs'; -import { InMemoryFileSystem } from '../memfs'; -import { extractDefinitelyTypedPackageName, extractNodeModulesPackageName, PackageManager } from '../packages'; -import { MapFileSystem } from './fs-helpers'; +import * as assert from 'assert' +import * as sinon from 'sinon' +import { FileSystemUpdater } from '../fs' +import { InMemoryFileSystem } from '../memfs' +import { extractDefinitelyTypedPackageName, extractNodeModulesPackageName, PackageManager } from '../packages' +import { MapFileSystem } from './fs-helpers' describe('packages.ts', () => { - describe('extractDefinitelyTypedPackageName()', () => { - it('should return the @types package name for a file in DefinitelyTyped', () => { - const packageName = extractDefinitelyTypedPackageName('file:///types/node/index.d.ts'); - assert.equal(packageName, '@types/node'); - }); - it('should return undefined otherwise', () => { - const packageName = extractDefinitelyTypedPackageName('file:///package.json'); - assert.strictEqual(packageName, undefined); - }); - }); - describe('extractNodeModulesPackageName()', () => { - it('should return the package name for a file in node_modules', () => { - const packageName = extractNodeModulesPackageName('file:///foo/node_modules/bar/baz/test.ts'); - assert.equal(packageName, 'bar'); - }); - it('should return the package name for a file in a scoped package in node_modules', () => { - const packageName = extractNodeModulesPackageName('file:///foo/node_modules/@types/bar/baz/test.ts'); - assert.equal(packageName, '@types/bar'); - }); - it('should return the package name for a file in nested node_modules', () => { - const packageName = extractNodeModulesPackageName('file:///foo/node_modules/bar/node_modules/baz/test.ts'); - assert.equal(packageName, 'baz'); - }); - it('should return undefined otherwise', () => { - const packageName = extractNodeModulesPackageName('file:///foo/bar'); - assert.strictEqual(packageName, undefined); - }); - }); - describe('PackageManager', () => { - it('should register new packages as they are added to InMemoryFileSystem', () => { - const memfs = new InMemoryFileSystem('/'); - const localfs = new MapFileSystem(new Map()); - const updater = new FileSystemUpdater(localfs, memfs); - const packageManager = new PackageManager(updater, memfs); + describe('extractDefinitelyTypedPackageName()', () => { + it('should return the @types package name for a file in DefinitelyTyped', () => { + const packageName = extractDefinitelyTypedPackageName('file:///types/node/index.d.ts') + assert.equal(packageName, '@types/node') + }) + it('should return undefined otherwise', () => { + const packageName = extractDefinitelyTypedPackageName('file:///package.json') + assert.strictEqual(packageName, undefined) + }) + }) + describe('extractNodeModulesPackageName()', () => { + it('should return the package name for a file in node_modules', () => { + const packageName = extractNodeModulesPackageName('file:///foo/node_modules/bar/baz/test.ts') + assert.equal(packageName, 'bar') + }) + it('should return the package name for a file in a scoped package in node_modules', () => { + const packageName = extractNodeModulesPackageName('file:///foo/node_modules/@types/bar/baz/test.ts') + assert.equal(packageName, '@types/bar') + }) + it('should return the package name for a file in nested node_modules', () => { + const packageName = extractNodeModulesPackageName('file:///foo/node_modules/bar/node_modules/baz/test.ts') + assert.equal(packageName, 'baz') + }) + it('should return undefined otherwise', () => { + const packageName = extractNodeModulesPackageName('file:///foo/bar') + assert.strictEqual(packageName, undefined) + }) + }) + describe('PackageManager', () => { + it('should register new packages as they are added to InMemoryFileSystem', () => { + const memfs = new InMemoryFileSystem('/') + const localfs = new MapFileSystem(new Map()) + const updater = new FileSystemUpdater(localfs, memfs) + const packageManager = new PackageManager(updater, memfs) - const listener = sinon.spy(); - packageManager.on('parsed', listener); + const listener = sinon.spy() + packageManager.on('parsed', listener) - memfs.add('file:///foo/package.json', '{}'); + memfs.add('file:///foo/package.json', '{}') - sinon.assert.calledOnce(listener); - sinon.assert.alwaysCalledWith(listener, 'file:///foo/package.json', {}); + sinon.assert.calledOnce(listener) + sinon.assert.alwaysCalledWith(listener, 'file:///foo/package.json', {}) - const packages = Array.from(packageManager.packageJsonUris()); - assert.deepEqual(packages, ['file:///foo/package.json']); - }); - }); -}); + const packages = Array.from(packageManager.packageJsonUris()) + assert.deepEqual(packages, ['file:///foo/package.json']) + }) + }) +}) diff --git a/src/test/plugins.test.ts b/src/test/plugins.test.ts index ea4037f59..4041ef5b1 100644 --- a/src/test/plugins.test.ts +++ b/src/test/plugins.test.ts @@ -1,69 +1,69 @@ -import * as path from 'path'; -import * as sinon from 'sinon'; -import * as ts from 'typescript'; -import {InMemoryFileSystem} from '../memfs'; -import {PluginLoader, PluginModule, PluginModuleFactory} from '../plugins'; -import {PluginSettings} from '../request-type'; -import { path2uri } from '../util'; +import * as path from 'path' +import * as sinon from 'sinon' +import * as ts from 'typescript' +import { InMemoryFileSystem } from '../memfs' +import { PluginLoader, PluginModule, PluginModuleFactory } from '../plugins' +import { PluginSettings } from '../request-type' +import { path2uri } from '../util' describe('plugins', () => { - describe('loadPlugins()', () => { - it('should do nothing if no plugins are configured', () => { - const memfs = new InMemoryFileSystem('/'); + describe('loadPlugins()', () => { + it('should do nothing if no plugins are configured', () => { + const memfs = new InMemoryFileSystem('/') - const loader = new PluginLoader('/', memfs); - const compilerOptions: ts.CompilerOptions = {}; - const applyProxy: (pluginModuleFactory: PluginModuleFactory) => PluginModule = sinon.spy(); - loader.loadPlugins(compilerOptions, applyProxy); + const loader = new PluginLoader('/', memfs) + const compilerOptions: ts.CompilerOptions = {} + const applyProxy: (pluginModuleFactory: PluginModuleFactory) => PluginModule = sinon.spy() + loader.loadPlugins(compilerOptions, applyProxy) - }); + }) - it('should load a global plugin if specified', () => { - const memfs = new InMemoryFileSystem('/'); - const peerPackagesPath = path.resolve(__filename, '../../../../'); - const peerPackagesUri = path2uri(peerPackagesPath); - memfs.add(peerPackagesUri + '/node_modules/some-plugin/package.json', '{ "name": "some-plugin", "version": "0.1.1", "main": "plugin.js"}'); - memfs.add(peerPackagesUri + '/node_modules/some-plugin/plugin.js', ''); - const pluginSettings: PluginSettings = { - globalPlugins: ['some-plugin'], - allowLocalPluginLoads: false, - pluginProbeLocations: [] - }; - const pluginFactoryFunc = (modules: any) => 5; - const fakeRequire = (path: string) => pluginFactoryFunc; - const loader = new PluginLoader('/', memfs, pluginSettings, undefined, memfs, fakeRequire); - const compilerOptions: ts.CompilerOptions = {}; - const applyProxy = sinon.spy(); - loader.loadPlugins(compilerOptions, applyProxy); - sinon.assert.calledOnce(applyProxy); - sinon.assert.calledWithExactly(applyProxy, pluginFactoryFunc, sinon.match({ name: 'some-plugin', global: true})); - }); + it('should load a global plugin if specified', () => { + const memfs = new InMemoryFileSystem('/') + const peerPackagesPath = path.resolve(__filename, '../../../../') + const peerPackagesUri = path2uri(peerPackagesPath) + memfs.add(peerPackagesUri + '/node_modules/some-plugin/package.json', '{ "name": "some-plugin", "version": "0.1.1", "main": "plugin.js"}') + memfs.add(peerPackagesUri + '/node_modules/some-plugin/plugin.js', '') + const pluginSettings: PluginSettings = { + globalPlugins: ['some-plugin'], + allowLocalPluginLoads: false, + pluginProbeLocations: [] + } + const pluginFactoryFunc = (modules: any) => 5 + const fakeRequire = (path: string) => pluginFactoryFunc + const loader = new PluginLoader('/', memfs, pluginSettings, undefined, memfs, fakeRequire) + const compilerOptions: ts.CompilerOptions = {} + const applyProxy = sinon.spy() + loader.loadPlugins(compilerOptions, applyProxy) + sinon.assert.calledOnce(applyProxy) + sinon.assert.calledWithExactly(applyProxy, pluginFactoryFunc, sinon.match({ name: 'some-plugin', global: true})) + }) - it('should load a local plugin if specified', () => { - const rootDir = (process.platform === 'win32' ? 'c:\\' : '/') + 'some-project'; - const rootUri = path2uri(rootDir) + '/'; - const memfs = new InMemoryFileSystem('/some-project'); - memfs.add(rootUri + 'node_modules/some-plugin/package.json', '{ "name": "some-plugin", "version": "0.1.1", "main": "plugin.js"}'); - memfs.add(rootUri + 'node_modules/some-plugin/plugin.js', ''); - const pluginSettings: PluginSettings = { - globalPlugins: [], - allowLocalPluginLoads: true, - pluginProbeLocations: [] - }; - const pluginFactoryFunc = (modules: any) => 5; - const fakeRequire = (path: string) => pluginFactoryFunc; - const loader = new PluginLoader(rootDir, memfs, pluginSettings, undefined, memfs, fakeRequire); - const pluginOption: ts.PluginImport = { - name: 'some-plugin' - }; - const compilerOptions: ts.CompilerOptions = { - plugins: [pluginOption] - }; - const applyProxy = sinon.spy(); - loader.loadPlugins(compilerOptions, applyProxy); - sinon.assert.calledOnce(applyProxy); - sinon.assert.calledWithExactly(applyProxy, pluginFactoryFunc, sinon.match(pluginOption)); - }); + it('should load a local plugin if specified', () => { + const rootDir = (process.platform === 'win32' ? 'c:\\' : '/') + 'some-project' + const rootUri = path2uri(rootDir) + '/' + const memfs = new InMemoryFileSystem('/some-project') + memfs.add(rootUri + 'node_modules/some-plugin/package.json', '{ "name": "some-plugin", "version": "0.1.1", "main": "plugin.js"}') + memfs.add(rootUri + 'node_modules/some-plugin/plugin.js', '') + const pluginSettings: PluginSettings = { + globalPlugins: [], + allowLocalPluginLoads: true, + pluginProbeLocations: [] + } + const pluginFactoryFunc = (modules: any) => 5 + const fakeRequire = (path: string) => pluginFactoryFunc + const loader = new PluginLoader(rootDir, memfs, pluginSettings, undefined, memfs, fakeRequire) + const pluginOption: ts.PluginImport = { + name: 'some-plugin' + } + const compilerOptions: ts.CompilerOptions = { + plugins: [pluginOption] + } + const applyProxy = sinon.spy() + loader.loadPlugins(compilerOptions, applyProxy) + sinon.assert.calledOnce(applyProxy) + sinon.assert.calledWithExactly(applyProxy, pluginFactoryFunc, sinon.match(pluginOption)) + }) - }); -}); + }) +}) diff --git a/src/test/project-manager.test.ts b/src/test/project-manager.test.ts index aa8e8392b..db8ea1167 100644 --- a/src/test/project-manager.test.ts +++ b/src/test/project-manager.test.ts @@ -1,136 +1,136 @@ -import * as chai from 'chai'; -import chaiAsPromised = require('chai-as-promised'); -import { FileSystemUpdater } from '../fs'; -import { InMemoryFileSystem } from '../memfs'; -import { ProjectManager } from '../project-manager'; -import { MapFileSystem } from './fs-helpers'; -chai.use(chaiAsPromised); -const assert = chai.assert; +import * as chai from 'chai' +import chaiAsPromised = require('chai-as-promised') +import { FileSystemUpdater } from '../fs' +import { InMemoryFileSystem } from '../memfs' +import { ProjectManager } from '../project-manager' +import { MapFileSystem } from './fs-helpers' +chai.use(chaiAsPromised) +const assert = chai.assert describe('ProjectManager', () => { - let projectManager: ProjectManager; - let memfs: InMemoryFileSystem; + let projectManager: ProjectManager + let memfs: InMemoryFileSystem - it('should add a ProjectConfiguration when a tsconfig.json is added to the InMemoryFileSystem', () => { - memfs = new InMemoryFileSystem('/'); - const localfs = new MapFileSystem(new Map([ - ['file:///foo/tsconfig.json', '{}'] - ])); - const updater = new FileSystemUpdater(localfs, memfs); - projectManager = new ProjectManager('/', memfs, updater, true); - memfs.add('file:///foo/tsconfig.json', '{}'); - const configs = Array.from(projectManager.configurations()); - assert.isDefined(configs.find(config => config.configFilePath === '/foo/tsconfig.json')); - }); + it('should add a ProjectConfiguration when a tsconfig.json is added to the InMemoryFileSystem', () => { + memfs = new InMemoryFileSystem('/') + const localfs = new MapFileSystem(new Map([ + ['file:///foo/tsconfig.json', '{}'] + ])) + const updater = new FileSystemUpdater(localfs, memfs) + projectManager = new ProjectManager('/', memfs, updater, true) + memfs.add('file:///foo/tsconfig.json', '{}') + const configs = Array.from(projectManager.configurations()) + assert.isDefined(configs.find(config => config.configFilePath === '/foo/tsconfig.json')) + }) - describe('ensureBasicFiles', () => { - beforeEach(async () => { - memfs = new InMemoryFileSystem('/'); - const localfs = new MapFileSystem(new Map([ - ['file:///project/package.json', '{"name": "package-name-1"}'], - ['file:///project/tsconfig.json', '{ "compilerOptions": { "typeRoots": ["../types"]} }'], - ['file:///project/file.ts', 'console.log(GLOBALCONSTANT);'], - ['file:///types/types.d.ts', 'declare var GLOBALCONSTANT=1;'] + describe('ensureBasicFiles', () => { + beforeEach(async () => { + memfs = new InMemoryFileSystem('/') + const localfs = new MapFileSystem(new Map([ + ['file:///project/package.json', '{"name": "package-name-1"}'], + ['file:///project/tsconfig.json', '{ "compilerOptions": { "typeRoots": ["../types"]} }'], + ['file:///project/file.ts', 'console.log(GLOBALCONSTANT);'], + ['file:///types/types.d.ts', 'declare var GLOBALCONSTANT=1;'] - ])); - const updater = new FileSystemUpdater(localfs, memfs); - projectManager = new ProjectManager('/', memfs, updater, true); - }); - it('loads files from typeRoots', async () => { - await projectManager.ensureReferencedFiles('file:///project/file.ts').toPromise(); - memfs.getContent('file:///project/file.ts'); - memfs.getContent('file:///types/types.d.ts'); - }); - }); + ])) + const updater = new FileSystemUpdater(localfs, memfs) + projectManager = new ProjectManager('/', memfs, updater, true) + }) + it('loads files from typeRoots', async () => { + await projectManager.ensureReferencedFiles('file:///project/file.ts').toPromise() + memfs.getContent('file:///project/file.ts') + memfs.getContent('file:///types/types.d.ts') + }) + }) - describe('getPackageName()', () => { - beforeEach(async () => { - memfs = new InMemoryFileSystem('/'); - const localfs = new MapFileSystem(new Map([ - ['file:///package.json', '{"name": "package-name-1"}'], - ['file:///subdirectory-with-tsconfig/package.json', '{"name": "package-name-2"}'], - ['file:///subdirectory-with-tsconfig/src/tsconfig.json', '{}'], - ['file:///subdirectory-with-tsconfig/src/dummy.ts', ''] - ])); - const updater = new FileSystemUpdater(localfs, memfs); - projectManager = new ProjectManager('/', memfs, updater, true); - await projectManager.ensureAllFiles().toPromise(); - }); - }); - describe('ensureReferencedFiles()', () => { - beforeEach(() => { - memfs = new InMemoryFileSystem('/'); - const localfs = new MapFileSystem(new Map([ - ['file:///package.json', '{"name": "package-name-1"}'], - ['file:///node_modules/somelib/index.js', '/// \n/// '], - ['file:///node_modules/somelib/pathref.d.ts', ''], - ['file:///node_modules/%40types/node/index.d.ts', ''], - ['file:///src/dummy.ts', 'import * as somelib from "somelib";'] - ])); - const updater = new FileSystemUpdater(localfs, memfs); - projectManager = new ProjectManager('/', memfs, updater, true); - }); - it('should ensure content for imports and references is fetched', async () => { - await projectManager.ensureReferencedFiles('file:///src/dummy.ts').toPromise(); - memfs.getContent('file:///node_modules/somelib/index.js'); - memfs.getContent('file:///node_modules/somelib/pathref.d.ts'); - memfs.getContent('file:///node_modules/%40types/node/index.d.ts'); - }); - }); - describe('getConfiguration()', () => { - beforeEach(async () => { - memfs = new InMemoryFileSystem('/'); - const localfs = new MapFileSystem(new Map([ - ['file:///tsconfig.json', '{}'], - ['file:///src/jsconfig.json', '{}'] - ])); - const updater = new FileSystemUpdater(localfs, memfs); - projectManager = new ProjectManager('/', memfs, updater, true); - await projectManager.ensureAllFiles().toPromise(); - }); - it('should resolve best configuration based on file name', () => { - const jsConfig = projectManager.getConfiguration('/src/foo.js'); - const tsConfig = projectManager.getConfiguration('/src/foo.ts'); - assert.equal('/tsconfig.json', tsConfig.configFilePath); - assert.equal('/src/jsconfig.json', jsConfig.configFilePath); - }); - }); - describe('getParentConfiguration()', () => { - beforeEach(async () => { - memfs = new InMemoryFileSystem('/'); - const localfs = new MapFileSystem(new Map([ - ['file:///tsconfig.json', '{}'], - ['file:///src/jsconfig.json', '{}'] - ])); - const updater = new FileSystemUpdater(localfs, memfs); - projectManager = new ProjectManager('/', memfs, updater, true); - await projectManager.ensureAllFiles().toPromise(); - }); - it('should resolve best configuration based on file name', () => { - const config = projectManager.getParentConfiguration('file:///src/foo.ts'); - assert.isDefined(config); - assert.equal('/tsconfig.json', config!.configFilePath); - }); - }); - describe('getChildConfigurations()', () => { - beforeEach(async () => { - memfs = new InMemoryFileSystem('/'); - const localfs = new MapFileSystem(new Map([ - ['file:///tsconfig.json', '{}'], - ['file:///foo/bar/tsconfig.json', '{}'], - ['file:///foo/baz/tsconfig.json', '{}'] - ])); - const updater = new FileSystemUpdater(localfs, memfs); - projectManager = new ProjectManager('/', memfs, updater, true); - await projectManager.ensureAllFiles().toPromise(); - }); - it('should resolve best configuration based on file name', () => { - const configs = Array.from(projectManager.getChildConfigurations('file:///foo')).map(config => config.configFilePath); - assert.deepEqual(configs, [ - '/foo/bar/tsconfig.json', - '/foo/baz/tsconfig.json' - ]); - }); - }); -}); + describe('getPackageName()', () => { + beforeEach(async () => { + memfs = new InMemoryFileSystem('/') + const localfs = new MapFileSystem(new Map([ + ['file:///package.json', '{"name": "package-name-1"}'], + ['file:///subdirectory-with-tsconfig/package.json', '{"name": "package-name-2"}'], + ['file:///subdirectory-with-tsconfig/src/tsconfig.json', '{}'], + ['file:///subdirectory-with-tsconfig/src/dummy.ts', ''] + ])) + const updater = new FileSystemUpdater(localfs, memfs) + projectManager = new ProjectManager('/', memfs, updater, true) + await projectManager.ensureAllFiles().toPromise() + }) + }) + describe('ensureReferencedFiles()', () => { + beforeEach(() => { + memfs = new InMemoryFileSystem('/') + const localfs = new MapFileSystem(new Map([ + ['file:///package.json', '{"name": "package-name-1"}'], + ['file:///node_modules/somelib/index.js', '/// \n/// '], + ['file:///node_modules/somelib/pathref.d.ts', ''], + ['file:///node_modules/%40types/node/index.d.ts', ''], + ['file:///src/dummy.ts', 'import * as somelib from "somelib";'] + ])) + const updater = new FileSystemUpdater(localfs, memfs) + projectManager = new ProjectManager('/', memfs, updater, true) + }) + it('should ensure content for imports and references is fetched', async () => { + await projectManager.ensureReferencedFiles('file:///src/dummy.ts').toPromise() + memfs.getContent('file:///node_modules/somelib/index.js') + memfs.getContent('file:///node_modules/somelib/pathref.d.ts') + memfs.getContent('file:///node_modules/%40types/node/index.d.ts') + }) + }) + describe('getConfiguration()', () => { + beforeEach(async () => { + memfs = new InMemoryFileSystem('/') + const localfs = new MapFileSystem(new Map([ + ['file:///tsconfig.json', '{}'], + ['file:///src/jsconfig.json', '{}'] + ])) + const updater = new FileSystemUpdater(localfs, memfs) + projectManager = new ProjectManager('/', memfs, updater, true) + await projectManager.ensureAllFiles().toPromise() + }) + it('should resolve best configuration based on file name', () => { + const jsConfig = projectManager.getConfiguration('/src/foo.js') + const tsConfig = projectManager.getConfiguration('/src/foo.ts') + assert.equal('/tsconfig.json', tsConfig.configFilePath) + assert.equal('/src/jsconfig.json', jsConfig.configFilePath) + }) + }) + describe('getParentConfiguration()', () => { + beforeEach(async () => { + memfs = new InMemoryFileSystem('/') + const localfs = new MapFileSystem(new Map([ + ['file:///tsconfig.json', '{}'], + ['file:///src/jsconfig.json', '{}'] + ])) + const updater = new FileSystemUpdater(localfs, memfs) + projectManager = new ProjectManager('/', memfs, updater, true) + await projectManager.ensureAllFiles().toPromise() + }) + it('should resolve best configuration based on file name', () => { + const config = projectManager.getParentConfiguration('file:///src/foo.ts') + assert.isDefined(config) + assert.equal('/tsconfig.json', config!.configFilePath) + }) + }) + describe('getChildConfigurations()', () => { + beforeEach(async () => { + memfs = new InMemoryFileSystem('/') + const localfs = new MapFileSystem(new Map([ + ['file:///tsconfig.json', '{}'], + ['file:///foo/bar/tsconfig.json', '{}'], + ['file:///foo/baz/tsconfig.json', '{}'] + ])) + const updater = new FileSystemUpdater(localfs, memfs) + projectManager = new ProjectManager('/', memfs, updater, true) + await projectManager.ensureAllFiles().toPromise() + }) + it('should resolve best configuration based on file name', () => { + const configs = Array.from(projectManager.getChildConfigurations('file:///foo')).map(config => config.configFilePath) + assert.deepEqual(configs, [ + '/foo/bar/tsconfig.json', + '/foo/baz/tsconfig.json' + ]) + }) + }) +}) diff --git a/src/test/tracing.test.ts b/src/test/tracing.test.ts index d998ce58c..f4f7f0214 100644 --- a/src/test/tracing.test.ts +++ b/src/test/tracing.test.ts @@ -1,123 +1,123 @@ -import { Observable } from '@reactivex/rxjs'; -import * as chai from 'chai'; -import chaiAsPromised = require('chai-as-promised'); -import { Span } from 'opentracing'; -import * as sinon from 'sinon'; -import { traceObservable, tracePromise, traceSync } from '../tracing'; -chai.use(chaiAsPromised); -const assert = chai.assert; +import { Observable } from '@reactivex/rxjs' +import * as chai from 'chai' +import chaiAsPromised = require('chai-as-promised') +import { Span } from 'opentracing' +import * as sinon from 'sinon' +import { traceObservable, tracePromise, traceSync } from '../tracing' +chai.use(chaiAsPromised) +const assert = chai.assert describe('tracing.ts', () => { - let sandbox: sinon.SinonSandbox; - beforeEach(() => { - sandbox = sinon.sandbox.create(); - }); - afterEach(() => { - sandbox.restore(); - }); - describe('traceSync()', () => { - it('should trace the error if the function throws', () => { - let setTagStub: sinon.SinonStub | undefined; - let logStub: sinon.SinonStub | undefined; - let finishStub: sinon.SinonStub | undefined; - assert.throws(() => { - traceSync('Foo', new Span(), span => { - setTagStub = sandbox.stub(span, 'setTag'); - logStub = sandbox.stub(span, 'log'); - finishStub = sandbox.stub(span, 'finish'); - throw new Error('Bar'); - }); - }, 'Bar'); - sinon.assert.calledOnce(setTagStub!); - sinon.assert.calledOnce(logStub!); - sinon.assert.calledWith(setTagStub!, 'error', true); - sinon.assert.calledWith(logStub!, sinon.match({ event: 'error', message: 'Bar' })); - sinon.assert.calledOnce(finishStub!); - }); - }); - describe('tracePromise()', () => { - it('should trace the error if the Promise is rejected', async () => { - let setTagStub: sinon.SinonStub | undefined; - let logStub: sinon.SinonStub | undefined; - let finishStub: sinon.SinonStub | undefined; - await assert.isRejected( - tracePromise('Foo', new Span(), async span => { - setTagStub = sandbox.stub(span, 'setTag'); - logStub = sandbox.stub(span, 'log'); - finishStub = sandbox.stub(span, 'finish'); - throw new Error('Bar'); - }), - 'Bar' - ); - await new Promise(resolve => setTimeout(resolve, 0)); - sinon.assert.calledOnce(setTagStub!); - sinon.assert.calledOnce(logStub!); - sinon.assert.calledWith(setTagStub!, 'error', true); - sinon.assert.calledWith(logStub!, sinon.match({ event: 'error', message: 'Bar' })); - sinon.assert.calledOnce(finishStub!); - }); - it('should trace the error if the function throws an Error', async () => { - let setTagStub: sinon.SinonStub | undefined; - let logStub: sinon.SinonStub | undefined; - let finishStub: sinon.SinonStub | undefined; - await assert.isRejected( - tracePromise('Foo', new Span(), span => { - setTagStub = sandbox.stub(span, 'setTag'); - logStub = sandbox.stub(span, 'log'); - finishStub = sandbox.stub(span, 'finish'); - throw new Error('Bar'); - }), - 'Bar' - ); - await new Promise(resolve => setTimeout(resolve, 0)); - sinon.assert.calledOnce(setTagStub!); - sinon.assert.calledOnce(logStub!); - sinon.assert.calledWith(setTagStub!, 'error', true); - sinon.assert.calledWith(logStub!, sinon.match({ event: 'error', message: 'Bar' })); - sinon.assert.calledOnce(finishStub!); - }); - }); - describe('traceObservable()', () => { - it('should trace the error if the Observable errors', async () => { - let setTagStub: sinon.SinonStub | undefined; - let logStub: sinon.SinonStub | undefined; - let finishStub: sinon.SinonStub | undefined; - await assert.isRejected( - traceObservable('Foo', new Span(), span => { - setTagStub = sandbox.stub(span, 'setTag'); - logStub = sandbox.stub(span, 'log'); - finishStub = sandbox.stub(span, 'finish'); - return Observable.throw(new Error('Bar')); - }).toPromise(), - 'Bar' - ); - await new Promise(resolve => setTimeout(resolve, 0)); - sinon.assert.calledOnce(setTagStub!); - sinon.assert.calledOnce(logStub!); - sinon.assert.calledWith(setTagStub!, 'error', true); - sinon.assert.calledWith(logStub!, sinon.match({ event: 'error', message: 'Bar' })); - sinon.assert.calledOnce(finishStub!); - }); - it('should trace the error if the function throws an Error', async () => { - let setTagStub: sinon.SinonStub | undefined; - let logStub: sinon.SinonStub | undefined; - let finishStub: sinon.SinonStub | undefined; - await assert.isRejected( - traceObservable('Foo', new Span(), span => { - setTagStub = sandbox.stub(span, 'setTag'); - logStub = sandbox.stub(span, 'log'); - finishStub = sandbox.stub(span, 'finish'); - throw new Error('Bar'); - }).toPromise(), - 'Bar' - ); - await new Promise(resolve => setTimeout(resolve, 0)); - sinon.assert.calledOnce(setTagStub!); - sinon.assert.calledOnce(logStub!); - sinon.assert.calledWith(setTagStub!, 'error', true); - sinon.assert.calledWith(logStub!, sinon.match({ event: 'error', message: 'Bar' })); - sinon.assert.calledOnce(finishStub!); - }); - }); -}); + let sandbox: sinon.SinonSandbox + beforeEach(() => { + sandbox = sinon.sandbox.create() + }) + afterEach(() => { + sandbox.restore() + }) + describe('traceSync()', () => { + it('should trace the error if the function throws', () => { + let setTagStub: sinon.SinonStub | undefined + let logStub: sinon.SinonStub | undefined + let finishStub: sinon.SinonStub | undefined + assert.throws(() => { + traceSync('Foo', new Span(), span => { + setTagStub = sandbox.stub(span, 'setTag') + logStub = sandbox.stub(span, 'log') + finishStub = sandbox.stub(span, 'finish') + throw new Error('Bar') + }) + }, 'Bar') + sinon.assert.calledOnce(setTagStub!) + sinon.assert.calledOnce(logStub!) + sinon.assert.calledWith(setTagStub!, 'error', true) + sinon.assert.calledWith(logStub!, sinon.match({ event: 'error', message: 'Bar' })) + sinon.assert.calledOnce(finishStub!) + }) + }) + describe('tracePromise()', () => { + it('should trace the error if the Promise is rejected', async () => { + let setTagStub: sinon.SinonStub | undefined + let logStub: sinon.SinonStub | undefined + let finishStub: sinon.SinonStub | undefined + await Promise.resolve(assert.isRejected( + tracePromise('Foo', new Span(), async span => { + setTagStub = sandbox.stub(span, 'setTag') + logStub = sandbox.stub(span, 'log') + finishStub = sandbox.stub(span, 'finish') + throw new Error('Bar') + }), + 'Bar' + )) + await new Promise(resolve => setTimeout(resolve, 0)) + sinon.assert.calledOnce(setTagStub!) + sinon.assert.calledOnce(logStub!) + sinon.assert.calledWith(setTagStub!, 'error', true) + sinon.assert.calledWith(logStub!, sinon.match({ event: 'error', message: 'Bar' })) + sinon.assert.calledOnce(finishStub!) + }) + it('should trace the error if the function throws an Error', async () => { + let setTagStub: sinon.SinonStub | undefined + let logStub: sinon.SinonStub | undefined + let finishStub: sinon.SinonStub | undefined + await Promise.resolve(assert.isRejected( + tracePromise('Foo', new Span(), span => { + setTagStub = sandbox.stub(span, 'setTag') + logStub = sandbox.stub(span, 'log') + finishStub = sandbox.stub(span, 'finish') + throw new Error('Bar') + }), + 'Bar' + )) + await new Promise(resolve => setTimeout(resolve, 0)) + sinon.assert.calledOnce(setTagStub!) + sinon.assert.calledOnce(logStub!) + sinon.assert.calledWith(setTagStub!, 'error', true) + sinon.assert.calledWith(logStub!, sinon.match({ event: 'error', message: 'Bar' })) + sinon.assert.calledOnce(finishStub!) + }) + }) + describe('traceObservable()', () => { + it('should trace the error if the Observable errors', async () => { + let setTagStub: sinon.SinonStub | undefined + let logStub: sinon.SinonStub | undefined + let finishStub: sinon.SinonStub | undefined + await Promise.resolve(assert.isRejected( + traceObservable('Foo', new Span(), span => { + setTagStub = sandbox.stub(span, 'setTag') + logStub = sandbox.stub(span, 'log') + finishStub = sandbox.stub(span, 'finish') + return Observable.throw(new Error('Bar')) + }).toPromise(), + 'Bar' + )) + await new Promise(resolve => setTimeout(resolve, 0)) + sinon.assert.calledOnce(setTagStub!) + sinon.assert.calledOnce(logStub!) + sinon.assert.calledWith(setTagStub!, 'error', true) + sinon.assert.calledWith(logStub!, sinon.match({ event: 'error', message: 'Bar' })) + sinon.assert.calledOnce(finishStub!) + }) + it('should trace the error if the function throws an Error', async () => { + let setTagStub: sinon.SinonStub | undefined + let logStub: sinon.SinonStub | undefined + let finishStub: sinon.SinonStub | undefined + await Promise.resolve(assert.isRejected( + traceObservable('Foo', new Span(), span => { + setTagStub = sandbox.stub(span, 'setTag') + logStub = sandbox.stub(span, 'log') + finishStub = sandbox.stub(span, 'finish') + throw new Error('Bar') + }).toPromise(), + 'Bar' + )) + await new Promise(resolve => setTimeout(resolve, 0)) + sinon.assert.calledOnce(setTagStub!) + sinon.assert.calledOnce(logStub!) + sinon.assert.calledWith(setTagStub!, 'error', true) + sinon.assert.calledWith(logStub!, sinon.match({ event: 'error', message: 'Bar' })) + sinon.assert.calledOnce(finishStub!) + }) + }) +}) diff --git a/src/test/typescript-service-helpers.ts b/src/test/typescript-service-helpers.ts index 1c293b19d..cd10e6ebe 100644 --- a/src/test/typescript-service-helpers.ts +++ b/src/test/typescript-service-helpers.ts @@ -1,33 +1,33 @@ -import { Observable } from '@reactivex/rxjs'; -import * as chai from 'chai'; -import chaiAsPromised = require('chai-as-promised'); -import { applyReducer, Operation } from 'fast-json-patch'; -import { IBeforeAndAfterContext, ISuiteCallbackContext, ITestCallbackContext } from 'mocha'; -import * as sinon from 'sinon'; -import * as ts from 'typescript'; -import { CompletionItemKind, CompletionList, DiagnosticSeverity, InsertTextFormat, TextDocumentIdentifier, TextDocumentItem, WorkspaceEdit } from 'vscode-languageserver'; -import { Command, Diagnostic, Hover, Location, SignatureHelp, SymbolInformation, SymbolKind } from 'vscode-languageserver-types'; -import { LanguageClient, RemoteLanguageClient } from '../lang-handler'; -import { DependencyReference, PackageInformation, ReferenceInformation, TextDocumentContentParams, WorkspaceFilesParams } from '../request-type'; -import { ClientCapabilities, CompletionItem, SymbolLocationInformation } from '../request-type'; -import { TypeScriptService, TypeScriptServiceFactory } from '../typescript-service'; -import { observableFromIterable, toUnixPath, uri2path } from '../util'; - -chai.use(chaiAsPromised); -const assert = chai.assert; +import { Observable } from '@reactivex/rxjs' +import * as chai from 'chai' +import chaiAsPromised = require('chai-as-promised') +import { applyReducer, Operation } from 'fast-json-patch' +import { IBeforeAndAfterContext, ISuiteCallbackContext, ITestCallbackContext } from 'mocha' +import * as sinon from 'sinon' +import * as ts from 'typescript' +import { CompletionItemKind, CompletionList, DiagnosticSeverity, InsertTextFormat, TextDocumentIdentifier, TextDocumentItem, WorkspaceEdit } from 'vscode-languageserver' +import { Command, Diagnostic, Hover, Location, SignatureHelp, SymbolInformation, SymbolKind } from 'vscode-languageserver-types' +import { LanguageClient, RemoteLanguageClient } from '../lang-handler' +import { DependencyReference, PackageInformation, ReferenceInformation, TextDocumentContentParams, WorkspaceFilesParams } from '../request-type' +import { ClientCapabilities, CompletionItem, SymbolLocationInformation } from '../request-type' +import { TypeScriptService, TypeScriptServiceFactory } from '../typescript-service' +import { observableFromIterable, toUnixPath, uri2path } from '../util' + +chai.use(chaiAsPromised) +const assert = chai.assert const DEFAULT_CAPABILITIES: ClientCapabilities = { - xcontentProvider: true, - xfilesProvider: true -}; + xcontentProvider: true, + xfilesProvider: true +} export interface TestContext { - /** TypeScript service under test */ - service: TypeScriptService; + /** TypeScript service under test */ + service: TypeScriptService - /** Stubbed LanguageClient */ - client: { [K in keyof LanguageClient]: LanguageClient[K] & sinon.SinonStub }; + /** Stubbed LanguageClient */ + client: { [K in keyof LanguageClient]: LanguageClient[K] & sinon.SinonStub } } /** @@ -36,40 +36,45 @@ export interface TestContext { * @param createService A factory that creates the TypeScript service. Allows to test subclasses of TypeScriptService * @param files A Map from URI to file content of files that should be available in the workspace */ -export const initializeTypeScriptService = (createService: TypeScriptServiceFactory, rootUri: string, files: Map, clientCapabilities: ClientCapabilities = DEFAULT_CAPABILITIES) => async function (this: TestContext & IBeforeAndAfterContext): Promise { - - // Stub client - this.client = sinon.createStubInstance(RemoteLanguageClient); - this.client.textDocumentXcontent.callsFake((params: TextDocumentContentParams): Observable => { - if (!files.has(params.textDocument.uri)) { - return Observable.throw(new Error(`Text document ${params.textDocument.uri} does not exist`)); - } - return Observable.of({ - uri: params.textDocument.uri, - text: files.get(params.textDocument.uri)!, - version: 1, - languageId: '' - }); - }); - this.client.workspaceXfiles.callsFake((params: WorkspaceFilesParams): Observable => { - return observableFromIterable(files.keys()).map(uri => ({ uri })).toArray(); - }); - this.client.xcacheGet.callsFake(() => Observable.of(null)); - this.client.workspaceApplyEdit.callsFake(() => Observable.of({applied: true})); - this.service = createService(this.client); - - await this.service.initialize({ - processId: process.pid, - rootUri, - capabilities: clientCapabilities || DEFAULT_CAPABILITIES - }).toPromise(); -}; +export const initializeTypeScriptService = ( + createService: TypeScriptServiceFactory, + rootUri: string, + files: Map, + clientCapabilities: ClientCapabilities = DEFAULT_CAPABILITIES +) => async function(this: TestContext & IBeforeAndAfterContext): Promise { + + // Stub client + this.client = sinon.createStubInstance(RemoteLanguageClient) + this.client.textDocumentXcontent.callsFake((params: TextDocumentContentParams): Observable => { + if (!files.has(params.textDocument.uri)) { + return Observable.throw(new Error(`Text document ${params.textDocument.uri} does not exist`)) + } + return Observable.of({ + uri: params.textDocument.uri, + text: files.get(params.textDocument.uri)!, + version: 1, + languageId: '' + }) + }) + this.client.workspaceXfiles.callsFake((params: WorkspaceFilesParams): Observable => { + return observableFromIterable(files.keys()).map(uri => ({ uri })).toArray() + }) + this.client.xcacheGet.callsFake(() => Observable.of(null)) + this.client.workspaceApplyEdit.callsFake(() => Observable.of({applied: true})) + this.service = createService(this.client) + + await this.service.initialize({ + processId: process.pid, + rootUri, + capabilities: clientCapabilities || DEFAULT_CAPABILITIES + }).toPromise() +} /** * Shuts the TypeScriptService down (to be used in `afterEach()`) */ export async function shutdownTypeScriptService(this: TestContext & IBeforeAndAfterContext): Promise { - await this.service.shutdown().toPromise(); + await this.service.shutdown().toPromise() } /** @@ -79,2770 +84,2953 @@ export async function shutdownTypeScriptService(this: TestContext & IBeforeAndAf */ export function describeTypeScriptService(createService: TypeScriptServiceFactory, shutdownService = shutdownTypeScriptService, rootUri: string): void { - describe('Workspace without project files', function (this: TestContext & ISuiteCallbackContext) { - - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'a.ts', 'const abc = 1; console.log(abc);'], - [rootUri + 'foo/b.ts', [ - '/* This is class Foo */', - 'export class Foo {}' - ].join('\n')], - [rootUri + 'foo/c.ts', 'import {Foo} from "./b";'], - [rootUri + 'd.ts', [ - 'export interface I {', - ' target: string;', - '}' - ].join('\n')], - [rootUri + 'e.ts', [ - 'import * as d from "./d";', - '', - 'let i: d.I = { target: "hi" };', - 'let target = i.target;' - ].join('\n')] - ]))); - - afterEach(shutdownService); - - describe('textDocumentDefinition()', function (this: TestContext & ISuiteCallbackContext) { - specify('in same file', async function (this: TestContext & ITestCallbackContext) { - const result: Location[] = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 29 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - uri: rootUri + 'a.ts', - range: { - start: { - line: 0, - character: 6 - }, - end: { - line: 0, - character: 9 - } - } - }]); - }); - specify('on keyword (non-null)', async function (this: TestContext & ITestCallbackContext) { - const result: Location[] = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 0 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, []); - }); - specify('in other file', async function (this: TestContext & ITestCallbackContext) { - const result: Location[] = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'foo/c.ts' - }, - position: { - line: 0, - character: 9 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - uri: rootUri + 'foo/b.ts', - range: { - start: { - line: 1, - character: 13 - }, - end: { - line: 1, - character: 16 - } - } - }]); - }); - }); - describe('textDocumentXdefinition()', function (this: TestContext & ISuiteCallbackContext) { - specify('on interface field reference', async function (this: TestContext & ITestCallbackContext) { - const result: SymbolLocationInformation[] = await this.service.textDocumentXdefinition({ - textDocument: { - uri: rootUri + 'e.ts' - }, - position: { - line: 3, - character: 15 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - location: { - uri: rootUri + 'd.ts', - range: { - start: { - line: 1, - character: 2 - }, - end: { - line: 1, - character: 8 - } - } - }, - symbol: { - filePath: 'd.ts', - containerName: 'd.I', - containerKind: '', - kind: 'property', - name: 'target' - } - }]); - }); - specify('in same file', async function (this: TestContext & ITestCallbackContext) { - const result: SymbolLocationInformation[] = await this.service.textDocumentXdefinition({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 29 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - location: { - uri: rootUri + 'a.ts', - range: { - start: { - line: 0, - character: 6 - }, - end: { - line: 0, - character: 9 - } - } - }, - symbol: { - filePath: 'a.ts', - containerName: '"a"', - containerKind: 'module', - kind: 'const', - name: 'abc' - } - }]); - }); - }); - describe('textDocumentHover()', function (this: TestContext & ISuiteCallbackContext) { - specify('in same file', async function (this: TestContext & ITestCallbackContext) { - const result: Hover = await this.service.textDocumentHover({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 29 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - range: { - start: { - line: 0, - character: 27 - }, - end: { - line: 0, - character: 30 - } - }, - contents: [ - { language: 'typescript', value: 'const abc: 1' }, - '**const**' - ] - }); - }); - specify('in other file', async function (this: TestContext & ITestCallbackContext) { - const result: Hover = await this.service.textDocumentHover({ - textDocument: { - uri: rootUri + 'foo/c.ts' - }, - position: { - line: 0, - character: 9 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - range: { - end: { - line: 0, - character: 11 - }, - start: { - line: 0, - character: 8 - } - }, - contents: [ - { language: 'typescript', value: 'import Foo' }, - '**alias**' - ] - }); - }); - specify('over keyword (non-null)', async function (this: TestContext & ITestCallbackContext) { - const result: Hover = await this.service.textDocumentHover({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 0 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { contents: [] }); - }); - specify('over non-existent file', function (this: TestContext & ITestCallbackContext) { - return assert.isRejected(this.service.textDocumentHover({ - textDocument: { - uri: rootUri + 'foo/a.ts' - }, - position: { - line: 0, - character: 0 - } - }).toPromise()); - }); - }); - }); - - describe('Workspace with typings directory', function (this: TestContext & ISuiteCallbackContext) { - - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'src/a.ts', "import * as m from 'dep';"], - [rootUri + 'typings/dep.d.ts', "declare module 'dep' {}"], - [rootUri + 'src/tsconfig.json', [ - '{', - ' "compilerOptions": {', - ' "target": "ES5",', - ' "module": "commonjs",', - ' "sourceMap": true,', - ' "noImplicitAny": false,', - ' "removeComments": false,', - ' "preserveConstEnums": true', - ' }', - '}' - ].join('\n')], - [rootUri + 'src/tsd.d.ts', '/// '], - [rootUri + 'src/dir/index.ts', 'import * as m from "dep";'] - ]))); - - afterEach(shutdownService); - - describe('textDocumentDefinition()', function (this: TestContext & ISuiteCallbackContext) { - specify('with tsd.d.ts', async function (this: TestContext & ITestCallbackContext) { - const result: Location[] = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'src/dir/index.ts' - }, - position: { - line: 0, - character: 20 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - uri: rootUri + 'typings/dep.d.ts', - range: { - start: { - line: 0, - character: 15 - }, - end: { - line: 0, - character: 20 - } - } - }]); - }); - describe('on file in project root', function (this: TestContext & ISuiteCallbackContext) { - specify('on import alias', async function (this: TestContext & ITestCallbackContext) { - const result: Location[] = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'src/a.ts' - }, - position: { - line: 0, - character: 12 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - uri: rootUri + 'typings/dep.d.ts', - range: { - start: { - line: 0, - character: 15 - }, - end: { - line: 0, - character: 20 - } - } - }]); - }); - specify('on module name', async function (this: TestContext & ITestCallbackContext) { - const result: Location[] = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'src/a.ts' - }, - position: { - line: 0, - character: 20 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - uri: rootUri + 'typings/dep.d.ts', - range: { - start: { - line: 0, - character: 15 - }, - end: { - line: 0, - character: 20 - } - } - }]); - }); - }); - }); - }); - - describe('DefinitelyTyped', function (this: TestContext & ISuiteCallbackContext) { - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'package.json', JSON.stringify({ - private: true, - name: 'definitely-typed', - version: '0.0.1', - homepage: 'https://github.com/DefinitelyTyped/DefinitelyTyped', - repository: { - type: 'git', - url: 'git+https://github.com/DefinitelyTyped/DefinitelyTyped.git' - }, - license: 'MIT', - bugs: { - url: 'https://github.com/DefinitelyTyped/DefinitelyTyped/issues' - }, - engines: { - node: '>= 6.9.1' - }, - scripts: { - 'compile-scripts': 'tsc -p scripts', - 'new-package': 'node scripts/new-package.js', - 'not-needed': 'node scripts/not-needed.js', - 'lint': 'node scripts/lint.js', - 'test': 'node node_modules/types-publisher/bin/tester/test.js --run-from-definitely-typed --nProcesses 1' - }, - devDependencies: { - 'types-publisher': 'Microsoft/types-publisher#production' - } - }, null, 4)], - [rootUri + 'types/resolve/index.d.ts', [ - '/// ', - '', - 'type resolveCallback = (err: Error, resolved?: string) => void;', - 'declare function resolve(id: string, cb: resolveCallback): void;', - '' - ].join('\n')], - [rootUri + 'types/resolve/tsconfig.json', JSON.stringify({ - compilerOptions: { - module: 'commonjs', - lib: [ - 'es6' - ], - noImplicitAny: true, - noImplicitThis: true, - strictNullChecks: false, - baseUrl: '../', - typeRoots: [ - '../' - ], - types: [], - noEmit: true, - forceConsistentCasingInFileNames: true - }, - files: [ - 'index.d.ts' - ] - })], - [rootUri + 'types/notResolve/index.d.ts', [ - '/// ', - '', - 'type resolveCallback = (err: Error, resolved?: string) => void;', - 'declare function resolve(id: string, cb: resolveCallback): void;', - '' - ].join('\n')], - [rootUri + 'types/notResolve/tsconfig.json', JSON.stringify({ - compilerOptions: { - module: 'commonjs', - lib: [ - 'es6' - ], - noImplicitAny: true, - noImplicitThis: true, - strictNullChecks: false, - baseUrl: '../', - typeRoots: [ - '../' - ], - types: [], - noEmit: true, - forceConsistentCasingInFileNames: true - }, - files: [ - 'index.d.ts' - ] - })] - ]))); - - afterEach(shutdownService); - - describe('workspaceSymbol()', function (this: TestContext & ISuiteCallbackContext) { - it('should find a symbol by SymbolDescriptor query with name and package name', async function (this: TestContext & ITestCallbackContext) { - const result: SymbolInformation[] = await this.service.workspaceSymbol({ - symbol: { name: 'resolveCallback', package: { name: '@types/resolve' } } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - kind: SymbolKind.Variable, - location: { - range: { - end: { - character: 63, - line: 2 - }, - start: { - character: 0, - line: 2 - } - }, - uri: rootUri + 'types/resolve/index.d.ts' - }, - name: 'resolveCallback' - }]); - }); - it('should find a symbol by SymbolDescriptor query with name, containerKind and package name', async function (this: TestContext & ITestCallbackContext) { - const result: SymbolInformation[] = await this.service.workspaceSymbol({ - symbol: { - name: 'resolveCallback', - containerKind: 'module', - package: { - name: '@types/resolve' - } - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result[0], { - kind: SymbolKind.Variable, - location: { - range: { - end: { - character: 63, - line: 2 - }, - start: { - character: 0, - line: 2 - } - }, - uri: rootUri + 'types/resolve/index.d.ts' - }, - name: 'resolveCallback' - }); - }); - }); - }); - - describe('Workspace with root package.json', function (this: TestContext & ISuiteCallbackContext) { - - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'a.ts', 'class a { foo() { const i = 1;} }'], - [rootUri + 'foo/b.ts', 'class b { bar: number; baz(): number { return this.bar;}}; function qux() {}'], - [rootUri + 'c.ts', 'import { x } from "dep/dep";'], - [rootUri + 'package.json', JSON.stringify({ name: 'mypkg' })], - [rootUri + 'node_modules/dep/dep.ts', 'export var x = 1;'], - [rootUri + 'node_modules/dep/package.json', JSON.stringify({ name: 'dep' })] - ]))); - - afterEach(shutdownService); - - describe('workspaceSymbol()', function (this: TestContext & ISuiteCallbackContext) { - describe('with SymbolDescriptor query', function (this: TestContext & ISuiteCallbackContext) { - it('should find a symbol by name, kind and package name', async function (this: TestContext & ITestCallbackContext) { - const result: SymbolInformation[] = await this.service.workspaceSymbol({ - symbol: { - name: 'a', - kind: 'class', - package: { - name: 'mypkg' - } - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result[0], { - kind: SymbolKind.Class, - location: { - range: { - end: { - character: 33, - line: 0 - }, - start: { - character: 0, - line: 0 - } - }, - uri: rootUri + 'a.ts' - }, - name: 'a' - }); - }); - it('should find a symbol by name, kind, package name and ignore package version', async function (this: TestContext & ITestCallbackContext) { - const result: SymbolInformation[] = await this.service.workspaceSymbol({ - symbol: { name: 'a', kind: 'class', package: { name: 'mypkg', version: '203940234' } } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result[0], { - kind: SymbolKind.Class, - location: { - range: { - end: { - character: 33, - line: 0 - }, - start: { - character: 0, - line: 0 - } - }, - uri: rootUri + 'a.ts' - }, - name: 'a' - }); - }); - it('should find a symbol by name', async function (this: TestContext & ITestCallbackContext) { - const result: SymbolInformation[] = await this.service.workspaceSymbol({ - symbol: { - name: 'a' - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - kind: SymbolKind.Class, - location: { - range: { - end: { - character: 33, - line: 0 - }, - start: { - character: 0, - line: 0 - } - }, - uri: rootUri + 'a.ts' - }, - name: 'a' - }]); - }); - it('should return no result if the PackageDescriptor does not match', async function (this: TestContext & ITestCallbackContext) { - const result: SymbolInformation[] = await this.service.workspaceSymbol({ - symbol: { - name: 'a', - kind: 'class', - package: { - name: 'not-mypkg' - } - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, []); - }); - }); - describe('with text query', function (this: TestContext & ISuiteCallbackContext) { - it('should find a symbol', async function (this: TestContext & ITestCallbackContext) { - const result: SymbolInformation[] = await this.service.workspaceSymbol({ query: 'a' }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - kind: SymbolKind.Class, - location: { - range: { - end: { - character: 33, - line: 0 - }, - start: { - character: 0, - line: 0 - } - }, - uri: rootUri + 'a.ts' - }, - name: 'a' - }]); - }); - it('should return all symbols for an empty query excluding dependencies', async function (this: TestContext & ITestCallbackContext) { - const result: SymbolInformation[] = await this.service.workspaceSymbol({ query: '' }) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, [ - { - name: 'a', - kind: SymbolKind.Class, - location: { - uri: rootUri + 'a.ts', - range: { - start: { - line: 0, - character: 0 - }, - end: { - line: 0, - character: 33 - } - } - } - }, - { - name: 'foo', - kind: SymbolKind.Method, - location: { - uri: rootUri + 'a.ts', - range: { - start: { - line: 0, - character: 10 - }, - end: { - line: 0, - character: 31 - } - } - }, - containerName: 'a' - }, - { - name: 'i', - kind: SymbolKind.Constant, - location: { - uri: rootUri + 'a.ts', - range: { - start: { - line: 0, - character: 24 - }, - end: { - line: 0, - character: 29 - } - } - }, - containerName: 'foo' - }, - { - name: '"c"', - kind: SymbolKind.Module, - location: { - uri: rootUri + 'c.ts', - range: { - start: { - line: 0, - character: 0 - }, - end: { - line: 0, - character: 28 - } - } - } - }, - { - name: 'x', - containerName: '"c"', - kind: SymbolKind.Variable, - location: { - uri: rootUri + 'c.ts', - range: { - start: { - line: 0, - character: 9 - }, - end: { - line: 0, - character: 10 - } - } - } - }, - { - name: 'b', - kind: SymbolKind.Class, - location: { - uri: rootUri + 'foo/b.ts', - range: { - start: { - line: 0, - character: 0 - }, - end: { - line: 0, - character: 57 - } - } - } - }, - { - name: 'bar', - kind: SymbolKind.Property, - location: { - uri: rootUri + 'foo/b.ts', - range: { - start: { - line: 0, - character: 10 - }, - end: { - line: 0, - character: 22 - } - } - }, - containerName: 'b' - }, - { - name: 'baz', - kind: SymbolKind.Method, - location: { - uri: rootUri + 'foo/b.ts', - range: { - start: { - line: 0, - character: 23 - }, - end: { - line: 0, - character: 56 - } - } - }, - containerName: 'b' - }, - { - name: 'qux', - kind: SymbolKind.Function, - location: { - uri: rootUri + 'foo/b.ts', - range: { - start: { - line: 0, - character: 59 - }, - end: { - line: 0, - character: 76 - } - } - } - } - ]); - }); - }); - }); - - describe('workspaceXreferences()', function (this: TestContext & ISuiteCallbackContext) { - it('should return all references to a method', async function (this: TestContext & ITestCallbackContext) { - const result: ReferenceInformation[] = await this.service.workspaceXreferences({ query: { name: 'foo', kind: 'method', containerName: 'a' } }) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, [{ - symbol: { - filePath: 'a.ts', - containerKind: '', - containerName: 'a', - name: 'foo', - kind: 'method' - }, - reference: { - range: { - end: { - character: 13, - line: 0 - }, - start: { - character: 9, - line: 0 - } - }, - uri: rootUri + 'a.ts' - } - }]); - }); - it('should return all references to a method with hinted dependee package name', async function (this: TestContext & ITestCallbackContext) { - const result: ReferenceInformation[] = await this.service.workspaceXreferences({ query: { name: 'foo', kind: 'method', containerName: 'a' }, hints: { dependeePackageName: 'mypkg' } }) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, [{ - symbol: { - filePath: 'a.ts', - containerKind: '', - containerName: 'a', - name: 'foo', - kind: 'method' - }, - reference: { - range: { - end: { - character: 13, - line: 0 - }, - start: { - character: 9, - line: 0 - } - }, - uri: rootUri + 'a.ts' - } - }]); - }); - it('should return no references to a method if hinted dependee package name was not found', async function (this: TestContext & ITestCallbackContext) { - const result = await this.service.workspaceXreferences({ query: { name: 'foo', kind: 'method', containerName: 'a' }, hints: { dependeePackageName: 'NOT-mypkg' } }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, []); - }); - it('should return all references to a symbol from a dependency', async function (this: TestContext & ITestCallbackContext) { - const result: ReferenceInformation[] = await this.service.workspaceXreferences({ query: { name: 'x' } }) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, [{ - reference: { - range: { - end: { - character: 10, - line: 0 - }, - start: { - character: 8, - line: 0 - } - }, - uri: rootUri + 'c.ts' - }, - symbol: { - filePath: 'dep/dep.ts', - containerKind: '', - containerName: '"dep/dep"', - kind: 'var', - name: 'x' - } - }]); - }); - it('should return all references to a symbol from a dependency with PackageDescriptor query', async function (this: TestContext & ITestCallbackContext) { - const result: ReferenceInformation[] = await this.service.workspaceXreferences({ query: { name: 'x', package: { name: 'dep' } } }) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, [{ - reference: { - range: { - end: { - character: 10, - line: 0 - }, - start: { - character: 8, - line: 0 - } - }, - uri: rootUri + 'c.ts' - }, - symbol: { - filePath: 'dep/dep.ts', - containerKind: '', - containerName: '"dep/dep"', - kind: 'var', - name: 'x', - package: { - name: 'dep', - repoURL: undefined, - version: undefined - } - } - }]); - }); - it('should return all references to all symbols if empty SymbolDescriptor query is passed', async function (this: TestContext & ITestCallbackContext) { - const result: ReferenceInformation[] = await this.service.workspaceXreferences({ query: {} }) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, [ - { - symbol: { - filePath: 'a.ts', - containerName: '"a"', - containerKind: 'module', - kind: 'class', - name: 'a' - }, - reference: { - range: { - end: { - character: 7, - line: 0 - }, - start: { - character: 5, - line: 0 - } - }, - uri: rootUri + 'a.ts' - } - }, - { - symbol: { - filePath: 'a.ts', - containerName: 'a', - containerKind: '', - name: 'foo', - kind: 'method' - }, - reference: { - range: { - end: { - character: 13, - line: 0 - }, - start: { - character: 9, - line: 0 - } - }, - uri: rootUri + 'a.ts' - } - }, - { - symbol: { - filePath: 'a.ts', - containerName: '"a"', - containerKind: 'module', - name: 'i', - kind: 'const' - }, - reference: { - range: { - end: { - character: 25, - line: 0 - }, - start: { - character: 23, - line: 0 - } - }, - uri: rootUri + 'a.ts' - } - }, - { - reference: { - range: { - end: { - character: 10, - line: 0 - }, - start: { - character: 8, - line: 0 - } - }, - uri: rootUri + 'c.ts' - }, - symbol: { - filePath: 'dep/dep.ts', - containerKind: '', - containerName: '"dep/dep"', - kind: 'var', - name: 'x' - } - }, - { - symbol: { - filePath: 'foo/b.ts', - containerName: '"foo/b"', - containerKind: 'module', - name: 'b', - kind: 'class' - }, - reference: { - range: { - end: { - character: 7, - line: 0 - }, - start: { - character: 5, - line: 0 - } - }, - uri: rootUri + 'foo/b.ts' - } - }, - { - symbol: { - filePath: 'foo/b.ts', - containerName: 'b', - containerKind: '', - name: 'bar', - kind: 'property' - }, - reference: { - range: { - end: { - character: 13, - line: 0 - }, - start: { - character: 9, - line: 0 - } - }, - uri: rootUri + 'foo/b.ts' - } - }, - { - symbol: { - filePath: 'foo/b.ts', - containerName: 'b', - containerKind: '', - name: 'baz', - kind: 'method' - }, - reference: { - range: { - end: { - character: 26, - line: 0 - }, - start: { - character: 22, - line: 0 - } - }, - uri: rootUri + 'foo/b.ts' - } - }, - { - symbol: { - filePath: 'foo/b.ts', - containerName: 'b', - containerKind: '', - name: 'bar', - kind: 'property' - }, - reference: { - range: { - end: { - character: 54, - line: 0 - }, - start: { - character: 51, - line: 0 - } - }, - uri: rootUri + 'foo/b.ts' - } - }, - { - symbol: { - filePath: 'foo/b.ts', - containerName: '"foo/b"', - containerKind: 'module', - name: 'qux', - kind: 'function' - }, - reference: { - range: { - end: { - character: 71, - line: 0 - }, - start: { - character: 67, - line: 0 - } - }, - uri: rootUri + 'foo/b.ts' - } - } - ]); - }); - }); - }); - - describe('Dependency detection', function (this: TestContext & ISuiteCallbackContext) { - - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'package.json', JSON.stringify({ - name: 'tslint', - version: '4.0.2', - dependencies: { - 'babel-code-frame': '^6.16.0', - 'findup-sync': '~0.3.0' - }, - devDependencies: { - '@types/babel-code-frame': '^6.16.0', - '@types/optimist': '0.0.29', - 'chai': '^3.0.0', - 'tslint': 'latest', - 'tslint-test-config-non-relative': 'file:test/external/tslint-test-config-non-relative', - 'typescript': '2.0.10' - }, - peerDependencies: { - typescript: '>=2.0.0' - } - })], - [rootUri + 'node_modules/dep/package.json', JSON.stringify({ - name: 'foo', - dependencies: { - shouldnotinclude: '0.0.0' - } - })], - [rootUri + 'subproject/package.json', JSON.stringify({ - name: 'subproject', - repository: { - url: 'https://github.com/my/subproject' - }, - dependencies: { - 'subproject-dep': '0.0.0' - } - })] - ]))); - - afterEach(shutdownService); - - describe('workspaceXdependencies()', function (this: TestContext & ISuiteCallbackContext) { - it('should account for all dependencies', async function (this: TestContext & ITestCallbackContext) { - const result: DependencyReference[] = await this.service.workspaceXdependencies() - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, [ - { attributes: { name: 'babel-code-frame', version: '^6.16.0' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'findup-sync', version: '~0.3.0' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: '@types/babel-code-frame', version: '^6.16.0' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: '@types/optimist', version: '0.0.29' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'chai', version: '^3.0.0' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'tslint', version: 'latest' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'tslint-test-config-non-relative', version: 'file:test/external/tslint-test-config-non-relative' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'typescript', version: '2.0.10' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'typescript', version: '>=2.0.0' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'subproject-dep', version: '0.0.0' }, hints: { dependeePackageName: 'subproject' } } - ]); - }); - }); - describe('workspaceXpackages()', function (this: TestContext & ISuiteCallbackContext) { - it('should accournt for all packages', async function (this: TestContext & ITestCallbackContext) { - const result: PackageInformation[] = await this.service.workspaceXpackages() - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, [{ - package: { - name: 'tslint', - version: '4.0.2', - repoURL: undefined - }, - dependencies: [ - { attributes: { name: 'babel-code-frame', version: '^6.16.0' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'findup-sync', version: '~0.3.0' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: '@types/babel-code-frame', version: '^6.16.0' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: '@types/optimist', version: '0.0.29' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'chai', version: '^3.0.0' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'tslint', version: 'latest' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'tslint-test-config-non-relative', version: 'file:test/external/tslint-test-config-non-relative' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'typescript', version: '2.0.10' }, hints: { dependeePackageName: 'tslint' } }, - { attributes: { name: 'typescript', version: '>=2.0.0' }, hints: { dependeePackageName: 'tslint' } } - ] - }, { - package: { - name: 'subproject', - version: undefined, - repoURL: 'https://github.com/my/subproject' - }, - dependencies: [ - { attributes: { name: 'subproject-dep', version: '0.0.0' }, hints: { dependeePackageName: 'subproject' } } - ] - }]); - }); - }); - }); - - describe('TypeScript library', function (this: TestContext & ISuiteCallbackContext) { - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'a.ts', 'let parameters = [];'] - ]))); - - afterEach(shutdownService); - - specify('type of parameters should be any[]', async function (this: TestContext & ITestCallbackContext) { - const result: Hover = await this.service.textDocumentHover({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 5 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - range: { - end: { - character: 14, - line: 0 - }, - start: { - character: 4, - line: 0 - } - }, - contents: [ - { language: 'typescript', value: 'let parameters: any[]' }, - '**let**' - ] - }); - }); - }); - - describe('Live updates', function (this: TestContext & ISuiteCallbackContext) { - - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'a.ts', 'let parameters = [];'] - ]))); - - afterEach(shutdownService); - - it('should handle didChange when configuration is not yet initialized', async function (this: TestContext & ITestCallbackContext) { - - const hoverParams = { - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 5 - } - }; - - const range = { - end: { - character: 14, - line: 0 - }, - start: { - character: 4, - line: 0 - } - }; - - await this.service.textDocumentDidChange({ - textDocument: { - uri: rootUri + 'a.ts', - version: 1 - }, - contentChanges: [{ - text: 'let parameters: number[]' - }] - }); - - const result: Hover = await this.service.textDocumentHover(hoverParams) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, { - range, - contents: [ - { language: 'typescript', value: 'let parameters: number[]' }, - '**let**' - ] - }); - }); - - it('should handle didClose when configuration is not yet initialized', async function (this: TestContext & ITestCallbackContext) { - - const hoverParams = { - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 5 - } - }; - - const range = { - end: { - character: 14, - line: 0 - }, - start: { - character: 4, - line: 0 - } - }; - - await this.service.textDocumentDidClose({ - textDocument: { - uri: rootUri + 'a.ts' - } - }); - - const result: Hover = await this.service.textDocumentHover(hoverParams) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, { - range, - contents: [ - { language: 'typescript', value: 'let parameters: any[]' }, - '**let**' - ] - }); - }); - - it('should reflect updated content', async function (this: TestContext & ITestCallbackContext) { - - const hoverParams = { - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 5 - } - }; - - const range = { - end: { - character: 14, - line: 0 - }, - start: { - character: 4, - line: 0 - } - }; - - { - const result: Hover = await this.service.textDocumentHover(hoverParams) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, { - range, - contents: [ - { language: 'typescript', value: 'let parameters: any[]' }, - '**let**' - ] - }); - } - - await this.service.textDocumentDidOpen({ - textDocument: { - uri: rootUri + 'a.ts', - languageId: 'typescript', - version: 1, - text: 'let parameters: string[]' - } - }); - - { - const result: Hover = await this.service.textDocumentHover(hoverParams) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, { - range, - contents: [ - { language: 'typescript', value: 'let parameters: string[]' }, - '**let**' - ] - }); - } - - await this.service.textDocumentDidChange({ - textDocument: { - uri: rootUri + 'a.ts', - version: 2 - }, - contentChanges: [{ - text: 'let parameters: number[]' - }] - }); - - { - const result: Hover = await this.service.textDocumentHover(hoverParams) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, { - range, - contents: [ - { language: 'typescript', value: 'let parameters: number[]' }, - '**let**' - ] - }); - } - - await this.service.textDocumentDidClose({ - textDocument: { - uri: rootUri + 'a.ts' - } - }); - - { - const result: Hover = await this.service.textDocumentHover(hoverParams) - .reduce(applyReducer, null as any) - .toPromise(); - assert.deepEqual(result, { - range, - contents: [ - { language: 'typescript', value: 'let parameters: any[]' }, - '**let**' - ] - }); - } - }); - }); - - describe('Diagnostics', function (this: TestContext & ISuiteCallbackContext) { - - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'src/errors.ts', 'const text: string = 33;'] - ]))); - - afterEach(shutdownService); - - it('should publish diagnostics on didOpen', async function (this: TestContext & ITestCallbackContext) { - - await this.service.textDocumentDidOpen({ - textDocument: { - uri: rootUri + 'src/errors.ts', - languageId: 'typescript', - text: 'const text: string = 33;', - version: 1 - } - }); - - sinon.assert.calledOnce(this.client.textDocumentPublishDiagnostics); - sinon.assert.calledWithExactly(this.client.textDocumentPublishDiagnostics, { - diagnostics: [{ - message: "Type '33' is not assignable to type 'string'.", - range: { end: { character: 10, line: 0 }, start: { character: 6, line: 0 } }, - severity: 1, - source: 'ts', - code: 2322 - }], - uri: rootUri + 'src/errors.ts' - }); - }); - - it('should publish diagnostics on didChange', async function (this: TestContext & ITestCallbackContext) { - - await this.service.textDocumentDidOpen({ - textDocument: { - uri: rootUri + 'src/errors.ts', - languageId: 'typescript', - text: 'const text: string = 33;', - version: 1 - } - }); - - this.client.textDocumentPublishDiagnostics.resetHistory(); - - await this.service.textDocumentDidChange({ - textDocument: { - uri: rootUri + 'src/errors.ts', - version: 2 - }, - contentChanges: [ - { text: 'const text: boolean = 33;' } - ] - }); - - sinon.assert.calledOnce(this.client.textDocumentPublishDiagnostics); - sinon.assert.calledWithExactly(this.client.textDocumentPublishDiagnostics, { - diagnostics: [{ - message: "Type '33' is not assignable to type 'boolean'.", - range: { end: { character: 10, line: 0 }, start: { character: 6, line: 0 } }, - severity: 1, - source: 'ts', - code: 2322 - }], - uri: rootUri + 'src/errors.ts' - }); - }); - - it('should publish empty diagnostics on didChange if error was fixed', async function (this: TestContext & ITestCallbackContext) { - - await this.service.textDocumentDidOpen({ - textDocument: { - uri: rootUri + 'src/errors.ts', - languageId: 'typescript', - text: 'const text: string = 33;', - version: 1 - } - }); - - this.client.textDocumentPublishDiagnostics.resetHistory(); - - await this.service.textDocumentDidChange({ - textDocument: { - uri: rootUri + 'src/errors.ts', - version: 2 - }, - contentChanges: [ - { text: 'const text: number = 33;' } - ] - }); - - sinon.assert.calledOnce(this.client.textDocumentPublishDiagnostics); - sinon.assert.calledWithExactly(this.client.textDocumentPublishDiagnostics, { - diagnostics: [], - uri: rootUri + 'src/errors.ts' - }); - }); - - it('should clear diagnostics on didClose', async function (this: TestContext & ITestCallbackContext) { - - await this.service.textDocumentDidClose({ - textDocument: { - uri: rootUri + 'src/errors.ts' - } - }); - - sinon.assert.calledOnce(this.client.textDocumentPublishDiagnostics); - sinon.assert.calledWithExactly(this.client.textDocumentPublishDiagnostics, { - diagnostics: [], - uri: rootUri + 'src/errors.ts' - }); - }); - - }); - - describe('References and imports', function (this: TestContext & ISuiteCallbackContext) { - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'a.ts', '/// \nnamespace qux {let f : foo;}'], - [rootUri + 'b.ts', '/// '], - [rootUri + 'c.ts', 'import * as d from "./foo/d"\nd.bar()'], - [rootUri + 'foo/c.ts', 'namespace qux {export interface foo {}}'], - [rootUri + 'foo/d.ts', 'export function bar() {}'], - [rootUri + 'deeprefs/a.ts', '/// \nnamespace qux {\nlet f : foo;\n}'], - [rootUri + 'deeprefs/b.ts', '/// '], - [rootUri + 'deeprefs/c.ts', '/// '], - [rootUri + 'deeprefs/d.ts', '/// '], - [rootUri + 'deeprefs/e.ts', 'namespace qux {\nexport interface foo {}\n}'], - [rootUri + 'missing/a.ts', '/// \n/// \nnamespace t {\n function foo() : Bar {\n return null;\n }\n}'], - [rootUri + 'missing/b.ts', 'namespace t {\n export interface Bar {\n id?: number;\n }}'] - ]))); - - afterEach(shutdownService); - - describe('textDocumentDefinition()', function (this: TestContext & ISuiteCallbackContext) { - it('should resolve symbol imported with tripe-slash reference', async function (this: TestContext & ITestCallbackContext) { - const result: Location[] = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 1, - character: 23 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - // Note: technically this list should also - // include the 2nd definition of `foo` in - // deeprefs/e.ts, but there's no easy way to - // discover it through file-level imports and - // it is rare enough that we accept this - // omission. (It would probably show up in the - // definition response if the user has already - // navigated to deeprefs/e.ts.) - uri: rootUri + 'foo/c.ts', - range: { - start: { - line: 0, - character: 32 - }, - end: { - line: 0, - character: 35 - } - } - }]); - }); - it('should resolve symbol imported with import statement', async function (this: TestContext & ITestCallbackContext) { - const result: Location[] = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'c.ts' - }, - position: { - line: 1, - character: 2 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - uri: rootUri + 'foo/d.ts', - range: { - start: { - line: 0, - character: 16 - }, - end: { - line: 0, - character: 19 - } - } - }]); - }); - it('should resolve definition with missing reference', async function (this: TestContext & ITestCallbackContext) { - const result: Location[] = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'missing/a.ts' - }, - position: { - line: 3, - character: 21 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - uri: rootUri + 'missing/b.ts', - range: { - start: { - line: 1, - character: 21 - }, - end: { - line: 1, - character: 24 - } - } - }]); - }); - it('should resolve deep definitions', async function (this: TestContext & ITestCallbackContext) { - // This test passes only because we expect no response from LSP server - // for definition located in file references with depth 3 or more (a -> b -> c -> d (...)) - // This test will fail once we'll increase (or remove) depth limit - const result: Location[] = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'deeprefs/a.ts' - }, - position: { - line: 2, - character: 8 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - uri: rootUri + 'deeprefs/e.ts', - range: { - start: { - line: 1, - character: 17 - }, - end: { - line: 1, - character: 20 - } - } - }]); - }); - }); - }); - - describe('TypeScript libraries', function (this: TestContext & ISuiteCallbackContext) { - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'tsconfig.json', JSON.stringify({ - compilerOptions: { - lib: ['es2016', 'dom'] - } - })], - [rootUri + 'a.ts', 'function foo(n: Node): {console.log(n.parentNode, NaN})}'] - ]))); - - afterEach(shutdownService); - - describe('textDocumentHover()', function (this: TestContext & ISuiteCallbackContext) { - it('should load local library file', async function (this: TestContext & ITestCallbackContext) { - const result: Hover = await this.service.textDocumentHover({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 16 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - range: { - end: { - character: 20, - line: 0 - }, - start: { - character: 16, - line: 0 - } - }, - contents: [ - { - language: 'typescript', - value: [ - 'interface Node', - 'var Node: {', - ' new (): Node;', - ' prototype: Node;', - ' readonly ATTRIBUTE_NODE: number;', - ' readonly CDATA_SECTION_NODE: number;', - ' readonly COMMENT_NODE: number;', - ' readonly DOCUMENT_FRAGMENT_NODE: number;', - ' readonly DOCUMENT_NODE: number;', - ' readonly DOCUMENT_POSITION_CONTAINED_BY: number;', - ' readonly DOCUMENT_POSITION_CONTAINS: number;', - ' readonly DOCUMENT_POSITION_DISCONNECTED: number;', - ' readonly DOCUMENT_POSITION_FOLLOWING: number;', - ' readonly DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: number;', - ' readonly DOCUMENT_POSITION_PRECEDING: number;', - ' readonly DOCUMENT_TYPE_NODE: number;', - ' readonly ELEMENT_NODE: number;', - ' readonly ENTITY_NODE: number;', - ' readonly ENTITY_REFERENCE_NODE: number;', - ' readonly NOTATION_NODE: number;', - ' readonly PROCESSING_INSTRUCTION_NODE: number;', - ' readonly TEXT_NODE: number;', - '}' - ].join('\n') - }, - '**var** _(ambient)_' - ] - }); - }); - }); - describe('textDocumentDefinition()', function (this: TestContext & ISuiteCallbackContext) { - it('should resolve TS libraries to github URL', async function (this: TestContext & ITestCallbackContext) { - assert.deepEqual(await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 16 - } - }).reduce(applyReducer, null as any).toPromise(), [{ - uri: 'git://github.com/Microsoft/TypeScript?v' + ts.version + '#lib/lib.dom.d.ts', - range: { - start: { - line: 8259, - character: 10 - }, - end: { - line: 8259, - character: 14 - } - } - }, { - uri: 'git://github.com/Microsoft/TypeScript?v' + ts.version + '#lib/lib.dom.d.ts', - range: { - start: { - line: 8311, - character: 12 - }, - end: { - line: 8311, - character: 16 - } - } - }]); - - assert.deepEqual(await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 50 - } - }).reduce(applyReducer, null as any).toPromise(), [{ - uri: 'git://github.com/Microsoft/TypeScript?v' + ts.version + '#lib/lib.es5.d.ts', - range: { - start: { - line: 24, - character: 14 - }, - end: { - line: 24, - character: 17 - } - } - }]); - }); - }); - }); - - describe('textDocumentReferences()', function (this: TestContext & ISuiteCallbackContext) { - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'a.ts', [ - 'class A {', - ' /** foo doc*/', - ' foo() {}', - ' /** bar doc*/', - ' bar(): number { return 1; }', - ' /** ', - ' * The Baz function', - ' * @param num Number parameter', - ' * @param text Text parameter', - ' */', - ' baz(num: number, text: string): string { return ""; }', - ' /** qux doc*/', - ' qux: number;', - '}', - 'const a = new A();', - 'a.baz(32, sd)' - ].join('\n')], - [rootUri + 'uses-import.ts', [ - 'import * as i from "./import"', - 'i.d()' - ].join('\n')], - [rootUri + 'also-uses-import.ts', [ - 'import {d} from "./import"', - 'd()' - ].join('\n')], - [rootUri + 'import.ts', '/** d doc*/ export function d() {}'] - ]))); - - afterEach(shutdownService); - - it('should provide an empty response when no reference is found', async function (this: TestContext & ITestCallbackContext) { - const result = await this.service.textDocumentReferences({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 0 - }, - context: { includeDeclaration: false } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, []); - }); - - it('should include the declaration if requested', async function (this: TestContext & ITestCallbackContext) { - const result = await this.service.textDocumentReferences({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 4, - character: 5 - }, - context: { includeDeclaration: true } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - range: { - end: { - character: 7, - line: 4 - }, - start: { - character: 4, - line: 4 - } - }, - uri: rootUri + 'a.ts' - }]); - }); - - it('should provide a reference within the same file', async function (this: TestContext & ITestCallbackContext) { - const result = await this.service.textDocumentReferences({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 10, - character: 5 - }, - context: { includeDeclaration: false } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - range: { - end: { - character: 5, - line: 15 - }, - start: { - character: 2, - line: 15 - } - }, - uri: rootUri + 'a.ts' - }]); - }); - it('should provide two references from imports', async function (this: TestContext & ITestCallbackContext) { - const result = await this.service.textDocumentReferences({ - textDocument: { - uri: rootUri + 'import.ts' - }, - position: { - line: 0, - character: 28 - }, - context: { includeDeclaration: false } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [ - { - range: { - end: { - character: 3, - line: 1 - }, - start: { - character: 2, - line: 1 - } - }, - uri: rootUri + 'uses-import.ts' - }, - { - range: { - end: { - character: 1, - line: 1 - }, - start: { - character: 0, - line: 1 - } - }, - uri: rootUri + 'also-uses-import.ts' - } - ]); - }); - }); - - describe('textDocumentSignatureHelp()', function (this: TestContext & ISuiteCallbackContext) { - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'a.ts', [ - 'class A {', - ' /** foo doc*/', - ' foo() {}', - ' /** bar doc*/', - ' bar(): number { return 1; }', - ' /** ', - ' * The Baz function', - ' * @param num Number parameter', - ' * @param text Text parameter', - ' */', - ' baz(num: number, text: string): string { return ""; }', - ' /** qux doc*/', - ' qux: number;', - '}', - 'const a = new A();', - 'a.baz(32, sd)' - ].join('\n')], - [rootUri + 'uses-import.ts', [ - 'import * as i from "./import"', - 'i.d()' - ].join('\n')], - [rootUri + 'import.ts', '/** d doc*/ export function d() {}'], - [rootUri + 'uses-reference.ts', [ - '/// ', - 'let z : foo.' - ].join('\n')], - [rootUri + 'reference.ts', [ - 'namespace foo {', - ' /** bar doc*/', - ' export interface bar {}', - '}' - ].join('\n')], - [rootUri + 'empty.ts', ''] - ]))); - - afterEach(shutdownService); - - it('should provide a valid empty response when no signature is found', async function (this: TestContext & ITestCallbackContext) { - const result: SignatureHelp = await this.service.textDocumentSignatureHelp({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 0 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - signatures: [], - activeSignature: 0, - activeParameter: 0 - }); - }); - - it('should provide signature help with parameters in the same file', async function (this: TestContext & ITestCallbackContext) { - const result: SignatureHelp = await this.service.textDocumentSignatureHelp({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 15, - character: 11 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - signatures: [ - { - label: 'baz(num: number, text: string): string', - documentation: 'The Baz function', - parameters: [{ - label: 'num: number', - documentation: 'Number parameter' - }, { - label: 'text: string', - documentation: 'Text parameter' - }] - } - ], - activeSignature: 0, - activeParameter: 1 - }); - }); - - it('should provide signature help from imported symbols', async function (this: TestContext & ITestCallbackContext) { - const result: SignatureHelp = await this.service.textDocumentSignatureHelp({ - textDocument: { - uri: rootUri + 'uses-import.ts' - }, - position: { - line: 1, - character: 4 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - activeSignature: 0, - activeParameter: 0, - signatures: [{ - label: 'd(): void', - documentation: 'd doc', - parameters: [] - }] - }); - }); - - }); - - describe('textDocumentCompletion() with snippets', function (this: TestContext & ISuiteCallbackContext){ - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'a.ts', [ - 'class A {', - ' /** foo doc*/', - ' foo() {}', - ' /** bar doc*/', - ' bar(num: number): number { return 1; }', - ' /** baz doc*/', - ' baz(num: number): string { return ""; }', - ' /** qux doc*/', - ' qux: number;', - '}', - 'const a = new A();', - 'a.' - ].join('\n')] - ]), { - textDocument: { - completion: { - completionItem: { - snippetSupport: true - } - } - }, - ...DEFAULT_CAPABILITIES - })); - - afterEach(shutdownService); - - it('should produce completions', async function (this: TestContext & ITestCallbackContext) { - const result: CompletionList = await this.service.textDocumentCompletion({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 11, - character: 2 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.equal(result.isIncomplete, false); - assert.sameDeepMembers(result.items, [ - { - label: 'bar', - kind: CompletionItemKind.Method, - sortText: '0', - data: { - entryName: 'bar', - offset: 210, - uri: rootUri + 'a.ts' - } - }, - { - label: 'baz', - kind: CompletionItemKind.Method, - sortText: '0', - data: { - entryName: 'baz', - offset: 210, - uri: rootUri + 'a.ts' - } - }, - { - label: 'foo', - kind: CompletionItemKind.Method, - sortText: '0', - data: { - entryName: 'foo', - offset: 210, - uri: rootUri + 'a.ts' - } - }, - { - label: 'qux', - kind: CompletionItemKind.Property, - sortText: '0', - data: { - entryName: 'qux', - offset: 210, - uri: rootUri + 'a.ts' - } - } - ]); - }); - - it('should resolve completions with snippets', async function (this: TestContext & ITestCallbackContext) { - const result: CompletionList = await this.service.textDocumentCompletion({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 11, - character: 2 - } - }).reduce(applyReducer, null as any).toPromise(); - // * A snippet can define tab stops and placeholders with `$1`, `$2` - // * and `${3:foo}`. `$0` defines the final tab stop, it defaults to - // * the end of the snippet. Placeholders with equal identifiers are linked, - // * that is typing in one will update others too. - assert.equal(result.isIncomplete, false); - - const resolvedItems = await Observable.from(result.items) - .mergeMap(item => this.service - .completionItemResolve(item) - .reduce(applyReducer, null as any) - ) - .toArray() - .toPromise(); - - assert.sameDeepMembers(resolvedItems, [ - { - label: 'bar', - kind: CompletionItemKind.Method, - documentation: 'bar doc', - sortText: '0', - insertTextFormat: InsertTextFormat.Snippet, - insertText: 'bar(${1:num})', - detail: '(method) A.bar(num: number): number', - data: undefined - }, - { - label: 'baz', - kind: CompletionItemKind.Method, - documentation: 'baz doc', - sortText: '0', - insertTextFormat: InsertTextFormat.Snippet, - insertText: 'baz(${1:num})', - detail: '(method) A.baz(num: number): string', - data: undefined - }, - { - label: 'foo', - kind: CompletionItemKind.Method, - documentation: 'foo doc', - sortText: '0', - insertTextFormat: InsertTextFormat.Snippet, - insertText: 'foo()', - detail: '(method) A.foo(): void', - data: undefined - }, - { - label: 'qux', - kind: CompletionItemKind.Property, - documentation: 'qux doc', - sortText: '0', - insertTextFormat: InsertTextFormat.Snippet, - insertText: 'qux', - detail: '(property) A.qux: number', - data: undefined - } - ]); - - }); - }); - - describe('textDocumentCompletion()', function (this: TestContext & ISuiteCallbackContext) { - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'a.ts', [ - 'class A {', - ' /** foo doc*/', - ' foo() {}', - ' /** bar doc*/', - ' bar(): number { return 1; }', - ' /** baz doc*/', - ' baz(): string { return ""; }', - ' /** qux doc*/', - ' qux: number;', - '}', - 'const a = new A();', - 'a.' - ].join('\n')], - [rootUri + 'uses-import.ts', [ - 'import * as i from "./import"', - 'i.' - ].join('\n')], - [rootUri + 'import.ts', '/** d doc*/ export function d() {}'], - [rootUri + 'uses-reference.ts', [ - '/// ', - 'let z : foo.' - ].join('\n')], - [rootUri + 'reference.ts', [ - 'namespace foo {', - ' /** bar doc*/', - ' export interface bar {}', - '}' - ].join('\n')], - [rootUri + 'empty.ts', ''] - ]))); - - afterEach(shutdownService); - - it('produces completions in the same file', async function (this: TestContext & ITestCallbackContext) { - const result: CompletionList = await this.service.textDocumentCompletion({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 11, - character: 2 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.equal(result.isIncomplete, false); - assert.sameDeepMembers(result.items, [ - { - data: { - entryName: 'bar', - offset: 188, - uri: rootUri + 'a.ts' - }, - label: 'bar', - kind: CompletionItemKind.Method, - sortText: '0' - }, - { - data: { - entryName: 'baz', - offset: 188, - uri: rootUri + 'a.ts' - }, - label: 'baz', - kind: CompletionItemKind.Method, - sortText: '0' - }, - { - data: { - entryName: 'foo', - offset: 188, - uri: rootUri + 'a.ts' - }, - label: 'foo', - kind: CompletionItemKind.Method, - sortText: '0' - }, - { - data: { - entryName: 'qux', - offset: 188, - uri: rootUri + 'a.ts' - }, - label: 'qux', - kind: CompletionItemKind.Property, - sortText: '0' - } - ]); - }); - - it('resolves completions in the same file', async function (this: TestContext & ITestCallbackContext) { - const result: CompletionList = await this.service.textDocumentCompletion({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 11, - character: 2 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.equal(result.isIncomplete, false); - - const resolveItem = (item: CompletionItem) => this.service - .completionItemResolve(item) - .reduce(applyReducer, null as any).toPromise(); - - const resolvedItems = await Promise.all(result.items.map(resolveItem)); - - assert.sameDeepMembers(resolvedItems, [ - { - label: 'bar', - kind: CompletionItemKind.Method, - documentation: 'bar doc', - insertText: 'bar', - insertTextFormat: InsertTextFormat.PlainText, - sortText: '0', - detail: '(method) A.bar(): number', - data: undefined - }, - { - label: 'baz', - kind: CompletionItemKind.Method, - documentation: 'baz doc', - insertText: 'baz', - insertTextFormat: InsertTextFormat.PlainText, - sortText: '0', - detail: '(method) A.baz(): string', - data: undefined - }, - { - label: 'foo', - kind: CompletionItemKind.Method, - documentation: 'foo doc', - insertText: 'foo', - insertTextFormat: InsertTextFormat.PlainText, - sortText: '0', - detail: '(method) A.foo(): void', - data: undefined - }, - { - label: 'qux', - kind: CompletionItemKind.Property, - documentation: 'qux doc', - insertText: 'qux', - insertTextFormat: InsertTextFormat.PlainText, - sortText: '0', - detail: '(property) A.qux: number', - data: undefined - } - ]); - - }); - - it('produces completions for imported symbols', async function (this: TestContext & ITestCallbackContext) { - const result: CompletionList = await this.service.textDocumentCompletion({ - textDocument: { - uri: rootUri + 'uses-import.ts' - }, - position: { - line: 1, - character: 2 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - isIncomplete: false, - items: [{ - data: { - entryName: 'd', - offset: 32, - uri: rootUri + 'uses-import.ts' - }, - label: 'd', - kind: CompletionItemKind.Function, - sortText: '0' - }] - }); - }); - it('produces completions for referenced symbols', async function (this: TestContext & ITestCallbackContext) { - const result: CompletionList = await this.service.textDocumentCompletion({ - textDocument: { - uri: rootUri + 'uses-reference.ts' - }, - position: { - line: 1, - character: 13 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - isIncomplete: false, - items: [{ - data: { - entryName: 'bar', - offset: 51, - uri: rootUri + 'uses-reference.ts' - }, - label: 'bar', - kind: CompletionItemKind.Interface, - sortText: '0' - }] - }); - }); - it('produces completions for empty files', async function (this: TestContext & ITestCallbackContext) { - this.timeout(10000); - const result: CompletionList = await this.service.textDocumentCompletion({ - textDocument: { - uri: rootUri + 'empty.ts' - }, - position: { - line: 0, - character: 0 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.notDeepEqual(result.items, []); - }); - }); - - describe('textDocumentRename()', function (this: TestContext & ISuiteCallbackContext) { - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'package.json', JSON.stringify({ name: 'mypkg' })], - [rootUri + 'a.ts', [ - 'class A {', - ' /** foo doc*/', - ' foo() {}', - ' /** bar doc*/', - ' bar(): number { return 1; }', - ' /** baz doc*/', - ' baz(): string { return ""; }', - ' /** qux doc*/', - ' qux: number;', - '}', - 'const a = new A();', - 'a.' - ].join('\n')], - [rootUri + 'uses-import.ts', [ - 'import {d} from "./import"', - 'const x = d();' - ].join('\n')], - [rootUri + 'import.ts', 'export function d(): number { return 55; }'] - ]))); - - afterEach(shutdownService); - - it('should error on an invalid symbol', async function (this: TestContext & ITestCallbackContext) { - await assert.isRejected( - this.service.textDocumentRename({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 1 - }, - newName: 'asdf' - }).reduce(applyReducer, null as any).toPromise(), - 'This symbol cannot be renamed' - ); - }); - it('should return a correct WorkspaceEdit to rename a class', async function (this: TestContext & ITestCallbackContext) { - const result: WorkspaceEdit = await this.service.textDocumentRename({ - textDocument: { - uri: rootUri + 'a.ts' - }, - position: { - line: 0, - character: 6 - }, - newName: 'B' - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - changes: { - [rootUri + 'a.ts']: [{ - newText: 'B', - range: { - end: { - character: 7, - line: 0 - }, - start: { - character: 6, - line: 0 - } - } - }, { - newText: 'B', - range: { - end: { - character: 15, - line: 10 - }, - start: { - character: 14, - line: 10 - } - } - }] - } - }); - }); - it('should return a correct WorkspaceEdit to rename an imported function', async function (this: TestContext & ITestCallbackContext) { - const result: WorkspaceEdit = await this.service.textDocumentRename({ - textDocument: { - uri: rootUri + 'import.ts' - }, - position: { - line: 0, - character: 16 - }, - newName: 'f' - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - changes: { - [rootUri + 'import.ts']: [{ - newText: 'f', - range: { - end: { - character: 17, - line: 0 - }, - start: { - character: 16, - line: 0 - } - } - }], - [rootUri + 'uses-import.ts']: [{ - newText: 'f', - range: { - end: { - character: 9, - line: 0 - }, - start: { - character: 8, - line: 0 - } - } - }, { - newText: 'f', - range: { - end: { - character: 11, - line: 1 - }, - start: { - character: 10, - line: 1 - } - } - }] - } - }); - }); - }); - - describe('textDocumentCodeAction()', function (this: TestContext & ISuiteCallbackContext) { - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'package.json', JSON.stringify({ name: 'mypkg' })], - [rootUri + 'a.ts', [ - 'class A {', - '\tconstructor() {', - '\t\tmissingThis = 33;', - '\t}', - '}', - 'const a = new A();' - ].join('\n')] - ]))); - - afterEach(shutdownService); - - it('suggests a missing this', async function (this: TestContext & ITestCallbackContext) { - await this.service.textDocumentDidOpen({ - textDocument: { - uri: rootUri + 'a.ts', - languageId: 'typescript', - text: [ - 'class A {', - '\tmissingThis: number;', - '\tconstructor() {', - '\t\tmissingThis = 33;', - '\t}', - '}', - 'const a = new A();' - ].join('\n'), - version: 1 - } - }); - - const firstDiagnostic: Diagnostic = { - range: { - start: { line: 3, character: 4 }, - end: { line: 3, character: 15 } - }, - message: 'Cannot find name \'missingThis\'. Did you mean the instance member \'this.missingThis\'?', - severity: DiagnosticSeverity.Error, - code: 2663, - source: 'ts' - }; - const actions: Command[] = await this.service.textDocumentCodeAction({ - textDocument: { - uri: rootUri + 'a.ts' - }, - range: firstDiagnostic.range, - context: { - diagnostics: [firstDiagnostic] - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(actions, [{ - title: 'Add \'this.\' to unresolved variable.', - command: 'codeFix', - arguments: [{ - fileName: toUnixPath(uri2path(rootUri + 'a.ts')), // path only used by TS service - textChanges: [{ - span: { start: 49, length: 13 }, - newText: '\t\tthis.missingThis' - }] - }] - }]); - - }); - }); - - describe('workspaceExecuteCommand()', function (this: TestContext & ISuiteCallbackContext) { - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'package.json', JSON.stringify({ name: 'mypkg' })], - [rootUri + 'a.ts', [ - 'class A {', - ' constructor() {', - ' missingThis = 33;', - ' }', - '}', - 'const a = new A();' - ].join('\n')] - ]))); - - afterEach(shutdownService); - - describe('codeFix', () => { - it('should apply a WorkspaceEdit for the passed FileTextChanges', async function (this: TestContext & ITestCallbackContext) { - await this.service.workspaceExecuteCommand({ - command: 'codeFix', - arguments: [{ - fileName: uri2path(rootUri + 'a.ts'), - textChanges: [{ - span: { start: 50, length: 15 }, - newText: '\t\tthis.missingThis' - }] - }] - }).reduce(applyReducer, null as any).toPromise(); - - sinon.assert.calledOnce(this.client.workspaceApplyEdit); - const workspaceEdit = this.client.workspaceApplyEdit.lastCall.args[0]; - assert.deepEqual(workspaceEdit, { - edit: { - changes: { - [rootUri + 'a.ts']: [{ - newText: '\t\tthis.missingThis', - range: { - end: { - character: 9, - line: 5 - }, - start: { - character: 0, - line: 3 - } - } - }] - } - } - }); - }); - }); - }); - - describe('Special file names', function (this: TestContext & ISuiteCallbackContext) { - - beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ - [rootUri + 'keywords-in-path/class/constructor/a.ts', 'export function a() {}'], - [rootUri + 'special-characters-in-path/%40foo/b.ts', 'export function b() {}'], - [rootUri + 'windows/app/master.ts', '/// \nc();'], - [rootUri + 'windows/lib/master.ts', '/// '], - [rootUri + 'windows/lib/slave.ts', 'function c() {}'] - ]))); - - afterEach(shutdownService); - - it('should accept files with TypeScript keywords in path', async function (this: TestContext & ITestCallbackContext) { - const result: Hover = await this.service.textDocumentHover({ - textDocument: { - uri: rootUri + 'keywords-in-path/class/constructor/a.ts' - }, - position: { - line: 0, - character: 16 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - range: { - start: { - line: 0, - character: 16 - }, - end: { - line: 0, - character: 17 - } - }, - contents: [ - { language: 'typescript', value: 'function a(): void' }, - '**function** _(exported)_' - ] - }); - }); - it('should accept files with special characters in path', async function (this: TestContext & ITestCallbackContext) { - const result: Hover = await this.service.textDocumentHover({ - textDocument: { - uri: rootUri + 'special-characters-in-path/%40foo/b.ts' - }, - position: { - line: 0, - character: 16 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, { - range: { - start: { - line: 0, - character: 16 - }, - end: { - line: 0, - character: 17 - } - }, - contents: [ - { language: 'typescript', value: 'function b(): void' }, - '**function** _(exported)_' - ] - }); - }); - it('should handle Windows-style paths in triple slash references', async function (this: TestContext & ITestCallbackContext) { - const result = await this.service.textDocumentDefinition({ - textDocument: { - uri: rootUri + 'windows/app/master.ts' - }, - position: { - line: 1, - character: 0 - } - }).reduce(applyReducer, null as any).toPromise(); - assert.deepEqual(result, [{ - range: { - start: { - line: 0, - character: 9 - }, - end: { - line: 0, - character: 10 - } - }, - uri: rootUri + 'windows/lib/slave.ts' - }]); - }); - }); + describe('Workspace without project files', function(this: TestContext & ISuiteCallbackContext): void { + + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'a.ts', 'const abc = 1; console.log(abc);'], + [rootUri + 'foo/b.ts', [ + '/* This is class Foo */', + 'export class Foo {}' + ].join('\n')], + [rootUri + 'foo/c.ts', 'import {Foo} from "./b";'], + [rootUri + 'd.ts', [ + 'export interface I {', + ' target: string;', + '}' + ].join('\n')], + [rootUri + 'e.ts', [ + 'import * as d from "./d";', + '', + 'let i: d.I = { target: "hi" };', + 'let target = i.target;' + ].join('\n')] + ]))) + + afterEach(shutdownService) + + describe('textDocumentDefinition()', function(this: TestContext & ISuiteCallbackContext): void { + specify('in same file', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Location[] = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 29 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + uri: rootUri + 'a.ts', + range: { + start: { + line: 0, + character: 6 + }, + end: { + line: 0, + character: 9 + } + } + }]) + }) + specify('on keyword (non-null)', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Location[] = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 0 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, []) + }) + specify('in other file', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Location[] = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'foo/c.ts' + }, + position: { + line: 0, + character: 9 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + uri: rootUri + 'foo/b.ts', + range: { + start: { + line: 1, + character: 13 + }, + end: { + line: 1, + character: 16 + } + } + }]) + }) + }) + describe('textDocumentXdefinition()', function(this: TestContext & ISuiteCallbackContext): void { + specify('on interface field reference', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SymbolLocationInformation[] = await this.service.textDocumentXdefinition({ + textDocument: { + uri: rootUri + 'e.ts' + }, + position: { + line: 3, + character: 15 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + location: { + uri: rootUri + 'd.ts', + range: { + start: { + line: 1, + character: 2 + }, + end: { + line: 1, + character: 8 + } + } + }, + symbol: { + filePath: 'd.ts', + containerName: 'd.I', + containerKind: '', + kind: 'property', + name: 'target' + } + }]) + }) + specify('in same file', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SymbolLocationInformation[] = await this.service.textDocumentXdefinition({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 29 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + location: { + uri: rootUri + 'a.ts', + range: { + start: { + line: 0, + character: 6 + }, + end: { + line: 0, + character: 9 + } + } + }, + symbol: { + filePath: 'a.ts', + containerName: '"a"', + containerKind: 'module', + kind: 'const', + name: 'abc' + } + }]) + }) + }) + describe('textDocumentHover()', function(this: TestContext & ISuiteCallbackContext): void { + specify('in same file', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Hover = await this.service.textDocumentHover({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 29 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + range: { + start: { + line: 0, + character: 27 + }, + end: { + line: 0, + character: 30 + } + }, + contents: [ + { language: 'typescript', value: 'const abc: 1' }, + '**const**' + ] + }) + }) + specify('in other file', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Hover = await this.service.textDocumentHover({ + textDocument: { + uri: rootUri + 'foo/c.ts' + }, + position: { + line: 0, + character: 9 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + range: { + end: { + line: 0, + character: 11 + }, + start: { + line: 0, + character: 8 + } + }, + contents: [ + { language: 'typescript', value: 'import Foo' }, + '**alias**' + ] + }) + }) + specify('over keyword (non-null)', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Hover = await this.service.textDocumentHover({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 0 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { contents: [] }) + }) + specify('over non-existent file', async function(this: TestContext & ITestCallbackContext): Promise { + await Promise.resolve(assert.isRejected(this.service.textDocumentHover({ + textDocument: { + uri: rootUri + 'foo/a.ts' + }, + position: { + line: 0, + character: 0 + } + }).toPromise())) + }) + }) + }) + + describe('Workspace with typings directory', function(this: TestContext & ISuiteCallbackContext): void { + + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'src/a.ts', 'import * as m from \'dep\';'], + [rootUri + 'typings/dep.d.ts', 'declare module \'dep\' {}'], + [rootUri + 'src/tsconfig.json', [ + '{', + ' "compilerOptions": {', + ' "target": "ES5",', + ' "module": "commonjs",', + ' "sourceMap": true,', + ' "noImplicitAny": false,', + ' "removeComments": false,', + ' "preserveConstEnums": true', + ' }', + '}' + ].join('\n')], + [rootUri + 'src/tsd.d.ts', '/// '], + [rootUri + 'src/dir/index.ts', 'import * as m from "dep";'] + ]))) + + afterEach(shutdownService) + + describe('textDocumentDefinition()', function(this: TestContext & ISuiteCallbackContext): void { + specify('with tsd.d.ts', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Location[] = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'src/dir/index.ts' + }, + position: { + line: 0, + character: 20 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + uri: rootUri + 'typings/dep.d.ts', + range: { + start: { + line: 0, + character: 15 + }, + end: { + line: 0, + character: 20 + } + } + }]) + }) + describe('on file in project root', function(this: TestContext & ISuiteCallbackContext): void { + specify('on import alias', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Location[] = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'src/a.ts' + }, + position: { + line: 0, + character: 12 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + uri: rootUri + 'typings/dep.d.ts', + range: { + start: { + line: 0, + character: 15 + }, + end: { + line: 0, + character: 20 + } + } + }]) + }) + specify('on module name', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Location[] = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'src/a.ts' + }, + position: { + line: 0, + character: 20 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + uri: rootUri + 'typings/dep.d.ts', + range: { + start: { + line: 0, + character: 15 + }, + end: { + line: 0, + character: 20 + } + } + }]) + }) + }) + }) + }) + + describe('DefinitelyTyped', function(this: TestContext & ISuiteCallbackContext): void { + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'package.json', JSON.stringify({ + private: true, + name: 'definitely-typed', + version: '0.0.1', + homepage: 'https://github.com/DefinitelyTyped/DefinitelyTyped', + repository: { + type: 'git', + url: 'git+https://github.com/DefinitelyTyped/DefinitelyTyped.git' + }, + license: 'MIT', + bugs: { + url: 'https://github.com/DefinitelyTyped/DefinitelyTyped/issues' + }, + engines: { + node: '>= 6.9.1' + }, + scripts: { + 'compile-scripts': 'tsc -p scripts', + 'new-package': 'node scripts/new-package.js', + 'not-needed': 'node scripts/not-needed.js', + 'lint': 'node scripts/lint.js', + 'test': 'node node_modules/types-publisher/bin/tester/test.js --run-from-definitely-typed --nProcesses 1' + }, + devDependencies: { + 'types-publisher': 'Microsoft/types-publisher#production' + } + }, null, 4)], + [rootUri + 'types/resolve/index.d.ts', [ + '/// ', + '', + 'type resolveCallback = (err: Error, resolved?: string) => void;', + 'declare function resolve(id: string, cb: resolveCallback): void;', + '' + ].join('\n')], + [rootUri + 'types/resolve/tsconfig.json', JSON.stringify({ + compilerOptions: { + module: 'commonjs', + lib: [ + 'es6' + ], + noImplicitAny: true, + noImplicitThis: true, + strictNullChecks: false, + baseUrl: '../', + typeRoots: [ + '../' + ], + types: [], + noEmit: true, + forceConsistentCasingInFileNames: true + }, + files: [ + 'index.d.ts' + ] + })], + [rootUri + 'types/notResolve/index.d.ts', [ + '/// ', + '', + 'type resolveCallback = (err: Error, resolved?: string) => void;', + 'declare function resolve(id: string, cb: resolveCallback): void;', + '' + ].join('\n')], + [rootUri + 'types/notResolve/tsconfig.json', JSON.stringify({ + compilerOptions: { + module: 'commonjs', + lib: [ + 'es6' + ], + noImplicitAny: true, + noImplicitThis: true, + strictNullChecks: false, + baseUrl: '../', + typeRoots: [ + '../' + ], + types: [], + noEmit: true, + forceConsistentCasingInFileNames: true + }, + files: [ + 'index.d.ts' + ] + })] + ]))) + + afterEach(shutdownService) + + describe('workspaceSymbol()', function(this: TestContext & ISuiteCallbackContext): void { + it('should find a symbol by SymbolDescriptor query with name and package name', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SymbolInformation[] = await this.service.workspaceSymbol({ + symbol: { name: 'resolveCallback', package: { name: '@types/resolve' } } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + kind: SymbolKind.Variable, + location: { + range: { + end: { + character: 63, + line: 2 + }, + start: { + character: 0, + line: 2 + } + }, + uri: rootUri + 'types/resolve/index.d.ts' + }, + name: 'resolveCallback' + }]) + }) + it('should find a symbol by SymbolDescriptor query with name, containerKind and package name', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SymbolInformation[] = await this.service.workspaceSymbol({ + symbol: { + name: 'resolveCallback', + containerKind: 'module', + package: { + name: '@types/resolve' + } + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result[0], { + kind: SymbolKind.Variable, + location: { + range: { + end: { + character: 63, + line: 2 + }, + start: { + character: 0, + line: 2 + } + }, + uri: rootUri + 'types/resolve/index.d.ts' + }, + name: 'resolveCallback' + }) + }) + }) + }) + + describe('Workspace with root package.json', function(this: TestContext & ISuiteCallbackContext): void { + + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'a.ts', 'class a { foo() { const i = 1;} }'], + [rootUri + 'foo/b.ts', 'class b { bar: number; baz(): number { return this.bar;}}; function qux() {}'], + [rootUri + 'c.ts', 'import { x } from "dep/dep";'], + [rootUri + 'package.json', JSON.stringify({ name: 'mypkg' })], + [rootUri + 'node_modules/dep/dep.ts', 'export var x = 1;'], + [rootUri + 'node_modules/dep/package.json', JSON.stringify({ name: 'dep' })] + ]))) + + afterEach(shutdownService) + + describe('workspaceSymbol()', function(this: TestContext & ISuiteCallbackContext): void { + describe('with SymbolDescriptor query', function(this: TestContext & ISuiteCallbackContext): void { + it('should find a symbol by name, kind and package name', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SymbolInformation[] = await this.service.workspaceSymbol({ + symbol: { + name: 'a', + kind: 'class', + package: { + name: 'mypkg' + } + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result[0], { + kind: SymbolKind.Class, + location: { + range: { + end: { + character: 33, + line: 0 + }, + start: { + character: 0, + line: 0 + } + }, + uri: rootUri + 'a.ts' + }, + name: 'a' + }) + }) + it('should find a symbol by name, kind, package name and ignore package version', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SymbolInformation[] = await this.service.workspaceSymbol({ + symbol: { name: 'a', kind: 'class', package: { name: 'mypkg', version: '203940234' } } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result[0], { + kind: SymbolKind.Class, + location: { + range: { + end: { + character: 33, + line: 0 + }, + start: { + character: 0, + line: 0 + } + }, + uri: rootUri + 'a.ts' + }, + name: 'a' + }) + }) + it('should find a symbol by name', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SymbolInformation[] = await this.service.workspaceSymbol({ + symbol: { + name: 'a' + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + kind: SymbolKind.Class, + location: { + range: { + end: { + character: 33, + line: 0 + }, + start: { + character: 0, + line: 0 + } + }, + uri: rootUri + 'a.ts' + }, + name: 'a' + }]) + }) + it('should return no result if the PackageDescriptor does not match', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SymbolInformation[] = await this.service.workspaceSymbol({ + symbol: { + name: 'a', + kind: 'class', + package: { + name: 'not-mypkg' + } + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, []) + }) + }) + describe('with text query', function(this: TestContext & ISuiteCallbackContext): void { + it('should find a symbol', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SymbolInformation[] = await this.service.workspaceSymbol({ query: 'a' }) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, [{ + kind: SymbolKind.Class, + location: { + range: { + end: { + character: 33, + line: 0 + }, + start: { + character: 0, + line: 0 + } + }, + uri: rootUri + 'a.ts' + }, + name: 'a' + }]) + }) + it('should return all symbols for an empty query excluding dependencies', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SymbolInformation[] = await this.service.workspaceSymbol({ query: '' }) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, [ + { + name: 'a', + kind: SymbolKind.Class, + location: { + uri: rootUri + 'a.ts', + range: { + start: { + line: 0, + character: 0 + }, + end: { + line: 0, + character: 33 + } + } + } + }, + { + name: 'foo', + kind: SymbolKind.Method, + location: { + uri: rootUri + 'a.ts', + range: { + start: { + line: 0, + character: 10 + }, + end: { + line: 0, + character: 31 + } + } + }, + containerName: 'a' + }, + { + name: 'i', + kind: SymbolKind.Constant, + location: { + uri: rootUri + 'a.ts', + range: { + start: { + line: 0, + character: 24 + }, + end: { + line: 0, + character: 29 + } + } + }, + containerName: 'foo' + }, + { + name: '"c"', + kind: SymbolKind.Module, + location: { + uri: rootUri + 'c.ts', + range: { + start: { + line: 0, + character: 0 + }, + end: { + line: 0, + character: 28 + } + } + } + }, + { + name: 'x', + containerName: '"c"', + kind: SymbolKind.Variable, + location: { + uri: rootUri + 'c.ts', + range: { + start: { + line: 0, + character: 9 + }, + end: { + line: 0, + character: 10 + } + } + } + }, + { + name: 'b', + kind: SymbolKind.Class, + location: { + uri: rootUri + 'foo/b.ts', + range: { + start: { + line: 0, + character: 0 + }, + end: { + line: 0, + character: 57 + } + } + } + }, + { + name: 'bar', + kind: SymbolKind.Property, + location: { + uri: rootUri + 'foo/b.ts', + range: { + start: { + line: 0, + character: 10 + }, + end: { + line: 0, + character: 22 + } + } + }, + containerName: 'b' + }, + { + name: 'baz', + kind: SymbolKind.Method, + location: { + uri: rootUri + 'foo/b.ts', + range: { + start: { + line: 0, + character: 23 + }, + end: { + line: 0, + character: 56 + } + } + }, + containerName: 'b' + }, + { + name: 'qux', + kind: SymbolKind.Function, + location: { + uri: rootUri + 'foo/b.ts', + range: { + start: { + line: 0, + character: 59 + }, + end: { + line: 0, + character: 76 + } + } + } + } + ]) + }) + }) + }) + + describe('workspaceXreferences()', function(this: TestContext & ISuiteCallbackContext): void { + it('should return all references to a method', async function(this: TestContext & ITestCallbackContext): Promise { + const result: ReferenceInformation[] = await this.service.workspaceXreferences({ query: { name: 'foo', kind: 'method', containerName: 'a' } }) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, [{ + symbol: { + filePath: 'a.ts', + containerKind: '', + containerName: 'a', + name: 'foo', + kind: 'method' + }, + reference: { + range: { + end: { + character: 13, + line: 0 + }, + start: { + character: 9, + line: 0 + } + }, + uri: rootUri + 'a.ts' + } + }]) + }) + it('should return all references to a method with hinted dependee package name', async function(this: TestContext & ITestCallbackContext): Promise { + const result: ReferenceInformation[] = await this.service.workspaceXreferences({ + query: { + name: 'foo', + kind: 'method', + containerName: 'a' + }, + hints: { + dependeePackageName: 'mypkg' + } + }) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, [{ + symbol: { + filePath: 'a.ts', + containerKind: '', + containerName: 'a', + name: 'foo', + kind: 'method' + }, + reference: { + range: { + end: { + character: 13, + line: 0 + }, + start: { + character: 9, + line: 0 + } + }, + uri: rootUri + 'a.ts' + } + }]) + }) + it('should return no references to a method if hinted dependee package name was not found', async function(this: TestContext & ITestCallbackContext): Promise { + const result = await this.service.workspaceXreferences({ + query: { + name: 'foo', + kind: 'method', + containerName: 'a' + }, + hints: { + dependeePackageName: 'NOT-mypkg' + } + }) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, []) + }) + it('should return all references to a symbol from a dependency', async function(this: TestContext & ITestCallbackContext): Promise { + const result: ReferenceInformation[] = await this.service.workspaceXreferences({ query: { name: 'x' } }) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, [{ + reference: { + range: { + end: { + character: 10, + line: 0 + }, + start: { + character: 8, + line: 0 + } + }, + uri: rootUri + 'c.ts' + }, + symbol: { + filePath: 'dep/dep.ts', + containerKind: '', + containerName: '"dep/dep"', + kind: 'var', + name: 'x' + } + }]) + }) + it('should return all references to a symbol from a dependency with PackageDescriptor query', async function(this: TestContext & ITestCallbackContext): Promise { + const result: ReferenceInformation[] = await this.service.workspaceXreferences({ query: { name: 'x', package: { name: 'dep' } } }) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, [{ + reference: { + range: { + end: { + character: 10, + line: 0 + }, + start: { + character: 8, + line: 0 + } + }, + uri: rootUri + 'c.ts' + }, + symbol: { + filePath: 'dep/dep.ts', + containerKind: '', + containerName: '"dep/dep"', + kind: 'var', + name: 'x', + package: { + name: 'dep', + repoURL: undefined, + version: undefined + } + } + }]) + }) + it('should return all references to all symbols if empty SymbolDescriptor query is passed', async function(this: TestContext & ITestCallbackContext): Promise { + const result: ReferenceInformation[] = await this.service.workspaceXreferences({ query: {} }) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, [ + { + symbol: { + filePath: 'a.ts', + containerName: '"a"', + containerKind: 'module', + kind: 'class', + name: 'a' + }, + reference: { + range: { + end: { + character: 7, + line: 0 + }, + start: { + character: 5, + line: 0 + } + }, + uri: rootUri + 'a.ts' + } + }, + { + symbol: { + filePath: 'a.ts', + containerName: 'a', + containerKind: '', + name: 'foo', + kind: 'method' + }, + reference: { + range: { + end: { + character: 13, + line: 0 + }, + start: { + character: 9, + line: 0 + } + }, + uri: rootUri + 'a.ts' + } + }, + { + symbol: { + filePath: 'a.ts', + containerName: '"a"', + containerKind: 'module', + name: 'i', + kind: 'const' + }, + reference: { + range: { + end: { + character: 25, + line: 0 + }, + start: { + character: 23, + line: 0 + } + }, + uri: rootUri + 'a.ts' + } + }, + { + reference: { + range: { + end: { + character: 10, + line: 0 + }, + start: { + character: 8, + line: 0 + } + }, + uri: rootUri + 'c.ts' + }, + symbol: { + filePath: 'dep/dep.ts', + containerKind: '', + containerName: '"dep/dep"', + kind: 'var', + name: 'x' + } + }, + { + symbol: { + filePath: 'foo/b.ts', + containerName: '"foo/b"', + containerKind: 'module', + name: 'b', + kind: 'class' + }, + reference: { + range: { + end: { + character: 7, + line: 0 + }, + start: { + character: 5, + line: 0 + } + }, + uri: rootUri + 'foo/b.ts' + } + }, + { + symbol: { + filePath: 'foo/b.ts', + containerName: 'b', + containerKind: '', + name: 'bar', + kind: 'property' + }, + reference: { + range: { + end: { + character: 13, + line: 0 + }, + start: { + character: 9, + line: 0 + } + }, + uri: rootUri + 'foo/b.ts' + } + }, + { + symbol: { + filePath: 'foo/b.ts', + containerName: 'b', + containerKind: '', + name: 'baz', + kind: 'method' + }, + reference: { + range: { + end: { + character: 26, + line: 0 + }, + start: { + character: 22, + line: 0 + } + }, + uri: rootUri + 'foo/b.ts' + } + }, + { + symbol: { + filePath: 'foo/b.ts', + containerName: 'b', + containerKind: '', + name: 'bar', + kind: 'property' + }, + reference: { + range: { + end: { + character: 54, + line: 0 + }, + start: { + character: 51, + line: 0 + } + }, + uri: rootUri + 'foo/b.ts' + } + }, + { + symbol: { + filePath: 'foo/b.ts', + containerName: '"foo/b"', + containerKind: 'module', + name: 'qux', + kind: 'function' + }, + reference: { + range: { + end: { + character: 71, + line: 0 + }, + start: { + character: 67, + line: 0 + } + }, + uri: rootUri + 'foo/b.ts' + } + } + ]) + }) + }) + }) + + describe('Dependency detection', function(this: TestContext & ISuiteCallbackContext): void { + + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'package.json', JSON.stringify({ + name: 'tslint', + version: '4.0.2', + dependencies: { + 'babel-code-frame': '^6.16.0', + 'findup-sync': '~0.3.0' + }, + devDependencies: { + '@types/babel-code-frame': '^6.16.0', + '@types/optimist': '0.0.29', + 'chai': '^3.0.0', + 'tslint': 'latest', + 'tslint-test-config-non-relative': 'file:test/external/tslint-test-config-non-relative', + 'typescript': '2.0.10' + }, + peerDependencies: { + typescript: '>=2.0.0' + } + })], + [rootUri + 'node_modules/dep/package.json', JSON.stringify({ + name: 'foo', + dependencies: { + shouldnotinclude: '0.0.0' + } + })], + [rootUri + 'subproject/package.json', JSON.stringify({ + name: 'subproject', + repository: { + url: 'https://github.com/my/subproject' + }, + dependencies: { + 'subproject-dep': '0.0.0' + } + })] + ]))) + + afterEach(shutdownService) + + describe('workspaceXdependencies()', function(this: TestContext & ISuiteCallbackContext): void { + it('should account for all dependencies', async function(this: TestContext & ITestCallbackContext): Promise { + const result: DependencyReference[] = await this.service.workspaceXdependencies() + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, [ + { + attributes: { + name: 'babel-code-frame', + version: '^6.16.0' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'findup-sync', + version: '~0.3.0' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: '@types/babel-code-frame', + version: '^6.16.0' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: '@types/optimist', + version: '0.0.29' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'chai', + version: '^3.0.0' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'tslint', + version: 'latest' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'tslint-test-config-non-relative', + version: 'file:test/external/tslint-test-config-non-relative' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'typescript', + version: '2.0.10' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'typescript', + version: '>=2.0.0' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'subproject-dep', + version: '0.0.0' + }, + hints: { + dependeePackageName: 'subproject' + } + } + ]) + }) + }) + describe('workspaceXpackages()', function(this: TestContext & ISuiteCallbackContext): void { + it('should accournt for all packages', async function(this: TestContext & ITestCallbackContext): Promise { + const result: PackageInformation[] = await this.service.workspaceXpackages() + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, [{ + package: { + name: 'tslint', + version: '4.0.2', + repoURL: undefined + }, + dependencies: [ + { + attributes: { + name: 'babel-code-frame', + version: '^6.16.0' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'findup-sync', + version: '~0.3.0' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: '@types/babel-code-frame', + version: '^6.16.0' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: '@types/optimist', + version: '0.0.29' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'chai', + version: '^3.0.0' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'tslint', + version: 'latest' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'tslint-test-config-non-relative', + version: 'file:test/external/tslint-test-config-non-relative' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'typescript', + version: '2.0.10' + }, + hints: { + dependeePackageName: 'tslint' + } + }, + { + attributes: { + name: 'typescript', + version: '>=2.0.0' + }, + hints: { + dependeePackageName: 'tslint' + } + } + ] + }, { + package: { + name: 'subproject', + version: undefined, + repoURL: 'https://github.com/my/subproject' + }, + dependencies: [ + { attributes: { name: 'subproject-dep', version: '0.0.0' }, hints: { dependeePackageName: 'subproject' } } + ] + }]) + }) + }) + }) + + describe('TypeScript library', function(this: TestContext & ISuiteCallbackContext): void { + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'a.ts', 'let parameters = [];'] + ]))) + + afterEach(shutdownService) + + specify('type of parameters should be any[]', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Hover = await this.service.textDocumentHover({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 5 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + range: { + end: { + character: 14, + line: 0 + }, + start: { + character: 4, + line: 0 + } + }, + contents: [ + { language: 'typescript', value: 'let parameters: any[]' }, + '**let**' + ] + }) + }) + }) + + describe('Live updates', function(this: TestContext & ISuiteCallbackContext): void { + + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'a.ts', 'let parameters = [];'] + ]))) + + afterEach(shutdownService) + + it('should handle didChange when configuration is not yet initialized', async function(this: TestContext & ITestCallbackContext): Promise { + + const hoverParams = { + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 5 + } + } + + const range = { + end: { + character: 14, + line: 0 + }, + start: { + character: 4, + line: 0 + } + } + + await this.service.textDocumentDidChange({ + textDocument: { + uri: rootUri + 'a.ts', + version: 1 + }, + contentChanges: [{ + text: 'let parameters: number[]' + }] + }) + + const result: Hover = await this.service.textDocumentHover(hoverParams) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, { + range, + contents: [ + { language: 'typescript', value: 'let parameters: number[]' }, + '**let**' + ] + }) + }) + + it('should handle didClose when configuration is not yet initialized', async function(this: TestContext & ITestCallbackContext): Promise { + + const hoverParams = { + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 5 + } + } + + const range = { + end: { + character: 14, + line: 0 + }, + start: { + character: 4, + line: 0 + } + } + + await this.service.textDocumentDidClose({ + textDocument: { + uri: rootUri + 'a.ts' + } + }) + + const result: Hover = await this.service.textDocumentHover(hoverParams) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, { + range, + contents: [ + { language: 'typescript', value: 'let parameters: any[]' }, + '**let**' + ] + }) + }) + + it('should reflect updated content', async function(this: TestContext & ITestCallbackContext): Promise { + + const hoverParams = { + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 5 + } + } + + const range = { + end: { + character: 14, + line: 0 + }, + start: { + character: 4, + line: 0 + } + } + + { + const result: Hover = await this.service.textDocumentHover(hoverParams) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, { + range, + contents: [ + { language: 'typescript', value: 'let parameters: any[]' }, + '**let**' + ] + }) + } + + await this.service.textDocumentDidOpen({ + textDocument: { + uri: rootUri + 'a.ts', + languageId: 'typescript', + version: 1, + text: 'let parameters: string[]' + } + }) + + { + const result: Hover = await this.service.textDocumentHover(hoverParams) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, { + range, + contents: [ + { language: 'typescript', value: 'let parameters: string[]' }, + '**let**' + ] + }) + } + + await this.service.textDocumentDidChange({ + textDocument: { + uri: rootUri + 'a.ts', + version: 2 + }, + contentChanges: [{ + text: 'let parameters: number[]' + }] + }) + + { + const result: Hover = await this.service.textDocumentHover(hoverParams) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, { + range, + contents: [ + { language: 'typescript', value: 'let parameters: number[]' }, + '**let**' + ] + }) + } + + await this.service.textDocumentDidClose({ + textDocument: { + uri: rootUri + 'a.ts' + } + }) + + { + const result: Hover = await this.service.textDocumentHover(hoverParams) + .reduce(applyReducer, null as any) + .toPromise() + assert.deepEqual(result, { + range, + contents: [ + { language: 'typescript', value: 'let parameters: any[]' }, + '**let**' + ] + }) + } + }) + }) + + describe('Diagnostics', function(this: TestContext & ISuiteCallbackContext): void { + + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'src/errors.ts', 'const text: string = 33;'] + ]))) + + afterEach(shutdownService) + + it('should publish diagnostics on didOpen', async function(this: TestContext & ITestCallbackContext): Promise { + + await this.service.textDocumentDidOpen({ + textDocument: { + uri: rootUri + 'src/errors.ts', + languageId: 'typescript', + text: 'const text: string = 33;', + version: 1 + } + }) + + sinon.assert.calledOnce(this.client.textDocumentPublishDiagnostics) + sinon.assert.calledWithExactly(this.client.textDocumentPublishDiagnostics, { + diagnostics: [{ + message: 'Type \'33\' is not assignable to type \'string\'.', + range: { end: { character: 10, line: 0 }, start: { character: 6, line: 0 } }, + severity: 1, + source: 'ts', + code: 2322 + }], + uri: rootUri + 'src/errors.ts' + }) + }) + + it('should publish diagnostics on didChange', async function(this: TestContext & ITestCallbackContext): Promise { + + await this.service.textDocumentDidOpen({ + textDocument: { + uri: rootUri + 'src/errors.ts', + languageId: 'typescript', + text: 'const text: string = 33;', + version: 1 + } + }) + + this.client.textDocumentPublishDiagnostics.resetHistory() + + await this.service.textDocumentDidChange({ + textDocument: { + uri: rootUri + 'src/errors.ts', + version: 2 + }, + contentChanges: [ + { text: 'const text: boolean = 33;' } + ] + }) + + sinon.assert.calledOnce(this.client.textDocumentPublishDiagnostics) + sinon.assert.calledWithExactly(this.client.textDocumentPublishDiagnostics, { + diagnostics: [{ + message: 'Type \'33\' is not assignable to type \'boolean\'.', + range: { end: { character: 10, line: 0 }, start: { character: 6, line: 0 } }, + severity: 1, + source: 'ts', + code: 2322 + }], + uri: rootUri + 'src/errors.ts' + }) + }) + + it('should publish empty diagnostics on didChange if error was fixed', async function(this: TestContext & ITestCallbackContext): Promise { + + await this.service.textDocumentDidOpen({ + textDocument: { + uri: rootUri + 'src/errors.ts', + languageId: 'typescript', + text: 'const text: string = 33;', + version: 1 + } + }) + + this.client.textDocumentPublishDiagnostics.resetHistory() + + await this.service.textDocumentDidChange({ + textDocument: { + uri: rootUri + 'src/errors.ts', + version: 2 + }, + contentChanges: [ + { text: 'const text: number = 33;' } + ] + }) + + sinon.assert.calledOnce(this.client.textDocumentPublishDiagnostics) + sinon.assert.calledWithExactly(this.client.textDocumentPublishDiagnostics, { + diagnostics: [], + uri: rootUri + 'src/errors.ts' + }) + }) + + it('should clear diagnostics on didClose', async function(this: TestContext & ITestCallbackContext): Promise { + + await this.service.textDocumentDidClose({ + textDocument: { + uri: rootUri + 'src/errors.ts' + } + }) + + sinon.assert.calledOnce(this.client.textDocumentPublishDiagnostics) + sinon.assert.calledWithExactly(this.client.textDocumentPublishDiagnostics, { + diagnostics: [], + uri: rootUri + 'src/errors.ts' + }) + }) + + }) + + describe('References and imports', function(this: TestContext & ISuiteCallbackContext): void { + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'a.ts', '/// \nnamespace qux {let f : foo;}'], + [rootUri + 'b.ts', '/// '], + [rootUri + 'c.ts', 'import * as d from "./foo/d"\nd.bar()'], + [rootUri + 'foo/c.ts', 'namespace qux {export interface foo {}}'], + [rootUri + 'foo/d.ts', 'export function bar() {}'], + [rootUri + 'deeprefs/a.ts', '/// \nnamespace qux {\nlet f : foo;\n}'], + [rootUri + 'deeprefs/b.ts', '/// '], + [rootUri + 'deeprefs/c.ts', '/// '], + [rootUri + 'deeprefs/d.ts', '/// '], + [rootUri + 'deeprefs/e.ts', 'namespace qux {\nexport interface foo {}\n}'], + [rootUri + 'missing/a.ts', [ + '/// ', + '/// ', + 'namespace t {', + ' function foo() : Bar {', + ' return null;', + ' }', + '}' + ].join('\n')], + [rootUri + 'missing/b.ts', 'namespace t {\n export interface Bar {\n id?: number;\n }}'] + ]))) + + afterEach(shutdownService) + + describe('textDocumentDefinition()', function(this: TestContext & ISuiteCallbackContext): void { + it('should resolve symbol imported with tripe-slash reference', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Location[] = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 1, + character: 23 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + // Note: technically this list should also + // include the 2nd definition of `foo` in + // deeprefs/e.ts, but there's no easy way to + // discover it through file-level imports and + // it is rare enough that we accept this + // omission. (It would probably show up in the + // definition response if the user has already + // navigated to deeprefs/e.ts.) + uri: rootUri + 'foo/c.ts', + range: { + start: { + line: 0, + character: 32 + }, + end: { + line: 0, + character: 35 + } + } + }]) + }) + it('should resolve symbol imported with import statement', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Location[] = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'c.ts' + }, + position: { + line: 1, + character: 2 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + uri: rootUri + 'foo/d.ts', + range: { + start: { + line: 0, + character: 16 + }, + end: { + line: 0, + character: 19 + } + } + }]) + }) + it('should resolve definition with missing reference', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Location[] = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'missing/a.ts' + }, + position: { + line: 3, + character: 21 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + uri: rootUri + 'missing/b.ts', + range: { + start: { + line: 1, + character: 21 + }, + end: { + line: 1, + character: 24 + } + } + }]) + }) + it('should resolve deep definitions', async function(this: TestContext & ITestCallbackContext): Promise { + // This test passes only because we expect no response from LSP server + // for definition located in file references with depth 3 or more (a -> b -> c -> d (...)) + // This test will fail once we'll increase (or remove) depth limit + const result: Location[] = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'deeprefs/a.ts' + }, + position: { + line: 2, + character: 8 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + uri: rootUri + 'deeprefs/e.ts', + range: { + start: { + line: 1, + character: 17 + }, + end: { + line: 1, + character: 20 + } + } + }]) + }) + }) + }) + + describe('TypeScript libraries', function(this: TestContext & ISuiteCallbackContext): void { + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'tsconfig.json', JSON.stringify({ + compilerOptions: { + lib: ['es2016', 'dom'] + } + })], + [rootUri + 'a.ts', 'function foo(n: Node): {console.log(n.parentNode, NaN})}'] + ]))) + + afterEach(shutdownService) + + describe('textDocumentHover()', function(this: TestContext & ISuiteCallbackContext): void { + it('should load local library file', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Hover = await this.service.textDocumentHover({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 16 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + range: { + end: { + character: 20, + line: 0 + }, + start: { + character: 16, + line: 0 + } + }, + contents: [ + { + language: 'typescript', + value: [ + 'interface Node', + 'var Node: {', + ' new (): Node;', + ' prototype: Node;', + ' readonly ATTRIBUTE_NODE: number;', + ' readonly CDATA_SECTION_NODE: number;', + ' readonly COMMENT_NODE: number;', + ' readonly DOCUMENT_FRAGMENT_NODE: number;', + ' readonly DOCUMENT_NODE: number;', + ' readonly DOCUMENT_POSITION_CONTAINED_BY: number;', + ' readonly DOCUMENT_POSITION_CONTAINS: number;', + ' readonly DOCUMENT_POSITION_DISCONNECTED: number;', + ' readonly DOCUMENT_POSITION_FOLLOWING: number;', + ' readonly DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC: number;', + ' readonly DOCUMENT_POSITION_PRECEDING: number;', + ' readonly DOCUMENT_TYPE_NODE: number;', + ' readonly ELEMENT_NODE: number;', + ' readonly ENTITY_NODE: number;', + ' readonly ENTITY_REFERENCE_NODE: number;', + ' readonly NOTATION_NODE: number;', + ' readonly PROCESSING_INSTRUCTION_NODE: number;', + ' readonly TEXT_NODE: number;', + '}' + ].join('\n') + }, + '**var** _(ambient)_' + ] + }) + }) + }) + describe('textDocumentDefinition()', function(this: TestContext & ISuiteCallbackContext): void { + it('should resolve TS libraries to github URL', async function(this: TestContext & ITestCallbackContext): Promise { + assert.deepEqual(await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 16 + } + }).reduce(applyReducer, null as any).toPromise(), [{ + uri: 'git://github.com/Microsoft/TypeScript?v' + ts.version + '#lib/lib.dom.d.ts', + range: { + start: { + line: 8259, + character: 10 + }, + end: { + line: 8259, + character: 14 + } + } + }, { + uri: 'git://github.com/Microsoft/TypeScript?v' + ts.version + '#lib/lib.dom.d.ts', + range: { + start: { + line: 8311, + character: 12 + }, + end: { + line: 8311, + character: 16 + } + } + }]) + + assert.deepEqual(await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 50 + } + }).reduce(applyReducer, null as any).toPromise(), [{ + uri: 'git://github.com/Microsoft/TypeScript?v' + ts.version + '#lib/lib.es5.d.ts', + range: { + start: { + line: 24, + character: 14 + }, + end: { + line: 24, + character: 17 + } + } + }]) + }) + }) + }) + + describe('textDocumentReferences()', function(this: TestContext & ISuiteCallbackContext): void { + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'a.ts', [ + 'class A {', + ' /** foo doc*/', + ' foo() {}', + ' /** bar doc*/', + ' bar(): number { return 1; }', + ' /** ', + ' * The Baz function', + ' * @param num Number parameter', + ' * @param text Text parameter', + ' */', + ' baz(num: number, text: string): string { return ""; }', + ' /** qux doc*/', + ' qux: number;', + '}', + 'const a = new A();', + 'a.baz(32, sd)' + ].join('\n')], + [rootUri + 'uses-import.ts', [ + 'import * as i from "./import"', + 'i.d()' + ].join('\n')], + [rootUri + 'also-uses-import.ts', [ + 'import {d} from "./import"', + 'd()' + ].join('\n')], + [rootUri + 'import.ts', '/** d doc*/ export function d() {}'] + ]))) + + afterEach(shutdownService) + + it('should provide an empty response when no reference is found', async function(this: TestContext & ITestCallbackContext): Promise { + const result = await this.service.textDocumentReferences({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 0 + }, + context: { includeDeclaration: false } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, []) + }) + + it('should include the declaration if requested', async function(this: TestContext & ITestCallbackContext): Promise { + const result = await this.service.textDocumentReferences({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 4, + character: 5 + }, + context: { includeDeclaration: true } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + range: { + end: { + character: 7, + line: 4 + }, + start: { + character: 4, + line: 4 + } + }, + uri: rootUri + 'a.ts' + }]) + }) + + it('should provide a reference within the same file', async function(this: TestContext & ITestCallbackContext): Promise { + const result = await this.service.textDocumentReferences({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 10, + character: 5 + }, + context: { includeDeclaration: false } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + range: { + end: { + character: 5, + line: 15 + }, + start: { + character: 2, + line: 15 + } + }, + uri: rootUri + 'a.ts' + }]) + }) + it('should provide two references from imports', async function(this: TestContext & ITestCallbackContext): Promise { + const result = await this.service.textDocumentReferences({ + textDocument: { + uri: rootUri + 'import.ts' + }, + position: { + line: 0, + character: 28 + }, + context: { includeDeclaration: false } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [ + { + range: { + end: { + character: 3, + line: 1 + }, + start: { + character: 2, + line: 1 + } + }, + uri: rootUri + 'uses-import.ts' + }, + { + range: { + end: { + character: 1, + line: 1 + }, + start: { + character: 0, + line: 1 + } + }, + uri: rootUri + 'also-uses-import.ts' + } + ]) + }) + }) + + describe('textDocumentSignatureHelp()', function(this: TestContext & ISuiteCallbackContext): void { + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'a.ts', [ + 'class A {', + ' /** foo doc*/', + ' foo() {}', + ' /** bar doc*/', + ' bar(): number { return 1; }', + ' /** ', + ' * The Baz function', + ' * @param num Number parameter', + ' * @param text Text parameter', + ' */', + ' baz(num: number, text: string): string { return ""; }', + ' /** qux doc*/', + ' qux: number;', + '}', + 'const a = new A();', + 'a.baz(32, sd)' + ].join('\n')], + [rootUri + 'uses-import.ts', [ + 'import * as i from "./import"', + 'i.d()' + ].join('\n')], + [rootUri + 'import.ts', '/** d doc*/ export function d() {}'], + [rootUri + 'uses-reference.ts', [ + '/// ', + 'let z : foo.' + ].join('\n')], + [rootUri + 'reference.ts', [ + 'namespace foo {', + ' /** bar doc*/', + ' export interface bar {}', + '}' + ].join('\n')], + [rootUri + 'empty.ts', ''] + ]))) + + afterEach(shutdownService) + + it('should provide a valid empty response when no signature is found', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SignatureHelp = await this.service.textDocumentSignatureHelp({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 0 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + signatures: [], + activeSignature: 0, + activeParameter: 0 + }) + }) + + it('should provide signature help with parameters in the same file', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SignatureHelp = await this.service.textDocumentSignatureHelp({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 15, + character: 11 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + signatures: [ + { + label: 'baz(num: number, text: string): string', + documentation: 'The Baz function', + parameters: [{ + label: 'num: number', + documentation: 'Number parameter' + }, { + label: 'text: string', + documentation: 'Text parameter' + }] + } + ], + activeSignature: 0, + activeParameter: 1 + }) + }) + + it('should provide signature help from imported symbols', async function(this: TestContext & ITestCallbackContext): Promise { + const result: SignatureHelp = await this.service.textDocumentSignatureHelp({ + textDocument: { + uri: rootUri + 'uses-import.ts' + }, + position: { + line: 1, + character: 4 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + activeSignature: 0, + activeParameter: 0, + signatures: [{ + label: 'd(): void', + documentation: 'd doc', + parameters: [] + }] + }) + }) + + }) + + describe('textDocumentCompletion() with snippets', function(this: TestContext & ISuiteCallbackContext): void{ + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'a.ts', [ + 'class A {', + ' /** foo doc*/', + ' foo() {}', + ' /** bar doc*/', + ' bar(num: number): number { return 1; }', + ' /** baz doc*/', + ' baz(num: number): string { return ""; }', + ' /** qux doc*/', + ' qux: number;', + '}', + 'const a = new A();', + 'a.' + ].join('\n')] + ]), { + textDocument: { + completion: { + completionItem: { + snippetSupport: true + } + } + }, + ...DEFAULT_CAPABILITIES + })) + + afterEach(shutdownService) + + it('should produce completions', async function(this: TestContext & ITestCallbackContext): Promise { + const result: CompletionList = await this.service.textDocumentCompletion({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 11, + character: 2 + } + }).reduce(applyReducer, null as any).toPromise() + assert.equal(result.isIncomplete, false) + assert.sameDeepMembers(result.items, [ + { + label: 'bar', + kind: CompletionItemKind.Method, + sortText: '0', + data: { + entryName: 'bar', + offset: 222, + uri: rootUri + 'a.ts' + } + }, + { + label: 'baz', + kind: CompletionItemKind.Method, + sortText: '0', + data: { + entryName: 'baz', + offset: 222, + uri: rootUri + 'a.ts' + } + }, + { + label: 'foo', + kind: CompletionItemKind.Method, + sortText: '0', + data: { + entryName: 'foo', + offset: 222, + uri: rootUri + 'a.ts' + } + }, + { + label: 'qux', + kind: CompletionItemKind.Property, + sortText: '0', + data: { + entryName: 'qux', + offset: 222, + uri: rootUri + 'a.ts' + } + } + ]) + }) + + it('should resolve completions with snippets', async function(this: TestContext & ITestCallbackContext): Promise { + const result: CompletionList = await this.service.textDocumentCompletion({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 11, + character: 2 + } + }).reduce(applyReducer, null as any).toPromise() + // * A snippet can define tab stops and placeholders with `$1`, `$2` + // * and `${3:foo}`. `$0` defines the final tab stop, it defaults to + // * the end of the snippet. Placeholders with equal identifiers are linked, + // * that is typing in one will update others too. + assert.equal(result.isIncomplete, false) + + const resolvedItems = await Observable.from(result.items) + .mergeMap(item => this.service + .completionItemResolve(item) + .reduce(applyReducer, null as any) + ) + .toArray() + .toPromise() + + // tslint:disable:no-invalid-template-strings + assert.sameDeepMembers(resolvedItems, [ + { + label: 'bar', + kind: CompletionItemKind.Method, + documentation: 'bar doc', + sortText: '0', + insertTextFormat: InsertTextFormat.Snippet, + insertText: 'bar(${1:num})', + detail: '(method) A.bar(num: number): number', + data: undefined + }, + { + label: 'baz', + kind: CompletionItemKind.Method, + documentation: 'baz doc', + sortText: '0', + insertTextFormat: InsertTextFormat.Snippet, + insertText: 'baz(${1:num})', + detail: '(method) A.baz(num: number): string', + data: undefined + }, + { + label: 'foo', + kind: CompletionItemKind.Method, + documentation: 'foo doc', + sortText: '0', + insertTextFormat: InsertTextFormat.Snippet, + insertText: 'foo()', + detail: '(method) A.foo(): void', + data: undefined + }, + { + label: 'qux', + kind: CompletionItemKind.Property, + documentation: 'qux doc', + sortText: '0', + insertTextFormat: InsertTextFormat.PlainText, + insertText: 'qux', + detail: '(property) A.qux: number', + data: undefined + } + ]) + // tslint:enable:no-invalid-template-strings + }) + }) + + describe('textDocumentCompletion()', function(this: TestContext & ISuiteCallbackContext): void { + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'a.ts', [ + 'class A {', + ' /** foo doc*/', + ' foo() {}', + ' /** bar doc*/', + ' bar(): number { return 1; }', + ' /** baz doc*/', + ' baz(): string { return ""; }', + ' /** qux doc*/', + ' qux: number;', + '}', + 'const a = new A();', + 'a.' + ].join('\n')], + [rootUri + 'uses-import.ts', [ + 'import * as i from "./import"', + 'i.' + ].join('\n')], + [rootUri + 'import.ts', '/** d doc*/ export function d() {}'], + [rootUri + 'uses-reference.ts', [ + '/// ', + 'let z : foo.' + ].join('\n')], + [rootUri + 'reference.ts', [ + 'namespace foo {', + ' /** bar doc*/', + ' export interface bar {}', + '}' + ].join('\n')], + [rootUri + 'empty.ts', ''] + ]))) + + afterEach(shutdownService) + + it('produces completions in the same file', async function(this: TestContext & ITestCallbackContext): Promise { + const result: CompletionList = await this.service.textDocumentCompletion({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 11, + character: 2 + } + }).reduce(applyReducer, null as any).toPromise() + assert.equal(result.isIncomplete, false) + assert.sameDeepMembers(result.items, [ + { + data: { + entryName: 'bar', + offset: 200, + uri: rootUri + 'a.ts' + }, + label: 'bar', + kind: CompletionItemKind.Method, + sortText: '0' + }, + { + data: { + entryName: 'baz', + offset: 200, + uri: rootUri + 'a.ts' + }, + label: 'baz', + kind: CompletionItemKind.Method, + sortText: '0' + }, + { + data: { + entryName: 'foo', + offset: 200, + uri: rootUri + 'a.ts' + }, + label: 'foo', + kind: CompletionItemKind.Method, + sortText: '0' + }, + { + data: { + entryName: 'qux', + offset: 200, + uri: rootUri + 'a.ts' + }, + label: 'qux', + kind: CompletionItemKind.Property, + sortText: '0' + } + ]) + }) + + it('resolves completions in the same file', async function(this: TestContext & ITestCallbackContext): Promise { + const result: CompletionList = await this.service.textDocumentCompletion({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 11, + character: 2 + } + }).reduce(applyReducer, null as any).toPromise() + assert.equal(result.isIncomplete, false) + + const resolveItem = (item: CompletionItem) => this.service + .completionItemResolve(item) + .reduce(applyReducer, null as any).toPromise() + + const resolvedItems = await Promise.all(result.items.map(resolveItem)) + + assert.sameDeepMembers(resolvedItems, [ + { + label: 'bar', + kind: CompletionItemKind.Method, + documentation: 'bar doc', + insertText: 'bar', + insertTextFormat: InsertTextFormat.PlainText, + sortText: '0', + detail: '(method) A.bar(): number', + data: undefined + }, + { + label: 'baz', + kind: CompletionItemKind.Method, + documentation: 'baz doc', + insertText: 'baz', + insertTextFormat: InsertTextFormat.PlainText, + sortText: '0', + detail: '(method) A.baz(): string', + data: undefined + }, + { + label: 'foo', + kind: CompletionItemKind.Method, + documentation: 'foo doc', + insertText: 'foo', + insertTextFormat: InsertTextFormat.PlainText, + sortText: '0', + detail: '(method) A.foo(): void', + data: undefined + }, + { + label: 'qux', + kind: CompletionItemKind.Property, + documentation: 'qux doc', + insertText: 'qux', + insertTextFormat: InsertTextFormat.PlainText, + sortText: '0', + detail: '(property) A.qux: number', + data: undefined + } + ]) + + }) + + it('produces completions for imported symbols', async function(this: TestContext & ITestCallbackContext): Promise { + const result: CompletionList = await this.service.textDocumentCompletion({ + textDocument: { + uri: rootUri + 'uses-import.ts' + }, + position: { + line: 1, + character: 2 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + isIncomplete: false, + items: [{ + data: { + entryName: 'd', + offset: 32, + uri: rootUri + 'uses-import.ts' + }, + label: 'd', + kind: CompletionItemKind.Function, + sortText: '0' + }] + }) + }) + it('produces completions for referenced symbols', async function(this: TestContext & ITestCallbackContext): Promise { + const result: CompletionList = await this.service.textDocumentCompletion({ + textDocument: { + uri: rootUri + 'uses-reference.ts' + }, + position: { + line: 1, + character: 13 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + isIncomplete: false, + items: [{ + data: { + entryName: 'bar', + offset: 51, + uri: rootUri + 'uses-reference.ts' + }, + label: 'bar', + kind: CompletionItemKind.Interface, + sortText: '0' + }] + }) + }) + it('produces completions for empty files', async function(this: TestContext & ITestCallbackContext): Promise { + this.timeout(10000) + const result: CompletionList = await this.service.textDocumentCompletion({ + textDocument: { + uri: rootUri + 'empty.ts' + }, + position: { + line: 0, + character: 0 + } + }).reduce(applyReducer, null as any).toPromise() + assert.notDeepEqual(result.items, []) + }) + }) + + describe('textDocumentRename()', function(this: TestContext & ISuiteCallbackContext): void { + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'package.json', JSON.stringify({ name: 'mypkg' })], + [rootUri + 'a.ts', [ + 'class A {', + ' /** foo doc*/', + ' foo() {}', + ' /** bar doc*/', + ' bar(): number { return 1; }', + ' /** baz doc*/', + ' baz(): string { return ""; }', + ' /** qux doc*/', + ' qux: number;', + '}', + 'const a = new A();', + 'a.' + ].join('\n')], + [rootUri + 'uses-import.ts', [ + 'import {d} from "./import"', + 'const x = d();' + ].join('\n')], + [rootUri + 'import.ts', 'export function d(): number { return 55; }'] + ]))) + + afterEach(shutdownService) + + it('should error on an invalid symbol', async function(this: TestContext & ITestCallbackContext): Promise { + await Promise.resolve(assert.isRejected( + this.service.textDocumentRename({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 1 + }, + newName: 'asdf' + }).reduce(applyReducer, null as any).toPromise(), + 'This symbol cannot be renamed' + )) + }) + it('should return a correct WorkspaceEdit to rename a class', async function(this: TestContext & ITestCallbackContext): Promise { + const result: WorkspaceEdit = await this.service.textDocumentRename({ + textDocument: { + uri: rootUri + 'a.ts' + }, + position: { + line: 0, + character: 6 + }, + newName: 'B' + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + changes: { + [rootUri + 'a.ts']: [{ + newText: 'B', + range: { + end: { + character: 7, + line: 0 + }, + start: { + character: 6, + line: 0 + } + } + }, { + newText: 'B', + range: { + end: { + character: 15, + line: 10 + }, + start: { + character: 14, + line: 10 + } + } + }] + } + }) + }) + it('should return a correct WorkspaceEdit to rename an imported function', async function(this: TestContext & ITestCallbackContext): Promise { + const result: WorkspaceEdit = await this.service.textDocumentRename({ + textDocument: { + uri: rootUri + 'import.ts' + }, + position: { + line: 0, + character: 16 + }, + newName: 'f' + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + changes: { + [rootUri + 'import.ts']: [{ + newText: 'f', + range: { + end: { + character: 17, + line: 0 + }, + start: { + character: 16, + line: 0 + } + } + }], + [rootUri + 'uses-import.ts']: [{ + newText: 'f', + range: { + end: { + character: 9, + line: 0 + }, + start: { + character: 8, + line: 0 + } + } + }, { + newText: 'f', + range: { + end: { + character: 11, + line: 1 + }, + start: { + character: 10, + line: 1 + } + } + }] + } + }) + }) + }) + + describe('textDocumentCodeAction()', function(this: TestContext & ISuiteCallbackContext): void { + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'package.json', JSON.stringify({ name: 'mypkg' })], + [rootUri + 'a.ts', [ + 'class A {', + '\tconstructor() {', + '\t\tmissingThis = 33;', + '\t}', + '}', + 'const a = new A();' + ].join('\n')] + ]))) + + afterEach(shutdownService) + + it('suggests a missing this', async function(this: TestContext & ITestCallbackContext): Promise { + await this.service.textDocumentDidOpen({ + textDocument: { + uri: rootUri + 'a.ts', + languageId: 'typescript', + text: [ + 'class A {', + '\tmissingThis: number;', + '\tconstructor() {', + '\t\tmissingThis = 33;', + '\t}', + '}', + 'const a = new A();' + ].join('\n'), + version: 1 + } + }) + + const firstDiagnostic: Diagnostic = { + range: { + start: { line: 3, character: 4 }, + end: { line: 3, character: 15 } + }, + message: 'Cannot find name \'missingThis\'. Did you mean the instance member \'this.missingThis\'?', + severity: DiagnosticSeverity.Error, + code: 2663, + source: 'ts' + } + const actions: Command[] = await this.service.textDocumentCodeAction({ + textDocument: { + uri: rootUri + 'a.ts' + }, + range: firstDiagnostic.range, + context: { + diagnostics: [firstDiagnostic] + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(actions, [{ + title: 'Add \'this.\' to unresolved variable.', + command: 'codeFix', + arguments: [{ + fileName: toUnixPath(uri2path(rootUri + 'a.ts')), // path only used by TS service + textChanges: [{ + span: { start: 49, length: 13 }, + newText: '\t\tthis.missingThis' + }] + }] + }]) + + }) + }) + + describe('workspaceExecuteCommand()', function(this: TestContext & ISuiteCallbackContext): void { + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'package.json', JSON.stringify({ name: 'mypkg' })], + [rootUri + 'a.ts', [ + 'class A {', + ' constructor() {', + ' missingThis = 33;', + ' }', + '}', + 'const a = new A();' + ].join('\n')] + ]))) + + afterEach(shutdownService) + + describe('codeFix', () => { + it('should apply a WorkspaceEdit for the passed FileTextChanges', async function(this: TestContext & ITestCallbackContext): Promise { + await this.service.workspaceExecuteCommand({ + command: 'codeFix', + arguments: [{ + fileName: uri2path(rootUri + 'a.ts'), + textChanges: [{ + span: { start: 50, length: 15 }, + newText: '\t\tthis.missingThis' + }] + }] + }).reduce(applyReducer, null as any).toPromise() + + sinon.assert.calledOnce(this.client.workspaceApplyEdit) + const workspaceEdit = this.client.workspaceApplyEdit.lastCall.args[0] + assert.deepEqual(workspaceEdit, { + edit: { + changes: { + [rootUri + 'a.ts']: [{ + newText: '\t\tthis.missingThis', + range: { + end: { + character: 9, + line: 5 + }, + start: { + character: 0, + line: 3 + } + } + }] + } + } + }) + }) + }) + }) + + describe('Special file names', function(this: TestContext & ISuiteCallbackContext): void { + + beforeEach(initializeTypeScriptService(createService, rootUri, new Map([ + [rootUri + 'keywords-in-path/class/constructor/a.ts', 'export function a() {}'], + [rootUri + 'special-characters-in-path/%40foo/b.ts', 'export function b() {}'], + [rootUri + 'windows/app/master.ts', '/// \nc();'], + [rootUri + 'windows/lib/master.ts', '/// '], + [rootUri + 'windows/lib/slave.ts', 'function c() {}'] + ]))) + + afterEach(shutdownService) + + it('should accept files with TypeScript keywords in path', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Hover = await this.service.textDocumentHover({ + textDocument: { + uri: rootUri + 'keywords-in-path/class/constructor/a.ts' + }, + position: { + line: 0, + character: 16 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + range: { + start: { + line: 0, + character: 16 + }, + end: { + line: 0, + character: 17 + } + }, + contents: [ + { language: 'typescript', value: 'function a(): void' }, + '**function** _(exported)_' + ] + }) + }) + it('should accept files with special characters in path', async function(this: TestContext & ITestCallbackContext): Promise { + const result: Hover = await this.service.textDocumentHover({ + textDocument: { + uri: rootUri + 'special-characters-in-path/%40foo/b.ts' + }, + position: { + line: 0, + character: 16 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, { + range: { + start: { + line: 0, + character: 16 + }, + end: { + line: 0, + character: 17 + } + }, + contents: [ + { language: 'typescript', value: 'function b(): void' }, + '**function** _(exported)_' + ] + }) + }) + it('should handle Windows-style paths in triple slash references', async function(this: TestContext & ITestCallbackContext): Promise { + const result = await this.service.textDocumentDefinition({ + textDocument: { + uri: rootUri + 'windows/app/master.ts' + }, + position: { + line: 1, + character: 0 + } + }).reduce(applyReducer, null as any).toPromise() + assert.deepEqual(result, [{ + range: { + start: { + line: 0, + character: 9 + }, + end: { + line: 0, + character: 10 + } + }, + uri: rootUri + 'windows/lib/slave.ts' + }]) + }) + }) } diff --git a/src/test/typescript-service.test.ts b/src/test/typescript-service.test.ts index d5d7161ea..4bc11ae48 100644 --- a/src/test/typescript-service.test.ts +++ b/src/test/typescript-service.test.ts @@ -1,11 +1,11 @@ -import {TypeScriptService} from '../typescript-service'; -import {describeTypeScriptService} from './typescript-service-helpers'; +import { TypeScriptService } from '../typescript-service' +import { describeTypeScriptService } from './typescript-service-helpers' describe('TypeScriptService', () => { - for (const rootUri of ['file:///', 'file:///c:/foo/bar/', 'file:///foo/bar/']) { - describe(`rootUri ${rootUri}`, () => { - describeTypeScriptService((client, options) => new TypeScriptService(client, options), undefined, rootUri); - }); - } -}); + for (const rootUri of ['file:///', 'file:///c:/foo/bar/', 'file:///foo/bar/']) { + describe(`rootUri ${rootUri}`, () => { + describeTypeScriptService((client, options) => new TypeScriptService(client, options), undefined, rootUri) + }) + } +}) diff --git a/src/test/util-test.ts b/src/test/util-test.ts index 92f914c55..821c7f127 100644 --- a/src/test/util-test.ts +++ b/src/test/util-test.ts @@ -1,175 +1,175 @@ -import * as assert from 'assert'; -import { getMatchingPropertyCount, getPropertyCount, isGlobalTSFile, isSymbolDescriptorMatch, JSONPTR, path2uri, uri2path } from '../util'; +import * as assert from 'assert' +import { getMatchingPropertyCount, getPropertyCount, isGlobalTSFile, isSymbolDescriptorMatch, JSONPTR, path2uri, uri2path } from '../util' describe('util', () => { - describe('JSONPTR', () => { - it('should escape JSON Pointer components', () => { - const uri = 'file:///foo/~bar'; - const pointer = JSONPTR`/changes/${uri}/-`; - assert.equal(pointer, '/changes/file:~1~1~1foo~1~0bar/-'); - }); - }); - describe('getMatchingPropertyCount()', () => { - it('should return a score of 4 if 4 properties match', () => { - const score = getMatchingPropertyCount({ - containerName: 'ts', - kind: 'interface', - name: 'Program', - package: undefined - }, { - containerKind: 'module', - containerName: 'ts', - kind: 'interface', - name: 'Program', - package: undefined - }); - assert.equal(score, 4); - }); - it('should return a score of 0.6 if a string property is 60% similar', () => { - const score = getMatchingPropertyCount({ - filePath: 'lib/foo.d.ts' - }, { - filePath: 'src/foo.ts' - }); - assert.equal(score, 0.6); - }); - it('should return a score of 4 if 4 properties match and 1 does not', () => { - const score = getMatchingPropertyCount({ - containerKind: '', - containerName: 'util', - kind: 'var', - name: 'colors', - package: undefined - }, { - containerKind: '', - containerName: '', - kind: 'var', - name: 'colors', - package: undefined - }); - assert.equal(score, 4); - }); - it('should return a score of 3 if 3 properties match deeply', () => { - const score = getMatchingPropertyCount({ - name: 'a', - kind: 'class', - package: { name: 'mypkg' }, - containerKind: undefined - }, { - kind: 'class', - name: 'a', - containerKind: '', - containerName: '', - package: { name: 'mypkg' } - }); - assert.equal(score, 3); - }); - }); - describe('getPropertyCount()', () => { - it('should return the amount of leaf properties', () => { - const count = getPropertyCount({ - name: 'a', // 1 - kind: 'class', // 2 - package: { - name: 'mypkg' // 3 - }, - containerKind: '' // 4 - }); - assert.equal(count, 4); - }); - }); - describe('isSymbolDescriptorMatch()', () => { - it('should return true for a matching query', () => { - const matches = isSymbolDescriptorMatch({ - containerKind: undefined, - containerName: 'ts', - kind: 'interface', - name: 'Program', - filePath: 'foo/bar.ts', - package: undefined - }, { - containerKind: 'module', - containerName: 'ts', - kind: 'interface', - name: 'Program', - filePath: 'foo/bar.ts', - package: undefined - }); - assert.equal(matches, true); - }); - it('should return true for a matching query with PackageDescriptor', () => { - const matches = isSymbolDescriptorMatch({ - name: 'a', - kind: 'class', - package: { name: 'mypkg' }, - filePath: 'foo/bar.ts', - containerKind: undefined - }, { - kind: 'class', - name: 'a', - containerKind: '', - containerName: '', - filePath: 'foo/bar.ts', - package: { name: 'mypkg' } - }); - assert.equal(matches, true); - }); - }); - describe('isGlobalTSFile()', () => { - it('should match the synthetic reference to tsdlib when using importHelpers', () => { - assert.equal(isGlobalTSFile('/node_modules/tslib/tslib.d.ts'), true); - }); - it('should not include non-declaration files', () => { - assert.equal(isGlobalTSFile('/node_modules/@types/node/Readme.MD'), false); - }); - it('should include some libraries from @types with global declarations', () => { - assert.equal(isGlobalTSFile('/node_modules/@types/node/index.d.ts'), true); - assert.equal(isGlobalTSFile('/node_modules/@types/jest/index.d.ts'), true); - assert.equal(isGlobalTSFile('/node_modules/@types/jasmine/index.d.ts'), true); - assert.equal(isGlobalTSFile('/node_modules/@types/mocha/index.d.ts'), true); - }); - }); - describe('path2uri()', () => { - it('should throw an error if a non-absolute uri is passed in', () => { - assert.throws(() => path2uri('baz/qux')); - }); - it('should convert a Unix file path to a URI', () => { - const uri = path2uri('/baz/qux'); - assert.equal(uri, 'file:///baz/qux'); - }); - it('should convert a Windows file path to a URI', () => { - const uri = path2uri('C:\\baz\\qux'); - assert.equal(uri, 'file:///C:/baz/qux'); - }); - it('should encode special characters', () => { - const uri = path2uri('/💩'); - assert.equal(uri, 'file:///%F0%9F%92%A9'); - }); - it('should encode unreserved special characters', () => { - const uri = path2uri('/@baz'); - assert.equal(uri, 'file:///%40baz'); - }); - }); - describe('uri2path()', () => { - it('should convert a Unix file URI to a file path', () => { - const filePath = uri2path('file:///baz/qux'); - assert.equal(filePath, '/baz/qux'); - }); - it('should convert a Windows file URI to a file path', () => { - const filePath = uri2path('file:///c:/baz/qux'); - assert.equal(filePath, 'c:\\baz\\qux'); - }); - it('should convert a Windows file URI with uppercase drive letter to a file path', () => { - const filePath = uri2path('file:///C:/baz/qux'); - assert.equal(filePath, 'C:\\baz\\qux'); - }); - it('should decode special characters', () => { - const filePath = uri2path('file:///%F0%9F%92%A9'); - assert.equal(filePath, '/💩'); - }); - it('should decode unreserved special characters', () => { - const filePath = uri2path('file:///%40foo'); - assert.equal(filePath, '/@foo'); - }); - }); -}); + describe('JSONPTR', () => { + it('should escape JSON Pointer components', () => { + const uri = 'file:///foo/~bar' + const pointer = JSONPTR`/changes/${uri}/-` + assert.equal(pointer, '/changes/file:~1~1~1foo~1~0bar/-') + }) + }) + describe('getMatchingPropertyCount()', () => { + it('should return a score of 4 if 4 properties match', () => { + const score = getMatchingPropertyCount({ + containerName: 'ts', + kind: 'interface', + name: 'Program', + package: undefined + }, { + containerKind: 'module', + containerName: 'ts', + kind: 'interface', + name: 'Program', + package: undefined + }) + assert.equal(score, 4) + }) + it('should return a score of 0.6 if a string property is 60% similar', () => { + const score = getMatchingPropertyCount({ + filePath: 'lib/foo.d.ts' + }, { + filePath: 'src/foo.ts' + }) + assert.equal(score, 0.6) + }) + it('should return a score of 4 if 4 properties match and 1 does not', () => { + const score = getMatchingPropertyCount({ + containerKind: '', + containerName: 'util', + kind: 'var', + name: 'colors', + package: undefined + }, { + containerKind: '', + containerName: '', + kind: 'var', + name: 'colors', + package: undefined + }) + assert.equal(score, 4) + }) + it('should return a score of 3 if 3 properties match deeply', () => { + const score = getMatchingPropertyCount({ + name: 'a', + kind: 'class', + package: { name: 'mypkg' }, + containerKind: undefined + }, { + kind: 'class', + name: 'a', + containerKind: '', + containerName: '', + package: { name: 'mypkg' } + }) + assert.equal(score, 3) + }) + }) + describe('getPropertyCount()', () => { + it('should return the amount of leaf properties', () => { + const count = getPropertyCount({ + name: 'a', // 1 + kind: 'class', // 2 + package: { + name: 'mypkg' // 3 + }, + containerKind: '' // 4 + }) + assert.equal(count, 4) + }) + }) + describe('isSymbolDescriptorMatch()', () => { + it('should return true for a matching query', () => { + const matches = isSymbolDescriptorMatch({ + containerKind: undefined, + containerName: 'ts', + kind: 'interface', + name: 'Program', + filePath: 'foo/bar.ts', + package: undefined + }, { + containerKind: 'module', + containerName: 'ts', + kind: 'interface', + name: 'Program', + filePath: 'foo/bar.ts', + package: undefined + }) + assert.equal(matches, true) + }) + it('should return true for a matching query with PackageDescriptor', () => { + const matches = isSymbolDescriptorMatch({ + name: 'a', + kind: 'class', + package: { name: 'mypkg' }, + filePath: 'foo/bar.ts', + containerKind: undefined + }, { + kind: 'class', + name: 'a', + containerKind: '', + containerName: '', + filePath: 'foo/bar.ts', + package: { name: 'mypkg' } + }) + assert.equal(matches, true) + }) + }) + describe('isGlobalTSFile()', () => { + it('should match the synthetic reference to tsdlib when using importHelpers', () => { + assert.equal(isGlobalTSFile('/node_modules/tslib/tslib.d.ts'), true) + }) + it('should not include non-declaration files', () => { + assert.equal(isGlobalTSFile('/node_modules/@types/node/Readme.MD'), false) + }) + it('should include some libraries from @types with global declarations', () => { + assert.equal(isGlobalTSFile('/node_modules/@types/node/index.d.ts'), true) + assert.equal(isGlobalTSFile('/node_modules/@types/jest/index.d.ts'), true) + assert.equal(isGlobalTSFile('/node_modules/@types/jasmine/index.d.ts'), true) + assert.equal(isGlobalTSFile('/node_modules/@types/mocha/index.d.ts'), true) + }) + }) + describe('path2uri()', () => { + it('should throw an error if a non-absolute uri is passed in', () => { + assert.throws(() => path2uri('baz/qux')) + }) + it('should convert a Unix file path to a URI', () => { + const uri = path2uri('/baz/qux') + assert.equal(uri, 'file:///baz/qux') + }) + it('should convert a Windows file path to a URI', () => { + const uri = path2uri('C:\\baz\\qux') + assert.equal(uri, 'file:///C:/baz/qux') + }) + it('should encode special characters', () => { + const uri = path2uri('/💩') + assert.equal(uri, 'file:///%F0%9F%92%A9') + }) + it('should encode unreserved special characters', () => { + const uri = path2uri('/@baz') + assert.equal(uri, 'file:///%40baz') + }) + }) + describe('uri2path()', () => { + it('should convert a Unix file URI to a file path', () => { + const filePath = uri2path('file:///baz/qux') + assert.equal(filePath, '/baz/qux') + }) + it('should convert a Windows file URI to a file path', () => { + const filePath = uri2path('file:///c:/baz/qux') + assert.equal(filePath, 'c:\\baz\\qux') + }) + it('should convert a Windows file URI with uppercase drive letter to a file path', () => { + const filePath = uri2path('file:///C:/baz/qux') + assert.equal(filePath, 'C:\\baz\\qux') + }) + it('should decode special characters', () => { + const filePath = uri2path('file:///%F0%9F%92%A9') + assert.equal(filePath, '/💩') + }) + it('should decode unreserved special characters', () => { + const filePath = uri2path('file:///%40foo') + assert.equal(filePath, '/@foo') + }) + }) +}) diff --git a/src/tracing.ts b/src/tracing.ts index f641e1f4c..8fc77f24e 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -1,6 +1,6 @@ -import { Observable } from '@reactivex/rxjs'; -import { Span } from 'opentracing'; +import { Observable } from '@reactivex/rxjs' +import { Span } from 'opentracing' /** * Traces a synchronous function by passing it a new child span. @@ -12,16 +12,16 @@ import { Span } from 'opentracing'; * @param operation The function to call */ export function traceSync(operationName: string, childOf: Span, operation: (span: Span) => T): T { - const span = childOf.tracer().startSpan(operationName, { childOf }); - try { - return operation(span); - } catch (err) { - span.setTag('error', true); - span.log({ 'event': 'error', 'error.object': err, 'stack': err.stack, 'message': err.message }); - throw err; - } finally { - span.finish(); - } + const span = childOf.tracer().startSpan(operationName, { childOf }) + try { + return operation(span) + } catch (err) { + span.setTag('error', true) + span.log({ 'event': 'error', 'error.object': err, 'stack': err.stack, 'message': err.message }) + throw err + } finally { + span.finish() + } } /** @@ -34,16 +34,16 @@ export function traceSync(operationName: string, childOf: Span, operation: (s * @param operation The function to call */ export async function tracePromise(operationName: string, childOf: Span, operation: (span: Span) => Promise): Promise { - const span = childOf.tracer().startSpan(operationName, { childOf }); - try { - return await operation(span); - } catch (err) { - span.setTag('error', true); - span.log({ 'event': 'error', 'error.object': err, 'stack': err.stack, 'message': err.message }); - throw err; - } finally { - span.finish(); - } + const span = childOf.tracer().startSpan(operationName, { childOf }) + try { + return await operation(span) + } catch (err) { + span.setTag('error', true) + span.log({ 'event': 'error', 'error.object': err, 'stack': err.stack, 'message': err.message }) + throw err + } finally { + span.finish() + } } /** @@ -56,20 +56,20 @@ export async function tracePromise(operationName: string, childOf: Span, oper * @param operation The function to call */ export function traceObservable(operationName: string, childOf: Span, operation: (span: Span) => Observable): Observable { - const span = childOf.tracer().startSpan(operationName, { childOf }); - try { - return operation(span) - .do(undefined as any, err => { - span.setTag('error', true); - span.log({ 'event': 'error', 'error.object': err, 'stack': err.stack, 'message': err.message }); - }) - .finally(() => { - span.finish(); - }); - } catch (err) { - span.setTag('error', true); - span.log({ 'event': 'error', 'error.object': err, 'stack': err.stack, 'message': err.message }); - span.finish(); - return Observable.throw(err); - } + const span = childOf.tracer().startSpan(operationName, { childOf }) + try { + return operation(span) + .do(undefined as any, err => { + span.setTag('error', true) + span.log({ 'event': 'error', 'error.object': err, 'stack': err.stack, 'message': err.message }) + }) + .finally(() => { + span.finish() + }) + } catch (err) { + span.setTag('error', true) + span.log({ 'event': 'error', 'error.object': err, 'stack': err.stack, 'message': err.message }) + span.finish() + return Observable.throw(err) + } } diff --git a/src/typescript-service.ts b/src/typescript-service.ts index 4c6e6f116..2be507a9e 100644 --- a/src/typescript-service.ts +++ b/src/typescript-service.ts @@ -1,94 +1,117 @@ -import { Observable } from '@reactivex/rxjs'; -import { Operation } from 'fast-json-patch'; -import iterate from 'iterare'; -import { toPairs } from 'lodash'; -import { castArray, merge, omit } from 'lodash'; -import hashObject = require('object-hash'); -import { Span } from 'opentracing'; -import * as ts from 'typescript'; -import * as url from 'url'; +import { Observable } from '@reactivex/rxjs' +import { Operation } from 'fast-json-patch' +import iterate from 'iterare' +import { toPairs } from 'lodash' +import { castArray, merge, omit } from 'lodash' +import hashObject = require('object-hash') +import { Span } from 'opentracing' +import * as ts from 'typescript' +import * as url from 'url' import { - CodeActionParams, - Command, - CompletionItemKind, - CompletionList, - DidChangeConfigurationParams, - DidChangeTextDocumentParams, - DidCloseTextDocumentParams, - DidOpenTextDocumentParams, - DidSaveTextDocumentParams, - DocumentSymbolParams, - ExecuteCommandParams, - Hover, - InsertTextFormat, - Location, - MarkedString, - ParameterInformation, - ReferenceParams, - RenameParams, - SignatureHelp, - SignatureInformation, - SymbolInformation, - TextDocumentPositionParams, - TextDocumentSyncKind, - TextEdit, - WorkspaceEdit -} from 'vscode-languageserver'; -import { walkMostAST } from './ast'; -import { convertTsDiagnostic } from './diagnostics'; -import { FileSystem, FileSystemUpdater, LocalFileSystem, RemoteFileSystem } from './fs'; -import { LanguageClient } from './lang-handler'; -import { Logger, LSPLogger } from './logging'; -import { InMemoryFileSystem, isTypeScriptLibrary } from './memfs'; -import { extractDefinitelyTypedPackageName, extractNodeModulesPackageName, PackageJson, PackageManager } from './packages'; -import { ProjectConfiguration, ProjectManager } from './project-manager'; + CodeActionParams, + Command, + CompletionItemKind, + CompletionList, + DidChangeConfigurationParams, + DidChangeTextDocumentParams, + DidCloseTextDocumentParams, + DidOpenTextDocumentParams, + DidSaveTextDocumentParams, + DocumentSymbolParams, + ExecuteCommandParams, + Hover, + InsertTextFormat, + Location, + MarkedString, + ParameterInformation, + ReferenceParams, + RenameParams, + SignatureHelp, + SignatureInformation, + SymbolInformation, + TextDocumentPositionParams, + TextDocumentSyncKind, + TextEdit, + WorkspaceEdit +} from 'vscode-languageserver' +import { walkMostAST } from './ast' +import { convertTsDiagnostic } from './diagnostics' +import { FileSystem, FileSystemUpdater, LocalFileSystem, RemoteFileSystem } from './fs' +import { LanguageClient } from './lang-handler' +import { Logger, LSPLogger } from './logging' +import { InMemoryFileSystem, isTypeScriptLibrary } from './memfs' +import { extractDefinitelyTypedPackageName, extractNodeModulesPackageName, PackageJson, PackageManager } from './packages' +import { ProjectConfiguration, ProjectManager } from './project-manager' import { - CompletionItem, - DependencyReference, - InitializeParams, - InitializeResult, - PackageDescriptor, - PackageInformation, - PluginSettings, - ReferenceInformation, - SymbolDescriptor, - SymbolLocationInformation, - WorkspaceReferenceParams, - WorkspaceSymbolParams -} from './request-type'; + CompletionItem, + DependencyReference, + InitializeParams, + InitializeResult, + PackageDescriptor, + PackageInformation, + PluginSettings, + ReferenceInformation, + SymbolDescriptor, + SymbolLocationInformation, + WorkspaceReferenceParams, + WorkspaceSymbolParams +} from './request-type' import { - definitionInfoToSymbolDescriptor, - locationUri, - navigateToItemToSymbolInformation, - navigationTreeIsSymbol, - navigationTreeToSymbolDescriptor, - navigationTreeToSymbolInformation, - walkNavigationTree -} from './symbols'; -import { traceObservable } from './tracing'; + definitionInfoToSymbolDescriptor, + locationUri, + navigateToItemToSymbolInformation, + navigationTreeIsSymbol, + navigationTreeToSymbolDescriptor, + navigationTreeToSymbolInformation, + walkNavigationTree +} from './symbols' +import { traceObservable } from './tracing' import { - getMatchingPropertyCount, - getPropertyCount, - JSONPTR, - normalizeUri, - observableFromIterable, - path2uri, - toUnixPath, - uri2path -} from './util'; + getMatchingPropertyCount, + getPropertyCount, + JSONPTR, + normalizeUri, + observableFromIterable, + path2uri, + toUnixPath, + uri2path +} from './util' export interface TypeScriptServiceOptions { - traceModuleResolution?: boolean; - strict?: boolean; + traceModuleResolution?: boolean + strict?: boolean } -export type TypeScriptServiceFactory = (client: LanguageClient, options?: TypeScriptServiceOptions) => TypeScriptService; +export type TypeScriptServiceFactory = (client: LanguageClient, options?: TypeScriptServiceOptions) => TypeScriptService /** * Settings synced through `didChangeConfiguration` */ export interface Settings extends PluginSettings { - format: ts.FormatCodeSettings; + format: ts.FormatCodeSettings +} + +/** + * Maps string-based CompletionEntry::kind to enum-based CompletionItemKind + */ +const completionKinds: { [name: string]: CompletionItemKind } = { + class: CompletionItemKind.Class, + constructor: CompletionItemKind.Constructor, + enum: CompletionItemKind.Enum, + field: CompletionItemKind.Field, + file: CompletionItemKind.File, + function: CompletionItemKind.Function, + interface: CompletionItemKind.Interface, + keyword: CompletionItemKind.Keyword, + method: CompletionItemKind.Method, + module: CompletionItemKind.Module, + property: CompletionItemKind.Property, + reference: CompletionItemKind.Reference, + snippet: CompletionItemKind.Snippet, + text: CompletionItemKind.Text, + unit: CompletionItemKind.Unit, + value: CompletionItemKind.Value, + variable: CompletionItemKind.Variable } /** @@ -105,1412 +128,1391 @@ export interface Settings extends PluginSettings { */ export class TypeScriptService { - projectManager: ProjectManager; - - /** - * The rootPath as passed to `initialize` or converted from `rootUri` - */ - root: string; - - /** - * The root URI as passed to `initialize` or converted from `rootPath` - */ - protected rootUri: string; - - /** - * Cached response for empty workspace/symbol query - */ - private emptyQueryWorkspaceSymbols: Observable; - - private traceModuleResolution: boolean; - - /** - * The remote (or local), asynchronous, file system to fetch files from - */ - protected fileSystem: FileSystem; - - protected logger: Logger; - - /** - * Holds file contents and workspace structure in memory - */ - protected inMemoryFileSystem: InMemoryFileSystem; - - /** - * Syncs the remote file system with the in-memory file system - */ - protected updater: FileSystemUpdater; - - /** - * Emits true or false depending on whether the root package.json is named "definitely-typed". - * On DefinitelyTyped, files are not prefetched and a special workspace/symbol algorithm is used. - */ - protected isDefinitelyTyped: Observable; - - /** - * Keeps track of package.jsons in the workspace - */ - protected packageManager: PackageManager; - - /** - * Settings synced though `didChangeConfiguration` - */ - protected settings: Settings = { - format: { - tabSize: 4, - indentSize: 4, - newLineCharacter: '\n', - convertTabsToSpaces: false, - insertSpaceAfterCommaDelimiter: true, - insertSpaceAfterSemicolonInForStatements: true, - insertSpaceBeforeAndAfterBinaryOperators: true, - insertSpaceAfterKeywordsInControlFlowStatements: true, - insertSpaceAfterFunctionKeywordForAnonymousFunctions: true, - insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: false, - insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: false, - insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: false, - insertSpaceBeforeFunctionParenthesis: false, - placeOpenBraceOnNewLineForFunctions: false, - placeOpenBraceOnNewLineForControlBlocks: false - }, - allowLocalPluginLoads: false, - globalPlugins: [], - pluginProbeLocations: [] - }; - - /** - * Indicates if the client prefers completion results formatted as snippets. - */ - private supportsCompletionWithSnippets: boolean = false; - - constructor(protected client: LanguageClient, protected options: TypeScriptServiceOptions = {}) { - this.logger = new LSPLogger(client); - } - - /** - * The initialize request is sent as the first request from the client to the server. If the - * server receives request or notification before the `initialize` request it should act as - * follows: - * - * - for a request the respond should be errored with `code: -32002`. The message can be picked by - * the server. - * - notifications should be dropped, except for the exit notification. This will allow the exit a - * server without an initialize request. - * - * Until the server has responded to the `initialize` request with an `InitializeResult` the - * client must not sent any additional requests or notifications to the server. - * - * During the `initialize` request the server is allowed to sent the notifications - * `window/showMessage`, `window/logMessage` and `telemetry/event` as well as the - * `window/showMessageRequest` request to the client. - * - * @return Observable of JSON Patches that build an `InitializeResult` - */ - initialize(params: InitializeParams, span = new Span()): Observable { - if (params.rootUri || params.rootPath) { - this.root = params.rootPath || uri2path(params.rootUri!); - this.rootUri = params.rootUri || path2uri(params.rootPath!); - - this.supportsCompletionWithSnippets = params.capabilities.textDocument && - params.capabilities.textDocument.completion && - params.capabilities.textDocument.completion.completionItem && - params.capabilities.textDocument.completion.completionItem.snippetSupport || false; - - // The root URI always refers to a directory - if (!this.rootUri.endsWith('/')) { - this.rootUri += '/'; - } - this._initializeFileSystems(!this.options.strict && !(params.capabilities.xcontentProvider && params.capabilities.xfilesProvider)); - this.updater = new FileSystemUpdater(this.fileSystem, this.inMemoryFileSystem); - this.projectManager = new ProjectManager( - this.root, - this.inMemoryFileSystem, - this.updater, - this.traceModuleResolution, - this.settings, - this.logger - ); - this.packageManager = new PackageManager(this.updater, this.inMemoryFileSystem, this.logger); - // Detect DefinitelyTyped - // Fetch root package.json (if exists) - const normRootUri = this.rootUri.endsWith('/') ? this.rootUri : this.rootUri + '/'; - const packageJsonUri = normRootUri + 'package.json'; - this.isDefinitelyTyped = Observable.from(this.packageManager.getPackageJson(packageJsonUri, span)) - // Check name - .map(packageJson => packageJson.name === 'definitely-typed') - .catch(err => [false]) - .publishReplay() - .refCount(); - - // Pre-fetch files in the background if not DefinitelyTyped - this.isDefinitelyTyped - .mergeMap(isDefinitelyTyped => { - if (!isDefinitelyTyped) { - return this.projectManager.ensureOwnFiles(span); - } - return []; - }) - .subscribe(undefined, err => { - this.logger.error(err); - }); - } - const result: InitializeResult = { - capabilities: { - // Tell the client that the server works in FULL text document sync mode - textDocumentSync: TextDocumentSyncKind.Full, - hoverProvider: true, - signatureHelpProvider: { - triggerCharacters: ['(', ','] - }, - definitionProvider: true, - referencesProvider: true, - documentSymbolProvider: true, - workspaceSymbolProvider: true, - xworkspaceReferencesProvider: true, - xdefinitionProvider: true, - xdependenciesProvider: true, - completionProvider: { - resolveProvider: true, - triggerCharacters: ['.'] - }, - codeActionProvider: true, - renameProvider: true, - executeCommandProvider: { - commands: [] - }, - xpackagesProvider: true - } - }; - return Observable.of({ - op: 'add', - path: '', - value: result - } as Operation); - } - - /** - * Initializes the remote file system and in-memory file system. - * Can be overridden - * - * @param accessDisk Whether the language server is allowed to access the local file system - */ - protected _initializeFileSystems(accessDisk: boolean): void { - this.fileSystem = accessDisk ? new LocalFileSystem(this.rootUri) : new RemoteFileSystem(this.client); - this.inMemoryFileSystem = new InMemoryFileSystem(this.root, this.logger); - } - - /** - * The shutdown request is sent from the client to the server. It asks the server to shut down, - * but to not exit (otherwise the response might not be delivered correctly to the client). - * There is a separate exit notification that asks the server to exit. - * - * @return Observable of JSON Patches that build a `null` result - */ - shutdown(params = {}, span = new Span()): Observable { - this.projectManager.dispose(); - this.packageManager.dispose(); - return Observable.of({ op: 'add', path: '', value: null } as Operation); - } - - /** - * A notification sent from the client to the server to signal the change of configuration - * settings. - */ - workspaceDidChangeConfiguration(params: DidChangeConfigurationParams): void { - merge(this.settings, params.settings); - } - - /** - * The goto definition request is sent from the client to the server to resolve the definition - * location of a symbol at a given text document position. - * - * @return Observable of JSON Patches that build a `Location[]` result - */ - - textDocumentDefinition(params: TextDocumentPositionParams, span = new Span()): Observable { - return this._getDefinitionLocations(params, span) - .map((location: Location): Operation => ({ op: 'add', path: '/-', value: location })) - .startWith({ op: 'add', path: '', value: [] }); - } - - /** - * Returns an Observable of all definition locations found for a symbol. - */ - protected _getDefinitionLocations(params: TextDocumentPositionParams, span = new Span()): Observable { - const uri = normalizeUri(params.textDocument.uri); - - // Fetch files needed to resolve definition - return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) - .toArray() - .mergeMap(() => { - const fileName: string = uri2path(uri); - const configuration = this.projectManager.getConfiguration(fileName); - configuration.ensureBasicFiles(span); - - const sourceFile = this._getSourceFile(configuration, fileName, span); - if (!sourceFile) { - throw new Error(`Expected source file ${fileName} to exist`); - } - - const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character); - const definitions: ts.DefinitionInfo[] | undefined = configuration.getService().getDefinitionAtPosition(fileName, offset); - - return Observable.from(definitions || []) - .map((definition): Location => { - const sourceFile = this._getSourceFile(configuration, definition.fileName, span); - if (!sourceFile) { - throw new Error('expected source file "' + definition.fileName + '" to exist in configuration'); - } - const start = ts.getLineAndCharacterOfPosition(sourceFile, definition.textSpan.start); - const end = ts.getLineAndCharacterOfPosition(sourceFile, definition.textSpan.start + definition.textSpan.length); - return { - uri: locationUri(definition.fileName), - range: { - start, - end - } - }; - }); - }); - } - - /** - * This method is the same as textDocument/definition, except that: - * - * - The method returns metadata about the definition (the same metadata that - * workspace/xreferences searches for). - * - The concrete location to the definition (location field) - * is optional. This is useful because the language server might not be able to resolve a goto - * definition request to a concrete location (e.g. due to lack of dependencies) but still may - * know some information about it. - * - * @return Observable of JSON Patches that build a `SymbolLocationInformation[]` result - */ - - textDocumentXdefinition(params: TextDocumentPositionParams, span = new Span()): Observable { - return this._getSymbolLocationInformations(params, span) - .map(symbol => ({ op: 'add', path: '/-', value: symbol } as Operation)) - .startWith({ op: 'add', path: '', value: [] }); - } - - /** - * Returns an Observable of SymbolLocationInformations for the definition of a symbol at the given position - */ - protected _getSymbolLocationInformations(params: TextDocumentPositionParams, span = new Span()): Observable { - const uri = normalizeUri(params.textDocument.uri); - // Ensure files needed to resolve SymbolLocationInformation are fetched - return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) - .toArray() - .mergeMap(() => { - // Convert URI to file path - const fileName: string = uri2path(uri); - // Get closest tsconfig configuration - const configuration = this.projectManager.getConfiguration(fileName); - configuration.ensureBasicFiles(span); - const sourceFile = this._getSourceFile(configuration, fileName, span); - if (!sourceFile) { - throw new Error(`Unknown text document ${uri}`); - } - // Convert line/character to offset - const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character); - // Query TypeScript for references - return Observable.from(configuration.getService().getDefinitionAtPosition(fileName, offset) || []) - .mergeMap((definition: ts.DefinitionInfo): Observable => { - const definitionUri = locationUri(definition.fileName); - // Get the PackageDescriptor - return this._getPackageDescriptor(definitionUri) - .defaultIfEmpty(undefined) - .map((packageDescriptor: PackageDescriptor | undefined): SymbolLocationInformation => { - const sourceFile = this._getSourceFile(configuration, definition.fileName, span); - if (!sourceFile) { - throw new Error(`Expected source file ${definition.fileName} to exist in configuration`); - } - const symbol = definitionInfoToSymbolDescriptor(definition, this.root); - if (packageDescriptor) { - symbol.package = packageDescriptor; - } - return { - symbol, - location: { - uri: definitionUri, - range: { - start: ts.getLineAndCharacterOfPosition(sourceFile, definition.textSpan.start), - end: ts.getLineAndCharacterOfPosition(sourceFile, definition.textSpan.start + definition.textSpan.length) - } - } - }; - }); - }); - }); - } - - /** - * Finds the PackageDescriptor a given file belongs to - * - * @return Observable that emits a single PackageDescriptor or never if the definition does not belong to any package - */ - protected _getPackageDescriptor(uri: string, childOf = new Span()): Observable { - return traceObservable('Get PackageDescriptor', childOf, span => { - span.addTags({ uri }); - // Get package name of the dependency in which the symbol is defined in, if any - const packageName = extractNodeModulesPackageName(uri); - if (packageName) { - // The symbol is part of a dependency in node_modules - // Build URI to package.json of the Dependency - const encodedPackageName = packageName.split('/').map(encodeURIComponent).join('/'); - const parts: url.UrlObject = url.parse(uri); - const packageJsonUri = url.format({ ...parts, pathname: parts.pathname!.slice(0, parts.pathname!.lastIndexOf('/node_modules/' + encodedPackageName)) + `/node_modules/${encodedPackageName}/package.json` }); - // Fetch the package.json of the dependency - return this.updater.ensure(packageJsonUri, span) - .concat(Observable.defer((): Observable => { - const packageJson: PackageJson = JSON.parse(this.inMemoryFileSystem.getContent(packageJsonUri)); - const { name, version } = packageJson; - if (!name) { - return Observable.empty(); - } - // Used by the LSP proxy to shortcut database lookup of repo URL for PackageDescriptor - let repoURL: string | undefined; - if (name.startsWith('@types/')) { - // if the dependency package is an @types/ package, point the repo to DefinitelyTyped - repoURL = 'https://github.com/DefinitelyTyped/DefinitelyTyped'; - } else { - // else use repository field from package.json - repoURL = typeof packageJson.repository === 'object' ? packageJson.repository.url : undefined; - } - return Observable.of({ name, version, repoURL }); - })); - } else { - // The symbol is defined in the root package of the workspace, not in a dependency - // Get root package.json - return this.packageManager.getClosestPackageJson(uri, span) - .mergeMap(packageJson => { - let { name, version } = packageJson; - if (!name) { - return []; - } - let repoURL = typeof packageJson.repository === 'object' ? packageJson.repository.url : undefined; - // If the root package is DefinitelyTyped, find out the proper @types package name for each typing - if (name === 'definitely-typed') { - name = extractDefinitelyTypedPackageName(uri); - if (!name) { - this.logger.error(`Could not extract package name from DefinitelyTyped URI ${uri}`); - return []; - } - version = undefined; - repoURL = 'https://github.com/DefinitelyTyped/DefinitelyTyped'; - } - return [{ name, version, repoURL } as PackageDescriptor]; - }); - } - }); - } - - /** - * The hover request is sent from the client to the server to request hover information at a - * given text document position. - * - * @return Observable of JSON Patches that build a `Hover` result - */ - textDocumentHover(params: TextDocumentPositionParams, span = new Span()): Observable { - return this._getHover(params, span) - .map(hover => ({ op: 'add', path: '', value: hover }) as Operation); - } - - /** - * Returns an Observable for a Hover at the given position - */ - protected _getHover(params: TextDocumentPositionParams, span = new Span()): Observable { - const uri = normalizeUri(params.textDocument.uri); - - // Ensure files needed to resolve hover are fetched - return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) - .toArray() - .map((): Hover => { - const fileName: string = uri2path(uri); - const configuration = this.projectManager.getConfiguration(fileName); - configuration.ensureBasicFiles(span); - - const sourceFile = this._getSourceFile(configuration, fileName, span); - if (!sourceFile) { - throw new Error(`Unknown text document ${uri}`); - } - const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character); - const info = configuration.getService().getQuickInfoAtPosition(fileName, offset); - if (!info) { - return { contents: [] }; - } - const contents: (MarkedString | string)[] = []; - // Add declaration without the kind - const declaration = ts.displayPartsToString(info.displayParts).replace(/^\(.+\)\s+/, ''); - contents.push({ language: 'typescript', value: declaration }); - // Add kind with modifiers, e.g. "method (private, ststic)", "class (exported)" - if (info.kind) { - let kind = '**' + info.kind + '**'; - const modifiers = info.kindModifiers - .split(',') - // Filter out some quirks like "constructor (exported)" - .filter(mod => mod && ( - mod !== ts.ScriptElementKindModifier.exportedModifier - || info.kind !== ts.ScriptElementKind.constructorImplementationElement - )) - // Make proper adjectives - .map(mod => ({ - [ts.ScriptElementKindModifier.ambientModifier]: 'ambient', - [ts.ScriptElementKindModifier.exportedModifier]: 'exported' - })[mod] || mod); - if (modifiers.length > 0) { - kind += ' _(' + modifiers.join(', ') + ')_'; - } - contents.push(kind); - } - // Add documentation - const documentation = ts.displayPartsToString(info.documentation); - if (documentation) { - contents.push(documentation); - } - const start = ts.getLineAndCharacterOfPosition(sourceFile, info.textSpan.start); - const end = ts.getLineAndCharacterOfPosition(sourceFile, info.textSpan.start + info.textSpan.length); - - return { - contents, - range: { - start, - end - } - }; - }); - } - - /** - * The references request is sent from the client to the server to resolve project-wide - * references for the symbol denoted by the given text document position. - * - * Returns all references to the symbol at the position in the own workspace, including references inside node_modules. - * - * @return Observable of JSON Patches that build a `Location[]` result - */ - textDocumentReferences(params: ReferenceParams, span = new Span()): Observable { - const uri = normalizeUri(params.textDocument.uri); - - // Ensure all files were fetched to collect all references - return this.projectManager.ensureOwnFiles(span) - .concat(Observable.defer(() => { - // Convert URI to file path because TypeScript doesn't work with URIs - const fileName = uri2path(uri); - // Get tsconfig configuration for requested file - const configuration = this.projectManager.getConfiguration(fileName); - // Ensure all files have been added - configuration.ensureAllFiles(span); - const program = configuration.getProgram(span); - if (!program) { - return Observable.empty(); - } - // Get SourceFile object for requested file - const sourceFile = this._getSourceFile(configuration, fileName, span); - if (!sourceFile) { - throw new Error(`Source file ${fileName} does not exist`); - } - // Convert line/character to offset - const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character); - // Request references at position from TypeScript - // Despite the signature, getReferencesAtPosition() can return undefined - return Observable.from(configuration.getService().getReferencesAtPosition(fileName, offset) || []) - .filter(reference => - // Filter declaration if not requested - (!reference.isDefinition || (params.context && params.context.includeDeclaration)) - // Filter references in node_modules - && !reference.fileName.includes('/node_modules/') - ) - .map((reference): Location => { - const sourceFile = program.getSourceFile(reference.fileName); - if (!sourceFile) { - throw new Error(`Source file ${reference.fileName} does not exist`); - } - // Convert offset to line/character position - const start = ts.getLineAndCharacterOfPosition(sourceFile, reference.textSpan.start); - const end = ts.getLineAndCharacterOfPosition(sourceFile, reference.textSpan.start + reference.textSpan.length); - return { - uri: path2uri(reference.fileName), - range: { - start, - end - } - }; - }); - })) - .map((location: Location): Operation => ({ op: 'add', path: '/-', value: location })) - // Initialize with array - .startWith({ op: 'add', path: '', value: [] }); - } - - /** - * The workspace symbol request is sent from the client to the server to list project-wide - * symbols matching the query string. The text document parameter specifies the active document - * at time of the query. This can be used to rank or limit results. - * - * @return Observable of JSON Patches that build a `SymbolInformation[]` result - */ - workspaceSymbol(params: WorkspaceSymbolParams, span = new Span()): Observable { - - // Return cached result for empty query, if available - if (!params.query && !params.symbol && this.emptyQueryWorkspaceSymbols) { - return this.emptyQueryWorkspaceSymbols; - } - - /** A sorted array that keeps track of symbol match scores to determine the index to insert the symbol at */ - const scores: number[] = []; - - let observable = this.isDefinitelyTyped - .mergeMap((isDefinitelyTyped: boolean): Observable<[number, SymbolInformation]> => { - // Use special logic for DefinitelyTyped - // Search only in the correct subdirectory for the given PackageDescriptor - if (isDefinitelyTyped) { - // Error if not passed a SymbolDescriptor query with an `@types` PackageDescriptor - if (!params.symbol || !params.symbol.package || !params.symbol.package.name || !params.symbol.package.name.startsWith('@types/')) { - return Observable.throw(new Error('workspace/symbol on DefinitelyTyped is only supported with a SymbolDescriptor query with an @types PackageDescriptor')); - } - - // Fetch all files in the package subdirectory - // All packages are in the types/ subdirectory - const normRootUri = this.rootUri.endsWith('/') ? this.rootUri : this.rootUri + '/'; - const packageRootUri = normRootUri + params.symbol.package.name.substr(1) + '/'; - - return this.updater.ensureStructure(span) - .concat(Observable.defer(() => observableFromIterable(this.inMemoryFileSystem.uris()))) - .filter(uri => uri.startsWith(packageRootUri)) - .mergeMap(uri => this.updater.ensure(uri, span)) - .concat(Observable.defer(() => { - span.log({ event: 'fetched package files' }); - const config = this.projectManager.getParentConfiguration(packageRootUri, 'ts'); - if (!config) { - throw new Error(`Could not find tsconfig for ${packageRootUri}`); - } - // Don't match PackageDescriptor on symbols - return this._getSymbolsInConfig(config, omit(params.symbol!, 'package'), span); - })); - } - // Regular workspace symbol search - // Search all symbols in own code, but not in dependencies - return this.projectManager.ensureOwnFiles(span) - .concat(Observable.defer(() => { - if (params.symbol && params.symbol.package && params.symbol.package.name) { - // If SymbolDescriptor query with PackageDescriptor, search for package.jsons with matching package name - return observableFromIterable(this.packageManager.packageJsonUris()) - .filter(packageJsonUri => (JSON.parse(this.inMemoryFileSystem.getContent(packageJsonUri)) as PackageJson).name === params.symbol!.package!.name) - // Find their parent and child tsconfigs - .mergeMap(packageJsonUri => Observable.merge( - castArray(this.projectManager.getParentConfiguration(packageJsonUri) || []), - // Search child directories starting at the directory of the package.json - observableFromIterable(this.projectManager.getChildConfigurations(url.resolve(packageJsonUri, '.'))) - )); - } - // Else search all tsconfigs in the workspace - return observableFromIterable(this.projectManager.configurations()); - })) - // If PackageDescriptor is given, only search project with the matching package name - .mergeMap(config => this._getSymbolsInConfig(config, params.query || params.symbol, span)); - }) - // Filter duplicate symbols - // There may be few configurations that contain the same file(s) - // or files from different configurations may refer to the same file(s) - .distinct(symbol => hashObject(symbol, { respectType: false } as any)) - // Limit the total amount of symbols returned for text or empty queries - // Higher limit for programmatic symbol queries because it could exclude results with a higher score - .take(params.symbol ? 1000 : 100) - // Find out at which index to insert the symbol to maintain sorting order by score - .map(([score, symbol]) => { - const index = scores.findIndex(s => s < score); - if (index === -1) { - scores.push(score); - return { op: 'add', path: '/-', value: symbol } as Operation; - } - scores.splice(index, 0, score); - return { op: 'add', path: '/' + index, value: symbol } as Operation; - }) - .startWith({ op: 'add', path: '', value: [] }); - - if (!params.query && !params.symbol) { - observable = this.emptyQueryWorkspaceSymbols = observable.publishReplay().refCount(); - } - - return observable; - } - - /** - * The document symbol request is sent from the client to the server to list all symbols found - * in a given text document. - * - * @return Observable of JSON Patches that build a `SymbolInformation[]` result - */ - textDocumentDocumentSymbol(params: DocumentSymbolParams, span = new Span()): Observable { - const uri = normalizeUri(params.textDocument.uri); - - // Ensure files needed to resolve symbols are fetched - return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) - .toArray() - .mergeMap(() => { - const fileName = uri2path(uri); - - const config = this.projectManager.getConfiguration(fileName); - config.ensureBasicFiles(span); - const sourceFile = this._getSourceFile(config, fileName, span); - if (!sourceFile) { - return []; - } - const tree = config.getService().getNavigationTree(fileName); - return observableFromIterable(walkNavigationTree(tree)) - .filter(({ tree, parent }) => navigationTreeIsSymbol(tree)) - .map(({ tree, parent }) => navigationTreeToSymbolInformation(tree, parent, sourceFile, this.root)); - }) - .map(symbol => ({ op: 'add', path: '/-', value: symbol }) as Operation) - .startWith({ op: 'add', path: '', value: [] } as Operation); - } - - /** - * The workspace references request is sent from the client to the server to locate project-wide - * references to a symbol given its description / metadata. - * - * @return Observable of JSON Patches that build a `ReferenceInformation[]` result - */ - workspaceXreferences(params: WorkspaceReferenceParams, span = new Span()): Observable { - const queryWithoutPackage = omit(params.query, 'package'); - const minScore = Math.min(4.75, getPropertyCount(queryWithoutPackage)); - return this.isDefinitelyTyped - .mergeMap(isDefinitelyTyped => { - if (isDefinitelyTyped) { - throw new Error('workspace/xreferences not supported in DefinitelyTyped'); - } - return this.projectManager.ensureAllFiles(span); - }) - .concat(Observable.defer(() => { - // if we were hinted that we should only search a specific package, find it and only search the owning tsconfig.json - if (params.hints && params.hints.dependeePackageName) { - return observableFromIterable(this.packageManager.packageJsonUris()) - .filter(uri => (JSON.parse(this.inMemoryFileSystem.getContent(uri)) as PackageJson).name === params.hints!.dependeePackageName) - .take(1) - .mergeMap(uri => { - const config = this.projectManager.getParentConfiguration(uri); - if (!config) { - return observableFromIterable(this.projectManager.configurations()); - } - return [config]; - }); - } - // else search all tsconfig.jsons - return observableFromIterable(this.projectManager.configurations()); - })) - .mergeMap((config: ProjectConfiguration) => { - config.ensureAllFiles(span); - const program = config.getProgram(span); - if (!program) { - return Observable.empty(); - } - return Observable.from(program.getSourceFiles()) - // Ignore dependency files - .filter(source => !toUnixPath(source.fileName).includes('/node_modules/')) - .mergeMap(source => - // Iterate AST of source file - observableFromIterable(walkMostAST(source)) - // Filter Identifier Nodes - // TODO: include string-interpolated references - .filter((node): node is ts.Identifier => node.kind === ts.SyntaxKind.Identifier) - .mergeMap(node => { - try { - // Find definition for node - return Observable.from(config.getService().getDefinitionAtPosition(source.fileName, node.pos + 1) || []) - .mergeMap(definition => { - const symbol = definitionInfoToSymbolDescriptor(definition, this.root); - // Check if SymbolDescriptor without PackageDescriptor matches - const score = getMatchingPropertyCount(queryWithoutPackage, symbol); - if (score < minScore || (params.query.package && !definition.fileName.includes(params.query.package.name))) { - return []; - } - span.log({ event: 'match', score }); - // If no PackageDescriptor query, return match - if (!params.query.package || !params.query.package) { - return [symbol]; - } - // If SymbolDescriptor matched and the query contains a PackageDescriptor, get package.json and match PackageDescriptor name - // TODO match full PackageDescriptor (version) and fill out the symbol.package field - const uri = path2uri(definition.fileName); - return this._getPackageDescriptor(uri, span) - .defaultIfEmpty(undefined) - .filter(packageDescriptor => !!(packageDescriptor && packageDescriptor.name === params.query.package!.name!)) - .map(packageDescriptor => { - symbol.package = packageDescriptor; - return symbol; - }); - }) - .map((symbol: SymbolDescriptor): ReferenceInformation => ({ - symbol, - reference: { - uri: locationUri(source.fileName), - range: { - start: ts.getLineAndCharacterOfPosition(source, node.pos), - end: ts.getLineAndCharacterOfPosition(source, node.end) - } - } - })); - } catch (err) { - // Continue with next node on error - // Workaround for https://github.com/Microsoft/TypeScript/issues/15219 - this.logger.error(`workspace/xreferences: Error getting definition for ${source.fileName} at offset ${node.pos + 1}`, err); - span.log({ 'event': 'error', 'error.object': err, 'message': err.message, 'stack': err.stack }); - return []; - } - }) - ); - }) - .map((reference): Operation => ({ op: 'add', path: '/-', value: reference })) - .startWith({ op: 'add', path: '', value: [] }); - } - - /** - * This method returns metadata about the package(s) defined in a workspace and a list of - * dependencies for each package. - * - * This method is necessary to implement cross-repository jump-to-def when it is not possible to - * resolve the global location of the definition from data present or derived from the local - * workspace. For example, a package manager might not include information about the source - * repository of each dependency. In this case, definition resolution requires mapping from - * package descriptor to repository revision URL. A reverse index can be constructed from calls - * to workspace/xpackages to provide an efficient mapping. - * - * @return Observable of JSON Patches that build a `PackageInformation[]` result - */ - workspaceXpackages(params = {}, span = new Span()): Observable { - return this.isDefinitelyTyped - .mergeMap((isDefinitelyTyped: boolean): Observable => { - // In DefinitelyTyped, report all @types/ packages - if (isDefinitelyTyped) { - const typesUri = url.resolve(this.rootUri, 'types/'); - return observableFromIterable(this.inMemoryFileSystem.uris()) - // Find all types/ subdirectories - .filter(uri => uri.startsWith(typesUri)) - // Get the directory names - .map((uri): PackageInformation => ({ - package: { - name: '@types/' + decodeURIComponent(uri.substr(typesUri.length).split('/')[0]) - // TODO report a version by looking at subfolders like v6 - }, - // TODO parse /// comments in .d.ts files for collecting dependencies between @types packages - dependencies: [] - })); - } - // For other workspaces, search all package.json files - return this.projectManager.ensureModuleStructure(span) - // Iterate all files - .concat(Observable.defer(() => observableFromIterable(this.inMemoryFileSystem.uris()))) - // Filter own package.jsons - .filter(uri => uri.includes('/package.json') && !uri.includes('/node_modules/')) - // Map to contents of package.jsons - .mergeMap(uri => this.packageManager.getPackageJson(uri)) - // Map each package.json to a PackageInformation - .mergeMap(packageJson => { - if (!packageJson.name) { - return []; - } - const packageDescriptor: PackageDescriptor = { - name: packageJson.name, - version: packageJson.version, - repoURL: typeof packageJson.repository === 'object' && packageJson.repository.url || undefined - }; - // Collect all dependencies for this package.json - return Observable.of('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies') - .filter(key => !!packageJson[key]) - // Get [name, version] pairs - .mergeMap(key => toPairs(packageJson[key]) as [string, string][]) - // Map to DependencyReferences - .map(([name, version]): DependencyReference => ({ - attributes: { - name, - version - }, - hints: { - dependeePackageName: packageJson.name - } - })) - .toArray() - .map((dependencies): PackageInformation => ({ - package: packageDescriptor, - dependencies - })); - }); - }) - .map((packageInfo): Operation => ({ op: 'add', path: '/-', value: packageInfo })) - .startWith({ op: 'add', path: '', value: [] }); - } - - /** - * Returns all dependencies of a workspace. - * Superseded by workspace/xpackages - * - * @return Observable of JSON Patches that build a `DependencyReference[]` result - */ - workspaceXdependencies(params = {}, span = new Span()): Observable { - // Ensure package.json files - return this.projectManager.ensureModuleStructure() - // Iterate all files - .concat(Observable.defer(() => observableFromIterable(this.inMemoryFileSystem.uris()))) - // Filter own package.jsons - .filter(uri => uri.includes('/package.json') && !uri.includes('/node_modules/')) - // Ensure contents of own package.jsons - .mergeMap(uri => this.packageManager.getPackageJson(uri)) - // Map package.json to DependencyReferences - .mergeMap(packageJson => - Observable.of('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies') - .filter(key => !!packageJson[key]) - // Get [name, version] pairs - .mergeMap(key => toPairs(packageJson[key]) as [string, string][]) - .map(([name, version]): DependencyReference => ({ - attributes: { - name, - version - }, - hints: { - dependeePackageName: packageJson.name - } - })) - ) - .map((dependency): Operation => ({ op: 'add', path: '/-', value: dependency })) - .startWith({ op: 'add', path: '', value: [] }); - } - - /** - * The Completion request is sent from the client to the server to compute completion items at a - * given cursor position. Completion items are presented in the - * [IntelliSense](https://code.visualstudio.com/docs/editor/editingevolved#_intellisense) user - * interface. If computing full completion items is expensive, servers can additionally provide - * a handler for the completion item resolve request ('completionItem/resolve'). This request is - * sent when a completion item is selected in the user interface. A typically use case is for - * example: the 'textDocument/completion' request doesn't fill in the `documentation` property - * for returned completion items since it is expensive to compute. When the item is selected in - * the user interface then a 'completionItem/resolve' request is sent with the selected - * completion item as a param. The returned completion item should have the documentation - * property filled in. - * - * @return Observable of JSON Patches that build a `CompletionList` result - */ - textDocumentCompletion(params: TextDocumentPositionParams, span = new Span()): Observable { - const uri = normalizeUri(params.textDocument.uri); - - // Ensure files needed to suggest completions are fetched - return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) - .toArray() - .mergeMap(() => { - - const fileName: string = uri2path(uri); - - const configuration = this.projectManager.getConfiguration(fileName); - configuration.ensureBasicFiles(span); - - const sourceFile = this._getSourceFile(configuration, fileName, span); - if (!sourceFile) { - return []; - } - - const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character); - const completions = configuration.getService().getCompletionsAtPosition(fileName, offset); - - if (completions == null) { - return []; - } - - return Observable.from(completions.entries) - .map(entry => { - const item: CompletionItem = { label: entry.name }; - - const kind = completionKinds[entry.kind]; - if (kind) { - item.kind = kind; - } - if (entry.sortText) { - item.sortText = entry.sortText; - } - - // context for future resolve requests: - item.data = { - uri, - offset, - entryName: entry.name - }; - - return { op: 'add', path: '/items/-', value: item } as Operation; - }) - .startWith({ op: 'add', path: '/isIncomplete', value: false } as Operation); - }) - .startWith({ op: 'add', path: '', value: { isIncomplete: true, items: [] } as CompletionList } as Operation); - } - - /** - * The completionItem/resolve request is used to fill in additional details from an incomplete - * CompletionItem returned from the textDocument/completions call. - * - * @return Observable of JSON Patches that build a `CompletionItem` result - */ - completionItemResolve(item: CompletionItem, span = new Span()): Observable { - if (!item.data) { - throw new Error('Cannot resolve completion item without data'); - } - const {uri, offset, entryName} = item.data; - const fileName: string = uri2path(uri); - return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) - .toArray() - .map(() => { - - const configuration = this.projectManager.getConfiguration(fileName); - configuration.ensureBasicFiles(span); - - const details = configuration.getService().getCompletionEntryDetails(fileName, offset, entryName); - if (details) { - item.documentation = ts.displayPartsToString(details.documentation); - item.detail = ts.displayPartsToString(details.displayParts); - if (this.supportsCompletionWithSnippets) { - item.insertTextFormat = InsertTextFormat.Snippet; - if (details.kind === 'method' || details.kind === 'function') { - const parameters = details.displayParts - .filter(p => p.kind === 'parameterName') - .map((p, i) => '${' + `${i + 1}:${p.text}` + '}'); - const paramString = parameters.join(', '); - item.insertText = details.name + `(${paramString})`; - } else { - item.insertText = details.name; - - } - } else { - item.insertTextFormat = InsertTextFormat.PlainText; - item.insertText = details.name; - } - item.data = undefined; - } - return item; - }) - .map(completionItem => ({ op: 'add', path: '', value: completionItem }) as Operation); - } - - /** - * The signature help request is sent from the client to the server to request signature - * information at a given cursor position. - * - * @return Observable of JSON Patches that build a `SignatureHelp` result - */ - textDocumentSignatureHelp(params: TextDocumentPositionParams, span = new Span()): Observable { - const uri = normalizeUri(params.textDocument.uri); - - // Ensure files needed to resolve signature are fetched - return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) - .toArray() - .map((): SignatureHelp => { - - const filePath = uri2path(uri); - const configuration = this.projectManager.getConfiguration(filePath); - configuration.ensureBasicFiles(span); - - const sourceFile = this._getSourceFile(configuration, filePath, span); - if (!sourceFile) { - throw new Error(`expected source file ${filePath} to exist in configuration`); - } - const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character); - - const signatures: ts.SignatureHelpItems = configuration.getService().getSignatureHelpItems(filePath, offset); - if (!signatures) { - return { signatures: [], activeParameter: 0, activeSignature: 0 }; - } - - const signatureInformations = signatures.items.map((item): SignatureInformation => { - const prefix = ts.displayPartsToString(item.prefixDisplayParts); - const params = item.parameters.map(p => ts.displayPartsToString(p.displayParts)).join(', '); - const suffix = ts.displayPartsToString(item.suffixDisplayParts); - const parameters = item.parameters.map((p): ParameterInformation => ({ - label: ts.displayPartsToString(p.displayParts), - documentation: ts.displayPartsToString(p.documentation) - })); - return { - label: prefix + params + suffix, - documentation: ts.displayPartsToString(item.documentation), - parameters - }; - }); - - return { - signatures: signatureInformations, - activeSignature: signatures.selectedItemIndex, - activeParameter: signatures.argumentIndex - }; - }) - .map(signatureHelp => ({ op: 'add', path: '', value: signatureHelp }) as Operation); - } - - /** - * The code action request is sent from the client to the server to compute commands for a given - * text document and range. These commands are typically code fixes to either fix problems or to - * beautify/refactor code. - * - * @return Observable of JSON Patches that build a `Command[]` result - */ - textDocumentCodeAction(params: CodeActionParams, span = new Span()): Observable { - const uri = normalizeUri(params.textDocument.uri); - return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) - .toArray() - .mergeMap(() => { - const configuration = this.projectManager.getParentConfiguration(uri); - if (!configuration) { - throw new Error(`Could not find tsconfig for ${uri}`); - } - configuration.ensureBasicFiles(span); - - const filePath = uri2path(uri); - const sourceFile = this._getSourceFile(configuration, filePath, span); - if (!sourceFile) { - throw new Error(`Expected source file ${filePath} to exist in configuration`); - } - - const start = ts.getPositionOfLineAndCharacter(sourceFile, params.range.start.line, params.range.start.character); - const end = ts.getPositionOfLineAndCharacter(sourceFile, params.range.end.line, params.range.end.character); - - const errorCodes = iterate(params.context.diagnostics) - .map(diagnostic => diagnostic.code) - .filter(code => typeof code === 'number') - .toArray() as number[]; - - return configuration.getService().getCodeFixesAtPosition(filePath, start, end, errorCodes, this.settings.format || {}) || []; - }) - .map((action: ts.CodeAction): Operation => ({ - op: 'add', - path: '/-', - value: { - title: action.description, - command: 'codeFix', - arguments: action.changes - } as Command - })) - .startWith({ op: 'add', path: '', value: [] } as Operation); - } - - /** - * The workspace/executeCommand request is sent from the client to the server to trigger command - * execution on the server. In most cases the server creates a WorkspaceEdit structure and - * applies the changes to the workspace using the request workspace/applyEdit which is sent from - * the server to the client. - */ - workspaceExecuteCommand(params: ExecuteCommandParams, span = new Span()): Observable { - switch (params.command) { - case 'codeFix': - if (!params.arguments || params.arguments.length < 1) { - return Observable.throw(new Error(`Command ${params.command} requires arguments`)); - } - return this.executeCodeFixCommand(params.arguments, span); - default: - return Observable.throw(new Error(`Unknown command ${params.command}`)); - } - } - - /** - * Executes the `codeFix` command - * - * @return Observable of JSON Patches for `null` result - */ - executeCodeFixCommand(fileTextChanges: ts.FileTextChanges[], span = new Span()): Observable { - if (fileTextChanges.length === 0) { - return Observable.throw(new Error('No changes supplied for code fix command')); - } - - return this.projectManager.ensureOwnFiles(span) - .concat(Observable.defer(() => { - const configuration = this.projectManager.getConfiguration(fileTextChanges[0].fileName); - configuration.ensureBasicFiles(span); - - const changes: {[uri: string]: TextEdit[]} = {}; - for (const change of fileTextChanges) { - const sourceFile = this._getSourceFile(configuration, change.fileName, span); - if (!sourceFile) { - throw new Error(`Expected source file ${change.fileName} to exist in configuration`); - } - const uri = path2uri(change.fileName); - changes[uri] = change.textChanges.map(({ span, newText }): TextEdit => ({ - range: { - start: ts.getLineAndCharacterOfPosition(sourceFile, span.start), - end: ts.getLineAndCharacterOfPosition(sourceFile, span.start + span.length) - }, - newText - })); - } - - return this.client.workspaceApplyEdit({ edit: { changes }}, span); - })) - .map(() => ({ op: 'add', path: '', value: null }) as Operation); - } - - /** - * The rename request is sent from the client to the server to perform a workspace-wide rename of a symbol. - * - * @return Observable of JSON Patches that build a `WorkspaceEdit` result - */ - textDocumentRename(params: RenameParams, span = new Span()): Observable { - const uri = normalizeUri(params.textDocument.uri); - const editUris = new Set(); - return this.projectManager.ensureOwnFiles(span) - .concat(Observable.defer(() => { - - const filePath = uri2path(uri); - const configuration = this.projectManager.getParentConfiguration(params.textDocument.uri); - if (!configuration) { - throw new Error(`tsconfig.json not found for ${filePath}`); - } - configuration.ensureAllFiles(span); - - const sourceFile = this._getSourceFile(configuration, filePath, span); - if (!sourceFile) { - throw new Error(`Expected source file ${filePath} to exist in configuration`); - } - - const position = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character); - - const renameInfo = configuration.getService().getRenameInfo(filePath, position); - if (!renameInfo.canRename) { - throw new Error('This symbol cannot be renamed'); - } - - return Observable.from(configuration.getService().findRenameLocations(filePath, position, false, true)) - .map((location: ts.RenameLocation): [string, TextEdit] => { - const sourceFile = this._getSourceFile(configuration, location.fileName, span); - if (!sourceFile) { - throw new Error(`expected source file ${location.fileName} to exist in configuration`); - } - const editUri = path2uri(location.fileName); - const start = ts.getLineAndCharacterOfPosition(sourceFile, location.textSpan.start); - const end = ts.getLineAndCharacterOfPosition(sourceFile, location.textSpan.start + location.textSpan.length); - const edit: TextEdit = { range: { start, end }, newText: params.newName }; - return [editUri, edit]; - }); - })) - .map(([uri, edit]): Operation => { - // if file has no edit yet, initialize array - if (!editUris.has(uri)) { - editUris.add(uri); - return { op: 'add', path: JSONPTR`/changes/${uri}`, value: [edit] }; - } - // else append to array - return { op: 'add', path: JSONPTR`/changes/${uri}/-`, value: edit }; - }) - .startWith({ op: 'add', path: '', value: { changes: {} } as WorkspaceEdit } as Operation); - } - - /** - * The document open notification is sent from the client to the server to signal newly opened - * text documents. The document's truth is now managed by the client and the server must not try - * to read the document's truth using the document's uri. - */ - async textDocumentDidOpen(params: DidOpenTextDocumentParams): Promise { - const uri = normalizeUri(params.textDocument.uri); - // Ensure files needed for most operations are fetched - await this.projectManager.ensureReferencedFiles(uri).toPromise(); - this.projectManager.didOpen(uri, params.textDocument.text); - await new Promise(resolve => setTimeout(resolve, 200)); - this._publishDiagnostics(uri); - } - - /** - * The document change notification is sent from the client to the server to signal changes to a - * text document. In 2.0 the shape of the params has changed to include proper version numbers - * and language ids. - */ - async textDocumentDidChange(params: DidChangeTextDocumentParams): Promise { - const uri = normalizeUri(params.textDocument.uri); - let text: string | undefined; - for (const change of params.contentChanges) { - if (change.range || change.rangeLength) { - throw new Error('incremental updates in textDocument/didChange not supported for file ' + uri); - } - text = change.text; - } - if (!text) { - return; - } - this.projectManager.didChange(uri, text); - await new Promise(resolve => setTimeout(resolve, 200)); - this._publishDiagnostics(uri); - } - - /** - * Generates and publishes diagnostics for a given file - * - * @param uri URI of the file to check - */ - private _publishDiagnostics(uri: string, span = new Span()): void { - const config = this.projectManager.getParentConfiguration(uri); - if (!config) { - return; - } - const fileName = uri2path(uri); - const tsDiagnostics = config.getService().getSyntacticDiagnostics(fileName).concat(config.getService().getSemanticDiagnostics(fileName)); - const diagnostics = iterate(tsDiagnostics) - // TS can report diagnostics without a file and range in some cases - // These cannot be represented as LSP Diagnostics since the range and URI is required - // https://github.com/Microsoft/TypeScript/issues/15666 - .filter(diagnostic => !!diagnostic.file) - .map(convertTsDiagnostic) - .toArray(); - this.client.textDocumentPublishDiagnostics({ uri, diagnostics }); - } - - /** - * The document save notification is sent from the client to the server when the document was - * saved in the client. - */ - async textDocumentDidSave(params: DidSaveTextDocumentParams): Promise { - const uri = normalizeUri(params.textDocument.uri); - - // Ensure files needed to suggest completions are fetched - await this.projectManager.ensureReferencedFiles(uri).toPromise(); - this.projectManager.didSave(uri); - } - - /** - * The document close notification is sent from the client to the server when the document got - * closed in the client. The document's truth now exists where the document's uri points to - * (e.g. if the document's uri is a file uri the truth now exists on disk). - */ - async textDocumentDidClose(params: DidCloseTextDocumentParams): Promise { - const uri = normalizeUri(params.textDocument.uri); - - // Ensure files needed to suggest completions are fetched - await this.projectManager.ensureReferencedFiles(uri).toPromise(); - - this.projectManager.didClose(uri); - - // Clear diagnostics - this.client.textDocumentPublishDiagnostics({ uri, diagnostics: [] }); - } - - /** - * Fetches (or creates if needed) source file object for a given file name - * - * @param configuration project configuration - * @param fileName file name to fetch source file for or create it - * @param span Span for tracing - */ - private _getSourceFile(configuration: ProjectConfiguration, fileName: string, span = new Span()): ts.SourceFile | undefined { - let program = configuration.getProgram(span); - if (!program) { - return undefined; - } - const sourceFile = program.getSourceFile(fileName); - if (sourceFile) { - return sourceFile; - } - if (!this.projectManager.hasFile(fileName)) { - return undefined; - } - configuration.getHost().addFile(fileName); - program = configuration.getProgram(span); - return program && program.getSourceFile(fileName); - } - - /** - * Returns an Observable for all symbols in a given config that match a given SymbolDescriptor or text query - * - * @param config The ProjectConfiguration to search - * @param query A text or SymbolDescriptor query - * @return Observable of [match score, SymbolInformation] - */ - protected _getSymbolsInConfig(config: ProjectConfiguration, query?: string | Partial, childOf = new Span()): Observable<[number, SymbolInformation]> { - return traceObservable('Get symbols in config', childOf, span => { - span.addTags({ config: config.configFilePath, query }); - config.ensureAllFiles(span); - - const program = config.getProgram(span); - if (!program) { - return Observable.empty(); - } - - if (typeof query === 'string') { - // Query by text query - // Limit the amount of symbols searched for text queries - return Observable.from(config.getService().getNavigateToItems(query, 100, undefined, false)) - // Exclude dependencies and standard library - .filter(item => !isTypeScriptLibrary(item.fileName) && !item.fileName.includes('/node_modules/')) - // Same score for all - .map(item => [1, navigateToItemToSymbolInformation(item, program, this.root)] as [number, SymbolInformation]); - } else { - const queryWithoutPackage = query && omit(query, 'package') as SymbolDescriptor; - // Require at least 2 properties to match (or all if less provided) - const minScore = Math.min(2, getPropertyCount(query)); - const minScoreWithoutPackage = Math.min(2, getPropertyCount(queryWithoutPackage)); - const service = config.getService(); - return Observable.from(program.getSourceFiles()) - // Exclude dependencies and standard library - .filter(sourceFile => !isTypeScriptLibrary(sourceFile.fileName) && !sourceFile.fileName.includes('/node_modules/')) - .mergeMap(sourceFile => { - try { - const tree = service.getNavigationTree(sourceFile.fileName); - const nodes = observableFromIterable(walkNavigationTree(tree)) - .filter(({ tree, parent }) => navigationTreeIsSymbol(tree)); - let matchedNodes: Observable<{ score: number, tree: ts.NavigationTree, parent?: ts.NavigationTree }>; - if (!query) { - matchedNodes = nodes - .map(({ tree, parent }) => ({ score: 1, tree, parent })); - } else { - matchedNodes = nodes - // Get a score how good the symbol matches the SymbolDescriptor (ignoring PackageDescriptor) - .map(({ tree, parent }) => { - const symbolDescriptor = navigationTreeToSymbolDescriptor(tree, parent, sourceFile.fileName, this.root); - const score = getMatchingPropertyCount(queryWithoutPackage, symbolDescriptor); - return { score, tree, parent }; - }) - // Require the minimum score without the PackageDescriptor name - .filter(({ score }) => score >= minScoreWithoutPackage) - // If SymbolDescriptor matched, get package.json and match PackageDescriptor name - // TODO get and match full PackageDescriptor (version) - .mergeMap(({ score, tree, parent }) => { - if (!query.package || !query.package.name) { - return [{ score, tree, parent }]; - } - const uri = path2uri(sourceFile.fileName); - return this.packageManager.getClosestPackageJson(uri, span) - // If PackageDescriptor matches, increase score - .defaultIfEmpty(undefined) - .map(packageJson => { - if (packageJson && packageJson.name === query.package!.name!) { - score++; - } - return { score, tree, parent }; - }); - }) - // Require a minimum score to not return thousands of results - .filter(({ score }) => score >= minScore); - } - return matchedNodes - .map(({ score, tree, parent }) => [score, navigationTreeToSymbolInformation(tree, parent, sourceFile, this.root)] as [number, SymbolInformation]); - } catch (e) { - this.logger.error('Could not get navigation tree for file', sourceFile.fileName); - return []; - } - }); - } - }); - } + public projectManager: ProjectManager + + /** + * The rootPath as passed to `initialize` or converted from `rootUri` + */ + public root: string + + /** + * The root URI as passed to `initialize` or converted from `rootPath` + */ + protected rootUri: string + + /** + * Cached response for empty workspace/symbol query + */ + private emptyQueryWorkspaceSymbols: Observable + + private traceModuleResolution: boolean + + /** + * The remote (or local), asynchronous, file system to fetch files from + */ + protected fileSystem: FileSystem + + protected logger: Logger + + /** + * Holds file contents and workspace structure in memory + */ + protected inMemoryFileSystem: InMemoryFileSystem + + /** + * Syncs the remote file system with the in-memory file system + */ + protected updater: FileSystemUpdater + + /** + * Emits true or false depending on whether the root package.json is named "definitely-typed". + * On DefinitelyTyped, files are not prefetched and a special workspace/symbol algorithm is used. + */ + protected isDefinitelyTyped: Observable + + /** + * Keeps track of package.jsons in the workspace + */ + protected packageManager: PackageManager + + /** + * Settings synced though `didChangeConfiguration` + */ + protected settings: Settings = { + format: { + tabSize: 4, + indentSize: 4, + newLineCharacter: '\n', + convertTabsToSpaces: false, + insertSpaceAfterCommaDelimiter: true, + insertSpaceAfterSemicolonInForStatements: true, + insertSpaceBeforeAndAfterBinaryOperators: true, + insertSpaceAfterKeywordsInControlFlowStatements: true, + insertSpaceAfterFunctionKeywordForAnonymousFunctions: true, + insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: false, + insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: false, + insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: false, + insertSpaceBeforeFunctionParenthesis: false, + placeOpenBraceOnNewLineForFunctions: false, + placeOpenBraceOnNewLineForControlBlocks: false + }, + allowLocalPluginLoads: false, + globalPlugins: [], + pluginProbeLocations: [] + } + + /** + * Indicates if the client prefers completion results formatted as snippets. + */ + private supportsCompletionWithSnippets = false + + constructor(protected client: LanguageClient, protected options: TypeScriptServiceOptions = {}) { + this.logger = new LSPLogger(client) + } + + /** + * The initialize request is sent as the first request from the client to the server. If the + * server receives request or notification before the `initialize` request it should act as + * follows: + * + * - for a request the respond should be errored with `code: -32002`. The message can be picked by + * the server. + * - notifications should be dropped, except for the exit notification. This will allow the exit a + * server without an initialize request. + * + * Until the server has responded to the `initialize` request with an `InitializeResult` the + * client must not sent any additional requests or notifications to the server. + * + * During the `initialize` request the server is allowed to sent the notifications + * `window/showMessage`, `window/logMessage` and `telemetry/event` as well as the + * `window/showMessageRequest` request to the client. + * + * @return Observable of JSON Patches that build an `InitializeResult` + */ + public initialize(params: InitializeParams, span = new Span()): Observable { + // tslint:disable:deprecation + if (params.rootUri || params.rootPath) { + this.root = params.rootPath || uri2path(params.rootUri!) + this.rootUri = params.rootUri || path2uri(params.rootPath!) + // tslint:enable:deprecation + + this.supportsCompletionWithSnippets = params.capabilities.textDocument && + params.capabilities.textDocument.completion && + params.capabilities.textDocument.completion.completionItem && + params.capabilities.textDocument.completion.completionItem.snippetSupport || false + + // The root URI always refers to a directory + if (!this.rootUri.endsWith('/')) { + this.rootUri += '/' + } + this._initializeFileSystems(!this.options.strict && !(params.capabilities.xcontentProvider && params.capabilities.xfilesProvider)) + this.updater = new FileSystemUpdater(this.fileSystem, this.inMemoryFileSystem) + this.projectManager = new ProjectManager( + this.root, + this.inMemoryFileSystem, + this.updater, + this.traceModuleResolution, + this.settings, + this.logger + ) + this.packageManager = new PackageManager(this.updater, this.inMemoryFileSystem, this.logger) + // Detect DefinitelyTyped + // Fetch root package.json (if exists) + const normRootUri = this.rootUri.endsWith('/') ? this.rootUri : this.rootUri + '/' + const packageJsonUri = normRootUri + 'package.json' + this.isDefinitelyTyped = Observable.from(this.packageManager.getPackageJson(packageJsonUri, span)) + // Check name + .map(packageJson => packageJson.name === 'definitely-typed') + .catch(err => [false]) + .publishReplay() + .refCount() + + // Pre-fetch files in the background if not DefinitelyTyped + this.isDefinitelyTyped + .mergeMap(isDefinitelyTyped => { + if (!isDefinitelyTyped) { + return this.projectManager.ensureOwnFiles(span) + } + return [] + }) + .subscribe(undefined, err => { + this.logger.error(err) + }) + } + const result: InitializeResult = { + capabilities: { + // Tell the client that the server works in FULL text document sync mode + textDocumentSync: TextDocumentSyncKind.Full, + hoverProvider: true, + signatureHelpProvider: { + triggerCharacters: ['(', ','] + }, + definitionProvider: true, + referencesProvider: true, + documentSymbolProvider: true, + workspaceSymbolProvider: true, + xworkspaceReferencesProvider: true, + xdefinitionProvider: true, + xdependenciesProvider: true, + completionProvider: { + resolveProvider: true, + triggerCharacters: ['.'] + }, + codeActionProvider: true, + renameProvider: true, + executeCommandProvider: { + commands: [] + }, + xpackagesProvider: true + } + } + return Observable.of({ + op: 'add', + path: '', + value: result + } as Operation) + } + + /** + * Initializes the remote file system and in-memory file system. + * Can be overridden + * + * @param accessDisk Whether the language server is allowed to access the local file system + */ + protected _initializeFileSystems(accessDisk: boolean): void { + this.fileSystem = accessDisk ? new LocalFileSystem(this.rootUri) : new RemoteFileSystem(this.client) + this.inMemoryFileSystem = new InMemoryFileSystem(this.root, this.logger) + } + + /** + * The shutdown request is sent from the client to the server. It asks the server to shut down, + * but to not exit (otherwise the response might not be delivered correctly to the client). + * There is a separate exit notification that asks the server to exit. + * + * @return Observable of JSON Patches that build a `null` result + */ + public shutdown(params = {}, span = new Span()): Observable { + this.projectManager.dispose() + this.packageManager.dispose() + return Observable.of({ op: 'add', path: '', value: null } as Operation) + } + + /** + * A notification sent from the client to the server to signal the change of configuration + * settings. + */ + public workspaceDidChangeConfiguration(params: DidChangeConfigurationParams): void { + merge(this.settings, params.settings) + } + + /** + * The goto definition request is sent from the client to the server to resolve the definition + * location of a symbol at a given text document position. + * + * @return Observable of JSON Patches that build a `Location[]` result + */ + + public textDocumentDefinition(params: TextDocumentPositionParams, span = new Span()): Observable { + return this._getDefinitionLocations(params, span) + .map((location: Location): Operation => ({ op: 'add', path: '/-', value: location })) + .startWith({ op: 'add', path: '', value: [] }) + } + + /** + * Returns an Observable of all definition locations found for a symbol. + */ + protected _getDefinitionLocations(params: TextDocumentPositionParams, span = new Span()): Observable { + const uri = normalizeUri(params.textDocument.uri) + + // Fetch files needed to resolve definition + return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) + .toArray() + .mergeMap(() => { + const fileName: string = uri2path(uri) + const configuration = this.projectManager.getConfiguration(fileName) + configuration.ensureBasicFiles(span) + + const sourceFile = this._getSourceFile(configuration, fileName, span) + if (!sourceFile) { + throw new Error(`Expected source file ${fileName} to exist`) + } + + const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character) + const definitions: ts.DefinitionInfo[] | undefined = configuration.getService().getDefinitionAtPosition(fileName, offset) + + return Observable.from(definitions || []) + .map((definition): Location => { + const sourceFile = this._getSourceFile(configuration, definition.fileName, span) + if (!sourceFile) { + throw new Error('expected source file "' + definition.fileName + '" to exist in configuration') + } + const start = ts.getLineAndCharacterOfPosition(sourceFile, definition.textSpan.start) + const end = ts.getLineAndCharacterOfPosition(sourceFile, definition.textSpan.start + definition.textSpan.length) + return { + uri: locationUri(definition.fileName), + range: { + start, + end + } + } + }) + }) + } + + /** + * This method is the same as textDocument/definition, except that: + * + * - The method returns metadata about the definition (the same metadata that + * workspace/xreferences searches for). + * - The concrete location to the definition (location field) + * is optional. This is useful because the language server might not be able to resolve a goto + * definition request to a concrete location (e.g. due to lack of dependencies) but still may + * know some information about it. + * + * @return Observable of JSON Patches that build a `SymbolLocationInformation[]` result + */ + + public textDocumentXdefinition(params: TextDocumentPositionParams, span = new Span()): Observable { + return this._getSymbolLocationInformations(params, span) + .map(symbol => ({ op: 'add', path: '/-', value: symbol } as Operation)) + .startWith({ op: 'add', path: '', value: [] }) + } + + /** + * Returns an Observable of SymbolLocationInformations for the definition of a symbol at the given position + */ + protected _getSymbolLocationInformations(params: TextDocumentPositionParams, span = new Span()): Observable { + const uri = normalizeUri(params.textDocument.uri) + // Ensure files needed to resolve SymbolLocationInformation are fetched + return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) + .toArray() + .mergeMap(() => { + // Convert URI to file path + const fileName: string = uri2path(uri) + // Get closest tsconfig configuration + const configuration = this.projectManager.getConfiguration(fileName) + configuration.ensureBasicFiles(span) + const sourceFile = this._getSourceFile(configuration, fileName, span) + if (!sourceFile) { + throw new Error(`Unknown text document ${uri}`) + } + // Convert line/character to offset + const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character) + // Query TypeScript for references + return Observable.from(configuration.getService().getDefinitionAtPosition(fileName, offset) || []) + .mergeMap((definition: ts.DefinitionInfo): Observable => { + const definitionUri = locationUri(definition.fileName) + // Get the PackageDescriptor + return this._getPackageDescriptor(definitionUri) + .defaultIfEmpty(undefined) + .map((packageDescriptor: PackageDescriptor | undefined): SymbolLocationInformation => { + const sourceFile = this._getSourceFile(configuration, definition.fileName, span) + if (!sourceFile) { + throw new Error(`Expected source file ${definition.fileName} to exist in configuration`) + } + const symbol = definitionInfoToSymbolDescriptor(definition, this.root) + if (packageDescriptor) { + symbol.package = packageDescriptor + } + return { + symbol, + location: { + uri: definitionUri, + range: { + start: ts.getLineAndCharacterOfPosition(sourceFile, definition.textSpan.start), + end: ts.getLineAndCharacterOfPosition(sourceFile, definition.textSpan.start + definition.textSpan.length) + } + } + } + }) + }) + }) + } + + /** + * Finds the PackageDescriptor a given file belongs to + * + * @return Observable that emits a single PackageDescriptor or never if the definition does not belong to any package + */ + protected _getPackageDescriptor(uri: string, childOf = new Span()): Observable { + return traceObservable('Get PackageDescriptor', childOf, span => { + span.addTags({ uri }) + // Get package name of the dependency in which the symbol is defined in, if any + const packageName = extractNodeModulesPackageName(uri) + if (packageName) { + // The symbol is part of a dependency in node_modules + // Build URI to package.json of the Dependency + const encodedPackageName = packageName.split('/').map(encodeURIComponent).join('/') + const parts: url.UrlObject = url.parse(uri) + const packageJsonUri = url.format({ + ...parts, + pathname: parts.pathname!.slice(0, parts.pathname!.lastIndexOf('/node_modules/' + encodedPackageName)) + `/node_modules/${encodedPackageName}/package.json` + }) + // Fetch the package.json of the dependency + return this.updater.ensure(packageJsonUri, span) + .concat(Observable.defer((): Observable => { + const packageJson: PackageJson = JSON.parse(this.inMemoryFileSystem.getContent(packageJsonUri)) + const { name, version } = packageJson + if (!name) { + return Observable.empty() + } + // Used by the LSP proxy to shortcut database lookup of repo URL for PackageDescriptor + let repoURL: string | undefined + if (name.startsWith('@types/')) { + // if the dependency package is an @types/ package, point the repo to DefinitelyTyped + repoURL = 'https://github.com/DefinitelyTyped/DefinitelyTyped' + } else { + // else use repository field from package.json + repoURL = typeof packageJson.repository === 'object' ? packageJson.repository.url : undefined + } + return Observable.of({ name, version, repoURL }) + })) + } else { + // The symbol is defined in the root package of the workspace, not in a dependency + // Get root package.json + return this.packageManager.getClosestPackageJson(uri, span) + .mergeMap(packageJson => { + let { name, version } = packageJson + if (!name) { + return [] + } + let repoURL = typeof packageJson.repository === 'object' ? packageJson.repository.url : undefined + // If the root package is DefinitelyTyped, find out the proper @types package name for each typing + if (name === 'definitely-typed') { + name = extractDefinitelyTypedPackageName(uri) + if (!name) { + this.logger.error(`Could not extract package name from DefinitelyTyped URI ${uri}`) + return [] + } + version = undefined + repoURL = 'https://github.com/DefinitelyTyped/DefinitelyTyped' + } + return [{ name, version, repoURL } as PackageDescriptor] + }) + } + }) + } + + /** + * The hover request is sent from the client to the server to request hover information at a + * given text document position. + * + * @return Observable of JSON Patches that build a `Hover` result + */ + public textDocumentHover(params: TextDocumentPositionParams, span = new Span()): Observable { + return this._getHover(params, span) + .map(hover => ({ op: 'add', path: '', value: hover }) as Operation) + } + + /** + * Returns an Observable for a Hover at the given position + */ + protected _getHover(params: TextDocumentPositionParams, span = new Span()): Observable { + const uri = normalizeUri(params.textDocument.uri) + + // Ensure files needed to resolve hover are fetched + return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) + .toArray() + .map((): Hover => { + const fileName: string = uri2path(uri) + const configuration = this.projectManager.getConfiguration(fileName) + configuration.ensureBasicFiles(span) + + const sourceFile = this._getSourceFile(configuration, fileName, span) + if (!sourceFile) { + throw new Error(`Unknown text document ${uri}`) + } + const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character) + const info = configuration.getService().getQuickInfoAtPosition(fileName, offset) + if (!info) { + return { contents: [] } + } + const contents: (MarkedString | string)[] = [] + // Add declaration without the kind + const declaration = ts.displayPartsToString(info.displayParts).replace(/^\(.+\)\s+/, '') + contents.push({ language: 'typescript', value: declaration }) + // Add kind with modifiers, e.g. "method (private, ststic)", "class (exported)" + if (info.kind) { + let kind = '**' + info.kind + '**' + const modifiers = info.kindModifiers + .split(',') + // Filter out some quirks like "constructor (exported)" + .filter(mod => mod && ( + mod !== ts.ScriptElementKindModifier.exportedModifier + || info.kind !== ts.ScriptElementKind.constructorImplementationElement + )) + // Make proper adjectives + .map(mod => ({ + [ts.ScriptElementKindModifier.ambientModifier]: 'ambient', + [ts.ScriptElementKindModifier.exportedModifier]: 'exported' + })[mod] || mod) + if (modifiers.length > 0) { + kind += ' _(' + modifiers.join(', ') + ')_' + } + contents.push(kind) + } + // Add documentation + const documentation = ts.displayPartsToString(info.documentation) + if (documentation) { + contents.push(documentation) + } + const start = ts.getLineAndCharacterOfPosition(sourceFile, info.textSpan.start) + const end = ts.getLineAndCharacterOfPosition(sourceFile, info.textSpan.start + info.textSpan.length) + + return { + contents, + range: { + start, + end + } + } + }) + } + + /** + * The references request is sent from the client to the server to resolve project-wide + * references for the symbol denoted by the given text document position. + * + * Returns all references to the symbol at the position in the own workspace, including references inside node_modules. + * + * @return Observable of JSON Patches that build a `Location[]` result + */ + public textDocumentReferences(params: ReferenceParams, span = new Span()): Observable { + const uri = normalizeUri(params.textDocument.uri) + + // Ensure all files were fetched to collect all references + return this.projectManager.ensureOwnFiles(span) + .concat(Observable.defer(() => { + // Convert URI to file path because TypeScript doesn't work with URIs + const fileName = uri2path(uri) + // Get tsconfig configuration for requested file + const configuration = this.projectManager.getConfiguration(fileName) + // Ensure all files have been added + configuration.ensureAllFiles(span) + const program = configuration.getProgram(span) + if (!program) { + return Observable.empty() + } + // Get SourceFile object for requested file + const sourceFile = this._getSourceFile(configuration, fileName, span) + if (!sourceFile) { + throw new Error(`Source file ${fileName} does not exist`) + } + // Convert line/character to offset + const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character) + // Request references at position from TypeScript + // Despite the signature, getReferencesAtPosition() can return undefined + return Observable.from(configuration.getService().getReferencesAtPosition(fileName, offset) || []) + .filter(reference => + // Filter declaration if not requested + (!reference.isDefinition || (params.context && params.context.includeDeclaration)) + // Filter references in node_modules + && !reference.fileName.includes('/node_modules/') + ) + .map((reference): Location => { + const sourceFile = program.getSourceFile(reference.fileName) + if (!sourceFile) { + throw new Error(`Source file ${reference.fileName} does not exist`) + } + // Convert offset to line/character position + const start = ts.getLineAndCharacterOfPosition(sourceFile, reference.textSpan.start) + const end = ts.getLineAndCharacterOfPosition(sourceFile, reference.textSpan.start + reference.textSpan.length) + return { + uri: path2uri(reference.fileName), + range: { + start, + end + } + } + }) + })) + .map((location: Location): Operation => ({ op: 'add', path: '/-', value: location })) + // Initialize with array + .startWith({ op: 'add', path: '', value: [] }) + } + + /** + * The workspace symbol request is sent from the client to the server to list project-wide + * symbols matching the query string. The text document parameter specifies the active document + * at time of the query. This can be used to rank or limit results. + * + * @return Observable of JSON Patches that build a `SymbolInformation[]` result + */ + public workspaceSymbol(params: WorkspaceSymbolParams, span = new Span()): Observable { + + // Return cached result for empty query, if available + if (!params.query && !params.symbol && this.emptyQueryWorkspaceSymbols) { + return this.emptyQueryWorkspaceSymbols + } + + /** A sorted array that keeps track of symbol match scores to determine the index to insert the symbol at */ + const scores: number[] = [] + + let observable = this.isDefinitelyTyped + .mergeMap((isDefinitelyTyped: boolean): Observable<[number, SymbolInformation]> => { + // Use special logic for DefinitelyTyped + // Search only in the correct subdirectory for the given PackageDescriptor + if (isDefinitelyTyped) { + // Error if not passed a SymbolDescriptor query with an `@types` PackageDescriptor + if (!params.symbol || !params.symbol.package || !params.symbol.package.name || !params.symbol.package.name.startsWith('@types/')) { + return Observable.throw(new Error('workspace/symbol on DefinitelyTyped is only supported with a SymbolDescriptor query with an @types PackageDescriptor')) + } + + // Fetch all files in the package subdirectory + // All packages are in the types/ subdirectory + const normRootUri = this.rootUri.endsWith('/') ? this.rootUri : this.rootUri + '/' + const packageRootUri = normRootUri + params.symbol.package.name.substr(1) + '/' + + return this.updater.ensureStructure(span) + .concat(Observable.defer(() => observableFromIterable(this.inMemoryFileSystem.uris()))) + .filter(uri => uri.startsWith(packageRootUri)) + .mergeMap(uri => this.updater.ensure(uri, span)) + .concat(Observable.defer(() => { + span.log({ event: 'fetched package files' }) + const config = this.projectManager.getParentConfiguration(packageRootUri, 'ts') + if (!config) { + throw new Error(`Could not find tsconfig for ${packageRootUri}`) + } + // Don't match PackageDescriptor on symbols + return this._getSymbolsInConfig(config, omit, Partial>(params.symbol!, 'package'), span) + })) + } + // Regular workspace symbol search + // Search all symbols in own code, but not in dependencies + return this.projectManager.ensureOwnFiles(span) + .concat(Observable.defer(() => { + if (params.symbol && params.symbol.package && params.symbol.package.name) { + // If SymbolDescriptor query with PackageDescriptor, search for package.jsons with matching package name + return observableFromIterable(this.packageManager.packageJsonUris()) + .filter(packageJsonUri => (JSON.parse(this.inMemoryFileSystem.getContent(packageJsonUri)) as PackageJson).name === params.symbol!.package!.name) + // Find their parent and child tsconfigs + .mergeMap(packageJsonUri => Observable.merge( + castArray(this.projectManager.getParentConfiguration(packageJsonUri) || []), + // Search child directories starting at the directory of the package.json + observableFromIterable(this.projectManager.getChildConfigurations(url.resolve(packageJsonUri, '.'))) + )) + } + // Else search all tsconfigs in the workspace + return observableFromIterable(this.projectManager.configurations()) + })) + // If PackageDescriptor is given, only search project with the matching package name + .mergeMap(config => this._getSymbolsInConfig(config, params.query || params.symbol, span)) + }) + // Filter duplicate symbols + // There may be few configurations that contain the same file(s) + // or files from different configurations may refer to the same file(s) + .distinct(symbol => hashObject(symbol, { respectType: false } as any)) + // Limit the total amount of symbols returned for text or empty queries + // Higher limit for programmatic symbol queries because it could exclude results with a higher score + .take(params.symbol ? 1000 : 100) + // Find out at which index to insert the symbol to maintain sorting order by score + .map(([score, symbol]) => { + const index = scores.findIndex(s => s < score) + if (index === -1) { + scores.push(score) + return { op: 'add', path: '/-', value: symbol } as Operation + } + scores.splice(index, 0, score) + return { op: 'add', path: '/' + index, value: symbol } as Operation + }) + .startWith({ op: 'add', path: '', value: [] }) + + if (!params.query && !params.symbol) { + observable = this.emptyQueryWorkspaceSymbols = observable.publishReplay().refCount() + } + + return observable + } + + /** + * The document symbol request is sent from the client to the server to list all symbols found + * in a given text document. + * + * @return Observable of JSON Patches that build a `SymbolInformation[]` result + */ + public textDocumentDocumentSymbol(params: DocumentSymbolParams, span = new Span()): Observable { + const uri = normalizeUri(params.textDocument.uri) + + // Ensure files needed to resolve symbols are fetched + return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) + .toArray() + .mergeMap(() => { + const fileName = uri2path(uri) + + const config = this.projectManager.getConfiguration(fileName) + config.ensureBasicFiles(span) + const sourceFile = this._getSourceFile(config, fileName, span) + if (!sourceFile) { + return [] + } + const tree = config.getService().getNavigationTree(fileName) + return observableFromIterable(walkNavigationTree(tree)) + .filter(({ tree, parent }) => navigationTreeIsSymbol(tree)) + .map(({ tree, parent }) => navigationTreeToSymbolInformation(tree, parent, sourceFile, this.root)) + }) + .map(symbol => ({ op: 'add', path: '/-', value: symbol }) as Operation) + .startWith({ op: 'add', path: '', value: [] } as Operation) + } + + /** + * The workspace references request is sent from the client to the server to locate project-wide + * references to a symbol given its description / metadata. + * + * @return Observable of JSON Patches that build a `ReferenceInformation[]` result + */ + public workspaceXreferences(params: WorkspaceReferenceParams, span = new Span()): Observable { + const queryWithoutPackage = omit, Partial>(params.query, 'package') + const minScore = Math.min(4.75, getPropertyCount(queryWithoutPackage)) + return this.isDefinitelyTyped + .mergeMap(isDefinitelyTyped => { + if (isDefinitelyTyped) { + throw new Error('workspace/xreferences not supported in DefinitelyTyped') + } + return this.projectManager.ensureAllFiles(span) + }) + .concat(Observable.defer(() => { + // if we were hinted that we should only search a specific package, find it and only search the owning tsconfig.json + if (params.hints && params.hints.dependeePackageName) { + return observableFromIterable(this.packageManager.packageJsonUris()) + .filter(uri => (JSON.parse(this.inMemoryFileSystem.getContent(uri)) as PackageJson).name === params.hints!.dependeePackageName) + .take(1) + .mergeMap(uri => { + const config = this.projectManager.getParentConfiguration(uri) + if (!config) { + return observableFromIterable(this.projectManager.configurations()) + } + return [config] + }) + } + // else search all tsconfig.jsons + return observableFromIterable(this.projectManager.configurations()) + })) + .mergeMap((config: ProjectConfiguration) => { + config.ensureAllFiles(span) + const program = config.getProgram(span) + if (!program) { + return Observable.empty() + } + return Observable.from(program.getSourceFiles()) + // Ignore dependency files + .filter(source => !toUnixPath(source.fileName).includes('/node_modules/')) + .mergeMap(source => + // Iterate AST of source file + observableFromIterable(walkMostAST(source)) + // Filter Identifier Nodes + // TODO: include string-interpolated references + .filter((node): node is ts.Identifier => node.kind === ts.SyntaxKind.Identifier) + .mergeMap(node => { + try { + // Find definition for node + return Observable.from(config.getService().getDefinitionAtPosition(source.fileName, node.pos + 1) || []) + .mergeMap(definition => { + const symbol = definitionInfoToSymbolDescriptor(definition, this.root) + // Check if SymbolDescriptor without PackageDescriptor matches + const score = getMatchingPropertyCount(queryWithoutPackage, symbol) + if (score < minScore || (params.query.package && !definition.fileName.includes(params.query.package.name))) { + return [] + } + span.log({ event: 'match', score }) + // If no PackageDescriptor query, return match + if (!params.query.package || !params.query.package) { + return [symbol] + } + // If SymbolDescriptor matched and the query contains a PackageDescriptor, get package.json and match PackageDescriptor name + // TODO match full PackageDescriptor (version) and fill out the symbol.package field + const uri = path2uri(definition.fileName) + return this._getPackageDescriptor(uri, span) + .defaultIfEmpty(undefined) + .filter(packageDescriptor => !!(packageDescriptor && packageDescriptor.name === params.query.package!.name!)) + .map(packageDescriptor => { + symbol.package = packageDescriptor + return symbol + }) + }) + .map((symbol: SymbolDescriptor): ReferenceInformation => ({ + symbol, + reference: { + uri: locationUri(source.fileName), + range: { + start: ts.getLineAndCharacterOfPosition(source, node.pos), + end: ts.getLineAndCharacterOfPosition(source, node.end) + } + } + })) + } catch (err) { + // Continue with next node on error + // Workaround for https://github.com/Microsoft/TypeScript/issues/15219 + this.logger.error(`workspace/xreferences: Error getting definition for ${source.fileName} at offset ${node.pos + 1}`, err) + span.log({ 'event': 'error', 'error.object': err, 'message': err.message, 'stack': err.stack }) + return [] + } + }) + ) + }) + .map((reference): Operation => ({ op: 'add', path: '/-', value: reference })) + .startWith({ op: 'add', path: '', value: [] }) + } + + /** + * This method returns metadata about the package(s) defined in a workspace and a list of + * dependencies for each package. + * + * This method is necessary to implement cross-repository jump-to-def when it is not possible to + * resolve the global location of the definition from data present or derived from the local + * workspace. For example, a package manager might not include information about the source + * repository of each dependency. In this case, definition resolution requires mapping from + * package descriptor to repository revision URL. A reverse index can be constructed from calls + * to workspace/xpackages to provide an efficient mapping. + * + * @return Observable of JSON Patches that build a `PackageInformation[]` result + */ + public workspaceXpackages(params = {}, span = new Span()): Observable { + return this.isDefinitelyTyped + .mergeMap((isDefinitelyTyped: boolean): Observable => { + // In DefinitelyTyped, report all @types/ packages + if (isDefinitelyTyped) { + const typesUri = url.resolve(this.rootUri, 'types/') + return observableFromIterable(this.inMemoryFileSystem.uris()) + // Find all types/ subdirectories + .filter(uri => uri.startsWith(typesUri)) + // Get the directory names + .map((uri): PackageInformation => ({ + package: { + name: '@types/' + decodeURIComponent(uri.substr(typesUri.length).split('/')[0]) + // TODO report a version by looking at subfolders like v6 + }, + // TODO parse /// comments in .d.ts files for collecting dependencies between @types packages + dependencies: [] + })) + } + // For other workspaces, search all package.json files + return this.projectManager.ensureModuleStructure(span) + // Iterate all files + .concat(Observable.defer(() => observableFromIterable(this.inMemoryFileSystem.uris()))) + // Filter own package.jsons + .filter(uri => uri.includes('/package.json') && !uri.includes('/node_modules/')) + // Map to contents of package.jsons + .mergeMap(uri => this.packageManager.getPackageJson(uri)) + // Map each package.json to a PackageInformation + .mergeMap(packageJson => { + if (!packageJson.name) { + return [] + } + const packageDescriptor: PackageDescriptor = { + name: packageJson.name, + version: packageJson.version, + repoURL: typeof packageJson.repository === 'object' && packageJson.repository.url || undefined + } + // Collect all dependencies for this package.json + return Observable.of('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies') + .filter(key => !!packageJson[key]) + // Get [name, version] pairs + .mergeMap(key => toPairs(packageJson[key]) as [string, string][]) + // Map to DependencyReferences + .map(([name, version]): DependencyReference => ({ + attributes: { + name, + version + }, + hints: { + dependeePackageName: packageJson.name + } + })) + .toArray() + .map((dependencies): PackageInformation => ({ + package: packageDescriptor, + dependencies + })) + }) + }) + .map((packageInfo): Operation => ({ op: 'add', path: '/-', value: packageInfo })) + .startWith({ op: 'add', path: '', value: [] }) + } + + /** + * Returns all dependencies of a workspace. + * Superseded by workspace/xpackages + * + * @return Observable of JSON Patches that build a `DependencyReference[]` result + */ + public workspaceXdependencies(params = {}, span = new Span()): Observable { + // Ensure package.json files + return this.projectManager.ensureModuleStructure() + // Iterate all files + .concat(Observable.defer(() => observableFromIterable(this.inMemoryFileSystem.uris()))) + // Filter own package.jsons + .filter(uri => uri.includes('/package.json') && !uri.includes('/node_modules/')) + // Ensure contents of own package.jsons + .mergeMap(uri => this.packageManager.getPackageJson(uri)) + // Map package.json to DependencyReferences + .mergeMap(packageJson => + Observable.of('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies') + .filter(key => !!packageJson[key]) + // Get [name, version] pairs + .mergeMap(key => toPairs(packageJson[key]) as [string, string][]) + .map(([name, version]): DependencyReference => ({ + attributes: { + name, + version + }, + hints: { + dependeePackageName: packageJson.name + } + })) + ) + .map((dependency): Operation => ({ op: 'add', path: '/-', value: dependency })) + .startWith({ op: 'add', path: '', value: [] }) + } + + /** + * The Completion request is sent from the client to the server to compute completion items at a + * given cursor position. Completion items are presented in the + * [IntelliSense](https://code.visualstudio.com/docs/editor/editingevolved#_intellisense) user + * interface. If computing full completion items is expensive, servers can additionally provide + * a handler for the completion item resolve request ('completionItem/resolve'). This request is + * sent when a completion item is selected in the user interface. A typically use case is for + * example: the 'textDocument/completion' request doesn't fill in the `documentation` property + * for returned completion items since it is expensive to compute. When the item is selected in + * the user interface then a 'completionItem/resolve' request is sent with the selected + * completion item as a param. The returned completion item should have the documentation + * property filled in. + * + * @return Observable of JSON Patches that build a `CompletionList` result + */ + public textDocumentCompletion(params: TextDocumentPositionParams, span = new Span()): Observable { + const uri = normalizeUri(params.textDocument.uri) + + // Ensure files needed to suggest completions are fetched + return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) + .toArray() + .mergeMap(() => { + + const fileName: string = uri2path(uri) + + const configuration = this.projectManager.getConfiguration(fileName) + configuration.ensureBasicFiles(span) + + const sourceFile = this._getSourceFile(configuration, fileName, span) + if (!sourceFile) { + return [] + } + + const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character) + const completions = configuration.getService().getCompletionsAtPosition(fileName, offset) + + if (!completions) { + return [] + } + + return Observable.from(completions.entries) + .map(entry => { + const item: CompletionItem = { label: entry.name } + + const kind = completionKinds[entry.kind] + if (kind) { + item.kind = kind + } + if (entry.sortText) { + item.sortText = entry.sortText + } + + // context for future resolve requests: + item.data = { + uri, + offset, + entryName: entry.name + } + + return { op: 'add', path: '/items/-', value: item } as Operation + }) + .startWith({ op: 'add', path: '/isIncomplete', value: false } as Operation) + }) + .startWith({ op: 'add', path: '', value: { isIncomplete: true, items: [] } as CompletionList } as Operation) + } + + /** + * The completionItem/resolve request is used to fill in additional details from an incomplete + * CompletionItem returned from the textDocument/completions call. + * + * @return Observable of JSON Patches that build a `CompletionItem` result + */ + public completionItemResolve(item: CompletionItem, span = new Span()): Observable { + if (!item.data) { + throw new Error('Cannot resolve completion item without data') + } + const {uri, offset, entryName} = item.data + const fileName: string = uri2path(uri) + return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) + .toArray() + .map(() => { + + const configuration = this.projectManager.getConfiguration(fileName) + configuration.ensureBasicFiles(span) + + const details = configuration.getService().getCompletionEntryDetails(fileName, offset, entryName) + if (details) { + item.documentation = ts.displayPartsToString(details.documentation) + item.detail = ts.displayPartsToString(details.displayParts) + if (this.supportsCompletionWithSnippets && + (details.kind === 'method' || details.kind === 'function')) { + const parameters = details.displayParts + .filter(p => p.kind === 'parameterName') + // tslint:disable-next-line:no-invalid-template-strings + .map((p, i) => '${' + `${i + 1}:${p.text}` + '}') + const paramString = parameters.join(', ') + item.insertText = details.name + `(${paramString})` + item.insertTextFormat = InsertTextFormat.Snippet + } else { + item.insertTextFormat = InsertTextFormat.PlainText + item.insertText = details.name + } + item.data = undefined + } + return item + }) + .map(completionItem => ({ op: 'add', path: '', value: completionItem }) as Operation) + } + + /** + * The signature help request is sent from the client to the server to request signature + * information at a given cursor position. + * + * @return Observable of JSON Patches that build a `SignatureHelp` result + */ + public textDocumentSignatureHelp(params: TextDocumentPositionParams, span = new Span()): Observable { + const uri = normalizeUri(params.textDocument.uri) + + // Ensure files needed to resolve signature are fetched + return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) + .toArray() + .map((): SignatureHelp => { + + const filePath = uri2path(uri) + const configuration = this.projectManager.getConfiguration(filePath) + configuration.ensureBasicFiles(span) + + const sourceFile = this._getSourceFile(configuration, filePath, span) + if (!sourceFile) { + throw new Error(`expected source file ${filePath} to exist in configuration`) + } + const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character) + + const signatures: ts.SignatureHelpItems = configuration.getService().getSignatureHelpItems(filePath, offset) + if (!signatures) { + return { signatures: [], activeParameter: 0, activeSignature: 0 } + } + + const signatureInformations = signatures.items.map((item): SignatureInformation => { + const prefix = ts.displayPartsToString(item.prefixDisplayParts) + const params = item.parameters.map(p => ts.displayPartsToString(p.displayParts)).join(', ') + const suffix = ts.displayPartsToString(item.suffixDisplayParts) + const parameters = item.parameters.map((p): ParameterInformation => ({ + label: ts.displayPartsToString(p.displayParts), + documentation: ts.displayPartsToString(p.documentation) + })) + return { + label: prefix + params + suffix, + documentation: ts.displayPartsToString(item.documentation), + parameters + } + }) + + return { + signatures: signatureInformations, + activeSignature: signatures.selectedItemIndex, + activeParameter: signatures.argumentIndex + } + }) + .map(signatureHelp => ({ op: 'add', path: '', value: signatureHelp }) as Operation) + } + + /** + * The code action request is sent from the client to the server to compute commands for a given + * text document and range. These commands are typically code fixes to either fix problems or to + * beautify/refactor code. + * + * @return Observable of JSON Patches that build a `Command[]` result + */ + public textDocumentCodeAction(params: CodeActionParams, span = new Span()): Observable { + const uri = normalizeUri(params.textDocument.uri) + return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) + .toArray() + .mergeMap(() => { + const configuration = this.projectManager.getParentConfiguration(uri) + if (!configuration) { + throw new Error(`Could not find tsconfig for ${uri}`) + } + configuration.ensureBasicFiles(span) + + const filePath = uri2path(uri) + const sourceFile = this._getSourceFile(configuration, filePath, span) + if (!sourceFile) { + throw new Error(`Expected source file ${filePath} to exist in configuration`) + } + + const start = ts.getPositionOfLineAndCharacter(sourceFile, params.range.start.line, params.range.start.character) + const end = ts.getPositionOfLineAndCharacter(sourceFile, params.range.end.line, params.range.end.character) + + const errorCodes = iterate(params.context.diagnostics) + .map(diagnostic => diagnostic.code) + .filter(code => typeof code === 'number') + .toArray() as number[] + + return configuration.getService().getCodeFixesAtPosition(filePath, start, end, errorCodes, this.settings.format || {}) || [] + }) + .map((action: ts.CodeAction): Operation => ({ + op: 'add', + path: '/-', + value: { + title: action.description, + command: 'codeFix', + arguments: action.changes + } as Command + })) + .startWith({ op: 'add', path: '', value: [] } as Operation) + } + + /** + * The workspace/executeCommand request is sent from the client to the server to trigger command + * execution on the server. In most cases the server creates a WorkspaceEdit structure and + * applies the changes to the workspace using the request workspace/applyEdit which is sent from + * the server to the client. + */ + public workspaceExecuteCommand(params: ExecuteCommandParams, span = new Span()): Observable { + switch (params.command) { + case 'codeFix': + if (!params.arguments || params.arguments.length < 1) { + return Observable.throw(new Error(`Command ${params.command} requires arguments`)) + } + return this.executeCodeFixCommand(params.arguments, span) + default: + return Observable.throw(new Error(`Unknown command ${params.command}`)) + } + } + + /** + * Executes the `codeFix` command + * + * @return Observable of JSON Patches for `null` result + */ + public executeCodeFixCommand(fileTextChanges: ts.FileTextChanges[], span = new Span()): Observable { + if (fileTextChanges.length === 0) { + return Observable.throw(new Error('No changes supplied for code fix command')) + } + + return this.projectManager.ensureOwnFiles(span) + .concat(Observable.defer(() => { + const configuration = this.projectManager.getConfiguration(fileTextChanges[0].fileName) + configuration.ensureBasicFiles(span) + + const changes: {[uri: string]: TextEdit[]} = {} + for (const change of fileTextChanges) { + const sourceFile = this._getSourceFile(configuration, change.fileName, span) + if (!sourceFile) { + throw new Error(`Expected source file ${change.fileName} to exist in configuration`) + } + const uri = path2uri(change.fileName) + changes[uri] = change.textChanges.map(({ span, newText }): TextEdit => ({ + range: { + start: ts.getLineAndCharacterOfPosition(sourceFile, span.start), + end: ts.getLineAndCharacterOfPosition(sourceFile, span.start + span.length) + }, + newText + })) + } + + return this.client.workspaceApplyEdit({ edit: { changes }}, span) + })) + .map(() => ({ op: 'add', path: '', value: null }) as Operation) + } + + /** + * The rename request is sent from the client to the server to perform a workspace-wide rename of a symbol. + * + * @return Observable of JSON Patches that build a `WorkspaceEdit` result + */ + public textDocumentRename(params: RenameParams, span = new Span()): Observable { + const uri = normalizeUri(params.textDocument.uri) + const editUris = new Set() + return this.projectManager.ensureOwnFiles(span) + .concat(Observable.defer(() => { + + const filePath = uri2path(uri) + const configuration = this.projectManager.getParentConfiguration(params.textDocument.uri) + if (!configuration) { + throw new Error(`tsconfig.json not found for ${filePath}`) + } + configuration.ensureAllFiles(span) + + const sourceFile = this._getSourceFile(configuration, filePath, span) + if (!sourceFile) { + throw new Error(`Expected source file ${filePath} to exist in configuration`) + } + + const position = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character) + + const renameInfo = configuration.getService().getRenameInfo(filePath, position) + if (!renameInfo.canRename) { + throw new Error('This symbol cannot be renamed') + } + + return Observable.from(configuration.getService().findRenameLocations(filePath, position, false, true)) + .map((location: ts.RenameLocation): [string, TextEdit] => { + const sourceFile = this._getSourceFile(configuration, location.fileName, span) + if (!sourceFile) { + throw new Error(`expected source file ${location.fileName} to exist in configuration`) + } + const editUri = path2uri(location.fileName) + const start = ts.getLineAndCharacterOfPosition(sourceFile, location.textSpan.start) + const end = ts.getLineAndCharacterOfPosition(sourceFile, location.textSpan.start + location.textSpan.length) + const edit: TextEdit = { range: { start, end }, newText: params.newName } + return [editUri, edit] + }) + })) + .map(([uri, edit]): Operation => { + // if file has no edit yet, initialize array + if (!editUris.has(uri)) { + editUris.add(uri) + return { op: 'add', path: JSONPTR`/changes/${uri}`, value: [edit] } + } + // else append to array + return { op: 'add', path: JSONPTR`/changes/${uri}/-`, value: edit } + }) + .startWith({ op: 'add', path: '', value: { changes: {} } as WorkspaceEdit } as Operation) + } + + /** + * The document open notification is sent from the client to the server to signal newly opened + * text documents. The document's truth is now managed by the client and the server must not try + * to read the document's truth using the document's uri. + */ + public async textDocumentDidOpen(params: DidOpenTextDocumentParams): Promise { + const uri = normalizeUri(params.textDocument.uri) + // Ensure files needed for most operations are fetched + await this.projectManager.ensureReferencedFiles(uri).toPromise() + this.projectManager.didOpen(uri, params.textDocument.text) + await new Promise(resolve => setTimeout(resolve, 200)) + this._publishDiagnostics(uri) + } + + /** + * The document change notification is sent from the client to the server to signal changes to a + * text document. In 2.0 the shape of the params has changed to include proper version numbers + * and language ids. + */ + public async textDocumentDidChange(params: DidChangeTextDocumentParams): Promise { + const uri = normalizeUri(params.textDocument.uri) + let text: string | undefined + for (const change of params.contentChanges) { + if (change.range || change.rangeLength) { + throw new Error('incremental updates in textDocument/didChange not supported for file ' + uri) + } + text = change.text + } + if (!text) { + return + } + this.projectManager.didChange(uri, text) + await new Promise(resolve => setTimeout(resolve, 200)) + this._publishDiagnostics(uri) + } + + /** + * Generates and publishes diagnostics for a given file + * + * @param uri URI of the file to check + */ + private _publishDiagnostics(uri: string, span = new Span()): void { + const config = this.projectManager.getParentConfiguration(uri) + if (!config) { + return + } + const fileName = uri2path(uri) + const tsDiagnostics = config.getService().getSyntacticDiagnostics(fileName).concat(config.getService().getSemanticDiagnostics(fileName)) + const diagnostics = iterate(tsDiagnostics) + // TS can report diagnostics without a file and range in some cases + // These cannot be represented as LSP Diagnostics since the range and URI is required + // https://github.com/Microsoft/TypeScript/issues/15666 + .filter(diagnostic => !!diagnostic.file) + .map(convertTsDiagnostic) + .toArray() + this.client.textDocumentPublishDiagnostics({ uri, diagnostics }) + } + + /** + * The document save notification is sent from the client to the server when the document was + * saved in the client. + */ + public async textDocumentDidSave(params: DidSaveTextDocumentParams): Promise { + const uri = normalizeUri(params.textDocument.uri) + + // Ensure files needed to suggest completions are fetched + await this.projectManager.ensureReferencedFiles(uri).toPromise() + this.projectManager.didSave(uri) + } + + /** + * The document close notification is sent from the client to the server when the document got + * closed in the client. The document's truth now exists where the document's uri points to + * (e.g. if the document's uri is a file uri the truth now exists on disk). + */ + public async textDocumentDidClose(params: DidCloseTextDocumentParams): Promise { + const uri = normalizeUri(params.textDocument.uri) + + // Ensure files needed to suggest completions are fetched + await this.projectManager.ensureReferencedFiles(uri).toPromise() + + this.projectManager.didClose(uri) + + // Clear diagnostics + this.client.textDocumentPublishDiagnostics({ uri, diagnostics: [] }) + } + + /** + * Fetches (or creates if needed) source file object for a given file name + * + * @param configuration project configuration + * @param fileName file name to fetch source file for or create it + * @param span Span for tracing + */ + private _getSourceFile(configuration: ProjectConfiguration, fileName: string, span = new Span()): ts.SourceFile | undefined { + let program = configuration.getProgram(span) + if (!program) { + return undefined + } + const sourceFile = program.getSourceFile(fileName) + if (sourceFile) { + return sourceFile + } + if (!this.projectManager.hasFile(fileName)) { + return undefined + } + configuration.getHost().addFile(fileName) + program = configuration.getProgram(span) + return program && program.getSourceFile(fileName) + } + + /** + * Returns an Observable for all symbols in a given config that match a given SymbolDescriptor or text query + * + * @param config The ProjectConfiguration to search + * @param query A text or SymbolDescriptor query + * @return Observable of [match score, SymbolInformation] + */ + protected _getSymbolsInConfig(config: ProjectConfiguration, query?: string | Partial, childOf = new Span()): Observable<[number, SymbolInformation]> { + return traceObservable('Get symbols in config', childOf, span => { + span.addTags({ config: config.configFilePath, query }) + config.ensureAllFiles(span) + + const program = config.getProgram(span) + if (!program) { + return Observable.empty() + } + + if (typeof query === 'string') { + // Query by text query + // Limit the amount of symbols searched for text queries + return Observable.from(config.getService().getNavigateToItems(query, 100, undefined, false)) + // Exclude dependencies and standard library + .filter(item => !isTypeScriptLibrary(item.fileName) && !item.fileName.includes('/node_modules/')) + // Same score for all + .map(item => [1, navigateToItemToSymbolInformation(item, program, this.root)] as [number, SymbolInformation]) + } else { + const queryWithoutPackage = query && omit, Partial>(query, 'package') as SymbolDescriptor + // Require at least 2 properties to match (or all if less provided) + const minScore = Math.min(2, getPropertyCount(query)) + const minScoreWithoutPackage = Math.min(2, getPropertyCount(queryWithoutPackage)) + const service = config.getService() + return Observable.from(program.getSourceFiles()) + // Exclude dependencies and standard library + .filter(sourceFile => !isTypeScriptLibrary(sourceFile.fileName) && !sourceFile.fileName.includes('/node_modules/')) + .mergeMap(sourceFile => { + try { + const tree = service.getNavigationTree(sourceFile.fileName) + const nodes = observableFromIterable(walkNavigationTree(tree)) + .filter(({ tree, parent }) => navigationTreeIsSymbol(tree)) + let matchedNodes: Observable<{ score: number, tree: ts.NavigationTree, parent?: ts.NavigationTree }> + if (!query) { + matchedNodes = nodes + .map(({ tree, parent }) => ({ score: 1, tree, parent })) + } else { + matchedNodes = nodes + // Get a score how good the symbol matches the SymbolDescriptor (ignoring PackageDescriptor) + .map(({ tree, parent }) => { + const symbolDescriptor = navigationTreeToSymbolDescriptor(tree, parent, sourceFile.fileName, this.root) + const score = getMatchingPropertyCount(queryWithoutPackage, symbolDescriptor) + return { score, tree, parent } + }) + // Require the minimum score without the PackageDescriptor name + .filter(({ score }) => score >= minScoreWithoutPackage) + // If SymbolDescriptor matched, get package.json and match PackageDescriptor name + // TODO get and match full PackageDescriptor (version) + .mergeMap(({ score, tree, parent }) => { + if (!query.package || !query.package.name) { + return [{ score, tree, parent }] + } + const uri = path2uri(sourceFile.fileName) + return this.packageManager.getClosestPackageJson(uri, span) + // If PackageDescriptor matches, increase score + .defaultIfEmpty(undefined) + .map(packageJson => { + if (packageJson && packageJson.name === query.package!.name!) { + score++ + } + return { score, tree, parent } + }) + }) + // Require a minimum score to not return thousands of results + .filter(({ score }) => score >= minScore) + } + return matchedNodes + .map(({ score, tree, parent }) => [score, navigationTreeToSymbolInformation(tree, parent, sourceFile, this.root)] as [number, SymbolInformation]) + } catch (e) { + this.logger.error('Could not get navigation tree for file', sourceFile.fileName) + return [] + } + }) + } + }) + } } - -/** - * Maps string-based CompletionEntry::kind to enum-based CompletionItemKind - */ -const completionKinds: { [name: string]: CompletionItemKind } = { - class: CompletionItemKind.Class, - constructor: CompletionItemKind.Constructor, - enum: CompletionItemKind.Enum, - field: CompletionItemKind.Field, - file: CompletionItemKind.File, - function: CompletionItemKind.Function, - interface: CompletionItemKind.Interface, - keyword: CompletionItemKind.Keyword, - method: CompletionItemKind.Method, - module: CompletionItemKind.Module, - property: CompletionItemKind.Property, - reference: CompletionItemKind.Reference, - snippet: CompletionItemKind.Snippet, - text: CompletionItemKind.Text, - unit: CompletionItemKind.Unit, - value: CompletionItemKind.Value, - variable: CompletionItemKind.Variable -}; diff --git a/src/typings/string-similarity.d.ts b/src/typings/string-similarity.d.ts index c54760e30..ce1e030c5 100644 --- a/src/typings/string-similarity.d.ts +++ b/src/typings/string-similarity.d.ts @@ -1,4 +1,4 @@ declare module 'string-similarity' { - export function compareTwoStrings(a: string, b: string): number; + export function compareTwoStrings(a: string, b: string): number } diff --git a/src/util.ts b/src/util.ts index 619d33827..6624e98f8 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,30 +1,30 @@ -import { Observable } from '@reactivex/rxjs'; -import { escapePathComponent } from 'fast-json-patch'; -import { compareTwoStrings } from 'string-similarity'; -import * as ts from 'typescript'; -import * as url from 'url'; -import { PackageDescriptor, SymbolDescriptor } from './request-type'; +import { Observable } from '@reactivex/rxjs' +import { escapePathComponent } from 'fast-json-patch' +import { compareTwoStrings } from 'string-similarity' +import * as ts from 'typescript' +import * as url from 'url' +import { PackageDescriptor, SymbolDescriptor } from './request-type' /** * Converts an Iterable to an Observable. * Workaround for https://github.com/ReactiveX/rxjs/issues/2306 */ export function observableFromIterable(iterable: Iterable): Observable { - return Observable.from(iterable as any); + return Observable.from(iterable as any) } /** * Template string tag to escape JSON Pointer components as per https://tools.ietf.org/html/rfc6901#section-3 */ export function JSONPTR(strings: TemplateStringsArray, ...toEscape: string[]): string { - return strings.reduce((left, right, i) => left + escapePathComponent(toEscape[i - 1]) + right); + return strings.reduce((left, right, i) => left + escapePathComponent(toEscape[i - 1]) + right) } /** * Makes documentation string from symbol display part array returned by TS */ export function docstring(parts: ts.SymbolDisplayPart[]): string { - return ts.displayPartsToString(parts); + return ts.displayPartsToString(parts) } /** @@ -32,24 +32,24 @@ export function docstring(parts: ts.SymbolDisplayPart[]): string { * This conversion should only be necessary to convert windows paths when calling TS APIs. */ export function toUnixPath(filePath: string): string { - return filePath.replace(/\\/g, '/'); + return filePath.replace(/\\/g, '/') } /** * Normalizes URI encoding by encoding _all_ special characters in the pathname */ export function normalizeUri(uri: string): string { - const parts = url.parse(uri); - if (!parts.pathname) { - return uri; - } - const pathParts = parts.pathname.split('/').map(segment => encodeURIComponent(decodeURIComponent(segment))); - // Decode Windows drive letter colon - if (/^[a-z]%3A$/i.test(pathParts[1])) { - pathParts[1] = decodeURIComponent(pathParts[1]); - } - parts.pathname = pathParts.join('/'); - return url.format(parts); + const parts = url.parse(uri) + if (!parts.pathname) { + return uri + } + const pathParts = parts.pathname.split('/').map(segment => encodeURIComponent(decodeURIComponent(segment))) + // Decode Windows drive letter colon + if (/^[a-z]%3A$/i.test(pathParts[1])) { + pathParts[1] = decodeURIComponent(pathParts[1]) + } + parts.pathname = pathParts.join('/') + return url.format(parts) } /** @@ -58,22 +58,22 @@ export function normalizeUri(uri: string): string { * @param path an absolute path */ export function path2uri(path: string): string { - // Require a leading slash, on windows prefixed with drive letter - if (!/^(?:[a-z]:)?[\\\/]/i.test(path)) { - throw new Error(`${path} is not an absolute path`); - } + // Require a leading slash, on windows prefixed with drive letter + if (!/^(?:[a-z]:)?[\\\/]/i.test(path)) { + throw new Error(`${path} is not an absolute path`) + } - const parts = path.split(/[\\\/]/); + const parts = path.split(/[\\\/]/) - // If the first segment is a Windows drive letter, prefix with a slash and skip encoding - let head = parts.shift()!; - if (head !== '') { - head = '/' + head; - } else { - head = encodeURIComponent(head); - } + // If the first segment is a Windows drive letter, prefix with a slash and skip encoding + let head = parts.shift()! + if (head !== '') { + head = '/' + head + } else { + head = encodeURIComponent(head) + } - return `file://${head}/${parts.map(encodeURIComponent).join('/')}`; + return `file://${head}/${parts.map(encodeURIComponent).join('/')}` } /** @@ -83,46 +83,46 @@ export function path2uri(path: string): string { * @param uri a file:// uri */ export function uri2path(uri: string): string { - const parts = url.parse(uri); - if (parts.protocol !== 'file:') { - throw new Error('Cannot resolve non-file uri to path: ' + uri); - } + const parts = url.parse(uri) + if (parts.protocol !== 'file:') { + throw new Error('Cannot resolve non-file uri to path: ' + uri) + } - let filePath = parts.pathname || ''; + let filePath = parts.pathname || '' - // If the path starts with a drive letter, return a Windows path - if (/^\/[a-z]:\//i.test(filePath)) { - filePath = filePath.substr(1).replace(/\//g, '\\'); - } + // If the path starts with a drive letter, return a Windows path + if (/^\/[a-z]:\//i.test(filePath)) { + filePath = filePath.substr(1).replace(/\//g, '\\') + } - return decodeURIComponent(filePath); + return decodeURIComponent(filePath) } -const jstsPattern = /\.[tj]sx?$/; +const jstsPattern = /\.[tj]sx?$/ export function isJSTSFile(filename: string): boolean { - return jstsPattern.test(filename); + return jstsPattern.test(filename) } -const jstsConfigPattern = /(^|\/)[tj]sconfig\.json$/; +const jstsConfigPattern = /(^|\/)[tj]sconfig\.json$/ export function isConfigFile(filename: string): boolean { - return jstsConfigPattern.test(filename); + return jstsConfigPattern.test(filename) } -const packageJsonPattern = /(^|\/)package\.json$/; +const packageJsonPattern = /(^|\/)package\.json$/ export function isPackageJsonFile(filename: string): boolean { - return packageJsonPattern.test(filename); + return packageJsonPattern.test(filename) } const globalTSPatterns = [ - /(^|\/)globals?\.d\.ts$/, - /node_modules\/(?:\@|%40)types\/(node|jasmine|jest|mocha)\/.*\.d\.ts$/, - /(^|\/)typings\/.*\.d\.ts$/, - /(^|\/)tsd\.d\.ts($|\/)/, - /(^|\/)tslib\.d\.ts$/ // for the 'synthetic reference' created by typescript when using importHelpers -]; + /(^|\/)globals?\.d\.ts$/, + /node_modules\/(?:\@|%40)types\/(node|jasmine|jest|mocha)\/.*\.d\.ts$/, + /(^|\/)typings\/.*\.d\.ts$/, + /(^|\/)tsd\.d\.ts($|\/)/, + /(^|\/)tslib\.d\.ts$/ // for the 'synthetic reference' created by typescript when using importHelpers +] // isGlobalTSFile returns whether or not the filename contains global // variables based on a best practices heuristic @@ -131,20 +131,20 @@ const globalTSPatterns = [ // import statement, but to check this, we'd have to read each // TypeScript file. export function isGlobalTSFile(filename: string): boolean { - for (const globalTSPattern of globalTSPatterns) { - if (globalTSPattern.test(filename)) { - return true; - } - } - return false; + for (const globalTSPattern of globalTSPatterns) { + if (globalTSPattern.test(filename)) { + return true + } + } + return false } export function isDependencyFile(filename: string): boolean { - return filename.startsWith('node_modules/') || filename.indexOf('/node_modules/') !== -1; + return filename.startsWith('node_modules/') || filename.indexOf('/node_modules/') !== -1 } export function isDeclarationFile(filename: string): boolean { - return filename.endsWith('.d.ts'); + return filename.endsWith('.d.ts') } /** @@ -152,22 +152,22 @@ export function isDeclarationFile(filename: string): boolean { * E.g. if 2 of 4 properties in the query match, will return 2 */ export function getMatchingPropertyCount(query: any, value: any): number { - // Compare strings by similarity - // This allows to match a path like "lib/foo/bar.d.ts" with "src/foo/bar.ts" - // Last check is a workaround for https://github.com/aceakash/string-similarity/issues/6 - if (typeof query === 'string' && typeof value === 'string' && !(query.length <= 1 && value.length <= 1)) { - return compareTwoStrings(query, value); - } - // If query is a scalar value, compare by identity and return 0 or 1 - if (typeof query !== 'object' || query === null) { - return +(query === value); - } - // If value is scalar, return no match - if (typeof value !== 'object' && value !== null) { - return 0; - } - // Both values are objects, compare each property and sum the scores - return Object.keys(query).reduce((score, key) => score + getMatchingPropertyCount(query[key], value[key]), 0); + // Compare strings by similarity + // This allows to match a path like "lib/foo/bar.d.ts" with "src/foo/bar.ts" + // Last check is a workaround for https://github.com/aceakash/string-similarity/issues/6 + if (typeof query === 'string' && typeof value === 'string' && !(query.length <= 1 && value.length <= 1)) { + return compareTwoStrings(query, value) + } + // If query is a scalar value, compare by identity and return 0 or 1 + if (typeof query !== 'object' || query === null) { + return +(query === value) + } + // If value is scalar, return no match + if (typeof value !== 'object' && value !== null) { + return 0 + } + // Both values are objects, compare each property and sum the scores + return Object.keys(query).reduce((score, key) => score + getMatchingPropertyCount(query[key], value[key]), 0) } /** @@ -175,41 +175,41 @@ export function getMatchingPropertyCount(query: any, value: any): number { * E.g. for `{ name, kind, package: { name }}` will return 3 */ export function getPropertyCount(query: any): number { - if (typeof query === 'object' && query !== null) { - return Object.keys(query).reduce((score, key) => score + getPropertyCount(query[key]), 0); - } - return 1; + if (typeof query === 'object' && query !== null) { + return Object.keys(query).reduce((score, key) => score + getPropertyCount(query[key]), 0) + } + return 1 } /** * Returns true if the passed SymbolDescriptor has at least the same properties as the passed partial SymbolDescriptor */ export function isSymbolDescriptorMatch(query: Partial, symbol: SymbolDescriptor): boolean { - for (const key of Object.keys(query)) { - if (!(query as any)[key]) { - continue; - } - if (key === 'package') { - if (!symbol.package || !isPackageDescriptorMatch(query.package!, symbol.package)) { - return false; - } - continue; - } - if ((query as any)[key] !== (symbol as any)[key]) { - return false; - } - } - return true; + for (const key of Object.keys(query)) { + if (!(query as any)[key]) { + continue + } + if (key === 'package') { + if (!symbol.package || !isPackageDescriptorMatch(query.package!, symbol.package)) { + return false + } + continue + } + if ((query as any)[key] !== (symbol as any)[key]) { + return false + } + } + return true } function isPackageDescriptorMatch(query: Partial, pkg: PackageDescriptor): boolean { - for (const key of Object.keys(query)) { - if ((query as any)[key] === undefined) { - continue; - } - if ((query as any)[key] !== (pkg as any)[key]) { - return false; - } - } - return true; + for (const key of Object.keys(query)) { + if ((query as any)[key] === undefined) { + continue + } + if ((query as any)[key] !== (pkg as any)[key]) { + return false + } + } + return true } diff --git a/tsconfig.json b/tsconfig.json index 8a08a9727..453fdda2e 100755 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,21 @@ { + "extends": "./node_modules/@sourcegraph/tsconfig/tsconfig.json", "compilerOptions": { + "outDir": "lib", "target": "es2016", + "module": "commonjs", + "moduleResolution": "node", "lib": [ "es2016", "dom" ], - "module": "commonjs", - "moduleResolution": "node", "sourceMap": true, - "outDir": "lib", "declaration": true, - "allowUnreachableCode": false, - "allowUnusedLabels": false, - "noImplicitReturns": true, - "noImplicitThis": true, - "noUnusedLocals": true, - "noImplicitAny": true, - "strictNullChecks": true + "plugins": [ + { + "name": "tslint-language-service" + } + ] }, "include": [ "src/**/*.ts" diff --git a/tsfmt.json b/tsfmt.json index f578e5665..e2899e24c 100644 --- a/tsfmt.json +++ b/tsfmt.json @@ -1,8 +1,7 @@ { - "tabSize": 4, "indentSize": 4, "newLineCharacter": "\n", - "convertTabsToSpaces": false, + "convertTabsToSpaces": true, "insertSpaceAfterCommaDelimiter": true, "insertSpaceAfterSemicolonInForStatements": true, "insertSpaceBeforeAndAfterBinaryOperators": true, diff --git a/tslint.json b/tslint.json index 23dc9b8e4..af675ae43 100644 --- a/tslint.json +++ b/tslint.json @@ -1,33 +1,11 @@ { - "extends": "tslint:recommended", + "extends": "@sourcegraph/tslint-config", "rules": { - "semicolon": [true, "always"], - "indent": [true, "tabs"], - "eofline": true, - "max-line-length": [false], - "linebreak-style": [true, "LF"], - "quotemark": [true, "single", "avoid-escape"], - "array-type": [true, "array"], - "arrow-parens": [true, "ban-single-arg-parens"], - "comment-format": [true, "check-space"], - "interface-over-type-literal": true, - "prefer-for-of": true, - "no-consecutive-blank-lines": [true], - "object-literal-shorthand": true, - "one-variable-per-declaration": [true, "ignore-for-loop"], - "prefer-method-signature": true, - "space-before-function-paren": [true, {"anonymous": "always", "named": "never", "asyncArrow": "always"}], - "switch-default": false, - "interface-name": [true, "never-prefix"], - "no-var-requires": false, - "no-namespace": [false], - "member-access": [false], - "max-classes-per-file": [false], - "object-literal-sort-keys": false, - "no-shadowed-variable": false, - "member-ordering": [false], - "trailing-comma": [true, {"singleline": "never", "multiline": "never"}], - "no-console": [true, "log", "info", "warn", "error", "trace", "time", "timeEnd", "assert", "count", "dir", "table"], - "prefer-const": [true, {"destructuring": "all"}] + + "rxjs-add": false, + "rxjs-no-add": true, + "rxjs-no-wholesale": false, + + "no-console": [true, "log", "info", "warn", "error", "trace", "time", "timeEnd", "assert", "count", "dir", "table"] } }