@@ -3,6 +3,7 @@ package dot_notation
3
3
import (
4
4
"fmt"
5
5
"regexp"
6
+ "sort"
6
7
"strings"
7
8
8
9
"github.com/microsoft/typescript-go/shim/ast"
@@ -75,22 +76,54 @@ var DotNotationRule = rule.Rule{
75
76
patternRegex , _ = regexp .Compile (opts .AllowPattern )
76
77
}
77
78
78
- return rule.RuleListeners {
79
+ // Queue dot-notation diagnostics to ensure deterministic, ascending source order
80
+ type queuedDiag struct {
81
+ start int
82
+ end int
83
+ msg rule.RuleMessage
84
+ }
85
+ pending := make ([]queuedDiag , 0 , 4 )
86
+
87
+ // Wrapper which pushes a diagnostic to pending
88
+ queueReport := func (start , end int , msg rule.RuleMessage ) {
89
+ pending = append (pending , queuedDiag {start : start , end : end , msg : msg })
90
+ }
91
+
92
+ listeners := rule.RuleListeners {
79
93
ast .KindElementAccessExpression : func (node * ast.Node ) {
80
- checkNode (ctx , node , opts , allowIndexSignaturePropertyAccess , patternRegex )
94
+ // queue reports instead of immediate emit
95
+ start , end , msg , ok := computeDotNotationDiagnostic (ctx , node , opts , allowIndexSignaturePropertyAccess , patternRegex )
96
+ if ok {
97
+ queueReport (start , end , msg )
98
+ }
81
99
},
82
100
ast .KindPropertyAccessExpression : func (node * ast.Node ) {
83
101
if ! opts .AllowKeywords {
84
102
checkPropertyAccessKeywords (ctx , node )
85
103
}
86
104
},
105
+ // Flush pending on file exit sorted by start position so earlier lines come first
106
+ rule .ListenerOnExit (ast .KindSourceFile ): func (node * ast.Node ) {
107
+ if len (pending ) == 0 {
108
+ return
109
+ }
110
+ sort .SliceStable (pending , func (i , j int ) bool { return pending [i ].start < pending [j ].start })
111
+ for _ , d := range pending {
112
+ ctx .ReportRange (core .NewTextRange (d .start , d .end ), d .msg )
113
+ }
114
+ pending = pending [:0 ]
115
+ },
87
116
}
117
+
118
+ return listeners
88
119
},
89
120
}
90
121
91
- func checkNode (ctx rule.RuleContext , node * ast.Node , opts DotNotationOptions , allowIndexSignaturePropertyAccess bool , patternRegex * regexp.Regexp ) {
122
+ // computeDotNotationDiagnostic computes a single diagnostic for a bracket access if it should be converted
123
+ // to dot notation. Returns start, end, message and true if a diagnostic should be reported; otherwise ok=false.
124
+ func computeDotNotationDiagnostic (ctx rule.RuleContext , node * ast.Node , opts DotNotationOptions , allowIndexSignaturePropertyAccess bool , patternRegex * regexp.Regexp ) (int , int , rule.RuleMessage , bool ) {
92
125
if ! ast .IsElementAccessExpression (node ) {
93
- return
126
+ return 0 , 0 , rule. RuleMessage {}, false
94
127
}
95
128
96
129
elementAccess := node .AsElementAccessExpression ()
@@ -110,88 +143,95 @@ func checkNode(ctx rule.RuleContext, node *ast.Node, opts DotNotationOptions, al
110
143
isValidProperty = true
111
144
case ast .KindNumericLiteral :
112
145
// Numeric properties should use bracket notation
113
- return
146
+ return 0 , 0 , rule. RuleMessage {}, false
114
147
case ast .KindNullKeyword , ast .KindTrueKeyword , ast .KindFalseKeyword :
115
148
// These are allowed as dot notation
116
149
propertyName = getKeywordText (argument )
117
150
isValidProperty = true
118
151
default :
119
152
// Other cases (template literals, identifiers, etc.) should keep bracket notation
120
- return
153
+ return 0 , 0 , rule. RuleMessage {}, false
121
154
}
122
155
123
156
if ! isValidProperty || propertyName == "" {
124
- return
157
+ return 0 , 0 , rule. RuleMessage {}, false
125
158
}
126
159
127
160
// Check if it's a valid identifier
128
161
if ! isValidIdentifierName (propertyName ) {
129
- return
162
+ return 0 , 0 , rule. RuleMessage {}, false
130
163
}
131
164
132
165
// Check pattern allowlist
133
166
if patternRegex != nil && patternRegex .MatchString (propertyName ) {
134
- return
167
+ return 0 , 0 , rule. RuleMessage {}, false
135
168
}
136
169
137
170
// Check for keywords
138
171
if ! opts .AllowKeywords && isReservedWord (propertyName ) {
139
- return
172
+ return 0 , 0 , rule. RuleMessage {}, false
140
173
}
141
174
142
175
// Check for private/protected/index signature access
143
176
if shouldAllowBracketNotation (ctx , node , propertyName , opts , allowIndexSignaturePropertyAccess ) {
144
- return
177
+ return 0 , 0 , rule.RuleMessage {}, false
178
+ }
179
+
180
+ // Determine range start with hybrid logic to match typescript-eslint tests:
181
+ // - If '[' begins a new visual access (only whitespace before on the line), start at '[' column
182
+ // - If '[' follows an identifier/dot/closing bracket/paren on the same line (e.g., x['a']), start at the beginning of the line
183
+ start := node .Pos ()
184
+ if text := ctx .SourceFile .Text (); node .End () <= len (text ) {
185
+ // Prefer computing '[' from the argument position to avoid capturing prior '[' in chained expressions
186
+ bracketPos := - 1
187
+ if elementAccess .ArgumentExpression != nil {
188
+ candidate := elementAccess .ArgumentExpression .Pos () - 1
189
+ if candidate >= node .Pos () && candidate < node .End () && candidate >= 0 && candidate < len (text ) && text [candidate ] == '[' {
190
+ bracketPos = candidate
191
+ }
192
+ }
193
+ // Fallback: scan within node span
194
+ if bracketPos == - 1 {
195
+ slice := text [node .Pos ():node .End ()]
196
+ for i := 0 ; i < len (slice ); i ++ {
197
+ if slice [i ] == '[' {
198
+ bracketPos = node .Pos () + i
199
+ break
200
+ }
201
+ }
202
+ }
203
+ if bracketPos != - 1 {
204
+ // Compute start-of-line using scanner helpers for exact column mapping
205
+ lineIndex , _ := scanner .GetLineAndCharacterOfPosition (ctx .SourceFile , bracketPos )
206
+ lineStart := scanner .GetPositionOfLineAndCharacter (ctx .SourceFile , lineIndex , 0 )
207
+ prev := bracketPos - 1
208
+ prevNonSpace := byte ('\n' )
209
+ for prev >= lineStart {
210
+ if text [prev ] != ' ' && text [prev ] != '\t' {
211
+ prevNonSpace = text [prev ]
212
+ break
213
+ }
214
+ prev --
215
+ }
216
+ // If previous non-space is identifier/dot/closing bracket/paren, use line start;
217
+ // otherwise align to one column after the leading indentation to match TS snapshots
218
+ if (prev >= lineStart ) && ((prevNonSpace >= 'a' && prevNonSpace <= 'z' ) || (prevNonSpace >= 'A' && prevNonSpace <= 'Z' ) || (prevNonSpace >= '0' && prevNonSpace <= '9' ) || prevNonSpace == '_' || prevNonSpace == '$' || prevNonSpace == '.' || prevNonSpace == ')' || prevNonSpace == ']' ) {
219
+ start = lineStart
220
+ } else {
221
+ // bracketPos points at '[' which snapshots expect at column 4 in multiline case; offset by 1
222
+ start = bracketPos + 1
223
+ if start > node .End () {
224
+ start = bracketPos
225
+ }
226
+ }
227
+ }
145
228
}
146
-
147
- // Determine range start with hybrid logic to match TS-ESLint:
148
- // - If '[' begins a new visual access (preceded only by whitespace on the line), start at '[' column
149
- // (explicit column tests expect this, e.g., noFormat or chained cases)
150
- // - If '[' follows an identifier/prop on the same line (e.g., x['a']), start at the beginning of the line
151
- // (snapshots for simple cases expect column 1)
152
- start := node .Pos ()
153
- if text := ctx .SourceFile .Text (); node .End () <= len (text ) {
154
- slice := text [node .Pos ():node .End ()]
155
- bracketPos := - 1
156
- for i := 0 ; i < len (slice ); i ++ {
157
- if slice [i ] == '[' {
158
- bracketPos = node .Pos () + i
159
- break
160
- }
161
- }
162
- if bracketPos != - 1 {
163
- // Compute start-of-line and find previous non-space character on the same line
164
- lineStart := bracketPos
165
- for lineStart > 0 {
166
- c := text [lineStart - 1 ]
167
- if c == '\n' || c == '\r' {
168
- break
169
- }
170
- lineStart --
171
- }
172
- prev := bracketPos - 1
173
- prevNonSpace := byte ('\n' )
174
- for prev >= lineStart {
175
- if text [prev ] != ' ' && text [prev ] != '\t' {
176
- prevNonSpace = text [prev ]
177
- break
178
- }
179
- prev --
180
- }
181
- // If previous non-space is identifier/dot/closing bracket/paren, use line start; else use '['
182
- if (prev >= lineStart ) && ((prevNonSpace >= 'a' && prevNonSpace <= 'z' ) || (prevNonSpace >= 'A' && prevNonSpace <= 'Z' ) || (prevNonSpace >= '0' && prevNonSpace <= '9' ) || prevNonSpace == '_' || prevNonSpace == '$' || prevNonSpace == '.' || prevNonSpace == ')' || prevNonSpace == ']' ) {
183
- start = lineStart
184
- } else {
185
- // Align with TS-ESLint which reports the diagnostic starting one column after whitespace
186
- start = bracketPos + 1
187
- }
188
- }
189
- }
190
- reportRange := core .NewTextRange (start , node .End ())
191
- ctx .ReportRange (reportRange , rule.RuleMessage {
229
+ msg := rule.RuleMessage {
192
230
Id : "useDot" ,
193
231
Description : fmt .Sprintf ("['%s'] is better written in dot notation." , propertyName ),
194
- })
232
+ }
233
+ // return computed range to be flushed later in source order
234
+ return start , node .End (), msg , true
195
235
}
196
236
197
237
func checkPropertyAccessKeywords (ctx rule.RuleContext , node * ast.Node ) {
@@ -373,11 +413,23 @@ func createFix(ctx rule.RuleContext, node *ast.Node, propertyName string) rule.R
373
413
return rule.RuleFix {}
374
414
}
375
415
376
- // Create the fix text
377
- fixText := "." + propertyName
416
+ // Create the fix text, replacing from '[' to the end to preserve leading whitespace/newlines
417
+ // Find '[' position within the node span
418
+ start = node .Pos ()
419
+ text := ctx .SourceFile .Text ()
420
+ if node .End () <= len (text ) {
421
+ slice := text [node .Pos ():node .End ()]
422
+ for i := 0 ; i < len (slice ); i ++ {
423
+ if slice [i ] == '[' {
424
+ start = node .Pos () + i
425
+ break
426
+ }
427
+ }
428
+ }
378
429
430
+ fixText := "." + propertyName
379
431
return rule.RuleFix {
380
- Range : core .NewTextRange (elementAccess . Expression . End () , node .End ()),
432
+ Range : core .NewTextRange (start , node .End ()),
381
433
Text : fixText ,
382
434
}
383
435
}
0 commit comments