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
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { ESQL_AUTOCOMPLETE_TRIGGER_CHARS } from '../esql';
import { wrapAsMonacoSuggestions } from '../esql/lib/converters/suggestions';
import { ConsoleParsedRequestsProvider } from './console_parsed_requests_provider';
import { buildConsoleTheme } from './theme';
import { isInsideTripleQuotes } from './utils';
import { checkForTripleQuotesAndQueries, unescapeInvalidChars } from './utils';
import type { LangModuleType } from '../../types';

const workerProxyService = new ConsoleWorkerProxyService();
Expand Down Expand Up @@ -60,18 +60,24 @@ export const ConsoleLang: LangModuleType = {
const fullText = model.getValue();
const cursorOffset = model.getOffsetAt(position);
const textBeforeCursor = fullText.slice(0, cursorOffset);
const { insideQuery } = isInsideTripleQuotes(textBeforeCursor);
if (esqlCallbacks && insideQuery) {
const queryStartOffset = textBeforeCursor.lastIndexOf('"""') + 3;
const queryText = textBeforeCursor.slice(queryStartOffset, cursorOffset);
const { insideSingleQuotesQuery, insideTripleQuotesQuery, queryIndex } =
checkForTripleQuotesAndQueries(textBeforeCursor);
if (esqlCallbacks && (insideSingleQuotesQuery || insideTripleQuotesQuery)) {
const queryText = textBeforeCursor.slice(queryIndex, cursorOffset);
const unescapedQuery = unescapeInvalidChars(queryText);
const esqlSuggestions = await suggest(
queryText,
cursorOffset - queryStartOffset,
unescapedQuery,
unescapedQuery.length,
context,
esqlCallbacks
);
return {
suggestions: wrapAsMonacoSuggestions(esqlSuggestions, queryText, false),
suggestions: wrapAsMonacoSuggestions(
esqlSuggestions,
queryText,
false,
insideSingleQuotesQuery
),
};
} else if (actionsProvider.current) {
return actionsProvider.current?.provideCompletionItems(model, position, context);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,17 @@ import {
/*
* This rule is used inside json root to start an esql highlighting sequence
*/
export const buildEsqlStartRule = (esqlRoot: string = 'esql_root') => {
export const buildEsqlStartRule = (tripleQuotes: boolean, esqlRoot: string = 'esql_root') => {
return [
/("query")(\s*?)(:)(\s*?)(""")/,
tripleQuotes ? /("query")(\s*?)(:)(\s*?)(""")/ : /("query")(\s*?)(:)(\s*?)(")/,
[
'variable',
'whitespace',
'punctuation.colon',
'whitespace',
{
token: 'punctuation',
next: `@${esqlRoot}`,
next: tripleQuotes ? `@${esqlRoot}_triple_quotes` : `@${esqlRoot}_single_quotes`,
},
],
];
Expand All @@ -39,7 +39,7 @@ export const buildEsqlStartRule = (esqlRoot: string = 'esql_root') => {
export const buildEsqlRules = (esqlRoot: string = 'esql_root') => {
const { root, comment, numbers, strings } = esqlLexerRules.tokenizer;
return {
[esqlRoot]: [
[`${esqlRoot}_triple_quotes`]: [
// the rule to end esql highlighting and get back to the previous tokenizer state
[
/"""/,
Expand All @@ -52,6 +52,20 @@ export const buildEsqlRules = (esqlRoot: string = 'esql_root') => {
...numbers,
...strings,
],
[`${esqlRoot}_single_quotes`]: [
[/@escapes/, 'string.escape'],
// the rule to end esql highlighting and get back to the previous tokenizer state
[
/"/, // Unescaped quote
{
token: 'punctuation',
next: '@pop',
},
],
...root,
...numbers,
...strings,
],
comment,
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ xjsonRules.json_root = [
// @ts-expect-error include a rule to start painless highlighting
buildPainlessStartRule(),
// @ts-expect-error include a rule to start esql highlighting
buildEsqlStartRule(),
buildEsqlStartRule(false),
// @ts-expect-error include a rule to start esql highlighting
buildEsqlStartRule(true),
...xjsonRules.json_root,
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,47 +7,135 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { isInsideTripleQuotes } from './autocomplete_utils';
import { checkForTripleQuotesAndQueries, unescapeInvalidChars } from './autocomplete_utils';

describe('autocomplete_utils', () => {
describe('isInsideTripleQuotes', () => {
it('should return false for both flags for an empty string', () => {
expect(isInsideTripleQuotes('')).toEqual({
describe('checkForTripleQuotesAndQueries', () => {
it('should return false for all flags for an empty string', () => {
expect(checkForTripleQuotesAndQueries('')).toEqual({
insideTripleQuotes: false,
insideQuery: false,
insideSingleQuotesQuery: false,
insideTripleQuotesQuery: false,
queryIndex: -1,
});
});

it('should return false for both flags for a request without triple quotes', () => {
it('should return false for all flags for a request without triple quotes', () => {
const request = `POST _search\n{\n "query": {\n "match": {\n "message": "hello world"\n }\n }\n}`;
expect(isInsideTripleQuotes(request)).toEqual({
expect(checkForTripleQuotesAndQueries(request)).toEqual({
insideTripleQuotes: false,
insideQuery: false,
insideSingleQuotesQuery: false,
insideTripleQuotesQuery: false,
queryIndex: -1,
});
});

it('should return true for insideTripleQuotes and false for insideQuery if triple quotes are not in query', () => {
it('should return true for insideTripleQuotes and false for insideTripleQuotesQuery 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(isInsideTripleQuotes(request)).toEqual({
expect(checkForTripleQuotesAndQueries(request)).toEqual({
insideTripleQuotes: true,
insideQuery: false,
insideSingleQuotesQuery: false,
insideTripleQuotesQuery: false,
queryIndex: -1,
});
});

it('should return false for both flags if triple-quoted string is properly closed', () => {
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(isInsideTripleQuotes(request)).toEqual({
expect(checkForTripleQuotesAndQueries(request)).toEqual({
insideTripleQuotes: false,
insideQuery: false,
insideSingleQuotesQuery: false,
insideTripleQuotesQuery: false,
queryIndex: -1,
});
});

it('should return true for both flags if inside triple quotes and inside a "query" field', () => {
const request = `POST _query\n{\n "query": """FROM test `;
expect(isInsideTripleQuotes(request)).toEqual({
it('should return true for both insideTripleQuotes and insideTripleQuotesQuery if inside a "query" field', () => {
const request = `POST _search\n{\n "query": """FROM test `;
expect(checkForTripleQuotesAndQueries(request)).toEqual({
insideTripleQuotes: true,
insideQuery: true,
insideSingleQuotesQuery: false,
insideTripleQuotesQuery: true,
queryIndex: request.indexOf('"""') + 3,
});
});

it('should return true for insideSingleQuotesQuery if inside a single-quoted "query" string', () => {
const request = `GET index/_search\n{\n "query": "SELECT * FROM logs `;
expect(checkForTripleQuotesAndQueries(request)).toEqual({
insideTripleQuotes: false,
insideSingleQuotesQuery: true,
insideTripleQuotesQuery: false,
queryIndex: 32,
});
});

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({
insideTripleQuotes: false,
insideSingleQuotesQuery: false,
insideTripleQuotesQuery: false,
queryIndex: -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({
insideTripleQuotes: false,
insideSingleQuotesQuery: true,
insideTripleQuotesQuery: false,
queryIndex: 26,
});
});

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,
});
});
});

describe('unescapeInvalidChars', () => {
it('should return the original string if there are no escape sequences', () => {
const input = 'simple string';
expect(unescapeInvalidChars(input)).toBe('simple string');
});

it('should unescape escaped double quotes', () => {
const input = '\\"hello\\"';
expect(unescapeInvalidChars(input)).toBe('"hello"');
});

it('should unescape escaped backslashes', () => {
const input = 'path\\\\to\\\\file';
expect(unescapeInvalidChars(input)).toBe('path\\to\\file');
});

it('should unescape both escaped backslashes and quotes', () => {
const input = 'say: \\"hello\\" and path: C:\\\\Program Files\\\\App';
expect(unescapeInvalidChars(input)).toBe('say: "hello" and path: C:\\Program Files\\App');
});

it('should handle mixed content correctly', () => {
const input = 'log: \\"User \\\\\\"admin\\\\\\" logged in\\"';
expect(unescapeInvalidChars(input)).toBe('log: "User \\"admin\\" logged in"');
});

it('should leave already unescaped characters alone', () => {
const input = '"already unescaped" \\ and /';
expect(unescapeInvalidChars(input)).toBe('"already unescaped" \\ and /');
});

it('should not over-unescape multiple backslashes', () => {
const input = '\\\\\\\\"test\\\\"';
// \\\\\" becomes \\", \\ becomes \
expect(unescapeInvalidChars(input)).toBe('\\"test"');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,69 @@
*/

/**
* This function determines whether the given text ends with unclosed triple quotes
* and whether it ends with an unclosed triple-quotes query ("query": """...)
* This function takes a Console text up to the current position and determines whether
* the current position is inside triple quotes, triple-quote or single-quote query,
* and the start index of the current query.
* @param text The text up to the current position
*/
export const isInsideTripleQuotes = (
export const checkForTripleQuotesAndQueries = (
text: string
): { insideTripleQuotes: boolean; insideQuery: boolean } => {
): {
insideTripleQuotes: boolean;
insideSingleQuotesQuery: boolean;
insideTripleQuotesQuery: boolean;
queryIndex: number;
} => {
let insideSingleQuotes = false;
let insideTripleQuotes = false;
let isCurrentTripleQuoteQuery = false;

let insideSingleQuotesQuery = false;
let insideTripleQuotesQuery = false;

let currentQueryStartIndex = -1;
let i = 0;

while (i < text.length) {
if (text.startsWith('"""', i)) {
insideTripleQuotes = !insideTripleQuotes;
if (insideTripleQuotes) {
isCurrentTripleQuoteQuery = /.*"query"\s*:\s*/.test(text.slice(0, i));
insideTripleQuotesQuery = /.*"query"\s*:\s*$/.test(text.slice(0, i));
if (insideTripleQuotesQuery) {
currentQueryStartIndex = i + 3;
}
} else {
insideTripleQuotesQuery = false;
currentQueryStartIndex = -1;
}
i += 3; // Skip the triple quotes
} else if (text.at(i) === '"' && text.at(i - 1) !== '\\') {
insideSingleQuotes = !insideSingleQuotes;
if (insideSingleQuotes) {
insideSingleQuotesQuery = /.*"query"\s*:\s*$/.test(text.slice(0, i));
if (insideSingleQuotesQuery) {
currentQueryStartIndex = i + 1;
}
} else {
insideSingleQuotesQuery = false;
currentQueryStartIndex = -1;
}
i++;
} else {
i++;
}
}

return { insideTripleQuotes, insideQuery: insideTripleQuotes && isCurrentTripleQuoteQuery };
return {
insideTripleQuotes,
insideSingleQuotesQuery,
insideTripleQuotesQuery,
queryIndex: currentQueryStartIndex,
};
};

/**
* This function unescapes chars that are invalid in a Console string.
*/
export const unescapeInvalidChars = (str: string): string => {
return str.replace(/\\\\/g, '\\').replace(/\\"/g, '"');
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

export { isInsideTripleQuotes } from './autocomplete_utils';
export { checkForTripleQuotesAndQueries, unescapeInvalidChars } from './autocomplete_utils';
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ import { monaco } from '../../../../monaco_imports';
import { MonacoAutocompleteCommandDefinition } from '../types';
import { offsetRangeToMonacoRange } from '../shared/utils';

function escapeForStringLiteral(str: string): string {
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}

export function wrapAsMonacoSuggestions(
suggestions: SuggestionRawDefinition[],
fullText: string,
defineRange: boolean = true
defineRange: boolean = true,
escapeSpecialChars: boolean = false
): MonacoAutocompleteCommandDefinition[] {
return suggestions.map<MonacoAutocompleteCommandDefinition>(
({
Expand All @@ -32,7 +37,7 @@ export function wrapAsMonacoSuggestions(
}) => {
const monacoSuggestion: MonacoAutocompleteCommandDefinition = {
label,
insertText: text,
insertText: escapeSpecialChars ? escapeForStringLiteral(text) : text,
filterText,
kind:
kind in monaco.languages.CompletionItemKind
Expand Down
Loading