src/core/formatter.js
import ScopeNode from "./scopeTree"
/**
* This is the core formatter. It contains most of the logic behind the formatting,
* and it is written in such a way that it is easily extendable to other programming
* languages. To create a formatter for a new programming language, extend this class
* and follow the JavaFormatter as an example.
*/
export default class AFormatter {
/**
* Create a new AFormatter (abstract formatter).
*
* @constructor
* @param formatUnit The token to be used for line indentations.
*/
constructor(formatUnit) {
/**
* @private
*/
this.formatUnit = formatUnit
/**
* @private
*/
this.fullCodeArray = ''
/**
* @private
*/
this.startSelection = 0
/**
* @private
*/
this.endSelection = 0
/**
* @private
*/
this.snippetOffset = 0
/**
* @private
*/
this.bodyCommentToken = ''
/**
* @private
*/
this.simpleCommentToken = ''
/**
* @private
*/
this.expressionIdentifier = null
/**
* @private
*/
this.scopeEnterFunc = null
/**
* @private
*/
this.scopeExitFunc = null
/**
* @private
*/
this.identifyMethodSigFunc = null
/**
* @private
*/
this.identifySpecialStatement = null
}
/**
* Format a string of code. The string will be cut into lines and lines
* will be indented accordingly to their scope.
*
* As a reference, look at the format function in the JavaFormatter.
*
* @param code String of code to format.
* @param expressionIdentifier Function that identifies if a line qualifies as an expression:
* - A line that ends with a termination token (e.g. ';')
* - A line that defines a scope start (e.g. '\{')
* - A line that defines a scope end (e.g. '\}')
* - A line that starts with a special character (e.g. '@')
* - A line that starts with a comment (e.g. '//')
* - An empty line (e.g. '')
* @param scopeEnterFunc Function that identifies if a new scope is entered in a line.
* @param scopeExitFunc Function that identifies if a scope is exited in a line.
* @param formatCommentsFunc Function that formats comments (e.g. add space before body and
* end comment in Javadoc)
* @returns {Array} An array of formatted lines.
*/
format(code, expressionIdentifier, scopeEnterFunc, scopeExitFunc, formatCommentsFunc) {
return this.formatSnippet(code, null, null, null, expressionIdentifier, scopeEnterFunc,
scopeExitFunc, formatCommentsFunc, null, null, null, null)
}
/**
* A slight variation of format(codeString). Useful if you want to display
* a code snippet around a selection of lines.
*
* As a reference, look at the formatSnippet function in the JavaFormatter.
*
* In addition to indenting lines, formatSnippet takes a selection as a
* start and end row in a large slab of code and cuts out a snippet of
* code around this selection. The start and end of the snippet is based
* on an offset that is provided as a parameter. The offset with the start
* and end of the selection create a sort of range from which the snippet
* is taken.
*
* In the example below, the selection is identified to belong to test2()
* and thus only test2() is returned. If the method is longer than the
* offset, than only the part within the offset will be returned. No code
* is added to the range with the exception of comment lines above the
* selection, to close unfinished comments.
*
* @example
* <caption>Selection start row: 11 ---- Selection end row: 11 ---- Offset: 6 ----> Snippet range: [11 - 6, 11 + 6] = [5, 17]</caption>
*
* START:
* 1. @Test
* 2. public void test1() {
* 3. System.out.println("Test 1");
* 4. }
* 5.
* 6. // ------------------
* 7. // Perform test 2.
* 8. // ------------------
* 9. @Test
* 10. public void test2() {
* 11. System.out.println("Test 2");
* 12. }
* 13.
* 14. @Test
* 15. public void test3() {
* 16. System.out.println("Test 3");
* 17. }
* 18. ...
*
* RESULT:
* 6. // ------------------
* 7. // Perform test 2.
* 8. // ------------------
* 9. @Test
* 10. public void test2() {
* 11. System.out.println("Test 1");
* 12. }
*
* @param code The original code base in which the selection is.
* @param startRow The start row of the selection in the code base.
* @param endRow The end row of the selection in the code base.
* @param offset The offset the defines the range on which to base the
* snippet.
* @param expressionIdentifier Function that identifies if a line qualifies as an expression:
* An expression is defined as:
* - A line that ends with a termination token (e.g. ';')
* - A line that defines a scope start (e.g. '\{')
* - A line that defines a scope end (e.g. '\}')
* - A line that starts with a special character (e.g. '@')
* - A line that starts with a comment (e.g. '//')
* - An empty line (e.g. '')
* @param scopeEnterFunc Function that identifies if a new scope is entered in a line.
* @param scopeExitFunc Function that identifies if a scope is exited in a line.
* @param formatCommentsFunc Function that formats comments (e.g. add space before body and
* end comment in Javadoc)
* @param identifyMethodSigFunc Function that identifies if a line is a method signature.
* @param identifySpecialStatement Function that identifies if a line contains a special
* statement (e.g. comment or '@' in Java)
* @param bodyCommentToken The token for 'body comments' (e.g. '*' in Java)
* @param simpleCommentToken Simple comment token (e.g. '//')
* @returns {Array} An array of formatted lines that form the snippet, separated
* into prefix selection and suffix, as well as the start and
* end lines of the snippet in the original code base.
*/
formatSnippet(code, startRow, endRow, offset, expressionIdentifier, scopeEnterFunc,
scopeExitFunc, formatCommentsFunc, identifyMethodSigFunc, identifySpecialStatement,
bodyCommentToken, simpleCommentToken) {
// Initialize
this.fullCodeArray = code.split('\n')
this.startSelection = startRow
this.endSelection = endRow
this.snippetOffset = offset
this.expressionIdentifier = expressionIdentifier
this.scopeEnterFunc = scopeEnterFunc
this.scopeExitFunc = scopeExitFunc
this.formatCommentFunc = formatCommentsFunc
this.identifyMethodSigFunc = identifyMethodSigFunc
this.identifySpecialStatement = identifySpecialStatement
this.bodyCommentToken = bodyCommentToken
this.simpleCommentToken = simpleCommentToken
let codeArray = this.fullCodeArray
// In case we have a snippet, find the snippet
let snippetPresent = this.startSelection !== null && this.endSelection !== null
&& offset !== null && this.startSelection !== -1 && this.endSelection !== -1
&& offset !== -1
let snippet = []
if (snippetPresent) {
snippet = [this.fullCodeArray.slice(this.startSelection - 1, this.endSelection),
this.fullCodeArray.slice(this.startSelection - offset - 1,
this.endSelection + offset)]
codeArray = this._preFormatSnippet(snippet[1], snippet[0])
}
// Build and balance the scope tree
codeArray = codeArray.map(line => line.trim())
let scopeTree = new ScopeNode(null, null)
// copy array because it is consumed
scopeTree.build(codeArray.slice(), 0, this.scopeEnterFunc, this.scopeExitFunc)
scopeTree.balance()
// Init formatted array
let formattedArray = []
for (let i = 0; i < codeArray.length; i++) {
formattedArray[i] = ""
}
this._preFormatArray(formattedArray, scopeTree)
// Fill formatted array with code
for (let i = 0; i < codeArray.length; i++) {
formattedArray[i] += codeArray[i]
}
// Add spaces after lines that do not qualify as an expression (e.g. let str = "hello" +)
for (let i = 0; i < formattedArray.length; i++) {
if (!this.expressionIdentifier(formattedArray, i) && formattedArray.length > i + 1) {
if (this.scopeEnterFunc(formattedArray, i + 1) === null
&& this.scopeExitFunc(formattedArray, i + 1) === null) {
formattedArray[i + 1] = this.formatUnit + formattedArray[i + 1]
}
}
}
// Format comments correctly (some languages have special formatting for comments, e.g. Javadoc)
formattedArray = this.formatCommentFunc(formattedArray)
// If we don't have a snippet, return the result now
if (!snippetPresent) {
formattedArray = this._trimBeginning(formattedArray)
return this._trimEnd(formattedArray)
}
// Format prefix and suffix
let selection = this._splitSelection(formattedArray, snippet[0])
let prefixResult = this._formatPrefix(scopeTree, selection[0],
this.startSelection - selection[0].length)
let suffixResult = this._formatSuffix(selection[2],
this.endSelection + selection[2].length)
let range = [prefixResult[1], suffixResult[1]]
// Turn array into string
let selectionString = ""
if (selection[1].length > 0) {
selectionString = selection[1].reduce(((acc, line) => acc + '\n' + line))
}
return [prefixResult[0], selectionString, suffixResult[0], range]
}
/**
* Performs the following preliminary tasks:
* - Fills open comments
* - Removes extra method signatures (===> scope break so it will be removed later)
*
* @param snippet The snippet, as an array of lines, to work on.
* @param selection The selection, as an array of lines, within the snippet.
* @returns {Array} Array of pre-formatted lines.
* @private
*/
_preFormatSnippet(snippet, selection) {
let splitSnippet = this._splitSelection(snippet, selection)
let prefixArray = this._handleOpenComments(splitSnippet[0],
this.startSelection - this.snippetOffset - 1)
prefixArray = this._removeExtraMethodSigAbove(prefixArray)
let suffixArray = this._removeExtraMethodSigBelow(splitSnippet[2])
return prefixArray.concat(splitSnippet[1].concat(suffixArray))
}
/**
* Format the prefix of a snippet. The prefix is defines as all lines above the
* selection. Performs the following tasks:
* - Removes scopes that close but never open.
* - Removes empty lines at beginning of snippet.
*
* @param scopeTree The scope tree of the snippet.
* @param array The formatted snippet as an array of lines.
* @param oldStart The old start of the snippet in the original file
* (line number, starting with 1 not 0).
* @returns {Array} Formatted prefix string with the new beginning of the snippet
* in the original file (again index starting with 1).
* @private
*/
_formatPrefix(scopeTree, array, oldStart) {
let originalLength = array.length
let limit = scopeTree.getChildren()
.filter(node => node.getStart() === null && node.getEnd() !== null)
.filter(node => node.getEnd() < array.length)
.reduce((limit, current) => Math.max(limit, current.getEnd()), -1)
let result = []
for (let i = limit + 1; i < array.length; i++) {
result.push(array[i])
}
result = this._trimBeginning(result)
let offset = originalLength - result.length
if (result.length > 0) {
result = result.reduce((acc, line) => acc + '\n' + line)
} else {
result = ""
}
return [result, oldStart + offset]
}
/**
* Format the suffix of a snippet. The suffix is defines as all lines below the
* selection. Performs the following tasks:
* - Removes methods and comments belonging to method below selection.
* - Removes empty lines at end of snippet.
*
* @param codeArray The formatted suffix as an array of lines.
* @param oldEnd The old end of the snippet in the original file
* (line number, starting with 1 not 0).
* @returns {Array} Formatted suffix string with the new end of the snippet
* in the original file (again index starting with 1).
* @private
*/
_formatSuffix(codeArray, oldEnd) {
if (codeArray.length === 0) {
return codeArray
}
let originalLength = codeArray.length
let codeFound = false
let index = codeArray.length - 1
while (index >= 0) {
let line = codeArray[index].trim()
if (this.identifySpecialStatement(line) && line !== ''
&& (!line.trim().startsWith(this.simpleCommentToken.trim()) || !codeFound)) {
codeArray.splice(index, 1)
} else if (line !== '') {
codeFound = true
}
index--
}
codeArray = this._trimEnd(codeArray)
let offset = originalLength - codeArray.length
// Make sure we don't reduce an empty array
if (codeArray.length === 0) {
return codeArray
}
return [codeArray.reduce(((acc, line) => acc + '\n' + line)), oldEnd - offset]
}
/**
* Pre-formats an empty array by adding the indentations
* (the specified format unit).
*
* @param array Array of lines of code.
* @param node Node of the scope tree.
* @private
*/
_preFormatArray(array, node) {
let start = node.getStart()
let end = node.getEnd()
if (start !== null && end !== null) {
this._fillBucketRange(array, start + 1, end - 1)
} else if (start === null && end !== null) {
this._fillBucketRange(array, 0, end - 1)
} else if (start !== null && end === null) {
this._fillBucketRange(array, start + 1, array.length - 1)
}
node.getChildren().forEach(child => this._preFormatArray(array, child))
}
/**
* Adds indentations to the array from the start to the end index, inclusive.
*
* @param array Array of lines of code.
* @param start Start index of where to add indentations.
* @param end End index of where to stop adding indentations.
* @private
*/
_fillBucketRange(array, start, end) {
for (let i = start; i <= end; i++) {
array[i] += this.formatUnit
}
}
/**
* If an open comment is met, take all lines necessary to close it
* from the original file and append them to the snippet.
*
* @param codeArray Array of lines of code.
* @param startLine The start line of the snippet in the original file.
* @returns {Array} A snippet as an array of lines of code with no more
* open comments.
* @private
*/
_handleOpenComments(codeArray, startLine) {
if (codeArray.length > 0 && startLine - 1 < this.fullCodeArray.length
&& codeArray[0].trim().startsWith(this.bodyCommentToken)) {
codeArray.unshift(this.fullCodeArray[startLine - 1])
return this._handleOpenComments(codeArray, startLine - 1)
} else {
return codeArray
}
}
/**
* Remove method signatures in the prefix that do not belong to the selection.
*
* @param codeArray Array of lines of code (prefix).
* @returns {Array} Prefix with no more extra method signatures.
* @private
*/
_removeExtraMethodSigAbove(codeArray) {
if (codeArray.length === 0) {
return codeArray
}
let methodSigCount = 0
let index = codeArray.length - 1
while (index >= 0) {
let line = codeArray[index].trim()
if (this.identifyMethodSigFunc(line) && methodSigCount === 0) {
methodSigCount++
} else if (this.identifyMethodSigFunc(line) && methodSigCount === 1) {
codeArray.splice(index, 1)
methodSigCount++
} else if (this.scopeEnterFunc([line], 0) !== null && index - 1 > 0
&& methodSigCount === 1) {
if (this.identifyMethodSigFunc(codeArray[index - 1])) {
codeArray.splice(index, 1)
methodSigCount++
}
} else if (methodSigCount > 1) {
codeArray.splice(index, 1)
}
index--
}
if (this.scopeEnterFunc([codeArray[0].trim()], 0) === 0) {
codeArray.shift()
}
return codeArray
}
/**
* Remove method signatures in the suffix that do not belong to the selection.
*
* @param codeArray Array of lines of code (suffix).
* @returns {Array} Suffix with no more extra method signatures.
* @private
*/
_removeExtraMethodSigBelow(codeArray) {
let index = 0
let found = false
while (index < codeArray.length) {
let line = codeArray[index].trim()
if (this.identifyMethodSigFunc(line) || found) {
codeArray.splice(index, 1)
found = true
} else {
index++
}
}
return codeArray
}
/**
* Removes empty lines at beginning of snippet.
*
* @param codeArray Array of lines of code.
* @returns {Array} Code array with no empty lines at beginning.
* @private
*/
_trimBeginning(codeArray) {
let canTrim = true
while (canTrim && codeArray.length > 0) {
let line = codeArray[0]
if (line === "\n" || line.trim() === "") {
codeArray.shift()
} else {
canTrim = false
}
}
return codeArray
}
/**
* Removes empty lines at end of snippet.
*
* @param codeArray Array of lines of code.
* @returns {Array} Code array with no empty lines at end.
* @private
*/
_trimEnd(codeArray) {
let canTrim = true
while (canTrim && codeArray.length > 0) {
let line = codeArray[codeArray.length - 1]
if (line === "\n" || line.trim() === "") {
codeArray.pop()
} else {
canTrim = false
}
}
return codeArray
}
/**
* Splits the code array into three parts: prefix, selection, suffix.
*
* Prefix: All lines above the selection.
* Suffix: All lines below the selection.
*
* @param codeArray Array of lines of code.
* @param selection Selection to split codeArray on.
* @returns {Array} An array containing again the prefix, selection and suffix as
* arrays of lines of code.
* @private
*/
_splitSelection(codeArray, selection) {
let startArray = []
let selectionArray = []
let endArray = []
let selectionFound = false
let selectionDone = false
codeArray.forEach(line => {
if (selection.length !== 0) {
if (!selectionDone && selection.reduce((result, sLine) => result
|| line.includes(sLine.trim()), false)) {
selectionFound = true
selectionArray.push(line)
} else if (selectionFound) {
endArray.push(line)
selectionDone = true
} else {
startArray.push(line)
}
}
})
return [startArray, selectionArray, endArray]
}
}