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

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-02 09:01:48 +08:00
1 parent 3e779d24d7
commit 0bf2475804
15 files changed
+407 -2

No files matched your search

+81
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" });
});
});
});
+35 -1
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");
});
});