UNPKG

ponicode

Version:

Ponicode is a quick and easy AI-powered solution for Javascript unit testing.

725 lines (724 loc) 27.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const stringUtils_1 = require("./utils/stringUtils"); const CommentChar_1 = require("./CommentChar"); // Using the char codes is a performance improvement (about 5.5% faster when writing because it eliminates additional string allocations). const CHARS = { BACK_SLASH: "\\".charCodeAt(0), FORWARD_SLASH: "/".charCodeAt(0), NEW_LINE: "\n".charCodeAt(0), CARRIAGE_RETURN: "\r".charCodeAt(0), ASTERISK: "*".charCodeAt(0), DOUBLE_QUOTE: "\"".charCodeAt(0), SINGLE_QUOTE: "'".charCodeAt(0), BACK_TICK: "`".charCodeAt(0), OPEN_BRACE: "{".charCodeAt(0), CLOSE_BRACE: "}".charCodeAt(0), DOLLAR_SIGN: "$".charCodeAt(0), }; const isCharToHandle = new Set([ CHARS.BACK_SLASH, CHARS.FORWARD_SLASH, CHARS.NEW_LINE, CHARS.CARRIAGE_RETURN, CHARS.ASTERISK, CHARS.DOUBLE_QUOTE, CHARS.SINGLE_QUOTE, CHARS.BACK_TICK, CHARS.OPEN_BRACE, CHARS.CLOSE_BRACE, ]); /** * Code writer that assists with formatting and visualizing blocks of JavaScript or TypeScript code. */ class CodeBlockWriter { /** * Constructor. * @param opts - Options for the writer. */ constructor(opts = {}) { /** @internal */ this._currentIndentation = 0; /** @internal */ this._length = 0; /** @internal */ this._newLineOnNextWrite = false; /** @internal */ this._currentCommentChar = undefined; /** @internal */ this._stringCharStack = []; /** @internal */ this._isInRegEx = false; /** @internal */ this._isOnFirstLineOfBlock = true; // An array of strings is used rather than a single string because it was // found to be ~11x faster when printing a 10K line file (~11s to ~1s). /** @internal */ this._texts = []; this._newLine = opts.newLine || "\n"; this._useTabs = opts.useTabs || false; this._indentNumberOfSpaces = opts.indentNumberOfSpaces || 4; this._indentationText = getIndentationText(this._useTabs, this._indentNumberOfSpaces); this._quoteChar = opts.useSingleQuote ? "'" : `"`; } /** * Gets the options. */ getOptions() { return { indentNumberOfSpaces: this._indentNumberOfSpaces, newLine: this._newLine, useTabs: this._useTabs, useSingleQuote: this._quoteChar === "'", }; } queueIndentationLevel(countOrText) { this._queuedIndentation = this._getIndentationLevelFromArg(countOrText); this._queuedOnlyIfNotBlock = undefined; return this; } /** * Writes the text within the provided action with hanging indentation. * @param action - Action to perform with hanging indentation. */ hangingIndent(action) { return this._withResetIndentation(() => this.queueIndentationLevel(this.getIndentationLevel() + 1), action); } /** * Writes the text within the provided action with hanging indentation unless writing a block. * @param action - Action to perform with hanging indentation unless a block is written. */ hangingIndentUnlessBlock(action) { return this._withResetIndentation(() => { this.queueIndentationLevel(this.getIndentationLevel() + 1); this._queuedOnlyIfNotBlock = true; }, action); } setIndentationLevel(countOrText) { this._currentIndentation = this._getIndentationLevelFromArg(countOrText); return this; } withIndentationLevel(countOrText, action) { return this._withResetIndentation(() => this.setIndentationLevel(countOrText), action); } /** @internal */ _withResetIndentation(setStateAction, writeAction) { const previousState = this._getIndentationState(); setStateAction(); try { writeAction(); } finally { this._setIndentationState(previousState); } return this; } /** * Gets the current indentation level. */ getIndentationLevel() { return this._currentIndentation; } /** * Writes a block using braces. * @param block - Write using the writer within this block. */ block(block) { this._newLineIfNewLineOnNextWrite(); if (this.getLength() > 0 && !this.isLastNewLine()) this.spaceIfLastNot(); this.inlineBlock(block); this._newLineOnNextWrite = true; return this; } /** * Writes an inline block with braces. * @param block - Write using the writer within this block. */ inlineBlock(block) { this._newLineIfNewLineOnNextWrite(); this.write("{"); this._indentBlockInternal(block); this.newLineIfLastNot().write("}"); return this; } indent(timesOrBlock = 1) { if (typeof timesOrBlock === "number") { this._newLineIfNewLineOnNextWrite(); return this.write(this._indentationText.repeat(timesOrBlock)); } else { this._indentBlockInternal(timesOrBlock); if (!this.isLastNewLine()) this._newLineOnNextWrite = true; return this; } } /** @internal */ _indentBlockInternal(block) { if (this.getLastChar() != null) this.newLineIfLastNot(); this._currentIndentation++; this._isOnFirstLineOfBlock = true; if (block != null) block(); this._isOnFirstLineOfBlock = false; this._currentIndentation = Math.max(0, this._currentIndentation - 1); } conditionalWriteLine(condition, strOrFunc) { if (condition) this.writeLine(stringUtils_1.getStringFromStrOrFunc(strOrFunc)); return this; } /** * Writes a line of text. * @param text - String to write. */ writeLine(text) { this._newLineIfNewLineOnNextWrite(); if (this.getLastChar() != null) this.newLineIfLastNot(); this._writeIndentingNewLines(text); this.newLine(); return this; } /** * Writes a newline if the last line was not a newline. */ newLineIfLastNot() { this._newLineIfNewLineOnNextWrite(); if (!this.isLastNewLine()) this.newLine(); return this; } /** * Writes a blank line if the last written text was not a blank line. */ blankLineIfLastNot() { if (!this.isLastBlankLine()) this.blankLine(); return this; } /** * Writes a blank line if the condition is true. * @param condition - Condition to evaluate. */ conditionalBlankLine(condition) { if (condition) this.blankLine(); return this; } /** * Writes a blank line. */ blankLine() { return this.newLineIfLastNot().newLine(); } /** * Writes a newline if the condition is true. * @param condition - Condition to evaluate. */ conditionalNewLine(condition) { if (condition) this.newLine(); return this; } /** * Writes a newline. */ newLine() { this._newLineOnNextWrite = false; this._baseWriteNewline(); return this; } quote(text) { this._newLineIfNewLineOnNextWrite(); this._writeIndentingNewLines(text == null ? this._quoteChar : this._quoteChar + stringUtils_1.escapeForWithinString(text, this._quoteChar) + this._quoteChar); return this; } /** * Writes a space if the last character was not a space. */ spaceIfLastNot() { this._newLineIfNewLineOnNextWrite(); if (!this.isLastSpace()) this._writeIndentingNewLines(" "); return this; } /** * Writes a space. * @param times - Number of times to write a space. */ space(times = 1) { this._newLineIfNewLineOnNextWrite(); this._writeIndentingNewLines(" ".repeat(times)); return this; } /** * Writes a tab if the last character was not a tab. */ tabIfLastNot() { this._newLineIfNewLineOnNextWrite(); if (!this.isLastTab()) this._writeIndentingNewLines("\t"); return this; } /** * Writes a tab. * @param times - Number of times to write a tab. */ tab(times = 1) { this._newLineIfNewLineOnNextWrite(); this._writeIndentingNewLines("\t".repeat(times)); return this; } conditionalWrite(condition, textOrFunc) { if (condition) this.write(stringUtils_1.getStringFromStrOrFunc(textOrFunc)); return this; } /** * Writes the provided text. * @param text - Text to write. */ write(text) { this._newLineIfNewLineOnNextWrite(); this._writeIndentingNewLines(text); return this; } /** * Writes text to exit a comment if in a comment. */ closeComment() { const commentChar = this._currentCommentChar; switch (commentChar) { case CommentChar_1.CommentChar.Line: this.newLine(); break; case CommentChar_1.CommentChar.Star: if (!this.isLastNewLine()) this.spaceIfLastNot(); this.write("*/"); break; default: const assertUndefined = commentChar; break; } return this; } /** * Inserts text at the provided position. * * This method is "unsafe" because it won't update the state of the writer unless * inserting at the end position. It is biased towards being fast at inserting closer * to the start or end, but slower to insert in the middle. Only use this if * absolutely necessary. * @param pos - Position to insert at. * @param text - Text to insert. */ unsafeInsert(pos, text) { const textLength = this._length; const texts = this._texts; verifyInput(); if (pos === textLength) return this.write(text); updateInternalArray(); this._length += text.length; return this; function verifyInput() { if (pos < 0) throw new Error(`Provided position of '${pos}' was less than zero.`); if (pos > textLength) throw new Error(`Provided position of '${pos}' was greater than the text length of '${textLength}'.`); } function updateInternalArray() { const { index, localIndex } = getArrayIndexAndLocalIndex(); if (localIndex === 0) texts.splice(index, 0, text); else if (localIndex === texts[index].length) texts.splice(index + 1, 0, text); else { const textItem = texts[index]; const startText = textItem.substring(0, localIndex); const endText = textItem.substring(localIndex); texts.splice(index, 1, startText, text, endText); } } function getArrayIndexAndLocalIndex() { if (pos < textLength / 2) { // start searching from the front let endPos = 0; for (let i = 0; i < texts.length; i++) { const textItem = texts[i]; const startPos = endPos; endPos += textItem.length; if (endPos >= pos) return { index: i, localIndex: pos - startPos }; } } else { // start searching from the back let startPos = textLength; for (let i = texts.length - 1; i >= 0; i--) { const textItem = texts[i]; startPos -= textItem.length; if (startPos <= pos) return { index: i, localIndex: pos - startPos }; } } throw new Error("Unhandled situation inserting. This should never happen."); } } /** * Gets the length of the string in the writer. */ getLength() { return this._length; } /** * Gets if the writer is currently in a comment. */ isInComment() { return this._currentCommentChar !== undefined; } /** * Gets if the writer is currently at the start of the first line of the text, block, or indentation block. */ isAtStartOfFirstLineOfBlock() { return this.isOnFirstLineOfBlock() && (this.isLastNewLine() || this.getLastChar() == null); } /** * Gets if the writer is currently on the first line of the text, block, or indentation block. */ isOnFirstLineOfBlock() { return this._isOnFirstLineOfBlock; } /** * Gets if the writer is currently in a string. */ isInString() { return this._stringCharStack.length > 0 && this._stringCharStack[this._stringCharStack.length - 1] !== CHARS.OPEN_BRACE; } /** * Gets if the last chars written were for a newline. */ isLastNewLine() { const lastChar = this.getLastChar(); return lastChar === "\n" || lastChar === "\r"; } /** * Gets if the last chars written were for a blank line. */ isLastBlankLine() { let foundCount = 0; // todo: consider extracting out iterating over past characters, but don't use // an iterator because it will be slow. for (let i = this._texts.length - 1; i >= 0; i--) { const currentText = this._texts[i]; for (let j = currentText.length - 1; j >= 0; j--) { const currentChar = currentText.charCodeAt(j); if (currentChar === CHARS.NEW_LINE) { foundCount++; if (foundCount === 2) return true; } else if (currentChar !== CHARS.CARRIAGE_RETURN) { return false; } } } return false; } /** * Gets if the last char written was a space. */ isLastSpace() { return this.getLastChar() === " "; } /** * Gets if the last char written was a tab. */ isLastTab() { return this.getLastChar() === "\t"; } /** * Gets the last char written. */ getLastChar() { const charCode = this._getLastCharCodeWithOffset(0); return charCode == null ? undefined : String.fromCharCode(charCode); } /** * Gets if the writer ends with the provided text. * @param text - Text to check if the writer ends with the provided text. */ endsWith(text) { const length = this._length; return this.iterateLastCharCodes((charCode, index) => { const offset = length - index; const textIndex = text.length - offset; if (text.charCodeAt(textIndex) !== charCode) return false; return textIndex === 0 ? true : undefined; }) || false; } /** * Iterates over the writer characters in reverse order. The iteration stops when a non-null or * undefined value is returned from the action. The returned value is then returned by the method. * * @remarks It is much more efficient to use this method rather than `#toString()` since `#toString()` * will combine the internal array into a string. */ iterateLastChars(action) { return this.iterateLastCharCodes((charCode, index) => action(String.fromCharCode(charCode), index)); } /** * Iterates over the writer character char codes in reverse order. The iteration stops when a non-null or * undefined value is returned from the action. The returned value is then returned by the method. * * @remarks It is much more efficient to use this method rather than `#toString()` since `#toString()` * will combine the internal array into a string. Additionally, this is slightly more efficient that * `iterateLastChars` as this won't allocate a string per character. */ iterateLastCharCodes(action) { let index = this._length; for (let i = this._texts.length - 1; i >= 0; i--) { const currentText = this._texts[i]; for (let j = currentText.length - 1; j >= 0; j--) { index--; const result = action(currentText.charCodeAt(j), index); if (result != null) return result; } } return undefined; } /** * Gets the writer's text. */ toString() { if (this._texts.length > 1) { const text = this._texts.join(""); this._texts.length = 0; this._texts.push(text); } return this._texts[0] || ""; } /** @internal */ _writeIndentingNewLines(text) { text = text || ""; if (text.length === 0) { writeIndividual(this, ""); return; } const items = text.split(CodeBlockWriter._newLineRegEx); items.forEach((s, i) => { if (i > 0) this._baseWriteNewline(); if (s.length === 0) return; writeIndividual(this, s); }); function writeIndividual(writer, s) { if (!writer.isInString()) { const isAtStartOfLine = writer.isLastNewLine() || writer.getLastChar() == null; if (isAtStartOfLine) writer._writeIndentation(); } writer._updateInternalState(s); writer._internalWrite(s); } } /** @internal */ _baseWriteNewline() { if (this._currentCommentChar === CommentChar_1.CommentChar.Line) this._currentCommentChar = undefined; const lastStringCharOnStack = this._stringCharStack[this._stringCharStack.length - 1]; if ((lastStringCharOnStack === CHARS.DOUBLE_QUOTE || lastStringCharOnStack === CHARS.SINGLE_QUOTE) && this._getLastCharCodeWithOffset(0) !== CHARS.BACK_SLASH) this._stringCharStack.pop(); this._internalWrite(this._newLine); this._isOnFirstLineOfBlock = false; this._dequeueQueuedIndentation(); } /** @internal */ _dequeueQueuedIndentation() { if (this._queuedIndentation == null) return; if (this._queuedOnlyIfNotBlock && wasLastBlock(this)) { this._queuedIndentation = undefined; this._queuedOnlyIfNotBlock = undefined; } else { this._currentIndentation = this._queuedIndentation; this._queuedIndentation = undefined; } function wasLastBlock(writer) { let foundNewLine = false; return writer.iterateLastCharCodes(charCode => { switch (charCode) { case CHARS.NEW_LINE: if (foundNewLine) return false; else foundNewLine = true; break; case CHARS.CARRIAGE_RETURN: return undefined; case CHARS.OPEN_BRACE: return true; default: return false; } }); } } /** @internal */ _updateInternalState(str) { for (let i = 0; i < str.length; i++) { const currentChar = str.charCodeAt(i); // This is a performance optimization to short circuit all the checks below. If the current char // is not in this set then it won't change any internal state so no need to continue and do // so many other checks (this made it 3x faster in one scenario I tested). if (!isCharToHandle.has(currentChar)) continue; const pastChar = i === 0 ? this._getLastCharCodeWithOffset(0) : str.charCodeAt(i - 1); const pastPastChar = i === 0 ? this._getLastCharCodeWithOffset(1) : i === 1 ? this._getLastCharCodeWithOffset(0) : str.charCodeAt(i - 2); // handle regex if (this._isInRegEx) { if (pastChar === CHARS.FORWARD_SLASH && pastPastChar !== CHARS.BACK_SLASH || pastChar === CHARS.NEW_LINE) this._isInRegEx = false; else continue; } else if (!this.isInString() && !this.isInComment() && isRegExStart(currentChar, pastChar, pastPastChar)) { this._isInRegEx = true; continue; } // handle comments if (this._currentCommentChar == null && pastChar === CHARS.FORWARD_SLASH && currentChar === CHARS.FORWARD_SLASH) this._currentCommentChar = CommentChar_1.CommentChar.Line; else if (this._currentCommentChar == null && pastChar === CHARS.FORWARD_SLASH && currentChar === CHARS.ASTERISK) this._currentCommentChar = CommentChar_1.CommentChar.Star; else if (this._currentCommentChar === CommentChar_1.CommentChar.Star && pastChar === CHARS.ASTERISK && currentChar === CHARS.FORWARD_SLASH) this._currentCommentChar = undefined; if (this.isInComment()) continue; // handle strings const lastStringCharOnStack = this._stringCharStack.length === 0 ? undefined : this._stringCharStack[this._stringCharStack.length - 1]; if (pastChar !== CHARS.BACK_SLASH && (currentChar === CHARS.DOUBLE_QUOTE || currentChar === CHARS.SINGLE_QUOTE || currentChar === CHARS.BACK_TICK)) { if (lastStringCharOnStack === currentChar) this._stringCharStack.pop(); else if (lastStringCharOnStack === CHARS.OPEN_BRACE || lastStringCharOnStack === undefined) this._stringCharStack.push(currentChar); } else if (pastPastChar !== CHARS.BACK_SLASH && pastChar === CHARS.DOLLAR_SIGN && currentChar === CHARS.OPEN_BRACE && lastStringCharOnStack === CHARS.BACK_TICK) this._stringCharStack.push(currentChar); else if (currentChar === CHARS.CLOSE_BRACE && lastStringCharOnStack === CHARS.OPEN_BRACE) this._stringCharStack.pop(); } } /** @internal - This is private, but exposed for testing. */ _getLastCharCodeWithOffset(offset) { if (offset >= this._length || offset < 0) return undefined; for (let i = this._texts.length - 1; i >= 0; i--) { const currentText = this._texts[i]; if (offset >= currentText.length) offset -= currentText.length; else return currentText.charCodeAt(currentText.length - 1 - offset); } return undefined; } /** @internal */ _writeIndentation() { const flooredIndentation = Math.floor(this._currentIndentation); this._internalWrite(this._indentationText.repeat(flooredIndentation)); const overflow = this._currentIndentation - flooredIndentation; if (this._useTabs) { if (overflow > 0.5) this._internalWrite(this._indentationText); } else { const portion = Math.round(this._indentationText.length * overflow); // build up the string first, then append it for performance reasons let text = ""; for (let i = 0; i < portion; i++) text += this._indentationText[i]; this._internalWrite(text); } } /** @internal */ _newLineIfNewLineOnNextWrite() { if (!this._newLineOnNextWrite) return; this._newLineOnNextWrite = false; this.newLine(); } /** @internal */ _internalWrite(text) { if (text.length === 0) return; this._texts.push(text); this._length += text.length; } /** @internal */ _getIndentationLevelFromArg(countOrText) { if (typeof countOrText === "number") { if (countOrText < 0) throw new Error("Passed in indentation level should be greater than or equal to 0."); return countOrText; } else if (typeof countOrText === "string") { if (!CodeBlockWriter._spacesOrTabsRegEx.test(countOrText)) throw new Error("Provided string must be empty or only contain spaces or tabs."); const { spacesCount, tabsCount } = getSpacesAndTabsCount(countOrText); return tabsCount + spacesCount / this._indentNumberOfSpaces; } else { throw new Error("Argument provided must be a string or number."); } } /** @internal */ _setIndentationState(state) { this._currentIndentation = state.current; this._queuedIndentation = state.queued; this._queuedOnlyIfNotBlock = state.queuedOnlyIfNotBlock; } /** @internal */ _getIndentationState() { return { current: this._currentIndentation, queued: this._queuedIndentation, queuedOnlyIfNotBlock: this._queuedOnlyIfNotBlock, }; } } exports.default = CodeBlockWriter; /** @internal */ CodeBlockWriter._newLineRegEx = /\r?\n/; /** @internal */ CodeBlockWriter._spacesOrTabsRegEx = /^[ \t]*$/; function isRegExStart(currentChar, pastChar, pastPastChar) { return pastChar === CHARS.FORWARD_SLASH && currentChar !== CHARS.FORWARD_SLASH && currentChar !== CHARS.ASTERISK && pastPastChar !== CHARS.ASTERISK && pastPastChar !== CHARS.FORWARD_SLASH; } function getIndentationText(useTabs, numberSpaces) { if (useTabs) return "\t"; return Array(numberSpaces + 1).join(" "); } function getSpacesAndTabsCount(str) { let spacesCount = 0; let tabsCount = 0; for (const char of str) { if (char === " ") spacesCount++; else if (char === "\t") tabsCount++; } return { spacesCount, tabsCount }; }