From b381547982addc145bd58a184a18e4e6665d2b03 Mon Sep 17 00:00:00 2001 From: Taejin Kim Date: Wed, 3 Jul 2024 11:34:57 +0900 Subject: [PATCH] [New] `jsx-closing-tag-location`: add `line-aligned` option --- CHANGELOG.md | 5 ++ docs/rules/jsx-closing-tag-location.md | 78 +++++++++++++++++ lib/rules/jsx-closing-tag-location.js | 69 +++++++++++++++- tests/lib/rules/jsx-closing-tag-location.js | 92 +++++++++++++++++++-- 4 files changed, 234 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db11b6487..e0175b618f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,15 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## Unreleased +### Added + +* [`jsx-closing-tag-location`]: add `line-aligned` option ([#3777] @kimtaejin3) + ### Fixed * [`prop-types`]: fix `className` missing in prop validation false negative ([#3749] @akulsr0) +[#3777]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3777 [#3749]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3749 ## [7.34.3] - 2024.06.18 diff --git a/docs/rules/jsx-closing-tag-location.md b/docs/rules/jsx-closing-tag-location.md index c740fc27bc..7da35c7357 100644 --- a/docs/rules/jsx-closing-tag-location.md +++ b/docs/rules/jsx-closing-tag-location.md @@ -35,6 +35,84 @@ Examples of **correct** code for this rule: marklar ``` + +## Rule Options + +There is one way to configure this rule. + +The configuration is a string shortcut corresponding to the `location` values specified below. If omitted, it defaults to `"tag-aligned"`. + +```js +"react/jsx-closing-tag-location": // -> [, "tag-aligned"] +"react/jsx-closing-tag-location": [, ""] +``` + +### `location` + +Enforced location for the closing tag. + +- `tag-aligned`: must be aligned with the opening tag. +- `line-aligned`: must be aligned with the line containing the opening tag. + +Defaults to `tag-aligned`. + +For backward compatibility, you may pass an object `{ "location": }` that is equivalent to the first string shortcut form. + +Examples of **incorrect** code for this rule: + +```jsx +// 'jsx-closing-tag-location': 1 +// 'jsx-closing-tag-location': [1, 'tag-aligned'] +// 'jsx-closing-tag-location': [1, {"location":'tag-aligned'}] + + Hello + ; + +// 'jsx-closing-tag-location': [1, 'tag-aligned'] +// 'jsx-closing-tag-location': [1, {"location":'tag-aligned'}] +const App = + Foo +; + + +// 'jsx-closing-tag-location': [1, 'line-aligned'] +// 'jsx-closing-tag-location': [1, {"location":'line-aligned'}] +const App = + Foo + ; + + +``` + +Examples of **correct** code for this rule: + +```jsx +// 'jsx-closing-tag-location': 1 +// 'jsx-closing-tag-location': [1, 'tag-aligned'] +// 'jsx-closing-tag-location': [1, {"location":'tag-aligned'}] + + Hello +; + +// 'jsx-closing-tag-location': [1, 'tag-aligned'] +// 'jsx-closing-tag-location': [1, {"location":'tag-aligned'}] +const App = + Foo + ; + +// 'jsx-closing-tag-location': [1, 'line-aligned'] +// 'jsx-closing-tag-location': [1, {"location":'line-aligned'}] +const App = + Foo +; + + +``` + ## When Not To Use It If you do not care about closing tag JSX alignment then you can disable this rule. diff --git a/lib/rules/jsx-closing-tag-location.js b/lib/rules/jsx-closing-tag-location.js index 70c78a6987..9865afed2c 100644 --- a/lib/rules/jsx-closing-tag-location.js +++ b/lib/rules/jsx-closing-tag-location.js @@ -6,9 +6,11 @@ 'use strict'; const repeat = require('string.prototype.repeat'); +const has = require('hasown'); const astUtil = require('../util/ast'); const docsUrl = require('../util/docsUrl'); +const getSourceCode = require('../util/eslint').getSourceCode; const report = require('../util/report'); // ------------------------------------------------------------------------------ @@ -18,6 +20,14 @@ const report = require('../util/report'); const messages = { onOwnLine: 'Closing tag of a multiline JSX expression must be on its own line.', matchIndent: 'Expected closing tag to match indentation of opening.', + alignWithOpening: 'Expected closing tag to be aligned with the line containing the opening tag', +}; + +const defaultOption = 'tag-aligned'; + +const optionMessageMap = { + 'tag-aligned': 'matchIndent', + 'line-aligned': 'alignWithOpening', }; /** @type {import('eslint').Rule.RuleModule} */ @@ -31,31 +41,84 @@ module.exports = { }, fixable: 'whitespace', messages, + schema: [{ + anyOf: [ + { + enum: ['tag-aligned', 'line-aligned'], + }, + { + type: 'object', + properties: { + location: { + enum: ['tag-aligned', 'line-aligned'], + }, + }, + additionalProperties: false, + }, + ], + }], }, create(context) { + const config = context.options[0]; + let option = defaultOption; + + if (typeof config === 'string') { + option = config; + } else if (typeof config === 'object') { + if (has(config, 'location')) { + option = config.location; + } + } + + function getIndentation(openingStartOfLine, opening) { + switch (option) { + case 'line-aligned': + return openingStartOfLine.column + 1; + case 'tag-aligned': + return opening.loc.start.column + 1; + default: + return null; + } + } + function handleClosingElement(node) { if (!node.parent) { return; } + const sourceCode = getSourceCode(context); const opening = node.parent.openingElement || node.parent.openingFragment; + const openingLoc = sourceCode.getFirstToken(opening).loc.start; + const openingLine = sourceCode.lines[openingLoc.line - 1]; + + const openingStartOfLine = { + column: /^\s*/.exec(openingLine)[0].length, + line: openingLoc.line, + }; + if (opening.loc.start.line === node.loc.start.line) { return; } - if (opening.loc.start.column === node.loc.start.column) { + if (opening.loc.start.column === node.loc.start.column && option === 'tag-aligned') { + return; + } + + if (openingStartOfLine.column === node.loc.start.column && option === 'line-aligned') { return; } const messageId = astUtil.isNodeFirstInLine(context, node) - ? 'matchIndent' + ? optionMessageMap[option] : 'onOwnLine'; + report(context, messages[messageId], messageId, { node, loc: node.loc, fix(fixer) { - const indent = repeat(' ', opening.loc.start.column + 1); + const indent = repeat(' ', getIndentation(openingStartOfLine, opening)); + if (astUtil.isNodeFirstInLine(context, node)) { return fixer.replaceTextRange( [node.range[0] - node.loc.start.column, node.range[0]], diff --git a/tests/lib/rules/jsx-closing-tag-location.js b/tests/lib/rules/jsx-closing-tag-location.js index 4f1b24205f..1a7d073f5f 100644 --- a/tests/lib/rules/jsx-closing-tag-location.js +++ b/tests/lib/rules/jsx-closing-tag-location.js @@ -29,6 +29,65 @@ const parserOptions = { const ruleTester = new RuleTester({ parserOptions }); ruleTester.run('jsx-closing-tag-location', rule, { valid: parsers.all([ + { + code: ` + const foo = () => { + return + bar + } + `, + options: ['line-aligned'], + }, + { + code: ` + const foo = () => { + return + bar + } + `, + }, + { + code: ` + const foo = () => { + return + bar + + } + `, + options: ['line-aligned'], + }, + { + code: ` + const foo = + bar + + `, + options: ['line-aligned'], + }, + { + code: ` + const x = + foo + + `, + }, + { + code: ` + const foo = + + bar + + `, + options: ['line-aligned'], + }, + { + code: ` + const foo = + + bar + + `, + }, { code: ` @@ -95,20 +154,39 @@ ruleTester.run('jsx-closing-tag-location', rule, { foo `, - errors: [{ messageId: 'matchIndent' }], + errors: [{ messageId: 'matchIndent' }], // here }, { code: ` - <> - foo + const x = () => { + return + foo + } `, - features: ['fragment', 'no-ts-old'], // TODO: FIXME: remove no-ts-old and fix output: ` - <> - foo - + const x = () => { + return + foo + + } `, errors: [{ messageId: 'onOwnLine' }], + options: ['line-aligned'], }, + { + code: ` + const x = + foo + + `, + output: ` + const x = + foo + + `, + errors: [{ messageId: 'alignWithOpening' }], + options: ['line-aligned'], + }, + ]), });