diff --git a/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts b/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts index 81e7561109..7da78dd539 100644 --- a/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts @@ -145,4 +145,26 @@ describe("codemirrorSqlDialect", () => { expect(nodeNameAt(dialect, doubleQuoteStatement, '"escaped\\"quote"')).toBe("String"); expect(nodeNameAt(dialect, doubleQuoteStatement, "col")).toBe("Identifier"); }); + + it("tags QuotedIdentifier as tags.name so quoted columns/tables match unquoted identifiers (#12122)", async () => { + const { highlightTree, tags } = await import("@lezer/highlight"); + const { HighlightStyle } = await import("@codemirror/language"); + + const style = HighlightStyle.define([ + { tag: tags.keyword, color: "keyword" }, + { tag: tags.string, color: "string" }, + { tag: tags.name, color: "identifier" }, + ]); + + const mysqlDialect = createDbxCodeMirrorSqlDialect(langSql, "mysql", "mysql"); + const doc = "SELECT `quoted_col`, plain_col, 'literal_string' FROM `tbl`"; + const tree = mysqlDialect.language.parser.parse(doc); + const classes = new Map(); + highlightTree(tree, style, (from, to, cls) => classes.set(doc.slice(from, to), cls)); + + expect(classes.get("`quoted_col`")).toBeDefined(); + expect(classes.get("`quoted_col`")).toBe(classes.get("plain_col")); + expect(classes.get("`quoted_col`")).toBe(classes.get("`tbl`")); + expect(classes.get("`quoted_col`")).not.toBe(classes.get("'literal_string'")); + }); }); diff --git a/apps/desktop/src/lib/__tests__/editorThemes.spec.ts b/apps/desktop/src/lib/__tests__/editorThemes.spec.ts index ff105d2529..16f5b610ae 100644 --- a/apps/desktop/src/lib/__tests__/editorThemes.spec.ts +++ b/apps/desktop/src/lib/__tests__/editorThemes.spec.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from "vitest"; -import { buildEditorFontThemeRules, buildSqlCompletionThemeRules, EDITOR_FONT_FAMILY_CSS_VAR, editorDiagnosticColors, editorThemeAppearanceFor, IDE_EDITOR_THEMES, loadEditorTheme, resolveCustomThemeBackgrounds, resolveEditorTheme, SQL_BUILTIN_HIGHLIGHT_TAG } from "@/lib/editor/editorThemes"; +import { + buildEditorFontThemeRules, + buildSqlCompletionThemeRules, + EDITOR_FONT_FAMILY_CSS_VAR, + editorDiagnosticColors, + editorThemeAppearanceFor, + IDE_EDITOR_THEMES, + loadEditorTheme, + resolveCustomThemeBackgrounds, + resolveEditorTheme, + SQL_BUILTIN_HIGHLIGHT_TAG, + SQL_QUOTED_IDENTIFIER_HIGHLIGHT_TAG, +} from "@/lib/editor/editorThemes"; import { DEFAULT_APP_CUSTOM_UI_COLORS, wcagContrastRatio, type AppThemePalette } from "@/lib/app/appTheme"; import { DEFAULT_CUSTOM_THEME_COLORS, type CustomThemeColors, type EditorTheme } from "@/stores/settingsStore"; import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; @@ -306,6 +318,43 @@ describe("SQL builtin highlight tag", () => { }); }); +describe("SQL quoted identifier highlighting (#12122)", () => { + it("highlights quoted identifiers with identifier/variable color, distinct from string literals", async () => { + const { highlightTree, tags } = await import("@lezer/highlight"); + const { HighlightStyle } = await import("@codemirror/language"); + const style = HighlightStyle.define([ + { tag: tags.keyword, color: "keyword" }, + { tag: tags.string, color: "string" }, + { tag: [tags.name, tags.variableName, SQL_QUOTED_IDENTIFIER_HIGHLIGHT_TAG], color: "variable" }, + ]); + + const dialects = [ + { name: "mysql", sql: "SELECT `col`, col, 'val' FROM `tbl`" }, + { name: "postgres", sql: 'SELECT "col", col, \'val\' FROM "tbl"' }, + { name: "sqlserver", sql: "SELECT [col], col, 'val' FROM [tbl]" }, + ]; + + for (const { name, sql } of dialects) { + const dialect = createDbxCodeMirrorSqlDialect(langSql, name as any, name as any); + const tree = dialect.language.parser.parse(sql); + const classesByToken = new Map(); + highlightTree(tree, style, (from, to, cls) => classesByToken.set(sql.slice(from, to), cls)); + + const quotedCol = name === "mysql" ? "`col`" : name === "postgres" ? '"col"' : "[col]"; + const quotedTbl = name === "mysql" ? "`tbl`" : name === "postgres" ? '"tbl"' : "[tbl]"; + + expect(classesByToken.get(quotedCol), `${name} quoted col`).toBe(classesByToken.get("col")); + expect(classesByToken.get(quotedCol), `${name} quoted col vs string`).not.toBe(classesByToken.get("'val'")); + expect(classesByToken.get(quotedTbl), `${name} quoted tbl`).toBe(classesByToken.get("col")); + } + }); + + it("maps SQL_QUOTED_IDENTIFIER_HIGHLIGHT_TAG to special(string) for theme compatibility", async () => { + const { tags } = await import("@lezer/highlight"); + expect(SQL_QUOTED_IDENTIFIER_HIGHLIGHT_TAG).toBe(tags.special(tags.string)); + }); +}); + describe("editor gutters", () => { it("does not let the gutter minimum height cycle against the scroller content height", () => { const rules = buildEditorFontThemeRules(); diff --git a/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts b/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts index 70958ac9ac..151f780d0d 100644 --- a/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts +++ b/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts @@ -1,7 +1,16 @@ +import { styleTags, tags } from "@lezer/highlight"; import type { SQLDialect } from "@codemirror/lang-sql"; import type { DatabaseType } from "@/types/database"; import { driverProfileSqlBuiltinTerms } from "@/lib/database/driverProfileExtensions"; +// In SQL, quoted identifiers (e.g. `col`, "col", [col]) represent column, table, +// and schema names — not string literals (#12122). CodeMirror's SQL grammar tags +// QuotedIdentifier as special(string) by default, which themes highlight as a +// string literal. We retag QuotedIdentifier as tags.name so all themes (both DBX +// custom/IDE themes and standard third-party themes) highlight quoted identifiers +// identically to unquoted identifiers. +const SQL_DIALECT_LANGUAGE_PROPS = [styleTags({ QuotedIdentifier: tags.name })]; + export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "clickhouse" | "soql"; export function supportsQueryEditorSqlLanguage(databaseType?: DatabaseType): boolean { @@ -368,6 +377,8 @@ export function createDbxCodeMirrorSqlDialect(langSql: CodeMirrorSqlLanguageModu caseInsensitiveIdentifiers: true, identifierQuotes: "", doubleDollarQuotedStrings: false, + }).configureLanguage({ + props: SQL_DIALECT_LANGUAGE_PROPS, }); } const baseDialect = codeMirrorBaseDialect(langSql, dialectName, databaseType); @@ -409,5 +420,7 @@ export function createDbxCodeMirrorSqlDialect(langSql: CodeMirrorSqlLanguageModu : {}), ...(isPlsql ? { doubleQuotedStrings: false } : {}), doubleDollarQuotedStrings: false, + }).configureLanguage({ + props: SQL_DIALECT_LANGUAGE_PROPS, }); } diff --git a/apps/desktop/src/lib/editor/editorThemes.ts b/apps/desktop/src/lib/editor/editorThemes.ts index f3c35d7bec..a236462336 100644 --- a/apps/desktop/src/lib/editor/editorThemes.ts +++ b/apps/desktop/src/lib/editor/editorThemes.ts @@ -10,6 +10,13 @@ import { tags } from "@lezer/highlight"; // sync with what the SQL tokenizer actually emits. export const SQL_BUILTIN_HIGHLIGHT_TAG = tags.standard(tags.name); +// @codemirror/lang-sql tags quoted identifiers (e.g. `col`, "col", [col]) as +// special(string) by default. In SQL, quoted identifiers represent columns, tables, +// and database objects — not string literals (#12122). Both the SQL dialect parser +// configuration and DBX theme highlight styles map this token to identifier/variable +// color instead of string literal color. +export const SQL_QUOTED_IDENTIFIER_HIGHLIGHT_TAG = tags.special(tags.string); + type CodeMirrorStyleSpec = Parameters[0]; type LucideIconNode = Array<[string, Record]>; @@ -160,7 +167,7 @@ function createCustomTheme(EditorView: typeof import("@codemirror/view").EditorV { tag: tags.moduleKeyword, color: c.keyword }, { tag: tags.operatorKeyword, color: c.keyword }, { tag: tags.string, color: c.string }, - { tag: tags.special(tags.string), color: c.string }, + { tag: SQL_QUOTED_IDENTIFIER_HIGHLIGHT_TAG, color: c.variable }, { tag: tags.number, color: c.number }, { tag: tags.integer, color: c.number }, { tag: tags.float, color: c.number }, @@ -204,7 +211,6 @@ function createCustomTheme(EditorView: typeof import("@codemirror/view").EditorV { tag: tags.namespace, color: c.className }, { tag: tags.macroName, color: c.function }, { tag: tags.literal, color: c.string }, - { tag: tags.special(tags.string), color: c.string }, { tag: tags.regexp, color: c.string }, { tag: tags.escape, color: c.string }, { tag: tags.processingInstruction, color: c.keyword }, @@ -569,11 +575,11 @@ function createIdeEditorTheme(EditorView: typeof import("@codemirror/view").Edit const highlightStyle = HighlightStyle.define([ { tag: [tags.keyword, tags.controlKeyword, tags.definitionKeyword, tags.moduleKeyword, tags.operatorKeyword, tags.modifier, tags.bool, tags.null], color: c.keyword, ...(c.keywordBold ? { fontWeight: "bold" } : {}) }, - { tag: [tags.string, tags.special(tags.string), tags.regexp, tags.escape, tags.inserted], color: c.string, ...(c.stringBold ? { fontWeight: "bold" } : {}) }, + { tag: [tags.string, tags.regexp, tags.escape, tags.inserted], color: c.string, ...(c.stringBold ? { fontWeight: "bold" } : {}) }, { tag: [tags.number, tags.integer, tags.float], color: c.number, ...(c.numberBold ? { fontWeight: "bold" } : {}) }, { tag: [tags.comment, tags.lineComment, tags.blockComment, tags.quote], color: c.comment, fontStyle: "italic" }, { tag: [tags.typeName, tags.typeOperator, tags.unit], color: c.type }, - { tag: [tags.name, tags.variableName, tags.definition(tags.variableName)], color: c.variable }, + { tag: [tags.name, tags.variableName, tags.definition(tags.variableName), SQL_QUOTED_IDENTIFIER_HIGHLIGHT_TAG], color: c.variable }, { tag: [tags.function(tags.variableName), tags.function(tags.propertyName), tags.function(tags.name), tags.macroName], color: c.function }, { tag: [SQL_BUILTIN_HIGHLIGHT_TAG, tags.special(tags.name)], color: c.builtin }, { tag: [tags.propertyName, tags.labelName, tags.annotation], color: c.property },