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..8682fa54b6 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,7 @@ 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('-')) { + if (!this.isItemSafeToAppend(item)) { this.items.push(WS.SPACE); } this.items.push(item); @@ -68,9 +65,34 @@ export default class Layout { } } - private lastItemEndsWith(suffix: string): boolean { + /** + * Whether `item` can be written directly after the preceding item without the + * two re-lexing as a single token. + * + * Only an item starting with "-" or "+" is at risk, and only when the preceding + * item ends in operator characters. "-" after a trailing "-" forms "--", a line + * comment that swallows the rest of the line, in every dialect. In a dialect that + * lexes a run of operator characters as one operator, a sign after an operator + * containing any of ~!@#%^&|`? merges too: "5 % -2" written densely as "5%-2" + * re-parses as the operator "%-". + */ + private isItemSafeToAppend(item: string): boolean { + if (!item.startsWith('-') && !item.startsWith('+')) { + return true; + } const lastItem = last(this.items); - return typeof lastItem === 'string' && lastItem.endsWith(suffix); + if (typeof lastItem !== 'string') { + return true; + } + // The operator characters the new item would be written against. + const precedingOperatorChars = /[-+*/<>=~!@#%^&|`?]+$/u.exec(lastItem)?.[0]; + if (!precedingOperatorChars) { + return true; + } + if (item.startsWith('-') && precedingOperatorChars.endsWith('-')) { + return false; + } + return !(this.operatorsCombine && /[~!@#%^&|`?]/u.test(precedingOperatorChars)); } private trimHorizontalWhitespace() { 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..58a9e9ac49 100644 --- a/test/postgresql.test.ts +++ b/test/postgresql.test.ts @@ -234,6 +234,39 @@ describe('PostgreSqlFormatter', () => { `); }); + // Every character that lets PostgreSQL lex a run as a single operator can swallow a + // following sign, so each one is checked rather than a sample. The tenth such + // character, a backtick, is legal in CREATE OPERATOR but the lexer never yields it + // as an operator, so it is not reachable from here. + it.each(['~', '!~', '@>', '#', '%', '^', '&', '|', '?'])( + 'keeps a space between the operator %s and a following sign with denseOperators', + operator => { + expect(format(`SELECT a ${operator} -1`, { denseOperators: true })).toBe(dedent` + SELECT + a${operator} -1 + `); + } + ); + + 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`