diff --git a/package.json b/package.json index 226ed002c..cb7cc4674 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "vscode-jsonrpc": "^3.1.0", "vscode-languageserver": "^3.1.0", "vscode-languageserver-types": "^3.0.3", + "whatwg-url": "^4.7.1", "yarn": "^0.21.3" }, "devDependencies": { @@ -61,7 +62,7 @@ "@types/lodash": "4.14.63", "@types/mocha": "2.2.41", "@types/mz": "0.0.31", - "@types/node": "7.0.14", + "@types/node": "6.0.70", "@types/object-hash": "0.5.28", "@types/rimraf": "0.0.28", "@types/sinon": "2.1.3", diff --git a/src/fs.ts b/src/fs.ts index 729817c61..8da8afa94 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,12 +1,12 @@ import * as fs from 'mz/fs'; -import * as path from 'path'; import { LanguageClient } from './lang-handler'; import glob = require('glob'); import iterate from 'iterare'; import { Span } from 'opentracing'; import Semaphore from 'semaphore-async-await'; +import { URL } from 'whatwg-url'; import { InMemoryFileSystem } from './memfs'; -import { normalizeDir, path2uri, toUnixPath, uri2path } from './util'; +import { path2uri, uri2path } from './util'; export interface FileSystem { /** @@ -15,7 +15,7 @@ export interface FileSystem { * @param base A URI under which to search, resolved relative to the rootUri * @return A promise that is fulfilled with an array of URIs */ - getWorkspaceFiles(base?: string, childOf?: Span): Promise>; + getWorkspaceFiles(base?: URL, childOf?: Span): Promise>; /** * Returns the content of a text document @@ -23,7 +23,7 @@ export interface FileSystem { * @param uri The URI of the text document, resolved relative to the rootUri * @return A promise that is fulfilled with the text document content */ - getTextDocumentContent(uri: string, childOf?: Span): Promise; + getTextDocumentContent(uri: URL, childOf?: Span): Promise; } export class RemoteFileSystem implements FileSystem { @@ -34,45 +34,54 @@ export class RemoteFileSystem implements FileSystem { * 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. */ - async getWorkspaceFiles(base?: string, childOf = new Span()): Promise> { - return iterate(await this.client.workspaceXfiles({ base }, childOf)) - .map(textDocument => textDocument.uri); + async getWorkspaceFiles(base?: URL, childOf = new Span()): Promise> { + return iterate(await this.client.workspaceXfiles({ base: base && base.href }, childOf)) + .map(textDocument => new URL(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. */ - async getTextDocumentContent(uri: string, childOf = new Span()): Promise { - const textDocument = await this.client.textDocumentXcontent({ textDocument: { uri } }, childOf); + async getTextDocumentContent(uri: URL, childOf = new Span()): Promise { + const textDocument = await this.client.textDocumentXcontent({ textDocument: { uri: uri.href } }, childOf); return textDocument.text; } } +/** + * FileSystem implementation that reads from the local disk + */ export class LocalFileSystem implements FileSystem { /** - * @param rootPath The root directory path that relative URIs should be resolved to + * @param rootUri The workspace root URI that is used when no base is given */ - constructor(private rootPath: string) {} + constructor(protected rootUri: URL) {} /** - * Converts the URI to an absolute path + * Returns the file path where a given URI should be located on disk */ - protected resolveUriToPath(uri: string): string { - return toUnixPath(path.resolve(this.rootPath, uri2path(uri))); + protected resolveUriToPath(uri: URL): string { + return uri2path(uri); } - async getWorkspaceFiles(base?: string): Promise> { - // Even if no base provided, still need to call resolveUriToPath which may be overridden - const root = this.resolveUriToPath(base || path2uri('', this.rootPath)); - const baseUri = path2uri('', normalizeDir(root)) + '/'; + async getWorkspaceFiles(base: URL = this.rootUri): Promise> { const files = await new Promise((resolve, reject) => { - glob('*', { cwd: root, nodir: true, matchBase: true }, (err, matches) => err ? reject(err) : resolve(matches)); + glob('*', { + // Search the base directory + cwd: this.resolveUriToPath(base), + // Don't return directories + nodir: true, + // Search directories recursively + matchBase: true, + // Return absolute file paths + absolute: true + } as any, (err, matches) => err ? reject(err) : resolve(matches)); }); - return iterate(files).map(file => baseUri + file.split('/').map(encodeURIComponent).join('/')); + return iterate(files).map(filePath => path2uri(base, filePath)); } - async getTextDocumentContent(uri: string): Promise { + async getTextDocumentContent(uri: URL): Promise { return fs.readFile(this.resolveUriToPath(uri), 'utf8'); } } @@ -107,7 +116,7 @@ export class FileSystemUpdater { * @param uri URI of the file to fetch * @param childOf A parent span for tracing */ - async fetch(uri: string, childOf = new Span()): Promise { + async fetch(uri: URL, childOf = new Span()): Promise { // Limit concurrent fetches const promise = this.concurrencyLimit.execute(async () => { try { @@ -115,11 +124,11 @@ export class FileSystemUpdater { this.inMemoryFs.add(uri, content); this.inMemoryFs.getContent(uri); } catch (err) { - this.fetches.delete(uri); + this.fetches.delete(uri.href); throw err; } }); - this.fetches.set(uri, promise); + this.fetches.set(uri.href, promise); return promise; } @@ -130,8 +139,8 @@ export class FileSystemUpdater { * @param uri URI of the file to ensure * @param span An OpenTracing span for tracing */ - ensure(uri: string, span = new Span()): Promise { - return this.fetches.get(uri) || this.fetch(uri, span); + ensure(uri: URL, span = new Span()): Promise { + return this.fetches.get(uri.href) || this.fetch(uri, span); } /** @@ -176,8 +185,8 @@ export class FileSystemUpdater { * * @param uri URI of the file that changed */ - invalidate(uri: string): void { - this.fetches.delete(uri); + invalidate(uri: URL): void { + this.fetches.delete(uri.href); } /** diff --git a/src/memfs.ts b/src/memfs.ts index 19bdf22d7..c9e00274c 100644 --- a/src/memfs.ts +++ b/src/memfs.ts @@ -1,6 +1,8 @@ import * as fs_ from 'fs'; +import iterate from 'iterare'; import * as path_ from 'path'; import * as ts from 'typescript'; +import { URL } from 'whatwg-url'; import { Logger, NoopLogger } from './logging'; import * as match from './match-files'; import * as util from './util'; @@ -44,7 +46,10 @@ export class InMemoryFileSystem implements ts.ParseConfigHost, ts.ModuleResoluti */ rootNode: FileSystemNode; - constructor(path: string, private logger: Logger = new NoopLogger()) { + /** + * @param rootUri The workspace root URI + */ + constructor(private rootUri: URL, path: string, private logger: Logger = new NoopLogger()) { this.path = path; this.overlay = new Map(); this.rootNode = { file: false, children: new Map() }; @@ -53,8 +58,8 @@ export class InMemoryFileSystem implements ts.ParseConfigHost, ts.ModuleResoluti /** * Returns an IterableIterator for all URIs known to exist in the workspace (content loaded or not) */ - uris(): IterableIterator { - return this.files.keys(); + uris(): IterableIterator { + return iterate(this.files.keys()).map(uri => new URL(uri)); } /** @@ -63,13 +68,13 @@ export class InMemoryFileSystem implements ts.ParseConfigHost, ts.ModuleResoluti * @param uri The URI of the file * @param content The optional content */ - add(uri: string, content?: string): void { + add(uri: URL, content?: string): void { // Make sure not to override existing content with undefined - if (content !== undefined || !this.files.has(uri)) { - this.files.set(uri, content); + if (content !== undefined || !this.files.has(uri.href)) { + this.files.set(uri.href, content); } // Add to directory tree - const filePath = util.uri2path(uri); + const filePath = util.toUnixPath(util.uri2path(uri)); const components = filePath.split('/').filter(c => c); let node = this.rootNode; for (const [i, component] of components.entries()) { @@ -93,8 +98,8 @@ export class InMemoryFileSystem implements ts.ParseConfigHost, ts.ModuleResoluti * * @param uri URI to a file */ - has(uri: string): boolean { - return this.files.has(uri) || this.fileExists(util.uri2path(uri)); + has(uri: URL): boolean { + return this.files.has(uri.href) || this.fileExists(util.toUnixPath(util.uri2path(uri))); } /** @@ -104,10 +109,10 @@ export class InMemoryFileSystem implements ts.ParseConfigHost, ts.ModuleResoluti * * TODO take overlay into account */ - getContent(uri: string): string { - let content = this.files.get(uri); + getContent(uri: URL): string { + let content = this.files.get(uri.href); if (content === undefined) { - content = typeScriptLibraries.get(util.uri2path(uri)); + content = typeScriptLibraries.get(util.toUnixPath(util.uri2path(uri))); } if (content === undefined) { throw new Error(`Content of ${uri} is not available in memory`); @@ -118,10 +123,10 @@ export class InMemoryFileSystem implements ts.ParseConfigHost, ts.ModuleResoluti /** * 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) + * @param filePath Path to file, absolute or relative to `rootUri` */ - fileExists(path: string): boolean { - return this.readFileIfExists(path) !== undefined || this.files.has(path) || this.files.has(util.path2uri(this.path, path)); + fileExists(filePath: string): boolean { + return this.readFileIfExists(filePath) !== undefined || this.files.has(filePath) || this.files.has(util.path2uri(this.rootUri, filePath).href); } /** @@ -129,17 +134,17 @@ export class InMemoryFileSystem implements ts.ParseConfigHost, ts.ModuleResoluti * @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 { - return this.readFileIfExists(path) || ''; + readFile(filePath: string): string { + return this.readFileIfExists(filePath) || ''; } /** - * @param path file path (both absolute or relative file paths are accepted) + * @param filePath Path to the file, absolute or relative to `rootUri` * @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 = util.path2uri(this.path, path); + readFileIfExists(filePath: string): string | undefined { + const uri = util.path2uri(this.rootUri, filePath).href; let content = this.overlay.get(uri); if (content !== undefined) { return content; @@ -153,23 +158,23 @@ export class InMemoryFileSystem implements ts.ParseConfigHost, ts.ModuleResoluti return content; } - return typeScriptLibraries.get(path); + return typeScriptLibraries.get(filePath); } /** * Invalidates temporary content denoted by the given URI * @param uri file's URI */ - didClose(uri: string) { - this.overlay.delete(uri); + didClose(uri: URL) { + this.overlay.delete(uri.href); } /** * Adds temporary content denoted by the given URI * @param uri file's URI */ - didSave(uri: string) { - const content = this.overlay.get(uri); + didSave(uri: URL) { + const content = this.overlay.get(uri.href); if (content !== undefined) { this.add(uri, content); } @@ -179,8 +184,8 @@ export class InMemoryFileSystem implements ts.ParseConfigHost, ts.ModuleResoluti * Updates temporary content denoted by the given URI * @param uri file's URI */ - didChange(uri: string, text: string) { - this.overlay.set(uri, text); + didChange(uri: URL, text: string) { + this.overlay.set(uri.href, text); } /** diff --git a/src/project-manager.ts b/src/project-manager.ts index f302e965d..f732e8f91 100644 --- a/src/project-manager.ts +++ b/src/project-manager.ts @@ -4,7 +4,6 @@ import { Span } from 'opentracing'; import * as os from 'os'; import * as path_ from 'path'; import * as ts from 'typescript'; -import * as url from 'url'; import { Disposable } from 'vscode-languageserver'; import { FileSystemUpdater } from './fs'; import { Logger, NoopLogger } from './logging'; @@ -83,7 +82,7 @@ export class ProjectManager implements Disposable { /** * A URI Map from file to files referenced by the file, so files only need to be pre-processed once */ - private referencedFiles = new Map>(); + private referencedFiles = new Map>(); /** * @param rootPath root path as passed to `initialize` @@ -91,7 +90,7 @@ export class ProjectManager implements Disposable { * @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, strict: boolean, traceModuleResolution?: boolean, protected logger: Logger = new NoopLogger()) { + constructor(private rootUri: URL, rootPath: string, inMemoryFileSystem: InMemoryFileSystem, updater: FileSystemUpdater, strict: boolean, traceModuleResolution?: boolean, protected logger: Logger = new NoopLogger()) { this.rootPath = util.toUnixPath(rootPath); this.updater = updater; this.localFs = inMemoryFileSystem; @@ -155,7 +154,7 @@ export class ProjectManager implements Disposable { // Ensure content of all all global .d.ts, [tj]sconfig.json, package.json files await Promise.all( iterate(this.localFs.uris()) - .filter(uri => util.isGlobalTSFile(uri) || util.isConfigFile(uri) || util.isPackageJsonFile(uri)) + .filter(uri => util.isGlobalTSFile(uri.pathname) || util.isConfigFile(uri.pathname) || util.isPackageJsonFile(uri.pathname)) .map(uri => this.updater.ensure(uri)) ); // Scan for [tj]sconfig.json files @@ -201,7 +200,7 @@ export class ProjectManager implements Disposable { await this.updater.ensureStructure(span); await Promise.all( iterate(this.localFs.uris()) - .filter(uri => !uri.includes('/node_modules/') && util.isJSTSFile(uri) || util.isConfigFile(uri) || util.isPackageJsonFile(uri)) + .filter(uri => !uri.pathname.includes('/node_modules/') && util.isJSTSFile(uri.pathname) || util.isConfigFile(uri.pathname) || util.isPackageJsonFile(uri.pathname)) .map(uri => this.updater.ensure(uri)) ); this.createConfigurations(); @@ -232,7 +231,7 @@ export class ProjectManager implements Disposable { await this.updater.ensureStructure(span); await Promise.all( iterate(this.localFs.uris()) - .filter(uri => util.isJSTSFile(uri) || util.isConfigFile(uri) || util.isPackageJsonFile(uri)) + .filter(uri => util.isJSTSFile(uri.pathname) || util.isConfigFile(uri.pathname) || util.isPackageJsonFile(uri.pathname)) .map(uri => this.updater.ensure(uri)) ); this.createConfigurations(); @@ -265,15 +264,15 @@ export class ProjectManager implements Disposable { * @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 { + ensureReferencedFiles(uri: URL, maxDepth = 30, ignore = new Set(), childOf = new Span()): Observable { const span = childOf.tracer().startSpan('Ensure referenced files', { childOf }); span.addTags({ uri, maxDepth }); - ignore.add(uri); + ignore.add(uri.href); return Observable.from(this.ensureModuleStructure(span)) // If max depth was reached, don't go any further .mergeMap(() => maxDepth === 0 ? [] : this.resolveReferencedFiles(uri, span)) // Prevent cycles - .filter(referencedUri => !ignore.has(referencedUri)) + .filter(referencedUri => !ignore.has(referencedUri.href)) // Call method recursively with one less dep level .mergeMap(referencedUri => this.ensureReferencedFiles(referencedUri, maxDepth - 1, ignore) @@ -284,7 +283,7 @@ export class ProjectManager implements Disposable { }) ) // Log errors to span - .catch(err => { + .catch(err => { span.setTag('error', true); span.log({ 'event': 'error', 'error.object': err, 'message': err.message, 'stack': err.stack }); throw err; @@ -315,19 +314,15 @@ export class ProjectManager implements Disposable { * @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); + private resolveReferencedFiles(uri: URL, span = new Span()): Observable { + let observable = this.referencedFiles.get(uri.href); if (observable) { return observable; } - const parts = url.parse(uri); - if (!parts.pathname) { - return Observable.throw(new Error(`Invalid URI ${uri}`)); - } - // TypeScript works with file paths, not URIs - const filePath = parts.pathname.split('/').map(decodeURIComponent).join('/'); observable = Observable.from(this.updater.ensure(uri)) .mergeMap(() => { + // TypeScript works with file paths, not URIs + const filePath = util.uri2path(uri); const config = this.getConfiguration(filePath); config.ensureBasicFiles(span); const contents = this.localFs.getContent(uri); @@ -370,16 +365,16 @@ export class ProjectManager implements Disposable { ); }) // Use same scheme, slashes, host for referenced URI as input file - .map(filePath => url.format({ ...parts, pathname: filePath.split(/[\\\/]/).map(encodeURIComponent).join('/'), search: undefined, hash: undefined })) + .map(filePath => util.path2uri(uri, filePath)) // Don't cache errors - .catch(err => { - this.referencedFiles.delete(uri); + .catch(err => { + this.referencedFiles.delete(uri.href); throw err; }) // Make sure all subscribers get the same values .publishReplay() .refCount(); - this.referencedFiles.set(uri, observable); + this.referencedFiles.set(uri.href, observable); return observable; } @@ -428,7 +423,7 @@ export class ProjectManager implements Disposable { * @param uri file's URI * @param text file's content */ - didOpen(uri: string, text: string) { + didOpen(uri: URL, text: string) { this.didChange(uri, text); } @@ -436,11 +431,11 @@ export class ProjectManager implements Disposable { * 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 = util.uri2path(uri); + didClose(uri: URL, span = new Span()) { + const filePath = util.toUnixPath(util.uri2path(uri)); this.localFs.didClose(uri); - let version = this.versions.get(uri) || 0; - this.versions.set(uri, ++version); + let version = this.versions.get(uri.href) || 0; + this.versions.set(uri.href, ++version); const config = this.getConfigurationIfExists(filePath); if (!config) { return; @@ -455,11 +450,11 @@ export class ProjectManager implements Disposable { * @param uri file's URI * @param text file's content */ - didChange(uri: string, text: string, span = new Span()) { - const filePath = util.uri2path(uri); + didChange(uri: URL, text: string, span = new Span()) { + const filePath = util.toUnixPath(util.uri2path(uri)); this.localFs.didChange(uri, text); - let version = this.versions.get(uri) || 0; - this.versions.set(uri, ++version); + let version = this.versions.get(uri.href) || 0; + this.versions.set(uri.href, ++version); const config = this.getConfigurationIfExists(filePath); if (!config) { return; @@ -473,7 +468,7 @@ export class ProjectManager implements Disposable { * Called when file was saved by client * @param uri file's URI */ - didSave(uri: string) { + didSave(uri: URL) { this.localFs.didSave(uri); } @@ -484,7 +479,7 @@ export class ProjectManager implements Disposable { */ createConfigurations() { for (const uri of this.localFs.uris()) { - const filePath = util.uri2path(uri); + const filePath = util.toUnixPath(util.uri2path(uri)); if (!/(^|\/)[tj]sconfig\.json$/.test(filePath)) { continue; } @@ -500,7 +495,7 @@ export class ProjectManager implements Disposable { } const configType = this.getConfigurationType(filePath); const configs = this.configs[configType]; - configs.set(dir, new ProjectConfiguration(this.localFs, dir, this.versions, filePath, undefined, this.traceModuleResolution, this.logger)); + configs.set(dir, new ProjectConfiguration(this.rootUri, this.localFs, dir, this.versions, filePath, undefined, this.traceModuleResolution, this.logger)); } const rootPath = this.rootPath.replace(/\/+$/, ''); @@ -520,7 +515,7 @@ export class ProjectManager implements Disposable { if (configs.size > 0) { tsConfig.exclude = ['**/*']; } - configs.set(rootPath, new ProjectConfiguration(this.localFs, this.rootPath, this.versions, '', tsConfig, this.traceModuleResolution, this.logger)); + configs.set(rootPath, new ProjectConfiguration(this.rootUri, this.localFs, this.rootPath, this.versions, '', tsConfig, this.traceModuleResolution, this.logger)); } } @@ -531,6 +526,7 @@ export class ProjectManager implements Disposable { * @return configuration type to use for a given file */ private getConfigurationType(filePath: string): ConfigType { + filePath = util.toUnixPath(filePath); const name = path_.posix.basename(filePath); if (name === 'tsconfig.json') { return 'ts'; @@ -595,7 +591,7 @@ export class InMemoryLanguageServiceHost implements ts.LanguageServiceHost { */ private versions: Map; - constructor(rootPath: string, options: ts.CompilerOptions, fs: InMemoryFileSystem, expectedFiles: string[], versions: Map, private logger: Logger = new NoopLogger()) { + constructor(private rootUri: URL, rootPath: string, options: ts.CompilerOptions, fs: InMemoryFileSystem, expectedFiles: string[], versions: Map, private logger: Logger = new NoopLogger()) { this.rootPath = rootPath; this.options = options; this.fs = fs; @@ -640,35 +636,31 @@ export class InMemoryLanguageServiceHost implements ts.LanguageServiceHost { } /** - * @param fileName relative or absolute file path + * @param fileName Path to the file, absolute or relative to `rootUri` */ - getScriptVersion(fileName: string): string { + getScriptVersion(filePath: string): string { - const uri = util.path2uri(this.rootPath, fileName); - if (path_.posix.isAbsolute(fileName) || path_.isAbsolute(fileName)) { - fileName = path_.posix.relative(this.rootPath, util.toUnixPath(fileName)); + const uri = util.path2uri(this.rootUri, filePath); + if (path_.posix.isAbsolute(filePath) || path_.isAbsolute(filePath)) { + filePath = path_.posix.relative(this.rootPath, util.toUnixPath(filePath)); } - let version = this.versions.get(uri); + let version = this.versions.get(uri.href); if (!version) { version = 1; - this.versions.set(uri, version); + this.versions.set(uri.href, version); } return '' + version; } /** - * @param fileName relative or absolute file path + * @param fileName Path to the file, absolute or relative to `rootUri` */ - getScriptSnapshot(fileName: string): ts.IScriptSnapshot | undefined { - let exists = this.fs.fileExists(fileName); - if (!exists) { - fileName = path_.posix.join(this.rootPath, fileName); - exists = this.fs.fileExists(fileName); - } - if (!exists) { + getScriptSnapshot(filePath: string): ts.IScriptSnapshot | undefined { + const content = this.fs.readFileIfExists(filePath); + if (content === undefined) { return undefined; } - return ts.ScriptSnapshot.fromString(this.fs.readFile(fileName)); + return ts.ScriptSnapshot.fromString(content); } getCurrentDirectory(): string { @@ -759,7 +751,7 @@ export class ProjectConfiguration { * @param configFilePath configuration file path, absolute * @param configContent optional configuration content to use instead of reading configuration file) */ - constructor(fs: InMemoryFileSystem, rootFilePath: string, versions: Map, configFilePath: string, configContent?: any, traceModuleResolution?: boolean, private logger: Logger = new NoopLogger()) { + constructor(private rootUri: URL, fs: InMemoryFileSystem, rootFilePath: string, versions: Map, configFilePath: string, configContent?: any, traceModuleResolution?: boolean, private logger: Logger = new NoopLogger()) { this.fs = fs; this.configFilePath = configFilePath; this.configContent = configContent; @@ -895,6 +887,7 @@ export class ProjectConfiguration { options.traceResolution = true; } this.host = new InMemoryLanguageServiceHost( + this.rootUri, this.fs.path, options, this.fs, @@ -933,7 +926,7 @@ export class ProjectConfiguration { let changed = false; for (const uri of this.fs.uris()) { - const fileName = util.uri2path(uri); + const fileName = util.toUnixPath(util.uri2path(uri)); if (util.isGlobalTSFile(fileName) || (!util.isDependencyFile(fileName) && util.isDeclarationFile(fileName))) { const sourceFile = program.getSourceFile(fileName); if (!sourceFile) { diff --git a/src/test/fs-helpers.ts b/src/test/fs-helpers.ts index 1e9b3c1ca..576adfe82 100644 --- a/src/test/fs-helpers.ts +++ b/src/test/fs-helpers.ts @@ -1,4 +1,5 @@ import { iterate } from 'iterare'; +import { URL } from 'whatwg-url'; import { FileSystem } from '../fs'; /** @@ -8,13 +9,14 @@ export class MapFileSystem implements FileSystem { constructor(private files: Map) { } - async getWorkspaceFiles(base?: string): Promise> { + async getWorkspaceFiles(base?: URL): Promise> { return iterate(this.files.keys()) - .filter(path => !base || path.startsWith(base)); + .filter(uri => !base || uri.startsWith(base.href)) + .map(uri => new URL(uri)); } - async getTextDocumentContent(uri: string): Promise { - const ret = this.files.get(uri); + async getTextDocumentContent(uri: URL): Promise { + const ret = this.files.get(uri.href); if (ret === undefined) { throw new Error(`Attempt to read not-existent file ${uri}`); } diff --git a/src/test/fs.test.ts b/src/test/fs.test.ts index b43bc57db..a60259af3 100644 --- a/src/test/fs.test.ts +++ b/src/test/fs.test.ts @@ -4,8 +4,9 @@ import * as fs from 'mz/fs'; import * as path from 'path'; import * as rimraf from 'rimraf'; import * as temp from 'temp'; +import { URL } from 'whatwg-url'; import { LocalFileSystem } from '../fs'; -import { path2uri, toUnixPath } from '../util'; +import { path2uri } from '../util'; import chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); const assert = chai.assert; @@ -14,21 +15,22 @@ describe('fs.ts', () => { describe('LocalFileSystem', () => { let temporaryDir: string; let fileSystem: LocalFileSystem; - let baseUri: string; + let baseUri: URL; before(async () => { temporaryDir = await new Promise((resolve, reject) => { temp.mkdir('local-fs', (err: Error, dirPath: string) => err ? reject(err) : resolve(dirPath)); }); - baseUri = path2uri('', temporaryDir) + '/'; + baseUri = path2uri(new URL('file:///'), temporaryDir + path.sep); await fs.mkdir(path.join(temporaryDir, 'foo')); await fs.mkdir(path.join(temporaryDir, '@types')); await fs.mkdir(path.join(temporaryDir, '@types', 'diff')); + await fs.writeFile(path.join(temporaryDir, '💩'), 'hi'); await fs.writeFile(path.join(temporaryDir, 'tweedledee'), 'hi'); await fs.writeFile(path.join(temporaryDir, 'tweedledum'), 'bye'); await fs.writeFile(path.join(temporaryDir, 'foo', 'bar.ts'), 'baz'); await fs.writeFile(path.join(temporaryDir, '@types', 'diff', 'index.d.ts'), 'baz'); - fileSystem = new LocalFileSystem(toUnixPath(temporaryDir)); + fileSystem = new LocalFileSystem(baseUri); }); after(async () => { await new Promise((resolve, reject) => { @@ -38,25 +40,25 @@ describe('fs.ts', () => { describe('getWorkspaceFiles()', () => { it('should return all files in the workspace', async () => { - assert.sameMembers(iterate(await fileSystem.getWorkspaceFiles()).toArray(), [ + const files = iterate(await fileSystem.getWorkspaceFiles()).map(uri => uri.href).toArray(); + assert.sameMembers(files, [ baseUri + 'tweedledee', baseUri + 'tweedledum', + baseUri + '%F0%9F%92%A9', baseUri + 'foo/bar.ts', baseUri + '%40types/diff/index.d.ts' ]); }); it('should return all files under specific root', async () => { - assert.sameMembers(iterate(await fileSystem.getWorkspaceFiles(baseUri + 'foo')).toArray(), [ + const files = iterate(await fileSystem.getWorkspaceFiles(new URL('foo/', baseUri.href))).map(uri => uri.href).toArray(); + assert.sameMembers(files, [ baseUri + 'foo/bar.ts' ]); }); }); describe('getTextDocumentContent()', () => { - it('should read files denoted by relative URI', async () => { - assert.equal(await fileSystem.getTextDocumentContent('tweedledee'), 'hi'); - }); - it('should read files denoted by absolute URI', async () => { - assert.equal(await fileSystem.getTextDocumentContent(baseUri + 'tweedledee'), 'hi'); + it('should return the content of a file by URI', async () => { + assert.equal(await fileSystem.getTextDocumentContent(new URL('tweedledee', baseUri.href)), 'hi'); }); }); }); diff --git a/src/test/memfs.test.ts b/src/test/memfs.test.ts index f1c726895..863df7a15 100644 --- a/src/test/memfs.test.ts +++ b/src/test/memfs.test.ts @@ -3,6 +3,7 @@ import iterate from 'iterare'; import { InMemoryFileSystem, typeScriptLibraries } from '../memfs'; import { uri2path } from '../util'; import chaiAsPromised = require('chai-as-promised'); +import { URL } from 'whatwg-url'; chai.use(chaiAsPromised); const assert = chai.assert; @@ -10,19 +11,19 @@ describe('memfs.ts', () => { describe('InMemoryFileSystem', () => { describe('uris()', () => { it('should hide TypeScript library files', async () => { - const fs = new InMemoryFileSystem('/'); + const fs = new InMemoryFileSystem(new URL('file:///'), '/'); assert.isFalse(iterate(fs.uris()).some(uri => typeScriptLibraries.has(uri2path(uri)))); }); }); describe('fileExists()', () => { it('should expose TypeScript library files', async () => { - const fs = new InMemoryFileSystem('/'); + const fs = new InMemoryFileSystem(new URL('file:///'), '/'); assert.isTrue(iterate(typeScriptLibraries.keys()).every(path => fs.fileExists(path))); }); }); describe('readFile()', () => { it('should expose TypeScript library files', async () => { - const fs = new InMemoryFileSystem('/'); + const fs = new InMemoryFileSystem(new URL('file:///'), '/'); assert.isTrue(iterate(typeScriptLibraries.keys()).every(path => !!fs.readFile(path))); }); }); diff --git a/src/test/project-manager.test.ts b/src/test/project-manager.test.ts index b37aa960b..2f8e4677d 100644 --- a/src/test/project-manager.test.ts +++ b/src/test/project-manager.test.ts @@ -2,6 +2,7 @@ import * as chai from 'chai'; import chaiAsPromised = require('chai-as-promised'); chai.use(chaiAsPromised); const assert = chai.assert; +import { URL } from 'whatwg-url'; import { FileSystemUpdater } from '../fs'; import { InMemoryFileSystem } from '../memfs'; import { ProjectManager } from '../project-manager'; @@ -14,7 +15,7 @@ describe('ProjectManager', () => { describe('getPackageName()', () => { beforeEach(async () => { - memfs = new InMemoryFileSystem('/'); + memfs = new InMemoryFileSystem(new URL('file:///'), '/'); const localfs = new MapFileSystem(new Map([ ['file:///package.json', '{"name": "package-name-1"}'], ['file:///subdirectory-with-tsconfig/package.json', '{"name": "package-name-2"}'], @@ -22,7 +23,7 @@ describe('ProjectManager', () => { ['file:///subdirectory-with-tsconfig/src/dummy.ts', ''] ])); const updater = new FileSystemUpdater(localfs, memfs); - projectManager = new ProjectManager('/', memfs, updater, true); + projectManager = new ProjectManager(new URL('file:///'), '/', memfs, updater, true); await projectManager.ensureAllFiles(); }); it('should resolve package name when package.json is at the same level', () => { @@ -34,7 +35,7 @@ describe('ProjectManager', () => { }); describe('ensureReferencedFiles()', () => { beforeEach(() => { - memfs = new InMemoryFileSystem('/'); + memfs = new InMemoryFileSystem(new URL('file:///'), '/'); const localfs = new MapFileSystem(new Map([ ['file:///package.json', '{"name": "package-name-1"}'], ['file:///node_modules/somelib/index.js', '/// \n/// '], @@ -43,24 +44,24 @@ describe('ProjectManager', () => { ['file:///src/dummy.ts', 'import * as somelib from "somelib";'] ])); const updater = new FileSystemUpdater(localfs, memfs); - projectManager = new ProjectManager('/', memfs, updater, true); + projectManager = new ProjectManager(new URL('file:///'), '/', 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'); + await projectManager.ensureReferencedFiles(new URL('file:///src/dummy.ts')).toPromise(); + memfs.getContent(new URL('file:///node_modules/somelib/index.js')); + memfs.getContent(new URL('file:///node_modules/somelib/pathref.d.ts')); + memfs.getContent(new URL('file:///node_modules/%40types/node/index.d.ts')); }); }); describe('getConfiguration()', () => { beforeEach(async () => { - memfs = new InMemoryFileSystem('/'); + memfs = new InMemoryFileSystem(new URL('file:///'), '/'); 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); + projectManager = new ProjectManager(new URL('file:///'), '/', memfs, updater, true); await projectManager.ensureAllFiles(); }); it('should resolve best configuration based on file name', () => { diff --git a/src/test/util-test.ts b/src/test/util-test.ts index fbbfeac8c..3184dc7f0 100644 --- a/src/test/util-test.ts +++ b/src/test/util-test.ts @@ -1,6 +1,48 @@ -import { symbolDescriptorMatch } from '../util'; +import * as assert from 'assert'; +import { URL } from 'whatwg-url'; +import { path2uri, symbolDescriptorMatch, uri2path } from '../util'; -describe('util tests', () => { +describe('util', () => { + describe('path2uri()', () => { + it('should convert a Unix file path to a URI', () => { + const uri = path2uri(new URL('file:///foo/bar'), '/baz/qux'); + assert.equal(uri.href, 'file:///baz/qux'); + }); + it('should convert a Windows file path to a URI', () => { + const uri = path2uri(new URL('file:///foo/bar'), 'C:\\baz\\qux'); + assert.equal(uri.href, 'file:///C:/baz/qux'); + }); + it('should encode special characters', () => { + const uri = path2uri(new URL('file:///foo/bar'), '/💩'); + assert.equal(uri.href, 'file:///%F0%9F%92%A9'); + }); + it('should encode unreserved special characters', () => { + const uri = path2uri(new URL('file:///foo/bar'), '/@baz'); + assert.equal(uri.href, 'file:///%40baz'); + }); + }); + describe('uri2path()', () => { + it('should convert a Unix file URI to a file path', () => { + const filePath = uri2path(new URL('file:///baz/qux')); + assert.equal(filePath, '/baz/qux'); + }); + it('should convert a Windows file URI to a file path', () => { + const filePath = uri2path(new URL('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(new URL('file:///C:/baz/qux')); + assert.equal(filePath, 'C:\\baz\\qux'); + }); + it('should decode special characters', () => { + const filePath = uri2path(new URL('file:///%F0%9F%92%A9')); + assert.equal(filePath, '/💩'); + }); + it('should decode unreserved special characters', () => { + const filePath = uri2path(new URL('file:///%40foo')); + assert.equal(filePath, '/@foo'); + }); + }); describe('symbolDescriptorMatch', () => { it('', (done: (err?: Error) => void) => { const want = true; diff --git a/src/typescript-service.ts b/src/typescript-service.ts index 1e20d2dd5..8155cbf39 100644 --- a/src/typescript-service.ts +++ b/src/typescript-service.ts @@ -4,7 +4,6 @@ import { toPairs } from 'lodash'; import { Span } from 'opentracing'; import * as path_ from 'path'; import * as ts from 'typescript'; -import * as url from 'url'; import { CompletionItem, CompletionItemKind, @@ -46,6 +45,7 @@ import { } from './request-type'; import * as util from './util'; import hashObject = require('object-hash'); +import { URL } from 'whatwg-url'; export interface TypeScriptServiceOptions { traceModuleResolution?: boolean; @@ -78,7 +78,7 @@ export class TypeScriptService { /** * The root URI as passed to `initialize` or converted from `rootPath` */ - protected rootUri: string; + protected rootUri: URL; /** * Cached response for empty workspace/symbol query @@ -133,17 +133,16 @@ export class TypeScriptService { */ async initialize(params: InitializeParams, span = new Span()): Promise { if (params.rootUri || params.rootPath) { - this.root = params.rootPath || util.uri2path(params.rootUri!); - this.rootUri = params.rootUri || util.path2uri('', params.rootPath!); + this.rootUri = params.rootUri ? new URL(params.rootUri) : util.path2uri(new URL('file:'), params.rootPath!); + this.root = params.rootPath || util.uri2path(this.rootUri); this._initializeFileSystems(!this.options.strict && !(params.capabilities.xcontentProvider && params.capabilities.xfilesProvider)); this.updater = new FileSystemUpdater(this.fileSystem, this.inMemoryFileSystem); - this.projectManager = new pm.ProjectManager(this.root, this.inMemoryFileSystem, this.updater, !!this.options.strict, this.traceModuleResolution, this.logger); + this.projectManager = new pm.ProjectManager(this.rootUri, this.root, this.inMemoryFileSystem, this.updater, !!this.options.strict, this.traceModuleResolution, this.logger); // Detect DefinitelyTyped this.isDefinitelyTyped = (async () => { try { // Fetch root package.json (if exists) - const rootUriParts = url.parse(this.rootUri); - const packageJsonUri = url.format({ ...rootUriParts, pathname: path_.posix.join(rootUriParts.pathname || '', 'package.json') }); + const packageJsonUri = new URL('package.json', this.rootUri.href); await this.updater.ensure(packageJsonUri); // Check name const packageJson = JSON.parse(this.inMemoryFileSystem.getContent(packageJsonUri)); @@ -194,8 +193,8 @@ export class TypeScriptService { * @param accessDisk Whether the language server is allowed to access the local file system */ protected _initializeFileSystems(accessDisk: boolean): void { - this.fileSystem = accessDisk ? new LocalFileSystem(util.uri2path(this.root)) : new RemoteFileSystem(this.client); - this.inMemoryFileSystem = new InMemoryFileSystem(this.root); + this.fileSystem = accessDisk ? new LocalFileSystem(this.rootUri) : new RemoteFileSystem(this.client); + this.inMemoryFileSystem = new InMemoryFileSystem(this.rootUri, this.root); } /** @@ -215,11 +214,12 @@ export class TypeScriptService { async textDocumentDefinition(params: TextDocumentPositionParams, span = new Span()): Promise { const line = params.position.line; const column = params.position.character; + const uri = new URL(params.textDocument.uri); // Fetch files needed to resolve definition - await this.projectManager.ensureReferencedFiles(params.textDocument.uri, undefined, undefined, span).toPromise(); + await this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span).toPromise(); - const fileName: string = util.uri2path(params.textDocument.uri); + const fileName: string = util.toUnixPath(util.uri2path(uri)); const configuration = this.projectManager.getConfiguration(fileName); configuration.ensureBasicFiles(span); @@ -239,7 +239,7 @@ export class TypeScriptService { } const start = ts.getLineAndCharacterOfPosition(sourceFile, def.textSpan.start); const end = ts.getLineAndCharacterOfPosition(sourceFile, def.textSpan.start + def.textSpan.length); - ret.push(Location.create(this._defUri(def.fileName), { + ret.push(Location.create(this._defUri(def.fileName).href, { start, end })); @@ -259,12 +259,13 @@ export class TypeScriptService { * know some information about it. */ textDocumentXdefinition(params: TextDocumentPositionParams, span = new Span()): Observable { + const uri = new URL(params.textDocument.uri); // Ensure files needed to resolve SymbolLocationInformation are fetched - return this.projectManager.ensureReferencedFiles(params.textDocument.uri, undefined, undefined, span) + return this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span) .toArray() .mergeMap(() => { // Convert URI to file path - const fileName: string = util.uri2path(params.textDocument.uri); + const fileName: string = util.toUnixPath(util.uri2path(uri)); // Get closest tsconfig configuration const configuration = this.projectManager.getConfiguration(fileName); configuration.ensureBasicFiles(span); @@ -288,7 +289,7 @@ export class TypeScriptService { return { symbol: util.defInfoToSymbolDescriptor(def), location: { - uri: this._defUri(def.fileName), + uri: this._defUri(def.fileName).href, range: { start, end @@ -306,17 +307,18 @@ export class TypeScriptService { * given text document position. */ async textDocumentHover(params: TextDocumentPositionParams, span = new Span()): Promise { + const uri = new URL(params.textDocument.uri); // Ensure files needed to resolve hover are fetched - await this.projectManager.ensureReferencedFiles(params.textDocument.uri, undefined, undefined, span).toPromise(); + await this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span).toPromise(); - const fileName: string = util.uri2path(params.textDocument.uri); + const fileName: string = util.toUnixPath(util.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 ${params.textDocument.uri}`); + throw new Error(`Expected source file ${fileName} to exist`); } const offset: number = ts.getPositionOfLineAndCharacter(sourceFile, params.position.line, params.position.character); const info = configuration.getService().getQuickInfoAtPosition(fileName, offset); @@ -344,11 +346,12 @@ export class TypeScriptService { * Returns all references to the symbol at the position in the own workspace, including references inside node_modules. */ textDocumentReferences(params: ReferenceParams, span = new Span()): Observable { + const uri = new URL(params.textDocument.uri); // Ensure all files were fetched to collect all references return Observable.from(this.projectManager.ensureOwnFiles(span)) .mergeMap(() => { // Convert URI to file path because TypeScript doesn't work with URIs - const fileName = util.uri2path(params.textDocument.uri); + const fileName = util.toUnixPath(util.uri2path(uri)); // Get tsconfig configuration for requested file const configuration = this.projectManager.getConfiguration(fileName); // Ensure all files have been added @@ -383,7 +386,7 @@ export class TypeScriptService { const start = ts.getLineAndCharacterOfPosition(sourceFile, reference.textSpan.start); const end = ts.getLineAndCharacterOfPosition(sourceFile, reference.textSpan.start + reference.textSpan.length); return { - uri: util.path2uri(this.root, reference.fileName), + uri: util.path2uri(this.rootUri, reference.fileName).href, range: { start, end @@ -488,14 +491,12 @@ export class TypeScriptService { } // Fetch all files in the package subdirectory - const rootUriParts = url.parse(this.rootUri); // All packages are in the types/ subdirectory - const packageRoot = path_.posix.join(rootUriParts.pathname || '', params.symbol.package.name.substr(1)) + '/'; - const packageRootUri = url.format({ ...rootUriParts, pathname: packageRoot, search: undefined, hash: undefined }); + const packageRootUri = new URL(params.symbol.package.name.substr(1), this.rootUri.href); await this.updater.ensureStructure(span); await Promise.all( iterate(this.inMemoryFileSystem.uris()) - .filter(uri => uri.startsWith(packageRootUri)) + .filter(uri => uri.href.startsWith(packageRootUri.href)) .map(uri => this.updater.ensure(uri, span)) ); this.projectManager.createConfigurations(); @@ -503,7 +504,7 @@ export class TypeScriptService { // Search symbol in configuration // forcing TypeScript mode - const config = this.projectManager.getConfiguration(packageRoot, 'ts'); + const config = this.projectManager.getConfiguration(util.toUnixPath(util.uri2path(packageRootUri)), 'ts'); return Array.from(this._collectWorkspaceSymbols(config, params.query || symbolQuery, params.limit)); } catch (err) { span.setTag('error', true); @@ -519,11 +520,12 @@ export class TypeScriptService { * in a given text document. */ async textDocumentDocumentSymbol(params: DocumentSymbolParams, span = new Span()): Promise { + const uri = new URL(params.textDocument.uri); // Ensure files needed to resolve symbols are fetched - await this.projectManager.ensureReferencedFiles(params.textDocument.uri, undefined, undefined, span).toPromise(); + await this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span).toPromise(); - const fileName = util.uri2path(params.textDocument.uri); + const fileName = util.toUnixPath(util.uri2path(uri)); const config = this.projectManager.getConfiguration(fileName); config.ensureBasicFiles(span); @@ -567,7 +569,7 @@ export class TypeScriptService { .map(symbol => ({ symbol, reference: { - uri: this._defUri(source.fileName), + uri: this._defUri(source.fileName).href, range: { start: ts.getLineAndCharacterOfPosition(source, node.pos + 1), end: ts.getLineAndCharacterOfPosition(source, node.end) @@ -602,9 +604,9 @@ export class TypeScriptService { // Ensure package.json files return Observable.from(this.projectManager.ensureModuleStructure(span)) // Iterate all files - .mergeMap(() => this.inMemoryFileSystem.uris() as any) + .mergeMap(() => this.inMemoryFileSystem.uris() as any) // Filter own package.jsons - .filter(uri => uri.includes('/package.json') && !uri.includes('/node_modules/')) + .filter(uri => uri.pathname.includes('/package.json') && !uri.pathname.includes('/node_modules/')) // Map to contents of package.jsons .mergeMap(uri => Observable.from(this.updater.ensure(uri)) @@ -650,9 +652,9 @@ export class TypeScriptService { // Ensure package.json files return Observable.from(this.projectManager.ensureModuleStructure()) // Iterate all files - .mergeMap(() => this.inMemoryFileSystem.uris() as any) + .mergeMap(() => this.inMemoryFileSystem.uris() as any) // Filter own package.jsons - .filter(uri => uri.includes('/package.json') && !uri.includes('/node_modules/')) + .filter(uri => uri.pathname.includes('/package.json') && !uri.pathname.includes('/node_modules/')) // Ensure contents of own package.jsons .mergeMap(uri => Observable.from(this.updater.ensure(uri)) @@ -691,11 +693,12 @@ export class TypeScriptService { * property filled in. */ async textDocumentCompletion(params: TextDocumentPositionParams, span = new Span()): Promise { + const uri = new URL(params.textDocument.uri); // Ensure files needed to suggest completions are fetched - await this.projectManager.ensureReferencedFiles(params.textDocument.uri, undefined, undefined, span).toPromise(); + await this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span).toPromise(); - const fileName: string = util.uri2path(params.textDocument.uri); + const fileName: string = util.toUnixPath(util.uri2path(uri)); const configuration = this.projectManager.getConfiguration(fileName); configuration.ensureBasicFiles(span); @@ -735,11 +738,12 @@ export class TypeScriptService { * information at a given cursor position. */ async textDocumentSignatureHelp(params: TextDocumentPositionParams, span = new Span()): Promise { + const uri = new URL(params.textDocument.uri); // Ensure files needed to resolve signature are fetched - await this.projectManager.ensureReferencedFiles(params.textDocument.uri, undefined, undefined, span).toPromise(); + await this.projectManager.ensureReferencedFiles(uri, undefined, undefined, span).toPromise(); - const filePath = util.uri2path(params.textDocument.uri); + const filePath = util.toUnixPath(util.uri2path(uri)); const configuration = this.projectManager.getConfiguration(filePath); configuration.ensureBasicFiles(span); @@ -777,11 +781,12 @@ export class TypeScriptService { * to read the document's truth using the document's uri. */ async textDocumentDidOpen(params: DidOpenTextDocumentParams): Promise { + const uri = new URL(params.textDocument.uri); // Ensure files needed for most operations are fetched - await this.projectManager.ensureReferencedFiles(params.textDocument.uri).toPromise(); + await this.projectManager.ensureReferencedFiles(uri).toPromise(); - this.projectManager.didOpen(params.textDocument.uri, params.textDocument.text); + this.projectManager.didOpen(uri, params.textDocument.text); } /** @@ -790,17 +795,18 @@ export class TypeScriptService { * and language ids. */ async textDocumentDidChange(params: DidChangeTextDocumentParams): Promise { + const uri = new URL(params.textDocument.uri); let text = null; - params.contentChanges.forEach(change => { + for (const change of params.contentChanges) { if (change.range || change.rangeLength) { - throw new Error('incremental updates in textDocument/didChange not supported for file ' + params.textDocument.uri); + throw new Error('Incremental updates in textDocument/didChange not supported for file ' + uri); } text = change.text; - }); + } if (!text) { return; } - this.projectManager.didChange(params.textDocument.uri, text); + this.projectManager.didChange(uri, text); } /** @@ -808,10 +814,11 @@ export class TypeScriptService { * saved in the client. */ async textDocumentDidSave(params: DidSaveTextDocumentParams): Promise { + const uri = new URL(params.textDocument.uri); // Ensure files needed to suggest completions are fetched - await this.projectManager.ensureReferencedFiles(params.textDocument.uri).toPromise(); - this.projectManager.didSave(params.textDocument.uri); + await this.projectManager.ensureReferencedFiles(uri).toPromise(); + this.projectManager.didSave(uri); } /** @@ -820,11 +827,12 @@ export class TypeScriptService { * (e.g. if the document's uri is a file uri the truth now exists on disk). */ async textDocumentDidClose(params: DidCloseTextDocumentParams): Promise { + const uri = new URL(params.textDocument.uri); // Ensure files needed to suggest completions are fetched - await this.projectManager.ensureReferencedFiles(params.textDocument.uri).toPromise(); + await this.projectManager.ensureReferencedFiles(uri).toPromise(); - this.projectManager.didClose(params.textDocument.uri); + this.projectManager.didClose(uri); } /** @@ -900,7 +908,7 @@ export class TypeScriptService { name: item.name, kind: util.convertStringtoSymbolKind(item.kind), location: { - uri: this._defUri(item.fileName), + uri: this._defUri(item.fileName).href, range: { start: ts.getLineAndCharacterOfPosition(sourceFile, item.textSpan.start), end: ts.getLineAndCharacterOfPosition(sourceFile, item.textSpan.start + item.textSpan.length) @@ -924,11 +932,11 @@ export class TypeScriptService { * Transforms definition's file name to URI. If definition belongs to TypeScript library, * returns git://github.com/Microsoft/TypeScript URL, otherwise returns file:// one */ - private _defUri(filePath: string): string { + private _defUri(filePath: string): URL { if (isTypeScriptLibrary(filePath)) { - return 'git://github.com/Microsoft/TypeScript?v' + ts.version + '#lib/' + path_.basename(filePath); + return new URL('git://github.com/Microsoft/TypeScript?v' + ts.version + '#lib/' + path_.basename(filePath)); } - return util.path2uri(this.root, filePath); + return util.path2uri(this.rootUri, filePath); } /** @@ -966,7 +974,7 @@ export class TypeScriptService { name: item.text, kind: util.convertStringtoSymbolKind(item.kind), location: { - uri: this._defUri(sourceFile.fileName), + uri: this._defUri(sourceFile.fileName).href, range: { start: ts.getLineAndCharacterOfPosition(sourceFile, span.start), end: ts.getLineAndCharacterOfPosition(sourceFile, span.start + span.length) diff --git a/src/typings/whatwg-url.d.ts b/src/typings/whatwg-url.d.ts new file mode 100644 index 000000000..a75adbcd7 --- /dev/null +++ b/src/typings/whatwg-url.d.ts @@ -0,0 +1,4 @@ + +declare module 'whatwg-url' { + export const URL: typeof window.URL; +} diff --git a/src/util.ts b/src/util.ts index 9f08c677e..de2fbbef0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,8 +1,8 @@ import * as os from 'os'; import * as path from 'path'; - import * as ts from 'typescript'; import { Position, Range, SymbolKind } from 'vscode-languageserver'; +import { URL } from 'whatwg-url'; import * as rt from './request-type'; let strict = false; @@ -79,32 +79,34 @@ export function convertStringtoSymbolKind(kind: string): SymbolKind { } } -export function path2uri(root: string, file: string): string { - let ret = 'file://'; - if (!strict && process.platform === 'win32') { - ret += '/'; - } - let p; - if (root) { - p = resolve(root, file); - } else { - p = file; +/** + * Returns the given file path as a URL. + * The returned URL uses protocol and host of the passed root URL + */ +export function path2uri(root: URL, file: string): URL { + const uri = new URL(root.href); + let pathname = file.split(/[\\\/]/).map((val, i) => i <= 1 && /^[a-z]:$/i.test(val) ? val : encodeURIComponent(val)).join('/'); + if (!pathname.startsWith('/')) { + pathname = '/' + pathname; } - p = toUnixPath(p).split('/').map((val, i) => i <= 1 && /^[a-z]:$/i.test(val) ? val : encodeURIComponent(val)).join('/'); - return ret + p; + uri.pathname = pathname; + return uri; } -export function uri2path(uri: string): string { - if (uri.startsWith('file://')) { - uri = uri.substring('file://'.length); - if (process.platform === 'win32') { - if (!strict) { - uri = uri.substring(1); - } - } - uri = uri.split('/').map(decodeURIComponent).join('/'); +/** + * Returns the path component of the passed URI as a file path. + * The OS style and seperator is determined by the presence of a Windows drive letter + colon in the URI. + * Does not check the URI protocol. + */ +export function uri2path(uri: URL): string { + // %-decode parts + let filePath = decodeURIComponent(uri.pathname); + // Strip the leading slash on Windows + const isWindowsUri = /^\/[a-z]:\//i.test(filePath); + if (isWindowsUri) { + filePath = filePath.substr(1); } - return uri; + return filePath.replace(/\//g, isWindowsUri ? '\\' : '/'); } export function isLocalUri(uri: string): boolean {