diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 6d2f3d4fa78f8..5147546faee23 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -67,6 +67,7 @@ import { toRootedPath, } from "../path.ts"; import { prepareCompilerOptions } from "../prepareCompilerOptions.generated.ts"; +import type { PrintNodeComment } from "../proto.generated.ts"; import type { BuildResponse, CleanBuildResponse, @@ -3129,24 +3130,28 @@ export class Printer { } async printNode(node: Node, options: PrintNodeOptions = {}): Promise { - const encoded = encodeNode(node); + const comments: PrintNodeComment[] = []; + const encoded = encodeNode(node, comments); const base64 = uint8ArrayToBase64(encoded); return this.client.apiRequest("printNode", { data: base64, preserveSourceNewlines: options.preserveSourceNewlines, neverAsciiEscape: options.neverAsciiEscape, terminateUnterminatedLiterals: options.terminateUnterminatedLiterals, + comments: comments.length ? comments : undefined, }); } async printFile(sourceFile: SourceFile, options: PrintNodeOptions = {}): Promise { - const encoded = encodeNode(sourceFile); + const comments: PrintNodeComment[] = []; + const encoded = encodeNode(sourceFile, comments); const base64 = uint8ArrayToBase64(encoded); return this.client.apiRequest("printNode", { data: base64, preserveSourceNewlines: options.preserveSourceNewlines, neverAsciiEscape: options.neverAsciiEscape, terminateUnterminatedLiterals: options.terminateUnterminatedLiterals, + comments: comments.length ? comments : undefined, }); } } diff --git a/packages/typescript/src/api/node/encoder.ts b/packages/typescript/src/api/node/encoder.ts index 0bfdcc2ff02e4..b567abc63f3bc 100644 --- a/packages/typescript/src/api/node/encoder.ts +++ b/packages/typescript/src/api/node/encoder.ts @@ -6,7 +6,12 @@ import type { SourceFile, TemplateLiteralLikeNode, } from "../../ast/index.ts"; -import { SyntaxKind } from "../../ast/index.ts"; +import { + getSyntheticLeadingComments, + getSyntheticTrailingComments, + SyntaxKind, +} from "../../ast/index.ts"; +import type { PrintNodeComment } from "../proto.generated.ts"; import { getNodeCommonData, getNodeDataType, @@ -199,7 +204,7 @@ export function encodeSourceFile(sourceFile: SourceFile): Uint8Array { * Encode an arbitrary AST node into the binary format. * When encoding a non-SourceFile node, the header hash and parse options fields will be zero. */ -export function encodeNode(node: Node): Uint8Array { +export function encodeNode(node: Node, comments?: PrintNodeComment[]): Uint8Array { const strs = new StringTable(); const extendedDataValues: number[] = []; const structuredWriter = new MsgpackWriter(); @@ -214,9 +219,20 @@ export function encodeNode(node: Node): Uint8Array { let parentIndex = 0; let prevIndex = 0; + function collectComments(node: Node, index: number): void { + if (!comments) return; + for (const comment of getSyntheticLeadingComments(node) ?? []) { + comments.push({ node: index, kind: comment.kind, text: comment.text, hasTrailingNewLine: comment.hasTrailingNewLine || undefined }); + } + for (const comment of getSyntheticTrailingComments(node) ?? []) { + comments.push({ node: index, trailing: true, kind: comment.kind, text: comment.text, hasTrailingNewLine: comment.hasTrailingNewLine || undefined }); + } + } + function visitNode(node: Node): void { nodeCount++; const currentIndex = nodeCount; + collectComments(node, currentIndex); if (prevIndex !== 0) { // Set next pointer on previous sibling @@ -300,6 +316,7 @@ export function encodeNode(node: Node): Uint8Array { // Encode root node nodeCount++; + collectComments(node, 1); parentIndex++; const rootData = getNodeData(node, strs, extendedDataValues, structuredWriter); nodeValues.push( diff --git a/packages/typescript/src/api/proto.generated.ts b/packages/typescript/src/api/proto.generated.ts index 01e2fa27b12c2..609b88b77789e 100644 --- a/packages/typescript/src/api/proto.generated.ts +++ b/packages/typescript/src/api/proto.generated.ts @@ -1056,6 +1056,8 @@ export interface PrintNodeParams { preserveSourceNewlines?: boolean | undefined; neverAsciiEscape?: boolean | undefined; terminateUnterminatedLiterals?: boolean | undefined; + /** Comments are synthetic comments attached to nodes of the encoded AST. */ + comments?: readonly PrintNodeComment[] | undefined; } export interface EmitParams { @@ -1696,6 +1698,17 @@ export interface DiagnosticSourceLineResponse { text: string; } +/** PrintNodeComment is a synthetic comment attached to a node of the encoded AST. */ +export interface PrintNodeComment { + /** index of the node in the encoded AST data */ + node: number; + trailing?: boolean | undefined; + /** SingleLineCommentTrivia or MultiLineCommentTrivia */ + kind: number; + text: string; + hasTrailingNewLine?: boolean | undefined; +} + export interface EmitOutputFile { fileName: RootedFilePath; text: string; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 3c938ec100554..854db25987ed4 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -84,6 +84,7 @@ import { toRootedPath, } from "../path.ts"; import { prepareCompilerOptions } from "../prepareCompilerOptions.generated.ts"; +import type { PrintNodeComment } from "../proto.generated.ts"; import type { BuildResponse, CleanBuildResponse, @@ -6484,23 +6485,27 @@ export class Printer { owner, "printNode", function (node: Node, options: PrintNodeOptions = {}): string { - const encoded = encodeNode(node); + const comments: PrintNodeComment[] = []; + const encoded = encodeNode(node, comments); const base64 = uint8ArrayToBase64(encoded); return owner.client.apiRequest("printNode", { data: base64, preserveSourceNewlines: options.preserveSourceNewlines, neverAsciiEscape: options.neverAsciiEscape, terminateUnterminatedLiterals: options.terminateUnterminatedLiterals, + comments: comments.length ? comments : undefined, }); }, function* (node: Node, options: PrintNodeOptions = {}): Generator { - const encoded = encodeNode(node); + const comments: PrintNodeComment[] = []; + const encoded = encodeNode(node, comments); const base64 = uint8ArrayToBase64(encoded); return yield* apiRequest("printNode", { data: base64, preserveSourceNewlines: options.preserveSourceNewlines, neverAsciiEscape: options.neverAsciiEscape, terminateUnterminatedLiterals: options.terminateUnterminatedLiterals, + comments: comments.length ? comments : undefined, }); }, ); @@ -6515,23 +6520,27 @@ export class Printer { owner, "printFile", function (sourceFile: SourceFile, options: PrintNodeOptions = {}): string { - const encoded = encodeNode(sourceFile); + const comments: PrintNodeComment[] = []; + const encoded = encodeNode(sourceFile, comments); const base64 = uint8ArrayToBase64(encoded); return owner.client.apiRequest("printNode", { data: base64, preserveSourceNewlines: options.preserveSourceNewlines, neverAsciiEscape: options.neverAsciiEscape, terminateUnterminatedLiterals: options.terminateUnterminatedLiterals, + comments: comments.length ? comments : undefined, }); }, function* (sourceFile: SourceFile, options: PrintNodeOptions = {}): Generator { - const encoded = encodeNode(sourceFile); + const comments: PrintNodeComment[] = []; + const encoded = encodeNode(sourceFile, comments); const base64 = uint8ArrayToBase64(encoded); return yield* apiRequest("printNode", { data: base64, preserveSourceNewlines: options.preserveSourceNewlines, neverAsciiEscape: options.neverAsciiEscape, terminateUnterminatedLiterals: options.terminateUnterminatedLiterals, + comments: comments.length ? comments : undefined, }); }, ); diff --git a/packages/typescript/src/ast/index.ts b/packages/typescript/src/ast/index.ts index fcef01b579dc5..d0ba56cd479c6 100644 --- a/packages/typescript/src/ast/index.ts +++ b/packages/typescript/src/ast/index.ts @@ -20,5 +20,6 @@ export * from "./is.ts"; export * from "./jsdoc.ts"; export * from "./scanner.ts"; export * from "./spanMap.ts"; +export * from "./syntheticComments.ts"; export * from "./utils.ts"; export * from "./visitor.ts"; diff --git a/packages/typescript/src/ast/syntheticComments.ts b/packages/typescript/src/ast/syntheticComments.ts new file mode 100644 index 0000000000000..cfa295743f771 --- /dev/null +++ b/packages/typescript/src/ast/syntheticComments.ts @@ -0,0 +1,66 @@ +import { SyntaxKind } from "#enums/syntaxKind"; +import type { Node } from "./ast.ts"; + +export interface SynthesizedComment { + readonly kind: SyntaxKind.SingleLineCommentTrivia | SyntaxKind.MultiLineCommentTrivia; + readonly text: string; + readonly hasTrailingNewLine?: boolean | undefined; +} + +const leadingComments = new WeakMap(); +const trailingComments = new WeakMap(); + +function add(store: WeakMap, node: Node, comment: SynthesizedComment): void { + const existing = store.get(node); + if (existing) { + existing.push(comment); + } + else { + store.set(node, [comment]); + } +} + +/** + * Attaches a synthetic comment before `node`. It is emitted by the printer when the node is printed. + * For multi-line comments `text` excludes the `/*` and `*\/` delimiters, so a JSDoc block is `"*\n * text\n "`. + */ +export function addSyntheticLeadingComment( + node: T, + kind: SynthesizedComment["kind"], + text: string, + hasTrailingNewLine?: boolean, +): T { + add(leadingComments, node, { kind, text, hasTrailingNewLine }); + return node; +} + +/** Attaches a synthetic comment after `node`. */ +export function addSyntheticTrailingComment( + node: T, + kind: SynthesizedComment["kind"], + text: string, + hasTrailingNewLine?: boolean, +): T { + add(trailingComments, node, { kind, text, hasTrailingNewLine }); + return node; +} + +export function getSyntheticLeadingComments(node: Node): readonly SynthesizedComment[] | undefined { + return leadingComments.get(node); +} + +export function getSyntheticTrailingComments(node: Node): readonly SynthesizedComment[] | undefined { + return trailingComments.get(node); +} + +export function setSyntheticLeadingComments(node: T, comments: readonly SynthesizedComment[] | undefined): T { + if (comments) leadingComments.set(node, [...comments]); + else leadingComments.delete(node); + return node; +} + +export function setSyntheticTrailingComments(node: T, comments: readonly SynthesizedComment[] | undefined): T { + if (comments) trailingComments.set(node, [...comments]); + else trailingComments.delete(node); + return node; +} diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index b9ef5842d4489..80894b7906224 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -1,5 +1,7 @@ import { type __String, + addSyntheticLeadingComment, + addSyntheticTrailingComment, cast, escapeLeadingUnderscores, type Expression, @@ -45,6 +47,7 @@ import { import { cloneNode, createArrayTypeNode, + createBlock, createFunctionTypeNode, createIdentifier, createKeywordTypeNode, @@ -7937,6 +7940,51 @@ export const obj = { m: 1, s: "hi", b: true }; assert.strictEqual(text, "(x: string) => number"); }); + function createConstStatement(name: string, value: string) { + return createVariableStatement( + undefined, + createVariableDeclarationList( + [createVariableDeclaration(createIdentifier(name), undefined, undefined, createNumericLiteral(value, 0))], + NodeFlags.Const, + ), + ); + } + + test("printNode emits synthetic leading JSDoc comment", async () => { + await using api = spawnAPI(emitterFiles); + + const statement = addSyntheticLeadingComment(createConstStatement("x", "1"), SyntaxKind.MultiLineCommentTrivia, "*\n * Hello.\n ", true); + const text = await api.printer.printNode(statement); + assert.strictEqual(text.trimEnd(), "/**\n * Hello.\n */\nconst x = 1;"); + }); + + test("printNode emits synthetic leading and trailing comments of both kinds", async () => { + await using api = spawnAPI(emitterFiles); + + const statement = createConstStatement("y", "2"); + addSyntheticLeadingComment(statement, SyntaxKind.SingleLineCommentTrivia, " first", true); + addSyntheticLeadingComment(statement, SyntaxKind.MultiLineCommentTrivia, " second ", true); + addSyntheticTrailingComment(statement, SyntaxKind.SingleLineCommentTrivia, " after"); + const text = await api.printer.printNode(statement); + assert.strictEqual(text.trimEnd(), "// first\n/* second */\nconst y = 2; // after"); + }); + + test("printNode emits synthetic comments on nested nodes", async () => { + await using api = spawnAPI(emitterFiles); + + const inner = createConstStatement("a", "1"); + const outer = createBlock([inner, createConstStatement("b", "2")], true); + addSyntheticLeadingComment(inner, SyntaxKind.SingleLineCommentTrivia, " inner", true); + const text = await api.printer.printNode(outer); + assert.match(text, /\{\n\s+\/\/ inner\n\s+const a = 1;\n\s+const b = 2;\n\}/); + }); + + test("printNode without synthetic comments is unchanged", async () => { + await using api = spawnAPI(emitterFiles); + + assert.strictEqual((await api.printer.printNode(createConstStatement("z", "3"))).trimEnd(), "const z = 3;"); + }); + test("printNode with factory-created type reference", async () => { await using api = spawnAPI(emitterFiles); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index dbe34fb1b2d4b..67c8efdf4bd9a 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -8,6 +8,8 @@ // import { type __String, + addSyntheticLeadingComment, + addSyntheticTrailingComment, cast, escapeLeadingUnderscores, type Expression, @@ -53,6 +55,7 @@ import { import { cloneNode, createArrayTypeNode, + createBlock, createFunctionTypeNode, createIdentifier, createKeywordTypeNode, @@ -7738,6 +7741,51 @@ export const obj = { m: 1, s: "hi", b: true }; assert.strictEqual(text, "(x: string) => number"); }); + function createConstStatement(name: string, value: string) { + return createVariableStatement( + undefined, + createVariableDeclarationList( + [createVariableDeclaration(createIdentifier(name), undefined, undefined, createNumericLiteral(value, 0))], + NodeFlags.Const, + ), + ); + } + + test("printNode emits synthetic leading JSDoc comment", () => { + using api = spawnAPI(emitterFiles); + + const statement = addSyntheticLeadingComment(createConstStatement("x", "1"), SyntaxKind.MultiLineCommentTrivia, "*\n * Hello.\n ", true); + const text = api.printer.printNode(statement); + assert.strictEqual(text.trimEnd(), "/**\n * Hello.\n */\nconst x = 1;"); + }); + + test("printNode emits synthetic leading and trailing comments of both kinds", () => { + using api = spawnAPI(emitterFiles); + + const statement = createConstStatement("y", "2"); + addSyntheticLeadingComment(statement, SyntaxKind.SingleLineCommentTrivia, " first", true); + addSyntheticLeadingComment(statement, SyntaxKind.MultiLineCommentTrivia, " second ", true); + addSyntheticTrailingComment(statement, SyntaxKind.SingleLineCommentTrivia, " after"); + const text = api.printer.printNode(statement); + assert.strictEqual(text.trimEnd(), "// first\n/* second */\nconst y = 2; // after"); + }); + + test("printNode emits synthetic comments on nested nodes", () => { + using api = spawnAPI(emitterFiles); + + const inner = createConstStatement("a", "1"); + const outer = createBlock([inner, createConstStatement("b", "2")], true); + addSyntheticLeadingComment(inner, SyntaxKind.SingleLineCommentTrivia, " inner", true); + const text = api.printer.printNode(outer); + assert.match(text, /\{\n\s+\/\/ inner\n\s+const a = 1;\n\s+const b = 2;\n\}/); + }); + + test("printNode without synthetic comments is unchanged", () => { + using api = spawnAPI(emitterFiles); + + assert.strictEqual((api.printer.printNode(createConstStatement("z", "3"))).trimEnd(), "const z = 3;"); + }); + test("printNode with factory-created type reference", () => { using api = spawnAPI(emitterFiles); diff --git a/tsc/internal/api/encoder/decoder.go b/tsc/internal/api/encoder/decoder.go index 9deb4abe5f152..448c3c4fec95c 100644 --- a/tsc/internal/api/encoder/decoder.go +++ b/tsc/internal/api/encoder/decoder.go @@ -43,11 +43,22 @@ func DecodeSourceFile(data []byte) (*ast.SourceFile, error) { // DecodeNodes decodes binary-encoded AST data into a tree of *ast.Node objects. func DecodeNodes(data []byte) (*ast.Node, error) { + root, _, err := DecodeNodesIndexed(data) + return root, err +} + +// DecodeNodesIndexed decodes the root node and also returns every decoded node by its +// index in the encoded buffer. Indices that denote NodeLists or the nil node hold nil. +func DecodeNodesIndexed(data []byte) (*ast.Node, []*ast.Node, error) { d, err := newASTDecoder(data) if err != nil { - return nil, err + return nil, nil, err + } + root, err := d.decode() + if err != nil { + return nil, nil, err } - return d.decode() + return root, d.nodes, nil } func newASTDecoder(data []byte) (*astDecoder, error) { diff --git a/tsc/internal/api/printnode_comments_test.go b/tsc/internal/api/printnode_comments_test.go new file mode 100644 index 0000000000000..f55406374110a --- /dev/null +++ b/tsc/internal/api/printnode_comments_test.go @@ -0,0 +1,42 @@ +package api + +import ( + "errors" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/printer" + "gotest.tools/v3/assert" +) + +func TestApplyPrintNodeComments(t *testing.T) { + t.Parallel() + + f := ast.NewNodeFactory(ast.NodeFactoryHooks{}) + nodes := []*ast.Node{nil, f.NewIdentifier("x")} + + t.Run("valid", func(t *testing.T) { + t.Parallel() + emitContext := printer.NewEmitContext() + err := applyPrintNodeComments(emitContext, nodes, []PrintNodeComment{ + {Node: 1, Kind: int32(ast.KindMultiLineCommentTrivia), Text: " a "}, + {Node: 1, Trailing: true, Kind: int32(ast.KindSingleLineCommentTrivia), Text: " b"}, + }) + assert.NilError(t, err) + assert.Equal(t, len(emitContext.GetSyntheticLeadingComments(nodes[1])), 1) + assert.Equal(t, len(emitContext.GetSyntheticTrailingComments(nodes[1])), 1) + }) + + for name, comment := range map[string]PrintNodeComment{ + "nil node index": {Node: 0, Kind: int32(ast.KindSingleLineCommentTrivia)}, + "out of range": {Node: 5, Kind: int32(ast.KindSingleLineCommentTrivia)}, + "negative": {Node: -1, Kind: int32(ast.KindSingleLineCommentTrivia)}, + "invalid comment kind": {Node: 1, Kind: int32(ast.KindIdentifier)}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + err := applyPrintNodeComments(printer.NewEmitContext(), nodes, []PrintNodeComment{comment}) + assert.Assert(t, errors.Is(err, ErrClientError)) + }) + } +} diff --git a/tsc/internal/api/proto.go b/tsc/internal/api/proto.go index 8072445202b44..37161c7afead8 100644 --- a/tsc/internal/api/proto.go +++ b/tsc/internal/api/proto.go @@ -1778,6 +1778,17 @@ type PrintNodeParams struct { PreserveSourceNewlines bool `json:"preserveSourceNewlines,omitempty"` NeverAsciiEscape bool `json:"neverAsciiEscape,omitempty"` TerminateUnterminatedLiterals bool `json:"terminateUnterminatedLiterals,omitempty"` + // Comments are synthetic comments attached to nodes of the encoded AST. + Comments []PrintNodeComment `json:"comments,omitempty"` +} + +// PrintNodeComment is a synthetic comment attached to a node of the encoded AST. +type PrintNodeComment struct { + Node int `json:"node"` // index of the node in the encoded AST data + Trailing bool `json:"trailing,omitempty"` + Kind int32 `json:"kind"` // SingleLineCommentTrivia or MultiLineCommentTrivia + Text string `json:"text"` + HasTrailingNewLine bool `json:"hasTrailingNewLine,omitempty"` } type EmitParams struct { diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index 93d6ab53c303d..8d40822ea8a64 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -3944,37 +3944,62 @@ func (s *Session) handleTypeToString(ctx context.Context, params *TypeToTypeNode // handlePrintNode decodes a binary-encoded AST node and prints it to text. func (s *Session) handlePrintNode(_ context.Context, params *PrintNodeParams) (string, error) { - node, err := decodePrintNode(params.Data) + node, nodes, err := decodePrintNode(params.Data) if err != nil { return "", err } + emitContext := printer.NewEmitContext() + if err := applyPrintNodeComments(emitContext, nodes, params.Comments); err != nil { + return "", err + } var sourceFile *ast.SourceFile if ast.IsSourceFile(node) { sourceFile = node.AsSourceFile() } - return newPrinter(params).Emit(node, sourceFile), nil + return newPrinter(params, emitContext).Emit(node, sourceFile), nil +} + +func applyPrintNodeComments(emitContext *printer.EmitContext, nodes []*ast.Node, comments []PrintNodeComment) error { + for _, comment := range comments { + if comment.Node < 1 || comment.Node >= len(nodes) || nodes[comment.Node] == nil { + return fmt.Errorf("%w: comment refers to invalid node index %d", ErrClientError, comment.Node) + } + kind := ast.Kind(comment.Kind) + if kind != ast.KindSingleLineCommentTrivia && kind != ast.KindMultiLineCommentTrivia { + return fmt.Errorf("%w: invalid comment kind %d", ErrClientError, comment.Kind) + } + node := nodes[comment.Node] + if comment.Trailing { + emitContext.AddSyntheticTrailingComment(node, kind, comment.Text, comment.HasTrailingNewLine) + } else { + emitContext.AddSyntheticLeadingComment(node, kind, comment.Text, comment.HasTrailingNewLine) + } + } + return nil } -func decodePrintNode(encoded string) (*ast.Node, error) { +func decodePrintNode(encoded string) (*ast.Node, []*ast.Node, error) { data, err := base64.StdEncoding.DecodeString(encoded) if err != nil { - return nil, fmt.Errorf("%w: invalid base64 data: %w", ErrClientError, err) + return nil, nil, fmt.Errorf("%w: invalid base64 data: %w", ErrClientError, err) } - node, err := encoder.DecodeNodes(data) + node, nodes, err := encoder.DecodeNodesIndexed(data) if err != nil { - return nil, fmt.Errorf("%w: failed to decode AST: %w", ErrClientError, err) + return nil, nil, fmt.Errorf("%w: failed to decode AST: %w", ErrClientError, err) } - return node, nil + return node, nodes, nil } -func newPrinter(params *PrintNodeParams) *printer.Printer { +func newPrinter(params *PrintNodeParams, emitContext *printer.EmitContext) *printer.Printer { return printer.NewPrinter(printer.PrinterOptions{ PreserveSourceNewlines: params.PreserveSourceNewlines, NeverAsciiEscape: params.NeverAsciiEscape, TerminateUnterminatedLiterals: params.TerminateUnterminatedLiterals, - }, printer.PrintHandlers{}, nil) + + EmitSyntheticCommentsWithoutSourceFile: true, + }, printer.PrintHandlers{}, emitContext) } func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResponse, error) { diff --git a/tsc/internal/ast/precedence.go b/tsc/internal/ast/precedence.go index bfaaa679b63af..b99e9872005aa 100644 --- a/tsc/internal/ast/precedence.go +++ b/tsc/internal/ast/precedence.go @@ -658,6 +658,8 @@ func GetTypeNodePrecedence(n *TypeNode) TypePrecedence { return TypePrecedenceConditional case KindJSDocOptionalType, KindJSDocVariadicType: return TypePrecedenceJSDoc + case KindJSDocTypeExpression, KindJSDocNameReference, KindJSDocTypeLiteral, KindJSDocSignature: + return TypePrecedenceNonArray case KindFunctionType, KindConstructorType: return TypePrecedenceFunction case KindUnionType: diff --git a/tsc/internal/printer/printer.go b/tsc/internal/printer/printer.go index a27452d51472d..b181d1de1c336 100644 --- a/tsc/internal/printer/printer.go +++ b/tsc/internal/printer/printer.go @@ -46,7 +46,11 @@ type PrinterOptions struct { OmitBraceSourceMapPositions bool // ExtendedDiagnostics bool OnlyPrintJSDocStyle bool - NeverAsciiEscape bool + + // EmitSyntheticCommentsWithoutSourceFile emits synthetic comments even when no source file + // is being printed. Comments from original source text are still only emitted with a source file. + EmitSyntheticCommentsWithoutSourceFile bool + NeverAsciiEscape bool // StripInternal bool PreserveSourceNewlines bool TerminateUnterminatedLiterals bool // !!! @@ -2376,6 +2380,18 @@ func (p *Printer) emitTypeNode(node *ast.TypeNode, precedence ast.TypePrecedence case ast.KindJSDocAllType: p.emitJSDocAllType(node) + case ast.KindJSDocTypeExpression: + p.writePunctuation("{") + if typeNode := node.AsJSDocTypeExpression().Type; typeNode != nil { + p.emitTypeNode(typeNode, ast.TypePrecedenceJSDoc) + } + p.writePunctuation("}") + case ast.KindJSDocNameReference: + p.emitJSDocNameReference(node.AsJSDocNameReference()) + case ast.KindJSDocTypeLiteral: + p.emitJSDocTypeLiteral(node.AsJSDocTypeLiteral()) + case ast.KindJSDocSignature: + p.emitJSDocSignature(node.AsJSDocSignature(), true /*emitTypeParameters*/) case ast.KindJSDocNonNullableType: p.emitJSDocNonNullableType(node.AsJSDocNonNullableType()) case ast.KindJSDocNullableType: @@ -4601,8 +4617,523 @@ func (p *Printer) emitEnumMemberNode(node *ast.EnumMemberNode) { // func (p *Printer) emitJSDocNode(node *ast.Node) { - // !!! - panic("not implemented") + switch node.Kind { + case ast.KindJSDoc: + p.emitJSDoc(node.AsJSDoc()) + case ast.KindJSDocTypeExpression: + p.writePunctuation("{") + if typeNode := node.AsJSDocTypeExpression().Type; typeNode != nil { + p.emitTypeNode(typeNode, ast.TypePrecedenceJSDoc) + } + p.writePunctuation("}") + case ast.KindJSDocNameReference: + p.emitJSDocNameReference(node.AsJSDocNameReference()) + case ast.KindJSDocTypeLiteral: + p.emitJSDocTypeLiteral(node.AsJSDocTypeLiteral()) + case ast.KindJSDocSignature: + p.emitJSDocSignature(node.AsJSDocSignature(), true /*emitTypeParameters*/) + case ast.KindJSDocText: + p.write(node.Text()) + case ast.KindJSDocLink, ast.KindJSDocLinkCode, ast.KindJSDocLinkPlain: + var text strings.Builder + p.writeJSDocCommentNode(&text, node) + p.write(text.String()) + default: + if ast.IsJSDocTag(node) { + p.emitJSDocTag(node) + return + } + panic(fmt.Sprintf("unhandled JSDoc node: %v", node.Kind)) + } +} + +func (p *Printer) emitJSDoc(node *ast.JSDoc) { + hasComment := node.Comment != nil && len(node.Comment.Nodes) > 0 + hasTags := node.Tags != nil && len(node.Tags.Nodes) > 0 + var commentText strings.Builder + if hasComment { + for _, commentNode := range node.Comment.Nodes { + p.writeJSDocCommentNode(&commentText, commentNode) + } + } + text := commentText.String() + p.write("/**") + if !hasComment && !hasTags { + p.write(" */") + return + } + + p.writeLine() + if hasComment { + p.emitJSDocCommentLines(text) + p.writeLine() + } + if hasTags { + for _, tag := range node.Tags.Nodes { + p.write(" * ") + p.emitJSDocTag(tag) + p.writeLine() + } + } + p.write(" */") +} + +func (p *Printer) emitJSDocCommentLines(text string) { + for i, line := range strings.Split(text, "\n") { + if i > 0 { + p.writeLine() + } + p.write(" *") + line = strings.TrimSuffix(line, "\r") + p.writeSpace() + if line != "" { + p.write(line) + } + } +} + +func (p *Printer) emitJSDocNameReference(node *ast.JSDocNameReference) { + p.writeSpace() + p.writePunctuation("{") + p.emitEntityName(node.Name()) + p.writePunctuation("}") +} + +func (p *Printer) writeJSDocCommentNode(text *strings.Builder, node *ast.Node) { + switch node.Kind { + case ast.KindJSDocText: + text.WriteString(node.Text()) + case ast.KindJSDocLink, ast.KindJSDocLinkCode, ast.KindJSDocLinkPlain: + kind := "link" + var name *ast.Node + switch node.Kind { + case ast.KindJSDocLink: + name = node.AsJSDocLink().Name() + case ast.KindJSDocLinkCode: + kind = "linkcode" + name = node.AsJSDocLinkCode().Name() + case ast.KindJSDocLinkPlain: + kind = "linkplain" + name = node.AsJSDocLinkPlain().Name() + } + linkText := node.Text() + text.WriteString("{@") + text.WriteString(kind) + text.WriteByte(' ') + if name != nil { + text.WriteString(jsDocEntityNameText(name)) + if linkText != "" && !strings.HasPrefix(linkText, "://") { + text.WriteByte(' ') + } + } + text.WriteString(linkText) + text.WriteByte('}') + default: + panic(fmt.Sprintf("unhandled JSDoc comment node: %v", node.Kind)) + } +} + +func jsDocEntityNameText(node *ast.Node) string { + switch node.Kind { + case ast.KindIdentifier, ast.KindPrivateIdentifier: + return node.Text() + case ast.KindQualifiedName: + name := node.AsQualifiedName() + return jsDocEntityNameText(name.Left) + "." + jsDocEntityNameText(name.Right) + case ast.KindThisKeyword: + return "this" + default: + panic(fmt.Sprintf("unhandled JSDoc entity name: %v", node.Kind)) + } +} + +func (p *Printer) emitJSDocType(node *ast.Node) { + if node == nil { + return + } + p.writeSpace() + p.writePunctuation("{") + if typeLiteral := getJSDocTypeLiteral(node); typeLiteral != nil { + p.emitJSDocObjectType(typeLiteral) + } else { + typeNode := unwrapJSDocTypeExpression(node) + if typeNode != nil { + p.emitTypeNode(typeNode, ast.TypePrecedenceJSDoc) + } + } + p.writePunctuation("}") +} + +func (p *Printer) emitJSDocTag(node *ast.Node) { + tagName, comment := jsDocTagBase(node) + var signature *ast.JSDocSignature + var nestedTags *ast.JSDocTypeLiteral + if node.Kind == ast.KindJSDocCallbackTag { + if typeExpression := node.AsJSDocCallbackTag().TypeExpression; typeExpression != nil { + signature = typeExpression.AsJSDocSignature() + } + } else if node.Kind == ast.KindJSDocOverloadTag { + if typeExpression := node.AsJSDocOverloadTag().TypeExpression; typeExpression != nil { + signature = typeExpression.AsJSDocSignature() + } + } + if signature != nil && signature.TypeParameters != nil { + for i, typeParameter := range signature.TypeParameters.Nodes { + if i > 0 { + p.writeLine() + p.write(" * @template ") + } else { + p.write("@template ") + } + p.emitJSDocTemplateTypeParameter(typeParameter.AsTypeParameterDeclaration()) + } + p.writeLine() + p.write(" * ") + } + p.write("@") + if tagName != nil { + p.write(tagName.Text()) + } + + switch node.Kind { + case ast.KindJSDocParameterTag, ast.KindJSDocPropertyTag: + tag := node.AsJSDocParameterOrPropertyTag() + nestedTags = getJSDocTypeLiteral(tag.TypeExpression) + if !tag.IsNameFirst { + p.emitJSDocType(tag.TypeExpression) + } + if tag.Name() != nil { + p.writeSpace() + if tag.IsBracketed { + p.write("[") + } + p.emitEntityName(tag.Name()) + if tag.IsBracketed { + p.write("]") + } + } + if tag.IsNameFirst { + p.emitJSDocType(tag.TypeExpression) + } + case ast.KindJSDocReturnTag: + p.emitJSDocType(node.AsJSDocReturnTag().TypeExpression) + case ast.KindJSDocThisTag: + p.emitJSDocType(node.AsJSDocThisTag().TypeExpression) + case ast.KindJSDocThrowsTag: + p.emitJSDocType(node.AsJSDocThrowsTag().TypeExpression) + case ast.KindJSDocSatisfiesTag: + p.emitJSDocType(node.AsJSDocSatisfiesTag().TypeExpression) + case ast.KindJSDocTypeTag: + p.emitJSDocType(node.AsJSDocTypeTag().TypeExpression) + case ast.KindJSDocTemplateTag: + tag := node.AsJSDocTemplateTag() + if tag.Constraint != nil { + p.emitJSDocType(tag.Constraint) + } + if tag.TypeParameters != nil { + p.writeSpace() + for i, typeParameter := range tag.TypeParameters.Nodes { + if i > 0 { + p.writePunctuation(",") + p.writeSpace() + } + p.emitJSDocTemplateTypeParameter(typeParameter.AsTypeParameterDeclaration()) + } + } + case ast.KindJSDocSeeTag: + tag := node.AsJSDocSeeTag() + if tag.NameExpression != nil { + if tag.NameExpression.Kind == ast.KindJSDocNameReference { + p.emitJSDocNameReference(tag.NameExpression.AsJSDocNameReference()) + } else { + p.writeSpace() + p.emitTypeNode(tag.NameExpression, ast.TypePrecedenceJSDoc) + } + } + case ast.KindJSDocImplementsTag: + tag := node.AsJSDocImplementsTag() + if tag.ClassName != nil { + p.writeSpace() + p.writePunctuation("{") + p.emitExpressionWithTypeArguments(tag.ClassName.AsExpressionWithTypeArguments()) + p.writePunctuation("}") + } + case ast.KindJSDocAugmentsTag: + tag := node.AsJSDocAugmentsTag() + if tag.ClassName != nil { + p.writeSpace() + p.writePunctuation("{") + p.emitExpressionWithTypeArguments(tag.ClassName.AsExpressionWithTypeArguments()) + p.writePunctuation("}") + } + case ast.KindJSDocCallbackTag: + tag := node.AsJSDocCallbackTag() + if tag.Name() != nil { + p.writeSpace() + p.emitJSDocFullName(tag.Name()) + } + case ast.KindJSDocTypedefTag: + tag := node.AsJSDocTypedefTag() + nestedTags = getJSDocTypeLiteral(tag.TypeExpression) + p.emitJSDocType(tag.TypeExpression) + if tag.Name() != nil { + p.writeSpace() + p.emitJSDocFullName(tag.Name()) + } + case ast.KindJSDocImportTag: + tag := node.AsJSDocImportTag() + if tag.ImportClause != nil { + p.writeSpace() + importClause := tag.ImportClause.AsImportClause() + if name := importClause.Name(); name != nil { + p.emitBindingIdentifier(name.AsIdentifier()) + if importClause.NamedBindings != nil { + p.writePunctuation(",") + p.writeSpace() + } + } + p.emitNamedImportBindings(importClause.NamedBindings) + p.writeSpace() + p.write("from") + } + if tag.ModuleSpecifier != nil { + p.writeSpace() + p.emitExpression(tag.ModuleSpecifier, ast.OperatorPrecedenceLowest) + } + if tag.Attributes != nil { + p.writeSpace() + p.emitImportAttributes(tag.Attributes.AsImportAttributes()) + } + } + + if comment != nil && len(comment.Nodes) > 0 { + var text strings.Builder + for _, commentNode := range comment.Nodes { + p.writeJSDocCommentNode(&text, commentNode) + } + lines := strings.Split(text.String(), "\n") + if len(lines) > 0 { + if lines[0] != "" { + p.writeSpace() + p.write(strings.TrimSuffix(lines[0], "\r")) + } + for _, line := range lines[1:] { + p.writeLine() + p.write(" * ") + p.write(strings.TrimSuffix(line, "\r")) + } + } + } + if nestedTags != nil { + p.emitJSDocTypeLiteralTags(nestedTags) + } + if signature != nil { + p.emitJSDocSignature(signature, false /*emitTypeParameters*/) + } +} + +func getJSDocTypeLiteral(node *ast.Node) *ast.JSDocTypeLiteral { + if node == nil { + return nil + } + if node.Kind == ast.KindJSDocTypeExpression { + node = node.AsJSDocTypeExpression().Type + } + if node != nil && node.Kind == ast.KindJSDocTypeLiteral { + return node.AsJSDocTypeLiteral() + } + return nil +} + +func (p *Printer) emitJSDocTypeLiteralTags(node *ast.JSDocTypeLiteral) { + for _, tag := range node.JSDocPropertyTags { + p.writeLine() + p.write(" * ") + p.emitJSDocTag(tag) + } +} + +func (p *Printer) emitJSDocObjectType(node *ast.JSDocTypeLiteral) { + p.write("Object") + if node.IsArrayType { + p.writePunctuation("[") + p.writePunctuation("]") + } +} + +func (p *Printer) emitJSDocPropertyName(name *ast.EntityName) { + if ast.IsIdentifier(name) { + p.emitEntityName(name) + return + } + p.emitEntityName(name.AsQualifiedName().Right) +} + +func (p *Printer) emitJSDocTypeLiteral(node *ast.JSDocTypeLiteral) { + p.writePunctuation("{") + if len(node.JSDocPropertyTags) > 0 { + p.writeSpace() + for i, property := range node.JSDocPropertyTags { + if i > 0 { + p.writePunctuation(",") + p.writeSpace() + } + tag := property.AsJSDocParameterOrPropertyTag() + if tag.Name() != nil { + p.emitJSDocPropertyName(tag.Name()) + typeNode := unwrapJSDocTypeExpression(tag.TypeExpression) + if tag.IsBracketed || typeNode != nil && typeNode.Kind == ast.KindJSDocOptionalType { + p.writePunctuation("?") + } + if typeNode != nil && typeNode.Kind == ast.KindJSDocOptionalType { + typeNode = typeNode.AsJSDocOptionalType().Type + } + } + typeNode := unwrapJSDocTypeExpression(tag.TypeExpression) + if typeNode != nil && typeNode.Kind == ast.KindJSDocOptionalType { + typeNode = typeNode.AsJSDocOptionalType().Type + } + if typeNode != nil { + p.writePunctuation(":") + p.writeSpace() + p.emitTypeNode(typeNode, ast.TypePrecedenceJSDoc) + } + } + p.writeSpace() + } + p.writePunctuation("}") + if node.IsArrayType { + p.writePunctuation("[") + p.writePunctuation("]") + } +} + +func unwrapJSDocTypeExpression(node *ast.Node) *ast.Node { + if node != nil && node.Kind == ast.KindJSDocTypeExpression { + return node.AsJSDocTypeExpression().Type + } + return node +} + +func (p *Printer) emitJSDocTemplateTypeParameter(typeParameter *ast.TypeParameterDeclaration) { + if typeParameter.DefaultType != nil { + p.writePunctuation("[") + } + p.emitModifierList(typeParameter.AsNode(), typeParameter.Modifiers(), false /*allowDecorators*/) + p.emitBindingIdentifier(typeParameter.Name().AsIdentifier()) + if typeParameter.DefaultType != nil { + p.writePunctuation("=") + p.emitTypeNode(typeParameter.DefaultType, ast.TypePrecedenceJSDoc) + p.writePunctuation("]") + } +} + +func (p *Printer) emitJSDocSignature(signature *ast.JSDocSignature, emitTypeParameters bool) { + if emitTypeParameters && signature.TypeParameters != nil { + for _, typeParameter := range signature.TypeParameters.Nodes { + p.writeLine() + p.write(" * @template ") + p.emitJSDocTemplateTypeParameter(typeParameter.AsTypeParameterDeclaration()) + } + } + if signature.Parameters != nil { + for _, parameter := range signature.Parameters.Nodes { + p.writeLine() + p.write(" * ") + p.emitJSDocTag(parameter) + } + } + if signature.Type != nil { + p.writeLine() + p.write(" * ") + p.emitJSDocTag(signature.Type.AsNode()) + } +} + +func (p *Printer) emitJSDocFullName(node *ast.JSDocFullName) { + switch node.Kind { + case ast.KindIdentifier: + p.emitIdentifierName(node.AsIdentifier()) + case ast.KindModuleDeclaration: + declaration := node.AsModuleDeclaration() + p.emitJSDocFullName(declaration.Name()) + if declaration.Body != nil && declaration.Body.Kind != ast.KindModuleBlock { + p.writePunctuation(".") + p.emitJSDocFullName(declaration.Body) + } + default: + panic(fmt.Sprintf("unhandled JSDoc full name: %v", node.Kind)) + } +} + +func jsDocTagBase(node *ast.Node) (*ast.IdentifierNode, *ast.NodeList) { + switch node.Kind { + case ast.KindJSDocTypeTag: + tag := node.AsJSDocTypeTag() + return tag.TagName, tag.Comment + case ast.KindJSDocUnknownTag: + tag := node.AsJSDocUnknownTag() + return tag.TagName, tag.Comment + case ast.KindJSDocTemplateTag: + tag := node.AsJSDocTemplateTag() + return tag.TagName, tag.Comment + case ast.KindJSDocReturnTag: + tag := node.AsJSDocReturnTag() + return tag.TagName, tag.Comment + case ast.KindJSDocPublicTag: + tag := node.AsJSDocPublicTag() + return tag.TagName, tag.Comment + case ast.KindJSDocPrivateTag: + tag := node.AsJSDocPrivateTag() + return tag.TagName, tag.Comment + case ast.KindJSDocProtectedTag: + tag := node.AsJSDocProtectedTag() + return tag.TagName, tag.Comment + case ast.KindJSDocReadonlyTag: + tag := node.AsJSDocReadonlyTag() + return tag.TagName, tag.Comment + case ast.KindJSDocOverrideTag: + tag := node.AsJSDocOverrideTag() + return tag.TagName, tag.Comment + case ast.KindJSDocDeprecatedTag: + tag := node.AsJSDocDeprecatedTag() + return tag.TagName, tag.Comment + case ast.KindJSDocSeeTag: + tag := node.AsJSDocSeeTag() + return tag.TagName, tag.Comment + case ast.KindJSDocImplementsTag: + tag := node.AsJSDocImplementsTag() + return tag.TagName, tag.Comment + case ast.KindJSDocAugmentsTag: + tag := node.AsJSDocAugmentsTag() + return tag.TagName, tag.Comment + case ast.KindJSDocSatisfiesTag: + tag := node.AsJSDocSatisfiesTag() + return tag.TagName, tag.Comment + case ast.KindJSDocThrowsTag: + tag := node.AsJSDocThrowsTag() + return tag.TagName, tag.Comment + case ast.KindJSDocThisTag: + tag := node.AsJSDocThisTag() + return tag.TagName, tag.Comment + case ast.KindJSDocImportTag: + tag := node.AsJSDocImportTag() + return tag.TagName, tag.Comment + case ast.KindJSDocCallbackTag: + tag := node.AsJSDocCallbackTag() + return tag.TagName, tag.Comment + case ast.KindJSDocOverloadTag: + tag := node.AsJSDocOverloadTag() + return tag.TagName, tag.Comment + case ast.KindJSDocTypedefTag: + tag := node.AsJSDocTypedefTag() + return tag.TagName, tag.Comment + case ast.KindJSDocParameterTag, ast.KindJSDocPropertyTag: + tag := node.AsJSDocParameterOrPropertyTag() + return tag.TagName, tag.Comment + default: + panic(fmt.Sprintf("unhandled JSDoc tag: %v", node.Kind)) + } } // @@ -5288,7 +5819,19 @@ func (p *Printer) Write(node *ast.Node, sourceFile *ast.SourceFile, writer EmitT // Comments // +func (p *Printer) shouldEmitSyntheticCommentsOnly(node *ast.Node) bool { + return p.Options.EmitSyntheticCommentsWithoutSourceFile && + !p.commentsDisabled && + p.currentSourceFile == nil && + !ast.IsSourceFile(node) +} + func (p *Printer) emitCommentsBeforeNode(node *ast.Node) *commentState { + if p.shouldEmitSyntheticCommentsOnly(node) { + emitFlags := p.emitContext.EmitFlags(node) + p.emitLeadingSyntheticCommentsOfNode(node, emitFlags) + return &commentState{emitFlags: emitFlags} + } if !p.shouldEmitComments(node) { return nil } @@ -5316,6 +5859,11 @@ func (p *Printer) emitCommentsAfterNode(node *ast.Node, state *commentState) { return } + if p.currentSourceFile == nil { + p.emitTrailingSyntheticCommentsOfNode(node, state.emitFlags) + return + } + emitFlags := state.emitFlags commentRange := state.commentRange containerPos := state.containerPos diff --git a/tsc/internal/printer/printer_test.go b/tsc/internal/printer/printer_test.go index 867ef150d491c..387eee1569315 100644 --- a/tsc/internal/printer/printer_test.go +++ b/tsc/internal/printer/printer_test.go @@ -11,6 +11,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/testutil/parsetestutil" "github.com/microsoft/TypeScript/tsc/internal/transformers" "github.com/microsoft/TypeScript/tsc/internal/transformers/tstransforms" + "gotest.tools/v3/assert" ) func TestEmit(t *testing.T) { @@ -590,6 +591,444 @@ func TestEmit(t *testing.T) { } } +func TestEmitJSDoc(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + doc := factory.NewJSDoc( + factory.NewNodeList([]*ast.Node{ + factory.NewJSDocText([]string{"This is a function description"}), + }), + factory.NewNodeList([]*ast.Node{ + factory.NewJSDocParameterOrPropertyTag( + ast.KindJSDocParameterTag, + factory.NewIdentifier("param"), + factory.NewIdentifier("foo"), + false, /*isBracketed*/ + nil, /*typeExpression*/ + false, /*isNameFirst*/ + factory.NewNodeList([]*ast.Node{ + factory.NewJSDocText([]string{"The foo parameter"}), + }), + ), + }), + ) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(doc, nil) + expected := "/**\n * This is a function description\n * @param foo The foo parameter\n */" + if actual != expected { + t.Errorf("unexpected JSDoc output:\n got: %q\nwant: %q", actual, expected) + } + + file := parsetestutil.ParseTypeScript("/** function description */\nfunction f() {}", false) + emittestutil.CheckEmit(t, nil, file, "/** function description */\nfunction f() { }") +} + +func TestEmitJSDocSingleLine(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + node := factory.NewJSDoc( + factory.NewNodeList([]*ast.Node{ + factory.NewJSDocText([]string{"A short description"}), + }), + nil, /*tags*/ + ) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(node, nil) + expected := "/**\n * A short description\n */" + if actual != expected { + t.Errorf("unexpected single-line JSDoc output: %q", actual) + } +} + +func TestEmitJSDocTypeExpression(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + node := factory.NewJSDocTypeExpression(factory.NewKeywordTypeNode(ast.KindNumberKeyword)) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(node, nil) + if actual != "{number}" { + t.Errorf("unexpected JSDoc type output: %q", actual) + } +} + +func TestEmitJSDocParameterType(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + node := factory.NewJSDocParameterOrPropertyTag( + ast.KindJSDocParameterTag, + factory.NewIdentifier("param"), + factory.NewIdentifier("foo"), + false, /*isBracketed*/ + factory.NewKeywordTypeNode(ast.KindNumberKeyword), + false, /*isNameFirst*/ + nil, /*comment*/ + ) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(node, nil) + if actual != "@param {number} foo" { + t.Errorf("unexpected JSDoc parameter output: %q", actual) + } +} + +func TestEmitJSDocLink(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + node := factory.NewJSDoc( + factory.NewNodeList([]*ast.Node{ + factory.NewJSDocText([]string{"See "}), + factory.NewJSDocLink(factory.NewIdentifier("Foo"), []string{"the docs"}), + }), + nil, /*tags*/ + ) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(node, nil) + expected := "/**\n * See {@link Foo the docs}\n */" + if actual != expected { + t.Errorf("unexpected JSDoc link output:\n got: %q\nwant: %q", actual, expected) + } +} + +func TestEmitJSDocReviewRegressions(t *testing.T) { + t.Parallel() + + data := []struct { + name string + input string + output string + }{ + { + name: "ObjectTypedefProperties", + input: `/** + * @typedef {Object} Options + * @property {number} count Number of items; see {@link Item} + */ +const options = {};`, + output: `/** + * @typedef {Object} Options + * @property {number} count Number of items; see {@link Item} + */ +const options = {};`, + }, + { + name: "NestedParameterProperties", + input: `/** + * @param {Object} options The options + * @param {number} options.count Number of items + */ +function f(options) {}`, + output: `/** + * @param {Object} options The options + * @param {number} options.count Number of items + */ +function f(options) { }`, + }, + { + name: "CallbackSignature", + input: `/** + * @callback Handler + * @param {string} value The value + * @returns {number} The result + */ +const handler = () => 0;`, + output: `/** + * @callback Handler + * @param {string} value The value + * @returns {number} The result + */ +const handler = () => 0;`, + }, + { + name: "TemplateModifiersAndDefaults", + input: `/** + * @template const T + * @template [U=string] + */ +function f() {}`, + output: `/** + * @template const T + * @template [U=string] + */ +function f() { }`, + }, + { + name: "ImportAndQualifiedTypedef", + input: `/** + * @import { Foo } from "pkg" + * @typedef {Object} Namespace.Foo + * @see {Foo} + */ +const value = 0;`, + output: `/** + * @import { Foo } from "pkg" + * @typedef {Object} Namespace.Foo + * @see {Foo} + */ +const value = 0;`, + }, + } + + for _, rec := range data { + t.Run(rec.name, func(t *testing.T) { + t.Parallel() + file := parsetestutil.ParseTypeScript(rec.input, false) + emittestutil.CheckEmit(t, nil, file, rec.output) + }) + } +} + +func TestEmitJSDocTypedefRecord(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + numberType := factory.NewJSDocTypeExpression(factory.NewKeywordTypeNode(ast.KindNumberKeyword)) + nestedProperty := factory.NewJSDocParameterOrPropertyTag( + ast.KindJSDocPropertyTag, + factory.NewIdentifier("property"), + factory.NewQualifiedName(factory.NewIdentifier("options"), factory.NewIdentifier("count")), + true, /*isBracketed*/ + numberType, + false, /*isNameFirst*/ + factory.NewNodeList([]*ast.Node{ + factory.NewJSDocText([]string{"Number of items"}), + factory.NewJSDocText([]string{"; see "}), + factory.NewJSDocLink(factory.NewIdentifier("Item"), nil), + }), + ) + nestedType := factory.NewJSDocTypeLiteral([]*ast.Node{nestedProperty}, false) + optionsProperty := factory.NewJSDocParameterOrPropertyTag( + ast.KindJSDocPropertyTag, + factory.NewIdentifier("property"), + factory.NewIdentifier("options"), + false, /*isBracketed*/ + factory.NewJSDocTypeExpression(nestedType), + false, /*isNameFirst*/ + nil, /*comment*/ + ) + recordType := factory.NewJSDocTypeLiteral([]*ast.Node{optionsProperty}, false) + typedef := factory.NewJSDocTypedefTag( + factory.NewIdentifier("typedef"), + recordType, + factory.NewIdentifier("Options"), + nil, /*comment*/ + ) + doc := factory.NewJSDoc(nil, factory.NewNodeList([]*ast.Node{typedef})) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(doc, nil) + expected := "/**\n * @typedef {Object} Options\n * @property {Object} options\n * @property {number} [options.count] Number of items; see {@link Item}\n */" + if actual != expected { + t.Errorf("unexpected JSDoc typedef record:\n got: %q\nwant: %q", actual, expected) + } +} + +func TestEmitJSDocOptionalTypeLiteralProperty(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + property := factory.NewJSDocParameterOrPropertyTag( + ast.KindJSDocPropertyTag, + factory.NewIdentifier("property"), + factory.NewIdentifier("count"), + false, /*isBracketed*/ + factory.NewJSDocTypeExpression(factory.NewJSDocOptionalType(factory.NewKeywordTypeNode(ast.KindNumberKeyword))), + false, /*isNameFirst*/ + nil, /*comment*/ + ) + node := factory.NewJSDocTypeLiteral([]*ast.Node{property}, false) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(node, nil) + if actual != "{ count?: number }" { + t.Errorf("unexpected optional JSDoc property output: %q", actual) + } +} + +func TestEmitJSDocTagCommentWithLeadingNewline(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + tag := factory.NewJSDocUnknownTag( + factory.NewIdentifier("example"), + factory.NewNodeList([]*ast.Node{ + factory.NewJSDocText([]string{"\nconsole.log(1)"}), + }), + ) + doc := factory.NewJSDoc(nil, factory.NewNodeList([]*ast.Node{tag})) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(doc, nil) + expected := "/**\n * @example\n * console.log(1)\n */" + if actual != expected { + t.Errorf("unexpected multiline JSDoc tag comment:\n got: %q\nwant: %q", actual, expected) + } +} + +func TestEmitJSDocGenericCallbackAndOverload(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + typeParameter := factory.NewTypeParameterDeclaration( + nil, /*modifiers*/ + factory.NewIdentifier("T"), + nil, /*constraint*/ + nil, /*expression*/ + nil, /*defaultType*/ + ) + parameter := factory.NewJSDocParameterOrPropertyTag( + ast.KindJSDocParameterTag, + factory.NewIdentifier("param"), + factory.NewIdentifier("value"), + false, /*isBracketed*/ + factory.NewJSDocTypeExpression(factory.NewTypeReferenceNode(factory.NewIdentifier("T"), nil)), + false, /*isNameFirst*/ + nil, /*comment*/ + ) + signature := factory.NewJSDocSignature( + factory.NewNodeList([]*ast.Node{typeParameter}), + factory.NewNodeList([]*ast.Node{parameter}), + nil, /*type*/ + ) + nodes := []struct { + name string + tag *ast.Node + want string + }{ + { + name: "callback", + tag: factory.NewJSDocCallbackTag( + factory.NewIdentifier("callback"), + signature, + factory.NewIdentifier("Handler"), + nil, /*comment*/ + ), + want: "/**\n * @template T\n * @callback Handler\n * @param {T} value\n */", + }, + { + name: "overload", + tag: factory.NewJSDocOverloadTag( + factory.NewIdentifier("overload"), + signature, + nil, /*comment*/ + ), + want: "/**\n * @template T\n * @overload\n * @param {T} value\n */", + }, + } + + for _, test := range nodes { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + doc := factory.NewJSDoc(nil, factory.NewNodeList([]*ast.Node{test.tag})) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(doc, nil) + if actual != test.want { + t.Fatalf("unexpected generic JSDoc %s:\n got: %q\nwant: %q", test.name, actual, test.want) + } + file := parsetestutil.ParseTypeScript(actual, false) + parsetestutil.CheckDiagnostics(t, file) + }) + } +} + +func TestEmitJSDocQualifiedTypedefName(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + fullName := factory.NewModuleDeclaration( + nil, /*modifiers*/ + ast.KindNamespaceKeyword, + factory.NewIdentifier("Namespace"), + nil, /*attributes*/ + factory.NewIdentifier("Foo"), + ) + tag := factory.NewJSDocTypedefTag( + factory.NewIdentifier("typedef"), + factory.NewJSDocTypeExpression(factory.NewKeywordTypeNode(ast.KindNumberKeyword)), + fullName, + nil, /*comment*/ + ) + doc := factory.NewJSDoc(nil, factory.NewNodeList([]*ast.Node{tag})) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(doc, nil) + if actual != "/**\n * @typedef {number} Namespace.Foo\n */" { + t.Errorf("unexpected qualified JSDoc typedef name: %q", actual) + } +} + +func TestEmitJSDocNameReference(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + node := factory.NewJSDocNameReference(factory.NewIdentifier("Options")) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(node, nil) + if actual != " {Options}" { + t.Errorf("unexpected JSDoc name reference output: %q", actual) + } +} + +func TestEmitJSDocPreservesTextWhitespace(t *testing.T) { + t.Parallel() + + var factory ast.NodeFactory + node := factory.NewJSDoc( + factory.NewNodeList([]*ast.Node{ + factory.NewJSDocText([]string{" first line\n\n indented line\r"}), + }), + factory.NewNodeList([]*ast.Node{ + factory.NewJSDocUnknownTag( + factory.NewIdentifier("example"), + factory.NewNodeList([]*ast.Node{ + factory.NewJSDocText([]string{" tag text\n indented tag\r"}), + }), + ), + }), + ) + actual := printer.NewPrinter( + printer.PrinterOptions{NewLine: core.NewLineKindLF}, + printer.PrintHandlers{}, + nil, + ).Emit(node, nil) + expected := "/**\n * first line\n * \n * indented line\n * @example tag text\n * indented tag\n */" + if actual != expected { + t.Errorf("unexpected JSDoc whitespace:\n got: %q\nwant: %q", actual, expected) + } +} + func TestParenthesizeDecorator(t *testing.T) { t.Parallel() @@ -2622,3 +3061,38 @@ func TestOmitTrailingSemicolon(t *testing.T) { t.Fatalf("omit EmitSourceFile(for) = %q, want %q", got, "for (;;) { }") } } + +func TestSyntheticCommentsWithoutSourceFile(t *testing.T) { + t.Parallel() + + newStatement := func(emitContext *printer.EmitContext) *ast.Node { + f := emitContext.Factory.AsNodeFactory() + return f.NewExpressionStatement(f.NewIdentifier("x")) + } + emit := func(emitContext *printer.EmitContext, node *ast.Node, enabled bool) string { + p := printer.NewPrinter(printer.PrinterOptions{EmitSyntheticCommentsWithoutSourceFile: enabled}, printer.PrintHandlers{}, emitContext) + return strings.TrimRight(p.Emit(node, nil), "\n") + } + + t.Run("leading and trailing", func(t *testing.T) { + t.Parallel() + emitContext := printer.NewEmitContext() + node := newStatement(emitContext) + emitContext.AddSyntheticLeadingComment(node, ast.KindMultiLineCommentTrivia, "*\n * Doc.\n ", true) + emitContext.AddSyntheticLeadingComment(node, ast.KindSingleLineCommentTrivia, " lead", true) + emitContext.AddSyntheticTrailingComment(node, ast.KindSingleLineCommentTrivia, " trail", false) + assert.Equal(t, emit(emitContext, node, true), "/**\n * Doc.\n */\n// lead\nx; // trail") + }) + t.Run("disabled by default", func(t *testing.T) { + t.Parallel() + emitContext := printer.NewEmitContext() + node := newStatement(emitContext) + emitContext.AddSyntheticLeadingComment(node, ast.KindSingleLineCommentTrivia, " lead", true) + assert.Equal(t, emit(emitContext, node, false), "x;") + }) + t.Run("no comments", func(t *testing.T) { + t.Parallel() + emitContext := printer.NewEmitContext() + assert.Equal(t, emit(emitContext, newStatement(emitContext), true), "x;") + }) +}