@ttsc/lint 0.12.4 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/index.js +225 -135
  2. package/lib/index.js.map +1 -1
  3. package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
  4. package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
  5. package/linthost/ast_helpers.go +68 -16
  6. package/linthost/compile.go +117 -119
  7. package/linthost/config.go +518 -707
  8. package/linthost/config_format.go +16 -4
  9. package/linthost/contrib_adapter.go +7 -0
  10. package/linthost/directives.go +44 -0
  11. package/linthost/engine.go +152 -44
  12. package/linthost/fix.go +24 -33
  13. package/linthost/flags_gen.go +33 -0
  14. package/linthost/format.go +96 -3
  15. package/linthost/host.go +144 -8
  16. package/linthost/print_dispatch.go +121 -23
  17. package/linthost/print_doc.go +19 -0
  18. package/linthost/print_engine.go +168 -4
  19. package/linthost/print_nodes_array.go +17 -7
  20. package/linthost/print_nodes_call.go +129 -20
  21. package/linthost/print_nodes_function.go +353 -0
  22. package/linthost/print_nodes_imports.go +46 -29
  23. package/linthost/print_nodes_list.go +86 -5
  24. package/linthost/print_nodes_object.go +56 -11
  25. package/linthost/rules_escape.go +20 -3
  26. package/linthost/rules_format_print_width.go +267 -15
  27. package/linthost/rules_gap.go +55 -3
  28. package/linthost/rules_logic.go +64 -5
  29. package/linthost/rules_problems.go +65 -21
  30. package/linthost/rules_promise.go +3 -0
  31. package/linthost/rules_suggestions.go +160 -5
  32. package/linthost/rules_var.go +7 -1
  33. package/package.json +3 -3
  34. package/src/index.ts +243 -168
  35. package/src/structures/ITtscLintPluginConfig.ts +10 -83
  36. package/src/structures/TtscLintRuleOptions.ts +15 -0
  37. package/linthost/eslint_runtime.go +0 -351
@@ -2,6 +2,7 @@ package linthost
2
2
 
3
3
  import (
4
4
  shimast "github.com/microsoft/typescript-go/shim/ast"
5
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
5
6
  )
6
7
 
7
8
  // printObjectLiteral renders an ObjectLiteralExpression with width-aware
