|
| 1 | +import type { AST } from 'svelte-eslint-parser'; |
| 2 | +import { createRule } from '../utils'; |
| 3 | +import { getSourceCode } from '../utils/compat'; |
| 4 | +import type { SourceCode } from '../types'; |
| 5 | + |
| 6 | +type ExpectedNode = AST.SvelteStartTag | AST.SvelteEndTag; |
| 7 | +type OptionValue = 'always' | 'never'; |
| 8 | +type RuleOptions = { |
| 9 | + singleline: OptionValue; |
| 10 | + multiline: OptionValue; |
| 11 | + selfClosingTag?: Omit<RuleOptions, 'selfClosingTag'>; |
| 12 | +}; |
| 13 | + |
| 14 | +function getPhrase(lineBreaks: number) { |
| 15 | + switch (lineBreaks) { |
| 16 | + case 0: { |
| 17 | + return 'no line breaks'; |
| 18 | + } |
| 19 | + case 1: { |
| 20 | + return '1 line break'; |
| 21 | + } |
| 22 | + default: { |
| 23 | + return `${lineBreaks} line breaks`; |
| 24 | + } |
| 25 | + } |
| 26 | +} |
| 27 | + |
| 28 | +function getExpectedLineBreaks( |
| 29 | + node: ExpectedNode, |
| 30 | + options: RuleOptions, |
| 31 | + type: keyof Omit<RuleOptions, 'selfClosingTag'> |
| 32 | +) { |
| 33 | + const isSelfClosingTag = node.type === 'SvelteStartTag' && node.selfClosing; |
| 34 | + if (isSelfClosingTag && options.selfClosingTag && options.selfClosingTag[type]) { |
| 35 | + return options.selfClosingTag[type] === 'always' ? 1 : 0; |
| 36 | + } |
| 37 | + |
| 38 | + return options[type] === 'always' ? 1 : 0; |
| 39 | +} |
| 40 | + |
| 41 | +type NodeData = { |
| 42 | + actualLineBreaks: number; |
| 43 | + expectedLineBreaks: number; |
| 44 | + startToken: AST.Token; |
| 45 | + endToken: AST.Token; |
| 46 | +}; |
| 47 | + |
| 48 | +function getSelfClosingData( |
| 49 | + sourceCode: SourceCode, |
| 50 | + node: AST.SvelteStartTag, |
| 51 | + options: RuleOptions |
| 52 | +): NodeData | null { |
| 53 | + const tokens = sourceCode.getTokens(node); |
| 54 | + const closingToken = tokens[tokens.length - 2]; |
| 55 | + if (closingToken.value !== '/') { |
| 56 | + return null; |
| 57 | + } |
| 58 | + |
| 59 | + const prevToken = sourceCode.getTokenBefore(closingToken)!; |
| 60 | + const type = node.loc.start.line === prevToken.loc.end.line ? 'singleline' : 'multiline'; |
| 61 | + |
| 62 | + const expectedLineBreaks = getExpectedLineBreaks(node, options, type); |
| 63 | + const actualLineBreaks = closingToken.loc.start.line - prevToken.loc.end.line; |
| 64 | + |
| 65 | + return { actualLineBreaks, expectedLineBreaks, startToken: prevToken, endToken: closingToken }; |
| 66 | +} |
| 67 | + |
| 68 | +function getNodeData( |
| 69 | + sourceCode: SourceCode, |
| 70 | + node: ExpectedNode, |
| 71 | + options: RuleOptions |
| 72 | +): NodeData | null { |
| 73 | + const closingToken = sourceCode.getLastToken(node); |
| 74 | + if (closingToken.value !== '>') { |
| 75 | + return null; |
| 76 | + } |
| 77 | + |
| 78 | + const prevToken = sourceCode.getTokenBefore(closingToken)!; |
| 79 | + const type = node.loc.start.line === prevToken.loc.end.line ? 'singleline' : 'multiline'; |
| 80 | + |
| 81 | + const expectedLineBreaks = getExpectedLineBreaks(node, options, type); |
| 82 | + const actualLineBreaks = closingToken.loc.start.line - prevToken.loc.end.line; |
| 83 | + |
| 84 | + return { actualLineBreaks, expectedLineBreaks, startToken: prevToken, endToken: closingToken }; |
| 85 | +} |
| 86 | + |
| 87 | +export default createRule('html-closing-bracket-new-line', { |
| 88 | + meta: { |
| 89 | + docs: { |
| 90 | + description: "Require or disallow a line break before tag's closing brackets", |
| 91 | + category: 'Stylistic Issues', |
| 92 | + recommended: false, |
| 93 | + conflictWithPrettier: true |
| 94 | + }, |
| 95 | + schema: [ |
| 96 | + { |
| 97 | + type: 'object', |
| 98 | + properties: { |
| 99 | + singleline: { enum: ['always', 'never'] }, |
| 100 | + multiline: { enum: ['always', 'never'] }, |
| 101 | + selfClosingTag: { |
| 102 | + type: 'object', |
| 103 | + properties: { |
| 104 | + singleline: { enum: ['always', 'never'] }, |
| 105 | + multiline: { enum: ['always', 'never'] } |
| 106 | + }, |
| 107 | + additionalProperties: false, |
| 108 | + minProperties: 1 |
| 109 | + } |
| 110 | + }, |
| 111 | + additionalProperties: false |
| 112 | + } |
| 113 | + ], |
| 114 | + messages: { |
| 115 | + expectedBeforeClosingBracket: |
| 116 | + 'Expected {{expected}} before closing bracket, but {{actual}} found.' |
| 117 | + }, |
| 118 | + fixable: 'code', |
| 119 | + type: 'suggestion' |
| 120 | + }, |
| 121 | + create(context) { |
| 122 | + const options: RuleOptions = context.options[0] ?? {}; |
| 123 | + options.singleline ??= 'never'; |
| 124 | + options.multiline ??= 'always'; |
| 125 | + |
| 126 | + const sourceCode = getSourceCode(context); |
| 127 | + |
| 128 | + return { |
| 129 | + 'SvelteStartTag, SvelteEndTag'(node: ExpectedNode) { |
| 130 | + const data = |
| 131 | + node.type === 'SvelteStartTag' && node.selfClosing |
| 132 | + ? getSelfClosingData(sourceCode, node, options) |
| 133 | + : getNodeData(sourceCode, node, options); |
| 134 | + if (!data) { |
| 135 | + return; |
| 136 | + } |
| 137 | + |
| 138 | + const { actualLineBreaks, expectedLineBreaks, startToken, endToken } = data; |
| 139 | + if (actualLineBreaks !== expectedLineBreaks) { |
| 140 | + // For SvelteEndTag, does not make sense to add a line break, so we only fix if there are extra line breaks |
| 141 | + if (node.type === 'SvelteEndTag' && expectedLineBreaks !== 0) { |
| 142 | + return; |
| 143 | + } |
| 144 | + |
| 145 | + context.report({ |
| 146 | + node, |
| 147 | + loc: { start: startToken.loc.end, end: endToken.loc.start }, |
| 148 | + messageId: 'expectedBeforeClosingBracket', |
| 149 | + data: { |
| 150 | + expected: getPhrase(expectedLineBreaks), |
| 151 | + actual: getPhrase(actualLineBreaks) |
| 152 | + }, |
| 153 | + fix(fixer) { |
| 154 | + const range: AST.Range = [startToken.range[1], endToken.range[0]]; |
| 155 | + const text = '\n'.repeat(expectedLineBreaks); |
| 156 | + return fixer.replaceTextRange(range, text); |
| 157 | + } |
| 158 | + }); |
| 159 | + } |
| 160 | + } |
| 161 | + }; |
| 162 | + } |
| 163 | +}); |
0 commit comments