[Console Monaco Migration] Implement autocomplete support (#181347)

## Summary
Partially addresses https://github.com/elastic/kibana/issues/180208 

This PR adds autocomplete support for method, url and url parameters in
the Monaco editor. The url suggestions include the indices name.

#### Screen recording 


b31f87b2-df11-48ad-88a7-cc3f9fbb5ffc






### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces—unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes—Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Yulia Čech 2024-05-03 12:46:21 +02:00 committed by GitHub
parent de75523495
commit 9fb859ea67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 595 additions and 124 deletions

View file

@ -413,7 +413,7 @@ export const createParser = () => {
addRequestMethod(parsedMethod);
strictWhite();
const parsedUrl = url();
addRequestUrl(parsedUrl );
addRequestUrl(parsedUrl);
strictWhite(); // advance to one new line
newLine();
strictWhite();

View file

@ -13,7 +13,7 @@ export interface ErrorAnnotation {
export interface ParsedRequest {
startOffset: number;
endOffset: number;
endOffset?: number;
method: string;
url: string;
data?: Array<Record<string, unknown>>;

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import React, { CSSProperties, useCallback, useMemo, useRef, useState } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui';
import { css } from '@emotion/react';
import { CodeEditor } from '@kbn/code-editor';
@ -22,6 +22,7 @@ import { useSetInitialValue } from './use_set_initial_value';
import { MonacoEditorActionsProvider } from './monaco_editor_actions_provider';
import { useSetupAutocompletePolling } from './use_setup_autocomplete_polling';
import { useSetupAutosave } from './use_setup_autosave';
import { getSuggestionProvider } from './monaco_editor_suggestion_provider';
import { useResizeCheckerUtils } from './use_resize_checker_utils';
export interface EditorProps {
@ -75,6 +76,9 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
await actionsProvider.current?.sendRequests(toasts, dispatch, trackUiMetric, http);
}, [dispatch, http, toasts, trackUiMetric]);
const suggestionProvider = useMemo(() => {
return getSuggestionProvider(actionsProvider);
}, []);
const [value, setValue] = useState(initialTextValue);
useSetInitialValue({ initialTextValue, setValue, toasts });
@ -137,6 +141,7 @@ export const MonacoEditor = ({ initialTextValue }: EditorProps) => {
wordWrap: settings.wrapMode === true ? 'on' : 'off',
theme: CONSOLE_THEME_ID,
}}
suggestionProvider={suggestionProvider}
/>
</div>
);

View file

@ -147,4 +147,67 @@ describe('Editor actions provider', () => {
expect(link).toBe(docsLink);
});
});
describe('provideCompletionItems', () => {
const mockModel = {
getWordUntilPosition: () => {
return {
startColumn: 1,
};
},
getPositionAt: () => {
return {
lineNumber: 1,
};
},
getLineCount: () => 1,
getLineContent: () => 'GET ',
getValueInRange: () => 'GET ',
} as unknown as jest.Mocked<monaco.editor.ITextModel>;
const mockPosition = { lineNumber: 1, column: 1 } as jest.Mocked<monaco.Position>;
const mockContext = {} as jest.Mocked<monaco.languages.CompletionContext>;
const token = {} as jest.Mocked<monaco.CancellationToken>;
it('returns completion items for method if no requests', async () => {
mockGetParsedRequests.mockResolvedValue([]);
const completionItems = await editorActionsProvider.provideCompletionItems(
mockModel,
mockPosition,
mockContext,
token
);
expect(completionItems?.suggestions.length).toBe(6);
const methods = completionItems?.suggestions.map((suggestion) => suggestion.label);
expect((methods as string[]).sort()).toEqual([
'DELETE',
'GET',
'HEAD',
'PATCH',
'POST',
'PUT',
]);
});
it('returns completion items for url path if method already typed in', async () => {
// mock a parsed request that only has a method
mockGetParsedRequests.mockResolvedValue([
{
startOffset: 0,
method: 'GET',
},
]);
mockPopulateContext.mockImplementation((...args) => {
const context = args[0][1];
context.autoCompleteSet = [{ name: '_search' }, { name: '_cat' }];
});
const completionItems = await editorActionsProvider.provideCompletionItems(
mockModel,
mockPosition,
mockContext,
token
);
expect(completionItems?.suggestions.length).toBe(2);
const endpoints = completionItems?.suggestions.map((suggestion) => suggestion.label);
expect((endpoints as string[]).sort()).toEqual(['_cat', '_search']);
});
});
});

