2060 lines
63 KiB
TypeScript
2060 lines
63 KiB
TypeScript
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),
|
||
};
|
||
}
|