Skip to content

Commit 0e081d6

Browse files
Escape quotes in synthesized module specifiers (#64839)
1 parent bb9afd2 commit 0e081d6

3 files changed

Lines changed: 179 additions & 4 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package fourslash_test
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/microsoft/TypeScript/tsc/internal/fourslash"
9+
. "github.com/microsoft/TypeScript/tsc/internal/fourslash/tests/util"
10+
"github.com/microsoft/TypeScript/tsc/internal/ls/lsutil"
11+
"github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto"
12+
"github.com/microsoft/TypeScript/tsc/internal/testutil"
13+
)
14+
15+
func TestAutoImportQuotedModuleSpecifier(t *testing.T) {
16+
t.Parallel()
17+
for _, quote := range []struct {
18+
preference lsutil.QuotePreference
19+
specifier string
20+
}{
21+
{lsutil.QuotePreferenceSingle, `'dep/a\'b"c'`},
22+
{lsutil.QuotePreferenceDouble, `"dep/a'b\"c"`},
23+
} {
24+
for _, test := range []struct {
25+
name string
26+
extension string
27+
declaration string
28+
usage string
29+
result string
30+
statement string
31+
snippet bool
32+
}{
33+
{
34+
name: "named",
35+
extension: "mts",
36+
declaration: "export class Foo {}",
37+
usage: "Foo/**/;",
38+
result: "import { Foo } from %s;\n\nFoo;",
39+
statement: "import { Foo$1 } from %s;",
40+
snippet: true,
41+
},
42+
{
43+
name: "namedNoSnippet",
44+
extension: "mts",
45+
declaration: "export class Foo {}",
46+
usage: "Foo/**/;",
47+
result: "import { Foo } from %s;\n\nFoo;",
48+
statement: "import { Foo } from %s;",
49+
},
50+
{
51+
name: "default",
52+
extension: "mts",
53+
declaration: "export default class Foo {}",
54+
usage: "Foo/**/;",
55+
result: "import Foo from %s;\n\nFoo;",
56+
statement: "import Foo$1 from %s;",
57+
snippet: true,
58+
},
59+
{
60+
name: "require",
61+
extension: "cjs",
62+
declaration: "export class Foo {}",
63+
usage: "Foo/**/;",
64+
result: "const { Foo } = require(%s);\n\nFoo;",
65+
},
66+
{
67+
name: "jsdoc",
68+
extension: "js",
69+
declaration: "export interface Foo {}",
70+
usage: "/** @type {Foo/**/} */\nlet value;",
71+
result: "/** @type {import(%s).Foo} */\nlet value;",
72+
},
73+
} {
74+
t.Run(string(quote.preference)+"/"+test.name, func(t *testing.T) {
75+
t.Parallel()
76+
defer testutil.RecoverAndFail(t, "Panic on fourslash test")
77+
content := fmt.Sprintf(`// @Filename: /tsconfig.json
78+
{
79+
"compilerOptions": {
80+
"module": "node20",
81+
"allowJs": true,
82+
"checkJs": true,
83+
"noEmit": true
84+
}
85+
}
86+
// @Filename: /node_modules/dep/package.json
87+
{
88+
"name": "dep",
89+
"exports": { "./a'b\"c": "./index.d.ts" }
90+
}
91+
// @Filename: /node_modules/dep/index.d.ts
92+
%s
93+
// @Filename: /fix.%s
94+
%s
95+
// @Filename: /completion.%s
96+
%s
97+
// @Filename: /all.%s
98+
%s
99+
// @Filename: /statement.%s
100+
[|import F/*statement*/|]`,
101+
test.declaration, test.extension, test.usage, test.extension,
102+
strings.ReplaceAll(test.usage, "/**/", "/*completion*/"), test.extension,
103+
strings.ReplaceAll(test.usage, "/**/", "/*all*/")+"\n"+
104+
strings.ReplaceAll(strings.ReplaceAll(test.usage, "/**/", ""), "value", "other"), test.extension)
105+
f, done := fourslash.NewFourslash(t, fourslash.GetDefaultCapabilitiesWithOptions(&fourslash.ClientCapabilitiesOptions{
106+
CompletionItem: &lsproto.ClientCompletionItemOptions{SnippetSupport: new(test.snippet)},
107+
}), content)
108+
defer done()
109+
preferences := lsutil.NewDefaultUserPreferences()
110+
preferences.QuotePreference = quote.preference
111+
expected := fmt.Sprintf(test.result, quote.specifier)
112+
113+
f.GoToMarker(t, "")
114+
f.VerifyImportFixAtPosition(t, []string{expected}, &preferences)
115+
reset := f.ConfigureWithReset(t, preferences)
116+
defer reset()
117+
f.GoToMarker(t, "all")
118+
allExpected := expected + "\nFoo;"
119+
if test.name == "jsdoc" {
120+
allExpected = expected + "\n" + strings.ReplaceAll(expected, "value", "other")
121+
}
122+
f.VerifyCodeFixAll(t, fourslash.VerifyCodeFixAllOptions{
123+
FixID: "fixMissingImport",
124+
NewFileContent: allExpected,
125+
})
126+
127+
f.VerifyApplyCodeActionFromCompletion(t, new("completion"), &fourslash.ApplyCodeActionFromCompletionOptions{
128+
Name: "Foo",
129+
Source: `dep/a'b"c`,
130+
UserPreferences: &preferences,
131+
NewFileContent: new(expected),
132+
})
133+
134+
if test.statement != "" {
135+
statement := fmt.Sprintf(test.statement, quote.specifier)
136+
var insertTextFormat *lsproto.InsertTextFormat
137+
if test.snippet {
138+
// Snippets escape backslashes independently of string literal escaping.
139+
statement = strings.ReplaceAll(statement, `\`, `\\`)
140+
insertTextFormat = new(lsproto.InsertTextFormatSnippet)
141+
}
142+
f.VerifyCompletions(t, "statement", &fourslash.CompletionsExpectedList{
143+
IsIncomplete: false,
144+
ItemDefaults: &fourslash.CompletionsExpectedItemDefaults{
145+
CommitCharacters: &[]string{},
146+
EditRange: Ignored,
147+
},
148+
Items: &fourslash.CompletionsExpectedItems{
149+
Includes: []fourslash.CompletionsExpectedItem{
150+
&lsproto.CompletionItem{
151+
Label: "Foo",
152+
InsertText: new(statement),
153+
InsertTextFormat: insertTextFormat,
154+
TextEdit: &lsproto.TextEditOrInsertReplaceEdit{
155+
TextEdit: &lsproto.TextEdit{
156+
NewText: statement,
157+
Range: f.Ranges()[0].LSRange,
158+
},
159+
},
160+
Data: &lsproto.CompletionItemData{
161+
AutoImport: &lsproto.AutoImportFix{ModuleSpecifier: `dep/a'b"c`},
162+
},
163+
},
164+
},
165+
},
166+
})
167+
}
168+
})
169+
}
170+
}
171+
}

