Skip to content
This repository was archived by the owner on Nov 6, 2018. It is now read-only.

Add search features API #95

Merged
merged 17 commits into from
Oct 26, 2018
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/client/api/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
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'

export interface SearchAPI {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add /** @internal */ tsdoc directives as in https://sourcegraph.com/github.com/sourcegraph/sourcegraph-extension-api/-/blob/src/client/api/configuration.ts#L8:1 and other files because this type is part of the internal API (so we want to communicate that to readers and to tools like doc generators).

$registerQueryTransformer(id: number): void
$unregister(id: number): void
}

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('searchFeatures', connection, this)
}

public $registerQueryTransformer(id: number): void {
this.registrations.add(
id,
this.queryTransformerRegistry.registerProvider(
{},
(query: string): Observable<string | null | undefined> => from(this.proxy.$transformQuery(id, query))
)
)
}

public $unregister(id: number): void {
this.registrations.remove(id)
}

public unsubscribe(): void {
this.subscriptions.unsubscribe()
}
}
2 changes: 2 additions & 0 deletions src/client/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -268,6 +269,7 @@ export class Controller<X extends Extension, C extends ConfigurationCascade> imp
this.registries.textDocumentReferences
)
)
subscription.add(new Search(client, this.registries.queryTransformer))
subscription.add(new ClientCommands(client, this.registries.commands))
}

Expand Down
66 changes: 66 additions & 0 deletions src/client/providers/queryTransformer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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_MERGED = 'foo bar'
const FIXTURE_RESULT = 'bar'

describe('transformQuery', () => {
describe('0 providers', () => {
it('returns original query', () =>
scheduler().run(({ cold, expectObservable }) =>
expectObservable(
transformQuery(cold<TransformQuerySignature[]>('-a-|', { a: [] }), FIXTURE_INPUT)
).toBe('-a-|', {
a: FIXTURE_INPUT,
})
))
})

describe('1 provider', () => {
it('returns result from provider', () =>
scheduler().run(({ cold, expectObservable }) =>
expectObservable(
transformQuery(
cold<TransformQuerySignature[]>('-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<TransformQuerySignature[]>('-a-|', {
a: [q => of(q), q => of(`${q} ${FIXTURE_RESULT}`)],
}),
FIXTURE_INPUT
)
).toBe('-a-|', { a: FIXTURE_RESULT_MERGED })
))
})

describe('Multiple emissions', () => {
it('returns stream of results', () =>
scheduler().run(({ cold, expectObservable }) =>
expectObservable(
transformQuery(
cold<TransformQuerySignature[]>('-a-b-|', {
a: [q => of(q)],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the first query transform is just the identity func, then this doesn't really test the behavior of "all query transform funcs are applied". Instead you should test something like:

  • 1st query transform func appends a to the string
  • 2nd query transform func appends b to the string

and test that the result is the original string with ab appended.

(I think the current test would pass even if your logic was like this.providers[1](query), which would obviously be incorrect and would not be caught by the test.)

b: [q => of(FIXTURE_RESULT)],
}),
FIXTURE_INPUT
)
).toBe('-a-b-|', { a: FIXTURE_INPUT, b: FIXTURE_RESULT })
))
})
})
39 changes: 39 additions & 0 deletions src/client/providers/queryTransformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Observable, of } from 'rxjs'
import { flatMap, map, switchMap } from 'rxjs/operators'
import { FeatureProviderRegistry } from './registry'

export type TransformQuerySignature = (query: string) => Observable<string | null | undefined>

/** Provides transformed queries from the first search extension. */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inaccurate now: it is not just the first search extension, it calls all of them.

Also, there is no such concept as a "search extension". An extension can have any number of query transformers. The relevant concept here is "query transformers".

So:

/** Transforms search queries using registered query transformers from extensions. */

export class QueryTransformerRegistry extends FeatureProviderRegistry<{}, TransformQuerySignature> {
public transformQuery(query: string): Observable<string | null | undefined> {
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 search
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The term "search provider" is inaccurate; there is no such concept (or it is an imprecise way to refer to query transformers). It should be "query transformers".

* providers.
*/
export function transformQuery(
providers: Observable<TransformQuerySignature[]>,
query: string
): Observable<string | null | undefined> {
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 || q)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the desired behavior?

  • If a query transformer returns null or the empty string, use the original input string.
  • Otherwise, use the original input string.

I suspect you don't intend to consider the empty string as meaning "use the original input string". But the empty string is falsey.

So I would do:

transformedQuery => transformedQuery === null ? q : transformedQuery

Or, really, why special-case null at all? If a query transformer wants to not make any modifications to the string, it can just return it unmodified. That is actually what I'd recommend.

),
of(query)
)
})
)
}
2 changes: 2 additions & 0 deletions src/client/registries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -25,4 +26,5 @@ export class Registries<X extends Extension, C extends ConfigurationCascade> {
public readonly textDocumentTypeDefinition = new TextDocumentLocationProviderRegistry()
public readonly textDocumentHover = new TextDocumentHoverProviderRegistry()
public readonly textDocumentDecoration = new TextDocumentDecorationProviderRegistry()
public readonly queryTransformer = new QueryTransformerRegistry()
}
26 changes: 26 additions & 0 deletions src/extension/api/search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Unsubscribable } from 'rxjs'
import { QueryTransformerProvider } from 'sourcegraph'
import { SearchAPI } from 'src/client/api/search'
import { ProviderMap } from './common'

