Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions tsc/internal/binder/binder.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ type Binder struct {
flowListArena core.Arena[ast.FlowList]
singleDeclarationsArena core.Arena[*ast.Node]
expandoAssignments []ExpandoAssignmentInfo

seenCommonJSModuleIndicator bool
}

type ActiveLabel struct {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -927,24 +929,34 @@ 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()
}
}
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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tsc/internal/compiler/program_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
64 changes: 62 additions & 2 deletions tsc/internal/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ type Parser struct {
currentParent *ast.Node
setParentFromContext ast.Visitor
reparsedClones []*ast.Node

commonJSModuleIndicator *ast.Node
jsdocHasCommonJSConstruct bool
}

func newParser() *Parser {
Expand Down Expand Up @@ -346,6 +349,8 @@ type ParserState struct {
reparsedClonesLen int
statementHasAwaitIdentifier bool
hasParseError bool
commonJSModuleIndicator *ast.Node
jsdocHasCommonJSConstruct bool
}

func (p *Parser) mark() ParserState {
Expand All @@ -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,
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
40 changes: 40 additions & 0 deletions tsc/internal/parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
2 changes: 2 additions & 0 deletions tsc/internal/parser/reparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading