diff --git a/tsc/internal/binder/binder.go b/tsc/internal/binder/binder.go index bc462e49a7f95..eb03bab1fd282 100644 --- a/tsc/internal/binder/binder.go +++ b/tsc/internal/binder/binder.go @@ -79,6 +79,8 @@ type Binder struct { flowListArena core.Arena[ast.FlowList] singleDeclarationsArena core.Arena[*ast.Node] expandoAssignments []ExpandoAssignmentInfo + + seenCommonJSModuleIndicator bool } type ActiveLabel struct { @@ -758,7 +760,7 @@ func (b *Binder) bindPropertyWorker(node *ast.Node) { func (b *Binder) bindSourceFileIfExternalModule() { b.setExportContextFlag(b.file.AsNode()) - if ast.IsExternalOrCommonJSModule(b.file) { + if b.isExternalOrCommonJSModule() { b.bindSourceFileAsExternalModule() } else if ast.IsJsonSourceFile(b.file) { b.bindSourceFileAsExternalModule() @@ -927,17 +929,21 @@ func (b *Binder) bindFunctionExpression(node *ast.Node) { func (b *Binder) bindCallExpression(node *ast.Node) { // We're only inspecting call expressions to detect CommonJS modules, so we can skip // this check if we've already seen the module indicator - if b.file.CommonJSModuleIndicator == nil && ast.IsRequireCall(node, false /*requireStringLiteralLikeArgument*/) { + if !b.seenCommonJSModuleIndicator && ast.IsRequireCall(node, false /*requireStringLiteralLikeArgument*/) { b.setCommonJSModuleIndicator(node) } } +// setCommonJSModuleIndicator reports whether node's file is a CommonJS module, which the +// parser has already determined. The file symbol is created at the first CommonJS construct +// in the walk, rather than up front, to preserve symbol creation order. func (b *Binder) setCommonJSModuleIndicator(node *ast.Node) bool { - if b.file.ExternalModuleIndicator != nil && b.file.ExternalModuleIndicator != b.file.AsNode() { + if b.file.CommonJSModuleIndicator == nil { + debug.Assert(b.file.ExternalModuleIndicator != nil && b.file.ExternalModuleIndicator != b.file.AsNode(), "parser missed a CommonJS module indicator") return false } - if b.file.CommonJSModuleIndicator == nil { - b.file.CommonJSModuleIndicator = node + if !b.seenCommonJSModuleIndicator { + b.seenCommonJSModuleIndicator = true if b.file.ExternalModuleIndicator == nil { b.bindSourceFileAsExternalModule() } @@ -945,6 +951,12 @@ func (b *Binder) setCommonJSModuleIndicator(node *ast.Node) bool { return true } +// isExternalOrCommonJSModule is like ast.IsExternalOrCommonJSModule, but a CommonJS file only +// counts as a module once the walk has reached its first CommonJS construct. +func (b *Binder) isExternalOrCommonJSModule() bool { + return b.file.ExternalModuleIndicator != nil || b.seenCommonJSModuleIndicator +} + func (b *Binder) bindClassLikeDeclaration(node *ast.Node) { name := node.Name() switch node.Kind { @@ -1248,7 +1260,7 @@ func (b *Binder) bindBlockScopedDeclaration(node *ast.Node, symbolFlags ast.Symb case ast.KindModuleDeclaration: b.declareModuleMember(node, symbolFlags, symbolExcludes) case ast.KindSourceFile: - if ast.IsExternalOrCommonJSModule(b.container.AsSourceFile()) { + if b.isExternalOrCommonJSModule() { b.declareModuleMember(node, symbolFlags, symbolExcludes) break } @@ -1612,12 +1624,13 @@ func (b *Binder) bindContainer(node *ast.Node, containerFlags ContainerFlags) { b.bindBlockScopedDeclaration(statement, ast.SymbolFlagsTypeAlias, ast.SymbolFlagsTypeAliasExcludes) } } - if b.file.CommonJSModuleIndicator != nil { + debug.Assert(b.seenCommonJSModuleIndicator == (b.file.CommonJSModuleIndicator != nil), "binder missed a CommonJS module indicator") + if b.seenCommonJSModuleIndicator { b.declareCommonJSVariable("module") b.declareCommonJSVariable("exports") } } - if ast.IsSourceFile(node) && ast.IsExternalOrCommonJSModule(node.AsSourceFile()) || ast.IsAmbientModule(node) { + if ast.IsSourceFile(node) && b.isExternalOrCommonJSModule() || ast.IsAmbientModule(node) { b.bindCommonJSTypeExports(node.Symbol()) } b.container = saveContainer diff --git a/tsc/internal/compiler/program_test.go b/tsc/internal/compiler/program_test.go index 4ef0537403903..df16384ac5576 100644 --- a/tsc/internal/compiler/program_test.go +++ b/tsc/internal/compiler/program_test.go @@ -621,3 +621,29 @@ func BenchmarkNewProgram(b *testing.B) { } }) } + +func TestUpdateProgramCommonJSFile(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/src/tsconfig.json": `{"compilerOptions":{"allowJs":true,"noLib":true},"files":["index.js"]}`, + "/src/index.js": "exports.a = 1;", + } + fs := vfstest.FromMap(files, tspath.CaseSensitive) + config, diagnostics := tsoptions.GetParsedCommandLineOfConfigFile("/src/tsconfig.json", nil, nil, tsoptions.NewDefaultParseConfigHost(fs), nil) + assert.Equal(t, len(diagnostics), 0) + program := NewProgram(ProgramOptions{Config: config, Host: NewCompilerHost(fs, "/", nil, nil, nil)}) + program.BindSourceFiles() + + update := func(content string) bool { + files["/src/index.js"] = content + host := NewCompilerHost(vfstest.FromMap(files, tspath.CaseSensitive), "/", nil, nil, nil) + var reused bool + program, _, reused = program.UpdateProgram("/src/index.js", host, nil, nil) + program.BindSourceFiles() + return reused + } + + assert.Assert(t, update("exports.a = 2;"), "editing a CommonJS file should reuse the program") + assert.Assert(t, update("var a = 2;") == false, "a CommonJS file becoming a script should not reuse the program") + assert.Assert(t, update("exports.a = 2;") == false, "a script becoming a CommonJS file should not reuse the program") +} diff --git a/tsc/internal/parser/parser.go b/tsc/internal/parser/parser.go index c670d64ad21c2..82c36656ddd50 100644 --- a/tsc/internal/parser/parser.go +++ b/tsc/internal/parser/parser.go @@ -98,6 +98,9 @@ type Parser struct { currentParent *ast.Node setParentFromContext ast.Visitor reparsedClones []*ast.Node + + commonJSModuleIndicator *ast.Node + jsdocHasCommonJSConstruct bool } func newParser() *Parser { @@ -346,6 +349,8 @@ type ParserState struct { reparsedClonesLen int statementHasAwaitIdentifier bool hasParseError bool + commonJSModuleIndicator *ast.Node + jsdocHasCommonJSConstruct bool } func (p *Parser) mark() ParserState { @@ -358,6 +363,8 @@ func (p *Parser) mark() ParserState { reparsedClonesLen: len(p.reparsedClones), statementHasAwaitIdentifier: p.statementHasAwaitIdentifier, hasParseError: p.hasParseError, + commonJSModuleIndicator: p.commonJSModuleIndicator, + jsdocHasCommonJSConstruct: p.jsdocHasCommonJSConstruct, } } @@ -371,6 +378,8 @@ func (p *Parser) rewind(state ParserState) { p.reparsedClones = p.reparsedClones[0:state.reparsedClonesLen] p.statementHasAwaitIdentifier = state.statementHasAwaitIdentifier p.hasParseError = state.hasParseError + p.commonJSModuleIndicator = state.commonJSModuleIndicator + p.jsdocHasCommonJSConstruct = state.jsdocHasCommonJSConstruct } func (p *Parser) lookAhead(callback func(p *Parser) bool) bool { @@ -451,6 +460,10 @@ func (p *Parser) parseSourceFileWorker() *ast.SourceFile { if !result.IsDeclarationFile && result.ExternalModuleIndicator != nil && len(p.possibleAwaitSpans) > 0 { reparse := p.finishNode(p.reparseTopLevelAwait(result), pos) if node != reparse { + // Reparsing can change which CommonJS constructs exist, so recompute from the final tree. + if p.contextFlags&ast.NodeFlagsJavaScriptFile != 0 { + p.commonJSModuleIndicator = findCommonJSModuleIndicator(reparse) + } result = reparse.AsSourceFile() p.finishSourceFile(result, isDeclarationFile) } @@ -483,6 +496,53 @@ func (p *Parser) finishSourceFile(result *ast.SourceFile, isDeclarationFile bool slices.SortFunc(p.reparsedClones, ast.CompareNodePositions) result.ReparsedClones = slices.Clone(p.reparsedClones) ast.SetExternalModuleIndicator(result, p.opts.ExternalModuleIndicatorOptions) + // CommonJS constructs only make a file a module when it has no ES module syntax. + if result.ExternalModuleIndicator == nil || result.ExternalModuleIndicator == result.AsNode() { + if p.commonJSModuleIndicator == nil && p.jsdocHasCommonJSConstruct { + // Only reparsed JSDoc clones that survive into the tree are bound. + p.commonJSModuleIndicator = findCommonJSModuleIndicator(result.AsNode()) + } + result.CommonJSModuleIndicator = p.commonJSModuleIndicator + } else { + result.CommonJSModuleIndicator = nil + } +} + +// checkCommonJSModuleIndicator records the first CommonJS construct in a JavaScript file +// (a require call, or an assignment or Object.defineProperty on exports or module.exports). +// The binder binds the same constructs as CommonJS exports. Nodes parsed within JSDoc are +// only bound if reparsed, so for those we just note that the final tree needs checking. +func (p *Parser) checkCommonJSModuleIndicator(node *ast.Node) *ast.Node { + if p.commonJSModuleIndicator == nil && node.Flags&ast.NodeFlagsJavaScriptFile != 0 && isCommonJSModuleIndicator(node) { + if node.Flags&ast.NodeFlagsJSDoc == 0 { + p.commonJSModuleIndicator = node + } else { + p.jsdocHasCommonJSConstruct = true + } + } + return node +} + +func findCommonJSModuleIndicator(node *ast.Node) *ast.Node { + var found *ast.Node + var visit func(*ast.Node) bool + visit = func(n *ast.Node) bool { + if isCommonJSModuleIndicator(n) { + found = n + return true + } + return n.ForEachChild(visit) + } + visit(node) + return found +} + +func isCommonJSModuleIndicator(node *ast.Node) bool { + switch ast.GetAssignmentDeclarationKind(node) { + case ast.JSDeclarationKindModuleExports, ast.JSDeclarationKindExportsProperty, ast.JSDeclarationKindObjectDefinePropertyExports: + return true + } + return ast.IsRequireCall(node, false /*requireStringLiteralLikeArgument*/) } func (p *Parser) createJSDocCache() map[*ast.Node][]*ast.Node { @@ -4740,7 +4800,7 @@ func (p *Parser) makeAsExpression(left *ast.Expression, right *ast.TypeNode) *as } func (p *Parser) makeBinaryExpression(left *ast.Expression, operatorToken *ast.Node, right *ast.Expression, pos int) *ast.Node { - return p.finishNode(p.factory.NewBinaryExpression(nil /*modifiers*/, left, nil /*typeNode*/, operatorToken, right), pos) + return p.checkCommonJSModuleIndicator(p.finishNode(p.factory.NewBinaryExpression(nil /*modifiers*/, left, nil /*typeNode*/, operatorToken, right), pos)) } func (p *Parser) parseUnaryExpressionOrHigher() *ast.Expression { @@ -5556,7 +5616,7 @@ func (p *Parser) parseCallExpressionRest(pos int, expression *ast.Expression) *a inner := expression argumentList := p.parseArgumentList() isOptionalChain := questionDotToken != nil || p.tryReparseOptionalChain(expression) - expression = p.checkJSSyntax(p.finishNode(p.factory.NewCallExpression(expression, questionDotToken, typeArguments, argumentList, core.IfElse(isOptionalChain, ast.NodeFlagsOptionalChain, ast.NodeFlagsNone)), pos)) + expression = p.checkCommonJSModuleIndicator(p.checkJSSyntax(p.finishNode(p.factory.NewCallExpression(expression, questionDotToken, typeArguments, argumentList, core.IfElse(isOptionalChain, ast.NodeFlagsOptionalChain, ast.NodeFlagsNone)), pos))) p.unparseExpressionWithTypeArguments(inner, typeArguments, expression) continue } diff --git a/tsc/internal/parser/parser_test.go b/tsc/internal/parser/parser_test.go index ffa96713eb444..839e9fc52f377 100644 --- a/tsc/internal/parser/parser_test.go +++ b/tsc/internal/parser/parser_test.go @@ -571,3 +571,43 @@ namespace N { assert.Equal(t, positionMap.UTF8ToUTF16(afterBoxDrawingCharacter), afterBoxDrawingCharacter-2) assert.Equal(t, positionMap.UTF8ToUTF16(len(sourceText)), len(sourceText)-2) } + +func TestCommonJSModuleIndicator(t *testing.T) { + t.Parallel() + tests := []struct { + fileName tspath.RootedFilePath + source string + expected bool + }{ + {"/a.js", `const x = require("x");`, true}, + {"/a.js", `module.exports = {};`, true}, + {"/a.js", `exports.x = 1;`, true}, + {"/a.js", `module.exports.x = 1;`, true}, + {"/a.js", `Object.defineProperty(exports, "x", { value: 1 });`, true}, + {"/a.js", `function f() { exports.x = 1; }`, true}, + {"/a.js", `var x = 1; foo(x);`, false}, + {"/a.js", `/** @type {{ [require("x")]: number }} */ var x;`, true}, + {"/a.js", `/** @param {number} [x=require("x")] */ function f(x) {}`, false}, + {"/a.js", "/**\n * @type {{ [require('x')]: () => void }}\n * @type {() => void}\n */\nfunction f() {}", false}, + {"/a.js", "/**\n * @type {{ [require('x')]: number }}\n * @type {number}\n */\nthis.x = 1;", false}, + {"/a.js", "/**\n * @template {{ [require('x')]: number }} T\n * @typedef\n */\nfunction f() {}", false}, + {"/a.js", `exports.x = 1; export {};`, false}, + {"/a.js", `export const y = require("x");`, false}, + {"/a.js", `/** @satisfies {any} */ module.exports = exports;`, true}, + {"/a.js", "await / import.meta / 1;\nawait / require('x') / 1;", false}, + {"/a.js", `await / 1 ; export {}; / ; require('x');`, true}, + {"/a.cjs", `exports.x = 1;`, true}, + {"/a.cjs", `await / 1 ; export {}; / ; require('x');`, true}, + {"/a.mjs", `await /require("x")/;`, false}, + {"/a.ts", `exports.x = 1; const y = require("x");`, false}, + } + for _, test := range tests { + t.Run(test.fileName.AsString()+" "+test.source, func(t *testing.T) { + t.Parallel() + opts := ast.SourceFileParseOptions{FileName: test.fileName, PathKey: tspath.CaseSensitive.PathKey(test.fileName.AsPath())} + opts.ExternalModuleIndicatorOptions.Force = test.fileName.ExtensionIsOneOf([]string{tspath.ExtensionCjs, tspath.ExtensionMjs}) + file := parser.ParseSourceFile(opts, test.source, core.GetScriptKindFromFileName(test.fileName)) + assert.Equal(t, file.CommonJSModuleIndicator != nil, test.expected) + }) + } +} diff --git a/tsc/internal/parser/reparser.go b/tsc/internal/parser/reparser.go index bd0e5e4d04588..ef420f7e19971 100644 --- a/tsc/internal/parser/reparser.go +++ b/tsc/internal/parser/reparser.go @@ -18,6 +18,8 @@ func (p *Parser) finishReparsedNode(node *ast.Node, locationNode *ast.Node) { func (p *Parser) finishMutatedNode(node *ast.Node) { p.overrideParentInImmediateChildren(node) + // Wrapping an assignment's right side can make it a CommonJS export assignment. + p.checkCommonJSModuleIndicator(node) } // Deep-clone the given node and add the clone to the reparsed clone list. The list is used by ast.GetReparsedNodeForNode