Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,52 @@ import { foldingRangeProvider } from './folding_range_provider';

export const CONSOLE_TRIGGER_CHARS = ['/', '.', '_', ',', '?', '=', '&', '"'];

const requestMethodRe = /^\s*(GET|POST|PUT|DELETE|HEAD|PATCH)\b/i;
const esqlRequestLineRe = /^\s*post\s+\/?_query(?:\/async)?(?:\s|\?|$)/i;
/**
* Safeguards for request-line lookup. We scan backwards from the cursor until we find the nearest
* request method line (GET/POST/...), but we cap the amount of work to avoid a potentially large
* number of `getLineContent()` calls on very long documents.
*
* If these limits are hit, ES|QL context detection is skipped and we fall back to the
* actions provider (preserving completion behavior, just without ES|QL suggestions).
*/
const MAX_REQUEST_LINE_LOOKBACK_LINES = 2000;
const MAX_REQUEST_LINE_LOOKBACK_CHARS = 100_000;

const findEsqlRequestLineNumber = (
model: monaco.editor.ITextModel,
positionLineNumber: number
): number | undefined => {
for (
let lineNumber = positionLineNumber, scannedLines = 0, scannedChars = 0;
lineNumber >= 1 &&
scannedLines < MAX_REQUEST_LINE_LOOKBACK_LINES &&
scannedChars < MAX_REQUEST_LINE_LOOKBACK_CHARS;
lineNumber--, scannedLines++
) {
const line = model.getLineContent(lineNumber);
Comment thread
kapral18 marked this conversation as resolved.
scannedChars += line.length + 1;
if (requestMethodRe.test(line)) {
// Only treat this as an ES|QL request if the request line matches POST _query(/async)?...
return esqlRequestLineRe.test(line) ? lineNumber : undefined;
}
}
};

const getRequestTextBeforeCursor = (
model: monaco.editor.ITextModel,
requestLineNumber: number,
position: monaco.Position
): string => {
return model.getValueInRange({
startLineNumber: requestLineNumber,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
};

/**
* @description This language definition is used for the console input panel
*/
Expand All @@ -46,24 +92,47 @@ export const ConsoleLang: LangModuleType = {
},
languageThemeResolver: buildConsoleTheme,
getSuggestionProvider: (
esqlCallbacks: Pick<ESQLCallbacks, 'getSources' | 'getPolicies'>,
actionsProvider: MutableRefObject<any>
esqlCallbacks: Pick<ESQLCallbacks, 'getSources' | 'getPolicies'> | undefined,
actionsProvider: MutableRefObject<{
provideCompletionItems: monaco.languages.CompletionItemProvider['provideCompletionItems'];
} | null>
): monaco.languages.CompletionItemProvider => {
return {
// force suggestions when these characters are used
triggerCharacters: [...CONSOLE_TRIGGER_CHARS, ...ESQL_AUTOCOMPLETE_TRIGGER_CHARS],
provideCompletionItems: async (
model: monaco.editor.ITextModel,
position: monaco.Position,
context: monaco.languages.CompletionContext
context: monaco.languages.CompletionContext,
token: monaco.CancellationToken
) => {
const fullText = model.getValue();
const cursorOffset = model.getOffsetAt(position);
const textBeforeCursor = fullText.slice(0, cursorOffset);
// NOTE: Materializing the full editor content (e.g. via `model.getValue()`) can be very
// expensive for large inputs (like pasted JSON with huge string fields). We only do ES|QL
// context detection when the cursor is within a POST /_query request.
const delegateToActionsProvider = () => {
const actions = actionsProvider.current;
return (
actions?.provideCompletionItems(model, position, context, token) ?? {
suggestions: [],
}
);
};

const esqlRequestLineNumber = findEsqlRequestLineNumber(model, position.lineNumber);
if (!esqlRequestLineNumber) {
return delegateToActionsProvider();
}

const requestTextBeforeCursor = getRequestTextBeforeCursor(
model,
esqlRequestLineNumber,
position
);
const { insideTripleQuotes, insideEsqlQuery, esqlQueryIndex } =
checkForTripleQuotesAndEsqlQuery(textBeforeCursor);
checkForTripleQuotesAndEsqlQuery(requestTextBeforeCursor);

if (esqlCallbacks && insideEsqlQuery) {
const queryText = textBeforeCursor.slice(esqlQueryIndex, cursorOffset);
const queryText = requestTextBeforeCursor.slice(esqlQueryIndex);
const unescapedQuery = unescapeInvalidChars(queryText);
const esqlSuggestions = await suggest(
unescapedQuery,
Expand All @@ -77,12 +146,8 @@ export const ConsoleLang: LangModuleType = {
!insideTripleQuotes,
true
);
} else if (actionsProvider.current) {
return actionsProvider.current?.provideCompletionItems(model, position, context);
}
return {
suggestions: [],
};
return delegateToActionsProvider();
},
};
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ describe('autocomplete_utils', () => {
esqlQueryIndex: -1,
});
});

it('does not treat longer words as request methods (e.g. GETS, POSTER)', () => {
const methods = ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'] as const;
for (const method of methods) {
const requestMethod = `${method}A`;
const request = `${requestMethod} _query\n{\n "query": "SELECT * FROM logs `;
expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: false,
esqlQueryIndex: -1,
});
}
});
});

it('sets insideEsqlQuery for single quoted query after POST _query', () => {
Expand Down Expand Up @@ -114,6 +127,16 @@ describe('autocomplete_utils', () => {
});
});

it('detects query with /_query/async endpoint', () => {
const request = `POST /_query/async\n{\n "query": "FROM logs | STATS `;
const result = checkForTripleQuotesAndEsqlQuery(request);
expect(result).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: true,
esqlQueryIndex: request.indexOf('"FROM logs ') + 1,
});
});

it('detects triple quoted query after POST _query?foo=bar with extra spaces', () => {
const request = `POST _query?foo=bar\n{\n "query": """FROM metrics `;
const result = checkForTripleQuotesAndEsqlQuery(request);
Expand All @@ -124,6 +147,36 @@ describe('autocomplete_utils', () => {
});
});

it('detects query when request line is indented', () => {
const request = ` \tPOST _query\n{\n "query": "FROM logs | STATS `;
const result = checkForTripleQuotesAndEsqlQuery(request);
expect(result).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: true,
esqlQueryIndex: request.indexOf('"FROM logs ') + 1,
});
});

it('detects query value with whitespace around the colon', () => {
const request = `POST _query\n{\n "query" :\t "FROM logs | STATS `;
const result = checkForTripleQuotesAndEsqlQuery(request);
expect(result).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: true,
esqlQueryIndex: request.indexOf('"FROM logs ') + 1,
});
});

it('does not treat near-miss keys as the "query" value', () => {
const request = `POST _query\n{\n "queryx": "FROM logs | STATS `;
const result = checkForTripleQuotesAndEsqlQuery(request);
expect(result).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: false,
esqlQueryIndex: -1,
});
});

it('does not set ESQL flags for subsequent non-_query request in same buffer', () => {
const request = `POST _query\n{\n "query": "FROM a | STATS "\n}\nGET other_index/_search\n{\n "query": "match_all" }`;
const result = checkForTripleQuotesAndEsqlQuery(request);
Expand Down
Loading
Loading