import { escapeForWithinString, getStringFromStrOrFunc } from "./utils/string_utils.ts";
enum CommentChar { Line, Star,}
export interface Options { newLine: "\n" | "\r\n"; indentNumberOfSpaces: number; useTabs: boolean; useSingleQuote: boolean;}
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), SPACE: " ".charCodeAt(0), TAB: "\t".charCodeAt(0),};const isCharToHandle = new Set<number>([ 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,]);
export default class CodeBlockWriter { private readonly _indentationText: string; private readonly _newLine: "\n" | "\r\n"; private readonly _useTabs: boolean; private readonly _quoteChar: string; private readonly _indentNumberOfSpaces: number; private _currentIndentation = 0; private _queuedIndentation: number | undefined; private _queuedOnlyIfNotBlock: true | undefined; private _length = 0; private _newLineOnNextWrite = false; private _currentCommentChar: CommentChar | undefined = undefined; private _stringCharStack: number[] = []; private _isInRegEx = false; private _isOnFirstLineOfBlock = true; private _texts: string[] = [];
constructor(opts: Partial<Options> = {}) { 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 ? "'" : `"`; }
getOptions(): Options { return { indentNumberOfSpaces: this._indentNumberOfSpaces, newLine: this._newLine, useTabs: this._useTabs, useSingleQuote: this._quoteChar === "'", }; }
queueIndentationLevel(indentationLevel: number): this; queueIndentationLevel(whitespaceText: string): this; queueIndentationLevel(countOrText: string | number): this; queueIndentationLevel(countOrText: string | number) { this._queuedIndentation = this._getIndentationLevelFromArg(countOrText); this._queuedOnlyIfNotBlock = undefined; return this; }
hangingIndent(action: () => void): this { return this._withResetIndentation(() => this.queueIndentationLevel(this.getIndentationLevel() + 1), action); }
hangingIndentUnlessBlock(action: () => void): this { return this._withResetIndentation(() => { this.queueIndentationLevel(this.getIndentationLevel() + 1); this._queuedOnlyIfNotBlock = true; }, action); }
setIndentationLevel(indentationLevel: number): this; setIndentationLevel(whitespaceText: string): this; setIndentationLevel(countOrText: string | number): this; setIndentationLevel(countOrText: string | number) { this._currentIndentation = this._getIndentationLevelFromArg(countOrText); return this; }
withIndentationLevel(indentationLevel: number, action: () => void): this; withIndentationLevel(whitespaceText: string, action: () => void): this; withIndentationLevel(countOrText: string | number, action: () => void) { return this._withResetIndentation(() => this.setIndentationLevel(countOrText), action); }
private _withResetIndentation(setStateAction: () => void, writeAction: () => void) { const previousState = this._getIndentationState(); setStateAction(); try { writeAction(); } finally { this._setIndentationState(previousState); } return this; }
getIndentationLevel(): number { return this._currentIndentation; }
block(block?: () => void): this { this._newLineIfNewLineOnNextWrite(); if (this.getLength() > 0 && !this.isLastNewLine()) { this.spaceIfLastNot(); } this.inlineBlock(block); this._newLineOnNextWrite = true; return this; }
inlineBlock(block?: () => void): this { this._newLineIfNewLineOnNextWrite(); this.write("{"); this._indentBlockInternal(block); this.newLineIfLastNot().write("}");
return this; }
indent(times?: number): this; indent(block: () => void): this; indent(timesOrBlock: number | (() => void) = 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; } }
private _indentBlockInternal(block?: () => void) { 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: boolean | undefined, textFunc: () => string): this; conditionalWriteLine(condition: boolean | undefined, text: string): this; conditionalWriteLine(condition: boolean | undefined, strOrFunc: string | (() => string)) { if (condition) { this.writeLine(getStringFromStrOrFunc(strOrFunc)); }
return this; }
writeLine(text: string): this { this._newLineIfNewLineOnNextWrite(); if (this.getLastChar() != null) { this.newLineIfLastNot(); } this._writeIndentingNewLines(text); this.newLine();
return this; }
newLineIfLastNot(): this { this._newLineIfNewLineOnNextWrite();
if (!this.isLastNewLine()) { this.newLine(); }
return this; }
blankLineIfLastNot(): this { if (!this.isLastBlankLine()) { this.blankLine(); } return this; }
conditionalBlankLine(condition: boolean | undefined): this { if (condition) { this.blankLine(); } return this; }
blankLine(): this { return this.newLineIfLastNot().newLine(); }
conditionalNewLine(condition: boolean | undefined): this { if (condition) { this.newLine(); } return this; }
newLine(): this { this._newLineOnNextWrite = false; this._baseWriteNewline(); return this; }
quote(): this; quote(text: string): this; quote(text?: string) { this._newLineIfNewLineOnNextWrite(); this._writeIndentingNewLines(text == null ? this._quoteChar : this._quoteChar + escapeForWithinString(text, this._quoteChar) + this._quoteChar); return this; }
spaceIfLastNot(): this { this._newLineIfNewLineOnNextWrite();
if (!this.isLastSpace()) { this._writeIndentingNewLines(" "); }
return this; }
space(times = 1): this { this._newLineIfNewLineOnNextWrite(); this._writeIndentingNewLines(" ".repeat(times)); return this; }
tabIfLastNot(): this { this._newLineIfNewLineOnNextWrite();
if (!this.isLastTab()) { this._writeIndentingNewLines("\t"); }
return this; }
tab(times = 1): this { this._newLineIfNewLineOnNextWrite(); this._writeIndentingNewLines("\t".repeat(times)); return this; }
conditionalWrite(condition: boolean | undefined, textFunc: () => string): this; conditionalWrite(condition: boolean | undefined, text: string): this; conditionalWrite(condition: boolean | undefined, textOrFunc: string | (() => string)) { if (condition) { this.write(getStringFromStrOrFunc(textOrFunc)); }
return this; }
write(text: string): this { this._newLineIfNewLineOnNextWrite(); this._writeIndentingNewLines(text); return this; }
closeComment(): this { const commentChar = this._currentCommentChar;
switch (commentChar) { case CommentChar.Line: this.newLine(); break; case CommentChar.Star: if (!this.isLastNewLine()) { this.spaceIfLastNot(); } this.write("*/"); break; default: { const _assertUndefined: undefined = commentChar; break; } }
return this; }
unsafeInsert(pos: number, text: string): this { 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) { 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 { 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."); } }
getLength(): number { return this._length; }
isInComment(): boolean { return this._currentCommentChar !== undefined; }
isAtStartOfFirstLineOfBlock(): boolean { return this.isOnFirstLineOfBlock() && (this.isLastNewLine() || this.getLastChar() == null); }
isOnFirstLineOfBlock(): boolean { return this._isOnFirstLineOfBlock; }
isInString(): boolean { return this._stringCharStack.length > 0 && this._stringCharStack[this._stringCharStack.length - 1] !== CHARS.OPEN_BRACE; }
isLastNewLine(): boolean { const lastChar = this.getLastChar(); return lastChar === "\n" || lastChar === "\r"; }
isLastBlankLine(): boolean { let foundCount = 0;
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; }
isLastSpace(): boolean { return this.getLastChar() === " "; }
isLastTab(): boolean { return this.getLastChar() === "\t"; }
getLastChar(): string | undefined { const charCode = this._getLastCharCodeWithOffset(0); return charCode == null ? undefined : String.fromCharCode(charCode); }
endsWith(text: string): boolean { 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; }
iterateLastChars<T>(action: (char: string, index: number) => T | undefined): T | undefined { return this.iterateLastCharCodes((charCode, index) => action(String.fromCharCode(charCode), index)); }
iterateLastCharCodes<T>(action: (charCode: number, index: number) => T | undefined): T | undefined { 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; }
toString(): string { if (this._texts.length > 1) { const text = this._texts.join(""); this._texts.length = 0; this._texts.push(text); }
return this._texts[0] || ""; }
private static readonly _newLineRegEx = /\r?\n/; private _writeIndentingNewLines(text: string) { 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: CodeBlockWriter, s: string) { if (!writer.isInString()) { const isAtStartOfLine = writer.isLastNewLine() || writer.getLastChar() == null; if (isAtStartOfLine) { writer._writeIndentation(); } }
writer._updateInternalState(s); writer._internalWrite(s); } }
private _baseWriteNewline() { if (this._currentCommentChar === 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(); }
private _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: CodeBlockWriter) { 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; } }); } }
private _updateInternalState(str: string) { for (let i = 0; i < str.length; i++) { const currentChar = str.charCodeAt(i);
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);
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; }
if (this._currentCommentChar == null && pastChar === CHARS.FORWARD_SLASH && currentChar === CHARS.FORWARD_SLASH) { this._currentCommentChar = CommentChar.Line; } else if (this._currentCommentChar == null && pastChar === CHARS.FORWARD_SLASH && currentChar === CHARS.ASTERISK) { this._currentCommentChar = CommentChar.Star; } else if (this._currentCommentChar === CommentChar.Star && pastChar === CHARS.ASTERISK && currentChar === CHARS.FORWARD_SLASH) { this._currentCommentChar = undefined; }
if (this.isInComment()) { continue; }
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(); } } }
_getLastCharCodeWithOffset(offset: number) { 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; }
private _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);
let text = ""; for (let i = 0; i < portion; i++) { text += this._indentationText[i]; } this._internalWrite(text); } }
private _newLineIfNewLineOnNextWrite() { if (!this._newLineOnNextWrite) { return; } this._newLineOnNextWrite = false; this.newLine(); }
private _internalWrite(text: string) { if (text.length === 0) { return; }
this._texts.push(text); this._length += text.length; }
private static readonly _spacesOrTabsRegEx = /^[ \t]*$/; private _getIndentationLevelFromArg(countOrText: string | number) { 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."); } }
private _setIndentationState(state: IndentationLevelState) { this._currentIndentation = state.current; this._queuedIndentation = state.queued; this._queuedOnlyIfNotBlock = state.queuedOnlyIfNotBlock; }
private _getIndentationState(): IndentationLevelState { return { current: this._currentIndentation, queued: this._queuedIndentation, queuedOnlyIfNotBlock: this._queuedOnlyIfNotBlock, }; }}
interface IndentationLevelState { current: number; queued: number | undefined; queuedOnlyIfNotBlock: true | undefined;}
function isRegExStart(currentChar: number, pastChar: number | undefined, pastPastChar: number | undefined) { return pastChar === CHARS.FORWARD_SLASH && currentChar !== CHARS.FORWARD_SLASH && currentChar !== CHARS.ASTERISK && pastPastChar !== CHARS.ASTERISK && pastPastChar !== CHARS.FORWARD_SLASH;}
function getIndentationText(useTabs: boolean, numberSpaces: number) { if (useTabs) { return "\t"; } return Array(numberSpaces + 1).join(" ");}
function getSpacesAndTabsCount(str: string) { let spacesCount = 0; let tabsCount = 0;
for (let i = 0; i < str.length; i++) { const charCode = str.charCodeAt(i); if (charCode === CHARS.SPACE) { spacesCount++; } else if (charCode === CHARS.TAB) { tabsCount++; } }
return { spacesCount, tabsCount };}