Skip to content

Commit 33e35b7

Browse files
authored
feat(react): add jsx-tag-spacing rule (#2308)
1 parent 5dc370e commit 33e35b7

8 files changed

Lines changed: 1561 additions & 0 deletions

File tree

‎internal/plugins/react/all.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import (
4141
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/jsx_props_no_spread_multi"
4242
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/jsx_props_no_spreading"
4343
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/jsx_sort_props"
44+
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/jsx_tag_spacing"
4445
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/jsx_uses_react"
4546
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/jsx_uses_vars"
4647
"github.com/web-infra-dev/rslint/internal/plugins/react/rules/jsx_wrap_multilines"
@@ -142,6 +143,7 @@ func GetAllRules() []rule.Rule {
142143
jsx_props_no_spreading.JsxPropsNoSpreadingRule,
143144
jsx_props_no_spread_multi.JsxPropsNoSpreadMultiRule,
144145
jsx_sort_props.JsxSortPropsRule,
146+
jsx_tag_spacing.JsxTagSpacingRule,
145147
jsx_uses_react.JsxUsesReactRule,
146148
jsx_uses_vars.JsxUsesVarsRule,
147149
jsx_wrap_multilines.JsxWrapMultilinesRule,
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package jsx_tag_spacing
2+
3+
import (
4+
_ "embed"
5+
6+
"github.com/microsoft/TypeScript/tsc/shim/ast"
7+
"github.com/microsoft/TypeScript/tsc/shim/core"
8+
"github.com/microsoft/TypeScript/tsc/shim/scanner"
9+
"github.com/web-infra-dev/rslint/internal/plugins/react/reactutil"
10+
"github.com/web-infra-dev/rslint/internal/rule"
11+
"github.com/web-infra-dev/rslint/internal/utils"
12+
"github.com/web-infra-dev/rslint/internal/utils/ecmascript"
13+
)
14+
15+
//go:embed jsx_tag_spacing.schema.json
16+
var schemaJSON []byte
17+
18+
var messages = map[string]string{
19+
"selfCloseSlashNoSpace": "Whitespace is forbidden between `/` and `>`; write `/>`",
20+
"selfCloseSlashNeedSpace": "Whitespace is required between `/` and `>`; write `/ >`",
21+
"closeSlashNoSpace": "Whitespace is forbidden between `<` and `/`; write `</`",
22+
"closeSlashNeedSpace": "Whitespace is required between `<` and `/`; write `< /`",
23+
"beforeSelfCloseNoSpace": "A space is forbidden before closing bracket",
24+
"beforeSelfCloseNeedSpace": "A space is required before closing bracket",
25+
"beforeSelfCloseNeedNewline": "A newline is required before closing bracket",
26+
"afterOpenNoSpace": "A space is forbidden after opening bracket",
27+
"afterOpenNeedSpace": "A space is required after opening bracket",
28+
"beforeCloseNoSpace": "A space is forbidden before closing bracket",
29+
"beforeCloseNeedSpace": "Whitespace is required before closing bracket",
30+
"beforeCloseNeedNewline": "A newline is required before closing bracket",
31+
}
32+
33+
var JsxTagSpacingRule = rule.Rule{
34+
Name: "react/jsx-tag-spacing",
35+
Schema: rule.NewSchema(schemaJSON),
36+
Run: func(ctx rule.RuleContext, options []any) rule.RuleListeners {
37+
config := map[string]string{
38+
"closingSlash": "never", "beforeSelfClosing": "always",
39+
"afterOpening": "never", "beforeClosing": "allow",
40+
}
41+
if len(options) > 0 {
42+
if values, ok := options[0].(map[string]any); ok {
43+
for key := range config {
44+
if value, ok := values[key].(string); ok {
45+
config[key] = value
46+
}
47+
}
48+
}
49+
}
50+
51+
sf := ctx.SourceFile
52+
s := scanner.NewScanner()
53+
s.SetText(sf.Text())
54+
s.SetSkipTrivia(false)
55+
// ESLint's isSpaceBetweenTokens ignores whitespace inside comments.
56+
spaced := func(left, right core.TextRange) bool {
57+
s.ResetTokenState(left.End())
58+
for pos := left.End(); pos < right.Pos(); pos = s.TokenEnd() {
59+
// Scan skips U+2028/U+2029 even with trivia enabled.
60+
if ecmascript.SkipLeadingWhitespace(sf.Text(), pos, right.Pos()) != pos {
61+
return true
62+
}
63+
if s.Scan() == ast.KindEndOfFile {
64+
break
65+
}
66+
}
67+
return false
68+
}
69+
report := func(id string, loc, edit core.TextRange, text string) {
70+
ctx.ReportRangeWithFixes(loc, rule.RuleMessage{Id: id, Description: messages[id]}, rule.RuleFix{Range: edit, Text: text})
71+
}
72+
insert := func(pos int) core.TextRange { return core.NewTextRange(pos, pos) }
73+
checkSlash := func(left, right core.TextRange, prefix string) {
74+
loc := core.NewTextRange(left.Pos(), right.End())
75+
switch config["closingSlash"] {
76+
case "never":
77+
if spaced(left, right) {
78+
report(prefix+"NoSpace", loc, core.NewTextRange(left.End(), right.Pos()), "")
79+
}
80+
case "always":
81+
if !spaced(left, right) {
82+
if prefix == "closeSlash" {
83+
// Inserting whitespace into `</` would produce invalid TSX.
84+
ctx.ReportRange(loc, rule.RuleMessage{Id: "closeSlashNeedSpace", Description: messages["closeSlashNeedSpace"]})
85+
return
86+
}
87+
report(prefix+"NeedSpace", loc, insert(right.Pos()), " ")
88+
}
89+
}
90+
}
91+
check := func(node *ast.Node) {
92+
selfClosing := node.Kind == ast.KindJsxSelfClosingElement
93+
closing := node.Kind == ast.KindJsxClosingElement
94+
name := reactutil.GetJsxTagName(node)
95+
if closing {
96+
name = node.AsJsxClosingElement().TagName
97+
}
98+
if name == nil {
99+
return
100+
}
101+
// Only the tag's immediate children are needed for its brackets.
102+
// Walking every attribute expression repeats work for nested JSX.
103+
children := utils.GetChildren(node, sf)
104+
if len(children) < 3 || children[len(children)-1].Kind != ast.KindGreaterThanToken {
105+
return
106+
}
107+
first := utils.TrimNodeTextRange(sf, children[0])
108+
last := utils.TrimNodeTextRange(sf, children[len(children)-1])
109+
nameRange := utils.TrimNodeTextRange(sf, name)
110+
opening := first
111+
if closing {
112+
// tsgo stores `</` as one token; ESTree exposes `<` and `/`.
113+
opening = core.NewTextRange(first.End()-1, first.End())
114+
}
115+
if selfClosing {
116+
checkSlash(utils.TrimNodeTextRange(sf, children[len(children)-2]), last, "selfCloseSlash")
117+
}
118+
option := config["afterOpening"]
119+
if option != "allow" && (option != "allow-multiline" || utils.IsSameLine(sf, opening.Pos(), nameRange.Pos())) {
120+
loc := core.NewTextRange(opening.Pos(), nameRange.Pos())
121+
if (option == "never" || option == "allow-multiline") && spaced(opening, nameRange) {
122+
report("afterOpenNoSpace", loc, core.NewTextRange(opening.End(), nameRange.Pos()), "")
123+
} else if option == "always" && !spaced(opening, nameRange) {
124+
report("afterOpenNeedSpace", loc, insert(nameRange.Pos()), " ")
125+
}
126+
}
127+
if closing {
128+
checkSlash(core.NewTextRange(first.Pos(), first.Pos()+1), opening, "closeSlash")
129+
}
130+
131+
// Upstream uses the whole last attribute (or tag name) for self-closing
132+
// and proportional checks, but the final token for ordinary `>` checks.
133+
left := nameRange
134+
if attrs := reactutil.GetJsxElementAttributes(node); len(attrs) > 0 {
135+
left = utils.TrimNodeTextRange(sf, attrs[len(attrs)-1])
136+
} else if !closing {
137+
if typeArgs := node.TypeArgumentList(); typeArgs != nil {
138+
// The parser's boundary identifies the outer `>`, including
139+
// nested types and comments containing other angle brackets.
140+
left = scanner.GetRangeOfTokenAtPosition(sf, typeArgs.End())
141+
}
142+
}
143+
if selfClosing {
144+
option = config["beforeSelfClosing"]
145+
} else {
146+
option = config["beforeClosing"]
147+
if option != "allow" && option != "proportional-always" {
148+
previous, ok := utils.TokenBeforePosition(sf, last.Pos())
149+
if !ok {
150+
return
151+
}
152+
left = previous.Range()
153+
}
154+
}
155+
if option == "allow" {
156+
return
157+
}
158+
next, ok := utils.TokenAtOrAfter(sf, left.End())
159+
if !ok {
160+
return
161+
}
162+
right := next.Range()
163+
multiline := !utils.IsSameLine(sf, first.Pos(), last.End())
164+
prefix := "beforeClose"
165+
if selfClosing {
166+
prefix = "beforeSelfClose"
167+
}
168+
if option == "proportional-always" && multiline && utils.IsSameLine(sf, left.End(), right.Pos()) {
169+
report(prefix+"NeedNewline", insert(left.End()), insert(right.Pos()), "\n")
170+
return
171+
}
172+
linePos := left.Pos()
173+
if selfClosing {
174+
linePos = left.End()
175+
}
176+
if !utils.IsSameLine(sf, linePos, right.Pos()) {
177+
return
178+
}
179+
adjacent := !spaced(left, right)
180+
loc := core.NewTextRange(left.End(), right.Pos())
181+
if selfClosing {
182+
loc = insert(right.Pos())
183+
}
184+
if option == "never" && !adjacent {
185+
report(prefix+"NoSpace", loc, core.NewTextRange(left.End(), right.Pos()), "")
186+
} else if (option == "always" || !closing && option == "proportional-always") && adjacent {
187+
report(prefix+"NeedSpace", loc, insert(right.Pos()), " ")
188+
}
189+
}
190+
return rule.RuleListeners{
191+
ast.KindJsxOpeningElement: check,
192+
ast.KindJsxSelfClosingElement: check,
193+
ast.KindJsxClosingElement: check,
194+
}
195+
},
196+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# jsx-tag-spacing
2+
3+
Enforce whitespace around JSX tag brackets and slashes. This rule supports automatic fixes.
4+
5+
## Rule details
6+
7+
This rule checks the opening and closing tags of JSX elements, including self-closing tags. It does not check fragment brackets (`<>` and `</>`).
8+
9+
Examples of **incorrect** code with the default options:
10+
11+
```jsx
12+
< App/>
13+
<App / >
14+
```
15+
16+
Examples of **correct** code with the default options:
17+
18+
```jsx
19+
<App />
20+
<App value={value}></App>
21+
<App
22+
value={value}
23+
/>
24+
```
25+
26+
## Options
27+
28+
The rule accepts one object. Set any option to `"allow"` to disable that check.
29+
30+
| Option | Default | Accepted values |
31+
| ------------------- | ---------- | -------------------------------------------------------- |
32+
| `closingSlash` | `"never"` | `"always"`, `"never"`, `"allow"` |
33+
| `beforeSelfClosing` | `"always"` | `"always"`, `"never"`, `"proportional-always"`, `"allow"` |
34+
| `afterOpening` | `"never"` | `"always"`, `"never"`, `"allow-multiline"`, `"allow"` |
35+
| `beforeClosing` | `"allow"` | `"always"`, `"never"`, `"proportional-always"`, `"allow"` |
36+
37+
- `closingSlash` controls whitespace inside `</` and `/>`.
38+
- `beforeSelfClosing` controls whitespace before `/>`. A bracket on a separate line is allowed. `"proportional-always"` requires a space for single-line tags and a newline before the slash for multiline tags.
39+
- `afterOpening` controls whitespace after `<` or `</`. `"allow-multiline"` permits a line break while forbidding spaces on the same line.
40+
- `beforeClosing` controls whitespace before `>` in opening and closing tags. It does not check self-closing tags. `"proportional-always"` requires a space for single-line opening tags and the bracket on a separate line for multiline tags.
41+
42+
To require a separate closing line for multiline tags:
43+
44+
```json
45+
{
46+
"react/jsx-tag-spacing": [
47+
"error",
48+
{
49+
"beforeSelfClosing": "proportional-always",
50+
"beforeClosing": "proportional-always"
51+
}
52+
]
53+
}
54+
```
55+
56+
## Differences from upstream
57+
58+
- Rslint does not support closing tags written with whitespace between `<` and `/`, such as `<App>< /App>`. With `closingSlash: "always"`, it reports `</App>` without an automatic fix, because inserting a space would make the code invalid. Automatic fixes for self-closing tags remain available.
59+
- For tags with TypeScript type arguments, spacing is checked after the type arguments. For example, the default fix for `<App<T>/>` is `<App<T> />`; it does not insert a space before `<T>`.
60+
- With `beforeClosing: "proportional-always"`, `<App></App>` is fixed to `<App ></App>`. Tags that already have this space are accepted, so repeated fixes do not keep adding spaces.
61+
62+
## Original documentation
63+
64+
- [eslint-plugin-react: jsx-tag-spacing](https://github.com/jsx-eslint/eslint-plugin-react/blob/v7.37.5/docs/rules/jsx-tag-spacing.md)
65+
- [Source code](https://github.com/jsx-eslint/eslint-plugin-react/blob/v7.37.5/lib/rules/jsx-tag-spacing.js)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"type": "array",
3+
"items": [
4+
{
5+
"type": "object",
6+
"properties": {
7+
"closingSlash": {
8+
"enum": ["always", "never", "allow"]
9+
},
10+
"beforeSelfClosing": {
11+
"enum": ["always", "proportional-always", "never", "allow"]
12+
},
13+
"afterOpening": {
14+
"enum": ["always", "allow-multiline", "never", "allow"]
15+
},
16+
"beforeClosing": {
17+
"enum": ["always", "proportional-always", "never", "allow"]
18+
}
19+
},
20+
"default": {
21+
"closingSlash": "never",
22+
"beforeSelfClosing": "always",
23+
"afterOpening": "never",
24+
"beforeClosing": "allow"
25+
},
26+
"additionalProperties": false
27+
}
28+
],
29+
"minItems": 0,
30+
"maxItems": 1
31+
}

0 commit comments

Comments
 (0)