Skip to content

Commit a9f0099

Browse files
authored
feat: port rule @stylistic/jsx-indent-props (#1028)
1 parent 2e0a77a commit a9f0099

9 files changed

Lines changed: 1176 additions & 124 deletions

File tree

internal/plugins/react/rules/jsx_indent_props/jsx_indent_props.go

Lines changed: 144 additions & 124 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ import (
77
"github.com/web-infra-dev/rslint/internal/utils"
88
)
99

10-
// JsxIndentPropsRule enforces JSX prop indentation.
10+
// JsxIndentPropsRule is the eslint-plugin-react variant of jsx-indent-props.
11+
var JsxIndentPropsRule = BuildRule("react/jsx-indent-props")
12+
13+
// BuildRule constructs the jsx-indent-props rule registered under name. Both
14+
// the react (`react/jsx-indent-props`) and @stylistic
15+
// (`@stylistic/jsx-indent-props`) variants are produced from this single
16+
// implementation — the two upstream rules are byte-identical (the @stylistic
17+
// fork carries no behavioral delta), so only the registered Name differs.
1118
//
1219
// Ported from eslint-plugin-react's `jsx-indent-props` rule. The rule walks
1320
// every JsxOpeningElement / JsxSelfClosingElement, computes the expected
@@ -24,146 +31,159 @@ import (
2431
// ECMA line map via `scanner.ComputeLineOfPosition`.
2532
//
2633
// Ternary operator handling: upstream maintains a `line.isUsingOperator`
27-
// flag that is set when the line containing the JSX `<` starts with `?` or
28-
// `:` after whitespace. When that flag is on (and `'first'` mode is off and
29-
// `ignoreTernaryOperator` is off), the FIRST prop that sits first-in-line
30-
// receives an extra `indentSize` bump, after which the flag is cleared.
31-
// We replicate that behaviour with a per-element `bumpApplied` boolean and
32-
// a per-prop bracket reset (mirrors upstream's `useBracket` reset inside
33-
// `getNodeIndent` — any prop whose first source line contains `<` cancels
34-
// the pending bump, exactly the same way upstream's per-call side effect
35-
// would).
36-
var JsxIndentPropsRule = rule.Rule{
37-
Name: "react/jsx-indent-props",
38-
Run: func(ctx rule.RuleContext, options any) rule.RuleListeners {
39-
indentType, indentSize, indentChar, indentIsFirst, ignoreTernaryOperator := parseOptions(options)
40-
41-
text := ctx.SourceFile.Text()
42-
lineMap := ctx.SourceFile.ECMALineMap()
43-
44-
// firstNonWhitespaceCharOnLine returns the first non space / tab
45-
// character on the line containing pos, or 0 if none.
46-
firstNonWhitespaceCharOnLine := func(pos int) byte {
47-
start := reactutil.IndentLineStart(lineMap, pos)
48-
for i := start; i < len(text); i++ {
49-
c := text[i]
50-
if c == ' ' || c == '\t' {
51-
continue
52-
}
53-
if c == '\n' || c == '\r' {
54-
return 0
34+
// flag, updated by `getNodeIndent` from each scanned line, that grants the
35+
// next first-in-line prop an extra `indentSize` bump (when `'first'` mode and
36+
// `ignoreTernaryOperator` are both off). We replicate `getNodeIndent`'s
37+
// per-prop side effect with the exact same precedence: a line starting with
38+
// `?`/`:` after whitespace (useOperator) sets the flag and PRECEDES the
39+
// `<`-contains reset (useBracket). That precedence is load-bearing for the
40+
// shape where the first prop shares the opening tag's `?`/`:` line — the line
41+
// both starts with the operator and contains `<`, and upstream keeps the flag
42+
// set so the bump carries to the following props.
43+
func BuildRule(name string) rule.Rule {
44+
return rule.Rule{
45+
Name: name,
46+
Run: func(ctx rule.RuleContext, options any) rule.RuleListeners {
47+
indentType, indentSize, indentChar, indentIsFirst, ignoreTernaryOperator := parseOptions(options)
48+
49+
text := ctx.SourceFile.Text()
50+
lineMap := ctx.SourceFile.ECMALineMap()
51+
52+
// firstNonWhitespaceCharOnLine returns the first non space / tab
53+
// character on the line containing pos, or 0 if none.
54+
firstNonWhitespaceCharOnLine := func(pos int) byte {
55+
start := reactutil.IndentLineStart(lineMap, pos)
56+
for i := start; i < len(text); i++ {
57+
c := text[i]
58+
if c == ' ' || c == '\t' {
59+
continue
60+
}
61+
if c == '\n' || c == '\r' {
62+
return 0
63+
}
64+
return c
5565
}
56-
return c
66+
return 0
5767
}
58-
return 0
59-
}
6068

61-
// firstLineContainsBracket reports whether the first source line
62-
// of node (from line-start of trimmed start to the next newline
63-
// or the trimmed end, whichever comes first) contains a `<`.
64-
// Mirrors upstream's `useBracket` check inside `getNodeIndent`,
65-
// which resets `isUsingOperator` whenever the scanned line
66-
// includes a `<` (i.e. the prop is on the same line as the
67-
// element's opening `<`, or carries an inline JSX-literal value).
68-
firstLineContainsBracket := func(node *ast.Node) bool {
69-
trimmed := utils.TrimNodeTextRange(ctx.SourceFile, node)
70-
lineStart := reactutil.IndentLineStart(lineMap, trimmed.Pos())
71-
end := trimmed.End()
72-
for i := lineStart; i < end && i < len(text); i++ {
73-
c := text[i]
74-
if c == '\n' {
75-
break
76-
}
77-
if c == '<' {
78-
return true
69+
// firstLineContainsBracket reports whether the first source line
70+
// of node (from line-start of trimmed start to the next newline
71+
// or the trimmed end, whichever comes first) contains a `<`.
72+
// Mirrors upstream's `useBracket` check inside `getNodeIndent`,
73+
// which resets `isUsingOperator` whenever the scanned line
74+
// includes a `<` (i.e. the prop is on the same line as the
75+
// element's opening `<`, or carries an inline JSX-literal value).
76+
firstLineContainsBracket := func(node *ast.Node) bool {
77+
trimmed := utils.TrimNodeTextRange(ctx.SourceFile, node)
78+
lineStart := reactutil.IndentLineStart(lineMap, trimmed.Pos())
79+
end := trimmed.End()
80+
for i := lineStart; i < end && i < len(text); i++ {
81+
c := text[i]
82+
if c == '\n' {
83+
break
84+
}
85+
if c == '<' {
86+
return true
87+
}
7988
}
89+
return false
8090
}
81-
return false
82-
}
8391

84-
check := func(node *ast.Node) {
85-
var props []*ast.Node
86-
switch node.Kind {
87-
case ast.KindJsxOpeningElement:
88-
opening := node.AsJsxOpeningElement()
89-
if opening.Attributes != nil {
90-
attrs := opening.Attributes.AsJsxAttributes()
91-
if attrs != nil && attrs.Properties != nil {
92-
props = attrs.Properties.Nodes
92+
check := func(node *ast.Node) {
93+
var props []*ast.Node
94+
switch node.Kind {
95+
case ast.KindJsxOpeningElement:
96+
opening := node.AsJsxOpeningElement()
97+
if opening.Attributes != nil {
98+
attrs := opening.Attributes.AsJsxAttributes()
99+
if attrs != nil && attrs.Properties != nil {
100+
props = attrs.Properties.Nodes
101+
}
93102
}
94-
}
95-
case ast.KindJsxSelfClosingElement:
96-
self := node.AsJsxSelfClosingElement()
97-
if self.Attributes != nil {
98-
attrs := self.Attributes.AsJsxAttributes()
99-
if attrs != nil && attrs.Properties != nil {
100-
props = attrs.Properties.Nodes
103+
case ast.KindJsxSelfClosingElement:
104+
self := node.AsJsxSelfClosingElement()
105+
if self.Attributes != nil {
106+
attrs := self.Attributes.AsJsxAttributes()
107+
if attrs != nil && attrs.Properties != nil {
108+
props = attrs.Properties.Nodes
109+
}
101110
}
102111
}
103-
}
104-
if len(props) == 0 {
105-
return
106-
}
107-
108-
openingTrimmed := utils.TrimNodeTextRange(ctx.SourceFile, node)
109-
openingStart := openingTrimmed.Pos()
110-
111-
// isUsingOperator: line containing the opening `<` starts
112-
// with `?` or `:` after whitespace.
113-
leadChar := firstNonWhitespaceCharOnLine(openingStart)
114-
isUsingOperator := leadChar == '?' || leadChar == ':'
115-
116-
elementIndent := reactutil.IndentLeading(text, lineMap, openingStart, indentChar)
117-
118-
var propIndent int
119-
if indentIsFirst {
120-
// 'first' mode aligns to the visual column of the
121-
// first prop. Use UTF-16 character column (matches
122-
// ESLint's `loc.start.column`) instead of byte
123-
// offset so multi-byte characters preceding the
124-
// first prop don't inflate the expected indent.
125-
propIndent = reactutil.NodeStartUTF16Column(ctx.SourceFile, props[0])
126-
} else {
127-
propIndent = elementIndent + indentSize
128-
}
129-
130-
nestedIndent := propIndent
131-
bumpApplied := false
132-
133-
for _, prop := range props {
134-
// Mirror upstream: `getNodeIndent(node)` is called per
135-
// prop regardless of first-in-line status, and resets
136-
// `isUsingOperator` when the scanned line contains a
137-
// `<`. Apply the same reset here so a prop whose first
138-
// source line includes `<` (e.g. on the element's own
139-
// `<` line, or carrying an inline JSX-literal value)
140-
// cancels the bump.
141-
if firstLineContainsBracket(prop) {
142-
isUsingOperator = false
112+
if len(props) == 0 {
113+
return
143114
}
144115

145-
if !reactutil.IsNodeFirstInLine(ctx.SourceFile, prop) {
146-
continue
116+
openingTrimmed := utils.TrimNodeTextRange(ctx.SourceFile, node)
117+
openingStart := openingTrimmed.Pos()
118+
119+
// isUsingOperator: line containing the opening `<` starts
120+
// with `?` or `:` after whitespace.
121+
leadChar := firstNonWhitespaceCharOnLine(openingStart)
122+
isUsingOperator := leadChar == '?' || leadChar == ':'
123+
124+
elementIndent := reactutil.IndentLeading(text, lineMap, openingStart, indentChar)
125+
126+
var propIndent int
127+
if indentIsFirst {
128+
// 'first' mode aligns to the visual column of the
129+
// first prop. Use UTF-16 character column (matches
130+
// ESLint's `loc.start.column`) instead of byte
131+
// offset so multi-byte characters preceding the
132+
// first prop don't inflate the expected indent.
133+
propIndent = reactutil.NodeStartUTF16Column(ctx.SourceFile, props[0])
134+
} else {
135+
propIndent = elementIndent + indentSize
147136
}
148137

149-
if !bumpApplied && isUsingOperator && !indentIsFirst && !ignoreTernaryOperator {
150-
nestedIndent += indentSize
151-
bumpApplied = true
152-
isUsingOperator = false
153-
}
138+
nestedIndent := propIndent
139+
140+
for _, prop := range props {
141+
// Mirror upstream's getNodeIndent side effect, which runs
142+
// for EVERY prop (before the first-in-line report gate). It
143+
// updates the ternary-operator state from the prop's first
144+
// source line, with useOperator (line starts with `?`/`:`
145+
// after whitespace) taking PRIORITY over useBracket (line
146+
// contains `<`) — exactly upstream's if/else ordering. The
147+
// priority matters when the first prop shares the opening
148+
// tag's `?`/`:` line: that line both starts with the
149+
// operator and contains `<`, and upstream keeps
150+
// isUsingOperator set so the bump carries to the next prop.
151+
propLead := firstNonWhitespaceCharOnLine(utils.TrimNodeTextRange(ctx.SourceFile, prop).Pos())
152+
currentOperator := false
153+
if propLead == '?' || propLead == ':' {
154+
isUsingOperator = true
155+
currentOperator = true
156+
} else if firstLineContainsBracket(prop) {
157+
isUsingOperator = false
158+
}
159+
160+
// Bump decision, also run for every prop. A prop whose own
161+
// line starts with the operator (currentOperator) does not
162+
// consume the bump; the first following prop that doesn't
163+
// re-arm the operator does. Applying it clears
164+
// isUsingOperator so the bump fires at most once.
165+
if isUsingOperator && !currentOperator && !indentIsFirst && !ignoreTernaryOperator {
166+
nestedIndent += indentSize
167+
isUsingOperator = false
168+
}
169+
170+
if !reactutil.IsNodeFirstInLine(ctx.SourceFile, prop) {
171+
continue
172+
}
154173

155-
actualIndent := reactutil.NodeStartIndent(ctx.SourceFile, prop, indentChar)
156-
if actualIndent != nestedIndent {
157-
reactutil.ReportIndentReplaceLeading(ctx, prop, nestedIndent, actualIndent, indentChar, indentType)
174+
actualIndent := reactutil.NodeStartIndent(ctx.SourceFile, prop, indentChar)
175+
if actualIndent != nestedIndent {
176+
reactutil.ReportIndentReplaceLeading(ctx, prop, nestedIndent, actualIndent, indentChar, indentType)
177+
}
158178
}
159179
}
160-
}
161180

162-
return rule.RuleListeners{
163-
ast.KindJsxOpeningElement: check,
164-
ast.KindJsxSelfClosingElement: check,
165-
}
166-
},
181+
return rule.RuleListeners{
182+
ast.KindJsxOpeningElement: check,
183+
ast.KindJsxSelfClosingElement: check,
184+
}
185+
},
186+
}
167187
}
168188

169189
// parseOptions parses the rule options. The first positional option may be:

internal/plugins/react/rules/jsx_indent_props/jsx_indent_props_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,31 @@ func TestJsxIndentPropsRule(t *testing.T) {
406406
Tsx: true,
407407
Options: []interface{}{map[string]interface{}{"indentMode": "spaces"}},
408408
},
409+
410+
// First prop sharing the opening tag's `?`/`:` line: upstream's
411+
// getNodeIndent gives useOperator (line starts with the operator)
412+
// priority over useBracket (line contains `<`), so the operator state
413+
// survives the `<` and the bump carries to the new-line props.
414+
// Confirmed valid via ESLint differential. (Regression guard: an
415+
// earlier port reset the state on `<` unconditionally and reported
416+
// false positives on these.)
417+
{
418+
Code: "\n const x = cond\n ? <App foo\n bar\n />\n : null\n ",
419+
Tsx: true,
420+
Options: []interface{}{float64(2)},
421+
},
422+
// `:` alternate is symmetric.
423+
{
424+
Code: "\n const x = cond\n ? null\n : <App foo\n bar\n />\n ",
425+
Tsx: true,
426+
Options: []interface{}{float64(2)},
427+
},
428+
// Multiple new-line props after the operator-line first prop — all bumped.
429+
{
430+
Code: "\n const y = cond\n ? <App foo\n bar\n baz\n />\n : null\n ",
431+
Tsx: true,
432+
Options: []interface{}{float64(2)},
433+
},
409434
}, []rule_tester.InvalidTestCase{
410435
// ---- Default 4-space indent — child at 10 must be 12. ----
411436
{
@@ -810,5 +835,19 @@ func TestJsxIndentPropsRule(t *testing.T) {
810835
},
811836
},
812837
},
838+
839+
// First prop on the `?`/`:` line, new-line prop at the UN-bumped column
840+
// (12) — must report needs 14 (propIndent 12 + bump 2), proving the
841+
// ternary bump is applied. Direct regression guard for useOperator
842+
// preceding useBracket in the per-prop side effect.
843+
{
844+
Code: "\n const x = cond\n ? <App foo\n bar\n />\n : null\n ",
845+
Output: []string{"\n const x = cond\n ? <App foo\n bar\n />\n : null\n "},
846+
Tsx: true,
847+
Options: []interface{}{float64(2)},
848+
Errors: []rule_tester.InvalidTestCaseError{
849+
{MessageId: "wrongIndent", Message: "Expected indentation of 14 space characters but found 12.", Line: 4, Column: 13},
850+
},
851+
},
813852
})
814853
}

internal/plugins/stylistic/all.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/web-infra-dev/rslint/internal/plugins/stylistic/rules/jsx_equals_spacing"
2222
"github.com/web-infra-dev/rslint/internal/plugins/stylistic/rules/jsx_first_prop_new_line"
2323
"github.com/web-infra-dev/rslint/internal/plugins/stylistic/rules/jsx_function_call_newline"
24+
"github.com/web-infra-dev/rslint/internal/plugins/stylistic/rules/jsx_indent_props"
2425
"github.com/web-infra-dev/rslint/internal/rule"
2526
)
2627

@@ -46,5 +47,6 @@ func GetAllRules() []rule.Rule {
4647
jsx_equals_spacing.JsxEqualsSpacingRule,
4748
jsx_first_prop_new_line.JsxFirstPropNewLineRule,
4849
jsx_function_call_newline.JsxFunctionCallNewlineRule,
50+
jsx_indent_props.JsxIndentPropsRule,
4951
}
5052
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Package jsx_indent_props ports `@stylistic/jsx-indent-props` to rslint. It
2+
// enforces a consistent indentation style for the props of a JSX element:
3+
// either N spaces / one tab relative to the element's own indent, or visual
4+
// alignment with the column of the first prop (`'first'` mode).
5+
//
6+
// The @stylistic rule is a byte-identical fork of `react/jsx-indent-props` (no
7+
// behavioral delta — their test suites match case-for-case), so the full
8+
// implementation is shared via the react rule's BuildRule; only the registered
9+
// name differs.
10+
package jsx_indent_props
11+
12+
import (
13+
reactRule "github.com/web-infra-dev/rslint/internal/plugins/react/rules/jsx_indent_props"
14+
"github.com/web-infra-dev/rslint/internal/rule"
15+
)
16+
17+
// JsxIndentPropsRule is the @stylistic/eslint-plugin variant of
18+
// jsx-indent-props.
19+
var JsxIndentPropsRule rule.Rule = reactRule.BuildRule("@stylistic/jsx-indent-props")

0 commit comments

Comments
 (0)