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)) {
+ 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 (/^$/);
+ 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("") || trimmed.startsWith("