Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package fourslash_test

import (
"testing"

"github.com/microsoft/typescript-go/internal/fourslash"
"github.com/microsoft/typescript-go/internal/testutil"
)

// Test that auto-imports for JSX tags don't crash when React is type-imported.
// When both the JSX namespace (React) and the component need to be imported,
// getSymbolNamesToImport returns multiple names and the type-only promotion
// path should handle this gracefully instead of panicking.
func TestCodeFixPromoteTypeOnlyImportJsxTag(t *testing.T) {
t.Parallel()
defer testutil.RecoverAndFail(t, "Panic on fourslash test")
const content = `// @module: preserve
// @verbatimModuleSyntax: true
// @jsx: react
// @Filename: /react.ts
const React: any = {};
export default React;
// @Filename: /bar.tsx
import type React from "./react";

<Foo/**/ />;`
f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content)
defer done()
f.GoToMarker(t, "")
// The fix should promote the type-only import of React to a regular import.
// The "Cannot find name 'Foo'" error does not produce an auto-import for
// React since it's already imported (as type-only, handled by promotion).
f.VerifyImportFixAtPosition(t, []string{
`import React from "./react";

<Foo />;`,
}, nil /*preferences*/)
}

// Test edge case where both the component name (Foo) and the JSX namespace (React)
// are type-only imported. Each diagnostic is matched to its symbol via the error
// message, so each produces only its own promotion fix (no duplicates).
func TestCodeFixPromoteTypeOnlyImportJsxTagBothTypeOnly(t *testing.T) {
t.Parallel()
defer testutil.RecoverAndFail(t, "Panic on fourslash test")
const content = `// @module: preserve
// @verbatimModuleSyntax: true
// @jsx: react
// @Filename: /react.ts
const React: any = {};
export default React;
// @Filename: /foo.ts
export function Foo() { return null; }
// @Filename: /bar.tsx
import type React from "./react";
import type { Foo } from "./foo";

<Foo/**/ />;`
f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content)
defer done()
f.GoToMarker(t, "")
// Both Foo and React are type-only imported. The error message string
// matching disambiguates which diagnostic is about which symbol, so each
// diagnostic produces only its own promotion fix (no duplicates).
f.VerifyImportFixAtPosition(t, []string{
`import type React from "./react";
import { Foo } from "./foo";

<Foo />;`,
`import React from "./react";
import type { Foo } from "./foo";

<Foo />;`,
}, nil /*preferences*/)
}
76 changes: 57 additions & 19 deletions internal/ls/codeactions_importfixes.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package ls
import (
"context"
"slices"
"strings"

"github.com/microsoft/typescript-go/internal/ast"
"github.com/microsoft/typescript-go/internal/astnav"
Expand Down Expand Up @@ -183,20 +184,33 @@ func getFixInfos(ctx context.Context, fixContext *CodeFixContext, errorCode int3
} else if !ast.IsIdentifier(symbolToken) {
return nil, nil
} else if errorCode == diagnostics.X_0_cannot_be_used_as_a_value_because_it_was_imported_using_import_type.Code() {
// Handle type-only import promotion
ch, done := fixContext.Program.GetTypeChecker(ctx)
defer done()
compilerOptions := fixContext.Program.Options()
symbolNames := getSymbolNamesToImport(fixContext.SourceFile, ch, symbolToken, compilerOptions)
if len(symbolNames) != 1 {
panic("Expected exactly one symbol name for type-only import promotion")

// Best-effort: use the diagnostic message to disambiguate which symbol
// the error is about. The message format is "'X' cannot be used as a
// value because it was imported using 'import type'." so we check for
// the symbol name in single quotes.
diagnosticMessage := ""
if fixContext.Diagnostic != nil {
diagnosticMessage = fixContext.Diagnostic.Message
}
symbolName := symbolNames[0]
fix := getTypeOnlyPromotionFix(ctx, fixContext.SourceFile, symbolToken, symbolName, fixContext.Program)
if fix != nil {
return []*fixInfo{{fix: fix, symbolName: symbolName, errorIdentifierText: symbolToken.Text()}}, nil

for _, sn := range symbolNames {
if !sn.isTypeOnly {
continue
}
if diagnosticMessage != "" && !strings.Contains(diagnosticMessage, "'"+sn.name+"'") {
continue
}
fix := getTypeOnlyPromotionFix(ctx, fixContext.SourceFile, symbolToken, sn.name, fixContext.Program)
if fix != nil {
info = append(info, &fixInfo{fix: fix, symbolName: sn.name, errorIdentifierText: symbolToken.Text()})
}
}
return nil, nil
return info, nil
} else {
var err error
view, err = fixContext.LS.getPreparedAutoImportView(fixContext.SourceFile)
Expand Down Expand Up @@ -289,7 +303,13 @@ func getFixesInfoForNonUMDImport(ctx context.Context, fixContext *CodeFixContext
// Compute usage position for JSDoc import type fixes
usagePosition := fixContext.LS.converters.PositionToLineAndCharacter(fixContext.SourceFile, core.TextPos(scanner.GetTokenPosOfNode(symbolToken, fixContext.SourceFile, false)))

for _, symbolName := range symbolNames {
for _, sn := range symbolNames {
// Type-only imports are handled by the promotion code path, not the auto-import path.
if sn.isTypeOnly {
continue
}

symbolName := sn.name
// "default" is a keyword and not a legal identifier for the import
if symbolName == "default" {
continue
Expand All @@ -310,8 +330,9 @@ func getFixesInfoForNonUMDImport(ctx context.Context, fixContext *CodeFixContext
fixes := view.GetFixes(ctx, export, isJSXTagName, isValidTypeOnlyUseSite, &usagePosition)
for _, fix := range fixes {
allInfo = append(allInfo, &fixInfo{
fix: fix,
symbolName: symbolName,
fix: fix,
symbolName: symbolName,
isJsxNamespaceFix: symbolName != symbolToken.Text(),
})
}
}
Expand Down Expand Up @@ -344,22 +365,40 @@ func getTypeOnlyPromotionFix(ctx context.Context, sourceFile *ast.SourceFile, sy
}
}

func getSymbolNamesToImport(sourceFile *ast.SourceFile, ch *checker.Checker, symbolToken *ast.Node, compilerOptions *core.CompilerOptions) []string {
type symbolNameInfo struct {
name string
isTypeOnly bool // whether the symbol currently resolves to a type-only import
}

func getSymbolNamesToImport(sourceFile *ast.SourceFile, ch *checker.Checker, symbolToken *ast.Node, compilerOptions *core.CompilerOptions) []symbolNameInfo {
parent := symbolToken.Parent
if (ast.IsJsxOpeningLikeElement(parent) || ast.IsJsxClosingElement(parent)) &&
parent.TagName() == symbolToken &&
jsxModeNeedsExplicitImport(compilerOptions.Jsx) {
jsxNamespace := ch.GetJsxNamespace(sourceFile.AsNode())
if needsJsxNamespaceFix(jsxNamespace, symbolToken, ch) {
needsComponentNameFix := !scanner.IsIntrinsicJsxName(symbolToken.Text()) &&
ch.ResolveName(symbolToken.Text(), symbolToken, ast.SymbolFlagsValue, false /* excludeGlobals */) == nil
if needsComponentNameFix {
return []string{symbolToken.Text(), jsxNamespace}
var result []symbolNameInfo
if !scanner.IsIntrinsicJsxName(symbolToken.Text()) {
compSymbol := ch.ResolveName(symbolToken.Text(), symbolToken, ast.SymbolFlagsValue, false /* excludeGlobals */)
if compSymbol == nil {
result = append(result, symbolNameInfo{name: symbolToken.Text()})
} else if ch.GetTypeOnlyAliasDeclaration(compSymbol) != nil {
result = append(result, symbolNameInfo{name: symbolToken.Text(), isTypeOnly: true})
}
}
nsIsTypeOnly := false
if nsSymbol := ch.ResolveName(jsxNamespace, symbolToken, ast.SymbolFlagsValue, true /* excludeGlobals */); nsSymbol != nil {
nsIsTypeOnly = ch.GetTypeOnlyAliasDeclaration(nsSymbol) != nil
}
return []string{jsxNamespace}
result = append(result, symbolNameInfo{name: jsxNamespace, isTypeOnly: nsIsTypeOnly})
return result
}
}
return []string{symbolToken.Text()}
tokenIsTypeOnly := false
if sym := ch.ResolveName(symbolToken.Text(), symbolToken, ast.SymbolFlagsValue, true /* excludeGlobals */); sym != nil {
tokenIsTypeOnly = ch.GetTypeOnlyAliasDeclaration(sym) != nil
}
return []symbolNameInfo{{name: symbolToken.Text(), isTypeOnly: tokenIsTypeOnly}}
}

func needsJsxNamespaceFix(jsxNamespace string, symbolToken *ast.Node, ch *checker.Checker) bool {
Expand All @@ -370,7 +409,6 @@ func needsJsxNamespaceFix(jsxNamespace string, symbolToken *ast.Node, ch *checke
if namespaceSymbol == nil {
return true
}
// Check if all declarations are type-only
if slices.ContainsFunc(namespaceSymbol.Declarations, ast.IsTypeOnlyImportOrExportDeclaration) {
return (namespaceSymbol.Flags & ast.SymbolFlagsValue) == 0
}
Expand Down