diff --git a/packages/typescript/enums/nodeFlags.d.ts b/packages/typescript/enums/nodeFlags.d.ts index 405ad7d48183e..2461c2a82e1ff 100644 --- a/packages/typescript/enums/nodeFlags.d.ts +++ b/packages/typescript/enums/nodeFlags.d.ts @@ -31,6 +31,8 @@ export declare enum NodeFlags { PossiblyContainsDeprecatedTag = 1 << 26, Unreachable = 1 << 27, ReparserTransformedLiteral = 1 << 28, + EmitSingleLine = 1 << 29, + EmitNoAsciiEscaping = 1 << 30, BlockScoped = Let | Const | Using, Constant = Const | Using, AwaitUsing = Const | Using, diff --git a/packages/typescript/enums/nodeFlags.js b/packages/typescript/enums/nodeFlags.js index 05e7c99f21190..bc3260321afb9 100644 --- a/packages/typescript/enums/nodeFlags.js +++ b/packages/typescript/enums/nodeFlags.js @@ -31,6 +31,8 @@ export var NodeFlags; NodeFlags[NodeFlags["PossiblyContainsDeprecatedTag"] = 67108864] = "PossiblyContainsDeprecatedTag"; NodeFlags[NodeFlags["Unreachable"] = 134217728] = "Unreachable"; NodeFlags[NodeFlags["ReparserTransformedLiteral"] = 268435456] = "ReparserTransformedLiteral"; + NodeFlags[NodeFlags["EmitSingleLine"] = 536870912] = "EmitSingleLine"; + NodeFlags[NodeFlags["EmitNoAsciiEscaping"] = 1073741824] = "EmitNoAsciiEscaping"; NodeFlags[NodeFlags["BlockScoped"] = 7] = "BlockScoped"; NodeFlags[NodeFlags["Constant"] = 6] = "Constant"; NodeFlags[NodeFlags["AwaitUsing"] = 6] = "AwaitUsing"; diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 3d00791be523d..06482f925f213 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -8712,6 +8712,35 @@ export const obj = { m: 1, s: "hi", b: true }; assert.strictEqual(text, "number[]"); }); + test("typeToTypeNode + printNode keeps tuples and type literals on a single line", async () => { + await using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "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/src/main.ts": `export type Shape = { tuple: [Date, number]; obj: { a: string } };`, + }); + + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const { checker, program } = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = await program.getSourceFile("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/src/main.ts"); + assert.ok(sourceFile); + const alias = sourceFile.statements.find(s => (s as any).name?.text === "Shape") as any; + assert.ok(alias); + const symbol = await checker.getSymbolAtLocation(alias.name); + assert.ok(symbol); + const shape = await checker.getDeclaredTypeOfSymbol(symbol); + assert.ok(shape); + + const printed: Record = {}; + for (const prop of await checker.getPropertiesOfType(shape)) { + const type = await checker.getTypeOfSymbol(prop); + assert.ok(type); + const node = await checker.typeToTypeNode(type); + assert.ok(node); + printed[prop.name] = await api.printer.printNode(node); + assert.strictEqual(printed[prop.name], await checker.typeToString(type)); + } + assert.deepStrictEqual(printed, { tuple: "[Date, number]", obj: "{ a: string; }" }); + }); + test("typeToTypeNode + printNode round-trip", 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 7f7e2e31c6e0e..a684562d2a8f0 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -8512,6 +8512,35 @@ export const obj = { m: 1, s: "hi", b: true }; assert.strictEqual(text, "number[]"); }); + test("typeToTypeNode + printNode keeps tuples and type literals on a single line", () => { + using api = spawnAPI({ + "/tsconfig.json": JSON.stringify({ compilerOptions: { strict: true } }), + "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/src/main.ts": `export type Shape = { tuple: [Date, number]; obj: { a: string } };`, + }); + + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const { checker, program } = snapshot.getConfiguredProject("/tsconfig.json")!; + const sourceFile = program.getSourceFile("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/src/main.ts"); + assert.ok(sourceFile); + const alias = sourceFile.statements.find(s => (s as any).name?.text === "Shape") as any; + assert.ok(alias); + const symbol = checker.getSymbolAtLocation(alias.name); + assert.ok(symbol); + const shape = checker.getDeclaredTypeOfSymbol(symbol); + assert.ok(shape); + + const printed: Record = {}; + for (const prop of checker.getPropertiesOfType(shape)) { + const type = checker.getTypeOfSymbol(prop); + assert.ok(type); + const node = checker.typeToTypeNode(type); + assert.ok(node); + printed[prop.name] = api.printer.printNode(node); + assert.strictEqual(printed[prop.name], checker.typeToString(type)); + } + assert.deepStrictEqual(printed, { tuple: "[Date, number]", obj: "{ a: string; }" }); + }); + test("typeToTypeNode + printNode round-trip", () => { using api = spawnAPI(emitterFiles); diff --git a/tsc/internal/api/encoder/encoder.go b/tsc/internal/api/encoder/encoder.go index 39367ebc9fae8..e779078683928 100644 --- a/tsc/internal/api/encoder/encoder.go +++ b/tsc/internal/api/encoder/encoder.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/ast" "github.com/microsoft/TypeScript/tsc/internal/core" + "github.com/microsoft/TypeScript/tsc/internal/printer" "github.com/microsoft/TypeScript/tsc/internal/spanmap" "github.com/zeebo/xxh3" ) @@ -420,7 +421,7 @@ func GetNodeIndexTable(sourceFile *ast.SourceFile) *NodeIndexTable { // EncodeSourceFile encodes an entire source file AST into the binary format. // Returns the encoded bytes and a NodeIndexTable mapping encoder indices to AST nodes. func EncodeSourceFile(sourceFile *ast.SourceFile) ([]byte, *NodeIndexTable, error) { - data, nodeTable, err := encodeTree(sourceFile.AsNode(), sourceFile) + data, nodeTable, err := encodeTree(sourceFile.AsNode(), sourceFile, nil) if err != nil { return nil, nil, err } @@ -445,10 +446,33 @@ func SetSourceFileID(data []byte, id uint64) { // When encoding a non-SourceFile node, the header hash and parse options fields will be zero. // Returns the encoded bytes and a NodeIndexTable mapping encoder indices to AST nodes. func EncodeNode(node *ast.Node, sourceFile *ast.SourceFile) ([]byte, *NodeIndexTable, error) { - return encodeTree(node, sourceFile) + return encodeTree(node, sourceFile, nil) } -func encodeTree(rootNode *ast.Node, sourceFile *ast.SourceFile) ([]byte, *NodeIndexTable, error) { +// EncodeNodeWithEmitContext is like EncodeNode, but also transmits printer hints recorded for synthesized nodes in +// emitContext (currently single-line and no-ASCII-escaping), as ast.NodeFlagsEmitSingleLine and +// ast.NodeFlagsEmitNoAsciiEscaping in each node's flags. This allows nodes built by the node builder to print +// the same way after a round trip through the API as they would in-process. +func EncodeNodeWithEmitContext(node *ast.Node, sourceFile *ast.SourceFile, emitContext *printer.EmitContext) ([]byte, *NodeIndexTable, error) { + return encodeTree(node, sourceFile, emitContext) +} + +// encodedNodeFlags returns the flags to encode for node, including transported emit flags. +func encodedNodeFlags(node *ast.Node, emitContext *printer.EmitContext) uint32 { + flags := node.Flags + if emitContext != nil { + emitFlags := emitContext.EmitFlags(node) + if emitFlags&printer.EFSingleLine != 0 { + flags |= ast.NodeFlagsEmitSingleLine + } + if emitFlags&printer.EFNoAsciiEscaping != 0 { + flags |= ast.NodeFlagsEmitNoAsciiEscaping + } + } + return uint32(flags) +} + +func encodeTree(rootNode *ast.Node, sourceFile *ast.SourceFile, emitContext *printer.EmitContext) ([]byte, *NodeIndexTable, error) { var parentIndex, nodeCount, prevIndex uint32 var extendedData []byte var structuredData []byte @@ -554,7 +578,7 @@ func encodeTree(rootNode *ast.Node, sourceFile *ast.SourceFile) ([]byte, *NodeIn nodes[prevIndex*NodeSize+NodeOffsetNext+3] = b3 } - nodes = appendUint32s(nodes, uint32(node.Kind), utf16(node.Pos()), utf16(node.End()), 0, parentIndex, getNodeData(node, strs, positionMap, &extendedData, &structuredData), uint32(node.Flags)) + nodes = appendUint32s(nodes, uint32(node.Kind), utf16(node.Pos()), utf16(node.End()), 0, parentIndex, getNodeData(node, strs, positionMap, &extendedData, &structuredData), encodedNodeFlags(node, emitContext)) if nodeIndexMap != nil { if _, ok := nodeIndexMap[node]; ok { @@ -585,7 +609,7 @@ func encodeTree(rootNode *ast.Node, sourceFile *ast.SourceFile) ([]byte, *NodeIn nodeTable = append(nodeTable, rootNode) // index 1 = root node sfExtendedDataOffset = len(extendedData) - nodes = appendUint32s(nodes, uint32(rootNode.Kind), utf16(rootNode.Pos()), utf16(rootNode.End()), 0, 0, getNodeData(rootNode, strs, positionMap, &extendedData, &structuredData), uint32(rootNode.Flags)) + nodes = appendUint32s(nodes, uint32(rootNode.Kind), utf16(rootNode.Pos()), utf16(rootNode.End()), 0, 0, getNodeData(rootNode, strs, positionMap, &extendedData, &structuredData), encodedNodeFlags(rootNode, emitContext)) visitor.VisitEachChild(rootNode) if sourceFile != nil { diff --git a/tsc/internal/api/enum_values_generated.go b/tsc/internal/api/enum_values_generated.go index 1697a479539a5..3f807773de78a 100644 --- a/tsc/internal/api/enum_values_generated.go +++ b/tsc/internal/api/enum_values_generated.go @@ -751,6 +751,8 @@ func main() { "PossiblyContainsDeprecatedTag": toInt32(ast.NodeFlagsPossiblyContainsDeprecatedTag), "Unreachable": toInt32(ast.NodeFlagsUnreachable), "ReparserTransformedLiteral": toInt32(ast.NodeFlagsReparserTransformedLiteral), + "EmitSingleLine": toInt32(ast.NodeFlagsEmitSingleLine), + "EmitNoAsciiEscaping": toInt32(ast.NodeFlagsEmitNoAsciiEscaping), "BlockScoped": toInt32(ast.NodeFlagsBlockScoped), "Constant": toInt32(ast.NodeFlagsConstant), "AwaitUsing": toInt32(ast.NodeFlagsAwaitUsing), diff --git a/tsc/internal/api/printnode_emitflags_test.go b/tsc/internal/api/printnode_emitflags_test.go new file mode 100644 index 0000000000000..01a6c31574e42 --- /dev/null +++ b/tsc/internal/api/printnode_emitflags_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/api/encoder" + "github.com/microsoft/TypeScript/tsc/internal/ast" + "github.com/microsoft/TypeScript/tsc/internal/printer" + "gotest.tools/v3/assert" +) + +// Nodes built by the node builder carry EFSingleLine in an emit context. That must survive an encode/decode round +// trip so that printNode matches in-process printing. +func TestPrintNodeRestoresEmitFlags(t *testing.T) { + t.Parallel() + + build := func(ec *printer.EmitContext, singleLine bool) *ast.Node { + f := ec.Factory.AsNodeFactory() + str := func() *ast.Node { return f.NewKeywordTypeNode(ast.KindStringKeyword) } + member := f.NewPropertySignatureDeclaration(nil, f.NewIdentifier("a"), nil, str(), nil) + lit := f.NewTypeLiteralNode(f.NewNodeList([]*ast.Node{member})) + tuple := f.NewTupleTypeNode(f.NewNodeList([]*ast.Node{f.NewTypeReferenceNode(f.NewIdentifier("Date"), nil), str(), lit})) + if singleLine { + ec.SetEmitFlags(lit, printer.EFSingleLine) + ec.SetEmitFlags(tuple, printer.EFSingleLine) + } + return tuple + } + + roundTrip := func(singleLine bool) string { + ec := printer.NewEmitContext() + node := build(ec, singleLine) + data, _, err := encoder.EncodeNodeWithEmitContext(node, nil, ec) + assert.NilError(t, err) + decoded, err := encoder.DecodeNodes(data) + assert.NilError(t, err) + return newPrinter(&PrintNodeParams{}, decoded).Emit(decoded, nil) + } + + assert.Equal(t, roundTrip(true), "[Date, string, { a: string; }]") + assert.Assert(t, roundTrip(false) != "[Date, string, { a: string; }]") // no hint, default multi-line layout +} + +// EFNoAsciiEscaping must also survive the round trip: a synthesized non-ASCII string literal is escaped by default +// and printed verbatim when the node builder marked it. +func TestPrintNodeRestoresNoAsciiEscaping(t *testing.T) { + t.Parallel() + + roundTrip := func(noEscape bool) string { + ec := printer.NewEmitContext() + f := ec.Factory.AsNodeFactory() + node := f.NewLiteralTypeNode(f.NewStringLiteral("é", ast.TokenFlagsNone)) + if noEscape { + ec.SetEmitFlags(node.AsLiteralTypeNode().Literal, printer.EFNoAsciiEscaping) + } + data, _, err := encoder.EncodeNodeWithEmitContext(node, nil, ec) + assert.NilError(t, err) + decoded, err := encoder.DecodeNodes(data) + assert.NilError(t, err) + return newPrinter(&PrintNodeParams{}, decoded).Emit(decoded, nil) + } + + assert.Equal(t, roundTrip(true), `"é"`) + assert.Equal(t, roundTrip(false), `"\u00E9"`) +} diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index c29058ece0df7..f963fde6da469 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -4035,7 +4035,7 @@ func (s *Session) handleTypeToTypeNode(ctx context.Context, params *TypeToTypeNo } } - typeNode := setup.checker.TypeToTypeNode(t, enclosingDeclaration, nodebuilder.Flags(params.Flags), nil) + typeNode, emitContext := setup.checker.TypeToTypeNodeWithEmitContext(t, enclosingDeclaration, nodebuilder.Flags(params.Flags), nil) if typeNode == nil { if s.useBinaryResponses { return RawBinary(nil), nil @@ -4043,7 +4043,7 @@ func (s *Session) handleTypeToTypeNode(ctx context.Context, params *TypeToTypeNo return nil, nil } - data, _, err := encoder.EncodeNode(typeNode.AsNode(), nil) + data, _, err := encoder.EncodeNodeWithEmitContext(typeNode.AsNode(), nil, emitContext) if err != nil { return nil, fmt.Errorf("failed to encode type node: %w", err) } @@ -4078,7 +4078,7 @@ func (s *Session) handleSignatureToSignatureDeclaration(ctx context.Context, par } } - node := setup.checker.SignatureToSignatureDeclaration(sig, ast.Kind(params.Kind), enclosingDeclaration, nodebuilder.Flags(params.Flags)) + node, emitContext := setup.checker.SignatureToSignatureDeclarationWithEmitContext(sig, ast.Kind(params.Kind), enclosingDeclaration, nodebuilder.Flags(params.Flags)) if node == nil { if s.useBinaryResponses { return RawBinary(nil), nil @@ -4086,7 +4086,7 @@ func (s *Session) handleSignatureToSignatureDeclaration(ctx context.Context, par return nil, nil } - data, _, err := encoder.EncodeNode(node.AsNode(), nil) + data, _, err := encoder.EncodeNodeWithEmitContext(node.AsNode(), nil, emitContext) if err != nil { return nil, fmt.Errorf("failed to encode signature declaration: %w", err) } @@ -4137,7 +4137,7 @@ func (s *Session) handlePrintNode(_ context.Context, params *PrintNodeParams) (s if ast.IsSourceFile(node) { sourceFile = node.AsSourceFile() } - return newPrinter(params).Emit(node, sourceFile), nil + return newPrinter(params, node).Emit(node, sourceFile), nil } func decodePrintNode(encoded string) (*ast.Node, error) { @@ -4153,12 +4153,35 @@ func decodePrintNode(encoded string) (*ast.Node, error) { return node, nil } -func newPrinter(params *PrintNodeParams) *printer.Printer { +func newPrinter(params *PrintNodeParams, root *ast.Node) *printer.Printer { + emitContext := printer.NewEmitContext() + restoreEmitFlags(emitContext, root) return printer.NewPrinter(printer.PrinterOptions{ PreserveSourceNewlines: params.PreserveSourceNewlines, NeverAsciiEscape: params.NeverAsciiEscape, TerminateUnterminatedLiterals: params.TerminateUnterminatedLiterals, - }, printer.PrintHandlers{}, nil) + }, printer.PrintHandlers{}, emitContext) +} + +// restoreEmitFlags converts the printer hints that the encoder transported in node flags (see +// encoder.EncodeNodeWithEmitContext) back into emit flags on emitContext, for every node in the tree. +func restoreEmitFlags(emitContext *printer.EmitContext, root *ast.Node) { + const transported = ast.NodeFlagsEmitSingleLine | ast.NodeFlagsEmitNoAsciiEscaping + var visit func(n *ast.Node) bool + visit = func(n *ast.Node) bool { + if n.Flags&transported != 0 { + var flags printer.EmitFlags + if n.Flags&ast.NodeFlagsEmitSingleLine != 0 { + flags |= printer.EFSingleLine + } + if n.Flags&ast.NodeFlagsEmitNoAsciiEscaping != 0 { + flags |= printer.EFNoAsciiEscaping + } + emitContext.AddEmitFlags(n, flags) + } + return n.ForEachChild(visit) + } + visit(root) } func (s *Session) handleEmit(ctx context.Context, params *EmitParams) (*EmitResponse, error) { diff --git a/tsc/internal/ast/nodeflags.go b/tsc/internal/ast/nodeflags.go index 2c808601a2260..f568e1ece2b1c 100644 --- a/tsc/internal/ast/nodeflags.go +++ b/tsc/internal/ast/nodeflags.go @@ -46,6 +46,12 @@ const ( NodeFlagsUnreachable NodeFlags = 1 << 27 // If node is unreachable according to the binder NodeFlagsReparserTransformedLiteral NodeFlags = 1 << 28 // If node was transformed during parsing, making its' naive text source not match the AST + // The following flags are only used to carry printer hints (see printer.EmitFlags) across the API boundary, + // where the emit context that normally holds them is not available. They are set by the API encoder on nodes + // that were given the corresponding emit flag (e.g. by the node builder), and are never set by the parser or binder. + NodeFlagsEmitSingleLine NodeFlags = 1 << 29 // Node should be printed on a single line (printer.EFSingleLine) + NodeFlagsEmitNoAsciiEscaping NodeFlags = 1 << 30 // Node's text should not be ASCII-escaped when printed (printer.EFNoAsciiEscaping) + NodeFlagsBlockScoped = NodeFlagsLet | NodeFlagsConst | NodeFlagsUsing NodeFlagsConstant = NodeFlagsConst | NodeFlagsUsing NodeFlagsAwaitUsing = NodeFlagsConst | NodeFlagsUsing // Variable declaration (NOTE: on a single node these flags would otherwise be mutually exclusive) diff --git a/tsc/internal/checker/printer.go b/tsc/internal/checker/printer.go index b84c967883d88..5d7173d9677d0 100644 --- a/tsc/internal/checker/printer.go +++ b/tsc/internal/checker/printer.go @@ -290,6 +290,20 @@ func (c *Checker) TypeToTypeNode(t *Type, enclosingDeclaration *ast.Node, flags return nodeBuilder.TypeToTypeNode(t, enclosingDeclaration, flags, nodebuilder.InternalFlagsNone, nil) } +// TypeToTypeNodeWithEmitContext is like TypeToTypeNode, but also returns the emit context holding the emit flags +// (such as single-line) that the node builder recorded on the resulting nodes. +func (c *Checker) TypeToTypeNodeWithEmitContext(t *Type, enclosingDeclaration *ast.Node, flags nodebuilder.Flags, idToSymbol map[*ast.IdentifierNode]*ast.Symbol) (*ast.TypeNode, *printer.EmitContext) { + nodeBuilder := c.getNodeBuilderEx(idToSymbol) + return nodeBuilder.TypeToTypeNode(t, enclosingDeclaration, flags, nodebuilder.InternalFlagsNone, nil), nodeBuilder.EmitContext() +} + +// SignatureToSignatureDeclarationWithEmitContext is like SignatureToSignatureDeclaration, but also returns the emit +// context holding the emit flags that the node builder recorded on the resulting nodes. +func (c *Checker) SignatureToSignatureDeclarationWithEmitContext(signature *Signature, kind ast.Kind, enclosingDeclaration *ast.Node, flags nodebuilder.Flags) (*ast.Node, *printer.EmitContext) { + nodeBuilder := c.getNodeBuilderEx(nil /*idToSymbol*/) + return nodeBuilder.SignatureToSignatureDeclaration(signature, kind, enclosingDeclaration, flags, nodebuilder.InternalFlagsNone, nil), nodeBuilder.EmitContext() +} + func (c *Checker) SignatureToSignatureDeclaration(signature *Signature, kind ast.Kind, enclosingDeclaration *ast.Node, flags nodebuilder.Flags) *ast.Node { nodeBuilder, release := c.getNodeBuilder() defer release()