123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375 |
- /**
- * @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<ContentsOptions> } 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 '</{{tag}}>'.",
- expectedOpeningLinebreak:
- "Expected {{expected}} after '<{{tag}}>', but {{actual}} found.",
- expectedClosingLinebreak:
- "Expected {{expected}} before '</{{tag}}>', but {{actual}} found.",
- missingOpeningLinebreak: "A line break is required after '<{{tag}}>'.",
- missingClosingLinebreak: "A line break is required before '</{{tag}}>'."
- }
- },
- /** @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)
- }
- }
- }
- )
- }
- }
|