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..d21bd0becf4 --- /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 splitting a 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..482ea4aa96e 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,65 @@ function printTemplateParameters( } } +/** + * Print a template parameter declaration list(e.g. ``). + * + * 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, + options: TypeSpecPrettierOptions, + print: PrettierChildPrint, + propertyName: keyof T, +) { + const node = path.node; + const params = node[propertyName] as any as TemplateParameterDeclarationNode[]; + if (params.length === 0) { + 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( @@ -741,7 +805,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 +851,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 +1142,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 +1175,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 +1329,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 +1429,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 +1537,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..ef529883497 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -2722,6 +2722,176 @@ 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("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 {} + +model Bar is Base< + TResource, + Options, + NoContentResponse +>; +`, + }); + }); + + 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/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]; }>;