feat(editor): add semantic selection and match-all cursors

This commit is contained in:
fuuulstack authored and GitHub committed 2026-08-03 15:50:43 +08:00
1 parent 02bad5d754
commit ed26fc10f5
29 files changed
+1794 -54

No files matched your search

@@ -23,6 +23,25 @@ export interface EditorSearchMatch {
to: number;
}
export interface EditorSearchMatchCount {
count: number;
currentIndex: number;
}
export function countEditorSearchMatches(query: SearchQuery, state: EditorState, from: number, to: number, current?: EditorSearchMatch): EditorSearchMatchCount {
let count = 0;
let currentIndex = 0;
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
if (result.value.from < from || result.value.to > to) continue;
count++;
if (current && result.value.from === current.from && result.value.to === current.to) currentIndex = count;
}
return { count, currentIndex };
}
export function collectEditorSearchMatches(query: SearchQuery, state: EditorState, from: number, to: number, limit = Number.POSITIVE_INFINITY): EditorSearchMatch[] {
const matches: EditorSearchMatch[] = [];
const cursor = query.getCursor(state);
@@ -0,0 +1,53 @@
import { EditorSelection, type SelectionRange } from "@codemirror/state";
import type { EditorSearchMatch } from "@/lib/editor/editorSearchQuery";
import { isMacShortcutPlatform } from "@/lib/editor/shortcutDisplay";
export type EditorSearchSelectionDirection = "next" | "prev";
export interface SearchSelectionModifierEvent {
metaKey?: boolean;
ctrlKey?: boolean;
}
export function isSearchAddSelectionModifier(event: SearchSelectionModifierEvent, platform = globalThis.navigator?.platform || ""): boolean {
return isMacShortcutPlatform(platform) ? !!event.metaKey : !!event.ctrlKey;
}
function isMatchCovered(selection: EditorSelection, match: EditorSearchMatch): boolean {
let low = 0;
let high = selection.ranges.length;
while (low < high) {
const middle = (low + high) >> 1;
if (selection.ranges[middle].from <= match.from) low = middle + 1;
else high = middle;
}
const range = selection.ranges[low - 1];
return !!range && range.to >= match.to;
}
export function findSearchMatch(matches: Iterable<EditorSearchMatch>, cursor: number, direction: EditorSearchSelectionDirection, accept: (match: EditorSearchMatch) => boolean = () => true): EditorSearchMatch | null {
let wrapped: EditorSearchMatch | null = null;
let target: EditorSearchMatch | null = null;
for (const match of matches) {
if (!accept(match)) continue;
if (direction === "next") {
wrapped ??= match;
if (match.from >= cursor) return match;
} else {
wrapped = match;
if (match.to <= cursor) target = match;
}
}
return target ?? wrapped;
}
export function selectionRangesForSearchMatches(matches: readonly EditorSearchMatch[]): SelectionRange[] {
return matches.map((match) => EditorSelection.range(match.from, match.to));
}
export function appendSearchMatchSelection(selection: EditorSelection, matches: Iterable<EditorSearchMatch>, direction: EditorSearchSelectionDirection): EditorSelection | null {
const target = findSearchMatch(matches, selection.main.head, direction, (match) => !isMatchCovered(selection, match));
return target ? selection.addRange(EditorSelection.range(target.from, target.to), true) : null;
}
@@ -0,0 +1,149 @@
import { createSemanticSelectionRangeIndex, type SemanticSelectionContext, type SemanticSelectionRange, type SemanticSelectionRangeIndex } from "@/lib/editor/semanticSelectionRanges";
const WORD_CHARACTER = /[\p{L}\p{N}\p{M}_$]/u;
const QUOTE_PAIRS: Readonly<Record<string, string>> = { "'": "'", '"': '"', "`": "`" };
const BRACKET_PAIRS: Readonly<Record<string, string>> = { "(": ")", "[": "]", "{": "}" };
const CLOSING_BRACKETS = new Set(Object.values(BRACKET_PAIRS));
function addRange(ranges: SemanticSelectionRange[], from: number, to: number) {
if (from < to) ranges.push({ from, to });
}
function isEscaped(text: string, position: number): boolean {
let backslashes = 0;
for (let index = position - 1; index >= 0 && text[index] === "\\"; index -= 1) backslashes += 1;
return backslashes % 2 === 1;
}
function scanQuotedRanges(doc: string, ranges: SemanticSelectionRange[]) {
for (let start = 0; start < doc.length; start += 1) {
const close = QUOTE_PAIRS[doc[start] ?? ""];
if (!close || (start > 0 && isEscaped(doc, start))) continue;
for (let index = start + 1; index < doc.length; index += 1) {
if (isEscaped(doc, index)) continue;
if (doc[index] !== close) continue;
if (close === "'" || close === '"' || close === "`") {
if (doc[index + 1] === close) {
index += 1;
continue;
}
}
addRange(ranges, start + 1, index);
addRange(ranges, start, index + 1);
start = index;
break;
}
}
}
function scanBracketRanges(doc: string, ranges: SemanticSelectionRange[]) {
const stack: Array<{ open: string; from: number }> = [];
let quote: string | null = null;
for (let index = 0; index < doc.length; index += 1) {
const character = doc[index] ?? "";
if (quote) {
if (character === quote && !isEscaped(doc, index)) {
if (doc[index + 1] === quote) {
index += 1;
} else {
quote = null;
}
}
continue;
}
if (QUOTE_PAIRS[character]) {
quote = character;
continue;
}
if (BRACKET_PAIRS[character]) {
stack.push({ open: character, from: index });
continue;
}
if (!CLOSING_BRACKETS.has(character)) continue;
const expectedOpen = Object.entries(BRACKET_PAIRS).find(([, close]) => close === character)?.[0];
if (stack[stack.length - 1]?.open !== expectedOpen) continue;
const opening = stack.pop();
if (!opening) continue;
addRange(ranges, opening.from + 1, index);
addRange(ranges, opening.from, index + 1);
}
}
function scanAllWordRanges(doc: string, ranges: SemanticSelectionRange[]) {
let index = 0;
while (index < doc.length) {
if (!WORD_CHARACTER.test(doc[index] ?? "")) {
index += 1;
continue;
}
const start = index;
while (index < doc.length && WORD_CHARACTER.test(doc[index] ?? "")) index += 1;
addRange(ranges, start, index);
}
}
function scanAllLineAndParagraphRanges(doc: string, ranges: SemanticSelectionRange[]) {
let lineStart = 0;
for (let index = 0; index <= doc.length; index += 1) {
if (index !== doc.length && doc[index] !== "\n") continue;
addRange(ranges, lineStart, index);
lineStart = index + 1;
}
let paragraphStart = 0;
while (paragraphStart <= doc.length) {
const separator = doc.indexOf("\n\n", paragraphStart);
const paragraphEnd = separator < 0 ? doc.length : separator;
addRange(ranges, paragraphStart, paragraphEnd);
if (separator < 0) break;
paragraphStart = separator + 2;
}
}
export interface PlainTextSelectionAnalysis {
doc: string;
allRanges: SemanticSelectionRangeIndex;
delimitedRanges: SemanticSelectionRangeIndex;
quotedRanges: SemanticSelectionRangeIndex;
}
export function analyzePlainTextSelectionRanges(doc: string): PlainTextSelectionAnalysis {
const words: SemanticSelectionRange[] = [];
const quotes: SemanticSelectionRange[] = [];
const delimited: SemanticSelectionRange[] = [];
scanAllWordRanges(doc, words);
scanQuotedRanges(doc, quotes);
delimited.push(...quotes);
scanBracketRanges(doc, delimited);
scanAllLineAndParagraphRanges(doc, delimited);
addRange(delimited, 0, doc.length);
return {
doc,
allRanges: createSemanticSelectionRangeIndex([...words, ...delimited]),
delimitedRanges: createSemanticSelectionRangeIndex(delimited),
quotedRanges: createSemanticSelectionRangeIndex(quotes),
};
}
function hasContainingQuote(analysis: PlainTextSelectionAnalysis, cursor: number): boolean {
return analysis.quotedRanges.containing({ from: cursor, to: cursor }).some((range) => range.from < cursor && cursor < range.to);
}
function selectionRangeIndex(context: SemanticSelectionContext, analysis: PlainTextSelectionAnalysis): SemanticSelectionRangeIndex {
return context.preferDelimitedContent && hasContainingQuote(analysis, context.cursor) ? analysis.delimitedRanges : analysis.allRanges;
}
export function plainTextSelectionRanges(context: SemanticSelectionContext, analysis = analyzePlainTextSelectionRanges(context.doc)): SemanticSelectionRange[] {
const { doc } = context;
if (doc.length === 0) return [];
if (analysis.doc !== doc) analysis = analyzePlainTextSelectionRanges(doc);
return selectionRangeIndex(context, analysis).containing(context.current);
}
export function nextPlainTextSelectionRange(context: SemanticSelectionContext, analysis = analyzePlainTextSelectionRanges(context.doc)): SemanticSelectionRange | null {
if (context.doc.length === 0) return null;
if (analysis.doc !== context.doc) analysis = analyzePlainTextSelectionRanges(context.doc);
return selectionRangeIndex(context, analysis).findNext(context.current, context.cursor);
}
@@ -0,0 +1,49 @@
import type { Command, EditorView } from "@codemirror/view";
import { EditorSelection } from "@codemirror/state";
import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts";
import { analyzePlainTextSelectionRanges, nextPlainTextSelectionRange } from "@/lib/editor/plainTextSelectionRanges";
import { chooseNextSemanticSelectionRange, type SemanticSelectionRange } from "@/lib/editor/semanticSelectionRanges";
import { analyzeSqlSemanticSelectionRanges, nextSqlSemanticSelectionRange, type SqlSemanticSelectionOptions } from "@/lib/editor/sqlSemanticSelectionRanges";
export interface QueryEditorExtendSelectionOptions extends SqlSemanticSelectionOptions {
language?: "sql" | "text";
}
export function extendQueryEditorSelection(view: EditorView, options: QueryEditorExtendSelectionOptions = {}): boolean {
const doc = view.state.doc.toString();
const plainTextAnalysis = analyzePlainTextSelectionRanges(doc);
const sqlAnalysis = options.language === "text" ? null : analyzeSqlSemanticSelectionRanges(doc, options);
let changed = false;
const ranges = view.state.selection.ranges.map((range) => {
const context = {
doc,
cursor: range.head,
current: { from: range.from, to: range.to },
preferDelimitedContent: options.language !== "text",
};
const candidates: SemanticSelectionRange[] = [];
const plainTextCandidate = nextPlainTextSelectionRange(context, plainTextAnalysis);
if (plainTextCandidate) candidates.push(plainTextCandidate);
const sqlCandidate = sqlAnalysis ? nextSqlSemanticSelectionRange(context, options, sqlAnalysis) : null;
if (sqlCandidate) candidates.push(sqlCandidate);
const next = chooseNextSemanticSelectionRange(context.current, context.cursor, candidates);
if (!next) return range;
changed = true;
return range.anchor <= range.head ? EditorSelection.range(next.from, next.to) : EditorSelection.range(next.to, next.from);
});
if (!changed) return false;
view.dispatch({ selection: EditorSelection.create(ranges, view.state.selection.mainIndex), scrollIntoView: true });
return true;
}
export interface PreventableShortcutEvent extends ShortcutLikeEvent {
preventDefault(): void;
}
export function runQueryEditorAltExtendSelection(event: PreventableShortcutEvent, shortcut: string, view: EditorView, command: Command | null, platform = globalThis.navigator?.platform || ""): boolean {
if (!command || !event.altKey || event.ctrlKey || event.metaKey) return false;
if (!matchesShortcut(event, shortcut, platform)) return false;
if (!command(view)) return false;
event.preventDefault();
return true;
}
@@ -0,0 +1,156 @@
export interface SemanticSelectionRange {
from: number;
to: number;
}
export type SemanticSelectionNodeKind =
| "document"
| "paragraph"
| "line"
| "quoted-content"
| "quoted"
| "bracket-content"
| "bracketed"
| "word"
| "statement"
| "set-expression"
| "query-block"
| "clause"
| "logical-expression"
| "binary-expression"
| "unary-expression"
| "function-call"
| "qualified-identifier"
| "literal";
export interface SemanticSelectionNode extends SemanticSelectionRange {
kind: SemanticSelectionNodeKind;
children: SemanticSelectionNode[];
}
export interface SemanticSelectionContext {
doc: string;
cursor: number;
current: SemanticSelectionRange;
preferDelimitedContent?: boolean;
}
export interface SemanticSelectionRangeIndex {
containing(current: SemanticSelectionRange): SemanticSelectionRange[];
findNext(current: SemanticSelectionRange, cursor: number): SemanticSelectionRange | null;
}
interface SemanticSelectionRangeIndexNode {
from: number;
to: number;
maxRangeTo: number;
minRangeLength: number;
left?: SemanticSelectionRangeIndexNode;
right?: SemanticSelectionRangeIndexNode;
}
function compareSemanticSelectionRanges(left: SemanticSelectionRange, right: SemanticSelectionRange, cursor: number): number {
const lengthDifference = left.to - left.from - (right.to - right.from);
if (lengthDifference !== 0) return lengthDifference;
const leftDistance = Math.abs(cursor - (left.from + left.to) / 2);
const rightDistance = Math.abs(cursor - (right.from + right.to) / 2);
return leftDistance - rightDistance || left.from - right.from || left.to - right.to;
}
export function createSemanticSelectionRangeIndex(candidates: readonly SemanticSelectionRange[]): SemanticSelectionRangeIndex {
const unique = new Map<string, SemanticSelectionRange>();
for (const range of candidates) {
if (!Number.isInteger(range.from) || !Number.isInteger(range.to)) continue;
if (range.from < 0 || range.from >= range.to) continue;
unique.set(`${range.from}:${range.to}`, range);
}
const ranges = [...unique.values()].sort((left, right) => left.from - right.from || left.to - right.to);
const buildNode = (from: number, to: number): SemanticSelectionRangeIndexNode | undefined => {
if (from >= to) return undefined;
if (to - from === 1) {
const range = ranges[from];
if (!range) return undefined;
return { from, to, maxRangeTo: range.to, minRangeLength: range.to - range.from };
}
const middle = from + Math.floor((to - from) / 2);
const left = buildNode(from, middle);
const right = buildNode(middle, to);
return {
from,
to,
maxRangeTo: Math.max(left?.maxRangeTo ?? -1, right?.maxRangeTo ?? -1),
minRangeLength: Math.min(left?.minRangeLength ?? Number.POSITIVE_INFINITY, right?.minRangeLength ?? Number.POSITIVE_INFINITY),
left,
right,
};
};
const root = buildNode(0, ranges.length);
const upperBound = (position: number): number => {
let low = 0;
let high = ranges.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if ((ranges[middle]?.from ?? Number.POSITIVE_INFINITY) <= position) low = middle + 1;
else high = middle;
}
return low;
};
const visitContaining = (current: SemanticSelectionRange, visit: (range: SemanticSelectionRange) => void) => {
const maxIndex = upperBound(current.from);
const visitNode = (node: SemanticSelectionRangeIndexNode | undefined) => {
if (!node || node.from >= maxIndex || node.maxRangeTo < current.to) return;
if (node.to - node.from === 1) {
const range = ranges[node.from];
if (range && range.to >= current.to) visit(range);
return;
}
visitNode(node.left);
visitNode(node.right);
};
visitNode(root);
};
return {
containing(current) {
const result: SemanticSelectionRange[] = [];
visitContaining(current, (range) => result.push(range));
return result;
},
findNext(current, cursor) {
const maxIndex = upperBound(current.from);
let best: SemanticSelectionRange | null = null;
const visitNode = (node: SemanticSelectionRangeIndexNode | undefined) => {
if (!node || node.from >= maxIndex || node.maxRangeTo < current.to) return;
if (best && node.minRangeLength > best.to - best.from) return;
if (node.to - node.from === 1) {
const range = ranges[node.from];
if (!range || range.to < current.to || (range.from === current.from && range.to === current.to)) return;
if (!best || compareSemanticSelectionRanges(range, best, cursor) < 0) best = range;
return;
}
const first = (node.left?.minRangeLength ?? Number.POSITIVE_INFINITY) <= (node.right?.minRangeLength ?? Number.POSITIVE_INFINITY) ? node.left : node.right;
const second = first === node.left ? node.right : node.left;
visitNode(first);
visitNode(second);
};
visitNode(root);
return best;
},
};
}
export function chooseNextSemanticSelectionRange(current: SemanticSelectionRange, cursor: number, candidates: readonly SemanticSelectionRange[]): SemanticSelectionRange | null {
const unique = new Map<string, SemanticSelectionRange>();
for (const range of candidates) {
if (!Number.isInteger(range.from) || !Number.isInteger(range.to)) continue;
if (range.from < 0 || range.from >= range.to) continue;
if (range.from > current.from || range.to < current.to) continue;
if (range.from === current.from && range.to === current.to) continue;
unique.set(`${range.from}:${range.to}`, range);
}
return [...unique.values()].sort((left, right) => compareSemanticSelectionRanges(left, right, cursor))[0] ?? null;
}
@@ -18,6 +18,7 @@ export type ShortcutActionId =
| "undo"
| "redo"
| "selectAll"
| "extendSelection"
| "uppercaseSelection"
| "lowercaseSelection"
| "exPasteSqlInCondition"
@@ -185,6 +186,12 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
scope: "editor",
defaultShortcut: "Mod+A",
},
{
id: "extendSelection",
labelKey: "settings.shortcutExtendSelection",
scope: "editor",
defaultShortcut: "Alt+W",
},
{
id: "uppercaseSelection",
labelKey: "settings.shortcutUppercaseSelection",
@@ -0,0 +1,429 @@
import type { DatabaseType } from "@/types/database";
import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect";
import { tokenizeSqlSemantic } from "@/lib/sql/semantic/tokens";
import type { SqlSemanticToken } from "@/lib/sql/semantic/types";
import { createSemanticSelectionRangeIndex, type SemanticSelectionContext, type SemanticSelectionRange, type SemanticSelectionRangeIndex } from "@/lib/editor/semanticSelectionRanges";
export interface SqlSemanticSelectionOptions {
databaseType?: DatabaseType;
dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse";
}
interface SqlSelectionToken {
kind: SqlSemanticToken["kind"];
text: string;
normalized: string;
from: number;
to: number;
depth: number;
quote?: string;
}
interface StatementWindow {
start: number;
end: number;
tokens: SqlSelectionToken[];
}
interface PreparedStatementWindow extends StatementWindow {
ranges?: SemanticSelectionRangeIndex;
}
interface QueryBlockRange extends SemanticSelectionRange {
depth: number;
}
export interface SqlSemanticSelectionAnalysis {
doc: string;
statements: readonly PreparedStatementWindow[];
}
const QUERY_CLAUSE_WORDS = new Set(["where", "having", "on", "using", "limit", "offset", "fetch", "returning", "qualify", "window"]);
const SET_OPERATOR_WORDS = new Set(["union", "intersect", "except", "minus"]);
const COMBINED_OPERATORS = new Set(["!=", "<>", "<=", ">=", "||", "::", "->", "->>"]);
const SQL_BINARY_PRECEDENCE: Record<string, number> = {
or: 10,
and: 20,
"=": 30,
"!=": 30,
"<>": 30,
"<": 30,
"<=": 30,
">": 30,
">=": 30,
is: 30,
in: 30,
like: 30,
between: 30,
"||": 40,
"+": 50,
"-": 50,
"*": 60,
"/": 60,
"%": 60,
};
function addRange(ranges: SemanticSelectionRange[], from: number, to: number) {
if (from < to) ranges.push({ from, to });
}
function addTrimmedRange(input: string, ranges: SemanticSelectionRange[], from: number, to: number) {
while (from < to && /\s/.test(input[from] ?? "")) from += 1;
while (to > from && /\s/.test(input[to - 1] ?? "")) to -= 1;
addRange(ranges, from, to);
}
function significantTokens(tokens: SqlSelectionToken[]): SqlSelectionToken[] {
return tokens.filter((token) => token.kind !== "comment");
}
function normalizeTokens(input: string, tokens: SqlSemanticToken[]): SqlSelectionToken[] {
const result: SqlSelectionToken[] = [];
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) continue;
const next = tokens[index + 1];
if (token.kind === "operator" && next?.kind === "operator" && token.span.end === next.span.start) {
const combined = `${token.text}${next.text}`;
const nextNext = tokens[index + 2];
const triple = nextNext?.kind === "operator" && next.span.end === nextNext.span.start ? `${combined}${nextNext.text}` : "";
if (triple && COMBINED_OPERATORS.has(triple)) {
result.push({ kind: "operator", text: triple, normalized: triple, from: token.span.start, to: nextNext.span.end, depth: token.depth });
index += 2;
continue;
}
if (COMBINED_OPERATORS.has(combined)) {
result.push({ kind: "operator", text: combined, normalized: combined, from: token.span.start, to: next.span.end, depth: token.depth });
index += 1;
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 });
}
return result;
}
function validateTokens(input: string, tokens: SqlSelectionToken[]): boolean {
const stack: SqlSelectionToken[] = [];
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.kind === "comment" && token.text.startsWith("/*") && !token.text.endsWith("*/")) return false;
if (token.text === "(") stack.push(token);
if (token.text === ")") {
if (stack.length === 0) return false;
stack.pop();
}
}
if (stack.length > 0) return false;
return input.trim().length > 0;
}
function statementWindows(input: string, tokens: SqlSelectionToken[]): StatementWindow[] {
const significant = significantTokens(tokens);
const boundaries = [-1, ...significant.map((token, index) => (token.text === ";" && token.depth === 0 ? index : -1)).filter((index) => index >= 0), significant.length];
const statements: StatementWindow[] = [];
for (let boundaryIndex = 0; boundaryIndex < boundaries.length - 1; boundaryIndex += 1) {
const left = boundaries[boundaryIndex] ?? -1;
const right = boundaries[boundaryIndex + 1] ?? significant.length;
const first = significant[left + 1];
const last = significant[right - 1];
if (!first) continue;
const start = first.from;
const end = last?.text === ";" ? last.from : (last?.to ?? input.length);
statements.push({ start, end, tokens: significant.slice(left + 1, last?.text === ";" ? right - 1 : right) });
}
return statements;
}
function parenthesisMaps(tokens: SqlSelectionToken[]): { opening: Map<number, number>; closing: Map<number, number> } | null {
const stack: number[] = [];
const opening = new Map<number, number>();
const closing = new Map<number, number>();
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) continue;
if (token.text === "(") stack.push(index);
if (token.text === ")") {
const open = stack.pop();
if (open == null) return null;
opening.set(open, index);
closing.set(index, open);
}
}
return stack.length === 0 ? { opening, closing } : null;
}
function clauseStart(tokens: SqlSelectionToken[], index: number): number {
const token = tokens[index];
if (!token || token.kind !== "word") return 0;
if ((token.normalized === "group" || token.normalized === "order") && tokens[index + 1]?.normalized === "by") return token.from;
return token.normalized === "from" || token.normalized === "select" ? token.from : token.from;
}
function isClauseAt(tokens: SqlSelectionToken[], index: number): boolean {
const token = tokens[index];
if (!token || token.kind !== "word") return false;
if ((token.normalized === "group" || token.normalized === "order") && tokens[index + 1]?.normalized === "by") return true;
return QUERY_CLAUSE_WORDS.has(token.normalized);
}
function rangeEndForDepth(tokens: SqlSelectionToken[], startIndex: number, statementEnd: number): number {
const depth = tokens[startIndex]?.depth ?? 0;
for (let index = startIndex + 1; index < tokens.length; index += 1) {
const token = tokens[index];
if (token?.text === ")" && token.depth < depth) return token.from;
}
return statementEnd;
}
function queryBlockEnd(tokens: SqlSelectionToken[], startIndex: number, statementEnd: number): number {
const depth = tokens[startIndex]?.depth ?? 0;
for (let index = startIndex + 1; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) continue;
if (token.text === ")" && token.depth < depth) return token.from;
if (token.depth === depth && token.kind === "word" && SET_OPERATOR_WORDS.has(token.normalized)) return token.from;
}
return statementEnd;
}
function nextClauseOrSetBoundary(tokens: SqlSelectionToken[], startIndex: number, statementEnd: number): number {
const depth = tokens[startIndex]?.depth ?? 0;
for (let index = startIndex + 1; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) continue;
if (token.text === ")" && token.depth < depth) return token.from;
if (token.depth !== depth) continue;
if (isClauseAt(tokens, index) || (token.kind === "word" && SET_OPERATOR_WORDS.has(token.normalized))) return token.from;
}
return rangeEndForDepth(tokens, startIndex, statementEnd);
}
function isExpressionBoundary(token: SqlSelectionToken | undefined): boolean {
if (!token) return true;
if (token.text === "," || token.text === ";") return true;
if (token.kind !== "word") return false;
return token.normalized === "select" || token.normalized === "from" || token.normalized === "join" || token.normalized === "group" || token.normalized === "order" || token.normalized === "as" || SET_OPERATOR_WORDS.has(token.normalized) || isClauseAt([token], 0);
}
function isBetweenAnd(tokens: SqlSelectionToken[], direct: number[], operatorPosition: number): boolean {
for (let position = operatorPosition - 1; position >= 0; position -= 1) {
const token = tokens[direct[position] ?? -1];
if (!token || isExpressionBoundary(token) || token.normalized === "and" || token.normalized === "or") return false;
if (token.kind === "word" && token.normalized === "between") return true;
}
return false;
}
function rangeForDirectTokens(tokens: SqlSelectionToken[], direct: number[], from: number, to: number, maps: { opening: Map<number, number>; closing: Map<number, number> }): SemanticSelectionRange | null {
const firstIndex = direct[from];
const lastIndex = direct[to - 1];
const first = firstIndex == null ? undefined : tokens[firstIndex];
const last = lastIndex == null ? undefined : tokens[lastIndex];
if (!first || !last) return null;
const closingIndex = last.text === "(" ? maps.opening.get(lastIndex) : undefined;
return { from: first.from, to: closingIndex == null ? last.to : (tokens[closingIndex]?.to ?? last.to) };
}
function isUnarySign(tokens: SqlSelectionToken[], direct: number[], position: number): boolean {
const token = tokens[direct[position] ?? -1];
if (!token || (token.normalized !== "+" && token.normalized !== "-")) return false;
if (position === 0) return true;
const previous = tokens[direct[position - 1] ?? -1];
return previous != null && SQL_BINARY_PRECEDENCE[previous.normalized] != null;
}
function operandEndForOperator(tokens: SqlSelectionToken[], direct: number[], operatorPosition: number): number {
const operator = tokens[direct[operatorPosition] ?? -1];
const previous = tokens[direct[operatorPosition - 1] ?? -1];
return previous?.normalized === "not" && (operator?.normalized === "in" || operator?.normalized === "like" || operator?.normalized === "between") ? operatorPosition - 1 : operatorPosition;
}
function collectExpressionSection(tokens: SqlSelectionToken[], direct: number[], maps: { opening: Map<number, number>; closing: Map<number, number> }, ranges: SemanticSelectionRange[]) {
const operatorPositions: number[] = [];
for (let position = 0; position < direct.length; position += 1) {
const token = tokens[direct[position] ?? -1];
if (!token || SQL_BINARY_PRECEDENCE[token.normalized] == null || isUnarySign(tokens, direct, position)) continue;
if (token.normalized !== "and" || !isBetweenAnd(tokens, direct, position)) operatorPositions.push(position);
}
if (operatorPositions.length === 0) return;
const operands: SemanticSelectionRange[] = [];
let start = 0;
for (const operatorPosition of operatorPositions) {
const operand = rangeForDirectTokens(tokens, direct, start, operandEndForOperator(tokens, direct, operatorPosition), maps);
if (!operand) return;
operands.push(operand);
start = operatorPosition + 1;
}
const lastOperand = rangeForDirectTokens(tokens, direct, start, direct.length, maps);
if (!lastOperand || operands.length !== operatorPositions.length) return;
operands.push(lastOperand);
for (let index = 0; index < operands.length; index += 1) {
const previousOperatorPosition = operatorPositions[index - 1];
const previousOperator = previousOperatorPosition == null ? undefined : tokens[direct[previousOperatorPosition] ?? -1];
if (previousOperator?.normalized !== "between") {
const operand = operands[index];
if (operand) addRange(ranges, operand.from, operand.to);
}
}
const expressionStack: SemanticSelectionRange[] = [operands[0] as SemanticSelectionRange];
const operatorStack: SqlSelectionToken[] = [];
const reduce = () => {
const right = expressionStack.pop();
const left = expressionStack.pop();
operatorStack.pop();
if (!left || !right) return false;
const combined = { from: left.from, to: right.to };
expressionStack.push(combined);
addRange(ranges, combined.from, combined.to);
return true;
};
for (let index = 0; index < operatorPositions.length; index += 1) {
const operator = tokens[direct[operatorPositions[index] ?? -1] ?? -1];
const operand = operands[index + 1];
if (!operator || !operand) return;
const precedence = SQL_BINARY_PRECEDENCE[operator.normalized] ?? 0;
while ((SQL_BINARY_PRECEDENCE[operatorStack[operatorStack.length - 1]?.normalized ?? ""] ?? -1) >= precedence) {
if (!reduce()) return;
}
operatorStack.push(operator);
expressionStack.push(operand);
}
while (operatorStack.length > 0) {
if (!reduce()) return;
}
}
function collectExpressionRanges(tokens: SqlSelectionToken[], start: number, end: number, maps: { opening: Map<number, number>; closing: Map<number, number> }, ranges: SemanticSelectionRange[]) {
const direct: number[] = [];
for (let index = start; index < end; index += 1) {
const token = tokens[index];
if (!token) continue;
if (token.text === "(") {
const close = maps.opening.get(index);
if (close == null || close >= end) return;
collectExpressionRanges(tokens, index + 1, close, maps, ranges);
direct.push(index);
index = close;
continue;
}
if (token.text === ")") continue;
direct.push(index);
}
let sectionStart = 0;
for (let position = 0; position <= direct.length; position += 1) {
const token = position === direct.length ? undefined : tokens[direct[position] ?? -1];
if (!isExpressionBoundary(token)) continue;
collectExpressionSection(tokens, direct.slice(sectionStart, position), maps, ranges);
sectionStart = position + 1;
}
}
function collectSqlRanges(input: string, statement: StatementWindow, maps: { opening: Map<number, number>; closing: Map<number, number> }): SemanticSelectionRange[] {
const tokens = statement.tokens;
const ranges: SemanticSelectionRange[] = [];
const queryBlocks: QueryBlockRange[] = [];
const setExpressions: QueryBlockRange[] = [];
addRange(ranges, statement.start, statement.end);
for (const token of tokens) {
if (token.kind === "string") {
const quote = token.quote ?? token.text[0] ?? "";
const quoteLength = quote.startsWith("$") ? quote.length : 1;
addRange(ranges, token.from + quoteLength, token.to - quoteLength);
addRange(ranges, token.from, token.to);
}
}
for (const [open, close] of maps.opening) {
const opening = tokens[open];
const closing = tokens[close];
if (!opening || !closing) continue;
addRange(ranges, opening.to, closing.from);
const functionName = tokens[open - 1];
if (functionName?.kind !== "word") addRange(ranges, opening.from, closing.to);
}
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) continue;
if (token.kind === "word" && token.normalized === "select") {
const previousLength = ranges.length;
addTrimmedRange(input, ranges, token.from, queryBlockEnd(tokens, index, statement.end));
const queryBlock = ranges.length > previousLength ? ranges[ranges.length - 1] : undefined;
if (queryBlock) queryBlocks.push({ ...queryBlock, depth: token.depth });
}
if (isClauseAt(tokens, index)) addTrimmedRange(input, ranges, clauseStart(tokens, index), nextClauseOrSetBoundary(tokens, index, statement.end));
if (token.kind === "word" && !isExpressionBoundary(token) && maps.opening.has(index + 1)) {
const close = maps.opening.get(index + 1);
const closing = close == null ? undefined : tokens[close];
if (closing) addTrimmedRange(input, ranges, token.from, closing.to);
}
}
collectExpressionRanges(tokens, 0, tokens.length, maps, ranges);
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (token?.kind === "word" && SET_OPERATOR_WORDS.has(token.normalized)) {
const left = [...queryBlocks, ...setExpressions].filter((range) => range.depth === token.depth && range.to <= token.from).sort((a, b) => b.to - a.to || a.from - b.from)[0];
const right = queryBlocks.filter((range) => range.depth === token.depth && range.from >= token.to).sort((a, b) => a.from - b.from || a.to - b.to)[0];
if (left && right) {
const expression = { from: left.from, to: right.to, depth: token.depth };
addRange(ranges, expression.from, expression.to);
setExpressions.push(expression);
}
}
}
return ranges;
}
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 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 };
}
function findStatement(context: SemanticSelectionContext, analysis: SqlSemanticSelectionAnalysis): PreparedStatementWindow | undefined {
let low = 0;
let high = analysis.statements.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if ((analysis.statements[middle]?.start ?? Number.POSITIVE_INFINITY) <= context.cursor) low = middle + 1;
else high = middle;
}
const statement = analysis.statements[low - 1];
if (!statement || context.cursor < statement.start || context.cursor > statement.end) return undefined;
if (context.current.from < statement.start || context.current.to > statement.end) return undefined;
return statement;
}
function statementRangeIndex(doc: string, statement: PreparedStatementWindow): SemanticSelectionRangeIndex | null {
if (statement.ranges) return statement.ranges;
const maps = parenthesisMaps(statement.tokens);
if (!maps) return null;
statement.ranges = createSemanticSelectionRangeIndex(collectSqlRanges(doc, statement, maps));
return statement.ranges;
}
export function sqlSemanticSelectionRanges(context: SemanticSelectionContext, options: SqlSemanticSelectionOptions = {}, analysis = analyzeSqlSemanticSelectionRanges(context.doc, options)): SemanticSelectionRange[] {
if (analysis.doc !== context.doc) analysis = analyzeSqlSemanticSelectionRanges(context.doc, options);
const statement = findStatement(context, analysis);
return statement ? (statementRangeIndex(context.doc, statement)?.containing(context.current) ?? []) : [];
}
export function nextSqlSemanticSelectionRange(context: SemanticSelectionContext, options: SqlSemanticSelectionOptions = {}, analysis = analyzeSqlSemanticSelectionRanges(context.doc, options)): SemanticSelectionRange | null {
if (analysis.doc !== context.doc) analysis = analyzeSqlSemanticSelectionRanges(context.doc, options);
const statement = findStatement(context, analysis);
return statement ? (statementRangeIndex(context.doc, statement)?.findNext(context.current, context.cursor) ?? null) : null;
}