fix(editor): quote handling, the duplicate value path, and word suggestions

Three review findings, all confirmed.

1. MONACO'S AUTO-CLOSED QUOTE. Typing a quote leaves the closer sitting after
   the cursor, where a parser reading only the text BEFORE the cursor cannot see
   it -- so the value inserted its own and produced `severity = 'INFO''`.
   Reproduced in the app before the fix and gone after it.

   Suppressing our closer is not enough: it yields the right text but parks the
   cursor INSIDE the literal, and the next thing typed lands in the string. I
   found that by doing it, then typing ` AND service_name = ` and getting
   `severity = 'INFO AND service_name = INFO'`. The entry now EXTENDS its
   replacement range over monaco's quote and inserts its own, so the cursor ends
   up outside -- what VS Code does. Per entry, because a numeric value inserts no
   closer and swallowing the quote would leave `status = '200` unterminated.

2. THE LEGACY VALUE PATH. getSuggestions still ran the same lookup, pushed the
   result down as contextKeywords and force-opened the widget -- so every value
   edit resolved twice and re-opened the popup over a list the provider had
   already produced. The provider is now the only value path; ~145 lines of
   composable code go with it, including analyzeSqlWhereClause, whose job
   parseValueContext took over.

   Its tests were not deleted wholesale. Operator detection and quoting moved to
   the provider helpers and are covered there (and buildValueEntries had NO
   direct tests before this -- 12 added). The merge and the composite IDB key
   still live in the composable, so those tests were retargeted at
   resolveFieldValues rather than at the branch that no longer exists. The
   "suggestions blank while a context list shows" invariant survives too: the
   FROM branch is now its only producer.

3. WORD-BASED SUGGESTIONS were turned off for EVERY language. N4 was about SQL
   and PromQL, where every suggestion should come from the catalog; VRL, JS,
   JSON and markdown have no catalog, so this removed the only completion they
   had. Now gated on language, with a test per branch.

Not done, deliberately: the SLO scope field WAS getting values (confirmed with
the user). A claim in the first draft of this message -- that a metrics stream
never can, because the value store is only written by a Logs search -- was
WRONG, and is corrected here rather than left in the history. captureFromSearchHits
lives under useLogs/ because that is the Logs PAGE composable, not because it is
restricted to the logs stream TYPE: it captures under whatever type was searched,
and the Logs stream-type selector covers metrics and traces. Verified by searching
cache_hit_ratio in the Logs UI, after which the store held 21 metrics keys and the
SLO page resolved environment -> [development, staging]. Traces does not even need
the store: the Traces page keeps its own in-session fieldValues map, which
resolveFieldValues merges first.

The real limitation is narrower -- values are SEARCH-DERIVED, so a stream nobody
has searched has none. Whether to fetch on demand for that case is a product
question, not a bug. The mistake was reading a cold cache as a structural gap on
the strength of one grep.

1248 passing across the touched specs; type-check, eslint and prettier clean.
This commit is contained in:
Prabhat Sharma 2026-08-02 16:55:19 -07:00
parent c83bef5f55
commit 4d231b9e48
7 changed files with 263 additions and 415 deletions

View File

