1 parent
68b0ab0476
commit
5c75c2391c
8 files changed
+307
-19
No files matched your search
@@ -128,6 +128,7 @@ import { computePasteCaretResyncTarget } from "@/lib/editor/queryEditorPasteCare
|
||||
import { queryEditorCommentTokens, queryEditorLineCommentToken } from "@/lib/editor/queryEditorLineComment";
|
||||
import { createShellLineCommentHighlight } from "@/lib/editor/codemirrorShellLineCommentHighlight";
|
||||
import { extendQueryEditorSelection, runQueryEditorAltExtendSelection } from "@/lib/editor/queryEditorExtendSelection";
|
||||
import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection";
|
||||
import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut";
|
||||
import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation";
|
||||
import { isSchemaAware, isSingleDatabase, supportsDatabaseNameCompletion, supportsDatabaseSchemaQualifier, supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
|
||||
@@ -191,6 +192,11 @@ function sqlBehaviorDialect(): "mysql" | "postgres" | "sqlserver" | undefined {
|
||||
return props.syntaxDialect === "clickhouse" ? props.dialect : (props.syntaxDialect ?? props.dialect);
|
||||
}
|
||||
|
||||
function queryEditorSelectionLanguage(): "sql" | "text" {
|
||||
const databaseType = props.databaseType;
|
||||
return databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "victoriametrics" ? "text" : "sql";
|
||||
}
|
||||
|
||||
const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120;
|
||||
const COMPLETION_DEBOUNCE_DELAY_MS = 150;
|
||||
const COMPLETION_TAB_RETRY_DELAY_MS = 16;
|
||||
@@ -1998,11 +2004,10 @@ function insertNewlineWithoutCompletion(view: EditorViewType): boolean {
|
||||
|
||||
function extendQueryEditorSelectionForView(currentView: EditorViewType): boolean {
|
||||
const databaseType = props.databaseType;
|
||||
const language = databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "victoriametrics" ? "text" : "sql";
|
||||
return extendQueryEditorSelection(currentView, {
|
||||
databaseType,
|
||||
dialect: sqlBehaviorDialect(),
|
||||
language,
|
||||
language: queryEditorSelectionLanguage(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5151,6 +5156,16 @@ onMounted(async () => {
|
||||
dropCursor(),
|
||||
props.readOnly ? [] : scrollPastEnd(),
|
||||
EditorView.dragMovesSelection.of((event) => !event.ctrlKey && !event.metaKey),
|
||||
Prec.highest(
|
||||
EditorView.mouseSelectionStyle.of((currentView, event) =>
|
||||
createQueryEditorStringMouseSelection(currentView, event, {
|
||||
databaseType: props.databaseType,
|
||||
dialect: sqlBehaviorDialect(),
|
||||
language: queryEditorSelectionLanguage(),
|
||||
composing: isEditorComposing(currentView),
|
||||
}),
|
||||
),
|
||||
),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
|
||||
@@ -398,6 +398,10 @@ describe("query editor extend selection", () => {
|
||||
expect(queryEditorSource).toContain("runQueryEditorAltExtendSelection");
|
||||
});
|
||||
|
||||
it("registers a high-priority SQL string mouse selection style", () => {
|
||||
expect(queryEditorSource).toMatch(/Prec\.highest\(\s*EditorView\.mouseSelectionStyle\.of/);
|
||||
});
|
||||
|
||||
it("matches macOS Option+W by physical key when the event key is transformed", () => {
|
||||
let commandRuns = 0;
|
||||
let prevented = false;
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import type { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection";
|
||||
import { sqlStringContentRangeAt, type SqlSemanticSelectionOptions } from "@/lib/editor/sqlSemanticSelectionRanges";
|
||||
|
||||
function eventAt(position: number, assoc: -1 | 1 = 1, overrides: Partial<MouseEvent> = {}): MouseEvent {
|
||||
return {
|
||||
altKey: false,
|
||||
button: 0,
|
||||
clientX: position,
|
||||
clientY: assoc,
|
||||
detail: 2,
|
||||
...overrides,
|
||||
} as MouseEvent;
|
||||
}
|
||||
|
||||
function fakeView(initialState: EditorState): { view: EditorView; setState: (state: EditorState) => void } {
|
||||
let state = initialState;
|
||||
return {
|
||||
view: {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
posAndSideAtCoords(coords: { x: number; y: number }) {
|
||||
return { pos: coords.x, assoc: coords.y < 0 ? -1 : 1 };
|
||||
},
|
||||
} as EditorView,
|
||||
setState(nextState) {
|
||||
state = nextState;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function contentAt(doc: string, position: number, assoc: -1 | 1 = 1, options: SqlSemanticSelectionOptions = {}): string | null {
|
||||
const range = sqlStringContentRangeAt(doc, position, assoc, options);
|
||||
return range ? doc.slice(range.from, range.to) : null;
|
||||
}
|
||||
|
||||
describe("query editor SQL string mouse selection", () => {
|
||||
it("finds the complete reported literal from its first, middle, and last segment", () => {
|
||||
const content = "2026-ads-1TH0G6A0gnI6EFujlZ3pMQ18";
|
||||
const doc = `select '${content}'`;
|
||||
const from = doc.indexOf(content);
|
||||
|
||||
expect(contentAt(doc, from, 1)).toBe(content);
|
||||
expect(contentAt(doc, from + content.indexOf("ads") + 1)).toBe(content);
|
||||
expect(contentAt(doc, from + content.length, -1)).toBe(content);
|
||||
});
|
||||
|
||||
it("supports punctuation, whitespace, CJK, emoji, escaped quotes, and PostgreSQL dollar quotes", () => {
|
||||
const cases: Array<{ doc: string; needle: string; expected: string; options?: SqlSemanticSelectionOptions }> = [
|
||||
{ doc: "select 'a.b / c'", needle: "/", expected: "a.b / c" },
|
||||
{ doc: "select '中文🙂内容'", needle: "🙂", expected: "中文🙂内容" },
|
||||
{ doc: "select 'O''Reilly'", needle: "Reilly", expected: "O''Reilly" },
|
||||
{ doc: "select $tag$a-b 中文$tag$", needle: "中文", expected: "a-b 中文", options: { dialect: "postgres" } },
|
||||
];
|
||||
|
||||
for (const value of cases) expect(contentAt(value.doc, value.doc.indexOf(value.needle), 1, value.options)).toBe(value.expected);
|
||||
});
|
||||
|
||||
it("falls back for delimiters, empty or incomplete strings, and non-string SQL tokens", () => {
|
||||
const doc = "select 'value', \"identifier\", 123 /* 'comment' */ + 1";
|
||||
const opening = doc.indexOf("'value'");
|
||||
const closing = opening + "'value'".length - 1;
|
||||
|
||||
expect(contentAt(doc, opening)).toBeNull();
|
||||
expect(contentAt(doc, closing)).toBeNull();
|
||||
expect(contentAt("select ''", "select '".length)).toBeNull();
|
||||
expect(contentAt("select 'unfinished", "select 'unfinished".length, -1)).toBeNull();
|
||||
expect(contentAt("select '''", "select ''".length, -1)).toBeNull();
|
||||
for (const needle of ["identifier", "123", "comment", "+"]) expect(contentAt(doc, doc.indexOf(needle))).toBeNull();
|
||||
});
|
||||
|
||||
it("only overrides an unmodified left-button SQL double click", () => {
|
||||
const doc = "select 'alpha-beta'";
|
||||
const state = EditorState.create({ doc });
|
||||
const { view } = fakeView(state);
|
||||
const position = doc.indexOf("alpha");
|
||||
|
||||
expect(createQueryEditorStringMouseSelection(view, eventAt(position))).not.toBeNull();
|
||||
expect(createQueryEditorStringMouseSelection(view, eventAt(position, 1, { button: 1 }))).toBeNull();
|
||||
expect(createQueryEditorStringMouseSelection(view, eventAt(position, 1, { detail: 1 }))).toBeNull();
|
||||
expect(createQueryEditorStringMouseSelection(view, eventAt(position, 1, { detail: 3 }))).toBeNull();
|
||||
expect(createQueryEditorStringMouseSelection(view, eventAt(position, 1, { altKey: true }))).toBeNull();
|
||||
expect(createQueryEditorStringMouseSelection(view, eventAt(position), { composing: true })).toBeNull();
|
||||
expect(createQueryEditorStringMouseSelection(view, eventAt(position), { language: "text" })).toBeNull();
|
||||
expect(createQueryEditorStringMouseSelection(view, eventAt(doc.indexOf("'")))).toBeNull();
|
||||
});
|
||||
|
||||
it("selects string content in editable and read-only states", () => {
|
||||
const doc = "select 'alpha-beta'";
|
||||
for (const readOnly of [false, true]) {
|
||||
const state = EditorState.create({ doc, extensions: [EditorState.readOnly.of(readOnly)] });
|
||||
const { view } = fakeView(state);
|
||||
const start = eventAt(doc.indexOf("alpha"));
|
||||
const style = createQueryEditorStringMouseSelection(view, start);
|
||||
|
||||
expect(style).not.toBeNull();
|
||||
const selection = style?.get(start, false, false);
|
||||
expect(selection && doc.slice(selection.main.from, selection.main.to)).toBe("alpha-beta");
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves Shift extension and Mod-added ranges with the original main index", () => {
|
||||
const doc = "z select 'alpha-beta' tail";
|
||||
const initialSelection = EditorSelection.create([EditorSelection.cursor(0), EditorSelection.cursor(doc.length)], 0);
|
||||
const state = EditorState.create({ doc, selection: initialSelection, extensions: [EditorState.allowMultipleSelections.of(true)] });
|
||||
const start = eventAt(doc.indexOf("alpha"));
|
||||
|
||||
const shiftStyle = createQueryEditorStringMouseSelection(fakeView(state).view, start);
|
||||
const extended = shiftStyle?.get(start, true, false);
|
||||
expect(extended?.ranges).toHaveLength(2);
|
||||
expect(extended?.mainIndex).toBe(0);
|
||||
expect(extended?.main.anchor).toBe(0);
|
||||
expect(extended?.main.to).toBe(doc.indexOf("' tail"));
|
||||
|
||||
const modStyle = createQueryEditorStringMouseSelection(fakeView(state).view, start);
|
||||
const added = modStyle?.get(start, false, true);
|
||||
expect(added?.ranges).toHaveLength(3);
|
||||
expect(added?.ranges.map((range) => doc.slice(range.from, range.to))).toContain("alpha-beta");
|
||||
});
|
||||
|
||||
it("extends a double-click drag by semantic string groups in both directions", () => {
|
||||
const doc = "'alpha-beta' + 'gamma-delta'";
|
||||
const state = EditorState.create({ doc });
|
||||
const alpha = eventAt(doc.indexOf("alpha"));
|
||||
const gamma = eventAt(doc.indexOf("gamma"));
|
||||
|
||||
const forward = createQueryEditorStringMouseSelection(fakeView(state).view, alpha)?.get(gamma, false, false).main;
|
||||
expect(forward && doc.slice(forward.from, forward.to)).toBe("alpha-beta' + 'gamma-delta");
|
||||
expect(forward?.anchor).toBeLessThan(forward?.head ?? 0);
|
||||
|
||||
const reverse = createQueryEditorStringMouseSelection(fakeView(state).view, gamma)?.get(alpha, false, false).main;
|
||||
expect(reverse && doc.slice(reverse.from, reverse.to)).toBe("alpha-beta' + 'gamma-delta");
|
||||
expect(reverse?.anchor).toBeGreaterThan(reverse?.head ?? Number.POSITIVE_INFINITY);
|
||||
});
|
||||
|
||||
it("maps the start selection and semantic analysis across document changes", () => {
|
||||
const doc = "select 'alpha-beta'";
|
||||
const state = EditorState.create({ doc, selection: EditorSelection.cursor(doc.length) });
|
||||
const fixture = fakeView(state);
|
||||
const start = eventAt(doc.indexOf("alpha"));
|
||||
const style = createQueryEditorStringMouseSelection(fixture.view, start);
|
||||
const transaction = state.update({ changes: { from: 0, insert: "-- " } });
|
||||
fixture.setState(transaction.state);
|
||||
style?.update({ docChanged: true, changes: transaction.changes, startState: state, state: transaction.state, view: fixture.view } as ViewUpdate);
|
||||
|
||||
const selection = style?.get(eventAt(doc.indexOf("alpha") + 3), false, false);
|
||||
expect(selection && transaction.state.sliceDoc(selection.main.from, selection.main.to)).toBe("alpha-beta");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,14 @@ describe("sqlSemanticTokens", () => {
|
||||
expect(isSuppressedSqlSemanticContext(tokens, "select * from users".length)).toBe(false);
|
||||
});
|
||||
|
||||
it("records whether quoted tokens have a closing delimiter", () => {
|
||||
const complete = tokenizeSqlSemantic("select 'value'").find((token) => token.kind === "string");
|
||||
const incomplete = tokenizeSqlSemantic("select '''").find((token) => token.kind === "string");
|
||||
|
||||
expect(complete?.closed).toBe(true);
|
||||
expect(incomplete?.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("handles hash tokens according to the SQL dialect", () => {
|
||||
const sqlServerSql = "SELECT * FROM #temp; SELECT * FROM ##global_temp; SELECT * FROM tempdb..#temp";
|
||||
const sqlServerTokens = tokenizeSqlSemantic(sqlServerSql, "sqlserver");
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { EditorSelection, findClusterBreak, type EditorState, type SelectionRange } from "@codemirror/state";
|
||||
import type { EditorView, MouseSelectionStyle, ViewUpdate } from "@codemirror/view";
|
||||
import { analyzeSqlSemanticSelectionRanges, sqlStringContentRangeAt, type SqlSemanticSelectionAnalysis, type SqlSemanticSelectionOptions } from "@/lib/editor/sqlSemanticSelectionRanges";
|
||||
|
||||
export interface QueryEditorStringMouseSelectionOptions extends SqlSemanticSelectionOptions {
|
||||
language?: "sql" | "text";
|
||||
composing?: boolean;
|
||||
}
|
||||
|
||||
interface PointerPosition {
|
||||
pos: number;
|
||||
assoc: -1 | 1;
|
||||
}
|
||||
|
||||
function groupAt(state: EditorState, position: PointerPosition): SelectionRange {
|
||||
const line = state.doc.lineAt(position.pos);
|
||||
if (line.length === 0) return EditorSelection.cursor(position.pos);
|
||||
let bias = position.assoc;
|
||||
const linePosition = position.pos - line.from;
|
||||
if (linePosition === 0) bias = 1;
|
||||
else if (linePosition === line.length) bias = -1;
|
||||
|
||||
let from = linePosition;
|
||||
let to = linePosition;
|
||||
if (bias < 0) from = findClusterBreak(line.text, linePosition, false);
|
||||
else to = findClusterBreak(line.text, linePosition);
|
||||
const categorize = state.charCategorizer(position.pos);
|
||||
const category = categorize(line.text.slice(from, to));
|
||||
while (from > 0) {
|
||||
const previous = findClusterBreak(line.text, from, false);
|
||||
if (categorize(line.text.slice(previous, from)) !== category) break;
|
||||
from = previous;
|
||||
}
|
||||
while (to < line.length) {
|
||||
const next = findClusterBreak(line.text, to);
|
||||
if (categorize(line.text.slice(to, next)) !== category) break;
|
||||
to = next;
|
||||
}
|
||||
return EditorSelection.range(from + line.from, to + line.from);
|
||||
}
|
||||
|
||||
function semanticOrDefaultRange(state: EditorState, position: PointerPosition, options: SqlSemanticSelectionOptions, analysis: SqlSemanticSelectionAnalysis): SelectionRange {
|
||||
const semantic = sqlStringContentRangeAt(state.doc.toString(), position.pos, position.assoc, options, analysis);
|
||||
return semantic ? EditorSelection.range(semantic.from, semantic.to) : groupAt(state, position);
|
||||
}
|
||||
|
||||
export function createQueryEditorStringMouseSelection(view: EditorView, event: MouseEvent, options: QueryEditorStringMouseSelectionOptions = {}): MouseSelectionStyle | null {
|
||||
if (event.button !== 0 || event.detail !== 2 || event.altKey || options.composing || options.language === "text") return null;
|
||||
let start = view.posAndSideAtCoords({ x: event.clientX, y: event.clientY }, false);
|
||||
let analysis = analyzeSqlSemanticSelectionRanges(view.state.doc.toString(), options);
|
||||
const semantic = sqlStringContentRangeAt(view.state.doc.toString(), start.pos, start.assoc, options, analysis);
|
||||
if (!semantic) return null;
|
||||
|
||||
let startRange = EditorSelection.range(semantic.from, semantic.to);
|
||||
let startSelection = view.state.selection;
|
||||
return {
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged) return;
|
||||
start = { pos: update.changes.mapPos(start.pos, start.assoc), assoc: start.assoc };
|
||||
startRange = startRange.map(update.changes);
|
||||
startSelection = startSelection.map(update.changes);
|
||||
analysis = analyzeSqlSemanticSelectionRanges(update.state.doc.toString(), options);
|
||||
},
|
||||
get(currentEvent, extend, multiple) {
|
||||
const current = view.posAndSideAtCoords({ x: currentEvent.clientX, y: currentEvent.clientY }, false);
|
||||
let range = start.pos === current.pos ? startRange : semanticOrDefaultRange(view.state, current, options, analysis);
|
||||
if (start.pos !== current.pos && !extend) {
|
||||
const from = Math.min(startRange.from, range.from);
|
||||
const to = Math.max(startRange.to, range.to);
|
||||
range = from < range.from ? EditorSelection.range(from, to, undefined, undefined, range.assoc) : EditorSelection.range(to, from, undefined, undefined, range.assoc);
|
||||
}
|
||||
if (extend) return startSelection.replaceRange(startSelection.main.extend(range.from, range.to, range.assoc));
|
||||
if (multiple) return startSelection.addRange(range);
|
||||
return EditorSelection.create([range]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,7 @@ interface SqlSelectionToken {
|
||||
to: number;
|
||||
depth: number;
|
||||
quote?: string;
|
||||
closed?: boolean;
|
||||
}
|
||||
|
||||
interface StatementWindow {
|
||||
@@ -36,6 +37,7 @@ interface QueryBlockRange extends SemanticSelectionRange {
|
||||
export interface SqlSemanticSelectionAnalysis {
|
||||
doc: string;
|
||||
statements: readonly PreparedStatementWindow[];
|
||||
stringContents: readonly SemanticSelectionRange[];
|
||||
}
|
||||
|
||||
const QUERY_CLAUSE_WORDS = new Set(["where", "having", "on", "using", "limit", "offset", "fetch", "returning", "qualify", "window"]);
|
||||
@@ -98,7 +100,7 @@ function normalizeTokens(input: string, tokens: SqlSemanticToken[]): SqlSelectio
|
||||
continue;
|
||||
}
|
||||
}
|
||||
result.push({ kind: token.kind, text: input.slice(token.span.start, token.span.end), normalized: token.normalized, from: token.span.start, to: token.span.end, depth: token.depth, quote: token.quote });
|
||||
result.push({ kind: token.kind, text: input.slice(token.span.start, token.span.end), normalized: token.normalized, from: token.span.start, to: token.span.end, depth: token.depth, quote: token.quote, closed: token.closed });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -108,7 +110,7 @@ function validateTokens(input: string, tokens: SqlSelectionToken[]): boolean {
|
||||
for (const token of tokens) {
|
||||
if (token.kind === "string") {
|
||||
const quote = token.quote ?? token.text[0] ?? "";
|
||||
if (quote.startsWith("$") ? !token.text.endsWith(quote) : !token.text.endsWith(quote)) return false;
|
||||
if (token.closed === false || !token.text.endsWith(quote)) return false;
|
||||
}
|
||||
if (token.kind === "comment" && token.text.startsWith("/*") && !token.text.endsWith("*/")) return false;
|
||||
if (token.text === "(") stack.push(token);
|
||||
@@ -382,16 +384,40 @@ function collectSqlRanges(input: string, statement: StatementWindow, maps: { ope
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function stringContentRange(token: SqlSelectionToken): SemanticSelectionRange | null {
|
||||
if (token.kind !== "string") return null;
|
||||
const quote = token.quote ?? token.text[0] ?? "";
|
||||
const quoteLength = quote.startsWith("$") ? quote.length : 1;
|
||||
const from = token.from + quoteLength;
|
||||
const to = token.to - quoteLength;
|
||||
return token.closed !== false && quote && token.text.startsWith(quote) && token.text.endsWith(quote) && from <= to ? { from, to } : null;
|
||||
}
|
||||
|
||||
export function analyzeSqlSemanticSelectionRanges(doc: string, options: SqlSemanticSelectionOptions = {}): SqlSemanticSelectionAnalysis {
|
||||
const dialect = sqlSemanticDialectFor(options).id;
|
||||
const rawTokens = tokenizeSqlSemantic(doc, dialect);
|
||||
const validationTokens = rawTokens.map((token) => ({ kind: token.kind, text: token.text, normalized: token.normalized, from: token.span.start, to: token.span.end, depth: token.depth, quote: token.quote }));
|
||||
const validationTokens = rawTokens.map((token) => ({ kind: token.kind, text: token.text, normalized: token.normalized, from: token.span.start, to: token.span.end, depth: token.depth, quote: token.quote, closed: token.closed }));
|
||||
const tokens = normalizeTokens(doc, rawTokens);
|
||||
const statements: PreparedStatementWindow[] = statementWindows(doc, tokens).filter((statement) => {
|
||||
const statementValidationTokens = validationTokens.filter((token) => token.from >= statement.start && token.to <= statement.end);
|
||||
return validateTokens(doc.slice(statement.start, statement.end), statementValidationTokens);
|
||||
});
|
||||
return { doc, statements };
|
||||
const stringContents = statements.flatMap((statement) => statement.tokens.map(stringContentRange).filter((range): range is SemanticSelectionRange => range != null));
|
||||
return { doc, statements, stringContents };
|
||||
}
|
||||
|
||||
export function sqlStringContentRangeAt(doc: string, position: number, assoc: -1 | 1, options: SqlSemanticSelectionOptions = {}, analysis = analyzeSqlSemanticSelectionRanges(doc, options)): SemanticSelectionRange | null {
|
||||
if (analysis.doc !== doc) analysis = analyzeSqlSemanticSelectionRanges(doc, options);
|
||||
const character = assoc < 0 ? position - 1 : position;
|
||||
let low = 0;
|
||||
let high = analysis.stringContents.length;
|
||||
while (low < high) {
|
||||
const middle = low + Math.floor((high - low) / 2);
|
||||
if ((analysis.stringContents[middle]?.to ?? Number.POSITIVE_INFINITY) <= character) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const range = analysis.stringContents[low];
|
||||
return range && range.from <= character && character < range.to ? range : null;
|
||||
}
|
||||
|
||||
function findStatement(context: SemanticSelectionContext, analysis: SqlSemanticSelectionAnalysis): PreparedStatementWindow | undefined {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SqlSemanticSpan, SqlSemanticToken } from "@/lib/sql/semantic/types
|
||||
const WORD_START = /[A-Za-z_@$#]/;
|
||||
const WORD_PART = /[A-Za-z0-9_@$#]/;
|
||||
|
||||
function token(kind: SqlSemanticToken["kind"], text: string, start: number, end: number, depth: number, quote?: string): SqlSemanticToken {
|
||||
function token(kind: SqlSemanticToken["kind"], text: string, start: number, end: number, depth: number, quote?: string, closed?: boolean): SqlSemanticToken {
|
||||
return {
|
||||
kind,
|
||||
text,
|
||||
@@ -11,10 +11,11 @@ function token(kind: SqlSemanticToken["kind"], text: string, start: number, end:
|
||||
span: { start, end },
|
||||
depth,
|
||||
quote,
|
||||
...(closed === undefined ? {} : { closed }),
|
||||
};
|
||||
}
|
||||
|
||||
function readQuoted(input: string, start: number, open: string, close: string): number {
|
||||
function readQuoted(input: string, start: number, open: string, close: string): { end: number; closed: boolean } {
|
||||
let index = start + open.length;
|
||||
while (index < input.length) {
|
||||
if (input.startsWith(close, index)) {
|
||||
@@ -22,11 +23,11 @@ function readQuoted(input: string, start: number, open: string, close: string):
|
||||
index += close.length * 2;
|
||||
continue;
|
||||
}
|
||||
return index + close.length;
|
||||
return { end: index + close.length, closed: true };
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return input.length;
|
||||
return { end: input.length, closed: false };
|
||||
}
|
||||
|
||||
const DOLLAR_QUOTE_TAG_PATTERN = /\$[A-Za-z_0-9]*\$/y;
|
||||
@@ -86,8 +87,9 @@ export function tokenizeSqlSemantic(input: string, dialectId = "mysql"): SqlSema
|
||||
}
|
||||
|
||||
if (ch === "'") {
|
||||
index = readQuoted(input, start, "'", "'");
|
||||
tokens.push(token("string", input.slice(start, index), start, index, depth, "'"));
|
||||
const quoted = readQuoted(input, start, "'", "'");
|
||||
index = quoted.end;
|
||||
tokens.push(token("string", input.slice(start, index), start, index, depth, "'", quoted.closed));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -96,26 +98,29 @@ export function tokenizeSqlSemantic(input: string, dialectId = "mysql"): SqlSema
|
||||
if (marker) {
|
||||
const closing = input.indexOf(marker, start + marker.length);
|
||||
index = closing < 0 ? input.length : closing + marker.length;
|
||||
tokens.push(token("string", input.slice(start, index), start, index, depth, marker));
|
||||
tokens.push(token("string", input.slice(start, index), start, index, depth, marker, closing >= 0));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
index = readQuoted(input, start, '"', '"');
|
||||
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, '"'));
|
||||
const quoted = readQuoted(input, start, '"', '"');
|
||||
index = quoted.end;
|
||||
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, '"', quoted.closed));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "`") {
|
||||
index = readQuoted(input, start, "`", "`");
|
||||
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, "`"));
|
||||
const quoted = readQuoted(input, start, "`", "`");
|
||||
index = quoted.end;
|
||||
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, "`", quoted.closed));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "[") {
|
||||
index = readQuoted(input, start, "[", "]");
|
||||
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, "["));
|
||||
const quoted = readQuoted(input, start, "[", "]");
|
||||
index = quoted.end;
|
||||
tokens.push(token("quoted_identifier", input.slice(start, index), start, index, depth, "[", quoted.closed));
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SqlSemanticToken {
|
||||
span: SqlSemanticSpan;
|
||||
depth: number;
|
||||
quote?: string;
|
||||
closed?: boolean;
|
||||
}
|
||||
|
||||
export interface SqlSemanticIdentifierPart {
|
||||
|
||||
Reference in new issue
Block a user