@ttsc/lint 0.14.0 → 0.14.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.
package/README.md CHANGED
@@ -66,8 +66,8 @@ export default {
66
66
  rules: {
67
67
  "no-var": "error",
68
68
  "prefer-const": "error",
69
- "typescript/no-explicit-any": "warning",
70
- "no-console": "off",
69
+ "typescript/no-explicit-any": "error",
70
+ "typescript/no-floating-promises": "error",
71
71
  },
72
72
  } satisfies ITtscLintConfig;
73
73
  ```
@@ -32,9 +32,11 @@ func printArrayLiteral(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
32
32
  return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
33
33
  }
34
34
  // A comment between elements (or after `[`) would be dropped by the fresh
35
- // separators; bail to verbatim so the enclosing reflow abstains.
35
+ // separators; bail to verbatim and report UNCOVERED (hard `false`, not
36
+ // `!nodeSpansMultipleLines`) so an enclosing reflow abstains instead of
37
+ // breaking around this single-line verbatim array and moving it off its line.
36
38
  if listHasInterItemComments(ctx, node) {
37
- return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
39
+ return verbatim(ctx, node), false
38
40
  }
39
41
  items := make([]Doc, 0, len(arr.Elements.Nodes))
40
42
  covered := true
@@ -119,12 +121,68 @@ func arrayForcesBreak(node *shimast.Node) bool {
119
121
  return arrayShouldForceBreak(arr.Elements.Nodes)
120
122
  }
121
123
 
124
+ // fastPathForcesBreak reports whether `node`, OR a force-breaking node nested
125
+ // within the subtree a reflow of `node` would print (its call/new arguments and
126
+ // array elements, recursively), must explode even though it fits flat. Prettier
127
+ // breaks such a descendant — an array `shouldBreak` or a function-composition
128
+ // call/new — when the enclosing node reflows; ttsc's print-width fast path
129
+ // returns first while the descendant abstains via hasReflowAncestor, leaving
130
+ // both flat (`new Map([["a", 1], ["b", 2]])`, `foo([[1, 2], [3, 4]])`). So the
131
+ // fast path must consult this, not only the visited node itself.
132
+ func fastPathForcesBreak(node *shimast.Node) bool {
133
+ if node == nil {
134
+ return false
135
+ }
136
+ if callForcesFunctionBreak(node) || arrayForcesBreak(node) {
137
+ return true
138
+ }
139
+ var children []*shimast.Node
140
+ switch node.Kind {
141
+ case shimast.KindCallExpression:
142
+ if c := node.AsCallExpression(); c != nil && c.Arguments != nil {
143
+ children = c.Arguments.Nodes
144
+ }
145
+ case shimast.KindNewExpression:
146
+ if n := node.AsNewExpression(); n != nil && n.Arguments != nil {
147
+ children = n.Arguments.Nodes
148
+ }
149
+ case shimast.KindArrayLiteralExpression:
150
+ if a := node.AsArrayLiteralExpression(); a != nil && a.Elements != nil {
151
+ children = a.Elements.Nodes
152
+ }
153
+ case shimast.KindObjectLiteralExpression:
154
+ // A force-breaking array/object nested in an object PROPERTY value
155
+ // (`{ m: [[1, 2], [3, 4]] }`) must also deny the fast path: the value
156
+ // abstains to its object ancestor via hasReflowAncestor, and the object
157
+ // itself fits flat, so without descending into property initializers both
158
+ // would stay flat where Prettier breaks them.
159
+ if o := node.AsObjectLiteralExpression(); o != nil && o.Properties != nil {
160
+ for _, p := range o.Properties.Nodes {
161
+ if p != nil && p.Kind == shimast.KindPropertyAssignment {
162
+ if pa := p.AsPropertyAssignment(); pa != nil && pa.Initializer != nil {
163
+ children = append(children, pa.Initializer)
164
+ }
165
+ }
166
+ }
167
+ }
168
+ }
169
+ for _, ch := range children {
170
+ if fastPathForcesBreak(ch) {
171
+ return true
172
+ }
173
+ }
174
+ return false
175
+ }
176
+
122
177
  // isConciselyPrintedArray reports whether an array should use Prettier's
123
- // concise "fill" layout: more than one element and every element a numeric
178
+ // concise "fill" layout: at least one element and every element a numeric
124
179
  // literal (optionally a `+`/`-` signed numeric). Prettier packs such arrays
125
180
  // several per line; mixed / string / identifier arrays stay one-per-line.
181
+ // Prettier's predicate gates on `elements.length > 0` (array.js), so a
182
+ // single-element numeric array counts — which also makes a `[42]` last
183
+ // argument decline last-argument hugging, matching shouldExpandLastArg.
126
184
  func isConciselyPrintedArray(elems []*shimast.Node) bool {
127
- if len(elems) < 2 {
185
+ if len(elems) < 1 {
128
186
  return false
129
187
  }
130
188
  for _, e := range elems {
@@ -136,13 +194,15 @@ func isConciselyPrintedArray(elems []*shimast.Node) bool {
136
194
  }
137
195
 
138
196
  // isNumericArrayElement reports whether `node` is a numeric literal or a
139
- // `+`/`-` prefix applied to one.
197
+ // `+`/`-` prefix applied to one. A BigInt literal is NOT numeric here: Prettier's
198
+ // isNumericLiteral matches only a number-valued literal, so `[1n, 2n]` prints
199
+ // one element per line rather than filling.
140
200
  func isNumericArrayElement(node *shimast.Node) bool {
141
201
  if node == nil {
142
202
  return false
143
203
  }
144
204
  switch node.Kind {
145
- case shimast.KindNumericLiteral, shimast.KindBigIntLiteral:
205
+ case shimast.KindNumericLiteral:
146
206
  return true
147
207
  case shimast.KindPrefixUnaryExpression:
148
208
  u := node.AsPrefixUnaryExpression()
@@ -152,8 +212,7 @@ func isNumericArrayElement(node *shimast.Node) bool {
152
212
  if u.Operator != shimast.KindPlusToken && u.Operator != shimast.KindMinusToken {
153
213
  return false
154
214
  }
155
- return u.Operand.Kind == shimast.KindNumericLiteral ||
156
- u.Operand.Kind == shimast.KindBigIntLiteral
215
+ return u.Operand.Kind == shimast.KindNumericLiteral
157
216
  }
158
217
  return false
159
218
  }
@@ -2,7 +2,6 @@ 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"
6
5
  )
7
6
 
8
7
  // printCallExpression renders a CallExpression with width-aware
@@ -52,6 +51,18 @@ func printCallExpression(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
52
51
  if hasNilEntry(call.Arguments) {
53
52
  return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
54
53
  }
54
+ // A comment in an argument gap (`foo(a, /* c */ b)`) would be dropped by the
55
+ // freshly minted `, ` separators in printArgList. The top-level print-width
56
+ // scan only masks a *direct* child's comments, so a comment inside a NESTED
57
+ // call's argument list slips through and is lost on reflow. Self-guard the
58
+ // way the object/array printers do: bail to verbatim and report UNCOVERED so
59
+ // an enclosing reflow abstains too. Uncovered must be hard `false`, not
60
+ // `!nodeSpansMultipleLines`: a single-line comment-bearing call would
61
+ // otherwise report covered and let the outer reflow break around it, moving
62
+ // the verbatim node off its line (the assertFormatUnchanged contract).
63
+ if listHasInterItemComments(ctx, node) {
64
+ return verbatim(ctx, node), false
65
+ }
55
66
  // Prettier never appends a trailing comma inside a dynamic
56
67
  // `import(...)`, so the printer's reflow must agree with
57
68
  // format/trailing-comma's same exception (see isDynamicImportCall).
@@ -90,6 +101,16 @@ func printNewExpression(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
90
101
  if ne.TypeArguments != nil {
91
102
  parts = append(parts, verbatimRange(ctx.Source, typeArgsStart(ctx.Source, ne.TypeArguments), typeArgsEnd(ctx.Source, ne.TypeArguments)))
92
103
  }
104
+ // A comment in the `new`->constructee gap (`new /* c */ Foo`) or an argument
105
+ // gap would be dropped by the minted `Text("new ")` / fresh separators — those
106
+ // gaps are not AST children, so a nested new-expression's comment slips past
107
+ // the top-level scan. Guard unconditionally (the no-args `new Foo` path mints
108
+ // `new ` too), mirroring printCallExpression's unconditional guard. Report
109
+ // UNCOVERED (hard `false`, not `!nodeSpansMultipleLines`) so an enclosing
110
+ // reflow abstains rather than breaking around this single-line verbatim node.
111
+ if listHasInterItemComments(ctx, node) {
112
+ return verbatim(ctx, node), false
113
+ }
93
114
  if ne.Arguments != nil {
94
115
  if hasNilEntry(ne.Arguments) {
95
116
  return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
@@ -155,13 +176,17 @@ func printArgList(ctx *PrintContext, list *shimast.NodeList, addComma bool, deco
155
176
  // doc carries hard breaks (a block-bodied callback). A leading object or
156
177
  // array that merely fits flat does NOT decline — Prettier still hugs the
157
178
  // last argument there (`doConfigure({ … }, () => { … })`).
158
- // A decorator's call hugs its last argument even past leading callbacks
159
- // (`@OneToMany(() => P, (p) => p.c, { … })` keeps the arrows inline and
160
- // breaks only the object); a plain call with two or more leading callbacks
161
- // explodes instead, so the function-arg gate applies only off a decorator.
179
+ // The useMemo/useEffect deps shape (`f(() => x, [deps])`) explodes its 2-arg
180
+ // arrow+array one-per-line even under a decorator: Prettier's shouldExpandLastArg
181
+ // declines `args.length === 2 && penultimate is ArrowFunction && last is Array`
182
+ // unconditionally (it is NOT decorator-gated). The genuine decorator hug
183
+ // (`@OneToMany(() => P, (p) => p.c, { … })`) is 3-arg, so isUseMemoArrowArrayShape
184
+ // already returns false for it; a 2-or-more-callback composition is handled by
185
+ // argListForcesFunctionBreak, and a block-bodied leading callback by
186
+ // anyLeadingItemBreaks.
162
187
  hugLast := shouldHugLastArgument(list.Nodes) &&
163
188
  !anyLeadingItemBreaks(items) &&
164
- (decoratorCall || !anyLeadingArgIsFunctionLike(list.Nodes))
189
+ !isUseMemoArrowArrayShape(list.Nodes)
165
190
  // Two or more function/arrow arguments force the list to explode, one per
166
191
  // line, even when it would fit flat: Prettier always breaks a call carrying
167
192
  // multiple callbacks (`promise.then(() => a, () => b)`), the
@@ -180,6 +205,7 @@ func printArgList(ctx *PrintContext, list *shimast.NodeList, addComma bool, deco
180
205
  hugLast = true
181
206
  forceFnBreak = false
182
207
  }
208
+ hugFirst := !hugLast && !forceFnBreak && shouldHugFirstArgument(ctx, list.Nodes)
183
209
  shape := listShape{
184
210
  OpenTok: "(",
185
211
  CloseTok: ")",
@@ -188,9 +214,16 @@ func printArgList(ctx *PrintContext, list *shimast.NodeList, addComma bool, deco
188
214
  AddComma: addComma,
189
215
  HugLast: hugLast,
190
216
  HugLastForce: forceHugLast,
191
- HugFirst: !hugLast && !forceFnBreak && shouldHugFirstArgument(ctx, list.Nodes),
192
- ForceBreak: forceFnBreak,
193
- BlankBefore: blankBeforeItems(ctx.Source, list.Nodes),
217
+ HugFirst: hugFirst,
218
+ // A non-empty array second argument reaches HugFirst only via the
219
+ // React-hook deps branch of shouldHugFirstArgument; let that deps array
220
+ // break one-per-line instead of being pinned flat on the close line.
221
+ HugFirstTrailingBreaks: hugFirst && len(list.Nodes) == 2 &&
222
+ list.Nodes[1] != nil &&
223
+ list.Nodes[1].Kind == shimast.KindArrayLiteralExpression,
224
+ HugFirstForce: hugFirst && isReactHookDepsCall(list.Nodes),
225
+ ForceBreak: forceFnBreak,
226
+ BlankBefore: blankBeforeItems(ctx.Source, list.Nodes),
194
227
  }
195
228
  return printList(ctx, shape), covered
196
229
  }
@@ -379,40 +412,78 @@ func argListForcesFunctionBreak(args []*shimast.Node, decoratorCall bool) bool {
379
412
  return isFunctionCompositionArgs(args)
380
413
  }
381
414
 
382
- // callForcesFunctionBreak reports whether a node is a CallExpression that the
383
- // multiple-callback rule forces to explode. The print-width rule consults it
384
- // so its flat-fit fast path does not leave such a call inline when the source
415
+ // callForcesFunctionBreak reports whether a node is a call OR new expression
416
+ // that the multiple-callback rule forces to explode. Prettier's printCallArguments
417
+ // is shared by NewExpression (its function-composition break is gated only by
418
+ // `path.parent.type !== "Decorator"`, not by call-vs-new), so `new Foo(() => a,
419
+ // () => b)` explodes the same as a call. The print-width rule consults this so
420
+ // its flat-fit fast path does not leave such a call/new inline when the source
385
421
  // wrote it on one line.
386
422
  func callForcesFunctionBreak(node *shimast.Node) bool {
387
- if node == nil || node.Kind != shimast.KindCallExpression {
423
+ if node == nil {
388
424
  return false
389
425
  }
390
- call := node.AsCallExpression()
391
- if call == nil || call.Arguments == nil {
426
+ var args *shimast.NodeList
427
+ switch node.Kind {
428
+ case shimast.KindCallExpression:
429
+ if c := node.AsCallExpression(); c != nil {
430
+ args = c.Arguments
431
+ }
432
+ case shimast.KindNewExpression:
433
+ if n := node.AsNewExpression(); n != nil {
434
+ args = n.Arguments
435
+ }
436
+ default:
392
437
  return false
393
438
  }
394
- decoratorCall := node.Parent != nil && node.Parent.Kind == shimast.KindDecorator
395
- return argListForcesFunctionBreak(call.Arguments.Nodes, decoratorCall)
439
+ if args == nil {
440
+ return false
441
+ }
442
+ // A new expression is never a decorator's call.
443
+ decoratorCall := node.Kind == shimast.KindCallExpression &&
444
+ node.Parent != nil && node.Parent.Kind == shimast.KindDecorator
445
+ return argListForcesFunctionBreak(args.Nodes, decoratorCall)
396
446
  }
397
447
 
398
- // anyLeadingArgIsFunctionLike reports whether any argument before the last
399
- // is a function or arrow expression. Prettier declines last-argument
400
- // hugging when a non-last argument is itself a function/arrow, regardless
401
- // of its body, so `useMemo(() => compute(), [deps])` explodes rather than
402
- // hugging the trailing array. This complements anyLeadingItemBreaks, which
403
- // only catches leads with hard line breaks (a block body) and so misses an
404
- // expression-bodied arrow.
405
- func anyLeadingArgIsFunctionLike(args []*shimast.Node) bool {
406
- for i := 0; i+1 < len(args); i++ {
407
- if args[i] == nil {
408
- continue
409
- }
410
- switch args[i].Kind {
411
- case shimast.KindArrowFunction, shimast.KindFunctionExpression:
412
- return true
413
- }
448
+ // isUseMemoArrowArrayShape reports the ONE leading-argument shape Prettier
449
+ // declines last-argument hugging for: exactly two arguments where the first is
450
+ // an arrow function and the last is an array literal — the
451
+ // `useMemo(() => x, [deps])` / `useEffect(() => { … }, [deps])` shape.
452
+ // Prettier's shouldExpandLastArg (call-arguments.js:260-262) has NO general
453
+ // "a leading function declines hugging" rule; its only leading-argument clause
454
+ // is `args.length === 2 && penultimate is ArrowFunctionExpression && last is
455
+ // ArrayExpression`. A block-bodied leading callback is handled independently by
456
+ // anyLeadingItemBreaks (it `willBreak`), and two-or-more callbacks by
457
+ // argListForcesFunctionBreak, so a leading expression-bodied arrow before a
458
+ // non-array huggable last argument (`f((x) => g(x), { a: 1 })`) must still hug.
459
+ func isUseMemoArrowArrayShape(args []*shimast.Node) bool {
460
+ if len(args) != 2 || args[0] == nil || args[1] == nil {
461
+ return false
414
462
  }
415
- return false
463
+ return args[0].Kind == shimast.KindArrowFunction &&
464
+ args[1].Kind == shimast.KindArrayLiteralExpression
465
+ }
466
+
467
+ // isReactHookDepsCall reports the React-hook deps shape Prettier hugs WITHOUT an
468
+ // exploded fallback: exactly two arguments, the first a ZERO-parameter
469
+ // block-bodied arrow, the second an array literal (`useEffect(() => { … },
470
+ // [deps])`). Prettier's isReactHookCallWithDepsArray / isValidHookCallbackAndDepsFormat
471
+ // keys on this and never explodes the args — it keeps the callback hugged and
472
+ // lets the open line overflow. A parameterized callback (`subscribe((e) => { … },
473
+ // [])`) is NOT this shape and keeps the fallback, so the zero-parameter +
474
+ // block-body gate is load-bearing (distinct from isUseMemoArrowArrayShape and
475
+ // from the HugFirstTrailingBreaks array check).
476
+ func isReactHookDepsCall(args []*shimast.Node) bool {
477
+ if len(args) != 2 || args[0] == nil || args[1] == nil {
478
+ return false
479
+ }
480
+ if args[1].Kind != shimast.KindArrayLiteralExpression {
481
+ return false
482
+ }
483
+ arrow := args[0].AsArrowFunction()
484
+ return arrow != nil && arrow.Body != nil &&
485
+ arrow.Body.Kind == shimast.KindBlock &&
486
+ len(args[0].Parameters()) == 0
416
487
  }
417
488
 
418
489
  // anyLeadingItemBreaks reports whether any item before the last carries a
@@ -460,6 +531,15 @@ func shouldHugLastArgument(args []*shimast.Node) bool {
460
531
  if pen := args[len(args)-2]; pen != nil && pen.Kind == last.Kind {
461
532
  return false
462
533
  }
534
+ // Prettier's shouldExpandLastArg also declines a CONCISELY-PRINTED numeric
535
+ // array as the last of two-plus arguments: it fills on its own line, so
536
+ // `drawPolygon(ctx, [1, 2, 3, …])` explodes rather than hugging.
537
+ if last.Kind == shimast.KindArrayLiteralExpression {
538
+ if arr := last.AsArrayLiteralExpression(); arr != nil && arr.Elements != nil &&
539
+ isConciselyPrintedArray(arr.Elements.Nodes) {
540
+ return false
541
+ }
542
+ }
463
543
  }
464
544
  return true
465
545
  }
@@ -471,10 +551,23 @@ func shouldHugLastArgument(args []*shimast.Node) bool {
471
551
  // flat hugging Concat would pin an overflowing call to one line.
472
552
  func lastArgHuggableShape(last *shimast.Node) bool {
473
553
  switch last.Kind {
474
- case shimast.KindFunctionExpression,
475
- shimast.KindObjectLiteralExpression,
476
- shimast.KindArrayLiteralExpression:
554
+ case shimast.KindFunctionExpression:
477
555
  return true
556
+ case shimast.KindObjectLiteralExpression:
557
+ // Prettier's couldExpandArg requires a NON-EMPTY object (`properties.length
558
+ // > 0`); an empty `{}` is not expandable, so `foo(a, b, {})` explodes the
559
+ // list rather than hugging. (Inter-item comments route to verbatim, so the
560
+ // hasComment branch is moot here.)
561
+ if obj := last.AsObjectLiteralExpression(); obj != nil {
562
+ return obj.Properties != nil && len(obj.Properties.Nodes) > 0
563
+ }
564
+ return false
565
+ case shimast.KindArrayLiteralExpression:
566
+ // Likewise a non-empty array; an empty `[]` is not expandable.
567
+ if arr := last.AsArrayLiteralExpression(); arr != nil {
568
+ return arr.Elements != nil && len(arr.Elements.Nodes) > 0
569
+ }
570
+ return false
478
571
  case shimast.KindArrowFunction:
479
572
  arrow := last.AsArrowFunction()
480
573
  if arrow == nil || arrow.Body == nil {
@@ -494,13 +587,26 @@ func lastArgHuggableShape(last *shimast.Node) bool {
494
587
  return true
495
588
  case shimast.KindObjectLiteralExpression,
496
589
  shimast.KindArrayLiteralExpression:
497
- // An expression-bodied arrow with an explicit return type is not hugged:
498
- // Prettier's couldGroupArg excludes it ("avoid breaking inside composite
499
- // return types"), so `map((r): T => ({ … }))` explodes the whole list
500
- // while `map((r) => ({ … }))` hugs.
501
- return arrow.Type == nil
590
+ // An object/array-bodied arrow hugs unless its return type is a type
591
+ // REFERENCE. Prettier's couldExpandArg gates only on
592
+ // `returnType.typeAnnotation.type === "TSTypeReference"`, so a keyword,
593
+ // union, array, or literal return type (`map((r): void => ({ … }))`)
594
+ // still hugs while a named-reference return (`map((r): Foo => ({ … })))`)
595
+ // explodes the whole list. A block body hugs regardless (handled above).
596
+ return arrow.Type == nil || arrow.Type.Kind != shimast.KindTypeReference
502
597
  }
503
598
  }
599
+ // DEFERRED (architectural, not a one-line predicate fix; do not re-add to the
600
+ // switch above without the supporting layout work):
601
+ // - A call/conditional/JSX-expression arrow body. Prettier's couldExpandArg
602
+ // hugs it, but ttsc emits the arrow signature (`=> `) verbatim with no
603
+ // break point after `=>`, so hugging here would force the FIRST group
604
+ // inside the call instead of breaking after `=>` — a different shape, not
605
+ // parity. Needs a break-after-`=>` arrow-body layout variant first.
606
+ // - A trailing `as`/`satisfies` cast wrapping a huggable last argument.
607
+ // Prettier's couldExpandArg recurses into the cast, but ttsc does not
608
+ // dispatch KindAsExpression (it prints verbatim), so forceBreakFirstGroup
609
+ // finds no group and the hug is inert. Needs a cast printer first.
504
610
  return false
505
611
  }
506
612
 
@@ -523,7 +629,22 @@ func shouldHugFirstArgument(ctx *PrintContext, args []*shimast.Node) bool {
523
629
  if first == nil || second == nil {
524
630
  return false
525
631
  }
526
- return isFirstArgHuggableCallback(first) && isSimpleTrailingArg(ctx, second)
632
+ if !isFirstArgHuggableCallback(first) {
633
+ return false
634
+ }
635
+ // A NON-EMPTY array trailing arg hugs only in Prettier's React-hook deps
636
+ // shape (isReactHookCallWithDepsArray): a ZERO-parameter arrow callback plus
637
+ // an array, e.g. `useEffect(() => { … }, [a, b])`. With a parameter the call
638
+ // explodes (`subscribe((event) => { … }, [a, b])`), and a function expression
639
+ // (not an arrow) never qualifies. An EMPTY array falls through to
640
+ // isSimpleTrailingArg.
641
+ if second.Kind == shimast.KindArrayLiteralExpression {
642
+ if arr := second.AsArrayLiteralExpression(); arr != nil &&
643
+ arr.Elements != nil && len(arr.Elements.Nodes) > 0 {
644
+ return first.Kind == shimast.KindArrowFunction && len(first.Parameters()) == 0
645
+ }
646
+ }
647
+ return isSimpleTrailingArg(ctx, second)
527
648
  }
528
649
 
529
650
  // isFirstArgHuggableCallback reports whether `node` is the callback shape
@@ -544,18 +665,18 @@ func isFirstArgHuggableCallback(node *shimast.Node) bool {
544
665
  // isSimpleTrailingArg reports whether `node` is a value that may trail a
545
666
  // hugged first-argument callback. Prettier hugs the leading callback when
546
667
  // the trailing argument is an identifier, member access, literal, `this`,
547
- // or an array literal most notably the `useEffect(() => { … }, [deps])`
548
- // idiom. It also accepts an empty object literal, a short call/new, and a
549
- // short arithmetic/logical expression (`setTimeout(fn, 1000 - x)`). A function,
550
- // arrow, conditional, or a non-empty object literal is excluded so
551
- // first-argument hugging declines and the whole list explodes.
668
+ // or an EMPTY array/object literal. It also accepts a short call/new (at
669
+ // most one value argument) and a short arithmetic/logical expression
670
+ // (`setTimeout(fn, 1000 - x)`). A function, arrow, conditional, a non-empty
671
+ // array, or a non-empty object literal is excluded, so first-argument hugging
672
+ // declines and the whole list explodes. (A non-empty array after a
673
+ // zero-parameter arrow is the React-hook deps shape, hugged earlier in
674
+ // shouldHugFirstArgument and never routed here.)
552
675
  //
553
- // A call expression is deliberately NOT included: the conditional-group
554
- // fit check only measures an option's first line, so a hugged-first option
555
- // whose trailing call overflows the closing line (`}, deeplyNested(…))`)
556
- // would still be selected, where Prettier explodes. The array case shares
557
- // that limitation in principle, but dependency arrays are short in
558
- // practice, the same way the identifier/member cases already are.
676
+ // The call/new case rides the close line: the conditional-group fit check
677
+ // only measures an option's first line, so a hugged-first option whose
678
+ // trailing call overflows the closing line is still selected. Prettier
679
+ // accepts the same trade for a short trailing call.
559
680
  func isSimpleTrailingArg(ctx *PrintContext, node *shimast.Node) bool {
560
681
  switch node.Kind {
561
682
  case shimast.KindIdentifier,
@@ -567,26 +688,46 @@ func isSimpleTrailingArg(ctx *PrintContext, node *shimast.Node) bool {
567
688
  shimast.KindNullKeyword,
568
689
  shimast.KindThisKeyword,
569
690
  shimast.KindPropertyAccessExpression,
570
- shimast.KindElementAccessExpression,
571
- shimast.KindArrayLiteralExpression:
691
+ shimast.KindElementAccessExpression:
572
692
  return true
693
+ case shimast.KindArrayLiteralExpression:
694
+ // Only an EMPTY array is simple-trailing (the `[] as T[]` cast idiom).
695
+ // Prettier's couldExpandArg treats an array with elements as expandable,
696
+ // so a non-empty array cast (`[x] as number[]`) explodes the list rather
697
+ // than hugging. The non-empty bare-array deps shape (zero-param arrow) is
698
+ // handled in shouldHugFirstArgument before reaching here.
699
+ if arr := node.AsArrayLiteralExpression(); arr != nil {
700
+ return arr.Elements == nil || len(arr.Elements.Nodes) == 0
701
+ }
702
+ return false
573
703
  case shimast.KindAsExpression:
574
704
  // `[] as string[]`: the `reduce(fn, [] as T[])` idiom. Hug when the cast
575
- // wraps a value that is itself a simple trailing arg AND the target type is
576
- // simple. A type carrying an object type literal (`[] as Array<{ … }>`) is
577
- // not simple, Prettier expands and explodes the list there, so exclude any
578
- // type whose source contains a `{` (a type literal).
705
+ // target is a simple type AND its inner expression is simple at depth 1
706
+ // Prettier's isHopefullyShortCallArgument cast branch is exactly
707
+ // `isSimpleType(typeAnnotation) && isSimpleCallArgument(node.expression, 1)`.
708
+ // Depth 1 (not the general isSimpleTrailingArg) is deliberate: it has no
709
+ // binaryish branch and bottoms a nested call's argument out at depth 0, so
710
+ // `(a - b) as T` and `makeInit(x) as T[]` are NOT simple and explode.
579
711
  if as := node.AsAsExpression(); as != nil && as.Expression != nil {
580
- return isSimpleTrailingArg(ctx, as.Expression) && !typeHasObjectLiteral(ctx.Source, as.Type)
712
+ return isSimpleCallArg(as.Expression, 1) && isSimpleCastType(as.Type)
713
+ }
714
+ case shimast.KindSatisfiesExpression:
715
+ // `[] satisfies T[]`: Prettier's isBinaryCastExpression covers
716
+ // TSSatisfiesExpression identically to TSAsExpression, so a satisfies cast
717
+ // takes the same simple-type + depth-1 simple-expression path as `as`.
718
+ if s := node.AsSatisfiesExpression(); s != nil && s.Expression != nil {
719
+ return isSimpleCallArg(s.Expression, 1) && isSimpleCastType(s.Type)
581
720
  }
582
721
  case shimast.KindCallExpression, shimast.KindNewExpression:
583
722
  // `reduce(fn, Object.create(null))` / `reduce(fn, new Map<…>())`: Prettier
584
- // hugs the first arg when the trailing call/new has at most one value
585
- // argument, regardless of its length, type arguments and long names do not
586
- // matter and the close line is allowed to overflow. A call with two or more
587
- // arguments explodes the whole list instead. Mirrors Prettier's
588
- // isHopefullyShortCallArgument for call-like nodes.
589
- return callValueArgCount(node) <= 1
723
+ // hugs the first arg over a trailing call/new only when it has at most one
724
+ // value argument AND that argument is itself structurally simple. Its
725
+ // isHopefullyShortCallArgument early-rejects >1 args, then falls through to
726
+ // isSimpleCallArgument (depth 2), so a 1-arg call whose sole argument is a
727
+ // non-trivial nested call/object/array (`reduce(fn, makeInit(deep(arg)))`)
728
+ // is NOT simple and the whole list explodes. Type arguments and long names
729
+ // still do not matter; the close line is allowed to overflow.
730
+ return callValueArgCount(node) <= 1 && isSimpleCallArg(node, 2)
590
731
  case shimast.KindObjectLiteralExpression:
591
732
  // `reduce(fn, {})`: an EMPTY object literal hugs (Prettier's couldGroupArg
592
733
  // excludes a property-less object); an object with properties expands and
@@ -612,6 +753,109 @@ func isSimpleTrailingArg(ctx *PrintContext, node *shimast.Node) bool {
612
753
  return false
613
754
  }
614
755
 
756
+ // isSimpleCallArg ports Prettier's isSimpleCallArgument (utilities/index.js): a
757
+ // depth-bounded structural simplicity check. A node is simple when it is a leaf
758
+ // (identifier, literal, this, member access) or a shallow composite whose parts
759
+ // are simple at a reduced depth — a call/new is simple only when its callee is
760
+ // simple, it has at most `depth` arguments, and every argument is simple at
761
+ // `depth-1`. The recursion floor (`depth <= 0`) is what makes a nested call
762
+ // like `makeInit(deep(arg))` non-simple, so first-argument hugging declines and
763
+ // the list explodes, matching Prettier. A non-empty object is treated as
764
+ // non-simple (its values bottom out below the depth floor anyway — the safe
765
+ // under-match direction). Element access is treated as a simple leaf, matching
766
+ // ttsc's existing member-access handling, rather than recursing into its
767
+ // computed key (a pre-existing, rare residual over-match left out of scope).
768
+ func isSimpleCallArg(node *shimast.Node, depth int) bool {
769
+ if node == nil || depth <= 0 {
770
+ return false
771
+ }
772
+ switch node.Kind {
773
+ case shimast.KindIdentifier,
774
+ shimast.KindStringLiteral,
775
+ shimast.KindNumericLiteral,
776
+ shimast.KindBigIntLiteral,
777
+ shimast.KindTrueKeyword,
778
+ shimast.KindFalseKeyword,
779
+ shimast.KindNullKeyword,
780
+ shimast.KindThisKeyword,
781
+ shimast.KindElementAccessExpression:
782
+ return true
783
+ case shimast.KindParenthesizedExpression:
784
+ if p := node.AsParenthesizedExpression(); p != nil {
785
+ return isSimpleCallArg(p.Expression, depth)
786
+ }
787
+ case shimast.KindNonNullExpression:
788
+ // Prettier's isSimpleCallArgument unwraps TSNonNullExpression at the same
789
+ // depth, so `foo!()` / `target!` recurse into the asserted expression.
790
+ if n := node.AsNonNullExpression(); n != nil {
791
+ return isSimpleCallArg(n.Expression, depth)
792
+ }
793
+ case shimast.KindPrefixUnaryExpression:
794
+ if u := node.AsPrefixUnaryExpression(); u != nil {
795
+ return isSimpleCallArg(u.Operand, depth)
796
+ }
797
+ case shimast.KindPropertyAccessExpression:
798
+ // The `.name` half is an identifier (trivially simple); recurse the object
799
+ // so `a.b(x, y).c` (a member of a multi-argument call) is correctly not
800
+ // simple, matching Prettier's member recursion.
801
+ if m := node.AsPropertyAccessExpression(); m != nil {
802
+ return isSimpleCallArg(m.Expression, depth)
803
+ }
804
+ case shimast.KindArrayLiteralExpression:
805
+ if arr := node.AsArrayLiteralExpression(); arr != nil {
806
+ if arr.Elements == nil {
807
+ return true
808
+ }
809
+ for _, el := range arr.Elements.Nodes {
810
+ if !isSimpleCallArg(el, depth-1) {
811
+ return false
812
+ }
813
+ }
814
+ return true
815
+ }
816
+ case shimast.KindObjectLiteralExpression:
817
+ // Only an empty object is treated as simple. A non-empty object's values
818
+ // are checked at depth-1, which bottoms out to false at every depth that
819
+ // occurs as a trailing-call argument, so declining one never over-matches.
820
+ if obj := node.AsObjectLiteralExpression(); obj != nil {
821
+ return obj.Properties == nil || len(obj.Properties.Nodes) == 0
822
+ }
823
+ case shimast.KindCallExpression:
824
+ if c := node.AsCallExpression(); c != nil {
825
+ var args []*shimast.Node
826
+ if c.Arguments != nil {
827
+ args = c.Arguments.Nodes
828
+ }
829
+ if !isSimpleCallArg(c.Expression, depth) || len(args) > depth {
830
+ return false
831
+ }
832
+ for _, a := range args {
833
+ if !isSimpleCallArg(a, depth-1) {
834
+ return false
835
+ }
836
+ }
837
+ return true
838
+ }
839
+ case shimast.KindNewExpression:
840
+ if n := node.AsNewExpression(); n != nil {
841
+ var args []*shimast.Node
842
+ if n.Arguments != nil {
843
+ args = n.Arguments.Nodes
844
+ }
845
+ if !isSimpleCallArg(n.Expression, depth) || len(args) > depth {
846
+ return false
847
+ }
848
+ for _, a := range args {
849
+ if !isSimpleCallArg(a, depth-1) {
850
+ return false
851
+ }
852
+ }
853
+ return true
854
+ }
855
+ }
856
+ return false
857
+ }
858
+
615
859
  // isSimpleBinaryOperand reports whether a node is a short, non-recursive
616
860
  // operand, an identifier, a literal, `this`, or a member access. A binary,
617
861
  // call, conditional, or other compound expression is not, so a binary built
@@ -636,24 +880,48 @@ func isSimpleBinaryOperand(node *shimast.Node) bool {
636
880
  return false
637
881
  }
638
882
 
639
- // typeHasObjectLiteral reports whether a type node's source contains a `{`,
640
- // the mark of an object type literal (`{ a: 1 }`, `Array<{ }>`). Prettier's
641
- // isSimpleType rejects such a type; a flat type (`string[]`, `Foo<Bar>`,
642
- // `number[][]`) has none and stays simple.
643
- func typeHasObjectLiteral(src string, typeNode *shimast.Node) bool {
644
- if typeNode == nil {
645
- return false
646
- }
647
- start := shimscanner.SkipTrivia(src, typeNode.Pos())
648
- end := typeNode.End()
649
- if start < 0 || end < start || end > len(src) {
650
- return true // unmeasurable: be conservative and decline the hug
883
+ // isSimpleCastType ports the type half of Prettier's isHopefullyShortCallArgument
884
+ // cast branch: unwrap an array type up to two levels (`T[]`, `T[][]` -> `T`) and
885
+ // a single-type-argument reference (`Ref<X>` -> `X`), then require a SIMPLE type
886
+ // (isSimpleTypeNode). A reference still carrying type arguments after the unwrap
887
+ // (`Foo<A, B>`, `Array<Array<string>>`), or a union / intersection / function /
888
+ // tuple / object type, is not simple, so the cast declines the first-argument
889
+ // hug and the list explodes, matching Prettier.
890
+ func isSimpleCastType(typeNode *shimast.Node) bool {
891
+ node := typeNode
892
+ for i := 0; i < 2 && node != nil && node.Kind == shimast.KindArrayType; i++ {
893
+ at := node.AsArrayTypeNode()
894
+ if at == nil || at.ElementType == nil {
895
+ return false
896
+ }
897
+ node = at.ElementType
651
898
  }
652
- for i := start; i < end; i++ {
653
- if src[i] == '{' {
654
- return true
899
+ if node != nil && node.Kind == shimast.KindTypeReference {
900
+ if ref := node.AsTypeReferenceNode(); ref != nil && ref.TypeArguments != nil &&
901
+ len(ref.TypeArguments.Nodes) == 1 {
902
+ node = ref.TypeArguments.Nodes[0]
655
903
  }
656
904
  }
905
+ return isSimpleTypeNode(node)
906
+ }
907
+
908
+ // isSimpleTypeNode mirrors Prettier's isSimpleType: a keyword/primitive type, a
909
+ // literal type, `this`, or a bare type reference with NO type arguments.
910
+ func isSimpleTypeNode(node *shimast.Node) bool {
911
+ if node == nil {
912
+ return false
913
+ }
914
+ switch node.Kind {
915
+ case shimast.KindStringKeyword, shimast.KindNumberKeyword, shimast.KindBooleanKeyword,
916
+ shimast.KindAnyKeyword, shimast.KindUnknownKeyword, shimast.KindVoidKeyword,
917
+ shimast.KindNeverKeyword, shimast.KindUndefinedKeyword, shimast.KindNullKeyword,
918
+ shimast.KindObjectKeyword, shimast.KindSymbolKeyword, shimast.KindBigIntKeyword,
919
+ shimast.KindThisType, shimast.KindLiteralType, shimast.KindTemplateLiteralType:
920
+ return true
921
+ case shimast.KindTypeReference:
922
+ ref := node.AsTypeReferenceNode()
923
+ return ref != nil && (ref.TypeArguments == nil || len(ref.TypeArguments.Nodes) == 0)
924
+ }
657
925
  return false
658
926
  }
659
927
 
@@ -113,6 +113,16 @@ func printParenthesizedExpression(ctx *PrintContext, node *shimast.Node) (Doc, b
113
113
  if paren == nil || paren.Expression == nil {
114
114
  return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
115
115
  }
116
+ // A comment around the parens (`(/* c */ x)`, `(x /* c */)`) would be dropped
117
+ // by the minted `(`/`)` — the parens are not AST children, so a nested
118
+ // ParenthesizedExpression's gap comment slips past the top-level print-width
119
+ // scan and is lost on reflow. Bail to verbatim and report UNCOVERED (hard
120
+ // `false`, not `!nodeSpansMultipleLines`) so an enclosing reflow abstains
121
+ // instead of breaking around this single-line verbatim paren, like the
122
+ // object/array/call printers.
123
+ if listHasInterItemComments(ctx, node) {
124
+ return verbatim(ctx, node), false
125
+ }
116
126
  inner, covered := PrintNode(ctx, paren.Expression)
117
127
  return Concat(Text("("), inner, Text(")")), covered
118
128
  }
@@ -344,6 +354,14 @@ func printReturnStatement(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
344
354
  if !tailIsCleanTerminator(ctx.Source, stmt.Expression.End(), node.End()) {
345
355
  return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
346
356
  }
357
+ // A comment between `return` and its argument (`return /* c */ x`) lives in
358
+ // the argument's leading trivia and would be dropped by the minted
359
+ // `Text("return ")` — the gap is not an AST child, and a return statement is
360
+ // only reached as a nested child of a block, so the outer print-width and
361
+ // block scans mask it. Bail to verbatim (uncovered) like the round-7 printers.
362
+ if gapHasComment(ctx.Source, stmt.Expression.Pos(), shimscanner.SkipTrivia(ctx.Source, stmt.Expression.Pos())) {
363
+ return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
364
+ }
347
365
  exprDoc, covered := PrintNode(ctx, stmt.Expression)
348
366
  parts := []Doc{Text("return "), exprDoc}
349
367
  if sourceHasStatementTerminator(ctx.Source, node.End()) {
@@ -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
  // printNamedImports renders `{ a, b, c }` inside an import declaration.
@@ -142,9 +143,25 @@ func printImportDeclaration(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
142
143
  // prefix forms are exclusive.
143
144
  prefix := "import "
144
145
  if clause.IsTypeOnly() {
146
+ // A comment in the `type`->`{` gap (`import type /* c */ { … }`) would be
147
+ // dropped by the minted prefix. The `type` keyword is a clause modifier
148
+ // flag, not a child node, so the top-level scan masks it (it sits inside the
149
+ // ImportClause span) and the `nb`-scoped guard starts at `{`. Bail to
150
+ // verbatim, like the default-binding gap below.
151
+ if gapHasComment(ctx.Source, shimscanner.SkipTrivia(ctx.Source, clause.Pos()), shimscanner.SkipTrivia(ctx.Source, nb.Pos())) {
152
+ return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
153
+ }
145
154
  prefix = "import type "
146
155
  }
147
156
  if name := clause.Name(); name != nil {
157
+ // A comment in the default-binding-to-brace gap (`import D /* c */, { … }`)
158
+ // would be dropped by the minted `, ` prefix. That gap is not a direct child
159
+ // of the import node, so the top-level scan masks it and the `nb`-scoped
160
+ // guard above starts at `{` — same class as printReturnStatement's leading
161
+ // gap. Bail to verbatim.
162
+ if gapHasComment(ctx.Source, name.End(), shimscanner.SkipTrivia(ctx.Source, nb.Pos())) {
163
+ return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
164
+ }
148
165
  prefix = "import " + identifierText(name) + ", "
149
166
  }
150
167
 
@@ -66,6 +66,22 @@ type listShape struct {
66
66
  // `callback, simpleArg` shape Prettier hugs (`foo(() => { … }, x)`).
67
67
  // HugLast and HugFirst are mutually exclusive.
68
68
  HugFirst bool
69
+ // HugFirstTrailingBreaks tells printListHuggingFirst NOT to flatten the
70
+ // trailing item, letting it render through its own breakable group. The
71
+ // call-argument printer sets it for the React-hook deps shape
72
+ // (`useEffect(() => { … }, [deps])`), whose deps array Prettier breaks
73
+ // one-element-per-line when it overflows rather than pinning it flat on the
74
+ // close line. The short leaf/call trailing args of every other first-arg-hug
75
+ // case still flatten (they ride the close line flat, per Prettier).
76
+ HugFirstTrailingBreaks bool
77
+ // HugFirstForce drops the exploded fallback from a HugFirst list, the mirror
78
+ // of HugLastForce. The call-argument printer sets it for a genuine React-hook
79
+ // deps call (`useEffect(() => { … }, [deps])`: a zero-parameter block-bodied
80
+ // arrow plus an array literal), which Prettier's isReactHookCallWithDepsArray
81
+ // path never explodes — it keeps the callback hugged and lets the open line
82
+ // overflow. Distinct from HugFirstTrailingBreaks, which also fires for a
83
+ // parameterized callback whose empty-array second arg DOES keep the fallback.
84
+ HugFirstForce bool
69
85
  // HugLastForce drops the exploded fallback from a HugLast list: the hugged
70
86
  // shape is chosen even when its opening line overflows printWidth. The
71
87
  // call-argument printer sets it for a test-framework call
@@ -153,10 +169,12 @@ func printList(ctx *PrintContext, shape listShape) Doc {
153
169
  if shape.HugFirst {
154
170
  hugged = printListHuggingFirst(ctx, shape)
155
171
  }
156
- // A test-framework call hugs its callback unconditionally Prettier never
157
- // explodes its arguments so drop the exploded fallback. The all-flat
158
- // option still wins when the whole call fits (an empty-body callback).
159
- if shape.HugLastForce {
172
+ // A test-framework call (HugLastForce) and a genuine React-hook deps call
173
+ // (HugFirstForce, `useEffect(() => { }, [deps])`) hug their callback
174
+ // unconditionally Prettier never explodes their arguments, it lets the open
175
+ // line overflow — so drop the exploded fallback. The all-flat option still
176
+ // wins when the whole call fits (an empty-body callback).
177
+ if shape.HugLastForce || shape.HugFirstForce {
160
178
  if allFlat, ok := flatten(plain); ok {
161
179
  return ConditionalGroup(allFlat, hugged)
162
180
  }
@@ -317,9 +335,14 @@ func printListHuggingFirst(ctx *PrintContext, shape listShape) Doc {
317
335
  // The trailing simple arguments ride the close line flat — Prettier keeps
318
336
  // them on one line even when that line overflows (a long zero/one-argument
319
337
  // trailing call `}, makeAccumulator(single))` is not broken). Force each
320
- // flat so its own Group does not break against the close-line width.
321
- if flat, ok := flatten(item); ok {
322
- item = flat
338
+ // flat so its own Group does not break against the close-line width. The
339
+ // React-hook deps array is the exception (HugFirstTrailingBreaks): Prettier
340
+ // renders it through a breakable group, so leave it unflattened to break
341
+ // one-element-per-line when the close line overflows.
342
+ if !shape.HugFirstTrailingBreaks {
343
+ if flat, ok := flatten(item); ok {
344
+ item = flat
345
+ }
323
346
  }
324
347
  parts = append(parts, Text(", "), item)
325
348
  }
@@ -36,9 +36,11 @@ func printObjectLiteral(ctx *PrintContext, node *shimast.Node) (Doc, bool) {
36
36
  return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
37
37
  }
38
38
  // A comment between properties (or after `{`) would be dropped by the fresh
39
- // separators; bail to verbatim so the enclosing reflow abstains.
39
+ // separators; bail to verbatim and report UNCOVERED (hard `false`, not
40
+ // `!nodeSpansMultipleLines`) so an enclosing reflow abstains instead of
41
+ // breaking around this single-line verbatim object and moving it off its line.
40
42
  if listHasInterItemComments(ctx, node) {
41
- return verbatim(ctx, node), !nodeSpansMultipleLines(ctx, node)
43
+ return verbatim(ctx, node), false
42
44
  }
43
45
  items := make([]Doc, 0, len(obj.Properties.Nodes))
44
46
  covered := true
@@ -20,9 +20,11 @@ import (
20
20
  // (`a ? b : c ? d : e`) or every rung breaks onto its own line. Nesting
21
21
  // is expressed by recursing the chain builder without wrapping the
22
22
  // nested conditional in its own Group. The outermost chain indents its arms
23
- // by `tabWidth`, but each nested chain indents by a FIXED 2 columns (not
24
- // `tabWidth`), matching Prettier 3: at tabWidth 4 the outer arms sit at
25
- // column 4 and the nested arms at column 6, not 8.
23
+ // by `tabWidth`. A nested chain in the ALTERNATE (`: `) position indents by a
24
+ // fixed 2 columns; a nested chain in the CONSEQUENT (`? `) position indents by
25
+ // `max(2, tabWidth)` (Prettier's extra `align(tabWidth-2)` on the consequent).
26
+ // So at tabWidth 4 the outer arms sit at column 4, an alternate-nested arm at
27
+ // column 6, and a consequent-nested arm at column 8; they coincide at tabWidth 2.
26
28
  //
27
29
  // The second return value is the coverage flag (see PrintNode): the AND
28
30
  // of the test and both branches, so a multi-line verbatim node anywhere
@@ -46,6 +48,16 @@ func buildConditionalChain(ctx *PrintContext, node *shimast.Node, indentCols int
46
48
  if cond == nil || cond.Condition == nil || cond.WhenTrue == nil || cond.WhenFalse == nil {
47
49
  return Doc{}, true
48
50
  }
51
+ // A comment around the `?`/`:` markers (`a ? /* c */ b : c`) would be dropped
52
+ // by the minted "? "/": " text — the markers are not AST children, so a
53
+ // nested conditional's gap comment slips past the top-level print-width scan
54
+ // and is lost on reflow. Bail to verbatim and report UNCOVERED (hard `false`,
55
+ // not `!nodeSpansMultipleLines`) so an enclosing reflow abstains instead of
56
+ // breaking around this single-line verbatim conditional and moving it off its
57
+ // line; the bytes survive.
58
+ if listHasInterItemComments(ctx, node) {
59
+ return verbatim(ctx, node), false
60
+ }
49
61
  testDoc, c1 := PrintNode(ctx, cond.Condition)
50
62
  consDoc, c2 := ternaryArm(ctx, cond.WhenTrue, true)
51
63
  altDoc, c3 := ternaryArm(ctx, cond.WhenFalse, false)
@@ -87,10 +99,39 @@ func buildConditionalChain(ctx *PrintContext, node *shimast.Node, indentCols int
87
99
  // the broken staircase. A nested conditional in the ALTERNATE (`: `) position
88
100
  // is never wrapped — it chains. `isConsequent` selects between the two.
89
101
  func ternaryArm(ctx *PrintContext, node *shimast.Node, isConsequent bool) (Doc, bool) {
90
- if node != nil && node.Kind == shimast.KindConditionalExpression {
91
- // A nested chain indents a fixed 2 columns past its parent rung,
92
- // independent of tabWidth (Prettier 3's nested-ternary rule).
93
- chain, covered := buildConditionalChain(ctx, node, 2)
102
+ inner := node
103
+ // A nested ternary written with explicit source parentheses — `a ? (b ? c : d)
104
+ // : e` is the same chain link as a bare nested ternary: Prettier's AST has no
105
+ // ParenthesizedExpression node, so its `printTernary` sees the consequent as a
106
+ // ConditionalExpression and joins the staircase (re-adding the parens only in
107
+ // flat mode via ifBreak). Unwrap a parenthesized conditional so it chains too,
108
+ // instead of printing flat inside kept parens. Only a ConditionalExpression
109
+ // inner is unwrapped; `(a ?? b)` and other parenthesized expressions keep their
110
+ // parens through the normal printer.
111
+ // Do NOT unwrap when a comment sits between the parens and the inner
112
+ // conditional (`(/* keep */ b ? c : d)`): dropping the ParenExpr wrapper would
113
+ // delete that comment. Leaving `inner` as the ParenExpr routes it through the
114
+ // normal printer, whose own self-guard bails to verbatim and preserves it.
115
+ if inner != nil && inner.Kind == shimast.KindParenthesizedExpression &&
116
+ !listHasInterItemComments(ctx, inner) {
117
+ if p := inner.AsParenthesizedExpression(); p != nil && p.Expression != nil &&
118
+ p.Expression.Kind == shimast.KindConditionalExpression {
119
+ inner = p.Expression
120
+ }
121
+ }
122
+ if inner != nil && inner.Kind == shimast.KindConditionalExpression {
123
+ // A nested chain's arms align at 2 past their parent rung. Prettier's
124
+ // ternary-old.js uses that as-is for an ALTERNATE-position nested chain
125
+ // (`align(2)`), but a CONSEQUENT-position one gets an extra
126
+ // `align(Math.max(0, tabWidth - 2))`, for a total of `max(2, tabWidth)`.
127
+ // The two coincide at the default tabWidth 2; they diverge at tabWidth > 2.
128
+ indentCols := 2
129
+ if isConsequent {
130
+ if u := ctx.indentUnit(); u > indentCols {
131
+ indentCols = u
132
+ }
133
+ }
134
+ chain, covered := buildConditionalChain(ctx, inner, indentCols)
94
135
  if isConsequent {
95
136
  // `ifBreak("", "(")` … `ifBreak("", ")")`: parens in flat mode only.
96
137
  chain = Concat(
@@ -77,6 +77,17 @@ func (formatArrowParens) Check(ctx *Context, node *shimast.Node) {
77
77
  return
78
78
  }
79
79
 
80
+ // A comment in the parameter region (leading trivia, or between the name and
81
+ // its `)`/`=>`) defeats the whitespace-only paren scan below: the scan stops
82
+ // at the comment byte and reports "not wrapped", so the "always" branch would
83
+ // wrap an already-parenthesized name a second time and emit invalid
84
+ // `(/* c */ (x)) => x`. Prettier leaves such an arrow alone
85
+ // (canPrintParamsWithoutParens requires `!hasComment(parameters[0])`), so
86
+ // abstain rather than corrupt.
87
+ if arrowParamRegionHasComment(src, param.Pos(), nameStart, nameEnd) {
88
+ return
89
+ }
90
+
80
91
  // Is the parameter already wrapped in `(` … `)`? Scan over whitespace on
81
92
  // each side; the sole parameter of an arrow is delimited by the
82
93
  // parameter-list parens when present.
@@ -162,6 +173,41 @@ func scanForwardForByte(src string, from int, target byte) int {
162
173
  return -1
163
174
  }
164
175
 
176
+ // arrowParamRegionHasComment reports whether a `//` or `/*` comment sits in the
177
+ // single parameter's region — its leading trivia (`[paramPos, nameStart)`) or
178
+ // the trailing trivia after the name up to the first real token (`)` or `=>`).
179
+ // The byte scan is safe here because a bare-identifier parameter region holds
180
+ // only comments, whitespace, the identifier, and parentheses (no strings:
181
+ // typed/defaulted params are already excluded by isBareIdentifierParam).
182
+ func arrowParamRegionHasComment(src string, paramPos, nameStart, nameEnd int) bool {
183
+ for i := paramPos; i+1 < nameStart && i+1 < len(src); i++ {
184
+ if src[i] == '/' && (src[i+1] == '*' || src[i+1] == '/') {
185
+ return true
186
+ }
187
+ }
188
+ // Trailing trivia after the identifier, up to `=>`. Tolerate one closing
189
+ // paren so a comment between `)` and `=>` (`(x) /* c */ => x`) — which in
190
+ // "avoid" mode would otherwise be stranded when the parens are stripped — is
191
+ // also detected (Prettier keeps the parens of an arrow with such a dangling
192
+ // comment).
193
+ sawParen := false
194
+ for i := nameEnd; i+1 < len(src); i++ {
195
+ c := src[i]
196
+ if c == ' ' || c == '\t' || c == '\r' || c == '\n' {
197
+ continue
198
+ }
199
+ if c == '/' && (src[i+1] == '*' || src[i+1] == '/') {
200
+ return true
201
+ }
202
+ if c == ')' && !sawParen {
203
+ sawParen = true
204
+ continue
205
+ }
206
+ break
207
+ }
208
+ return false
209
+ }
210
+
165
211
  func init() {
166
212
  Register(formatArrowParens{})
167
213
  }
@@ -96,6 +96,14 @@ func joinClauseBody(
96
96
  if body == nil || body.Kind == shimast.KindBlock {
97
97
  return
98
98
  }
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.
104
+ if body.Kind == shimast.KindEmptyStatement {
105
+ return
106
+ }
99
107
  bodyStart := shimscanner.SkipTrivia(src, body.Pos())
100
108
  bodyEnd := body.End()
101
109
  if bodyStart < 0 || bodyEnd < bodyStart || bodyEnd > len(src) {
@@ -149,12 +157,11 @@ func isClauseGapByte(c byte) bool {
149
157
  return c == ' ' || c == '\t' || c == '\r' || c == '\n'
150
158
  }
151
159
 
152
- // visualWidth returns the column width of `s`, expanding tab characters
153
- // to `tabWidth` columns. Non-tab bytes count as one column, which is
154
- // exact for the ASCII that dominates control-flow headers and the common
155
- // case; it can under/over-count multi-byte runes, but clause bodies that
156
- // land exactly on the printWidth boundary are rare enough that the
157
- // approximation never changes a real join decision.
160
+ // visualWidth returns the display-column width of `s`: a tab expands to a flat
161
+ // `tabWidth` columns and every other rune is charged its display width via
162
+ // runeWidth (combining marks 0, wide East-Asian/emoji 2), matching displayWidth.
163
+ // The only approximation left is the flat tab expansion (no tab-stop rounding),
164
+ // which never changes a real clause-join decision.
158
165
  func visualWidth(s string, tabWidth int) int {
159
166
  width := 0
160
167
  for _, r := range s {
@@ -93,11 +93,6 @@ func (formatPrintWidth) Visits() []shimast.Kind {
93
93
  // re-render still over-breaks some of these; the next slice should
94
94
  // measure fitsFirstLine against `pw - col - trailingNonComment`
95
95
  // before committing to the broken layout.
96
- // - Default-combined imports (`import D, { X } from "x"`) are kept
97
- // verbatim by printImportDeclaration (its `clause.Name() != nil`
98
- // guard), so a default import whose named clause overflows is not
99
- // broken; Prettier breaks it. Lifting the guard, threading the default
100
- // name into the printed prefix, is a future default-import reflow slice.
101
96
  //
102
97
  // (A single-specifier `import/export { X } from "long-path"` that overflows
103
98
  // only because of the `from "..."` tail is now kept inline by the
@@ -162,15 +157,15 @@ func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
162
157
  // the per-byte comment scan in hasNonChildComments. Charging that
163
158
  // cost only on nodes that actually overflow keeps the hot path on
164
159
  // well-formatted code allocation- and scan-free.
165
- // A call with two or more function/arrow arguments explodes regardless of
166
- // width (Prettier's multiple-callback rule), and an array of same-kind
167
- // multi-child arrays/objects explodes under Prettier's shouldBreak heuristic,
168
- // so a flat one-line node of either shape still needs a reflow. Skip the fast
169
- // path for them; the printer's ForceBreak then produces the exploded shape.
170
- // Everything else that fits flat is byte-identical after reflow, so the fast
171
- // path stands.
172
- if !callForcesFunctionBreak(node) &&
173
- !arrayForcesBreak(node) &&
160
+ // A call/new with two or more callback arguments explodes regardless of width
161
+ // (Prettier's multiple-callback rule), and an array of same-kind multi-child
162
+ // arrays/objects explodes under Prettier's shouldBreak heuristic — even when
163
+ // such a node is nested inside an otherwise-fitting call/new/array. So a flat
164
+ // one-line node containing either shape still needs a reflow. fastPathForcesBreak
165
+ // walks the reflow subtree; skip the fast path for it so the printer's
166
+ // ForceBreak produces the exploded shape. Everything else that fits flat is
167
+ // byte-identical after reflow, so the fast path stands.
168
+ if !fastPathForcesBreak(node) &&
174
169
  !sliceContainsNewline(src, start, end) &&
175
170
  printOpts.StartingColumn+displayWidth(src[start:end])+trailingWidth <= printOpts.PrintWidth {
176
171
  return
@@ -359,6 +359,12 @@ type siSpec struct {
359
359
  sortKey string
360
360
  text string
361
361
  fromTypeOnlyDecl bool
362
+ // inlineType is the specifier's own AST `type` modifier (`import { type Foo }`),
363
+ // distinct from fromTypeOnlyDecl (the whole `import type { … }` declaration).
364
+ // It classifies a specifier without re-parsing its text, so a binding literally
365
+ // named `type` (`import { type as bar }` -> name `type`, alias `bar`, NOT a type
366
+ // specifier) is not mistaken for a type modifier by a `"type "` string prefix.
367
+ inlineType bool
362
368
  }
363
369
 
364
370
  // siDecl is the parsed shape of one import declaration used by the block
@@ -371,8 +377,13 @@ type siDecl struct {
371
377
  defaultName string
372
378
  named []siSpec
373
379
  namespace bool
374
- original string
375
- semicolon bool
380
+ // hasSpecComment marks a declaration whose named-import braces carry a comment.
381
+ // The merge rebuilder joins specifier texts with ", " and would drop such a
382
+ // comment, so a flagged declaration is made unmergeable (like a namespace
383
+ // binding) and re-emitted from its original text. See mergeKey.
384
+ hasSpecComment bool
385
+ original string
386
+ semicolon bool
376
387
  }
377
388
 
378
389
  // siEntry is a (possibly merged) import declaration ready to be grouped,
@@ -451,6 +462,42 @@ func parseImportDecl(src string, decl *shimast.Node) siDecl {
451
462
  }
452
463
  out.typeOnly = clause.PhaseModifier == shimast.KindTypeKeyword
453
464
  out.defaultName = identifierText(clause.Name())
465
+ // A comment anywhere in the rebuilt import prefix (`import [type] [D] [, { … }]
466
+ // from `) would be lost when renderMergedDecl reconstructs the statement
467
+ // field-by-field. Flag it so mergeKey keeps the declaration unmergeable and
468
+ // its original text is preserved. This runs BEFORE the no-named-bindings /
469
+ // namespace early returns so a default-only import (`import a /* c */ from
470
+ // "m"`) is flagged too; it depends only on decl.Pos() and the module
471
+ // specifier, not on the named bindings. The prefix holds no module-path
472
+ // string, so a `//` or `/*` there is unambiguously a comment (a leading
473
+ // comment before `import` is excluded: SkipTrivia advances to `import`).
474
+ if imp.ModuleSpecifier != nil {
475
+ pStart := shimscanner.SkipTrivia(src, decl.Pos())
476
+ pEnd := shimscanner.SkipTrivia(src, imp.ModuleSpecifier.Pos())
477
+ if pStart >= 0 && pEnd <= len(src) && pStart < pEnd {
478
+ if span := src[pStart:pEnd]; strings.Contains(span, "//") || strings.Contains(span, "/*") {
479
+ out.hasSpecComment = true
480
+ }
481
+ }
482
+ // Also the module-specifier -> `;` tail (`from "m" /* keep */;`): a block
483
+ // comment there is interior to the declaration but outside the
484
+ // inter-declaration gap that leadingTriviaIsAllWhitespace scans, so the
485
+ // merge rebuilder would drop it. The module string itself is excluded (the
486
+ // span starts at ModuleSpecifier.End()), so no `//`-in-a-path false positive.
487
+ if tStart, tEnd := imp.ModuleSpecifier.End(), decl.End(); tStart >= 0 && tEnd <= len(src) && tStart < tEnd {
488
+ if span := src[tStart:tEnd]; strings.Contains(span, "//") || strings.Contains(span, "/*") {
489
+ out.hasSpecComment = true
490
+ }
491
+ }
492
+ }
493
+ // An import-attributes clause (`with { type: "json" }` / `assert { … }`) is not
494
+ // reconstructed by renderMergedDecl, so merging would silently drop those
495
+ // bytes. Flag the declaration unmergeable (its original text is then re-emitted
496
+ // verbatim), mirroring the print-width import printer, which bails to verbatim
497
+ // on `imp.Attributes != nil` for the same reason.
498
+ if imp.Attributes != nil {
499
+ out.hasSpecComment = true
500
+ }
454
501
  if clause.NamedBindings == nil {
455
502
  return out
456
503
  }
@@ -475,6 +522,7 @@ func parseImportDecl(src string, decl *shimast.Node) siDecl {
475
522
  sortKey: identifierText(s.Name()),
476
523
  text: src[start:spec.End()],
477
524
  fromTypeOnlyDecl: out.typeOnly,
525
+ inlineType: s.IsTypeOnly,
478
526
  })
479
527
  }
480
528
  return out
@@ -509,6 +557,11 @@ func mergeKey(d siDecl, combine bool) string {
509
557
  if d.namespace {
510
558
  return "\x00ns\x00" + d.original
511
559
  }
560
+ // A named-import list carrying a comment is unmergeable so the comment
561
+ // survives in the declaration's original text (a per-declaration key).
562
+ if d.hasSpecComment {
563
+ return "\x00cmt\x00" + d.original
564
+ }
512
565
  if combine || !d.typeOnly {
513
566
  return "v\x00" + d.specifier
514
567
  }
@@ -613,10 +666,18 @@ func collectMergedSpecs(group []siDecl, mergedTypeOnly, caseSensitive bool) []st
613
666
  for _, d := range group {
614
667
  for _, s := range d.named {
615
668
  text := s.text
616
- if s.fromTypeOnlyDecl && !mergedTypeOnly && !strings.HasPrefix(text, "type ") {
669
+ // Classify by AST flags, not a `"type "` text prefix. A binding literally
670
+ // named `type` (`import { type as bar }`) has inlineType=false and must stay
671
+ // a value specifier; a string-prefix check would mis-promote it to a type
672
+ // specifier and skip the modifier when folding a type-only declaration.
673
+ isType := s.inlineType
674
+ if s.fromTypeOnlyDecl && !mergedTypeOnly {
675
+ // A specifier from `import type { … }` carries no inline `type` modifier
676
+ // (TS forbids it there), so folding into a mixed value import must add one.
617
677
  text = "type " + text
678
+ isType = true
618
679
  }
619
- cur := spec{name: s.sortKey, isType: strings.HasPrefix(text, "type "), text: text}
680
+ cur := spec{name: s.sortKey, isType: isType, text: text}
620
681
  if at, dup := index[s.sortKey]; dup {
621
682
  if items[at].isType && !cur.isType {
622
683
  items[at] = cur
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/lint",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Reference ttsc plugin: ESLint-style lint rules hosted in the same Program/Checker as the type-check pass.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -36,7 +36,7 @@
36
36
  "@typescript/native-preview": "7.0.0-dev.20260519.1",
37
37
  "@types/node": "^25.3.0",
38
38
  "rimraf": "^6.1.2",
39
- "ttsc": "0.14.0"
39
+ "ttsc": "0.14.1"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",