@tsrx/prettier-plugin 0.3.104 → 0.3.106

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsrx/prettier-plugin",
3
- "version": "0.3.104",
3
+ "version": "0.3.106",
4
4
  "description": "Ripple plugin for Prettier",
5
5
  "type": "module",
6
6
  "module": "src/index.js",
@@ -27,7 +27,7 @@
27
27
  "prettier": "^3.8.4"
28
28
  },
29
29
  "dependencies": {
30
- "@tsrx/core": "0.1.44"
30
+ "@tsrx/core": "0.1.45"
31
31
  },
32
32
  "files": [
33
33
  "src/"
package/src/index.js CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  /** @typedef {Partial<Pick<ParserOptions, 'singleQuote' | 'jsxSingleQuote' | 'semi' | 'trailingComma' | 'useTabs' | 'tabWidth' | 'singleAttributePerLine' | 'bracketSameLine' | 'bracketSpacing' | 'arrowParens' | 'originalText' | 'printWidth'>> & { locStart: (node: AST.NodeWithLocation) => number, locEnd: (node: AST.NodeWithLocation) => number }} RippleFormatOptions */
18
18
 
19
- /** @typedef {{ isInAttribute?: boolean, isInArray?: boolean, allowInlineObject?: boolean, isConditionalTest?: boolean, isNestedConditional?: boolean, suppressLeadingComments?: boolean, suppressExpressionLeadingComments?: boolean, isInlineContext?: boolean, isStatement?: boolean, isLogicalAndOr?: boolean, allowShorthandProperty?: boolean, isFirstChild?: boolean, noBreakInside?: boolean, expandLastArg?: boolean, preferInlineSimpleUnionType?: boolean }} PrintArgs */
19
+ /** @typedef {{ isInAttribute?: boolean, isInArray?: boolean, allowInlineObject?: boolean, isConditionalTest?: boolean, isNestedConditional?: boolean, suppressLeadingComments?: boolean, suppressExpressionLeadingComments?: boolean, suppressOwnParens?: boolean, isInlineContext?: boolean, isStatement?: boolean, isLogicalAndOr?: boolean, allowShorthandProperty?: boolean, isFirstChild?: boolean, noBreakInside?: boolean, expandLastArg?: boolean, preferInlineSimpleUnionType?: boolean }} PrintArgs */
20
20
 
21
21
  import { parseModule } from '@tsrx/core';
22
22
  import { doc } from 'prettier';
@@ -423,6 +423,27 @@ function binaryExpressionNeedsParens(node, parent) {
423
423
  return false;
424
424
  }
425
425
 
426
+ /**
427
+ * Check whether the operand of an `as`/`satisfies` cast must stay parenthesized.
428
+ * The cast binds at relational precedence, so lower-precedence operands parse
429
+ * differently without their parens: `a ?? b as string` is `a ?? (b as string)`,
430
+ * and mixing `??` with an unparenthesized cast operand is a TS syntax error.
431
+ * @param {AST.Node} expression - The cast operand
432
+ * @returns {boolean} - True if parentheses are required
433
+ */
434
+ function castOperandNeedsParens(expression) {
435
+ switch (expression.type) {
436
+ case 'LogicalExpression':
437
+ case 'ArrowFunctionExpression':
438
+ case 'YieldExpression':
439
+ return true;
440
+ case 'BinaryExpression':
441
+ return getPrecedence(expression.operator) < PRECEDENCE['<'];
442
+ default:
443
+ return false;
444
+ }
445
+ }
446
+
426
447
  /**
427
448
  * Check if a parenthesized AssignmentExpression needs its parentheses preserved.
428
449
  * @param {AST.AssignmentExpression} node - The expression node
@@ -1413,7 +1434,10 @@ function printRippleNode(node, path, options, print, args) {
1413
1434
  }
1414
1435
 
1415
1436
  const trailingDoc = shouldUseTrailingComma ? ifBreak(',', '') : '';
1416
- nodeContent = group(['[', indent([softline, fill(fillParts), trailingDoc]), softline, ']']);
1437
+ // All-or-nothing group instead of fill(): packing several elements
1438
+ // per wrapped line is never a fixpoint — the repacked output reparses
1439
+ // as a multiline array and would then break one element per line.
1440
+ nodeContent = group(['[', indent([softline, fillParts, trailingDoc]), softline, ']']);
1417
1441
  break;
1418
1442
  }
1419
1443
 
@@ -1578,7 +1602,7 @@ function printRippleNode(node, path, options, print, args) {
1578
1602
  }
1579
1603
 
1580
1604
  case 'MemberExpression':
1581
- nodeContent = printMemberExpression(node, path, options, print);
1605
+ nodeContent = printMemberExpression(node, path, options, print, args);
1582
1606
  break;
1583
1607
 
1584
1608
  case 'MetaProperty':
@@ -1640,7 +1664,7 @@ function printRippleNode(node, path, options, print, args) {
1640
1664
 
1641
1665
  // Preserve parentheses for type-annotated call expressions
1642
1666
  // When parenthesized with leading comments, use grouping to allow breaking
1643
- if (node.metadata?.parenthesized) {
1667
+ if (node.metadata?.parenthesized && !args?.suppressOwnParens) {
1644
1668
  const hasLeadingComments = node.leadingComments && node.leadingComments.length > 0;
1645
1669
  if (hasLeadingComments) {
1646
1670
  // Group with softline to allow breaking after opening paren
@@ -1688,10 +1712,14 @@ function printRippleNode(node, path, options, print, args) {
1688
1712
  (typePath) => print(typePath, { preferInlineSimpleUnionType: true }),
1689
1713
  'typeAnnotation',
1690
1714
  );
1715
+ const expressionDoc = path.call(print, 'expression');
1716
+ const operand = castOperandNeedsParens(node.expression)
1717
+ ? ['(', expressionDoc, ')']
1718
+ : expressionDoc;
1691
1719
  nodeContent =
1692
1720
  node.typeAnnotation.type !== 'TSTypeLiteral' && willBreak(typeAnnotation)
1693
- ? [path.call(print, 'expression'), ' as', indent([line, typeAnnotation])]
1694
- : [path.call(print, 'expression'), ' as ', typeAnnotation];
1721
+ ? [operand, ' as', indent([line, typeAnnotation])]
1722
+ : [operand, ' as ', typeAnnotation];
1695
1723
  break;
1696
1724
  }
1697
1725
 
@@ -1700,10 +1728,14 @@ function printRippleNode(node, path, options, print, args) {
1700
1728
  (typePath) => print(typePath, { preferInlineSimpleUnionType: true }),
1701
1729
  'typeAnnotation',
1702
1730
  );
1731
+ const expressionDoc = path.call(print, 'expression');
1732
+ const operand = castOperandNeedsParens(node.expression)
1733
+ ? ['(', expressionDoc, ')']
1734
+ : expressionDoc;
1703
1735
  nodeContent =
1704
1736
  node.typeAnnotation.type !== 'TSTypeLiteral' && willBreak(typeAnnotation)
1705
- ? [path.call(print, 'expression'), ' satisfies', indent([line, typeAnnotation])]
1706
- : [path.call(print, 'expression'), ' satisfies ', typeAnnotation];
1737
+ ? [operand, ' satisfies', indent([line, typeAnnotation])]
1738
+ : [operand, ' satisfies ', typeAnnotation];
1707
1739
  break;
1708
1740
  }
1709
1741
 
@@ -1892,22 +1924,34 @@ function printRippleNode(node, path, options, print, args) {
1892
1924
  }
1893
1925
  case 'Identifier': {
1894
1926
  // Simple case - just return the name directly like Prettier core
1927
+ const parent = path.getParentNode();
1928
+ // The definite-assignment assertion (`let x!: T`) lives on the declarator
1929
+ const definiteMarker =
1930
+ parent && parent.type === 'VariableDeclarator' && parent.id === node && parent.definite
1931
+ ? '!'
1932
+ : '';
1895
1933
  let identifierContent;
1896
1934
  if (node.typeAnnotation) {
1897
1935
  const optionalMarker = node.optional ? '?' : '';
1898
- identifierContent = [node.name, optionalMarker, ': ', path.call(print, 'typeAnnotation')];
1936
+ identifierContent = [
1937
+ node.name,
1938
+ definiteMarker,
1939
+ optionalMarker,
1940
+ ': ',
1941
+ path.call(print, 'typeAnnotation'),
1942
+ ];
1899
1943
  } else {
1900
- identifierContent = node.name;
1944
+ identifierContent = definiteMarker ? [node.name, definiteMarker] : node.name;
1901
1945
  }
1902
1946
  // Preserve parentheses for type-cast identifiers, but only if:
1903
1947
  // 1. The identifier itself is marked as parenthesized
1904
1948
  // 2. The parent is NOT handling parentheses itself (MemberExpression, AssignmentExpression, etc.)
1905
- const parent = path.getParentNode();
1906
1949
  const parentHandlesParens =
1907
1950
  parent &&
1908
1951
  (parent.type === 'MemberExpression' ||
1909
1952
  (parent.type === 'AssignmentExpression' && parent.left === node));
1910
- const shouldAddParens = node.metadata?.parenthesized && !parentHandlesParens;
1953
+ const shouldAddParens =
1954
+ node.metadata?.parenthesized && !parentHandlesParens && !args?.suppressOwnParens;
1911
1955
  if (shouldAddParens) {
1912
1956
  nodeContent = ['(', identifierContent, ')'];
1913
1957
  } else {
@@ -2068,8 +2112,26 @@ function printRippleNode(node, path, options, print, args) {
2068
2112
  /** @type {Doc[]} */
2069
2113
  const parts = ['return'];
2070
2114
  if (node.argument) {
2071
- parts.push(' ');
2072
- parts.push(path.call(print, 'argument'));
2115
+ if (argumentHasOwnLineLeadingComment(node.argument)) {
2116
+ // The comment prints on its own line, which would separate `return`
2117
+ // from its argument and trigger ASI — keep the argument in parens.
2118
+ // These parens replace any the argument would print for itself.
2119
+ parts.push(
2120
+ ' (',
2121
+ indent([
2122
+ hardline,
2123
+ path.call(
2124
+ (argumentPath) => print(argumentPath, { suppressOwnParens: true }),
2125
+ 'argument',
2126
+ ),
2127
+ ]),
2128
+ hardline,
2129
+ ')',
2130
+ );
2131
+ } else {
2132
+ parts.push(' ');
2133
+ parts.push(path.call(print, 'argument'));
2134
+ }
2073
2135
  }
2074
2136
  parts.push(semi(options));
2075
2137
  nodeContent = parts;
@@ -2247,7 +2309,7 @@ function printRippleNode(node, path, options, print, args) {
2247
2309
  }
2248
2310
 
2249
2311
  // Wrap in parentheses if metadata indicates they were present
2250
- if (node.metadata?.parenthesized) {
2312
+ if (node.metadata?.parenthesized && !args?.suppressOwnParens) {
2251
2313
  result = ['(', result, ')'];
2252
2314
  }
2253
2315
 
@@ -2271,7 +2333,7 @@ function printRippleNode(node, path, options, print, args) {
2271
2333
  }
2272
2334
 
2273
2335
  case 'MemberExpression':
2274
- nodeContent = printMemberExpression(node, path, options, print);
2336
+ nodeContent = printMemberExpression(node, path, options, print, args);
2275
2337
  break;
2276
2338
 
2277
2339
  case 'ObjectPattern':
@@ -2737,12 +2799,8 @@ function printFunctionExpression(node, path, options, print) {
2737
2799
  parts.push(' ');
2738
2800
  }
2739
2801
 
2740
- // Print parameters using shared function
2741
- const paramsPart = printFunctionParameters(path, options, print);
2742
- parts.push(group(paramsPart)); // Handle return type annotation
2743
- if (node.returnType) {
2744
- parts.push(': ', path.call(print, 'returnType'));
2745
- }
2802
+ // Print parameters and return type as a single group
2803
+ parts.push(printFunctionSignature(node, path, options, print));
2746
2804
 
2747
2805
  parts.push(' ');
2748
2806
  parts.push(path.call(print, 'body'));
@@ -2790,12 +2848,8 @@ function printArrowFunction(node, path, options, print, args) {
2790
2848
  ) {
2791
2849
  parts.push(path.call(print, 'params', 0));
2792
2850
  } else {
2793
- // Print parameters using shared function
2794
- const paramsPart = printFunctionParameters(path, options, print);
2795
- parts.push(group(paramsPart));
2796
- } // Handle return type annotation
2797
- if (node.returnType) {
2798
- parts.push(': ', path.call(print, 'returnType'));
2851
+ // Print parameters and return type as a single group
2852
+ parts.push(printFunctionSignature(node, path, options, print));
2799
2853
  }
2800
2854
 
2801
2855
  // For block statements, print the body directly to get proper formatting
@@ -2808,7 +2862,6 @@ function printArrowFunction(node, path, options, print, args) {
2808
2862
  // to avoid ambiguity with block statements or to clarify intent
2809
2863
  const bodyDoc = path.call(print, 'body');
2810
2864
  const groupId = Symbol('arrow');
2811
- const shouldBreakBody = shouldBreakArrowExpressionBody(node.body, options, args);
2812
2865
  /** @type {Doc | Doc[]} */
2813
2866
  let bodyContent;
2814
2867
  if (
@@ -2820,21 +2873,26 @@ function printArrowFunction(node, path, options, print, args) {
2820
2873
  } else {
2821
2874
  bodyContent = bodyDoc;
2822
2875
  }
2823
- if (shouldBreakBody) {
2824
- parts.push(' =>', indent([hardline, bodyContent]));
2825
- } else {
2826
- if (isTemplateExpression(node.body)) {
2827
- return conditionalGroup([
2828
- group([...parts, ' => ', bodyContent]),
2829
- group([...parts, ' =>', indent([hardline, bodyContent])]),
2830
- ]);
2831
- }
2832
- parts.push(
2833
- ' =>',
2834
- group(indent(line), { id: groupId }),
2835
- indentIfBreak(bodyContent, { groupId }),
2836
- );
2876
+ if (isTemplateExpression(node.body)) {
2877
+ return conditionalGroup([
2878
+ group([...parts, ' => ', bodyContent]),
2879
+ group([...parts, ' =>', indent([hardline, bodyContent])]),
2880
+ ]);
2837
2881
  }
2882
+ if (node.body.type === 'BinaryExpression' || node.body.type === 'LogicalExpression') {
2883
+ // Keep the body inline when it fits; otherwise break right after `=>`
2884
+ // so the body starts on its own line. An inner group scoped to the body
2885
+ // makes that call from the printed doc (not the original source span,
2886
+ // which kept the two passes disagreeing) and still works when the
2887
+ // parameter list itself breaks.
2888
+ parts.push(' =>', group(indent([line, bodyContent])));
2889
+ return group(parts);
2890
+ }
2891
+ parts.push(
2892
+ ' =>',
2893
+ group(indent(line), { id: groupId }),
2894
+ indentIfBreak(bodyContent, { groupId }),
2895
+ );
2838
2896
  }
2839
2897
 
2840
2898
  return group(parts);
@@ -2849,25 +2907,6 @@ function isTemplateExpression(node) {
2849
2907
  return node.type === 'JSXElement' || node.type === 'JSXFragment';
2850
2908
  }
2851
2909
 
2852
- /**
2853
- * Check whether a braced attribute expression should close on its own line.
2854
- * @param {AST.Node} node - The expression inside the attribute braces
2855
- * @param {RippleFormatOptions} options
2856
- * @param {AST.Node} [attributeNode]
2857
- * @returns {boolean}
2858
- */
2859
- function shouldBreakAttributeExpressionClosingBrace(node, options, attributeNode = node) {
2860
- return (
2861
- node.type === 'ArrowFunctionExpression' &&
2862
- node.body &&
2863
- isTemplateExpression(node.body) &&
2864
- sourceSpanExceedsPrintWidth(
2865
- /** @type {AST.NodeWithLocation} */ (/** @type {unknown} */ (attributeNode ?? node)),
2866
- options,
2867
- )
2868
- );
2869
- }
2870
-
2871
2910
  /**
2872
2911
  * Print an export default declaration
2873
2912
  * @param {AST.ExportDefaultDeclaration} node - The export default node
@@ -2904,10 +2943,50 @@ function shouldHugTheOnlyFunctionParameter(node) {
2904
2943
  parameter.type === 'ArrayPattern' ||
2905
2944
  (parameter.type === 'Identifier' &&
2906
2945
  !!parameter.typeAnnotation &&
2907
- parameter.typeAnnotation.type === 'TSTypeAnnotation'))
2946
+ parameter.typeAnnotation.type === 'TSTypeAnnotation' &&
2947
+ isHuggableParameterType(parameter.typeAnnotation.typeAnnotation)))
2908
2948
  );
2909
2949
  }
2910
2950
 
2951
+ /**
2952
+ * Check if a type node is an object-like type (object literal or mapped type)
2953
+ * @param {AST.Node | undefined} node - The type node
2954
+ * @returns {boolean}
2955
+ */
2956
+ function isObjectType(node) {
2957
+ return !!node && (node.type === 'TSTypeLiteral' || node.type === 'TSMappedType');
2958
+ }
2959
+
2960
+ /**
2961
+ * Check if a parameter's type annotation should keep the parameter hugged.
2962
+ * Object-like types hug like vanilla prettier; additionally a type reference
2963
+ * wrapping a single object type (`props: Props<{ ... }>`) hugs, since that is
2964
+ * the common TSRX component-props shape. Other references, like a plain
2965
+ * `initialState: State`, leave the parameter list free to break.
2966
+ * @param {AST.Node | undefined} node - The type node
2967
+ * @returns {boolean}
2968
+ */
2969
+ function isHuggableParameterType(node) {
2970
+ if (isObjectType(node)) {
2971
+ return true;
2972
+ }
2973
+ if (node?.type === 'TSIntersectionType') {
2974
+ const types = /** @type {AST.TSIntersectionType} */ (node).types;
2975
+ return types?.length > 0 && isHuggableParameterType(types[types.length - 1]);
2976
+ }
2977
+ if (node?.type === 'TSTypeReference') {
2978
+ const typeArguments =
2979
+ /** @type {AST.TSTypeReference & { typeParameters?: AST.TSTypeParameterInstantiation }} */ (
2980
+ node
2981
+ ).typeArguments ??
2982
+ /** @type {AST.TSTypeReference & { typeParameters?: AST.TSTypeParameterInstantiation }} */ (
2983
+ node
2984
+ ).typeParameters;
2985
+ return typeArguments?.params?.length === 1 && isObjectType(typeArguments.params[0]);
2986
+ }
2987
+ return false;
2988
+ }
2989
+
2911
2990
  /**
2912
2991
  * Print function parameters with proper formatting
2913
2992
  * @param {AstPath<AST.FunctionExpression | AST.ArrowFunctionExpression | AST.TSDeclareFunction | AST.FunctionDeclaration>} path - The function path
@@ -2963,6 +3042,56 @@ function printFunctionParameters(path, options, print) {
2963
3042
  ];
2964
3043
  }
2965
3044
 
3045
+ /**
3046
+ * Check whether the parameter list should be grouped separately from the return
3047
+ * type, so a breaking return type does not force the parameters to break too.
3048
+ * @param {AST.FunctionExpression | AST.ArrowFunctionExpression | AST.TSDeclareFunction | AST.FunctionDeclaration} functionNode - The function node
3049
+ * @param {Doc} returnTypeDoc - The printed return type
3050
+ * @returns {boolean}
3051
+ */
3052
+ function shouldGroupFunctionParameters(functionNode, returnTypeDoc) {
3053
+ const returnTypeNode = functionNode.returnType?.typeAnnotation;
3054
+ const typeParameters = functionNode.typeParameters?.params;
3055
+ if (typeParameters) {
3056
+ if (typeParameters.length > 1) {
3057
+ return false;
3058
+ }
3059
+ if (typeParameters.length === 1) {
3060
+ const typeParameter = typeParameters[0];
3061
+ if (typeParameter.constraint || typeParameter.default) {
3062
+ return false;
3063
+ }
3064
+ }
3065
+ }
3066
+ return (
3067
+ getFunctionParameters(functionNode).length === 1 &&
3068
+ (isObjectType(returnTypeNode) || willBreak(returnTypeDoc))
3069
+ );
3070
+ }
3071
+
3072
+ /**
3073
+ * Print function parameters together with the return type as a single group, so
3074
+ * the fitter breaks the parameter list before type arguments nested in the
3075
+ * return type, matching vanilla prettier's signature layout.
3076
+ * @param {AST.FunctionExpression | AST.ArrowFunctionExpression | AST.TSDeclareFunction | AST.FunctionDeclaration} node - The function node
3077
+ * @param {AstPath<AST.FunctionExpression | AST.ArrowFunctionExpression | AST.TSDeclareFunction | AST.FunctionDeclaration>} path - The function path
3078
+ * @param {RippleFormatOptions} options - Prettier options
3079
+ * @param {PrintFn} print - Print callback
3080
+ * @returns {Doc}
3081
+ */
3082
+ function printFunctionSignature(node, path, options, print) {
3083
+ const paramsPart = printFunctionParameters(path, options, print);
3084
+ if (!node.returnType) {
3085
+ return group(paramsPart);
3086
+ }
3087
+ /** @type {Doc[]} */
3088
+ const returnTypeDoc = [': ', path.call(print, 'returnType')];
3089
+ if (shouldGroupFunctionParameters(node, returnTypeDoc)) {
3090
+ return group([group(paramsPart), ...returnTypeDoc]);
3091
+ }
3092
+ return group([...paramsPart, ...returnTypeDoc]);
3093
+ }
3094
+
2966
3095
  /**
2967
3096
  * Check if a node is spread-like (SpreadElement or RestElement)
2968
3097
  * @param {AST.Node} node - The AST node
@@ -3060,34 +3189,6 @@ function shouldHugArrowFunctions(args) {
3060
3189
  return firstBlockIndex === 0;
3061
3190
  }
3062
3191
 
3063
- /**
3064
- * Check whether a node's original source span exceeds the configured print width.
3065
- * @param {AST.NodeWithLocation} node - The node to check
3066
- * @param {RippleFormatOptions} options - Prettier options
3067
- * @returns {boolean}
3068
- */
3069
- function sourceSpanExceedsPrintWidth(node, options) {
3070
- const printWidth = options.printWidth ?? 80;
3071
- if (!options.originalText || node.start === undefined || node.end === undefined) {
3072
- return false;
3073
- }
3074
- return options.originalText.slice(node.start, node.end).length > printWidth;
3075
- }
3076
-
3077
- /**
3078
- * Check if an arrow expression body should break immediately after `=>`.
3079
- * @param {AST.Expression} node - The arrow body expression
3080
- * @param {RippleFormatOptions} options - Prettier options
3081
- * @param {PrintArgs} [args] - Additional context arguments
3082
- * @returns {boolean}
3083
- */
3084
- function shouldBreakArrowExpressionBody(node, options, args) {
3085
- return (
3086
- (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') &&
3087
- sourceSpanExceedsPrintWidth(/** @type {AST.NodeWithLocation} */ (node), options)
3088
- );
3089
- }
3090
-
3091
3192
  /**
3092
3193
  * Print call expression arguments
3093
3194
  * @param {AstPath<AST.CallExpression>} path - The call path
@@ -3337,14 +3438,8 @@ function printTSDeclareFunction(node, path, options, print) {
3337
3438
  }
3338
3439
  }
3339
3440
 
3340
- // Print parameters using shared function
3341
- const paramsPart = printFunctionParameters(path, options, print);
3342
- parts.push(group(paramsPart));
3343
-
3344
- // Handle return type annotation
3345
- if (node.returnType) {
3346
- parts.push(': ', path.call(print, 'returnType'));
3347
- }
3441
+ // Print parameters and return type as a single group
3442
+ parts.push(printFunctionSignature(node, path, options, print));
3348
3443
 
3349
3444
  // TSDeclareFunction ends with semicolon, no body
3350
3445
  parts.push(';');
@@ -3389,14 +3484,8 @@ function printFunctionDeclaration(node, path, options, print) {
3389
3484
  }
3390
3485
  }
3391
3486
 
3392
- // Print parameters using shared function
3393
- const paramsPart = printFunctionParameters(path, options, print);
3394
- parts.push(group(paramsPart));
3395
-
3396
- // Handle return type annotation
3397
- if (node.returnType) {
3398
- parts.push(': ', path.call(print, 'returnType'));
3399
- }
3487
+ // Print parameters and return type as a single group
3488
+ parts.push(printFunctionSignature(node, path, options, print));
3400
3489
 
3401
3490
  parts.push(' ');
3402
3491
  parts.push(path.call(print, 'body'));
@@ -4119,9 +4208,10 @@ function printMethodDefinition(node, path, options, print) {
4119
4208
  * @param {AstPath<AST.MemberExpression>} path - The AST path
4120
4209
  * @param {RippleFormatOptions} options - Prettier options
4121
4210
  * @param {PrintFn} print - Print callback
4211
+ * @param {PrintArgs} [args] - Additional context arguments
4122
4212
  * @returns {Doc}
4123
4213
  */
4124
- function printMemberExpression(node, path, options, print) {
4214
+ function printMemberExpression(node, path, options, print, args) {
4125
4215
  let objectPart = path.call(print, 'object');
4126
4216
  // Preserve parentheses around the object when present
4127
4217
  if (node.object.metadata?.parenthesized) {
@@ -4139,7 +4229,7 @@ function printMemberExpression(node, path, options, print) {
4139
4229
  }
4140
4230
 
4141
4231
  // Preserve parentheses around the entire member expression when present
4142
- if (node.metadata?.parenthesized) {
4232
+ if (node.metadata?.parenthesized && !args?.suppressOwnParens) {
4143
4233
  // Check if there are leading comments - if so, use group with softlines to allow breaking
4144
4234
  const hasLeadingComments = node.leadingComments && node.leadingComments.length > 0;
4145
4235
  if (hasLeadingComments) {
@@ -4323,12 +4413,50 @@ function printTaggedTemplateExpression(node, path, options, print) {
4323
4413
  function printThrowStatement(node, path, options, print) {
4324
4414
  /** @type {Doc[]} */
4325
4415
  const parts = [];
4326
- parts.push('throw ');
4327
- parts.push(path.call(print, 'argument'));
4416
+ if (argumentHasOwnLineLeadingComment(node.argument)) {
4417
+ // Same ASI hazard as `return`: keep the argument attached via parens.
4418
+ // These parens replace any the argument would print for itself.
4419
+ parts.push(
4420
+ 'throw (',
4421
+ indent([
4422
+ hardline,
4423
+ path.call((argumentPath) => print(argumentPath, { suppressOwnParens: true }), 'argument'),
4424
+ ]),
4425
+ hardline,
4426
+ ')',
4427
+ );
4428
+ } else {
4429
+ parts.push('throw ');
4430
+ parts.push(path.call(print, 'argument'));
4431
+ }
4328
4432
  parts.push(semi(options));
4329
4433
  return parts;
4330
4434
  }
4331
4435
 
4436
+ /**
4437
+ * Check whether a return/throw argument has a leading comment that prints on
4438
+ * its own line (line comments always do; block comments only when they did not
4439
+ * share a line with the argument in the source).
4440
+ * @param {AST.Node | null | undefined} argument - The statement argument
4441
+ * @returns {boolean}
4442
+ */
4443
+ function argumentHasOwnLineLeadingComment(argument) {
4444
+ if (!argument) {
4445
+ return false;
4446
+ }
4447
+ const comments = /** @type {AST.NodeWithMaybeComments} */ (argument).leadingComments;
4448
+ if (!comments) {
4449
+ return false;
4450
+ }
4451
+ return comments.some(
4452
+ (comment) =>
4453
+ comment.type === 'Line' ||
4454
+ !comment.loc ||
4455
+ !argument.loc ||
4456
+ comment.loc.end.line !== argument.loc.start.line,
4457
+ );
4458
+ }
4459
+
4332
4460
  /**
4333
4461
  * Print a TypeScript interface declaration
4334
4462
  * @param {AST.TSInterfaceDeclaration} node - The interface declaration node
@@ -4524,19 +4652,28 @@ function printTSTypeParameterDeclaration(node, path, options, print) {
4524
4652
  if (!node.params || node.params.length === 0) {
4525
4653
  return '';
4526
4654
  }
4527
- /** @type {Doc[]} */
4528
- const parts = [];
4529
- parts.push('<');
4530
4655
  const paramList = path.map(print, 'params');
4531
- for (let i = 0; i < paramList.length; i++) {
4532
- if (i > 0) parts.push(', ');
4533
- parts.push(paramList[i]);
4534
- }
4535
- if (node.params.length === 1 && node.extra?.trailingComma !== undefined) {
4536
- parts.push(',');
4656
+
4657
+ // In JSX-shaped files a lone `<T>` on an arrow function is ambiguous with a JSX
4658
+ // element, so a source-level trailing comma (`<T,>`) is syntactically meaningful
4659
+ // there. Keep single-param arrow generics flat and preserve that comma; breaking
4660
+ // them would add a trailing comma that flattens back on the next pass.
4661
+ const parent = /** @type {AST.Node | null} */ (path.getParentNode());
4662
+ if (parent?.type === 'ArrowFunctionExpression' && node.params.length === 1) {
4663
+ const trailing = node.extra?.trailingComma !== undefined ? ',' : '';
4664
+ return ['<', paramList[0], trailing, '>'];
4537
4665
  }
4538
- parts.push('>');
4539
- return parts;
4666
+
4667
+ return group([
4668
+ '<',
4669
+ indent([
4670
+ softline,
4671
+ join([',', line], paramList),
4672
+ ifBreak(shouldPrintComma(options, 'all') ? ',' : ''),
4673
+ ]),
4674
+ softline,
4675
+ '>',
4676
+ ]);
4540
4677
  }
4541
4678
 
4542
4679
  /**
@@ -4580,18 +4717,18 @@ function printTSTypeParameterInstantiation(node, path, options, print) {
4580
4717
 
4581
4718
  const paramList = path.map(print, 'params');
4582
4719
 
4720
+ // Hug a lone object-type argument against the brackets: Foo<{ ... }>
4721
+ if (
4722
+ node.params.length === 1 &&
4723
+ isObjectType(node.params[0]) &&
4724
+ !hasComment(/** @type {AST.Node & AST.NodeWithMaybeComments} */ (node.params[0]))
4725
+ ) {
4726
+ return ['<', paramList[0], '>'];
4727
+ }
4728
+
4583
4729
  // Check if any param has line breaks (e.g., contains object types)
4584
4730
  const hasBreakingParam = paramList.some((param) => willBreak(param));
4585
4731
 
4586
- // Build inline version: <T, U>
4587
- /** @type {Doc[]} */
4588
- const inlineParts = ['<'];
4589
- for (let i = 0; i < paramList.length; i++) {
4590
- if (i > 0) inlineParts.push(', ');
4591
- inlineParts.push(paramList[i]);
4592
- }
4593
- inlineParts.push('>');
4594
-
4595
4732
  // If any param breaks, use the breaking version with proper indentation
4596
4733
  if (hasBreakingParam) {
4597
4734
  // Build breaking version: <\n T,\n U\n>
@@ -5349,9 +5486,16 @@ function printTSTypeLiteral(node, path, options, print) {
5349
5486
  '}',
5350
5487
  ]);
5351
5488
 
5352
- return conditionalGroup(
5353
- wasOriginallySingleLine(node) ? [inlineDoc, multilineDoc] : [multilineDoc, inlineDoc],
5354
- );
5489
+ if (!wasOriginallySingleLine(node)) {
5490
+ // A hardline-first conditionalGroup always picks that state (fits()
5491
+ // short-circuits to true on hardlines) while hiding the hardlines from
5492
+ // enclosing groups' break propagation — ancestors then stay flat and
5493
+ // print following siblings past printWidth. The multiline doc alone is
5494
+ // equivalent and propagates its breaks correctly.
5495
+ return multilineDoc;
5496
+ }
5497
+
5498
+ return conditionalGroup([inlineDoc, multilineDoc]);
5355
5499
  }
5356
5500
 
5357
5501
  /**
@@ -5520,26 +5664,16 @@ function printTSTypeReference(node, path, options, print) {
5520
5664
  /** @type {Doc[]} */
5521
5665
  const parts = [path.call(print, 'typeName')];
5522
5666
 
5523
- // Handle both typeArguments and typeParameters (different AST variations)
5667
+ // Handle both typeArguments and typeParameters (different AST variations).
5668
+ // Both are TSTypeParameterInstantiation nodes, whose printer can break the
5669
+ // argument list when it does not fit.
5524
5670
  if (node.typeArguments) {
5525
- parts.push('<');
5526
- const typeArgs = path.map(print, 'typeArguments', 'params');
5527
- for (let i = 0; i < typeArgs.length; i++) {
5528
- if (i > 0) parts.push(', ');
5529
- parts.push(typeArgs[i]);
5530
- }
5531
- parts.push('>');
5671
+ parts.push(path.call(print, 'typeArguments'));
5532
5672
  // @ts-expect-error - acorn-typescript uses typeParameters instead of typeArguments
5533
5673
  // we normalize it in the analyze phase, but here we get the parser ast
5534
5674
  } else if (node.typeParameters) {
5535
- parts.push('<');
5536
5675
  // @ts-expect-error - acorn-typescript uses typeParameters instead of typeArguments
5537
- const typeParams = path.map(print, 'typeParameters', 'params');
5538
- for (let i = 0; i < typeParams.length; i++) {
5539
- if (i > 0) parts.push(', ');
5540
- parts.push(typeParams[i]);
5541
- }
5542
- parts.push('>');
5676
+ parts.push(path.call(print, 'typeParameters'));
5543
5677
  }
5544
5678
 
5545
5679
  return parts;
@@ -5829,7 +5963,9 @@ function isSimpleJSXExpressionChild(child) {
5829
5963
  expression?.type === 'MemberExpression' ||
5830
5964
  expression?.type === 'CallExpression' ||
5831
5965
  expression?.type === 'BinaryExpression' ||
5832
- expression?.type === 'LogicalExpression'
5966
+ expression?.type === 'LogicalExpression' ||
5967
+ // Text holes like `{expr as string}` are as simple as their operand
5968
+ expression?.type === 'TSAsExpression'
5833
5969
  );
5834
5970
  }
5835
5971
 
@@ -6548,9 +6684,6 @@ function printJSXAttribute(attr, path, options, print) {
6548
6684
  'value',
6549
6685
  'expression',
6550
6686
  );
6551
- if (shouldBreakAttributeExpressionClosingBrace(expression, options, attr)) {
6552
- return [name, '={', exprDoc, hardline, '}'];
6553
- }
6554
6687
  return [name, '={', exprDoc, '}'];
6555
6688
  }
6556
6689
 
package/src/index.test.js CHANGED
@@ -55,15 +55,23 @@ describe('prettier-plugin', () => {
55
55
  });
56
56
 
57
57
  /**
58
+ * Format tsrx source. Every call also verifies the output is a fixpoint:
59
+ * formatting must be single-pass idempotent (a second pass may not change
60
+ * a single byte), so the whole suite doubles as an idempotence corpus.
58
61
  * @param {string} code
59
62
  * @param {import('prettier').Options} [options]
60
63
  */
61
64
  const format = async (code, options = {}) => {
62
- return await prettier.format(code, {
65
+ /** @type {import('prettier').Options} */
66
+ const resolvedOptions = {
63
67
  parser: 'tsrx',
64
68
  plugins: [join(__dirname, 'index.js')],
65
69
  ...options,
66
- });
70
+ };
71
+ const once = await prettier.format(code, resolvedOptions);
72
+ const twice = await prettier.format(once, resolvedOptions);
73
+ expect(twice, 'formatting must be idempotent (second pass changed the output)').toBe(once);
74
+ return once;
67
75
  };
68
76
 
69
77
  it('formats functions that return native elements', async () => {
@@ -888,8 +896,7 @@ const items=[1,2,3];
888
896
  <List
889
897
  items={props.items}
890
898
  renderItem={(item) =>
891
- <><ItemView {item} onSelect={props.onSelect} /></>
892
- }
899
+ <><ItemView {item} onSelect={props.onSelect} /></>}
893
900
  />
894
901
  }`;
895
902
  const result = await format(input, { singleQuote: true, printWidth: 60 });
@@ -1243,7 +1250,8 @@ const items=[1,2,3];
1243
1250
  it('should format destructured dynamic import() in Promise.all', async () => {
1244
1251
  const input = `const [{ EditorState }, { oneDark }] = await Promise.all([import('@codemirror/state'), import('@codemirror/theme-one-dark')]);`;
1245
1252
  const expected = `const [{ EditorState }, { oneDark }] = await Promise.all([
1246
- import("@codemirror/state"), import("@codemirror/theme-one-dark"),
1253
+ import("@codemirror/state"),
1254
+ import("@codemirror/theme-one-dark"),
1247
1255
  ]);`;
1248
1256
  const result = await format(input);
1249
1257
  expect(result).toBeWithNewline(expected);
@@ -3377,7 +3385,8 @@ function test() {
3377
3385
 
3378
3386
  const expected = `const arr = [
3379
3387
  1, /* comment 1 */
3380
- 2, 3,
3388
+ 2,
3389
+ 3,
3381
3390
  // comment 2
3382
3391
  ];`;
3383
3392
 
@@ -3763,6 +3772,45 @@ const deleteButton = container.querySelector(
3763
3772
  expect(result).toBeWithNewline(expected);
3764
3773
  });
3765
3774
 
3775
+ it('should keep comments attached to their type arguments and stay idempotent', async () => {
3776
+ const input = `interface Props {
3777
+ form: AppFieldExtendedReactFormApi<
3778
+ unknown,
3779
+ | undefined
3780
+ | FormAsyncValidateOrFn<unknown>, // this types it as 'never' in the render prop. It should prevent any
3781
+ // untyped meta passed to the handleSubmit by accident.
3782
+ NoInfer<TSubmitMeta>
3783
+ >;
3784
+ }`;
3785
+ // Matches vanilla prettier's typescript parser output for the same input.
3786
+ const expected = `interface Props {
3787
+ form: AppFieldExtendedReactFormApi<
3788
+ unknown,
3789
+ undefined | FormAsyncValidateOrFn<unknown>, // this types it as 'never' in the render prop. It should prevent any
3790
+ // untyped meta passed to the handleSubmit by accident.
3791
+ NoInfer<TSubmitMeta>
3792
+ >;
3793
+ }`;
3794
+ const options = { useTabs: true, tabWidth: 2, singleQuote: true, printWidth: 100 };
3795
+ const result = await format(input, options);
3796
+ expect(result).toBeWithNewline(expected);
3797
+ expect(await format(result, options)).toBe(result);
3798
+ });
3799
+
3800
+ it('should hug a lone object type argument against the angle brackets', async () => {
3801
+ const input = `function Button(props: PropsWithExtras<{
3802
+ variant: string;
3803
+ label: string;
3804
+ onClick: EventListener;
3805
+ }>) @{
3806
+ <button class={props.variant} onClick={props.onClick}>{props.label}</button>
3807
+ }`;
3808
+ const options = { useTabs: true, tabWidth: 2, singleQuote: true, printWidth: 100 };
3809
+ const result = await format(input, options);
3810
+ expect(result).toBeWithNewline(input);
3811
+ expect(await format(result, options)).toBe(result);
3812
+ });
3813
+
3766
3814
  it('should not overindent multiline object type aliases', async () => {
3767
3815
  const input = `type ModuleShape = {
3768
3816
  default: ComponentType<{ value: string }>;
@@ -6493,4 +6541,297 @@ function RowList({ rows, Row }) {
6493
6541
  expect(result).toBeWithNewline(expected);
6494
6542
  });
6495
6543
  });
6544
+
6545
+ describe('parens around as-cast operands', () => {
6546
+ it('keeps parens around a nullish coalescing operand of an as-cast', async () => {
6547
+ const input = `function App() {
6548
+ return <span>{(activeAuthor ?? "All authors") as string}</span>;
6549
+ }`;
6550
+
6551
+ const result = await format(input);
6552
+ expect(result).toBeWithNewline(input);
6553
+ });
6554
+
6555
+ it('keeps parens around logical and equality operands of as-casts', async () => {
6556
+ const input = `function App() {
6557
+ const a = (x || y) as string;
6558
+ const b = (x == y) as boolean;
6559
+ const c = (x ?? y) satisfies string;
6560
+ return <div>{a}</div>;
6561
+ }`;
6562
+
6563
+ const result = await format(input);
6564
+ expect(result).toBeWithNewline(input);
6565
+ });
6566
+
6567
+ it('does not add parens around higher-precedence operands of as-casts', async () => {
6568
+ const input = `function App() {
6569
+ const a = x + y as string;
6570
+ const b = x < y as unknown;
6571
+ return <div>{a}</div>;
6572
+ }`;
6573
+
6574
+ const result = await format(input);
6575
+ expect(result).toBeWithNewline(input);
6576
+ });
6577
+ });
6578
+
6579
+ describe('definite assignment assertions', () => {
6580
+ it('keeps the definite assignment assertion on variable declarations', async () => {
6581
+ const input = `function App() {
6582
+ let cleanup!: () => void;
6583
+ var count!: number;
6584
+ return <div />;
6585
+ }`;
6586
+
6587
+ const result = await format(input);
6588
+ expect(result).toBeWithNewline(input);
6589
+ });
6590
+ });
6591
+
6592
+ describe('idempotence', () => {
6593
+ /**
6594
+ * @param {string} code
6595
+ * @param {import('prettier').Options} [options]
6596
+ */
6597
+ const expectStable = async (code, options = {}) => {
6598
+ const once = await format(code, options);
6599
+ const twice = await format(once, options);
6600
+ expect(twice).toBe(once);
6601
+ return once;
6602
+ };
6603
+
6604
+ it('keeps a return argument with leading line comments after the return keyword', async () => {
6605
+ const input = `function isXOrYInValid(xOrY: string | number | undefined) {
6606
+ return (
6607
+ // number that is not NaN or Infinity
6608
+ (typeof xOrY === 'number' && Number.isFinite(xOrY)) ||
6609
+ // for percentage
6610
+ typeof xOrY === 'string'
6611
+ );
6612
+ }`;
6613
+
6614
+ const once = await expectStable(input, {
6615
+ useTabs: true,
6616
+ singleQuote: true,
6617
+ printWidth: 100,
6618
+ });
6619
+ // The argument must stay attached to the return; printing the comment
6620
+ // between `return` and the expression triggers ASI and returns undefined.
6621
+ expect(once).not.toMatch(/return[;\s]*\/\//);
6622
+ });
6623
+
6624
+ it('does not double-wrap self-parenthesizing return and throw arguments', async () => {
6625
+ const input = `function f() {
6626
+ return (
6627
+ // pick the fallback
6628
+ cond ? a : b
6629
+ );
6630
+ }
6631
+
6632
+ function g() {
6633
+ throw (
6634
+ // wrap the cause
6635
+ makeError(cause)
6636
+ );
6637
+ }`;
6638
+
6639
+ const result = await format(input);
6640
+ expect(result).toBeWithNewline(input);
6641
+ });
6642
+
6643
+ it('breaks long logical arrow bodies after multiline type-literal params', async () => {
6644
+ const input = `function f() {
6645
+ const mapping = result.mappings.find(
6646
+ (mapping: {
6647
+ sourceOffsets: number[];
6648
+ generatedOffsets: number[];
6649
+ }) =>
6650
+ mapping.sourceOffsets[0] === source_offset &&
6651
+ mapping.generatedOffsets[0] === generated_offset &&
6652
+ mapping.lengths[0] === identifier.length,
6653
+ );
6654
+ }`;
6655
+
6656
+ // The multiline param type used to hide its hardlines from enclosing
6657
+ // groups (fits() short-circuits on hardlines inside conditionalGroup
6658
+ // states), so the body printed flat past printWidth.
6659
+ const result = await format(input, {
6660
+ useTabs: true,
6661
+ singleQuote: true,
6662
+ printWidth: 100,
6663
+ });
6664
+ expect(result).toBeWithNewline(input);
6665
+ });
6666
+
6667
+ it('stabilizes long arrow bodies with logical expressions in one pass', async () => {
6668
+ const input = `function useStack() {
6669
+ return horizontal
6670
+ ? {
6671
+ defined: (d: AreaStackDatum<XScale, YScale>) =>
6672
+ isValidNumber(yScale(getStackValue(d.data))) && isValidNumber(xScale(getSecondItem(d))),
6673
+ }
6674
+ : null;
6675
+ }`;
6676
+
6677
+ await expectStable(input, {
6678
+ useTabs: true,
6679
+ singleQuote: true,
6680
+ printWidth: 100,
6681
+ });
6682
+ });
6683
+
6684
+ it('stabilizes deeply nested JSX attribute arrows returning JSX in one pass', async () => {
6685
+ const input = `function Parent() {
6686
+ return <div>
6687
+ <section>
6688
+ <article>
6689
+ <fieldset>
6690
+ <group.Subscribe
6691
+ children={(state) => (
6692
+ <span data-testid="state-lastName">{state.values.lastName}</span>
6693
+ )}
6694
+ />
6695
+ </fieldset>
6696
+ </article>
6697
+ </section>
6698
+ </div>;
6699
+ }`;
6700
+
6701
+ await expectStable(input, {
6702
+ useTabs: true,
6703
+ singleQuote: true,
6704
+ printWidth: 100,
6705
+ });
6706
+ });
6707
+ });
6708
+
6709
+ describe('type parameter declarations', () => {
6710
+ /**
6711
+ * Formats twice and asserts the output is stable (idempotent).
6712
+ * @param {string} code
6713
+ * @param {import('prettier').Options} [options]
6714
+ */
6715
+ const formatStable = async (code, options = {}) => {
6716
+ const once = await format(code, options);
6717
+ const twice = await format(once, options);
6718
+ expect(twice).toBe(once);
6719
+ return once;
6720
+ };
6721
+
6722
+ it('breaks long interface type parameter lists one per line with a trailing comma', async () => {
6723
+ const input = `export interface WithFieldGroupProps<TFieldGroupData, TFieldComponents extends Record<string, HookComponentType<any>>, TFormComponents extends Record<string, HookComponentType<any>>, TSubmitMeta, TRenderProps extends object = Record<string, never>> extends BaseFormOptions<TFieldGroupData, TSubmitMeta> {
6724
+ props?: TRenderProps;
6725
+ }`;
6726
+ const expected = `export interface WithFieldGroupProps<
6727
+ TFieldGroupData,
6728
+ TFieldComponents extends Record<string, HookComponentType<any>>,
6729
+ TFormComponents extends Record<string, HookComponentType<any>>,
6730
+ TSubmitMeta,
6731
+ TRenderProps extends object = Record<string, never>,
6732
+ > extends BaseFormOptions<TFieldGroupData, TSubmitMeta> {
6733
+ props?: TRenderProps;
6734
+ }`;
6735
+
6736
+ const result = await formatStable(input, {
6737
+ printWidth: 100,
6738
+ useTabs: true,
6739
+ singleQuote: true,
6740
+ });
6741
+ expect(result).toBeWithNewline(expected);
6742
+ });
6743
+
6744
+ it('prefers breaking the function parameter list over the type parameter list', async () => {
6745
+ const input = `export default function useStateWithCallback<State>(initialState: State): [State, SetStateWithCallback<State>] {
6746
+ return null;
6747
+ }`;
6748
+ const expected = `export default function useStateWithCallback<State>(
6749
+ initialState: State,
6750
+ ): [State, SetStateWithCallback<State>] {
6751
+ return null;
6752
+ }`;
6753
+
6754
+ const result = await formatStable(input, {
6755
+ printWidth: 100,
6756
+ useTabs: true,
6757
+ singleQuote: true,
6758
+ });
6759
+ expect(result).toBeWithNewline(expected);
6760
+ });
6761
+
6762
+ it('breaks function type parameter lists that overflow on their own', async () => {
6763
+ const input = `function useAppForm<TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TSubmitMeta>(props: FormOptions<TFormData, TOnMount, TOnChange, TSubmitMeta>): void {
6764
+ return;
6765
+ }`;
6766
+ const expected = `function useAppForm<
6767
+ TFormData,
6768
+ TOnMount extends undefined | FormValidateOrFn<TFormData>,
6769
+ TOnChange extends undefined | FormValidateOrFn<TFormData>,
6770
+ TSubmitMeta,
6771
+ >(props: FormOptions<TFormData, TOnMount, TOnChange, TSubmitMeta>): void {
6772
+ return;
6773
+ }`;
6774
+
6775
+ const result = await formatStable(input, {
6776
+ printWidth: 100,
6777
+ useTabs: true,
6778
+ singleQuote: true,
6779
+ });
6780
+ expect(result).toBeWithNewline(expected);
6781
+ });
6782
+
6783
+ it('stays idempotent when a single type parameter has to break', async () => {
6784
+ const input = `interface Container<TExtremelyLongParameterName extends Record<string, unknown>> {
6785
+ value: TExtremelyLongParameterName;
6786
+ }`;
6787
+ const expected = `interface Container<
6788
+ TExtremelyLongParameterName extends Record<string, unknown>,
6789
+ > {
6790
+ value: TExtremelyLongParameterName;
6791
+ }`;
6792
+
6793
+ const result = await formatStable(input);
6794
+ expect(result).toBeWithNewline(expected);
6795
+ });
6796
+
6797
+ it('preserves the trailing comma of single-param arrow function generics', async () => {
6798
+ const expected = `const identity = <T,>(value: T): T => value;`;
6799
+
6800
+ const result = await formatStable(expected);
6801
+ expect(result).toBeWithNewline(expected);
6802
+ });
6803
+
6804
+ it('keeps single-param arrow function generics without a trailing comma as-is', async () => {
6805
+ const expected = `const identity = <T>(value: T): T => value;`;
6806
+
6807
+ const result = await formatStable(expected);
6808
+ expect(result).toBeWithNewline(expected);
6809
+ });
6810
+
6811
+ it('drops meaningless trailing commas from non-arrow type parameter lists', async () => {
6812
+ const input = `function pick<State,>(value: State): State {
6813
+ return value;
6814
+ }`;
6815
+ const expected = `function pick<State>(value: State): State {
6816
+ return value;
6817
+ }`;
6818
+
6819
+ const result = await formatStable(input);
6820
+ expect(result).toBeWithNewline(expected);
6821
+ });
6822
+
6823
+ it('omits the trailing comma in broken type parameter lists when trailingComma is none', async () => {
6824
+ const input = `interface Container<TExtremelyLongParameterName extends Record<string, unknown>> {
6825
+ value: TExtremelyLongParameterName;
6826
+ }`;
6827
+ const expected = `interface Container<
6828
+ TExtremelyLongParameterName extends Record<string, unknown>
6829
+ > {
6830
+ value: TExtremelyLongParameterName;
6831
+ }`;
6832
+
6833
+ const result = await formatStable(input, { trailingComma: 'none' });
6834
+ expect(result).toBeWithNewline(expected);
6835
+ });
6836
+ });
6496
6837
  });