diff --git a/src/platform/packages/shared/kbn-monaco/src/console/index.ts b/src/platform/packages/shared/kbn-monaco/src/console/index.ts index 438a3340c6054..fe4bc212d9590 100644 --- a/src/platform/packages/shared/kbn-monaco/src/console/index.ts +++ b/src/platform/packages/shared/kbn-monaco/src/console/index.ts @@ -26,7 +26,7 @@ import { import { foldingRangeProvider } from './folding_range_provider'; import { ESQL_AUTOCOMPLETE_TRIGGER_CHARS } from '../esql'; import { wrapAsMonacoSuggestions } from '../esql/lib/converters/suggestions'; -import { checkForTripleQuotesAndQueries, unescapeInvalidChars } from './utils'; +import { checkForTripleQuotesAndEsqlQuery, unescapeInvalidChars } from './utils'; export { CONSOLE_LANG_ID, CONSOLE_OUTPUT_LANG_ID } from './constants'; /** @@ -56,10 +56,10 @@ export const ConsoleLang: LangModuleType = { const fullText = model.getValue(); const cursorOffset = model.getOffsetAt(position); const textBeforeCursor = fullText.slice(0, cursorOffset); - const { insideSingleQuotesQuery, insideTripleQuotesQuery, queryIndex } = - checkForTripleQuotesAndQueries(textBeforeCursor); - if (esqlCallbacks && (insideSingleQuotesQuery || insideTripleQuotesQuery)) { - const queryText = textBeforeCursor.slice(queryIndex, cursorOffset); + const { insideTripleQuotes, insideEsqlQuery, esqlQueryIndex } = + checkForTripleQuotesAndEsqlQuery(textBeforeCursor); + if (esqlCallbacks && insideEsqlQuery) { + const queryText = textBeforeCursor.slice(esqlQueryIndex, cursorOffset); const unescapedQuery = unescapeInvalidChars(queryText); const esqlSuggestions = await suggest( unescapedQuery, @@ -72,7 +72,7 @@ export const ConsoleLang: LangModuleType = { esqlSuggestions, queryText, false, - insideSingleQuotesQuery + !insideTripleQuotes ), }; } else if (actionsProvider.current) { diff --git a/src/platform/packages/shared/kbn-monaco/src/console/utils/autocomplete_utils.test.ts b/src/platform/packages/shared/kbn-monaco/src/console/utils/autocomplete_utils.test.ts index fb26b298ec346..3dde1b534c8a7 100644 --- a/src/platform/packages/shared/kbn-monaco/src/console/utils/autocomplete_utils.test.ts +++ b/src/platform/packages/shared/kbn-monaco/src/console/utils/autocomplete_utils.test.ts @@ -7,97 +7,150 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -import { checkForTripleQuotesAndQueries, unescapeInvalidChars } from './autocomplete_utils'; +import { checkForTripleQuotesAndEsqlQuery, unescapeInvalidChars } from './autocomplete_utils'; describe('autocomplete_utils', () => { describe('checkForTripleQuotesAndQueries', () => { - it('should return false for all flags for an empty string', () => { - expect(checkForTripleQuotesAndQueries('')).toEqual({ + it('returns false for all flags for an empty string', () => { + expect(checkForTripleQuotesAndEsqlQuery('')).toEqual({ insideTripleQuotes: false, - insideSingleQuotesQuery: false, - insideTripleQuotesQuery: false, - queryIndex: -1, + insideEsqlQuery: false, + esqlQueryIndex: -1, }); }); - it('should return false for all flags for a request without triple quotes', () => { + 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}`; - expect(checkForTripleQuotesAndQueries(request)).toEqual({ + expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ insideTripleQuotes: false, - insideSingleQuotesQuery: false, - insideTripleQuotesQuery: false, - queryIndex: -1, + insideEsqlQuery: false, + esqlQueryIndex: -1, }); }); - it('should return true for insideTripleQuotes and false for insideTripleQuotesQuery when triple quotes are outside a query', () => { + 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`; - expect(checkForTripleQuotesAndQueries(request)).toEqual({ + expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ insideTripleQuotes: true, - insideSingleQuotesQuery: false, - insideTripleQuotesQuery: false, - queryIndex: -1, + insideEsqlQuery: false, + esqlQueryIndex: -1, }); }); - it('should return false for all flags when triple-quoted string is properly closed', () => { - const request = `POST _ingest/pipeline/_simulate\n{\n "pipeline": {\n "processors": [\n {\n "script": {\n "source":\n """\n return 'hello';\n """\n }\n }\n ]\n }\n}`; - expect(checkForTripleQuotesAndQueries(request)).toEqual({ - insideTripleQuotes: false, - insideSingleQuotesQuery: false, - insideTripleQuotesQuery: false, - queryIndex: -1, - }); - }); - - it('should return true for both insideTripleQuotes and insideTripleQuotesQuery if inside a "query" field', () => { + 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 `; - expect(checkForTripleQuotesAndQueries(request)).toEqual({ + expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ insideTripleQuotes: true, - insideSingleQuotesQuery: false, - insideTripleQuotesQuery: true, - queryIndex: request.indexOf('"""') + 3, + insideEsqlQuery: false, + esqlQueryIndex: -1, }); }); - it('should return true for insideSingleQuotesQuery if inside a single-quoted "query" string', () => { + 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 `; - expect(checkForTripleQuotesAndQueries(request)).toEqual({ + const result = checkForTripleQuotesAndEsqlQuery(request); + expect(result).toEqual({ insideTripleQuotes: false, - insideSingleQuotesQuery: true, - insideTripleQuotesQuery: false, - queryIndex: 32, + insideEsqlQuery: false, + esqlQueryIndex: -1, }); }); - it('should return false for all flags if single quote is closed', () => { - const request = `GET index/_search\n{\n "query": "SELECT * FROM logs" }`; - expect(checkForTripleQuotesAndQueries(request)).toEqual({ + it('returns false for all flags if single quote is closed', () => { + const request = `POST _query\n{\n "query": "SELECT * FROM logs" }`; + expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ insideTripleQuotes: false, - insideSingleQuotesQuery: false, - insideTripleQuotesQuery: false, - queryIndex: -1, + insideEsqlQuery: false, + esqlQueryIndex: -1, }); }); - it('should handle escaped quotes correctly (not toggling inside state)', () => { - const request = `GET _search\n{\n "query": "FROM test | WHERE KQL(\\"\\"\\")`; - expect(checkForTripleQuotesAndQueries(request)).toEqual({ + it('returns false for all flags if triple quote is closed', () => { + const request = `POST _query\n{\n "query": """SELECT * FROM logs""" }`; + expect(checkForTripleQuotesAndEsqlQuery(request)).toEqual({ insideTripleQuotes: false, - insideSingleQuotesQuery: true, - insideTripleQuotesQuery: false, - queryIndex: 26, + insideEsqlQuery: false, + esqlQueryIndex: -1, }); }); + }); - it('should reset the state after closing triple quotes', () => { - const request = `GET _search\n{\n "query": """SELECT * FROM logs"""\n}`; - expect(checkForTripleQuotesAndQueries(request)).toEqual({ - insideTripleQuotes: false, - insideSingleQuotesQuery: false, - insideTripleQuotesQuery: false, - queryIndex: -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 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 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('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('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, }); }); diff --git a/src/platform/packages/shared/kbn-monaco/src/console/utils/autocomplete_utils.ts b/src/platform/packages/shared/kbn-monaco/src/console/utils/autocomplete_utils.ts index 86b5423a88d66..bd8bba48c9b25 100644 --- a/src/platform/packages/shared/kbn-monaco/src/console/utils/autocomplete_utils.ts +++ b/src/platform/packages/shared/kbn-monaco/src/console/utils/autocomplete_utils.ts @@ -13,13 +13,12 @@ * and the start index of the current query. * @param text The text up to the current position */ -export const checkForTripleQuotesAndQueries = ( +export const checkForTripleQuotesAndEsqlQuery = ( text: string ): { insideTripleQuotes: boolean; - insideSingleQuotesQuery: boolean; - insideTripleQuotesQuery: boolean; - queryIndex: number; + insideEsqlQuery: boolean; + esqlQueryIndex: number; } => { let insideSingleQuotes = false; let insideTripleQuotes = false; @@ -27,14 +26,18 @@ export const checkForTripleQuotesAndQueries = ( let insideSingleQuotesQuery = false; let insideTripleQuotesQuery = false; + let insideEsqlQueryRequest = false; + let currentQueryStartIndex = -1; let i = 0; while (i < text.length) { + const textBefore = text.slice(0, i); + const textFromIndex = text.slice(i); if (text.startsWith('"""', i)) { insideTripleQuotes = !insideTripleQuotes; if (insideTripleQuotes) { - insideTripleQuotesQuery = /.*"query"\s*:\s*$/.test(text.slice(0, i)); + insideTripleQuotesQuery = /.*"query"\s*:\s*$/.test(textBefore); if (insideTripleQuotesQuery) { currentQueryStartIndex = i + 3; } @@ -46,7 +49,7 @@ export const checkForTripleQuotesAndQueries = ( } else if (text.at(i) === '"' && text.at(i - 1) !== '\\') { insideSingleQuotes = !insideSingleQuotes; if (insideSingleQuotes) { - insideSingleQuotesQuery = /.*"query"\s*:\s*$/.test(text.slice(0, i)); + insideSingleQuotesQuery = /.*"query"\s*:\s*$/.test(textBefore); if (insideSingleQuotesQuery) { currentQueryStartIndex = i + 1; } @@ -55,6 +58,17 @@ export const checkForTripleQuotesAndQueries = ( currentQueryStartIndex = -1; } i++; + } else if (/^(GET|POST|PUT|DELETE|HEAD|PATCH)/i.test(textFromIndex)) { + // If this is the start of a new request, check if it is a _query API request + insideEsqlQueryRequest = /^(P|p)(O|o)(S|s)(T|t)\s+\/?_query(\n|\s|\?)/.test(textFromIndex); + // Move the index past the current line that contains request method and endpoint. + const newlineIndex = text.indexOf('\n', i); + if (newlineIndex === -1) { + // No newline after the request line; advance to end to avoid infinite loop. + i = text.length; + } else { + i = newlineIndex + 1; // Position at start of next line + } } else { i++; } @@ -62,9 +76,8 @@ export const checkForTripleQuotesAndQueries = ( return { insideTripleQuotes, - insideSingleQuotesQuery, - insideTripleQuotesQuery, - queryIndex: currentQueryStartIndex, + insideEsqlQuery: insideEsqlQueryRequest && (insideSingleQuotesQuery || insideTripleQuotesQuery), + esqlQueryIndex: insideEsqlQueryRequest ? currentQueryStartIndex : -1, }; }; diff --git a/src/platform/packages/shared/kbn-monaco/src/console/utils/index.ts b/src/platform/packages/shared/kbn-monaco/src/console/utils/index.ts index b6cec66819ea2..378fc07afcd6e 100644 --- a/src/platform/packages/shared/kbn-monaco/src/console/utils/index.ts +++ b/src/platform/packages/shared/kbn-monaco/src/console/utils/index.ts @@ -7,4 +7,4 @@ * License v3.0 only", or the "Server Side Public License, v 1". */ -export { checkForTripleQuotesAndQueries, unescapeInvalidChars } from './autocomplete_utils'; +export { checkForTripleQuotesAndEsqlQuery, unescapeInvalidChars } from './autocomplete_utils'; diff --git a/src/platform/plugins/shared/console/public/application/containers/editor/monaco_editor_actions_provider.ts b/src/platform/plugins/shared/console/public/application/containers/editor/monaco_editor_actions_provider.ts index 426d7a5bd5945..bad1f30f85d2c 100644 --- a/src/platform/plugins/shared/console/public/application/containers/editor/monaco_editor_actions_provider.ts +++ b/src/platform/plugins/shared/console/public/application/containers/editor/monaco_editor_actions_provider.ts @@ -14,7 +14,7 @@ import { i18n } from '@kbn/i18n'; import { toMountPoint } from '@kbn/react-kibana-mount'; import { XJson } from '@kbn/es-ui-shared-plugin/public'; import { ErrorAnnotation } from '@kbn/monaco/src/console/types'; -import { checkForTripleQuotesAndQueries } from '@kbn/monaco/src/console/utils'; +import { checkForTripleQuotesAndEsqlQuery } from '@kbn/monaco/src/console/utils'; import { isQuotaExceededError } from '../../../services/history'; import { DEFAULT_VARIABLES, KIBANA_API_PREFIX } from '../../../../common/constants'; import { getStorage, StorageKeys } from '../../../services'; @@ -785,7 +785,7 @@ export class MonacoEditorActionsProvider { private async isPositionInsideTripleQuotesAndQuery( model: monaco.editor.ITextModel, position: monaco.Position - ): Promise<{ insideTripleQuotes: boolean; insideQuery: boolean }> { + ): Promise<{ insideTripleQuotes: boolean; insideEsqlQuery: boolean }> { const selectedRequests = await this.getSelectedParsedRequests(); for (const request of selectedRequests) { @@ -800,21 +800,21 @@ export class MonacoEditorActionsProvider { endColumn: position.column, }); - const { insideTripleQuotes, insideSingleQuotesQuery, insideTripleQuotesQuery } = - checkForTripleQuotesAndQueries(requestContentBefore); + const { insideTripleQuotes, insideEsqlQuery } = + checkForTripleQuotesAndEsqlQuery(requestContentBefore); return { insideTripleQuotes, - insideQuery: insideSingleQuotesQuery || insideTripleQuotesQuery, + insideEsqlQuery, }; } if (request.startLineNumber > position.lineNumber) { // Stop iteration once we pass the cursor position - return { insideTripleQuotes: false, insideQuery: false }; + return { insideTripleQuotes: false, insideEsqlQuery: false }; } } // Return false if the position is not inside a request - return { insideTripleQuotes: false, insideQuery: false }; + return { insideTripleQuotes: false, insideEsqlQuery: false }; } private triggerSuggestions() { @@ -824,8 +824,8 @@ export class MonacoEditorActionsProvider { return; } this.isPositionInsideTripleQuotesAndQuery(model, position).then( - ({ insideTripleQuotes, insideQuery }) => { - if (insideTripleQuotes && !insideQuery) { + ({ insideTripleQuotes, insideEsqlQuery }) => { + if (insideTripleQuotes && !insideEsqlQuery) { // Don't trigger autocomplete suggestions inside scripts and strings return; } @@ -839,11 +839,11 @@ export class MonacoEditorActionsProvider { // Trigger suggestions if the line: // - is empty // - matches specified regex - // - is inside a query + // - is inside an ESQL query if ( !lineContentBefore.trim() || shouldTriggerSuggestions(lineContentBefore) || - insideQuery + insideEsqlQuery ) { this.editor.trigger(TRIGGER_SUGGESTIONS_ACTION_LABEL, TRIGGER_SUGGESTIONS_HANDLER_ID, {}); }