fix(editor): stop per-keystroke full-document statement reparse
Closes #8600
This commit is contained in:
5 files changed
+297
-52
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user