fix(sql): preserve ranges after incomplete EXPLAIN options

This commit is contained in:
vrustx authored and GitHub committed 2026-07-15 20:52:56 +08:00
1 parent f9bf039aef
commit d3118256a4
2 files changed
+191 -2

No files matched your search

+105 -2
View File
@@ -487,8 +487,12 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
for (const lineStart of lineStarts) {
if (lineStart.from <= statement.from) continue;
if (currentKeyword === "WITH" && !consumedWithMainStatement && WITH_MAIN_STATEMENT_KEYWORDS.has(lineStart.keyword)) {
if (currentBodyKeyword === "WITH" && !consumedWithMainStatement && WITH_MAIN_STATEMENT_KEYWORDS.has(lineStart.keyword)) {
consumedWithMainStatement = true;
// The CTE main statement also satisfies a pending EXPLAIN target, and its
// own body rules (e.g. UPDATE ... SET) must take over from here.
consumedExplainStatement = true;
currentBodyKeyword = lineStart.keyword;
continue;
}
@@ -511,6 +515,10 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
}
if (currentBodyKeyword === "INSERT" && INSERT_BODY_KEYWORDS.has(lineStart.keyword)) {
// Hand over to the source query's own rules so only its continuations
// (CTE main statement, set operations) stay attached to the INSERT.
currentBodyKeyword = lineStart.keyword;
if (lineStart.keyword === "WITH") consumedWithMainStatement = false;
continue;
}
@@ -553,6 +561,10 @@ function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, d
function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): Array<{ hitFrom: number; from: number; keyword: string }> {
const starts: Array<{ hitFrom: number; from: number; keyword: string }> = [];
const len = statement.to;
const explainOptionsStart = explainOptionsParenAt(sql, statement.from);
// Recover soft statement boundaries while the user is still typing an
// EXPLAIN option list; otherwise its unmatched opener hides every later line.
const unclosedExplainOptionsStart = explainOptionsStart !== null && skipBalancedParens(sql, explainOptionsStart) === null ? explainOptionsStart : null;
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
let parenDepth = 0;
@@ -702,7 +714,7 @@ function topLevelSoftStatementLineStarts(sql: string, statement: RawStatement, d
continue;
}
}
if (ch === "(") {
if (ch === "(" && i !== unclosedExplainOptionsStart) {
parenDepth += 1;
} else if (ch === ")" && parenDepth > 0) {
parenDepth -= 1;
@@ -926,6 +938,15 @@ function isExplainLikeKeyword(keyword: string | null): boolean {
return keyword === "EXPLAIN" || keyword === "DESCRIBE" || keyword === "DESC";
}
function explainOptionsParenAt(sql: string, pos: number): number | null {
const prefixMatch = /^[A-Za-z_][\w$]*/.exec(sql.slice(pos));
if (prefixMatch?.[0]?.toUpperCase() !== "EXPLAIN") return null;
let i = pos + prefixMatch[0].length;
while (i < sql.length && isSqlWhitespace(sql[i])) i += 1;
return sql[i] === "(" ? i : null;
}
function explainLikeTargetKeywordAt(sql: string, pos: number): string | null {
const prefixMatch = /^[A-Za-z_][\w$]*/.exec(sql.slice(pos));
const prefix = prefixMatch?.[0]?.toUpperCase();
@@ -933,11 +954,93 @@ function explainLikeTargetKeywordAt(sql: string, pos: number): string | null {
let i = pos + (prefixMatch?.[0].length ?? 0);
while (i < sql.length && isSqlWhitespace(sql[i])) i += 1;
// Parenthesized EXPLAIN options (e.g. Postgres `EXPLAIN (ANALYZE, BUFFERS) ...`)
// sit between the keyword and its target statement. DESC/DESCRIBE take no
// options — a paren there is a subquery (ClickHouse `DESCRIBE (SELECT ...)`).
if (prefix === "EXPLAIN" && sql[i] === "(") {
const optionsEnd = skipBalancedParens(sql, i);
if (optionsEnd === null) return null;
i = optionsEnd;
while (i < sql.length && isSqlWhitespace(sql[i])) i += 1;
}
const targetMatch = /^[A-Za-z_][\w$]*/.exec(sql.slice(i));
const targetKeyword = targetMatch?.[0]?.toUpperCase();
return targetKeyword && EXPLAIN_STATEMENT_KEYWORDS.has(targetKeyword) ? targetKeyword : null;
}
function skipBalancedParens(sql: string, pos: number): number | null {
let state: "none" | "single" | "double" | "lineComment" | "blockComment" = "none";
let depth = 0;
let i = pos;
while (i < sql.length) {
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "lineComment") {
if (ch === "\n") state = "none";
i += 1;
continue;
}
if (state === "blockComment") {
if (ch === "*" && next === "/") {
state = "none";
i += 2;
continue;
}
i += 1;
continue;
}
if (state === "single") {
if (ch === "'" && next === "'") {
i += 2;
continue;
}
if (ch === "'") state = "none";
i += 1;
continue;
}
if (state === "double") {
if (ch === '"' && next === '"') {
i += 2;
continue;
}
if (ch === '"') state = "none";
i += 1;
continue;
}
if (ch === "-" && next === "-") {
state = "lineComment";
i += 2;
continue;
}
if (ch === "/" && next === "*") {
state = "blockComment";
i += 2;
continue;
}
if (ch === "'") {
state = "single";
i += 1;
continue;
}
if (ch === '"') {
state = "double";
i += 1;
continue;
}
if (ch === "(") depth += 1;
if (ch === ")") {
depth -= 1;
if (depth === 0) return i + 1;
}
i += 1;
}
return null;
}
function startsLineComment(sql: string, pos: number): boolean {
return (sql[pos] === "-" && sql[pos + 1] === "-") || sql[pos] === "#";
}