@ttsc/lint 0.25.0 → 0.26.0

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.
@@ -13,16 +13,38 @@ import (
13
13
  // if (a)
14
14
  // b();
15
15
  //
16
- // becomes `if (a) b();` when the joined line fits printWidth. The same
17
- // applies to `for`, `for-in`, `for-of`, and `while` headers. A braced
18
- // body, a body that already shares the header line, a multi-line body,
19
- // or a join that would overflow printWidth is left untouched.
16
+ // becomes `if (a) b();` when the joined line fits printWidth. The covered set
17
+ // is Prettier's, not the set whose header happens to end in `)`: the `if`
18
+ // consequent and alternate, the `for`, `for-in`, `for-of`, and `while` bodies,
19
+ // the `do` body, the `with` body, and a labeled statement's body. A braced
20
+ // body, a body that already shares the header line, a multi-line body, or a
21
+ // join that would overflow printWidth is left untouched.
20
22
  //
21
- // The rule only ever rewrites the whitespace gap between a header's
22
- // closing `)` and the controlled statement, so its edits never overlap
23
- // `format/indent` (leading whitespace of a line) or `format/print-width`
24
- // (interior reflow). Idempotent: once joined the gap holds no newline
25
- // and the rule abstains.
23
+ // Two clauses join unconditionally because Prettier gives them no group of
24
+ // their own: an `else` whose alternate is another `if` (the `else if` chain),
25
+ // and a labeled statement, which prints `label: statement` on one line whatever
26
+ // the statement is, braces included. Those are the only clauses that can hoist a
27
+ // multi-line body, and their continuation lines move by the same column delta
28
+ // their first line travels, so the join does not settle on output Prettier would
29
+ // still reindent.
30
+ //
31
+ // A continuation line whose newline sits inside a string, a template, or a
32
+ // block comment is skipped rather than shifted: those bytes are content, and
33
+ // moving them would change what the program prints or what a comment says. The
34
+ // join itself still happens, because abandoning it leaves `format/indent` to
35
+ // move the body's interior anyway and settles the file on a hybrid layout
36
+ // Prettier never emits.
37
+ //
38
+ // The rule rewrites the whitespace gap between the clause's own header token
39
+ // and the controlled statement, and, when it hoists a multi-line body, the
40
+ // leading whitespace of that body's continuation lines. That surface overlaps
41
+ // `format/indent`, which owns the same column for a hoisted body that contains
42
+ // a block and cedes it only for a braceless control-flow body. The two agree on
43
+ // the target column, so a collision costs a dropped finding and a re-fire rather
44
+ // than a fight. Hoisting also contends with a nested join over the same bytes,
45
+ // so a staircase settles roughly one level per cascade pass. Idempotent: once
46
+ // joined the gap holds no newline, the shift compares against the text it would
47
+ // write, and the rule abstains.
26
48
  type formatClauseJoin struct{ optionsRule }
27
49
 
28
50
  // formatClauseJoinOptions mirrors the printWidth/indent keys the rule
@@ -46,6 +68,9 @@ func (formatClauseJoin) Visits() []shimast.Kind {
46
68
  shimast.KindForStatement,
47
69
  shimast.KindForInStatement,
48
70
  shimast.KindForOfStatement,
71
+ shimast.KindDoStatement,
72
+ shimast.KindWithStatement,
73
+ shimast.KindLabeledStatement,
49
74
  }
50
75
  }
51
76
 
@@ -63,24 +88,67 @@ func (formatClauseJoin) Check(ctx *Context, node *shimast.Node) {
63
88
  if opts.TabWidth != nil && *opts.TabWidth > 0 {
64
89
  tabWidth = *opts.TabWidth
65
90
  }
66
- joinClauseBody(ctx, ctx.File.Text(), node, clauseControlledBody(node), printWidth, tabWidth)
91
+ src := ctx.File.Text()
92
+ for _, target := range clauseControlledBodies(node) {
93
+ joinClauseBody(ctx, src, node, target, printWidth, tabWidth)
94
+ }
67
95
  }
