Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<AreaPreviewLabel extends
| FoundryFeaturesOptInKeys
| AgentDefinitionOptInKeys> is FoundryDataPlanePreviewOperation<AreaPreviewLabel>;

// After
op deleteJobPreview<
AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKeys
> is FoundryDataPlanePreviewOperation<AreaPreviewLabel>;
```
95 changes: 83 additions & 12 deletions packages/compiler/src/formatter/print/printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ",
Expand Down Expand Up @@ -383,19 +383,24 @@ export function printCallExpression(
return [path.call(print, "target"), args];
}

function printTemplateParameters<T extends Node>(
/**
* Print a template argument list(e.g. `<string, T = int32>`).
*
* A single argument is hugged(`Foo<{...}>`) so object like arguments and long unions stay attached to the reference.
*/
function printTemplateArguments<T extends Node>(
path: AstPath<T>,
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 {
Expand All @@ -404,6 +409,65 @@ function printTemplateParameters<T extends Node>(
}
}

/**
* Print a template parameter declaration list(e.g. `<T extends string, U = int32>`).
*
* A single parameter is hugged(`Foo<T extends string>`) 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<T extends Node>(
path: AstPath<T>,
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(
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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)] : ";";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<DecorableNode>, options, print, {
tryInline: true,
});
Expand Down Expand Up @@ -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];
}

Expand Down
170 changes: 170 additions & 0 deletions packages/compiler/test/formatter/formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2722,6 +2722,176 @@ model Foo<T extends string =
model Foo<T extends string = "abc"> {}`,
});
});

// 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<AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKeys> is FoundryDataPlanePreviewOperation<AreaPreviewLabel, { /** The ID of the job to delete. */ @path jobId: string; }, NoContentResponse>;
`,
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<AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptIn>(): void; }
`,
expected: `
interface Jobs {
deleteJobPreview<
AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptIn
>(): void;
}
`,
});
});

it("model", async () => {
await assertFormat({
code: `
model Foo<AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKeys> is Base<AreaPreviewLabel, Bar, NoContentResponse>;
`,
expected: `
model Foo<
AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKeys
> is Base<AreaPreviewLabel, Bar, NoContentResponse>;
`,
});
});

it("alias", async () => {
await assertFormat({
code: `
alias Foo<AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKeys> = Base<AreaPreviewLabel>;
`,
expected: `
alias Foo<
AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKeys
> = Base<AreaPreviewLabel>;
`,
});
});

it("interface", async () => {
await assertFormat({
code: `
interface Foo<AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptIn> { bar(): void; }
`,
expected: `
interface Foo<
AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptIn
> {
bar(): void;
}
`,
});
});

it("union", async () => {
await assertFormat({
code: `
union Foo<AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKey> { a: AreaPreviewLabel }
`,
expected: `
union Foo<
AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKey
> {
a: AreaPreviewLabel,
}
`,
});
});

it("scalar", async () => {
await assertFormat({
code: `
scalar Foo<AreaPreviewLabel extends FoundryFeaturesOptInKeys | AgentDefinitionOptInKey> 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<AreaPreviewLabel extends FoundryFeaturesOptInKeysExtraLongNeedSplit | FoundryFeaturesOptInKeysExtraLongNeedSplit | AgentDefinitionOptInKeys> {}
`,
expected: `
model Foo<
AreaPreviewLabel extends
| FoundryFeaturesOptInKeysExtraLongNeedSplit
| FoundryFeaturesOptInKeysExtraLongNeedSplit
| AgentDefinitionOptInKeys
> {}
`,
});
});

it("splits a default that is too long", async () => {
await assertFormat({
code: `
model Foo<AreaPreviewLabel = FoundryFeaturesOptInKeys | AgentDefinitionOptInKeysMoreLong> {}
`,
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<AreaPreviewLabelIsExtremelyLongNameHereOkFineAndEvenLongerThanThatYesYes> {}

model Bar<TResource extends TypeSpec.Reflection.Model> is Base<TResource, Options, NoContentResponse>;
`,
expected: `
model Foo<AreaPreviewLabelIsExtremelyLongNameHereOkFineAndEvenLongerThanThatYesYes> {}

model Bar<TResource extends TypeSpec.Reflection.Model> is Base<
TResource,
Options,
NoContentResponse
>;
`,
});
});

it("keeps the inlined model expression properties separated with a space", async () => {
await assertFormat({
code: `
model Foo<T extends { someProperty: string, anotherProperty: string, thirdProp: int32 }> {}
`,
expected: `
model Foo<
T extends {someProperty: string; anotherProperty: string; thirdProp: int32}
> {}
`,
});
});
});
});

describe("template references", () => {
Expand Down
2 changes: 1 addition & 1 deletion website/src/content/docs/release-notes/typespec-0-59.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ model Foo {}
Example

```tsp
model User<T extends {name: string;age: int16}> {}
model User<T extends {name: string; age: int16}> {}
alias user = User<{
┆: [age] | [name];
}>;
Expand Down
Loading