From 2994cd13491e99a4c03a303fdb6ce8003b9efede Mon Sep 17 00:00:00 2001 From: el-j Date: Fri, 30 Oct 2026 16:38:41 +0100 Subject: [PATCH 1/4] feat(printer): implement JSDoc node emission in AST printer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tsc/internal/printer/printer.go | 388 ++++++++++++++++++++++++++- tsc/internal/printer/printer_test.go | 116 ++++++++ 2 files changed, 502 insertions(+), 2 deletions(-) diff --git a/tsc/internal/printer/printer.go b/tsc/internal/printer/printer.go index a27452d51472d..3f86442305001 100644 --- a/tsc/internal/printer/printer.go +++ b/tsc/internal/printer/printer.go @@ -2376,6 +2376,19 @@ 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.emitEntityName(node.AsJSDocNameReference().Name()) + case ast.KindJSDocTypeLiteral: + p.emitJSDocTypeLiteral(node.AsJSDocTypeLiteral()) + case ast.KindJSDocSignature: + p.write("function") + p.emitSignature(node) case ast.KindJSDocNonNullableType: p.emitJSDocNonNullableType(node.AsJSDocNonNullableType()) case ast.KindJSDocNullableType: @@ -4601,8 +4614,379 @@ 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.emitEntityName(node.AsJSDocNameReference().Name()) + case ast.KindJSDocTypeLiteral: + p.emitJSDocTypeLiteral(node.AsJSDocTypeLiteral()) + case ast.KindJSDocSignature: + p.write("function") + p.emitSignature(node) + 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 && len(node.Comment.Nodes) == 1 && text != "" && + len(text) <= 80 && !strings.Contains(text, "\n") { + p.writeSpace() + p.write(strings.TrimSpace(text)) + p.writeSpace() + p.write("*/") + return + } + 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(" *") + if line = strings.TrimSpace(line); line != "" { + p.writeSpace() + p.write(line) + } + } +} + +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 node.Kind == ast.KindJSDocTypeExpression { + if typeNode := node.AsJSDocTypeExpression().Type; typeNode != nil { + p.emitTypeNode(typeNode, ast.TypePrecedenceJSDoc) + } + } else { + p.emitTypeNode(node, ast.TypePrecedenceJSDoc) + } + p.writePunctuation("}") +} + +func (p *Printer) emitJSDocTag(node *ast.Node) { + tagName, comment := jsDocTagBase(node) + p.write("@") + if tagName != nil { + p.write(tagName.Text()) + } + + switch node.Kind { + case ast.KindJSDocParameterTag, ast.KindJSDocPropertyTag: + tag := node.AsJSDocParameterOrPropertyTag() + 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.emitEntityName(typeParameter.AsTypeParameterDeclaration().Name()) + } + } + case ast.KindJSDocSeeTag: + tag := node.AsJSDocSeeTag() + if tag.NameExpression != nil { + p.writeSpace() + p.emitTypeNode(tag.NameExpression, ast.TypePrecedenceJSDoc) + } + case ast.KindJSDocImplementsTag: + tag := node.AsJSDocImplementsTag() + if tag.ClassName != nil { + p.writeSpace() + p.emitExpressionWithTypeArguments(tag.ClassName.AsExpressionWithTypeArguments()) + } + case ast.KindJSDocAugmentsTag: + tag := node.AsJSDocAugmentsTag() + if tag.ClassName != nil { + p.writeSpace() + p.emitExpressionWithTypeArguments(tag.ClassName.AsExpressionWithTypeArguments()) + } + case ast.KindJSDocCallbackTag: + tag := node.AsJSDocCallbackTag() + p.emitJSDocType(tag.TypeExpression) + if tag.Name() != nil { + p.writeSpace() + p.emitJSDocFullName(tag.Name()) + } + case ast.KindJSDocOverloadTag: + p.emitJSDocType(node.AsJSDocOverloadTag().TypeExpression) + case ast.KindJSDocTypedefTag: + tag := node.AsJSDocTypedefTag() + 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() + p.emitImportClause(tag.ImportClause.AsImportClause()) + } + 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(strings.TrimSpace(text.String()), "\n") + if len(lines) > 0 && lines[0] != "" { + p.writeSpace() + p.write(strings.TrimSpace(lines[0])) + for _, line := range lines[1:] { + p.writeLine() + p.write(" * ") + p.write(strings.TrimSpace(line)) + } + } + } +} + +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.emitEntityName(tag.Name()) + } + if tag.TypeExpression != nil { + p.writePunctuation(":") + p.writeSpace() + p.emitTypeNode(tag.TypeExpression, ast.TypePrecedenceJSDoc) + } + } + p.writeSpace() + } + p.writePunctuation("}") + if node.IsArrayType { + p.writePunctuation("[") + p.writePunctuation("]") + } +} + +func (p *Printer) emitJSDocFullName(node *ast.JSDocFullName) { + switch node.Kind { + case ast.KindIdentifier: + p.emitIdentifierName(node.AsIdentifier()) + case ast.KindModuleDeclaration: + p.emitModuleDeclaration(node.AsModuleDeclaration()) + 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)) + } } // diff --git a/tsc/internal/printer/printer_test.go b/tsc/internal/printer/printer_test.go index 867ef150d491c..c05deab451a63 100644 --- a/tsc/internal/printer/printer_test.go +++ b/tsc/internal/printer/printer_test.go @@ -590,6 +590,122 @@ 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) + if actual != "/** A short description */" { + 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 TestParenthesizeDecorator(t *testing.T) { t.Parallel() From f93deeb62c29a1088e26e932c47296e41b39bc82 Mon Sep 17 00:00:00 2001 From: el-j Date: Fri, 30 Oct 2026 17:03:56 +0100 Subject: [PATCH 2/4] fix(printer): address JSDoc printer review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tsc/internal/ast/precedence.go | 2 + tsc/internal/printer/printer.go | 129 +++++++++++++++++---- tsc/internal/printer/printer_test.go | 161 +++++++++++++++++++++++++++ 3 files changed, 272 insertions(+), 20 deletions(-) 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 3f86442305001..edb084baddeeb 100644 --- a/tsc/internal/printer/printer.go +++ b/tsc/internal/printer/printer.go @@ -2383,12 +2383,11 @@ func (p *Printer) emitTypeNode(node *ast.TypeNode, precedence ast.TypePrecedence } p.writePunctuation("}") case ast.KindJSDocNameReference: - p.emitEntityName(node.AsJSDocNameReference().Name()) + p.emitJSDocNameReference(node.AsJSDocNameReference()) case ast.KindJSDocTypeLiteral: p.emitJSDocTypeLiteral(node.AsJSDocTypeLiteral()) case ast.KindJSDocSignature: - p.write("function") - p.emitSignature(node) + p.emitJSDocSignature(node.AsJSDocSignature()) case ast.KindJSDocNonNullableType: p.emitJSDocNonNullableType(node.AsJSDocNonNullableType()) case ast.KindJSDocNullableType: @@ -4624,12 +4623,11 @@ func (p *Printer) emitJSDocNode(node *ast.Node) { } p.writePunctuation("}") case ast.KindJSDocNameReference: - p.emitEntityName(node.AsJSDocNameReference().Name()) + p.emitJSDocNameReference(node.AsJSDocNameReference()) case ast.KindJSDocTypeLiteral: p.emitJSDocTypeLiteral(node.AsJSDocTypeLiteral()) case ast.KindJSDocSignature: - p.write("function") - p.emitSignature(node) + p.emitJSDocSignature(node.AsJSDocSignature()) case ast.KindJSDocText: p.write(node.Text()) case ast.KindJSDocLink, ast.KindJSDocLinkCode, ast.KindJSDocLinkPlain: @@ -4659,7 +4657,7 @@ func (p *Printer) emitJSDoc(node *ast.JSDoc) { if hasComment && !hasTags && len(node.Comment.Nodes) == 1 && text != "" && len(text) <= 80 && !strings.Contains(text, "\n") { p.writeSpace() - p.write(strings.TrimSpace(text)) + p.write(strings.TrimSuffix(text, "\r")) p.writeSpace() p.write("*/") return @@ -4690,13 +4688,21 @@ func (p *Printer) emitJSDocCommentLines(text string) { p.writeLine() } p.write(" *") - if line = strings.TrimSpace(line); line != "" { + line = strings.TrimSuffix(line, "\r") + if line != "" { p.writeSpace() 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: @@ -4763,6 +4769,7 @@ func (p *Printer) emitJSDocType(node *ast.Node) { func (p *Printer) emitJSDocTag(node *ast.Node) { tagName, comment := jsDocTagBase(node) + var signature *ast.JSDocSignature p.write("@") if tagName != nil { p.write(tagName.Text()) @@ -4809,36 +4816,48 @@ func (p *Printer) emitJSDocTag(node *ast.Node) { p.writePunctuation(",") p.writeSpace() } - p.emitEntityName(typeParameter.AsTypeParameterDeclaration().Name()) + p.emitJSDocTemplateTypeParameter(typeParameter.AsTypeParameterDeclaration()) } } case ast.KindJSDocSeeTag: tag := node.AsJSDocSeeTag() if tag.NameExpression != nil { - p.writeSpace() - p.emitTypeNode(tag.NameExpression, ast.TypePrecedenceJSDoc) + 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() - p.emitJSDocType(tag.TypeExpression) if tag.Name() != nil { p.writeSpace() p.emitJSDocFullName(tag.Name()) } + if tag.TypeExpression != nil { + signature = tag.TypeExpression.AsJSDocSignature() + } case ast.KindJSDocOverloadTag: - p.emitJSDocType(node.AsJSDocOverloadTag().TypeExpression) + if typeExpression := node.AsJSDocOverloadTag().TypeExpression; typeExpression != nil { + signature = typeExpression.AsJSDocSignature() + } case ast.KindJSDocTypedefTag: tag := node.AsJSDocTypedefTag() p.emitJSDocType(tag.TypeExpression) @@ -4850,7 +4869,17 @@ func (p *Printer) emitJSDocTag(node *ast.Node) { tag := node.AsJSDocImportTag() if tag.ImportClause != nil { p.writeSpace() - p.emitImportClause(tag.ImportClause.AsImportClause()) + 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() @@ -4867,17 +4896,20 @@ func (p *Printer) emitJSDocTag(node *ast.Node) { for _, commentNode := range comment.Nodes { p.writeJSDocCommentNode(&text, commentNode) } - lines := strings.Split(strings.TrimSpace(text.String()), "\n") + lines := strings.Split(text.String(), "\n") if len(lines) > 0 && lines[0] != "" { p.writeSpace() - p.write(strings.TrimSpace(lines[0])) + p.write(strings.TrimSuffix(lines[0], "\r")) for _, line := range lines[1:] { p.writeLine() p.write(" * ") - p.write(strings.TrimSpace(line)) + p.write(strings.TrimSuffix(line, "\r")) } } } + if signature != nil { + p.emitJSDocSignature(signature) + } } func (p *Printer) emitJSDocTypeLiteral(node *ast.JSDocTypeLiteral) { @@ -4891,12 +4923,21 @@ func (p *Printer) emitJSDocTypeLiteral(node *ast.JSDocTypeLiteral) { } tag := property.AsJSDocParameterOrPropertyTag() if tag.Name() != nil { - p.emitEntityName(tag.Name()) + p.emitJSDocPropertyName(tag.Name()) + if tag.IsBracketed { + p.writePunctuation("?") + } } if tag.TypeExpression != nil { p.writePunctuation(":") p.writeSpace() - p.emitTypeNode(tag.TypeExpression, ast.TypePrecedenceJSDoc) + typeNode := tag.TypeExpression + if typeNode.Kind == ast.KindJSDocTypeExpression { + typeNode = typeNode.AsJSDocTypeExpression().Type + } + if typeNode != nil { + p.emitTypeNode(typeNode, ast.TypePrecedenceJSDoc) + } } } p.writeSpace() @@ -4908,12 +4949,60 @@ func (p *Printer) emitJSDocTypeLiteral(node *ast.JSDocTypeLiteral) { } } +func (p *Printer) emitJSDocPropertyName(name *ast.EntityName) { + if ast.IsIdentifier(name) { + p.emitEntityName(name) + return + } + p.emitEntityName(name.AsQualifiedName().Right) +} + +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) { + if 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: - p.emitModuleDeclaration(node.AsModuleDeclaration()) + declaration := node.AsModuleDeclaration() + p.emitJSDocFullName(declaration.Name()) + if declaration.Body != nil && declaration.Body.Kind == ast.KindModuleDeclaration { + p.writePunctuation(".") + p.emitJSDocFullName(declaration.Body) + } default: panic(fmt.Sprintf("unhandled JSDoc full name: %v", node.Kind)) } diff --git a/tsc/internal/printer/printer_test.go b/tsc/internal/printer/printer_test.go index c05deab451a63..217d39bbac845 100644 --- a/tsc/internal/printer/printer_test.go +++ b/tsc/internal/printer/printer_test.go @@ -706,6 +706,167 @@ func TestEmitJSDocLink(t *testing.T) { } } +func TestEmitJSDocReviewRegressions(t *testing.T) { + t.Parallel() + + data := []struct { + name string + input string + output string + }{ + { + name: "ObjectTypedefProperties", + input: `/** + * @typedef {Object} Options + * @property {number} count + */ +const options = {};`, + output: `/** + * @typedef {Object} Options + * @property {number} count + */ +const 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*/ + nil, /*comment*/ + ) + 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 {{ options: { count?: number } }} Options\n */" + if actual != expected { + t.Errorf("unexpected JSDoc typedef record:\n got: %q\nwant: %q", actual, expected) + } +} + +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 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 * 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() From 4c2c6fa3ac9de3bbf5ee5daed2799ac6667a5ac5 Mon Sep 17 00:00:00 2001 From: el-j Date: Fri, 30 Oct 2026 17:44:11 +0100 Subject: [PATCH 3/4] fix(printer): preserve JSDoc emission details Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tsc/internal/printer/printer.go | 142 +++++++++++++++------- tsc/internal/printer/printer_test.go | 175 +++++++++++++++++++++++++-- 2 files changed, 266 insertions(+), 51 deletions(-) diff --git a/tsc/internal/printer/printer.go b/tsc/internal/printer/printer.go index edb084baddeeb..a0a3cc7604198 100644 --- a/tsc/internal/printer/printer.go +++ b/tsc/internal/printer/printer.go @@ -2387,7 +2387,7 @@ func (p *Printer) emitTypeNode(node *ast.TypeNode, precedence ast.TypePrecedence case ast.KindJSDocTypeLiteral: p.emitJSDocTypeLiteral(node.AsJSDocTypeLiteral()) case ast.KindJSDocSignature: - p.emitJSDocSignature(node.AsJSDocSignature()) + p.emitJSDocSignature(node.AsJSDocSignature(), true /*emitTypeParameters*/) case ast.KindJSDocNonNullableType: p.emitJSDocNonNullableType(node.AsJSDocNonNullableType()) case ast.KindJSDocNullableType: @@ -4627,7 +4627,7 @@ func (p *Printer) emitJSDocNode(node *ast.Node) { case ast.KindJSDocTypeLiteral: p.emitJSDocTypeLiteral(node.AsJSDocTypeLiteral()) case ast.KindJSDocSignature: - p.emitJSDocSignature(node.AsJSDocSignature()) + p.emitJSDocSignature(node.AsJSDocSignature(), true /*emitTypeParameters*/) case ast.KindJSDocText: p.write(node.Text()) case ast.KindJSDocLink, ast.KindJSDocLinkCode, ast.KindJSDocLinkPlain: @@ -4654,14 +4654,6 @@ func (p *Printer) emitJSDoc(node *ast.JSDoc) { } text := commentText.String() p.write("/**") - if hasComment && !hasTags && len(node.Comment.Nodes) == 1 && text != "" && - len(text) <= 80 && !strings.Contains(text, "\n") { - p.writeSpace() - p.write(strings.TrimSuffix(text, "\r")) - p.writeSpace() - p.write("*/") - return - } if !hasComment && !hasTags { p.write(" */") return @@ -4689,8 +4681,8 @@ func (p *Printer) emitJSDocCommentLines(text string) { } p.write(" *") line = strings.TrimSuffix(line, "\r") + p.writeSpace() if line != "" { - p.writeSpace() p.write(line) } } @@ -4726,7 +4718,7 @@ func (p *Printer) writeJSDocCommentNode(text *strings.Builder, node *ast.Node) { text.WriteByte(' ') if name != nil { text.WriteString(jsDocEntityNameText(name)) - if linkText == "" || !strings.HasPrefix(linkText, "://") { + if linkText != "" && !strings.HasPrefix(linkText, "://") { text.WriteByte(' ') } } @@ -4757,12 +4749,13 @@ func (p *Printer) emitJSDocType(node *ast.Node) { } p.writeSpace() p.writePunctuation("{") - if node.Kind == ast.KindJSDocTypeExpression { - if typeNode := node.AsJSDocTypeExpression().Type; typeNode != nil { + if typeLiteral := getJSDocTypeLiteral(node); typeLiteral != nil { + p.emitJSDocObjectType(typeLiteral) + } else { + typeNode := unwrapJSDocTypeExpression(node) + if typeNode != nil { p.emitTypeNode(typeNode, ast.TypePrecedenceJSDoc) } - } else { - p.emitTypeNode(node, ast.TypePrecedenceJSDoc) } p.writePunctuation("}") } @@ -4770,6 +4763,29 @@ func (p *Printer) emitJSDocType(node *ast.Node) { 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()) @@ -4778,6 +4794,7 @@ func (p *Printer) emitJSDocTag(node *ast.Node) { switch node.Kind { case ast.KindJSDocParameterTag, ast.KindJSDocPropertyTag: tag := node.AsJSDocParameterOrPropertyTag() + nestedTags = getJSDocTypeLiteral(tag.TypeExpression) if !tag.IsNameFirst { p.emitJSDocType(tag.TypeExpression) } @@ -4851,15 +4868,9 @@ func (p *Printer) emitJSDocTag(node *ast.Node) { p.writeSpace() p.emitJSDocFullName(tag.Name()) } - if tag.TypeExpression != nil { - signature = tag.TypeExpression.AsJSDocSignature() - } - case ast.KindJSDocOverloadTag: - if typeExpression := node.AsJSDocOverloadTag().TypeExpression; typeExpression != nil { - signature = typeExpression.AsJSDocSignature() - } case ast.KindJSDocTypedefTag: tag := node.AsJSDocTypedefTag() + nestedTags = getJSDocTypeLiteral(tag.TypeExpression) p.emitJSDocType(tag.TypeExpression) if tag.Name() != nil { p.writeSpace() @@ -4897,9 +4908,11 @@ func (p *Printer) emitJSDocTag(node *ast.Node) { p.writeJSDocCommentNode(&text, commentNode) } lines := strings.Split(text.String(), "\n") - if len(lines) > 0 && lines[0] != "" { - p.writeSpace() - p.write(strings.TrimSuffix(lines[0], "\r")) + 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(" * ") @@ -4907,11 +4920,51 @@ func (p *Printer) emitJSDocTag(node *ast.Node) { } } } + if nestedTags != nil { + p.emitJSDocTypeLiteralTags(nestedTags) + } if signature != nil { - p.emitJSDocSignature(signature) + 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 { @@ -4924,20 +4977,22 @@ func (p *Printer) emitJSDocTypeLiteral(node *ast.JSDocTypeLiteral) { tag := property.AsJSDocParameterOrPropertyTag() if tag.Name() != nil { p.emitJSDocPropertyName(tag.Name()) - if tag.IsBracketed { + 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 tag.TypeExpression != nil { + if typeNode != nil { p.writePunctuation(":") p.writeSpace() - typeNode := tag.TypeExpression - if typeNode.Kind == ast.KindJSDocTypeExpression { - typeNode = typeNode.AsJSDocTypeExpression().Type - } - if typeNode != nil { - p.emitTypeNode(typeNode, ast.TypePrecedenceJSDoc) - } + p.emitTypeNode(typeNode, ast.TypePrecedenceJSDoc) } } p.writeSpace() @@ -4949,12 +5004,11 @@ func (p *Printer) emitJSDocTypeLiteral(node *ast.JSDocTypeLiteral) { } } -func (p *Printer) emitJSDocPropertyName(name *ast.EntityName) { - if ast.IsIdentifier(name) { - p.emitEntityName(name) - return +func unwrapJSDocTypeExpression(node *ast.Node) *ast.Node { + if node != nil && node.Kind == ast.KindJSDocTypeExpression { + return node.AsJSDocTypeExpression().Type } - p.emitEntityName(name.AsQualifiedName().Right) + return node } func (p *Printer) emitJSDocTemplateTypeParameter(typeParameter *ast.TypeParameterDeclaration) { @@ -4970,8 +5024,8 @@ func (p *Printer) emitJSDocTemplateTypeParameter(typeParameter *ast.TypeParamete } } -func (p *Printer) emitJSDocSignature(signature *ast.JSDocSignature) { - if signature.TypeParameters != nil { +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 ") @@ -4999,7 +5053,7 @@ func (p *Printer) emitJSDocFullName(node *ast.JSDocFullName) { case ast.KindModuleDeclaration: declaration := node.AsModuleDeclaration() p.emitJSDocFullName(declaration.Name()) - if declaration.Body != nil && declaration.Body.Kind == ast.KindModuleDeclaration { + if declaration.Body != nil && declaration.Body.Kind != ast.KindModuleBlock { p.writePunctuation(".") p.emitJSDocFullName(declaration.Body) } diff --git a/tsc/internal/printer/printer_test.go b/tsc/internal/printer/printer_test.go index 217d39bbac845..938248f7a05ca 100644 --- a/tsc/internal/printer/printer_test.go +++ b/tsc/internal/printer/printer_test.go @@ -641,7 +641,8 @@ func TestEmitJSDocSingleLine(t *testing.T) { printer.PrintHandlers{}, nil, ).Emit(node, nil) - if actual != "/** A short description */" { + expected := "/**\n * A short description\n */" + if actual != expected { t.Errorf("unexpected single-line JSDoc output: %q", actual) } } @@ -718,14 +719,27 @@ func TestEmitJSDocReviewRegressions(t *testing.T) { name: "ObjectTypedefProperties", input: `/** * @typedef {Object} Options - * @property {number} count + * @property {number} count Number of items; see {@link Item} */ const options = {};`, output: `/** * @typedef {Object} Options - * @property {number} count + * @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", @@ -793,7 +807,11 @@ func TestEmitJSDocTypedefRecord(t *testing.T) { true, /*isBracketed*/ numberType, false, /*isNameFirst*/ - nil, /*comment*/ + 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( @@ -818,12 +836,155 @@ func TestEmitJSDocTypedefRecord(t *testing.T) { printer.PrintHandlers{}, nil, ).Emit(doc, nil) - expected := "/**\n * @typedef {{ options: { count?: number } }} Options\n */" + 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() @@ -845,7 +1006,7 @@ func TestEmitJSDocPreservesTextWhitespace(t *testing.T) { var factory ast.NodeFactory node := factory.NewJSDoc( factory.NewNodeList([]*ast.Node{ - factory.NewJSDocText([]string{" first line\n indented line\r"}), + factory.NewJSDocText([]string{" first line\n\n indented line\r"}), }), factory.NewNodeList([]*ast.Node{ factory.NewJSDocUnknownTag( @@ -861,7 +1022,7 @@ func TestEmitJSDocPreservesTextWhitespace(t *testing.T) { printer.PrintHandlers{}, nil, ).Emit(node, nil) - expected := "/**\n * first line\n * indented line\n * @example tag text\n * indented tag\n */" + 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) } From 68a64a4d2e81fb006cd9ce9f22f040b6c0cfa1ff Mon Sep 17 00:00:00 2001 From: el-j Date: Fri, 30 Oct 2026 21:16:19 +0100 Subject: [PATCH 4/4] feat(api): support synthetic comments in printNode Carry synthetic leading/trailing comments from the JS API to the Go printer and emit them when printing nodes without a source file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/typescript/src/api/async/api.ts | 9 ++- packages/typescript/src/api/node/encoder.ts | 21 +++++- .../typescript/src/api/proto.generated.ts | 13 ++++ packages/typescript/src/api/sync/api.ts | 17 +++-- packages/typescript/src/ast/index.ts | 1 + .../typescript/src/ast/syntheticComments.ts | 66 +++++++++++++++++++ packages/typescript/test/async/api.test.ts | 48 ++++++++++++++ packages/typescript/test/sync/api.test.ts | 48 ++++++++++++++ tsc/internal/api/encoder/decoder.go | 15 ++++- tsc/internal/api/printnode_comments_test.go | 42 ++++++++++++ tsc/internal/api/proto.go | 11 ++++ tsc/internal/api/session.go | 43 +++++++++--- tsc/internal/printer/printer.go | 23 ++++++- tsc/internal/printer/printer_test.go | 36 ++++++++++ 14 files changed, 373 insertions(+), 20 deletions(-) create mode 100644 packages/typescript/src/ast/syntheticComments.ts create mode 100644 tsc/internal/api/printnode_comments_test.go 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/printer/printer.go b/tsc/internal/printer/printer.go index a0a3cc7604198..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 // !!! @@ -5815,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 } @@ -5843,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 938248f7a05ca..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) { @@ -3060,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;") + }) +}