import { trimSpacesEnd } from "../utils"; import Indentation from "./Indentation"; import InlineBlock from "./InlineBlock"; import type { PlaceholderParams } from "./Params"; import Params from "./Params"; import type Tokenizer from "./Tokenizer"; import type { Token } from "./token"; import { isAnd, isBetween, isLimit } from "./token"; import { tokenTypes } from "./tokenTypes"; export type KeywordCase = "upper" | "lower" | "preserve"; export type FormatterConfig = { indent: string; keywordCase: KeywordCase; linesBetweenQueries: number; params?: PlaceholderParams; }; export default class Formatter { config: FormatterConfig; indentation: Indentation; inlineBlock: InlineBlock; params: Params; previousReservedToken: Token | null; tokens: Token[]; index: number; constructor(config: FormatterConfig) { this.config = config; this.indentation = new Indentation(this.config.indent); this.inlineBlock = new InlineBlock(); this.params = new Params(this.config.params); this.previousReservedToken = null; this.tokens = []; this.index = 0; } /** * SQL Tokenizer for this formatter, provided by subclasses. */ tokenizer(): Tokenizer { throw new Error("tokenizer() not implemented by subclass"); } /** * Reprocess and modify a token based on parsed context. * * @param {Object} token The token to modify * @param {String} token.type * @param {String} token.value * @return {Object} new token or the original * @return {String} token.type * @return {String} token.value */ tokenOverride(token: Token): Token { // subclasses can override this to modify tokens during formatting return token; } /** * Formats whitespace in a SQL string to make it easier to read. * * @param {String} query The SQL query string * @return {String} formatted query */ format(query: string): string { this.tokens = this.tokenizer().tokenize(query); const formattedQuery = this.getFormattedQueryFromTokens(); return formattedQuery.trim(); } getFormattedQueryFromTokens(): string { let formattedQuery = ""; this.tokens.forEach((token, index) => { this.index = index; token = this.tokenOverride(token); if (token.type === tokenTypes.LINE_COMMENT) { formattedQuery = this.formatLineComment(token, formattedQuery); } else if (token.type === tokenTypes.BLOCK_COMMENT) { formattedQuery = this.formatBlockComment(token, formattedQuery); } else if (token.type === tokenTypes.RESERVED_TOP_LEVEL) { formattedQuery = this.formatTopLevelReservedWord(token, formattedQuery); this.previousReservedToken = token; } else if (token.type === tokenTypes.RESERVED_TOP_LEVEL_NO_INDENT) { formattedQuery = this.formatTopLevelReservedWordNoIndent( token, formattedQuery ); this.previousReservedToken = token; } else if (token.type === tokenTypes.RESERVED_NEWLINE) { formattedQuery = this.formatNewlineReservedWord(token, formattedQuery); this.previousReservedToken = token; } else if (token.type === tokenTypes.RESERVED) { formattedQuery = this.formatWithSpaces(token, formattedQuery); this.previousReservedToken = token; } else if (token.type === tokenTypes.OPEN_PAREN) { formattedQuery = this.formatOpeningParentheses(token, formattedQuery); } else if (token.type === tokenTypes.CLOSE_PAREN) { formattedQuery = this.formatClosingParentheses(token, formattedQuery); } else if (token.type === tokenTypes.PLACEHOLDER) { formattedQuery = this.formatPlaceholder(token, formattedQuery); } else if (token.value === ",") { formattedQuery = this.formatComma(token, formattedQuery); } else if (token.value === ":") { formattedQuery = this.formatWithSpaceAfter(token, formattedQuery); } else if (token.value === ".") { formattedQuery = this.formatWithoutSpaces(token, formattedQuery); } else if (token.value === ";") { formattedQuery = this.formatQuerySeparator(token, formattedQuery); } else { formattedQuery = this.formatWithSpaces(token, formattedQuery); } }); return formattedQuery; } formatLineComment(token: Token, query: string): string { return this.addNewline(query + this.show(token)); } formatBlockComment(token: Token, query: string): string { return this.addNewline( this.addNewline(query) + this.indentComment(token.value) ); } indentComment(comment: string): string { return comment.replace( /\n[ \t]*/gu, "\n" + this.indentation.getIndent() + " " ); } formatTopLevelReservedWordNoIndent(token: Token, query: string): string { this.indentation.decreaseTopLevel(); query = this.addNewline(query) + this.equalizeWhitespace(this.show(token)); return this.addNewline(query); } formatTopLevelReservedWord(token: Token, query: string): string { this.indentation.decreaseTopLevel(); query = this.addNewline(query); this.indentation.increaseTopLevel(); query += this.equalizeWhitespace(this.show(token)); return this.addNewline(query); } formatNewlineReservedWord(token: Token, query: string): string { if (isAnd(token) && isBetween(this.tokenLookBehind(2))) { return this.formatWithSpaces(token, query); } return ( this.addNewline(query) + this.equalizeWhitespace(this.show(token)) + " " ); } // Replace any sequence of whitespace characters with single space equalizeWhitespace(str: string): string { return str.replace(/\s+/gu, " "); } // Opening parentheses increase the block indent level and start a new line formatOpeningParentheses(token: Token, query: string) { // Take out the preceding space unless there was whitespace there in the original query // or another opening parens or line comment const preserveWhitespaceFor: Record = { [tokenTypes.OPEN_PAREN]: true, [tokenTypes.LINE_COMMENT]: true, [tokenTypes.OPERATOR]: true, }; if ( token.whitespaceBefore.length === 0 && !preserveWhitespaceFor[this.tokenLookBehind()?.type] ) { query = trimSpacesEnd(query); } query += this.show(token); this.inlineBlock.beginIfPossible(this.tokens, this.index); if (!this.inlineBlock.isActive()) { this.indentation.increaseBlockLevel(); query = this.addNewline(query); } return query; } // Closing parentheses decrease the block indent level formatClosingParentheses(token: Token, query: string): string { if (this.inlineBlock.isActive()) { this.inlineBlock.end(); return this.formatWithSpaceAfter(token, query); } else { this.indentation.decreaseBlockLevel(); return this.formatWithSpaces(token, this.addNewline(query)); } } formatPlaceholder(token: Token, query: string): string { return query + this.params.get(token) + " "; } // Commas start a new line (unless within inline parentheses or SQL "LIMIT" clause) formatComma(token: Token, query: string): string { query = trimSpacesEnd(query) + this.show(token) + " "; if (this.inlineBlock.isActive()) { return query; } else if ( this.previousReservedToken && isLimit(this.previousReservedToken) ) { return query; } else { return this.addNewline(query); } } formatWithSpaceAfter(token: Token, query: string) { return trimSpacesEnd(query) + this.show(token) + " "; } formatWithoutSpaces(token: Token, query: string) { return trimSpacesEnd(query) + this.show(token); } formatWithSpaces(token: Token, query: string) { return query + this.show(token) + " "; } formatQuerySeparator(token: Token, query: string) { this.indentation.resetIndentation(); return ( trimSpacesEnd(query) + this.show(token) + "\n".repeat(this.config.linesBetweenQueries) ); } // Converts token to string show(token: Token): string { const { type, value } = token; const isKeyword = type === tokenTypes.RESERVED || type === tokenTypes.RESERVED_TOP_LEVEL || type === tokenTypes.RESERVED_TOP_LEVEL_NO_INDENT || type === tokenTypes.RESERVED_NEWLINE || type === tokenTypes.OPEN_PAREN || type === tokenTypes.CLOSE_PAREN; if (isKeyword === false) { return value; } switch (this.config.keywordCase) { case "lower": return value.toLowerCase(); case "upper": return value.toUpperCase(); case "preserve": return value; default: return value; } } addNewline(query: string): string { query = trimSpacesEnd(query); if (!query.endsWith("\n")) { query += "\n"; } return query + this.indentation.getIndent(); } tokenLookBehind(n = 1): Token { return this.tokens[this.index - n]; } tokenLookAhead(n = 1): Token { return this.tokens[this.index + n]; } }