@ -505,6 +505,17 @@ describe("Phase 3 — providers are registered and configured", () => {
expect(opts.wordBasedSuggestions).toBe("off");
});
it("N4 — but NOT for the non-query languages", { timeout: 30000 }, async () => {
// N4 was about SQL and PromQL, where every suggestion should come from the
// catalog. VRL, JS, JSON and the rest have no catalog at all, so turning
// word-based completion off there removes the only completion they have.
for (const language of ["vrl", "javascript", "json", "markdown"]) {
const api = await mountEditor({ language });
const opts = vi.mocked(api.editor.create).mock.calls.at(-1)![1] as any;
expect(opts.wordBasedSuggestions, `${language} lost its word completion`).not.toBe("off");
}
});
it("N3 — quick suggestions are enabled inside string literals", { timeout: 30000 }, async () => {
const api = await mountEditor();
const opts = vi.mocked(api.editor.create).mock.calls.at(-1)![1] as any;

View File

@ -551,10 +551,13 @@ export default defineComponent({
// Monaco defaults strings to 'off', which is why field-VALUE completion
// used to need a forced hide/re-trigger to appear at all.
quickSuggestions: { other: "on", comments: "off", strings: "on" },
// Default is 'matchingDocuments'. Nothing observable comes from it in
// this app today, but the reason is not established this states the
// intent rather than relying on that continuing to hold.
wordBasedSuggestions: "off",
// Default is 'matchingDocuments'. Off for the QUERY languages only,
// where every suggestion should come from the catalog and a word
// scraped out of the query text is noise. VRL, JS, JSON and the rest
// have no catalog, and there local word completion is the only
// completion they have.
wordBasedSuggestions:
props.language === "sql" || props.language === "promql" ? "off" : "matchingDocuments",
stickyScroll: {
enabled: props.stickyScroll,
},
@ -818,9 +821,20 @@ export default defineComponent({
if (valueContext && resolver) {
const values = await resolver(valueContext.field);
if (values.length) {
// Monaco auto-closes a typed quote, so the closer is already
// sitting after the cursor invisible to a parser that only sees
// the text before it. Without this the insert produced
// `level = 'error''`.
const closingQuoteAhead =
(model.getLineContent?.(position.lineNumber) ?? "").charAt(position.column - 1) ===
"'";
return {
suggestions: buildCompletionItems({
keywords: buildValueEntries(values, valueContext.hasOpenQuote) as any[],
keywords: buildValueEntries(values, {
hasOpenQuote: valueContext.hasOpenQuote,
closingQuoteAhead,
range,
}) as any[],
suggestions: [],
word: word.word,
range,

View File

@ -58,239 +58,35 @@ const run = async (
return c.effectiveKeywords.value;
};
// ─── operator detection ───────────────────────────────────────────────────────
// ─── merge and IDB key: retargeted at the resolver ───────────────────────────
// The merge and the composite key still belong to this composable — only the
// caller changed. These were written against getSuggestions' value branch;
// that branch is gone (the provider awaits resolveFieldValues directly), so
// they now exercise the resolver. Cases the Phase 3 describe below already
// covers are not duplicated.
describe("analyzeSqlWhereClause — operator detection", () => {
describe("resolveFieldValues — in-session and stored value merge", () => {
beforeEach(() => vi.clearAllMocks());
const operators: [string, string][] = [
["=", "status = "],
["!=", "status != "],
["<>", "status <> "],
[">=", "code >= "],
["<=", "code <= "],
[">", "code > "],
["<", "code < "],
];
it.each(operators)("detects field %s operator", async (_op, query) => {
const c = makeComposable({ storedValues: ["200"] });
const keywords = await run(c, query);
expect(keywords.some((k: any) => k.kind === "Value")).toBe(true);
});
it("detects IN (", async () => {
const c = makeComposable({ storedValues: ["200"] });
const q = "status IN (";
const keywords = await run(c, q, q.length);
expect(keywords.some((k: any) => k.kind === "Value")).toBe(true);
});
it("detects NOT IN (", async () => {
const c = makeComposable({ storedValues: ["200"] });
const q = "status NOT IN (";
const keywords = await run(c, q, q.length);
expect(keywords.some((k: any) => k.kind === "Value")).toBe(true);
});
it("detects LIKE (space)", async () => {
const c = makeComposable({ storedValues: ["api"] });
const keywords = await run(c, "msg LIKE ");
expect(keywords.some((k: any) => k.kind === "Value")).toBe(true);
});
it("detects LIKE with open quote", async () => {
const c = makeComposable({ storedValues: ["api"] });
const q = "msg LIKE '";
const keywords = await run(c, q, q.length);
expect(keywords.some((k: any) => k.kind === "Value")).toBe(true);
});
it("detects NOT LIKE", async () => {
const c = makeComposable({ storedValues: ["api"] });
const q = "path NOT LIKE '";
const keywords = await run(c, q, q.length);
expect(keywords.some((k: any) => k.kind === "Value")).toBe(true);
});
it("detects str_match(field, )", async () => {
const c = makeComposable({ storedValues: ["frontend"] });
const q = "str_match(service, '";
const keywords = await run(c, q, q.length);
expect(keywords.some((k: any) => k.kind === "Value")).toBe(true);
});
it("detects fuzzy_match(field, )", async () => {
const c = makeComposable({ storedValues: ["frontend"] });
const q = "fuzzy_match(service, '";
const keywords = await run(c, q, q.length);
expect(keywords.some((k: any) => k.kind === "Value")).toBe(true);
});
it("handles auto-closed bracket IN () — cursor between ( and )", async () => {
const c = makeComposable({ storedValues: ["200"] });
const full = "status IN ()";
// cursor is between ( and ) — Monaco offset 11, getCursorIndex = offset-1 = 10.
// slice(0, 10+1) = "status IN (" which matches the IN regex.
const keywords = await run(c, full, 10);
expect(keywords.some((k: any) => k.kind === "Value")).toBe(true);
});
it("detects partial typed value after operator: field = '20", async () => {
const c = makeComposable({ storedValues: ["200"] });
const q = "status = '20";
const keywords = await run(c, q, q.length);
expect(keywords.some((k: any) => k.kind === "Value")).toBe(true);
});
it("falls through to keywords when no operator context", async () => {
const c = makeComposable({ storedValues: [] });
const keywords = await run(c, "SELECT * FROM stream WHERE ");
expect(keywords.some((k: any) => k.kind === "Value")).toBe(false);
expect(keywords.length).toBeGreaterThan(0);
});
it("shows default keywords for empty query", async () => {
const c = makeComposable({ storedValues: [] });
const keywords = await run(c, "");
expect(keywords.some((k: any) => k.kind === "Value")).toBe(false);
expect(keywords.length).toBeGreaterThan(0);
});
});
// ─── insertText quoting logic ─────────────────────────────────────────────────
describe("getSuggestions — insertText quoting", () => {
beforeEach(() => vi.clearAllMocks());
it("wraps string value in single quotes when no open quote", async () => {
const c = makeComposable({ storedValues: ["prod"] });
const keywords = await run(c, "env = ");
const item = keywords.find((k: any) => k.label === "prod");
expect(item?.insertText).toBe("'prod'");
});
it("closes only when open quote already typed", async () => {
const c = makeComposable({ storedValues: ["prod"] });
const q = "env = '";
const keywords = await run(c, q, q.length);
const item = keywords.find((k: any) => k.label === "prod");
expect(item?.insertText).toBe("prod'");
});
it("wraps in quotes for second condition when first condition's closing quote is in the query", async () => {
// Regression: http = 'te' and host = <cursor>
// The closing quote of 'te' must NOT be mistaken for an open quote for host.
const c = makeComposable({ storedValues: ["node-1"] });
const q = "http = 'te' and host = ";
const keywords = await run(c, q, q.length);
const item = keywords.find((k: any) => k.label === "node-1");
expect(item?.insertText).toBe("'node-1'");
});
it("closes only when second condition genuinely has an open quote", async () => {
const c = makeComposable({ storedValues: ["node-1"] });
const q = "http = 'te' and host = '";
const keywords = await run(c, q, q.length);
const item = keywords.find((k: any) => k.label === "node-1");
expect(item?.insertText).toBe("node-1'");
});
it("inserts numeric values without quotes", async () => {
const c = makeComposable({ storedValues: ["200", "404"] });
const keywords = await run(c, "status = ");
const item = keywords.find((k: any) => k.label === "200");
expect(item?.insertText).toBe("200");
});
it("inserts boolean 'true' without quotes", async () => {
const c = makeComposable({ storedValues: ["true"] });
const keywords = await run(c, "active = ");
const item = keywords.find((k: any) => k.label === "true");
expect(item?.insertText).toBe("true");
});
it("inserts boolean 'false' without quotes", async () => {
const c = makeComposable({ storedValues: ["false"] });
const keywords = await run(c, "active = ");
const item = keywords.find((k: any) => k.label === "false");
expect(item?.insertText).toBe("false");
});
it("sortText starts with \\x01 so values sort above keywords", async () => {
const c = makeComposable({ storedValues: ["200"] });
const keywords = await run(c, "status = ");
const item = keywords.find((k: any) => k.kind === "Value");
expect(item?.sortText.startsWith("\x01")).toBe(true);
});
it("all value suggestions have kind = 'Value'", async () => {
const c = makeComposable({ storedValues: ["200", "404", "500"] });
const keywords = await run(c, "status = ");
const valueItems = keywords.filter((k: any) => k.kind === "Value");
expect(valueItems).toHaveLength(3);
});
});
// ─── merge: in-session vs stored ─────────────────────────────────────────────
describe("getSuggestions — in-session and stored value merge", () => {
beforeEach(() => vi.clearAllMocks());
it("shows in-session values from fieldValues prop", async () => {
const c = makeComposable({
storedValues: [],
inSessionValues: { status: ["200", "404"] },
});
const keywords = await run(c, "status = ");
const labels = keywords.map((k: any) => k.label);
expect(labels).toContain("200");
expect(labels).toContain("404");
});
it("merges stored values with in-session values", async () => {
const c = makeComposable({
storedValues: ["500"],
inSessionValues: { status: ["200"] },
});
const keywords = await run(c, "status = ");
const labels = keywords.map((k: any) => k.label);
expect(labels).toContain("200");
expect(labels).toContain("500");
});
it("deduplicates values appearing in both sources", async () => {
const c = makeComposable({
storedValues: ["200", "500"],
inSessionValues: { status: ["200", "404"] },
});
const keywords = await run(c, "status = ");
const labels = keywords.map((k: any) => k.label);
expect(labels.filter((l: string) => l === "200")).toHaveLength(1);
expect(labels).toContain("404");
expect(labels).toContain("500");
const values = await c.resolveFieldValues("status");
expect(values.filter((v: string) => v === "200")).toHaveLength(1);
expect(values).toContain("404");
expect(values).toContain("500");
});
it("skips IDB read when stream context is missing", async () => {
it("skips the IDB read when stream context is missing", async () => {
const c = useSqlSuggestions();
// org/streamType/streamName left as empty strings
c.autoCompleteData.value.fieldValues = {};
c.autoCompleteData.value.popup.open = vi.fn();
await run(c, "status = ");
await c.resolveFieldValues("status");
expect(getFieldValuesForSuggestion).not.toHaveBeenCalled();
});
it("falls through to updateAutoComplete when merged is empty", async () => {
const c = makeComposable({ storedValues: [] });
const keywords = await run(c, "status = ");
expect(keywords.some((k: any) => k.kind === "Value")).toBe(false);
expect(keywords.length).toBeGreaterThan(0);
});
});
// ─── IDB context forwarding ───────────────────────────────────────────────────
describe("getSuggestions — IDB context forwarding", () => {
it("passes org/streamType/streamName to getFieldValuesForSuggestion", async () => {
vi.mocked(getFieldValuesForSuggestion).mockResolvedValue(["prod"]);
const c = useSqlSuggestions();
@ -298,8 +94,7 @@ describe("getSuggestions — IDB context forwarding", () => {
c.autoCompleteData.value.streamType = "traces";
c.autoCompleteData.value.streamName = "default";
c.autoCompleteData.value.fieldValues = {};
c.autoCompleteData.value.popup.open = vi.fn();
await run(c, "env = ");
await c.resolveFieldValues("env");
expect(getFieldValuesForSuggestion).toHaveBeenCalledWith(
{ org: "acme", streamType: "traces", streamName: "default" },
"env",
@ -327,45 +122,34 @@ describe("updateFieldKeywords", () => {
});
});
// ─── effectiveSuggestions: no functions when showing values ───────────────────
// ─── effectiveSuggestions: no functions when a context list is showing ───────
// This describe used to drive the invariant through the value branch. That
// branch is gone, but the invariant is not — the FROM branch is now its only
// producer, and a stream list mixed with functions is exactly as wrong as a
// value list was.
describe("effectiveSuggestions — empty when value suggestions are shown", () => {
describe("effectiveSuggestions — empty while a context list is showing", () => {
beforeEach(() => vi.clearAllMocks());
it("is empty when value context is active", async () => {
const c = makeComposable({ storedValues: ["200", "404"] });
await run(c, "status = ");
it("is empty when the FROM context is active", async () => {
const c = makeComposable({ storedValues: [] });
c.updateStreamKeywords([{ name: "http_logs" }]);
await run(c, "SELECT * FROM ");
expect(c.effectiveKeywords.value.map((k: any) => k.label)).toEqual(["http_logs"]);
expect(c.effectiveSuggestions.value).toEqual([]);
});
it("effectiveKeywords has no Text-kind function items during value context", async () => {
const c = makeComposable({ storedValues: ["200"] });
await run(c, "status = ");
const functionItems = c.effectiveKeywords.value.filter((k: any) => k.kind === "Text");
expect(functionItems).toHaveLength(0);
});
it("only Value-kind items appear in effectiveKeywords during value context", async () => {
const c = makeComposable({ storedValues: ["200", "404"] });
await run(c, "status = ");
const kinds = c.effectiveKeywords.value.map((k: any) => k.kind);
expect(kinds.every((kind: string) => kind === "Value")).toBe(true);
});
it("is non-empty in normal (non-value) context", async () => {
it("is non-empty in normal context", async () => {
const c = makeComposable({ storedValues: [] });
await run(c, "SELECT * FROM stream WHERE ");
expect(c.effectiveSuggestions.value.length).toBeGreaterThan(0);
});
it("transitions back to non-empty after value context clears", async () => {
const c = makeComposable({ storedValues: ["200"] });
// First enter value context
await run(c, "status = ");
it("transitions back to non-empty once the context clears", async () => {
const c = makeComposable({ storedValues: [] });
c.updateStreamKeywords([{ name: "http_logs" }]);
await run(c, "SELECT * FROM ");
expect(c.effectiveSuggestions.value).toEqual([]);
// Then move to a context with no operator match (no stored values for empty)
vi.mocked(getFieldValuesForSuggestion).mockResolvedValue([]);
c.autoCompleteData.value.fieldValues = {};
await run(c, "SELECT * FROM stream WHERE ");
expect(c.effectiveSuggestions.value.length).toBeGreaterThan(0);
});
@ -500,12 +284,16 @@ describe("Phase 2 — SQL clause keywords are offered (B1)", () => {
expect(labels).toContain("=");
});
it("suppresses clause keywords in value context", async () => {
const c = makeComposable({ storedValues: ["error"] });
await run(c, "level = ");
it("suppresses clause keywords while a context list is showing", async () => {
// Was written against the value branch, which no longer exists here; the
// FROM branch is now the only producer of a context list, and mixing
// SELECT into a list of stream names is the same mistake.
const c = makeComposable({ storedValues: [] });
c.updateStreamKeywords([{ name: "http_logs" }]);
await run(c, "SELECT * FROM ");
const labels = c.effectiveKeywords.value.map((k: any) => k.label);
expect(labels).not.toContain("SELECT");
expect(labels).toEqual(["error"]);
expect(labels).toEqual(["http_logs"]);
});
it("sorts fields ahead of clause keywords", async () => {
@ -560,10 +348,11 @@ describe("Phase 2 — server-supplied functions reach the suggestion list (B4)",
expect(sums[0].insertText).toBe("sum(${1:field})");
});
it("still blanks the suggestion list in value context", async () => {
const c = makeComposable({ storedValues: ["error"] });
it("still blanks the suggestion list while a context list is showing", async () => {
const c = makeComposable({ storedValues: [] });
c.setServerFunctions(serverList);
await run(c, "level = ");
c.updateStreamKeywords([{ name: "http_logs" }]);
await run(c, "SELECT * FROM ");
expect(c.effectiveSuggestions.value).toEqual([]);
});

View File

@ -133,74 +133,6 @@ const useSqlSuggestions = () => {
contextKeywords.value.length ? [] : autoCompleteSuggestions.value,
);
function analyzeSqlWhereClause(whereClause: string, cursorIndex: number) {
const labelMeta = {
hasLabels: false,
isFocused: false,
isEmpty: true,
focusOn: "", // label or value
meta: {
label: "",
value: "",
hasOpenQuote: false,
},
};
// Detects whether the cursor is positioned after an operator that expects
// a value, and extracts the field name to the left of that operator.
//
// 4 alternatives — each captures the field name in a different group:
// match[1]: symbolic operators = != <> >= <= > <
// e.g. "status = ", "code >= ", "env != 'pro"
// match[2]: IN / NOT IN (
// e.g. "status IN (", "env NOT IN ('pro"
// match[3]: LIKE / NOT LIKE
// e.g. "msg LIKE '", "path NOT LIKE '%api"
// match[4]: str_match / fuzzy_match function second argument
// e.g. "str_match(field, ", "fuzzy_match(field, 'par"
//
// Why >=/<= appear before >/<:
// Regex alternation is left-to-right. If > appeared first, ">=" would
// match on ">" and stop, leaving "=" unmatched. Longer tokens must come first.
//
// Why (?:'[^']*)?$ at the end of each alternative:
// Allows the regex to match even after the user has typed an opening quote
// and a partial value. Without it, "status = 'pro" would not match — we
// would stop showing value suggestions the moment the user starts typing.
const columnValueRegex =
/(\w+)\s*(?:!=|<>|>=|<=|=|>|<)\s*(?:'[^']*)?$|(\w+)\s+(?:NOT\s+)?IN\s+\(\s*(?:'[^']*)?$|(\w+)\s+(?:NOT\s+)?LIKE\s*(?:'[^']*)?$|(?:str_match|fuzzy_match)\s*\(\s*(\w+)\s*,\s*(?:'[^']*)?$/i;
// Slice the query at the cursor position before matching, so that the $
// anchor lands at the cursor — not at the end of the full query string.
//
// Why this matters — auto-closing brackets example:
// User types "status IN (" → editor auto-inserts ")" → full string is "status IN ()"
// Without slicing: $ anchors after ")" → regex does NOT match
// After slicing at cursor (between "(" and ")"): text is "status IN (" → matches
//
// Slice the WHERE clause up to (and including) the cursor position.
// Fall back to full string length only when cursorIndex is negative,
// which indicates no cursor tracking (e.g. called without a position).
const endIdx = cursorIndex >= 0 ? cursorIndex + 1 : whereClause.length;
const textUpToCursor = whereClause.slice(0, endIdx);
const match = columnValueRegex.exec(textUpToCursor);
if (match) {
labelMeta.focusOn = "value";
labelMeta.isFocused = true;
// Pick whichever capture group matched — only one will be non-null.
labelMeta.meta.label = match[1] ?? match[2] ?? match[3] ?? match[4];
// True when the user has already typed an opening quote, e.g. field = 'partial
// In this case insertText should be value' (close only), not 'value'
//
// Scope the check to the text starting at the current match, not the full
// query. A closed quote from a preceding condition (e.g. http = 'te') has
// no trailing non-quote chars after it until cursor, which would otherwise
// make /'[^']*$/ fire and wrongly set hasOpenQuote for the new condition.
labelMeta.meta.hasOpenQuote = /'[^']*$/.test(textUpToCursor.slice(match.index));
}
return labelMeta;
}
/**
* Field values for one column, for the completion provider to await directly.
*
@ -230,6 +162,16 @@ const useSqlSuggestions = () => {
return [...new Set([...inSession, ...stored])];
};
/**
* Context suggestions the PARENT still owns: stream names after FROM.
*
* Field VALUES used to be resolved here too the same lookup this file's
* resolveFieldValues does and then pushed down as contextKeywords with a
* forced popup.open. The completion provider now awaits the resolver inline
* (C4), so keeping that branch meant every value edit did the lookup twice
* and re-opened the widget on top of a list it had already produced. The
* provider is the only value path now.
*/
const getSuggestions = async () => {
// Awaited so the server functions are present on the FIRST popup, not the
// next keystroke.
@ -241,7 +183,8 @@ const useSqlSuggestions = () => {
const cursorIndex =
(autoCompleteData.value as any).cursorIndex ?? autoCompleteData.value.position.cursorIndex;
// Compute text up to cursor (same slice logic used by analyzeSqlWhereClause).
// Compute text up to cursor, so the FROM regex anchors at the cursor rather
// than at the end of the query.
const query = autoCompleteData.value.query;
const endIdx = cursorIndex >= 0 ? cursorIndex + 1 : query.length;
let textUpToCursor = query.slice(0, endIdx);
@ -284,83 +227,6 @@ const useSqlSuggestions = () => {
}
}
// Determine if the cursor is currently after an operator expecting a value.
// If so, sqlWhereClause.meta.label is the field name (e.g. "status").
const sqlWhereClause = analyzeSqlWhereClause(autoCompleteData.value.query, cursorIndex);
if (sqlWhereClause.meta.label) {
const fieldName = sqlWhereClause.meta.label;
// In-session values — collected from the current session's
// search result hits and stored in the reactive fieldValues prop.
// These are available immediately (no async) but disappear on page reload.
const inSessionValues = Array.from(
autoCompleteData.value.fieldValues[fieldName] || new Set(),
) as string[];
// Persisted values — read from IndexedDB (via in-memory cache).
// These survive page reloads and accumulate across multiple searches.
// Guard: only query IDB if stream context is set — without org/streamType/
// streamName we cannot build the composite key and would get empty results.
let storedValues: string[] = [];
if (
autoCompleteData.value.org &&
autoCompleteData.value.streamType &&
autoCompleteData.value.streamName
) {
storedValues = await getFieldValuesForSuggestion(
{
org: autoCompleteData.value.org,
streamType: autoCompleteData.value.streamType,
streamName: autoCompleteData.value.streamName,
},
fieldName,
);
}
// Merge in-session + stored, deduplicate via Set.
// inSessionValues come first so they appear at the top of the dropdown
// (they are from the current search context, most relevant).
// storedValues from previous sessions fill in anything not seen today.
const merged = [...new Set([...inSessionValues, ...storedValues])];
if (merged.length > 0) {
const hasOpenQuote = sqlWhereClause.meta.hasOpenQuote;
// Build Monaco suggestion items with smart quoting and sort order.
contextKeywords.value = merged.map((item, idx) => {
const isNumeric = item !== "" && !isNaN(Number(item));
const isBoolean = item === "true" || item === "false";
// Quoting rules:
// numeric / boolean → no quotes (SQL: status = 200, active = true)
// string, open quote already typed → close only (field = 'val → val')
// string, no open quote → wrap fully (field = → 'val')
let insertText: string;
if (isNumeric || isBoolean) {
insertText = item;
} else if (hasOpenQuote) {
insertText = `${item}'`; // user already typed the opening '
} else {
insertText = `'${item}'`;
}
// \x01 (ASCII 1) is the lowest-sorting printable character.
// Prefixing sortText with it ensures value suggestions always appear
// ABOVE keywords ("and", "or", "like") and functions in the Monaco
// dropdown, which sort by their label (starting with a letter > \x01).
// The padded index preserves the order of values as returned from IDB.
const sortText = `\x01${String(idx).padStart(6, "0")}`;
return { label: item, insertText, kind: "Value", sortText };
});
autoCompleteData.value.popup.open?.(autoCompleteData.value.query);
// Return early — do NOT fall through to updateAutoComplete().
// We don't want keywords/fields/functions mixed into a value dropdown.
return;
}
}
// Normal context — clear the context override so effectiveKeywords falls
// back to autoCompleteKeywords (fields + functions + SQL keywords).
contextKeywords.value = [];

View File

@ -32,6 +32,8 @@ import {
isNumericField,
wantsNumericColumn,
rankNumericFieldsFirst,
parseValueContext,
buildValueEntries,
} from "./editorProviders";
const fn = (name: string) => SQL_FUNCTIONS.find((f) => f.name === name)!;
@ -573,3 +575,123 @@ describe("rankNumericFieldsFirst", () => {
expect(order(ranked)).toEqual(["a_num", "b_num"]);
});
});
// ───────────────────────────────────────────────────────────────────────────
// Field VALUE completion
//
// parseValueContext decides the cursor is where a value belongs;
// buildValueEntries decides how to insert one. The quoting is the fiddly half:
// monaco AUTO-CLOSES a typed quote, so the text is already `level = ''` with
// the cursor between them, and appending our own closer produced
// `level = 'error''` — reported from the SLO scope field.
// ───────────────────────────────────────────────────────────────────────────
describe("parseValueContext — where a value belongs", () => {
it("recognises the comparison operators", () => {
for (const op of ["=", "!=", "<>", ">", "<", ">=", "<="]) {
expect(parseValueContext(`WHERE code ${op} `)?.field, op).toBe("code");
}
});
it("recognises IN, NOT IN and LIKE", () => {
expect(parseValueContext("WHERE level IN (")?.field).toBe("level");
expect(parseValueContext("WHERE level NOT IN (")?.field).toBe("level");
expect(parseValueContext("WHERE body LIKE ")?.field).toBe("body");
});
it("recognises the match functions' value argument", () => {
expect(parseValueContext("WHERE str_match(body, ")?.field).toBe("body");
expect(parseValueContext("WHERE fuzzy_match(body, ")?.field).toBe("body");
});
it("reports an open quote so the inserted value can close it", () => {
expect(parseValueContext("WHERE level = '")).toEqual({ field: "level", hasOpenQuote: true });
expect(parseValueContext("WHERE level = ")).toEqual({ field: "level", hasOpenQuote: false });
});
it("stays open while the value is being typed", () => {
expect(parseValueContext("WHERE level = 'err")).toEqual({ field: "level", hasOpenQuote: true });
});
it("is not fooled by a CLOSED quote from an earlier condition", () => {
// `http = 'te'` has no unterminated quote; the new condition must not
// inherit one, or its value would be inserted with a stray closer.
expect(parseValueContext("WHERE http = 'te' AND level = ")).toEqual({
field: "level",
hasOpenQuote: false,
});
});
it("returns null where no value belongs", () => {
expect(parseValueContext("")).toBeNull();
expect(parseValueContext("SELECT ")).toBeNull();
expect(parseValueContext("SELECT * FROM logs WHERE ")).toBeNull();
});
});
describe("buildValueEntries — inserting a value", () => {
const RANGE = { startLineNumber: 1, endLineNumber: 1, startColumn: 12, endColumn: 12 };
const insert = (v: string[], o: any) => buildValueEntries(v, o).map((e) => e.insertText);
it("wraps a bare string in quotes when none was typed", () => {
expect(insert(["error"], { hasOpenQuote: false })).toEqual(["'error'"]);
});
it("closes the quote the user opened", () => {
expect(insert(["error"], { hasOpenQuote: true })).toEqual(["error'"]);
});
it("leaves numbers and booleans unquoted", () => {
expect(insert(["200", "true", "false", "1.5"], { hasOpenQuote: false })).toEqual([
"200",
"true",
"false",
"1.5",
]);
});
it("quotes a value that only looks empty", () => {
expect(insert([""], { hasOpenQuote: false })).toEqual(["''"]);
});
it("sorts values above every other lane, in the order given", () => {
const entries = buildValueEntries(["b", "a"], { hasOpenQuote: false });
expect(entries.map((e) => e.sortText)).toEqual(["\u0000000000", "\u0000000001"]);
expect(entries.every((e) => e.kind === "Value")).toBe(true);
});
describe("when monaco has already auto-closed the quote", () => {
const opts = { hasOpenQuote: true, closingQuoteAhead: true, range: RANGE };
it("still inserts its own closing quote", () => {
expect(insert(["error"], opts)).toEqual(["error'"]);
});
it("extends the range over monaco's quote so it is not left behind", () => {
// Text-only alternatives (omit the closer, keep the range) produce the
// right string but park the cursor INSIDE the literal, so the next thing
// typed lands inside the quotes.
const [entry] = buildValueEntries(["error"], opts);
expect(entry.range).toEqual({ ...RANGE, endColumn: RANGE.endColumn + 1 });
});
it("does NOT extend it for a numeric value, which inserts no closer", () => {
// Swallowing the quote there would leave `status = '200` unterminated.
const [entry] = buildValueEntries(["200"], opts);
expect(entry.range).toBeUndefined();
expect(entry.insertText).toBe("200");
});
it("does not touch the range when no quote is ahead", () => {
expect(
buildValueEntries(["error"], { hasOpenQuote: true, range: RANGE })[0].range,
).toBeUndefined();
});
it("degrades to text-only when the caller supplies no range", () => {
const [entry] = buildValueEntries(["error"], { hasOpenQuote: true, closingQuoteAhead: true });
expect(entry.insertText).toBe("error'");
expect(entry.range).toBeUndefined();
});
});
});

View File

@ -289,23 +289,60 @@ export const parseValueContext = (
};
};
/** Build completion entries for resolved field values, quoted appropriately. */
export const buildValueEntries = (values: string[], hasOpenQuote: boolean): LooseEntry[] =>
export interface ValueEntryOptions {
/** The user has typed an opening quote, so the value only needs closing. */
hasOpenQuote: boolean;
/**
* Monaco's auto-closed quote sits immediately after the cursor.
*
* Invisible to a parser that sees only the text BEFORE the cursor: typing a
* quote makes the text `level = ''` with the cursor between them, and
* appending our own closer produced `level = 'error''`.
*/
closingQuoteAhead?: boolean;
/** The replacement range monaco reported. Needed to swallow that quote. */
range?: Record<string, number>;
}
/**
* Build completion entries for resolved field values, quoted appropriately.
*
* When monaco has already auto-closed the quote, the entry EXTENDS its
* replacement range over that quote and inserts its own. Simply omitting the
* closer produces the right TEXT but leaves the cursor inside the string, so
* the next thing typed lands inside the quotes -- which is how
* `severity = 'INFO AND service_name = INFO'` happened while testing this.
* Numeric values keep the plain range: they insert no closer, so swallowing
* the quote would leave the literal unterminated.
*/
export const buildValueEntries = (
values: string[],
{ hasOpenQuote, closingQuoteAhead = false, range }: ValueEntryOptions,
): LooseEntry[] =>
values.map((value, index) => {
const isNumeric = value !== "" && !Number.isNaN(Number(value));
const isBoolean = value === "true" || value === "false";
let insertText: string;
if (isNumeric || isBoolean) insertText = value;
else if (hasOpenQuote) insertText = `${value}'`;
else insertText = `'${value}'`;
return {
const entry: LooseEntry = {
name: value,
label: value,
kind: "Value",
insertText,
insertText: value,
// Values sort above every other lane. Written as an ESCAPE, not a raw
// control character: a literal NUL in the source makes the file binary to
// git and grep, and is silently easy to mangle in an edit.
// control character: a literal NUL in the source makes the file binary
// to git and grep, and is silently easy to mangle in an edit.
sortText: `\u0000${String(index).padStart(6, "0")}`,
};
if (isNumeric || isBoolean) return entry;
if (!hasOpenQuote) {
entry.insertText = `'${value}'`;
return entry;
}
entry.insertText = `${value}'`;
if (closingQuoteAhead && range) {
entry.range = { ...range, endColumn: (range.endColumn ?? 1) + 1 };
}
return entry;
});

View File

@ -66,6 +66,13 @@ export interface SqlCompletionEntry {
sortText?: string;
/** Still accepted by the backend, but rewritten to something else. */
deprecated?: boolean;
/**
* Replacement range for THIS entry, overriding the one the provider computed.
*
* Only field values use it, to extend over monaco's auto-closed quote. The
* shared range is right for everything else, so leaving it unset is normal.
*/
range?: Record<string, number>;
}
/** An entry as it may arrive from the `suggestions` prop callers outside this
@ -906,7 +913,9 @@ const toMonacoItem = (
label,
kind: kinds[entry.kind ?? "Text"],
insertText,
range,
// An entry may widen its own range — a field value swallows monaco's
// auto-closed quote so the cursor ends up outside the string.
range: entry.range ?? range,
};
// The string name MUST be translated here. Monaco does `insertTextRules & 4`,