feat(editor): auto-detect JSON and XML formatting

This commit is contained in:
Abeautifulsnow 2026-08-02 09:01:48 +08:00 committed by GitHub
parent 3e779d24d7
commit 0bf2475804
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 407 additions and 2 deletions

View File

@ -18,6 +18,7 @@ import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@
import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints";
import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns";
import { formatSqlText, compressSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { detectAndFormatStructured } from "@/lib/sql/autoFormat";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { blankLineDeletionChanges, replaceSelectedEditorText } from "@/lib/editor/queryEditorTextEdits";
import { createSqlSignatureTooltipDom } from "@/lib/editor/sqlSignatureTooltip";
@ -2346,7 +2347,25 @@ async function formatCurrentSql() {
if (!source.trim()) return;
try {
const formatted = props.databaseType === "mongodb" ? formatMongoShellText(source, settingsStore.editorSettings.sqlFormatter) : await formatSqlText(source, props.formatDialect ?? props.dialect ?? "generic", settingsStore.editorSettings.sqlFormatter);
let formatted: string;
if (props.databaseType === "mongodb") {
formatted = formatMongoShellText(source, settingsStore.editorSettings.sqlFormatter);
} else {
const structured = detectAndFormatStructured(source, {
indentSize: settingsStore.editorSettings.sqlFormatter.tabWidth,
useTabs: settingsStore.editorSettings.sqlFormatter.useTabs,
});
if (structured.kind === "json" || structured.kind === "xml") {
formatted = structured.formatted;
} else if (structured.kind === "unsupported") {
// Keep invalid structured text untouched the SQL formatter would
// silently corrupt XML-looking content.
toast(t("toolbar.formatAutoDetectFailed"), 3000);
return;
} else {
formatted = await formatSqlText(source, props.formatDialect ?? props.dialect ?? "generic", settingsStore.editorSettings.sqlFormatter);
}
}
if (view.value !== currentView || currentView.state !== originalState || currentView.state.sliceDoc(from, to) !== source) {
return;
}

View File

@ -33,6 +33,7 @@ export default {
stopExplain: "Stop explain",
formatSql: "Format SQL",
formatSqlFailed: "Failed to format SQL",
formatAutoDetectFailed: "Cannot recognize or format the selected content",
compressSql: "Compress SQL",
keywordCaseLower: "Use lower-case SQL keywords",
keywordCaseUpper: "Use upper-case SQL keywords",

View File

@ -35,6 +35,7 @@ export default withEnglishFallback({
stopExplain: "Detener análisis",
formatSql: "Formatear SQL",
formatSqlFailed: "Error al formatear el SQL",
formatAutoDetectFailed: "No se pudo reconocer ni formatear el contenido seleccionado",
compressSql: "Comprimir SQL",
keywordCaseLower: "Usar palabras clave SQL en minúsculas",
keywordCaseUpper: "Usar palabras clave SQL en mayúsculas",

View File

@ -34,6 +34,7 @@ export default withEnglishFallback({
stopExplain: "Interrompi spiegazione",
formatSql: "Formatta SQL",
formatSqlFailed: "Impossibile formattare SQL",
formatAutoDetectFailed: "Impossibile riconoscere o formattare il contenuto selezionato",
compressSql: "Comprimi SQL",
keywordCaseLower: "Usa parole chiave SQL minuscole",
keywordCaseUpper: "Usa parole chiave SQL maiuscole",

View File

@ -35,6 +35,7 @@ export default withEnglishFallback({
stopExplain: "実行計画の表示を停止",
formatSql: "SQLをフォーマット",
formatSqlFailed: "SQLのフォーマットに失敗しました",
formatAutoDetectFailed: "選択内容を認識またはフォーマットできません",
compressSql: "SQLを圧縮",
keywordCaseLower: "SQLキーワードを小文字にする",
keywordCaseUpper: "SQLキーワードを大文字にする",

View File

@ -35,6 +35,7 @@ export default withEnglishFallback({
stopExplain: "실행 계획 중지",
formatSql: "SQL 정렬",
formatSqlFailed: "SQL 정렬에 실패했습니다",
formatAutoDetectFailed: "선택한 내용을 인식하거나 포맷할 수 없습니다",
compressSql: "SQL 압축",
keywordCaseLower: "SQL 키워드 소문자 사용",
keywordCaseUpper: "SQL 키워드 대문자 사용",

View File

@ -35,6 +35,7 @@ export default withEnglishFallback({
stopExplain: "Parar explicação",
formatSql: "Formatar SQL",
formatSqlFailed: "Falha ao formatar SQL",
formatAutoDetectFailed: "Não foi possível reconhecer ou formatar o conteúdo selecionado",
compressSql: "Comprimir SQL",
keywordCaseLower: "Usar palavras-chave SQL em minúsculas",
keywordCaseUpper: "Usar palavras-chave SQL em maiúsculas",

View File

@ -35,6 +35,7 @@ export default withEnglishFallback({
stopExplain: "停止执行计划",
formatSql: "格式化 SQL",
formatSqlFailed: "SQL 格式化失败",
formatAutoDetectFailed: "无法识别或格式化所选内容",
compressSql: "压缩 SQL",
keywordCaseLower: "使用小写 SQL 关键字",
keywordCaseUpper: "使用大写 SQL 关键字",

View File

@ -35,6 +35,7 @@ export default withEnglishFallback({
stopExplain: "停止執行計畫",
formatSql: "格式化 SQL",
formatSqlFailed: "SQL 格式化失敗",
formatAutoDetectFailed: "無法識別或格式化所選內容",
compressSql: "壓縮 SQL",
keywordCaseLower: "使用小寫 SQL 關鍵字",
keywordCaseUpper: "使用大寫 SQL 關鍵字",

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { detectAndFormatStructured, firstNonWhitespaceChar } from "@/lib/sql/autoFormat";
describe("autoFormat", () => {
describe("firstNonWhitespaceChar", () => {
it("skips leading whitespace and returns the first significant character", () => {
expect(firstNonWhitespaceChar(` \t\n{"a":1}`)).toBe("{");
expect(firstNonWhitespaceChar("SELECT")).toBe("S");
expect(firstNonWhitespaceChar(" \n\t")).toBeNull();
});
});
describe("detectAndFormatStructured", () => {
it("formats a JSON object losslessly with the requested indentation", () => {
expect(detectAndFormatStructured(`{"a":1,"b":[1,2]}`, { indentSize: 2 })).toEqual({
kind: "json",
formatted: `{\n "a": 1,\n "b": [\n 1,\n 2\n ]\n}`,
});
});
it("formats a JSON array", () => {
const result = detectAndFormatStructured(`[1,2]`, { indentSize: 2 });
expect(result.kind).toBe("json");
if (result.kind === "json") {
expect(result.formatted).toContain("\n");
}
});
it("preserves big numeric literals that JSON.parse would round", () => {
const result = detectAndFormatStructured(`{"big":9007199254740993,"id":1}`, { indentSize: 2 });
expect(result.kind).toBe("json");
if (result.kind === "json") {
expect(result.formatted).toContain("9007199254740993");
}
});
it("routes invalid JSON-looking text to the SQL formatter", () => {
expect(detectAndFormatStructured(`{a:1}`, { indentSize: 2 })).toEqual({ kind: "sql" });
expect(detectAndFormatStructured(`{`, { indentSize: 2 })).toEqual({ kind: "sql" });
});
it("does not mistake a SQL Server bracket-quoted identifier for JSON", () => {
expect(detectAndFormatStructured(`[dbo].[orders]`, { indentSize: 2 })).toEqual({ kind: "sql" });
});
it("does not mistake a selected SQL comparison fragment for XML", () => {
expect(detectAndFormatStructured(`< 10`, { indentSize: 2 })).toEqual({ kind: "sql" });
});
it("formats XML with nested elements", () => {
expect(detectAndFormatStructured(`<root><item id="1">value</item><empty/></root>`, { indentSize: 2 })).toEqual({
kind: "xml",
formatted: `<root>\n <item id="1">value</item>\n <empty/>\n</root>`,
});
});
it("preserves XML mixed content and supports tab indentation", () => {
expect(detectAndFormatStructured(`<root>Hello <strong>world</strong>!</root>`, { indentSize: 2, useTabs: true })).toEqual({
kind: "xml",
formatted: `<root>Hello <strong>world</strong>!</root>`,
});
});
it("rejects invalid XML without handing it to the SQL formatter", () => {
expect(detectAndFormatStructured(`<root><item></root>`, { indentSize: 2 })).toEqual({ kind: "unsupported", detectedType: "xml" });
});
it("routes SQL (and SQL containing an embedded JSON literal) to the SQL formatter", () => {
expect(detectAndFormatStructured("SELECT * FROM t", { indentSize: 2 })).toEqual({ kind: "sql" });
expect(detectAndFormatStructured(`SELECT '{"a":1}'::jsonb FROM t`, { indentSize: 2 })).toEqual({ kind: "sql" });
});
it("does not treat a leading JSON string literal as JSON (identifier risk)", () => {
expect(detectAndFormatStructured(`"column"`, { indentSize: 2 })).toEqual({ kind: "sql" });
});
it("routes whitespace-only input to the SQL formatter (no-op upstream)", () => {
expect(detectAndFormatStructured(" \n\t", { indentSize: 2 })).toEqual({ kind: "sql" });
});
});
});

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatSqlForDisplay, formatSqlText, MAX_SQL_FORMAT_CHARS, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { formatSqlForDisplay, formatSqlText, MAX_SQL_FORMAT_CHARS, sqlFormatDialectForDbType, UnsupportedStructuredInputError } from "@/lib/sql/sqlFormatter";
describe("sqlFormatter", () => {
it("maps PostgreSQL-compatible database types to the postgres formatter dialect", () => {
@ -56,4 +56,38 @@ describe("sqlFormatter", () => {
await expect(formatSqlText(oversizedSql, "postgres")).rejects.toThrow("SQL is too large to format safely.");
await expect(formatSqlForDisplay(oversizedSql, "postgres")).resolves.toBe(oversizedSql);
});
it("refuses to format XML-looking input instead of corrupting it (regression: silent rewrite)", async () => {
const xml = `<root><item id="1">value</item></root>`;
// The SQL formatter previously accepted this and rewrote it into corrupted
// output (`< root > < item id = "1" > ...`). It must now be refused so no
// caller can ever write sql-formatter output back over the user's text.
await expect(formatSqlText(xml, "generic")).rejects.toBeInstanceOf(UnsupportedStructuredInputError);
await expect(formatSqlText(xml, "postgres")).rejects.toBeInstanceOf(UnsupportedStructuredInputError);
});
it("still formats selected SQL Server bracket-quoted identifiers", async () => {
await expect(formatSqlText(`[dbo].[orders]`, "sqlserver")).resolves.toBe(`[dbo].[orders]`);
});
it("still formats selected SQL comparison fragments", async () => {
await expect(formatSqlText(`< 10`, "postgres")).resolves.toBe(`< 10`);
await expect(formatSqlText(`< 10 AND score > 2`, "postgres")).resolves.toBe(`< 10\nAND score > 2`);
});
it("keeps display formatting lossless for XML/JSON-looking input", async () => {
const xml = `<root><item id="1">value</item></root>`;
const json = `{"a":1}`;
await expect(formatSqlForDisplay(xml, "generic")).resolves.toBe(xml);
await expect(formatSqlForDisplay(json, "generic")).resolves.toBe(json);
});
it("still formats genuine SQL that starts with a non-structured token", async () => {
const formatted = await formatSqlText(`SELECT '{"a":1}'::jsonb AS j FROM t`, "postgres");
expect(formatted).toContain("SELECT");
expect(formatted).toContain("::jsonb");
});
});

View File

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { formatXmlSource } from "../xmlFormat";
describe("formatXmlSource", () => {
it("formats element-only XML while preserving attributes", () => {
expect(formatXmlSource(`<root attr="a > b"><child/><child>value</child></root>`)).toBe(`<root attr="a > b">\n <child/>\n <child>value</child>\n</root>`);
});
it("preserves XML declarations, DOCTYPE subsets, comments, and CDATA", () => {
const source = `<?xml version="1.0"?><!DOCTYPE root [<!ENTITY name "DBX">]><root><!-- note --><value><![CDATA[a < b]]></value></root>`;
expect(formatXmlSource(source)).toBe(`<?xml version="1.0"?>\n<!DOCTYPE root [<!ENTITY name "DBX">]>\n<root>\n <!-- note -->\n <value><![CDATA[a < b]]></value>\n</root>`);
});
it("does not alter mixed content", () => {
const source = `<p>Hello <em>there</em>, welcome.</p>`;
expect(formatXmlSource(source)).toBe(source);
});
it("does not alter whitespace-only text nodes", () => {
const source = `<p> <em/> </p>`;
expect(formatXmlSource(source)).toBe(source);
});
it("rejects malformed XML", () => {
expect(() => formatXmlSource(`<root><child></root>`)).toThrow(/closing tag/i);
expect(() => formatXmlSource(`<root>`)).toThrow(/Unclosed/i);
});
});

View File

@ -0,0 +1,154 @@
type XmlNode = XmlElementNode | XmlRawNode | XmlTextNode;
interface XmlElementNode {
type: "element";
name: string;
open: string;
close?: string;
children: XmlNode[];
}
interface XmlRawNode {
type: "raw";
raw: string;
preservesText?: boolean;
}
interface XmlTextNode {
type: "text";
text: string;
}
/**
* Formats XML without using a DOM serializer. Attributes, comments, CDATA,
* processing instructions, and DOCTYPE declarations remain byte-for-byte;
* only structural whitespace is regenerated. Any element with direct text keeps
* its source, including whitespace-only text nodes, so formatting cannot alter
* text-node semantics.
*/
export function formatXmlSource(source: string, indent = " "): string {
const nodes = parseXmlDocument(source);
return nodes
.filter((node) => node.type !== "text" || node.text.trim())
.map((node) => formatNode(node, 0, indent))
.join("\n");
}
function parseXmlDocument(source: string): XmlNode[] {
const roots: XmlNode[] = [];
const stack: XmlElementNode[] = [];
let index = 0;
const add = (node: XmlNode) => {
const parent = stack[stack.length - 1];
if (parent) parent.children.push(node);
else roots.push(node);
};
while (index < source.length) {
if (source[index] !== "<") {
const end = source.indexOf("<", index);
add({ type: "text", text: source.slice(index, end < 0 ? source.length : end) });
index = end < 0 ? source.length : end;
continue;
}
if (source.startsWith("<!--", index)) {
const end = source.indexOf("-->", index + 4);
if (end < 0) throw new SyntaxError("Unterminated XML comment");
add({ type: "raw", raw: source.slice(index, end + 3) });
index = end + 3;
continue;
}
if (source.startsWith("<![CDATA[", index)) {
const end = source.indexOf("]]>", index + 9);
if (end < 0) throw new SyntaxError("Unterminated CDATA section");
add({ type: "raw", raw: source.slice(index, end + 3), preservesText: true });
index = end + 3;
continue;
}
if (source.startsWith("<?", index)) {
const end = source.indexOf("?>", index + 2);
if (end < 0) throw new SyntaxError("Unterminated XML processing instruction");
add({ type: "raw", raw: source.slice(index, end + 2) });
index = end + 2;
continue;
}
if (/^<!DOCTYPE\b/i.test(source.slice(index))) {
const end = findMarkupEnd(source, index + 9);
add({ type: "raw", raw: source.slice(index, end + 1) });
index = end + 1;
continue;
}
if (source.startsWith("</", index)) {
const end = findMarkupEnd(source, index + 2);
const raw = source.slice(index, end + 1);
const match = raw.match(/^<\/\s*([A-Za-z_:][\w:.-]*)\s*>$/);
if (!match) throw new SyntaxError("Invalid XML closing tag");
const element = stack.pop();
if (!element || element.name !== match[1]) throw new SyntaxError(`Unexpected XML closing tag: ${match[1]}`);
element.close = raw;
index = end + 1;
continue;
}
if (source.startsWith("<!", index)) {
const end = findMarkupEnd(source, index + 2);
add({ type: "raw", raw: source.slice(index, end + 1) });
index = end + 1;
continue;
}
const end = findMarkupEnd(source, index + 1);
const raw = source.slice(index, end + 1);
const match = raw.match(/^<\s*([A-Za-z_:][\w:.-]*)(?=[\s/>])/);
if (!match) throw new SyntaxError("Invalid XML opening tag");
const element: XmlElementNode = { type: "element", name: match[1], open: raw, children: [] };
add(element);
if (!/\/\s*>$/.test(raw)) stack.push(element);
index = end + 1;
}
if (stack.length) throw new SyntaxError(`Unclosed XML element: ${stack[stack.length - 1]?.name}`);
const elements = roots.filter((node): node is XmlElementNode => node.type === "element");
if (elements.length !== 1 || roots.some((node) => node.type === "text" && node.text.trim())) throw new SyntaxError("XML input must contain exactly one root element");
return roots;
}
function findMarkupEnd(source: string, start: number): number {
let quote: string | null = null;
let subsetDepth = 0;
for (let index = start; index < source.length; index++) {
const character = source[index];
if (quote) {
if (character === quote) quote = null;
} else if (character === '"' || character === "'") {
quote = character;
} else if (character === "[") {
subsetDepth++;
} else if (character === "]") {
subsetDepth--;
} else if (character === ">" && subsetDepth === 0) {
return index;
}
}
throw new SyntaxError("Unterminated XML tag or declaration");
}
function formatNode(node: XmlNode, depth: number, indent: string): string {
if (node.type === "text") return node.text;
if (node.type === "raw") return node.raw;
if (!node.close) return node.open;
if (hasMeaningfulDirectText(node)) return originalNode(node);
const children = node.children.filter((child) => child.type !== "text" || child.text.trim());
if (!children.length) return `${node.open}${node.close}`;
const childIndent = indent.repeat(depth + 1);
return `${node.open}\n${children.map((child) => `${childIndent}${formatNode(child, depth + 1, indent)}`).join("\n")}\n${indent.repeat(depth)}${node.close}`;
}
function hasMeaningfulDirectText(element: XmlElementNode): boolean {
return element.children.some((child) => child.type === "text" || (child.type === "raw" && child.preservesText));
}
function originalNode(node: XmlNode): string {
if (node.type === "text") return node.text;
if (node.type === "raw") return node.raw;
return `${node.open}${node.children.map(originalNode).join("")}${node.close ?? ""}`;
}

View File

@ -0,0 +1,61 @@
import { formatJsonSource } from "@/lib/common/safeJsonFormat";
import { formatXmlSource } from "@/lib/common/xmlFormat";
/** Structured (non-SQL) content types the auto-format can recognise. */
export type AutoDetectedStructuredType = "json" | "xml";
/**
* Result of {@link detectAndFormatStructured}.
*
* - `{ kind: "sql" }` not a JSON/XML document; hand off to the SQL formatter.
* - `{ kind: "json" | "xml", formatted }` a valid structured document.
* - `{ kind: "unsupported", detectedType }` structured-looking invalid text.
* The caller must keep the original text unchanged and must never pass it to
* the SQL formatter.
*/
export type StructuredFormatResult = { kind: "sql" } | { kind: "json" | "xml"; formatted: string } | { kind: "unsupported"; detectedType: AutoDetectedStructuredType };
/** First non-whitespace character of `text`, or null when it is all whitespace. */
export function firstNonWhitespaceChar(text: string): string | null {
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c !== " " && c !== "\t" && c !== "\n" && c !== "\r" && c !== "\f" && c !== "\v") return c;
}
return null;
}
/**
* Detects whether an editor selection is a JSON/XML document and formats it.
*
* A valid JSON document must start with `{` or `[`, but an editor selection can
* also be a SQL fragment such as a SQL Server bracket-quoted identifier. Only a
* successful JSON source parse is therefore classified as JSON; all other text
* continues through the existing SQL path.
*
* Safety contract: valid JSON/XML is formatted before reaching the SQL formatter.
* Invalid XML is returned as unsupported because sql-formatter silently rewrites
* XML-looking input into corrupted output.
*/
export function detectAndFormatStructured(text: string, options: { indentSize: number; useTabs?: boolean }): StructuredFormatResult {
const first = firstNonWhitespaceChar(text);
if (first === "{" || first === "[") {
try {
return { kind: "json", formatted: formatJsonSource(text, options.indentSize) };
} catch {
return { kind: "sql" };
}
}
if (looksLikeXml(text)) {
try {
return { kind: "xml", formatted: formatXmlSource(text, options.useTabs ? "\t" : " ".repeat(options.indentSize)) };
} catch {
return { kind: "unsupported", detectedType: "xml" };
}
}
return { kind: "sql" };
}
export function looksLikeXml(text: string): boolean {
const trimmed = text.trimStart();
return trimmed.startsWith("<?") || trimmed.startsWith("<!--") || /^<!DOCTYPE\b/i.test(trimmed) || /^<\/?[A-Za-z_:]/.test(trimmed);
}

View File

@ -1,9 +1,25 @@
import { DEFAULT_SQL_FORMATTER_SETTINGS, sqlFormatterOptions, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
import { looksLikeXml } from "@/lib/sql/autoFormat";
export type SqlFormatDialect = "mysql" | "postgres" | "sqlite" | "sqlserver" | "clickhouse" | "generic";
export const MAX_SQL_FORMAT_CHARS = 1_000_000;
/**
* Thrown by {@link formatSqlText} when the input is XML-looking and must never
* be run through the SQL formatter. sql-formatter silently rewrites well-formed
* XML into corrupted output, so this guard keeps any caller (including future
* ones) from corrupting structured text. Callers that can format XML should
* route before calling (see {@link detectAndFormatStructured}); this is
* defense-in-depth.
*/
export class UnsupportedStructuredInputError extends Error {
constructor(readonly detectedType: "xml") {
super(`Cannot format ${detectedType} content as SQL.`);
this.name = "UnsupportedStructuredInputError";
}
}
/**
* Maps a connection's database type to the SQL-formatter dialect to use.
*
@ -65,6 +81,10 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
throw new Error("SQL is too large to format safely.");
}
if (looksLikeXml(sql)) {
throw new UnsupportedStructuredInputError("xml");
}
const { format } = await import("sql-formatter");
const options = sqlFormatterOptions(settings);
const language = formatterLanguage(dialect);