diff --git a/package.json b/package.json index 0415300..1dc65b0 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ "@sourcegraph/tslint-config": "^12.0.0", "@types/minimatch": "^3.0.3", "@types/mocha": "^5.2.5", - "@types/node": "^10.5.0", + "@types/node": "^10.12.2", "concurrently": "^4.0.1", "cpy-cli": "^2.0.0", "husky": "^0.14.3", diff --git a/src/client/api/fileSystem.ts b/src/client/api/fileSystem.ts new file mode 100644 index 0000000..e254d15 --- /dev/null +++ b/src/client/api/fileSystem.ts @@ -0,0 +1,29 @@ +import { FileSystem, FileType } from 'sourcegraph' +import { createProxyAndHandleRequests } from '../../common/proxy' +import { Connection } from '../../protocol/jsonrpc2/connection' +import { URI } from '../../shared/uri' + +/** @internal */ +export interface ClientFileSystemAPI { + $readDirectory(uri: string): Promise<[string, FileType][]> + $readFile(uri: string): Promise +} + +/** @internal */ +export class ClientFileSystem { + constructor(connection: Connection, private getFileSystem: (uri: string) => FileSystem) { + createProxyAndHandleRequests('fileSystem', connection, this) + } + + public $readDirectory(uri: string): Promise<[string, FileType][]> { + return this.getFileSystem(uri).readDirectory(URI.parse(uri)) + } + + public $readFile(uri: string): Promise { + return this.getFileSystem(uri).readFile(URI.parse(uri)) + } + + public unsubscribe(): void { + // noop + } +} diff --git a/src/client/api/roots.ts b/src/client/api/roots.ts new file mode 100644 index 0000000..fae5dc6 --- /dev/null +++ b/src/client/api/roots.ts @@ -0,0 +1,25 @@ +import { Observable, Subscription } from 'rxjs' +import { createProxyAndHandleRequests } from '../../common/proxy' +import { ExtRootsAPI } from '../../extension/api/roots' +import { Connection } from '../../protocol/jsonrpc2/connection' +import { WorkspaceRoot } from '../../protocol/plainTypes' + +/** @internal */ +export class ClientRoots { + private subscriptions = new Subscription() + private proxy: ExtRootsAPI + + constructor(connection: Connection, environmentRoots: Observable) { + this.proxy = createProxyAndHandleRequests('roots', connection, this) + + this.subscriptions.add( + environmentRoots.subscribe(roots => { + this.proxy.$acceptRoots(roots || []) + }) + ) + } + + public unsubscribe(): void { + this.subscriptions.unsubscribe() + } +} diff --git a/src/client/controller.ts b/src/client/controller.ts index 6a96102..e03b0d1 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -1,6 +1,6 @@ import { BehaviorSubject, Observable, Subject, Subscription, Unsubscribable } from 'rxjs' import { distinctUntilChanged, map } from 'rxjs/operators' -import { ContextValues } from 'sourcegraph' +import { ContextValues, FileSystem } from 'sourcegraph' import { ConfigurationCascade, ConfigurationUpdateParams, @@ -18,7 +18,9 @@ import { ClientCommands } from './api/commands' import { ClientConfiguration } from './api/configuration' import { ClientContext } from './api/context' import { ClientDocuments } from './api/documents' +import { ClientFileSystem } from './api/fileSystem' import { ClientLanguageFeatures } from './api/languageFeatures' +import { ClientRoots } from './api/roots' import { Search } from './api/search' import { ClientViews } from './api/views' import { ClientWindows } from './api/windows' @@ -67,6 +69,11 @@ export interface ControllerOptions) => Environment + + /** + * Called to obtain a {@link module:sourcegraph.FileSystem} to access the file system for a URI. + */ + getFileSystem: (uri: string) => FileSystem } /** @@ -273,6 +280,16 @@ export class Controller imp ) subscription.add(new Search(client, this.registries.queryTransformer)) subscription.add(new ClientCommands(client, this.registries.commands)) + subscription.add( + new ClientRoots( + client, + this.environment.pipe( + map(({ roots }) => roots), + distinctUntilChanged() + ) + ) + ) + subscription.add(new ClientFileSystem(client, this.options.getFileSystem)) } public set trace(value: Trace) { diff --git a/src/client/environment.ts b/src/client/environment.ts index 6a87519..18fb730 100644 --- a/src/client/environment.ts +++ b/src/client/environment.ts @@ -1,4 +1,5 @@ import { ConfigurationCascade } from '../protocol' +import { WorkspaceRoot } from '../protocol/plainTypes' import { Context, EMPTY_CONTEXT } from './context/context' import { Extension } from './extension' import { TextDocumentItem } from './types/textDocument' @@ -13,6 +14,11 @@ import { TextDocumentItem } from './types/textDocument' * @template C configuration cascade type */ export interface Environment { + /** + * The currently open workspace roots (typically a single repository). + */ + readonly roots: WorkspaceRoot[] | null + /** * The text documents that are currently visible. Each text document is represented to extensions as being * in its own visible CodeEditor. @@ -31,6 +37,7 @@ export interface Environment = { + roots: null, visibleTextDocuments: null, extensions: null, configuration: { merged: {} }, diff --git a/src/extension/api/fileSystem.ts b/src/extension/api/fileSystem.ts new file mode 100644 index 0000000..b84f756 --- /dev/null +++ b/src/extension/api/fileSystem.ts @@ -0,0 +1,15 @@ +import { FileType, URI } from 'sourcegraph' +import { ClientFileSystemAPI } from '../../client/api/fileSystem' + +/** @internal */ +export class ExtFileSystem { + constructor(private proxy: ClientFileSystemAPI) {} + + public readDirectory(uri: URI): Promise<[string, FileType][]> { + return this.proxy.$readDirectory(uri.toString()) + } + + public readFile(uri: URI): Promise { + return this.proxy.$readFile(uri.toString()) + } +} diff --git a/src/extension/api/roots.ts b/src/extension/api/roots.ts new file mode 100644 index 0000000..0bdb1a2 --- /dev/null +++ b/src/extension/api/roots.ts @@ -0,0 +1,33 @@ +import { Observable, Subject } from 'rxjs' +import * as sourcegraph from 'sourcegraph' +import { WorkspaceRoot as PlainWorkspaceRoot } from '../../protocol/plainTypes' +import { URI } from '../../shared/uri' + +/** @internal */ +export interface ExtRootsAPI { + $acceptRoots(roots: PlainWorkspaceRoot[]): void +} + +/** @internal */ +export class ExtRoots implements ExtRootsAPI { + private roots: ReadonlyArray = [] + + /** + * Returns all workspace roots. + * + * @internal + */ + public getAll(): ReadonlyArray { + return this.roots + } + + private changes = new Subject() + public readonly onDidChange: Observable = this.changes + + public $acceptRoots(roots: PlainWorkspaceRoot[]): void { + this.roots = Object.freeze( + roots.map(plain => ({ ...plain, uri: URI.parse(plain.uri) } as sourcegraph.WorkspaceRoot)) + ) + this.changes.next() + } +} diff --git a/src/extension/extensionHost.ts b/src/extension/extensionHost.ts index 208e6f5..74ef84a 100644 --- a/src/extension/extensionHost.ts +++ b/src/extension/extensionHost.ts @@ -3,11 +3,14 @@ import * as sourcegraph from 'sourcegraph' import { createProxy, handleRequests } from '../common/proxy' import { Connection, createConnection, Logger, MessageTransports } from '../protocol/jsonrpc2/connection' import { createWebWorkerMessageTransports } from '../protocol/jsonrpc2/transports/webWorker' +import { URI } from '../shared/uri' import { ExtCommands } from './api/commands' import { ExtConfiguration } from './api/configuration' import { ExtContext } from './api/context' import { ExtDocuments } from './api/documents' +import { ExtFileSystem } from './api/fileSystem' import { ExtLanguageFeatures } from './api/languageFeatures' +import { ExtRoots } from './api/roots' import { ExtSearch } from './api/search' import { ExtViews } from './api/views' import { ExtWindows } from './api/windows' @@ -15,7 +18,6 @@ import { Location } from './types/location' import { Position } from './types/position' import { Range } from './types/range' import { Selection } from './types/selection' -import { URI } from './types/uri' const consoleLogger: Logger = { error(message: string): void { @@ -81,6 +83,12 @@ function createExtensionHandle(initData: InitData, connection: Connection): type const documents = new ExtDocuments(sync) handleRequests(connection, 'documents', documents) + const roots = new ExtRoots() + handleRequests(connection, 'roots', roots) + + const fileSystem = new ExtFileSystem(proxy('fileSystem')) + handleRequests(connection, 'fileSystem', fileSystem) + const windows = new ExtWindows(proxy('windows'), proxy('codeEditor'), documents) handleRequests(connection, 'windows', windows) @@ -109,6 +117,12 @@ function createExtensionHandle(initData: InitData, connection: Connection): type PlainText: sourcegraph.MarkupKind.PlainText, Markdown: sourcegraph.MarkupKind.Markdown, }, + FileType: { + Unknown: sourcegraph.FileType.Unknown, + File: sourcegraph.FileType.File, + Directory: sourcegraph.FileType.Directory, + SymbolicLink: sourcegraph.FileType.SymbolicLink, + }, app: { get activeWindow(): sourcegraph.Window | undefined { @@ -125,6 +139,11 @@ function createExtensionHandle(initData: InitData, connection: Connection): type return documents.getAll() }, onDidOpenTextDocument: documents.onDidOpenTextDocument, + fileSystem, + get roots(): ReadonlyArray { + return roots.getAll() + }, + onDidChangeRoots: roots.onDidChange, }, configuration: { @@ -156,7 +175,7 @@ function createExtensionHandle(initData: InitData, connection: Connection): type internal: { sync, updateContext: updates => context.updateContext(updates), - sourcegraphURL: new URI(initData.sourcegraphURL), + sourcegraphURL: URI.parse(initData.sourcegraphURL), clientApplication: initData.clientApplication, }, } diff --git a/src/extension/types/location.test.ts b/src/extension/types/location.test.ts index 83a471b..15276b8 100644 --- a/src/extension/types/location.test.ts +++ b/src/extension/types/location.test.ts @@ -1,17 +1,17 @@ +import { URI } from '../../shared/uri' import { assertToJSON } from './common.test' import { Location } from './location' import { Position } from './position' import { Range } from './range' -import { URI } from './uri' describe('Location', () => { it('toJSON', () => { - assertToJSON(new Location(URI.file('u.ts'), new Position(3, 4)), { - uri: URI.parse('file://u.ts').toJSON(), + assertToJSON(new Location(URI.parse('file:///u.ts'), new Position(3, 4)), { + uri: URI.parse('file:///u.ts').toJSON(), range: { start: { line: 3, character: 4 }, end: { line: 3, character: 4 } }, }) - assertToJSON(new Location(URI.file('u.ts'), new Range(1, 2, 3, 4)), { - uri: URI.parse('file://u.ts').toJSON(), + assertToJSON(new Location(URI.parse('file:///u.ts'), new Range(1, 2, 3, 4)), { + uri: URI.parse('file:///u.ts').toJSON(), range: { start: { line: 1, character: 2 }, end: { line: 3, character: 4 } }, }) }) diff --git a/src/extension/types/location.ts b/src/extension/types/location.ts index 538a6bb..3f2f895 100644 --- a/src/extension/types/location.ts +++ b/src/extension/types/location.ts @@ -1,7 +1,7 @@ import * as sourcegraph from 'sourcegraph' +import { URI } from '../../shared/uri' import { Position } from './position' import { Range } from './range' -import { URI } from './uri' export class Location implements sourcegraph.Location { public static isLocation(thing: any): thing is sourcegraph.Location { diff --git a/src/extension/types/uri.ts b/src/extension/types/uri.ts deleted file mode 100644 index 9085a68..0000000 --- a/src/extension/types/uri.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as sourcegraph from 'sourcegraph' - -export class URI implements sourcegraph.URI { - public static parse(uri: string): sourcegraph.URI { - return new URI(uri) - } - - public static file(path: string): sourcegraph.URI { - return new URI(`file://${path}`) - } - - public static isURI(value: any): value is sourcegraph.URI { - return value instanceof URI || typeof value === 'string' - } - - constructor(private value: string) {} - - public toString(): string { - return this.value - } - - public toJSON(): any { - return this.value - } -} diff --git a/src/extension/workerMain.ts b/src/extension/workerMain.ts index 67f33ef..06e0382 100644 --- a/src/extension/workerMain.ts +++ b/src/extension/workerMain.ts @@ -32,49 +32,53 @@ export function extensionHostWorkerMain(self: DedicatedWorkerGlobalScope): void self.addEventListener('message', receiveExtensionURL) function receiveExtensionURL(ev: MessageEvent): void { - // Only listen for the 1st URL. - self.removeEventListener('message', receiveExtensionURL) + try { + // Only listen for the 1st URL. + self.removeEventListener('message', receiveExtensionURL) - if (ev.origin && ev.origin !== self.location.origin) { - console.error(`Invalid extension host message origin: ${ev.origin} (expected ${self.location.origin})`) - self.close() - } + if (ev.origin && ev.origin !== self.location.origin) { + console.error(`Invalid extension host message origin: ${ev.origin} (expected ${self.location.origin})`) + self.close() + } - const initData: InitData = ev.data - if (typeof initData.bundleURL !== 'string' || !initData.bundleURL.startsWith('blob:')) { - console.error(`Invalid extension bundle URL: ${initData.bundleURL}`) - self.close() - } + const initData: InitData = ev.data + if (typeof initData.bundleURL !== 'string' || !initData.bundleURL.startsWith('blob:')) { + console.error(`Invalid extension bundle URL: ${initData.bundleURL}`) + self.close() + } - const api = createExtensionHost(initData) - // Make `import 'sourcegraph'` or `require('sourcegraph')` return the extension host's - // implementation of the `sourcegraph` module. - ;(self as any).require = (modulePath: string): any => { - if (modulePath === 'sourcegraph') { - return api + const api = createExtensionHost(initData) + // Make `import 'sourcegraph'` or `require('sourcegraph')` return the extension host's + // implementation of the `sourcegraph` module. + ;(self as any).require = (modulePath: string): any => { + if (modulePath === 'sourcegraph') { + return api + } + throw new Error(`require: module not found: ${modulePath}`) } - throw new Error(`require: module not found: ${modulePath}`) - } - // Load the extension bundle and retrieve the extension entrypoint module's exports on the global - // `module` property. - ;(self as any).exports = {} - ;(self as any).module = {} - self.importScripts(initData.bundleURL) - const extensionExports = (self as any).module.exports - delete (self as any).module + // Load the extension bundle and retrieve the extension entrypoint module's exports on the global + // `module` property. + ;(self as any).exports = {} + ;(self as any).module = {} + self.importScripts(initData.bundleURL) + const extensionExports = (self as any).module.exports + delete (self as any).module - if ('activate' in extensionExports) { - try { - tryCatchPromise(() => extensionExports.activate()).catch((err: any) => { - console.error(`Error creating extension host:`, err) - self.close() - }) - } catch (err) { - console.error(`Error activating extension.`, err) + if ('activate' in extensionExports) { + try { + tryCatchPromise(() => extensionExports.activate()).catch((err: any) => { + console.error(`Error creating extension host:`, err) + self.close() + }) + } catch (err) { + console.error(`Error activating extension.`, err) + } + } else { + console.error(`Extension did not export an 'activate' function.`) } - } else { - console.error(`Extension did not export an 'activate' function.`) + } catch (err) { + console.error(err) } } } diff --git a/src/integration-test/fileSystem.test.ts b/src/integration-test/fileSystem.test.ts new file mode 100644 index 0000000..143b16e --- /dev/null +++ b/src/integration-test/fileSystem.test.ts @@ -0,0 +1,32 @@ +import * as assert from 'assert' +import * as sourcegraph from 'sourcegraph' +import { URI } from '../shared/uri' +import { tryCatchPromise } from '../util' +import { integrationTestContext } from './helpers.test' + +describe('File system (integration)', () => { + describe('workspace.fileSystem', () => { + it('readDirectory', async () => { + const { extensionHost, ready } = await integrationTestContext() + + await ready + assert.deepStrictEqual(await extensionHost.workspace.fileSystem.readDirectory(URI.parse('file:///')), [ + ['f', 1 as sourcegraph.FileType.File], + ] as [string, sourcegraph.FileType][]) + }) + + it('readFile', async () => { + const { extensionHost, ready } = await integrationTestContext() + + await ready + assert.deepStrictEqual( + await extensionHost.workspace.fileSystem.readFile(URI.parse('file:///f')), + new Uint8Array([116]) as Uint8Array + ) + // tslint:disable-next-line:no-floating-promises + assert.rejects( + tryCatchPromise(() => extensionHost.workspace.fileSystem.readFile(URI.parse('file:///does-not-exist'))) + ) + }) + }) +}) diff --git a/src/integration-test/helpers.test.ts b/src/integration-test/helpers.test.ts index 3fe1b1e..b90fb34 100644 --- a/src/integration-test/helpers.test.ts +++ b/src/integration-test/helpers.test.ts @@ -1,11 +1,13 @@ import { filter, first } from 'rxjs/operators' import * as sourcegraph from 'sourcegraph' +import { TextEncoder } from 'util' import { Controller } from '../client/controller' import { Environment } from '../client/environment' import { createExtensionHost } from '../extension/extensionHost' import { createMessageTransports } from '../protocol/jsonrpc2/helpers.test' const FIXTURE_ENVIRONMENT: Environment = { + roots: [{ uri: 'file:///' }], visibleTextDocuments: [ { uri: 'file:///f', @@ -38,6 +40,7 @@ export async function integrationTestContext(): Promise< const clientController = new Controller({ clientOptions: () => ({ createMessageTransports: () => clientTransports }), + getFileSystem: () => memoryFileSystem({ 'file:///f': 't' }), }) clientController.setEnvironment(FIXTURE_ENVIRONMENT) @@ -81,6 +84,39 @@ export async function integrationTestContext(): Promise< } } +/** + * Creates a new implementation of {@link sourcegraph.FileSystem} backed by an object whose keys are URIs and whose + * values are the file contents. + * + * @todo Support nested file systems. + * + * @param data An object of URI to file contents. All entries are treated as files, and no hierarchy is supported. + */ +export function memoryFileSystem(data: { [uri: string]: string }): sourcegraph.FileSystem { + data = data || {} + return { + readDirectory: dir => + Promise.resolve( + Object.keys(data).map( + // HACK: Can't use sourcegraph.FileType.File value or else it complains "Cannot find module + // 'sourcegraph'". + uri => + [uri.toString().replace(dir.toString(), ''), 1 as sourcegraph.FileType.File] as [ + string, + sourcegraph.FileType + ] + ) + ), + readFile: uri => { + const contents = data[uri.toString()] + if (contents === undefined) { + throw new Error(`file not found: ${uri}`) + } + return Promise.resolve(new TextEncoder().encode(contents)) + }, + } +} + /** @internal */ async function ready({ extensionHost }: TestContext): Promise { await extensionHost.internal.sync() diff --git a/src/integration-test/languageFeatures.test.ts b/src/integration-test/languageFeatures.test.ts index e8d2b6f..d0c4649 100644 --- a/src/integration-test/languageFeatures.test.ts +++ b/src/integration-test/languageFeatures.test.ts @@ -4,8 +4,8 @@ import * as sourcegraph from 'sourcegraph' import { languages as sourcegraphLanguages } from 'sourcegraph' import { Controller } from '../client/controller' import { assertToJSON } from '../extension/types/common.test' -import { URI } from '../extension/types/uri' import { Definition } from '../protocol/plainTypes' +import { URI } from '../shared/uri' import { createBarrier, integrationTestContext } from './helpers.test' describe('LanguageFeatures (integration)', () => { @@ -31,7 +31,7 @@ describe('LanguageFeatures (integration)', () => { extensionHost => extensionHost.languages.registerDefinitionProvider, label => ({ - provideDefinition: (doc, pos) => [{ uri: new URI(`file:///${label}`) }], + provideDefinition: (doc, pos) => [{ uri: URI.parse(`file:///${label}`) }], } as sourcegraph.DefinitionProvider), labeledDefinitionResults, run => ({ provideDefinition: run } as sourcegraph.DefinitionProvider), @@ -46,7 +46,7 @@ describe('LanguageFeatures (integration)', () => { extensionHost => extensionHost.languages.registerTypeDefinitionProvider, label => ({ - provideTypeDefinition: (doc, pos) => [{ uri: new URI(`file:///${label}`) }], + provideTypeDefinition: (doc, pos) => [{ uri: URI.parse(`file:///${label}`) }], } as sourcegraph.TypeDefinitionProvider), labeledDefinitionResults, run => ({ provideTypeDefinition: run } as sourcegraph.TypeDefinitionProvider), @@ -61,7 +61,7 @@ describe('LanguageFeatures (integration)', () => { extensionHost => extensionHost.languages.registerImplementationProvider, label => ({ - provideImplementation: (doc, pos) => [{ uri: new URI(`file:///${label}`) }], + provideImplementation: (doc, pos) => [{ uri: URI.parse(`file:///${label}`) }], } as sourcegraph.ImplementationProvider), labeledDefinitionResults, run => ({ provideImplementation: run } as sourcegraph.ImplementationProvider), @@ -76,7 +76,7 @@ describe('LanguageFeatures (integration)', () => { extensionHost => extensionHost.languages.registerReferenceProvider, label => ({ - provideReferences: (doc, pos, context) => [{ uri: new URI(`file:///${label}`) }], + provideReferences: (doc, pos, context) => [{ uri: URI.parse(`file:///${label}`) }], } as sourcegraph.ReferenceProvider), labels => labels.map(label => ({ uri: `file:///${label}`, range: undefined })), run => ({ provideReferences: run } as sourcegraph.ReferenceProvider), diff --git a/src/integration-test/roots.test.ts b/src/integration-test/roots.test.ts new file mode 100644 index 0000000..f990646 --- /dev/null +++ b/src/integration-test/roots.test.ts @@ -0,0 +1,50 @@ +import * as assert from 'assert' +import { WorkspaceRoot } from 'sourcegraph' +import { URI } from '../shared/uri' +import { collectSubscribableValues, integrationTestContext } from './helpers.test' + +describe('Workspace roots (integration)', () => { + describe('workspace.roots', () => { + it('lists roots', async () => { + const { extensionHost, ready } = await integrationTestContext() + + await ready + assert.deepStrictEqual(extensionHost.workspace.roots, [{ uri: URI.parse('file:///') }] as WorkspaceRoot[]) + }) + + it('adds new text documents', async () => { + const { clientController, extensionHost, getEnvironment, ready } = await integrationTestContext() + + const prevEnvironment = getEnvironment() + clientController.setEnvironment({ + ...prevEnvironment, + roots: [{ uri: 'file:///a' }, { uri: 'file:///b' }], + }) + + await ready + assert.deepStrictEqual(extensionHost.workspace.roots, [ + { uri: URI.parse('file:///a') }, + { uri: URI.parse('file:///b') }, + ] as WorkspaceRoot[]) + }) + }) + + describe('workspace.onDidChangeRoots', () => { + it('fires when a root is added or removed', async () => { + const { clientController, extensionHost, getEnvironment, ready } = await integrationTestContext() + + await ready + const values = collectSubscribableValues(extensionHost.workspace.onDidChangeRoots) + assert.deepStrictEqual(values, [] as void[]) + + const prevEnvironment = getEnvironment() + clientController.setEnvironment({ + ...prevEnvironment, + roots: [{ uri: 'file:///a' }], + }) + await extensionHost.internal.sync() + + assert.deepStrictEqual(values, [void 0]) + }) + }) +}) diff --git a/src/protocol/jsonrpc2/connection.ts b/src/protocol/jsonrpc2/connection.ts index e742527..6f0ed02 100644 --- a/src/protocol/jsonrpc2/connection.ts +++ b/src/protocol/jsonrpc2/connection.ts @@ -154,7 +154,7 @@ function _createConnection(transports: MessageTransports, logger: Logger, strate let starNotificationHandler: StarNotificationHandler | undefined const notificationHandlers: { [name: string]: NotificationHandlerElement | undefined } = Object.create(null) - let timer: NodeJS.Timer | undefined + let timer: NodeJS.Timer | NodeJS.Immediate | undefined let messageQueue: MessageQueue = new LinkedMap() let responsePromises: { [name: string]: ResponsePromise } = Object.create(null) let requestTokens: { [id: string]: CancellationTokenSource } = Object.create(null) @@ -661,7 +661,7 @@ function _createConnection(transports: MessageTransports, logger: Logger, strate } /** Support browser and node environments without needing a transpiler. */ -function setImmediateCompat(f: () => void): NodeJS.Timer { +function setImmediateCompat(f: () => void): NodeJS.Timer | NodeJS.Immediate { if (typeof setImmediate !== 'undefined') { return setImmediate(f) } diff --git a/src/protocol/plainTypes.ts b/src/protocol/plainTypes.ts index 477a672..a3a7c02 100644 --- a/src/protocol/plainTypes.ts +++ b/src/protocol/plainTypes.ts @@ -1,5 +1,10 @@ import * as sourcegraph from 'sourcegraph' +/** The plain properties of a {@link module:sourcegraph.WorkspaceRoot}, without methods and accessors. */ +export interface WorkspaceRoot extends Pick { + uri: string +} + /** The plain properties of a {@link module:sourcegraph.Position}, without methods and accessors. */ export interface Position extends Pick {} diff --git a/src/shared/uri.test.ts b/src/shared/uri.test.ts new file mode 100644 index 0000000..b0c31b0 --- /dev/null +++ b/src/shared/uri.test.ts @@ -0,0 +1,68 @@ +import assert from 'assert' +import { URI, URIComponents } from './uri' + +function assertParsedURI(uriStr: string, expected: Partial, expectedStr = uriStr): void { + expected.scheme = expected.scheme || '' + expected.authority = expected.authority || '' + expected.path = expected.path || '' + expected.query = expected.query || '' + expected.fragment = expected.fragment || '' + + const uri = URI.parse(uriStr) + assert.deepStrictEqual(uri.toJSON(), expected, `URI.parse(${JSON.stringify(uriStr)})`) + + assert.strictEqual(uri.toString(), expectedStr, `URI#toString ${JSON.stringify(uri.toJSON())}`) +} + +describe('URI', () => { + it('parses and produces string representation', () => { + // A '/' is automatically added to the path for special schemes by WHATWG URL. This is undesirable but + // harmless. + assertParsedURI( + 'https://example.com', + { + scheme: 'https', + authority: 'example.com', + path: '/', + }, + 'https://example.com/' + ) + assertParsedURI( + 'foo://example.com', + { + scheme: 'foo', + authority: 'example.com', + }, + 'foo://example.com' + ) + assertParsedURI('https://example.com/a', { + scheme: 'https', + authority: 'example.com', + path: '/a', + }) + assertParsedURI('foo://example.com/a', { + scheme: 'foo', + authority: 'example.com', + path: '/a', + }) + assertParsedURI('https://u:p@example.com:1234/a/b?c=d#e', { + scheme: 'https', + authority: 'u:p@example.com:1234', + path: '/a/b', + query: 'c=d', + fragment: 'e', + }) + assertParsedURI('file:///a', { + scheme: 'file', + path: '/a', + }) + }) + + it('with', () => + assert.strictEqual( + URI.parse('https://example.com/a') + .with({ path: '/b', query: 'c' }) + .toString(), + 'https://example.com/b?c' + )) +}) diff --git a/src/shared/uri.ts b/src/shared/uri.ts new file mode 100644 index 0000000..5f48666 --- /dev/null +++ b/src/shared/uri.ts @@ -0,0 +1,142 @@ +import * as sourcegraph from 'sourcegraph' +import { URL as _URL } from 'url' + +declare class URL extends _URL { + constructor(urlStr: string) +} + +/** + * A serialized representation of a {@link URI} produced by {@link URI#toJSON} and that can be deserialized with + * {@link URI.fromJSON}. + */ +export interface URIComponents { + scheme: string + authority: string + path: string + query: string + fragment: string +} + +/** + * A uniform resource identifier (URI), as defined in [RFC 3986](https://tools.ietf.org/html/rfc3986#section-3). + * + * This URI class should be used instead of WHATWG URL because it parses all URI schemes in the same way on all + * platforms. The WHATWG URL spec requires "non-special schemes" (anything other than http, https, and a few other + * common schemes; see https://url.spec.whatwg.org/#special-scheme) to be parsed differently in a way that is not + * desirable. For example: + * + * With WHATWG URL: + * + * new URL("https://foo/bar").pathname === "/bar" + * + * new URL("git://foo/bar").pathname === "//foo/bar" // UNDESIRABLE + * + * With this URI class: + * + * URI.parse("https://foo/bar").pathname === "/bar" + * + * URI.parse("git://foo/bar").pathname === "/bar" + * + * Sourcegraph extensions generally intend to treat all URI schemes identically (as in the second example). This + * class implements that behavior. + */ +export class URI implements sourcegraph.URI, URIComponents { + public static parse(uri: string): sourcegraph.URI { + const url = new URL(uri) + return new URI({ + scheme: url.protocol.slice(0, -1), // omit trailing ':' + authority: makeAuthority(url), + path: url.pathname, + query: url.search.slice(1), // omit leading '?' + fragment: url.hash.slice(1), // omit leading '#' + }) + } + + public static isURI(value: any): value is sourcegraph.URI { + return ( + value instanceof URI || + (value && + (typeof value.scheme === 'string' && + typeof value.authority === 'string' && + typeof value.path === 'string' && + typeof value.query === 'string' && + typeof value.fragment === 'string')) + ) + } + + private constructor(components: URIComponents) { + this.scheme = components.scheme + this.authority = components.authority + this.path = components.path + this.query = components.query + this.fragment = components.fragment + } + + public readonly scheme: string + + public readonly authority: string + + public readonly path: string + + public readonly query: string + + public readonly fragment: string + + public with(change: Partial): URI { + return new URI({ ...this.toJSON(), ...change }) + } + + public toString(): string { + let s = '' + if (this.scheme) { + s += this.scheme + s += ':' + } + if (this.authority || this.scheme === 'file') { + s += '//' + } + if (this.authority) { + s += this.authority + } + if (this.path) { + s += this.path + } + if (this.query) { + s += '?' + s += this.query + } + if (this.fragment) { + s += '#' + s += this.fragment + } + return s + } + + public toJSON(): URIComponents { + return { + scheme: this.scheme, + authority: this.authority, + path: this.path, + query: this.query, + fragment: this.fragment, + } + } + + public static fromJSON(value: URIComponents): sourcegraph.URI { + return new URI(value) + } +} + +function makeAuthority(url: URL): string { + let s = '' + if (url.username) { + s += url.username + } + if (url.password) { + s += `:${url.password}` + } + if (url.username || url.password) { + s += '@' + } + return s + url.host +} diff --git a/src/sourcegraph.d.ts b/src/sourcegraph.d.ts index e370537..fa99585 100644 --- a/src/sourcegraph.d.ts +++ b/src/sourcegraph.d.ts @@ -10,20 +10,88 @@ declare module 'sourcegraph' { unsubscribe(): void } + /** + * A uniform resource identifier (URI), as defined in [RFC + * 3986](https://tools.ietf.org/html/rfc3986#section-3). + * + * This URI implementation is preferred because the browser URL class implements the WHATWG URL spec, which + * requires special treatment for certain URI schemes (such as http and https). That special treatment is + * undesirable for Sourcegraph extensions, which need to treat URIs from any scheme in the same way. + */ export class URI { + /** + * Parses a URI from its string representation. + */ static parse(value: string): URI - static file(path: string): URI - constructor(value: string) + /** + * Use {@link URI.parse} or {@link URI.fromJSON} to create {@link URI} instances. + */ + private constructor(args: never) + + /** + * The scheme component of the URI. + * + * @summary `https` in `https://example.com` + * @see [RFC 3986](https://tools.ietf.org/html/rfc3986#section-3) + */ + readonly scheme: string + + /** + * The authority component of the URI. + * + * @summary `example.com:1234` in `https://example.com:1234/a/b` + * @see [RFC 3986](https://tools.ietf.org/html/rfc3986#section-3) + */ + readonly authority: string + + /** + * The path component of the URI. + * + * @summary `/a/b` in `https://example.com/a/b` + * @see [RFC 3986](https://tools.ietf.org/html/rfc3986#section-3) + */ + readonly path: string + /** + * The query component of the URI. + * + * @summary `b=c&d=e` in `https://example.com/a?b=c&d=e` + * @see [RFC 3986](https://tools.ietf.org/html/rfc3986#section-3) + */ + readonly query: string + + /** + * The fragment component of the URI. + * + * @summary `g` in `https://example.com/a#g` + * @see [RFC 3986](https://tools.ietf.org/html/rfc3986#section-3) + */ + readonly fragment: string + + /** + * Derives a new URI from this URI. + * + * @returns A copy of the URI with the changed components. + */ + with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): URI + + /** + * Returns the string representation of this URI. + */ toString(): string /** - * Returns a JSON representation of this Uri. + * Returns a JSON representation of this URI. * * @return An object. */ toJSON(): any + + /** + * Revives the URI from its JSON representation that was produced with {@link URI#toJSON}. + */ + static fromJSON(value: any): URI } export class Position { @@ -550,6 +618,90 @@ declare module 'sourcegraph' { export function createPanelView(id: string): PanelView } + /** + * The possible types of a file in a {@link FileSystem}. + */ + export const enum FileType { + /** The file type is unknown. */ + Unknown = 0, + + /** A regular file. */ + File = 1, + + /** A directory. */ + Directory = 2, + + /** A symbolic link. */ + SymbolicLink = 64, + } + + /** + * A file system exposes common file operations on files and directories. It uses URIs instead of file paths. + * + * @todo Add a way to list all files matching a glob (to reduce network roundtrips). + */ + export interface FileSystem { + /** + * List all entries in a directory. + * + * @todo Distinguish between the types of thrown errors. + * + * @param uri The URI of the directory. + * @return An array of [name, type] tuples for the directory's entries. + * @throws If the directory is unable to be read. + */ + readDirectory(uri: URI): Promise<[string, FileType][]> + + /** + * Read the contents of a file. + * + * @todo Distinguish between the types of thrown errors. + * + * @param uri The URI of the file. + * @return An array of bytes. + * @throws If the file is unable to be read. + */ + readFile(uri: URI): Promise + } + + /** + * A root directory (of the {@link workspace}). This is typically the root directory of a repository. + */ + export interface WorkspaceRoot { + /** + * The URI of the root. + * + * @example git+https://github.com/sourcegraph/sourcegraph/mybranch/mydir/myfile.txt + */ + readonly uri: URI + + /** + * Information about the repository that contains the root, if any. + * + * @todo Document what happens when the root is a subdirectory of a repository. + */ + readonly repository?: { + /** + * The fully qualified name of the repository (as seen by Sourcegraph). + * + * This is equivalent to Repository.name in the Sourcegraph GraphQL API schema. + * + * By convention, this is often the hostname and path of the repository, such as + * "github.com/owner/repo". However, it is possible to configure Sourcegraph to use any name for any + * repository, so the format of a repository name should be treated as an opaque (but human-readable) + * value. + */ + readonly name: string + + /** + * A stable identifier for this repository on Sourcegraph. This value is opaque and not human readable. + * + * This is equivalent to Repository.id in the Sourcegraph GraphQL API schema. + */ + readonly id: string + } + } + /** * The logical workspace that the extension is running in, which may consist of multiple folders, projects, and * repositories. @@ -566,6 +718,26 @@ declare module 'sourcegraph' { * An event that is fired when a new text document is opened. */ export const onDidOpenTextDocument: Subscribable + + /** + * A {@link FileSystem} that exposes access to the contents of files and directories in the workspace roots + * (and, if available, at other URIs that are resolvable). + */ + export const fileSystem: FileSystem + + /** + * The root directories of the workspace, if any. + * + * @example The repository that is currently being viewed is a root. + * @todo Currently only a single root is supported. + * @readonly + */ + export const roots: ReadonlyArray + + /** + * An event that is fired when a workspace root is added or removed from the workspace. + */ + export const onDidChangeRoots: Subscribable } /** diff --git a/yarn.lock b/yarn.lock index 86af860..ab3448a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -377,10 +377,10 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.5.tgz#8a4accfc403c124a0bafe8a9fc61a05ec1032073" integrity sha512-lAVp+Kj54ui/vLUFxsJTMtWvZraZxum3w3Nwkble2dNuV5VnPA+Mi2oGX9XYJAaIvZi3tn3cbjS/qcJXRb6Bww== -"@types/node@^10.5.0": - version "10.9.4" - resolved "https://registry.yarnpkg.com/@types/node/-/node-10.9.4.tgz#0f4cb2dc7c1de6096055357f70179043c33e9897" - integrity sha512-fCHV45gS+m3hH17zgkgADUSi2RR1Vht6wOZ0jyHP8rjiQra9f+mIcgwPQHllmDocYOstIEbKlxbFDYlgrTPYqw== +"@types/node@^10.12.2": + version "10.12.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.12.2.tgz#d77f9faa027cadad9c912cd47f4f8b07b0fb0864" + integrity sha512-53ElVDSnZeFUUFIYzI8WLQ25IhWzb6vbddNp8UHlXQyU0ET2RhV5zg0NfubzU7iNMh5bBXb0htCzfvrSVNgzaQ== JSONStream@^1.0.4, JSONStream@^1.3.4: version "1.3.4"