feat(sql-formatter): add setting to preserve blank lines

This commit is contained in:
二丫讲梵
2026-08-27 08:38:50 +08:00
committed by GitHub
parent 699043ff07
commit 62f2185f44
16 changed files with 158 additions and 7 deletions
@@ -109,6 +109,7 @@ const sqlFormatterOptionLabelKeys: Record<keyof SqlFormatterOptionSettings, stri
fromClauseLayout: "settings.sqlFormatterFromClauseLayout",
expressionWidth: "settings.sqlFormatterExpressionWidth",
linesBetweenQueries: "settings.sqlFormatterLinesBetweenQueries",
preserveEmptyLines: "settings.sqlFormatterPreserveEmptyLines",
denseOperators: "settings.sqlFormatterDenseOperators",
newlineBeforeSemicolon: "settings.sqlFormatterNewlineBeforeSemicolon",
paramTypes: "settings.sqlFormatterParamTypes",
@@ -667,6 +668,11 @@ onBeforeUnmount(() => {
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<Label for="sql-formatter-preserve-empty-lines">{{ t("settings.sqlFormatterPreserveEmptyLines") }}</Label>
<Switch id="sql-formatter-preserve-empty-lines" :model-value="settings.preserveEmptyLines" @update:model-value="(value: boolean) => updateOption('preserveEmptyLines', value)" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<Label for="sql-formatter-dense-operators">{{ t("settings.sqlFormatterDenseOperators") }}</Label>
<Switch id="sql-formatter-dense-operators" :model-value="settings.denseOperators" @update:model-value="(value: boolean) => updateOption('denseOperators', value)" />
@@ -9,6 +9,7 @@ import { useToast } from "@/composables/useToast";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { createShikiSqlHighlighter, type SqlHighlighter } from "@/lib/sql/sqlHighlighter";
import { useSettingsStore } from "@/stores/settingsStore";
const props = defineProps<{
sql: string;
@@ -27,6 +28,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const { isDark } = useTheme();
const { toast } = useToast();
const settingsStore = useSettingsStore();
const isFormatted = ref(false);
const formattedSql = ref("");
@@ -79,7 +81,7 @@ async function toggleFormat() {
formatting.value = true;
try {
formattedSql.value = await formatSqlText(props.sql, props.sqlFormatDialect ?? "generic");
formattedSql.value = await formatSqlText(props.sql, props.sqlFormatDialect ?? "generic", settingsStore.editorSettings.sqlFormatter);
isFormatted.value = true;
await highlightSql();
} catch {
+1
View File
@@ -6057,6 +6057,7 @@ export default {
sqlFormatterFromSameLine: "FROM with first table",
sqlFormatterExpressionWidth: "Expression width",
sqlFormatterLinesBetweenQueries: "Lines between queries",
sqlFormatterPreserveEmptyLines: "Preserve empty lines",
sqlFormatterDenseOperators: "Dense operators",
sqlFormatterNewlineBeforeSemicolon: "Semicolon on new line",
sqlFormatterAdvancedOptions: "Advanced options",
+1
View File
@@ -5786,6 +5786,7 @@ export default withEnglishFallback({
sqlFormatterFromSameLine: "FROM con la primera tabla",
sqlFormatterExpressionWidth: "Ancho de expresión",
sqlFormatterLinesBetweenQueries: "Líneas entre consultas",
sqlFormatterPreserveEmptyLines: "Conservar líneas vacías",
sqlFormatterDenseOperators: "Operadores compactos",
sqlFormatterNewlineBeforeSemicolon: "Punto y coma en nueva línea",
sqlFormatterAdvancedOptions: "Opciones avanzadas",
+1
View File
@@ -5786,6 +5786,7 @@ export default withEnglishFallback({
sqlFormatterFromSameLine: "FROM con la prima tabella",
sqlFormatterExpressionWidth: "Larghezza espressione",
sqlFormatterLinesBetweenQueries: "Righe tra query",
sqlFormatterPreserveEmptyLines: "Mantieni le righe vuote",
sqlFormatterDenseOperators: "Operatori compatti",
sqlFormatterNewlineBeforeSemicolon: "Punto e virgola su nuova riga",
sqlFormatterAdvancedOptions: "Opzioni avanzate",
+1
View File
@@ -5824,6 +5824,7 @@ export default withEnglishFallback({
sqlFormatterFromSameLine: "FROM と最初のテーブルを同じ行に配置",
sqlFormatterExpressionWidth: "式の幅",
sqlFormatterLinesBetweenQueries: "クエリ間の行数",
sqlFormatterPreserveEmptyLines: "空行を保持",
sqlFormatterDenseOperators: "演算子を詰める",
sqlFormatterNewlineBeforeSemicolon: "セミコロンを新しい行に",
sqlFormatterAdvancedOptions: "詳細オプション",
+1
View File
@@ -5465,6 +5465,7 @@ export default withEnglishFallback({
sqlFormatterFromSameLine: "FROM과 첫 번째 테이블을 같은 줄에 배치",
sqlFormatterExpressionWidth: "표현식 너비",
sqlFormatterLinesBetweenQueries: "쿼리 사이 줄 수",
sqlFormatterPreserveEmptyLines: "빈 줄 유지",
sqlFormatterDenseOperators: "조밀한 연산자",
sqlFormatterNewlineBeforeSemicolon: "세미콜론 새 줄에",
sqlFormatterAdvancedOptions: "고급 옵션",
+1
View File
@@ -5788,6 +5788,7 @@ export default withEnglishFallback({
sqlFormatterFromSameLine: "FROM com a primeira tabela",
sqlFormatterExpressionWidth: "Largura da expressão",
sqlFormatterLinesBetweenQueries: "Linhas entre consultas",
sqlFormatterPreserveEmptyLines: "Preservar linhas em branco",
sqlFormatterDenseOperators: "Operadores compactos",
sqlFormatterNewlineBeforeSemicolon: "Ponto e vírgula em nova linha",
sqlFormatterAdvancedOptions: "Opções avançadas",
+1
View File
@@ -6041,6 +6041,7 @@ export default withEnglishFallback({
sqlFormatterFromSameLine: "FROM 与首个表同行",
sqlFormatterExpressionWidth: "表达式宽度",
sqlFormatterLinesBetweenQueries: "查询之间空行",
sqlFormatterPreserveEmptyLines: "保留原有空行",
sqlFormatterDenseOperators: "紧凑运算符",
sqlFormatterNewlineBeforeSemicolon: "分号单独换行",
sqlFormatterAdvancedOptions: "高级选项",
+1
View File
@@ -5110,6 +5110,7 @@ export default withEnglishFallback({
sqlFormatterFromSameLine: "FROM 與第一個表同行",
sqlFormatterExpressionWidth: "運算式寬度",
sqlFormatterLinesBetweenQueries: "查詢之間空行",
sqlFormatterPreserveEmptyLines: "保留原有空行",
sqlFormatterDenseOperators: "緊湊運算子",
sqlFormatterNewlineBeforeSemicolon: "分號獨立換行",
sqlFormatterAdvancedOptions: "進階選項",
+24
View File
@@ -16,6 +16,30 @@ describe("sqlFormatter", () => {
expect(canFormatSqlForDatabaseType("mysql")).toBe(true);
});
it("preserves source empty lines only when configured", async () => {
const sql = "-- tstt\n\nSELECT * FROM AUX_TABLE AS au LIMIT 100;";
const consecutiveEmptyLines = "-- section\n\n\nSELECT 1;";
const queriesWithEmptyLine = "SELECT 1;\n\nSELECT 2;";
const queriesWithTwoEmptyLines = "SELECT 1;\n\n\nSELECT 2;";
const defaultFormatted = await formatSqlForEditing(sql, "generic");
const preserved = await formatSqlForEditing(sql, "generic", { preserveEmptyLines: true });
const preservedConsecutive = await formatSqlForEditing(consecutiveEmptyLines, "generic", { preserveEmptyLines: true });
const preservedQueriesNoSpacing = await formatSqlForEditing(queriesWithEmptyLine, "generic", { preserveEmptyLines: true, linesBetweenQueries: 0 });
const preservedQueries = await formatSqlForEditing(queriesWithEmptyLine, "generic", { preserveEmptyLines: true, linesBetweenQueries: 1 });
const preservedQueriesWideSpacing = await formatSqlForEditing(queriesWithEmptyLine, "generic", { preserveEmptyLines: true, linesBetweenQueries: 2 });
const preservedQueriesWithTwoEmptyLines = await formatSqlForEditing(queriesWithTwoEmptyLines, "generic", { preserveEmptyLines: true, linesBetweenQueries: 1 });
expect(defaultFormatted).toContain("-- tstt\nSELECT");
expect(preserved).toContain("-- tstt\n\nSELECT");
expect(preservedConsecutive).toContain("-- section\n\n\nSELECT");
expect(preservedQueriesNoSpacing).toBe("SELECT\n 1;\n\nSELECT\n 2;");
expect(preservedQueries).toBe("SELECT\n 1;\n\nSELECT\n 2;");
expect(preservedQueriesWideSpacing).toBe("SELECT\n 1;\n\n\nSELECT\n 2;");
expect(preservedQueriesWithTwoEmptyLines).toBe("SELECT\n 1;\n\n\nSELECT\n 2;");
expect(preserved).not.toContain("__DBX_PRESERVE_EMPTY_LINE_");
});
it("maps PostgreSQL-compatible database types to the postgres formatter dialect", () => {
for (const dbType of ["postgres", "kwdb", "gaussdb", "opengauss", "questdb", "kingbase", "highgo", "vastbase", "redshift"]) {
expect(sqlFormatDialectForDbType(dbType)).toBe("postgres");
@@ -63,4 +63,13 @@ describe("sqlFormatterConfig shortcut storage", () => {
if (result.ok) expect(result.settings.fromClauseLayout).toBe("sameLine");
expect(JSON.parse(serializeSqlFormatterConfig({ fromClauseLayout: "sameLine" })).options.fromClauseLayout).toBe("sameLine");
});
it("defaults empty-line preservation off and serializes an explicit opt-in", () => {
const defaultConfig = JSON.parse(serializeSqlFormatterConfig({}));
const optIn = parseSqlFormatterConfig(JSON.stringify({ version: 1, formatter: "sql-formatter", options: { preserveEmptyLines: true } }));
expect(defaultConfig.options.preserveEmptyLines).toBe(false);
expect(optIn).toEqual(expect.objectContaining({ ok: true }));
if (optIn.ok) expect(optIn.settings.preserveEmptyLines).toBe(true);
});
});
@@ -239,6 +239,7 @@ describe("settings search", () => {
{ titleKey: "settings.sqlFormatterLogicalOperatorNewline", category: "formatter", targetId: "formatter" },
{ titleKey: "settings.sqlFormatterExpressionWidth", category: "formatter", targetId: "formatter" },
{ titleKey: "settings.sqlFormatterLinesBetweenQueries", category: "formatter", targetId: "formatter" },
{ titleKey: "settings.sqlFormatterPreserveEmptyLines", category: "formatter", targetId: "formatter" },
{ titleKey: "settings.sqlFormatterDenseOperators", category: "formatter", targetId: "formatter" },
{ titleKey: "settings.sqlFormatterNewlineBeforeSemicolon", category: "formatter", targetId: "formatter" },
{ titleKey: "settings.sqlFormatterParamTypes", category: "formatter", targetId: "formatter" },
@@ -142,6 +142,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "formatter-from-clause-layout", category: "formatter", titleKey: "settings.sqlFormatterFromClauseLayout", targetId: "formatter" },
{ id: "formatter-expression-width", category: "formatter", titleKey: "settings.sqlFormatterExpressionWidth", targetId: "formatter" },
{ id: "formatter-lines-between-queries", category: "formatter", titleKey: "settings.sqlFormatterLinesBetweenQueries", targetId: "formatter" },
{ id: "formatter-preserve-empty-lines", category: "formatter", titleKey: "settings.sqlFormatterPreserveEmptyLines", targetId: "formatter" },
{ id: "formatter-dense-operators", category: "formatter", titleKey: "settings.sqlFormatterDenseOperators", targetId: "formatter" },
{ id: "formatter-newline-before-semicolon", category: "formatter", titleKey: "settings.sqlFormatterNewlineBeforeSemicolon", targetId: "formatter" },
{ id: "formatter-param-types", category: "formatter", titleKey: "settings.sqlFormatterParamTypes", targetId: "formatter" },
+101 -6
View File
@@ -118,6 +118,95 @@ function splitTrailingStandaloneDot(sql: string): { body: string; suffix: string
};
}
interface EmptyLineProtection {
sql: string;
markers: string[];
}
/**
* Replaces blank source lines with unique line comments while the third-party
* formatter runs. sql-formatter intentionally normalizes whitespace, whereas
* the optional DBX setting needs to retain visual paragraph boundaries such as
* the blank line between a heading comment and a query. Line comments are
* valid at every SQL code boundary and are restored only after all DBX layout
* post-processing is complete.
*/
function protectEmptyLines(sql: string): EmptyLineProtection {
let namespace = 0;
while (sql.includes(`__DBX_PRESERVE_EMPTY_LINE_${namespace}_`)) namespace += 1;
const markers: string[] = [];
const lineBreakPattern = /\r\n|\r|\n/g;
let output = "";
let lineStart = 0;
let match: RegExpExecArray | null;
while ((match = lineBreakPattern.exec(sql))) {
const line = sql.slice(lineStart, match.index);
if (line.trim().length === 0) {
const marker = `-- __DBX_PRESERVE_EMPTY_LINE_${namespace}_${markers.length}__`;
markers.push(marker);
output += marker;
} else {
output += line;
}
output += match[0];
lineStart = lineBreakPattern.lastIndex;
}
// A final non-terminated blank line is still a user-authored empty line.
// Do not manufacture one after a trailing line break, though: that would
// turn the normal EOF sentinel into an additional preserved blank line.
const finalLine = sql.slice(lineStart);
if (finalLine.length > 0 && finalLine.trim().length === 0) {
const marker = `-- __DBX_PRESERVE_EMPTY_LINE_${namespace}_${markers.length}__`;
markers.push(marker);
output += marker;
} else {
output += finalLine;
}
return { sql: markers.length > 0 ? output : sql, markers };
}
function restoreProtectedEmptyLines(sql: string, markers: readonly string[]): string {
if (markers.length === 0) return sql;
const markerSet = new Set(markers);
const lines = sql.split(/\r\n|\r|\n/);
for (let index = 0; index < lines.length; ) {
if (!markerSet.has(lines[index].trim())) {
index += 1;
continue;
}
let runEnd = index;
while (runEnd < lines.length && markerSet.has(lines[runEnd].trim())) runEnd += 1;
const markerCount = runEnd - index;
// sql-formatter adds `linesBetweenQueries` blank lines before a marker
// group placed between statements. Those lines did not exist in the
// source—the marker group itself represents the source blank lines—so
// remove at most one generated line per original blank line. This keeps
// the larger of the user-authored spacing and the formatter's configured
// query spacing instead of adding the two counts together.
let removed = 0;
while (removed < markerCount && index > 0 && lines[index - 1].trim().length === 0) {
lines.splice(index - 1, 1);
index -= 1;
runEnd -= 1;
removed += 1;
}
for (let markerIndex = index; markerIndex < runEnd; markerIndex += 1) {
lines[markerIndex] = "";
}
index = runEnd;
}
return lines.join("\n");
}
const DUCKDB_ALIAS_IDENTIFIER_START_RE = /[\p{L}_]/u;
const DUCKDB_ALIAS_IDENTIFIER_CHAR_RE = /[\p{L}\p{N}_]/u;
@@ -253,7 +342,9 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
const normalizedSettings = normalizeSqlFormatterSettings(settings);
const options = sqlFormatterOptions(normalizedSettings);
const language = formatterLanguage(dialect);
const protectedInput = dialect === "duckdb" ? protectDuckDbPrefixAliasSeparators(sql) : { sql, marker: null };
const emptyLineProtection = normalizedSettings.preserveEmptyLines ? protectEmptyLines(sql) : null;
const sqlWithProtectedEmptyLines = emptyLineProtection?.sql ?? sql;
const protectedInput = dialect === "duckdb" ? protectDuckDbPrefixAliasSeparators(sqlWithProtectedEmptyLines) : { sql: sqlWithProtectedEmptyLines, marker: null };
const formatterOptions =
language === "postgresql"
? {
@@ -290,16 +381,20 @@ export async function formatSqlText(sql: string, dialect: SqlFormatDialect = "ge
}
};
const finalizeFormattedSql = (formatted: string): string => {
const restoredDuckDbAliases = restoreDuckDbPrefixAliasSeparators(formatted, protectedInput.marker);
const laidOut = applySqlFormatterLayout(restoredDuckDbAliases, normalizedSettings, dialect);
return emptyLineProtection ? restoreProtectedEmptyLines(laidOut, emptyLineProtection.markers) : laidOut;
};
try {
const formatted = restoreDuckDbPrefixAliasSeparators(formatWithFallback(protectedInput.sql), protectedInput.marker);
return applySqlFormatterLayout(formatted, normalizedSettings, dialect);
return finalizeFormattedSql(formatWithFallback(protectedInput.sql));
} catch (err) {
const trailingDot = dialect === "dameng" ? splitTrailingStandaloneDot(sql) : null;
const trailingDot = dialect === "dameng" ? splitTrailingStandaloneDot(protectedInput.sql) : null;
if (!trailingDot) throw err;
try {
const formatted = applySqlFormatterLayout(formatWithFallback(trailingDot.body), normalizedSettings, dialect);
return `${formatted}${trailingDot.suffix}`;
return finalizeFormattedSql(`${formatWithFallback(trailingDot.body)}${trailingDot.suffix}`);
} catch {
throw err;
}
@@ -49,6 +49,7 @@ export interface SqlFormatterOptionSettings {
fromClauseLayout: SqlFormatterFromClauseLayout;
expressionWidth: SqlFormatterExpressionWidth;
linesBetweenQueries: SqlFormatterLinesBetweenQueries;
preserveEmptyLines: boolean;
denseOperators: boolean;
newlineBeforeSemicolon: boolean;
paramTypes: SqlFormatterParamTypes | null;
@@ -76,6 +77,7 @@ export const DEFAULT_SQL_FORMATTER_SETTINGS: SqlFormatterSettings = {
fromClauseLayout: "newLine",
expressionWidth: 50,
linesBetweenQueries: 1,
preserveEmptyLines: false,
denseOperators: false,
newlineBeforeSemicolon: false,
paramTypes: null,
@@ -93,6 +95,7 @@ const SQL_FORMATTER_OPTION_KEYS = new Set<keyof SqlFormatterOptionSettings>([
"fromClauseLayout",
"expressionWidth",
"linesBetweenQueries",
"preserveEmptyLines",
"denseOperators",
"newlineBeforeSemicolon",
"paramTypes",
@@ -110,6 +113,7 @@ const SQL_FORMATTER_OPTION_VALIDATORS: Record<keyof SqlFormatterOptionSettings,
fromClauseLayout: (value) => isStringChoice(value, FROM_CLAUSE_LAYOUT_VALUES),
expressionWidth: (value) => isNumberChoice(value, EXPRESSION_WIDTH_VALUES),
linesBetweenQueries: (value) => isNumberChoice(value, LINES_BETWEEN_QUERIES_VALUES),
preserveEmptyLines: (value) => typeof value === "boolean",
denseOperators: (value) => typeof value === "boolean",
newlineBeforeSemicolon: (value) => typeof value === "boolean",
paramTypes: isSqlFormatterParamTypes,
@@ -191,6 +195,7 @@ export function sqlFormatterOptionSettings(settings: unknown): SqlFormatterOptio
fromClauseLayout: normalizeChoice(input.fromClauseLayout, FROM_CLAUSE_LAYOUT_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.fromClauseLayout),
expressionWidth: normalizeNumberChoice(input.expressionWidth, EXPRESSION_WIDTH_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.expressionWidth),
linesBetweenQueries: normalizeNumberChoice(input.linesBetweenQueries, LINES_BETWEEN_QUERIES_VALUES, DEFAULT_SQL_FORMATTER_SETTINGS.linesBetweenQueries),
preserveEmptyLines: normalizeBoolean(input.preserveEmptyLines, DEFAULT_SQL_FORMATTER_SETTINGS.preserveEmptyLines),
denseOperators: normalizeBoolean(input.denseOperators, DEFAULT_SQL_FORMATTER_SETTINGS.denseOperators),
newlineBeforeSemicolon: normalizeBoolean(input.newlineBeforeSemicolon, DEFAULT_SQL_FORMATTER_SETTINGS.newlineBeforeSemicolon),
paramTypes: normalizeParamTypes(input.paramTypes, DEFAULT_SQL_FORMATTER_SETTINGS.paramTypes),