@ttsc/lint 0.24.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.
@@ -0,0 +1,267 @@
1
+ package linthost
2
+
3
+ import (
4
+ "strings"
5
+
6
+ shimast "github.com/microsoft/typescript-go/shim/ast"
7
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
8
+ )
9
+
10
+ // formatBraceContinuation places a continuation keyword against the clause it
11
+ // continues, mirroring Prettier. The rule is one decision read in two
12
+ // directions, taken from the shape of the preceding clause:
13
+ //
14
+ // - the clause is a block, so the keyword shares its closing brace's line:
15
+ // `} else {`, `} catch (e) {`, `} finally {`, `} while (ready);`
16
+ // - the clause is not a block, so the keyword starts its own line:
17
+ // `if (a) x();` then `else y();`, and `do tick();` then `while (ready);`
18
+ //
19
+ // Both halves belong to one rule because they are the same decision. Splitting
20
+ // them across two owners lets a source satisfy neither, which is what the
21
+ // formatter did before: it never touched this boundary at all, so an
22
+ // Allman-braced file survived `ttsc format` unchanged and a one-line
23
+ // `if (a) x(); else y();` stayed on one line.
24
+ //
25
+ // The rule rewrites only the gap between the preceding clause's last byte and
26
+ // the continuation keyword's first byte. No structural format rule contends for
27
+ // it: `format/clause-join` rewrites the gap AFTER a header token,
28
+ // `format/statement-split` splits statement-list members, and `format/indent`
29
+ // visits statement-list members, closing-brace lines, and member headers.
30
+ // `format/whitespace`'s trailing-whitespace trim does land inside the gap when
31
+ // the clause line ends in spaces; the host drops one of the two findings and the
32
+ // survivor re-fires on the next cascade pass, so the file still converges.
33
+ //
34
+ // A comment in the gap makes the rule abstain rather than relocate it, the same
35
+ // abstention `format/clause-join` and `format/statement-split` apply. Idempotent
36
+ // in both directions: once the keyword sits where it belongs the gap already
37
+ // holds the target text and the rule emits nothing.
38
+ type formatBraceContinuation struct{ optionsRule }
39
+
40
+ // formatBraceContinuationOptions carries only the EOL setting. The push-down
41
+ // direction copies the owning statement's own indent verbatim rather than
42
+ // synthesizing one, so tabWidth and useTabs would decide nothing here and the
43
+ // rule takes the same trimmed option surface `format/whitespace` does. The JSON
44
+ // tag matches the `format` block key the config layer mirrors in (see
45
+ // expandFormatBlock).
46
+ type formatBraceContinuationOptions struct {
47
+ EndOfLine *string `json:"endOfLine"`
48
+ }
49
+
50
+ func (formatBraceContinuation) Name() string { return "format/brace-continuation" }
51
+ func (formatBraceContinuation) IsFormat() bool { return true }
52
+
53
+ func (formatBraceContinuation) Visits() []shimast.Kind {
54
+ return []shimast.Kind{
55
+ shimast.KindIfStatement,
56
+ shimast.KindTryStatement,
57
+ shimast.KindDoStatement,
58
+ }
59
+ }
60
+
61
+ // braceContinuation is one keyword placement: the clause that precedes it and
62
+ // the keyword that continues it.
63
+ type braceContinuation struct {
64
+ // previous is the clause the keyword continues. Its `End()` is the left edge
65
+ // of the gap, and whether it is a block decides the direction.
66
+ previous *shimast.Node
67
+ // keyword is the exact lexeme expected at the right edge of the gap. The
68
+ // following node cannot bound the scan: a catch clause and a finally block
69
+ // both start at the preceding clause's end, so their Pos() is the gap's left
70
+ // edge, not its right. Matching the lexeme instead is also what makes a
71
+ // comment in the gap detectable, since the first non-whitespace byte is then
72
+ // the comment rather than the keyword.
73
+ keyword string
74
+ }
75
+
76
+ func (formatBraceContinuation) Check(ctx *Context, node *shimast.Node) {
77
+ if ctx == nil || ctx.File == nil || node == nil {
78
+ return
79
+ }
80
+ var opts formatBraceContinuationOptions
81
+ _ = ctx.DecodeOptions(&opts)
82
+ eol := "\n"
83
+ if opts.EndOfLine != nil && *opts.EndOfLine == "crlf" {
84
+ eol = "\r\n"
85
+ }
86
+ src := ctx.File.Text()
87
+ for _, continuation := range braceContinuations(node) {
88
+ placeBraceContinuation(ctx, src, node, continuation, eol)
89
+ }
90
+ }
91
+
92
+ // braceContinuations returns every continuation keyword `node` carries, paired
93
+ // with the clause it continues.
94
+ func braceContinuations(node *shimast.Node) []braceContinuation {
95
+ switch node.Kind {
96
+ case shimast.KindIfStatement:
97
+ stmt := node.AsIfStatement()
98
+ if stmt == nil || stmt.ElseStatement == nil || stmt.ThenStatement == nil {
99
+ return nil
100
+ }
101
+ return []braceContinuation{{previous: stmt.ThenStatement, keyword: "else"}}
102
+ case shimast.KindTryStatement:
103
+ stmt := node.AsTryStatement()
104
+ if stmt == nil || stmt.TryBlock == nil {
105
+ return nil
106
+ }
107
+ out := make([]braceContinuation, 0, 2)
108
+ previous := stmt.TryBlock.AsNode()
109
+ if stmt.CatchClause != nil {
110
+ out = append(out, braceContinuation{previous: previous, keyword: "catch"})
111
+ previous = stmt.CatchClause
112
+ }
113
+ if stmt.FinallyBlock != nil {
114
+ out = append(out, braceContinuation{previous: previous, keyword: "finally"})
115
+ }
116
+ return out
117
+ case shimast.KindDoStatement:
118
+ stmt := node.AsDoStatement()
119
+ if stmt == nil || stmt.Statement == nil || stmt.Expression == nil {
120
+ return nil
121
+ }
122
+ return []braceContinuation{{previous: stmt.Statement, keyword: "while"}}
123
+ }
124
+ return nil
125
+ }
126
+
127
+ func placeBraceContinuation(
128
+ ctx *Context,
129
+ src string,
130
+ node *shimast.Node,
131
+ continuation braceContinuation,
132
+ eol string,
133
+ ) {
134
+ previous := continuation.previous
135
+ if previous == nil {
136
+ return
137
+ }
138
+ gapStart := previous.End()
139
+ if gapStart < 0 || gapStart > len(src) {
140
+ return
141
+ }
142
+ // The gap is the whitespace run after the preceding clause. The bytes that
143
+ // follow it must be the continuation keyword itself: a comment there is
144
+ // content the rewrite would delete, and it shows up as a first non-whitespace
145
+ // byte that is not the keyword.
146
+ keywordStart := gapStart
147
+ for keywordStart < len(src) && isBraceContinuationGapByte(src[keywordStart]) {
148
+ keywordStart++
149
+ }
150
+ if !braceContinuationKeywordAt(src, keywordStart, continuation.keyword) {
151
+ return
152
+ }
153
+
154
+ want := " "
155
+ if !braceContinuationEndsInBlock(previous) {
156
+ // A non-block clause pushes the keyword onto its own line, indented to the
157
+ // statement that owns it. The whole gap is the target, so a keyword already
158
+ // on its own line at the wrong column is corrected by the same comparison
159
+ // instead of being ceded to a rule that never visits that line.
160
+ indent, ok := braceContinuationIndent(src, node)
161
+ if !ok {
162
+ return
163
+ }
164
+ want = eol + indent
165
+ }
166
+ if src[gapStart:keywordStart] == want {
167
+ return
168
+ }
169
+ ctx.ReportRangeFix(
170
+ gapStart,
171
+ keywordStart,
172
+ "Continuation keyword should sit against the clause it continues.",
173
+ TextEdit{Pos: gapStart, End: keywordStart, Text: want},
174
+ )
175
+ }
176
+
177
+ // braceContinuationIndent returns the leading whitespace of the line the
178
+ // statement starts on, which is the column Prettier gives the pushed-down
179
+ // keyword, and reports whether that column is knowable yet.
180
+ //
181
+ // A statement sharing its line with something else has no column of its own,
182
+ // and nothing would repair a keyword pushed to column zero: `format/indent`
183
+ // visits statement-list members, closing-brace lines, and member headers, and a
184
+ // continuation-keyword line is none of the three. So the rule abstains, lets
185
+ // `format/statement-split` give the statement its own line first, and reads the
186
+ // real column on the next cascade pass. `format/indent` takes the same
187
+ // not-first-on-its-line abstention for the same reason.
188
+ func braceContinuationIndent(src string, node *shimast.Node) (string, bool) {
189
+ start := shimscanner.SkipTrivia(src, node.Pos())
190
+ if start < 0 || start > len(src) {
191
+ return "", false
192
+ }
193
+ lineStart := lineStartOffset(src, start)
194
+ indentEnd := lineStart
195
+ for indentEnd < start && (src[indentEnd] == ' ' || src[indentEnd] == '\t') {
196
+ indentEnd++
197
+ }
198
+ // A label prefix is part of the statement's own line rather than another
199
+ // statement sharing it, and nothing downstream would ever give a labeled body
200
+ // its own line, so refusing here would strand the keyword permanently.
201
+ if !isBraceContinuationLabelPrefix(src[indentEnd:start]) {
202
+ return "", false
203
+ }
204
+ return src[lineStart:indentEnd], true
205
+ }
206
+
207
+ // isBraceContinuationLabelPrefix reports whether `prefix` is empty or a run of
208
+ // `identifier :` labels, the only thing allowed to precede a statement on the
209
+ // line whose indent the pushed-down keyword adopts.
210
+ func isBraceContinuationLabelPrefix(prefix string) bool {
211
+ for {
212
+ prefix = strings.TrimLeft(prefix, " \t")
213
+ if prefix == "" {
214
+ return true
215
+ }
216
+ name := 0
217
+ for name < len(prefix) && isBraceContinuationIdentifierByte(prefix[name]) {
218
+ name++
219
+ }
220
+ if name == 0 {
221
+ return false
222
+ }
223
+ rest := strings.TrimLeft(prefix[name:], " \t")
224
+ if !strings.HasPrefix(rest, ":") {
225
+ return false
226
+ }
227
+ prefix = rest[1:]
228
+ }
229
+ }
230
+
231
+ // braceContinuationEndsInBlock reports whether the keyword shares the preceding
232
+ // clause's last line, which is what decides the direction. The predicate is the
233
+ // clause's kind, not its last byte: a `switch` consequent and an Annex-B
234
+ // `function` consequent both end in `}` and Prettier still pushes `else` onto
235
+ // its own line after them. A catch clause is the one non-block kind that shares
236
+ // its line, because the try printer always spaces `} finally`.
237
+ func braceContinuationEndsInBlock(previous *shimast.Node) bool {
238
+ return previous.Kind == shimast.KindBlock || previous.Kind == shimast.KindCatchClause
239
+ }
240
+
241
+ // braceContinuationKeywordAt reports whether `keyword` starts at `offset` as a
242
+ // whole token. The trailing boundary matters: `finally` must not match the
243
+ // `final` prefix of an identifier a comment left exposed.
244
+ func braceContinuationKeywordAt(src string, offset int, keyword string) bool {
245
+ end := offset + len(keyword)
246
+ if offset < 0 || end > len(src) || src[offset:end] != keyword {
247
+ return false
248
+ }
249
+ return end == len(src) || !isBraceContinuationIdentifierByte(src[end])
250
+ }
251
+
252
+ // isBraceContinuationIdentifierByte reports whether `c` can appear inside an
253
+ // identifier, so a keyword is only accepted as a whole token.
254
+ func isBraceContinuationIdentifierByte(c byte) bool {
255
+ return c == 0x5f || c == 0x24 ||
256
+ (c >= 0x61 && c <= 0x7a) || (c >= 0x41 && c <= 0x5a) || (c >= 0x30 && c <= 0x39)
257
+ }
258
+
259
+ // isBraceContinuationGapByte reports whether `c` is whitespace that may appear
260
+ // between a clause and the keyword continuing it.
261
+ func isBraceContinuationGapByte(c byte) bool {
262
+ return c == ' ' || c == '\t' || c == '\r' || c == '\n'
263
+ }
264
+
265
+ func init() {
266
+ Register(formatBraceContinuation{})
267
+ }
@@ -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