dbx/apps/desktop/src/lib/sql/sqlStatementRanges.ts

2060 lines
63 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import type { DatabaseType } from "@/types/database";
/**
* A contiguous range of SQL text expressed as document offsets plus the
* extracted (original) substring.
*/
export interface SqlTextRange {
from: number;
to: number;
sql: string;
}
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "neo4j"]);
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
return !!databaseType && (databaseType === "redis" || databaseType === "elasticsearch" || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
}
export function hasMultipleExecutionTargets(sql: string, databaseType?: DatabaseType): boolean {
if (databaseType === "redis") {
return redisExecutableCommandCount(sql) > 1;
}
return splitSqlStatementRanges(sql, databaseType).length > 1;
}
interface RawStatement {
/** Start offset (inclusive) of whitespace that can still target this statement. */
hitFrom: number;
/** Start offset (inclusive) of the statement's first non-whitespace char. */
from: number;
/** End offset (exclusive) — up to and excluding the terminating semicolon. */
to: number;
/** The statement text, sliced from the source document. */
sql: string;
}
interface ElasticsearchRequestLine {
from: number;
to: number;
text: string;
}
interface ElasticsearchRequestLineCandidate {
lineIndex: number;
from: number;
}
export interface ElasticsearchRestRequestTarget {
method: "GET" | "POST" | "PUT" | "DELETE" | "HEAD";
path: string;
}
const ELASTICSEARCH_REST_REQUEST_LINE = /^\s*(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
function leadingElasticsearchPreambleEnd(value: string): number {
let offset = 0;
while (offset < value.length) {
while (offset < value.length && /\s/.test(value[offset] ?? "")) offset += 1;
if (offset >= value.length) return offset;
if (value[offset] === "#" || value.startsWith("//", offset)) {
const newline = value.indexOf("\n", offset);
offset = newline < 0 ? value.length : newline + 1;
continue;
}
if (value.startsWith("/*", offset)) {
const close = value.indexOf("*/", offset + 2);
offset = close < 0 ? value.length : close + 2;
continue;
}
break;
}
return offset;
}
export function stripLeadingElasticsearchComments(value: string): string {
return value.slice(leadingElasticsearchPreambleEnd(value)).trimStart();
}
function isElasticsearchRequestPreamble(value: string): boolean {
return leadingElasticsearchPreambleEnd(value) === value.length;
}
function elasticsearchRequestLines(sql: string): ElasticsearchRequestLine[] {
const lines: ElasticsearchRequestLine[] = [];
let from = 0;
while (from <= sql.length) {
const newline = sql.indexOf("\n", from);
const to = newline >= 0 ? newline : sql.length;
lines.push({ from, to, text: sql.slice(from, to) });
if (newline < 0) break;
from = newline + 1;
}
return lines;
}
function elasticsearchRequestLineCandidates(lines: ElasticsearchRequestLine[]): ElasticsearchRequestLineCandidate[] {
const candidates: ElasticsearchRequestLineCandidate[] = [];
let inBlockComment = false;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const line = lines[lineIndex];
let offset = 0;
while (offset < line.text.length) {
if (inBlockComment) {
const close = line.text.indexOf("*/", offset);
if (close < 0) break;
inBlockComment = false;
offset = close + 2;
continue;
}
while (offset < line.text.length && /\s/.test(line.text[offset] ?? "")) offset += 1;
if (offset >= line.text.length) break;
if (line.text.startsWith("/*", offset)) {
inBlockComment = true;
offset += 2;
continue;
}
if (line.text[offset] === "#" || line.text.startsWith("//", offset)) break;
if (ELASTICSEARCH_REST_REQUEST_LINE.test(line.text.slice(offset))) {
candidates.push({ lineIndex, from: line.from + offset });
}
break;
}
}
return candidates;
}
export function parseElasticsearchRestRequestTarget(value: string): ElasticsearchRestRequestTarget | null {
const requestLine = stripLeadingElasticsearchComments(value).split("\n", 1)[0]?.trim() ?? "";
const match = requestLine.match(/^(GET|POST|PUT|DELETE|HEAD)\s+(\S+)/i);
if (!match) return null;
return {
method: match[1].toUpperCase() as ElasticsearchRestRequestTarget["method"],
path: match[2].startsWith("/") ? match[2] : `/${match[2]}`,
};
}
export function isElasticsearchRestRequestText(value: string): boolean {
return parseElasticsearchRestRequestTarget(value) !== null;
}
function splitElasticsearchRestRequestRanges(sql: string): RawStatement[] | undefined {
const lines = elasticsearchRequestLines(sql);
const candidates = elasticsearchRequestLineCandidates(lines);
const firstRequestIndex = candidates.findIndex((candidate) => isElasticsearchRequestPreamble(sql.slice(0, candidate.from)));
if (firstRequestIndex < 0) return undefined;
const requests = candidates.slice(firstRequestIndex);
const hitFroms = requests.map((request, requestIndex) => {
const previousRequestLine = requestIndex > 0 ? requests[requestIndex - 1].lineIndex : -1;
let hitFrom = request.from;
for (let preambleLine = previousRequestLine + 1; preambleLine < request.lineIndex; preambleLine += 1) {
const candidateFrom = lines[preambleLine].from;
if (isElasticsearchRequestPreamble(sql.slice(candidateFrom, request.from))) {
hitFrom = candidateFrom;
break;
}
}
return hitFrom;
});
return requests.map((request, requestIndex) => {
const from = request.from;
const rawTo = requestIndex + 1 < requests.length ? hitFroms[requestIndex + 1] : sql.length;
const to = trimRangeEnd(sql, from, rawTo);
return {
hitFrom: hitFroms[requestIndex],
from,
to,
sql: sql.slice(from, to),
};
});
}
type QuoteState = "none" | "single" | "double" | "backtick" | "bracket" | "dollar";
const COMMON_SOFT_STATEMENT_START_KEYWORDS = [
"SELECT",
"WITH",
"CREATE",
"ALTER",
"DROP",
"INSERT",
"UPDATE",
"DELETE",
"MERGE",
"REPLACE",
"TRUNCATE",
"GRANT",
"REVOKE",
"COMMENT",
"EXPLAIN",
"SHOW",
"DESCRIBE",
"DESC",
"USE",
"SET",
"CALL",
"EXEC",
"EXECUTE",
"BEGIN",
"COMMIT",
"ROLLBACK",
"DECLARE",
"ANALYZE",
"VACUUM",
"PRAGMA",
"REFRESH",
"COPY",
] as const;
const DATABASE_SOFT_STATEMENT_KEYWORDS: Partial<Record<DatabaseType, readonly string[]>> = {
mysql: ["HANDLER", "LOAD", "OPTIMIZE", "REPAIR"],
postgres: ["DO", "LISTEN", "NOTIFY", "UNLISTEN"],
sqlite: ["ATTACH", "DETACH", "REINDEX"],
duckdb: ["ATTACH", "DETACH", "EXPORT", "IMPORT", "INSTALL", "LOAD"],
clickhouse: ["ATTACH", "CHECK", "DETACH", "EXCHANGE", "KILL", "OPTIMIZE", "SYSTEM"],
sqlserver: ["BACKUP", "DBCC", "DENY", "RESTORE"],
saphana: ["DO"],
oracle: ["FLASHBACK", "LOCK", "PURGE"],
dameng: ["FLASHBACK", "LOCK", "PURGE"],
gaussdb: ["DO", "LOCK"],
"oceanbase-oracle": ["FLASHBACK", "LOCK", "PURGE"],
redis: [],
mongodb: [],
elasticsearch: [],
qdrant: [],
milvus: [],
weaviate: [],
chromadb: [],
mq: [],
etcd: [],
zookeeper: [],
};
const WITH_MAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE"]);
const EXPLAIN_STATEMENT_KEYWORDS = new Set(["SELECT", "WITH", "INSERT", "UPDATE", "DELETE", "MERGE", "CREATE", "ALTER", "DROP"]);
const CREATE_BODY_KEYWORDS = new Set(["SELECT", "WITH", "BEGIN", "DECLARE"]);
const INSERT_BODY_KEYWORDS = new Set(["SELECT", "WITH"]);
const ALTER_BODY_KEYWORDS = new Set(["ADD", "ALTER", "COMMENT", "DROP", "MODIFY", "RENAME", "SET"]);
const CLICKHOUSE_ALTER_TABLE_HEADER = /^ALTER\s+TABLE\s+(?:(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+")\s*\.\s*)?(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+")(?:\s+ON\s+CLUSTER\s+(?:[A-Za-z_][\w$]*|`(?:``|[^`])+`|"(?:""|[^"])+"|'(?:''|[^'])+'))?\s*$/i;
const SET_OPERATION_KEYWORDS = new Set(["UNION", "INTERSECT", "EXCEPT", "MINUS"]);
const SET_OPERATION_MODIFIER_KEYWORDS = new Set(["ALL", "DISTINCT"]);
const ORACLE_LIKE_PL_SQL_DATABASES: ReadonlySet<DatabaseType> = new Set(["oracle", "dameng", "gaussdb", "yashandb", "oscar", "oceanbase-oracle", "xugu"]);
const MYSQL_ROUTINE_BLOCK_DATABASES: ReadonlySet<DatabaseType> = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]);
const MYSQL_CREATE_TABLE_OPTION_DATABASES: ReadonlySet<DatabaseType> = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb", "gbase"]);
const MYSQL_ROUTINE_OBJECT_TYPES = new Set(["PROCEDURE", "FUNCTION", "TRIGGER", "EVENT"]);
const MYSQL_NON_ROUTINE_CREATE_TYPES = new Set(["DATABASE", "INDEX", "LOGFILE", "ROLE", "SCHEMA", "SERVER", "SPATIAL", "TABLE", "TEMPORARY", "UNIQUE", "USER", "VIEW"]);
const MYSQL_CONTROL_BLOCK_SUFFIXES = new Set(["IF", "LOOP", "CASE", "REPEAT", "WHILE"]);
const ORACLE_PL_SQL_BLOCK_STARTERS = new Set(["DECLARE", "BEGIN"]);
// Plain CREATE TYPE ... AS OBJECT (...); ends with ");" and is not a PL/SQL block.
// Only PACKAGE (spec), PACKAGE/TYPE BODY, and routine/trigger objects are PL/SQL blocks.
const ORACLE_PL_SQL_CREATE_OBJECT_TYPES = new Set(["FUNCTION", "PROCEDURE", "TRIGGER", "PACKAGE"]);
const ORACLE_PL_SQL_TERMINATORS = new Set(["IF", "LOOP", "CASE"]);
const SAP_HANA_SCRIPT_BLOCK_TERMINATORS = new Set(["IF", "FOR", "WHILE"]);
/**
* Parse the SQL document into top-level statement ranges delimited by `;`.
*
* Delimiters inside string literals, double/backtick/bracket quoted
* identifiers, dollar-quoted bodies (Postgres), line comments (`--`, `#`) and
* block comments (`/* */`) are ignored, mirroring the backend splitter in
* `dbx-core/src/sql.rs`. Ranges are returned as `[from, to)` offsets covering
* only the statement text (the trailing semicolon and inter-statement
* whitespace are excluded so editor highlights stay tight).
*/
export function splitSqlStatementRanges(sql: string, databaseType?: DatabaseType): RawStatement[] {
if (databaseType === "elasticsearch") {
const requests = splitElasticsearchRestRequestRanges(sql);
if (requests) return requests;
}
const statements: RawStatement[] = [];
const len = sql.length;
const supportsDelimiterCommands = databaseType === "mysql";
let statementStart = -1;
let statementEnd = -1;
let statementHitStart = 0;
let pendingHintStart = -1;
let customDelimiter: string | null = null;
let state: QuoteState = "none";
let dollarTag = "";
let i = 0;
const isWhitespace = (ch: string) => ch === " " || ch === "\t" || ch === "\r" || ch === "\n";
const markContent = (pos: number) => {
if (statementStart === -1) {
statementStart = pendingHintStart === -1 ? pos : pendingHintStart;
pendingHintStart = -1;
}
statementEnd = pos + 1;
};
const flush = (to = statementEnd) => {
if (statementStart === -1) {
statementEnd = -1;
pendingHintStart = -1;
return;
}
const trimmedTo = trimRangeEnd(sql, statementStart, to);
if (trimmedTo > statementStart) {
statements.push({ hitFrom: statementHitStart, from: statementStart, to: trimmedTo, sql: sql.slice(statementStart, trimmedTo) });
}
statementStart = -1;
statementEnd = -1;
pendingHintStart = -1;
};
while (i < len) {
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "dollar") {
// Inside a Postgres dollar-quoted body; look for the closing $tag$.
if (ch === "$") {
const closingTag = `$${dollarTag}$`;
if (sql.startsWith(closingTag, i)) {
markContent(i);
for (let k = 0; k < closingTag.length; k += 1) {
markContent(i + k);
}
i += closingTag.length;
state = "none";
dollarTag = "";
continue;
}
}
markContent(i);
i += 1;
continue;
}
if (state === "single") {
markContent(i);
// Backslash escapes the next char (e.g. PostgreSQL standard_conforming_strings=off style).
if (ch === "\\" && next) {
i += 2;
continue;
}
if (ch === "'") {
// Doubled single quote '' is an escaped quote, not a terminator.
if (next === "'") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "double") {
markContent(i);
if (ch === '"') {
if (next === '"') {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "backtick") {
markContent(i);
if (ch === "`") {
if (next === "`") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "bracket") {
markContent(i);
if (ch === "]") {
state = "none";
}
i += 1;
continue;
}
// state === "none"
if (supportsDelimiterCommands && isAtLineStart(sql, i) && startsDelimiterCommand(sql, i)) {
const lineEnd = findLineEnd(sql, i);
const delimiter = parseDelimiterCommand(sql.slice(i, lineEnd));
if (delimiter !== null) {
flush();
customDelimiter = delimiter === ";" ? null : delimiter;
i = nextLineStart(sql, lineEnd);
statementHitStart = i;
continue;
}
}
if (isOracleLikeDatabase(databaseType) && isAtLineStart(sql, i) && isSlashLine(sql, i)) {
const lineEnd = findLineEnd(sql, i);
flush(i);
i = nextLineStart(sql, lineEnd);
statementHitStart = i;
continue;
}
// Line comments consume up to (and including) the newline.
if (ch === "-" && next === "-") {
const newline = sql.indexOf("\n", i);
i = newline === -1 ? len : newline + 1;
continue;
}
if (ch === "#") {
const newline = sql.indexOf("\n", i);
i = newline === -1 ? len : newline + 1;
continue;
}
// Block comments consume until the closing */.
if (ch === "/" && next === "*") {
if (statementStart === -1 && pendingHintStart === -1 && sql[i + 2] === "+") pendingHintStart = i;
const close = sql.indexOf("*/", i + 2);
i = close === -1 ? len : close + 2;
continue;
}
if (supportsSqlServerGoCommands(databaseType) && isAtLineStart(sql, i) && isSqlServerGoLine(sql, i)) {
const lineEnd = findLineEnd(sql, i);
flush(i);
i = nextLineStart(sql, lineEnd);
statementHitStart = i;
continue;
}
if (ch === "'") {
markContent(i);
state = "single";
i += 1;
continue;
}
if (ch === '"') {
markContent(i);
state = "double";
i += 1;
continue;
}
if (ch === "`") {
markContent(i);
state = "backtick";
i += 1;
continue;
}
if (ch === "[") {
markContent(i);
state = "bracket";
i += 1;
continue;
}
// Postgres dollar quoting: $tag$ ... $tag$ (tag may be empty, i.e. $$)
if (!customDelimiter && ch === "$") {
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
if (tagMatch) {
markContent(i);
dollarTag = tagMatch[0].slice(1, -1);
i += tagMatch[0].length;
state = "dollar";
continue;
}
}
if (customDelimiter) {
if (sql.startsWith(customDelimiter, i)) {
flush(i);
i += customDelimiter.length;
statementHitStart = i;
continue;
}
} else if (ch === ";") {
const isMysqlRoutineBlock = isMysqlRoutineBlockDatabase(databaseType) && statementStart !== -1 && startsWithMysqlRoutineBlock(sql.slice(statementStart, i));
if (isMysqlRoutineBlock) {
if (!mysqlRoutineBlockIsComplete(sql.slice(statementStart, i + 1))) {
markContent(i);
i += 1;
continue;
}
// The final semicolon is the client-side statement delimiter.
// Internal semicolons remain part of the routine body.
flush();
} else {
const statementSoFar = statementStart === -1 ? "" : sql.slice(statementStart, i);
const isOraclePlSql = isOracleLikeDatabase(databaseType) && statementStart !== -1 && startsWithOraclePlSqlBlock(statementSoFar);
const isSapHanaScriptBlock = isSapHanaScriptBlockDatabase(databaseType) && statementStart !== -1 && startsWithSapHanaScriptBlock(statementSoFar);
if (isOraclePlSql || isSapHanaScriptBlock) {
markContent(i);
if (isOraclePlSql && !oraclePlSqlBlockIsComplete(sql.slice(statementStart, i + 1))) {
i += 1;
continue;
}
if (isSapHanaScriptBlock && !sapHanaScriptBlockIsComplete(sql.slice(statementStart, i + 1))) {
i += 1;
continue;
}
flush(i + 1);
} else {
flush();
}
}
statementHitStart = i + 1;
i += 1;
continue;
}
if (!isWhitespace(ch)) {
markContent(i);
}
i += 1;
}
// Flush any trailing statement that lacks a terminating semicolon.
flush();
return statements;
}
/**
* Returns the statement that contains `cursorPos`, or `null` when the cursor
* sits on a blank line or no statement can be resolved.
*
* The returned range covers only the statement's own text (no trailing `;`),
* which lets the editor highlight a tight preview range.
*/
export function statementRangeAtCursor(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlTextRange | null {
const pos = clampCursor(sql, cursorPos);
if (isCursorOnBlankLine(sql, pos)) return null;
const statements = splitSqlStatementRanges(sql, databaseType);
for (let index = 0; index < statements.length; index += 1) {
const statement = statements[index];
const softRanges = splitStatementRangeAtSoftStarts(sql, statement, databaseType);
// Cursor inside the statement body, including the exact start/end.
if (pos >= statement.from && pos <= statement.to) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
const next = statements[index + 1];
// A caret after a statement's semicolon still belongs to that statement
// until the next statement's text begins.
if (pos > statement.to && (!next || pos < next.from) && isCursorInSameLineDelimiterGap(sql, statement.to, pos)) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
// Cursor in indentation or inter-statement whitespace immediately before
// the statement should still target that statement, while the returned
// execution range remains tight around the SQL text itself.
if (pos >= statement.hitFrom && pos < statement.from && (sql.slice(pos, statement.from).trim() === "" || (databaseType === "elasticsearch" && isElasticsearchRequestPreamble(sql.slice(statement.hitFrom, statement.from))))) {
const previous = statements[index - 1];
if (previous && isCursorInSameLineDelimiterGap(sql, previous.to, pos)) {
const previousSoftRanges = splitStatementRangeAtSoftStarts(sql, previous, databaseType);
return rangeForCursorInSoftRanges(sql, previousSoftRanges, pos) ?? rangeFor(previous, sql);
}
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
if (pos > statement.to && (!next || pos < next.hitFrom) && isCursorOnStatementLine(sql, pos, statement)) {
return rangeForCursorInSoftRanges(sql, softRanges, pos) ?? rangeFor(statement, sql);
}
}
return null;
}
export function mongoCommandRangeAtCursor(sql: string, cursorPos: number): SqlTextRange | null {
const pos = clampCursor(sql, cursorPos);
if (isCursorOnBlankLine(sql, pos)) return null;
const commands = splitMongoCommandRanges(sql);
for (let index = 0; index < commands.length; index += 1) {
const command = commands[index];
const range = { from: command.from, to: command.to, sql: command.text };
if (pos >= command.from && pos <= command.to) return range;
const next = commands[index + 1];
if (pos > command.to && (!next || pos < next.from) && isCursorInSameLineDelimiterGap(sql, command.to, pos)) return range;
if (pos < command.from && sql.slice(pos, command.from).trim() === "" && isCursorOnStatementLine(sql, pos, command)) return range;
}
return null;
}
function isCursorInSameLineDelimiterGap(sql: string, previousStatementEnd: number, cursorPos: number): boolean {
if (cursorPos <= previousStatementEnd) return false;
const between = sql.slice(previousStatementEnd, cursorPos);
const delimiterIndex = between.lastIndexOf(";");
if (delimiterIndex === -1) return false;
const afterDelimiter = between.slice(delimiterIndex + 1);
return !afterDelimiter.includes("\n") && between.slice(0, delimiterIndex).trim() === "" && afterDelimiter.trim() === "";
}
function rangeForCursorInSoftRanges(sql: string, ranges: RawStatement[], pos: number): SqlTextRange | null {
for (let index = 0; index < ranges.length; index += 1) {
const range = ranges[index];
if (pos >= range.from && pos <= range.to) {
return rangeFor(range, sql);
}
if (pos >= range.hitFrom && pos < range.from && sql.slice(pos, range.from).trim() === "") {
return rangeFor(range, sql);
}
const next = ranges[index + 1];
if (pos > range.to && (!next || pos < next.hitFrom) && isCursorOnStatementLine(sql, pos, range)) {
return rangeFor(range, sql);
}
}
return null;
}
function splitStatementRangeAtSoftStarts(sql: string, statement: RawStatement, databaseType?: DatabaseType): RawStatement[] {
if (isOraclePlSqlStatement(statement.sql, databaseType)) return [statement];
if (isSapHanaScriptBlockStatement(statement.sql, databaseType)) return [statement];
// Routine bodies contain top-level-looking SET/INSERT/SELECT lines that are not independent statements.
if (isMysqlRoutineBlockDatabase(databaseType) && startsWithMysqlRoutineBlock(statement.sql)) return [statement];
const lineStarts = topLevelSoftStatementLineStarts(sql, statement, databaseType);
if (lineStarts.length <= 1) return [statement];
const boundaries: Array<{ hitFrom: number; from: number; keyword: string }> = [];
let currentKeyword = softStatementKeywordAt(sql, statement.from, databaseType);
let currentExplainTargetKeyword = explainLikeTargetKeywordAt(sql, statement.from);
let currentBodyKeyword = currentExplainTargetKeyword ?? currentKeyword;
let consumedWithMainStatement = false;
let consumedExplainStatement = false;
boundaries.push({ hitFrom: statement.hitFrom, from: statement.from, keyword: currentKeyword ?? "" });
for (const lineStart of lineStarts) {
if (lineStart.from <= statement.from) continue;
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;
}
if (isSetOperationQueryContinuation(sql, statement.from, lineStart.from, lineStart.keyword)) {
continue;
}
if (!consumedExplainStatement && EXPLAIN_STATEMENT_KEYWORDS.has(lineStart.keyword) && (currentKeyword === "EXPLAIN" || currentExplainTargetKeyword !== null)) {
consumedExplainStatement = true;
currentBodyKeyword = lineStart.keyword;
continue;
}
if (currentBodyKeyword === "CREATE" && CREATE_BODY_KEYWORDS.has(lineStart.keyword)) {
continue;
}
if (currentBodyKeyword === "CREATE" && isMysqlCreateTableOptionContinuation(sql, statement.from, lineStart.from, lineStart.keyword, databaseType)) {
continue;
}
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;
}
if (currentBodyKeyword === "UPDATE" && lineStart.keyword === "SET") {
continue;
}
if (currentBodyKeyword === "ALTER" && isClickHouseAlterTableUpdateContinuation(sql, boundaries[boundaries.length - 1].from, lineStart.from, lineStart.keyword, databaseType)) {
// ClickHouse mutations use UPDATE as the first ALTER TABLE action, not as
// a standalone statement. Keep this dialect-specific to preserve soft boundaries elsewhere.
continue;
}
if (currentBodyKeyword === "ALTER" && isMysqlAlterTableTruncatePartitionContinuation(sql, boundaries[boundaries.length - 1].from, lineStart.from, lineStart.keyword, databaseType)) {
continue;
}
if (currentBodyKeyword === "ALTER" && ALTER_BODY_KEYWORDS.has(lineStart.keyword)) {
continue;
}
boundaries.push(lineStart);
currentKeyword = lineStart.keyword;
currentExplainTargetKeyword = explainLikeTargetKeywordAt(sql, lineStart.from);
currentBodyKeyword = currentExplainTargetKeyword ?? currentKeyword;
consumedWithMainStatement = false;
consumedExplainStatement = false;
}
if (boundaries.length <= 1) return [statement];
const ranges: RawStatement[] = [];
for (let index = 0; index < boundaries.length; index += 1) {
const boundary = boundaries[index];
const next = boundaries[index + 1];
const to = next ? trimRangeEndBeforeNextBoundary(sql, boundary.from, next.from) : trimRangeEnd(sql, boundary.from, statement.to);
if (to > boundary.from) {
ranges.push({
hitFrom: boundary.hitFrom,
from: boundary.from,
to,
sql: sql.slice(boundary.from, to),
});
}
}
return ranges.length > 0 ? ranges : [statement];
}
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;
let lineStart = statement.from;
let firstNonWhitespaceOnLine = -1;
let i = statement.from;
while (i < len) {
const ch = sql[i];
const next = sql[i + 1] ?? "";
if (state === "none" && firstNonWhitespaceOnLine === -1 && ch !== "\n" && ch !== "\r" && !isSqlWhitespace(ch) && !startsLineComment(sql, i) && !startsBlockComment(sql, i)) {
firstNonWhitespaceOnLine = i;
if (parenDepth === 0) {
const keyword = softStatementKeywordAt(sql, i, databaseType);
if (keyword) {
starts.push({ hitFrom: lineStart, from: i, keyword });
}
}
}
if (ch === "\n") {
if (state === "lineComment") state = "none";
lineStart = i + 1;
firstNonWhitespaceOnLine = -1;
i += 1;
continue;
}
if (state === "lineComment") {
i += 1;
continue;
}
if (state === "blockComment") {
if (ch === "*" && next === "/") {
state = "none";
i += 2;
continue;
}
i += 1;
continue;
}
if (state === "dollar") {
if (ch === "$") {
const closingTag = `$${dollarTag}$`;
if (sql.startsWith(closingTag, i)) {
i += closingTag.length;
state = "none";
dollarTag = "";
continue;
}
}
i += 1;
continue;
}
if (state === "single") {
if (ch === "\\" && next) {
i += 2;
continue;
}
if (ch === "'") {
if (next === "'") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "double") {
if (ch === '"') {
if (next === '"') {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "backtick") {
if (ch === "`") {
if (next === "`") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "bracket") {
if (ch === "]") state = "none";
i += 1;
continue;
}
// state === "none"
if (ch === "-" && next === "-") {
state = "lineComment";
i += 2;
continue;
}
if (ch === "#") {
state = "lineComment";
i += 1;
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 === "`") {
state = "backtick";
i += 1;
continue;
}
if (ch === "[") {
state = "bracket";
i += 1;
continue;
}
if (ch === "$") {
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
if (tagMatch) {
dollarTag = tagMatch[0].slice(1, -1);
i += tagMatch[0].length;
state = "dollar";
continue;
}
}
if (ch === "(" && i !== unclosedExplainOptionsStart) {
parenDepth += 1;
} else if (ch === ")" && parenDepth > 0) {
parenDepth -= 1;
}
i += 1;
}
return starts;
}
function softStatementKeywordAt(sql: string, pos: number, databaseType?: DatabaseType): string | null {
const match = /^[A-Za-z_][\w$]*/.exec(sql.slice(pos));
if (!match) return null;
const keyword = match[0].toUpperCase();
if (keyword === "REPLACE" && nextNonWhitespaceChar(sql, pos + match[0].length) === "(") return null;
// COMMENT is also a common column name. Only COMMENT ON starts a standalone
// SQL command; otherwise a line-start projection column must stay in SELECT.
if (keyword === "COMMENT" && nextSqlWord(sql, pos + match[0].length) !== "ON") return null;
return softStatementStartKeywords(databaseType).has(keyword) ? keyword : null;
}
function softStatementStartKeywords(databaseType?: DatabaseType): Set<string> {
return new Set([...COMMON_SOFT_STATEMENT_START_KEYWORDS, ...(databaseType ? (DATABASE_SOFT_STATEMENT_KEYWORDS[databaseType] ?? []) : [])]);
}
function isSetOperationQueryContinuation(sql: string, from: number, to: number, keyword: string): boolean {
if (keyword !== "SELECT" && keyword !== "WITH") return false;
const words = topLevelWordsBefore(sql, from, to, 3);
const last = words[words.length - 1];
if (last && SET_OPERATION_KEYWORDS.has(last)) return true;
if (last && SET_OPERATION_MODIFIER_KEYWORDS.has(last)) {
const previous = words[words.length - 2];
return !!previous && SET_OPERATION_KEYWORDS.has(previous);
}
return false;
}
function isMysqlCreateTableOptionContinuation(sql: string, statementFrom: number, lineStartFrom: number, keyword: string, databaseType?: DatabaseType): boolean {
if (databaseType && !MYSQL_CREATE_TABLE_OPTION_DATABASES.has(databaseType)) return false;
if (keyword !== "COMMENT") return false;
if (!startsWithMysqlCreateTable(sql, statementFrom)) return false;
const next = nextNonWhitespaceChar(sql, lineStartFrom + keyword.length);
return next === "=" || next === "'" || next === '"';
}
function isClickHouseAlterTableUpdateContinuation(sql: string, statementFrom: number, lineStartFrom: number, keyword: string, databaseType?: DatabaseType): boolean {
if (databaseType !== "clickhouse" || keyword !== "UPDATE") return false;
return CLICKHOUSE_ALTER_TABLE_HEADER.test(sql.slice(statementFrom, lineStartFrom));
}
function isMysqlAlterTableTruncatePartitionContinuation(sql: string, statementFrom: number, lineStartFrom: number, keyword: string, databaseType?: DatabaseType): boolean {
if (databaseType !== "mysql" || keyword !== "TRUNCATE") return false;
if (!startsWithSqlWords(sql, statementFrom, ["ALTER", "TABLE"])) return false;
return nextSqlWord(sql, lineStartFrom + keyword.length) === "PARTITION";
}
function startsWithMysqlCreateTable(sql: string, statementFrom: number): boolean {
const text = sql.slice(statementFrom, statementFrom + 256);
return /^CREATE\s+(?:TEMPORARY\s+)?TABLE\b/i.test(text);
}
function topLevelWordsBefore(sql: string, from: number, to: number, limit: number): string[] {
const words: string[] = [];
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
let parenDepth = 0;
let i = from;
while (i < to) {
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 === "dollar") {
if (ch === "$") {
const closingTag = `$${dollarTag}$`;
if (sql.startsWith(closingTag, i)) {
i += closingTag.length;
state = "none";
dollarTag = "";
continue;
}
}
i += 1;
continue;
}
if (state === "single") {
if (ch === "\\" && next) {
i += 2;
continue;
}
if (ch === "'") {
if (next === "'") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "double") {
if (ch === '"') {
if (next === '"') {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "backtick") {
if (ch === "`") {
if (next === "`") {
i += 2;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "bracket") {
if (ch === "]") state = "none";
i += 1;
continue;
}
if (ch === "-" && next === "-") {
state = "lineComment";
i += 2;
continue;
}
if (ch === "#") {
state = "lineComment";
i += 1;
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 === "`") {
state = "backtick";
i += 1;
continue;
}
if (ch === "[") {
state = "bracket";
i += 1;
continue;
}
if (ch === "$") {
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
if (tagMatch) {
dollarTag = tagMatch[0].slice(1, -1);
i += tagMatch[0].length;
state = "dollar";
continue;
}
}
if (ch === "(") {
parenDepth += 1;
i += 1;
continue;
}
if (ch === ")") {
if (parenDepth > 0) parenDepth -= 1;
i += 1;
continue;
}
if (parenDepth === 0) {
const match = /^[A-Za-z_][\w$]*/.exec(sql.slice(i));
if (match) {
words.push(match[0].toUpperCase());
if (words.length > limit) words.shift();
i += match[0].length;
continue;
}
}
i += 1;
}
return words;
}
function nextNonWhitespaceChar(sql: string, pos: number): string | null {
let i = pos;
while (i < sql.length && isSqlWhitespace(sql[i])) i += 1;
return i < sql.length ? sql[i] : null;
}
function nextSqlWord(sql: string, pos: number): string | null {
return nextSqlWordToken(sql, pos)?.word ?? null;
}
function startsWithSqlWords(sql: string, pos: number, expectedWords: readonly string[]): boolean {
let i = pos;
for (const expectedWord of expectedWords) {
const token = nextSqlWordToken(sql, i);
if (token?.word !== expectedWord) return false;
i = token.end;
}
return true;
}
function nextSqlWordToken(sql: string, pos: number): { word: string; end: number } | null {
let i = pos;
// SQL comments may legally appear anywhere whitespace can separate keywords.
while (i < sql.length) {
if (isSqlWhitespace(sql[i])) {
i += 1;
continue;
}
if (sql[i] === "-" && sql[i + 1] === "-") {
i += 2;
while (i < sql.length && sql[i] !== "\n" && sql[i] !== "\r") i += 1;
continue;
}
if (sql[i] === "#") {
i += 1;
while (i < sql.length && sql[i] !== "\n" && sql[i] !== "\r") i += 1;
continue;
}
if (sql[i] === "/" && sql[i + 1] === "*") {
const commentEnd = sql.indexOf("*/", i + 2);
if (commentEnd < 0) return null;
i = commentEnd + 2;
continue;
}
break;
}
const match = /^[A-Za-z_][\w$]*/.exec(sql.slice(i));
if (!match) return null;
return { word: match[0].toUpperCase(), end: i + match[0].length };
}
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();
if (!isExplainLikeKeyword(prefix ?? null)) return 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] === "#";
}
function startsBlockComment(sql: string, pos: number): boolean {
return sql[pos] === "/" && sql[pos + 1] === "*";
}
function trimRangeEnd(sql: string, from: number, to: number): number {
let end = to;
while (end > from && isSqlWhitespace(sql[end - 1])) {
end -= 1;
}
return end;
}
function trimRangeEndBeforeNextBoundary(sql: string, from: number, nextBoundaryFrom: number): number {
let state: QuoteState | "lineComment" | "blockComment" = "none";
let dollarTag = "";
let lastContentEnd = from;
let i = from;
while (i < nextBoundaryFrom) {
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 === "dollar") {
lastContentEnd = i + 1;
if (ch === "$") {
const closingTag = `$${dollarTag}$`;
if (sql.startsWith(closingTag, i)) {
i += closingTag.length;
lastContentEnd = i;
state = "none";
dollarTag = "";
continue;
}
}
i += 1;
continue;
}
if (state === "single") {
lastContentEnd = i + 1;
if (ch === "\\" && next) {
i += 2;
lastContentEnd = i;
continue;
}
if (ch === "'") {
if (next === "'") {
i += 2;
lastContentEnd = i;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "double") {
lastContentEnd = i + 1;
if (ch === '"') {
if (next === '"') {
i += 2;
lastContentEnd = i;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "backtick") {
lastContentEnd = i + 1;
if (ch === "`") {
if (next === "`") {
i += 2;
lastContentEnd = i;
continue;
}
state = "none";
}
i += 1;
continue;
}
if (state === "bracket") {
lastContentEnd = i + 1;
if (ch === "]") state = "none";
i += 1;
continue;
}
if (ch === "-" && next === "-") {
state = "lineComment";
i += 2;
continue;
}
if (ch === "#") {
state = "lineComment";
i += 1;
continue;
}
if (ch === "/" && next === "*") {
state = "blockComment";
i += 2;
continue;
}
if (ch === "'") {
state = "single";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === '"') {
state = "double";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === "`") {
state = "backtick";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === "[") {
state = "bracket";
lastContentEnd = i + 1;
i += 1;
continue;
}
if (ch === "$") {
const tagMatch = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(i));
if (tagMatch) {
state = "dollar";
dollarTag = tagMatch[0].slice(1, -1);
i += tagMatch[0].length;
lastContentEnd = i;
continue;
}
}
if (!isSqlWhitespace(ch)) {
lastContentEnd = i + 1;
}
i += 1;
}
return trimRangeEnd(sql, from, lastContentEnd);
}
function isSqlWhitespace(ch: string): boolean {
return ch === " " || ch === "\t" || ch === "\r" || ch === "\n";
}
export function isOracleLikeDatabase(databaseType?: DatabaseType): boolean {
return !!databaseType && ORACLE_LIKE_PL_SQL_DATABASES.has(databaseType);
}
export function isOraclePlSqlStatement(sql: string, databaseType?: DatabaseType): boolean {
return isOracleLikeDatabase(databaseType) && startsWithOraclePlSqlBlock(sql);
}
function isSapHanaScriptBlockDatabase(databaseType?: DatabaseType): boolean {
return databaseType === "saphana";
}
function isSapHanaScriptBlockStatement(sql: string, databaseType?: DatabaseType): boolean {
return isSapHanaScriptBlockDatabase(databaseType) && startsWithSapHanaScriptBlock(sql);
}
function isMysqlRoutineBlockDatabase(databaseType?: DatabaseType): boolean {
return !!databaseType && MYSQL_ROUTINE_BLOCK_DATABASES.has(databaseType);
}
function startsWithMysqlRoutineBlock(sql: string): boolean {
return isMysqlRoutineDdlStart(sql) && mysqlRoutineTokens(sql).some((token) => token.kind === "word" && token.value === "BEGIN");
}
function isMysqlRoutineDdlStart(sql: string): boolean {
const words = mysqlRoutineWords(sql).slice(0, 16);
if (words[0] !== "CREATE") return false;
for (const word of words.slice(1)) {
if (MYSQL_ROUTINE_OBJECT_TYPES.has(word)) return true;
if (MYSQL_NON_ROUTINE_CREATE_TYPES.has(word)) return false;
}
return false;
}
function mysqlRoutineBlockIsComplete(sql: string): boolean {
if (!startsWithMysqlRoutineBlock(sql)) return false;
const tokens = mysqlRoutineTokens(sql);
let beginDepth = 0;
let sawBegin = false;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token.kind !== "word") continue;
if (token.value === "BEGIN") {
if (previousWordToken(tokens, index) === "END") continue;
sawBegin = true;
beginDepth += 1;
continue;
}
if (token.value === "END" && sawBegin) {
if (MYSQL_CONTROL_BLOCK_SUFFIXES.has(nextWordToken(tokens, index) ?? "")) continue;
beginDepth = Math.max(0, beginDepth - 1);
}
}
return sawBegin && beginDepth === 0 && tokens[tokens.length - 1]?.kind === "semicolon";
}
function mysqlRoutineWords(sql: string): string[] {
return mysqlRoutineTokens(sql)
.filter((token): token is { kind: "word"; value: string } => token.kind === "word")
.map((token) => token.value);
}
function mysqlRoutineTokens(sql: string): Array<{ kind: "word" | "semicolon"; value: string }> {
const tokens: Array<{ kind: "word" | "semicolon"; value: string }> = [];
let state: QuoteState | "lineComment" | "blockComment" = "none";
let i = 0;
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 === "'" && next === "'") {
i += 2;
continue;
}
if (ch === "'") state = "none";
i += 1;
continue;
}
if (state === "double") {
if (ch === "\\" && next) {
i += 2;
continue;
}
if (ch === '"' && next === '"') {
i += 2;
continue;
}
if (ch === '"') state = "none";
i += 1;
continue;
}
if (state === "backtick") {
if (ch === "`" && next === "`") {
i += 2;
continue;
}
if (ch === "`") state = "none";
i += 1;
continue;
}
if (ch === "-" && next === "-") {
state = "lineComment";
i += 2;
continue;
}
if (ch === "#") {
state = "lineComment";
i += 1;
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 === "`") {
state = "backtick";
i += 1;
continue;
}
if (ch === ";") {
tokens.push({ kind: "semicolon", value: ";" });
i += 1;
continue;
}
const word = /^[A-Za-z_][\w$]*/.exec(sql.slice(i))?.[0];
if (word) {
tokens.push({ kind: "word", value: word.toUpperCase() });
i += word.length;
continue;
}
i += 1;
}
return tokens;
}
function startsWithOraclePlSqlBlock(sql: string): boolean {
const words = oraclePlSqlWords(sql);
const first = words[0];
if (!first) return false;
if (ORACLE_PL_SQL_BLOCK_STARTERS.has(first)) return first !== "BEGIN" || words[1] !== "TRANSACTION";
if (first !== "CREATE") return false;
const index = skipOraclePlSqlCreateModifiers(words, 1);
// PACKAGE BODY / TYPE BODY are programmable blocks with an outer END.
if (words[index] === "PACKAGE" && words[index + 1] === "BODY") return true;
if (words[index] === "TYPE" && words[index + 1] === "BODY") return true;
// Plain CREATE TYPE ... AS OBJECT (...); is ordinary SQL terminated by ';'.
if (words[index] === "TYPE") return false;
return ORACLE_PL_SQL_CREATE_OBJECT_TYPES.has(words[index] ?? "");
}
/** Skip OR REPLACE / FORCE / NOFORCE / EDITIONABLE modifiers after CREATE. */
function skipOraclePlSqlCreateModifiers(words: readonly string[], startIndex: number): number {
let index = startIndex;
while (index < words.length) {
if (words[index] === "OR" && words[index + 1] === "REPLACE") {
index += 2;
continue;
}
if (["FORCE", "NOFORCE", "EDITIONABLE", "NONEDITIONABLE"].includes(words[index] ?? "")) {
index += 1;
continue;
}
break;
}
return index;
}
function startsWithSapHanaScriptBlock(sql: string): boolean {
return oraclePlSqlWords(sql)[0] === "DO";
}
function sapHanaScriptBlockIsComplete(sql: string): boolean {
if (!startsWithSapHanaScriptBlock(sql)) return false;
const tokens = oraclePlSqlTokens(sql);
const stack: string[] = [];
let sawBegin = false;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token.kind !== "word") continue;
if (token.value === "BEGIN") {
if (previousWordToken(tokens, index) === "END") continue;
stack.push("BLOCK");
sawBegin = true;
continue;
}
if (token.value === "IF" || token.value === "FOR" || token.value === "WHILE" || token.value === "CASE") {
if (previousWordToken(tokens, index) !== "END") stack.push(token.value);
continue;
}
if (token.value === "END") {
const next = nextWordToken(tokens, index);
const top = stack[stack.length - 1];
const target = SAP_HANA_SCRIPT_BLOCK_TERMINATORS.has(next ?? "") ? next : top === "CASE" ? "CASE" : "BLOCK";
if (top === target) stack.pop();
}
}
return sawBegin && stack.length === 0 && tokens[tokens.length - 1]?.kind === "semicolon";
}
function oraclePlSqlBlockIsComplete(sql: string): boolean {
const tokens = oraclePlSqlTokens(sql);
if (!startsWithOraclePlSqlBlock(sql)) return false;
// Package/type specifications have no BEGIN — only declarations closed by
// END [name];. Bodies also own an outer END beyond nested routine END pairs.
const objectKind = oraclePlSqlCreateObjectKind(sql);
const stack: string[] = objectKind === "body" ? ["OBJECT_BODY"] : objectKind === "spec" ? ["OBJECT_SPEC"] : [];
let sawBegin = false;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token.kind !== "word") continue;
if (token.value === "DECLARE") {
stack.push("DECLARATION");
continue;
}
if (token.value === "BEGIN") {
if (tokens[index - 1]?.kind === "word" && tokens[index - 1]?.value === "TRANSACTION") continue;
const previous = previousWordToken(tokens, index);
if (previous === "END") continue;
sawBegin = true;
if (stack[stack.length - 1] === "DECLARATION") stack[stack.length - 1] = "BLOCK";
else stack.push("BLOCK");
continue;
}
if (token.value === "IF") {
const previous = previousWordToken(tokens, index);
if (previous !== "END" && previous !== "ELSIF") stack.push("IF");
continue;
}
if (token.value === "LOOP") {
if (previousWordToken(tokens, index) !== "END") stack.push("LOOP");
continue;
}
if (token.value === "CASE") {
// Both CASE statements and CASE expressions own an END. The CASE token
// following END CASE is ignored below, so it cannot start a new scope.
if (previousWordToken(tokens, index) !== "END") stack.push("CASE");
continue;
}
if (token.value === "END") {
const top = stack[stack.length - 1];
// CASE expressions close as END; while CASE statements close as END CASE;.
// In either form, this END belongs to CASE rather than the surrounding block.
if (top === "CASE") {
stack.pop();
continue;
}
const next = nextWordToken(tokens, index);
const target = ORACLE_PL_SQL_TERMINATORS.has(next ?? "") ? next : top === "OBJECT_BODY" || top === "OBJECT_SPEC" ? top : "BLOCK";
if (top === target || (target === "BLOCK" && top === "BLOCK")) stack.pop();
continue;
}
}
const endsWithSemicolon = tokens[tokens.length - 1]?.kind === "semicolon";
if (objectKind === "spec") {
// Specs complete on outer END [name]; without requiring a BEGIN block.
return stack.length === 0 && endsWithSemicolon;
}
return sawBegin && stack.length === 0 && endsWithSemicolon;
}
/**
* Classify CREATE programmable objects:
* - body: PACKAGE BODY / TYPE BODY (outer END beyond nested routines)
* - spec: PACKAGE specification only (declarations + END, no BEGIN)
* - null: ordinary SQL / other objects (including plain CREATE TYPE ... AS OBJECT)
*/
function oraclePlSqlCreateObjectKind(sql: string): "body" | "spec" | null {
const words = oraclePlSqlWords(sql);
if (words[0] !== "CREATE") return null;
const index = skipOraclePlSqlCreateModifiers(words, 1);
if ((words[index] === "PACKAGE" || words[index] === "TYPE") && words[index + 1] === "BODY") {
return "body";
}
// Only PACKAGE specs lack BEGIN; plain TYPE objects end with ");".
if (words[index] === "PACKAGE") {
return "spec";
}
return null;
}
function oraclePlSqlWords(sql: string): string[] {
return oraclePlSqlTokens(sql)
.filter((token): token is { kind: "word"; value: string } => token.kind === "word")
.map((token) => token.value);
}
function oraclePlSqlTokens(sql: string): Array<{ kind: "word" | "semicolon"; value: string }> {
const tokens: Array<{ kind: "word" | "semicolon"; value: string }> = [];
let state: QuoteState | "lineComment" | "blockComment" = "none";
let i = 0;
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 === ";") {
tokens.push({ kind: "semicolon", value: ";" });
i += 1;
continue;
}
const word = /^[A-Za-z_][\w$]*/.exec(sql.slice(i))?.[0];
if (word) {
tokens.push({ kind: "word", value: word.toUpperCase() });
i += word.length;
continue;
}
i += 1;
}
return tokens;
}
function previousWordToken(tokens: Array<{ kind: "word" | "semicolon"; value: string }>, index: number): string | null {
for (let i = index - 1; i >= 0; i -= 1) {
if (tokens[i].kind === "word") return tokens[i].value;
}
return null;
}
function nextWordToken(tokens: Array<{ kind: "word" | "semicolon"; value: string }>, index: number): string | null {
for (let i = index + 1; i < tokens.length; i += 1) {
if (tokens[i].kind === "word") return tokens[i].value;
}
return null;
}
function isAtLineStart(sql: string, pos: number): boolean {
for (let i = pos - 1; i >= 0; i -= 1) {
const ch = sql[i];
if (ch === "\n" || ch === "\r") return true;
if (ch !== " " && ch !== "\t") return false;
}
return true;
}
function isSlashLine(sql: string, pos: number): boolean {
const lineEnd = findLineEnd(sql, pos);
return sql.slice(pos, lineEnd).trim() === "/";
}
function supportsSqlServerGoCommands(databaseType?: DatabaseType): boolean {
return databaseType === "sqlserver";
}
function isSqlServerGoLine(sql: string, pos: number): boolean {
const lineEnd = findLineEnd(sql, pos);
return /^go(?:\s+\d+)?$/i.test(sql.slice(pos, lineEnd).trim());
}
function startsDelimiterCommand(sql: string, pos: number): boolean {
const prefix = sql.slice(pos, pos + 9);
return prefix.toLowerCase() === "delimiter" && (sql[pos + 9] === " " || sql[pos + 9] === "\t");
}
function parseDelimiterCommand(line: string): string | null {
const match = /^delimiter[ \t]+(.+)$/i.exec(line.trim());
const delimiter = match?.[1]?.trim();
return delimiter ? delimiter : null;
}
function findLineEnd(sql: string, pos: number): number {
const newline = sql.indexOf("\n", pos);
const carriageReturn = sql.indexOf("\r", pos);
if (newline === -1) return carriageReturn === -1 ? sql.length : carriageReturn;
if (carriageReturn === -1) return newline;
return Math.min(newline, carriageReturn);
}
function nextLineStart(sql: string, lineEnd: number): number {
if (sql[lineEnd] === "\r" && sql[lineEnd + 1] === "\n") return lineEnd + 2;
if (sql[lineEnd] === "\n" || sql[lineEnd] === "\r") return lineEnd + 1;
return lineEnd;
}
function rangeFor(statement: RawStatement, sql: string): SqlTextRange {
return {
from: statement.from,
to: statement.to,
sql: sql.slice(statement.from, statement.to),
};
}
function clampCursor(sql: string, cursorPos: number): number {
if (!Number.isFinite(cursorPos)) return 0;
if (cursorPos < 0) return 0;
if (cursorPos > sql.length) return sql.length;
return cursorPos;
}
function isCursorOnBlankLine(sql: string, pos: number): boolean {
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
let lineEnd = sql.indexOf("\n", pos);
if (lineEnd === -1) lineEnd = sql.length;
return sql.slice(lineStart, lineEnd).trim() === "";
}
function isCursorOnStatementLine(sql: string, pos: number, statement: Pick<RawStatement, "from">): boolean {
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
let lineEnd = sql.indexOf("\n", pos);
if (lineEnd === -1) lineEnd = sql.length;
return statement.from >= lineStart && statement.from <= lineEnd;
}
/**
* Returns the full document as a range, or `null` when it is empty/whitespace.
*/
export function fullSqlRange(sql: string): SqlTextRange | null {
const trimmed = sql.trim();
if (!trimmed) return null;
const from = sql.length - sql.trimStart().length;
const to = from + trimmed.length;
return { from, to, sql: sql.slice(from, to) };
}
function normalizeSql(sql: string): string {
return sql.replace(/\s+/g, " ").replace(/;\s*$/, "").trim();
}
/**
* Build the ordered list of execution candidates to show in the picker.
*
* Order is always `[cursor, all]` when both are available, except when the
* cursor statement and the full document are effectively the same SQL — in
* that case only a single candidate is returned to avoid duplicates.
*/
export function executableStatementRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
if (databaseType === "redis") return redisExecutableCommandRanges(sql);
if (databaseType === "mongodb") return splitMongoCommandRanges(sql).map(({ from, to, text }) => ({ from, to, sql: text }));
return splitSqlStatementRanges(sql, databaseType).flatMap((statement) => splitStatementRangeAtSoftStarts(sql, statement, databaseType).map((range) => rangeFor(range, sql)));
}
export function currentExecutableStatementRange(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlTextRange | null {
if (databaseType === "redis") return redisCommandRangeAtCursor(sql, cursorPos);
if (databaseType === "mongodb") return null;
return statementRangeAtCursor(sql, cursorPos, databaseType);
}
export function buildExecutionCandidates(sql: string, cursorPos: number, databaseType?: DatabaseType): SqlExecutionCandidate[] {
const full = fullSqlRange(sql);
const cursorStatement = currentExecutableStatementRange(sql, cursorPos, databaseType);
if (!full && !cursorStatement) return [];
if (!full) {
return cursorStatement ? [candidateFromRange(cursorStatement, "cursor", databaseType)] : [];
}
if (!cursorStatement) {
return [candidateFromRange(full, "all", databaseType)];
}
const sameContent = normalizeSql(cursorStatement.sql) === normalizeSql(full.sql);
if (sameContent) {
return [candidateFromRange(full, "all", databaseType)];
}
return [candidateFromRange(cursorStatement, "cursor", databaseType), candidateFromRange(full, "all", databaseType)];
}
function candidateFromRange(range: SqlTextRange, kind: SqlExecutionCandidate["kind"], databaseType?: DatabaseType): SqlExecutionCandidate {
const isRedis = databaseType === "redis";
return {
kind,
label: kind === "cursor" ? (isRedis ? "currentCommand" : "currentStatement") : isRedis ? "allCommands" : "allStatements",
sql: range.sql,
from: range.from,
to: range.to,
};
}
function redisExecutableCommandCount(sql: string): number {
let count = 0;
for (const range of redisExecutableCommandRanges(sql)) {
if (!range.sql.trim()) continue;
count += 1;
if (count > 1) return count;
}
return count;
}
function redisExecutableCommandRanges(sql: string): SqlTextRange[] {
const ranges: SqlTextRange[] = [];
let lineStart = 0;
while (lineStart <= sql.length) {
let lineEnd = sql.indexOf("\n", lineStart);
if (lineEnd === -1) lineEnd = sql.length;
const rawLine = sql.slice(lineStart, lineEnd);
const leadingWhitespace = rawLine.length - rawLine.trimStart().length;
const trailingWhitespace = rawLine.length - rawLine.trimEnd().length;
const trimmedLine = rawLine.trim();
if (trimmedLine && !trimmedLine.startsWith("#")) {
const from = lineStart + leadingWhitespace;
const to = lineStart + rawLine.length - trailingWhitespace;
ranges.push({ from, to, sql: sql.slice(from, to) });
}
if (lineEnd >= sql.length) break;
lineStart = lineEnd + 1;
}
return ranges;
}
function redisCommandRangeAtCursor(sql: string, cursorPos: number): SqlTextRange | null {
const pos = clampCursor(sql, cursorPos);
if (isCursorOnBlankLine(sql, pos)) return null;
const lineStart = sql.lastIndexOf("\n", pos - 1) + 1;
let lineEnd = sql.indexOf("\n", pos);
if (lineEnd === -1) lineEnd = sql.length;
const rawLine = sql.slice(lineStart, lineEnd);
const leadingWhitespace = rawLine.length - rawLine.trimStart().length;
const trimmedLine = rawLine.trim();
if (!trimmedLine || trimmedLine.startsWith("#")) return null;
const from = lineStart + leadingWhitespace;
const to = lineStart + rawLine.length - (rawLine.length - rawLine.trimEnd().length);
return {
from,
to,
sql: sql.slice(from, to),
};
}