fix(sql): stop completion at the next top-level statement

This commit is contained in:
dienaso authored and GitHub committed 2026-09-18 01:35:53 +08:00
1 parent 7b164d0084
commit 4cdf18d652
2 files changed
+137 -3

No files matched your search

@@ -1484,3 +1484,62 @@ describe("select alias visibility", () => {
expect(untyped.selectAliases).toContain("cnt");
});
});
describe("line block statement boundary", () => {
it.each(["-- explanatory comment", "/* explanatory comment */", "/*\n explanatory comment\n*/"])("keeps column sources after %s", (comment) => {
const sql = `select na\n${comment}\nfrom users`;
const cursor = "select na".length;
const context = getSqlCompletionContext(sql, cursor, { databaseType: "iris" });
expect(context.referencedTables.map((table) => table.name)).toEqual(["users"]);
const items = buildSqlCompletionItems(sql, cursor, {
databaseType: "iris",
tables: [{ name: "users", type: "table" }],
columnsByTable: new Map([["users", [{ name: "name", table: "users", key: "name" } as never]]]),
});
expect(items.some((item) => item.label === "name")).toBe(true);
});
it("still ends the block at a real blank line", () => {
const sql = "select na\n\nfrom users";
const context = getSqlCompletionContext(sql, "select na".length, { databaseType: "iris" });
expect(context.referencedTables).toEqual([]);
});
it("stops the active block at a top-level statement line without a semicolon", () => {
// t8y2/dbx#9370: two semicolon-free SELECT lines — completion at the first
// statement's WHERE must not pull the second statement's table in.
const sql = "select * from CT_Nation where\nselect * from CT_CityArea";
const cursor = sql.indexOf("where") + "where".length;
const context = getSqlCompletionContext(sql, cursor, { databaseType: "iris" });
expect(context.referencedTables.map((table) => table.name)).toEqual(["CT_Nation"]);
});
it("keeps column completion unqualified once the second statement is excluded", () => {
const sql = "select * from CT_Nation where c\nselect * from CT_CityArea";
const cursor = sql.indexOf("where") + "where c".length;
const items = buildSqlCompletionItems(sql, cursor, {
databaseType: "iris",
tables: [
{ name: "CT_Nation", type: "table" },
{ name: "CT_CityArea", type: "table" },
],
columnsByTable: new Map([
["CT_Nation", [{ name: "CTNAT_Code", table: "CT_Nation", key: "k1" } as never]],
["CT_CityArea", [{ name: "CITAREA_Code", table: "CT_CityArea", key: "k2" } as never]],
]),
});
expect(items.some((item) => item.label === "CT_CityArea.CITAREA_Code")).toBe(false);
expect(items.some((item) => item.label === "CTNAT_Code")).toBe(true);
});
it("does not end the block at a SELECT line inside parentheses", () => {
const sql = "select *\nfrom (\n select id from users\n) x\nwhere |";
const cursor = sql.length;
const context = getSqlCompletionContext(sql, cursor, { databaseType: "mysql" });
expect(context.referencedTables.map((table) => table.name)).toEqual(expect.arrayContaining(["users"]));
});
});
+78 -3
View File
@@ -2134,18 +2134,93 @@ function currentSqlLikeLineBlockSpan(sql: string, cursor: number, activeStatemen
return { start, end: blockEnd == null ? activeStatementSpan.end : Math.min(activeStatementSpan.end, blockEnd) };
}
// Equal-length masking of string/comment characters, so parenthesis depth and
// line-start keywords can be scanned on the masked copy while offsets stay
// valid against the original text.
function maskSqlForStructureScan(sql: string): string {
let out = "";
let i = 0;
while (i < sql.length) {
const ch = sql[i]!;
if (ch === "'" || ch === '"' || ch === "`") {
const quote = ch;
out += ch;
i += 1;
while (i < sql.length) {
if (sql[i] === "\\" && i + 1 < sql.length) {
out += " ";
i += 2;
continue;
}
const same = sql[i] === quote;
out += same ? quote : " ";
i += 1;
if (same) break;
}
continue;
}
if (ch === "-" && sql[i + 1] === "-") {
while (i < sql.length && sql[i] !== "\n") {
out += " ";
i += 1;
}
continue;
}
if (ch === "/" && sql[i + 1] === "*") {
out += " ";
i += 2;
while (i < sql.length) {
if (sql[i] === "*" && sql[i + 1] === "/") {
out += " ";
i += 2;
break;
}
out += sql[i] === "\n" ? "\n" : " ";
i += 1;
}
continue;
}
out += ch;
i += 1;
}
return out;
}
// Statements users type without a trailing semicolon: a line starting one of
// these at top level begins a new statement block (t8y2/dbx#9370).
const STATEMENT_START_LINE_PATTERN = /^(?:select|with|insert|update|delete|create|drop|alter)\b/i;
function currentLineBlockEnd(sql: string, cursor: number, start: number): number | null {
const masked = maskSqlForStructureScan(sql);
let lineStart = sql.lastIndexOf("\n", cursor - 1) + 1;
let depth = 0;
for (let i = start; i < lineStart; i += 1) {
const ch = masked[i];
if (ch === "(") depth += 1;
else if (ch === ")") depth = Math.max(0, depth - 1);
}
let atCursorLine = true;
while (lineStart < sql.length) {
const lineEnd = sql.indexOf("\n", lineStart);
const boundedLineEnd = lineEnd >= 0 ? lineEnd : sql.length;
const line = sql.slice(lineStart, boundedLineEnd);
const trimmed = line.trimStart();
if (lineStart > start && (!trimmed || /^(get|post|put|delete|patch|head)\s+\//i.test(trimmed))) {
const originalTrimmed = sql.slice(lineStart, boundedLineEnd).trimStart();
const trimmed = masked.slice(lineStart, boundedLineEnd).trimStart();
if (lineStart > start && (!originalTrimmed || /^(get|post|put|delete|patch|head)\s+\//i.test(originalTrimmed))) {
return lineStart;
}
// The cursor's own line always belongs to the block; only a following
// top-level statement line ends it.
if (!atCursorLine && lineStart > start && depth === 0 && STATEMENT_START_LINE_PATTERN.test(trimmed)) {
return lineStart;
}
if (lineEnd < 0) break;
for (let i = lineStart; i < boundedLineEnd; i += 1) {
const ch = masked[i];
if (ch === "(") depth += 1;
else if (ch === ")") depth = Math.max(0, depth - 1);
}
lineStart = lineEnd + 1;
atCursorLine = false;
}
return null;
}