feat(editor): highlight unknown SQL objects

This commit is contained in:
大峰
2026-09-24 22:34:15 +08:00
committed by GitHub
parent cd24f69daa
commit 46cc9be7d9
4 changed files with 490 additions and 56 deletions
@@ -111,7 +111,7 @@ import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatch
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh, supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection";
import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste";
@@ -122,18 +122,12 @@ import { addNextQueryEditorSelectionOccurrence, selectAllQueryEditorSelectionOcc
import { createQueryEditorStringMouseSelection } from "@/lib/editor/queryEditorStringMouseSelection";
import { createQueryEditorCompletionShortcutBindings } from "@/lib/editor/queryEditorCompletionShortcut";
import { createQueryEditorSelectionCaseShortcutBindings } from "@/lib/editor/queryEditorSelectionCaseShortcut";
import { createQueryEditorExecutionShortcutBindings, createQueryEditorPostCompositionKeyGuard } from "@/lib/editor/queryEditorExecutionShortcut";
import { supportsQueryEditorBlockComments, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport";
import { queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { resolveSqlDialectId } from "@/lib/sql/semantic/dialect";
import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion";
const props = defineProps<QueryEditorProps>();
@@ -160,6 +154,9 @@ const COMPLETION_ENTER_MAX_WAIT_MS = 125;
const SQL_SIGNATURE_HELP_WINDOW_CHARS = 10_000;
// Internal rollback switch: flip to false to route completion, diagnostics, and navigation through the legacy SQL context path.
const SEMANTIC_SQL_COMPLETION_ENABLED = true;
// Master switch for colouring table/column names that the connected database does not have.
// There is no settings entry yet: flip this to false to turn the whole feature off.
const SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED = true;
const emit = defineEmits<{
"update:modelValue": [value: string];
@@ -216,7 +213,8 @@ const sqlDriverProfile = computed(() => (props.connectionId ? connectionStore.ge
const MAX_COMPLETION_TABLES = 200;
const PRESTO_ON_DEMAND_TABLE_COMPLETION_MIN_PREFIX = 2;
const PRESTO_ON_DEMAND_TABLE_COMPLETION_LIMIT = 20;
const SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS = 900;
const SQL_UNKNOWN_OBJECT_DEBOUNCE_MS = 600;
const liveFontSize = ref(settingsStore.editorSettings.fontSize);
const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize);
const isGestureZooming = ref(false);
@@ -1499,52 +1497,55 @@ async function expandSelectStar(target = selectStarExpansionTarget.value) {
currentView.focus();
}
const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics } = useQueryEditorDiagnostics({
props,
view,
settingsStore,
connectionStore,
sqlDriverProfile,
sqlStatementParameterOptions,
sqlBehaviorDialect,
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
maxCompletionTables: MAX_COMPLETION_TABLES,
runtime: {
get setSqlDiagnosticsEffect() {
return codeMirrorRuntime.setSqlDiagnosticsEffect;
const { sqlErrorDecorationRange, sqlSemanticDecorationRanges, reconfigureDiagnostics, setSemanticDiagnostics, clearScheduledSemanticDiagnostics, invalidateSemanticDiagnosticsForDocumentChange, shouldSkipSqlSemanticDiagnostics, scheduleSemanticDiagnostics, loadSqlUnknownObjectSpans } =
useQueryEditorDiagnostics({
props,
view,
settingsStore,
connectionStore,
sqlDriverProfile,
sqlStatementParameterOptions,
sqlBehaviorDialect,
queryEditorSelectionLanguage,
semanticCompletionEnabled: SEMANTIC_SQL_COMPLETION_ENABLED,
maxCompletionTables: MAX_COMPLETION_TABLES,
unknownObjectHighlightEnabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
runtime: {
get setSqlDiagnosticsEffect() {
return codeMirrorRuntime.setSqlDiagnosticsEffect;
},
get diagnosticComp() {
return codeMirrorRuntime.diagnosticComp;
},
get buildSqlDiagnosticExtension() {
return codeMirrorRuntime.buildSqlDiagnosticExtension;
},
get codeMirrorCompletionStatus() {
return codeMirrorRuntime.codeMirrorCompletionStatus;
},
get executableStatementRangeCache() {
return executableStatementRangeCache;
},
set executableStatementRangeCache(value) {
executableStatementRangeCache = value;
},
get editorIsActive() {
return editorIsActive;
},
},
get diagnosticComp() {
return codeMirrorRuntime.diagnosticComp;
metadata: {
get cachedTables() {
return completionMetadata.cachedTables;
},
cachedColumnsByTable,
loadedColumnsByTable,
usesOracleSessionCompletionColumns,
findExactSemanticDiagnosticTable,
completionCacheKey,
ensureColumnsForTable,
isMissingTableMetadataError,
},
get buildSqlDiagnosticExtension() {
return codeMirrorRuntime.buildSqlDiagnosticExtension;
},
get codeMirrorCompletionStatus() {
return codeMirrorRuntime.codeMirrorCompletionStatus;
},
get executableStatementRangeCache() {
return executableStatementRangeCache;
},
set executableStatementRangeCache(value) {
executableStatementRangeCache = value;
},
get editorIsActive() {
return editorIsActive;
},
},
metadata: {
get cachedTables() {
return completionMetadata.cachedTables;
},
cachedColumnsByTable,
loadedColumnsByTable,
usesOracleSessionCompletionColumns,
findExactSemanticDiagnosticTable,
completionCacheKey,
ensureColumnsForTable,
isMissingTableMetadataError,
},
});
});
async function formatCurrentSql() {
if (props.readOnly) return;
@@ -1872,6 +1873,12 @@ const codeMirrorLifecycle = useQueryEditorCodeMirror({
Prec.highest(keymap.of([{ key: "Space", run: acceptSqlServerCompletionOnSpace }])),
initializedRuntime.sqlLanguageComp.of(sqlExtensions.buildSqlLanguageExtension()),
initializedRuntime.sqlSemanticHighlightComp.of(sqlExtensions.buildSqlSemanticHighlightExtension()),
createSqlUnknownObjectHighlights({
enabled: SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED,
load: loadSqlUnknownObjectSpans,
initialDelayMs: SQL_UNKNOWN_OBJECT_INITIAL_DELAY_MS,
debounceMs: SQL_UNKNOWN_OBJECT_DEBOUNCE_MS,
}),
tooltips({ parent: tooltipParent }),
initializedRuntime.completionComp.of(sqlExtensions.buildSqlCompletionExtension()),
sqlCompletionTheme(EditorView),
@@ -2495,6 +2502,7 @@ function resumeQueryEditorBackgroundWork() {
editorIsActive = true;
registerTableReferenceDropListener();
scheduleSemanticDiagnostics();
view.value?.dispatch({ effects: [refreshSqlUnknownObjectHighlights.of(null)] });
// Warm the database driver/pool while the user is still reading or typing, so
// the first Run does not pay pool creation or external-driver startup.
warmActiveTabConnection();
@@ -17,6 +17,8 @@ import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics"
import { buildSqlServerRoutineSyntaxDiagnostics } from "@/lib/sql/sqlServerRoutineSyntaxDiagnostics";
import { needsDiagnosticCaretReanchor } from "@/lib/editor/queryEditorDiagnosticCaretAnchor";
import { metadataSchemaForConnection } from "@/lib/database/jdbcDialect";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import type { SqlUnknownObjectSpan } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
import {
areSqlSemanticDiagnosticsEqual,
buildSqlParserErrorDiagnostic,
@@ -29,11 +31,11 @@ import {
tableReferenceKey,
type SqlSemanticDiagnostic,
} from "@/lib/sql/semantic/diagnostics";
import { sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect";
import { resolveSqlDialectId, sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect";
import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics";
import { buildMongoSyntaxDiagnostics } from "@/lib/mongo/mongoSyntaxDiagnostics";
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
import type { SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
import type { DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
interface QueryEditorDiagnosticsRuntime {
setSqlDiagnosticsEffect: import("@codemirror/state").StateEffectType<SqlSemanticDiagnostic[]> | null;
@@ -63,8 +65,10 @@ interface QueryEditorDiagnosticsOptions {
sqlDriverProfile: ComputedRef<string | undefined>;
sqlStatementParameterOptions: () => SqlParameterOptions;
sqlBehaviorDialect: () => QueryEditorProps["dialect"];
queryEditorSelectionLanguage: () => "sql" | "text";
semanticCompletionEnabled: boolean;
maxCompletionTables: number;
unknownObjectHighlightEnabled: boolean;
runtime: QueryEditorDiagnosticsRuntime;
metadata: QueryEditorDiagnosticMetadata;
}
@@ -73,6 +77,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
const { props, view, settingsStore, connectionStore, sqlDriverProfile, sqlStatementParameterOptions, sqlBehaviorDialect, runtime, metadata } = options;
const SEMANTIC_SQL_COMPLETION_ENABLED = options.semanticCompletionEnabled;
const MAX_COMPLETION_TABLES = options.maxCompletionTables;
const SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED = options.unknownObjectHighlightEnabled;
const MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES = 4;
let semanticDiagnostics: SqlSemanticDiagnostic[] = [];
let semanticDiagnosticTimer: ReturnType<typeof setTimeout> | null = null;
@@ -287,7 +292,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
return { tables: enriched, missingTables };
}
async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[], scope?: CompletionMetadataScope): Promise<Set<string>> {
async function ensureColumnsForSemanticDiagnostics(tables: SqlTableReference[], scope?: CompletionMetadataScope, limit = MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES): Promise<Set<string>> {
const missingTables = new Set<string>();
const seen = new Set<string>();
const targets: SqlTableReference[] = [];
@@ -303,7 +308,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
if (seen.has(normalizedKey)) continue;
seen.add(normalizedKey);
targets.push(table);
if (targets.length >= MAX_SEMANTIC_DIAGNOSTIC_COLUMN_TABLES) break;
if (targets.length >= limit) break;
}
await Promise.all(
targets.map(async (table) => {
@@ -493,6 +498,135 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
onBeforeUnmount(clearScheduledSemanticDiagnostics);
// ==================== Unknown table/column highlighting ====================
const NON_SQL_UNKNOWN_OBJECT_DATABASE_TYPES: ReadonlySet<DatabaseType> = new Set(["qdrant", "milvus", "weaviate", "chromadb", "solr"]);
const MAX_SQL_UNKNOWN_OBJECT_SQL_LENGTH = 200_000;
const MAX_SQL_UNKNOWN_OBJECT_STATEMENTS = 80;
const SQL_UNKNOWN_OBJECT_COLUMN_TABLE_LIMIT = 24;
const MAX_SQL_UNKNOWN_OBJECT_MEMO_ENTRIES = 200;
const sqlUnknownObjectSpanMemo = new Map<string, SqlUnknownObjectSpan[]>();
function sqlUnknownObjectMemoKey(range: SqlTextRange, metadataEpoch: string): string {
return `${sqlTextFingerprint(range.sql)}|${range.from}:${range.to}|${metadataEpoch}`;
}
function rememberSqlUnknownObjectSpans(key: string, spans: SqlUnknownObjectSpan[]) {
sqlUnknownObjectSpanMemo.set(key, spans);
while (sqlUnknownObjectSpanMemo.size > MAX_SQL_UNKNOWN_OBJECT_MEMO_ENTRIES) {
const oldest = sqlUnknownObjectSpanMemo.keys().next();
if (oldest.done) break;
sqlUnknownObjectSpanMemo.delete(oldest.value);
}
}
async function loadSqlUnknownObjectSpans(currentView: EditorViewType): Promise<SqlUnknownObjectSpan[]> {
if (!SQL_UNKNOWN_OBJECT_HIGHLIGHT_ENABLED || !runtime.editorIsActive) return [];
if (options.queryEditorSelectionLanguage() !== "sql") return [];
if (shouldSkipSqlSemanticDiagnostics()) return [];
if (props.databaseType && NON_SQL_UNKNOWN_OBJECT_DATABASE_TYPES.has(props.databaseType)) return [];
if (!props.connectionId || props.database == null) return [];
const doc = currentView.state.doc;
if (doc.length > MAX_SQL_UNKNOWN_OBJECT_SQL_LENGTH) return [];
const sql = doc.toString();
if (!sql.trim()) return [];
if (props.databaseType !== "sqlserver") {
runtime.executableStatementRangeCache = executableStatementRangeCacheForDoc(runtime.executableStatementRangeCache, doc, props.databaseType, sqlStatementParameterOptions());
}
const ranges = sqlSemanticDiagnosticRangesForViewport(sql, [{ from: 0, to: sql.length }], props.databaseType, props.databaseType === "sqlserver" ? undefined : runtime.executableStatementRangeCache?.ranges, sqlStatementParameterOptions()).slice(0, MAX_SQL_UNKNOWN_OBJECT_STATEMENTS);
if (ranges.length === 0) return [];
const metadataEpoch = `${resolveSqlDialectId({ databaseType: props.databaseType, dialect: sqlBehaviorDialect() })}|${props.connectionId}|${props.database}|${props.schema ?? ""}|${props.databaseType}|${semanticDiagnosticRunId}|${metadata.cachedColumnsByTable.size}:${metadata.loadedColumnsByTable.size}`;
const spans: SqlUnknownObjectSpan[] = [];
let analyzedStatements = 0;
let touchedTables = 0;
let columnMetadataLoads = 0;
for (const range of ranges) {
const memoKey = sqlUnknownObjectMemoKey(range, metadataEpoch);
const cachedSpans = sqlUnknownObjectSpanMemo.get(memoKey);
if (cachedSpans) {
spans.push(...cachedSpans);
continue;
}
if (currentView.state.doc !== doc) return [];
const statementSpans: SqlUnknownObjectSpan[] = [];
try {
const analysis = await api.analyzeSqlReferences(
range.sql,
sqlReferenceAnalysisDialectFor({
databaseType: props.databaseType,
identifierQuote: connectionStore.connectionIdentifierQuote(props.connectionId),
fallbackDialect: props.formatDialect ?? props.dialect ?? "generic",
}),
);
if (currentView.state.doc !== doc) return [];
const semanticCursor = Math.max(0, Math.min(currentView.state.selection.main.head - range.from, range.sql.length));
const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED
? buildSqlSemanticModel(range.sql, semanticCursor, {
databaseType: props.databaseType,
dialect: sqlBehaviorDialect(),
})
: null;
const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis;
const metadataScope = semanticDiagnosticMetadataScope(sql, range);
const scopedAnalysis = {
...semanticAnalysis,
tables: semanticDiagnosticTablesForScope(semanticAnalysis.tables, metadataScope),
};
const { tables, missingTables } = await enrichSemanticDiagnosticTables(scopedAnalysis.tables, metadataScope);
const loadedColumnsBefore = metadata.loadedColumnsByTable.size;
const columnMetadataMissingTables = await ensureColumnsForSemanticDiagnostics(tables, metadataScope, SQL_UNKNOWN_OBJECT_COLUMN_TABLE_LIMIT);
for (const tableKey of columnMetadataMissingTables) missingTables.add(tableKey);
if (currentView.state.doc !== doc) return [];
touchedTables += tables.length;
columnMetadataLoads += Math.max(0, metadata.loadedColumnsByTable.size - loadedColumnsBefore);
const diagnostics = offsetSqlSemanticDiagnostics(
buildSqlSemanticDiagnostics(
{ ...scopedAnalysis, tables },
{
tables: metadata.cachedTables,
columnsByTable: metadata.cachedColumnsByTable,
missingTables,
loadedColumnTables: metadata.loadedColumnsByTable,
sql: range.sql,
databaseType: props.databaseType,
},
),
range,
sql,
);
for (const diagnostic of diagnostics) {
const offsetRange = sqlTextSpanToRange(sql, diagnostic.span);
if (offsetRange) statementSpans.push(offsetRange);
}
} catch {
// A failed parse must not colour anything.
}
analyzedStatements += 1;
rememberSqlUnknownObjectSpans(memoKey, statementSpans);
spans.push(...statementSpans);
}
const dedupedSpans: SqlUnknownObjectSpan[] = [];
const seenSpans = new Set<string>();
for (const span of spans) {
const key = `${span.from}:${span.to}`;
if (seenSpans.has(key)) continue;
seenSpans.add(key);
dedupedSpans.push(span);
}
dedupedSpans.sort((left, right) => left.from - right.from || left.to - right.to);
return dedupedSpans;
}
return {
sqlErrorDecorationRange,
sqlSemanticDecorationRanges,
@@ -502,6 +636,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
invalidateSemanticDiagnosticsForDocumentChange,
shouldSkipSqlSemanticDiagnostics,
scheduleSemanticDiagnostics,
loadSqlUnknownObjectSpans,
get diagnostics() {
return semanticDiagnostics;
},
@@ -0,0 +1,147 @@
// @vitest-environment happy-dom
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSqlUnknownObjectHighlights, refreshSqlUnknownObjectHighlights, setSqlUnknownObjectSpans, type SqlUnknownObjectSpan } from "@/lib/editor/codemirrorSqlUnknownObjectHighlights";
const DOC = "SELECT USER_ID FROM T";
let view: EditorView | null = null;
function mount(doc: string, options: { enabled?: boolean; load?: (view: EditorView) => Promise<SqlUnknownObjectSpan[]> } = {}) {
const parent = document.createElement("div");
document.body.appendChild(parent);
view = new EditorView({
parent,
state: EditorState.create({
doc,
extensions: [
createSqlUnknownObjectHighlights({
enabled: options.enabled ?? true,
load: options.load ?? (async () => []),
initialDelayMs: 0,
debounceMs: 0,
}),
],
}),
});
return view;
}
/** Let the zero-delay deferred task fire and its promise chain settle. */
async function settle() {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
function highlightedText(current: EditorView): string[] {
return Array.from(current.dom.querySelectorAll(".cm-sql-unknown-object")).map((element) => element.textContent ?? "");
}
afterEach(() => {
view?.destroy();
view = null;
document.body.innerHTML = "";
});
describe("createSqlUnknownObjectHighlights", () => {
it("returns no extension when disabled", () => {
expect(createSqlUnknownObjectHighlights({ enabled: false, load: async () => [], initialDelayMs: 0, debounceMs: 0 })).toEqual([]);
});
it("colours the ranges supplied through the effect", () => {
const current = mount(DOC);
current.dispatch({ effects: setSqlUnknownObjectSpans.of([{ from: 7, to: 14 }]) });
expect(highlightedText(current)).toEqual(["USER_ID"]);
current.dispatch({ effects: setSqlUnknownObjectSpans.of([]) });
expect(highlightedText(current)).toEqual([]);
});
it("clamps, dedupes and orders the supplied ranges", () => {
const current = mount(DOC);
current.dispatch({
effects: setSqlUnknownObjectSpans.of([
{ from: 15, to: 500 },
{ from: 7, to: 14 },
{ from: 7, to: 14 },
{ from: 3, to: 3 },
]),
});
// Ordered by start, duplicate collapsed, out-of-range clipped to the document end, empty dropped.
expect(highlightedText(current)).toEqual(["USER_ID", "FROM T"]);
});
it("shifts existing highlights through document changes without clearing them", () => {
const current = mount(DOC);
current.dispatch({ effects: setSqlUnknownObjectSpans.of([{ from: 7, to: 14 }]) });
current.dispatch({ changes: { from: 0, insert: "XX" } });
expect(highlightedText(current)).toEqual(["USER_ID"]);
});
it("runs load on mount and applies the resolved spans", async () => {
const load = vi.fn(async () => [{ from: 7, to: 14 }]);
const current = mount(DOC, { load });
expect(highlightedText(current)).toEqual([]);
await settle();
expect(load).toHaveBeenCalledTimes(1);
expect(highlightedText(current)).toEqual(["USER_ID"]);
});
it("re-runs load when a refresh effect is dispatched", async () => {
const load = vi.fn(async () => [{ from: 7, to: 14 }]);
const current = mount(DOC, { load });
await settle();
expect(load).toHaveBeenCalledTimes(1);
current.dispatch({ effects: refreshSqlUnknownObjectHighlights.of(null) });
await settle();
expect(load).toHaveBeenCalledTimes(2);
expect(highlightedText(current)).toEqual(["USER_ID"]);
});
it("discards a result computed against an older document", async () => {
let resolveLoad: ((spans: SqlUnknownObjectSpan[]) => void) | null = null;
const load = () =>
new Promise<SqlUnknownObjectSpan[]>((resolve) => {
resolveLoad = resolve;
});
const current = mount(DOC, { load });
await settle();
current.dispatch({ changes: { from: 0, insert: "XX" } });
resolveLoad?.([{ from: 7, to: 14 }]);
await settle();
expect(highlightedText(current)).toEqual([]);
});
it("ignores a load failure", async () => {
const load = async () => {
throw new Error("metadata unavailable");
};
const current = mount(DOC, { load });
await settle();
expect(highlightedText(current)).toEqual([]);
});
it("emits an !important colour rule that outranks the table-name colour", () => {
mount(DOC);
const css = Array.from(document.querySelectorAll("style"))
.map((style) => style.textContent ?? "")
.join("\n");
// The repeated class is what beats .cm-sql-table-name (same specificity as a
// single class once the generated theme id prefix is accounted for).
expect(css).toContain(".cm-sql-unknown-object.cm-sql-unknown-object.cm-sql-unknown-object");
expect(css).toContain("var(--dbx-sql-unknown-object-color, #e5484d) !important");
});
});
@@ -0,0 +1,144 @@
import { StateEffect, StateField, type Extension } from "@codemirror/state";
import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from "@codemirror/view";
import { createDeferredEditorTask } from "@/lib/editor/deferredEditorTask";
/** Absolute document range that must be rendered with the "unknown object" colour. */
export interface SqlUnknownObjectSpan {
from: number;
to: number;
}
export const setSqlUnknownObjectSpans = StateEffect.define<SqlUnknownObjectSpan[]>();
/**
* Ask a mounted editor to re-run `load` immediately (used on tab activation,
* where KeepAlive does not necessarily produce a focus or viewport change).
*/
export const refreshSqlUnknownObjectHighlights = StateEffect.define<null>();
export interface SqlUnknownObjectHighlightOptions {
enabled: boolean;
/** Resolves the ranges to colour. Called off the typing path, never per keystroke. */
load: (view: EditorView) => Promise<SqlUnknownObjectSpan[]>;
initialDelayMs: number;
debounceMs: number;
}
const unknownObjectMark = Decoration.mark({
class: "cm-sql-unknown-object",
attributes: {
"data-sql-token": "unknown",
},
});
function buildDecorationSet(spans: readonly SqlUnknownObjectSpan[], docLength: number): DecorationSet {
const normalized: SqlUnknownObjectSpan[] = [];
const seen = new Set<string>();
for (const span of spans) {
const from = Math.max(0, Math.min(span.from, docLength));
const to = Math.max(0, Math.min(span.to, docLength));
if (to <= from) continue;
const key = `${from}:${to}`;
if (seen.has(key)) continue;
seen.add(key);
normalized.push({ from, to });
}
// DecorationSet requires ranges in ascending order.
normalized.sort((left, right) => left.from - right.from || left.to - right.to);
return Decoration.set(
normalized.map((span) => unknownObjectMark.range(span.from, span.to)),
true,
);
}
export function createSqlUnknownObjectHighlights(options: SqlUnknownObjectHighlightOptions): Extension {
if (!options.enabled) return [];
// Keeps the decorations as a DecorationSet so that typing shifts them through
// the ChangeSet instead of clearing them until the next deferred scan lands.
const spansField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(decorations, transaction) {
for (const effect of transaction.effects) {
if (effect.is(setSqlUnknownObjectSpans)) return buildDecorationSet(effect.value, transaction.state.doc.length);
}
if (transaction.docChanged) return decorations.map(transaction.changes);
return decorations;
},
provide: (field) => EditorView.decorations.from(field),
});
const plugin = ViewPlugin.fromClass(
class {
private running = false;
private view: EditorView;
private readonly runTask: () => void;
private readonly initialTask: ReturnType<typeof createDeferredEditorTask>;
private readonly changeTask: ReturnType<typeof createDeferredEditorTask>;
constructor(view: EditorView) {
this.view = view;
this.runTask = () => {
void this.run();
};
this.initialTask = createDeferredEditorTask(this.runTask, options.initialDelayMs);
this.changeTask = createDeferredEditorTask(this.runTask, options.debounceMs);
this.initialTask.schedule();
}
update(update: ViewUpdate) {
this.view = update.view;
if (update.docChanged) {
this.initialTask.cancel();
this.changeTask.schedule();
return;
}
if (update.transactions.some((transaction) => transaction.effects.some((effect) => effect.is(refreshSqlUnknownObjectHighlights)))) {
this.initialTask.cancel();
this.changeTask.cancel();
this.runTask();
}
}
destroy() {
this.initialTask.cancel();
this.changeTask.cancel();
}
private async run() {
if (this.running) return;
const view = this.view;
if (!view.dom.isConnected) return;
const doc = view.state.doc;
this.running = true;
let spans: SqlUnknownObjectSpan[] = [];
try {
spans = await options.load(view);
} catch {
spans = [];
} finally {
this.running = false;
}
// A result computed against an older document (or a detached editor) is
// stale: the next debounced run owns the current text.
if (!view.dom.isConnected || view.state.doc !== doc) return;
view.dispatch({ effects: setSqlUnknownObjectSpans.of(spans) });
}
},
);
return [
spansField,
plugin,
EditorView.baseTheme({
// The generated theme class prefixes this selector, and the table-name
// colour is an `!important` rule at the same specificity, so the class is
// repeated to win the cascade regardless of style-module injection order.
".cm-sql-unknown-object.cm-sql-unknown-object.cm-sql-unknown-object": {
color: "var(--dbx-sql-unknown-object-color, #e5484d) !important",
},
".cm-sql-unknown-object.cm-sql-unknown-object.cm-sql-unknown-object *": {
color: "var(--dbx-sql-unknown-object-color, #e5484d) !important",
},
}),
];
}