/** * @author Toru Nagashima * See LICENSE file in root directory for full license. */ 'use strict' // ------------------------------------------------------------------------------ // Requirements // ------------------------------------------------------------------------------ const { isArrowToken, isOpeningParenToken, isClosingParenToken, isNotOpeningParenToken, isNotClosingParenToken, isOpeningBraceToken, isClosingBraceToken, isNotOpeningBraceToken, isOpeningBracketToken, isClosingBracketToken, isSemicolonToken, isNotSemicolonToken } = require('eslint-utils') const { isComment, isNotComment, isWildcard, isExtendsKeyword, isNotWhitespace, isNotEmptyTextNode, isPipeOperator, last } = require('./indent-utils') const { defineVisitor: tsDefineVisitor } = require('./indent-ts') /** * @typedef {import('../../typings/eslint-plugin-vue/util-types/node').HasLocation} HasLocation * @typedef { { type: string } & HasLocation } MaybeNode */ // ------------------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------------------ 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'] /** * @typedef {object} IndentOptions * @property { " " | "\t" } IndentOptions.indentChar * @property {number} IndentOptions.indentSize * @property {number} IndentOptions.baseIndent * @property {number} IndentOptions.attribute * @property {object} IndentOptions.closeBracket * @property {number} IndentOptions.closeBracket.startTag * @property {number} IndentOptions.closeBracket.endTag * @property {number} IndentOptions.closeBracket.selfClosingTag * @property {number} IndentOptions.switchCase * @property {boolean} IndentOptions.alignAttributesVertically * @property {string[]} IndentOptions.ignores */ /** * @typedef {object} IndentUserOptions * @property { " " | "\t" } [IndentUserOptions.indentChar] * @property {number} [IndentUserOptions.indentSize] * @property {number} [IndentUserOptions.baseIndent] * @property {number} [IndentUserOptions.attribute] * @property {IndentOptions['closeBracket'] | number} [IndentUserOptions.closeBracket] * @property {number} [IndentUserOptions.switchCase] * @property {boolean} [IndentUserOptions.alignAttributesVertically] * @property {string[]} [IndentUserOptions.ignores] */ /** * Normalize options. * @param {number|"tab"|undefined} type The type of indentation. * @param {IndentUserOptions} options Other options. * @param {Partial} defaultOptions The default value of options. * @returns {IndentOptions} Normalized options. */ function parseOptions(type, options, defaultOptions) { /** @type {IndentOptions} */ const ret = Object.assign( { indentChar: ' ', indentSize: 2, baseIndent: 0, attribute: 1, closeBracket: { startTag: 0, endTag: 0, selfClosingTag: 0 }, switchCase: 0, alignAttributesVertically: true, ignores: [] }, defaultOptions ) if (Number.isSafeInteger(type)) { ret.indentSize = Number(type) } else if (type === 'tab') { ret.indentChar = '\t' ret.indentSize = 1 } if (options.baseIndent != null && Number.isSafeInteger(options.baseIndent)) { ret.baseIndent = options.baseIndent } if (options.attribute != null && Number.isSafeInteger(options.attribute)) { ret.attribute = options.attribute } if (Number.isSafeInteger(options.closeBracket)) { const num = Number(options.closeBracket) ret.closeBracket = { startTag: num, endTag: num, selfClosingTag: num } } else if (options.closeBracket) { ret.closeBracket = Object.assign( { startTag: 0, endTag: 0, selfClosingTag: 0 }, options.closeBracket ) } if (options.switchCase != null && 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 node is at the beginning of line. * @param {MaybeNode|null} node The node to check. * @param {number} index The index of the node in the nodes. * @param {(MaybeNode|null)[]} 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 === ']'))) ) } /** * Checks whether a given token is a optional token. * @param {Token} token The token to check. * @returns {boolean} `true` if the token is a optional token. */ function isOptionalToken(token) { return token.type === 'Punctuator' && token.value === '?.' } /** * Creates AST event handlers for html-indent. * * @param {RuleContext} context The rule context. * @param {ParserServices.TokenStore | SourceCode} tokenStore The token store object to get tokens. * @param {Partial} defaultOptions The default value of options. * @returns {NodeListener} 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() /** * @typedef { { baseToken: Token | null, offset: number, baseline: boolean, expectedIndent: number | undefined } } OffsetData */ /** @type {Map} */ const offsets = new Map() const ignoreTokens = new Set() /** * Set offset to the given tokens. * @param {Token|Token[]|null|(Token|null)[]} token The token to set. * @param {number} offset The offset of the tokens. * @param {Token} baseToken The token of the base offset. * @returns {void} */ function setOffset(token, offset, baseToken) { if (!token || token === baseToken) { return } if (Array.isArray(token)) { for (const t of token) { if (!t || t === baseToken) continue offsets.set(t, { baseToken, offset, baseline: false, expectedIndent: undefined }) } } else { offsets.set(token, { baseToken, offset, baseline: false, expectedIndent: undefined }) } } /** * Copy offset to the given tokens from srcToken. * @param {Token} token The token to set. * @param {Token} srcToken The token of the source offset. * @returns {void} */ function copyOffset(token, srcToken) { if (!token) { return } const offsetData = offsets.get(srcToken) if (!offsetData) { return } setOffset( token, offsetData.offset, /** @type {Token} */ (offsetData.baseToken) ) if (offsetData.baseline) { setBaseline(token) } const o = /** @type {OffsetData} */ (offsets.get(token)) o.expectedIndent = offsetData.expectedIndent } /** * 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 {VElement} node The node to set. * @returns {void} */ function setPreformattedTokens(node) { const endToken = (node.endTag && tokenStore.getFirstToken(node.endTag)) || tokenStore.getTokenAfter(node) /** @type {SourceCode.CursorWithSkipOptions} */ const cursorOptions = { includeComments: true, filter: (token) => token != null && (token.type === 'HTMLText' || token.type === 'HTMLRCDataText' || token.type === 'HTMLTagOpen' || token.type === 'HTMLEndTagOpen' || token.type === 'HTMLComment') } const contentTokens = endToken ? tokenStore.getTokensBetween(node.startTag, endToken, cursorOptions) : tokenStore.getTokensAfter(node.startTag, cursorOptions) for (const token of contentTokens) { 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 {MaybeNode} 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 = 0) { 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 && isOpeningParenToken(t) && isClosingParenToken(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 {(MaybeNode|null)[]} nodeList The node to process. * @param {MaybeNode|Token|null} left The left parenthesis token. * @param {MaybeNode|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) const rightToken = right && tokenStore.getFirstToken(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 && leftToken != null) { setOffset(rightToken, 0, leftToken) } } /** * Process the given node as body. * The body node maybe a block statement or an expression node. * @param {ASTNode} 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, isOpeningBraceToken(firstToken) ? 0 : 1, baseToken) } /** * Process semicolons of the given statement node. * @param {MaybeNode} node The statement node to process. * @returns {void} */ function processSemicolons(node) { const firstToken = tokenStore.getFirstToken(node) const lastToken = tokenStore.getLastToken(node) if (isSemicolonToken(lastToken) && firstToken !== lastToken) { setOffset(lastToken, 0, firstToken) } // Set to the semicolon of the previous token for semicolon-free style. // E.g., // foo // ;[1,2,3].forEach(f) const info = offsets.get(firstToken) const prevToken = tokenStore.getTokenBefore(firstToken) if ( info != null && prevToken && isSemicolonToken(prevToken) && prevToken.loc.end.line === firstToken.loc.start.line ) { offsets.set(prevToken, info) } } /** * Find the head of chaining nodes. * @param {ASTNode} 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 && node.parent.type === type) { const prevToken = tokenStore.getTokenBefore(node) if (isOpeningParenToken(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 {ASTNode} 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 && node.parent != null) { const parent = node.parent if ( parent.type.endsWith('Statement') || parent.type.endsWith('Declaration') ) { return parent.range[0] === token.range[0] } if (parent.type === 'VExpressionContainer') { if (node.range[0] !== token.range[0]) { return false } const prevToken = tokenStore.getTokenBefore(belongingNode) if (isOpeningParenToken(prevToken)) { // It is not the first token because it is enclosed in parentheses. return false } return true } if (parent.type === 'CallExpression' || parent.type === 'NewExpression') { const openParen = /** @type {Token} */ ( tokenStore.getTokenAfter(parent.callee, isNotClosingParenToken) ) return parent.arguments.some( (param) => getFirstAndLastTokens(param, openParen.range[1]).firstToken .range[0] === token.range[0] ) } if (parent.type === 'ArrayExpression') { return parent.elements.some( (element) => element != null && getFirstAndLastTokens(element).firstToken.range[0] === token.range[0] ) } if (parent.type === '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 {ASTNode} 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 {ASTNode} 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 {NodeListener} visitor The visitor to define functions to ignore nodes. * @returns {NodeListener} 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, ...args) { // @ts-expect-error const ret = handler.call(this, node, ...args) 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 { { expectedIndent: number, expectedBaseIndent: number } |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 indentation. * @param {number} expectedIndent The number of expected indentation. * @returns { (fixer: RuleFixer) => Fix } 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) => { /** @type {Range} */ 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) => { /** @type {Range} */ 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, number?]} [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 `