diff --git a/src/dialect.ts b/src/dialect.ts index 5a4b40ec77..0c04d7598b 100644 --- a/src/dialect.ts +++ b/src/dialect.ts @@ -47,4 +47,5 @@ const processDialectFormatOptions = ({ (options.tabularOnelineClauses ?? options.onelineClauses).map(name => [name, true]) ), identifierDashes: Boolean(tokenizerOptions.identChars?.dashes), + operatorsCombine: Boolean(options.operatorsCombine), }); diff --git a/src/formatter/ExpressionFormatter.ts b/src/formatter/ExpressionFormatter.ts index 98d00d0b0e..5005dec6a8 100644 --- a/src/formatter/ExpressionFormatter.ts +++ b/src/formatter/ExpressionFormatter.ts @@ -52,6 +52,9 @@ export interface DialectFormatOptions { onelineClauses: string[]; // List of clauses that should be formatted on a single line in tabular style tabularOnelineClauses?: string[]; + // True in dialects that lex a run of operator characters as a single operator + // (PostgreSQL, Redshift), where two operators densed together re-parse as one. + operatorsCombine?: boolean; } // Contains the same data as DialectFormatOptions, @@ -64,6 +67,8 @@ export interface ProcessedDialectFormatOptions { // In such dialects the "-" operator must keep its surrounding spaces, // otherwise "a - b" densed to "a-b" would re-parse as a single identifier. identifierDashes: boolean; + // See DialectFormatOptions.operatorsCombine. + operatorsCombine: boolean; } /** Formats a generic SQL expression */ diff --git a/src/formatter/Formatter.ts b/src/formatter/Formatter.ts index 8f10f87791..48a723458e 100644 --- a/src/formatter/Formatter.ts +++ b/src/formatter/Formatter.ts @@ -48,7 +48,10 @@ export default class Formatter { cfg: this.cfg, dialectCfg: this.dialect.formatOptions, params: this.params, - layout: new Layout(new Indentation(indentString(this.cfg))), + layout: new Layout( + new Indentation(indentString(this.cfg)), + this.dialect.formatOptions.operatorsCombine + ), }).format(statement.children); if (!statement.hasSemicolon) { diff --git a/src/formatter/Layout.ts b/src/formatter/Layout.ts index 39fd4071b7..55bb27e044 100644 --- a/src/formatter/Layout.ts +++ b/src/formatter/Layout.ts @@ -25,7 +25,7 @@ export type LayoutItem = WS.SPACE | WS.SINGLE_INDENT | WS.NEWLINE | WS.MANDATORY export default class Layout { private items: LayoutItem[] = []; - constructor(public indentation: Indentation) {} + constructor(public indentation: Indentation, private operatorsCombine = false) {} /** * Appends token strings and whitespace modifications to SQL string. @@ -57,10 +57,13 @@ export default class Layout { this.items.push(WS.SINGLE_INDENT); break; default: - // Don't glue a layout item starting with "-" directly onto one ending with - // "-": that forms "--", which re-parses as a line comment and - // swallows the rest of the line (e.g. densing "a - -b" into "a--b"). - if (item.startsWith('-') && this.lastItemEndsWith('-')) { + // Don't glue an item starting with "-"/"+" onto a preceding operator when + // the two would re-lex as one token. "-" onto "-" forms "--" (a line + // comment that swallows the rest of the line) in every dialect. In dialects + // that lex a run of operator characters as a single operator (PostgreSQL, + // Redshift), a sign onto an operator containing ~!@#%^&|`? also merges + // (e.g. densing "5 % -2" into "5%-2", which re-parses as the operator "%-"). + if (this.wouldMergeIntoOperator(item)) { this.items.push(WS.SPACE); } this.items.push(item); @@ -73,6 +76,24 @@ export default class Layout { return typeof lastItem === 'string' && lastItem.endsWith(suffix); } + private wouldMergeIntoOperator(item: string): boolean { + if (!item.startsWith('-') && !item.startsWith('+')) { + return false; + } + const lastItem = last(this.items); + if (typeof lastItem !== 'string') { + return false; + } + const run = /[-+*/<>=~!@#%^&|`?]+$/u.exec(lastItem)?.[0]; + if (!run) { + return false; + } + if (item.startsWith('-') && run.endsWith('-')) { + return true; + } + return this.operatorsCombine && /[~!@#%^&|`?]/u.test(run); + } + private trimHorizontalWhitespace() { while (isHorizontalWhitespace(last(this.items))) { this.items.pop(); diff --git a/src/languages/postgresql/postgresql.formatter.ts b/src/languages/postgresql/postgresql.formatter.ts index 08697d088e..5eae3858cf 100644 --- a/src/languages/postgresql/postgresql.formatter.ts +++ b/src/languages/postgresql/postgresql.formatter.ts @@ -406,5 +406,6 @@ export const postgresql: DialectOptions = { alwaysDenseOperators: ['::', ':'], onelineClauses: [...standardOnelineClauses, ...tabularOnelineClauses], tabularOnelineClauses, + operatorsCombine: true, }, }; diff --git a/src/languages/redshift/redshift.formatter.ts b/src/languages/redshift/redshift.formatter.ts index ef4a8e2f9b..619a507cd6 100644 --- a/src/languages/redshift/redshift.formatter.ts +++ b/src/languages/redshift/redshift.formatter.ts @@ -182,5 +182,6 @@ export const redshift: DialectOptions = { alwaysDenseOperators: ['::'], onelineClauses: [...standardOnelineClauses, ...tabularOnelineClauses], tabularOnelineClauses, + operatorsCombine: true, }, }; diff --git a/test/mysql.test.ts b/test/mysql.test.ts index e6bcd37af9..c626de924e 100644 --- a/test/mysql.test.ts +++ b/test/mysql.test.ts @@ -114,4 +114,12 @@ describe('MySqlFormatter', () => { DROP DEFAULT; `); }); + + it('does not space a sign after an operator in dense mode', () => { + expect(format('SELECT 5 % -2, 5 & -2', { denseOperators: true })).toBe(dedent` + SELECT + 5%-2, + 5&-2 + `); + }); }); diff --git a/test/postgresql.test.ts b/test/postgresql.test.ts index 4542d364a2..d42206358f 100644 --- a/test/postgresql.test.ts +++ b/test/postgresql.test.ts @@ -234,6 +234,25 @@ describe('PostgreSqlFormatter', () => { `); }); + it('keeps a space between an operator and a following sign with denseOperators', () => { + expect(format('SELECT 5 % -2, 2 ^ -2, 8 # -1', { denseOperators: true })).toBe(dedent` + SELECT + 5% -2, + 2^ -2, + 8# -1 + `); + expect(format(`SELECT '[1,2]'::jsonb @> -1`, { denseOperators: true })).toBe(dedent` + SELECT + '[1,2]'::jsonb@> -1 + `); + expect(format(`SELECT data ? -1 FROM t`, { denseOperators: true })).toBe(dedent` + SELECT + data? -1 + FROM + t + `); + }); + // Issue #813 it('supports OR REPLACE in CREATE FUNCTION', () => { expect(format(`CREATE OR REPLACE FUNCTION foo ();`)).toBe(dedent`