View file

@ -17,21 +17,24 @@ import {
import { IToasts } from '@kbn/core-notifications-browser';
import { i18n } from '@kbn/i18n';
import type { HttpSetup } from '@kbn/core-http-browser';
import { AutoCompleteContext } from '../../../../lib/autocomplete/types';
import { populateContext } from '../../../../lib/autocomplete/engine';
import { DEFAULT_VARIABLES } from '../../../../../common/constants';
import { getStorage, StorageKeys } from '../../../../services';
import { getTopLevelUrlCompleteComponents } from '../../../../lib/kb';
import { sendRequest } from '../../../hooks/use_send_current_request/send_request';
import { MetricsTracker } from '../../../../types';
import { Actions } from '../../../stores/request';
import {
stringifyRequest,
replaceRequestVariables,
containsUrlParams,
getCurlRequest,
getDocumentationLink,
getLineTokens,
getMethodCompletionItems,
getRequestEndLineNumber,
getRequestStartLineNumber,
getUrlParamsCompletionItems,
getUrlPathCompletionItems,
replaceRequestVariables,
stringifyRequest,
trackSentRequests,
tokenizeRequestUrl,
getDocumentationLinkFromAutocompleteContext,
} from './utils';
const selectedRequestsClass = 'console__monaco_editor__selectedRequests';
@ -42,6 +45,17 @@ export interface EditorRequest {
data: string[];
}
interface AdjustedParsedRequest extends ParsedRequest {
startLineNumber: number;
endLineNumber: number;
}
enum AutocompleteType {
PATH = 'path',
URL_PARAMS = 'url_params',
METHOD = 'method',
BODY = 'body',
}
export class MonacoEditorActionsProvider {
private parsedRequestsProvider: ConsoleParsedRequestsProvider;
private highlightedLines: monaco.editor.IEditorDecorationsCollection;
@ -91,12 +105,21 @@ export class MonacoEditorActionsProvider {
private async highlightRequests(): Promise<void> {
// get the requests in the selected range
const { range: selectedRange, parsedRequests } = await this.getSelectedParsedRequestsAndRange();
const parsedRequests = await this.getSelectedParsedRequests();
// if any requests are selected, highlight the lines and update the position of actions buttons
if (parsedRequests.length > 0) {
const selectedRequestStartLine = selectedRange.startLineNumber;
// display the actions buttons on the 1st line of the 1st selected request
this.updateEditorActions(selectedRequestStartLine);
const selectionStartLineNumber = parsedRequests[0].startLineNumber;
this.updateEditorActions(selectionStartLineNumber);
// highlight the lines from the 1st line of the first selected request
// to the last line of the last selected request
const selectionEndLineNumber = parsedRequests[parsedRequests.length - 1].endLineNumber;
const selectedRange = new monaco.Range(
selectionStartLineNumber,
1,
selectionEndLineNumber,
this.editor.getModel()?.getLineMaxColumn(selectionEndLineNumber) ?? 1
);
this.highlightedLines.set([
{
range: selectedRange,
@ -113,67 +136,51 @@ export class MonacoEditorActionsProvider {
}
}
private async getSelectedParsedRequestsAndRange(): Promise<{
parsedRequests: ParsedRequest[];
range: monaco.IRange;
}> {
private async getSelectedParsedRequests(): Promise<AdjustedParsedRequest[]> {
const model = this.editor.getModel();
const selection = this.editor.getSelection();
if (!model || !selection) {
return Promise.resolve({
parsedRequests: [],
range: selection ?? new monaco.Range(1, 1, 1, 1),
});
return Promise.resolve([]);
}
const { startLineNumber, endLineNumber } = selection;
const parsedRequests = await this.parsedRequestsProvider.getRequests();
const selectedRequests = [];
let selectionStartLine = startLineNumber;
let selectionEndLine = endLineNumber;
for (const parsedRequest of parsedRequests) {
const { startOffset: requestStart, endOffset: requestEnd } = parsedRequest;
const { lineNumber: requestStartLine } = model.getPositionAt(requestStart);
let { lineNumber: requestEndLine } = model.getPositionAt(requestEnd);
const requestEndLineContent = model.getLineContent(requestEndLine);
return this.getRequestsBetweenLines(model, startLineNumber, endLineNumber);
}
// sometimes the parser includes a trailing empty line into the request
if (requestEndLineContent.trim().length < 1) {
requestEndLine = requestEndLine - 1;
}
if (requestStartLine > endLineNumber) {
private async getRequestsBetweenLines(
model: monaco.editor.ITextModel,
startLineNumber: number,
endLineNumber: number
): Promise<AdjustedParsedRequest[]> {
const parsedRequests = await this.parsedRequestsProvider.getRequests();
const selectedRequests: AdjustedParsedRequest[] = [];
for (const [index, parsedRequest] of parsedRequests.entries()) {
const requestStartLineNumber = getRequestStartLineNumber(parsedRequest, model);
const requestEndLineNumber = getRequestEndLineNumber(
parsedRequest,
model,
index,
parsedRequests
);
if (requestStartLineNumber > endLineNumber) {
// request is past the selection, no need to check further requests
break;
}
if (requestEndLine < startLineNumber) {
if (requestEndLineNumber < startLineNumber) {
// request is before the selection, do nothing
} else {
// request is selected
selectedRequests.push(parsedRequest);
// expand the start of the selection to the request start
if (selectionStartLine > requestStartLine) {
selectionStartLine = requestStartLine;
}
// expand the end of the selection to the request end
if (selectionEndLine < requestEndLine) {
selectionEndLine = requestEndLine;
}
selectedRequests.push({
...parsedRequest,
startLineNumber: requestStartLineNumber,
endLineNumber: requestEndLineNumber,
});
}
}
return {
parsedRequests: selectedRequests,
// the expanded selected range goes from the 1st char of the start line of the 1st request
// to the last char of the last line of the last request
range: new monaco.Range(
selectionStartLine,
1,
selectionEndLine,
model.getLineMaxColumn(selectionEndLine)
),
};
return selectedRequests;
}
private async getRequests() {
const { parsedRequests } = await this.getSelectedParsedRequestsAndRange();
const parsedRequests = await this.getSelectedParsedRequests();
const stringifiedRequests = parsedRequests.map((parsedRequest) =>
stringifyRequest(parsedRequest)
);
@ -248,21 +255,89 @@ export class MonacoEditorActionsProvider {
}
const request = requests[0];
// get autocomplete components for the request method
const components = getTopLevelUrlCompleteComponents(request.method);
// get the url parts from the request url
const urlTokens = tokenizeRequestUrl(request.url);
return getDocumentationLink(request, docLinkVersion);
}
// this object will contain the information later, it needs to be initialized with some data
// similar to the old ace editor context
const context: AutoCompleteContext = {
method: request.method,
urlTokenPath: urlTokens,
private async getAutocompleteType(
model: monaco.editor.ITextModel,
{ lineNumber, column }: monaco.Position
): Promise<AutocompleteType | null> {
// get the current request on this line
const currentRequests = await this.getRequestsBetweenLines(model, lineNumber, lineNumber);
const currentRequest = currentRequests.at(0);
// if there is no request, suggest method
if (!currentRequest) {
return AutocompleteType.METHOD;
}
// if on the 1st line of the request, suggest method, url or url_params depending on the content
const { startLineNumber: requestStartLineNumber } = currentRequest;
if (lineNumber === requestStartLineNumber) {
// get the content on the line up until the position
const lineContent = model.getValueInRange({
startLineNumber: lineNumber,
startColumn: 1,
endLineNumber: lineNumber,
endColumn: column,
});
const lineTokens = getLineTokens(lineContent);
// if there is 1 or fewer tokens, suggest method
if (lineTokens.length <= 1) {
return AutocompleteType.METHOD;
}
// if there are 2 tokens, look at the 2nd one and suggest path or url_params
if (lineTokens.length === 2) {
const token = lineTokens[1];
if (containsUrlParams(token)) {
return AutocompleteType.URL_PARAMS;
}
return AutocompleteType.PATH;
}
// if more than 2 tokens, no suggestions
return null;
}
// if not on the 1st line of the request, suggest request body
return AutocompleteType.BODY;
}
private async getSuggestions(model: monaco.editor.ITextModel, position: monaco.Position) {
// determine autocomplete type
const autocompleteType = await this.getAutocompleteType(model, position);
if (!autocompleteType) {
return {
suggestions: [],
};
}
if (autocompleteType === AutocompleteType.METHOD) {
return {
// suggest all methods, the editor will filter according to the input automatically
suggestions: getMethodCompletionItems(model, position),
};
}
if (autocompleteType === AutocompleteType.PATH) {
return {
suggestions: getUrlPathCompletionItems(model, position),
};
}
if (autocompleteType === AutocompleteType.URL_PARAMS) {
return {
suggestions: getUrlParamsCompletionItems(model, position),
};
}
return {
suggestions: [],
};
// this function uses the autocomplete info and the url tokens to find the correct endpoint
populateContext(urlTokens, context, undefined, true, components);
return getDocumentationLinkFromAutocompleteContext(context, docLinkVersion);
}
public provideCompletionItems(
model: monaco.editor.ITextModel,
position: monaco.Position,
context: monaco.languages.CompletionContext,
token: monaco.CancellationToken
): monaco.languages.ProviderResult<monaco.languages.CompletionList> {
return this.getSuggestions(model, position);
}
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { monaco } from '@kbn/monaco';
import { MutableRefObject } from 'react';
import { MonacoEditorActionsProvider } from './monaco_editor_actions_provider';
export const getSuggestionProvider = (
actionsProvider: MutableRefObject<MonacoEditorActionsProvider | null>
): monaco.languages.CompletionItemProvider => {
return {
// force suggestions when these characters are used
triggerCharacters: ['/', '.', '_', ',', '?', '=', '&'],
provideCompletionItems: (...args) => {
if (actionsProvider.current) {
return actionsProvider.current?.provideCompletionItems(...args);
}
return {
suggestions: [],
};
},
};
};

View file

@ -8,16 +8,28 @@
import {
getCurlRequest,
getDocumentationLinkFromAutocompleteContext,
getDocumentationLink,
removeTrailingWhitespaces,
replaceRequestVariables,
stringifyRequest,
tokenizeRequestUrl,
trackSentRequests,
} from './utils';
import { MetricsTracker } from '../../../../types';
import { AutoCompleteContext } from '../../../../lib/autocomplete/types';
/*
* Mock the function "populateContext" that accesses the autocomplete definitions
*/
const mockPopulateContext = jest.fn();
jest.mock('../../../../lib/autocomplete/engine', () => {
return {
populateContext: (...args: any) => {
mockPopulateContext(args);
},
};
});
describe('monaco editor utils', () => {
const dataObjects = [
{
@ -183,28 +195,21 @@ describe('monaco editor utils', () => {
});
});
describe('tokenizeRequestUrl', () => {
it('returns the url if it has only 1 part', () => {
const url = '_search';
const urlTokens = tokenizeRequestUrl(url);
expect(urlTokens).toEqual(['_search', '__url_path_end__']);
});
it('returns correct url tokens', () => {
const url = '_search/test';
const urlTokens = tokenizeRequestUrl(url);
expect(urlTokens).toEqual(['_search', 'test', '__url_path_end__']);
});
});
describe('getDocumentationLinkFromAutocompleteContext', () => {
describe('getDocumentationLink', () => {
const mockRequest = { method: 'GET', url: '_search', data: [] };
const version = '8.13';
const expectedLink = 'http://elastic.co/8.13/_search';
it('correctly replaces {branch} with the version', () => {
const endpoint = {
documentation: 'http://elastic.co/{branch}/_search',
} as AutoCompleteContext['endpoint'];
const link = getDocumentationLinkFromAutocompleteContext({ endpoint }, version);
// mock the populateContext function that finds the correct autocomplete endpoint object and puts it into the context object
mockPopulateContext.mockImplementation((...args) => {
const context = args[0][1];
context.endpoint = endpoint;
});
const link = getDocumentationLink(mockRequest, version);
expect(link).toBe(expectedLink);
});
@ -212,7 +217,12 @@ describe('monaco editor utils', () => {
const endpoint = {
documentation: 'http://elastic.co/master/_search',
} as AutoCompleteContext['endpoint'];
const link = getDocumentationLinkFromAutocompleteContext({ endpoint }, version);
// mock the populateContext function that finds the correct autocomplete endpoint object and puts it into the context object
mockPopulateContext.mockImplementation((...args) => {
const context = args[0][1];
context.endpoint = endpoint;
});
const link = getDocumentationLink(mockRequest, version);
expect(link).toBe(expectedLink);
});
@ -220,7 +230,12 @@ describe('monaco editor utils', () => {
const endpoint = {
documentation: 'http://elastic.co/current/_search',
} as AutoCompleteContext['endpoint'];
const link = getDocumentationLinkFromAutocompleteContext({ endpoint }, version);
// mock the populateContext function that finds the correct autocomplete endpoint object and puts it into the context object
mockPopulateContext.mockImplementation((...args) => {
const context = args[0][1];
context.endpoint = endpoint;
});
const link = getDocumentationLink(mockRequest, version);
expect(link).toBe(expectedLink);
});
});

View file

@ -6,20 +6,54 @@
* Side Public License, v 1.
*/
import { ParsedRequest } from '@kbn/monaco';
import { monaco, ParsedRequest } from '@kbn/monaco';
import { i18n } from '@kbn/i18n';
import { getTopLevelUrlCompleteComponents } from '../../../../lib/kb';
import { AutoCompleteContext } from '../../../../lib/autocomplete/types';
import { constructUrl } from '../../../../lib/es';
import type { DevToolsVariable } from '../../../components';
import { EditorRequest } from './monaco_editor_actions_provider';
import { MetricsTracker } from '../../../../types';
import { populateContext } from '../../../../lib/autocomplete/engine';
const whitespacesRegex = /\s/;
/*
* Helper constants
*/
const whitespacesRegex = /\s+/;
const slashRegex = /\//;
const ampersandRegex = /&/;
const equalsSignRegex = /=/;
const variableTemplateRegex = /\${(\w+)}/g;
const endOfUrlToken = '__url_path_end__';
/*
* Helper interfaces
*/
export interface ParsedLineTokens {
method: string;
urlPathTokens: string[];
urlParamsTokens: string[][];
}
/*
* i18n for autocomplete labels
*/
const methodDetailLabel = i18n.translate('console.autocompleteSuggestions.methodLabel', {
defaultMessage: 'method',
});
const endpointDetailLabel = i18n.translate('console.autocompleteSuggestions.endpointLabel', {
defaultMessage: 'endpoint',
});
const paramDetailLabel = i18n.translate('console.autocompleteSuggestions.paramLabel', {
defaultMessage: 'param',
});
/*
* This functions removes any trailing inline comments, for example
* "_search // comment" -> "_search"
* Ideally the parser would do that, but currently they are included in url.
*/
export const removeTrailingWhitespaces = (url: string): string => {
/*
* This helper removes any trailing inline comments, for example
* "_search // comment" -> "_search"
* Ideally the parser removes those comments initially
*/
return url.trim().split(whitespacesRegex)[0];
};
@ -30,7 +64,6 @@ export const stringifyRequest = (parsedRequest: ParsedRequest): EditorRequest =>
return { url, method, data: data ?? [] };
};
const variableTemplateRegex = /\${(\w+)}/g;
const replaceVariables = (text: string, variables: DevToolsVariable[]): string => {
if (variableTemplateRegex.test(text)) {
text = text.replaceAll(variableTemplateRegex, (match, key) => {
@ -41,6 +74,7 @@ const replaceVariables = (text: string, variables: DevToolsVariable[]): string =
}
return text;
};
export const replaceRequestVariables = (
{ method, url, data }: EditorRequest,
variables: DevToolsVariable[]
@ -77,26 +111,20 @@ export const trackSentRequests = (
};
/*
* This function takes a request url as a string and returns it parts,
* for example '_search/test' => ['_search', 'test']
* This function initializes the autocomplete context for the request
* and returns a documentation link from the endpoint object
* with the branch in the url replaced by the current version "docLinkVersion"
*/
const urlPartsSeparatorRegex = /\//;
const endOfUrlToken = '__url_path_end__';
export const tokenizeRequestUrl = (url: string): string[] => {
const parts = url.split(urlPartsSeparatorRegex);
// this special token is used to mark the end of the url
parts.push(endOfUrlToken);
return parts;
};
/*
* This function returns a documentation link from the autocomplete endpoint object
* and replaces the branch in the url with the current version "docLinkVersion"
*/
export const getDocumentationLinkFromAutocompleteContext = (
{ endpoint }: AutoCompleteContext,
docLinkVersion: string
): string | null => {
export const getDocumentationLink = (request: EditorRequest, docLinkVersion: string) => {
// get the url parts from the request url
const { urlPathTokens } = parseUrlTokens(request.url);
// remove the last token, if it's empty
if (!urlPathTokens[urlPathTokens.length - 1]) {
urlPathTokens.pop();
}
// add the end of url token
urlPathTokens.push(endOfUrlToken);
const { endpoint } = populateContextForMethodAndUrl(request.method, urlPathTokens);
if (endpoint && endpoint.documentation && endpoint.documentation.indexOf('http') !== -1) {
return endpoint.documentation
.replace('/master/', `/${docLinkVersion}/`)
@ -105,3 +133,263 @@ export const getDocumentationLinkFromAutocompleteContext = (
}
return null;
};
/*
* This function converts the start offset value of the parsed request to a line number in the model
*/
export const getRequestStartLineNumber = (
parsedRequest: ParsedRequest,
model: monaco.editor.ITextModel
): number => {
return model.getPositionAt(parsedRequest.startOffset).lineNumber;
};
/*
* This function converts the end offset value of the parsed request to a line number in the model.
* If there is no end offset (the parser was not able to parse this request completely),
* then the last non-empty line is returned or the line before the next request.
*/
export const getRequestEndLineNumber = (
parsedRequest: ParsedRequest,
model: monaco.editor.ITextModel,
index: number,
parsedRequests: ParsedRequest[]
): number => {
let endLineNumber: number;
if (parsedRequest.endOffset) {
// if the parser set an end offset for this request, then find the line number for it
endLineNumber = model.getPositionAt(parsedRequest.endOffset).lineNumber;
} else {
// if no end offset, try to find the line before the next request starts
const nextRequest = parsedRequests.at(index + 1);
if (nextRequest) {
const nextRequestStartLine = model.getPositionAt(nextRequest.startOffset).lineNumber;
endLineNumber = nextRequestStartLine - 1;
} else {
// if there is no next request, take the last line of the model
endLineNumber = model.getLineCount();
}
}
// if the end line is empty, go up to find the first non-empty line
let lineContent = model.getLineContent(endLineNumber).trim();
while (!lineContent) {
endLineNumber = endLineNumber - 1;
lineContent = model.getLineContent(endLineNumber).trim();
}
return endLineNumber;
};
/*
* This function returns an array of completion items for the request method
*/
const autocompleteMethods = ['GET', 'PUT', 'POST', 'DELETE', 'HEAD', 'PATCH'];
export const getMethodCompletionItems = (
model: monaco.editor.ITextModel,
position: monaco.Position
): monaco.languages.CompletionItem[] => {
// get the word before suggestions to replace when selecting a suggestion from the list
const wordUntilPosition = model.getWordUntilPosition(position);
return autocompleteMethods.map((method) => ({
label: method,
insertText: method,
detail: methodDetailLabel,
// only used to configure the icon
kind: monaco.languages.CompletionItemKind.Constant,
range: {
// replace the whole word with the suggestion
startColumn: wordUntilPosition.startColumn,
startLineNumber: position.lineNumber,
endColumn: position.column,
endLineNumber: position.lineNumber,
},
}));
};
/*
* This function splits a string on whitespaces and returns its parts as an array
*/
export const getLineTokens = (lineContent: string): string[] => {
return lineContent.split(whitespacesRegex);
};
/*
* This function checks if the url contains url params
*/
const questionMarkRegex = /\?/;
export const containsUrlParams = (lineContent: string): boolean => {
return questionMarkRegex.test(lineContent);
};
/*
* This function initializes the autocomplete context for the provided method and url token path.
*/
const populateContextForMethodAndUrl = (method: string, urlTokenPath: string[]) => {
// get autocomplete components for the request method
const components = getTopLevelUrlCompleteComponents(method);
// this object will contain the information later, it needs to be initialized with some data
// similar to the old ace editor context
const context: AutoCompleteContext = {
method,
urlTokenPath,
};
// mutate the context object and put the autocomplete information there
populateContext(urlTokenPath, context, undefined, true, components);
return context;
};
/*
* This function returns an array of completion items for the request method and the url path
*/
export const getUrlPathCompletionItems = (
model: monaco.editor.ITextModel,
position: monaco.Position
): monaco.languages.CompletionItem[] => {
const { lineNumber, column } = position;
// get the content of the line up until the current position
const lineContent = model.getValueInRange({
startLineNumber: lineNumber,
startColumn: 1,
endLineNumber: lineNumber,
endColumn: column,
});
// get the method and previous url parts for context
const { method, urlPathTokens } = parseLineContent(lineContent);
// remove the last token that is either empty if the url has like "_search/" as the last char
// or it's a word that need to be replaced with autocomplete suggestions like "_search/s"
urlPathTokens.pop();
const { autoCompleteSet } = populateContextForMethodAndUrl(method, urlPathTokens);
const wordUntilPosition = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
// replace the whole word with the suggestion
startColumn: lineContent.endsWith('.')
? // if there is a dot at the end of the content, it's ignored in the wordUntilPosition
wordUntilPosition.startColumn - 1
: wordUntilPosition.startColumn,
endLineNumber: position.lineNumber,
endColumn: position.column,
};
if (autoCompleteSet && autoCompleteSet.length > 0) {
return (
autoCompleteSet
// filter autocomplete items without a name
.filter(({ name }) => Boolean(name))
// map autocomplete items to completion items
.map((item) => {
return {
label: item.name!,
insertText: item.name!,
detail: item.meta ?? endpointDetailLabel,
// the kind is only used to configure the icon
kind: monaco.languages.CompletionItemKind.Constant,
range,
};
})
);
}
return [];
};
/*
* This function returns an array of completion items for the url params
*/
export const getUrlParamsCompletionItems = (
model: monaco.editor.ITextModel,
position: monaco.Position
): monaco.languages.CompletionItem[] => {
const { lineNumber, column } = position;
// get the content of the line up until the current position
const lineContent = model.getValueInRange({
startLineNumber: lineNumber,
startColumn: 1,
endLineNumber: lineNumber,
endColumn: column,
});
// get the method and previous url parts for context
const { method, urlPathTokens, urlParamsTokens } = parseLineContent(lineContent);
urlPathTokens.push(endOfUrlToken);
const context = populateContextForMethodAndUrl(method, urlPathTokens);
const urlParamsComponents = context.endpoint?.paramsAutocomplete.getTopLevelComponents(method);
const currentUrlParamToken = urlParamsTokens.pop();
// check if we are at the param name or the param value
const urlParamTokenPath = [];
// if there are 2 tokens in the current url param, then we have the name and the value of the param
if (currentUrlParamToken && currentUrlParamToken.length > 1) {
urlParamTokenPath.push(currentUrlParamToken![0]);
}
populateContext(urlParamTokenPath, context, undefined, true, urlParamsComponents);
if (context.autoCompleteSet && context.autoCompleteSet.length > 0) {
const wordUntilPosition = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
// replace the whole word with the suggestion
startColumn: wordUntilPosition.startColumn,
endLineNumber: position.lineNumber,
endColumn: position.column,
};
return (
context.autoCompleteSet
// filter autocomplete items without a name
.filter(({ name }) => Boolean(name))
// map autocomplete items to completion items
.map((item) => {
return {
label: item.name!,
insertText: item.name!,
detail: item.meta ?? paramDetailLabel,
// the kind is only used to configure the icon
kind: monaco.languages.CompletionItemKind.Constant,
range,
};
})
);
}
return [];
};
const parseLineContent = (lineContent: string): ParsedLineTokens => {
// try to parse into method and url (split on whitespace)
const parts = lineContent.split(whitespacesRegex);
// 1st part is the method
const method = parts[0];
// 2nd part is the url
const url = parts[1];
// try to parse into url path and url params (split on question mark)
const { urlPathTokens, urlParamsTokens } = parseUrlTokens(url);
return { method, urlPathTokens, urlParamsTokens };
};
const parseUrlTokens = (
url: string
): {
urlPathTokens: ParsedLineTokens['urlPathTokens'];
urlParamsTokens: ParsedLineTokens['urlParamsTokens'];
} => {
let urlPathTokens: ParsedLineTokens['urlPathTokens'] = [];
let urlParamsTokens: ParsedLineTokens['urlParamsTokens'] = [];
const urlParts = url.split(questionMarkRegex);
// 1st part is the url path
const urlPath = urlParts[0];
// try to parse into url path tokens (split on slash)
if (urlPath) {
urlPathTokens = urlPath.split(slashRegex);
}
// 2nd part is the url params
const urlParams = urlParts[1];
// try to parse into url param tokens
if (urlParams) {
urlParamsTokens = urlParams.split(ampersandRegex).map((urlParamsPart) => {
return urlParamsPart.split(equalsSignRegex);
});
}
return { urlPathTokens, urlParamsTokens };
};

View file

@ -337,11 +337,7 @@ export default function ({
}
}
function addMetaToTermsList(
list: unknown[],
meta: unknown,
template?: string
): Array<{ meta: unknown; template: unknown; name?: string }> {
function addMetaToTermsList(list: ResultTerm[], meta: string, template?: string): ResultTerm[] {
return _.map(list, function (t) {
if (typeof t !== 'object') {
t = { name: t };
@ -984,7 +980,7 @@ export default function ({
const components = getTopLevelUrlCompleteComponents(context.method);
let urlTokenPath = context.urlTokenPath;
let predicate: (term: ReturnType<typeof addMetaToTermsList>[0]) => boolean = () => true;
let predicate: (term: ResultTerm) => boolean = () => true;
const tokenIter = createTokenIterator({ editor, position: pos });
const currentTokenType = tokenIter.getCurrentToken()?.type;

View file

@ -9,6 +9,7 @@
import { CoreEditor, Range, Token } from '../../types';
export interface ResultTerm {
meta?: string;
context?: AutoCompleteContext;
insertValue?: string;
name?: string;