diff --git a/src/client/api/search.ts b/src/client/api/search.ts new file mode 100644 index 0000000..22d1d30 --- /dev/null +++ b/src/client/api/search.ts @@ -0,0 +1,47 @@ +import { from, Observable, Subscription } from 'rxjs' +import { ExtSearch } from 'src/extension/api/search' +import { Connection } from 'src/protocol/jsonrpc2/connection' +import { createProxyAndHandleRequests } from '../../common/proxy' +import { TransformQuerySignature } from '../providers/queryTransformer' +import { FeatureProviderRegistry } from '../providers/registry' +import { SubscriptionMap } from './common' + +/** @internal */ +export interface SearchAPI { + $registerQueryTransformer(id: number): void + $unregister(id: number): void +} + +/** @internal */ +export class Search implements SearchAPI { + private subscriptions = new Subscription() + private registrations = new SubscriptionMap() + private proxy: ExtSearch + + constructor( + connection: Connection, + private queryTransformerRegistry: FeatureProviderRegistry<{}, TransformQuerySignature> + ) { + this.subscriptions.add(this.registrations) + + this.proxy = createProxyAndHandleRequests('search', connection, this) + } + + public $registerQueryTransformer(id: number): void { + this.registrations.add( + id, + this.queryTransformerRegistry.registerProvider( + {}, + (query: string): Observable => from(this.proxy.$transformQuery(id, query)) + ) + ) + } + + public $unregister(id: number): void { + this.registrations.remove(id) + } + + public unsubscribe(): void { + this.subscriptions.unsubscribe() + } +} diff --git a/src/client/controller.ts b/src/client/controller.ts index f4936bc..9ff66be 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -19,6 +19,7 @@ import { ClientConfiguration } from './api/configuration' import { ClientContext } from './api/context' import { ClientDocuments } from './api/documents' import { ClientLanguageFeatures } from './api/languageFeatures' +import { Search } from './api/search' import { ClientWindows } from './api/windows' import { applyContextUpdate, EMPTY_CONTEXT } from './context/context' import { EMPTY_ENVIRONMENT, Environment } from './environment' @@ -268,6 +269,7 @@ export class Controller imp this.registries.textDocumentReferences ) ) + subscription.add(new Search(client, this.registries.queryTransformer)) subscription.add(new ClientCommands(client, this.registries.commands)) } diff --git a/src/client/providers/queryTransformer.test.ts b/src/client/providers/queryTransformer.test.ts new file mode 100644 index 0000000..766b2e4 --- /dev/null +++ b/src/client/providers/queryTransformer.test.ts @@ -0,0 +1,70 @@ +import * as assert from 'assert' +import { of } from 'rxjs' +import { TestScheduler } from 'rxjs/testing' +import { transformQuery, TransformQuerySignature } from './queryTransformer' + +const scheduler = () => new TestScheduler((a, b) => assert.deepStrictEqual(a, b)) + +const FIXTURE_INPUT = 'foo' +const FIXTURE_RESULT = 'bar' +const FIXTURE_RESULT_TWO = 'qux' +const FIXTURE_RESULT_MERGED = 'foo bar qux' + +describe('transformQuery', () => { + describe('0 providers', () => { + it('returns original query', () => + scheduler().run(({ cold, expectObservable }) => + expectObservable( + transformQuery(cold('-a-|', { a: [] }), FIXTURE_INPUT) + ).toBe('-a-|', { + a: FIXTURE_INPUT, + }) + )) + }) + + describe('1 provider', () => { + it('returns result from provider', () => + scheduler().run(({ cold, expectObservable }) => + expectObservable( + transformQuery( + cold('-a-|', { + a: [q => of(FIXTURE_RESULT)], + }), + FIXTURE_INPUT + ) + ).toBe('-a-|', { a: FIXTURE_RESULT }) + )) + }) + + describe('2 providers', () => { + it('returns a single query transformed by both providers', () => + scheduler().run(({ cold, expectObservable }) => + expectObservable( + transformQuery( + cold('-a-|', { + a: [q => of(`${q} ${FIXTURE_RESULT}`), q => of(`${q} ${FIXTURE_RESULT_TWO}`)], + }), + FIXTURE_INPUT + ) + ).toBe('-a-|', { a: FIXTURE_RESULT_MERGED }) + )) + }) + + describe('Multiple emissions', () => { + it('returns stream of results', () => + scheduler().run(({ cold, expectObservable }) => + expectObservable( + transformQuery( + cold('-a-b-|', { + a: [q => of(`${q} ${FIXTURE_RESULT}`)], + b: [q => of(`${q} ${FIXTURE_RESULT_TWO}`)], + }), + FIXTURE_INPUT + ) + ).toBe('-a-b-|', { + a: `${FIXTURE_INPUT} ${FIXTURE_RESULT}`, + b: `${FIXTURE_INPUT} ${FIXTURE_RESULT_TWO}`, + }) + )) + }) +}) diff --git a/src/client/providers/queryTransformer.ts b/src/client/providers/queryTransformer.ts new file mode 100644 index 0000000..ae32f24 --- /dev/null +++ b/src/client/providers/queryTransformer.ts @@ -0,0 +1,34 @@ +import { Observable, of } from 'rxjs' +import { flatMap, map, switchMap } from 'rxjs/operators' +import { FeatureProviderRegistry } from './registry' + +export type TransformQuerySignature = (query: string) => Observable + +/** Transforms search queries using registered query transformers from extensions. */ +export class QueryTransformerRegistry extends FeatureProviderRegistry<{}, TransformQuerySignature> { + public transformQuery(query: string): Observable { + return transformQuery(this.providers, query) + } +} + +/** + * Returns an observable that emits a query transformed by all providers whenever any of the last-emitted set of providers emits + * a query. + * + * Most callers should use QueryTransformerRegistry's transformQuery method, which uses the registered query transformers + * + */ +export function transformQuery(providers: Observable, query: string): Observable { + return providers.pipe( + switchMap(providers => { + if (providers.length === 0) { + return [query] + } + return providers.reduce( + (currentQuery, transformQuery) => + currentQuery.pipe(flatMap(q => transformQuery(q).pipe(map(transformedQuery => transformedQuery)))), + of(query) + ) + }) + ) +} diff --git a/src/client/registries.ts b/src/client/registries.ts index c1decd0..59e6640 100644 --- a/src/client/registries.ts +++ b/src/client/registries.ts @@ -7,6 +7,7 @@ import { ContributionRegistry } from './providers/contribution' import { TextDocumentDecorationProviderRegistry } from './providers/decoration' import { TextDocumentHoverProviderRegistry } from './providers/hover' import { TextDocumentLocationProviderRegistry, TextDocumentReferencesProviderRegistry } from './providers/location' +import { QueryTransformerRegistry } from './providers/queryTransformer' /** * Registries is a container for all provider registries. @@ -25,4 +26,5 @@ export class Registries { public readonly textDocumentTypeDefinition = new TextDocumentLocationProviderRegistry() public readonly textDocumentHover = new TextDocumentHoverProviderRegistry() public readonly textDocumentDecoration = new TextDocumentDecorationProviderRegistry() + public readonly queryTransformer = new QueryTransformerRegistry() } diff --git a/src/extension/api/search.ts b/src/extension/api/search.ts new file mode 100644 index 0000000..90f989e --- /dev/null +++ b/src/extension/api/search.ts @@ -0,0 +1,24 @@ +import { Unsubscribable } from 'rxjs' +import { QueryTransformer } from 'sourcegraph' +import { SearchAPI } from 'src/client/api/search' +import { ProviderMap } from './common' + +export interface ExtSearchAPI { + $transformQuery: (id: number, query: string) => Promise +} + +export class ExtSearch implements ExtSearchAPI { + private registrations = new ProviderMap(id => this.proxy.$unregister(id)) + constructor(private proxy: SearchAPI) {} + + public registerQueryTransformer(provider: QueryTransformer): Unsubscribable { + const { id, subscription } = this.registrations.add(provider) + this.proxy.$registerQueryTransformer(id) + return subscription + } + + public $transformQuery(id: number, query: string): Promise { + const provider = this.registrations.get(id) + return Promise.resolve(provider.transformQuery(query)) + } +} diff --git a/src/extension/extensionHost.ts b/src/extension/extensionHost.ts index d1c4993..7bb8724 100644 --- a/src/extension/extensionHost.ts +++ b/src/extension/extensionHost.ts @@ -8,6 +8,7 @@ import { ExtConfiguration } from './api/configuration' import { ExtContext } from './api/context' import { ExtDocuments } from './api/documents' import { ExtLanguageFeatures } from './api/languageFeatures' +import { ExtSearch } from './api/search' import { ExtWindows } from './api/windows' import { Location } from './types/location' import { Position } from './types/position' @@ -82,6 +83,9 @@ function createExtensionHandle(initData: InitData, connection: Connection): type const languageFeatures = new ExtLanguageFeatures(proxy('languageFeatures'), documents) handleRequests(connection, 'languageFeatures', languageFeatures) + const search = new ExtSearch(proxy('search')) + handleRequests(connection, 'search', search) + const commands = new ExtCommands(proxy('commands')) handleRequests(connection, 'commands', commands) @@ -129,6 +133,10 @@ function createExtensionHandle(initData: InitData, connection: Connection): type languageFeatures.registerReferenceProvider(selector, provider), }, + search: { + registerQueryTransformer: provider => search.registerQueryTransformer(provider), + }, + commands: { registerCommand: (command, callback) => commands.registerCommand({ command, callback }), executeCommand: (command, ...args) => commands.executeCommand(command, args), diff --git a/src/integration-test/search.test.ts b/src/integration-test/search.test.ts new file mode 100644 index 0000000..cdc08df --- /dev/null +++ b/src/integration-test/search.test.ts @@ -0,0 +1,46 @@ +import * as assert from 'assert' +import { take } from 'rxjs/operators' +import { integrationTestContext } from './helpers.test' + +describe('search (integration)', () => { + it('registers a query transformer', async () => { + const { clientController, extensionHost, ready } = await integrationTestContext() + + // Register the provider and call it + const unsubscribe = extensionHost.search.registerQueryTransformer({ transformQuery: () => 'bar' }) + await ready + assert.deepStrictEqual( + await clientController.registries.queryTransformer + .transformQuery('foo') + .pipe(take(1)) + .toPromise(), + 'bar' + ) + + // Unregister the provider and ensure it's removed. + unsubscribe.unsubscribe() + assert.deepStrictEqual( + await clientController.registries.queryTransformer + .transformQuery('foo') + .pipe(take(1)) + .toPromise(), + 'foo' + ) + }) + + it('supports multiple query transformers', async () => { + const { clientController, extensionHost, ready } = await integrationTestContext() + + // Register the provider and call it + extensionHost.search.registerQueryTransformer({ transformQuery: q => `${q} bar` }) + extensionHost.search.registerQueryTransformer({ transformQuery: q => `${q} qux` }) + await ready + assert.deepStrictEqual( + await clientController.registries.queryTransformer + .transformQuery('foo') + .pipe(take(1)) + .toPromise(), + 'foo bar qux' + ) + }) +}) diff --git a/src/sourcegraph.d.ts b/src/sourcegraph.d.ts index 051f89c..097ccf2 100644 --- a/src/sourcegraph.d.ts +++ b/src/sourcegraph.d.ts @@ -785,6 +785,38 @@ declare module 'sourcegraph' { ): Unsubscribable } + /** + * A query transformer alters a user's search query before executing a search. + * + * Query transformers allow extensions to define new search query operators and syntax, for example, + * by matching strings in a query (e.g. `go.imports:`) and replacing them with a regular expression or string. + */ + export interface QueryTransformer { + /** + * Transforms a search query into another, valid query. If there are no transformations to be made + * the original query is returned. + * + * @param query A search query. + */ + transformQuery(query: string): string | Promise + } + + /** + * API for extensions to augment search functionality. + */ + export namespace search { + /** + * Registers a query transformer. + * + * Multiple transformers can be registered. In that case, all transformations will be applied + * and the result is a single query that has been altered by all transformers. The order in + * which transfomers are applied is not defined. + * + * @param provider A query transformer. + */ + export function registerQueryTransformer(provider: QueryTransformer): Unsubscribable + } + /** * Commands are functions that are implemented and registered by extensions. Extensions can invoke any command * (including commands registered by other extensions). The extension can also define contributions (in