@@ -22,30 +23,74 @@ import (
22
23
  // The flat form uses a single space inside the braces, matching
23
24
  // Prettier's `bracketSpacing: true` default. Empty object literals
24
25
  // collapse to `{}` with no inner space, matching every formatter.
25
- func printObjectLiteral(ctx *PrintContext, node *shimast.Node) Doc {
26
+ //
27
+ // The second return value is the `covered` flag: see PrintNode. It is
28
+ // the AND of every property's coverage — one multi-line verbatim
29
+ // member taints the whole literal.
30
+ func printObjectLiteral(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
26
31
  if node == nil {
27
- return Doc{}
32
+ return Doc{}, true
28
33
  }
29
34
  obj := node.AsObjectLiteralExpression()
30
35
  if obj == nil || obj.Properties == nil {
31
- return verbatim(ctx, node)
36
+ return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
32
37
  }
33
38
  items := make([]Doc, 0, len(obj.Properties.Nodes))
39
+ covered := true
34
40
  for _, prop := range obj.Properties.Nodes {
35
41
  if prop == nil {
36
42
  // A nil child entry would render as an empty Doc and surface
37
43
  // as `a, , b` in the output. Bail to verbatim so the source
38
44
  // bytes round-trip unchanged.
39
- return verbatim(ctx, node)
45
+ return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
40
46
  }
41
- doc, _ := PrintNode(ctx, prop)
47
+ doc, childCovered := PrintNode(ctx, prop)
48
+ covered = covered && childCovered
42
49
  items = append(items, doc)
43
50
  }
51
+ // objectWrap:"preserve" — keep a non-empty object expanded when the
52
+ // source wrote a newline after `{`. An empty literal has no first
53
+ // property to anchor the check and never preserves.
54
+ forceBreak := false
55
+ if len(obj.Properties.Nodes) > 0 {
56
+ forceBreak = objectHasNewlineAfterBrace(ctx.Source, node, obj.Properties.Nodes[0])
57
+ }
58
+ // AddComma honors `format.trailingComma`: object literals accept
59
+ // trailing commas in ES5 so both "all" and "es5" keep them; only
60
+ // "none" suppresses. Pairs with the call/array branches so the printer
61
+ // never disagrees with the trailing-comma rule on the same setting.
44
62
  return printList(ctx, listShape{
45
- OpenTok: "{",
46
- CloseTok: "}",
47
- Items: items,
48
- Space: true,
49
- AddComma: true,
50
- })
63
+ OpenTok: "{",
64
+ CloseTok: "}",
65
+ Items: items,
66
+ Space: true,
67
+ AddComma: ctx.allowsEs5TrailingComma(),
68
+ ForceBreak: forceBreak,
69
+ }), covered
70
+ }
71
+
72
+ // objectHasNewlineAfterBrace reports whether the source places a
73
+ // newline between the object literal's `{` and its first property.
74
+ //
75
+ // Prettier's objectWrap:"preserve" default keeps such an object
76
+ // expanded even when it would fit flat, treating the author's line
77
+ // break as intentional structure. format/print-width mirrors that:
78
+ // without it a deliberately multi-line object that happens to fit
79
+ // would be silently collapsed onto one line, which Prettier never
80
+ // does.
81
+ func objectHasNewlineAfterBrace(src string, node *shimast.Node, firstProp *shimast.Node) bool {
82
+ if node == nil || firstProp == nil {
83
+ return false
84
+ }
85
+ brace := shimscanner.SkipTrivia(src, node.Pos())
86
+ propStart := shimscanner.SkipTrivia(src, firstProp.Pos())
87
+ if brace < 0 || propStart <= brace || propStart > len(src) {
88
+ return false
89
+ }
90
+ for i := brace; i < propStart; i++ {
91
+ if src[i] == '\n' {
92
+ return true
93
+ }
94
+ }
95
+ return false
51
96
  }
