diff --git a/tsc/internal/fourslash/tests/autoImportQuotedModuleSpecifier_test.go b/tsc/internal/fourslash/tests/autoImportQuotedModuleSpecifier_test.go new file mode 100644 index 0000000000000..ad2b8144d44b9 --- /dev/null +++ b/tsc/internal/fourslash/tests/autoImportQuotedModuleSpecifier_test.go @@ -0,0 +1,171 @@ +package fourslash_test + +import ( + "fmt" + "strings" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/fourslash" + . "github.com/microsoft/TypeScript/tsc/internal/fourslash/tests/util" + "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/testutil" +) + +func TestAutoImportQuotedModuleSpecifier(t *testing.T) { + t.Parallel() + for _, quote := range []struct { + preference lsutil.QuotePreference + specifier string + }{ + {lsutil.QuotePreferenceSingle, `'dep/a\'b"c'`}, + {lsutil.QuotePreferenceDouble, `"dep/a'b\"c"`}, + } { + for _, test := range []struct { + name string + extension string + declaration string + usage string + result string + statement string + snippet bool + }{ + { + name: "named", + extension: "mts", + declaration: "export class Foo {}", + usage: "Foo/**/;", + result: "import { Foo } from %s;\n\nFoo;", + statement: "import { Foo$1 } from %s;", + snippet: true, + }, + { + name: "namedNoSnippet", + extension: "mts", + declaration: "export class Foo {}", + usage: "Foo/**/;", + result: "import { Foo } from %s;\n\nFoo;", + statement: "import { Foo } from %s;", + }, + { + name: "default", + extension: "mts", + declaration: "export default class Foo {}", + usage: "Foo/**/;", + result: "import Foo from %s;\n\nFoo;", + statement: "import Foo$1 from %s;", + snippet: true, + }, + { + name: "require", + extension: "cjs", + declaration: "export class Foo {}", + usage: "Foo/**/;", + result: "const { Foo } = require(%s);\n\nFoo;", + }, + { + name: "jsdoc", + extension: "js", + declaration: "export interface Foo {}", + usage: "/** @type {Foo/**/} */\nlet value;", + result: "/** @type {import(%s).Foo} */\nlet value;", + }, + } { + t.Run(string(quote.preference)+"/"+test.name, func(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + content := fmt.Sprintf(`// @Filename: /tsconfig.json +{ + "compilerOptions": { + "module": "node20", + "allowJs": true, + "checkJs": true, + "noEmit": true + } +} +// @Filename: /node_modules/dep/package.json +{ + "name": "dep", + "exports": { "./a'b\"c": "./index.d.ts" } +} +// @Filename: /node_modules/dep/index.d.ts +%s +// @Filename: /fix.%s +%s +// @Filename: /completion.%s +%s +// @Filename: /all.%s +%s +// @Filename: /statement.%s +[|import F/*statement*/|]`, + test.declaration, test.extension, test.usage, test.extension, + strings.ReplaceAll(test.usage, "/**/", "/*completion*/"), test.extension, + strings.ReplaceAll(test.usage, "/**/", "/*all*/")+"\n"+ + strings.ReplaceAll(strings.ReplaceAll(test.usage, "/**/", ""), "value", "other"), test.extension) + f, done := fourslash.NewFourslash(t, fourslash.GetDefaultCapabilitiesWithOptions(&fourslash.ClientCapabilitiesOptions{ + CompletionItem: &lsproto.ClientCompletionItemOptions{SnippetSupport: new(test.snippet)}, + }), content) + defer done() + preferences := lsutil.NewDefaultUserPreferences() + preferences.QuotePreference = quote.preference + expected := fmt.Sprintf(test.result, quote.specifier) + + f.GoToMarker(t, "") + f.VerifyImportFixAtPosition(t, []string{expected}, &preferences) + reset := f.ConfigureWithReset(t, preferences) + defer reset() + f.GoToMarker(t, "all") + allExpected := expected + "\nFoo;" + if test.name == "jsdoc" { + allExpected = expected + "\n" + strings.ReplaceAll(expected, "value", "other") + } + f.VerifyCodeFixAll(t, fourslash.VerifyCodeFixAllOptions{ + FixID: "fixMissingImport", + NewFileContent: allExpected, + }) + + f.VerifyApplyCodeActionFromCompletion(t, new("completion"), &fourslash.ApplyCodeActionFromCompletionOptions{ + Name: "Foo", + Source: `dep/a'b"c`, + UserPreferences: &preferences, + NewFileContent: new(expected), + }) + + if test.statement != "" { + statement := fmt.Sprintf(test.statement, quote.specifier) + var insertTextFormat *lsproto.InsertTextFormat + if test.snippet { + // Snippets escape backslashes independently of string literal escaping. + statement = strings.ReplaceAll(statement, `\`, `\\`) + insertTextFormat = new(lsproto.InsertTextFormatSnippet) + } + f.VerifyCompletions(t, "statement", &fourslash.CompletionsExpectedList{ + IsIncomplete: false, + ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{ + CommitCharacters: &[]string{}, + EditRange: Ignored, + }, + Items: &fourslash.CompletionsExpectedItems{ + Includes: []fourslash.CompletionsExpectedItem{ + &lsproto.CompletionItem{ + Label: "Foo", + InsertText: new(statement), + InsertTextFormat: insertTextFormat, + TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ + TextEdit: &lsproto.TextEdit{ + NewText: statement, + Range: f.Ranges()[0].LSRange, + }, + }, + Data: &lsproto.CompletionItemData{ + AutoImport: &lsproto.AutoImportFix{ModuleSpecifier: `dep/a'b"c`}, + }, + }, + }, + }, + }) + } + }) + } + } +} diff --git a/tsc/internal/ls/autoimport/fix.go b/tsc/internal/ls/autoimport/fix.go index d0f5e27da6047..1333ef59cc134 100644 --- a/tsc/internal/ls/autoimport/fix.go +++ b/tsc/internal/ls/autoimport/fix.go @@ -22,6 +22,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/ls/lsutil" "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/modulespecifiers" + "github.com/microsoft/TypeScript/tsc/internal/printer" "github.com/microsoft/TypeScript/tsc/internal/scanner" "github.com/microsoft/TypeScript/tsc/internal/stringutil" "github.com/microsoft/TypeScript/tsc/internal/tspath" @@ -147,11 +148,11 @@ func addImportType(f *Fix, file *ast.SourceFile, preferences lsutil.UserPreferen panic("UsagePosition must be set for JSDoc type import fix") } quotePreference := lsutil.GetQuotePreference(file, preferences) - quoteChar := "\"" + quoteChar := printer.QuoteCharDoubleQuote if quotePreference == lsutil.QuotePreferenceSingle { - quoteChar = "'" + quoteChar = printer.QuoteCharSingleQuote } - importTypePrefix := fmt.Sprintf("import(%s%s%s).", quoteChar, f.ModuleSpecifier, quoteChar) + importTypePrefix := fmt.Sprintf("import(%c%s%c).", quoteChar, printer.EscapeString(f.ModuleSpecifier, quoteChar), quoteChar) tracker.InsertText(file, *f.UsagePosition, importTypePrefix) return diagnostics.Change_0_to_1.Localize(locale, f.Name, importTypePrefix+f.Name) } diff --git a/tsc/internal/ls/completions.go b/tsc/internal/ls/completions.go index 62564fbbd2f7e..079360c8f495c 100644 --- a/tsc/internal/ls/completions.go +++ b/tsc/internal/ls/completions.go @@ -2141,7 +2141,10 @@ func completionNameForLiteral( } func getInsertTextAndReplacementSpanForImportCompletion(fix *autoimport.Fix, importKind lsproto.ImportKind, importStatementCompletion *importStatementCompletionInfo, useSemicolons bool, file *ast.SourceFile, preferences lsutil.UserPreferences, isSnippet bool) (insertText string, replacementSpan *lsproto.Range) { - quotedModuleSpecifier := escapeSnippetText(quote(file, preferences, fix.ModuleSpecifier)) + quotedModuleSpecifier := quote(file, preferences, fix.ModuleSpecifier) + if isSnippet { + quotedModuleSpecifier = escapeSnippetText(strings.ReplaceAll(quotedModuleSpecifier, `\`, `\\`)) + } tabStop := core.IfElse(isSnippet, "$1", "") suffix := core.IfElse(useSemicolons, ";", "") topLevelTypeOnlyText := core.IfElse(importStatementCompletion.isTopLevelTypeOnly, " "+scanner.TokenToString(ast.KindTypeKeyword)+" ", " ")