diff --git a/x-pack/legacy/plugins/code/public/actions/editor.ts b/x-pack/legacy/plugins/code/public/actions/editor.ts index 146c54fca0fa1d..0baeae10fe93c1 100644 --- a/x-pack/legacy/plugins/code/public/actions/editor.ts +++ b/x-pack/legacy/plugins/code/public/actions/editor.ts @@ -8,17 +8,17 @@ import { Range } from 'monaco-editor'; import { createAction } from 'redux-actions'; import { Hover, Position, TextDocumentPositionParams } from 'vscode-languageserver'; -export interface ReferenceResults { - repos: GroupedRepoReferences[]; +export interface PanelResults { + repos: GroupedRepoResults[]; title: string; } -export interface GroupedRepoReferences { +export interface GroupedRepoResults { repo: string; - files: GroupedFileReferences[]; + files: GroupedFileResults[]; } -export interface GroupedFileReferences { +export interface GroupedFileResults { uri: string; file: string; language: string; @@ -30,8 +30,12 @@ export interface GroupedFileReferences { } export const findReferences = createAction('FIND REFERENCES'); -export const findReferencesSuccess = createAction('FIND REFERENCES SUCCESS'); +export const findReferencesSuccess = createAction('FIND REFERENCES SUCCESS'); export const findReferencesFailed = createAction('FIND REFERENCES ERROR'); -export const closeReferences = createAction('CLOSE REFERENCES'); +export const closePanel = createAction('CLOSE PANEL'); export const hoverResult = createAction('HOVER RESULT'); export const revealPosition = createAction('REVEAL POSITION'); + +export const findDefinitions = createAction('FIND DEFINITIONS'); +export const findDefinitionsSuccess = createAction('FIND DEFINITIONS SUCCESS'); +export const findDefinitionsFailed = createAction('FIND DEFINITIONS ERROR'); diff --git a/x-pack/legacy/plugins/code/public/components/editor/editor.tsx b/x-pack/legacy/plugins/code/public/components/editor/editor.tsx index 9eb57e54338615..6ea79e4e2282fc 100644 --- a/x-pack/legacy/plugins/code/public/components/editor/editor.tsx +++ b/x-pack/legacy/plugins/code/public/components/editor/editor.tsx @@ -9,9 +9,9 @@ import { editor as editorInterfaces, IDisposable } from 'monaco-editor'; import React from 'react'; import { connect } from 'react-redux'; import { RouteComponentProps, withRouter } from 'react-router-dom'; -import { Hover, Position, TextDocumentPositionParams } from 'vscode-languageserver-protocol'; +import { Hover, Position } from 'vscode-languageserver-protocol'; import { GitBlame } from '../../../common/git_blame'; -import { closeReferences, FetchFileResponse, findReferences, hoverResult } from '../../actions'; +import { closePanel, FetchFileResponse, hoverResult } from '../../actions'; import { MainRouteParams } from '../../common/types'; import { BlameWidget } from '../../monaco/blame/blame_widget'; import { monaco } from '../../monaco/monaco'; @@ -24,8 +24,7 @@ import { ReferencesPanel } from './references_panel'; import { encodeRevisionString } from '../../../common/uri_util'; export interface EditorActions { - closeReferences(changeUrl: boolean): void; - findReferences(params: TextDocumentPositionParams): void; + closePanel(changeUrl: boolean): void; hoverResult(hover: Hover): void; } @@ -33,10 +32,10 @@ interface Props { hidden?: boolean; file?: FetchFileResponse; revealPosition?: Position; - isReferencesOpen: boolean; - isReferencesLoading: boolean; - references: any[]; - referencesTitle: string; + panelShowing: boolean; + isPanelLoading: boolean; + panelContents: any[]; + panelTitle: string; hover?: Hover; refUrl?: string; blames: GitBlame[]; @@ -237,12 +236,12 @@ export class EditorComponent extends React.Component { private renderReferences() { return ( - this.props.isReferencesOpen && ( + this.props.panelShowing && ( this.props.closeReferences(true)} - references={this.props.references} - isLoading={this.props.isReferencesLoading} - title={this.props.referencesTitle} + onClose={() => this.props.closePanel(true)} + references={this.props.panelContents} + isLoading={this.props.isPanelLoading} + title={this.props.panelTitle} refUrl={this.props.refUrl} /> ) @@ -252,10 +251,10 @@ export class EditorComponent extends React.Component { const mapStateToProps = (state: RootState) => ({ file: state.file.file, - isReferencesOpen: state.editor.showing, - isReferencesLoading: state.editor.loading, - references: state.editor.references, - referencesTitle: state.editor.referencesTitle, + panelShowing: state.editor.panelShowing, + isPanelLoading: state.editor.loading, + panelContents: state.editor.panelContents, + panelTitle: state.editor.panelTitle, hover: state.editor.hover, refUrl: refUrlSelector(state), revealPosition: state.editor.revealPosition, @@ -263,8 +262,7 @@ const mapStateToProps = (state: RootState) => ({ }); const mapDispatchToProps = { - closeReferences, - findReferences, + closePanel, hoverResult, }; diff --git a/x-pack/legacy/plugins/code/public/components/editor/references_panel.tsx b/x-pack/legacy/plugins/code/public/components/editor/references_panel.tsx index fa6635c543378e..219c5e837155c3 100644 --- a/x-pack/legacy/plugins/code/public/components/editor/references_panel.tsx +++ b/x-pack/legacy/plugins/code/public/components/editor/references_panel.tsx @@ -18,14 +18,14 @@ import { IPosition } from 'monaco-editor'; import queryString from 'querystring'; import React from 'react'; import { parseSchema } from '../../../common/uri_util'; -import { GroupedFileReferences, GroupedRepoReferences } from '../../actions'; +import { GroupedFileResults, GroupedRepoResults } from '../../actions'; import { history } from '../../utils/url'; import { CodeBlock } from '../codeblock/codeblock'; interface Props { isLoading: boolean; title: string; - references: GroupedRepoReferences[]; + references: GroupedRepoResults[]; refUrl?: string; onClose(): void; } @@ -85,12 +85,12 @@ export class ReferencesPanel extends React.Component { } private renderGroupByRepo() { - return this.props.references.map((ref: GroupedRepoReferences) => { + return this.props.references.map((ref: GroupedRepoResults) => { return this.renderReferenceRepo(ref); }); } - private renderReferenceRepo({ repo, files }: GroupedRepoReferences) { + private renderReferenceRepo({ repo, files }: GroupedRepoResults) { const [org, name] = repo.split('/').slice(1); const buttonContent = ( @@ -112,7 +112,7 @@ export class ReferencesPanel extends React.Component { ); } - private renderReference(file: GroupedFileReferences) { + private renderReference(file: GroupedFileResults) { const key = `${file.uri}`; const lineNumberFn = (l: number) => { return file.lineNumbers[l - 1]; diff --git a/x-pack/legacy/plugins/code/public/monaco/definition/definition_provider.ts b/x-pack/legacy/plugins/code/public/monaco/definition/definition_provider.ts index f03c914be3f315..7ef9dfc88b904e 100644 --- a/x-pack/legacy/plugins/code/public/monaco/definition/definition_provider.ts +++ b/x-pack/legacy/plugins/code/public/monaco/definition/definition_provider.ts @@ -4,12 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import url from 'url'; +import queryString from 'querystring'; import { DetailSymbolInformation } from '@elastic/lsp-extension'; import { npStart } from 'ui/new_platform'; import { Location } from 'vscode-languageserver-types'; import { monaco } from '../monaco'; import { LspRestClient, TextDocumentMethods } from '../../../common/lsp_client'; +import { parseSchema } from '../../../common/uri_util'; +import { history } from '../../utils/url'; export const definitionProvider: monaco.languages.DefinitionProvider = { async provideDefinition( @@ -41,6 +45,20 @@ export const definitionProvider: monaco.languages.DefinitionProvider = { return []; } + function openDefinitionsPanel() { + if (model && position) { + const { uri } = parseSchema(model.uri.toString()); + const refUrl = `git:/${uri}!L${position.lineNumber - 1}:${position.column - 1}`; + const queries = url.parse(history.location.search, true).query; + const query = queryString.stringify({ + ...queries, + tab: 'definitions', + refUrl, + }); + history.push(`${uri}?${query}`); + } + } + const result = await lspMethods.edefinition.send({ position: { line: position.lineNumber - 1, @@ -52,20 +70,19 @@ export const definitionProvider: monaco.languages.DefinitionProvider = { }); if (result) { - const locations = result.filter(l => l.location !== undefined).map(l => l.location!); - if (locations.length > 0) { - return locations.map(handleLocation); + if (result.length > 1) { + openDefinitionsPanel(); + return []; } else { - let qnameResults: monaco.languages.Location[] = []; - for (const l of result) { - if (l.qname) { - qnameResults = qnameResults.concat(await handleQname(l.qname)); - } + const l = result[0]; + const location = l.location; + if (location) { + return [handleLocation(location)]; + } else if (l.qname) { + return await handleQname(l.qname); } - return qnameResults; } } - return []; }, }; diff --git a/x-pack/legacy/plugins/code/public/reducers/editor.ts b/x-pack/legacy/plugins/code/public/reducers/editor.ts index d1104f5144673f..ed7dc780b4407e 100644 --- a/x-pack/legacy/plugins/code/public/reducers/editor.ts +++ b/x-pack/legacy/plugins/code/public/reducers/editor.ts @@ -7,73 +7,100 @@ import produce from 'immer'; import { handleActions, Action } from 'redux-actions'; import { Hover, TextDocumentPositionParams } from 'vscode-languageserver'; import { - closeReferences, + closePanel, findReferences, findReferencesFailed, findReferencesSuccess, - GroupedRepoReferences, + GroupedRepoResults, hoverResult, revealPosition, - ReferenceResults, + PanelResults, + findDefinitions, + findDefinitionsSuccess, + findDefinitionsFailed, } from '../actions'; export interface EditorState { loading: boolean; - showing: boolean; - references: GroupedRepoReferences[]; + panelShowing: 'references' | 'definitions' | undefined; + panelContents: GroupedRepoResults[]; hover?: Hover; currentHover?: Hover; refPayload?: TextDocumentPositionParams; revealPosition?: Position; - referencesTitle: string; + panelTitle: string; } const initialState: EditorState = { loading: false, - showing: false, - references: [], - referencesTitle: '', + panelShowing: undefined, + panelContents: [], + panelTitle: '', }; -type EditorPayload = ReferenceResults & Hover & TextDocumentPositionParams & Position & string; +type EditorPayload = PanelResults & Hover & TextDocumentPositionParams & Position & string; + +function panelInit(draft: EditorState, action: Action) { + draft.refPayload = action.payload!; + draft.loading = true; + draft.panelContents = initialState.panelContents; + draft.panelTitle = initialState.panelTitle; +} + +function panelSuccess(draft: EditorState, action: Action) { + const { title, repos } = action.payload!; + draft.panelContents = repos; + draft.panelTitle = title; + draft.loading = false; +} +function panelFailed(draft: EditorState) { + draft.panelContents = []; + draft.loading = false; + delete draft.refPayload; +} export const editor = handleActions( { [String(findReferences)]: (state, action: Action) => - produce(state, draft => { - draft.refPayload = action.payload; - draft.showing = true; - draft.loading = true; - draft.references = initialState.references; - draft.hover = state.currentHover; - draft.referencesTitle = initialState.referencesTitle; + produce(state, (draft: EditorState) => { + panelInit(draft, action); + draft.panelShowing = 'references'; }), - [String(findReferencesSuccess)]: (state, action: any) => - produce(state, draft => { - const { title, repos } = action.payload; - draft.references = repos; - draft.referencesTitle = title; - draft.loading = false; + [String(findReferencesSuccess)]: (state, action: Action) => + produce(state, (draft: EditorState) => { + panelSuccess(draft, action); }), [String(findReferencesFailed)]: state => - produce(state, draft => { - draft.references = []; - draft.loading = false; - draft.refPayload = undefined; + produce(state, (draft: EditorState) => { + panelFailed(draft); + }), + [String(findDefinitions)]: (state, action: Action) => + produce(state, (draft: EditorState) => { + panelInit(draft, action); + draft.panelShowing = 'definitions'; + }), + [String(findDefinitionsSuccess)]: (state, action: Action) => + produce(state, (draft: EditorState) => { + panelSuccess(draft, action); + }), + [String(findDefinitionsFailed)]: state => + produce(state, (draft: EditorState) => { + panelFailed(draft); }), - [String(closeReferences)]: state => - produce(state, draft => { - draft.showing = false; + [String(closePanel)]: state => + produce(state, (draft: EditorState) => { + draft.panelShowing = undefined; draft.loading = false; - draft.refPayload = undefined; - draft.references = []; + delete draft.refPayload; + draft.panelContents = []; + draft.panelTitle = ''; }), [String(hoverResult)]: (state, action: Action) => - produce(state, draft => { - draft.currentHover = action.payload; + produce(state, (draft: EditorState) => { + draft.currentHover = action.payload!; }), [String(revealPosition)]: (state, action: Action) => - produce(state, draft => { - draft.revealPosition = action.payload; + produce(state, (draft: EditorState) => { + draft.revealPosition = action.payload!; }), }, initialState diff --git a/x-pack/legacy/plugins/code/public/sagas/editor.ts b/x-pack/legacy/plugins/code/public/sagas/editor.ts index 6a68e8b76cfeab..e00710d241260b 100644 --- a/x-pack/legacy/plugins/code/public/sagas/editor.ts +++ b/x-pack/legacy/plugins/code/public/sagas/editor.ts @@ -13,7 +13,6 @@ import { call, put, select, takeEvery, takeLatest } from 'redux-saga/effects'; import { parseGoto, parseLspUrl, toCanonicalUrl } from '../../common/uri_util'; import { FileTree } from '../../model'; import { - closeReferences, fetchFile, FetchFileResponse, fetchRepoTree, @@ -26,6 +25,10 @@ import { revealPosition, fetchRepos, turnOnDefaultRepoScope, + findDefinitions, + findDefinitionsSuccess, + findDefinitionsFailed, + closePanel, } from '../actions'; import { loadRepo, loadRepoFailed, loadRepoSuccess } from '../actions/status'; import { PathTypes } from '../common/types'; @@ -55,17 +58,35 @@ function* handleReferences(action: Action) { } } +function* handleDefinitions(action: Action) { + try { + const params: TextDocumentPositionParams = action.payload!; + const { title, files } = yield call(requestFindDefinitions, params); + const repos = Object.keys(files).map((repo: string) => ({ repo, files: files[repo] })); + yield put(findDefinitionsSuccess({ title, repos })); + } catch (error) { + yield put(findDefinitionsFailed(error)); + } +} + function requestFindReferences(params: TextDocumentPositionParams) { return npStart.core.http.post(`/api/code/lsp/findReferences`, { body: JSON.stringify(params), }); } +function requestFindDefinitions(params: TextDocumentPositionParams) { + return npStart.core.http.post(`/api/code/lsp/findDefinitions`, { + body: JSON.stringify(params), + }); +} + export function* watchLspMethods() { yield takeLatest(String(findReferences), handleReferences); + yield takeLatest(String(findDefinitions), handleDefinitions); } -function* handleCloseReferences(action: Action) { +function* handleClosePanel(action: Action) { if (action.payload) { const search = yield select(urlQueryStringSelector); const { pathname } = history.location; @@ -86,7 +107,7 @@ function* handleCloseReferences(action: Action) { } export function* watchCloseReference() { - yield takeLatest(String(closeReferences), handleCloseReferences); + yield takeLatest(String(closePanel), handleClosePanel); } function* handleReference(url: string) { @@ -107,6 +128,24 @@ function* handleReference(url: string) { } } +function* openDefinitions(url: string) { + const refUrl = yield select(refUrlSelector); + if (refUrl === url) { + return; + } + const { uri, position, schema, repoUri, file, revision } = parseLspUrl(url); + if (uri && position) { + yield put( + findDefinitions({ + textDocument: { + uri: toCanonicalUrl({ revision, schema, repoUri, file }), + }, + position, + }) + ); + } +} + function* handleFile(repoUri: string, file: string, revision: string) { const response: FetchFileResponse = yield select(fileSelector); const payload = response && response.payload; @@ -171,8 +210,10 @@ function* handleMainRouteChange(action: Action) { const { tab, refUrl } = queryParams; if (tab === 'references' && refUrl) { yield call(handleReference, decodeURIComponent(refUrl as string)); + } else if (tab === 'definitions' && refUrl) { + yield call(openDefinitions, decodeURIComponent(refUrl as string)); } else { - yield put(closeReferences(false)); + yield put(closePanel(false)); } yield call(handleFile, repoUri, file, revision); } else { diff --git a/x-pack/legacy/plugins/code/server/routes/lsp.ts b/x-pack/legacy/plugins/code/server/routes/lsp.ts index edeefe8f9808d8..2d37620207dec5 100644 --- a/x-pack/legacy/plugins/code/server/routes/lsp.ts +++ b/x-pack/legacy/plugins/code/server/routes/lsp.ts @@ -5,11 +5,10 @@ */ import Boom from 'boom'; -import { groupBy, last } from 'lodash'; import { ResponseError } from 'vscode-jsonrpc'; import { ResponseMessage } from 'vscode-jsonrpc/lib/messages'; -import { Location } from 'vscode-languageserver-types'; +import { SymbolLocator } from '@elastic/lsp-extension'; import { LanguageServerStartFailed, ServerNotInitialized, @@ -17,22 +16,16 @@ import { } from '../../common/lsp_error_codes'; import { parseLspUrl } from '../../common/uri_util'; import { Logger } from '../log'; -import { CTAGS, GO } from '../lsp/language_servers'; import { SymbolSearchClient } from '../search'; import { CodeServerRouter } from '../security'; import { ServerOptions } from '../server_options'; -import { - expandRanges, - extractSourceContent, - LineMapping, - mergeRanges, -} from '../utils/composite_source_merger'; -import { detectLanguage } from '../utils/detect_language'; + import { EsClientWithRequest } from '../utils/esclient_with_request'; import { promiseTimeout } from '../utils/timeout'; import { RequestFacade, ResponseToolkitFacade } from '../..'; import { CodeServices } from '../distributed/code_services'; import { GitServiceDefinition, LspServiceDefinition } from '../distributed/apis'; +import { findTitleFromHover, groupFiles } from '../utils/lsp_utils'; const LANG_SERVER_ERROR = 'language server error'; @@ -44,6 +37,7 @@ export function lspRoute( const log = new Logger(server.server); const lspService = codeServices.serviceFor(LspServiceDefinition); const gitService = codeServices.serviceFor(GitServiceDefinition); + server.route({ path: '/api/code/lsp/textDocument/{method}', async handler(req: RequestFacade, h: ResponseToolkitFacade) { @@ -95,6 +89,52 @@ export function lspRoute( method: 'POST', }); + server.route({ + path: '/api/code/lsp/findDefinitions', + method: 'POST', + async handler(req: RequestFacade, h: ResponseToolkitFacade) { + // @ts-ignore + const { textDocument, position } = req.payload; + const { uri } = textDocument; + const endpoint = await codeServices.locate(req, parseLspUrl(uri).repoUri); + const response: ResponseMessage = await promiseTimeout( + serverOptions.lsp.requestTimeoutMs, + lspService.sendRequest(endpoint, { + method: `textDocument/edefinition`, + params: { textDocument: { uri }, position }, + timeoutForInitializeMs: 1000, + }) + ); + const hover = await lspService.sendRequest(endpoint, { + method: 'textDocument/hover', + params: { + textDocument: { uri }, + position, + }, + }); + const title: string = await findTitleFromHover(hover, uri, position); + const symbolSearchClient = new SymbolSearchClient(new EsClientWithRequest(req), log); + + const locators = response.result as SymbolLocator[]; + const locations = []; + for (const locator of locators) { + if (locator.location) { + locations.push(locator.location); + } else if (locator.qname) { + const searchResults = await symbolSearchClient.findByQname(req.params.qname); + for (const symbol of searchResults.symbols) { + locations.push(symbol.symbolInformation.location); + } + } + } + const files = await groupFiles(locations, async loc => { + const ep = await codeServices.locate(req, loc.uri); + return await gitService.blob(ep, loc); + }); + return { title, files, uri, position }; + }, + }); + server.route({ path: '/api/code/lsp/findReferences', method: 'POST', @@ -119,77 +159,12 @@ export function lspRoute( position, }, }); - let title: string; - if (hover.result && hover.result.contents) { - if (Array.isArray(hover.result.contents)) { - const content = hover.result.contents[0]; - title = hover.result.contents[0].value; - const lang = await detectLanguage(uri.replace('file://', '')); - // TODO(henrywong) Find a gernal approach to construct the reference title. - if (content.kind) { - // The format of the hover result is 'MarkupContent', extract appropriate pieces as the references title. - if (GO.languages.includes(lang)) { - title = title.substring(title.indexOf('```go\n') + 5, title.lastIndexOf('\n```')); - if (title.includes('{\n')) { - title = title.substring(0, title.indexOf('{\n')); - } - } - } else if (CTAGS.languages.includes(lang)) { - // There are language servers may provide hover results with markdown syntax, like ctags-langserver, - // extract the plain text. - if (title.substring(0, 2) === '**' && title.includes('**\n')) { - title = title.substring(title.indexOf('**\n') + 3); - } - } - } else { - title = hover.result.contents as 'string'; - } - } else { - title = last(uri.toString().split('/')) + `(${position.line}, ${position.character})`; - } - const files = []; - const groupedLocations = groupBy(response.result as Location[], 'uri'); - for (const url of Object.keys(groupedLocations)) { - const { repoUri, revision, file } = parseLspUrl(url)!; - const ep = await codeServices.locate(req, repoUri); - const locations: Location[] = groupedLocations[url]; - const lines = locations.map(l => ({ - startLine: l.range.start.line, - endLine: l.range.end.line, - })); - const ranges = expandRanges(lines, 1); - const mergedRanges = mergeRanges(ranges); - const blob = await gitService.blob(ep, { uri: repoUri, path: file!, revision }); - if (blob.content) { - const source = blob.content.split('\n'); - const language = blob.lang; - const lineMappings = new LineMapping(); - const code = extractSourceContent(mergedRanges, source, lineMappings).join('\n'); - const lineNumbers = lineMappings.toStringArray(); - const highlights = locations.map(l => { - const { start, end } = l.range; - const startLineNumber = lineMappings.lineNumber(start.line); - const endLineNumber = lineMappings.lineNumber(end.line); - return { - startLineNumber, - startColumn: start.character + 1, - endLineNumber, - endColumn: end.character + 1, - }; - }); - files.push({ - repo: repoUri, - file, - language, - uri: url, - revision, - code, - lineNumbers, - highlights, - }); - } - } - return { title, files: groupBy(files, 'repo'), uri, position }; + const title: string = await findTitleFromHover(hover, uri, position); + const files = await groupFiles(response.result, async loc => { + const ep = await codeServices.locate(req, loc.uri); + return await gitService.blob(ep, loc); + }); + return { title, files, uri, position }; } catch (error) { log.error(error); if (error instanceof ResponseError) { @@ -217,8 +192,7 @@ export function symbolByQnameRoute(router: CodeServerRouter, log: Logger) { async handler(req: RequestFacade) { try { const symbolSearchClient = new SymbolSearchClient(new EsClientWithRequest(req), log); - const res = await symbolSearchClient.findByQname(req.params.qname); - return res; + return await symbolSearchClient.findByQname(req.params.qname); } catch (error) { return Boom.internal(`Search Exception`); } diff --git a/x-pack/legacy/plugins/code/server/utils/lsp_utils.test.ts b/x-pack/legacy/plugins/code/server/utils/lsp_utils.test.ts new file mode 100644 index 00000000000000..ee25000643eddf --- /dev/null +++ b/x-pack/legacy/plugins/code/server/utils/lsp_utils.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Location } from 'vscode-languageserver-types'; +import { groupFiles } from './lsp_utils'; + +test('group files', async () => { + const range = { + start: { character: 0, line: 1 }, + end: { character: 0, line: 2 }, + }; + const url1 = 'https://github.com/elastic/code/blob/master/1'; + const url2 = 'https://github.com/elastic/code/blob/master/2'; + const url3 = 'https://github.com/elastic/code2/blob/master/1'; + const locs = [ + { + uri: url1, + range, + }, + { + uri: url1, + range: { + start: { character: 0, line: 2 }, + end: { character: 0, line: 3 }, + }, + }, + { + uri: url2, + range, + }, + { + uri: url3, + range, + }, + ] as Location[]; + const fakeSource = `1 + 2 + 3 + 4 + 5 + `; + + const fakeLoader = () => Promise.resolve({ content: fakeSource, lang: 'test' }); + // @ts-ignore + const result = await groupFiles(locs, fakeLoader); + const files = result['github.com/elastic/code']; + expect(files.length).toBe(2); + const file = files.find((f: any) => f.uri === url1); + expect(file).not.toBeUndefined(); + expect(file.lineNumbers).toStrictEqual(['1', '2', '3', '4', '5', '..']); + expect(result['github.com/elastic/code2'].length).toBe(1); +}); diff --git a/x-pack/legacy/plugins/code/server/utils/lsp_utils.ts b/x-pack/legacy/plugins/code/server/utils/lsp_utils.ts new file mode 100644 index 00000000000000..0bd6ea0ca05fc6 --- /dev/null +++ b/x-pack/legacy/plugins/code/server/utils/lsp_utils.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { groupBy, last } from 'lodash'; +import { Location, Position } from 'vscode-languageserver-types'; +import { ResponseMessage } from 'vscode-jsonrpc/lib/messages'; +import { CTAGS, GO } from '../lsp/language_servers'; +import { + expandRanges, + extractSourceContent, + LineMapping, + mergeRanges, +} from './composite_source_merger'; +import { detectLanguage } from './detect_language'; +import { parseLspUrl } from '../../common/uri_util'; +import { GitServiceDefinition } from '../distributed/apis'; + +type SourceLoader = ( + loc: typeof GitServiceDefinition.blob.request +) => Promise; + +export interface File { + repo: string; + file: string; + language: string; + uri: string; + revision: string; + code: string; + lineNumbers: number[]; + highlights: any[]; +} + +export interface GroupedFiles { + [repo: string]: File[]; +} + +export async function groupFiles( + list: Location[], + sourceLoader: SourceLoader +): Promise { + const files = []; + const groupedLocations = groupBy(list, 'uri'); + for (const url of Object.keys(groupedLocations)) { + const { repoUri, revision, file } = parseLspUrl(url)!; + const locations: Location[] = groupedLocations[url]; + const lines = locations.map(l => ({ + startLine: l.range.start.line, + endLine: l.range.end.line, + })); + const ranges = expandRanges(lines, 1); + const mergedRanges = mergeRanges(ranges); + try { + const blob = await sourceLoader({ uri: repoUri, path: file!, revision }); + if (blob.content) { + const source = blob.content.split('\n'); + const language = blob.lang; + const lineMappings = new LineMapping(); + const code = extractSourceContent(mergedRanges, source, lineMappings).join('\n'); + const lineNumbers = lineMappings.toStringArray(); + const highlights = locations.map(l => { + const { start, end } = l.range; + const startLineNumber = lineMappings.lineNumber(start.line); + const endLineNumber = lineMappings.lineNumber(end.line); + return { + startLineNumber, + startColumn: start.character + 1, + endLineNumber, + endColumn: end.character + 1, + }; + }); + files.push({ + repo: repoUri, + file, + language, + uri: url, + revision, + code, + lineNumbers, + highlights, + }); + } + } catch (e) { + // can't load this file, ignore this result + } + } + return (groupBy(files, 'repo') as unknown) as GroupedFiles; +} + +export async function findTitleFromHover(hover: ResponseMessage, uri: string, position: Position) { + let title: string; + if (hover.result && hover.result.contents) { + if (Array.isArray(hover.result.contents)) { + const content = hover.result.contents[0]; + title = hover.result.contents[0].value; + const lang = await detectLanguage(uri.replace('file://', '')); + // TODO(henrywong) Find a gernal approach to construct the reference title. + if (content.kind) { + // The format of the hover result is 'MarkupContent', extract appropriate pieces as the references title. + if (GO.languages.includes(lang)) { + title = title.substring(title.indexOf('```go\n') + 5, title.lastIndexOf('\n```')); + if (title.includes('{\n')) { + title = title.substring(0, title.indexOf('{\n')); + } + } + } else if (CTAGS.languages.includes(lang)) { + // There are language servers may provide hover results with markdown syntax, like ctags-langserver, + // extract the plain text. + if (title.substring(0, 2) === '**' && title.includes('**\n')) { + title = title.substring(title.indexOf('**\n') + 3); + } + } + } else { + title = hover.result.contents as 'string'; + } + } else { + title = last(uri.split('/')) + `(${position.line}, ${position.character})`; + } + return title; +}