From ab5aba94975686905d9c65657bf032a57bd99e53 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 17 Sep 2026 01:30:02 +0800 Subject: [PATCH] fix(editor): stop per-keystroke full-document statement reparse Closes #8600 --- .../src/components/editor/QueryEditor.vue | 228 +++++++++++++++--- .../sql/executableStatementRangeCache.spec.ts | 42 +++- .../codemirrorCurrentStatementFrameLayer.ts | 10 +- .../lib/sql/executableStatementRangeCache.ts | 32 ++- apps/desktop/src/lib/sql/sqlCompletion.ts | 37 ++- 5 files changed, 297 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 48433de45f..e93deb54cd 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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 | null = null; +let statementBoundariesRefreshEffect: import("@codemirror/state").StateEffectType | 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(); + statementBoundariesRefreshEffect = StateEffect.define(); + // 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(); diff --git a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts index 7bd6a6269a..6fe7c4f52e 100644 --- a/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/executableStatementRangeCache.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts b/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts index d9a6254f8a..5862cfa150 100644 --- a/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts +++ b/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts @@ -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 diff --git a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts index 8f17e6c9d9..e17d1f8168 100644 --- a/apps/desktop/src/lib/sql/executableStatementRangeCache.ts +++ b/apps/desktop/src/lib/sql/executableStatementRangeCache.ts @@ -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; + executableLineStarts: ReadonlySet; +} + +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, changes: ChangeSet): Set { + const mapped = new Set(); + 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( diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index a7482795d3..1acc492d8b 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -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;