diff --git a/languages.ts b/languages.ts index 530ee1aa5..0b19ab9cf 100644 --- a/languages.ts +++ b/languages.ts @@ -1,4 +1,4 @@ -import { HandlerArgs, CommentStyle } from './package/lib/handler' +import { HandlerArgs, CommentStyle } from './package/src/index' const path = require('path-browserify') type Omit = Pick> diff --git a/package/src/abort.ts b/package/src/abort.ts new file mode 100644 index 000000000..d6e0426cd --- /dev/null +++ b/package/src/abort.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..477de03d4 --- /dev/null +++ b/package/src/activation.ts @@ -0,0 +1,258 @@ +import * as sourcegraph from 'sourcegraph' +import { HandlerArgs, Handler } from './search/handler' +import { initLSIF } from './lsif/activation' +import { impreciseBadge } from './badges' +import { shareReplay } from 'rxjs/operators' +import { Observable, Observer } from 'rxjs' +import { createAbortError } from './abort' +import { LSPProviders } from './lsp/providers' +import { LSIFProviders } from './lsif/providers' +import { SearchProviders } from './search/providers' + +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) + ) + ) +} + +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 + 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)) { + const badged = { ...searchResult, badge: impreciseBadge } + yield badged + return + } + + yield searchResult.map(v => ({ ...v, badge: impreciseBadge })) + } + + return { + provideDefinition: wrap(areProviderParamsEqual, provideDefinition), + } +} + +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 || [] + 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: wrap( + areProviderParamsContextEqual, + provideReferences + ), + } +} + +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 + 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: wrap(areProviderParamsEqual, provideHover), + } +} + +// +// +// + +const wrap =

( + compare: (a: P, b: P) => boolean, + fn: (...args: P) => AsyncGenerator +): ((...args: P) => Observable) => + memoizePrevious(compare, (...args) => + observableFromAsyncGenerator(() => fn(...args)).pipe(shareReplay(1)) + ) + +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]) + +const observableFromAsyncGenerator = ( + generator: () => AsyncGenerator +): Observable => + new Observable((observer: 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 + }) + } + } + }) + +/** Workaround for https://github.com/sourcegraph/sourcegraph/issues/1321 */ +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)) { + return previousResult + } + previousArgs = args + previousResult = fn(...args) + return previousResult + } +} 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 a15113ab7..5d1ed540c 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,17 +1,10 @@ -export { impreciseBadge } from './badges' -export { Handler, HandlerArgs } from './handler' +export { activateCodeIntel } from './activation' +export { HandlerArgs } from './search/handler' +export { CommentStyle, BlockCommentStyle } from './search/comments' +export { LSPProviders } from './lsp/providers' export { - initLSIF, - asyncFirst, - asyncWhen, - when, - wrapMaybe, - Maybe, - MaybeProviders, - noopMaybeProviders, - mkIsLSIFAvailable, - hover, - definition, - references, - Providers, -} from './lsif' + AbortError, + createAbortError, + isAbortError, + throwIfAbortError, +} from './abort' 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/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, 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" 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) } }