/** * @author Toru Nagashima * See LICENSE file in root directory for full license. */ 'use strict' // ------------------------------------------------------------------------------ // Requirements // ------------------------------------------------------------------------------ const assert = require('assert') // ------------------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------------------ const KNOWN_NODES = new Set(['ArrayExpression', 'ArrayPattern', 'ArrowFunctionExpression', 'AssignmentExpression', 'AssignmentPattern', 'AwaitExpression', 'BinaryExpression', 'BlockStatement', 'BreakStatement', 'CallExpression', 'CatchClause', 'ClassBody', 'ClassDeclaration', 'ClassExpression', 'ConditionalExpression', 'ContinueStatement', 'DebuggerStatement', 'DoWhileStatement', 'EmptyStatement', 'ExperimentalRestProperty', 'ExperimentalSpreadProperty', 'ExportAllDeclaration', 'ExportDefaultDeclaration', 'ExportNamedDeclaration', 'ExportSpecifier', 'ExpressionStatement', 'ForInStatement', 'ForOfStatement', 'ForStatement', 'FunctionDeclaration', 'FunctionExpression', 'Identifier', 'IfStatement', 'ImportDeclaration', 'ImportDefaultSpecifier', 'ImportNamespaceSpecifier', 'ImportSpecifier', 'LabeledStatement', 'Literal', 'LogicalExpression', 'MemberExpression', 'MetaProperty', 'MethodDefinition', 'NewExpression', 'ObjectExpression', 'ObjectPattern', 'Program', 'Property', 'RestElement', 'ReturnStatement', 'SequenceExpression', 'SpreadElement', 'Super', 'SwitchCase', 'SwitchStatement', 'TaggedTemplateExpression', 'TemplateElement', 'TemplateLiteral', 'ThisExpression', 'ThrowStatement', 'TryStatement', 'UnaryExpression', 'UpdateExpression', 'VariableDeclaration', 'VariableDeclarator', 'WhileStatement', 'WithStatement', 'YieldExpression', 'VAttribute', 'VDirectiveKey', 'VDocumentFragment', 'VElement', 'VEndTag', 'VExpressionContainer', 'VFilter', 'VFilterSequenceExpression', 'VForExpression', 'VIdentifier', 'VLiteral', 'VOnExpression', 'VSlotScopeExpression', 'VStartTag', 'VText']) const LT_CHAR = /[\r\n\u2028\u2029]/ const LINES = /[^\r\n\u2028\u2029]+(?:$|\r\n|[\r\n\u2028\u2029])/g const BLOCK_COMMENT_PREFIX = /^\s*\*/ const ITERATION_OPTS = Object.freeze({ includeComments: true, filter: isNotWhitespace }) const PREFORMATTED_ELEMENT_NAMES = ['pre', 'textarea'] /** * Normalize options. * @param {number|"tab"|undefined} type The type of indentation. * @param {Object} options Other options. * @param {Object} defaultOptions The default value of options. * @returns {{indentChar:" "|"\t",indentSize:number,baseIndent:number,attribute:number,closeBracket:number,switchCase:number,alignAttributesVertically:boolean,ignores:string[]}} Normalized options. */ function parseOptions (type, options, defaultOptions) { const ret = Object.assign({ indentChar: ' ', indentSize: 2, baseIndent: 0, attribute: 1, closeBracket: 0, switchCase: 0, alignAttributesVertically: true, ignores: [] }, defaultOptions) if (Number.isSafeInteger(type)) { ret.indentSize = type } else if (type === 'tab') { ret.indentChar = '\t' ret.indentSize = 1 } if (Number.isSafeInteger(options.baseIndent)) { ret.baseIndent = options.baseIndent } if (Number.isSafeInteger(options.attribute)) { ret.attribute = options.attribute } if (Number.isSafeInteger(options.closeBracket)) { ret.closeBracket = options.closeBracket } if (Number.isSafeInteger(options.switchCase)) { ret.switchCase = options.switchCase } if (options.alignAttributesVertically != null) { ret.alignAttributesVertically = options.alignAttributesVertically } if (options.ignores != null) { ret.ignores = options.ignores } return ret } /** * Check whether the given token is an arrow. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is an arrow. */ function isArrow (token) { return token != null && token.type === 'Punctuator' && token.value === '=>' } /** * Check whether the given token is a left parenthesis. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a left parenthesis. */ function isLeftParen (token) { return token != null && token.type === 'Punctuator' && token.value === '(' } /** * Check whether the given token is a left parenthesis. * @param {Token} token The token to check. * @returns {boolean} `false` if the token is a left parenthesis. */ function isNotLeftParen (token) { return token != null && (token.type !== 'Punctuator' || token.value !== '(') } /** * Check whether the given token is a right parenthesis. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a right parenthesis. */ function isRightParen (token) { return token != null && token.type === 'Punctuator' && token.value === ')' } /** * Check whether the given token is a right parenthesis. * @param {Token} token The token to check. * @returns {boolean} `false` if the token is a right parenthesis. */ function isNotRightParen (token) { return token != null && (token.type !== 'Punctuator' || token.value !== ')') } /** * Check whether the given token is a left brace. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a left brace. */ function isLeftBrace (token) { return token != null && token.type === 'Punctuator' && token.value === '{' } /** * Check whether the given token is a right brace. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a right brace. */ function isRightBrace (token) { return token != null && token.type === 'Punctuator' && token.value === '}' } /** * Check whether the given token is a left bracket. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a left bracket. */ function isLeftBracket (token) { return token != null && token.type === 'Punctuator' && token.value === '[' } /** * Check whether the given token is a right bracket. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a right bracket. */ function isRightBracket (token) { return token != null && token.type === 'Punctuator' && token.value === ']' } /** * Check whether the given token is a semicolon. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a semicolon. */ function isSemicolon (token) { return token != null && token.type === 'Punctuator' && token.value === ';' } /** * Check whether the given token is a comma. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a comma. */ function isComma (token) { return token != null && token.type === 'Punctuator' && token.value === ',' } /** * Check whether the given token is a whitespace. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a whitespace. */ function isNotWhitespace (token) { return token != null && token.type !== 'HTMLWhitespace' } /** * Check whether the given token is a comment. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a comment. */ function isComment (token) { return token != null && (token.type === 'Block' || token.type === 'Line' || token.type === 'Shebang' || token.type.endsWith('Comment')) } /** * Check whether the given token is a comment. * @param {Token} token The token to check. * @returns {boolean} `false` if the token is a comment. */ function isNotComment (token) { return token != null && token.type !== 'Block' && token.type !== 'Line' && token.type !== 'Shebang' && !token.type.endsWith('Comment') } /** * Check whether the given node is not an empty text node. * @param {Node} node The node to check. * @returns {boolean} `false` if the token is empty text node. */ function isNotEmptyTextNode (node) { return !(node.type === 'VText' && node.value.trim() === '') } /** * Check whether the given token is a pipe operator. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a pipe operator. */ function isPipeOperator (token) { return token != null && token.type === 'Punctuator' && token.value === '|' } /** * Get the last element. * @param {Array} xs The array to get the last element. * @returns {any|undefined} The last element or undefined. */ function last (xs) { return xs.length === 0 ? undefined : xs[xs.length - 1] } /** * Check whether the node is at the beginning of line. * @param {Node} node The node to check. * @param {number} index The index of the node in the nodes. * @param {Node[]} nodes The array of nodes. * @returns {boolean} `true` if the node is at the beginning of line. */ function isBeginningOfLine (node, index, nodes) { if (node != null) { for (let i = index - 1; i >= 0; --i) { const prevNode = nodes[i] if (prevNode == null) { continue } return node.loc.start.line !== prevNode.loc.end.line } } return false } /** * Check whether a given token is a closing token which triggers unindent. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a closing token. */ function isClosingToken (token) { return token != null && ( token.type === 'HTMLEndTagOpen' || token.type === 'VExpressionEnd' || ( token.type === 'Punctuator' && ( token.value === ')' || token.value === '}' || token.value === ']' ) ) ) } /** * Creates AST event handlers for html-indent. * * @param {RuleContext} context The rule context. * @param {TokenStore} tokenStore The token store object to get tokens. * @param {Object} defaultOptions The default value of options. * @returns {object} AST event handlers. */ module.exports.defineVisitor = function create (context, tokenStore, defaultOptions) { if (!context.getFilename().endsWith('.vue')) return {} const options = parseOptions(context.options[0], context.options[1] || {}, defaultOptions) const sourceCode = context.getSourceCode() const offsets = new Map() const ignoreTokens = new Set() /** * Set offset to the given tokens. * @param {Token|Token[]} token The token to set. * @param {number} offset The offset of the tokens. * @param {Token} baseToken The token of the base offset. * @param {boolean} [trivial=false] The flag for trivial tokens. * @returns {void} */ function setOffset (token, offset, baseToken) { assert(baseToken != null, "'baseToken' should not be null or undefined.") if (Array.isArray(token)) { for (const t of token) { offsets.set(t, { baseToken, offset, baseline: false, expectedIndent: undefined }) } } else { offsets.set(token, { baseToken, offset, baseline: false, expectedIndent: undefined }) } } /** * Set baseline flag to the given token. * @param {Token} token The token to set. * @returns {void} */ function setBaseline (token) { const offsetInfo = offsets.get(token) if (offsetInfo != null) { offsetInfo.baseline = true } } /** * Sets preformatted tokens to the given element node. * @param {Node} node The node to set. * @returns {void} */ function setPreformattedTokens (node) { const endToken = (node.endTag && tokenStore.getFirstToken(node.endTag)) || tokenStore.getTokenAfter(node) const option = { includeComments: true, filter: token => token != null && ( token.type === 'HTMLText' || token.type === 'HTMLRCDataText' || token.type === 'HTMLTagOpen' || token.type === 'HTMLEndTagOpen' || token.type === 'HTMLComment' ) } for (const token of tokenStore.getTokensBetween(node.startTag, endToken, option)) { ignoreTokens.add(token) } ignoreTokens.add(endToken) } /** * Get the first and last tokens of the given node. * If the node is parenthesized, this gets the outermost parentheses. * @param {Node} node The node to get. * @param {number} [borderOffset] The least offset of the first token. Defailt is 0. This value is used to prevent false positive in the following case: `(a) => {}` The parentheses are enclosing the whole parameter part rather than the first parameter, but this offset parameter is needed to distinguish. * @returns {{firstToken:Token,lastToken:Token}} The gotten tokens. */ function getFirstAndLastTokens (node, borderOffset) { borderOffset |= 0 let firstToken = tokenStore.getFirstToken(node) let lastToken = tokenStore.getLastToken(node) // Get the outermost left parenthesis if it's parenthesized. let t, u while ((t = tokenStore.getTokenBefore(firstToken)) != null && (u = tokenStore.getTokenAfter(lastToken)) != null && isLeftParen(t) && isRightParen(u) && t.range[0] >= borderOffset) { firstToken = t lastToken = u } return { firstToken, lastToken } } /** * Process the given node list. * The first node is offsetted from the given left token. * Rest nodes are adjusted to the first node. * @param {Node[]} nodeList The node to process. * @param {Node|Token|null} left The left parenthesis token. * @param {Node|Token|null} right The right parenthesis token. * @param {number} offset The offset to set. * @param {boolean} [alignVertically=true] The flag to align vertically. If `false`, this doesn't align vertically even if the first node is not at beginning of line. * @returns {void} */ function processNodeList (nodeList, left, right, offset, alignVertically) { let t const leftToken = (left && tokenStore.getFirstToken(left)) || left const rightToken = (right && tokenStore.getFirstToken(right)) || right if (nodeList.length >= 1) { let baseToken = null let lastToken = left const alignTokensBeforeBaseToken = [] const alignTokens = [] for (let i = 0; i < nodeList.length; ++i) { const node = nodeList[i] if (node == null) { // Holes of an array. continue } const elementTokens = getFirstAndLastTokens(node, lastToken != null ? lastToken.range[1] : 0) // Collect comma/comment tokens between the last token of the previous node and the first token of this node. if (lastToken != null) { t = lastToken while ( (t = tokenStore.getTokenAfter(t, ITERATION_OPTS)) != null && t.range[1] <= elementTokens.firstToken.range[0] ) { if (baseToken == null) { alignTokensBeforeBaseToken.push(t) } else { alignTokens.push(t) } } } if (baseToken == null) { baseToken = elementTokens.firstToken } else { alignTokens.push(elementTokens.firstToken) } // Save the last token to find tokens between this node and the next node. lastToken = elementTokens.lastToken } // Check trailing commas and comments. if (rightToken != null && lastToken != null) { t = lastToken while ( (t = tokenStore.getTokenAfter(t, ITERATION_OPTS)) != null && t.range[1] <= rightToken.range[0] ) { if (baseToken == null) { alignTokensBeforeBaseToken.push(t) } else { alignTokens.push(t) } } } // Set offsets. if (leftToken != null) { setOffset(alignTokensBeforeBaseToken, offset, leftToken) } if (baseToken != null) { // Set offset to the first token. if (leftToken != null) { setOffset(baseToken, offset, leftToken) } // Set baseline. if (nodeList.some(isBeginningOfLine)) { setBaseline(baseToken) } if (alignVertically === false && leftToken != null) { // Align tokens relatively to the left token. setOffset(alignTokens, offset, leftToken) } else { // Align the rest tokens to the first token. setOffset(alignTokens, 0, baseToken) } } } if (rightToken != null) { setOffset(rightToken, 0, leftToken) } } /** * Process the given node as body. * The body node maybe a block statement or an expression node. * @param {Node} node The body node to process. * @param {Token} baseToken The base token. * @returns {void} */ function processMaybeBlock (node, baseToken) { const firstToken = getFirstAndLastTokens(node).firstToken setOffset(firstToken, isLeftBrace(firstToken) ? 0 : 1, baseToken) } /** * Collect prefix tokens of the given property. * The prefix includes `async`, `get`, `set`, `static`, and `*`. * @param {Property|MethodDefinition} node The property node to collect prefix tokens. */ function getPrefixTokens (node) { const prefixes = [] let token = tokenStore.getFirstToken(node) while (token != null && token.range[1] <= node.key.range[0]) { prefixes.push(token) token = tokenStore.getTokenAfter(token) } while (isLeftParen(last(prefixes)) || isLeftBracket(last(prefixes))) { prefixes.pop() } return prefixes } /** * Find the head of chaining nodes. * @param {Node} node The start node to find the head. * @returns {Token} The head token of the chain. */ function getChainHeadToken (node) { const type = node.type while (node.parent.type === type) { const prevToken = tokenStore.getTokenBefore(node) if (isLeftParen(prevToken)) { // The chaining is broken by parentheses. break } node = node.parent } return tokenStore.getFirstToken(node) } /** * Check whether a given token is the first token of: * * - ExpressionStatement * - VExpressionContainer * - A parameter of CallExpression/NewExpression * - An element of ArrayExpression * - An expression of SequenceExpression * * @param {Token} token The token to check. * @param {Node} belongingNode The node that the token is belonging to. * @returns {boolean} `true` if the token is the first token of an element. */ function isBeginningOfElement (token, belongingNode) { let node = belongingNode while (node != null) { const parent = node.parent const t = parent && parent.type if (t != null && (t.endsWith('Statement') || t.endsWith('Declaration'))) { return parent.range[0] === token.range[0] } if (t === 'VExpressionContainer') { if (node.range[0] !== token.range[0]) { return false } const prevToken = tokenStore.getTokenBefore(belongingNode) if (isLeftParen(prevToken)) { // It is not the first token because it is enclosed in parentheses. return false } return true } if (t === 'CallExpression' || t === 'NewExpression') { const openParen = tokenStore.getTokenAfter(parent.callee, isNotRightParen) return parent.arguments.some(param => getFirstAndLastTokens(param, openParen.range[1]).firstToken.range[0] === token.range[0] ) } if (t === 'ArrayExpression') { return parent.elements.some(element => element != null && getFirstAndLastTokens(element).firstToken.range[0] === token.range[0] ) } if (t === 'SequenceExpression') { return parent.expressions.some(expr => getFirstAndLastTokens(expr).firstToken.range[0] === token.range[0] ) } node = parent } return false } /** * Set the base indentation to a given top-level AST node. * @param {Node} node The node to set. * @param {number} expectedIndent The number of expected indent. * @returns {void} */ function processTopLevelNode (node, expectedIndent) { const token = tokenStore.getFirstToken(node) const offsetInfo = offsets.get(token) if (offsetInfo != null) { offsetInfo.expectedIndent = expectedIndent } else { offsets.set(token, { baseToken: null, offset: 0, baseline: false, expectedIndent }) } } /** * Ignore all tokens of the given node. * @param {Node} node The node to ignore. * @returns {void} */ function ignore (node) { for (const token of tokenStore.getTokens(node)) { offsets.delete(token) ignoreTokens.add(token) } } /** * Define functions to ignore nodes into the given visitor. * @param {Object} visitor The visitor to define functions to ignore nodes. * @returns {Object} The visitor. */ function processIgnores (visitor) { for (const ignorePattern of options.ignores) { const key = `${ignorePattern}:exit` if (visitor.hasOwnProperty(key)) { const handler = visitor[key] visitor[key] = function (node) { const ret = handler.apply(this, arguments) ignore(node) return ret } } else { visitor[key] = ignore } } return visitor } /** * Calculate correct indentation of the line of the given tokens. * @param {Token[]} tokens Tokens which are on the same line. * @returns {object|null} Correct indentation. If it failed to calculate then `null`. */ function getExpectedIndents (tokens) { const expectedIndents = [] for (let i = 0; i < tokens.length; ++i) { const token = tokens[i] const offsetInfo = offsets.get(token) if (offsetInfo != null) { if (offsetInfo.expectedIndent != null) { expectedIndents.push(offsetInfo.expectedIndent) } else { const baseOffsetInfo = offsets.get(offsetInfo.baseToken) if (baseOffsetInfo != null && baseOffsetInfo.expectedIndent != null && (i === 0 || !baseOffsetInfo.baseline)) { expectedIndents.push(baseOffsetInfo.expectedIndent + (offsetInfo.offset * options.indentSize)) if (baseOffsetInfo.baseline) { break } } } } } if (!expectedIndents.length) { return null } return { expectedIndent: expectedIndents[0], expectedBaseIndent: expectedIndents.reduce((a, b) => Math.min(a, b)) } } /** * Get the text of the indentation part of the line which the given token is on. * @param {Token} firstToken The first token on a line. * @returns {string} The text of indentation part. */ function getIndentText (firstToken) { const text = sourceCode.text let i = firstToken.range[0] - 1 while (i >= 0 && !LT_CHAR.test(text[i])) { i -= 1 } return text.slice(i + 1, firstToken.range[0]) } /** * Define the function which fixes the problem. * @param {Token} token The token to fix. * @param {number} actualIndent The number of actual indentaion. * @param {number} expectedIndent The number of expected indentation. * @returns {Function} The defined function. */ function defineFix (token, actualIndent, expectedIndent) { if (token.type === 'Block' && token.loc.start.line !== token.loc.end.line) { // Fix indentation in multiline block comments. const lines = sourceCode.getText(token).match(LINES) const firstLine = lines.shift() if (lines.every(l => BLOCK_COMMENT_PREFIX.test(l))) { return fixer => { const range = [token.range[0] - actualIndent, token.range[1]] const indent = options.indentChar.repeat(expectedIndent) return fixer.replaceTextRange( range, `${indent}${firstLine}${lines.map(l => l.replace(BLOCK_COMMENT_PREFIX, `${indent} *`)).join('')}` ) } } } return fixer => { const range = [token.range[0] - actualIndent, token.range[0]] const indent = options.indentChar.repeat(expectedIndent) return fixer.replaceTextRange(range, indent) } } /** * Validate the given token with the pre-calculated expected indentation. * @param {Token} token The token to validate. * @param {number} expectedIndent The expected indentation. * @param {number[]|undefined} optionalExpectedIndents The optional expected indentation. * @returns {void} */ function validateCore (token, expectedIndent, optionalExpectedIndents) { const line = token.loc.start.line const indentText = getIndentText(token) // If there is no line terminator after the `