From 5c75c2391ce5bbc9d5c5f31d3536c0783babac08 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 24 Aug 2026 20:22:38 +0000 Subject: [PATCH] feat(editor): select SQL string content on double click Closes #4261 --- .../src/components/editor/QueryEditor.vue | 19 ++- .../editor/queryEditorExtendSelection.spec.ts | 4 + .../queryEditorStringMouseSelection.spec.ts | 152 ++++++++++++++++++ .../lib/__tests__/sql/semantic/tokens.spec.ts | 8 + .../editor/queryEditorStringMouseSelection.ts | 77 +++++++++ .../lib/editor/sqlSemanticSelectionRanges.ts | 34 +++- apps/desktop/src/lib/sql/semantic/tokens.ts | 31 ++-- apps/desktop/src/lib/sql/semantic/types.ts | 1 + 8 files changed, 307 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/editor/queryEditorStringMouseSelection.spec.ts create mode 100644 apps/desktop/src/lib/editor/queryEditorStringMouseSelection.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 9c24c81bc0..53e350e961 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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 }), diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorExtendSelection.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorExtendSelection.spec.ts index f5cac68d60..e65f40b883 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorExtendSelection.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorExtendSelection.spec.ts @@ -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; diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorStringMouseSelection.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorStringMouseSelection.spec.ts new file mode 100644 index 0000000000..da8b797d07 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorStringMouseSelection.spec.ts @@ -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 { + 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"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/tokens.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/tokens.spec.ts index c3efd2aa32..906180596e 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/tokens.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/tokens.spec.ts @@ -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"); diff --git a/apps/desktop/src/lib/editor/queryEditorStringMouseSelection.ts b/apps/desktop/src/lib/editor/queryEditorStringMouseSelection.ts new file mode 100644 index 0000000000..a614bbd508 --- /dev/null +++ b/apps/desktop/src/lib/editor/queryEditorStringMouseSelection.ts @@ -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]); + }, + }; +} diff --git a/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts b/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts index f401fe01b4..3bd59a23b3 100644 --- a/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts +++ b/apps/desktop/src/lib/editor/sqlSemanticSelectionRanges.ts @@ -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 { diff --git a/apps/desktop/src/lib/sql/semantic/tokens.ts b/apps/desktop/src/lib/sql/semantic/tokens.ts index 1524b64945..6b9e1c291f 100644 --- a/apps/desktop/src/lib/sql/semantic/tokens.ts +++ b/apps/desktop/src/lib/sql/semantic/tokens.ts @@ -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; } diff --git a/apps/desktop/src/lib/sql/semantic/types.ts b/apps/desktop/src/lib/sql/semantic/types.ts index 24540ad7b4..3ef4070a81 100644 --- a/apps/desktop/src/lib/sql/semantic/types.ts +++ b/apps/desktop/src/lib/sql/semantic/types.ts @@ -19,6 +19,7 @@ export interface SqlSemanticToken { span: SqlSemanticSpan; depth: number; quote?: string; + closed?: boolean; } export interface SqlSemanticIdentifierPart {