fix(editor): stop per-keystroke full-document statement reparse

Closes #8600
This commit is contained in:
t8y2
2026-09-17 01:30:02 +08:00
parent 84ae93aaa8
commit ab5aba9497
5 changed files with 297 additions and 52 deletions
@@ -32,7 +32,16 @@ import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard";
import { completionMatchRanges } from "@/lib/common/completionMatch";
import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
import {
executableStatementRangeAtCursor,
executableStatementRangeCacheForDoc,
executableStatementRangeStartingAt as executableStatementRangeStartingAtLine,
mapStatementGutterStartIndex,
statementGutterStartIndexForCache,
statementGutterStartIndexHasStartAt,
type ExecutableStatementRangeCache,
type StatementGutterStartIndex,
} from "@/lib/sql/executableStatementRangeCache";
import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask";
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview";
@@ -247,6 +256,9 @@ const COMPLETION_TRIGGER_DEFER_DELAY_MS = 50;
const COMPLETION_TAB_RETRY_DELAY_MS = 16;
const COMPLETION_TAB_MAX_WAIT_MS = COMPLETION_DEBOUNCE_DELAY_MS + COMPLETION_REMOTE_LATENCY_BUDGET_MS + 100;
const COMPLETION_ENTER_MAX_WAIT_MS = 125;
// Signature-help only ever looks backward from the cursor; past this distance
// no human-authored call site is worth the scan on huge documents.
const SQL_SIGNATURE_HELP_WINDOW_CHARS = 10_000;
// Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path.
const SEMANTIC_SQL_COMPLETION_ENABLED = true;
@@ -681,6 +693,68 @@ function runStatementGutterExtension(): import("@codemirror/state").Extension {
}
let executableStatementRangeCache: ExecutableStatementRangeCache | null = null;
// Lenient statement-boundary view shared by the run-statement gutter and the
// current-statement frame. Re-parsing the whole document on every keystroke is
// the dominant typing cost on large scripts, so while typing we only shift the
// known start positions / frame range through the ChangeSet (O(statements)) and
// rebuild exactly once after typing pauses. Paths that must stay exact (gutter
// click-to-execute, execution picker) keep using the full on-demand parse.
const STATEMENT_BOUNDARIES_REFRESH_MS = 150;
let statementBoundariesView: {
doc: import("@codemirror/state").Text;
startsIndex: StatementGutterStartIndex;
frameRange: { from: number; to: number } | null;
fresh: boolean;
generation: number;
} | null = null;
let statementBoundariesGeneration = 0;
let statementBoundariesRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let statementBoundariesRefreshEffect: import("@codemirror/state").StateEffectType<null> | null = null;
function refreshStatementBoundaries(state: import("@codemirror/state").EditorState) {
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, state.doc, props.databaseType, sqlStatementParameterOptions());
statementBoundariesView = {
doc: state.doc,
startsIndex: statementGutterStartIndexForCache(executableStatementRangeCache),
frameRange: null,
fresh: true,
generation: statementBoundariesGeneration,
};
}
interface StatementBoundariesView {
doc: import("@codemirror/state").Text;
startsIndex: StatementGutterStartIndex;
frameRange: { from: number; to: number } | null;
fresh: boolean;
generation: number;
}
// Returns the view matching `state`, rebuilding it synchronously when the
// tracked doc fell out of sync (first use, tab switch via setState) or the
// dialect generation moved. While typing, the tracking plugin keeps the doc
// reference current through ChangeSet mapping, so this stays cheap.
function statementBoundariesForState(state: import("@codemirror/state").EditorState): StatementBoundariesView {
if (statementBoundariesView && statementBoundariesView.doc === state.doc && statementBoundariesView.generation === statementBoundariesGeneration) return statementBoundariesView;
refreshStatementBoundaries(state);
return statementBoundariesView!;
}
function scheduleStatementBoundariesRefresh(currentView: EditorViewType) {
// True debounce: continuous typing must never pay a full-document parse —
// mapped positions serve the gutter/frame, and one rebuild lands only after
// the pause.
if (statementBoundariesRefreshTimer !== null) clearTimeout(statementBoundariesRefreshTimer);
statementBoundariesRefreshTimer = setTimeout(() => {
statementBoundariesRefreshTimer = null;
if (view.value !== currentView || !currentView.dom.isConnected) return;
refreshStatementBoundaries(currentView.state);
if (statementBoundariesRefreshEffect) {
currentView.dispatch({ effects: statementBoundariesRefreshEffect.of(null) });
}
}, STATEMENT_BOUNDARIES_REFRESH_MS);
}
let editorScrollbarPointerCleanup: (() => void) | null = null;
let editorSelectionDragCleanup: (() => void) | null = null;
let editorSelectionDropCursorEl: HTMLDivElement | null = null;
@@ -1344,7 +1418,11 @@ function tableNavigationIdentifierAt(currentView: EditorViewType, event: MouseEv
if (!props.connectionId || props.database == null) return null;
const pos = currentView.posAtCoords({ x: event.clientX, y: event.clientY });
if (pos == null) return null;
const extracted = extractIdentifierDetailsAt(currentView.state.doc.toString(), pos);
// Identifier extraction only looks around `pos`; slice a small window so
// modifier-held mouse moves never materialize the whole document string.
const windowFrom = Math.max(0, pos - 1024);
const doc = currentView.state.doc;
const extracted = extractIdentifierDetailsAt(doc.sliceString(windowFrom, Math.min(doc.length, pos + 1024)), pos - windowFrom);
if (!extracted || (!extracted.quoted && isSqlKeyword(extracted.identifier))) return null;
return extracted.identifier;
}
@@ -5873,6 +5951,45 @@ onMounted(async () => {
previewRangeComp = new Compartment();
indentComp = new Compartment();
setSqlDiagnosticsEffect = StateEffect.define<SqlSemanticDiagnostic[]>();
statementBoundariesRefreshEffect = StateEffect.define<null>();
// Keeps the lenient statement-boundary view glued to the live document:
// each keystroke shifts the known starts / frame range through the
// ChangeSet (O(statements)) and schedules one full rebuild after typing
// pauses, so neither the run gutter nor the statement frame re-parses the
// whole document synchronously on every keystroke.
const statementBoundariesTrackingPlugin = ViewPlugin.fromClass(
class {
update(update: import("@codemirror/view").ViewUpdate) {
if (!update.docChanged) return;
const boundaries = statementBoundariesView;
// No consumer (run gutter off + statement frame off) ever initialized
// the view — nothing to maintain and no refresh to schedule.
if (!boundaries) return;
if (boundaries.doc === update.startState.doc) {
statementBoundariesView = {
doc: update.state.doc,
startsIndex: mapStatementGutterStartIndex(boundaries.startsIndex, update.changes),
frameRange: boundaries.frameRange
? {
from: update.changes.mapPos(boundaries.frameRange.from, 1),
to: update.changes.mapPos(boundaries.frameRange.to, 1),
}
: null,
fresh: false,
generation: boundaries.generation,
};
}
scheduleStatementBoundariesRefresh(update.view);
}
destroy() {
if (statementBoundariesRefreshTimer !== null) {
clearTimeout(statementBoundariesRefreshTimer);
statementBoundariesRefreshTimer = null;
}
}
},
);
codeMirrorCompletionStatus = completionStatus;
codeMirrorAcceptCompletion = acceptCompletion;
codeMirrorCurrentCompletions = currentCompletions;
@@ -6108,9 +6225,18 @@ onMounted(async () => {
markers: (currentView) => currentView.state.field(field),
lineMarker(currentView, line, markers) {
const executionMarker = markers.find((marker) => marker instanceof StatementExecutionStateMarker)?.marker;
const canExecute = showRunButtons && !!executableStatementRangeStartingAt(currentView, line.from);
// Membership check against the lenient index (mapped through the
// ChangeSet while typing) instead of a full-document re-parse per
// keystroke. The click handler still resolves the exact range on
// demand, so display-level approximation never affects execution.
const canExecute = showRunButtons && statementGutterStartIndexHasStartAt(statementBoundariesForState(currentView.state).startsIndex, line.from);
return canExecute || executionMarker ? new StatementGutterMarker(canExecute, executionMarker) : null;
},
lineMarkerChange(update) {
// Redraw once the debounced full rebuild has landed.
const refreshEffect = statementBoundariesRefreshEffect;
return !!refreshEffect && update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshEffect)));
},
domEventHandlers: showRunButtons
? {
mousedown: executeSqlStatementFromGutter,
@@ -6122,10 +6248,15 @@ onMounted(async () => {
};
buildSqlSignatureExtension = () =>
showTooltip.compute(["doc", "selection"], (currentState) => {
const signature = getSqlFunctionSignatureHelp(currentState.doc.toString(), currentState.selection.main.head, props.databaseType, sqlDriverProfile.value);
const cursor = currentState.selection.main.head;
// Signature detection only scans backward from the cursor, so window the
// text instead of materializing the whole document prefix on every
// keystroke — doc.toString() was O(document) per key on large scripts.
const windowFrom = Math.max(0, cursor - SQL_SIGNATURE_HELP_WINDOW_CHARS);
const signature = getSqlFunctionSignatureHelp(currentState.doc.sliceString(windowFrom, cursor), cursor - windowFrom, props.databaseType, sqlDriverProfile.value, { truncatedPrefix: windowFrom > 0 });
if (!signature) return null;
return {
pos: currentState.selection.main.head,
pos: cursor,
above: false,
clip: false,
create: () => ({ dom: createSqlSignatureTooltipDom(signature) }),
@@ -6313,43 +6444,60 @@ onMounted(async () => {
await ensureCodeMirrorVim();
}
const currentStatementFrameExtension = currentStatementFrameLayer({ layer, RectangleMarker }, (view) => {
if (!settingsStore.editorSettings.showCurrentStatementFrame) return null;
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
let range = currentExecutableStatementRange(view);
if (!range) {
const currentStatementFrameExtension = currentStatementFrameLayer(
{ layer, RectangleMarker },
(view) => {
if (!settingsStore.editorSettings.showCurrentStatementFrame) return null;
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
const cursorPos = view.state.selection.main.head;
const cursorLine = view.state.doc.lineAt(cursorPos);
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, view.state.doc, props.databaseType, sqlStatementParameterOptions());
// Find ranges that overlap the cursor line, then expand to include
// adjacent ranges (handles parser fragments from edge cases like
// ultra-long comments splitting a statement).
let mergedFrom = cursorLine.from;
let mergedTo = cursorLine.from;
let changed = true;
while (changed) {
changed = false;
for (const cachedRange of executableStatementRangeCache.ranges) {
// Only merge ranges that overlap the cursor line or are adjacent
// to the current merged region
if (cachedRange.from <= mergedTo && cachedRange.to >= mergedFrom) {
const newFrom = Math.min(mergedFrom, cachedRange.from);
const newTo = Math.max(mergedTo, cachedRange.to);
if (newFrom !== mergedFrom || newTo !== mergedTo) {
mergedFrom = newFrom;
mergedTo = newTo;
changed = true;
const boundaries = statementBoundariesForState(view.state);
if (boundaries && !boundaries.fresh && boundaries.frameRange && cursorPos >= boundaries.frameRange.from && cursorPos <= boundaries.frameRange.to) {
// Typing in progress: reuse the ChangeSet-shifted range instead of
// re-parsing the whole document. The debounced refresh rebuilds and
// repaints the exact frame once typing pauses.
return { from: boundaries.frameRange.from, to: currentStatementFrameTo(view, { from: boundaries.frameRange.from, to: boundaries.frameRange.to, sql: "" }) };
}
let range = currentExecutableStatementRange(view);
if (!range) {
const cursorLine = view.state.doc.lineAt(cursorPos);
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, view.state.doc, props.databaseType, sqlStatementParameterOptions());
// Find ranges that overlap the cursor line, then expand to include
// adjacent ranges (handles parser fragments from edge cases like
// ultra-long comments splitting a statement).
let mergedFrom = cursorLine.from;
let mergedTo = cursorLine.from;
let changed = true;
while (changed) {
changed = false;
for (const cachedRange of executableStatementRangeCache.ranges) {
// Only merge ranges that overlap the cursor line or are adjacent
// to the current merged region
if (cachedRange.from <= mergedTo && cachedRange.to >= mergedFrom) {
const newFrom = Math.min(mergedFrom, cachedRange.from);
const newTo = Math.max(mergedTo, cachedRange.to);
if (newFrom !== mergedFrom || newTo !== mergedTo) {
mergedFrom = newFrom;
mergedTo = newTo;
changed = true;
}
}
}
}
if (mergedTo > mergedFrom) {
range = { from: mergedFrom, to: mergedTo, sql: view.state.doc.sliceString(mergedFrom, mergedTo) };
}
}
if (mergedTo > mergedFrom) {
range = { from: mergedFrom, to: mergedTo, sql: view.state.doc.sliceString(mergedFrom, mergedTo) };
}
}
if (!range) return null;
return { from: range.from, to: currentStatementFrameTo(view, range) };
});
if (boundaries) boundaries.frameRange = range ? { from: range.from, to: range.to } : null;
if (!range) return null;
return { from: range.from, to: currentStatementFrameTo(view, range) };
},
{
shouldRefresh(update) {
const refreshEffect = statementBoundariesRefreshEffect;
return !!refreshEffect && update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshEffect)));
},
},
);
function currentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number {
return currentStatementFrameRangeTo(view.state.doc, range);
@@ -6401,6 +6549,10 @@ onMounted(async () => {
// immediate drag-select there trigger CodeMirror's edge autoscroll.
scrollToMatch: (range) => EditorView.scrollIntoView(range, { y: "center" }),
}),
// Must update before the run gutter's plugin registers below: each
// keystroke maps the boundary view to the new doc before any lineMarker
// callback reads it, otherwise the gutter would trigger a full parse.
statementBoundariesTrackingPlugin,
runGutterComp.of(runStatementGutterExtension()),
lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)),
createQueryEditorLineNumberAlignmentExtension(ViewPlugin),
@@ -7167,6 +7319,7 @@ watch([() => props.clientSessionId, () => props.completionContextVersion], () =>
watch([() => props.databaseType, () => props.dialect, () => props.syntaxDialect, sqlDriverProfile], () => {
executableStatementRangeCache = null;
statementBoundariesGeneration += 1;
if (!view.value || !sqlLanguageComp || !buildSqlLanguageExtension || !sqlSemanticHighlightComp || !buildSqlSemanticHighlightExtension || !sqlSignatureComp || !buildSqlSignatureExtension) return;
// Signature tooltips depend on the external dialect, so refresh them even when the document and selection stay unchanged.
view.value.dispatch({
@@ -7183,6 +7336,7 @@ watch(
(now, before) => {
if (now === before) return;
executableStatementRangeCache = null;
statementBoundariesGeneration += 1;
if (props.databaseType !== "opengauss") return;
if (!view.value) return;
refreshCompletionCache();
@@ -1,6 +1,14 @@
import { Text } from "@codemirror/state";
import { ChangeSet, Text } from "@codemirror/state";
import { describe, expect, it, vi } from "vitest";
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeParser } from "@/lib/sql/executableStatementRangeCache";
import {
executableStatementRangeAtCursor,
executableStatementRangeCacheForDoc,
executableStatementRangeStartingAt,
mapStatementGutterStartIndex,
statementGutterStartIndexForCache,
statementGutterStartIndexHasStartAt,
type ExecutableStatementRangeParser,
} from "@/lib/sql/executableStatementRangeCache";
describe("executableStatementRangeCacheForDoc", () => {
it("tracks MongoDB commands for current-statement framing", () => {
@@ -237,3 +245,33 @@ describe("executableStatementRangeCacheForDoc", () => {
expect(parse).toHaveBeenCalledTimes(2);
});
});
describe("statementGutterStartIndex", () => {
it("derives membership from both statement starts and executable line starts", () => {
const doc = Text.of(["/*+ hint */", "SELECT 1;", "", "SELECT 2;"]);
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
const index = statementGutterStartIndexForCache(cache);
// First statement: executable start lands on the directive's following line.
expect(statementGutterStartIndexHasStartAt(index, doc.line(2).from)).toBe(true);
expect(statementGutterStartIndexHasStartAt(index, doc.line(4).from)).toBe(true);
expect(statementGutterStartIndexHasStartAt(index, doc.line(3).from)).toBe(false);
});
it("shifts start positions through insertions and deletions", () => {
const doc = Text.of(["SELECT 1;", "SELECT 2;"]);
const cache = executableStatementRangeCacheForDoc(null, doc, "mysql");
const index = statementGutterStartIndexForCache(cache);
const secondLineStart = doc.line(2).from;
expect(statementGutterStartIndexHasStartAt(index, secondLineStart)).toBe(true);
const header = "-- lead\n";
const afterInsert = mapStatementGutterStartIndex(index, ChangeSet.of({ from: 0, to: 0, insert: header }, doc.length));
expect(statementGutterStartIndexHasStartAt(afterInsert, secondLineStart)).toBe(false);
expect(statementGutterStartIndexHasStartAt(afterInsert, secondLineStart + header.length)).toBe(true);
const afterDelete = mapStatementGutterStartIndex(index, ChangeSet.of({ from: 0, to: "SELECT 1;\n".length, insert: "" }, doc.length));
expect(statementGutterStartIndexHasStartAt(afterDelete, secondLineStart - "SELECT 1;\n".length)).toBe(true);
expect(statementGutterStartIndexHasStartAt(afterDelete, 0)).toBe(true);
});
});
@@ -148,9 +148,11 @@ interface CurrentStatementFrameModule {
/**
* Draw one continuous green rectangle around the current executable
* statement. `resolve` decides visibility and returns the statement range;
* returning null hides the frame.
* returning null hides the frame. `options.shouldRefresh` forces a repaint on
* updates that change statement boundaries without touching the document
* (e.g. a debounced boundary rebuild landing after a typing pause).
*/
export function currentStatementFrameLayer(viewModule: CurrentStatementFrameModule, resolve: StatementFrameResolver): Extension {
export function currentStatementFrameLayer(viewModule: CurrentStatementFrameModule, resolve: StatementFrameResolver, options?: { shouldRefresh?: (update: ViewUpdate) => boolean }): Extension {
let lastRequest: StatementFrameRequest | null | undefined;
const sameRequest = (left: StatementFrameRequest | null | undefined, right: StatementFrameRequest | null): boolean => left === right || (!!left && !!right && left.from === right.from && left.to === right.to);
@@ -173,6 +175,10 @@ export function currentStatementFrameLayer(viewModule: CurrentStatementFrameModu
lastRequest = undefined;
return true;
}
if (options?.shouldRefresh?.(update)) {
lastRequest = undefined;
return true;
}
if (!update.selectionSet) return false;
// Moving inside one statement does not change its frame. Avoid repeating
@@ -1,4 +1,4 @@
import type { Text } from "@codemirror/state";
import type { ChangeSet, Text } from "@codemirror/state";
import type { DatabaseType } from "@/types/database";
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { executableStatementRanges, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
@@ -14,6 +14,36 @@ export interface ExecutableStatementRangeCache {
ranges: SqlTextRange[];
}
/**
* Membership-only view of the statement-start positions a run-statement gutter
* needs (`byStart` keys plus `byExecutableLineStart` keys). Kept separate from
* the full cache so it can be cheaply shifted through a ChangeSet on every
* keystroke and fully rebuilt only after typing pauses, instead of re-parsing
* the whole document synchronously per keystroke.
*/
export interface StatementGutterStartIndex {
starts: ReadonlySet<number>;
executableLineStarts: ReadonlySet<number>;
}
export function statementGutterStartIndexForCache(cache: ExecutableStatementRangeCache): StatementGutterStartIndex {
return { starts: new Set(cache.byStart.keys()), executableLineStarts: new Set(cache.byExecutableLineStart.keys()) };
}
export function mapStatementGutterStartIndex(index: StatementGutterStartIndex, changes: ChangeSet): StatementGutterStartIndex {
return { starts: mapStartPositions(index.starts, changes), executableLineStarts: mapStartPositions(index.executableLineStarts, changes) };
}
function mapStartPositions(positions: ReadonlySet<number>, changes: ChangeSet): Set<number> {
const mapped = new Set<number>();
for (const position of positions) mapped.add(changes.mapPos(position, 1));
return mapped;
}
export function statementGutterStartIndexHasStartAt(index: StatementGutterStartIndex, lineFrom: number): boolean {
return index.starts.has(lineFrom) || index.executableLineStarts.has(lineFrom);
}
export type ExecutableStatementRangeParser = (sql: string, databaseType?: DatabaseType, parameterOptions?: SqlParameterOptions) => SqlTextRange[];
export function executableStatementRangeCacheForDoc(
+27 -10
View File
@@ -1717,16 +1717,22 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number, options
if (isSqlCompletionSuppressedContext(sql, cursor, options)) return false;
const previousChar = sql[cursor - 1];
if (!previousChar) return false;
if (/\bon\s+$/i.test(sql.slice(0, cursor))) return true;
if (isAfterJoinModifierContext(sql.slice(0, cursor), options.databaseType)) return true;
if (/\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(sql.slice(0, cursor))) return true;
// Statement-bounded prefix: every probe below is anchored at the cursor and
// only looks back within the current statement. Slicing the whole prefix (and
// the literal-masking scans inside the modifier/expression helpers) made each
// completion trigger O(document) on large scripts.
const statementSpan = sqlCompletionStatementSpan(sql, cursor, options);
const beforeCursor = sql.slice(statementSpan.start, cursor);
if (/\bon\s+$/i.test(beforeCursor)) return true;
if (isAfterJoinModifierContext(beforeCursor, options.databaseType)) return true;
if (/\bcall\s+(?:[A-Za-z_][\w$]*\.)?$/i.test(beforeCursor)) return true;
const context = getSqlCompletionContext(sql, cursor, options);
if (previousChar === "(" && (context.insertTable || context.preferredValueKeywords?.length)) return true;
if (/[,;()[\]]/.test(previousChar)) return false;
if (context.exclusiveTableSuggestions || context.exclusiveRoutineSuggestions || context.suggestTables) {
return true;
}
if (context.exclusiveColumnSuggestions || shouldAutoOpenColumnCompletion(context, sql, cursor, options.databaseType)) return true;
if (context.exclusiveColumnSuggestions || shouldAutoOpenColumnCompletion(context, beforeCursor, beforeCursor.length, options.databaseType)) return true;
return /[A-Za-z_$@.]/.test(previousChar);
}
@@ -2150,9 +2156,9 @@ export function getSqlCompletionResultValidFor(sql: string, cursor: number): Reg
return undefined;
}
export function getSqlFunctionSignatureHelp(sql: string, cursor: number, databaseType?: DatabaseType, driverProfile?: string): SqlFunctionSignatureHelp | null {
export function getSqlFunctionSignatureHelp(sql: string, cursor: number, databaseType?: DatabaseType, driverProfile?: string, options?: { truncatedPrefix?: boolean }): SqlFunctionSignatureHelp | null {
const beforeCursor = sql.slice(0, cursor);
const call = findActiveFunctionCall(beforeCursor);
const call = findActiveFunctionCall(beforeCursor, options?.truncatedPrefix === true);
if (!call) return null;
const observedParameter = countTopLevelCommas(call.groupText);
@@ -5519,8 +5525,8 @@ interface ActiveFunctionCall {
groupText: string;
}
function findActiveFunctionCall(sqlBeforeCursor: string): ActiveFunctionCall | null {
const activeOpenParen = findActiveFunctionOpenParen(sqlBeforeCursor);
function findActiveFunctionCall(sqlBeforeCursor: string, truncatedPrefix = false): ActiveFunctionCall | null {
const activeOpenParen = findActiveFunctionOpenParen(sqlBeforeCursor, truncatedPrefix);
if (activeOpenParen == null) return null;
const beforeActiveGroup = sqlBeforeCursor.slice(0, activeOpenParen).trimEnd();
@@ -5569,12 +5575,23 @@ function findMatchingOpenParen(text: string, closeParenIndex: number): number |
return null;
}
function findActiveFunctionOpenParen(sqlBeforeCursor: string): number | null {
/**
* Backward signature scans stop after this many characters: no human-authored
* argument group is worth a longer scan, and generated SQL can embed
* megabyte-long value lists inside a single call.
*/
const SQL_SIGNATURE_SCAN_LIMIT_CHARS = 100_000;
function findActiveFunctionOpenParen(sqlBeforeCursor: string, truncatedPrefix = false): number | null {
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
// With a truncated window the scan must not trust index 0: an "unmatched"
// open paren sitting exactly on the window edge may have its match before
// the window, which would fabricate a signature tooltip.
const floorIndex = Math.max(truncatedPrefix ? 1 : 0, sqlBeforeCursor.length - SQL_SIGNATURE_SCAN_LIMIT_CHARS);
for (let i = sqlBeforeCursor.length - 1; i >= 0; i--) {
for (let i = sqlBeforeCursor.length - 1; i >= floorIndex; i--) {
const ch = sqlBeforeCursor[i];
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;