/** @internal */
export interface ExtSearchAPI {
$transformQuery: (id: number, query: string) => Promise<string | null | undefined>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above, I think you should only accept Promise<string>, not Promise<string | null | undefined>. I'm not sure what null and undefined mean without checking docs or impl, and it's not clear that there is any benefit to allowing those return types.

}

/** @internal */
export class ExtSearch implements ExtSearchAPI {
private registrations = new ProviderMap<QueryTransformerProvider>(id => this.proxy.$unregister(id))
constructor(private proxy: SearchAPI) {}

public registerQueryTransformer(provider: QueryTransformerProvider): Unsubscribable {
const { id, subscription } = this.registrations.add(provider)
this.proxy.$registerQueryTransformer(id)
return subscription
}

public $transformQuery(id: number, query: string): Promise<string | null | undefined> {
const provider = this.registrations.get<QueryTransformerProvider>(id)
return Promise.resolve(provider.transformQuery(query))
}
}
8 changes: 8 additions & 0 deletions src/extension/extensionHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -82,6 +83,9 @@ function createExtensionHandle(initData: InitData, connection: Connection): type
const languageFeatures = new ExtLanguageFeatures(proxy('languageFeatures'), documents)
handleRequests(connection, 'languageFeatures', languageFeatures)

const searchFeatures = new ExtSearch(proxy('searchFeatures'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You call it "search" everywhere else, not "searchFeatures". So for consistency, replace searchFeatures with search here and on the next line (in both the var name and string). This does not affect the behavior, it just makes it clearer and avoids the question "why was the term 'features' used here?"

handleRequests(connection, 'searchFeatures', searchFeatures)

const commands = new ExtCommands(proxy('commands'))
handleRequests(connection, 'commands', commands)

Expand Down Expand Up @@ -129,6 +133,10 @@ function createExtensionHandle(initData: InitData, connection: Connection): type
languageFeatures.registerReferenceProvider(selector, provider),
},

search: {
registerQueryTransformer: provider => searchFeatures.registerQueryTransformer(provider),
},

commands: {
registerCommand: (command, callback) => commands.registerCommand({ command, callback }),
executeCommand: (command, ...args) => commands.executeCommand(command, args),
Expand Down
46 changes: 46 additions & 0 deletions src/integration-test/search.test.ts
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for consistency, add a period after a line comment if it's a full sentence

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` })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but bar and baz look almost the same; it's not easy to visually spot that they are distinct, given that everything else on the 2 lines is the same. I would recommend using foo, qux, etc., here on one of the lines so it's easier to spot that they are different.

extensionHost.search.registerQueryTransformer({ transformQuery: q => `${q} baz` })
await ready
assert.deepStrictEqual(
await clientController.registries.queryTransformer
.transformQuery('foo')
.pipe(take(1))
.toPromise(),
'foo bar baz'
)
})
})
8 changes: 8 additions & 0 deletions src/sourcegraph.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,14 @@ declare module 'sourcegraph' {
): ProviderResult<Location[]>
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should just call it QueryTransformer not QueryTransformerProvider. I struggled with this in other cases but here's my thinking:

  • The goal of naming is to communicate what something is.
  • Using the "Provider" suffix is generally good for things like this because it communicates that this fits the "provider" pattern. Not in the strict Erich Gamma Design Patterns sense, but just in the "this is like hover providers, definition providers, etc., in that it can be registered/unregistered and provides data given some parameters".
  • But when there is a word that is similar to "provider", is more accurate, is short, and is the term you'd use anywhere else in code describing this, then you should use that word instead.
  • In this case, that word is "Transformer". QueryTransformer and QueryTransformerProvider basically mean the same thing; in the latter, "transformer" and "provider" basically mean the same thing, so it's weird to have them both.
  • Looking at vscode.d.ts, they have many things that follow the guidelines I laid out instead of always using the "Provider" suffix: registerUriHandler, registerWebviewPanelSerializer, etc. Those are way better than registerUriHandlerProvider or registerUriProvider, or registerWebviewPanelSerializationProvider or registerWebviewPanelSerializerProvider.

export interface QueryTransformerProvider {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add tsdoc docstrings to:

  • interface QueryTransformerProvider
  • QueryTransformerProvider#transformQuery
  • namespace search
  • function registerQueryTransformer

transformQuery(query: string): ProviderResult<string>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote 2 comments above about:

  • not special-casing null return values
  • using Promise<string> instead of Promise<string | null | undefined>

I see why you included null and undefined now, because ProviderResult adds them. But I think ProviderResult is only useful for things like hover providers, not for transform funcs. So you should just make this return string | Promise<string>.

The reason why ProviderResult is useful for hover/definition/reference/etc. providers is that they need to be able to return null to say "I have no answer for this query". But in transformQuery, you can say "I have no transformations to apply to this query" by just returning the original query input argument.

Another related reason you should use string | Promise<string> instead of ProviderResult<string> here is that it adds confusion if there are 3 things that all mean the same thing ("do not transform this query"):

  • query => query
  • query => null
  • query => undefined

There is no need for the 2nd and 3rd ways of expressing this.

}

export namespace search {
export function registerQueryTransformer(provider: QueryTransformerProvider): Unsubscribable
}

export namespace languages {
export function registerHoverProvider(selector: DocumentSelector, provider: HoverProvider): Unsubscribable

Expand Down