feat(editor): auto-detect JSON and XML formatting
This commit is contained in:
parent
3e779d24d7
commit
0bf2475804
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export default withEnglishFallback({
|
|||
stopExplain: "実行計画の表示を停止",
|
||||
formatSql: "SQLをフォーマット",
|
||||
formatSqlFailed: "SQLのフォーマットに失敗しました",
|
||||
formatAutoDetectFailed: "選択内容を認識またはフォーマットできません",
|
||||
compressSql: "SQLを圧縮",
|
||||
keywordCaseLower: "SQLキーワードを小文字にする",
|
||||
keywordCaseUpper: "SQLキーワードを大文字にする",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export default withEnglishFallback({
|
|||
stopExplain: "실행 계획 중지",
|
||||
formatSql: "SQL 정렬",
|
||||
formatSqlFailed: "SQL 정렬에 실패했습니다",
|
||||
formatAutoDetectFailed: "선택한 내용을 인식하거나 포맷할 수 없습니다",
|
||||
compressSql: "SQL 압축",
|
||||
keywordCaseLower: "SQL 키워드 소문자 사용",
|
||||
keywordCaseUpper: "SQL 키워드 대문자 사용",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export default withEnglishFallback({
|
|||
stopExplain: "停止执行计划",
|
||||
formatSql: "格式化 SQL",
|
||||
formatSqlFailed: "SQL 格式化失败",
|
||||
formatAutoDetectFailed: "无法识别或格式化所选内容",
|
||||
compressSql: "压缩 SQL",
|
||||
keywordCaseLower: "使用小写 SQL 关键字",
|
||||
keywordCaseUpper: "使用大写 SQL 关键字",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export default withEnglishFallback({
|
|||
stopExplain: "停止執行計畫",
|
||||
formatSql: "格式化 SQL",
|
||||
formatSqlFailed: "SQL 格式化失敗",
|
||||
formatAutoDetectFailed: "無法識別或格式化所選內容",
|
||||
compressSql: "壓縮 SQL",
|
||||
keywordCaseLower: "使用小寫 SQL 關鍵字",
|
||||
keywordCaseUpper: "使用大寫 SQL 關鍵字",
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ?? ""}`;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue