Repository: sveltejs/language-tools
Pull request #1123 (2021-07-30 07:28:07)

(feat) Import statement completion
Partially support the import statement completion in [typescript 4.3](https://devblogs.microsoft.com/typescript/announcing-typescript-4-3/#import-statement-completions). Except for the `import` + white space trigger, Which doesn't work consistently. 
![圖片](https://user-images.githubusercontent.com/36730922/127616000-2e9fd727-de68-4902-aaff-b84cca6c55d1.png)



Patch:
diff --git a/packages/language-server/src/ls-config.ts b/packages/language-server/src/ls-config.ts
index b20c47ce..006344bb 100644
--- a/packages/language-server/src/ls-config.ts
+++ b/packages/language-server/src/ls-config.ts
@@ -229,6 +229,8 @@ export interface TsUserPreferencesConfig {
  */
 export interface TSSuggestConfig {
     autoImports: UserPreferences['includeCompletionsForModuleExports'];
+    includeAutomaticOptionalChainCompletions: boolean | undefined;
+    includeCompletionsForImportStatements: boolean | undefined;
 }
 
 export type TsUserConfigLang = 'typescript' | 'javascript';
@@ -244,10 +246,14 @@ export class LSConfigManager {
     private listeners: Array<(config: LSConfigManager) => void> = [];
     private tsUserPreferences: Record<TsUserConfigLang, UserPreferences> = {
         typescript: {
-            includeCompletionsForModuleExports: true
+            includeCompletionsForModuleExports: true,
+            includeCompletionsForImportStatements: true,
+            includeCompletionsWithInsertText: true
         },
         javascript: {
-            includeCompletionsForModuleExports: true
+            includeCompletionsForModuleExports: true,
+            includeCompletionsForImportStatements: true,
+            includeCompletionsWithInsertText: true
         }
     };
     private prettierConfig: any = {};
@@ -344,7 +350,10 @@ export class LSConfigManager {
             importModuleSpecifierEnding: config.preferences?.importModuleSpecifierEnding,
             includePackageJsonAutoImports: config.preferences?.includePackageJsonAutoImports,
             quotePreference: config.preferences?.quoteStyle,
-            includeCompletionsForModuleExports: config.suggest?.autoImports ?? true
+            includeCompletionsForModuleExports: config.suggest?.autoImports ?? true,
+            includeCompletionsForImportStatements:
+                config.suggest?.includeCompletionsForImportStatements ?? true,
+            includeCompletionsWithInsertText: true
         };
     }
 
diff --git a/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts b/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts
index a04c8075..a08464bc 100644
--- a/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts
+++ b/packages/language-server/src/plugins/typescript/features/CompletionProvider.ts
@@ -2,6 +2,7 @@ import ts from 'typescript';
 import {
     CancellationToken,
     CompletionContext,
+    CompletionItem,
     CompletionList,
     CompletionTriggerKind,
     MarkupContent,
@@ -137,11 +138,18 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
             return null;
         }
 
+        const originalOffset = document.offsetAt(position);
+        const wordRange = getWordRangeAt(document.getText(), originalOffset, {
+            left: /\S+$/,
+            right: /[^\w$:]/
+        });
+
         const eventAndSlotLetCompletions = await this.getEventAndSlotLetCompletions(
             lang,
             document,
             tsDoc,
-            position
+            position,
+            wordRange
         );
 
         if (isEventOrSlotLetTriggerCharacter) {
@@ -163,6 +171,7 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
         }
 
         const existingImports = this.getExistingImports(document);
+        const wordRangeStartPosition = document.positionAt(wordRange.start);
         const completionItems = completions
             .filter(isValidCompletion(document, position))
             .map((comp) =>
@@ -176,10 +185,12 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
             )
             .filter(isNotNullOrUndefined)
             .map((comp) => mapCompletionItemToOriginal(fragment, comp))
+            .map((comp) => this.fixTextEditRange(wordRangeStartPosition, comp))
             .concat(eventAndSlotLetCompletions);
 
         const completionList = CompletionList.create(completionItems, !!tsDoc.parserError);
         this.lastCompletion = { key: document.getFilePath() || '', position, completionList };
+
         return completionList;
     }
 
@@ -214,19 +225,15 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
         lang: ts.LanguageService,
         doc: Document,
         tsDoc: SvelteDocumentSnapshot,
-        originalPosition: Position
+        originalPosition: Position,
+        wordRange: { start: number; end: number }
     ): Promise<Array<AppCompletionItem<CompletionEntryWithIdentifer>>> {
         const componentInfo = await getComponentAtPosition(lang, doc, tsDoc, originalPosition);
         if (!componentInfo) {
             return [];
         }
 
-        const offset = doc.offsetAt(originalPosition);
-        const { start, end } = getWordRangeAt(doc.getText(), offset, {
-            left: /\S+$/,
-            right: /[^\w$:]/
-        });
-
+        const { start, end } = wordRange;
         const events = componentInfo.getEvents().map((event) => mapToCompletionEntry(event, 'on:'));
         const slotLets = componentInfo
             .getSlotLets()
@@ -260,13 +267,17 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
             return null;
         }
 
-        const { label, insertText, isSvelteComp } = completionLabelAndInsert;
+        const { label, insertText, isSvelteComp, replacementSpan } = completionLabelAndInsert;
         // TS may suggest another Svelte component even if there already exists an import
         // with the same name, because under the hood every Svelte component is postfixed
         // with `__SvelteComponent`. In this case, filter out this completion by returning null.
         if (isSvelteComp && existingImports.has(label)) {
             return null;
         }
+        const textEdit =
+            replacementSpan && insertText
+                ? TextEdit.replace(convertRange(fragment, replacementSpan), insertText)
+                : undefined;
 
         return {
             label,
@@ -276,6 +287,7 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
             // Make sure svelte component takes precedence
             sortText: isSvelteComp ? '-1' : comp.sortText,
             preselect: isSvelteComp ? true : comp.isRecommended,
+            textEdit,
             // pass essential data for resolving completion
             data: {
                 ...comp,
@@ -289,14 +301,14 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
         fragment: SvelteSnapshotFragment,
         comp: ts.CompletionEntry
     ) {
-        let { kind, kindModifiers, name, source } = comp;
-        const isScriptElement = kind === ts.ScriptElementKind.scriptElement;
+        let { name, insertText, kindModifiers } = comp;
+        const isScriptElement = comp.kind === ts.ScriptElementKind.scriptElement;
         const hasModifier = Boolean(comp.kindModifiers);
         const isSvelteComp = this.isSvelteComponentImport(name);
         if (isSvelteComp) {
             name = this.changeSvelteComponentName(name);
 
-            if (this.isExistingSvelteComponentImport(fragment, name, source)) {
+            if (this.isExistingSvelteComponentImport(fragment, name, comp.source)) {
                 return null;
             }
         }
@@ -311,6 +323,15 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
             };
         }
 
+        if (comp.isImportStatementCompletion && insertText) {
+            return {
+                label: name,
+                isSvelteComp,
+                insertText: this.changeSvelteComponentName(insertText),
+                replacementSpan: comp.replacementSpan
+            };
+        }
+
         return {
             label: name,
             isSvelteComp
@@ -326,6 +347,51 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
         return !!source && !!fragment.text.match(importStatement);
     }
 
+    /**
+     * If the textEdit is out of the word range of the triggered position
+     * vscode would refuse to show the completions
+     * split those edits into additionalTextEdit to fix it
+     */
+    private fixTextEditRange(wordRangePosition: Position, completionItem: CompletionItem) {
+        const { textEdit } = completionItem;
+        if (!textEdit || !TextEdit.is(textEdit)) {
+            return completionItem;
+        }
+
+        const {
+            newText,
+            range: { start }
+        } = textEdit;
+
+        const wordRangeStartCharacter = wordRangePosition.character;
+        if (
+            wordRangePosition.line !== wordRangePosition.line ||
+            start.character > wordRangePosition.character
+        ) {
+            return completionItem;
+        }
+
+        textEdit.newText = newText.substring(wordRangeStartCharacter - start.character);
+        textEdit.range.start = {
+            line: start.line,
+            character: wordRangeStartCharacter
+        };
+        completionItem.additionalTextEdits = [
+            TextEdit.replace(
+                {
+                    start,
+                    end: {
+                        line: start.line,
+                        character: wordRangeStartCharacter
+                    }
+                },
+                newText.substring(0, wordRangeStartCharacter - start.character)
+            )
+        ];
+
+        return completionItem;
+    }
+
     async resolveCompletion(
         document: Document,
         completionItem: AppCompletionItem<CompletionEntryWithIdentifer>,
diff --git a/packages/svelte2tsx/src/svelte2tsx/processInstanceScriptContent.ts b/packages/svelte2tsx/src/svelte2tsx/processInstanceScriptContent.ts
index 7d65deed..e578d675 100644
--- a/packages/svelte2tsx/src/svelte2tsx/processInstanceScriptContent.ts
+++ b/packages/svelte2tsx/src/svelte2tsx/processInstanceScriptContent.ts
@@ -286,6 +286,15 @@ export function processInstanceScriptContent(
             events.checkIfImportIsEventDispatcher(node);
         }
 
+        // workaround for import statement completion
+        if (ts.isImportEqualsDeclaration(node)) {
+            const end = node.getEnd() + astOffset;
+
+            if (str.original[end - 1] !== ';') {
+                str.appendLeft(end, ';');
+            }
+        }
+
         if (ts.isVariableDeclaration(node)) {
             events.checkIfIsStringLiteralDeclaration(node);
             events.checkIfDeclarationInstantiatedEventDispatcher(node);


Test patch:
diff --git a/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts b/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts
index a1c24f29..575365db 100644
--- a/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts
+++ b/packages/language-server/test/plugins/typescript/features/CompletionProvider.test.ts
@@ -77,7 +77,8 @@ describe('CompletionProviderImpl', () => {
             kind: CompletionItemKind.Method,
             sortText: '1',
             commitCharacters: ['.', ',', '('],
-            preselect: undefined
+            preselect: undefined,
+            textEdit: undefined
         });
     });
 
@@ -102,7 +103,8 @@ describe('CompletionProviderImpl', () => {
             kind: CompletionItemKind.Field,
             sortText: '1',
             commitCharacters: ['.', ',', '('],
-            preselect: undefined
+            preselect: undefined,
+            textEdit: undefined
         });
     });
 
