diff --git a/internal/fourslash/tests/codeFixPromoteTypeOnlyImportJsxTag_test.go b/internal/fourslash/tests/codeFixPromoteTypeOnlyImportJsxTag_test.go new file mode 100644 index 00000000000..51267c119fa --- /dev/null +++ b/internal/fourslash/tests/codeFixPromoteTypeOnlyImportJsxTag_test.go @@ -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"; + +;` + 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"; + +;`, + }, 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"; + +;` + 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"; + +;`, + `import React from "./react"; +import type { Foo } from "./foo"; + +;`, + }, nil /*preferences*/) +} diff --git a/internal/ls/codeactions_importfixes.go b/internal/ls/codeactions_importfixes.go index 65ece5f59c5..146fd0970c1 100644 --- a/internal/ls/codeactions_importfixes.go +++ b/internal/ls/codeactions_importfixes.go @@ -3,6 +3,7 @@ package ls import ( "context" "slices" + "strings" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/astnav" @@ -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) @@ -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 @@ -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(), }) } } @@ -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 { @@ -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 }