68
96
 
69
- // clauseControlledBody returns the single controlled statement of a
70
- // control-flow header, the `then` branch for `if`, the loop body for
71
- // the iteration statements. The `else` branch is intentionally excluded:
72
- // its body is anchored after the `else` keyword rather than a `)`, so it
73
- // does not share this rule's `)`-anchored join shape.
74
- func clauseControlledBody(node *shimast.Node) *shimast.Node {
97
+ // clauseJoinTarget is one joinable clause body plus the token that ends the
98
+ // clause header immediately before it. The anchor is what generalizes the rule
99
+ // past the `)`-headed statements: `else` and `do` end in a keyword, a labeled
100
+ // statement ends in `:`, and requiring the exact token still keeps a comment
101
+ // between header and body from being swallowed.
102
+ type clauseJoinTarget struct {
103
+ body *shimast.Node
104
+ anchor string
105
+ // alwaysJoin marks a clause Prettier keeps on the header line regardless of
106
+ // the body's own line count or the joined width: an `else if` chain and a
107
+ // labeled statement.
108
+ alwaysJoin bool
109
+ }
110
+
111
+ // clauseControlledBodies returns every clause body of `node` that Prettier
112
+ // keeps on its header line, paired with the token that header ends in. The set
113
+ // is Prettier's, not the AST's: the `if` consequent and alternate, the loop
114
+ // bodies, the `do` body, the `with` body, and a labeled statement's body.
115
+ func clauseControlledBodies(node *shimast.Node) []clauseJoinTarget {
75
116
  switch node.Kind {
76
117
  case shimast.KindIfStatement:
77
- return node.AsIfStatement().ThenStatement
118
+ stmt := node.AsIfStatement()
119
+ if stmt == nil {
120
+ return nil
121
+ }
122
+ targets := []clauseJoinTarget{{body: stmt.ThenStatement, anchor: ")"}}
123
+ if stmt.ElseStatement != nil {
124
+ targets = append(targets, clauseJoinTarget{
125
+ body: stmt.ElseStatement,
126
+ anchor: "else",
127
+ // Prettier prints an `else if` chain flat, so the alternate being an
128
+ // `if` joins even when that nested statement spans lines.
129
+ alwaysJoin: stmt.ElseStatement.Kind == shimast.KindIfStatement,
130
+ })
131
+ }
132
+ return targets
78
133
  case shimast.KindWhileStatement:
79
- return node.AsWhileStatement().Statement
134
+ return []clauseJoinTarget{{body: node.AsWhileStatement().Statement, anchor: ")"}}
80
135
  case shimast.KindForStatement:
81
- return node.AsForStatement().Statement
136
+ return []clauseJoinTarget{{body: node.AsForStatement().Statement, anchor: ")"}}
82
137
  case shimast.KindForInStatement, shimast.KindForOfStatement:
83
- return node.AsForInOrOfStatement().Statement
138
+ return []clauseJoinTarget{{body: node.AsForInOrOfStatement().Statement, anchor: ")"}}
139
+ case shimast.KindDoStatement:
140
+ return []clauseJoinTarget{{body: node.AsDoStatement().Statement, anchor: "do"}}
141
+ case shimast.KindWithStatement:
142
+ return []clauseJoinTarget{{body: node.AsWithStatement().Statement, anchor: ")"}}
143
+ case shimast.KindLabeledStatement:
144
+ // A label carries no group of its own in Prettier: `label: statement` is
145
+ // one line whatever the statement is, so the body may be a block and may
146
+ // span lines.
147
+ return []clauseJoinTarget{{
148
+ body: node.AsLabeledStatement().Statement,
149
+ anchor: ":",
150
+ alwaysJoin: true,
151
+ }}
84
152
  }
85
153
  return nil
86
154
  }
@@ -89,18 +157,25 @@ func joinClauseBody(
89
157
  ctx *Context,
90
158
  src string,
91
159
  node *shimast.Node,
92
- body *shimast.Node,
160
+ target clauseJoinTarget,
93
161
  printWidth int,
94
162
  tabWidth int,
95
163
  ) {
96
- if body == nil || body.Kind == shimast.KindBlock {
164
+ body := target.body
165
+ if body == nil {
97
166
  return
98
167
  }
99
- // An empty-statement body (`while (x)\n;`) glues directly to the header with
100
- // NO space: Prettier's adjustClause special-cases EmptyStatement and returns
101
- // the bare `;` (`while (x);`), only prepending a space when the empty
102
- // statement carries a leading comment. This rule's gap->" " rewrite cannot
103
- // produce the spaceless `);` glue, so abstain and leave the source shape.
168
+ // A braced body already owns its own line layout, except behind a label,
169
+ // where Prettier writes `label: {`.
170
+ if body.Kind == shimast.KindBlock && !target.alwaysJoin {
171
+ return
172
+ }
173
+ // An empty-statement body glues directly to the header with NO space:
174
+ // Prettier's adjustClause special-cases EmptyStatement and returns the bare
175
+ // `;` (`while (x);`, `else;`, `do;`, `label:;`), only prepending a space when
176
+ // the empty statement carries a leading comment. This rule joins with a
177
+ // single space and deliberately does not take on the spaceless variant, so it
178
+ // abstains and leaves the source shape rather than emitting `while (x) ;`.
104
179
  if body.Kind == shimast.KindEmptyStatement {
105
180
  return
106
181
  }
@@ -110,24 +185,29 @@ func joinClauseBody(
110
185
  return
111
186
  }
112
187
  // The gap is the whitespace run immediately before the body. Walk back
113
- // over horizontal whitespace and newlines; the byte before it must be
114
- // the header's closing `)` so a comment between header and body (which
188
+ // over horizontal whitespace and newlines; the bytes before it must be the
189
+ // clause's own header token so a comment between header and body (which
115
190
  // SkipTrivia would have stepped over) can never be swallowed.
116
191
  gapStart := bodyStart
117
192
  for gapStart > 0 && isClauseGapByte(src[gapStart-1]) {
118
193
  gapStart--
119
194
  }
120
- if gapStart == 0 || src[gapStart-1] != ')' {
195
+ anchorStart := gapStart - len(target.anchor)
196
+ if anchorStart < 0 || src[anchorStart:gapStart] != target.anchor {
121
197
  return
122
198
  }
123
199
  gap := src[gapStart:bodyStart]
124
200
  if !strings.Contains(gap, "\n") {
125
201
  return // body already shares the header line
126
202
  }
127
- // The header and the body must each be single-line; a multi-line body
128
- // (e.g. a nested clause not yet joined) waits for the cascade to settle
129
- // its inner join first.
203
+ // The header line is measured from the token that opens the clause. For a
204
+ // `)`-headed statement and a label that is the statement's own start; for
205
+ // `else` and `do` it is the keyword, which is where Prettier starts the line
206
+ // it would print.
130
207
  headerStart := shimscanner.SkipTrivia(src, node.Pos())
208
+ if isClauseAnchorWord(target.anchor) {
209
+ headerStart = anchorStart
210
+ }
131
211
  if headerStart < 0 || headerStart > gapStart {
132
212
  return
133
213
  }
@@ -135,28 +215,197 @@ func joinClauseBody(
135
215
  if strings.ContainsRune(src[headerLineStart:gapStart], '\n') {
136
216
  return
137
217
  }
138
- if strings.ContainsRune(src[bodyStart:bodyEnd], '\n') {
139
- return
218
+ if !target.alwaysJoin {
219
+ // The body must be single-line; a multi-line body (e.g. a nested clause
220
+ // not yet joined) waits for the cascade to settle its inner join first.
221
+ if strings.ContainsRune(src[bodyStart:bodyEnd], '\n') {
222
+ return
223
+ }
224
+ joined := visualWidth(src[headerLineStart:gapStart], tabWidth) + 1 +
225
+ visualWidth(src[bodyStart:bodyEnd], tabWidth)
226
+ if joined > printWidth {
227
+ return
228
+ }
140
229
  }
141
- joined := visualWidth(src[headerLineStart:gapStart], tabWidth) + 1 +
142
- visualWidth(src[bodyStart:bodyEnd], tabWidth)
143
- if joined > printWidth {
144
- return
230
+ edits := []TextEdit{{Pos: gapStart, End: bodyStart, Text: " "}}
231
+ // Hoisting the body's first line moves its base column. Every continuation
232
+ // line has to move with it, or the join settles on output Prettier would
233
+ // still reindent (#1139). Only an alwaysJoin clause can reach a multi-line
234
+ // body; the others already abstained above.
235
+ if strings.ContainsRune(src[bodyStart:bodyEnd], '\n') {
236
+ edits = append(edits, reindentJoinedClauseBody(
237
+ ctx, src, headerLineStart, bodyStart, bodyEnd, loadFormatLayout(ctx),
238
+ )...)
145
239
  }
146
240
  ctx.ReportRangeFix(
147
241
  gapStart,
148
242
  bodyStart,
149
243
  "Single-statement clause body should join its header line.",
150
- TextEdit{Pos: gapStart, End: bodyStart, Text: " "},
244
+ edits...,
151
245
  )
152
246
  }
153
247
 
248
+ // reindentJoinedClauseBody returns the edits that move a hoisted body's
249
+ // continuation lines by the same column delta its first line travels.
250
+ //
251
+ // The delta is the header line's own indent minus the indent of the line the
252
+ // body currently starts on, because the body's first line lands on the header
253
+ // line and Prettier prints its continuations relative to that. Measuring from
254
+ // the anchor token instead would charge the width of a `} ` prefix and shift
255
+ // every continuation line two columns too far.
256
+ //
257
+ // A line the shift must not touch is skipped, not made to abandon the join.
258
+ // Abandoning is strictly worse: `format/indent` still moves the body's interior
259
+ // to its post-join column, so the file settles on a hybrid layout Prettier never
260
+ // emits, which is the very property this shift exists to remove. Three kinds of
261
+ // line are skipped: one whose newline sits inside a string, template, or block
262
+ // comment, where the bytes are content rather than layout; a blank one, which
263
+ // has no column; and one whose indent is shorter than an outdent needs.
264
+ func reindentJoinedClauseBody(
265
+ ctx *Context,
266
+ src string,
267
+ headerLineStart int,
268
+ bodyStart int,
269
+ bodyEnd int,
270
+ layout formatLayout,
271
+ ) []TextEdit {
272
+ protected := collectClauseJoinProtectedRanges(ctx.File, src)
273
+ headerIndentEnd := headerLineStart
274
+ for headerIndentEnd < len(src) &&
275
+ (src[headerIndentEnd] == ' ' || src[headerIndentEnd] == '\t') {
276
+ headerIndentEnd++
277
+ }
278
+ bodyLineStart := lineStartOffset(src, bodyStart)
279
+ delta := visualWidth(src[headerLineStart:headerIndentEnd], layout.tabWidth) -
280
+ visualWidth(src[bodyLineStart:bodyStart], layout.tabWidth)
281
+ if delta == 0 {
282
+ return nil
283
+ }
284
+ var edits []TextEdit
285
+ for offset := bodyStart; offset < bodyEnd; offset++ {
286
+ if src[offset] != '\n' {
287
+ continue
288
+ }
289
+ if inTemplate(protected, offset) {
290
+ continue
291
+ }
292
+ lineStart := offset + 1
293
+ indentEnd := lineStart
294
+ for indentEnd < bodyEnd && (src[indentEnd] == ' ' || src[indentEnd] == '\t') {
295
+ indentEnd++
296
+ }
297
+ if indentEnd >= bodyEnd || src[indentEnd] == '\n' || src[indentEnd] == '\r' {
298
+ continue // a blank line carries no column
299
+ }
300
+ width := visualWidth(src[lineStart:indentEnd], layout.tabWidth) + delta
301
+ if width < 0 {
302
+ continue
303
+ }
304
+ next := clauseJoinIndentOfWidth(layout, width)
305
+ if src[lineStart:indentEnd] == next {
306
+ continue
307
+ }
308
+ edits = append(edits, TextEdit{Pos: lineStart, End: indentEnd, Text: next})
309
+ }
310
+ return edits
311
+ }
312
+
313
+ // clauseJoinIndentOfWidth renders a column count in the project's own
314
+ // indentation unit, so a tab-indented file keeps its tabs instead of being
315
+ // silently respaced by the shift.
316
+ func clauseJoinIndentOfWidth(layout formatLayout, width int) string {
317
+ if width <= 0 {
318
+ return ""
319
+ }
320
+ if layout.useTabs && layout.tabWidth > 0 {
321
+ // A width that is not a whole number of tabs keeps the remainder in spaces
322
+ // rather than dropping every tab, which is the respacing this render exists
323
+ // to prevent.
324
+ return layout.indent(width/layout.tabWidth) +
325
+ strings.Repeat(" ", width%layout.tabWidth)
326
+ }
327
+ return strings.Repeat(" ", width)
328
+ }
329
+
330
+ // collectClauseJoinProtectedRanges returns every byte span whose interior
331
+ // newlines carry content rather than layout: template literals, string literals
332
+ // spanning a line continuation, and block comments. Shifting a line inside one
333
+ // changes what the program prints or what a comment says.
334
+ func collectClauseJoinProtectedRanges(file *shimast.SourceFile, src string) []byteRange {
335
+ ranges := collectTemplateRanges(file, src)
336
+ var walk func(node *shimast.Node)
337
+ walk = func(node *shimast.Node) {
338
+ if node == nil {
339
+ return
340
+ }
341
+ if node.Kind == shimast.KindStringLiteral {
342
+ pos := shimscanner.SkipTrivia(src, node.Pos())
343
+ end := node.End()
344
+ if pos >= 0 && end <= len(src) && end > pos {
345
+ ranges = append(ranges, byteRange{pos: pos, end: end})
346
+ }
347
+ }
348
+ node.ForEachChild(func(child *shimast.Node) bool {
349
+ walk(child)
350
+ return false
351
+ })
352
+ }
353
+ if file != nil && file.Statements != nil {
354
+ for _, stmt := range file.Statements.Nodes {
355
+ walk(stmt)
356
+ }
357
+ }
358
+ forEachCommentToken(file, func(kind shimast.Kind, start, end int) {
359
+ if kind != shimast.KindMultiLineCommentTrivia {
360
+ return
361
+ }
362
+ // Only a comment Prettier reprints verbatim is content. An indentable one,
363
+ // every continuation line starting with `*`, is realigned by Prettier to the
364
+ // current indentation, so protecting it would leave a well-formed JSDoc
365
+ // block misaligned behind a hoisted body.
366
+ if isIndentableBlockComment(src, start, end) {
367
+ return
368
+ }
369
+ ranges = append(ranges, byteRange{pos: start, end: end})
370
+ })
371
+ return ranges
372
+ }
373
+
374
+ // isIndentableBlockComment reports whether every continuation line of a block
375
+ // comment begins with `*`, which is the shape Prettier reindents rather than
376
+ // reproducing byte for byte.
377
+ func isIndentableBlockComment(src string, start, end int) bool {
378
+ if start < 0 || end > len(src) || end <= start {
379
+ return false
380
+ }
381
+ body := src[start:end]
382
+ if !strings.Contains(body, "\n") {
383
+ return false
384
+ }
385
+ for _, line := range strings.Split(body, "\n")[1:] {
386
+ trimmed := strings.TrimLeft(line, " \t\r")
387
+ if !strings.HasPrefix(trimmed, "*") {
388
+ return false
389
+ }
390
+ }
391
+ return true
392
+ }
393
+
154
394
  // isClauseGapByte reports whether `c` is whitespace that may appear in
155
395
  // the gap between a clause header and its controlled statement.
156
396
  func isClauseGapByte(c byte) bool {
157
397
  return c == ' ' || c == '\t' || c == '\r' || c == '\n'
158
398
  }
159
399
 
400
+ // isClauseAnchorWord reports whether an anchor is a keyword rather than
401
+ // punctuation, which decides where the width budget starts measuring. The
402
+ // anchor needs no identifier-boundary check: it is read backward from the AST
403
+ // body's own position, so the token before the gap is the clause's real keyword
404
+ // and an identifier ending in `else` or `do` cannot reach it.
405
+ func isClauseAnchorWord(anchor string) bool {
406
+ return anchor == "else" || anchor == "do"
407
+ }
408
+
160
409
  // visualWidth returns the display-column width of `s`: a tab expands to a flat
161
410
  // `tabWidth` columns and everything else is charged by displayWidth, which is
162
411
  // Prettier's own measurement. The only approximation left is the flat tab
@@ -10,9 +10,8 @@ import (
10
10
  // formatParameterProperties breaks a constructor's parameter list onto
11
11
  // one-parameter-per-line when it declares parameter properties, matching
12
12
  // Prettier 3. Prettier forces the break whenever a constructor has more
13
- // than one parameter and at least one carries an accessibility or
14
- // `readonly` modifier (a parameter property), regardless of whether the
15
- // flat form fits printWidth:
13
+ // than one parameter and at least one carries a parameter-property
14
+ // modifier, regardless of whether the flat form fits printWidth:
16
15
  //
17
16
  // constructor(
18
17
  // private readonly repo: Repository,
@@ -155,26 +154,15 @@ func (formatParameterProperties) Check(ctx *Context, node *shimast.Node) {
155
154
  )
156
155
  }
157
156
 
158
- // anyParameterProperty reports whether any parameter carries an
159
- // accessibility (`public`/`private`/`protected`) or `readonly` modifier,
160
- // which makes it a parameter property.
157
+ // anyParameterProperty reports whether any parameter is a parameter property.
158
+ // It defers to the package's shared `isParameterProperty` so one definition
159
+ // answers the question for every rule that asks it; restating the modifier set
160
+ // here is what left `override` out and made this rule abstain on a legal
161
+ // parameter property Prettier breaks.
161
162
  func anyParameterProperty(params []*shimast.Node) bool {
162
163
  for _, p := range params {
163
- if p == nil {
164
- continue
165
- }
166
- mods := p.Modifiers()
167
- if mods == nil {
168
- continue
169
- }
170
- for _, m := range mods.Nodes {
171
- switch m.Kind {
172
- case shimast.KindPublicKeyword,
173
- shimast.KindPrivateKeyword,
174
- shimast.KindProtectedKeyword,
175
- shimast.KindReadonlyKeyword:
176
- return true
177
- }
164
+ if isParameterProperty(p) {
165
+ return true
178
166
  }
179
167
  }
180
168
  return false
@@ -311,7 +311,11 @@ func collectTemplateRanges(file *shimast.SourceFile, src string) []byteRange {
311
311
  return
312
312
  }
313
313
  switch node.Kind {
314
- case shimast.KindNoSubstitutionTemplateLiteral, shimast.KindTemplateExpression:
314
+ case shimast.KindNoSubstitutionTemplateLiteral,
315
+ shimast.KindTemplateExpression,
316
+ // A template literal TYPE spans head plus spans like the expression form
317
+ // and its newlines are equally part of the declared type's text.
318
+ shimast.KindTemplateLiteralType:
315
319
  pos := shimscanner.SkipTrivia(src, node.Pos())
316
320
  end := node.End()
317
321
  if pos >= 0 && end <= len(src) && end > pos {