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

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 @@ -215,6 +215,72 @@ export const listCompleteItem: ISuggestionItem = withAutoSuggest({
sortText: 'A',
});

export const likePatternItems: ISuggestionItem[] = [
{
label: '%',
text: '"${0:%}"',
asSnippet: true,
kind: 'Value',
detail: i18n.translate('kbn-esql-ast.esql.autocomplete.likePercentDoc', {
defaultMessage: 'Matches any sequence of zero or more characters',
}),
sortText: '1',
},
{
label: '_',
text: '"${0:_}"',
asSnippet: true,
kind: 'Value',
detail: i18n.translate('kbn-esql-ast.esql.autocomplete.likeUnderscoreDoc', {
defaultMessage: 'Matches any single character',
}),
sortText: '1',
},
];

export const rlikePatternItems: ISuggestionItem[] = [
{
label: '.*',
text: '"${0:.*}"',
asSnippet: true,
kind: 'Value',
detail: i18n.translate('kbn-esql-ast.esql.autocomplete.rlikeAnyStringDoc', {
defaultMessage: 'Matches any sequence of zero or more characters',
}),
sortText: '1',
},
{
label: '.',
text: '"${0:.}"',
asSnippet: true,
kind: 'Value',
detail: i18n.translate('kbn-esql-ast.esql.autocomplete.rlikeAnySingleCharDoc', {
defaultMessage: 'Matches any single character',
}),
sortText: '1',
},
{
label: '^',
text: '"${0:^}"',
asSnippet: true,
kind: 'Value',
detail: i18n.translate('kbn-esql-ast.esql.autocomplete.rlikeStartAnchorDoc', {
defaultMessage: 'Match to the start of the string',
}),
sortText: '1',
},
{
label: '$',
text: '"${0:$}"',
asSnippet: true,
kind: 'Value',
detail: i18n.translate('kbn-esql-ast.esql.autocomplete.rlikeEndAnchorDoc', {
defaultMessage: 'Match to the end of the string',
}),
sortText: '1',
},
];

