c15efff370f73a76cea8f4bf5d53af99aebf46d7007b3ce34c5da7085a652ff01baac55e3f19eaea313161fd27a9eb7f7e033cf0b1cdd9d4e39e9fd746f83a 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. /**
  2. * @fileoverview Enforce line breaks style after opening and before closing block-level tags.
  3. * @author Yosuke Ota
  4. */
  5. 'use strict'
  6. const utils = require('../utils')
  7. /**
  8. * @typedef { 'always' | 'never' | 'consistent' | 'ignore' } OptionType
  9. * @typedef { { singleline?: OptionType, multiline?: OptionType, maxEmptyLines?: number } } ContentsOptions
  10. * @typedef { ContentsOptions & { blocks?: { [element: string]: ContentsOptions } } } Options
  11. * @typedef { Required<ContentsOptions> } ArgsOptions
  12. */
  13. /**
  14. * @param {string} text Source code as a string.
  15. * @returns {number}
  16. */
  17. function getLinebreakCount(text) {
  18. return text.split(/\r\n|[\r\n\u2028\u2029]/gu).length - 1
  19. }
  20. /**
  21. * @param {number} lineBreaks
  22. */
  23. function getPhrase(lineBreaks) {
  24. switch (lineBreaks) {
  25. case 1:
  26. return '1 line break'
  27. default:
  28. return `${lineBreaks} line breaks`
  29. }
  30. }
  31. // ------------------------------------------------------------------------------
  32. // Rule Definition
  33. // ------------------------------------------------------------------------------
  34. const ENUM_OPTIONS = { enum: ['always', 'never', 'consistent', 'ignore'] }
  35. module.exports = {
  36. meta: {
  37. type: 'layout',
  38. docs: {
  39. description:
  40. 'enforce line breaks after opening and before closing block-level tags',
  41. categories: undefined,
  42. url: 'https://eslint.vuejs.org/rules/block-tag-newline.html'
  43. },
  44. fixable: 'whitespace',
  45. schema: [
  46. {
  47. type: 'object',
  48. properties: {
  49. singleline: ENUM_OPTIONS,
  50. multiline: ENUM_OPTIONS,
  51. maxEmptyLines: { type: 'number', minimum: 0 },
  52. blocks: {
  53. type: 'object',
  54. patternProperties: {
  55. '^(?:\\S+)$': {
  56. type: 'object',
  57. properties: {
  58. singleline: ENUM_OPTIONS,
  59. multiline: ENUM_OPTIONS,
  60. maxEmptyLines: { type: 'number', minimum: 0 }
  61. },
  62. additionalProperties: false
  63. }
  64. },
  65. additionalProperties: false
  66. }
  67. },
  68. additionalProperties: false
  69. }
  70. ],
  71. messages: {
  72. unexpectedOpeningLinebreak:
  73. "There should be no line break after '<{{tag}}>'.",
  74. unexpectedClosingLinebreak:
  75. "There should be no line break before '</{{tag}}>'.",
  76. expectedOpeningLinebreak:
  77. "Expected {{expected}} after '<{{tag}}>', but {{actual}} found.",
  78. expectedClosingLinebreak:
  79. "Expected {{expected}} before '</{{tag}}>', but {{actual}} found.",
  80. missingOpeningLinebreak: "A line break is required after '<{{tag}}>'.",
  81. missingClosingLinebreak: "A line break is required before '</{{tag}}>'."
  82. }
  83. },
  84. /** @param {RuleContext} context */
  85. create(context) {
  86. const df =
  87. context.parserServices.getDocumentFragment &&
  88. context.parserServices.getDocumentFragment()
  89. if (!df) {
  90. return {}
  91. }
  92. const sourceCode = context.getSourceCode()
  93. /**
  94. * @param {VStartTag} startTag
  95. * @param {string} beforeText
  96. * @param {number} beforeLinebreakCount
  97. * @param {'always' | 'never'} beforeOption
  98. * @param {number} maxEmptyLines
  99. * @returns {void}
  100. */
  101. function verifyBeforeSpaces(
  102. startTag,
  103. beforeText,
  104. beforeLinebreakCount,
  105. beforeOption,
  106. maxEmptyLines
  107. ) {
  108. if (beforeOption === 'always') {
  109. if (beforeLinebreakCount === 0) {
  110. context.report({
  111. loc: {
  112. start: startTag.loc.end,
  113. end: startTag.loc.end
  114. },
  115. messageId: 'missingOpeningLinebreak',
  116. data: { tag: startTag.parent.name },
  117. fix(fixer) {
  118. return fixer.insertTextAfter(startTag, '\n')
  119. }
  120. })
  121. } else if (maxEmptyLines < beforeLinebreakCount - 1) {
  122. context.report({
  123. loc: {
  124. start: startTag.loc.end,
  125. end: sourceCode.getLocFromIndex(
  126. startTag.range[1] + beforeText.length
  127. )
  128. },
  129. messageId: 'expectedOpeningLinebreak',
  130. data: {
  131. tag: startTag.parent.name,
  132. expected: getPhrase(maxEmptyLines + 1),
  133. actual: getPhrase(beforeLinebreakCount)
  134. },
  135. fix(fixer) {
  136. return fixer.replaceTextRange(
  137. [startTag.range[1], startTag.range[1] + beforeText.length],
  138. '\n'.repeat(maxEmptyLines + 1)
  139. )
  140. }
  141. })
  142. }
  143. } else {
  144. if (beforeLinebreakCount > 0) {
  145. context.report({
  146. loc: {
  147. start: startTag.loc.end,
  148. end: sourceCode.getLocFromIndex(
  149. startTag.range[1] + beforeText.length
  150. )
  151. },
  152. messageId: 'unexpectedOpeningLinebreak',
  153. data: { tag: startTag.parent.name },
  154. fix(fixer) {
  155. return fixer.removeRange([
  156. startTag.range[1],
  157. startTag.range[1] + beforeText.length
  158. ])
  159. }
  160. })
  161. }
  162. }
  163. }
  164. /**
  165. * @param {VEndTag} endTag
  166. * @param {string} afterText
  167. * @param {number} afterLinebreakCount
  168. * @param {'always' | 'never'} afterOption
  169. * @param {number} maxEmptyLines
  170. * @returns {void}
  171. */
  172. function verifyAfterSpaces(
  173. endTag,
  174. afterText,
  175. afterLinebreakCount,
  176. afterOption,
  177. maxEmptyLines
  178. ) {
  179. if (afterOption === 'always') {
  180. if (afterLinebreakCount === 0) {
  181. context.report({
  182. loc: {
  183. start: endTag.loc.start,
  184. end: endTag.loc.start
  185. },
  186. messageId: 'missingClosingLinebreak',
  187. data: { tag: endTag.parent.name },
  188. fix(fixer) {
  189. return fixer.insertTextBefore(endTag, '\n')
  190. }
  191. })
  192. } else if (maxEmptyLines < afterLinebreakCount - 1) {
  193. context.report({
  194. loc: {
  195. start: sourceCode.getLocFromIndex(
  196. endTag.range[0] - afterText.length
  197. ),
  198. end: endTag.loc.start
  199. },
  200. messageId: 'expectedClosingLinebreak',
  201. data: {
  202. tag: endTag.parent.name,
  203. expected: getPhrase(maxEmptyLines + 1),
  204. actual: getPhrase(afterLinebreakCount)
  205. },
  206. fix(fixer) {
  207. return fixer.replaceTextRange(
  208. [endTag.range[0] - afterText.length, endTag.range[0]],
  209. '\n'.repeat(maxEmptyLines + 1)
  210. )
  211. }
  212. })
  213. }
  214. } else {
  215. if (afterLinebreakCount > 0) {
  216. context.report({
  217. loc: {
  218. start: sourceCode.getLocFromIndex(
  219. endTag.range[0] - afterText.length
  220. ),
  221. end: endTag.loc.start
  222. },
  223. messageId: 'unexpectedOpeningLinebreak',
  224. data: { tag: endTag.parent.name },
  225. fix(fixer) {
  226. return fixer.removeRange([
  227. endTag.range[0] - afterText.length,
  228. endTag.range[0]
  229. ])
  230. }
  231. })
  232. }
  233. }
  234. }
  235. /**
  236. * @param {VElement} element
  237. * @param {ArgsOptions} options
  238. * @returns {void}
  239. */
  240. function verifyElement(element, options) {
  241. const { startTag, endTag } = element
  242. if (startTag.selfClosing || endTag == null) {
  243. return
  244. }
  245. const text = sourceCode.text.slice(startTag.range[1], endTag.range[0])
  246. const trimText = text.trim()
  247. if (!trimText) {
  248. return
  249. }
  250. const option =
  251. options.multiline === options.singleline
  252. ? options.singleline
  253. : /[\n\r\u2028\u2029]/u.test(text.trim())
  254. ? options.multiline
  255. : options.singleline
  256. if (option === 'ignore') {
  257. return
  258. }
  259. const beforeText = /** @type {RegExpExecArray} */ (/^\s*/u.exec(text))[0]
  260. const afterText = /** @type {RegExpExecArray} */ (/\s*$/u.exec(text))[0]
  261. const beforeLinebreakCount = getLinebreakCount(beforeText)
  262. const afterLinebreakCount = getLinebreakCount(afterText)
  263. /** @type {'always' | 'never'} */
  264. let beforeOption
  265. /** @type {'always' | 'never'} */
  266. let afterOption
  267. if (option === 'always' || option === 'never') {
  268. beforeOption = option
  269. afterOption = option
  270. } else {
  271. // consistent
  272. if (beforeLinebreakCount > 0 === afterLinebreakCount > 0) {
  273. return
  274. }
  275. beforeOption = 'always'
  276. afterOption = 'always'
  277. }
  278. verifyBeforeSpaces(
  279. startTag,
  280. beforeText,
  281. beforeLinebreakCount,
  282. beforeOption,
  283. options.maxEmptyLines
  284. )
  285. verifyAfterSpaces(
  286. endTag,
  287. afterText,
  288. afterLinebreakCount,
  289. afterOption,
  290. options.maxEmptyLines
  291. )
  292. }
  293. /**
  294. * Normalizes a given option value.
  295. * @param { Options | undefined } option An option value to parse.
  296. * @returns { (element: VElement) => void } Verify function.
  297. */
  298. function normalizeOptionValue(option) {
  299. if (!option) {
  300. return normalizeOptionValue({})
  301. }
  302. /** @type {ContentsOptions} */
  303. const contentsOptions = option
  304. /** @type {ArgsOptions} */
  305. const options = {
  306. singleline: contentsOptions.singleline || 'consistent',
  307. multiline: contentsOptions.multiline || 'always',
  308. maxEmptyLines: contentsOptions.maxEmptyLines || 0
  309. }
  310. const { blocks } = option
  311. if (!blocks) {
  312. return (element) => verifyElement(element, options)
  313. }
  314. return (element) => {
  315. const { name } = element
  316. const elementsOptions = blocks[name]
  317. if (!elementsOptions) {
  318. verifyElement(element, options)
  319. } else {
  320. normalizeOptionValue({
  321. singleline: elementsOptions.singleline || options.singleline,
  322. multiline: elementsOptions.multiline || options.multiline,
  323. maxEmptyLines:
  324. elementsOptions.maxEmptyLines != null
  325. ? elementsOptions.maxEmptyLines
  326. : options.maxEmptyLines
  327. })(element)
  328. }
  329. }
  330. }
  331. const documentFragment = df
  332. const verify = normalizeOptionValue(context.options[0])
  333. /**
  334. * @returns {VElement[]}
  335. */
  336. function getTopLevelHTMLElements() {
  337. return documentFragment.children.filter(utils.isVElement)
  338. }
  339. return utils.defineTemplateBodyVisitor(
  340. context,
  341. {},
  342. {
  343. /** @param {Program} node */
  344. Program(node) {
  345. if (utils.hasInvalidEOF(node)) {
  346. return
  347. }
  348. for (const element of getTopLevelHTMLElements()) {
  349. verify(element)
  350. }
  351. }
  352. }
  353. )
  354. }
  355. }