From 437e391ba37ec9da21d72f8b3e11cab435e5207d Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Mon, 27 Jan 2020 09:06:35 -0600 Subject: [PATCH 01/14] WIP. --- languages.ts | 2 +- package/src/abortion.ts | 21 ++ package/src/activation.ts | 460 ++++++++++++++++++++++++++++++++++++++ package/src/index.ts | 7 + template/src/extension.ts | 87 +------ 5 files changed, 497 insertions(+), 80 deletions(-) create mode 100644 package/src/abortion.ts create mode 100644 package/src/activation.ts diff --git a/languages.ts b/languages.ts index 69dd63602..eea432786 100644 --- a/languages.ts +++ b/languages.ts @@ -1,4 +1,4 @@ -import { HandlerArgs, CommentStyle } from './package/lib/handler' +import { HandlerArgs, CommentStyle } from './package/src/handler' const path = require('path-browserify') type Omit = Pick> diff --git a/package/src/abortion.ts b/package/src/abortion.ts new file mode 100644 index 000000000..d6e0426cd --- /dev/null +++ b/package/src/abortion.ts @@ -0,0 +1,21 @@ +export interface AbortError extends Error { + name: 'AbortError' +} + +/** + * Creates an Error with name "AbortError" + */ +export const createAbortError = (): AbortError => + Object.assign(new Error('Aborted'), { name: 'AbortError' as const }) + +/** + * Returns true if the given value is an AbortError + */ +export const isAbortError = (err: any): err is AbortError => + typeof err === 'object' && err !== null && err.name === 'AbortError' + +export function throwIfAbortError(err: unknown): void { + if (isAbortError(err)) { + throw err + } +} diff --git a/package/src/activation.ts b/package/src/activation.ts new file mode 100644 index 000000000..5bf9d9b0d --- /dev/null +++ b/package/src/activation.ts @@ -0,0 +1,460 @@ +import * as sourcegraph from 'sourcegraph' +import { HandlerArgs, Handler } from './handler' +import { initLSIF } from './lsif' +import { impreciseBadge } from './badges' +import { + map, + finalize, + distinctUntilChanged, + shareReplay, +} from 'rxjs/operators' +import { BehaviorSubject, from, Observable, noop } from 'rxjs' +import { createAbortError } from './abortion' + +export type Maybe = { value: T } | undefined + +export interface LSIFProviders { + hover: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise> + + definition: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise> + + references: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise> +} + +export interface SearchProviders { + definition: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise + + references: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise + + hover: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise +} + +export interface LSPProviders { + definition: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => AsyncGenerator + + references: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position, + context: sourcegraph.ReferenceContext + ) => AsyncGenerator + + hover: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => AsyncGenerator + + externalReferences?: ExternalReferenceProvider + implementations?: ImplementationsProvider +} + +export interface ExternalReferenceProvider { + settingName: string + + references: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position, + context: sourcegraph.ReferenceContext + ) => AsyncGenerator +} + +export interface ImplementationsProvider { + implId: string + panelTitle: string + + locations: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => AsyncGenerator +} + +export function activateCodeIntel( + ctx: sourcegraph.ExtensionContext, + selector: sourcegraph.DocumentSelector, + handlerArgs: HandlerArgs, + lspProviders?: LSPProviders +): void { + const lsifProviders = initLSIF() + const searchProviders = new Handler(handlerArgs) + + ctx.subscriptions.add( + sourcegraph.languages.registerDefinitionProvider( + selector, + createDefinitionProvider( + lsifProviders, + searchProviders, + lspProviders + ) + ) + ) + ctx.subscriptions.add( + sourcegraph.languages.registerReferenceProvider( + selector, + createReferencesProvider( + lsifProviders, + searchProviders, + lspProviders + ) + ) + ) + ctx.subscriptions.add( + sourcegraph.languages.registerHoverProvider( + selector, + createHoverProvider(lsifProviders, searchProviders, lspProviders) + ) + ) + + if (lspProviders) { + const externalReferencesProvider = lspProviders.externalReferences + const implementationsProvider = lspProviders.implementations + + if (externalReferencesProvider) { + registerExternalReferencesProvider( + ctx, + selector, + externalReferencesProvider + ) + } + + if (implementationsProvider) { + registerImplementationsProvider( + ctx, + selector, + implementationsProvider + ) + } + } +} + +function createDefinitionProvider( + lsifProviders: LSIFProviders, + searchProviders: SearchProviders, + lspProviders?: LSPProviders +): sourcegraph.DefinitionProvider { + async function* provideDefinition( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ): AsyncGenerator { + const lsifResult = await lsifProviders.definition(doc, pos) + if (lsifResult) { + yield lsifResult.value + return + } + + if (lspProviders) { + yield* lspProviders.definition(doc, pos) + return + } + + let searchResult = await searchProviders.definition(doc, pos) + if (!searchResult) { + yield undefined + return + } + + + if (!Array.isArray(searchResult)) { + yield { ...searchResult, badge: impreciseBadge } + return + } + + yield searchResult.map(v => ({ ...v, badge: impreciseBadge })) + } + + return { + provideDefinition: memoizePrevious( + areProviderParamsEqual, + (doc, pos) => + observableFromAsyncGenerator( + abortPrevious(() => provideDefinition(doc, pos)) + ).pipe(shareReplay(1)) + ), + } +} + +function createReferencesProvider( + lsifProviders: LSIFProviders, + searchProviders: SearchProviders, + lspProviders?: LSPProviders +): sourcegraph.ReferenceProvider { + // Gets an opaque value that is the same for all locations + // within a file but different from other files. + const file = (loc: sourcegraph.Location) => + `${loc.uri.host} ${loc.uri.pathname} ${loc.uri.hash}` + + async function* provideReferences( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position, + ctx: sourcegraph.ReferenceContext + ): AsyncGenerator { + if (lspProviders) { + yield* lspProviders.references(doc, pos, ctx) + return + } + + // Get and extract LSIF results + const lsifResult = await lsifProviders.references(doc, pos) + const lsifReferences = (lsifResult && lsifResult.value) || [] + const lsifFiles = new Set(lsifReferences.map(file)) + + // Unconditionally get search references and append them with + // precise results because LSIF data might be sparse. Remove any + // search-based result that occurs in a file with an LSIF result. + const searchResults = ( + (await searchProviders.references(doc, pos)) || [] + ).filter(fuzzyRef => !lsifFiles.has(file(fuzzyRef))) + + yield [ + ...lsifReferences, + ...searchResults.map(v => ({ + ...v, + badge: impreciseBadge, + })), + ] + } + + return { + provideReferences: (doc, pos, ctx) => + observableFromAsyncGenerator(() => + provideReferences(doc, pos, ctx) + ), + } +} + +function createHoverProvider( + lsifProviders: LSIFProviders, + searchProviders: SearchProviders, + lspProviders?: LSPProviders +): sourcegraph.HoverProvider { + async function* provideHover( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ): AsyncGenerator< + sourcegraph.Badged | null|undefined, + void, + undefined + > { + const lsifResult = await lsifProviders.hover(doc, pos) + if (lsifResult) { + yield lsifResult.value + return + } + + if (lspProviders) { + yield* lspProviders.hover(doc, pos) + return + } + + const searchResult = await searchProviders.hover(doc, pos) + if (!searchResult) { + yield undefined + return + } + + yield { ...searchResult, badge: impreciseBadge } + } + + return { + provideHover: memoizePrevious( + areProviderParamsEqual, + (textDocument, position) => + observableFromAsyncGenerator( + abortPrevious(() => provideHover(textDocument, position)) + ).pipe(shareReplay(1)) + ), + } +} + +function registerExternalReferencesProvider( + ctx: sourcegraph.ExtensionContext, + selector: sourcegraph.DocumentSelector, + externalReferencesProvider: ExternalReferenceProvider +) { + const settings: BehaviorSubject> = new BehaviorSubject< + Partial + >({}) + ctx.subscriptions.add( + sourcegraph.configuration.subscribe(() => { + settings.next(sourcegraph.configuration.get>().value) + }) + ) + + let registration: sourcegraph.Unsubscribable | undefined + + const register = () => { + registration = sourcegraph.languages.registerReferenceProvider( + selector, + createExternalReferencesProvider(externalReferencesProvider) + ) + } + + const deregister = () => { + if (registration) { + registration.unsubscribe() + registration = undefined + } + } + + ctx.subscriptions.add( + from(settings) + .pipe( + map( + settings => + !!settings[externalReferencesProvider.settingName] + ), + distinctUntilChanged(), + map(enabled => (enabled ? register : deregister)()), + finalize(() => deregister()) + ) + .subscribe() + ) +} + +function createExternalReferencesProvider( + externalReferencesProvider: ExternalReferenceProvider +): sourcegraph.ReferenceProvider { + return { + provideReferences: (doc, pos, ctx) => + observableFromAsyncGenerator(() => + externalReferencesProvider.references(doc, pos, ctx) + ), + } +} + +function registerImplementationsProvider( + ctx: sourcegraph.ExtensionContext, + selector: sourcegraph.DocumentSelector, + implementationsProvider: ImplementationsProvider +) { + ctx.subscriptions.add( + sourcegraph.languages.registerLocationProvider( + implementationsProvider.implId, + selector, + { + provideLocations: (doc, pos) => + observableFromAsyncGenerator(() => + implementationsProvider.locations(doc, pos) + ), + } + ) + ) + + const IMPL_ID = implementationsProvider.implId + const panelView = sourcegraph.app.createPanelView(IMPL_ID) + panelView.title = implementationsProvider.panelTitle + panelView.component = { locationProvider: IMPL_ID } + panelView.priority = 160 + ctx.subscriptions.add(panelView) +} + +// +// +// + +export const areProviderParamsEqual = ( + [doc1, pos1]: [sourcegraph.TextDocument, sourcegraph.Position], + [doc2, pos2]: [sourcegraph.TextDocument, sourcegraph.Position] +): boolean => doc1.uri === doc2.uri && pos1.isEqual(pos2) + +export const observableFromAsyncGenerator = ( + generator: () => AsyncGenerator +): Observable => + new Observable(observer => { + const iterator = generator() + let unsubscribed = false + let iteratorDone = false + function next(): void { + iterator.next().then( + result => { + if (unsubscribed) { + return + } + if (result.done) { + iteratorDone = true + observer.complete() + } else { + observer.next(result.value) + next() + } + }, + err => { + observer.error(err) + } + ) + } + next() + return () => { + unsubscribed = true + if (!iteratorDone && iterator.throw) { + iterator.throw(createAbortError()).catch(() => { + // ignore + }) + } + } + }) + +/** + * Similar to Rx `switchMap`, finishes the generator returned from the previous call whenever the function is called again. + * + * Workaround for https://github.com/sourcegraph/sourcegraph/issues/1190 + */ +export const abortPrevious =

( + fn: (...args: P) => AsyncGenerator +): ((...args: P) => AsyncGenerator) => { + let abort = noop + return async function*(...args) { + abort() + let aborted = false + abort = () => { + aborted = true + } + for await (const element of fn(...args)) { + if (aborted) { + return + } + yield element + if (aborted) { + return + } + } + } +} + +/** Workaround for https://github.com/sourcegraph/sourcegraph/issues/1321 */ +export function memoizePrevious

( + compare: (a: P, b: P) => boolean, + fn: (...args: P) => R +): (...args: P) => R { + let previousResult: R + let previousArgs: P + return (...args) => { + if (previousArgs && compare(previousArgs, args)) { + console.log('returning previous') + return previousResult + } + previousArgs = args + console.log('invoking function') + previousResult = fn(...args) + return previousResult + } +} diff --git a/package/src/index.ts b/package/src/index.ts index a15113ab7..f6d8d11b7 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,3 +1,10 @@ +export { activateCodeIntel } from './activation' +export { + AbortError, + createAbortError, + isAbortError, + throwIfAbortError, +} from './abortion' export { impreciseBadge } from './badges' export { Handler, HandlerArgs } from './handler' export { diff --git a/template/src/extension.ts b/template/src/extension.ts index 17a042ac1..319fa3d1b 100644 --- a/template/src/extension.ts +++ b/template/src/extension.ts @@ -1,90 +1,19 @@ -import { Handler, initLSIF, impreciseBadge } from '../../package/lib' import * as sourcegraph from 'sourcegraph' import { languageSpecs } from '../../languages' -import { documentSelector } from '../../package/lib/handler' +import { activateCodeIntel } from '../../package/src' const DUMMY_CTX = { subscriptions: { add: (_unsubscribable: any) => void 0 } } -// Gets an opaque value that is the same for all locations -// within a file but different from other files. -const file = (loc: sourcegraph.Location) => - `${loc.uri.host} ${loc.uri.pathname} ${loc.uri.hash}` - export function activate(ctx: sourcegraph.ExtensionContext = DUMMY_CTX): void { // This is set to an individual language ID by the generator script. const languageID = 'all' - // LSIF is not language-specific, and we only want to initialize it once. - // Otherwise we will make a flurry of calls to the frontend to check if - // LSIF is enabled. - const lsif = initLSIF() - - for (const languageSpec of languageID === 'all' - ? languageSpecs - : [languageSpecs.find(l => l.handlerArgs.languageID === languageID)!]) { - const handler = new Handler({ - ...languageSpec.handlerArgs, - sourcegraph, - }) - const selector = documentSelector(languageSpec.handlerArgs.fileExts) - ctx.subscriptions.add( - sourcegraph.languages.registerHoverProvider(selector, { - provideHover: async (doc, pos) => { - const lsifResult = await lsif.hover(doc, pos) - if (lsifResult) { - return lsifResult.value - } - - const val = await handler.hover(doc, pos) - if (!val) { - return undefined - } - - return { ...val, badge: impreciseBadge } - }, - }) - ) - ctx.subscriptions.add( - sourcegraph.languages.registerDefinitionProvider(selector, { - provideDefinition: async (doc, pos) => { - const lsifResult = await lsif.definition(doc, pos) - if (lsifResult) { - return lsifResult.value - } - - const val = await handler.definition(doc, pos) - if (!val) { - return undefined - } - - return val.map(v => ({ ...v, badge: impreciseBadge })) - }, - }) - ) - ctx.subscriptions.add( - sourcegraph.languages.registerReferenceProvider(selector, { - provideReferences: async (doc, pos) => { - // Get and extract LSIF results - const lsifResult = await lsif.references(doc, pos) - const lsifValues = lsifResult ? lsifResult.value : [] - const lsifFiles = new Set(lsifValues.map(file)) - - // Unconditionally get search references and append them with - // precise results because LSIF data might be sparse. Remove any - // search-based result that occurs in a file with an LSIF result. - const searchReferences = ( - await handler.references(doc, pos) - ).filter(fuzzyRef => !lsifFiles.has(file(fuzzyRef))) - - return [ - ...lsifValues, - ...searchReferences.map(v => ({ - ...v, - badge: impreciseBadge, - })), - ] - }, - }) - ) + for (const languageSpec of languageSpecs.filter( + l => languageID === 'all' || l.handlerArgs.languageID === languageID + )) { + const extensions = languageSpec.handlerArgs.fileExts + const selector = extensions.map(ext => ({ pattern: `*.${ext}` })) + const handlerArgs = { sourcegraph, ...languageSpec.handlerArgs } + activateCodeIntel(ctx, selector, handlerArgs) } } From d5f8bba034c9b251de52d3313a27135714e40500 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Mon, 27 Jan 2020 10:31:28 -0600 Subject: [PATCH 02/14] WIP. --- package/src/activation.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/package/src/activation.ts b/package/src/activation.ts index 5bf9d9b0d..2cb1f4ba7 100644 --- a/package/src/activation.ts +++ b/package/src/activation.ts @@ -154,7 +154,11 @@ function createDefinitionProvider( async function* provideDefinition( doc: sourcegraph.TextDocument, pos: sourcegraph.Position - ): AsyncGenerator { + ): AsyncGenerator< + sourcegraph.Definition | null | undefined, + void, + undefined + > { const lsifResult = await lsifProviders.definition(doc, pos) if (lsifResult) { yield lsifResult.value @@ -172,7 +176,6 @@ function createDefinitionProvider( return } - if (!Array.isArray(searchResult)) { yield { ...searchResult, badge: impreciseBadge } return @@ -182,12 +185,10 @@ function createDefinitionProvider( } return { - provideDefinition: memoizePrevious( - areProviderParamsEqual, - (doc, pos) => - observableFromAsyncGenerator( - abortPrevious(() => provideDefinition(doc, pos)) - ).pipe(shareReplay(1)) + provideDefinition: memoizePrevious(areProviderParamsEqual, (doc, pos) => + observableFromAsyncGenerator( + abortPrevious(() => provideDefinition(doc, pos)) + ).pipe(shareReplay(1)) ), } } @@ -250,7 +251,7 @@ function createHoverProvider( doc: sourcegraph.TextDocument, pos: sourcegraph.Position ): AsyncGenerator< - sourcegraph.Badged | null|undefined, + sourcegraph.Badged | null | undefined, void, undefined > { @@ -449,11 +450,9 @@ export function memoizePrevious

( let previousArgs: P return (...args) => { if (previousArgs && compare(previousArgs, args)) { - console.log('returning previous') return previousResult } previousArgs = args - console.log('invoking function') previousResult = fn(...args) return previousResult } From 568d230aaedc92b35ca8e7df2ba79e2b5b0f9ddf Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Mon, 27 Jan 2020 14:15:35 -0600 Subject: [PATCH 03/14] Add todo notes. --- package/src/activation.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package/src/activation.ts b/package/src/activation.ts index 2cb1f4ba7..b360a62b2 100644 --- a/package/src/activation.ts +++ b/package/src/activation.ts @@ -236,6 +236,7 @@ function createReferencesProvider( return { provideReferences: (doc, pos, ctx) => + // TODO - add memoizePrevious, abortPrevious observableFromAsyncGenerator(() => provideReferences(doc, pos, ctx) ), @@ -336,6 +337,7 @@ function createExternalReferencesProvider( ): sourcegraph.ReferenceProvider { return { provideReferences: (doc, pos, ctx) => + // TODO - add memoizePrevious, abortPrevious observableFromAsyncGenerator(() => externalReferencesProvider.references(doc, pos, ctx) ), @@ -353,6 +355,7 @@ function registerImplementationsProvider( selector, { provideLocations: (doc, pos) => + // TODO - add memoizePrevious, abortPrevious observableFromAsyncGenerator(() => implementationsProvider.locations(doc, pos) ), From 5b1f89e3007295f3769bbf3b0ce04ccdcda0f9db Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Mon, 27 Jan 2020 18:23:10 -0600 Subject: [PATCH 04/14] Bump sourcegraph version. --- package/package.json | 2 +- package/yarn.lock | 5 +++++ template/yarn.lock | 9 +++++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/package/package.json b/package/package.json index ef7107fbb..89999d348 100644 --- a/package/package.json +++ b/package/package.json @@ -64,7 +64,7 @@ "lodash": "^4.17.11", "rxjs": "^6.3.3", "semver": "^6.3.0", - "sourcegraph": "^23.0.1", + "sourcegraph": "^23.1.0", "vscode-languageserver-types": "^3.14.0" }, "sideEffects": false diff --git a/package/yarn.lock b/package/yarn.lock index 52ab81ebd..7d3f33c46 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -2188,6 +2188,11 @@ sourcegraph@^23.0.1: resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.0.1.tgz#715fcf4129a6d94bc3bfd2740d9c706ae6357ffe" integrity sha512-4We7zqhOagOVxNFdS6/xT/Crhb0Arw/9ytGBu8JuHfjo5yjMtcUYt22kZyu2TaPHXwyPW9muUi1eKSFA6Qg4lw== +sourcegraph@^23.1.0: + version "23.1.0" + resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.1.0.tgz#3178979805239cdf777d7b2cb9aae99c2a3d1dcb" + integrity sha512-pcHP/Ad1TGJWDu4vh8iDE1bi4LOvDgc2Q+amlByjSfeg2+vd4jldpaW4HuP/fMVGYvvzxOa4jrjlluWeXFqyoA== + spawn-wrap@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-1.4.2.tgz#cff58e73a8224617b6561abdc32586ea0c82248c" diff --git a/template/yarn.lock b/template/yarn.lock index 7843b7c67..107929241 100644 --- a/template/yarn.lock +++ b/template/yarn.lock @@ -766,7 +766,7 @@ lodash "^4.17.11" rxjs "^6.3.3" semver "^6.3.0" - sourcegraph "^23.0.1" + sourcegraph "^23.1.0" vscode-languageserver-types "^3.14.0" "@types/mocha@5.2.7": @@ -5579,11 +5579,16 @@ source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= -sourcegraph@23.0.1, sourcegraph@^23.0.1: +sourcegraph@23.0.1: version "23.0.1" resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.0.1.tgz#715fcf4129a6d94bc3bfd2740d9c706ae6357ffe" integrity sha512-4We7zqhOagOVxNFdS6/xT/Crhb0Arw/9ytGBu8JuHfjo5yjMtcUYt22kZyu2TaPHXwyPW9muUi1eKSFA6Qg4lw== +sourcegraph@^23.1.0: + version "23.1.0" + resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.1.0.tgz#3178979805239cdf777d7b2cb9aae99c2a3d1dcb" + integrity sha512-pcHP/Ad1TGJWDu4vh8iDE1bi4LOvDgc2Q+amlByjSfeg2+vd4jldpaW4HuP/fMVGYvvzxOa4jrjlluWeXFqyoA== + spawn-wrap@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-1.4.2.tgz#cff58e73a8224617b6561abdc32586ea0c82248c" From 1f577ca7d235d261230d7562196a36ace2746c79 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Mon, 27 Jan 2020 18:23:19 -0600 Subject: [PATCH 05/14] Bump target. --- package/tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package/tsconfig.json b/package/tsconfig.json index 98895330a..3094dc5f4 100644 --- a/package/tsconfig.json +++ b/package/tsconfig.json @@ -1,10 +1,10 @@ { "extends": "@sourcegraph/tsconfig", "compilerOptions": { - "target": "es2018", + "target": "es2019", "module": "es2015", "moduleResolution": "node", - "lib": ["es2018", "webworker"], + "lib": ["es2019", "webworker"], "forceConsistentCasingInFileNames": true, "noErrorTruncation": true, "skipLibCheck": true, From 087e76f17e821ab2a41bd3bbcf188eb6eee5c17e Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Mon, 27 Jan 2020 18:23:30 -0600 Subject: [PATCH 06/14] Add missing type annotations. --- package/src/activation.ts | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/package/src/activation.ts b/package/src/activation.ts index b360a62b2..2231f99a3 100644 --- a/package/src/activation.ts +++ b/package/src/activation.ts @@ -8,7 +8,7 @@ import { distinctUntilChanged, shareReplay, } from 'rxjs/operators' -import { BehaviorSubject, from, Observable, noop } from 'rxjs' +import { Observer, BehaviorSubject, from, Observable, noop } from 'rxjs' import { createAbortError } from './abortion' export type Maybe = { value: T } | undefined @@ -154,11 +154,7 @@ function createDefinitionProvider( async function* provideDefinition( doc: sourcegraph.TextDocument, pos: sourcegraph.Position - ): AsyncGenerator< - sourcegraph.Definition | null | undefined, - void, - undefined - > { + ): AsyncGenerator { const lsifResult = await lsifProviders.definition(doc, pos) if (lsifResult) { yield lsifResult.value @@ -177,7 +173,8 @@ function createDefinitionProvider( } if (!Array.isArray(searchResult)) { - yield { ...searchResult, badge: impreciseBadge } + const badged = ({ ...searchResult, badge: impreciseBadge }) + yield badged return } @@ -235,7 +232,11 @@ function createReferencesProvider( } return { - provideReferences: (doc, pos, ctx) => + provideReferences: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position, + ctx: sourcegraph.ReferenceContext + ) => // TODO - add memoizePrevious, abortPrevious observableFromAsyncGenerator(() => provideReferences(doc, pos, ctx) @@ -336,7 +337,11 @@ function createExternalReferencesProvider( externalReferencesProvider: ExternalReferenceProvider ): sourcegraph.ReferenceProvider { return { - provideReferences: (doc, pos, ctx) => + provideReferences: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position, + ctx: sourcegraph.ReferenceContext + ) => // TODO - add memoizePrevious, abortPrevious observableFromAsyncGenerator(() => externalReferencesProvider.references(doc, pos, ctx) @@ -354,7 +359,10 @@ function registerImplementationsProvider( implementationsProvider.implId, selector, { - provideLocations: (doc, pos) => + provideLocations: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => // TODO - add memoizePrevious, abortPrevious observableFromAsyncGenerator(() => implementationsProvider.locations(doc, pos) @@ -383,7 +391,7 @@ export const areProviderParamsEqual = ( export const observableFromAsyncGenerator = ( generator: () => AsyncGenerator ): Observable => - new Observable(observer => { + new Observable((observer: Observer) => { const iterator = generator() let unsubscribed = false let iteratorDone = false From 07e948541569357790aac3147bec31a789704d79 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Mon, 27 Jan 2020 18:25:07 -0600 Subject: [PATCH 07/14] Run prettier. --- package/src/activation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/src/activation.ts b/package/src/activation.ts index 2231f99a3..2cc8c14ac 100644 --- a/package/src/activation.ts +++ b/package/src/activation.ts @@ -173,7 +173,7 @@ function createDefinitionProvider( } if (!Array.isArray(searchResult)) { - const badged = ({ ...searchResult, badge: impreciseBadge }) + const badged = { ...searchResult, badge: impreciseBadge } yield badged return } From 15cc456adb8609ad907803bf714abb4b0bee5dc8 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Tue, 28 Jan 2020 09:55:42 -0600 Subject: [PATCH 08/14] Rename abortion.ts. --- package/src/{abortion.ts => abort.ts} | 0 package/src/activation.ts | 2 +- package/src/index.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename package/src/{abortion.ts => abort.ts} (100%) diff --git a/package/src/abortion.ts b/package/src/abort.ts similarity index 100% rename from package/src/abortion.ts rename to package/src/abort.ts diff --git a/package/src/activation.ts b/package/src/activation.ts index 2cc8c14ac..f5f5a5337 100644 --- a/package/src/activation.ts +++ b/package/src/activation.ts @@ -9,7 +9,7 @@ import { shareReplay, } from 'rxjs/operators' import { Observer, BehaviorSubject, from, Observable, noop } from 'rxjs' -import { createAbortError } from './abortion' +import { createAbortError } from './abort' export type Maybe = { value: T } | undefined diff --git a/package/src/index.ts b/package/src/index.ts index f6d8d11b7..4e618886b 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -4,7 +4,7 @@ export { createAbortError, isAbortError, throwIfAbortError, -} from './abortion' +} from './abort' export { impreciseBadge } from './badges' export { Handler, HandlerArgs } from './handler' export { From db6a2f78da58037abb3df711c042caaa8fb3af74 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Tue, 28 Jan 2020 09:58:41 -0600 Subject: [PATCH 09/14] Deduplicate yarn.lock. --- package/yarn.lock | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/package/yarn.lock b/package/yarn.lock index 7d3f33c46..92227c0f1 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -2183,12 +2183,7 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -sourcegraph@^23.0.1: - version "23.0.1" - resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.0.1.tgz#715fcf4129a6d94bc3bfd2740d9c706ae6357ffe" - integrity sha512-4We7zqhOagOVxNFdS6/xT/Crhb0Arw/9ytGBu8JuHfjo5yjMtcUYt22kZyu2TaPHXwyPW9muUi1eKSFA6Qg4lw== - -sourcegraph@^23.1.0: +sourcegraph@^23.0.1, sourcegraph@^23.1.0: version "23.1.0" resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.1.0.tgz#3178979805239cdf777d7b2cb9aae99c2a3d1dcb" integrity sha512-pcHP/Ad1TGJWDu4vh8iDE1bi4LOvDgc2Q+amlByjSfeg2+vd4jldpaW4HuP/fMVGYvvzxOa4jrjlluWeXFqyoA== From 4af2541fdbed6365cde48ec29499dc5f84d66aa5 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Tue, 28 Jan 2020 10:03:35 -0600 Subject: [PATCH 10/14] Remove abortPrevious. --- package/src/activation.ts | 106 ++++++++++++++------------------------ 1 file changed, 40 insertions(+), 66 deletions(-) diff --git a/package/src/activation.ts b/package/src/activation.ts index f5f5a5337..210535d4e 100644 --- a/package/src/activation.ts +++ b/package/src/activation.ts @@ -8,7 +8,7 @@ import { distinctUntilChanged, shareReplay, } from 'rxjs/operators' -import { Observer, BehaviorSubject, from, Observable, noop } from 'rxjs' +import { Observer, BehaviorSubject, from, Observable } from 'rxjs' import { createAbortError } from './abort' export type Maybe = { value: T } | undefined @@ -182,11 +182,7 @@ function createDefinitionProvider( } return { - provideDefinition: memoizePrevious(areProviderParamsEqual, (doc, pos) => - observableFromAsyncGenerator( - abortPrevious(() => provideDefinition(doc, pos)) - ).pipe(shareReplay(1)) - ), + provideDefinition: wrap(areProviderParamsEqual, provideDefinition), } } @@ -232,15 +228,10 @@ function createReferencesProvider( } return { - provideReferences: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position, - ctx: sourcegraph.ReferenceContext - ) => - // TODO - add memoizePrevious, abortPrevious - observableFromAsyncGenerator(() => - provideReferences(doc, pos, ctx) - ), + provideReferences: wrap( + areProviderParamsContextEqual, + provideReferences + ), } } @@ -278,13 +269,7 @@ function createHoverProvider( } return { - provideHover: memoizePrevious( - areProviderParamsEqual, - (textDocument, position) => - observableFromAsyncGenerator( - abortPrevious(() => provideHover(textDocument, position)) - ).pipe(shareReplay(1)) - ), + provideHover: wrap(areProviderParamsEqual, provideHover), } } @@ -337,15 +322,12 @@ function createExternalReferencesProvider( externalReferencesProvider: ExternalReferenceProvider ): sourcegraph.ReferenceProvider { return { - provideReferences: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position, - ctx: sourcegraph.ReferenceContext - ) => - // TODO - add memoizePrevious, abortPrevious - observableFromAsyncGenerator(() => - externalReferencesProvider.references(doc, pos, ctx) - ), + provideReferences: wrap( + areProviderParamsContextEqual, + externalReferencesProvider.references.bind( + externalReferencesProvider + ) + ), } } @@ -359,14 +341,12 @@ function registerImplementationsProvider( implementationsProvider.implId, selector, { - provideLocations: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => - // TODO - add memoizePrevious, abortPrevious - observableFromAsyncGenerator(() => - implementationsProvider.locations(doc, pos) - ), + provideLocations: wrap( + areProviderParamsEqual, + implementationsProvider.locations.bind( + implementationsProvider + ) + ), } ) ) @@ -383,11 +363,32 @@ function registerImplementationsProvider( // // +const wrap =

( + compare: (a: P, b: P) => boolean, + fn: (...args: P) => AsyncGenerator +): ((...args: P) => Observable) => + memoizePrevious(compare, (...args) => + observableFromAsyncGenerator(() => fn(...args)).pipe(shareReplay(1)) + ) + export const areProviderParamsEqual = ( [doc1, pos1]: [sourcegraph.TextDocument, sourcegraph.Position], [doc2, pos2]: [sourcegraph.TextDocument, sourcegraph.Position] ): boolean => doc1.uri === doc2.uri && pos1.isEqual(pos2) +const areProviderParamsContextEqual = ( + [doc1, pos1]: [ + sourcegraph.TextDocument, + sourcegraph.Position, + sourcegraph.ReferenceContext + ], + [doc2, pos2]: [ + sourcegraph.TextDocument, + sourcegraph.Position, + sourcegraph.ReferenceContext + ] +): boolean => areProviderParamsEqual([doc1, pos1], [doc2, pos2]) + export const observableFromAsyncGenerator = ( generator: () => AsyncGenerator ): Observable => @@ -425,33 +426,6 @@ export const observableFromAsyncGenerator = ( } }) -/** - * Similar to Rx `switchMap`, finishes the generator returned from the previous call whenever the function is called again. - * - * Workaround for https://github.com/sourcegraph/sourcegraph/issues/1190 - */ -export const abortPrevious =

( - fn: (...args: P) => AsyncGenerator -): ((...args: P) => AsyncGenerator) => { - let abort = noop - return async function*(...args) { - abort() - let aborted = false - abort = () => { - aborted = true - } - for await (const element of fn(...args)) { - if (aborted) { - return - } - yield element - if (aborted) { - return - } - } - } -} - /** Workaround for https://github.com/sourcegraph/sourcegraph/issues/1321 */ export function memoizePrevious

( compare: (a: P, b: P) => boolean, From 987cf7904a4cf57c8a1ace6f29e94eafc43978b8 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Tue, 28 Jan 2020 10:06:03 -0600 Subject: [PATCH 11/14] Deduplicate yarn.lock. --- template/yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/template/yarn.lock b/template/yarn.lock index 9801dbcb3..80e1ce980 100644 --- a/template/yarn.lock +++ b/template/yarn.lock @@ -5579,7 +5579,7 @@ source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= -sourcegraph@23.1.0, sourcegraph@^23.1.0: +sourcegraph@23.1.0: version "23.1.0" resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.1.0.tgz#3178979805239cdf777d7b2cb9aae99c2a3d1dcb" integrity sha512-pcHP/Ad1TGJWDu4vh8iDE1bi4LOvDgc2Q+amlByjSfeg2+vd4jldpaW4HuP/fMVGYvvzxOa4jrjlluWeXFqyoA== From a82c86e883f92a53b8ab7a5b7574f2bd56735192 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Tue, 28 Jan 2020 10:07:41 -0600 Subject: [PATCH 12/14] Remove duplicate entry. --- template/yarn.lock | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/template/yarn.lock b/template/yarn.lock index 80e1ce980..d240be795 100644 --- a/template/yarn.lock +++ b/template/yarn.lock @@ -5579,12 +5579,7 @@ source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= -sourcegraph@23.1.0: - version "23.1.0" - resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.1.0.tgz#3178979805239cdf777d7b2cb9aae99c2a3d1dcb" - integrity sha512-pcHP/Ad1TGJWDu4vh8iDE1bi4LOvDgc2Q+amlByjSfeg2+vd4jldpaW4HuP/fMVGYvvzxOa4jrjlluWeXFqyoA== - -sourcegraph@^23.1.0: +sourcegraph@23.1.0, sourcegraph@^23.1.0: version "23.1.0" resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.1.0.tgz#3178979805239cdf777d7b2cb9aae99c2a3d1dcb" integrity sha512-pcHP/Ad1TGJWDu4vh8iDE1bi4LOvDgc2Q+amlByjSfeg2+vd4jldpaW4HuP/fMVGYvvzxOa4jrjlluWeXFqyoA== From b3bfc4842f743f717e5953be458c0a533ea21e37 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Tue, 28 Jan 2020 15:05:03 -0600 Subject: [PATCH 13/14] Undo the locations provider stuff. --- package/src/activation.ts | 139 +------------------------------------- package/src/index.ts | 2 +- 2 files changed, 3 insertions(+), 138 deletions(-) diff --git a/package/src/activation.ts b/package/src/activation.ts index 210535d4e..ed185a203 100644 --- a/package/src/activation.ts +++ b/package/src/activation.ts @@ -2,13 +2,8 @@ import * as sourcegraph from 'sourcegraph' import { HandlerArgs, Handler } from './handler' import { initLSIF } from './lsif' import { impreciseBadge } from './badges' -import { - map, - finalize, - distinctUntilChanged, - shareReplay, -} from 'rxjs/operators' -import { Observer, BehaviorSubject, from, Observable } from 'rxjs' +import { shareReplay } from 'rxjs/operators' +import { Observer, Observable } from 'rxjs' import { createAbortError } from './abort' export type Maybe = { value: T } | undefined @@ -63,29 +58,6 @@ export interface LSPProviders { doc: sourcegraph.TextDocument, pos: sourcegraph.Position ) => AsyncGenerator - - externalReferences?: ExternalReferenceProvider - implementations?: ImplementationsProvider -} - -export interface ExternalReferenceProvider { - settingName: string - - references: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position, - context: sourcegraph.ReferenceContext - ) => AsyncGenerator -} - -export interface ImplementationsProvider { - implId: string - panelTitle: string - - locations: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => AsyncGenerator } export function activateCodeIntel( @@ -123,27 +95,6 @@ export function activateCodeIntel( createHoverProvider(lsifProviders, searchProviders, lspProviders) ) ) - - if (lspProviders) { - const externalReferencesProvider = lspProviders.externalReferences - const implementationsProvider = lspProviders.implementations - - if (externalReferencesProvider) { - registerExternalReferencesProvider( - ctx, - selector, - externalReferencesProvider - ) - } - - if (implementationsProvider) { - registerImplementationsProvider( - ctx, - selector, - implementationsProvider - ) - } - } } function createDefinitionProvider( @@ -273,92 +224,6 @@ function createHoverProvider( } } -function registerExternalReferencesProvider( - ctx: sourcegraph.ExtensionContext, - selector: sourcegraph.DocumentSelector, - externalReferencesProvider: ExternalReferenceProvider -) { - const settings: BehaviorSubject> = new BehaviorSubject< - Partial - >({}) - ctx.subscriptions.add( - sourcegraph.configuration.subscribe(() => { - settings.next(sourcegraph.configuration.get>().value) - }) - ) - - let registration: sourcegraph.Unsubscribable | undefined - - const register = () => { - registration = sourcegraph.languages.registerReferenceProvider( - selector, - createExternalReferencesProvider(externalReferencesProvider) - ) - } - - const deregister = () => { - if (registration) { - registration.unsubscribe() - registration = undefined - } - } - - ctx.subscriptions.add( - from(settings) - .pipe( - map( - settings => - !!settings[externalReferencesProvider.settingName] - ), - distinctUntilChanged(), - map(enabled => (enabled ? register : deregister)()), - finalize(() => deregister()) - ) - .subscribe() - ) -} - -function createExternalReferencesProvider( - externalReferencesProvider: ExternalReferenceProvider -): sourcegraph.ReferenceProvider { - return { - provideReferences: wrap( - areProviderParamsContextEqual, - externalReferencesProvider.references.bind( - externalReferencesProvider - ) - ), - } -} - -function registerImplementationsProvider( - ctx: sourcegraph.ExtensionContext, - selector: sourcegraph.DocumentSelector, - implementationsProvider: ImplementationsProvider -) { - ctx.subscriptions.add( - sourcegraph.languages.registerLocationProvider( - implementationsProvider.implId, - selector, - { - provideLocations: wrap( - areProviderParamsEqual, - implementationsProvider.locations.bind( - implementationsProvider - ) - ), - } - ) - ) - - const IMPL_ID = implementationsProvider.implId - const panelView = sourcegraph.app.createPanelView(IMPL_ID) - panelView.title = implementationsProvider.panelTitle - panelView.component = { locationProvider: IMPL_ID } - panelView.priority = 160 - ctx.subscriptions.add(panelView) -} - // // // diff --git a/package/src/index.ts b/package/src/index.ts index 4e618886b..895a1e22a 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,4 +1,4 @@ -export { activateCodeIntel } from './activation' +export { activateCodeIntel, LSPProviders } from './activation' export { AbortError, createAbortError, From 8092359ff2aa1059a7fdef2313d4c4b0554ef6a0 Mon Sep 17 00:00:00 2001 From: Eric Fritz Date: Fri, 31 Jan 2020 09:03:30 -0600 Subject: [PATCH 14/14] Reorganize package code (#201) --- languages.ts | 2 +- package/src/activation.ts | 75 +- package/src/{memoizeAsync.ts => graphql.ts} | 22 +- package/src/index.ts | 22 +- package/src/lsif.ts | 647 ----------- package/src/lsif/activation.ts | 63 + package/src/lsif/graphql.ts | 254 ++++ package/src/lsif/http.ts | 201 ++++ package/src/lsif/lsif-conversion.ts | 26 + package/src/{ => lsif}/lsp-conversion.ts | 42 +- package/src/lsif/providers.ts | 18 + package/src/lsif/util.ts | 16 + package/src/lsp/providers.ts | 19 + package/src/{ => search}/api.ts | 24 +- package/src/search/comments.ts | 33 + package/src/{ => search}/handler.test.ts | 2 +- package/src/{ => search}/handler.ts | 1146 +++++++++---------- package/src/search/providers.ts | 18 + package/src/search/settings.ts | 7 + package/yarn.lock | 5 + 20 files changed, 1243 insertions(+), 1399 deletions(-) rename package/src/{memoizeAsync.ts => graphql.ts} (57%) delete mode 100644 package/src/lsif.ts create mode 100644 package/src/lsif/activation.ts create mode 100644 package/src/lsif/graphql.ts create mode 100644 package/src/lsif/http.ts create mode 100644 package/src/lsif/lsif-conversion.ts rename package/src/{ => lsif}/lsp-conversion.ts (95%) create mode 100644 package/src/lsif/providers.ts create mode 100644 package/src/lsif/util.ts create mode 100644 package/src/lsp/providers.ts rename package/src/{ => search}/api.ts (90%) create mode 100644 package/src/search/comments.ts rename package/src/{ => search}/handler.test.ts (99%) rename package/src/{ => search}/handler.ts (88%) create mode 100644 package/src/search/providers.ts create mode 100644 package/src/search/settings.ts diff --git a/languages.ts b/languages.ts index eea432786..b25c52032 100644 --- a/languages.ts +++ b/languages.ts @@ -1,4 +1,4 @@ -import { HandlerArgs, CommentStyle } from './package/src/handler' +import { HandlerArgs, CommentStyle } from './package/src/index' const path = require('path-browserify') type Omit = Pick> diff --git a/package/src/activation.ts b/package/src/activation.ts index ed185a203..477de03d4 100644 --- a/package/src/activation.ts +++ b/package/src/activation.ts @@ -1,64 +1,13 @@ import * as sourcegraph from 'sourcegraph' -import { HandlerArgs, Handler } from './handler' -import { initLSIF } from './lsif' +import { HandlerArgs, Handler } from './search/handler' +import { initLSIF } from './lsif/activation' import { impreciseBadge } from './badges' import { shareReplay } from 'rxjs/operators' -import { Observer, Observable } from 'rxjs' +import { Observable, Observer } from 'rxjs' import { createAbortError } from './abort' - -export type Maybe = { value: T } | undefined - -export interface LSIFProviders { - hover: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise> - - definition: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise> - - references: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise> -} - -export interface SearchProviders { - definition: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise - - references: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise - - hover: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise -} - -export interface LSPProviders { - definition: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => AsyncGenerator - - references: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position, - context: sourcegraph.ReferenceContext - ) => AsyncGenerator - - hover: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => AsyncGenerator -} +import { LSPProviders } from './lsp/providers' +import { LSIFProviders } from './lsif/providers' +import { SearchProviders } from './search/providers' export function activateCodeIntel( ctx: sourcegraph.ExtensionContext, @@ -108,7 +57,7 @@ function createDefinitionProvider( ): AsyncGenerator { const lsifResult = await lsifProviders.definition(doc, pos) if (lsifResult) { - yield lsifResult.value + yield lsifResult return } @@ -159,7 +108,7 @@ function createReferencesProvider( // Get and extract LSIF results const lsifResult = await lsifProviders.references(doc, pos) - const lsifReferences = (lsifResult && lsifResult.value) || [] + const lsifReferences = lsifResult || [] const lsifFiles = new Set(lsifReferences.map(file)) // Unconditionally get search references and append them with @@ -201,7 +150,7 @@ function createHoverProvider( > { const lsifResult = await lsifProviders.hover(doc, pos) if (lsifResult) { - yield lsifResult.value + yield lsifResult return } @@ -236,7 +185,7 @@ const wrap =

( observableFromAsyncGenerator(() => fn(...args)).pipe(shareReplay(1)) ) -export const areProviderParamsEqual = ( +const areProviderParamsEqual = ( [doc1, pos1]: [sourcegraph.TextDocument, sourcegraph.Position], [doc2, pos2]: [sourcegraph.TextDocument, sourcegraph.Position] ): boolean => doc1.uri === doc2.uri && pos1.isEqual(pos2) @@ -254,7 +203,7 @@ const areProviderParamsContextEqual = ( ] ): boolean => areProviderParamsEqual([doc1, pos1], [doc2, pos2]) -export const observableFromAsyncGenerator = ( +const observableFromAsyncGenerator = ( generator: () => AsyncGenerator ): Observable => new Observable((observer: Observer) => { @@ -292,7 +241,7 @@ export const observableFromAsyncGenerator = ( }) /** Workaround for https://github.com/sourcegraph/sourcegraph/issues/1321 */ -export function memoizePrevious

( +function memoizePrevious

( compare: (a: P, b: P) => boolean, fn: (...args: P) => R ): (...args: P) => R { diff --git a/package/src/memoizeAsync.ts b/package/src/graphql.ts similarity index 57% rename from package/src/memoizeAsync.ts rename to package/src/graphql.ts index 58595ba54..5c2998438 100644 --- a/package/src/memoizeAsync.ts +++ b/package/src/graphql.ts @@ -1,3 +1,23 @@ +// TODO(sqs): this will never release the memory of the cached responses; use an LRU cache or similar. +export const queryGraphQL = memoizeAsync( + async ({ + query, + vars, + sourcegraph, + }: { + query: string + vars: { [name: string]: any } + sourcegraph: typeof import('sourcegraph') + }): Promise => { + return sourcegraph.commands.executeCommand( + 'queryGraphQL', + query, + vars + ) + }, + arg => JSON.stringify({ query: arg.query, vars: arg.vars }) +) + /** * Creates a function that memoizes the async result of func. * If the promise rejects, the value will not be cached. @@ -5,7 +25,7 @@ * @param resolver If resolver provided, it determines the cache key for storing the result based on * the first argument provided to the memoized function. */ -export function memoizeAsync

( +function memoizeAsync

( func: (params: P) => Promise, resolver?: (params: P) => string ): (params: P, force?: boolean) => Promise { diff --git a/package/src/index.ts b/package/src/index.ts index 895a1e22a..5d1ed540c 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,24 +1,10 @@ -export { activateCodeIntel, LSPProviders } from './activation' +export { activateCodeIntel } from './activation' +export { HandlerArgs } from './search/handler' +export { CommentStyle, BlockCommentStyle } from './search/comments' +export { LSPProviders } from './lsp/providers' export { AbortError, createAbortError, isAbortError, throwIfAbortError, } from './abort' -export { impreciseBadge } from './badges' -export { Handler, HandlerArgs } from './handler' -export { - initLSIF, - asyncFirst, - asyncWhen, - when, - wrapMaybe, - Maybe, - MaybeProviders, - noopMaybeProviders, - mkIsLSIFAvailable, - hover, - definition, - references, - Providers, -} from './lsif' diff --git a/package/src/lsif.ts b/package/src/lsif.ts deleted file mode 100644 index 66aab962d..000000000 --- a/package/src/lsif.ts +++ /dev/null @@ -1,647 +0,0 @@ -import * as sourcegraph from 'sourcegraph' -import * as LSP from 'vscode-languageserver-types' -import { convertLocations, convertHover } from './lsp-conversion' -import { queryGraphQL } from './api' -import { compareVersion } from './versions' - -/** - * The date that the LSIF GraphQL API resolvers became available. - * - * Specifically, ensure that the commit 34e6a67ecca30afb4a5d8d200fc88a724d3c4ac5 - * exists, as there is a bad performance issue prior to that when a force push - * removes commits from the codehost for which we have LSIF data. - */ -const GRAPHQL_API_MINIMUM_DATE = '2020-01-08' - -/** The version that the LSIF GraphQL API resolvers became available. */ -const GRAPHQL_API_MINIMUM_VERSION = '3.12.0' - -function repositoryFromDoc(doc: sourcegraph.TextDocument): string { - const url = new URL(doc.uri) - return url.hostname + url.pathname -} - -function commitFromDoc(doc: sourcegraph.TextDocument): string { - const url = new URL(doc.uri) - return url.search.slice(1) -} - -function pathFromDoc(doc: sourcegraph.TextDocument): string { - const url = new URL(doc.uri) - return url.hash.slice(1) -} - -function setPath(doc: sourcegraph.TextDocument, path: string): string { - if (path.startsWith('git://')) { - return path - } - - const url = new URL(doc.uri) - url.hash = path - return url.href -} - -async function queryLSIF({ - doc, - method, - path, - position, -}: { - doc: sourcegraph.TextDocument - method: string - path: string - position: LSP.Position -}): Promise { - const url = new URL( - '.api/lsif/request', - sourcegraph.internal.sourcegraphURL - ) - url.searchParams.set('repository', repositoryFromDoc(doc)) - url.searchParams.set('commit', commitFromDoc(doc)) - - const response = await fetch(url.href, { - method: 'POST', - headers: new Headers({ - 'content-type': 'application/json', - 'x-requested-with': 'Basic code intel', - }), - body: JSON.stringify({ - method, - path, - position, - }), - }) - if (!response.ok) { - throw new Error(`LSIF /request returned ${response.statusText}`) - } - return await response.json() -} - -/** - * Creates an asynchronous predicate on a doc that checks for the existence of - * LSIF data for the given doc. It's a constructor because it creates an - * internal cache to reduce network traffic. - */ -export const mkIsLSIFAvailable = () => { - const lsifDocs = new Map>() - return (doc: sourcegraph.TextDocument): Promise => { - if (!sourcegraph.configuration.get().get('codeIntel.lsif')) { - console.log('LSIF is not enabled in global settings') - return Promise.resolve(false) - } - - if (lsifDocs.has(doc.uri)) { - return lsifDocs.get(doc.uri)! - } - - const repository = repositoryFromDoc(doc) - const commit = commitFromDoc(doc) - const file = pathFromDoc(doc) - - const url = new URL( - '.api/lsif/exists', - sourcegraph.internal.sourcegraphURL - ) - url.searchParams.set('repository', repository) - url.searchParams.set('commit', commit) - url.searchParams.set('file', file) - - const hasLSIFPromise = (async () => { - try { - // Prevent leaking the name of a private repository to - // Sourcegraph.com by relying on the Sourcegraph extension host's - // private repository detection, which will throw an error when - // making a GraphQL request. - await queryGraphQL({ - query: `query { currentUser { id } }`, - vars: {}, - sourcegraph, - }) - } catch (e) { - return false - } - const response = await fetch(url.href, { - method: 'POST', - headers: new Headers({ - 'x-requested-with': 'Basic code intel', - }), - }) - if (!response.ok) { - return false - } - return response.json() - })() - - lsifDocs.set(doc.uri, hasLSIFPromise) - return hasLSIFPromise - } -} - -export async function hover( - doc: sourcegraph.TextDocument, - position: sourcegraph.Position -): Promise> { - const hover: LSP.Hover | null = await queryLSIF({ - doc, - method: 'hover', - path: pathFromDoc(doc), - position, - }) - if (!hover) { - return undefined - } - return { value: convertHover(sourcegraph, hover) } -} - -export async function definition( - doc: sourcegraph.TextDocument, - position: sourcegraph.Position -): Promise> { - const body: LSP.Location | LSP.Location[] | null = await queryLSIF({ - doc, - method: 'definitions', - path: pathFromDoc(doc), - position, - }) - if (!body) { - return undefined - } - const locations = Array.isArray(body) ? body : [body] - if (locations.length === 0) { - return undefined - } - return { - value: convertLocations( - sourcegraph, - locations.map(d => ({ ...d, uri: setPath(doc, d.uri) })) - ), - } -} - -export async function references( - doc: sourcegraph.TextDocument, - position: sourcegraph.Position -): Promise { - const body: LSP.Location[] | null = await queryLSIF({ - doc, - method: 'references', - path: pathFromDoc(doc), - position, - }) - if (!body) { - return [] - } - const locations = Array.isArray(body) ? body : [body] - if (locations.length === 0) { - return [] - } - return convertLocations( - sourcegraph, - locations.map(r => ({ ...r, uri: setPath(doc, r.uri) })) - ) -} - -/** - * An optional value of type T. It's either `{ value: T }` or `undefined`. - */ -export type Maybe = { value: T } | undefined - -/** - * Converts an async function that returns a type `ReturnType` to an async - * function that returns the type `Maybe`. - */ -export const wrapMaybe = ( - f: (...args: Arguments) => Promise -) => async (...args: Arguments): Promise> => { - const returnValue = await f(...args) - return returnValue !== undefined ? { value: returnValue } : undefined -} - -/** - * Only runs the given async function `f` when the given sync predicate on the arguments - * succeeds. - */ -export function when( - predicate: (...args: Arguments) => boolean -): ( - f: (...args: Arguments) => Promise -) => (...args: Arguments) => Promise> { - return f => async (...args) => - predicate(...args) ? { value: await f(...args) } : undefined -} - -/** - * Only runs the given async function `f` when the given async predicate on the arguments - * succeeds. Async version of `when`. - */ -export function asyncWhen( - asyncPredicate: (...args: A) => Promise -): (f: (...args: A) => Promise) => (...args: A) => Promise> { - return f => async (...args) => - (await asyncPredicate(...args)) - ? { value: await f(...args) } - : undefined -} - -/** - * Only runs the given async function `f` when the given async predicate on the arguments - * succeeds. Async version of `when`. - */ -export function asyncWhenMaybe( - asyncPredicate: (...args: A) => Promise -): (f: (...args: A) => Promise>) => (...args: A) => Promise> { - return f => async (...args) => - (await asyncPredicate(...args)) ? await f(...args) : undefined -} - -/** - * Takes an array of async functions `fs` that return `Maybe`, calls - * each `f` in series, bails when one returns `{ value: ... }`, and returns that - * value. Defaults to `defaultValue` when no `f` returns `{ value: ... }`. - */ -export const asyncFirst = ( - fs: ((...args: Arguments) => Promise>)[], - defaultValue: ReturnType -) => async (...args: Arguments): Promise => { - for (const f of fs) { - const maybeReturnValue = await f(...args) - if (maybeReturnValue !== undefined) { - return maybeReturnValue.value - } - } - return defaultValue -} - -export interface MaybeProviders { - hover: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise> - definition: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise> - references: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise> -} - -export interface Providers { - hover: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise - definition: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise - references: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise -} - -export const noopMaybeProviders = { - hover: () => Promise.resolve(undefined), - definition: () => Promise.resolve(undefined), - references: () => Promise.resolve(undefined), -} - -export function initLSIF(): MaybeProviders { - const provider = (async () => { - if (await supportsGraphQL()) { - console.log('Sourcegraph instance supports LSIF GraphQL API') - return initGraphQL() - } - - console.log( - 'Sourcegraph instance does not support LSIF GraphQL API, falling back to HTTP API' - ) - return initHTTP() - })() - - return { - // If graphQL is supported, use the GraphQL implementation. - // Otherwise, use the legacy HTTP implementation. - definition: async (...args) => (await provider).definition(...args), - references: async (...args) => (await provider).references(...args), - hover: async (...args) => (await provider).hover(...args), - } -} - -async function supportsGraphQL(): Promise { - const query = ` - query SiteVersion { - site { - productVersion - } - } - ` - - const respObj = await queryGraphQL({ - query, - vars: {}, - sourcegraph, - }) - - return compareVersion({ - productVersion: respObj.data.site.productVersion, - minimumVersion: GRAPHQL_API_MINIMUM_VERSION, - minimumDate: GRAPHQL_API_MINIMUM_DATE, - }) -} - -function initHTTP(): MaybeProviders { - const isLSIFAvailable = mkIsLSIFAvailable() - - return { - // You can read this as "only send a hover request when LSIF data is - // available for the given doc". - hover: asyncWhenMaybe< - [sourcegraph.TextDocument, sourcegraph.Position], - sourcegraph.Hover - >(isLSIFAvailable)(hover), - definition: asyncWhenMaybe< - [sourcegraph.TextDocument, sourcegraph.Position], - sourcegraph.Definition - >(isLSIFAvailable)(definition), - references: asyncWhen< - [sourcegraph.TextDocument, sourcegraph.Position], - sourcegraph.Location[] - >(isLSIFAvailable)(references), - } -} - -function initGraphQL(): MaybeProviders { - const noLSIFData = new Set() - - const cacheUndefined = ( - f: ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ) => Promise> - ) => async ( - doc: sourcegraph.TextDocument, - pos: sourcegraph.Position - ): Promise> => { - if (!sourcegraph.configuration.get().get('codeIntel.lsif')) { - console.log('LSIF is not enabled in global settings') - return undefined - } - - if (noLSIFData.has(doc.uri)) { - return undefined - } - - const result = await f(doc, pos) - if (result === undefined) { - noLSIFData.add(doc.uri) - } - - return result - } - - return { - definition: cacheUndefined(definitionGraphQL), - references: cacheUndefined(referencesGraphQL), - hover: cacheUndefined(hoverGraphQL), - } -} - -async function definitionGraphQL( - doc: sourcegraph.TextDocument, - position: sourcegraph.Position -): Promise> { - const query = ` - query Definitions($repository: String!, $commit: String!, $path: String!, $line: Int!, $character: Int!) { - repository(name: $repository) { - commit(rev: $commit) { - blob(path: $path) { - lsif { - definitions(line: $line, character: $character) { - nodes { - resource { - path - repository { - name - } - commit { - oid - } - } - range { - start { - line - character - } - end { - line - character - } - } - } - } - } - } - } - } - } - ` - - const lsifObj = await queryLSIFGraphQL<{ - definitions: { nodes: LocationConnectionNode[] } - }>({ doc, query, position }) - - if (!lsifObj) { - return undefined - } - - return { value: lsifObj.definitions.nodes.map(nodeToLocation) } -} - -async function referencesGraphQL( - doc: sourcegraph.TextDocument, - position: sourcegraph.Position -): Promise> { - const query = ` - query References($repository: String!, $commit: String!, $path: String!, $line: Int!, $character: Int!) { - repository(name: $repository) { - commit(rev: $commit) { - blob(path: $path) { - lsif { - references(line: $line, character: $character) { - nodes { - resource { - path - repository { - name - } - commit { - oid - } - } - range { - start { - line - character - } - end { - line - character - } - } - } - } - } - } - } - } - } - ` - - const lsifObj = await queryLSIFGraphQL<{ - references: { nodes: LocationConnectionNode[] } - }>({ doc, query, position }) - - if (!lsifObj) { - return undefined - } - - return { value: lsifObj.references.nodes.map(nodeToLocation) } -} - -async function hoverGraphQL( - doc: sourcegraph.TextDocument, - position: sourcegraph.Position -): Promise> { - const query = ` - query Hover($repository: String!, $commit: String!, $path: String!, $line: Int!, $character: Int!) { - repository(name: $repository) { - commit(rev: $commit) { - blob(path: $path) { - lsif { - hover(line: $line, character: $character) { - markdown { - text - } - range { - start { - line - character - } - end { - line - character - } - } - } - } - } - } - } - } - ` - - const lsifObj = await queryLSIFGraphQL<{ - hover: { markdown: { text: string }; range: sourcegraph.Range } - }>({ - doc, - query, - position, - }) - - if (!lsifObj) { - return undefined - } - - return { - value: { - contents: { - value: lsifObj.hover.markdown.text, - kind: sourcegraph.MarkupKind.Markdown, - }, - range: lsifObj.hover.range, - }, - } -} - -async function queryLSIFGraphQL({ - doc, - query, - position, -}: { - doc: sourcegraph.TextDocument - query: string - position: LSP.Position -}): Promise { - repositoryFromDoc(doc) - commitFromDoc(doc) - - const vars = { - repository: repositoryFromDoc(doc), - commit: commitFromDoc(doc), - path: pathFromDoc(doc), - line: position.line, - character: position.character, - } - - const respObj: { - data: { - repository: { - commit: { - blob: { - lsif: T - } - } - } - } - errors: Error[] - } = await queryGraphQL({ - query, - vars, - sourcegraph, - }) - - if (respObj.errors) { - const asError = (err: { message: string }): Error => - Object.assign(new Error(err.message), err) - - if (respObj.errors.length === 1) { - throw asError(respObj.errors[0]) - } - - throw Object.assign( - new Error(respObj.errors.map(e => e.message).join('\n')), - { - name: 'AggregateError', - errors: respObj.errors.map(asError), - } - ) - } - - return respObj.data.repository.commit.blob.lsif -} - -type LocationConnectionNode = { - resource: { - path: string - repository: { name: string } - commit: { oid: string } - } - range: sourcegraph.Range -} - -function nodeToLocation(node: LocationConnectionNode): sourcegraph.Location { - return { - uri: new sourcegraph.URI( - `git://${node.resource.repository.name}?${node.resource.commit.oid}#${node.resource.path}` - ), - range: new sourcegraph.Range( - node.range.start.line, - node.range.start.character, - node.range.end.line, - node.range.end.character - ), - } -} diff --git a/package/src/lsif/activation.ts b/package/src/lsif/activation.ts new file mode 100644 index 000000000..25abb2cfe --- /dev/null +++ b/package/src/lsif/activation.ts @@ -0,0 +1,63 @@ +import * as sourcegraph from 'sourcegraph' +import { initGraphQL } from './graphql' +import { initHTTP } from './http' +import { queryGraphQL } from '../graphql' +import { compareVersion } from '../versions' +import { LSIFProviders } from './providers' + +/** + * The date that the LSIF GraphQL API resolvers became available. + * + * Specifically, ensure that the commit 34e6a67ecca30afb4a5d8d200fc88a724d3c4ac5 + * exists, as there is a bad performance issue prior to that when a force push + * removes commits from the codehost for which we have LSIF data. + */ +const GRAPHQL_API_MINIMUM_DATE = '2020-01-08' + +/** The version that the LSIF GraphQL API resolvers became available. */ +const GRAPHQL_API_MINIMUM_VERSION = '3.12.0' + +export function initLSIF(): LSIFProviders { + const provider = createProvider() + + return { + // If graphQL is supported, use the GraphQL implementation. + // Otherwise, use the legacy HTTP implementation. + definition: async (...args) => (await provider).definition(...args), + references: async (...args) => (await provider).references(...args), + hover: async (...args) => (await provider).hover(...args), + } +} + +async function createProvider(): Promise { + if (await supportsGraphQL()) { + console.log('Sourcegraph instance supports LSIF GraphQL API') + return initGraphQL() + } + console.log( + 'Sourcegraph instance does not support LSIF GraphQL API, falling back to HTTP API' + ) + return initHTTP() +} + +async function supportsGraphQL(): Promise { + const query = ` + query SiteVersion { + site { + productVersion + } + } + ` + + const respObj = await queryGraphQL({ + query, + vars: {}, + sourcegraph, + }) + + return compareVersion({ + productVersion: respObj.data.site.productVersion, + minimumVersion: GRAPHQL_API_MINIMUM_VERSION, + minimumDate: GRAPHQL_API_MINIMUM_DATE, + }) +} diff --git a/package/src/lsif/graphql.ts b/package/src/lsif/graphql.ts new file mode 100644 index 000000000..7c3e7e29e --- /dev/null +++ b/package/src/lsif/graphql.ts @@ -0,0 +1,254 @@ +import * as sourcegraph from 'sourcegraph' +import { LSIFProviders } from './providers' +import * as LSP from 'vscode-languageserver-types' +import { queryGraphQL } from '../graphql' +import { repositoryFromDoc, commitFromDoc, pathFromDoc } from './util' +import { LocationConnectionNode, nodeToLocation } from './lsif-conversion' + +export function initGraphQL(): LSIFProviders { + const noLSIFData = new Set() + + const cacheUndefined = ( + f: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise + ) => async ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ): Promise => { + if (!sourcegraph.configuration.get().get('codeIntel.lsif')) { + console.log('LSIF is not enabled in global settings') + return undefined + } + + if (noLSIFData.has(doc.uri)) { + return undefined + } + + const result = await f(doc, pos) + if (result === undefined) { + noLSIFData.add(doc.uri) + } + + return result + } + + return { + definition: cacheUndefined(definitionGraphQL), + references: cacheUndefined(referencesGraphQL), + hover: cacheUndefined(hoverGraphQL), + } +} + +async function definitionGraphQL( + doc: sourcegraph.TextDocument, + position: sourcegraph.Position +): Promise { + const query = ` + query Definitions($repository: String!, $commit: String!, $path: String!, $line: Int!, $character: Int!) { + repository(name: $repository) { + commit(rev: $commit) { + blob(path: $path) { + lsif { + definitions(line: $line, character: $character) { + nodes { + resource { + path + repository { + name + } + commit { + oid + } + } + range { + start { + line + character + } + end { + line + character + } + } + } + } + } + } + } + } + } + ` + + const lsifObj = await queryLSIFGraphQL<{ + definitions: { nodes: LocationConnectionNode[] } + }>({ doc, query, position }) + + if (!lsifObj) { + return undefined + } + + return lsifObj.definitions.nodes.map(nodeToLocation) +} + +async function referencesGraphQL( + doc: sourcegraph.TextDocument, + position: sourcegraph.Position +): Promise { + const query = ` + query References($repository: String!, $commit: String!, $path: String!, $line: Int!, $character: Int!) { + repository(name: $repository) { + commit(rev: $commit) { + blob(path: $path) { + lsif { + references(line: $line, character: $character) { + nodes { + resource { + path + repository { + name + } + commit { + oid + } + } + range { + start { + line + character + } + end { + line + character + } + } + } + } + } + } + } + } + } + ` + + const lsifObj = await queryLSIFGraphQL<{ + references: { nodes: LocationConnectionNode[] } + }>({ doc, query, position }) + + if (!lsifObj) { + return undefined + } + + return lsifObj.references.nodes.map(nodeToLocation) +} + +async function hoverGraphQL( + doc: sourcegraph.TextDocument, + position: sourcegraph.Position +): Promise { + const query = ` + query Hover($repository: String!, $commit: String!, $path: String!, $line: Int!, $character: Int!) { + repository(name: $repository) { + commit(rev: $commit) { + blob(path: $path) { + lsif { + hover(line: $line, character: $character) { + markdown { + text + } + range { + start { + line + character + } + end { + line + character + } + } + } + } + } + } + } + } + ` + + const lsifObj = await queryLSIFGraphQL<{ + hover: { markdown: { text: string }; range: sourcegraph.Range } + }>({ + doc, + query, + position, + }) + + if (!lsifObj) { + return undefined + } + + return { + contents: { + value: lsifObj.hover.markdown.text, + kind: sourcegraph.MarkupKind.Markdown, + }, + range: lsifObj.hover.range, + } +} + +async function queryLSIFGraphQL({ + doc, + query, + position, +}: { + doc: sourcegraph.TextDocument + query: string + position: LSP.Position +}): Promise { + repositoryFromDoc(doc) + commitFromDoc(doc) + + const vars = { + repository: repositoryFromDoc(doc), + commit: commitFromDoc(doc), + path: pathFromDoc(doc), + line: position.line, + character: position.character, + } + + const respObj: { + data: { + repository: { + commit: { + blob: { + lsif: T + } + } + } + } + errors: Error[] + } = await queryGraphQL({ + query, + vars, + sourcegraph, + }) + + if (respObj.errors) { + const asError = (err: { message: string }): Error => + Object.assign(new Error(err.message), err) + + if (respObj.errors.length === 1) { + throw asError(respObj.errors[0]) + } + + throw Object.assign( + new Error(respObj.errors.map(e => e.message).join('\n')), + { + name: 'AggregateError', + errors: respObj.errors.map(asError), + } + ) + } + + return respObj.data.repository.commit.blob.lsif +} diff --git a/package/src/lsif/http.ts b/package/src/lsif/http.ts new file mode 100644 index 000000000..010512822 --- /dev/null +++ b/package/src/lsif/http.ts @@ -0,0 +1,201 @@ +import * as sourcegraph from 'sourcegraph' +import { LSIFProviders } from './providers' +import { convertHover, convertLocations } from './lsp-conversion' +import { pathFromDoc, repositoryFromDoc, commitFromDoc } from './util' +import * as LSP from 'vscode-languageserver-types' +import { queryGraphQL } from '../graphql' + +export function initHTTP(): LSIFProviders { + const isLSIFAvailable = createLSIFAvailablilityCheck() + + const ensureExists = ( + f: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise + ): (( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise) => { + return async ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => ((await isLSIFAvailable(doc)) ? await f(doc, pos) : undefined) + } + + return { + // You can read this as "only send a hover request when LSIF data is + // available for the given doc". + definition: ensureExists(definition), + references: ensureExists(references), + hover: ensureExists(hover), + } +} + +/** + * Creates an asynchronous predicate on a doc that checks for the existence of + * LSIF data for the given doc. It's a constructor because it creates an + * internal cache to reduce network traffic. + */ +const createLSIFAvailablilityCheck = () => { + const lsifDocs = new Map>() + return (doc: sourcegraph.TextDocument): Promise => { + if (!sourcegraph.configuration.get().get('codeIntel.lsif')) { + console.log('LSIF is not enabled in global settings') + return Promise.resolve(false) + } + + if (lsifDocs.has(doc.uri)) { + return lsifDocs.get(doc.uri)! + } + + const repository = repositoryFromDoc(doc) + const commit = commitFromDoc(doc) + const file = pathFromDoc(doc) + + const url = new URL( + '.api/lsif/exists', + sourcegraph.internal.sourcegraphURL + ) + url.searchParams.set('repository', repository) + url.searchParams.set('commit', commit) + url.searchParams.set('file', file) + + const hasLSIFPromise = (async () => { + try { + // Prevent leaking the name of a private repository to + // Sourcegraph.com by relying on the Sourcegraph extension host's + // private repository detection, which will throw an error when + // making a GraphQL request. + await queryGraphQL({ + query: `query { currentUser { id } }`, + vars: {}, + sourcegraph, + }) + } catch (e) { + return false + } + const response = await fetch(url.href, { + method: 'POST', + headers: new Headers({ + 'x-requested-with': 'Basic code intel', + }), + }) + if (!response.ok) { + return false + } + return response.json() + })() + + lsifDocs.set(doc.uri, hasLSIFPromise) + return hasLSIFPromise + } +} + +async function definition( + doc: sourcegraph.TextDocument, + position: sourcegraph.Position +): Promise { + const body: LSP.Location | LSP.Location[] | null = await queryLSIF({ + doc, + method: 'definitions', + path: pathFromDoc(doc), + position, + }) + if (!body) { + return undefined + } + const locations = Array.isArray(body) ? body : [body] + if (locations.length === 0) { + return undefined + } + return convertLocations( + sourcegraph, + locations.map(d => ({ ...d, uri: setPath(doc, d.uri) })) + ) +} + +async function references( + doc: sourcegraph.TextDocument, + position: sourcegraph.Position +): Promise { + const body: LSP.Location[] | null = await queryLSIF({ + doc, + method: 'references', + path: pathFromDoc(doc), + position, + }) + if (!body) { + return [] + } + const locations = Array.isArray(body) ? body : [body] + if (locations.length === 0) { + return [] + } + return convertLocations( + sourcegraph, + locations.map(r => ({ ...r, uri: setPath(doc, r.uri) })) + ) +} + +async function hover( + doc: sourcegraph.TextDocument, + position: sourcegraph.Position +): Promise { + const hover: LSP.Hover | null = await queryLSIF({ + doc, + method: 'hover', + path: pathFromDoc(doc), + position, + }) + if (!hover) { + return undefined + } + return convertHover(sourcegraph, hover) +} + +async function queryLSIF({ + doc, + method, + path, + position, +}: { + doc: sourcegraph.TextDocument + method: string + path: string + position: LSP.Position +}): Promise { + const url = new URL( + '.api/lsif/request', + sourcegraph.internal.sourcegraphURL + ) + url.searchParams.set('repository', repositoryFromDoc(doc)) + url.searchParams.set('commit', commitFromDoc(doc)) + + const response = await fetch(url.href, { + method: 'POST', + headers: new Headers({ + 'content-type': 'application/json', + 'x-requested-with': 'Basic code intel', + }), + body: JSON.stringify({ + method, + path, + position, + }), + }) + if (!response.ok) { + throw new Error(`LSIF /request returned ${response.statusText}`) + } + return await response.json() +} + +function setPath(doc: sourcegraph.TextDocument, path: string): string { + if (path.startsWith('git://')) { + return path + } + + const url = new URL(doc.uri) + url.hash = path + return url.href +} diff --git a/package/src/lsif/lsif-conversion.ts b/package/src/lsif/lsif-conversion.ts new file mode 100644 index 000000000..e788b5e9e --- /dev/null +++ b/package/src/lsif/lsif-conversion.ts @@ -0,0 +1,26 @@ +import * as sourcegraph from 'sourcegraph' + +export type LocationConnectionNode = { + resource: { + path: string + repository: { name: string } + commit: { oid: string } + } + range: sourcegraph.Range +} + +export function nodeToLocation( + node: LocationConnectionNode +): sourcegraph.Location { + return { + uri: new sourcegraph.URI( + `git://${node.resource.repository.name}?${node.resource.commit.oid}#${node.resource.path}` + ), + range: new sourcegraph.Range( + node.range.start.line, + node.range.start.character, + node.range.end.line, + node.range.end.character + ), + } +} diff --git a/package/src/lsp-conversion.ts b/package/src/lsif/lsp-conversion.ts similarity index 95% rename from package/src/lsp-conversion.ts rename to package/src/lsif/lsp-conversion.ts index abff694f3..630572984 100644 --- a/package/src/lsp-conversion.ts +++ b/package/src/lsif/lsp-conversion.ts @@ -4,21 +4,6 @@ import * as sourcegraph from 'sourcegraph' import * as LSP from 'vscode-languageserver-types' -export const convertPosition = ( - sourcegraph: typeof import('sourcegraph'), - position: LSP.Position -): sourcegraph.Position => - new sourcegraph.Position(position.line, position.character) - -export const convertRange = ( - sourcegraph: typeof import('sourcegraph'), - range: LSP.Range -): sourcegraph.Range => - new sourcegraph.Range( - convertPosition(sourcegraph, range.start), - convertPosition(sourcegraph, range.end) - ) - export function convertHover( sourcegraph: typeof import('sourcegraph'), hover: LSP.Hover @@ -56,7 +41,14 @@ export function convertHover( } } -export const convertLocation = ( +export function convertLocations( + sourcegraph: typeof import('sourcegraph'), + locations: LSP.Location[] +): sourcegraph.Location[] { + return locations.map(location => convertLocation(sourcegraph, location)) +} + +const convertLocation = ( sourcegraph: typeof import('sourcegraph'), location: LSP.Location ): sourcegraph.Location => ({ @@ -64,9 +56,17 @@ export const convertLocation = ( range: convertRange(sourcegraph, location.range), }) -export function convertLocations( +const convertRange = ( sourcegraph: typeof import('sourcegraph'), - locations: LSP.Location[] -): sourcegraph.Location[] { - return locations.map(location => convertLocation(sourcegraph, location)) -} + range: LSP.Range +): sourcegraph.Range => + new sourcegraph.Range( + convertPosition(sourcegraph, range.start), + convertPosition(sourcegraph, range.end) + ) + +const convertPosition = ( + sourcegraph: typeof import('sourcegraph'), + position: LSP.Position +): sourcegraph.Position => + new sourcegraph.Position(position.line, position.character) diff --git a/package/src/lsif/providers.ts b/package/src/lsif/providers.ts new file mode 100644 index 000000000..b3505317d --- /dev/null +++ b/package/src/lsif/providers.ts @@ -0,0 +1,18 @@ +import * as sourcegraph from 'sourcegraph' + +export interface LSIFProviders { + hover: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise + + definition: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise + + references: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise +} diff --git a/package/src/lsif/util.ts b/package/src/lsif/util.ts new file mode 100644 index 000000000..4f7a585cd --- /dev/null +++ b/package/src/lsif/util.ts @@ -0,0 +1,16 @@ +import * as sourcegraph from 'sourcegraph' + +export function repositoryFromDoc(doc: sourcegraph.TextDocument): string { + const url = new URL(doc.uri) + return url.hostname + url.pathname +} + +export function commitFromDoc(doc: sourcegraph.TextDocument): string { + const url = new URL(doc.uri) + return url.search.slice(1) +} + +export function pathFromDoc(doc: sourcegraph.TextDocument): string { + const url = new URL(doc.uri) + return url.hash.slice(1) +} diff --git a/package/src/lsp/providers.ts b/package/src/lsp/providers.ts new file mode 100644 index 000000000..94a2b0cb9 --- /dev/null +++ b/package/src/lsp/providers.ts @@ -0,0 +1,19 @@ +import * as sourcegraph from 'sourcegraph' + +export interface LSPProviders { + definition: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => AsyncGenerator + + references: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position, + context: sourcegraph.ReferenceContext + ) => AsyncGenerator + + hover: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => AsyncGenerator +} diff --git a/package/src/api.ts b/package/src/search/api.ts similarity index 90% rename from package/src/api.ts rename to package/src/search/api.ts index 7db4484be..eb04de506 100644 --- a/package/src/api.ts +++ b/package/src/search/api.ts @@ -1,6 +1,6 @@ import { Location } from 'sourcegraph' -import { Settings } from './handler' -import { memoizeAsync } from './memoizeAsync' +import { Settings } from './settings' +import { queryGraphQL } from '../graphql' /** * Result represents a search result returned from the Sourcegraph API. @@ -197,23 +197,3 @@ export function parseUri( path: decodeURIComponent(uri.hash.slice(1)), // strip the leading # } } - -// TODO(sqs): this will never release the memory of the cached responses; use an LRU cache or similar. -export const queryGraphQL = memoizeAsync( - async ({ - query, - vars, - sourcegraph, - }: { - query: string - vars: { [name: string]: any } - sourcegraph: typeof import('sourcegraph') - }): Promise => { - return sourcegraph.commands.executeCommand( - 'queryGraphQL', - query, - vars - ) - }, - arg => JSON.stringify({ query: arg.query, vars: arg.vars }) -) diff --git a/package/src/search/comments.ts b/package/src/search/comments.ts new file mode 100644 index 000000000..b8bd8dfe5 --- /dev/null +++ b/package/src/search/comments.ts @@ -0,0 +1,33 @@ +export type CommentStyle = { + /** + * Specifies where documentation is placed relative to the definition. + * Defaults to `'above the definition'`. In Python, documentation is placed + * `'below the definition'`. + */ + docPlacement?: 'above the definition' | 'below the definition' + + /** + * Captures the content of a line comment. Also prevents jump-to-definition + * (except when the token appears to refer to code). Python example: + * `/#\s?(.*)/` + */ + lineRegex?: RegExp + block?: BlockCommentStyle +} + +export interface BlockCommentStyle { + /** + * Matches the start of a block comment. C++ example: `/\/\*\*?/` + */ + startRegex: RegExp + /** + * Matches the noise at the beginning of each line in a block comment after + * the start, end, and leading indentation have been stripped. C++ example: + * `/(\s\*\s?)?/` + */ + lineNoiseRegex?: RegExp + /** + * Matches the end of a block comment. C++ example: `/\*\//` + */ + endRegex: RegExp +} diff --git a/package/src/handler.test.ts b/package/src/search/handler.test.ts similarity index 99% rename from package/src/handler.test.ts rename to package/src/search/handler.test.ts index 1f8786f7a..f80587097 100644 --- a/package/src/handler.test.ts +++ b/package/src/search/handler.test.ts @@ -7,7 +7,7 @@ import { findSearchToken, } from './handler' import { TextDocument } from 'sourcegraph' -import { pythonStyle, cStyle } from '../../languages' +import { pythonStyle, cStyle } from '../../../languages' import { createStubTextDocument } from '@sourcegraph/extension-api-stubs' describe('search requests', () => { diff --git a/package/src/handler.ts b/package/src/search/handler.ts similarity index 88% rename from package/src/handler.ts rename to package/src/search/handler.ts index a2831759b..ed3c3ce63 100644 --- a/package/src/handler.ts +++ b/package/src/search/handler.ts @@ -1,13 +1,296 @@ -import { concat, Subscription, from } from 'rxjs' import { API, Result, parseUri } from './api' import { takeWhile, dropWhile, sortBy, flatten, omit } from 'lodash' -import { - DocumentSelector, - Location, - Position, - TextDocument, - Hover, -} from 'sourcegraph' +import { Location, Position, TextDocument, Hover } from 'sourcegraph' +import { CommentStyle, BlockCommentStyle } from './comments' + +export interface HandlerArgs { + /** + * Used to label markdown code blocks. + */ + languageID: string + /** + * The part of the filename after the `.` (e.g. `cpp` in `main.cpp`). + */ + fileExts: string[] + /** + * Regex that matches lines between a definition and the docstring that + * should be ignored. Java example: `/^\s*@/` for annotations. + */ + docstringIgnore?: RegExp + commentStyle?: CommentStyle + /** + * Regex that matches characters in an identifier. + */ + identCharPattern?: RegExp + sourcegraph: typeof import('sourcegraph') + /** + * Callback that filters the given symbol search results (e.g. to drop + * results from non-imported files). + */ + filterDefinitions?: FilterDefinitions +} + +export type FilterDefinitions = (args: { + repo: string + rev: string + filePath: string + fileContent: string + pos: Position + results: Result[] +}) => Result[] + +export class Handler { + /** + * api holds a reference to a Sourcegraph API client. + */ + public sourcegraph: typeof import('sourcegraph') + public api: API + public languageID: string = '' + public fileExts: string[] = [] + public commentStyle: CommentStyle | undefined + public identCharPattern: RegExp | undefined + public docstringIgnore: RegExp | undefined + public debugAnnotatedURIs: string[] + public filterDefinitions: FilterDefinitions + + /** + * Constructs a new Handler that provides code intelligence on files with the given + * file extensions. + */ + constructor({ + languageID, + fileExts = [], + commentStyle, + identCharPattern, + docstringIgnore, + sourcegraph, + filterDefinitions: filterDefinitions = ({ results }) => results, + }: HandlerArgs) { + this.sourcegraph = sourcegraph + this.api = new API(sourcegraph) + this.languageID = languageID + this.fileExts = fileExts + this.commentStyle = commentStyle + this.identCharPattern = identCharPattern + this.docstringIgnore = docstringIgnore + this.debugAnnotatedURIs = [] + this.filterDefinitions = filterDefinitions + } + + /** + * Returns whether or not a line is a comment. + */ + isComment(line: string): boolean { + return Boolean( + this.commentStyle && + this.commentStyle.lineRegex && + this.commentStyle.lineRegex.test(line) + ) + } + + async definition( + doc: TextDocument, + pos: Position + ): Promise { + const fileContent = await this.api.getFileContent( + new this.sourcegraph.Location(new URL(doc.uri)) + ) + if (!fileContent) { + return null + } + + const tokenResult = findSearchToken({ + text: fileContent, + position: pos, + lineRegex: this.commentStyle && this.commentStyle.lineRegex, + identCharPattern: this.identCharPattern, + }) + if (!tokenResult) { + return null + } + if (tokenResult.isComment) { + return null + } + const searchToken = tokenResult.searchToken + + for (const query of definitionQueries({ + searchToken, + doc, + fileExts: this.fileExts, + isSourcegraphDotCom: + this.sourcegraph.internal.sourcegraphURL.href === + 'https://sourcegraph.com/', + })) { + const symbolResults = this.filterDefinitions({ + ...repoRevFilePath(doc.uri), + pos, + fileContent, + results: (await this.api.search(query)).filter( + result => + !result.fileLocal || + result.file === + new URL(doc.uri).hash.replace(/^#/, '') || + // https://github.com/universal-ctags/ctags/issues/1844 + (doc.languageId === 'java' && + result.symbolKind === 'ENUMMEMBER') + ), + }).map(result => + resultToLocation({ result, sourcegraph: this.sourcegraph }) + ) + + if (symbolResults.length > 0) { + return sortByProximity({ + currentLocation: doc.uri, + locations: symbolResults, + }) + } + } + + return [] + } + + async references(doc: TextDocument, pos: Position): Promise { + if (doc.text === undefined) { + return [] + } + const tokenResult = findSearchToken({ + text: doc.text, + position: pos, + lineRegex: this.commentStyle && this.commentStyle.lineRegex, + identCharPattern: this.identCharPattern, + }) + if (!tokenResult) { + return [] + } + const searchToken = tokenResult.searchToken + + return sortByProximity({ + currentLocation: doc.uri, + locations: flatten( + await Promise.all( + referencesQueries({ + searchToken, + doc, + fileExts: this.fileExts, + isSourcegraphDotCom: + this.sourcegraph.internal.sourcegraphURL.href === + 'https://sourcegraph.com/', + }).map(query => this.api.search(query)) + ) + ).map(result => + resultToLocation({ result, sourcegraph: this.sourcegraph }) + ), + }) + } + + /** + * Return the first definition location's line. + */ + async hover(doc: TextDocument, pos: Position): Promise { + if (this.sourcegraph.configuration.get().get('codeintel.debug')) { + this.debugAnnotate(doc) + } + + const definitions = await this.definition(doc, pos) + if (!definitions || definitions.length === 0) { + return null + } + + const def = definitions[0] + if (!def.range) { + return null + } + + const content = await this.api.getFileContent(def) + if (!content) { + return null + } + const lines = content.split('\n') + + // Get the definition's line. + let line = lines[def.range.start.line] + if (!line) { + return null + } + // Clean up the line. + line = line.trim() + line = line.replace(/[:;=,{(<]+$/, '') + // Render the line as syntax-highlighted Markdown. + if (line.includes('```')) { + // Don't render the line if it would "break out" of the Markdown code block we will wrap it in. + return null + } + const codeLineMarkdown = '```' + this.languageID + '\n' + line + '\n```' + + const docstring = findDocstring({ + definitionLine: def.range.start.line, + fileText: content, + commentStyle: this.commentStyle, + docstringIgnore: this.docstringIgnore, + }) + + return { + contents: { + kind: this.sourcegraph.MarkupKind.Markdown, + value: [ + codeLineMarkdown, + docstring && + wrapIndentationInCodeBlocks({ + languageID: this.languageID, + docstring, + }), + ] + .filter(tooltip => tooltip) + .join('\n\n---\n\n'), + }, + } + } + + /** + * Highlights lines that contain symbol definitions in red. + */ + async debugAnnotate(doc: TextDocument): Promise { + if (this.debugAnnotatedURIs.includes(doc.uri)) { + return + } + this.debugAnnotatedURIs.push(doc.uri) + setTimeout(async () => { + const editor = this.sourcegraph.app.activeWindow + ? this.sourcegraph.app.activeWindow.visibleViewComponents[0] + : undefined + if (!editor) { + console.log('NO EDITOR') + } else { + const { repo, rev, path } = parseUri(new URL(doc.uri)) + + // ^ matches everything (can't leave out a query) + const r = await this.api.search( + `repo:^${repo}$@${rev} count:1000 file:${path} type:symbol ^` + ) + editor.setDecorations( + this.sourcegraph.app.createDecorationType(), + r.map(v => ({ + range: new this.sourcegraph.Range( + v.start.line, + 0, + v.end.line, + 0 + ), // -1 because lines are 0 indexed + border: 'solid', + borderWidth: '0 0 0 10px', + borderColor: 'red', + backgroundColor: 'hsla(0,100%,50%, 0.05)', + after: { + contentText: ` ${JSON.stringify( + omit(v, 'repo', 'rev', 'start', 'end', 'file') + )}`, + }, + })) + ) + } + }, 500) + } +} /** * The default regex for characters allowed in an identifier. It works well for @@ -16,27 +299,101 @@ import { */ const DEFAULT_IDENT_CHAR_PATTERN = /[A-Za-z0-9_]/ -/** - * Selects documents that the extension works on. - */ -export function documentSelector(fileExts: string[]): DocumentSelector { - return fileExts.map(ext => ({ pattern: `*.${ext}` })) +export function findSearchToken({ + text, + position, + lineRegex, + identCharPattern, +}: { + text: string + position: { line: number; character: number } + lineRegex?: RegExp + identCharPattern?: RegExp +}): { searchToken: string; isComment: boolean } | undefined { + identCharPattern = identCharPattern || DEFAULT_IDENT_CHAR_PATTERN + const lines = text.split('\n') + const line = lines[position.line] + let end = line.length + for (let c = position.character; c < line.length; c++) { + if (!identCharPattern.test(line[c])) { + end = c + break + } + } + let start = 0 + for (let c = position.character; c >= 0; c--) { + if (!identCharPattern.test(line[c])) { + start = c + 1 + break + } + } + if (start >= end) { + return undefined + } + const searchToken = line.substring(start, end) + if (!lineRegex) { + return { searchToken, isComment: false } + } + const match = line.match(lineRegex) + return { + searchToken, + isComment: + ((match && match.index! <= start) || false) && + !new RegExp(`('|"|\`)${searchToken}('|"|\`)`).test(line) && + !new RegExp(`${searchToken}\\(`).test(line) && + !new RegExp(`\\.${searchToken}`).test(line), + } +} + +export function definitionQueries({ + searchToken, + doc, + fileExts, + isSourcegraphDotCom, +}: { + searchToken: string + doc: TextDocument + fileExts: string[] + isSourcegraphDotCom: boolean +}): string[] { + const queryIn = (scope: Scope): string => + makeQuery({ + searchToken: `^${searchToken}$`, + searchType: 'symbol', + currentFileUri: doc.uri, + scope, + fileExts, + }) + return [ + queryIn('current repository'), + ...(isSourcegraphDotCom ? [] : [queryIn('all repositories')]), + ] } -/** - * fileExtTerm returns the search term to use to filter to specific file extensions - */ -function fileExtTerm(sourceFile: string, fileExts: string[]): string { - const i = sourceFile.lastIndexOf('.') - if (i === -1) { - return '' - } - const ext = sourceFile.substring(i + 1).toLowerCase() - const match = fileExts.includes(ext) - if (match) { - return `file:\\.(${fileExts.join('|')})$` - } - return '' +export function referencesQueries({ + searchToken, + doc, + fileExts, + isSourcegraphDotCom, +}: { + searchToken: string + doc: TextDocument + fileExts: string[] + isSourcegraphDotCom: boolean +}): string[] { + const from = (scope: Scope): string => + makeQuery({ + searchToken: `\\b${searchToken}\\b`, + searchType: 'file', + currentFileUri: doc.uri, + scope, + fileExts, + }) + + return [ + from('current repository'), + ...(isSourcegraphDotCom ? [] : [from('other repositories')]), + ] } type Scope = @@ -103,166 +460,19 @@ function makeQuery({ } /** - * resultToLocation maps a search result to a LSP Location instance. + * fileExtTerm returns the search term to use to filter to specific file extensions */ -function resultToLocation({ - result, - sourcegraph, -}: { - result: Result - sourcegraph: typeof import('sourcegraph') -}): Location { - const rev = result.rev ? result.rev : 'HEAD' - return { - uri: new sourcegraph.URI(`git://${result.repo}?${rev}#${result.file}`), - range: new sourcegraph.Range( - result.start.line, - result.start.character, - result.end.line, - result.end.character - ), - } -} - -export function findSearchToken({ - text, - position, - lineRegex, - identCharPattern, -}: { - text: string - position: { line: number; character: number } - lineRegex?: RegExp - identCharPattern?: RegExp -}): { searchToken: string; isComment: boolean } | undefined { - identCharPattern = identCharPattern || DEFAULT_IDENT_CHAR_PATTERN - const lines = text.split('\n') - const line = lines[position.line] - let end = line.length - for (let c = position.character; c < line.length; c++) { - if (!identCharPattern.test(line[c])) { - end = c - break - } - } - let start = 0 - for (let c = position.character; c >= 0; c--) { - if (!identCharPattern.test(line[c])) { - start = c + 1 - break - } - } - if (start >= end) { - return undefined - } - const searchToken = line.substring(start, end) - if (!lineRegex) { - return { searchToken, isComment: false } - } - const match = line.match(lineRegex) - return { - searchToken, - isComment: - ((match && match.index! <= start) || false) && - !new RegExp(`('|"|\`)${searchToken}('|"|\`)`).test(line) && - !new RegExp(`${searchToken}\\(`).test(line) && - !new RegExp(`\\.${searchToken}`).test(line), - } -} - -function takeWhileInclusive(array: T[], predicate: (t: T) => boolean): T[] { - const index = array.findIndex(value => !predicate(value)) - return index === -1 ? array : array.slice(0, index + 1) -} - -export function wrapIndentationInCodeBlocks({ - languageID, - docstring, -}: { - languageID: string - docstring: string -}): string { - if ( - /```/.test(docstring) || - /<\//.test(docstring) || - /^(1\.|- |\* )/m.test(docstring) - ) { - // It's already formatted, or it has numbered or bulleted lists that - // would get messed up by this function - return docstring - } - - type LineKind = 'prose' | 'code' - function kindOf(line: string): LineKind | undefined { - return ( - (/^( |>).*[^\s]/.test(line) && 'code') || - (/^[^\s]/.test(line) && 'prose') || - undefined - ) - } - - const unknownLines = docstring - .split('\n') - .map(line => ({ line, kind: kindOf(line) })) - - function propagateProse(lines: typeof unknownLines): void { - lines.reduce((s, line) => { - if (line.kind === undefined && s === 'prose') { - line.kind = 'prose' - } - return line.kind - }, 'prose' as LineKind | undefined) - } - - propagateProse(unknownLines) - propagateProse(unknownLines.slice().reverse()) - const knownLines: { line: string; kind: LineKind }[] = unknownLines.map( - line => ({ - line: line.line, - kind: line.kind === undefined ? 'code' : line.kind, - }) - ) - - let resultLines: string[] = [] - for (let i = 0; i < knownLines.length; i++) { - const currentLine = knownLines[i] - const nextLine = knownLines[i + 1] - resultLines.push(currentLine.line) - if (nextLine !== undefined) { - if (currentLine.kind === 'prose' && nextLine.kind === 'code') { - resultLines.push('```' + languageID) - } else if ( - currentLine.kind === 'code' && - nextLine.kind === 'prose' - ) { - resultLines.push('```') - } - } else if (currentLine.kind === 'code') { - resultLines.push('```') - } +function fileExtTerm(sourceFile: string, fileExts: string[]): string { + const i = sourceFile.lastIndexOf('.') + if (i === -1) { + return '' } - return resultLines.join('\n') -} - -function jaccard(a: T[], b: T[]): number { - const bSet = new Set(b) - const intersection = new Set(a.filter(value => bSet.has(value))) - const union = new Set([...a, ...b]) - return intersection.size / union.size -} - -function sortByProximity({ - currentLocation, - locations, -}: { - currentLocation: string - locations: Location[] -}): Location[] { - const currentPath = new URL(currentLocation).hash.slice(1) - return sortBy(locations, (location: Location) => { - const path = new URL(location.uri.toString()).hash.slice(1) - return -jaccard(currentPath.split('/'), path.split('/')) - }) + const ext = sourceFile.substring(i + 1).toLowerCase() + const match = fileExts.includes(ext) + if (match) { + return `file:\\.(${fileExts.join('|')})$` + } + return '' } /** @@ -283,55 +493,26 @@ function repoRevFilePath( } } -export function definitionQueries({ - searchToken, - doc, - fileExts, - isSourcegraphDotCom, -}: { - searchToken: string - doc: TextDocument - fileExts: string[] - isSourcegraphDotCom: boolean -}): string[] { - const queryIn = (scope: Scope): string => - makeQuery({ - searchToken: `^${searchToken}$`, - searchType: 'symbol', - currentFileUri: doc.uri, - scope, - fileExts, - }) - return [ - queryIn('current repository'), - ...(isSourcegraphDotCom ? [] : [queryIn('all repositories')]), - ] -} - -export function referencesQueries({ - searchToken, - doc, - fileExts, - isSourcegraphDotCom, +/** + * resultToLocation maps a search result to a LSP Location instance. + */ +function resultToLocation({ + result, + sourcegraph, }: { - searchToken: string - doc: TextDocument - fileExts: string[] - isSourcegraphDotCom: boolean -}): string[] { - const from = (scope: Scope): string => - makeQuery({ - searchToken: `\\b${searchToken}\\b`, - searchType: 'file', - currentFileUri: doc.uri, - scope, - fileExts, - }) - - return [ - from('current repository'), - ...(isSourcegraphDotCom ? [] : [from('other repositories')]), - ] + result: Result + sourcegraph: typeof import('sourcegraph') +}): Location { + const rev = result.rev ? result.rev : 'HEAD' + return { + uri: new sourcegraph.URI(`git://${result.repo}?${rev}#${result.file}`), + range: new sourcegraph.Range( + result.start.line, + result.start.character, + result.end.line, + result.end.character + ), + } } export function findDocstring({ @@ -430,411 +611,126 @@ export function findDocstring({ const unmungeLines: (lines: string[]) => string[] = commentStyle.docPlacement === 'below the definition' ? lines => lines - : lines => lines.reverse() - const block: BlockCommentStyle | undefined = - commentStyle.block && - (commentStyle.docPlacement === 'below the definition' - ? commentStyle.block - : { - ...commentStyle.block, - startRegex: commentStyle.block.endRegex, - endRegex: commentStyle.block.startRegex, - }) - - const allLines = fileText.split('\n') - - const docLines = - inlineComment(allLines[definitionLine]) || - (commentStyle.lineRegex && - findDocstringInLineComments({ - lineRegex: commentStyle.lineRegex, - lines: mungeLines(allLines), - })) || - (block && - findDocstringInBlockComment({ - block, - lines: mungeLines(allLines), - })) - - return docLines && unmungeLines(docLines).join('\n') -} - -export function registerFeedbackButton({ - languageID, - sourcegraph, - isPrecise, -}: { - languageID: string - isPrecise: boolean - sourcegraph: typeof import('sourcegraph') -}): Subscription { - if (sourcegraph.configuration.get().get('codeIntel.showFeedback')) { - return concat( - // Update the context once upon page load... - from(sourcegraph.workspace.textDocuments), - // ...and whenever a document is opened. - sourcegraph.workspace.onDidOpenTextDocument - ).subscribe(document => { - sourcegraph.internal.updateContext({ - showFeedback: true, - 'codeIntel.feedbackLink': feedbackLink({ - currentFile: document && document.uri, - language: languageID, - kind: isPrecise ? 'Precise' : 'Default', - }).href, - }) - }) - } - return Subscription.EMPTY -} - -function feedbackLink({ - currentFile, - language, - kind, -}: { - currentFile?: string - language: string - kind: 'Default' | 'Precise' -}): URL { - const url = new URL( - 'https://docs.google.com/forms/d/e/1FAIpQLSfmn4M3nVj6R5m8UuAor_4ft8IMhieND_Uu8AlerhGO7X9C9w/viewform?usp=pp_url' - ) - if (currentFile) { - url.searchParams.append('entry.1135698969', currentFile) - } - url.searchParams.append('entry.55312909', language) - url.searchParams.append('entry.1824476739', kind) - return url -} - -/** - * @see package.json contributes.configuration section for the configuration schema. - */ -export interface Settings { - ['basicCodeIntel.debug.traceSearch']?: boolean - ['fileLocal']?: boolean -} - -interface BlockCommentStyle { - /** - * Matches the start of a block comment. C++ example: `/\/\*\*?/` - */ - startRegex: RegExp - /** - * Matches the noise at the beginning of each line in a block comment after - * the start, end, and leading indentation have been stripped. C++ example: - * `/(\s\*\s?)?/` - */ - lineNoiseRegex?: RegExp - /** - * Matches the end of a block comment. C++ example: `/\*\//` - */ - endRegex: RegExp -} - -export type CommentStyle = { - /** - * Specifies where documentation is placed relative to the definition. - * Defaults to `'above the definition'`. In Python, documentation is placed - * `'below the definition'`. - */ - docPlacement?: 'above the definition' | 'below the definition' - - /** - * Captures the content of a line comment. Also prevents jump-to-definition - * (except when the token appears to refer to code). Python example: - * `/#\s?(.*)/` - */ - lineRegex?: RegExp - block?: BlockCommentStyle -} - -export type FilterDefinitions = (args: { - repo: string - rev: string - filePath: string - fileContent: string - pos: Position - results: Result[] -}) => Result[] - -export interface HandlerArgs { - /** - * Used to label markdown code blocks. - */ - languageID: string - /** - * The part of the filename after the `.` (e.g. `cpp` in `main.cpp`). - */ - fileExts: string[] - /** - * Regex that matches lines between a definition and the docstring that - * should be ignored. Java example: `/^\s*@/` for annotations. - */ - docstringIgnore?: RegExp - commentStyle?: CommentStyle - /** - * Regex that matches characters in an identifier. - */ - identCharPattern?: RegExp - sourcegraph: typeof import('sourcegraph') - /** - * Callback that filters the given symbol search results (e.g. to drop - * results from non-imported files). - */ - filterDefinitions?: FilterDefinitions -} - -export class Handler { - /** - * api holds a reference to a Sourcegraph API client. - */ - public sourcegraph: typeof import('sourcegraph') - public api: API - public languageID: string = '' - public fileExts: string[] = [] - public commentStyle: CommentStyle | undefined - public identCharPattern: RegExp | undefined - public docstringIgnore: RegExp | undefined - public debugAnnotatedURIs: string[] - public filterDefinitions: FilterDefinitions - - /** - * Constructs a new Handler that provides code intelligence on files with the given - * file extensions. - */ - constructor({ - languageID, - fileExts = [], - commentStyle, - identCharPattern, - docstringIgnore, - sourcegraph, - filterDefinitions: filterDefinitions = ({ results }) => results, - }: HandlerArgs) { - this.sourcegraph = sourcegraph - this.api = new API(sourcegraph) - this.languageID = languageID - this.fileExts = fileExts - this.commentStyle = commentStyle - this.identCharPattern = identCharPattern - this.docstringIgnore = docstringIgnore - this.debugAnnotatedURIs = [] - this.filterDefinitions = filterDefinitions - } - - /** - * Returns whether or not a line is a comment. - */ - isComment(line: string): boolean { - return Boolean( - this.commentStyle && - this.commentStyle.lineRegex && - this.commentStyle.lineRegex.test(line) - ) - } - - /** - * Return the first definition location's line. - */ - async hover(doc: TextDocument, pos: Position): Promise { - if (this.sourcegraph.configuration.get().get('codeintel.debug')) { - this.debugAnnotate(doc) - } - - const definitions = await this.definition(doc, pos) - if (!definitions || definitions.length === 0) { - return null - } - - const def = definitions[0] - if (!def.range) { - return null - } - - const content = await this.api.getFileContent(def) - if (!content) { - return null - } - const lines = content.split('\n') - - // Get the definition's line. - let line = lines[def.range.start.line] - if (!line) { - return null - } - // Clean up the line. - line = line.trim() - line = line.replace(/[:;=,{(<]+$/, '') - // Render the line as syntax-highlighted Markdown. - if (line.includes('```')) { - // Don't render the line if it would "break out" of the Markdown code block we will wrap it in. - return null - } - const codeLineMarkdown = '```' + this.languageID + '\n' + line + '\n```' + : lines => lines.reverse() + const block: BlockCommentStyle | undefined = + commentStyle.block && + (commentStyle.docPlacement === 'below the definition' + ? commentStyle.block + : { + ...commentStyle.block, + startRegex: commentStyle.block.endRegex, + endRegex: commentStyle.block.startRegex, + }) - const docstring = findDocstring({ - definitionLine: def.range.start.line, - fileText: content, - commentStyle: this.commentStyle, - docstringIgnore: this.docstringIgnore, - }) + const allLines = fileText.split('\n') - return { - contents: { - kind: this.sourcegraph.MarkupKind.Markdown, - value: [ - codeLineMarkdown, - docstring && - wrapIndentationInCodeBlocks({ - languageID: this.languageID, - docstring, - }), - ] - .filter(tooltip => tooltip) - .join('\n\n---\n\n'), - }, - } + const docLines = + inlineComment(allLines[definitionLine]) || + (commentStyle.lineRegex && + findDocstringInLineComments({ + lineRegex: commentStyle.lineRegex, + lines: mungeLines(allLines), + })) || + (block && + findDocstringInBlockComment({ + block, + lines: mungeLines(allLines), + })) + + return docLines && unmungeLines(docLines).join('\n') +} + +export function wrapIndentationInCodeBlocks({ + languageID, + docstring, +}: { + languageID: string + docstring: string +}): string { + if ( + /```/.test(docstring) || + /<\//.test(docstring) || + /^(1\.|- |\* )/m.test(docstring) + ) { + // It's already formatted, or it has numbered or bulleted lists that + // would get messed up by this function + return docstring } - async definition( - doc: TextDocument, - pos: Position - ): Promise { - const fileContent = await this.api.getFileContent( - new this.sourcegraph.Location(new URL(doc.uri)) + type LineKind = 'prose' | 'code' + function kindOf(line: string): LineKind | undefined { + return ( + (/^( |>).*[^\s]/.test(line) && 'code') || + (/^[^\s]/.test(line) && 'prose') || + undefined ) - if (!fileContent) { - return null - } - - const tokenResult = findSearchToken({ - text: fileContent, - position: pos, - lineRegex: this.commentStyle && this.commentStyle.lineRegex, - identCharPattern: this.identCharPattern, - }) - if (!tokenResult) { - return null - } - if (tokenResult.isComment) { - return null - } - const searchToken = tokenResult.searchToken + } - for (const query of definitionQueries({ - searchToken, - doc, - fileExts: this.fileExts, - isSourcegraphDotCom: - this.sourcegraph.internal.sourcegraphURL.href === - 'https://sourcegraph.com/', - })) { - const symbolResults = this.filterDefinitions({ - ...repoRevFilePath(doc.uri), - pos, - fileContent, - results: (await this.api.search(query)).filter( - result => - !result.fileLocal || - result.file === - new URL(doc.uri).hash.replace(/^#/, '') || - // https://github.com/universal-ctags/ctags/issues/1844 - (doc.languageId === 'java' && - result.symbolKind === 'ENUMMEMBER') - ), - }).map(result => - resultToLocation({ result, sourcegraph: this.sourcegraph }) - ) + const unknownLines = docstring + .split('\n') + .map(line => ({ line, kind: kindOf(line) })) - if (symbolResults.length > 0) { - return sortByProximity({ - currentLocation: doc.uri, - locations: symbolResults, - }) + function propagateProse(lines: typeof unknownLines): void { + lines.reduce((s, line) => { + if (line.kind === undefined && s === 'prose') { + line.kind = 'prose' } - } - - return [] + return line.kind + }, 'prose' as LineKind | undefined) } - async references(doc: TextDocument, pos: Position): Promise { - if (doc.text === undefined) { - return [] - } - const tokenResult = findSearchToken({ - text: doc.text, - position: pos, - lineRegex: this.commentStyle && this.commentStyle.lineRegex, - identCharPattern: this.identCharPattern, + propagateProse(unknownLines) + propagateProse(unknownLines.slice().reverse()) + const knownLines: { line: string; kind: LineKind }[] = unknownLines.map( + line => ({ + line: line.line, + kind: line.kind === undefined ? 'code' : line.kind, }) - if (!tokenResult) { - return [] - } - const searchToken = tokenResult.searchToken + ) - return sortByProximity({ - currentLocation: doc.uri, - locations: flatten( - await Promise.all( - referencesQueries({ - searchToken, - doc, - fileExts: this.fileExts, - isSourcegraphDotCom: - this.sourcegraph.internal.sourcegraphURL.href === - 'https://sourcegraph.com/', - }).map(query => this.api.search(query)) - ) - ).map(result => - resultToLocation({ result, sourcegraph: this.sourcegraph }) - ), - }) + let resultLines: string[] = [] + for (let i = 0; i < knownLines.length; i++) { + const currentLine = knownLines[i] + const nextLine = knownLines[i + 1] + resultLines.push(currentLine.line) + if (nextLine !== undefined) { + if (currentLine.kind === 'prose' && nextLine.kind === 'code') { + resultLines.push('```' + languageID) + } else if ( + currentLine.kind === 'code' && + nextLine.kind === 'prose' + ) { + resultLines.push('```') + } + } else if (currentLine.kind === 'code') { + resultLines.push('```') + } } + return resultLines.join('\n') +} - /** - * Highlights lines that contain symbol definitions in red. - */ - async debugAnnotate(doc: TextDocument): Promise { - if (this.debugAnnotatedURIs.includes(doc.uri)) { - return - } - this.debugAnnotatedURIs.push(doc.uri) - setTimeout(async () => { - const editor = this.sourcegraph.app.activeWindow - ? this.sourcegraph.app.activeWindow.visibleViewComponents[0] - : undefined - if (!editor) { - console.log('NO EDITOR') - } else { - const { repo, rev, path } = parseUri(new URL(doc.uri)) +function sortByProximity({ + currentLocation, + locations, +}: { + currentLocation: string + locations: Location[] +}): Location[] { + const currentPath = new URL(currentLocation).hash.slice(1) + return sortBy(locations, (location: Location) => { + const path = new URL(location.uri.toString()).hash.slice(1) + return -jaccard(currentPath.split('/'), path.split('/')) + }) +} - // ^ matches everything (can't leave out a query) - const r = await this.api.search( - `repo:^${repo}$@${rev} count:1000 file:${path} type:symbol ^` - ) - editor.setDecorations( - this.sourcegraph.app.createDecorationType(), - r.map(v => ({ - range: new this.sourcegraph.Range( - v.start.line, - 0, - v.end.line, - 0 - ), // -1 because lines are 0 indexed - border: 'solid', - borderWidth: '0 0 0 10px', - borderColor: 'red', - backgroundColor: 'hsla(0,100%,50%, 0.05)', - after: { - contentText: ` ${JSON.stringify( - omit(v, 'repo', 'rev', 'start', 'end', 'file') - )}`, - }, - })) - ) - } - }, 500) - } +function jaccard(a: T[], b: T[]): number { + const bSet = new Set(b) + const intersection = new Set(a.filter(value => bSet.has(value))) + const union = new Set([...a, ...b]) + return intersection.size / union.size +} + +function takeWhileInclusive(array: T[], predicate: (t: T) => boolean): T[] { + const index = array.findIndex(value => !predicate(value)) + return index === -1 ? array : array.slice(0, index + 1) } diff --git a/package/src/search/providers.ts b/package/src/search/providers.ts new file mode 100644 index 000000000..aaa96d550 --- /dev/null +++ b/package/src/search/providers.ts @@ -0,0 +1,18 @@ +import * as sourcegraph from 'sourcegraph' + +export interface SearchProviders { + definition: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise + + references: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise + + hover: ( + doc: sourcegraph.TextDocument, + pos: sourcegraph.Position + ) => Promise +} diff --git a/package/src/search/settings.ts b/package/src/search/settings.ts new file mode 100644 index 000000000..f819ee68f --- /dev/null +++ b/package/src/search/settings.ts @@ -0,0 +1,7 @@ +/** + * @see package.json contributes.configuration section for the configuration schema. + */ +export interface Settings { + ['basicCodeIntel.debug.traceSearch']?: boolean + ['fileLocal']?: boolean +} diff --git a/package/yarn.lock b/package/yarn.lock index 92227c0f1..f6f83d033 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -2188,6 +2188,11 @@ sourcegraph@^23.0.1, sourcegraph@^23.1.0: resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.1.0.tgz#3178979805239cdf777d7b2cb9aae99c2a3d1dcb" integrity sha512-pcHP/Ad1TGJWDu4vh8iDE1bi4LOvDgc2Q+amlByjSfeg2+vd4jldpaW4HuP/fMVGYvvzxOa4jrjlluWeXFqyoA== +sourcegraph@^23.1.0: + version "23.1.0" + resolved "https://registry.yarnpkg.com/sourcegraph/-/sourcegraph-23.1.0.tgz#3178979805239cdf777d7b2cb9aae99c2a3d1dcb" + integrity sha512-pcHP/Ad1TGJWDu4vh8iDE1bi4LOvDgc2Q+amlByjSfeg2+vd4jldpaW4HuP/fMVGYvvzxOa4jrjlluWeXFqyoA== + spawn-wrap@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/spawn-wrap/-/spawn-wrap-1.4.2.tgz#cff58e73a8224617b6561abdc32586ea0c82248c"