diff --git a/.chronus/changes/formatter-multiline-strings-2024-4-22-14-32-55.md b/.chronus/changes/formatter-multiline-strings-2024-4-22-14-32-55.md new file mode 100644 index 000000000..91eaf6abf --- /dev/null +++ b/.chronus/changes/formatter-multiline-strings-2024-4-22-14-32-55.md @@ -0,0 +1,8 @@ +--- +# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking +changeKind: feature +packages: + - "@typespec/compiler" +--- + +Formatter: Indent or dedent multiline strings to the current indentation diff --git a/docs/getting-started/typespec-for-openapi-dev.md b/docs/getting-started/typespec-for-openapi-dev.md index 90d6e8a02..a7c5b42d0 100644 --- a/docs/getting-started/typespec-for-openapi-dev.md +++ b/docs/getting-started/typespec-for-openapi-dev.md @@ -244,13 +244,13 @@ and can contain markdown formatting. ```typespec @doc(""" -Get status info for the service. -The status includes the current version of the service. -The status value may be one of: -- `ok`: the service is operating normally -- `degraded`: the service is operating in a degraded state -- `down`: the service is not operating -""") + Get status info for the service. + The status includes the current version of the service. + The status value may be one of: + - `ok`: the service is operating normally + - `degraded`: the service is operating in a degraded state + - `down`: the service is not operating + """) @tag("Status") @route("/status") @get diff --git a/docs/language-basics/type-literals.md b/docs/language-basics/type-literals.md index 049c990d7..2de0abad5 100644 --- a/docs/language-basics/type-literals.md +++ b/docs/language-basics/type-literals.md @@ -21,10 +21,10 @@ Multi-line string literals are denoted using three double quotes `"""`. ```typespec alias Str = """ -This is a multi line string - - opt 1 - - opt 2 -"""; + This is a multi line string + - opt 1 + - opt 2 + """; ``` - The opening `"""` must be followed by a new line. diff --git a/packages/compiler/src/formatter/print/printer.ts b/packages/compiler/src/formatter/print/printer.ts index 1d6d3d7bc..969ec4bb8 100644 --- a/packages/compiler/src/formatter/print/printer.ts +++ b/packages/compiler/src/formatter/print/printer.ts @@ -1,6 +1,11 @@ import type { AstPath, Doc, Printer } from "prettier"; import { builders } from "prettier/doc"; -import { isIdentifierContinue, isIdentifierStart, utf16CodeUnits } from "../../core/charcode.js"; +import { + CharCode, + isIdentifierContinue, + isIdentifierStart, + utf16CodeUnits, +} from "../../core/charcode.js"; import { compilerAssert } from "../../core/diagnostics.js"; import { Keywords } from "../../core/scanner.js"; import { @@ -86,7 +91,19 @@ import { commentHandler } from "./comment-handler.js"; import { needsParens } from "./needs-parens.js"; import { DecorableNode, PrettierChildPrint, TypeSpecPrettierOptions } from "./types.js"; import { util } from "./util.js"; -const { align, breakParent, group, hardline, ifBreak, indent, join, line, softline } = builders; +const { + align, + breakParent, + group, + hardline, + ifBreak, + indent, + join, + line, + softline, + literalline, + markAsRoot, +} = builders; const { isNextLineEmpty } = util as any; @@ -689,7 +706,8 @@ function printCallOrDecoratorArgs( const shouldHug = node.arguments.length === 1 && (node.arguments[0].kind === SyntaxKind.ModelExpression || - node.arguments[0].kind === SyntaxKind.StringLiteral); + node.arguments[0].kind === SyntaxKind.StringLiteral || + node.arguments[0].kind === SyntaxKind.StringTemplateExpression); if (shouldHug) { return [ @@ -1637,7 +1655,28 @@ function printStringLiteral( options: TypeSpecPrettierOptions ): Doc { const node = path.node; - return getRawText(node, options); + const multiline = isMultiline(node, options); + + const raw = getRawText(node, options); + if (multiline) { + const lines = splitLines(raw.slice(3)); + const whitespaceIndent = lines[lines.length - 1].length - 3; + const newLines = trimMultilineString(lines, whitespaceIndent); + return [`"""`, indent(markAsRoot(newLines))]; + } else { + return raw; + } +} + +function isMultiline( + node: StringLiteralNode | StringTemplateExpressionNode, + options: TypeSpecPrettierOptions +) { + return ( + options.originalText[node.pos] && + options.originalText[node.pos + 1] === `"` && + options.originalText[node.pos + 2] === `"` + ); } function printNumberLiteral( @@ -1871,14 +1910,79 @@ export function printStringTemplateExpression( print: PrettierChildPrint ) { const node = path.node; - const content = [ - getRawText(node.head, options), - path.map((span: AstPath) => { - const expression = span.call(print, "expression"); - return [expression, getRawText(span.node.literal, options)]; - }, "spans"), - ]; - return content; + const multiline = isMultiline(node, options); + const rawHead = getRawText(node.head, options); + if (multiline) { + const lastSpan = node.spans[node.spans.length - 1]; + const lastLines = splitLines(getRawText(lastSpan.literal, options)); + const whitespaceIndent = lastLines[lastLines.length - 1].length - 3; + const content = [ + trimMultilineString(splitLines(rawHead.slice(3)), whitespaceIndent), + path.map((span: AstPath) => { + const expression = span.call(print, "expression"); + const spanRawText = getRawText(span.node.literal, options); + const spanLines = splitLines(spanRawText); + return [ + expression, + spanLines[0], + literalline, + trimMultilineString(spanLines.slice(1), whitespaceIndent), + ]; + }, "spans"), + ]; + + return [`"""`, indent(markAsRoot([content]))]; + } else { + const content = [ + rawHead, + path.map((span: AstPath) => { + const expression = span.call(print, "expression"); + return [expression, getRawText(span.node.literal, options)]; + }, "spans"), + ]; + return content; + } +} + +function splitLines(text: string): string[] { + const lines = []; + let start = 0; + let pos = 0; + + while (pos < text.length) { + const ch = text.charCodeAt(pos); + switch (ch) { + case CharCode.CarriageReturn: + if (text.charCodeAt(pos + 1) === CharCode.LineFeed) { + lines.push(text.slice(start, pos)); + start = pos; + pos++; + } else { + lines.push(text.slice(start, pos)); + start = pos; + } + break; + case CharCode.LineFeed: + lines.push(text.slice(start, pos)); + start = pos; + break; + } + pos++; + } + + lines.push(text.slice(start)); + return lines; +} + +function trimMultilineString(lines: string[], whitespaceIndent: number): Doc[] { + const newLines = []; + for (let i = 0; i < lines.length; i++) { + newLines.push(lines[i].slice(whitespaceIndent)); + if (i < lines.length - 1) { + newLines.push(literalline); + } + } + return newLines; } function printItemList( diff --git a/packages/compiler/test/formatter/formatter.test.ts b/packages/compiler/test/formatter/formatter.test.ts index 43b03224f..244343543 100644 --- a/packages/compiler/test/formatter/formatter.test.ts +++ b/packages/compiler/test/formatter/formatter.test.ts @@ -1807,7 +1807,7 @@ namespace Foo { }); }); - describe("string literals", () => { + describe("single line string literals", () => { it("format single line string literal", async () => { await assertFormat({ code: ` @@ -1835,14 +1835,37 @@ model Foo {} `, }); }); + }); - it("format multi line string literal", async () => { + describe("multi line string literals", () => { + it("keeps trailing whitespaces", async () => { await assertFormat({ code: ` @doc( """ +3 whitespaces + +and blank line above +""" + ) +model Foo {} +`, + expected: ` +@doc(""" + 3 whitespaces -this is a doc. - that + and blank line above + """) +model Foo {} +`, + }); + }); + + it("keeps indent relative to closing quotes", async () => { + await assertFormat({ + code: ` +@doc( """ +this is a doc. + that span multiple lines. """ @@ -1851,12 +1874,31 @@ model Foo {} `, expected: ` @doc(""" - -this is a doc. - that - span - multiple lines. -""") + this is a doc. + that + span + multiple lines. + """) +model Foo {} +`, + }); + }); + + it("keeps escaped charaters", async () => { + await assertFormat({ + code: ` +@doc( """ +with \\n +and \\t +""" + ) +model Foo {} +`, + expected: ` +@doc(""" + with \\n + and \\t + """) model Foo {} `, }); @@ -2847,11 +2889,11 @@ alias T = "foo \${{ await assertFormat({ code: ` alias T = """ - This \${ "one" } goes over - multiple - \${ "two" } - lines - """;`, + This \${ "one" } goes over + multiple + \${ "two" } + lines + """;`, expected: ` alias T = """ This \${"one"} goes over diff --git a/packages/samples/specs/grpc-kiosk-example/kiosk.tsp b/packages/samples/specs/grpc-kiosk-example/kiosk.tsp index 7a2ddfa83..e6bac422f 100644 --- a/packages/samples/specs/grpc-kiosk-example/kiosk.tsp +++ b/packages/samples/specs/grpc-kiosk-example/kiosk.tsp @@ -80,9 +80,9 @@ model Kiosk { } @doc(""" -Describes a digital sign. -Signs can include text, images, or both. -""") + Describes a digital sign. + Signs can include text, images, or both. + """) model Sign { @doc("unique id") id?: int32; // Output only. diff --git a/packages/samples/specs/grpc-kiosk-example/types.tsp b/packages/samples/specs/grpc-kiosk-example/types.tsp index f0e93f6ad..2c8af4f05 100644 --- a/packages/samples/specs/grpc-kiosk-example/types.tsp +++ b/packages/samples/specs/grpc-kiosk-example/types.tsp @@ -15,12 +15,12 @@ model Timestamp { } @doc(""" -An object that represents a latitude/longitude pair. This is expressed as a -pair of doubles to represent degrees latitude and degrees longitude. Unless -specified otherwise, this must conform to the -WGS84 -standard. Values must be within normalized ranges. -""") + An object that represents a latitude/longitude pair. This is expressed as a + pair of doubles to represent degrees latitude and degrees longitude. Unless + specified otherwise, this must conform to the + WGS84 + standard. Values must be within normalized ranges. + """) model LatLng { // The latitude in degrees. It must be in the range [-90.0, +90.0]. latitude: float64; diff --git a/packages/samples/specs/grpc-library-example/library.tsp b/packages/samples/specs/grpc-library-example/library.tsp index 33f9f0451..5e54950f3 100644 --- a/packages/samples/specs/grpc-library-example/library.tsp +++ b/packages/samples/specs/grpc-library-example/library.tsp @@ -14,16 +14,16 @@ using TypeSpec.Http; namespace GrpcLibrarySample; @doc(""" -This API represents a simple digital library. It lets you manage Shelf -resources and Book resources in the library. It defines the following -resource model: - -- The API has a collection of [Shelf][google.example.library.v1.Shelf] - resources, named `shelves/*` - -- Each Shelf has a collection of [Book][google.example.library.v1.Book] - resources, named `shelves/*/books/*` -""") + This API represents a simple digital library. It lets you manage Shelf + resources and Book resources in the library. It defines the following + resource model: + + - The API has a collection of [Shelf][google.example.library.v1.Shelf] + resources, named `shelves/*` + + - Each Shelf has a collection of [Book][google.example.library.v1.Book] + resources, named `shelves/*/books/*` + """) @route("/v1") @tag("LibraryService") namespace LibraryService { @@ -37,9 +37,9 @@ namespace LibraryService { op getShelf(...GetShelfRequest): Shelf | RpcStatus; @doc(""" -Lists shelves. The order is unspecified but deterministic. Newly created -shelves will not necessarily be added to the end of this list. -""") + Lists shelves. The order is unspecified but deterministic. Newly created + shelves will not necessarily be added to the end of this list. + """) @route("shelves") op listShelves(...ListRequestBase): ListShelvesResponse | RpcStatus; @@ -49,13 +49,13 @@ shelves will not necessarily be added to the end of this list. op deleteShelf(...DeleteShelfRequest): void | RpcStatus; @doc(""" -Merges two shelves by adding all books from the shelf named -`other_shelf_name` to shelf `name`, and deletes -`other_shelf_name`. Returns the updated shelf. -The book ids of the moved books may not be the same as the original books. -Returns NOT_FOUND if either shelf does not exist. -This call is a no-op if the specified shelves are the same. -""") + Merges two shelves by adding all books from the shelf named + `other_shelf_name` to shelf `name`, and deletes + `other_shelf_name`. Returns the updated shelf. + The book ids of the moved books may not be the same as the original books. + Returns NOT_FOUND if either shelf does not exist. + This call is a no-op if the specified shelves are the same. + """) @route("shelves/{name}:merge") @post op mergeShelves(...MergeShelvesRequest): Shelf | RpcStatus; @@ -70,10 +70,10 @@ This call is a no-op if the specified shelves are the same. op getBook(...GetBookRequest): Book | RpcStatus; @doc(""" -Lists books in a shelf. The order is unspecified but deterministic. Newly -created books will not necessarily be added to the end of this list. -Returns NOT_FOUND if the shelf does not exist. -""") + Lists books in a shelf. The order is unspecified but deterministic. Newly + created books will not necessarily be added to the end of this list. + Returns NOT_FOUND if the shelf does not exist. + """) @route("shelves/{name}/books") op listBooks(...ListBooksRequest): ListBooksResponse | RpcStatus; @@ -84,19 +84,19 @@ Returns NOT_FOUND if the shelf does not exist. } @doc(""" -The name of a book. -Book names have the form `shelves/{shelf_id}/books/{book_id}` -""") + The name of a book. + Book names have the form `shelves/{shelf_id}/books/{book_id}` + """) @pattern("shelves/\\w+/books/\\w+") scalar book_name extends string; @doc("A single book in the library.") model Book { @doc(""" -The resource name of the book. -Book names have the form `shelves/{shelf_id}/books/{book_id}`. -The name is ignored when creating a book. -""") + The resource name of the book. + Book names have the form `shelves/{shelf_id}/books/{book_id}`. + The name is ignored when creating a book. + """) name: book_name; @doc("The name of the book author.") @@ -110,19 +110,19 @@ The name is ignored when creating a book. } @doc(""" -The name of a shelf. -Shelf names have the form `shelves/{shelf_id}`. -""") + The name of a shelf. + Shelf names have the form `shelves/{shelf_id}`. + """) @pattern("shelves/\\w+") scalar shelf_name extends string; @doc("A Shelf contains a collection of books with a theme.") model Shelf { @doc(""" -The resource name of the shelf. -Shelf names have the form `shelves/{shelf_id}`. -The name is ignored when creating a shelf. -""") + The resource name of the shelf. + Shelf names have the form `shelves/{shelf_id}`. + The name is ignored when creating a shelf. + """) name: shelf_name; @doc("The theme of the shelf") @@ -159,9 +159,9 @@ model DeleteShelfRequest { } @doc(""" -Describes the shelf being removed (other_shelf_name) and updated -(name) in this merge -""") + Describes the shelf being removed (other_shelf_name) and updated + (name) in this merge + """) model MergeShelvesRequest { @doc("The name of the shelf we're adding books to.") @path @@ -229,9 +229,9 @@ model DeleteBookRequest { } @doc(""" -Describes what book to move (name) and what shelf we're moving it -to (other_shelf_name). -""") + Describes what book to move (name) and what shelf we're moving it + to (other_shelf_name). + """) model MoveBookRequest { @doc("The name of the book to move.") name: book_name; @@ -246,30 +246,30 @@ model MoveBookRequest { model ListRequestBase { @doc(""" -Requested page size. Server may return fewer shelves than requested. -If unspecified, server will pick an appropriate default. -""") + Requested page size. Server may return fewer shelves than requested. + If unspecified, server will pick an appropriate default. + """) @query page_size?: int32; @doc(""" -A token identifying a page of results the server should return. -Typically, this is the value of -[ListShelvesResponse.next_page_token][google.example.library.v1.ListShelvesResponse.next_page_token] -returned from the previous call to `ListShelves` method. -""") + A token identifying a page of results the server should return. + Typically, this is the value of + [ListShelvesResponse.next_page_token][google.example.library.v1.ListShelvesResponse.next_page_token] + returned from the previous call to `ListShelves` method. + """) @query page_token?: string; } model ListResponseBase { @doc(""" -A token to retrieve next page of results. -Pass this value in the -[ListShelvesRequest.page_token][google.example.library.v1.ListShelvesRequest.page_token] -field in the subsequent call to `ListShelves` method to retrieve the next -page of results. -""") + A token to retrieve next page of results. + Pass this value in the + [ListShelvesRequest.page_token][google.example.library.v1.ListShelvesRequest.page_token] + field in the subsequent call to `ListShelves` method to retrieve the next + page of results. + """) next_page_token?: string; } diff --git a/packages/samples/specs/string-template/main.tsp b/packages/samples/specs/string-template/main.tsp index ba422dab7..5eac5bfc1 100644 --- a/packages/samples/specs/string-template/main.tsp +++ b/packages/samples/specs/string-template/main.tsp @@ -3,11 +3,11 @@ alias myConst = "foobar"; model Person { simple: "Simple ${123} end"; multiline: """ - Multi - ${123} - ${true} - line - """; + Multi + ${123} + ${true} + line + """; ref: "Ref this alias ${myConst} end"; template: Template<"custom">; } diff --git a/packages/website/versioned_docs/version-latest/getting-started/typespec-for-openapi-dev.md b/packages/website/versioned_docs/version-latest/getting-started/typespec-for-openapi-dev.md index 90d6e8a02..a7c5b42d0 100644 --- a/packages/website/versioned_docs/version-latest/getting-started/typespec-for-openapi-dev.md +++ b/packages/website/versioned_docs/version-latest/getting-started/typespec-for-openapi-dev.md @@ -244,13 +244,13 @@ and can contain markdown formatting. ```typespec @doc(""" -Get status info for the service. -The status includes the current version of the service. -The status value may be one of: -- `ok`: the service is operating normally -- `degraded`: the service is operating in a degraded state -- `down`: the service is not operating -""") + Get status info for the service. + The status includes the current version of the service. + The status value may be one of: + - `ok`: the service is operating normally + - `degraded`: the service is operating in a degraded state + - `down`: the service is not operating + """) @tag("Status") @route("/status") @get diff --git a/packages/website/versioned_docs/version-latest/language-basics/type-literals.md b/packages/website/versioned_docs/version-latest/language-basics/type-literals.md index 049c990d7..2de0abad5 100644 --- a/packages/website/versioned_docs/version-latest/language-basics/type-literals.md +++ b/packages/website/versioned_docs/version-latest/language-basics/type-literals.md @@ -21,10 +21,10 @@ Multi-line string literals are denoted using three double quotes `"""`. ```typespec alias Str = """ -This is a multi line string - - opt 1 - - opt 2 -"""; + This is a multi line string + - opt 1 + - opt 2 + """; ``` - The opening `"""` must be followed by a new line.