‎tsc/internal/ls/autoimport/fix.go‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/microsoft/TypeScript/tsc/internal/ls/lsutil"
2323
"github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto"
2424
"github.com/microsoft/TypeScript/tsc/internal/modulespecifiers"
25+
"github.com/microsoft/TypeScript/tsc/internal/printer"
2526
"github.com/microsoft/TypeScript/tsc/internal/scanner"
2627
"github.com/microsoft/TypeScript/tsc/internal/stringutil"
2728
"github.com/microsoft/TypeScript/tsc/internal/tspath"
@@ -147,11 +148,11 @@ func addImportType(f *Fix, file *ast.SourceFile, preferences lsutil.UserPreferen
147148
panic("UsagePosition must be set for JSDoc type import fix")
148149
}
149150
quotePreference := lsutil.GetQuotePreference(file, preferences)
150-
quoteChar := "\""
151+
quoteChar := printer.QuoteCharDoubleQuote
151152
if quotePreference == lsutil.QuotePreferenceSingle {
152-
quoteChar = "'"
153+
quoteChar = printer.QuoteCharSingleQuote
153154
}
154-
importTypePrefix := fmt.Sprintf("import(%s%s%s).", quoteChar, f.ModuleSpecifier, quoteChar)
155+
importTypePrefix := fmt.Sprintf("import(%c%s%c).", quoteChar, printer.EscapeString(f.ModuleSpecifier, quoteChar), quoteChar)
155156
tracker.InsertText(file, *f.UsagePosition, importTypePrefix)
156157
return diagnostics.Change_0_to_1.Localize(locale, f.Name, importTypePrefix+f.Name)
157158
}

‎tsc/internal/ls/completions.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2141,7 +2141,10 @@ func completionNameForLiteral(
21412141
}
21422142

21432143
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) {
2144-
quotedModuleSpecifier := escapeSnippetText(quote(file, preferences, fix.ModuleSpecifier))
2144+
quotedModuleSpecifier := quote(file, preferences, fix.ModuleSpecifier)
2145+
if isSnippet {
2146+
quotedModuleSpecifier = escapeSnippetText(strings.ReplaceAll(quotedModuleSpecifier, `\`, `\\`))
2147+
}
21452148
tabStop := core.IfElse(isSnippet, "$1", "")
21462149
suffix := core.IfElse(useSemicolons, ";", "")
21472150
topLevelTypeOnlyText := core.IfElse(importStatementCompletion.isTopLevelTypeOnly, " "+scanner.TokenToString(ast.KindTypeKeyword)+" ", " ")

0 commit comments

Comments
 (0)