275 lines
9.8 KiB
TypeScript
275 lines
9.8 KiB
TypeScript
export type ElasticsearchCompletionMode = "method" | "path" | "json";
|
|
|
|
export interface ElasticsearchCompletionItem {
|
|
label: string;
|
|
type: "keyword" | "property" | "text" | "snippet";
|
|
detail?: string;
|
|
info?: string;
|
|
apply?: string;
|
|
boost: number;
|
|
}
|
|
|
|
export interface ElasticsearchCompletionContext {
|
|
mode: ElasticsearchCompletionMode;
|
|
prefix: string;
|
|
from: number;
|
|
method?: string;
|
|
path?: string;
|
|
segmentIndex?: number;
|
|
}
|
|
|
|
export interface ElasticsearchCompletionInput {
|
|
indices?: string[];
|
|
}
|
|
|
|
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE"] as const;
|
|
|
|
const ROOT_ENDPOINTS = [
|
|
{ label: "/_search", apply: "_search", method: "GET", detail: "Search all indices" },
|
|
{ label: "/_cat/indices", apply: "_cat/indices?v", method: "GET", detail: "List indices" },
|
|
{ label: "/_cluster/health", apply: "_cluster/health", method: "GET", detail: "Cluster health" },
|
|
{ label: "/_aliases", apply: "_aliases", method: "GET", detail: "List aliases" },
|
|
{ label: "/_bulk", apply: "_bulk\n", method: "POST", detail: "Bulk operations" },
|
|
{ label: "/_count", apply: "_count", method: "GET", detail: "Count documents" },
|
|
];
|
|
|
|
const INDEX_ENDPOINTS = [
|
|
{
|
|
label: "_search",
|
|
apply: '_search\n{\n "query": {\n "match_all": {}\n }\n}',
|
|
method: "GET",
|
|
detail: "Search this index",
|
|
},
|
|
{ label: "_mapping", apply: "_mapping", method: "GET", detail: "Show mapping" },
|
|
{ label: "_settings", apply: "_settings", method: "GET", detail: "Show settings" },
|
|
{ label: "_count", apply: "_count", method: "GET", detail: "Count documents" },
|
|
{
|
|
label: "_doc",
|
|
apply: '_doc/${}\n{\n "${}": "${}"\n}',
|
|
method: "POST",
|
|
detail: "Create document",
|
|
},
|
|
{ label: "_refresh", apply: "_refresh", method: "POST", detail: "Refresh index" },
|
|
];
|
|
|
|
const JSON_KEYWORDS = ["query", "bool", "must", "should", "must_not", "filter", "match", "match_all", "term", "terms", "range", "exists", "sort", "aggs", "aggregations", "size", "from", "_source", "fields", "track_total_hits"];
|
|
|
|
const JSON_SNIPPETS = [
|
|
{
|
|
label: "match_all",
|
|
apply: '"match_all": {}',
|
|
detail: "Match all documents",
|
|
},
|
|
{
|
|
label: "bool",
|
|
apply: '"bool": {\n "must": [\n {}\n ],\n "filter": []\n}',
|
|
detail: "Bool query",
|
|
},
|
|
{
|
|
label: "range",
|
|
apply: '"range": {\n "${field}": {\n "gte": "${value}"\n }\n}',
|
|
detail: "Range query",
|
|
},
|
|
{
|
|
label: "terms",
|
|
apply: '"terms": {\n "${field}": []\n}',
|
|
detail: "Terms query",
|
|
},
|
|
];
|
|
|
|
export function getElasticsearchCompletionContext(text: string, cursor: number): ElasticsearchCompletionContext {
|
|
const safeCursor = Math.max(0, Math.min(cursor, text.length));
|
|
const lineStart = text.lastIndexOf("\n", safeCursor - 1) + 1;
|
|
const lineEnd = text.indexOf("\n", lineStart);
|
|
const currentLineEnd = lineEnd >= 0 ? lineEnd : text.length;
|
|
const beforeCursorOnLine = text.slice(lineStart, safeCursor);
|
|
const firstLineEnd = text.indexOf("\n");
|
|
const firstLineLimit = firstLineEnd >= 0 ? firstLineEnd : text.length;
|
|
|
|
if (lineStart > 0 || safeCursor > firstLineLimit || looksLikeJsonBody(text, safeCursor)) {
|
|
const jsonPrefix = readJsonPrefix(text, safeCursor);
|
|
return { mode: "json", prefix: jsonPrefix.prefix, from: jsonPrefix.from };
|
|
}
|
|
|
|
const methodMatch = /^([A-Za-z]*)$/.exec(beforeCursorOnLine);
|
|
if (methodMatch) {
|
|
return {
|
|
mode: "method",
|
|
prefix: methodMatch[1] ?? "",
|
|
from: lineStart,
|
|
};
|
|
}
|
|
|
|
const commandMatch = /^([A-Za-z]+)\s+(\S*)/.exec(text.slice(lineStart, currentLineEnd));
|
|
if (!commandMatch) {
|
|
const prefix = readWordPrefix(text, safeCursor);
|
|
return { mode: "method", prefix: prefix.prefix, from: prefix.from };
|
|
}
|
|
|
|
const method = commandMatch[1]?.toUpperCase();
|
|
const path = commandMatch[2] ?? "";
|
|
const pathStart = lineStart + (commandMatch[0].indexOf(path) >= 0 ? commandMatch[0].indexOf(path) : 0);
|
|
const pathCursor = Math.max(0, safeCursor - pathStart);
|
|
const boundedPathCursor = Math.min(pathCursor, path.length);
|
|
const beforePathCursor = path.slice(0, boundedPathCursor);
|
|
const segmentStartInPath = beforePathCursor.lastIndexOf("/") + 1;
|
|
const prefix = beforePathCursor.slice(segmentStartInPath);
|
|
const segmentIndex = beforePathCursor.slice(0, segmentStartInPath).split("/").filter(Boolean).length;
|
|
|
|
return {
|
|
mode: "path",
|
|
prefix,
|
|
from: pathStart + segmentStartInPath,
|
|
method,
|
|
path,
|
|
segmentIndex,
|
|
};
|
|
}
|
|
|
|
export function buildElasticsearchCompletionItems(text: string, cursor: number, input: ElasticsearchCompletionInput = {}): ElasticsearchCompletionItem[] {
|
|
const context = getElasticsearchCompletionContext(text, cursor);
|
|
return buildElasticsearchCompletionItemsFromContext(context, input);
|
|
}
|
|
|
|
export function buildElasticsearchCompletionItemsFromContext(context: ElasticsearchCompletionContext, input: ElasticsearchCompletionInput = {}): ElasticsearchCompletionItem[] {
|
|
if (context.mode === "method") return methodItems(context.prefix);
|
|
if (context.mode === "json") return jsonItems(context.prefix);
|
|
return pathItems(context, input.indices ?? []);
|
|
}
|
|
|
|
export function shouldAutoOpenElasticsearchCompletion(text: string, cursor: number): boolean {
|
|
const previousChar = text[cursor - 1];
|
|
if (!previousChar) return false;
|
|
if (/[{,}\]\n\r]/.test(previousChar)) return false;
|
|
if (/[\w/_."]/.test(previousChar)) return true;
|
|
return false;
|
|
}
|
|
|
|
export function getElasticsearchCompletionResultValidFor(): RegExp {
|
|
return /[\w/_."]*$/;
|
|
}
|
|
|
|
function methodItems(prefix: string): ElasticsearchCompletionItem[] {
|
|
return HTTP_METHODS.filter((method) => matchesPrefix(method, prefix)).map((method) => ({
|
|
label: method,
|
|
type: "keyword",
|
|
detail: "HTTP method",
|
|
apply: `${method} /`,
|
|
boost: 120,
|
|
}));
|
|
}
|
|
|
|
function pathItems(context: ElasticsearchCompletionContext, indices: string[]): ElasticsearchCompletionItem[] {
|
|
const items: ElasticsearchCompletionItem[] = [];
|
|
const path = context.path ?? "";
|
|
const segments = path.split("/").filter(Boolean);
|
|
const isFirstSegment = context.segmentIndex === 0;
|
|
const isRootApiSegment = isFirstSegment && context.prefix.startsWith("_");
|
|
|
|
if (isFirstSegment && !isRootApiSegment) {
|
|
items.push(...indexItems(context.prefix, indices));
|
|
}
|
|
|
|
if (isFirstSegment || isRootApiSegment) {
|
|
items.push(...rootEndpointItems(context.prefix));
|
|
}
|
|
|
|
if (segments.length >= 1 && !segments[0]?.startsWith("_")) {
|
|
items.push(...indexEndpointItems(context.prefix));
|
|
}
|
|
|
|
return dedupeAndSort(items);
|
|
}
|
|
|
|
function indexItems(prefix: string, indices: string[]): ElasticsearchCompletionItem[] {
|
|
return indices
|
|
.filter((index) => matchesFuzzyPrefix(index, prefix))
|
|
.slice(0, 100)
|
|
.map((index) => ({
|
|
label: index,
|
|
type: "text" as const,
|
|
detail: "index",
|
|
apply: index,
|
|
boost: index.toLowerCase().startsWith(prefix.toLowerCase()) ? 110 : 80,
|
|
}));
|
|
}
|
|
|
|
function rootEndpointItems(prefix: string): ElasticsearchCompletionItem[] {
|
|
const normalizedPrefix = prefix.startsWith("/") ? prefix.slice(1) : prefix;
|
|
return ROOT_ENDPOINTS.filter((endpoint) => matchesFuzzyPrefix(endpoint.label.slice(1), normalizedPrefix)).map((endpoint) => ({
|
|
label: endpoint.label,
|
|
type: endpoint.apply.includes("\n") ? ("snippet" as const) : ("property" as const),
|
|
detail: `${endpoint.method} ${endpoint.detail}`,
|
|
apply: endpoint.apply,
|
|
boost: 95,
|
|
}));
|
|
}
|
|
|
|
function indexEndpointItems(prefix: string): ElasticsearchCompletionItem[] {
|
|
return INDEX_ENDPOINTS.filter((endpoint) => matchesFuzzyPrefix(endpoint.label, prefix)).map((endpoint) => ({
|
|
label: endpoint.label,
|
|
type: endpoint.apply.includes("\n") ? ("snippet" as const) : ("property" as const),
|
|
detail: `${endpoint.method} ${endpoint.detail}`,
|
|
apply: endpoint.apply,
|
|
boost: 100,
|
|
}));
|
|
}
|
|
|
|
function jsonItems(prefix: string): ElasticsearchCompletionItem[] {
|
|
const normalizedPrefix = prefix.replace(/^"/, "");
|
|
const keyItems = JSON_KEYWORDS.filter((key) => matchesFuzzyPrefix(key, normalizedPrefix)).map((key) => ({
|
|
label: `"${key}"`,
|
|
type: "property" as const,
|
|
detail: "Query DSL field",
|
|
apply: `"${key}"`,
|
|
boost: key.startsWith(normalizedPrefix) ? 95 : 70,
|
|
}));
|
|
const snippetItems = JSON_SNIPPETS.filter((snippet) => matchesFuzzyPrefix(snippet.label, normalizedPrefix)).map((snippet) => ({
|
|
label: snippet.label,
|
|
type: "snippet" as const,
|
|
detail: snippet.detail,
|
|
apply: snippet.apply,
|
|
boost: 105,
|
|
}));
|
|
return dedupeAndSort([...snippetItems, ...keyItems]);
|
|
}
|
|
|
|
function looksLikeJsonBody(text: string, cursor: number): boolean {
|
|
const before = text.slice(0, cursor);
|
|
return before.includes("\n") || before.lastIndexOf("{") > before.lastIndexOf("\n");
|
|
}
|
|
|
|
function readJsonPrefix(text: string, cursor: number): { prefix: string; from: number } {
|
|
let from = cursor;
|
|
while (from > 0 && /[\w_"]/.test(text[from - 1] ?? "")) from--;
|
|
return { prefix: text.slice(from, cursor), from };
|
|
}
|
|
|
|
function readWordPrefix(text: string, cursor: number): { prefix: string; from: number } {
|
|
let from = cursor;
|
|
while (from > 0 && /[A-Za-z]/.test(text[from - 1] ?? "")) from--;
|
|
return { prefix: text.slice(from, cursor), from };
|
|
}
|
|
|
|
function matchesPrefix(value: string, prefix: string): boolean {
|
|
return value.toLowerCase().startsWith(prefix.toLowerCase());
|
|
}
|
|
|
|
function matchesFuzzyPrefix(value: string, prefix: string): boolean {
|
|
const normalizedValue = value.toLowerCase();
|
|
const normalizedPrefix = prefix.toLowerCase();
|
|
return !normalizedPrefix || normalizedValue.includes(normalizedPrefix);
|
|
}
|
|
|
|
function dedupeAndSort(items: ElasticsearchCompletionItem[]): ElasticsearchCompletionItem[] {
|
|
const seen = new Set<string>();
|
|
const deduped: ElasticsearchCompletionItem[] = [];
|
|
for (const item of items) {
|
|
const key = `${item.type}:${item.label}:${item.apply ?? ""}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
deduped.push(item);
|
|
}
|
|
return deduped.sort((a, b) => b.boost - a.boost);
|
|
}
|