Repository: sql-formatter-org/sql-formatter
Pull request #253 (2022-06-28 13:12:15)

SQL Formatter 8.0
Main integration branch for the large parser changes in upcoming 8.0 version.

Splitted up to multiple sub PRs:

- [x] #252 
- [x] #254
- [x] #255 
- [x] #256
- [x] #257
- [x] #258
- [ ] #259
- [x] #260
- [x] #261
- [x] #262
- [ ] #263
- [x] #264
- [x] #270 
- [ ] #273 


Patch:
diff --git a/.eslintrc b/.eslintrc
index a839e596..57dd6268 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -36,7 +36,7 @@
     "@typescript-eslint/indent": "off",
     "@typescript-eslint/lines-between-class-members": "off",
     "@typescript-eslint/naming-convention": "error",
-    "@typescript-eslint/no-unused-vars": "error",
+    "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
     "@typescript-eslint/quotes": [
       "error",
       "single",
diff --git a/README.md b/README.md
index 53ca712b..d994416d 100644
--- a/README.md
+++ b/README.md
@@ -149,13 +149,10 @@ All fields are optional and all fields that are not specified will be filled wit
 - [**`useTabs`**](docs/useTabs.md) to use tabs for indentation.
 - [**`keywordCase`**](docs/keywordCase.md) uppercases or lowercases keywords.
 - [**`indentStyle`**](docs/indentStyle.md) defines overall indentation style.
-- [**`multilineLists`**](docs/multilineLists.md) **(DEPRECATED)** determines when to break lists of items to multiple lines.
 - [**`logicalOperatorNewline`**](docs/logicalOperatorNewline.md) newline before or after boolean operator (AND, OR, XOR).
 - [**`aliasAs`**](docs/aliasAs.md) enforces or forbids use of AS keyword for aliases.
 - [**`tabulateAlias`**](docs/tabulateAlias.md) aligns column aliases vertically.
 - [**`commaPosition`**](docs/commaPosition.md) where to place the comma in column lists.
-- [**`newlineBeforeOpenParen`**](docs/newlineBeforeOpenParen.md) **(DEPRECATED)** placement of opening parenthesis.
-- [**`newlineBeforeCloseParen`**](docs/newlineBeforeCloseParen.md) **(DEPRECATED)** placement of closing parenthesis.
 - [**`expressionWidth`**](docs/expressionWidth.md) maximum number of characters in parenthesized expressions to be kept on single line.
 - [**`linesBetweenQueries`**](docs/linesBetweenQueries.md) how many newlines to insert between queries.
 - [**`denseOperators`**](docs/denseOperators.md) packs operators densely without spaces.
diff --git a/docs/expressionWidth.md b/docs/expressionWidth.md
index 7778f28f..225cc67c 100644
--- a/docs/expressionWidth.md
+++ b/docs/expressionWidth.md
@@ -7,8 +7,6 @@ Determines maximum length of parenthesized expressions.
 A number (default `50`) specifying the maximum length of parenthesized expression
 that's does not get split up to multiple lines.
 
-Note: `expressionWidth` also effects formatting of comma-separated lists when `multilineLists: "expressionWidth"` used.
-
 ### expressionWidth: 50 (default)
 
 Keeps the parenthesized expression (with length of 42) on single line:
diff --git a/docs/indentStyle.md b/docs/indentStyle.md
index a3a3ad3a..8915b96e 100644
--- a/docs/indentStyle.md
+++ b/docs/indentStyle.md
@@ -11,8 +11,6 @@ Switches between different indentation styles.
 Caveats of using `"tabularLeft"` and `"tabularRight"`:
 
 - `tabWidth` option is ignored. Indentation will always be 10 spaces, regardless of what is specified by `tabWidth`.
-- `newlineBeforeOpenParen` option is ignored.
-- `newlineBeforeCloseParen` option is ignored.
 
 ### standard
 
diff --git a/docs/multilineLists.md b/docs/multilineLists.md
deleted file mode 100644
index 13895504..00000000
--- a/docs/multilineLists.md
+++ /dev/null
@@ -1,152 +0,0 @@
-# multilineLists (DEPRECATED)
-
-Determines when to break lists of items (e.g. columns in `SELECT` clause) to multiple lines.
-
-## Options
-
-- `"always"` (default) always breaks to multiple lines (even when just a single item).
-- `"avoid"` avoids breaking to multiple lines, regardless of item count or line length.
-- `number` breaks to multiple lines when there are more items than the specified number.
-- `"expressionWidth"` breaks to multiple lines when the line would exceed value of `expressionWidth` option.
-
-### always (default)
-
-```
-SELECT
-  first_name,
-  last_name,
-  occupation,
-  age
-FROM
-  persons
-GROUP BY
-  age,
-  occupation
-```
-
-### avoid
-
-```
-SELECT first_name, last_name, occupation, age
-FROM persons
-GROUP BY age, occupation
-```
-
-But switches to multiple lines when CASE expression is seen:
-
-```
-SELECT
-  first_name,
-  CASE sex
-    WHEN 'F' THEN 'ms'
-    WHEN 'M' THEN 'mr'
-  END AS title
-FROM persons
-GROUP BY age, occupation
-```
-
-### number
-
-Using `multilineLists: 1`:
-
-```
-SELECT
-  first_name,
-  last_name,
-  occupation,
-  age
-FROM persons
-GROUP BY
-  age,
-  occupation
-```
-
-Using `multilineLists: 2`:
-
-```
-SELECT
-  first_name,
-  last_name,
-  occupation,
-  age
-FROM persons
-GROUP BY age, occupation
-```
-
-Using `multilineLists: 4`:
-
-```
-SELECT first_name, last_name, occupation, age
-FROM persons
-GROUP BY age, occupation
-```
-
-### expressionWidth
-
-Using `multilineLists: "expressionWidth", expressionWidth: 13`:
-
-```
-SELECT
-  first_name,
-  last_name,
-  occupation,
-  age
-FROM persons
-GROUP BY
-  age,
-  occupation
-```
-
-Using `multilineLists: "expressionWidth", expressionWidth: 15`:
-
-```
-SELECT
-  first_name,
-  last_name,
-  occupation,
-  age
-FROM persons
-GROUP BY age, occupation
-```
-
-Using `multilineLists: "expressionWidth", expressionWidth: 50`:
-
-```
-SELECT first_name, last_name, occupation, age
-FROM persons
-GROUP BY age, occupation
-```
-
-## Caveats
-
-This option has problematic behavior when dealing with comma-separated items within parenthesis.
-
-For example, with `multilineLists: "always"` you'd get this result:
-
-```
-INSERT INTO
-  records (25, 'John', 'Lennon', 1971, 'Imagine');
-
-INSERT INTO
-  records (
-    26,
-    'Michael',
-    'Jackson',
-    1997,
-    'Blood on the Dance Floor'
-  );
-```
-
-Which is not really always.
-
-And with `multilineLists: "avoid"` you'd get:
-
-```
-INSERT INTO records (25, 'John', 'Lennon', 1971, 'Imagine');
-
-INSERT INTO records (
-    26, 'Michael', 'Jackson', 1997, 'Blood on the Dance Floor'
-  );
-```
-
-Which still breaks things to multiple lines, but with incorrect indentation.
diff --git a/docs/newlineBeforeCloseParen.md b/docs/newlineBeforeCloseParen.md
deleted file mode 100644
index 64ecedad..00000000
--- a/docs/newlineBeforeCloseParen.md
+++ /dev/null
@@ -1,40 +0,0 @@
-# newlineBeforeCloseParen (DEPRECATED)
-
-Decides whether to place close-parenthesis `)` of sub-queries on a separate line.
-
-## Options
-
-- `true` (default) adds newline before close-parenthesis.
-- `false` no newline.
-
-### newlineBeforeCloseParen: true
-
-```
-SELECT
-  *
-FROM
-  (
-    SELECT
-      *
-    FROM
-      my_table
-  );
-```
-
-### newlineBeforeCloseParen: false
-
-```
-SELECT
-  *
-FROM
-  (
-    SELECT
-      *
-    FROM
-      my_table );
-```
-
-## Caveats
-
-- This option is ignored when `indentStyle: "tabularLeft"` or `"tabularRight"` is used.
-- This option is ignored when the parenthized content is smaller than `expressionWidth`.
diff --git a/docs/newlineBeforeOpenParen.md b/docs/newlineBeforeOpenParen.md
deleted file mode 100644
index 2f826eec..00000000
--- a/docs/newlineBeforeOpenParen.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# newlineBeforeOpenParen (DEPRECATED)
-
-Decides whether to place open-parenthesis `(` of sub-queries on a separate line.
-
-## Options
-
-- `true` (default) adds newline before open-parenthesis.
-- `false` no newline.
-
-### newlineBeforeOpenParen: true
-
-```
-SELECT
-  *
-FROM
-  (
-    SELECT
-      *
-    FROM
-      my_table
-  );
-```
-
-### newlineBeforeOpenParen: false
-
-```
-SELECT
-  *
-FROM (
-    SELECT
-      *
-    FROM
-      my_table
-  );
-```
-
-## Caveats
-
-- This option is ignored when `indentStyle: "tabularLeft"` or `"tabularRight"` is used.
-- This option is ignored when the parenthized content is smaller than `expressionWidth`.
-- This option is ignored for function calls.
diff --git a/docs/tabulateAlias.md b/docs/tabulateAlias.md
index 2a3afc58..fc695054 100644
--- a/docs/tabulateAlias.md
+++ b/docs/tabulateAlias.md
@@ -8,10 +8,6 @@ Does not effect table name aliases.
 - `false` (default) does nothing.
 - `true` aligns column aliases into single column.
 
-Caveats:
-
-Does not work when option `multilineLists: "avoid"` is used.
-
 ### tabulateAlias: false
 
 ```
diff --git a/src/core/ExpressionFormatter.ts b/src/core/ExpressionFormatter.ts
new file mode 100644
index 00000000..74097844
--- /dev/null
+++ b/src/core/ExpressionFormatter.ts
@@ -0,0 +1,398 @@
+import type { FormatOptions } from 'src/types';
+import { equalizeWhitespace } from 'src/utils';
+
+import InlineBlock from './InlineBlock';
+import Params from './Params';
+import { isReserved, type Token, TokenType } from './token';
+import {
+  AllColumnsAsterisk,
+  ArraySubscript,
+  AstNode,
+  BetweenPredicate,
+  BinaryClause,
+  Clause,
+  FunctionCall,
+  LimitClause,
+  NodeType,
+  Parenthesis,
+} from './ast';
+import { isTabularStyle } from './config';
+import Layout, { WS } from './Layout';
+import toTabularFormat, { isTabularToken } from './tabularStyle';
+
+interface ExpressionFormatterParams {
+  cfg: FormatOptions;
+  params: Params;
+  layout: Layout;
+  inline?: boolean;
+}
+
+/** Formats a generic SQL expression */
+export default class ExpressionFormatter {
+  private cfg: FormatOptions;
+  private inlineBlock: InlineBlock;
+  private params: Params;
+  private layout: Layout;
+
+  private inline = false;
+  private nodes: AstNode[] = [];
+  private index = -1;
+
+  constructor({ cfg, params, layout, inline = false }: ExpressionFormatterParams) {
+    this.cfg = cfg;
+    this.inline = inline;
+    this.inlineBlock = new InlineBlock(this.cfg.expressionWidth);
+    this.params = params;
+    this.layout = layout;
+  }
+
+  public format(nodes: AstNode[]): Layout {
+    this.nodes = nodes;
+
+    for (this.index = 0; this.index < this.nodes.length; this.index++) {
+      const node = this.nodes[this.index];
+      switch (node.type) {
+        case NodeType.function_call:
+          this.formatFunctionCall(node);
+          break;
+        case NodeType.array_subscript:
+          this.formatArraySubscript(node);
+          break;
+        case NodeType.parenthesis:
+          this.formatParenthesis(node);
+          break;
+        case NodeType.between_predicate:
+          this.formatBetweenPredicate(node);
+          break;
+        case NodeType.clause:
+          this.formatClause(node);
+          break;
+        case NodeType.binary_clause:
+          this.formatBinaryClause(node);
+          break;
+        case NodeType.limit_clause:
+          this.formatLimitClause(node);
+          break;
+        case NodeType.all_columns_asterisk:
+          this.formatAllColumnsAsterisk(node);
+          break;
+        case NodeType.token:
+          this.formatToken(node.token);
+          break;
+      }
+    }
+    return this.layout;
+  }
+
+  private formatFunctionCall(node: FunctionCall) {
+    this.layout.add(this.show(node.nameToken));
+    this.formatParenthesis(node.parenthesis);
+  }
+
+  private formatArraySubscript(node: ArraySubscript) {
+    this.layout.add(this.show(node.arrayToken));
+    this.formatParenthesis(node.parenthesis);
+  }
+
+  private formatParenthesis(node: Parenthesis) {
+    const inline = this.inlineBlock.isInlineBlock(node);
+
+    if (inline) {
+      this.layout.add(node.openParen);
+      this.layout = this.formatSubExpression(node.children, inline);
+      this.layout.add(WS.NO_SPACE, node.closeParen, WS.SPACE);
+    } else {
+      this.layout.add(node.openParen, WS.NEWLINE);
+
+      if (isTabularStyle(this.cfg)) {
+        this.layout.add(WS.INDENT);
+        this.layout = this.formatSubExpression(node.children, inline);
+      } else {
+        this.layout.indentation.increaseBlockLevel();
+        this.layout.add(WS.INDENT);
+        this.layout = this.formatSubExpression(node.children, inline);
+        this.layout.indentation.decreaseBlockLevel();
+      }
+
+      this.layout.add(WS.NEWLINE, WS.INDENT, node.closeParen, WS.SPACE);
+    }
+  }
+
+  private formatBetweenPredicate(node: BetweenPredicate) {
+    this.layout.add(
+      this.show(node.betweenToken),
+      WS.SPACE,
+      this.show(node.expr1),
+      WS.SPACE,
+      this.show(node.andToken),
+      WS.SPACE,
+      this.show(node.expr2),
+      WS.SPACE
+    );
+  }
+
+  private formatClause(node: Clause) {
+    if (isTabularStyle(this.cfg)) {
+      this.layout.add(WS.NEWLINE, WS.INDENT, this.show(node.nameToken), WS.SPACE);
+    } else {
+      this.layout.add(WS.NEWLINE, WS.INDENT, this.show(node.nameToken), WS.NEWLINE);
+    }
+    this.layout.indentation.increaseTopLevel();
+
+    if (!isTabularStyle(this.cfg)) {
+      this.layout.add(WS.INDENT);
+    }
+    this.layout = this.formatSubExpression(node.children);
+
+    this.layout.indentation.decreaseTopLevel();
+  }
+
+  private formatBinaryClause(node: BinaryClause) {
+    this.layout.indentation.decreaseTopLevel();
+    this.layout.add(WS.NEWLINE, WS.INDENT, this.show(node.nameToken), WS.NEWLINE);
+
+    this.layout.add(WS.INDENT);
+    this.layout = this.formatSubExpression(node.children);
+  }
+
+  private formatLimitClause(node: LimitClause) {
+    this.layout.add(WS.NEWLINE, WS.INDENT, this.show(node.limitToken));
+    this.layout.indentation.increaseTopLevel();
+
+    if (node.offsetToken) {
+      this.layout.add(
+        WS.NEWLINE,
+        WS.INDENT,
+        this.show(node.offsetToken),
+        ',',
+        WS.SPACE,
+        this.show(node.countToken),
+        WS.SPACE
+      );
+    } else {
+      this.layout.add(WS.NEWLINE, WS.INDENT, this.show(node.countToken), WS.SPACE);
+    }
+    this.layout.indentation.decreaseTopLevel();
+  }
+
+  private formatAllColumnsAsterisk(_node: AllColumnsAsterisk) {
+    this.layout.add('*', WS.SPACE);
+  }
+
+  private formatSubExpression(nodes: AstNode[], inline = this.inline): Layout {
+    return new ExpressionFormatter({
+      cfg: this.cfg,
+      params: this.params,
+      layout: this.layout,
+      inline,
+    }).format(nodes);
+  }
+
+  private formatToken(token: Token): void {
+    switch (token.type) {
+      case TokenType.LINE_COMMENT:
+        return this.formatLineComment(token);
+      case TokenType.BLOCK_COMMENT:
+        return this.formatBlockComment(token);
+      case TokenType.RESERVED_JOIN:
+        return this.formatJoin(token);
+      case TokenType.RESERVED_DEPENDENT_CLAUSE:
+        return this.formatDependentClause(token);
+      case TokenType.RESERVED_JOIN_CONDITION:
+        return this.formatJoinCondition(token);
+      case TokenType.RESERVED_LOGICAL_OPERATOR:
+        return this.formatLogicalOperator(token);
+      case TokenType.RESERVED_KEYWORD:
+        return this.formatKeyword(token);
+      case TokenType.RESERVED_CASE_START:
+        return this.formatCaseStart(token);
+      case TokenType.RESERVED_CASE_END:
+        return this.formatCaseEnd(token);
+      case TokenType.PARAMETER:
+        return this.formatParameter(token);
+      case TokenType.OPERATOR:
+        return this.formatOperator(token);
+      case TokenType.IDENT:
+      case TokenType.STRING:
+      case TokenType.NUMBER:
+      case TokenType.VARIABLE:
+        return this.formatWord(token);
+      default:
+        throw new Error(`Unexpected token type: ${token.type}`);
+    }
+  }
+
+  /**
+   * Formats ident/string/number/variable tokens
+   */
+  private formatWord(token: Token) {
+    this.layout.add(this.show(token), WS.SPACE);
+  }
+
+  /** Formats a line comment onto query */
+  private formatLineComment(token: Token) {
+    this.layout.add(this.show(token), WS.NEWLINE, WS.INDENT);
+  }
+
+  /** Formats a block comment onto query */
+  private formatBlockComment(token: Token) {
+    this.splitBlockComment(token.value).forEach(line => {
+      this.layout.add(WS.NEWLINE, WS.INDENT, line);
+    });
+    this.layout.add(WS.NEWLINE, WS.INDENT);
+  }
+
+  // Breaks up block comment to multiple lines.
+  // For example this comment (dots representing leading whitespace):
+  //
+  //   ..../**
+  //   .....* Some description here
+  //   .....* and here too
+  //   .....*/
+  //
+  // gets broken to this array (note the leading single spaces):
+  //
+  //   [ '/**',
+  //     '.* Some description here',
+  //     '.* and here too',
+  //     '.*/' ]
+  //
+  private splitBlockComment(comment: string): string[] {
+    return comment.split(/\n/).map(line => {
+      if (/^\s*\*/.test(line)) {
+        return ' ' + line.replace(/^\s*/, '');
+      } else {
+        return line.replace(/^\s*/, '');
+      }
+    });
+  }
+
+  private formatJoin(token: Token) {
+    if (isTabularStyle(this.cfg)) {
+      // in tabular style JOINs are at the same level as clauses
+      this.layout.indentation.decreaseTopLevel();
+      this.layout.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.SPACE);
+      this.layout.indentation.increaseTopLevel();
+    } else {
+      this.layout.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.SPACE);
+    }
+  }
+
+  /**
+   * Formats a Reserved Keyword onto query
+   */
+  private formatKeyword(token: Token) {
+    this.layout.add(this.show(token), WS.SPACE);
+  }
+
+  /**
+   * Formats a Reserved Dependent Clause token onto query, supporting the keyword that precedes it
+   */
+  private formatDependentClause(token: Token) {
+    this.layout.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.SPACE);
+  }
+
+  // Formats ON and USING keywords
+  private formatJoinCondition(token: Token) {
+    this.layout.add(this.show(token), WS.SPACE);
+  }
+
+  /**
+   * Formats an Operator onto query, following rules for specific characters
+   */
+  private formatOperator(token: Token) {
+    // special operator
+    if (token.value === ',') {
+      this.formatComma(token);
+      return;
+    } else if (token.value === ':') {
+      this.layout.add(WS.NO_SPACE, this.show(token), WS.SPACE);
+      return;
+    } else if (token.value === '.') {
+      this.layout.add(WS.NO_SPACE, this.show(token));
+      return;
+    }
+
+    // other operators
+    if (this.cfg.denseOperators) {
+      this.layout.add(WS.NO_SPACE, this.show(token));
+    } else {
+      this.layout.add(this.show(token), WS.SPACE);
+    }
+  }
+
+  /**
+   * Formats a Logical Operator onto query, joining boolean conditions
+   */
+  private formatLogicalOperator(token: Token) {
+    if (this.cfg.logicalOperatorNewline === 'before') {
+      if (isTabularStyle(this.cfg)) {
+        // In tabular style AND/OR is placed on the same level as clauses
+        this.layout.indentation.decreaseTopLevel();
+        this.layout.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.SPACE);
+        this.layout.indentation.increaseTopLevel();
+      } else {
+        this.layout.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.SPACE);
+      }
+    } else {
+      this.layout.add(this.show(token), WS.NEWLINE, WS.INDENT);
+    }
+  }
+
+  private formatCaseStart(token: Token) {
+    this.layout.indentation.increaseBlockLevel();
+    this.layout.add(this.show(token), WS.NEWLINE, WS.INDENT);
+  }
+
+  private formatCaseEnd(token: Token) {
+    this.formatMultilineBlockEnd(token);
+  }
+
+  private formatMultilineBlockEnd(token: Token) {
+    this.layout.indentation.decreaseBlockLevel();
+
+    this.layout.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.SPACE);
+  }
+
+  /**
+   * Formats a parameter placeholder item onto query, to be replaced with the value of the placeholder
+   */
+  private formatParameter(token: Token) {
+    this.layout.add(this.params.get(token), WS.SPACE);
+  }
+
+  /**
+   * Formats a comma Operator onto query, ending line unless in an Inline Block
+   */
+  private formatComma(token: Token) {
+    if (!this.inline) {
+      this.layout.add(WS.NO_SPACE, this.show(token), WS.NEWLINE, WS.INDENT);
+    } else {
+      this.layout.add(WS.NO_SPACE, this.show(token), WS.SPACE);
+    }
+  }
+
+  private show(token: Token): string {
+    if (isTabularToken(token)) {
+      return toTabularFormat(this.showToken(token), this.cfg.indentStyle);
+    } else {
+      return this.showToken(token);
+    }
+  }
+
+  // don't call this directly, always use show() instead.
+  private showToken(token: Token): string {
+    if (isReserved(token)) {
+      switch (this.cfg.keywordCase) {
+        case 'preserve':
+          return equalizeWhitespace(token.text);
+        case 'upper':
+          return token.value;
+        case 'lower':
+          return token.value.toLowerCase();
+      }
+    } else {
+      return token.value;
+    }
+  }
+}
diff --git a/src/core/Formatter.ts b/src/core/Formatter.ts
index daf03863..0d94ad12 100644
--- a/src/core/Formatter.ts
+++ b/src/core/Formatter.ts
@@ -3,10 +3,13 @@ import Params from './Params';
 import Tokenizer from './Tokenizer';
 import formatCommaPositions from './formatCommaPositions';
 import formatAliasPositions from './formatAliasPositions';
-import Parser, { type Statement } from './Parser';
-import StatementFormatter from './StatementFormatter';
+import Parser from './Parser';
+import ExpressionFormatter from './ExpressionFormatter';
 import { indentString } from './config';
 import AliasAs from './AliasAs';
+import { Statement } from './ast';
+import Layout, { WS } from './Layout';
+import Indentation from './Indentation';
 
 /** Main formatter class that produces a final output string from list of tokens */
 export default class Formatter {
@@ -53,10 +56,27 @@ export default class Formatter {
 
   private formatAst(statements: Statement[]): string {
     return statements
-      .map(stat => new StatementFormatter(this.cfg, this.params).format(stat))
+      .map(stat => this.formatStatement(stat))
       .join('\n'.repeat(this.cfg.linesBetweenQueries + 1));
   }
 
+  private formatStatement(statement: Statement): string {
+    const layout = new ExpressionFormatter({
+      cfg: this.cfg,
+      params: this.params,
+      layout: new Layout(new Indentation(indentString(this.cfg))),
+    }).format(statement.children);
+
+    if (!statement.hasSemicolon) {
+      // do nothing
+    } else if (this.cfg.newlineBeforeSemicolon) {
+      layout.add(WS.NEWLINE, ';');
+    } else {
+      layout.add(WS.NO_SPACE, ';');
+    }
+    return layout.toString();
+  }
+
   private postFormat(query: string): string {
     if (this.cfg.tabulateAlias) {
       query = formatAliasPositions(query);
diff --git a/src/core/InlineBlock.ts b/src/core/InlineBlock.ts
index 52c027db..adfd3a64 100644
--- a/src/core/InlineBlock.ts
+++ b/src/core/InlineBlock.ts
@@ -1,3 +1,5 @@
+import { sum } from 'src/utils';
+import { BetweenPredicate, NodeType, Parenthesis } from './ast';
 import { isToken, type Token, TokenType } from './token';
 
 /**
@@ -8,85 +10,68 @@ import { isToken, type Token, TokenType } from './token';
  * expressions where open-parenthesis causes newline and increase of indentation.
  */
 export default class InlineBlock {
-  level: number;
-  expressionWidth: number;
-
-  constructor(expressionWidth: number) {
-    this.level = 0;
-    this.expressionWidth = expressionWidth;
-  }
-
-  /**
-   * Begins inline block when lookahead through upcoming tokens determines
-   * that the block would be smaller than INLINE_MAX_LENGTH.
-   * @param  {Token[]} tokens Array of all tokens
-   * @param  {Number} index Current token position
-   */
-  beginIfPossible(tokens: Token[], index: number) {
-    if (this.level === 0 && this.isInlineBlock(tokens, index)) {
-      this.level = 1;
-    } else if (this.level > 0) {
-      this.level++;
-    } else {
-      this.level = 0;
-    }
-  }
-
-  /**
-   * Finishes current inline block.
-   * There might be several nested ones.
-   */
-  end() {
-    this.level--;
-  }
-
-  /**
-   * True when inside an inline block
-   */
-  isActive(): boolean {
-    return this.level > 0;
-  }
+  constructor(private expressionWidth: number) {}
 
   /**
    * Check if this should be an inline parentheses block
    * Examples are "NOW()", "COUNT(*)", "int(10)", key(`somecolumn`), DECIMAL(7,2)
    */
-  isInlineBlock(tokens: Token[], index: number): boolean {
-    let length = 0;
-    let level = 0;
+  public isInlineBlock(parenthesis: Parenthesis): boolean {
+    return this.inlineWidth(parenthesis) <= this.expressionWidth;
+  }
 
-    for (let i = index; i < tokens.length; i++) {
-      const token = tokens[i];
-      length += token.value.length;
+  private inlineWidth(parenthesis: Parenthesis): number {
+    let length = 2; // two parenthesis
 
-      if (this.isForbiddenToken(token)) {
-        return false;
+    for (const node of parenthesis.children) {
+      switch (node.type) {
+        case NodeType.function_call:
+          length += node.nameToken.value.length + this.inlineWidth(node.parenthesis);
+          break;
+        case NodeType.array_subscript:
+          length += node.arrayToken.value.length + this.inlineWidth(node.parenthesis);
+          break;
+        case NodeType.parenthesis:
+          length += this.inlineWidth(node);
+          break;
+        case NodeType.between_predicate:
+          length += this.betweenWidth(node);
+          break;
+        case NodeType.clause:
+        case NodeType.limit_clause:
+        case NodeType.binary_clause:
+          return Infinity;
+        case NodeType.all_columns_asterisk:
+          length += 1;
+          break;
+        case NodeType.token:
+          length += node.token.value.length;
+          if (this.isForbiddenToken(node.token)) {
+            return Infinity;
+          }
+          break;
       }
 
       // Overran max length
       if (length > this.expressionWidth) {
-        return false;
-      }
-
-      if (token.type === TokenType.OPEN_PAREN) {
-        level++;
-      } else if (token.type === TokenType.CLOSE_PAREN) {
-        level--;
-        if (level === 0) {
-          return true;
-        }
+        return length;
       }
     }
-    return false;
+    return length;
+  }
+
+  private betweenWidth(node: BetweenPredicate): number {
+    return sum(
+      [node.betweenToken, node.expr1, node.andToken, node.expr2].map(token => token.value.length)
+    );
   }
 
   // Reserved words that cause newlines, comments and semicolons
   // are not allowed inside inline parentheses block
-  isForbiddenToken(token: Token) {
+  private isForbiddenToken(token: Token) {
     return (
-      token.type === TokenType.RESERVED_COMMAND ||
       token.type === TokenType.RESERVED_LOGICAL_OPERATOR ||
-      // token.type === TokenType.LINE_COMMENT ||
+      token.type === TokenType.LINE_COMMENT ||
       token.type === TokenType.BLOCK_COMMENT ||
       token.value === ';' ||
       isToken.CASE(token) // CASE cannot have inline blocks
diff --git a/src/core/WhitespaceBuilder.ts b/src/core/Layout.ts
similarity index 52%
rename from src/core/WhitespaceBuilder.ts
rename to src/core/Layout.ts
index c19bf120..a364912e 100644
--- a/src/core/WhitespaceBuilder.ts
+++ b/src/core/Layout.ts
@@ -4,14 +4,15 @@ import Indentation from './Indentation';
 
 /** Whitespace modifiers to be used with add() method */
 export enum WS {
-  SPACE = 1, // Adds single space
-  NO_SPACE = 2, // Removes preceding spaces (if any)
-  NEWLINE = 3, // Adds single newline
-  NO_NEWLINE = 4, // Removes all preceding whitespace (including newlines)
-  INDENT = 5, // Adds indentation (as much as needed for current indentation level)
-  SINGLE_INDENT = 6, // Adds whitespace for single indentation step
+  SPACE, // Adds single space
+  NO_SPACE, // Removes preceding horizontal whitespace (if any)
+  NEWLINE, // Adds single newline (and removes any preceding whitespace)
+  INDENT, // Adds indentation (as much as needed for current indentation level)
+  SINGLE_INDENT, // Adds whitespace for single indentation step
 }
 
+export type LayoutItem = WS.SPACE | WS.SINGLE_INDENT | WS.NEWLINE | string;
+
 /**
  * API for constructing SQL string (especially the whitespace part).
  *
@@ -19,10 +20,10 @@ export enum WS {
  * Originally it used plain string concatenation, which was expensive.
  * Now it's storing items to array and builds the string only in the end.
  */
-export default class WhitespaceBuilder {
-  private query: (WS.SPACE | WS.SINGLE_INDENT | WS.NEWLINE | string)[] = [];
+export default class Layout {
+  private items: LayoutItem[] = [];
 
-  constructor(private indentation: Indentation) {}
+  constructor(public indentation: Indentation) {}
 
   /**
    * Appends token strings and whitespace modifications to SQL string.
@@ -31,7 +32,7 @@ export default class WhitespaceBuilder {
     for (const item of items) {
       switch (item) {
         case WS.SPACE:
-          this.query.push(WS.SPACE);
+          this.items.push(WS.SPACE);
           break;
         case WS.NO_SPACE:
           this.trimHorizontalWhitespace();
@@ -40,38 +41,33 @@ export default class WhitespaceBuilder {
           this.trimHorizontalWhitespace();
           this.addNewline();
           break;
-        case WS.NO_NEWLINE:
-          this.trimAllWhitespace();
-          break;
         case WS.INDENT:
-          for (let i = 0; i < this.indentation.getLevel(); i++) {
-            this.query.push(WS.SINGLE_INDENT);
-          }
+          this.addIndentation();
           break;
         case WS.SINGLE_INDENT:
-          this.query.push(WS.SINGLE_INDENT);
+          this.items.push(WS.SINGLE_INDENT);
           break;
         default:
-          this.query.push(item);
+          this.items.push(item);
       }
     }
   }
 
   private trimHorizontalWhitespace() {
-    while (isHorizontalWhitespace(last(this.query))) {
-      this.query.pop();
+    while (isHorizontalWhitespace(last(this.items))) {
+      this.items.pop();
     }
   }
 
-  private trimAllWhitespace() {
-    while (isWhitespace(last(this.query))) {
-      this.query.pop();
+  private addNewline() {
+    if (this.items.length > 0 && last(this.items) !== WS.NEWLINE) {
+      this.items.push(WS.NEWLINE);
     }
   }
 
-  private addNewline() {
-    if (this.query.length > 0 && last(this.query) !== WS.NEWLINE) {
-      this.query.push(WS.NEWLINE);
+  private addIndentation() {
+    for (let i = 0; i < this.indentation.getLevel(); i++) {
+      this.items.push(WS.SINGLE_INDENT);
     }
   }
 
@@ -79,10 +75,10 @@ export default class WhitespaceBuilder {
    * Returns the final SQL string.
    */
   public toString(): string {
-    return this.query.map(item => this.itemToString(item)).join('');
+    return this.items.map(item => this.itemToString(item)).join('');
   }
 
-  private itemToString(item: WS | string) {
+  private itemToString(item: LayoutItem): string {
     switch (item) {
       case WS.SPACE:
         return ' ';
@@ -98,6 +94,3 @@ export default class WhitespaceBuilder {
 
 const isHorizontalWhitespace = (item: WS | string | undefined) =>
   item === WS.SPACE || item === WS.SINGLE_INDENT;
-
-const isWhitespace = (item: WS | string | undefined) =>
-  item === WS.SPACE || item === WS.SINGLE_INDENT || item === WS.NEWLINE;
diff --git a/src/core/Parser.ts b/src/core/Parser.ts
index e50dc438..f7325893 100644
--- a/src/core/Parser.ts
+++ b/src/core/Parser.ts
@@ -1,13 +1,22 @@
-import { EOF_TOKEN, type Token, TokenType } from './token';
 /* eslint-disable no-cond-assign */
-
-export type Statement = {
-  type: 'statement';
-  tokens: Token[];
-};
+import {
+  AllColumnsAsterisk,
+  ArraySubscript,
+  AstNode,
+  BetweenPredicate,
+  BinaryClause,
+  Clause,
+  FunctionCall,
+  LimitClause,
+  NodeType,
+  Parenthesis,
+  Statement,
+  TokenNode,
+} from './ast';
+import { EOF_TOKEN, type Token, TokenType, isToken } from './token';
 
 /**
- * A rudimentary parser that slices token stream into list of SQL statements.
+ * A simple parser that creates a very rudimentary syntax tree.
  */
 export default class Parser {
   private index = 0;
@@ -24,30 +33,170 @@ export default class Parser {
   }
 
   private statement(): Statement | undefined {
-    const tokens: Token[] = [];
+    const children: AstNode[] = [];
     while (true) {
       if (this.look().value === ';') {
-        tokens.push(this.next());
-        return { type: 'statement', tokens };
+        this.next();
+        return { type: NodeType.statement, children, hasSemicolon: true };
       } else if (this.look().type === TokenType.EOF) {
-        if (tokens.length > 0) {
-          return { type: 'statement', tokens };
+        if (children.length > 0) {
+          return { type: NodeType.statement, children, hasSemicolon: false };
         } else {
           return undefined;
         }
       } else {
-        tokens.push(this.next());
+        children.push(this.expression());
+      }
+    }
+  }
+
+  private expression(): AstNode {
+    return (
+      this.limitClause() ||
+      this.clause() ||
+      this.binaryClause() ||
+      this.functionCall() ||
+      this.arraySubscript() ||
+      this.parenthesis() ||
+      this.betweenPredicate() ||
+      this.allColumnsAsterisk() ||
+      this.nextTokenNode()
+    );
+  }
+
+  private clause(): Clause | undefined {
+    if (this.look().type === TokenType.RESERVED_COMMAND) {
+      const name = this.next();
+      const children: AstNode[] = [];
+      while (
+        this.look().type !== TokenType.RESERVED_COMMAND &&
+        this.look().type !== TokenType.RESERVED_BINARY_COMMAND &&
+        this.look().type !== TokenType.EOF &&
+        this.look().type !== TokenType.CLOSE_PAREN &&
+        this.look().value !== ';'
+      ) {
+        children.push(this.expression());
+      }
+      return { type: NodeType.clause, nameToken: name, children };
+    }
+    return undefined;
+  }
+
+  private binaryClause(): BinaryClause | undefined {
+    if (this.look().type === TokenType.RESERVED_BINARY_COMMAND) {
+      const name = this.next();
+      const children: AstNode[] = [];
+      while (
+        this.look().type !== TokenType.RESERVED_COMMAND &&
+        this.look().type !== TokenType.RESERVED_BINARY_COMMAND &&
+        this.look().type !== TokenType.EOF &&
+        this.look().type !== TokenType.CLOSE_PAREN &&
+        this.look().value !== ';'
+      ) {
+        children.push(this.expression());
+      }
+      return { type: NodeType.binary_clause, nameToken: name, children };
+    }
+    return undefined;
+  }
+
+  private functionCall(): FunctionCall | undefined {
+    if (
+      (this.look().type === TokenType.RESERVED_KEYWORD || this.look().type === TokenType.IDENT) &&
+      this.look(1).value === '(' &&
+      !this.look(1).whitespaceBefore
+    ) {
+      return {
+        type: NodeType.function_call,
+        nameToken: this.next(),
+        parenthesis: this.parenthesis() as Parenthesis,
+      };
+    }
+    return undefined;
+  }
+
+  private arraySubscript(): ArraySubscript | undefined {
+    if (
+      (this.look().type === TokenType.RESERVED_KEYWORD || this.look().type === TokenType.IDENT) &&
+      this.look(1).value === '['
+    ) {
+      return {
+        type: NodeType.array_subscript,
+        arrayToken: this.next(),
+        parenthesis: this.parenthesis() as Parenthesis,
+      };
+    }
+    return undefined;
+  }
+
+  private parenthesis(): Parenthesis | undefined {
+    if (this.look().type === TokenType.OPEN_PAREN) {
+      const children: AstNode[] = [];
+      const token = this.next();
+      const openParen = token.value;
+      let closeParen = '';
+      while (this.look().type !== TokenType.CLOSE_PAREN && this.look().type !== TokenType.EOF) {
+        children.push(this.expression());
+      }
+      if (this.look().type === TokenType.CLOSE_PAREN) {
+        closeParen = this.next().value;
       }
+      return { type: NodeType.parenthesis, children, openParen, closeParen };
     }
+    return undefined;
+  }
+
+  private betweenPredicate(): BetweenPredicate | undefined {
+    if (isToken.BETWEEN(this.look()) && isToken.AND(this.look(2))) {
+      return {
+        type: NodeType.between_predicate,
+        betweenToken: this.next(),
+        expr1: this.next(),
+        andToken: this.next(),
+        expr2: this.next(),
+      };
+    }
+    return undefined;
+  }
+
+  private limitClause(): LimitClause | undefined {
+    if (isToken.LIMIT(this.look()) && this.look(2).value === ',') {
+      return {
+        type: NodeType.limit_clause,
+        limitToken: this.next(),
+        offsetToken: this.next(),
+        countToken: this.next() && this.next(), // Discard comma token
+      };
+    }
+    if (isToken.LIMIT(this.look())) {
+      return {
+        type: NodeType.limit_clause,
+        limitToken: this.next(),
+        countToken: this.next(),
+      };
+    }
+    return undefined;
+  }
+
+  private allColumnsAsterisk(): AllColumnsAsterisk | undefined {
+    if (this.look().value === '*' && isToken.SELECT(this.look(-1))) {
+      this.next();
+      return { type: NodeType.all_columns_asterisk };
+    }
+    return undefined;
   }
 
   // Returns current token without advancing the pointer
-  private look(): Token {
-    return this.tokens[this.index] || EOF_TOKEN;
+  private look(ahead = 0): Token {
+    return this.tokens[this.index + ahead] || EOF_TOKEN;
   }
 
   // Returns current token and advances the pointer to next token
   private next(): Token {
     return this.tokens[this.index++] || EOF_TOKEN;
   }
+
+  private nextTokenNode(): TokenNode {
+    return { type: NodeType.token, token: this.next() };
+  }
 }
diff --git a/src/core/StatementFormatter.ts b/src/core/StatementFormatter.ts
deleted file mode 100644
index 8c54faa1..00000000
--- a/src/core/StatementFormatter.ts
+++ /dev/null
@@ -1,442 +0,0 @@
-import type { FormatOptions } from 'src/types';
-import { equalizeWhitespace } from 'src/utils';
-
-import Indentation from './Indentation';
-import InlineBlock from './InlineBlock';
-import Params from './Params';
-import { isReserved, isCommand, isToken, type Token, TokenType, EOF_TOKEN } from './token';
-import toTabularFormat from './tabularStyle';
-import { type Statement } from './Parser';
-import { indentString, isTabularStyle } from './config';
-import WhitespaceBuilder, { WS } from './WhitespaceBuilder';
-
-/** Formats single SQL statement */
-export default class StatementFormatter {
-  private cfg: FormatOptions;
-  private indentation: Indentation;
-  private inlineBlock: InlineBlock;
-  private params: Params;
-  private query: WhitespaceBuilder;
-
-  private currentNewline = true;
-  private previousReservedToken: Token = EOF_TOKEN;
-  private previousCommandToken: Token = EOF_TOKEN;
-  private tokens: Token[] = [];
-  private index = -1;
-
-  constructor(cfg: FormatOptions, params: Params) {
-    this.cfg = cfg;
-    this.indentation = new Indentation(indentString(cfg));
-    this.inlineBlock = new InlineBlock(this.cfg.expressionWidth);
-    this.params = params;
-    this.query = new WhitespaceBuilder(this.indentation);
-  }
-
-  public format(statement: Statement): string {
-    this.tokens = statement.tokens;
-
-    for (this.index = 0; this.index < this.tokens.length; this.index++) {
-      const token = this.tokens[this.index];
-
-      // if token is a Reserved Keyword, Command, Binary Command, Dependent Clause, Logical Operator, CASE, END
-      if (isReserved(token)) {
-        this.previousReservedToken = token;
-        if (token.type === TokenType.RESERVED_COMMAND) {
-          this.previousCommandToken = token;
-        }
-      }
-
-      this.formatToken(token);
-    }
-    return this.query.toString();
-  }
-
-  private formatToken(token: Token): void {
-    switch (token.type) {
-      case TokenType.LINE_COMMENT:
-        return this.formatLineComment(token);
-      case TokenType.BLOCK_COMMENT:
-        return this.formatBlockComment(token);
-      case TokenType.RESERVED_COMMAND:
-        this.currentNewline = this.checkNewline(token);
-        return this.formatCommand(token);
-      case TokenType.RESERVED_BINARY_COMMAND:
-        return this.formatBinaryCommand(token);
-      case TokenType.RESERVED_DEPENDENT_CLAUSE:
-        return this.formatDependentClause(token);
-      case TokenType.RESERVED_JOIN_CONDITION:
-        return this.formatJoinCondition(token);
-      case TokenType.RESERVED_LOGICAL_OPERATOR:
-        return this.formatLogicalOperator(token);
-      case TokenType.RESERVED_KEYWORD:
-        return this.formatKeyword(token);
-      case TokenType.OPEN_PAREN:
-        return this.formatOpenParen(token);
-      case TokenType.CLOSE_PAREN:
-        return this.formatCloseParen(token);
-      case TokenType.RESERVED_CASE_START:
-        return this.formatCaseStart(token);
-      case TokenType.RESERVED_CASE_END:
-        return this.formatCaseEnd(token);
-      case TokenType.PARAMETER:
-        return this.formatParameter(token);
-      case TokenType.OPERATOR:
-        return this.formatOperator(token);
-      case TokenType.IDENT:
-      case TokenType.STRING:
-      case TokenType.NUMBER:
-      case TokenType.VARIABLE:
-        return this.formatWord(token);
-      default:
-        throw new Error(`Unexpected token type: ${token.type}`);
-    }
-  }
-
-  /**
-   * Formats ident/string/number/variable tokens
-   */
-  private formatWord(token: Token) {
-    this.query.add(this.show(token), WS.SPACE);
-  }
-
-  /**
-   * Checks if a newline should currently be inserted
-   */
-  private checkNewline(token: Token): boolean {
-    const nextTokens = this.tokensUntilNextCommandOrQueryEnd();
-
-    // auto break if SELECT includes CASE statements
-    if (this.isWithinSelect() && nextTokens.some(isToken.CASE)) {
-      return true;
-    }
-
-    switch (this.cfg.multilineLists) {
-      case 'always':
-        return true;
-      case 'avoid':
-        return false;
-      case 'expressionWidth':
-        return this.inlineWidth(token, nextTokens) > this.cfg.expressionWidth;
-      default: // multilineLists mode is a number
-        return (
-          this.countClauses(nextTokens) > this.cfg.multilineLists ||
-          this.inlineWidth(token, nextTokens) > this.cfg.expressionWidth
-        );
-    }
-  }
-
-  private inlineWidth(token: Token, tokens: Token[]): number {
-    const tokensString = tokens.map(({ value }) => (value === ',' ? value + ' ' : value)).join('');
-    return `${token.whitespaceBefore}${token.value} ${tokensString}`.length;
-  }
-
-  /**
-   * Counts comma-separated clauses (doesn't count commas inside blocks)
-   * Note: There's always at least one clause.
-   */
-  private countClauses(tokens: Token[]): number {
-    let count = 1;
-    let openBlocks = 0;
-    for (const { type, value } of tokens) {
-      if (value === ',' && openBlocks === 0) {
-        count++;
-      }
-      if (type === TokenType.OPEN_PAREN) {
-        openBlocks++;
-      }
-      if (type === TokenType.CLOSE_PAREN) {
-        openBlocks--;
-      }
-    }
-    return count;
-  }
-
-  /** get all tokens between current token and next Reserved Command or query end */
-  private tokensUntilNextCommandOrQueryEnd(): Token[] {
-    const tail = this.tokens.slice(this.index + 1);
-    return tail.slice(
-      0,
-      tail.length ? tail.findIndex(token => isCommand(token) || token.value === ';') : undefined
-    );
-  }
-
-  /** Formats a line comment onto query */
-  private formatLineComment(token: Token) {
-    this.query.add(this.show(token), WS.NEWLINE, WS.INDENT);
-  }
-
-  /** Formats a block comment onto query */
-  private formatBlockComment(token: Token) {
-    this.query.add(WS.NEWLINE, WS.INDENT, this.indentComment(token.value), WS.NEWLINE, WS.INDENT);
-  }
-
-  /** Aligns comment to current indentation level */
-  private indentComment(comment: string): string {
-    return comment.replace(/\n[ \t]*/gu, '\n' + this.indentation.getIndent() + ' ');
-  }
-
-  /**
-   * Formats a Reserved Command onto query, increasing indentation level where necessary
-   */
-  private formatCommand(token: Token) {
-    this.indentation.decreaseTopLevel();
-
-    this.query.add(WS.NEWLINE, WS.INDENT);
-
-    // indent tabular formats, except when preceding a (
-    if (isTabularStyle(this.cfg)) {
-      if (this.tokenLookAhead().value !== '(') {
-        this.indentation.increaseTopLevel();
-      }
-    } else {
-      this.indentation.increaseTopLevel();
-    }
-
-    if (this.currentNewline && !isTabularStyle(this.cfg)) {
-      this.query.add(this.show(token), WS.NEWLINE, WS.INDENT);
-    } else {
-      this.query.add(this.show(token), WS.SPACE);
-    }
-  }
-
-  /**
-   * Formats a Reserved Binary Command onto query, joining neighbouring tokens
-   */
-  private formatBinaryCommand(token: Token) {
-    const isJoin = /JOIN/i.test(token.value); // check if token contains JOIN
-    if (!isJoin || isTabularStyle(this.cfg)) {
-      // decrease for boolean set operators or in tabular mode
-      this.indentation.decreaseTopLevel();
-    }
-    if (isJoin) {
-      this.query.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.SPACE);
-    } else {
-      this.query.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.NEWLINE, WS.INDENT);
-    }
-  }
-
-  /**
-   * Formats a Reserved Keyword onto query
-   */
-  private formatKeyword(token: Token) {
-    this.query.add(this.show(token), WS.SPACE);
-  }
-
-  /**
-   * Formats a Reserved Dependent Clause token onto query, supporting the keyword that precedes it
-   */
-  private formatDependentClause(token: Token) {
-    this.query.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.SPACE);
-  }
-
-  // Formats ON and USING keywords
-  private formatJoinCondition(token: Token) {
-    this.query.add(this.show(token), WS.SPACE);
-  }
-
-  /**
-   * Formats an Operator onto query, following rules for specific characters
-   */
-  private formatOperator(token: Token) {
-    // special operator
-    if (token.value === ',') {
-      this.formatComma(token);
-      return;
-    } else if (token.value === ';') {
-      this.formatQuerySeparator(token);
-      return;
-    } else if (['$', '['].includes(token.value)) {
-      this.query.add(this.show(token));
-      return;
-    } else if ([':', ']'].includes(token.value)) {
-      this.query.add(WS.NO_SPACE, this.show(token), WS.SPACE);
-      return;
-    } else if (['.', '{', '}', '`'].includes(token.value)) {
-      this.query.add(WS.NO_SPACE, this.show(token));
-      return;
-    }
-
-    // other operators
-    // in dense operators mode do not trim whitespace if SELECT *
-    if (this.cfg.denseOperators && this.tokenLookBehind().type !== TokenType.RESERVED_COMMAND) {
-      this.query.add(WS.NO_SPACE, this.show(token));
-    } else {
-      this.query.add(this.show(token), WS.SPACE);
-    }
-  }
-
-  /**
-   * Formats a Logical Operator onto query, joining boolean conditions
-   */
-  private formatLogicalOperator(token: Token) {
-    // ignore AND when BETWEEN x [AND] y
-    if (isToken.AND(token) && isToken.BETWEEN(this.tokenLookBehind(2))) {
-      this.query.add(this.show(token), WS.SPACE);
-      return;
-    }
-
-    if (isTabularStyle(this.cfg)) {
-      this.indentation.decreaseTopLevel();
-    }
-
-    if (this.cfg.logicalOperatorNewline === 'before') {
-      if (this.currentNewline) {
-        this.query.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.SPACE);
-      } else {
-        this.query.add(this.show(token), WS.SPACE);
-      }
-    } else {
-      // eslint-disable-next-line no-lonely-if
-      if (this.currentNewline) {
-        this.query.add(this.show(token), WS.NEWLINE, WS.INDENT);
-      } else {
-        this.query.add(this.show(token));
-      }
-    }
-  }
-
-  private formatOpenParen(token: Token) {
-    // Take out the preceding space unless there was whitespace there in the original query
-    // or another opening parens or line comment
-    const preserveWhitespaceFor = [
-      TokenType.OPEN_PAREN,
-      TokenType.LINE_COMMENT,
-      TokenType.OPERATOR,
-    ];
-    if (
-      token.whitespaceBefore?.length === 0 &&
-      !preserveWhitespaceFor.includes(this.tokenLookBehind().type)
-    ) {
-      this.query.add(WS.NO_SPACE, this.show(token));
-    } else if (!this.cfg.newlineBeforeOpenParen) {
-      this.query.add(WS.NO_NEWLINE, WS.SPACE, this.show(token));
-    } else {
-      this.query.add(this.show(token));
-    }
-    this.inlineBlock.beginIfPossible(this.tokens, this.index);
-
-    if (!this.inlineBlock.isActive()) {
-      this.indentation.increaseBlockLevel();
-      this.query.add(WS.NEWLINE, WS.INDENT);
-    }
-  }
-
-  private formatCloseParen(token: Token) {
-    if (this.inlineBlock.isActive()) {
-      this.inlineBlock.end();
-      this.query.add(WS.NO_SPACE, this.show(token), WS.SPACE);
-    } else {
-      this.formatMultilineBlockEnd(token);
-    }
-  }
-
-  private formatCaseStart(token: Token) {
-    this.indentation.increaseBlockLevel();
-    if (this.cfg.multilineLists === 'always') {
-      this.query.add(this.show(token), WS.NEWLINE, WS.INDENT);
-    } else {
-      this.query.add(this.show(token), WS.SPACE);
-    }
-  }
-
-  private formatCaseEnd(token: Token) {
-    this.formatMultilineBlockEnd(token);
-  }
-
-  private formatMultilineBlockEnd(token: Token) {
-    this.indentation.decreaseBlockLevel();
-
-    if (isTabularStyle(this.cfg)) {
-      // +1 extra indentation step for the closing paren
-      this.query.add(WS.NEWLINE, WS.INDENT, WS.SINGLE_INDENT, this.show(token), WS.SPACE);
-    } else if (this.cfg.newlineBeforeCloseParen) {
-      this.query.add(WS.NEWLINE, WS.INDENT, this.show(token), WS.SPACE);
-    } else {
-      this.query.add(WS.NO_NEWLINE, WS.SPACE, this.show(token), WS.SPACE);
-    }
-  }
-
-  /**
-   * Formats a parameter placeholder item onto query, to be replaced with the value of the placeholder
-   */
-  private formatParameter(token: Token) {
-    this.query.add(this.params.get(token), WS.SPACE);
-  }
-
-  /**
-   * Formats a comma Operator onto query, ending line unless in an Inline Block
-   */
-  private formatComma(token: Token) {
-    if (
-      !this.inlineBlock.isActive() &&
-      !isToken.LIMIT(this.getPreviousReservedToken()) &&
-      this.currentNewline
-    ) {
-      this.query.add(WS.NO_SPACE, this.show(token), WS.NEWLINE, WS.INDENT);
-    } else {
-      this.query.add(WS.NO_SPACE, this.show(token), WS.SPACE);
-    }
-  }
-
-  private formatQuerySeparator(token: Token) {
-    if (this.cfg.newlineBeforeSemicolon) {
-      this.query.add(WS.NEWLINE, this.show(token));
-    } else {
-      this.query.add(WS.NO_SPACE, this.show(token));
-    }
-  }
-
-  private show(token: Token): string {
-    if (this.isTabularToken(token)) {
-      return toTabularFormat(this.showToken(token), this.cfg.indentStyle);
-    } else {
-      return this.showToken(token);
-    }
-  }
-
-  // These token types can be formatted in tabular style
-  private isTabularToken(token: Token): boolean {
-    return (
-      token.type === TokenType.RESERVED_LOGICAL_OPERATOR ||
-      token.type === TokenType.RESERVED_DEPENDENT_CLAUSE ||
-      token.type === TokenType.RESERVED_COMMAND ||
-      token.type === TokenType.RESERVED_BINARY_COMMAND
-    );
-  }
-
-  // don't call this directly, always use show() instead.
-  private showToken(token: Token): string {
-    if (isReserved(token)) {
-      switch (this.cfg.keywordCase) {
-        case 'preserve':
-          return equalizeWhitespace(token.text);
-        case 'upper':
-          return token.value;
-        case 'lower':
-          return token.value.toLowerCase();
-      }
-    } else {
-      return token.value;
-    }
-  }
-
-  /** Returns the latest encountered reserved keyword token */
-  private getPreviousReservedToken(): Token {
-    return this.previousReservedToken;
-  }
-
-  /** True when currently within SELECT command */
-  private isWithinSelect(): boolean {
-    return isToken.SELECT(this.previousCommandToken);
-  }
-
-  /** Fetches nth previous token from the token stream */
-  private tokenLookBehind(n = 1): Token {
-    return this.tokens[this.index - n] || EOF_TOKEN;
-  }
-
-  /** Fetches nth next token from the token stream */
-  private tokenLookAhead(n = 1): Token {
-    return this.tokens[this.index + n] || EOF_TOKEN;
-  }
-}
diff --git a/src/core/Tokenizer.ts b/src/core/Tokenizer.ts
index d0637278..9b2b69b4 100644
--- a/src/core/Tokenizer.ts
+++ b/src/core/Tokenizer.ts
@@ -29,8 +29,10 @@ interface TokenizerOptions {
   // Keywords in CASE expressions that begin new line, like: WHEN, ELSE
   reservedDependentClauses: string[];
   // Keywords that create newline but no indentaion of their body.
-  // These contain set operations like UNION and various joins like LEFT OUTER JOIN
+  // These contain set operations like UNION and INTERSECT
   reservedBinaryCommands: string[];
+  // Various joins like LEFT OUTER JOIN
+  reservedJoins: string[];
   // keywords used for JOIN conditions, defaults to: [ON, USING]
   reservedJoinConditions?: string[];
   // all other reserved words (not included to any of the above lists)
@@ -120,6 +122,10 @@ export default class Tokenizer {
         cfg.reservedBinaryCommands,
         cfg.identChars
       ),
+      [TokenType.RESERVED_JOIN]: regexFactory.createReservedWordRegex(
+        cfg.reservedJoins,
+        cfg.identChars
+      ),
       [TokenType.RESERVED_JOIN_CONDITION]: regexFactory.createReservedWordRegex(
         cfg.reservedJoinConditions ?? ['ON', 'USING'],
         cfg.identChars
@@ -279,6 +285,7 @@ export default class Tokenizer {
       this.matchReservedToken(TokenType.RESERVED_CASE_END) ||
       this.matchReservedToken(TokenType.RESERVED_COMMAND) ||
       this.matchReservedToken(TokenType.RESERVED_BINARY_COMMAND) ||
+      this.matchReservedToken(TokenType.RESERVED_JOIN) ||
       this.matchReservedToken(TokenType.RESERVED_DEPENDENT_CLAUSE) ||
       this.matchReservedToken(TokenType.RESERVED_LOGICAL_OPERATOR) ||
       this.matchReservedToken(TokenType.RESERVED_KEYWORD) ||
diff --git a/src/core/ast.ts b/src/core/ast.ts
new file mode 100644
index 00000000..4a542fc3
--- /dev/null
+++ b/src/core/ast.ts
@@ -0,0 +1,94 @@
+import { Token } from './token';
+
+export enum NodeType {
+  statement = 'statement',
+  clause = 'clause',
+  binary_clause = 'binary_clause',
+  function_call = 'function_call',
+  array_subscript = 'array_subscript',
+  parenthesis = 'parenthesis',
+  between_predicate = 'between_predicate',
+  limit_clause = 'limit_clause',
+  all_columns_asterisk = 'all_columns_asterisk',
+  token = 'token',
+}
+
+export type Statement = {
+  type: NodeType.statement;
+  children: AstNode[];
+  hasSemicolon: boolean;
+};
+
+export type Clause = {
+  type: NodeType.clause;
+  nameToken: Token;
+  children: AstNode[];
+};
+
+export type BinaryClause = {
+  type: NodeType.binary_clause;
+  nameToken: Token;
+  children: AstNode[];
+};
+
+// Wrapper for plain nodes inside AST
+export type TokenNode = {
+  type: NodeType.token;
+  token: Token;
+};
+
+export type FunctionCall = {
+  type: NodeType.function_call;
+  nameToken: Token;
+  parenthesis: Parenthesis;
+};
+
+// <ident>[<expr>]
+export type ArraySubscript = {
+  type: NodeType.array_subscript;
+  arrayToken: Token;
+  parenthesis: Parenthesis;
+};
+
+export type Parenthesis = {
+  type: NodeType.parenthesis;
+  children: AstNode[];
+  openParen: string;
+  closeParen: string;
+};
+
+// BETWEEN <expr1> AND <expr2>
+export type BetweenPredicate = {
+  type: NodeType.between_predicate;
+  betweenToken: Token;
+  expr1: Token;
+  andToken: Token;
+  expr2: Token;
+};
+
+// LIMIT <count>
+// LIMIT <offset>, <count>
+export type LimitClause = {
+  type: NodeType.limit_clause;
+  limitToken: Token;
+  countToken: Token;
+  offsetToken?: Token;
+};
+
+// The "*" operator used in SELECT *
+export type AllColumnsAsterisk = {
+  type: NodeType.all_columns_asterisk;
+};
+
+export type AstNode =
+  | Clause
+  | BinaryClause
+  | FunctionCall
+  | ArraySubscript
+  | Parenthesis
+  | BetweenPredicate
+  | LimitClause
+  | AllColumnsAsterisk
+  | TokenNode;
+
+export const isTokenNode = (node: AstNode): node is TokenNode => node.type === 'token';
diff --git a/src/core/tabularStyle.ts b/src/core/tabularStyle.ts
index b0057382..230fb9d5 100644
--- a/src/core/tabularStyle.ts
+++ b/src/core/tabularStyle.ts
@@ -1,4 +1,5 @@
 import type { IndentStyle } from 'src/types';
+import { Token, TokenType } from './token';
 
 /**
  * When tabular style enabled,
@@ -23,3 +24,16 @@ export default function toTabularFormat(tokenText: string, indentStyle: IndentSt
 
   return tokenText + ['', ...tail].join(' ');
 }
+
+/**
+ * True when the token can be formatted in tabular style
+ */
+export function isTabularToken(token: Token): boolean {
+  return (
+    token.type === TokenType.RESERVED_LOGICAL_OPERATOR ||
+    token.type === TokenType.RESERVED_DEPENDENT_CLAUSE ||
+    token.type === TokenType.RESERVED_COMMAND ||
+    token.type === TokenType.RESERVED_BINARY_COMMAND ||
+    token.type === TokenType.RESERVED_JOIN
+  );
+}
diff --git a/src/core/token.ts b/src/core/token.ts
index e75b5cfa..6458dcbf 100644
--- a/src/core/token.ts
+++ b/src/core/token.ts
@@ -8,6 +8,7 @@ export enum TokenType {
   RESERVED_DEPENDENT_CLAUSE = 'RESERVED_DEPENDENT_CLAUSE',
   RESERVED_BINARY_COMMAND = 'RESERVED_BINARY_COMMAND',
   RESERVED_COMMAND = 'RESERVED_COMMAND',
+  RESERVED_JOIN = 'RESERVED_JOIN',
   RESERVED_JOIN_CONDITION = 'RESERVED_JOIN_CONDITION',
   RESERVED_CASE_START = 'RESERVED_CASE_START',
   RESERVED_CASE_END = 'RESERVED_CASE_END',
@@ -46,6 +47,7 @@ export const testToken =
 export const isToken = {
   AS: testToken({ value: 'AS', type: TokenType.RESERVED_KEYWORD }),
   AND: testToken({ value: 'AND', type: TokenType.RESERVED_LOGICAL_OPERATOR }),
+  ARRAY: testToken({ value: 'ARRAY', type: TokenType.RESERVED_KEYWORD }),
   BETWEEN: testToken({ value: 'BETWEEN', type: TokenType.RESERVED_KEYWORD }),
   CASE: testToken({ value: 'CASE', type: TokenType.RESERVED_CASE_START }),
   CAST: testToken({ value: 'CAST', type: TokenType.RESERVED_KEYWORD }),
@@ -55,6 +57,7 @@ export const isToken = {
   LIMIT: testToken({ value: 'LIMIT', type: TokenType.RESERVED_COMMAND }),
   SELECT: testToken({ value: 'SELECT', type: TokenType.RESERVED_COMMAND }),
   SET: testToken({ value: 'SET', type: TokenType.RESERVED_COMMAND }),
+  STRUCT: testToken({ value: 'STRUCT', type: TokenType.RESERVED_KEYWORD }),
   TABLE: testToken({ value: 'TABLE', type: TokenType.RESERVED_KEYWORD }),
   WINDOW: testToken({ value: 'WINDOW', type: TokenType.RESERVED_COMMAND }),
   WITH: testToken({ value: 'WITH', type: TokenType.RESERVED_COMMAND }),
@@ -72,5 +75,6 @@ export const isReserved = (token: Token): boolean =>
   token.type === TokenType.RESERVED_JOIN_CONDITION ||
   token.type === TokenType.RESERVED_COMMAND ||
   token.type === TokenType.RESERVED_BINARY_COMMAND ||
+  token.type === TokenType.RESERVED_JOIN ||
   token.type === TokenType.RESERVED_CASE_START ||
   token.type === TokenType.RESERVED_CASE_END;
diff --git a/src/languages/bigquery.formatter.ts b/src/languages/bigquery.formatter.ts
index 036cbe67..a170958b 100644
--- a/src/languages/bigquery.formatter.ts
+++ b/src/languages/bigquery.formatter.ts
@@ -1,6 +1,6 @@
 import Formatter from 'src/core/Formatter';
 import Tokenizer from 'src/core/Tokenizer';
-import { EOF_TOKEN, TokenType, type Token } from 'src/core/token';
+import { EOF_TOKEN, isToken, TokenType, type Token } from 'src/core/token';
 import { dedupe } from 'src/utils';
 
 /**
@@ -797,13 +797,7 @@ const reservedCommands = [
   'EXPORT DATA',
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
@@ -813,7 +807,9 @@ const reservedBinaryCommands = [
   'EXCEPT',
   'EXCEPT ALL',
   'EXCEPT DISTINCT',
-  // joins
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -841,6 +837,7 @@ export default class BigQueryFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedKeywords: dedupe([
         ...Object.values(reservedFunctions).flat(),
@@ -894,7 +891,7 @@ function combineParameterizedTypes(tokens: Token[]) {
     const token = tokens[i];
     const nextToken = tokens[i + 1] || EOF_TOKEN;
 
-    if ((token.value === 'ARRAY' || token.value === 'STRUCT') && nextToken.value === '<') {
+    if ((isToken.ARRAY(token) || isToken.STRUCT(token)) && nextToken.value === '<') {
       const endIndex = findClosingAngleBracketIndex(tokens, i + 1);
       const typeDefTokens = tokens.slice(i, endIndex + 1);
       processed.push({
diff --git a/src/languages/db2.formatter.ts b/src/languages/db2.formatter.ts
index fd62ce47..5dd49b55 100644
--- a/src/languages/db2.formatter.ts
+++ b/src/languages/db2.formatter.ts
@@ -820,13 +820,7 @@ const reservedCommands = [
   'WITH',
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
@@ -836,7 +830,9 @@ const reservedBinaryCommands = [
   'EXCEPT',
   'EXCEPT ALL',
   'EXCEPT DISTINCT',
-  // joins
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -864,6 +860,7 @@ export default class Db2Formatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedKeywords: dedupe([
         ...Object.values(reservedFunctions).flat(),
diff --git a/src/languages/hive.formatter.ts b/src/languages/hive.formatter.ts
index 3c3715ba..7bb9978e 100644
--- a/src/languages/hive.formatter.ts
+++ b/src/languages/hive.formatter.ts
@@ -579,20 +579,16 @@ const reservedCommands = [
   'ROW FORMAT',
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
   'UNION',
   'UNION ALL',
   'UNION DISTINCT',
-  // joins
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -619,6 +615,7 @@ export default class HiveFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedKeywords: dedupe([
         ...Object.values(reservedFunctions).flat(),
diff --git a/src/languages/mariadb.formatter.ts b/src/languages/mariadb.formatter.ts
index e0749171..718e16bb 100644
--- a/src/languages/mariadb.formatter.ts
+++ b/src/languages/mariadb.formatter.ts
@@ -1110,13 +1110,7 @@ const reservedCommands = [
   'WHERE',
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
@@ -1129,7 +1123,9 @@ const reservedBinaryCommands = [
   'MINUS',
   'MINUS ALL',
   'MINUS DISTINCT',
-  // joins
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -1161,6 +1157,7 @@ export default class MariaDbFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedLogicalOperators: ['AND', 'OR', 'XOR'],
       reservedKeywords: dedupe([...reservedKeywords, ...reservedFunctions]),
diff --git a/src/languages/mysql.formatter.ts b/src/languages/mysql.formatter.ts
index bc05af51..ab9381a1 100644
--- a/src/languages/mysql.formatter.ts
+++ b/src/languages/mysql.formatter.ts
@@ -1276,13 +1276,7 @@ const reservedCommands = [
   'WHERE',
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
@@ -1292,7 +1286,9 @@ const reservedBinaryCommands = [
   'EXCEPT',
   'EXCEPT ALL',
   'EXCEPT DISTINCT',
-  // joins
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -1324,6 +1320,7 @@ export default class MySqlFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedLogicalOperators: ['AND', 'OR', 'XOR'],
       reservedKeywords: dedupe([...reservedKeywords, ...reservedFunctions]),
diff --git a/src/languages/n1ql.formatter.ts b/src/languages/n1ql.formatter.ts
index c1e2764f..303206bd 100644
--- a/src/languages/n1ql.formatter.ts
+++ b/src/languages/n1ql.formatter.ts
@@ -474,13 +474,7 @@ const reservedCommands = [
   'WITH',
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
@@ -493,7 +487,9 @@ const reservedBinaryCommands = [
   'MINUS',
   'MINUS ALL',
   'MINUS DISTINCT',
-  // joins
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -517,6 +513,7 @@ export default class N1qlFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedLogicalOperators: ['AND', 'OR', 'XOR'],
       reservedKeywords: dedupe([...reservedKeywords, ...reservedFunctions]),
diff --git a/src/languages/plsql.formatter.ts b/src/languages/plsql.formatter.ts
index 05b8cba6..537c5dfa 100644
--- a/src/languages/plsql.formatter.ts
+++ b/src/languages/plsql.formatter.ts
@@ -632,11 +632,6 @@ const reservedCommands = [
   'WITH',
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
   // set booleans
   'INTERSECT',
@@ -651,7 +646,12 @@ const reservedBinaryCommands = [
   'MINUS',
   'MINUS ALL',
   'MINUS DISTINCT',
-  // joins
+  // apply
+  'CROSS APPLY',
+  'OUTER APPLY',
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -662,9 +662,6 @@ const reservedBinaryCommands = [
   'FULL OUTER JOIN',
   'CROSS JOIN',
   'NATURAL JOIN',
-  // apply
-  'CROSS APPLY',
-  'OUTER APPLY',
 ];
 
 /**
@@ -691,6 +688,7 @@ export default class PlSqlFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedLogicalOperators: ['AND', 'OR', 'XOR'],
       reservedKeywords: dedupe([...reservedKeywords, ...Object.values(reservedFunctions).flat()]),
diff --git a/src/languages/postgresql.formatter.ts b/src/languages/postgresql.formatter.ts
index 61868dcc..e756e271 100644
--- a/src/languages/postgresql.formatter.ts
+++ b/src/languages/postgresql.formatter.ts
@@ -1582,13 +1582,7 @@ const reservedCommands = [
   'WITH',
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
@@ -1601,7 +1595,9 @@ const reservedBinaryCommands = [
   'MINUS',
   'MINUS ALL',
   'MINUS DISTINCT',
-  // joins
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -1676,6 +1672,7 @@ export default class PostgreSqlFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedKeywords: dedupe([...Object.values(reservedFunctions).flat(), ...reservedKeywords]),
       stringTypes: [{ quote: "''", prefixes: ['U&', 'E', 'X', 'B'] }, '$$'],
diff --git a/src/languages/redshift.formatter.ts b/src/languages/redshift.formatter.ts
index f55a529c..e6a4eb0b 100644
--- a/src/languages/redshift.formatter.ts
+++ b/src/languages/redshift.formatter.ts
@@ -677,13 +677,7 @@ const reservedCommands = [
   'SET SCHEMA', // verify
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
@@ -693,7 +687,9 @@ const reservedBinaryCommands = [
   'EXCEPT',
   'EXCEPT ALL',
   'EXCEPT DISTINCT',
-  // joins
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -721,6 +717,7 @@ export default class RedshiftFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedKeywords: dedupe([
         ...Object.values(reservedFunctions).flat(),
diff --git a/src/languages/spark.formatter.ts b/src/languages/spark.formatter.ts
index 40de2602..df4fca14 100644
--- a/src/languages/spark.formatter.ts
+++ b/src/languages/spark.formatter.ts
@@ -714,11 +714,6 @@ const reservedCommands = [
   'WINDOW', // verify
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
   // set booleans
   'INTERSECT',
@@ -733,7 +728,12 @@ const reservedBinaryCommands = [
   'MINUS',
   'MINUS ALL',
   'MINUS DISTINCT',
-  // joins
+  // apply
+  'CROSS APPLY',
+  'OUTER APPLY',
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -744,9 +744,6 @@ const reservedBinaryCommands = [
   'FULL OUTER JOIN',
   'CROSS JOIN',
   'NATURAL JOIN',
-  // apply
-  'CROSS APPLY',
-  'OUTER APPLY',
   // non-standard-joins
   'ANTI JOIN',
   'SEMI JOIN',
@@ -764,8 +761,6 @@ const reservedBinaryCommands = [
   'NATURAL RIGHT OUTER JOIN',
   'NATURAL RIGHT SEMI JOIN',
   'NATURAL SEMI JOIN',
-  'CROSS APPLY',
-  'OUTER APPLY',
 ];
 
 /**
@@ -783,6 +778,7 @@ export default class SparkFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedLogicalOperators: ['AND', 'OR', 'XOR'],
       reservedKeywords: dedupe([...Object.values(reservedFunctions).flat(), ...reservedKeywords]),
diff --git a/src/languages/sql.formatter.ts b/src/languages/sql.formatter.ts
index 6bcdf38d..0a049b7c 100644
--- a/src/languages/sql.formatter.ts
+++ b/src/languages/sql.formatter.ts
@@ -349,13 +349,7 @@ const reservedCommands = [
   'WITH',
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
@@ -365,7 +359,9 @@ const reservedBinaryCommands = [
   'EXCEPT',
   'EXCEPT ALL',
   'EXCEPT DISTINCT',
-  // joins
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -392,6 +388,7 @@ export default class SqlFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedKeywords: dedupe([...reservedKeywords, ...Object.values(reservedFunctions).flat()]),
       stringTypes: [{ quote: "''", prefixes: ['X'] }],
diff --git a/src/languages/sqlite.formatter.ts b/src/languages/sqlite.formatter.ts
index ac290ec6..4381f05e 100644
--- a/src/languages/sqlite.formatter.ts
+++ b/src/languages/sqlite.formatter.ts
@@ -237,7 +237,6 @@ const reservedCommands = [
 ];
 
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
@@ -247,7 +246,10 @@ const reservedBinaryCommands = [
   'EXCEPT',
   'EXCEPT ALL',
   'EXCEPT DISTINCT',
-  // joins - https://www.sqlite.org/syntax/join-operator.html
+];
+
+// joins - https://www.sqlite.org/syntax/join-operator.html
+const reservedJoins = [
   'JOIN',
   'LEFT JOIN',
   'LEFT OUTER JOIN',
@@ -270,6 +272,7 @@ export default class SqliteFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedKeywords: dedupe([...reservedKeywords, ...Object.values(reservedFunctions).flat()]),
       stringTypes: [{ quote: "''", prefixes: ['X'] }],
diff --git a/src/languages/tsql.formatter.ts b/src/languages/tsql.formatter.ts
index b82733c3..b16a92c6 100644
--- a/src/languages/tsql.formatter.ts
+++ b/src/languages/tsql.formatter.ts
@@ -1191,13 +1191,7 @@ const reservedCommands = [
   'WITH',
 ];
 
-/**
- * Priority 2
- * commands that operate on two tables or subqueries
- * two main categories: joins and boolean set operators
- */
 const reservedBinaryCommands = [
-  // set booleans
   'INTERSECT',
   'INTERSECT ALL',
   'INTERSECT DISTINCT',
@@ -1210,7 +1204,9 @@ const reservedBinaryCommands = [
   'MINUS',
   'MINUS ALL',
   'MINUS DISTINCT',
-  // joins
+];
+
+const reservedJoins = [
   'JOIN',
   'INNER JOIN',
   'LEFT JOIN',
@@ -1237,6 +1233,7 @@ export default class TSqlFormatter extends Formatter {
     return new Tokenizer({
       reservedCommands,
       reservedBinaryCommands,
+      reservedJoins,
       reservedDependentClauses,
       reservedKeywords: dedupe([
         ...Object.values(reservedFunctions).flat(),
diff --git a/src/sqlFormatter.ts b/src/sqlFormatter.ts
index 8e93492f..39e1f1a0 100644
--- a/src/sqlFormatter.ts
+++ b/src/sqlFormatter.ts
@@ -13,7 +13,6 @@ import SqlFormatter from 'src/languages/sql.formatter';
 import TSqlFormatter from 'src/languages/tsql.formatter';
 
 import type { FormatOptions } from './types';
-import { isNumber } from './utils';
 import { ParamItems } from './core/Params';
 
 export const formatters = {
@@ -42,13 +41,10 @@ const defaultOptions: FormatFnOptions = {
   useTabs: false,
   keywordCase: 'preserve',
   indentStyle: 'standard',
-  multilineLists: 'always',
   logicalOperatorNewline: 'before',
   aliasAs: 'preserve',
   tabulateAlias: false,
   commaPosition: 'after',
-  newlineBeforeOpenParen: true,
-  newlineBeforeCloseParen: true,
   expressionWidth: 50,
   linesBetweenQueries: 1,
   denseOperators: false,
@@ -83,8 +79,14 @@ function validateConfig(cfg: FormatFnOptions): FormatFnOptions {
     throw new ConfigError(`Unsupported SQL dialect: ${cfg.language}`);
   }
 
-  if (isNumber(cfg.multilineLists) && cfg.multilineLists <= 0) {
-    throw new ConfigError('multilineLists config must be a positive number.');
+  if ('multilineLists' in cfg) {
+    throw new ConfigError('multilineLists config is no more supported.');
+  }
+  if ('newlineBeforeOpenParen' in cfg) {
+    throw new ConfigError('newlineBeforeOpenParen config is no more supported.');
+  }
+  if ('newlineBeforeCloseParen' in cfg) {
+    throw new ConfigError('newlineBeforeCloseParen config is no more supported.');
   }
 
   if (cfg.expressionWidth <= 0) {
diff --git a/src/types.ts b/src/types.ts
index e321c586..3f8bd1e7 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -4,8 +4,6 @@ export type IndentStyle = 'standard' | 'tabularLeft' | 'tabularRight';
 
 export type KeywordCase = 'preserve' | 'upper' | 'lower';
 
-export type MultilineListsMode = 'always' | 'avoid' | 'expressionWidth';
-
 export type AliasMode = 'preserve' | 'always' | 'never' | 'select';
 
 export type CommaPosition = 'before' | 'after' | 'tabular';
@@ -17,13 +15,10 @@ export interface FormatOptions {
   useTabs: boolean;
   keywordCase: KeywordCase;
   indentStyle: IndentStyle;
-  multilineLists: MultilineListsMode | number;
   logicalOperatorNewline: LogicalOperatorNewline;
   aliasAs: AliasMode;
   tabulateAlias: boolean;
   commaPosition: CommaPosition;
-  newlineBeforeOpenParen: boolean;
-  newlineBeforeCloseParen: boolean;
   expressionWidth: number;
   linesBetweenQueries: number;
   denseOperators: boolean;
diff --git a/src/utils.ts b/src/utils.ts
index df8154d6..530a0919 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -25,3 +25,12 @@ export const equalizeWhitespace = (s: string) => s.replace(/\s+/gu, ' ');
 
 // identity function
 export const id = <T>(x: T) => x;
+
+// Adds up all values in array
+export const sum = (arr: number[]): number => {
+  let total = 0;
+  for (const x of arr) {
+    total += x;
+  }
+  return total;
+};
diff --git a/static/index.html b/static/index.html
index 4d9406de..288ae85f 100644
--- a/static/index.html
+++ b/static/index.html
@@ -87,24 +87,6 @@
               <option value="tabularRight">Tabular, Right</option>
             </select>
           </article>
-          <article class="config">
-            <label for="multilineLists">Multiline lists:</label>
-            <select id="multilineLists">
-              <option value="always">Always</option>
-              <option value="avoid">Avoid</option>
-              <option value="expressionWidth">Expression width</option>
-              <option value="itemCount">Item count</option>
-            </select>
-            <input
-              type="number"
-              id="multilineLists-itemCount"
-              name="multilineLists-itemCount"
-              min="1"
-              max="100"
-              value="1"
-              style="display: none"
-            />
-          </article>
           <article class="config">
             <label for="logicalOperatorNewline">AND/OR newlines:</label>
             <select id="logicalOperatorNewline">
@@ -133,24 +115,6 @@
               <option value="tabular">Tabular</option>
             </select>
           </article>
-          <article class="config">
-            <label for="newlineBeforeOpenParen">\n before (</label>
-            <input
-              type="checkbox"
-              id="newlineBeforeOpenParen"
-              name="newlineBeforeOpenParen"
-              checked
-            />
-          </article>
-          <article class="config">
-            <label for="newlineBeforeCloseParen">\n before )</label>
-            <input
-              type="checkbox"
-              id="newlineBeforeCloseParen"
-              name="newlineBeforeCloseParen"
-              checked
-            />
-          </article>
           <article class="config">
             <label for="expressionWidth">Expression width:</label>
             <input
diff --git a/static/index.js b/static/index.js
index 6b43c3ef..4e04b6a2 100644
--- a/static/index.js
+++ b/static/index.js
@@ -10,12 +10,8 @@ const attachFormat = () => {
   const indentStyle = document.getElementById('indentStyle');
   const logicalOperatorNewline = document.getElementById('logicalOperatorNewline');
   const aliasAs = document.getElementById('aliasAs');
-  const multilineLists = document.getElementById('multilineLists');
-  const itemCount = document.getElementById('multilineLists-itemCount');
   const tabulateAlias = document.getElementById('tabulateAlias');
   const commaPosition = document.getElementById('commaPosition');
-  const newlineBeforeOpenParen = document.getElementById('newlineBeforeOpenParen');
-  const newlineBeforeCloseParen = document.getElementById('newlineBeforeCloseParen');
   const expressionWidth = document.getElementById('expressionWidth');
   const lineBetweenQueries = document.getElementById('lineBetweenQueries');
   const denseOperators = document.getElementById('denseOperators');
@@ -34,12 +30,6 @@ const attachFormat = () => {
   }
 
   function format() {
-    if (multilineLists.options[multilineLists.selectedIndex].value === 'itemCount') {
-      itemCount.style.display = 'inline';
-    } else {
-      itemCount.style.display = 'none';
-    }
-
     try {
       const config = {
         language: language.options[language.selectedIndex].value,
@@ -50,14 +40,8 @@ const attachFormat = () => {
         logicalOperatorNewline:
           logicalOperatorNewline.options[logicalOperatorNewline.selectedIndex].value,
         aliasAs: aliasAs.options[aliasAs.selectedIndex].value,
-        multilineLists:
-          multilineLists.options[multilineLists.selectedIndex].value === 'itemCount'
-            ? itemCount.value
-            : multilineLists.options[multilineLists.selectedIndex].value,
         tabulateAlias: tabulateAlias.checked,
         commaPosition: commaPosition.options[commaPosition.selectedIndex].value,
-        newlineBeforeOpenParen: newlineBeforeOpenParen.checked,
-        newlineBeforeCloseParen: newlineBeforeCloseParen.checked,
         expressionWidth: expressionWidth.value,
         lineBetweenQueries: lineBetweenQueries.value,
         denseOperators: denseOperators.checked,
@@ -91,14 +75,10 @@ const attachFormat = () => {
     useTabs,
     keywordCase,
     indentStyle,
-    multilineLists,
-    itemCount,
     logicalOperatorNewline,
     aliasAs,
     tabulateAlias,
     commaPosition,
-    newlineBeforeOpenParen,
-    newlineBeforeCloseParen,
     expressionWidth,
     lineBetweenQueries,
     denseOperators,


Test patch:
diff --git a/test/behavesLikeSqlFormatter.ts b/test/behavesLikeSqlFormatter.ts
index 8c2c3f49..4d69a17d 100644
--- a/test/behavesLikeSqlFormatter.ts
+++ b/test/behavesLikeSqlFormatter.ts
@@ -7,11 +7,9 @@ import supportsNumbers from './features/numbers';
 import supportsTabWidth from './options/tabWidth';
 import supportsUseTabs from './options/useTabs';
 import supportsAliasAs from './options/aliasAs';
-import supportsMultilineLists from './options/multilineLists';
 import supportsExpressionWidth from './options/expressionWidth';
 import supportsKeywordCase from './options/keywordCase';
 import supportsIndentStyle from './options/indentStyle';
-import supportsNewlineBeforeParen from './options/newlineBeforeParen';
 import supportsCommaPosition from './options/commaPosition';
 import supportsLinesBetweenQueries from './options/linesBetweenQueries';
 import supportsNewlineBeforeSemicolon from './options/newlineBeforeSemicolon';
@@ -32,9 +30,7 @@ export default function behavesLikeSqlFormatter(format: FormatFn) {
   supportsKeywordCase(format);
   supportsIndentStyle(format);
   supportsLinesBetweenQueries(format);
-  supportsMultilineLists(format);
   supportsExpressionWidth(format);
-  supportsNewlineBeforeParen(format);
   supportsNewlineBeforeSemicolon(format);
   supportsCommaPosition(format);
   supportsLogicalOperatorNewline(format);
@@ -52,11 +48,11 @@ export default function behavesLikeSqlFormatter(format: FormatFn) {
 
   it('formats complex SELECT', () => {
     const result = format(
-      "SELECT DISTINCT [name], ROUND(age/7) field1, 18 + 20 AS field2, 'some string' FROM foo;"
+      "SELECT DISTINCT name, ROUND(age/7) field1, 18 + 20 AS field2, 'some string' FROM foo;"
     );
     expect(result).toBe(dedent`
       SELECT
-        DISTINCT [name],
+        DISTINCT name,
         ROUND(age / 7) field1,
         18 + 20 AS field2,
         'some string'
diff --git a/test/bigquery.test.ts b/test/bigquery.test.ts
index a034a708..68d2d53f 100644
--- a/test/bigquery.test.ts
+++ b/test/bigquery.test.ts
@@ -107,14 +107,17 @@ describe('BigQueryFormatter', () => {
     `);
   });
 
-  it('supports parametric ARRAY and STRUCT', () => {
-    const result = format('SELECT STRUCT<ARRAY<INT64>>([]), ARRAY<FLOAT>[1] FROM tbl');
-    expect(result).toBe(dedent`
+  it('supports parametric ARRAY', () => {
+    expect(format('SELECT ARRAY<FLOAT>[1]')).toBe(dedent`
       SELECT
-        STRUCT<ARRAY<INT64>>([]),
         ARRAY<FLOAT>[1]
-      FROM
-        tbl
+    `);
+  });
+
+  it('supports parametric STRUCT', () => {
+    expect(format('SELECT STRUCT<ARRAY<INT64>>([])')).toBe(dedent`
+      SELECT
+        STRUCT<ARRAY<INT64>>([])
     `);
   });
 
diff --git a/test/features/case.ts b/test/features/case.ts
index 4522b996..a8e07e63 100644
--- a/test/features/case.ts
+++ b/test/features/case.ts
@@ -109,21 +109,4 @@ export default function supportsCase(format: FormatFn) {
         quaz
     `);
   });
-
-  it('breaks SELECT with CASE to multiple lines even when multilineLists:avoid is used', () => {
-    const result = format("SELECT foo, bar, CASE baz WHEN 'one' THEN 1 ELSE 2 END FROM tbl;", {
-      multilineLists: 'avoid',
-    });
-
-    expect(result).toBe(dedent`
-      SELECT
-        foo,
-        bar,
-        CASE baz
-          WHEN 'one' THEN 1
-          ELSE 2
-        END
-      FROM tbl;
-    `);
-  });
 }
diff --git a/test/features/comments.ts b/test/features/comments.ts
index 50f2c9dc..c6d66360 100644
--- a/test/features/comments.ts
+++ b/test/features/comments.ts
@@ -89,8 +89,9 @@ export default function supportsComments(format: FormatFn, opts: CommentsConfig
   it('formats line comments followed by close-paren', () => {
     expect(format('SELECT ( a --comment\n )')).toBe(dedent`
       SELECT
-        (a --comment
-      )
+        (
+          a --comment
+        )
     `);
   });
 
diff --git a/test/features/operators.ts b/test/features/operators.ts
index 4199b58e..22edd3a4 100644
--- a/test/features/operators.ts
+++ b/test/features/operators.ts
@@ -20,7 +20,7 @@ export default function supportsOperators(
   });
 
   logicalOperators.forEach(op => {
-    describe(`supports ${op} operator`, () => {
+    it(`supports ${op} operator`, () => {
       const result = format(`SELECT true ${op} false AS foo;`);
       expect(result).toBe(dedent`
         SELECT
@@ -30,16 +30,6 @@ export default function supportsOperators(
     });
   });
 
-  it('supports braces', () => {
-    const result = format(`SELECT $\{a} FROM $\{b};`);
-    expect(result).toBe(dedent`
-      SELECT
-        $\{a}
-      FROM
-        $\{b};
-    `);
-  });
-
   it('supports set operators', () => {
     expect(format('foo ALL bar')).toBe('foo ALL bar');
     expect(format('foo = ANY (1, 2, 3)')).toBe('foo = ANY (1, 2, 3)');
diff --git a/test/options/multilineLists.ts b/test/options/multilineLists.ts
deleted file mode 100644
index 1c8abd74..00000000
--- a/test/options/multilineLists.ts
+++ /dev/null
@@ -1,182 +0,0 @@
-import dedent from 'dedent-js';
-
-import { FormatFn } from 'src/sqlFormatter';
-
-export default function supportsMultilineLists(format: FormatFn) {
-  it('throws error when multilineLists is negative number', () => {
-    expect(() => {
-      format('SELECT *', { multilineLists: -1 });
-    }).toThrowErrorMatchingInlineSnapshot(`"multilineLists config must be a positive number."`);
-  });
-
-  it('throws error when multilineLists is zero', () => {
-    expect(() => {
-      format('SELECT *', { multilineLists: 0 });
-    }).toThrowErrorMatchingInlineSnapshot(`"multilineLists config must be a positive number."`);
-  });
-
-  describe('multilineLists: always', () => {
-    it('always splits to multiple lines, even when just a single clause', () => {
-      const result = format('SELECT foo, bar FROM qux;', {
-        multilineLists: 'always',
-      });
-      expect(result).toBe(dedent`
-        SELECT
-          foo,
-          bar
-        FROM
-          qux;
-      `);
-    });
-  });
-
-  describe('multilineLists: avoid', () => {
-    it('does not split to multiple lines, regardless of count', () => {
-      const result = format('SELECT foo, bar, baz, qux FROM corge;', {
-        multilineLists: 'avoid',
-      });
-      expect(result).toBe(dedent`
-        SELECT foo, bar, baz, qux
-        FROM corge;
-      `);
-    });
-
-    it('places whole CREATE TABLE to single line', () => {
-      expect(
-        format('CREATE TABLE tbl (a INT PRIMARY KEY, b TEXT);', {
-          multilineLists: 'avoid',
-        })
-      ).toBe('CREATE TABLE tbl (a INT PRIMARY KEY, b TEXT);');
-    });
-  });
-
-  describe('multilineLists: number', () => {
-    it('splits to multiple lines when more clauses than than the specified number', () => {
-      const result = format('SELECT foo, bar, baz, qux FROM corge;', {
-        multilineLists: 3,
-      });
-      expect(result).toBe(dedent`
-        SELECT
-          foo,
-          bar,
-          baz,
-          qux
-        FROM corge;
-      `);
-    });
-
-    it('does not split to multiple lines when the same number of clauses as specified number', () => {
-      const result = format('SELECT foo, bar, baz FROM corge;', {
-        multilineLists: 3,
-      });
-      expect(result).toBe(dedent`
-        SELECT foo, bar, baz
-        FROM corge;
-      `);
-    });
-
-    it('does not split to multiple lines when less clauses than than the specified number', () => {
-      const result = format('SELECT foo, bar FROM corge;', {
-        multilineLists: 3,
-      });
-      expect(result).toBe(dedent`
-        SELECT foo, bar
-        FROM corge;
-      `);
-    });
-
-    it('regardless of count, splits up long clauses (exceeding default expressionWidth 50)', () => {
-      const result = format(
-        'SELECT customers.phone_number AS phone, customers.address AS addr FROM customers;',
-        {
-          multilineLists: 3,
-        }
-      );
-      expect(result).toBe(dedent`
-        SELECT
-          customers.phone_number AS phone,
-          customers.address AS addr
-        FROM customers;
-      `);
-    });
-
-    it('does not split smaller nr of clauses when their line width is exactly 50', () => {
-      const result = format('SELECT customer.phone phone, customer.addr AS addr FROM customers;', {
-        multilineLists: 3,
-      });
-      expect(result).toBe(dedent`
-        SELECT customer.phone phone, customer.addr AS addr
-        FROM customers;
-      `);
-    });
-
-    it('splits up even short clauses when expressionWidth is small', () => {
-      const result = format('SELECT foo, bar FROM customers GROUP BY foo, bar;', {
-        multilineLists: 3,
-        expressionWidth: 10,
-      });
-      expect(result).toBe(dedent`
-        SELECT
-          foo,
-          bar
-        FROM
-          customers
-        GROUP BY
-          foo,
-          bar;
-      `);
-    });
-
-    it('ignores commas inside parenthesis when counting clauses', () => {
-      const result = format('SELECT foo, some_function(a, b, c) AS bar FROM table1;', {
-        multilineLists: 3,
-      });
-      expect(result).toBe(dedent`
-        SELECT foo, some_function(a, b, c) AS bar
-        FROM table1;
-      `);
-    });
-
-    // TODO: the placement of closing paren is wrong
-    it('ignores commas inside nested parenthesis', () => {
-      const result = format('SELECT foo, func1(func2(a), b, c, d)) AS bar FROM table1;', {
-        multilineLists: 3,
-      });
-      expect(result).toBe(dedent`
-        SELECT foo, func1(func2(a), b, c, d)
-        ) AS bar
-        FROM table1;
-      `);
-    });
-  });
-
-  describe('multilineLists: expressionWidth', () => {
-    it('splits to multiple lines when single line would exceed specified expressionWidth', () => {
-      const result = format(
-        'SELECT first_field, second_field FROM some_excessively_long_table_name;',
-        {
-          multilineLists: 'expressionWidth',
-          expressionWidth: 20,
-        }
-      );
-      expect(result).toBe(dedent`
-        SELECT
-          first_field,
-          second_field
-        FROM
-          some_excessively_long_table_name;
-      `);
-    });
-
-    it('does not split to multiple lines when line at or below specified expressionWidth', () => {
-      const result = format('SELECT field1, field2 FROM table_name;', {
-        multilineLists: 'expressionWidth',
-        expressionWidth: 21,
-      });
-      expect(result).toBe(dedent`
-        SELECT field1, field2
-        FROM table_name;
-      `);
-    });
-  });
-}
diff --git a/test/options/newlineBeforeParen.ts b/test/options/newlineBeforeParen.ts
deleted file mode 100644
index a96addd5..00000000
--- a/test/options/newlineBeforeParen.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { expect } from '@jest/globals';
-import dedent from 'dedent-js';
-
-import { FormatFn } from 'src/sqlFormatter';
-
-export default function supportsNewlineBeforeParen(format: FormatFn) {
-  it('defaults to newline before opening and closing parenthesis', () => {
-    const result = format('SELECT a FROM ( SELECT b FROM c );');
-    expect(result).toBe(dedent`
-      SELECT
-        a
-      FROM
-        (
-          SELECT
-            b
-          FROM
-            c
-        );
-    `);
-  });
-
-  it('supports opening parenthesis on same line', () => {
-    const result = format('SELECT a FROM ( SELECT b FROM c );', {
-      newlineBeforeOpenParen: false,
-    });
-    expect(result).toBe(dedent`
-      SELECT
-        a
-      FROM (
-          SELECT
-            b
-          FROM
-            c
-        );
-    `);
-  });
-
-  it('supports closing parenthesis on same line', () => {
-    const result = format('SELECT a FROM ( SELECT b FROM c );', {
-      newlineBeforeCloseParen: false,
-    });
-    expect(result).toBe(dedent`
-      SELECT
-        a
-      FROM
-        (
-          SELECT
-            b
-          FROM
-            c );
-    `);
-  });
-
-  it('supports both opening and closing parenthesis on same line', () => {
-    const result = format('SELECT a FROM ( SELECT b FROM c );', {
-      newlineBeforeOpenParen: false,
-      newlineBeforeCloseParen: false,
-    });
-    expect(result).toBe(dedent`
-      SELECT
-        a
-      FROM (
-          SELECT
-            b
-          FROM
-            c );
-    `);
-  });
-
-  it('does not effect function parenthesis', () => {
-    const withNewlineOn = format(
-      "SELECT COUNT(col1), REGEXP_REPLACE(col2, '(_+)(X_+)?', '<space>') FROM tbl;",
-      {
-        newlineBeforeOpenParen: true,
-        newlineBeforeCloseParen: true,
-      }
-    );
-    const withNewlineOff = format(
-      "SELECT COUNT(col1), REGEXP_REPLACE(col2, '(_+)(X_+)?', '<space>') FROM tbl;",
-      {
-        newlineBeforeOpenParen: false,
-        newlineBeforeCloseParen: false,
-      }
-    );
-    expect(withNewlineOn).toBe(withNewlineOff);
-    expect(withNewlineOn).toBe(dedent`
-      SELECT
-        COUNT(col1),
-        REGEXP_REPLACE(col2, '(_+)(X_+)?', '<space>')
-      FROM
-        tbl;
-    `);
-  });
-
-  // TODO: I think this is not as intended.
-  it('has no effect when used together with indentStyle:tabularLeft', () => {
-    const withNewlineOn = format('SELECT a FROM ( SELECT b FROM c );', {
-      newlineBeforeOpenParen: true,
-      newlineBeforeCloseParen: true,
-      indentStyle: 'tabularLeft',
-    });
-    const withNewlineOff = format('SELECT a FROM ( SELECT b FROM c );', {
-      newlineBeforeOpenParen: false,
-      newlineBeforeCloseParen: false,
-      indentStyle: 'tabularLeft',
-    });
-
-    expect(withNewlineOn).toBe(withNewlineOff);
-    expect(withNewlineOn).toBe(dedent`
-    SELECT    a
-    FROM      (
-              SELECT    b
-              FROM      c
-              );
-    `);
-  });
-}
diff --git a/test/options/tabulateAlias.ts b/test/options/tabulateAlias.ts
index c3dd07ea..84b58b45 100644
--- a/test/options/tabulateAlias.ts
+++ b/test/options/tabulateAlias.ts
@@ -95,21 +95,6 @@ export default function supportsTabulateAlias(format: FormatFn) {
     `);
   });
 
-  it('does not tabulate aliases when multilineLists:avoid used', () => {
-    const result = format(
-      'SELECT alpha AS alp, MAX(beta), epsilon AS E FROM ( SELECT mu AS m, iota AS io FROM gamma );',
-      { multilineLists: 'avoid', tabulateAlias: true }
-    );
-
-    expect(result).toBe(dedent`
-      SELECT alpha AS alp, MAX(beta), epsilon AS E
-      FROM (
-          SELECT mu AS m, iota AS io
-          FROM gamma
-        );
-    `);
-  });
-
   it('works together with indentStyle:tabularLeft', () => {
     const result = format(
       dedent`SELECT alpha AS alp, MAX(beta), epsilon AS E FROM ( SELECT mu AS m, iota AS io FROM gamma );`,
diff --git a/test/plsql.test.ts b/test/plsql.test.ts
index d335f0e9..551ec886 100644
--- a/test/plsql.test.ts
+++ b/test/plsql.test.ts
@@ -70,7 +70,7 @@ describe('PlSqlFormatter', () => {
   it('does not support #, $ in named parameters', () => {
     expect(format('SELECT :col$foo')).toBe(dedent`
       SELECT
-        :col $foo
+        :col $ foo
     `);
 
     expect(() => format('SELECT :col#foo')).toThrowError('Parse error: Unexpected "#foo"');
diff --git a/test/sql.test.ts b/test/sql.test.ts
index 94b30863..ab591e07 100644
--- a/test/sql.test.ts
+++ b/test/sql.test.ts
@@ -56,7 +56,7 @@ describe('SqlFormatter', () => {
         @ name,
       : bar
       FROM
-      {foo};
+        { foo };
     `);
   });
 });
diff --git a/test/sqlFormatter.test.ts b/test/sqlFormatter.test.ts
index 3e16d006..c289bdb3 100644
--- a/test/sqlFormatter.test.ts
+++ b/test/sqlFormatter.test.ts
@@ -24,4 +24,22 @@ describe('sqlFormatter', () => {
       'Invalid query argument. Expected string, instead got undefined'
     );
   });
+
+  it('throws error when multilineLists config option used', () => {
+    expect(() => {
+      format('SELECT *', { multilineLists: 'always' } as any);
+    }).toThrow('multilineLists config is no more supported.');
+  });
+
+  it('throws error when newlineBeforeOpenParen config option used', () => {
+    expect(() => {
+      format('SELECT *', { newlineBeforeOpenParen: true } as any);
+    }).toThrow('newlineBeforeOpenParen config is no more supported.');
+  });
+
+  it('throws error when newlineBeforeCloseParen config option used', () => {
+    expect(() => {
+      format('SELECT *', { newlineBeforeCloseParen: true } as any);
+    }).toThrow('newlineBeforeCloseParen config is no more supported.');
+  });
 });
diff --git a/test/unit/Parser.test.ts b/test/unit/Parser.test.ts
new file mode 100644
index 00000000..140558d5
--- /dev/null
+++ b/test/unit/Parser.test.ts
@@ -0,0 +1,407 @@
+import Parser from 'src/core/Parser';
+import Tokenizer from 'src/core/Tokenizer';
+
+describe('Parser', () => {
+  const parse = (sql: string) => {
+    const tokens = new Tokenizer({
+      reservedCommands: ['SELECT', 'FROM', 'WHERE', 'LIMIT', 'CREATE TABLE'],
+      reservedDependentClauses: ['WHEN', 'ELSE'],
+      reservedBinaryCommands: ['UNION'],
+      reservedJoins: ['JOIN'],
+      reservedJoinConditions: ['ON', 'USING'],
+      reservedKeywords: ['BETWEEN', 'LIKE', 'SQRT'],
+      openParens: ['(', '['],
+      closeParens: [')', ']'],
+      stringTypes: ["''"],
+      identTypes: ['""'],
+    }).tokenize(sql);
+    return new Parser(tokens).parse();
+  };
+
+  it('parses empty list of tokens', () => {
+    expect(parse('')).toEqual([]);
+  });
+
+  it('parses multiple statements', () => {
+    expect(parse('foo; bar')).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "children": Array [
+            Object {
+              "token": Object {
+                "text": "foo",
+                "type": "IDENT",
+                "value": "foo",
+                "whitespaceBefore": "",
+              },
+              "type": "token",
+            },
+          ],
+          "hasSemicolon": true,
+          "type": "statement",
+        },
+        Object {
+          "children": Array [
+            Object {
+              "token": Object {
+                "text": "bar",
+                "type": "IDENT",
+                "value": "bar",
+                "whitespaceBefore": " ",
+              },
+              "type": "token",
+            },
+          ],
+          "hasSemicolon": false,
+          "type": "statement",
+        },
+      ]
+    `);
+  });
+
+  it('parses function call', () => {
+    expect(parse('SELECT SQRT(2)')).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "children": Array [
+            Object {
+              "children": Array [
+                Object {
+                  "nameToken": Object {
+                    "text": "SQRT",
+                    "type": "RESERVED_KEYWORD",
+                    "value": "SQRT",
+                    "whitespaceBefore": " ",
+                  },
+                  "parenthesis": Object {
+                    "children": Array [
+                      Object {
+                        "token": Object {
+                          "text": "2",
+                          "type": "NUMBER",
+                          "value": "2",
+                          "whitespaceBefore": "",
+                        },
+                        "type": "token",
+                      },
+                    ],
+                    "closeParen": ")",
+                    "openParen": "(",
+                    "type": "parenthesis",
+                  },
+                  "type": "function_call",
+                },
+              ],
+              "nameToken": Object {
+                "text": "SELECT",
+                "type": "RESERVED_COMMAND",
+                "value": "SELECT",
+                "whitespaceBefore": "",
+              },
+              "type": "clause",
+            },
+          ],
+          "hasSemicolon": false,
+          "type": "statement",
+        },
+      ]
+    `);
+  });
+
+  it('parses array subscript', () => {
+    expect(parse('SELECT my_array[OFFSET(5)]')).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "children": Array [
+            Object {
+              "children": Array [
+                Object {
+                  "arrayToken": Object {
+                    "text": "my_array",
+                    "type": "IDENT",
+                    "value": "my_array",
+                    "whitespaceBefore": " ",
+                  },
+                  "parenthesis": Object {
+                    "children": Array [
+                      Object {
+                        "nameToken": Object {
+                          "text": "OFFSET",
+                          "type": "IDENT",
+                          "value": "OFFSET",
+                          "whitespaceBefore": "",
+                        },
+                        "parenthesis": Object {
+                          "children": Array [
+                            Object {
+                              "token": Object {
+                                "text": "5",
+                                "type": "NUMBER",
+                                "value": "5",
+                                "whitespaceBefore": "",
+                              },
+                              "type": "token",
+                            },
+                          ],
+                          "closeParen": ")",
+                          "openParen": "(",
+                          "type": "parenthesis",
+                        },
+                        "type": "function_call",
+                      },
+                    ],
+                    "closeParen": "]",
+                    "openParen": "[",
+                    "type": "parenthesis",
+                  },
+                  "type": "array_subscript",
+                },
+              ],
+              "nameToken": Object {
+                "text": "SELECT",
+                "type": "RESERVED_COMMAND",
+                "value": "SELECT",
+                "whitespaceBefore": "",
+              },
+              "type": "clause",
+            },
+          ],
+          "hasSemicolon": false,
+          "type": "statement",
+        },
+      ]
+    `);
+  });
+
+  it('parses parenthesized expressions', () => {
+    expect(parse('SELECT (birth_year - (CURRENT_DATE + 1))')).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "children": Array [
+            Object {
+              "children": Array [
+                Object {
+                  "children": Array [
+                    Object {
+                      "token": Object {
+                        "text": "birth_year",
+                        "type": "IDENT",
+                        "value": "birth_year",
+                        "whitespaceBefore": "",
+                      },
+                      "type": "token",
+                    },
+                    Object {
+                      "token": Object {
+                        "text": "-",
+                        "type": "OPERATOR",
+                        "value": "-",
+                        "whitespaceBefore": " ",
+                      },
+                      "type": "token",
+                    },
+                    Object {
+                      "children": Array [
+                        Object {
+                          "token": Object {
+                            "text": "CURRENT_DATE",
+                            "type": "IDENT",
+                            "value": "CURRENT_DATE",
+                            "whitespaceBefore": "",
+                          },
+                          "type": "token",
+                        },
+                        Object {
+                          "token": Object {
+                            "text": "+",
+                            "type": "OPERATOR",
+                            "value": "+",
+                            "whitespaceBefore": " ",
+                          },
+                          "type": "token",
+                        },
+                        Object {
+                          "token": Object {
+                            "text": "1",
+                            "type": "NUMBER",
+                            "value": "1",
+                            "whitespaceBefore": " ",
+                          },
+                          "type": "token",
+                        },
+                      ],
+                      "closeParen": ")",
+                      "openParen": "(",
+                      "type": "parenthesis",
+                    },
+                  ],
+                  "closeParen": ")",
+                  "openParen": "(",
+                  "type": "parenthesis",
+                },
+              ],
+              "nameToken": Object {
+                "text": "SELECT",
+                "type": "RESERVED_COMMAND",
+                "value": "SELECT",
+                "whitespaceBefore": "",
+              },
+              "type": "clause",
+            },
+          ],
+          "hasSemicolon": false,
+          "type": "statement",
+        },
+      ]
+    `);
+  });
+
+  it('parses BETWEEN expression', () => {
+    expect(parse('WHERE age BETWEEN 10 and 15;')).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "children": Array [
+            Object {
+              "children": Array [
+                Object {
+                  "token": Object {
+                    "text": "age",
+                    "type": "IDENT",
+                    "value": "age",
+                    "whitespaceBefore": " ",
+                  },
+                  "type": "token",
+                },
+                Object {
+                  "andToken": Object {
+                    "text": "and",
+                    "type": "RESERVED_LOGICAL_OPERATOR",
+                    "value": "AND",
+                    "whitespaceBefore": " ",
+                  },
+                  "betweenToken": Object {
+                    "text": "BETWEEN",
+                    "type": "RESERVED_KEYWORD",
+                    "value": "BETWEEN",
+                    "whitespaceBefore": " ",
+                  },
+                  "expr1": Object {
+                    "text": "10",
+                    "type": "NUMBER",
+                    "value": "10",
+                    "whitespaceBefore": " ",
+                  },
+                  "expr2": Object {
+                    "text": "15",
+                    "type": "NUMBER",
+                    "value": "15",
+                    "whitespaceBefore": " ",
+                  },
+                  "type": "between_predicate",
+                },
+              ],
+              "nameToken": Object {
+                "text": "WHERE",
+                "type": "RESERVED_COMMAND",
+                "value": "WHERE",
+                "whitespaceBefore": "",
+              },
+              "type": "clause",
+            },
+          ],
+          "hasSemicolon": true,
+          "type": "statement",
+        },
+      ]
+    `);
+  });
+
+  it('parses LIMIT clause', () => {
+    expect(parse('LIMIT 10')).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "children": Array [
+            Object {
+              "countToken": Object {
+                "text": "10",
+                "type": "NUMBER",
+                "value": "10",
+                "whitespaceBefore": " ",
+              },
+              "limitToken": Object {
+                "text": "LIMIT",
+                "type": "RESERVED_COMMAND",
+                "value": "LIMIT",
+                "whitespaceBefore": "",
+              },
+              "type": "limit_clause",
+            },
+          ],
+          "hasSemicolon": false,
+          "type": "statement",
+        },
+      ]
+    `);
+  });
+
+  it('parses LIMIT clause with offset', () => {
+    expect(parse('LIMIT 200, 10')).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "children": Array [
+            Object {
+              "countToken": Object {
+                "text": "10",
+                "type": "NUMBER",
+                "value": "10",
+                "whitespaceBefore": " ",
+              },
+              "limitToken": Object {
+                "text": "LIMIT",
+                "type": "RESERVED_COMMAND",
+                "value": "LIMIT",
+                "whitespaceBefore": "",
+              },
+              "offsetToken": Object {
+                "text": "200",
+                "type": "NUMBER",
+                "value": "200",
+                "whitespaceBefore": " ",
+              },
+              "type": "limit_clause",
+            },
+          ],
+          "hasSemicolon": false,
+          "type": "statement",
+        },
+      ]
+    `);
+  });
+
+  it('parses SELECT *', () => {
+    expect(parse('SELECT *')).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "children": Array [
+            Object {
+              "children": Array [
+                Object {
+                  "type": "all_columns_asterisk",
+                },
+              ],
+              "nameToken": Object {
+                "text": "SELECT",
+                "type": "RESERVED_COMMAND",
+                "value": "SELECT",
+                "whitespaceBefore": "",
+              },
+              "type": "clause",
+            },
+          ],
+          "hasSemicolon": false,
+          "type": "statement",
+        },
+      ]
+    `);
+  });
+});