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 2c1fdc634c..1502468d91 100644 --- a/src/formatter/ExpressionFormatter.ts +++ b/src/formatter/ExpressionFormatter.ts @@ -54,6 +54,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, + // where an operator and a following sign densed together re-parse as one operator. + operatorsCombine?: boolean; } // Contains the same data as DialectFormatOptions, @@ -66,6 +69,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 */ @@ -509,7 +514,7 @@ export default class ExpressionFormatter { cfg: this.cfg, dialectCfg: this.dialectCfg, params: this.params, - layout: new InlineLayout(this.cfg.expressionWidth), + layout: new InlineLayout(this.cfg.expressionWidth, this.dialectCfg.operatorsCombine), inline: true, enclosingParenthesis, }).format(nodes); 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/InlineLayout.ts b/src/formatter/InlineLayout.ts index e4fc2ab092..acb5a40ab4 100644 --- a/src/formatter/InlineLayout.ts +++ b/src/formatter/InlineLayout.ts @@ -16,8 +16,8 @@ export default class InlineLayout extends Layout { // but only when there actually is a space to remove. private trailingSpace = false; - constructor(private expressionWidth: number) { - super(new Indentation('')); // no indentation in inline layout + constructor(private expressionWidth: number, operatorsCombine: boolean) { + super(new Indentation(''), operatorsCombine); // no indentation in inline layout } public add(...items: (WS | string)[]) { diff --git a/src/formatter/Layout.ts b/src/formatter/Layout.ts index 39fd4071b7..cf2c2216b8 100644 --- a/src/formatter/Layout.ts +++ b/src/formatter/Layout.ts @@ -2,6 +2,15 @@ import { last } from '../utils.js'; import Indentation from './Indentation.js'; +const STARTS_WITH_SIGN = /^[-+]/u; + +// An operator that would take a following sign into its own name. +// PostgreSQL lexes a run of operator characters as a single operator, and such a name +// may only end in "+" or "-" when it also contains one of ~!@#%^&|`? -- so "@-" is an +// operator name, while "*-" is not. +// https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-OPERATORS +const ENDS_WITH_SIGN_ABSORBING_OPERATOR = /[~!@#%^&|`?][-+*/<>=~!@#%^&|`?]*$/u; + /** Whitespace modifiers to be used with add() method */ export enum WS { SPACE, // Adds single space @@ -25,7 +34,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: boolean) {} /** * Appends token strings and whitespace modifications to SQL string. @@ -57,10 +66,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 +74,25 @@ export default class Layout { } } - private lastItemEndsWith(suffix: string): boolean { + /** Whether `item` can be written right after the preceding item without the two re-lexing as one. */ + private isItemSafeToAppend(item: string): boolean { const lastItem = last(this.items); - return typeof lastItem === 'string' && lastItem.endsWith(suffix); + if (typeof lastItem !== 'string') { + return true; + } + // "a - -b" densed to "a--b" would re-parse as a line comment. + if (lastItem.endsWith('-') && item.startsWith('-')) { + return false; + } + // "5 % -2" densed to "5%-2" would re-parse as the single operator "%-". + if ( + this.operatorsCombine && + ENDS_WITH_SIGN_ABSORBING_OPERATOR.test(lastItem) && + STARTS_WITH_SIGN.test(item) + ) { + return false; + } + return true; } private trimHorizontalWhitespace() { diff --git a/src/languages/postgresql/postgresql.formatter.ts b/src/languages/postgresql/postgresql.formatter.ts index d3d8a51fd2..8ffe13a473 100644 --- a/src/languages/postgresql/postgresql.formatter.ts +++ b/src/languages/postgresql/postgresql.formatter.ts @@ -408,5 +408,6 @@ export const postgresql: DialectOptions = { alwaysDenseOperators: ['::', ':'], onelineClauses: [...standardOnelineClauses, ...tabularOnelineClauses], tabularOnelineClauses, + operatorsCombine: true, }, }; diff --git a/test/features/operators.ts b/test/features/operators.ts index ad1770a93f..b72f44a762 100644 --- a/test/features/operators.ts +++ b/test/features/operators.ts @@ -7,6 +7,10 @@ export const standardOperators = ['+', '-', '*', '/', '>', '<', '=', '<>', '<=', type OperatorsConfig = { logicalOperators?: string[]; any?: boolean; + // Set in dialects where an operator can take a following sign into its own name, + // so that "1 % -2" must not be densed to "1%-2". + // Such dialects test the spacing of each of their operators separately. + operatorsAbsorbSign?: boolean; }; export default function supportsOperators( @@ -44,6 +48,16 @@ export default function supportsOperators( }); } + if (!cfg.operatorsAbsorbSign) { + operators + .filter(op => !op.endsWith('-')) + .forEach(op => { + it(`denses a sign after ${op} operator in dense mode`, () => { + expect(format(`foo ${op} -2`, { denseOperators: true })).toBe(`foo${op}-2`); + }); + }); + } + (cfg.logicalOperators || ['AND', 'OR']).forEach(op => { it(`supports ${op} operator`, () => { const result = format(`SELECT true ${op} false AS foo;`); diff --git a/test/postgresql.test.ts b/test/postgresql.test.ts index e8e99adfea..3d2316a6e1 100644 --- a/test/postgresql.test.ts +++ b/test/postgresql.test.ts @@ -139,7 +139,7 @@ describe('PostgreSqlFormatter', () => { '&&&', '|=|', ], - { any: true } + { any: true, operatorsAbsorbSign: true } ); supportsIsDistinctFrom(format); supportsJoin(format); @@ -249,6 +249,28 @@ describe('PostgreSqlFormatter', () => { `); }); + // An operator containing one of the ten characters ~!@#%^&|`? may end in "-" or "+", + // so densing a sign onto it would extend the operator name instead. Nine of the ten are + // covered here, together with the operators that end in "-" themselves. The tenth, the + // backtick, is never yielded as an operator by the lexer and can't be reached from here. + it.each(['~', '!~', '@', '@>', '#', '#>>', '%', '^', '&', '|', '||', '?', '@-@', '?-', '-|-'])( + 'keeps a space after %s operator and before a sign in dense mode', + op => { + expect(format(`foo ${op} -1`, { denseOperators: true })).toBe(`foo${op} -1`); + expect(format(`foo ${op} +1`, { denseOperators: true })).toBe(`foo${op} +1`); + } + ); + + // The remaining operators are built only from -+*/<>= and can't take a sign into their + // name, so a sign denses onto them just like in any other dialect. + it.each(['*', '/', '<', '>=', '<<', '->', '->>', '<>', '<->'])( + 'denses a sign after %s operator in dense mode', + op => { + expect(format(`foo ${op} -1`, { denseOperators: true })).toBe(`foo${op}-1`); + expect(format(`foo ${op} +1`, { denseOperators: true })).toBe(`foo${op}+1`); + } + ); + // Issue #813 it('supports OR REPLACE in CREATE FUNCTION', () => { expect(format(`CREATE OR REPLACE FUNCTION foo ();`)).toBe(dedent` diff --git a/test/redshift.test.ts b/test/redshift.test.ts index dc890be75d..bbc6921513 100644 --- a/test/redshift.test.ts +++ b/test/redshift.test.ts @@ -52,6 +52,9 @@ describe('RedshiftFormatter', () => { [...standardOperators, '^', '%', '@', '|/', '||/', '&', '|', '~', '<<', '>>', '||'], { any: true, + // Redshift inherits the operator syntax of PostgreSQL, + // but the formatter doesn't take that into account yet. + operatorsAbsorbSign: true, } ); supportsJoin(format); diff --git a/test/unit/Layout.test.ts b/test/unit/Layout.test.ts index 1b8c00a9d4..e54277252e 100644 --- a/test/unit/Layout.test.ts +++ b/test/unit/Layout.test.ts @@ -8,7 +8,7 @@ describe('Layout', () => { indentation.increaseTopLevel(); indentation.increaseTopLevel(); - const layout = new Layout(indentation); + const layout = new Layout(indentation, false); layout.add(...items); return layout.toString(); }