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);
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,37 +92,56 @@ 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,
unescapedQuery.length,
esqlCallbacks
);
return wrapAsMonacoSuggestions(esqlSuggestions, queryText, false, !insideTripleQuotes);
} 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 @@ -10,7 +10,7 @@
import { checkForTripleQuotesAndEsqlQuery, unescapeInvalidChars } from './autocomplete_utils';

describe('autocomplete_utils', () => {
describe('checkForTripleQuotesAndQueries', () => {
describe('checkForTripleQuotesAndEsqlQuery', () => {
it('returns false for all flags for an empty string', () => {
expect(checkForTripleQuotesAndEsqlQuery('')).toEqual({
insideTripleQuotes: false,
Expand All @@ -19,138 +19,105 @@ describe('autocomplete_utils', () => {
});
});

it('returns false for all flags for a request without triple quotes or ESQL query', () => {
const request = `POST _search\n{\n "query": {\n "match": {\n "message": "hello world"\n }\n }\n}`;
it('does not detect ES|QL inside non-_query requests', () => {
const request = `GET index/_search\n{\n "query": "FROM logs | STATS `;
expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: false,
esqlQueryIndex: -1,
});
});

it('returns true for insideTripleQuotes and false for ESQL flags when triple quotes are outside a query', () => {
const request = `POST _ingest/pipeline/_simulate\n{\n "pipeline": {\n "processors": [\n {\n "script": {\n "source":\n """\n for (field in params['fields']){\n if (!$(field, '').isEmpty()){\n`;
it('returns insideTripleQuotes=true but insideEsqlQuery=false when triple quotes are outside the query value', () => {
const request = `POST _query\n{\n "script": """FROM test `;
expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({
insideTripleQuotes: true,
insideEsqlQuery: false,
esqlQueryIndex: -1,
});
});

it('returns true for insideTripleQuotes but false for ESQL flags inside a non-_query request query field', () => {
const request = `POST _search\n{\n "query": """FROM test `;
it('sets insideEsqlQuery for single quoted query after POST _query', () => {
const request = `POST _query\n{\n "query": "FROM test `;
expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({
insideTripleQuotes: true,
insideEsqlQuery: false,
esqlQueryIndex: -1,
insideTripleQuotes: false,
insideEsqlQuery: true,
esqlQueryIndex: request.indexOf('"FROM test ') + 1,
});
});

it('returns false for ESQL flags inside a single-quoted query for non-_query request', () => {
const request = `GET index/_search\n{\n "query": "SELECT * FROM logs `;
const result = checkForTripleQuotesAndEsqlQuery(request);
expect(result).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: false,
esqlQueryIndex: -1,
it('sets insideEsqlQuery for triple quoted query after POST _query (case-insensitive)', () => {
const request = `post _query\n{\n "query": """FROM test `;
expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({
insideTripleQuotes: true,
insideEsqlQuery: true,
esqlQueryIndex: request.indexOf('"""') + 3,
});
});

it('returns false for all flags if single quote is closed', () => {
const request = `POST _query\n{\n "query": "SELECT * FROM logs" }`;
it('handles escaped quotes correctly (not toggling inside state)', () => {
const request = `POST _query\n{\n "query": "FROM test | WHERE KQL(\\\"\\\"\\\")`;
expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: false,
esqlQueryIndex: -1,
insideEsqlQuery: true,
esqlQueryIndex: request.indexOf('"FROM test ') + 1,
});
});

it('returns false for all flags if triple quote is closed', () => {
const request = `POST _query\n{\n "query": """SELECT * FROM logs""" }`;
it('detects query with /_query endpoint', () => {
const request = `POST /_query\n{\n "query": "FROM logs | STATS `;
expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: false,
esqlQueryIndex: -1,
insideEsqlQuery: true,
esqlQueryIndex: request.indexOf('"FROM logs ') + 1,
});
});
});

it('sets insideEsqlQuery for single quoted query after POST _query', () => {
const request = `POST _query\n{\n "query": "FROM test `;
expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: true,
esqlQueryIndex: request.indexOf('"FROM test ') + 1,
});
});

it('sets insideEsqlQuery for triple quoted query after POST _query (case-insensitive)', () => {
const request = `post _query\n{\n "query": """FROM test `; // lowercase POST should also match
const result = checkForTripleQuotesAndEsqlQuery(request);
expect(result).toEqual({
insideTripleQuotes: true,
insideEsqlQuery: true,
esqlQueryIndex: request.indexOf('"""') + 3,
});
});

it('detects single quoted query after POST _query?pretty suffix', () => {
const request = `POST _query?pretty\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 with /_query endpoint', () => {
const request = `POST /_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 with /_query/async endpoint', () => {
const request = `POST /_query/async\n{\n "query": "FROM logs | STATS `;
expect(checkForTripleQuotesAndEsqlQuery(request)).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);
expect(result).toEqual({
insideTripleQuotes: true,
insideEsqlQuery: true,
esqlQueryIndex: request.indexOf('"""') + 3,
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 request = `${method}A _query\n{\n "query": "FROM logs | STATS `;
expect(checkForTripleQuotesAndEsqlQuery(request)).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);
expect(result).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: false,
esqlQueryIndex: -1, // single quotes closed in second request
it('does not treat near-miss keys as the "query" value', () => {
const request = `POST _query\n{\n "queryx": "FROM logs | STATS `;
expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: false,
esqlQueryIndex: -1,
});
});
});

it('only flags current active _query section in mixed multi-request buffer', () => {
const partial = `POST _query\n{\n "query": "FROM a | STATS "\n}\nPOST _query\n{\n "query": """FROM b | WHERE foo = `; // cursor inside triple quotes of second request
const result = checkForTripleQuotesAndEsqlQuery(partial);
expect(result).toEqual({
insideTripleQuotes: true,
insideEsqlQuery: true,
esqlQueryIndex: partial.lastIndexOf('"""') + 3,
it('only flags the current active _query section in a mixed multi-request buffer', () => {
const partial = `POST _query\n{\n "query": "FROM a | STATS "\n}\nPOST _query\n{\n "query": """FROM b | WHERE foo = `;
expect(checkForTripleQuotesAndEsqlQuery(partial)).toEqual({
insideTripleQuotes: true,
insideEsqlQuery: true,
esqlQueryIndex: partial.lastIndexOf('"""') + 3,
});
});
});

it('handles request method at end of buffer without trailing newline (regression test)', () => {
const buffer = 'POST _query';
const result = checkForTripleQuotesAndEsqlQuery(buffer);
expect(result).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: false,
esqlQueryIndex: -1,
it('handles request method at end of buffer without trailing newline', () => {
expect(checkForTripleQuotesAndEsqlQuery('POST _query')).toEqual({
insideTripleQuotes: false,
insideEsqlQuery: false,
esqlQueryIndex: -1,
});
});
});

Expand Down
Loading