feat(editor): auto-detect JSON and XML formatting
This commit is contained in:
1 parent
3e779d24d7
commit
0bf2475804
15 files changed
+407
-2
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user