diff --git a/src/core/completions.js b/src/core/completions.js index ef0dbf6..1e64c12 100644 --- a/src/core/completions.js +++ b/src/core/completions.js @@ -11,6 +11,22 @@ import { SQL_KEYWORDS, SQL_FUNCS } from './sql-highlight.js'; const BUILTIN_KEYWORDS = [...SQL_KEYWORDS]; const BUILTIN_FUNCS = [...SQL_FUNCS]; +// Common ClickHouse output formats — the fallback for FORMAT-clause completion +// when system.formats isn't available (offline / old server / denied). The live +// set (all is_output formats) replaces this once a connection loads. +const BUILTIN_FORMATS = [ + 'CSV', 'CSVWithNames', 'JSON', 'JSONCompact', 'JSONEachRow', 'Markdown', 'Null', + 'Parquet', 'Pretty', 'PrettyCompact', 'TabSeparated', 'TabSeparatedWithNames', + 'TSV', 'TSVWithNames', 'Values', 'Vertical', 'XML', +]; + +// Clause keywords that share a name with an obscure function — typing the prefix +// almost always means the clause, so let the keyword win that tie once the user +// has typed enough to mean it. Deliberately tiny: most keyword/function name +// clashes (min, max, replace, left, in, like, …) should keep favoring the +// function, so only FORMAT (clause vs the rarely-used format() function) is here. +const PREFER_KEYWORD = new Set(['FORMAT']); + // Built-in hover docs for a few ClickHouse-specific keywords (#27). There's no // server table for keyword docs, so this static set covers the high-value ones; // function docs come from system.functions (loaded per connection). @@ -27,10 +43,12 @@ const KEYWORD_DOCS = { * Turn a loaded reference payload (or null) into the editor's in-memory shape: * { keywords: string[], // completion candidates * functions: { name: {kind,sig,ret,desc} }, + * formats: string[], // output formats for FORMAT-clause completion + * keywordDocs: { KW: doc }, // static hover docs * keywordSet: Set, // tokenizer highlight lookup * funcSet: Set } // tokenizer highlight lookup * Missing pieces fall back to the built-in sets so highlighting + keyword/ - * function completion still work offline / on older ClickHouse. + * function/format completion still work offline / on older ClickHouse. */ export function assembleReferenceData(loaded) { const keywords = loaded && loaded.keywords && loaded.keywords.length @@ -39,9 +57,11 @@ export function assembleReferenceData(loaded) { const functions = loaded && loaded.functions && Object.keys(loaded.functions).length ? loaded.functions : Object.fromEntries(BUILTIN_FUNCS.map((name) => [name, { kind: 'fn', sig: name + '()', ret: '', desc: '' }])); + const formats = loaded && loaded.formats && loaded.formats.length ? loaded.formats : BUILTIN_FORMATS; return { keywords, functions, + formats, keywordDocs: KEYWORD_DOCS, // for hover docs (#27); static built-in set keywordSet: new Set(keywords.map((k) => k.toUpperCase())), funcSet: new Set(Object.keys(functions)), @@ -65,7 +85,12 @@ export function buildCompletions(ref, schema) { // the parenthesised params — `(s, offset[, …])`, not `substring(s, …)` (#26). const sig = m.sig || name + '()'; const paren = sig.indexOf('('); - items.push({ label: name, kind, insert: name + '(', detail: paren >= 0 ? sig.slice(paren) : sig, doc: m.desc || '', ret: m.ret || '' }); + // Insert `name()` and (via caretBack) leave the caret between the parens — a + // matched pair like typing `(` gives, so accepting never strands a lone `(`. + items.push({ label: name, kind, insert: name + '()', caretBack: 1, detail: paren >= 0 ? sig.slice(paren) : sig, doc: m.desc || '', ret: m.ret || '' }); + } + for (const name of ref.formats || []) { + items.push({ label: name, kind: 'format', insert: name, detail: 'format' }); } for (const db of schema || []) { items.push({ label: db.db, kind: 'db', insert: db.db, detail: 'database' }); @@ -82,13 +107,22 @@ export function buildCompletions(ref, schema) { } /** - * The word being typed at the caret, and whether it is qualified (after a dot — - * `table.` → that table's columns). Returns {word, from, to, qualified, parent}. + * The word being typed at the caret, whether it is qualified (after a dot — + * `table.` → that table's columns), and whether it sits inside a FORMAT clause + * (`afterFormat` — the preceding token is FORMAT → complete output-format names). + * Returns {word, from, to, qualified, parent, afterFormat}. */ export function completionContext(value, pos) { let s = pos; while (s > 0 && /[A-Za-z0-9_]/.test(value[s - 1])) s--; const word = value.slice(s, pos); + // Inside a FORMAT clause? (the identifier just before the word is `FORMAT`) → + // complete output-format names instead of the general candidate set. + let b = s; + while (b > 0 && /\s/.test(value[b - 1])) b--; + let pf = b; + while (pf > 0 && /[A-Za-z0-9_]/.test(value[pf - 1])) pf--; + const afterFormat = value.slice(pf, b).toUpperCase() === 'FORMAT'; let qualified = false; let parent = null; if (value[s - 1] === '.') { @@ -100,7 +134,7 @@ export function completionContext(value, pos) { // and an empty dropdown — fall back to normal completion instead (#4 review). if (name) { qualified = true; parent = name; } } - return { word, from: s, to: pos, qualified, parent }; + return { word, from: s, to: pos, qualified, parent, afterFormat }; } /** @@ -115,17 +149,29 @@ export function rankCompletions(items, ctx) { const cols = items.filter((it) => it.kind === 'column' && it.parent === ctx.parent); return (w ? cols.filter((c) => c.label.toLowerCase().includes(w)) : cols).slice(0, 50); } + if (ctx.afterFormat) { + // FORMAT clause: only output-format names, prefix matches first. + const fmts = items.filter((it) => it.kind === 'format' && (!w || it.label.toLowerCase().includes(w))); + if (w) fmts.sort((a, b) => a.label.toLowerCase().indexOf(w) - b.label.toLowerCase().indexOf(w) || a.label.localeCompare(b.label)); + return fmts.slice(0, 50); + } if (!w) { return items.filter((it) => it.kind === 'keyword' || it.kind === 'table').slice(0, 40); } const scored = []; for (const it of items) { + if (it.kind === 'format') continue; // formats only inside a FORMAT clause const l = it.label.toLowerCase(); const idx = l.indexOf(w); if (idx === -1) continue; let score = idx === 0 ? 0 : 100 + idx; // prefix beats substring if (it.kind === 'column' || it.kind === 'table') score -= 10; // boost schema - if (it.kind === 'keyword') score += 5; + if (it.kind === 'keyword') { + // A clause keyword sharing a name with an obscure function wins the tie + // once enough of it is typed (≥3 chars, prefix) — e.g. `for` → FORMAT, not + // the format() function or formatDateTime; shorter prefixes stay neutral. + score += (idx === 0 && w.length >= 3 && PREFER_KEYWORD.has(it.label.toUpperCase())) ? -50 : 5; + } score += (l.length - w.length) * 0.1; // prefer closer length scored.push({ it, score }); } diff --git a/src/core/format.js b/src/core/format.js index 5b36283..0ca05f6 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -74,6 +74,12 @@ export function detectSqlFormat(sql) { return m ? m[1] : null; } +/** True if the statement is an EXPLAIN (leading keyword). EXPLAIN output is plan + * text, so the caller renders it raw unless the user gave an explicit FORMAT. Pure. */ +export function isExplain(sql) { + return /^\s*EXPLAIN\b/i.test(String(sql || '')); +} + /** * Derive a short display name for a saved query: "Query · " when a * FROM clause is present, else the first 48 chars of the collapsed SQL. diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 9353afb..f1ddb88 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -174,7 +174,8 @@ function firstLine(s) { * (loadEntityDoc, #27). Each source is best-effort; a missing/denied system * table yields null for that piece and the caller (assembleReferenceData) falls * back to the built-in set. - * Returns { keywords: string[]|null, functions: {name:{kind,sig,ret,desc}}|null }. + * Returns { keywords, functions, formats } — each null when its source is + * missing/denied (the caller falls back to a built-in set). */ export async function loadReferenceData(ctx) { const kw = await tryQueryData(ctx, 'SELECT keyword FROM system.keywords FORMAT JSON'); @@ -196,7 +197,11 @@ export async function loadReferenceData(ctx) { }; } } - return { keywords, functions }; + // Output format names for FORMAT-clause completion (system.formats); a separate + // catalog from keywords/functions, so it needs its own fetch. + const fmts = await tryQueryData(ctx, 'SELECT name FROM system.formats WHERE is_output ORDER BY name FORMAT JSON'); + const formats = fmts ? fmts.map((r) => r.name) : null; + return { keywords, functions, formats }; } /** @@ -231,11 +236,14 @@ export async function loadEntityDoc(ctx, name, sqlString) { export async function runQuery(ctx, sql, o = {}) { const fmt = o.format || 'Table'; const isStreaming = fmt === 'Table'; + // Streaming gets the progress-bearing JSON; raw mode sends the requested format + // verbatim as default_format (a real ClickHouse format name from a FORMAT clause + // or an implicit EXPLAIN). 'TSV' keeps its with-names-and-types expansion. const fmtParam = isStreaming ? 'JSONStringsEachRowWithProgress' : fmt === 'TSV' ? 'TabSeparatedWithNamesAndTypes' - : 'JSONCompact'; + : fmt; const url = chUrl(ctx.origin, { format: fmtParam, // wait_end_of_query buffers the whole response server-side so the HTTP diff --git a/src/ui/app.js b/src/ui/app.js index 7035cb4..de41cc7 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -11,7 +11,7 @@ import { } from '../state.js'; import { saveJSON, saveStr } from '../core/storage.js'; import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; -import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat } from '../core/format.js'; +import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak, detectSqlFormat, isExplain } from '../core/format.js'; import { resolveTarget } from '../core/target.js'; import { toTSV, toCSV } from '../core/export.js'; import { newResult, applyStreamLine } from '../core/stream.js'; @@ -378,9 +378,10 @@ export function createApp(env = {}) { await ensureConfig(); if (!(await getToken())) { chCtx.onSignedOut(); return; } - // Default to structured streaming (Table); if the user ends their SQL with a - // FORMAT clause, run raw and show ClickHouse's response verbatim (#format). - const fmt = detectSqlFormat(tab.sql) || 'Table'; + // Default to structured streaming (Table); an explicit FORMAT clause runs raw + // and shows ClickHouse's response verbatim, and so does EXPLAIN (its plan text + // reads far better raw than as a one-column table) unless a FORMAT was given. + const fmt = detectSqlFormat(tab.sql) || (isExplain(tab.sql) ? 'TabSeparated' : 'Table'); const t0 = now(); tab.result = newResult(fmt); app.state.resultSort = { col: null, dir: 'asc' }; diff --git a/src/ui/editor-complete.js b/src/ui/editor-complete.js index 598e82b..8c80bc0 100644 --- a/src/ui/editor-complete.js +++ b/src/ui/editor-complete.js @@ -7,7 +7,8 @@ // host = { // textarea, // getCompletions(), // () => candidate list (or []) -// replaceRange(from, to, text), // undoable edit that fires 'input' +// replaceRange(from, to, text, caretBack?), // undoable edit; caretBack pulls +// // the caret left from the end // caretAnchor(), // () => {x, y, lineHeight} in screen px // appendPopover(el), // mount the dropdown // suppressed(), // () => true to stay hidden (e.g. find open) @@ -32,6 +33,7 @@ const KIND_META = { table: { glyph: '▦', color: 'var(--accent)' }, column: { glyph: '▪', color: '#92E1D8' }, db: { glyph: '◈', color: '#A0A0A8' }, + format: { glyph: '≡', color: '#A0A0A8' }, }; export function createComplete(host) { @@ -76,7 +78,7 @@ export function createComplete(host) { const accept = (item) => { accepting = true; // the resulting 'input' must not re-trigger the dropdown - host.replaceRange(state.ctx.from, state.ctx.to, item.insert); + host.replaceRange(state.ctx.from, state.ctx.to, item.insert, item.caretBack || 0); accepting = false; hide(); }; diff --git a/src/ui/editor.js b/src/ui/editor.js index ff8ad1a..0f3983b 100644 --- a/src/ui/editor.js +++ b/src/ui/editor.js @@ -148,11 +148,14 @@ export function mountEditor(app, container) { gutter.scrollTop = ta.scrollTop; }; // Set the textarea selection to a range and replace it (undoable, fires input). - const replaceRange = (start, end, text) => { + // `caretBack` pulls the caret left from the end of the inserted text — used by + // function completion to land it between the just-inserted `()`. + const replaceRange = (start, end, text, caretBack = 0) => { ta.focus(); ta.selectionStart = start; ta.selectionEnd = end; applyEdit(ta, text); + if (caretBack) ta.selectionStart = ta.selectionEnd = start + text.length - caretBack; }; // Apply a structural bracket edit (#24) while PRESERVING the native undo stack. // A direct `ta.value = …` assignment wipes ⌘Z, so instead express the edit as a diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index dd058c4..b310bc7 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -349,6 +349,23 @@ describe('query run', () => { expect(app.activeTab().result.rawText).toBe('a\tb'); expect(app.activeTab().result.rawFormat).toBe('TabSeparatedWithNames'); // label for the raw tab }); + it('runs EXPLAIN raw (plan text) when no explicit FORMAT is given', async () => { + const { app } = appForRun([ + [(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'Expression\n ReadFromTable' })], + ]); + app.activeTab().sql = 'EXPLAIN SELECT 1'; + await app.actions.run(); + expect(app.activeTab().result.rawText).toBe('Expression\n ReadFromTable'); + expect(app.activeTab().result.rawFormat).toBe('TabSeparated'); // plain TS → no header noise + }); + it('an explicit FORMAT on an EXPLAIN still wins over the raw default', async () => { + const { app } = appForRun([ + [(u, sql) => /EXPLAIN/.test(sql), resp({ text: '{"plan":[]}' })], + ]); + app.activeTab().sql = 'EXPLAIN SELECT 1 FORMAT JSON'; + await app.actions.run(); + expect(app.activeTab().result.rawFormat).toBe('JSON'); // FORMAT clause, not the EXPLAIN default + }); }); describe('formatQuery', () => { diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index d5eabea..1b513ee 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -200,11 +200,11 @@ describe('loadReferenceData', () => { }); it('returns null pieces when a system table is missing/denied (best-effort)', async () => { const ctx = ctxWith(async () => textResp('Code: 60. DB::Exception: Unknown table', false, 500)); - expect(await loadReferenceData(ctx)).toEqual({ keywords: null, functions: null }); + expect(await loadReferenceData(ctx)).toEqual({ keywords: null, functions: null, formats: null }); }); it('tolerates an empty data shape', async () => { const ctx = ctxWith(async () => jsonResp({})); - expect(await loadReferenceData(ctx)).toEqual({ keywords: [], functions: {} }); + expect(await loadReferenceData(ctx)).toEqual({ keywords: [], functions: {}, formats: [] }); }); it('uses the syntax column for signatures; descriptions are NOT bulk-loaded (lazy, #27)', async () => { const ctx = ctxWith(async (url, o) => ( diff --git a/tests/unit/completions.test.js b/tests/unit/completions.test.js index 97d83a5..1ccab32 100644 --- a/tests/unit/completions.test.js +++ b/tests/unit/completions.test.js @@ -81,7 +81,7 @@ describe('buildCompletions', () => { const items = buildCompletions(ref, schema); expect(items.find((i) => i.label === 'SELECT')).toMatchObject({ kind: 'keyword', insert: 'SELECT' }); // detail shows only the params (the label already shows the name) — #26 - expect(items.find((i) => i.label === 'count')).toMatchObject({ kind: 'agg', insert: 'count(', detail: '([x])', ret: 'UInt64' }); + expect(items.find((i) => i.label === 'count')).toMatchObject({ kind: 'agg', insert: 'count()', caretBack: 1, detail: '([x])', ret: 'UInt64' }); expect(items.find((i) => i.label === 'toDate')).toMatchObject({ kind: 'cast', detail: '(x)' }); expect(items.find((i) => i.label === 'lower')).toMatchObject({ kind: 'fn', detail: '()' }); // sig fallback → just () expect(items.find((i) => i.label === 'plus')).toMatchObject({ kind: 'fn', detail: 'a + b' }); // no '(' → sig kept as-is @@ -100,13 +100,13 @@ describe('buildCompletions', () => { describe('completionContext', () => { it('reads the word at the caret', () => { - expect(completionContext('SELECT cou', 10)).toEqual({ word: 'cou', from: 7, to: 10, qualified: false, parent: null }); + expect(completionContext('SELECT cou', 10)).toEqual({ word: 'cou', from: 7, to: 10, qualified: false, parent: null, afterFormat: false }); }); it('detects a qualified word after a dot and its parent', () => { - expect(completionContext('ontime.Ye', 9)).toEqual({ word: 'Ye', from: 7, to: 9, qualified: true, parent: 'ontime' }); + expect(completionContext('ontime.Ye', 9)).toEqual({ word: 'Ye', from: 7, to: 9, qualified: true, parent: 'ontime', afterFormat: false }); }); it('qualified with empty word right after the dot', () => { - expect(completionContext('ontime.', 7)).toEqual({ word: '', from: 7, to: 7, qualified: true, parent: 'ontime' }); + expect(completionContext('ontime.', 7)).toEqual({ word: '', from: 7, to: 7, qualified: true, parent: 'ontime', afterFormat: false }); }); it('word at the very start', () => { expect(completionContext('SEL', 3)).toMatchObject({ word: 'SEL', from: 0, qualified: false }); @@ -151,3 +151,49 @@ describe('rankCompletions', () => { expect(r).not.toContainEqual(expect.objectContaining({ label: 'SELECT' })); }); }); + +describe('FORMAT-clause completion', () => { + it('assembleReferenceData uses loaded formats, falling back to a built-in set', () => { + expect(assembleReferenceData({ formats: ['Vertical', 'CSV'] }).formats).toEqual(['Vertical', 'CSV']); + const fb = assembleReferenceData(null).formats; + expect(fb).toContain('JSONEachRow'); + expect(fb).toContain('Vertical'); + expect(assembleReferenceData({ formats: [] }).formats).toEqual(fb); // empty → fallback + }); + it('buildCompletions includes format candidates', () => { + const ref = assembleReferenceData({ keywords: ['SELECT'], formats: ['Vertical', 'TSV'] }); + const fmts = buildCompletions(ref, []).filter((it) => it.kind === 'format'); + expect(fmts.map((f) => f.label)).toEqual(['Vertical', 'TSV']); + expect(fmts[0]).toMatchObject({ insert: 'Vertical', detail: 'format' }); + }); + it('completionContext flags a word inside a FORMAT clause', () => { + expect(completionContext('SELECT 1 FORMAT Ver', 19).afterFormat).toBe(true); + expect(completionContext('SELECT 1 FORMAT ', 16).afterFormat).toBe(true); // empty word after FORMAT + expect(completionContext('SELECT format', 13).afterFormat).toBe(false); // FORMAT is the word being typed + expect(completionContext('SELECT 1 FROM t', 15).afterFormat).toBe(false); + }); + it('rankCompletions: a FORMAT clause shows only formats (prefix first); excluded elsewhere', () => { + const items = buildCompletions(assembleReferenceData({ keywords: ['SELECT', 'FORMAT'], formats: ['JSONEachRow', 'JSONCompact', 'Vertical'] }), []); + // empty word inside FORMAT → every format, source order + expect(rankCompletions(items, { word: '', qualified: false, afterFormat: true }).map((i) => i.label)) + .toEqual(['JSONEachRow', 'JSONCompact', 'Vertical']); + // typed word → filtered; both prefix-match, so alpha order + expect(rankCompletions(items, { word: 'json', qualified: false, afterFormat: true }).map((i) => i.label)) + .toEqual(['JSONCompact', 'JSONEachRow']); + // general completion never surfaces formats + expect(rankCompletions(items, { word: 'json', qualified: false, afterFormat: false }).some((i) => i.kind === 'format')).toBe(false); + }); + it('prefers the FORMAT clause keyword over format()/formatDateTime once ≥3 chars are typed', () => { + const ref = assembleReferenceData({ + keywords: ['FORMAT', 'FROM'], + functions: { format: { kind: 'fn', sig: 'format(p, …)' }, formatDateTime: { kind: 'fn', sig: 'formatDateTime(t)' } }, + }); + const items = buildCompletions(ref, []); + // 'for' → the keyword wins + const top = rankCompletions(items, { word: 'for', qualified: false, afterFormat: false }); + expect(top[0]).toMatchObject({ label: 'FORMAT', kind: 'keyword' }); + // too short to disambiguate → keyword is not specially boosted (function leads) + const short = rankCompletions(items, { word: 'fo', qualified: false, afterFormat: false }); + expect(short[0].kind).not.toBe('keyword'); + }); +}); diff --git a/tests/unit/editor.test.js b/tests/unit/editor.test.js index 8e91763..b6020a0 100644 --- a/tests/unit/editor.test.js +++ b/tests/unit/editor.test.js @@ -487,8 +487,8 @@ describe('bracket matching + auto-close (#24)', () => { describe('autocomplete dropdown (#26)', () => { const CANDIDATES = [ { label: 'SELECT', kind: 'keyword', insert: 'SELECT', detail: 'keyword' }, - { label: 'count', kind: 'agg', insert: 'count(', detail: 'count([x])', ret: 'UInt64', doc: 'Counts rows.' }, - { label: 'concat', kind: 'fn', insert: 'concat(', detail: 'concat(s, …)', ret: 'String' }, // sig, no doc + { label: 'count', kind: 'agg', insert: 'count()', caretBack: 1, detail: 'count([x])', ret: 'UInt64', doc: 'Counts rows.' }, + { label: 'concat', kind: 'fn', insert: 'concat()', caretBack: 1, detail: 'concat(s, …)', ret: 'String' }, // sig, no doc { label: 'cobalt', kind: 'mystery', insert: 'cobalt', detail: '?' }, // unknown kind → glyph fallback { label: 'ontime', kind: 'table', insert: 'ontime', detail: 'table', parent: 'airline' }, { label: 'Year', kind: 'column', insert: 'Year', detail: 'UInt16', parent: 'ontime' }, @@ -540,7 +540,7 @@ describe('autocomplete dropdown (#26)', () => { typeAt(ta, 'ontime.', 7); expect(labels(container).sort()).toEqual(['Month', 'Year']); }); - it('arrows move the active row (wrapping); Enter accepts and a function inserts name(', () => { + it('arrows move the active row (wrapping); Enter accepts a function as name() with the caret inside', () => { const { app, container, ta } = mounted(); expect(app.dom.editorComplete.isOpen()).toBe(false); typeAt(ta, 'co', 2); @@ -554,7 +554,9 @@ describe('autocomplete dropdown (#26)', () => { press(ta, 'Enter'); expect(app.dom.editorComplete.isOpen()).toBe(false); expect(dropdown(container)).toBeNull(); - expect(ta.value).toContain(accepted + '('); // count/concat → name( + expect(ta.value).toContain(accepted + '()'); // function → matched name() + expect(ta.value[ta.selectionStart - 1]).toBe('('); // caret left between the parens + expect(ta.value[ta.selectionStart]).toBe(')'); }); it('Tab accepts a column as-is; Escape dismisses', () => { const { container, ta } = mounted(); diff --git a/tests/unit/format.test.js b/tests/unit/format.test.js index 5094f06..621c8d1 100644 --- a/tests/unit/format.test.js +++ b/tests/unit/format.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat, + clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, detectSqlFormat, isExplain, } from '../../src/core/format.js'; describe('clamp', () => { @@ -101,6 +101,20 @@ describe('detectSqlFormat', () => { }); }); +describe('isExplain', () => { + it('detects a leading EXPLAIN (any variant), ignoring leading whitespace/case', () => { + expect(isExplain('EXPLAIN SELECT 1')).toBe(true); + expect(isExplain(' explain pipeline SELECT 1')).toBe(true); + expect(isExplain('EXPLAIN AST SELECT 1')).toBe(true); + }); + it('is false for non-EXPLAIN statements', () => { + expect(isExplain('SELECT 1')).toBe(false); + expect(isExplain('SELECT explain FROM t')).toBe(false); // EXPLAIN not the leading keyword + expect(isExplain('')).toBe(false); + expect(isExplain(null)).toBe(false); + }); +}); + describe('inferQueryName', () => { it('uses FROM table when present', () => { expect(inferQueryName('SELECT * FROM system.tables')).toBe('Query · system.tables');