@@ -52,12 +52,12 @@ func (noUselessEscape) Check(ctx *Context, node *shimast.Node) {
52
52
  // single-char escape whitelist matches ESLint per-context.
53
53
  switch node.Kind {
54
54
  case shimast.KindStringLiteral:
55
- reportStringEscapes(ctx, raw, pos, stringValidEscapes)
55
+ reportStringEscapes(ctx, raw, pos, stringValidEscapes, false)
56
56
  case shimast.KindNoSubstitutionTemplateLiteral,
57
57
  shimast.KindTemplateHead,
58
58
  shimast.KindTemplateMiddle,
59
59
  shimast.KindTemplateTail:
60
- reportStringEscapes(ctx, raw, pos, templateValidEscapes)
60
+ reportStringEscapes(ctx, raw, pos, templateValidEscapes, true)
61
61
  case shimast.KindRegularExpressionLiteral:
62
62
  reportRegexEscapes(ctx, raw, pos)
63
63
  }
@@ -79,7 +79,16 @@ const regexValidEscapes = "^$\\.*+?()[]{}|/-\n\r"
79
79
  // translate to absolute file positions. The function issues an autofix
80
80
  // (delete the backslash) for ASCII escapes; multi-byte sequences are
81
81
  // reported without a fix to avoid corrupting UTF-8.
82
- func reportStringEscapes(ctx *Context, raw string, base int, whitelist string) {
82
+ //
83
+ // `isTemplate` is true for `NoSubstitutionTemplateLiteral` and
84
+ // `TemplateHead`/`Middle`/`Tail` payloads. Inside a template, `\${` escapes
85
+ // the interpolation opener: stripping the backslash from `\${expr}` would
86
+ // either turn the literal text into an interpolation (corrupting the
87
+ // program) or — when the surrounding template already contains a real
88
+ // `${expr}` — produce TS syntax that no longer parses. The explicit guard
89
+ // here pins that exception so future tightening of `templateValidEscapes`
90
+ // cannot regress the corruption.
91
+ func reportStringEscapes(ctx *Context, raw string, base int, whitelist string, isTemplate bool) {
83
92
  if len(raw) < 2 {
84
93
  return
85
94
  }
@@ -101,6 +110,14 @@ func reportStringEscapes(ctx *Context, raw string, base int, whitelist string) {
101
110
  return
102
111
  }
103
112
  next := raw[i+1]
113
+ // Template-literal exception: `\${` escapes the interpolation
114
+ // opener. Without the backslash the next two bytes would either
115
+ // start an interpolation or trigger a parse error, so the escape is
116
+ // load-bearing even though `\$` looks redundant in isolation.
117
+ if isTemplate && next == '$' && i+2 < len(raw) && raw[i+2] == '{' {
118
+ i++ // consume the `$` so the `{` is not re-examined as a fresh char.
119
+ continue
120
+ }
104
121
  if isUselessStringEscape(next, whitelist) {
105
122
  // Only emit a fix when both surrounding bytes are plain ASCII so
106
123
  // deleting one byte cannot corrupt a multi-byte sequence.
@@ -1,6 +1,8 @@
1
1
  package linthost
2
2
 
3
3
  import (
4
+ "strings"
5
+
4
6
  shimast "github.com/microsoft/typescript-go/shim/ast"
5
7
  shimscanner "github.com/microsoft/typescript-go/shim/scanner"
6
8
  )
@@ -25,16 +27,22 @@ import (
25
27
  // printer's StartingIndent so continuation lines align under the
26
28
  // opening token and fit measurement charges the prefix against
27
29
  // the budget.
28
- // 3. Build the node's Doc via PrintNode.
29
- // 4. Render with the configured printWidth / tabWidth / useTabs /
30
+ // 3. Build the node's Doc via PrintNode, which also reports a
31
+ // `covered` flag.
32
+ // 4. Abstain when `covered` is false: the subtree holds a multi-line
33
+ // verbatim node whose frozen columns would not survive a reflow.
34
+ // 5. Render with the configured printWidth / tabWidth / useTabs /
30
35
  // endOfLine.
31
- // 5. Slice the original source bytes for the node's range.
32
- // 6. If the rendered output differs, emit one TextEdit replacing
36
+ // 6. Slice the original source bytes for the node's range.
37
+ // 7. If the rendered output differs, emit one TextEdit replacing
33
38
  // [start, end) with the new bytes.
34
39
  //
35
40
  // The "no diff → no edit" invariant is what keeps `ttsc format`
36
41
  // idempotent: a second pass renders identical bytes, the comparison
37
- // short-circuits, and the cascade converges.
42
+ // short-circuits, and the cascade converges. The `covered` abstain in
43
+ // step 4 is the safety floor: `ttsc format` either reflows correctly or
44
+ // leaves the node byte-identical — it never emits a half-reflowed,
45
+ // inconsistently indented shape.
38
46
  //
39
47
  // The rule is a format-class rule (IsFormat == true) so `ttsc format`
40
48
  // applies its edits while `ttsc check` only emits diagnostics for
@@ -44,11 +52,19 @@ import (
44
52
  type formatPrintWidth struct{}
45
53
 
46
54
  // formatPrintWidthOptions mirrors `TtscLintRuleOptions.PrintWidth`.
55
+ //
56
+ // TrailingComma reaches this rule because the printer's reflow decides
57
+ // whether to emit a trailing comma on every multi-line list — and that
58
+ // decision must match the user's `format.trailingComma` setting or the
59
+ // reflow oscillates against `format/trailing-comma` on every cascade
60
+ // pass. The config layer mirrors `format.trailingComma` into both
61
+ // rules' option blobs (see `expandFormatBlock` in config_format.go).
47
62
  type formatPrintWidthOptions struct {
48
- PrintWidth *int `json:"printWidth"`
49
- TabWidth *int `json:"tabWidth"`
50
- UseTabs *bool `json:"useTabs"`
51
- EndOfLine *string `json:"endOfLine"`
63
+ PrintWidth *int `json:"printWidth"`
64
+ TabWidth *int `json:"tabWidth"`
65
+ UseTabs *bool `json:"useTabs"`
66
+ EndOfLine *string `json:"endOfLine"`
67
+ TrailingComma *string `json:"trailingComma"`
52
68
  }
53
69
 
54
70
  func (formatPrintWidth) Name() string { return "format/print-width" }
@@ -66,6 +82,29 @@ func (formatPrintWidth) Visits() []shimast.Kind {
66
82
  }
67
83
  }
68
84
 
85
+ // Known residual divergence from Prettier 3 (investigated against the
86
+ // nestjs / typeorm / vscode benchmark fixtures, not closed in this
87
+ // pass):
88
+ //
89
+ // - Multi-line `reduce(...)` (or other single-arg method) calls where
90
+ // Prettier 3 keeps the inline form because it fits print-width
91
+ // minus the trailing-suffix budget. The current shrunk-budget
92
+ // re-render still over-breaks some of these; the next slice should
93
+ // measure fitsFirstLine against `pw - col - trailingNonComment`
94
+ // before committing to the broken layout.
95
+ // - Single-line `export type { X } from "long-path"` reexports.
96
+ // Prettier 3 keeps them flat even when the whole declaration
97
+ // overflows; ttsc-lint visits `KindNamedExports` in isolation and
98
+ // breaks the brace clause. A real fix requires teaching the rule
99
+ // about the surrounding ExportDeclaration so the brace clause is
100
+ // measured against the full declaration line, or visiting
101
+ // ExportDeclaration directly so the `from "..."` tail joins the
102
+ // reflow surface.
103
+ //
104
+ // Both cases are tracked benchmark cases that forced
105
+ // `format/print-width: 'off'` on the ttsc-lint branch. They are listed
106
+ // here so a future slice can pick them up without rediscovering the
107
+ // divergence.
69
108
  func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
70
109
  if ctx == nil || ctx.File == nil || node == nil {
71
110
  return
@@ -85,6 +124,9 @@ func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
85
124
  if opts.EndOfLine != nil {
86
125
  printOpts.EndOfLine = *opts.EndOfLine
87
126
  }
127
+ if opts.TrailingComma != nil {
128
+ printOpts.TrailingComma = *opts.TrailingComma
129
+ }
88
130
 
89
131
  src := ctx.File.Text()
90
132
  start := shimscanner.SkipTrivia(src, node.Pos())
@@ -101,6 +143,16 @@ func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
101
143
  return
102
144
  }
103
145
 
146
+ // Abstain on any node nested inside a template-literal substitution.
147
+ // Prettier renders `${…}` expressions at printWidth:Infinity — it
148
+ // never breaks an interpolation the source wrote on one line — so
149
+ // reflowing a call or literal inside `${…}` would split the template
150
+ // across lines and diverge from Prettier. See the printWidth:Infinity
151
+ // branch in Prettier's printTemplateExpression.
152
+ if hasTemplateSubstitutionAncestor(node) {
153
+ return
154
+ }
155
+
104
156
  // Safety: abstain when the node carries comments outside its
105
157
  // children. The per-node printers join child docs with a fresh
106
158
  // `, ` separator and have no path for trivia between siblings, so
@@ -113,29 +165,85 @@ func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
113
165
  }
114
166
 
115
167
  printOpts.StartingColumn = leadingColumn(src, start, printOpts.TabWidth)
116
- printOpts.BaseIndent = lineLeadingIndent(src, start, printOpts.TabWidth)
168
+ // A node reflowed on a ternary-arm continuation line (`? expr` or
169
+ // `: expr`) hangs its broken continuation under the arm's expression,
170
+ // two columns past the `?`/`:` marker — not under the marker itself.
171
+ printOpts.BaseIndent = lineLeadingIndent(src, start, printOpts.TabWidth) +
172
+ ternaryArmIndentBonus(src, start)
173
+
174
+ // trailingWidth is the column span of the tokens that stay on the
175
+ // node's last line after `end` — a `;`, a `);`, a `) {`. The reflow
176
+ // replaces only [start, end) and cannot move them, so both the fast
177
+ // path and the layout budget must reserve those columns; otherwise
178
+ // the rule emits a line that overflows by exactly the suffix.
179
+ trailingWidth := trailingLineWidth(src, end, printOpts.TabWidth)
117
180
 
118
181
  // Fast path: if the node's existing single-line bytes already fit
119
- // the printWidth budget, the reflowed output cannot differ from
120
- // the source (the printer would render the same flat shape). Skip
121
- // the Doc build + render entirely. This is the common case on
182
+ // the printWidth budget prefix column, node width and trailing
183
+ // suffix all charged the reflowed output cannot differ from the
184
+ // source (the printer would render the same flat shape). Skip the
185
+ // Doc build + render entirely. This is the common case on
122
186
  // well-formatted code — every short call, every short literal —
123
187
  // and saves the allocations from PrintNode + Print.
124
188
  if !sliceContainsNewline(src, start, end) &&
125
- printOpts.StartingColumn+(end-start) <= printOpts.PrintWidth {
189
+ printOpts.StartingColumn+(end-start)+trailingWidth <= printOpts.PrintWidth {
126
190
  return
127
191
  }
128
192
 
129
193
  printCtx := NewPrintContext(ctx.File, printOpts)
130
- doc, _ := PrintNode(printCtx, node)
194
+ doc, covered := PrintNode(printCtx, node)
131
195
  if doc.IsNil() {
132
196
  return
133
197
  }
198
+ // Safety abstain: the printed subtree contains a multi-line verbatim
199
+ // node — one the dispatcher has no printer for. Such a node keeps the
200
+ // source columns its lines were written at, while the reflow
201
+ // re-indents everything around it. Emitting the edit would produce
202
+ // inconsistently indented, corrupt output (a callback header at one
203
+ // indent, its body frozen at another). Abstaining leaves the bytes
204
+ // byte-identical, which is always safe. See the coverage-signal note
205
+ // in print_dispatch.go.
206
+ if !covered {
207
+ return
208
+ }
209
+ // Render at the full printWidth budget. A reflow that breaks across
210
+ // lines then makes every layout decision — which call argument hugs,
211
+ // where a list explodes — against the true column budget. The
212
+ // un-movable trailing suffix (`;`, `) {`, ` satisfies T`) lands on a
213
+ // short last line; charging it against the whole budget would
214
+ // wrongly penalize the interior lines and over-break the node.
134
215
  rendered := Print(doc, printOpts)
216
+ // The one case the suffix genuinely shares the node's line is a
217
+ // reflow that collapses to a single line. When the flat form plus
218
+ // the suffix would overflow, re-render under a budget shrunk by the
219
+ // suffix so the node breaks instead of spilling the suffix past
220
+ // printWidth — the regression that keeps a call flat at exactly
221
+ // printWidth while the trailing `;` runs over.
222
+ if trailingWidth > 0 &&
223
+ !strings.Contains(rendered, "\n") &&
224
+ maxLineWidth(rendered, printOpts.StartingColumn, trailingWidth, printOpts.TabWidth) > printOpts.PrintWidth &&
225
+ printOpts.PrintWidth-trailingWidth >= 1 {
226
+ shrunk := printOpts
227
+ shrunk.PrintWidth -= trailingWidth
228
+ rendered = Print(doc, shrunk)
229
+ }
135
230
  original := src[start:end]
136
231
  if rendered == original {
137
232
  return
138
233
  }
234
+ // Safety floor: never emit an edit that makes the widest line wider
235
+ // than it already was. The reflow may be unable to break an
236
+ // un-breakable token run — a long string literal, a verbatim object
237
+ // member — but it must never *worsen* the worst line. That is exactly
238
+ // the regression this guards: `ttsc format` collapsing an
239
+ // already-broken, fitting call into one over-wide line. A reflow that
240
+ // only fixes indentation and leaves a pre-existing over-wide line
241
+ // untouched is still emitted.
242
+ renderedMax := maxLineWidth(rendered, printOpts.StartingColumn, trailingWidth, printOpts.TabWidth)
243
+ originalMax := maxLineWidth(original, printOpts.StartingColumn, trailingWidth, printOpts.TabWidth)
244
+ if renderedMax > printOpts.PrintWidth && renderedMax > originalMax {
245
+ return
246
+ }
139
247
  ctx.ReportRangeFix(
140
248
  start,
141
249
  end,
@@ -144,6 +252,113 @@ func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
144
252
  )
145
253
  }
146
254
 
255
+ // trailingLineWidth returns the visual column width of src[end:] up to
256
+ // the next newline, with trailing whitespace trimmed. The format/print-
257
+ // width reflow replaces only the node's own byte range, so whatever
258
+ // shares the node's last source line — a statement `;`, a `) {` header
259
+ // tail, a `, nextArg)` continuation — stays put. Charging that width
260
+ // against the budget keeps the rule from emitting a line that overflows
261
+ // by exactly the suffix it could never move.
262
+ //
263
+ // Trailing `//` line comments are excluded from the budget. A line
264
+ // comment runs to the end of the source line by definition, so breaking
265
+ // the reflowed node to make the comment fit cannot help — Prettier 3
266
+ // keeps the node inline and lets the comment trail (see typeorm's
267
+ // `comment.replaceAll(...) // Null bytes' shape that pushed
268
+ // `format/print-width: 'off'` onto the ttsc-lint benchmark branch).
269
+ // Excluding the comment from `trailingLineWidth` matches that
270
+ // behavior: the fast path sees just the un-movable punctuation suffix,
271
+ // and the shrunk-budget re-render does not over-shrink and over-break.
272
+ func trailingLineWidth(src string, end int, tabWidth int) int {
273
+ if end < 0 || end > len(src) {
274
+ return 0
275
+ }
276
+ if tabWidth <= 0 {
277
+ tabWidth = 2
278
+ }
279
+ lineEnd := trailingSuffixEnd(src, end)
280
+ col := 0
281
+ for i := end; i < lineEnd; i++ {
282
+ if src[i] == '\t' {
283
+ col += tabWidth - (col % tabWidth)
284
+ } else {
285
+ col++
286
+ }
287
+ }
288
+ return col
289
+ }
290
+
291
+ // trailingSuffixEnd returns the byte offset where the node's un-movable
292
+ // trailing suffix ends on the line that begins at `end`. The walk stops
293
+ // at the first `//` line comment (Prettier-style "free" trailing
294
+ // attachment, see trailingLineWidth) or at the newline, then trims
295
+ // trailing whitespace so a `;<spaces><newline>` tail measures the
296
+ // `;` only. Trailing block comments inside the un-movable suffix span
297
+ // (`} /* note */`) keep their bytes counted; that path is rare and
298
+ // already exercised through the existing block-comment fixture.
299
+ func trailingSuffixEnd(src string, end int) int {
300
+ lineEnd := end
301
+ for lineEnd < len(src) && src[lineEnd] != '\n' {
302
+ if src[lineEnd] == '/' && lineEnd+1 < len(src) {
303
+ next := src[lineEnd+1]
304
+ if next == '/' {
305
+ // `//` line comment — Prettier treats the whole tail as a
306
+ // trailing comment that runs to EOL. Drop it from the suffix
307
+ // budget so the rule does not break the node to chase a
308
+ // comment that cannot be moved or wrapped.
309
+ break
310
+ }
311
+ }
312
+ lineEnd++
313
+ }
314
+ for lineEnd > end {
315
+ c := src[lineEnd-1]
316
+ if c != ' ' && c != '\t' && c != '\r' {
317
+ break
318
+ }
319
+ lineEnd--
320
+ }
321
+ return lineEnd
322
+ }
323
+
324
+ // maxLineWidth returns the widest effective column span among the lines
325
+ // of `text`. The first line is charged `startingColumn` — the prefix
326
+ // already on that source line that the reflow does not re-emit — and
327
+ // the last line is charged `trailingWidth` for the un-movable suffix
328
+ // that follows the node. Tabs count as `tabWidth` columns.
329
+ //
330
+ // The rule compares the rendered output's widest line against the
331
+ // source node's: a reflow that cannot fit an un-breakable token is
332
+ // still allowed through as long as it does not make the worst line any
333
+ // wider than it already was.
334
+ func maxLineWidth(text string, startingColumn, trailingWidth, tabWidth int) int {
335
+ if tabWidth <= 0 {
336
+ tabWidth = 2
337
+ }
338
+ lines := strings.Split(text, "\n")
339
+ widest := 0
340
+ for i, line := range lines {
341
+ width := 0
342
+ for _, r := range line {
343
+ if r == '\t' {
344
+ width += tabWidth - (width % tabWidth)
345
+ } else if r != '\r' {
346
+ width++
347
+ }
348
+ }
349
+ if i == 0 {
350
+ width += startingColumn
351
+ }
352
+ if i == len(lines)-1 {
353
+ width += trailingWidth
354
+ }
355
+ if width > widest {
356
+ widest = width
357
+ }
358
+ }
359
+ return widest
360
+ }
361
+
147
362
  // leadingColumn returns the visual column the byte at `pos` occupies on
148
363
  // its line. Tabs expand to `tabWidth` columns; other bytes count as 1.
149
364
  // The rule uses this to seed the printer's StartingColumn so fit
@@ -240,6 +455,25 @@ func lineStartOffset(src string, pos int) int {
240
455
  return pos
241
456
  }
242
457
 
458
+ // ternaryArmIndentBonus returns 2 when the line containing `pos` begins,
459
+ // after its leading whitespace, with a `? ` or `: ` ternary-arm marker,
460
+ // and 0 otherwise. format/print-width adds it to BaseIndent so a node
461
+ // reflowed inside a ternary arm indents its broken continuation under
462
+ // the arm's expression rather than under the `?`/`:` token. In practice
463
+ // only a ternary arm opens a reflow target's line with `? ` / `: `; the
464
+ // two-byte prefix is a heuristic, and a rare false positive only shifts
465
+ // a broken continuation by two columns — it never corrupts bytes.
466
+ func ternaryArmIndentBonus(src string, pos int) int {
467
+ i := lineStartOffset(src, pos)
468
+ for i < len(src) && (src[i] == ' ' || src[i] == '\t') {
469
+ i++
470
+ }
471
+ if i+1 < len(src) && (src[i] == '?' || src[i] == ':') && src[i+1] == ' ' {
472
+ return 2
473
+ }
474
+ return 0
475
+ }
476
+
243
477
  // hasReflowAncestor reports whether any ancestor of `node` would also
244
478
  // match the format/print-width visitor. The rule uses this to suppress
245
479
  // nested fires when an enclosing reflow target already covers the
@@ -256,6 +490,24 @@ func hasReflowAncestor(node *shimast.Node) bool {
256
490
  return false
257
491
  }
258
492
 
493
+ // hasTemplateSubstitutionAncestor reports whether `node` sits inside a
494
+ // template-literal substitution (`${…}`). format/print-width abstains
495
+ // on such nodes: Prettier prints template interpolations at infinite
496
+ // printWidth and only keeps a break the source already had, so a reflow
497
+ // of a nested call or literal would split a one-line `${…}` and never
498
+ // match Prettier's output.
499
+ func hasTemplateSubstitutionAncestor(node *shimast.Node) bool {
500
+ if node == nil {
501
+ return false
502
+ }
503
+ for parent := node.Parent; parent != nil; parent = parent.Parent {
504
+ if parent.Kind == shimast.KindTemplateExpression {
505
+ return true
506
+ }
507
+ }
508
+ return false
509
+ }
510
+
259
511
  // isReflowKind reports whether `k` is one of the node kinds the
260
512
  // format/print-width rule visits. Kept in sync with Visits() so
261
513
  // hasReflowAncestor does not need to call Visits() at runtime.
@@ -8,7 +8,10 @@
8
8
  // Each rule is registered in init() at the bottom of the file.
9
9
  package linthost
10
10
 
11
- import shimast "github.com/microsoft/typescript-go/shim/ast"
11
+ import (
12
+ shimast "github.com/microsoft/typescript-go/shim/ast"
13
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
14
+ )
12
15
 
13
16
  // no-empty-static-block: `class C { static {} }` has no effect.
14
17
  // ESLint recommended: https://eslint.org/docs/latest/rules/no-empty-static-block
@@ -372,14 +375,63 @@ func (dotNotation) Visits() []shimast.Kind {
372
375
  }
373
376
  func (dotNotation) Check(ctx *Context, node *shimast.Node) {
374
377
  access := node.AsElementAccessExpression()
375
- if access == nil {
378
+ if access == nil || access.Expression == nil || access.ArgumentExpression == nil {
376
379
  return
377
380
  }
378
381
  key := stringLiteralText(access.ArgumentExpression)
379
382
  if key == "" || !isSimpleIdentifierName(key) {
380
383
  return
381
384
  }
382
- ctx.Report(node, "Use dot notation instead of a string literal property access.")
385
+ message := "Use dot notation instead of a string literal property access."
386
+ // Conservative: keep reserved words as bracket access. Modern JS accepts
387
+ // `obj.class`/`obj.if`/etc. syntactically but mixing them with member
388
+ // expression syntax is jarring and can confuse minifiers and older runtimes.
389
+ if isReservedWord(key) {
390
+ ctx.Report(node, message)
391
+ return
392
+ }
393
+ src := ctx.File.Text()
394
+ // Determine where the bracket access begins. With an optional chain
395
+ // (`obj?.["foo"]`) the `?.` token already separates object from access, so
396
+ // we replace the `["foo"]` tail with `foo`. Otherwise we replace the
397
+ // `["foo"]` tail with `.foo`.
398
+ var replaceFrom int
399
+ var replacement string
400
+ if access.QuestionDotToken != nil {
401
+ replaceFrom = shimscanner.SkipTrivia(src, access.QuestionDotToken.End())
402
+ replacement = key
403
+ } else {
404
+ replaceFrom = access.Expression.End()
405
+ replacement = "." + key
406
+ }
407
+ if replaceFrom < 0 || replaceFrom >= node.End() {
408
+ ctx.Report(node, message)
409
+ return
410
+ }
411
+ ctx.ReportFix(
412
+ node,
413
+ message,
414
+ TextEdit{Pos: replaceFrom, End: node.End(), Text: replacement},
415
+ )
416
+ }
417
+
418
+ // isReservedWord reports whether `value` is an ECMAScript reserved word. The
419
+ // `dot-notation` autofix uses this to skip the rewrite even though modern
420
+ // parsers accept reserved-word member names: leaving bracket access matches
421
+ // the conservative branch ESLint takes when `allowKeywords: false`.
422
+ func isReservedWord(value string) bool {
423
+ switch value {
424
+ case "break", "case", "catch", "class", "const", "continue",
425
+ "debugger", "default", "delete", "do", "else", "enum",
426
+ "export", "extends", "false", "finally", "for", "function",
427
+ "if", "implements", "import", "in", "instanceof", "interface",
428
+ "let", "new", "null", "package", "private", "protected",
429
+ "public", "return", "static", "super", "switch", "this",
430
+ "throw", "true", "try", "typeof", "var", "void",
431
+ "while", "with", "yield", "await":
432
+ return true
433
+ }
434
+ return false
383
435
  }
384
436
 
385
437
  // isSimpleIdentifierName reports whether value is a valid bare identifier