/** * @fileoverview Enforce line breaks style after opening and before closing block-level tags. * @author Yosuke Ota */ 'use strict' const utils = require('../utils') /** * @typedef { 'always' | 'never' | 'consistent' | 'ignore' } OptionType * @typedef { { singleline?: OptionType, multiline?: OptionType, maxEmptyLines?: number } } ContentsOptions * @typedef { ContentsOptions & { blocks?: { [element: string]: ContentsOptions } } } Options * @typedef { Required } ArgsOptions */ /** * @param {string} text Source code as a string. * @returns {number} */ function getLinebreakCount(text) { return text.split(/\r\n|[\r\n\u2028\u2029]/gu).length - 1 } /** * @param {number} lineBreaks */ function getPhrase(lineBreaks) { switch (lineBreaks) { case 1: return '1 line break' default: return `${lineBreaks} line breaks` } } // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ const ENUM_OPTIONS = { enum: ['always', 'never', 'consistent', 'ignore'] } module.exports = { meta: { type: 'layout', docs: { description: 'enforce line breaks after opening and before closing block-level tags', categories: undefined, url: 'https://eslint.vuejs.org/rules/block-tag-newline.html' }, fixable: 'whitespace', schema: [ { type: 'object', properties: { singleline: ENUM_OPTIONS, multiline: ENUM_OPTIONS, maxEmptyLines: { type: 'number', minimum: 0 }, blocks: { type: 'object', patternProperties: { '^(?:\\S+)$': { type: 'object', properties: { singleline: ENUM_OPTIONS, multiline: ENUM_OPTIONS, maxEmptyLines: { type: 'number', minimum: 0 } }, additionalProperties: false } }, additionalProperties: false } }, additionalProperties: false } ], messages: { unexpectedOpeningLinebreak: "There should be no line break after '<{{tag}}>'.", unexpectedClosingLinebreak: "There should be no line break before ''.", expectedOpeningLinebreak: "Expected {{expected}} after '<{{tag}}>', but {{actual}} found.", expectedClosingLinebreak: "Expected {{expected}} before '', but {{actual}} found.", missingOpeningLinebreak: "A line break is required after '<{{tag}}>'.", missingClosingLinebreak: "A line break is required before ''." } }, /** @param {RuleContext} context */ create(context) { const df = context.parserServices.getDocumentFragment && context.parserServices.getDocumentFragment() if (!df) { return {} } const sourceCode = context.getSourceCode() /** * @param {VStartTag} startTag * @param {string} beforeText * @param {number} beforeLinebreakCount * @param {'always' | 'never'} beforeOption * @param {number} maxEmptyLines * @returns {void} */ function verifyBeforeSpaces( startTag, beforeText, beforeLinebreakCount, beforeOption, maxEmptyLines ) { if (beforeOption === 'always') { if (beforeLinebreakCount === 0) { context.report({ loc: { start: startTag.loc.end, end: startTag.loc.end }, messageId: 'missingOpeningLinebreak', data: { tag: startTag.parent.name }, fix(fixer) { return fixer.insertTextAfter(startTag, '\n') } }) } else if (maxEmptyLines < beforeLinebreakCount - 1) { context.report({ loc: { start: startTag.loc.end, end: sourceCode.getLocFromIndex( startTag.range[1] + beforeText.length ) }, messageId: 'expectedOpeningLinebreak', data: { tag: startTag.parent.name, expected: getPhrase(maxEmptyLines + 1), actual: getPhrase(beforeLinebreakCount) }, fix(fixer) { return fixer.replaceTextRange( [startTag.range[1], startTag.range[1] + beforeText.length], '\n'.repeat(maxEmptyLines + 1) ) } }) } } else { if (beforeLinebreakCount > 0) { context.report({ loc: { start: startTag.loc.end, end: sourceCode.getLocFromIndex( startTag.range[1] + beforeText.length ) }, messageId: 'unexpectedOpeningLinebreak', data: { tag: startTag.parent.name }, fix(fixer) { return fixer.removeRange([ startTag.range[1], startTag.range[1] + beforeText.length ]) } }) } } } /** * @param {VEndTag} endTag * @param {string} afterText * @param {number} afterLinebreakCount * @param {'always' | 'never'} afterOption * @param {number} maxEmptyLines * @returns {void} */ function verifyAfterSpaces( endTag, afterText, afterLinebreakCount, afterOption, maxEmptyLines ) { if (afterOption === 'always') { if (afterLinebreakCount === 0) { context.report({ loc: { start: endTag.loc.start, end: endTag.loc.start }, messageId: 'missingClosingLinebreak', data: { tag: endTag.parent.name }, fix(fixer) { return fixer.insertTextBefore(endTag, '\n') } }) } else if (maxEmptyLines < afterLinebreakCount - 1) { context.report({ loc: { start: sourceCode.getLocFromIndex( endTag.range[0] - afterText.length ), end: endTag.loc.start }, messageId: 'expectedClosingLinebreak', data: { tag: endTag.parent.name, expected: getPhrase(maxEmptyLines + 1), actual: getPhrase(afterLinebreakCount) }, fix(fixer) { return fixer.replaceTextRange( [endTag.range[0] - afterText.length, endTag.range[0]], '\n'.repeat(maxEmptyLines + 1) ) } }) } } else { if (afterLinebreakCount > 0) { context.report({ loc: { start: sourceCode.getLocFromIndex( endTag.range[0] - afterText.length ), end: endTag.loc.start }, messageId: 'unexpectedOpeningLinebreak', data: { tag: endTag.parent.name }, fix(fixer) { return fixer.removeRange([ endTag.range[0] - afterText.length, endTag.range[0] ]) } }) } } } /** * @param {VElement} element * @param {ArgsOptions} options * @returns {void} */ function verifyElement(element, options) { const { startTag, endTag } = element if (startTag.selfClosing || endTag == null) { return } const text = sourceCode.text.slice(startTag.range[1], endTag.range[0]) const trimText = text.trim() if (!trimText) { return } const option = options.multiline === options.singleline ? options.singleline : /[\n\r\u2028\u2029]/u.test(text.trim()) ? options.multiline : options.singleline if (option === 'ignore') { return } const beforeText = /** @type {RegExpExecArray} */ (/^\s*/u.exec(text))[0] const afterText = /** @type {RegExpExecArray} */ (/\s*$/u.exec(text))[0] const beforeLinebreakCount = getLinebreakCount(beforeText) const afterLinebreakCount = getLinebreakCount(afterText) /** @type {'always' | 'never'} */ let beforeOption /** @type {'always' | 'never'} */ let afterOption if (option === 'always' || option === 'never') { beforeOption = option afterOption = option } else { // consistent if (beforeLinebreakCount > 0 === afterLinebreakCount > 0) { return } beforeOption = 'always' afterOption = 'always' } verifyBeforeSpaces( startTag, beforeText, beforeLinebreakCount, beforeOption, options.maxEmptyLines ) verifyAfterSpaces( endTag, afterText, afterLinebreakCount, afterOption, options.maxEmptyLines ) } /** * Normalizes a given option value. * @param { Options | undefined } option An option value to parse. * @returns { (element: VElement) => void } Verify function. */ function normalizeOptionValue(option) { if (!option) { return normalizeOptionValue({}) } /** @type {ContentsOptions} */ const contentsOptions = option /** @type {ArgsOptions} */ const options = { singleline: contentsOptions.singleline || 'consistent', multiline: contentsOptions.multiline || 'always', maxEmptyLines: contentsOptions.maxEmptyLines || 0 } const { blocks } = option if (!blocks) { return (element) => verifyElement(element, options) } return (element) => { const { name } = element const elementsOptions = blocks[name] if (!elementsOptions) { verifyElement(element, options) } else { normalizeOptionValue({ singleline: elementsOptions.singleline || options.singleline, multiline: elementsOptions.multiline || options.multiline, maxEmptyLines: elementsOptions.maxEmptyLines != null ? elementsOptions.maxEmptyLines : options.maxEmptyLines })(element) } } } const documentFragment = df const verify = normalizeOptionValue(context.options[0]) /** * @returns {VElement[]} */ function getTopLevelHTMLElements() { return documentFragment.children.filter(utils.isVElement) } return utils.defineTemplateBodyVisitor( context, {}, { /** @param {Program} node */ Program(node) { if (utils.hasInvalidEOF(node)) { return } for (const element of getTopLevelHTMLElements()) { verify(element) } } } ) } }