export const getCommandAutocompleteDefinitions = (commands: string[]): ISuggestionItem[] => {
const suggestions: ISuggestionItem[] = [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,18 @@ import type { ISuggestionItem } from '../../../../../commands_registry/types';
import {
listCompleteItem,
commaCompleteItem,
likePatternItems,
rlikePatternItems,
} from '../../../../../commands_registry/complete_items';
import type { ESQLColumn, ESQLFunction } from '../../../../../types';
import type { ESQLColumn, ESQLFunction, ESQLSingleAstItem } from '../../../../../types';
import { getBinaryExpressionOperand, getExpressionType } from '../../../expressions';
import type { ExpressionContext } from '../types';
import { getLogicalContinuationSuggestions, shouldSuggestOpenListForOperand } from './utils';
import {
getLogicalContinuationSuggestions,
isOperandMissing,
shouldSuggestOpenListForOperand,
} from './utils';
import { shouldSuggestComma } from '../comma_decision_engine';
import { buildConstantsDefinitions } from '../../../literals';
import { SuggestionBuilder } from '../suggestion_builder';
import { withAutoSuggest } from '../../helpers';

Expand All @@ -31,8 +36,11 @@ export async function handleListOperator(ctx: ExpressionContext): Promise<ISugge
const { expressionRoot, innerText } = ctx;

const fn = expressionRoot as ESQLFunction;
const list = getBinaryExpressionOperand(fn, 'right');
const leftOperand = getBinaryExpressionOperand(fn, 'left');

// For IN/NOT IN, args are never arrays because the parser builds a single ESQLList node
// containing the values, not a JS array of values.
const list = getBinaryExpressionOperand(fn, 'right') as ESQLSingleAstItem | undefined;
const leftOperand = getBinaryExpressionOperand(fn, 'left') as ESQLSingleAstItem | undefined;

// No list yet: suggest opening parenthesis
if (shouldSuggestOpenListForOperand(list)) {
Expand Down Expand Up @@ -145,34 +153,10 @@ export async function handleStringListOperator(
}

const operator = fn.name.toLowerCase();
const rightOperand = getBinaryExpressionOperand(fn, 'right');
const leftOperand = getBinaryExpressionOperand(fn, 'left');

// No list yet: suggest any string expressions (LIKE pattern can be any string expression)
if (shouldSuggestOpenListForOperand(rightOperand)) {
// LIKE/RLIKE accepts any string pattern, so suggest all string-compatible expressions
const builder = new SuggestionBuilder(context);

const ignoredColumns = isColumn(leftOperand)
? [leftOperand.parts.join('.')].filter(Boolean)
: [];

await builder.addFields({
types: ['any'],
ignoredColumns,
});

builder
.addFunctions({
types: ['any'],
})
.addLiterals({
types: ['any'],
includeDateLiterals: false,
includeCompatibleLiterals: true,
});
const rightOperand = getBinaryExpressionOperand(fn, 'right') as ESQLSingleAstItem | undefined;

return builder.build();
if (isOperandMissing(rightOperand)) {
return getStringPatternSuggestions(operator);
}

// Only handle list form; otherwise, delegate by returning null
Expand All @@ -193,7 +177,6 @@ export async function handleStringListOperator(
shouldSuggestComma({
position: 'inside_list',
innerText: context.innerText,
listHasValues: list.values && list.values.length > 0,
})
) {
return [{ ...commaCompleteItem, text: ', ' }];
Expand All @@ -203,13 +186,7 @@ export async function handleStringListOperator(
return getStringPatternSuggestions(operator);
}

/** Returns basic pattern suggestions for LIKE/RLIKE operators */
/** Returns pattern suggestions for LIKE/RLIKE operators */
function getStringPatternSuggestions(operator: string): ISuggestionItem[] {
const isRlike = operator.includes('rlike');

// RLIKE: empty string, match anything, match from start to end
// LIKE: empty string, wildcard for any characters
const patterns = isRlike ? ['""', '".*"', '"^.*$"'] : ['""', '"*"'];

return buildConstantsDefinitions(patterns, undefined, 'A');
return operator.includes('rlike') ? rlikePatternItems : likePatternItems;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ import type {
ESQLSingleAstItem,
} from '../../../../../../types';
import type { PartialOperatorDetection } from '../../types';
import { endsWithInOrNotInToken, endsWithIsOrIsNotToken, endsWithLikeOrRlikeToken } from '../utils';
import {
endsWithInOrNotInToken,
endsWithIsOrIsNotToken,
endsWithLikeOrRlikeToken,
LIKE_OPERATOR_REGEX,
NOT_IN_REGEX,
IS_NOT_REGEX,
} from '../utils';
import { Builder } from '../../../../../../builder';

const NOT_LIKE_REGEX = /\bnot\s+like\s*$/i;
const NOT_IN_REGEX = /\bnot\s+in\s*$/i;
const IS_NOT_REGEX = /\bis\s+not\b/i;

// Regex to extract field name before operator: match[1] = fieldName
// Matches with or without opening parenthesis
const FIELD_BEFORE_IN_REGEX = /([\w.]+)\s+(?:not\s+)?in\s*\(?\s*$/i;
Expand Down Expand Up @@ -126,18 +129,25 @@ export function detectNullCheck(innerText: string): PartialOperatorDetection | n
}

/**
* Detects partial LIKE / NOT LIKE operators.
* Examples: "field LIKE ", "field NOT LIKE "
* Detects partial LIKE / RLIKE / NOT LIKE / NOT RLIKE operators.
* Examples: "field LIKE ", "field RLIKE ", "field NOT LIKE ", "field NOT RLIKE "
*/
export function detectLike(innerText: string): PartialOperatorDetection | null {
if (!endsWithLikeOrRlikeToken(innerText)) {
return null;
}

const isNotLike = NOT_LIKE_REGEX.test(innerText);
const match = innerText.match(LIKE_OPERATOR_REGEX);

if (!match) {
return null;
}

// Normalize: lowercase, trim, collapse multiple spaces
const operatorName = match[0].toLowerCase().trim().replace(/\s+/g, ' ');

return {
operatorName: isNotLike ? 'not like' : 'like',
operatorName,
textBeforeCursor: innerText,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,40 @@ import { isList } from '../../../../../ast/is';
import { isMarkerNode } from '../../../ast';
import { getOperatorSuggestion } from '../../../operators';
import type { ISuggestionItem } from '../../../../../commands_registry/types';
import type { ESQLSingleAstItem } from '../../../../../types';
import { logicalOperators } from '../../../../all_operators';

/** Returns true if we should suggest opening a list for the right operand */
export function shouldSuggestOpenListForOperand(operand: any): boolean {
export const LIKE_OPERATOR_REGEX = /\b(not\s+)?(r?like)\s*$/i;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that we had it like that but isn't it dangerous to check for the operands with regex in the entire queryString? As we are looking into it in this PR, does it makes sense to check here if we can use AST to detect the operands instead of regexes?

Copy link
Contributor Author

@bartoval bartoval Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regexes are used to handle unknown cases when these 3 operators are incomplete. However, they are only used to route between "partials" and "non-partials." Afterwards, I either use the ast node if it exists or create a synthetic one.

Operator Outside Function Inside ANY Function Regex Necessary?
IS NULL  Parser ✅ Parser ❌ (unknown) ✅ Yes (for nested)
IS NOT NULL  Parser ✅ Parser ❌ (unknown) ✅ Yes (for nested)
IN  Parser ✅ Parser ❌ (unknown) ✅ Yes (for nested)
NOT IN  Parser ✅ Parser ❌ (unknown) ✅ Yes (for nested)
LIKE/RLIKE  Parser ❌ Parser ❌ (unknown) ✅ Yes (always)
export const IS_NOT_REGEX = /\bis\s+not\b/i;
export const IS_NULL_OPERATOR_REGEX =
/\bis\s+(?:n(?:o(?:t(?:\s+n(?:u(?:l)?)?|\s*)?)?|u(?:l)?)?)?$/i;
export const IN_OPERATOR_REGEX = /\b(?:not\s+)?in\s*\(?\s*$/i;
export const NOT_IN_REGEX = /\bnot\s+in\s*$/i;

export function endsWithInOrNotInToken(innerText: string): boolean {
return IN_OPERATOR_REGEX.test(innerText);
}

export function endsWithLikeOrRlikeToken(innerText: string): boolean {
return LIKE_OPERATOR_REGEX.test(innerText);
}

export function endsWithIsOrIsNotToken(innerText: string): boolean {
return IS_NULL_OPERATOR_REGEX.test(innerText);
}

export function isOperandMissing(operand: ESQLSingleAstItem | undefined): boolean {
return (
!operand ||
isMarkerNode(operand) ||
(operand?.type === 'unknown' && operand?.incomplete === true) ||
(operand?.type === 'unknown' && operand?.incomplete === true)
);
}

/** Returns true if we should suggest opening a list for the right operand */
export function shouldSuggestOpenListForOperand(operand: ESQLSingleAstItem | undefined): boolean {
return (
isOperandMissing(operand) ||
(isList(operand) && operand.location.min === 0 && operand.location.max === 0)
);
}
Expand All @@ -27,15 +53,3 @@ export function shouldSuggestOpenListForOperand(operand: any): boolean {
export function getLogicalContinuationSuggestions(): ISuggestionItem[] {
return logicalOperators.map(getOperatorSuggestion);
}

export function endsWithInOrNotInToken(innerText: string): boolean {
return /\b(?:not\s+)?in\s*\(?\s*$/i.test(innerText);
}

export function endsWithLikeOrRlikeToken(innerText: string): boolean {
return /\b(?:not\s+)?r?like\s+$/i.test(innerText);
}

export function endsWithIsOrIsNotToken(innerText: string): boolean {
return /\bis\s+(?:n(?:o(?:t(?:\s+n(?:u(?:l(?:l)?)?)?|\s*)?)?|u(?:l(?:l)?)?)?)?$/i.test(innerText);
}