From f85314ca83c9fad3d8caf7bb37214fd9a9f21222 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 2 Sep 2026 15:40:24 -0400 Subject: [PATCH 1/4] fix(formatter): split template parameter list instead of the constraint Fixes https://github.com/microsoft/typespec/issues/11836 --- ...e-parameter-formatting-2026-8-2-15-40-0.md | 19 ++ .../compiler/src/formatter/print/printer.ts | 59 +++++-- .../compiler/test/formatter/formatter.test.ts | 164 ++++++++++++++++++ .../http-client-generator-test/tsp/lro.tsp | 4 +- 4 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 .chronus/changes/fix-template-parameter-formatting-2026-8-2-15-40-0.md diff --git a/.chronus/changes/fix-template-parameter-formatting-2026-8-2-15-40-0.md b/.chronus/changes/fix-template-parameter-formatting-2026-8-2-15-40-0.md new file mode 100644 index 00000000000..1f2934543f9 --- /dev/null +++ b/.chronus/changes/fix-template-parameter-formatting-2026-8-2-15-40-0.md @@ -0,0 +1,19 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +[formatter] Split the template parameter list instead of the last parameter constraint or default when the declaration is too long + +```tsp +// Before +op deleteJobPreview is FoundryDataPlanePreviewOperation; + +// After +op deleteJobPreview< + AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKeys +> is FoundryDataPlanePreviewOperation; +``` diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 51491a656ab..815beca5003 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -343,7 +343,7 @@ export function printAliasStatement( print: PrettierChildPrint, ) { const id = path.call(print, "id"); - const template = printTemplateParameters(path, options, print, "templateParameters"); + const template = printTemplateParameterDeclarations(path, options, print, "templateParameters"); return [ printModifiers(path, options, print), "alias ", @@ -383,19 +383,24 @@ export function printCallExpression( return [path.call(print, "target"), args]; } -function printTemplateParameters( +/** + * Print a template argument list(e.g. ``). + * + * A single argument is hugged(`Foo<{...}>`) so object like arguments and long unions stay attached to the reference. + */ +function printTemplateArguments( path: AstPath, options: TypeSpecPrettierOptions, print: PrettierChildPrint, propertyName: keyof T, ) { const node = path.node; - const args = node[propertyName] as any as TemplateParameterDeclarationNode[]; - if ((args as any).length === 0) { + const args = node[propertyName] as any as TemplateArgumentNode[]; + if (args.length === 0) { return ""; } - const shouldHug = (args as any).length === 1; + const shouldHug = args.length === 1; if (shouldHug) { return ["<", join(", ", path.map(print, propertyName as any)), ">"]; } else { @@ -404,6 +409,29 @@ function printTemplateParameters( } } +/** + * Print a template parameter declaration list(e.g. ``). + * + * Unlike template arguments, parameter declarations are never hugged: when the list doesn't fit on + * the line every parameter is moved to its own indented line instead of letting the last parameter + * constraint or default break on its own. + */ +function printTemplateParameterDeclarations( + path: AstPath, + options: TypeSpecPrettierOptions, + print: PrettierChildPrint, + propertyName: keyof T, +) { + const node = path.node; + const params = node[propertyName] as any as TemplateParameterDeclarationNode[]; + if (params.length === 0) { + return ""; + } + + const body = indent([softline, join([",", line], path.map(print, propertyName as any))]); + return group(["<", body, softline, ">"]); +} + export function canAttachComment(node: Node): boolean { const kind = node.kind as SyntaxKind; return Boolean( @@ -741,7 +769,7 @@ export function printUnionStatement( ) { const id = path.call(print, "id"); const { decorators } = printDecorators(path, options, print, { tryInline: false }); - const generic = printTemplateParameters(path, options, print, "templateParameters"); + const generic = printTemplateParameterDeclarations(path, options, print, "templateParameters"); return [ decorators, printModifiers(path, options, print), @@ -787,7 +815,7 @@ export function printInterfaceStatement( ) { const id = path.call(print, "id"); const { decorators } = printDecorators(path, options, print, { tryInline: false }); - const generic = printTemplateParameters(path, options, print, "templateParameters"); + const generic = printTemplateParameterDeclarations(path, options, print, "templateParameters"); const extendList = printInterfaceExtends(path, options, print); return [ @@ -1078,7 +1106,7 @@ export function printModelStatement( const id = path.call(print, "id"); const heritage = printHeritageClause(path, print, "extends", "extends"); const isBase = printHeritageClause(path, print, "is", "is"); - const generic = printTemplateParameters(path, options, print, "templateParameters"); + const generic = printTemplateParameterDeclarations(path, options, print, "templateParameters"); const nodeHasComments = hasComments(node, CommentCheckFlags.Dangling); const shouldPrintBody = nodeHasComments || !(node.properties.length === 0 && node.is); const body = shouldPrintBody ? [" ", printModelPropertiesBlock(path, options, print)] : ";"; @@ -1111,7 +1139,9 @@ function printModelPropertiesBlock( } const tryInline = path.getParentNode()?.kind === SyntaxKind.TemplateParameterDeclaration; const lineDoc = tryInline ? softline : hardline; - const seperator = isModelAValue(path) ? "," : ";"; + const rawSeperator: string = isModelAValue(path) ? "," : ";"; + // When inlined the line between the properties collapses so the separator needs to provide the space itself. + const seperator: Doc = tryInline ? ifBreak(rawSeperator, `${rawSeperator} `) : rawSeperator; const body = [joinMembersInBlock(path, "properties", options, print, seperator, lineDoc)]; if (nodeHasComments) { @@ -1263,7 +1293,7 @@ function printScalarStatement( ) { const node = path.node; const id = path.call(print, "id"); - const template = printTemplateParameters(path, options, print, "templateParameters"); + const template = printTemplateParameterDeclarations(path, options, print, "templateParameters"); const heritage = printHeritageClause(path, print, "extends", "extends"); const nodeHasComments = hasComments(node, CommentCheckFlags.Dangling); @@ -1363,7 +1393,12 @@ export function printOperationStatement( print: PrettierChildPrint, ) { const inInterface = (path.getParentNode()?.kind as any) === SyntaxKind.InterfaceStatement; - const templateParams = printTemplateParameters(path, options, print, "templateParameters"); + const templateParams = printTemplateParameterDeclarations( + path, + options, + print, + "templateParameters", + ); const { decorators } = printDecorators(path as AstPath, options, print, { tryInline: true, }); @@ -1466,7 +1501,7 @@ export function printTypeReference( print: PrettierChildPrint, ): Doc { const type = path.call(print, "target"); - const template = printTemplateParameters(path, options, print, "arguments"); + const template = printTemplateArguments(path, options, print, "arguments"); return [type, template]; } diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index dce7cb8fe2c..db01c0e858d 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -2722,6 +2722,170 @@ model Foo {}`, }); }); + + // Regression tests for https://github.com/microsoft/typespec/issues/11836 + describe("splits the parameter list instead of the last parameter constraint or default", () => { + it("op is", async () => { + await assertFormat({ + code: ` +@delete +op deleteJobPreview is FoundryDataPlanePreviewOperation; +`, + expected: ` +@delete +op deleteJobPreview< + AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKeys +> is FoundryDataPlanePreviewOperation< + AreaPreviewLabel, + { + /** The ID of the job to delete. */ + @path jobId: string; + }, + NoContentResponse +>; +`, + }); + }); + + it("op in interface", async () => { + await assertFormat({ + code: ` +interface Jobs { op deleteJobPreview(): void; } +`, + expected: ` +interface Jobs { + deleteJobPreview< + AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptIn + >(): void; +} +`, + }); + }); + + it("model", async () => { + await assertFormat({ + code: ` +model Foo is Base; +`, + expected: ` +model Foo< + AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKeys +> is Base; +`, + }); + }); + + it("alias", async () => { + await assertFormat({ + code: ` +alias Foo = Base; +`, + expected: ` +alias Foo< + AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKeys +> = Base; +`, + }); + }); + + it("interface", async () => { + await assertFormat({ + code: ` +interface Foo { bar(): void; } +`, + expected: ` +interface Foo< + AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptIn +> { + bar(): void; +} +`, + }); + }); + + it("union", async () => { + await assertFormat({ + code: ` +union Foo { a: AreaPreviewLabel } +`, + expected: ` +union Foo< + AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKey +> { + a: AreaPreviewLabel, +} +`, + }); + }); + + it("scalar", async () => { + await assertFormat({ + code: ` +scalar Foo extends string; +`, + expected: ` +scalar Foo< + AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKey +> extends string; +`, + }); + }); + + it("splits the constraint union only when it doesn't fit on its own line", async () => { + await assertFormat({ + code: ` +model Foo {} +`, + expected: ` +model Foo< + AreaPreviewLabel extends + | FoundryFeaturesOptInKeysExtraLongNeedSplit + | FoundryFeaturesOptInKeysExtraLongNeedSplit + | AgentDefinitionOptInKeys +> {} +`, + }); + }); + + it("splits a default that is too long", async () => { + await assertFormat({ + code: ` +model Foo {} +`, + expected: ` +model Foo< + AreaPreviewLabel = FoundryFeaturesOptInKeys | AgentDefinitionOptInKeysMoreLong +> {} +`, + }); + }); + + it("splits a single parameter without constraint that is too long", async () => { + await assertFormat({ + code: ` +model Foo {} +`, + expected: ` +model Foo< + AreaPreviewLabelIsExtremelyLongNameHereOkFineAndEvenLongerThanThatYesYes +> {} +`, + }); + }); + + it("keeps the inlined model expression properties separated with a space", async () => { + await assertFormat({ + code: ` +model Foo {} +`, + expected: ` +model Foo< + T extends {someProperty: string; anotherProperty: string; thirdProp: int32} +> {} +`, + }); + }); + }); }); describe("template references", () => { diff --git a/packages/http-client-java/generator/http-client-generator-test/tsp/lro.tsp b/packages/http-client-java/generator/http-client-generator-test/tsp/lro.tsp index 04d361facea..93febaaa4c0 100644 --- a/packages/http-client-java/generator/http-client-generator-test/tsp/lro.tsp +++ b/packages/http-client-java/generator/http-client-generator-test/tsp/lro.tsp @@ -95,7 +95,9 @@ namespace TspTest.LongRunning { Traits >; - op LroLongRunningPollOperation is Azure.Core.RpcOperation< + op LroLongRunningPollOperation< + TResult extends TypeSpec.Reflection.Model + > is Azure.Core.RpcOperation< { @path("id") id: Azure.Core.uuid; From fd9aaf0cd8097438a501bdbe557c71231d151978 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 2 Sep 2026 15:53:31 -0400 Subject: [PATCH 2/4] Fix formatting of release notes sample and add java changelog entry --- .../template-parameter-formatting-java-2026-8-2-15-52-0.md | 7 +++++++ website/src/content/docs/release-notes/typespec-0-59.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .chronus/changes/template-parameter-formatting-java-2026-8-2-15-52-0.md diff --git a/.chronus/changes/template-parameter-formatting-java-2026-8-2-15-52-0.md b/.chronus/changes/template-parameter-formatting-java-2026-8-2-15-52-0.md new file mode 100644 index 00000000000..2ff756c2c66 --- /dev/null +++ b/.chronus/changes/template-parameter-formatting-java-2026-8-2-15-52-0.md @@ -0,0 +1,7 @@ +--- +changeKind: internal +packages: + - "@typespec/http-client-java" +--- + +Formatting diff --git a/website/src/content/docs/release-notes/typespec-0-59.md b/website/src/content/docs/release-notes/typespec-0-59.md index 21afc668f90..a085ccd2c25 100644 --- a/website/src/content/docs/release-notes/typespec-0-59.md +++ b/website/src/content/docs/release-notes/typespec-0-59.md @@ -94,7 +94,7 @@ model Foo {} Example ```tsp - model User {} + model User {} alias user = User<{ ┆: [age] | [name]; }>; From cd6fd76a129ebb47a778f8dbe5cc7af66f278f5c Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 2 Sep 2026 16:09:35 -0400 Subject: [PATCH 3/4] Only break the template parameter list when the parameter itself can break --- ...e-parameter-formatting-2026-8-2-15-40-0.md | 2 +- ...ameter-formatting-java-2026-8-2-15-52-0.md | 7 ---- .../compiler/src/formatter/print/printer.ts | 42 +++++++++++++++++-- .../compiler/test/formatter/formatter.test.ts | 14 +++++-- .../http-client-generator-test/tsp/lro.tsp | 4 +- 5 files changed, 51 insertions(+), 18 deletions(-) delete mode 100644 .chronus/changes/template-parameter-formatting-java-2026-8-2-15-52-0.md diff --git a/.chronus/changes/fix-template-parameter-formatting-2026-8-2-15-40-0.md b/.chronus/changes/fix-template-parameter-formatting-2026-8-2-15-40-0.md index 1f2934543f9..d21bd0becf4 100644 --- a/.chronus/changes/fix-template-parameter-formatting-2026-8-2-15-40-0.md +++ b/.chronus/changes/fix-template-parameter-formatting-2026-8-2-15-40-0.md @@ -4,7 +4,7 @@ packages: - "@typespec/compiler" --- -[formatter] Split the template parameter list instead of the last parameter constraint or default when the declaration is too long +[formatter] Split the template parameter list instead of splitting a parameter constraint or default when the declaration is too long ```tsp // Before diff --git a/.chronus/changes/template-parameter-formatting-java-2026-8-2-15-52-0.md b/.chronus/changes/template-parameter-formatting-java-2026-8-2-15-52-0.md deleted file mode 100644 index 2ff756c2c66..00000000000 --- a/.chronus/changes/template-parameter-formatting-java-2026-8-2-15-52-0.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -changeKind: internal -packages: - - "@typespec/http-client-java" ---- - -Formatting diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 815beca5003..a6b58591a86 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -412,9 +412,9 @@ function printTemplateArguments( /** * Print a template parameter declaration list(e.g. ``). * - * Unlike template arguments, parameter declarations are never hugged: when the list doesn't fit on - * the line every parameter is moved to its own indented line instead of letting the last parameter - * constraint or default break on its own. + * A single parameter is hugged(`Foo`) as long as it cannot break by itself. When the + * parameter has a breakable constraint or default(union, model expression, ...) the list breaks instead, + * so the parameter never gets split while the `<` and `>` stay glued to the surrounding code. */ function printTemplateParameterDeclarations( path: AstPath, @@ -428,10 +428,46 @@ function printTemplateParameterDeclarations( return ""; } + if (params.length === 1 && isUnbreakableTemplateParameter(params[0])) { + return ["<", join(", ", path.map(print, propertyName as any)), ">"]; + } + const body = indent([softline, join([",", line], path.map(print, propertyName as any))]); return group(["<", body, softline, ">"]); } +/** Check the template parameter declaration will always be printed on a single line. */ +function isUnbreakableTemplateParameter(node: TemplateParameterDeclarationNode): boolean { + return isUnbreakableType(node.constraint) && isUnbreakableType(node.default); +} + +/** Check the type expression has no line break opportunity and so will always be printed on a single line. */ +function isUnbreakableType(node: Node | undefined): boolean { + if (node === undefined) { + return true; + } + switch (node.kind) { + case SyntaxKind.Identifier: + case SyntaxKind.MemberExpression: + case SyntaxKind.StringLiteral: + case SyntaxKind.NumericLiteral: + case SyntaxKind.BooleanLiteral: + case SyntaxKind.VoidKeyword: + case SyntaxKind.NeverKeyword: + case SyntaxKind.UnknownKeyword: + return true; + case SyntaxKind.TypeReference: + return node.arguments.length === 0; + case SyntaxKind.ArrayExpression: + return isUnbreakableType(node.elementType); + case SyntaxKind.ValueOfExpression: + case SyntaxKind.TypeOfExpression: + return isUnbreakableType(node.target); + default: + return false; + } +} + export function canAttachComment(node: Node): boolean { const kind = node.kind as SyntaxKind; return Boolean( diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index db01c0e858d..ef529883497 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -2860,15 +2860,21 @@ model Foo< }); }); - it("splits a single parameter without constraint that is too long", async () => { + it("keeps a single parameter that cannot break hugged even if the line is too long", async () => { await assertFormat({ code: ` model Foo {} + +model Bar is Base; `, expected: ` -model Foo< - AreaPreviewLabelIsExtremelyLongNameHereOkFineAndEvenLongerThanThatYesYes -> {} +model Foo {} + +model Bar is Base< + TResource, + Options, + NoContentResponse +>; `, }); }); diff --git a/packages/http-client-java/generator/http-client-generator-test/tsp/lro.tsp b/packages/http-client-java/generator/http-client-generator-test/tsp/lro.tsp index 93febaaa4c0..04d361facea 100644 --- a/packages/http-client-java/generator/http-client-generator-test/tsp/lro.tsp +++ b/packages/http-client-java/generator/http-client-generator-test/tsp/lro.tsp @@ -95,9 +95,7 @@ namespace TspTest.LongRunning { Traits >; - op LroLongRunningPollOperation< - TResult extends TypeSpec.Reflection.Model - > is Azure.Core.RpcOperation< + op LroLongRunningPollOperation is Azure.Core.RpcOperation< { @path("id") id: Azure.Core.uuid; From 0d6583eef3d86aa38d1fa30cd8b2494b1c869ac9 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 3 Sep 2026 11:34:04 -0400 Subject: [PATCH 4/4] Fix formatting and grammar in printTemplateArguments comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/compiler/src/formatter/print/printer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index a6b58591a86..482ea4aa96e 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -384,9 +384,9 @@ export function printCallExpression( } /** - * Print a template argument list(e.g. ``). + * Print a template argument list (e.g. ``). * - * A single argument is hugged(`Foo<{...}>`) so object like arguments and long unions stay attached to the reference. + * A single argument is hugged (`Foo<{...}>`) so object-like arguments and long unions stay attached to the reference. */ function printTemplateArguments( path: AstPath,