From 0bf24758047e22eceb9c9bd2fd626313cd16c7d5 Mon Sep 17 00:00:00 2001 From: Abeautifulsnow <1491444340@qq.com> Date: Sun, 2 Aug 2026 09:01:48 +0800 Subject: [PATCH] feat(editor): auto-detect JSON and XML formatting --- .../src/components/editor/QueryEditor.vue | 21 ++- apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../src/lib/__tests__/sql/autoFormat.spec.ts | 81 +++++++++ .../lib/__tests__/sql/sqlFormatter.spec.ts | 36 +++- .../lib/common/__tests__/xmlFormat.spec.ts | 28 ++++ apps/desktop/src/lib/common/xmlFormat.ts | 154 ++++++++++++++++++ apps/desktop/src/lib/sql/autoFormat.ts | 61 +++++++ apps/desktop/src/lib/sql/sqlFormatter.ts | 20 +++ 15 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/sql/autoFormat.spec.ts create mode 100644 apps/desktop/src/lib/common/__tests__/xmlFormat.spec.ts create mode 100644 apps/desktop/src/lib/common/xmlFormat.ts create mode 100644 apps/desktop/src/lib/sql/autoFormat.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 43f395964..2e56d90f9 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -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; } diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 5ea83a085..2e65fcaab 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index b3d851223..1524e7e07 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 6203fa4c3..bc405055e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 24873b105..f4f6e8795 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -35,6 +35,7 @@ export default withEnglishFallback({ stopExplain: "実行計画の表示を停止", formatSql: "SQLをフォーマット", formatSqlFailed: "SQLのフォーマットに失敗しました", + formatAutoDetectFailed: "選択内容を認識またはフォーマットできません", compressSql: "SQLを圧縮", keywordCaseLower: "SQLキーワードを小文字にする", keywordCaseUpper: "SQLキーワードを大文字にする", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 88b7fb373..94c3489cf 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -35,6 +35,7 @@ export default withEnglishFallback({ stopExplain: "실행 계획 중지", formatSql: "SQL 정렬", formatSqlFailed: "SQL 정렬에 실패했습니다", + formatAutoDetectFailed: "선택한 내용을 인식하거나 포맷할 수 없습니다", compressSql: "SQL 압축", keywordCaseLower: "SQL 키워드 소문자 사용", keywordCaseUpper: "SQL 키워드 대문자 사용", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 5e3696103..15d04e081 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -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", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index eef822fab..4bf7d2c8c 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -35,6 +35,7 @@ export default withEnglishFallback({ stopExplain: "停止执行计划", formatSql: "格式化 SQL", formatSqlFailed: "SQL 格式化失败", + formatAutoDetectFailed: "无法识别或格式化所选内容", compressSql: "压缩 SQL", keywordCaseLower: "使用小写 SQL 关键字", keywordCaseUpper: "使用大写 SQL 关键字", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index e734425a7..81c12dc57 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -35,6 +35,7 @@ export default withEnglishFallback({ stopExplain: "停止執行計畫", formatSql: "格式化 SQL", formatSqlFailed: "SQL 格式化失敗", + formatAutoDetectFailed: "無法識別或格式化所選內容", compressSql: "壓縮 SQL", keywordCaseLower: "使用小寫 SQL 關鍵字", keywordCaseUpper: "使用大寫 SQL 關鍵字", diff --git a/apps/desktop/src/lib/__tests__/sql/autoFormat.spec.ts b/apps/desktop/src/lib/__tests__/sql/autoFormat.spec.ts new file mode 100644 index 000000000..3e9f24bc0 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/autoFormat.spec.ts @@ -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(`value`, { indentSize: 2 })).toEqual({ + kind: "xml", + formatted: `\n value\n \n`, + }); + }); + + it("preserves XML mixed content and supports tab indentation", () => { + expect(detectAndFormatStructured(`Hello world!`, { indentSize: 2, useTabs: true })).toEqual({ + kind: "xml", + formatted: `Hello world!`, + }); + }); + + it("rejects invalid XML without handing it to the SQL formatter", () => { + expect(detectAndFormatStructured(``, { 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" }); + }); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts index 35e159a64..ea5cb2a18 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlFormatter.spec.ts @@ -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 = `value`; + + // 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 = `value`; + 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"); + }); }); diff --git a/apps/desktop/src/lib/common/__tests__/xmlFormat.spec.ts b/apps/desktop/src/lib/common/__tests__/xmlFormat.spec.ts new file mode 100644 index 000000000..e2c3e542a --- /dev/null +++ b/apps/desktop/src/lib/common/__tests__/xmlFormat.spec.ts @@ -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(`value`)).toBe(`\n \n value\n`); + }); + + it("preserves XML declarations, DOCTYPE subsets, comments, and CDATA", () => { + const source = `]>`; + expect(formatXmlSource(source)).toBe(`\n]>\n\n \n \n`); + }); + + it("does not alter mixed content", () => { + const source = `

Hello there, welcome.

`; + expect(formatXmlSource(source)).toBe(source); + }); + + it("does not alter whitespace-only text nodes", () => { + const source = `

`; + expect(formatXmlSource(source)).toBe(source); + }); + + it("rejects malformed XML", () => { + expect(() => formatXmlSource(``)).toThrow(/closing tag/i); + expect(() => formatXmlSource(``)).toThrow(/Unclosed/i); + }); +}); diff --git a/apps/desktop/src/lib/common/xmlFormat.ts b/apps/desktop/src/lib/common/xmlFormat.ts new file mode 100644 index 000000000..e2e744ca1 --- /dev/null +++ b/apps/desktop/src/lib/common/xmlFormat.ts @@ -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 + 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("", 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 + 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 (/^$/); + 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("])/); + 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 ?? ""}`; +} diff --git a/apps/desktop/src/lib/sql/autoFormat.ts b/apps/desktop/src/lib/sql/autoFormat.ts new file mode 100644 index 000000000..5d2180e59 --- /dev/null +++ b/apps/desktop/src/lib/sql/autoFormat.ts @@ -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("