@@ -995,6 +997,62 @@ describe('CompletionProviderImpl', () => {
             }
         ]);
     });
+
+    it('provides import statement completion', async () => {
+        const { completionProvider, document } = setup('importstatementcompletions.svelte');
+
+        const completions = await completionProvider.getCompletions(
+            document,
+            {
+                line: 1,
+                character: 14
+            },
+            {
+                triggerKind: CompletionTriggerKind.Invoked
+            }
+        );
+
+        const item = completions?.items.find((item) => item.label === 'blubb');
+
+        delete item?.data;
+
+        assert.deepStrictEqual(item, {
+            additionalTextEdits: [
+                {
+                    newText: 'import ',
+                    range: {
+                        end: {
+                            character: 11,
+                            line: 1
+                        },
+                        start: {
+                            character: 4,
+                            line: 1
+                        }
+                    }
+                }
+            ],
+            label: 'blubb',
+            insertText: 'import { blubb } from "../definitions";',
+            kind: CompletionItemKind.Function,
+            sortText: '1',
+            commitCharacters: ['.', ',', '('],
+            preselect: undefined,
+            textEdit: {
+                newText: '{ blubb } from "../definitions";',
+                range: {
+                    end: {
+                        character: 15,
+                        line: 1
+                    },
+                    start: {
+                        character: 11,
+                        line: 1
+                    }
+                }
+            }
+        });
+    });
 });
 
 function harmonizeNewLines(input?: string) {
diff --git a/packages/language-server/test/plugins/typescript/features/preferences.test.ts b/packages/language-server/test/plugins/typescript/features/preferences.test.ts
index f27ae875..e319c45a 100644
--- a/packages/language-server/test/plugins/typescript/features/preferences.test.ts
+++ b/packages/language-server/test/plugins/typescript/features/preferences.test.ts
@@ -43,7 +43,9 @@ describe('ts user preferences', () => {
                 quoteStyle: 'single'
             },
             suggest: {
-                autoImports: true
+                autoImports: true,
+                includeAutomaticOptionalChainCompletions: undefined,
+                includeCompletionsForImportStatements: undefined
             }
         };
     }
@@ -114,7 +116,11 @@ describe('ts user preferences', () => {
     it('provides auto import suggestions according to preferences', async () => {
         const { docManager, document } = setup('code-action.svelte');
         const lsAndTsDocResolver = createLSAndTSDocResolver(docManager, {
-            suggest: { autoImports: false }
+            suggest: {
+                autoImports: false,
+                includeAutomaticOptionalChainCompletions: undefined,
+                includeCompletionsForImportStatements: undefined
+            }
         });
         const completionProvider = new CompletionsProviderImpl(lsAndTsDocResolver);
 
diff --git a/packages/language-server/test/plugins/typescript/testfiles/completions/importstatementcompletions.svelte b/packages/language-server/test/plugins/typescript/testfiles/completions/importstatementcompletions.svelte
new file mode 100644
index 00000000..06c8fb9b
--- /dev/null
+++ b/packages/language-server/test/plugins/typescript/testfiles/completions/importstatementcompletions.svelte
@@ -0,0 +1,3 @@
+<script lang="ts">
+    import blu
+</script>
\ No newline at end of file
diff --git a/packages/svelte2tsx/test/svelte2tsx/samples/import-equal/expected.tsx b/packages/svelte2tsx/test/svelte2tsx/samples/import-equal/expected.tsx
new file mode 100644
index 00000000..b1e7aabe
--- /dev/null
+++ b/packages/svelte2tsx/test/svelte2tsx/samples/import-equal/expected.tsx
@@ -0,0 +1,11 @@
+///<reference types="svelte" />
+<></>;function render() {
+
+    import A;
+    import C = require('');
+;
+() => (<></>);
+return { props: {}, slots: {}, getters: {}, events: {} }}
+
+export default class Input__SvelteComponent_ extends __sveltets_1_createSvelte2TsxComponent(__sveltets_1_partial(__sveltets_1_with_any_event(render()))) {
+}
\ No newline at end of file
diff --git a/packages/svelte2tsx/test/svelte2tsx/samples/import-equal/input.svelte b/packages/svelte2tsx/test/svelte2tsx/samples/import-equal/input.svelte
new file mode 100644
index 00000000..7391b714
--- /dev/null
+++ b/packages/svelte2tsx/test/svelte2tsx/samples/import-equal/input.svelte
@@ -0,0 +1,4 @@
+<script>
+    import A
+    import C = require('');
+</script>
\ No newline at end of file