Skip to content

Commit 936fa6f

Browse files
authored
(feat) better ./$types auto imports (#1592)
TypeScript doesn't resolve imports to SvelteKit's $types very well, help it doing better
1 parent 967806d commit 936fa6f

File tree

3 files changed

+126
-21
lines changed

3 files changed

+126
-21
lines changed

packages/language-server/src/lib/documents/configLoader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface SvelteConfig {
2323
preprocess?: InternalPreprocessorGroup | InternalPreprocessorGroup[];
2424
loadConfigError?: any;
2525
isFallbackConfig?: boolean;
26+
kit?: any;
2627
}
2728

2829
const DEFAULT_OPTIONS: CompileOptions = {

packages/language-server/src/plugins/typescript/features/CompletionProvider.ts

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { basename, dirname } from 'path';
12
import ts from 'typescript';
23
import {
34
CancellationToken,
@@ -515,7 +516,7 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
515516
completionItem: AppCompletionItem<CompletionEntryWithIdentifier>,
516517
cancellationToken?: CancellationToken
517518
): Promise<AppCompletionItem<CompletionEntryWithIdentifier>> {
518-
const { data: comp } = completionItem;
519+
let { data: comp } = completionItem;
519520
const { tsDoc, lang, userPreferences } = await this.lsAndTsDocResolver.getLSAndTSDoc(
520521
document
521522
);
@@ -530,19 +531,44 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
530531
? this.fixUserPreferencesForSvelteComponentImport(userPreferences)
531532
: userPreferences;
532533

533-
const detail = lang.getCompletionEntryDetails(
534-
filePath,
535-
tsDoc.offsetAt(tsDoc.getGeneratedPosition(comp.position)),
536-
comp.name,
537-
{},
538-
comp.source,
539-
errorPreventingUserPreferences,
540-
comp.data
541-
);
534+
let is$typeImport = false;
535+
const originalComp = { ...comp };
536+
if (basename(filePath).startsWith('+') && comp.source?.includes('.svelte-kit/types')) {
537+
// resolve path from filePath to svelte-kit/types
538+
// src/routes/foo/+page.svelte -> .svelte-kit/types/foo/$types.d.ts
539+
const routesFolder = document.config?.kit?.files?.routes || 'src/routes';
540+
const relativeFilePath = filePath.split(routesFolder)[1]?.slice(1);
541+
if (relativeFilePath) {
542+
is$typeImport = true;
543+
comp.source =
544+
comp.source.split('.svelte-kit/types')[0] +
545+
// note the missing .d.ts at the end - TS wants it that way for some reason
546+
`.svelte-kit/types/${routesFolder}/${dirname(relativeFilePath)}/$types`;
547+
comp.data = undefined;
548+
}
549+
}
550+
551+
const getDetail = () =>
552+
lang.getCompletionEntryDetails(
553+
filePath,
554+
tsDoc.offsetAt(tsDoc.getGeneratedPosition(comp!.position)),
555+
comp!.name,
556+
{},
557+
comp!.source,
558+
errorPreventingUserPreferences,
559+
comp!.data
560+
);
561+
let detail = getDetail();
562+
if (!detail && is$typeImport) {
563+
// try again
564+
is$typeImport = false;
565+
comp = originalComp;
566+
detail = getDetail();
567+
}
542568

543569
if (detail) {
544570
const { detail: itemDetail, documentation: itemDocumentation } =
545-
this.getCompletionDocument(detail);
571+
this.getCompletionDocument(detail, is$typeImport);
546572

547573
completionItem.detail = itemDetail;
548574
completionItem.documentation = itemDocumentation;
@@ -562,7 +588,8 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
562588
tsDoc,
563589
change,
564590
isImport,
565-
comp.position
591+
comp.position,
592+
is$typeImport
566593
)
567594
);
568595
}
@@ -576,12 +603,16 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
576603
return completionItem;
577604
}
578605

579-
private getCompletionDocument(compDetail: ts.CompletionEntryDetails) {
606+
private getCompletionDocument(compDetail: ts.CompletionEntryDetails, is$typeImport: boolean) {
580607
const { sourceDisplay, documentation: tsDocumentation, displayParts, tags } = compDetail;
581608
let detail: string = changeSvelteComponentName(ts.displayPartsToString(displayParts));
582609

583610
if (sourceDisplay) {
584-
const importPath = ts.displayPartsToString(sourceDisplay);
611+
let importPath = ts.displayPartsToString(sourceDisplay);
612+
if (is$typeImport) {
613+
// Take into account Node16 moduleResolution
614+
importPath = `'./$types${importPath.endsWith('.js') ? '.js' : ''}'`;
615+
}
585616
detail = `Auto import from ${importPath}\n${detail}`;
586617
}
587618

@@ -601,15 +632,17 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
601632
snapshot: SvelteDocumentSnapshot,
602633
changes: ts.FileTextChanges,
603634
isImport: boolean,
604-
originalTriggerPosition: Position
635+
originalTriggerPosition: Position,
636+
is$typeImport?: boolean
605637
): TextEdit[] {
606638
return changes.textChanges.map((change) =>
607639
this.codeActionChangeToTextEdit(
608640
doc,
609641
snapshot,
610642
change,
611643
isImport,
612-
originalTriggerPosition
644+
originalTriggerPosition,
645+
is$typeImport
613646
)
614647
);
615648
}
@@ -619,11 +652,13 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
619652
snapshot: SvelteDocumentSnapshot,
620653
change: ts.TextChange,
621654
isImport: boolean,
622-
originalTriggerPosition: Position
655+
originalTriggerPosition: Position,
656+
is$typeImport?: boolean
623657
): TextEdit {
624658
change.newText = this.changeComponentImport(
625659
change.newText,
626-
isInScript(originalTriggerPosition, doc)
660+
isInScript(originalTriggerPosition, doc),
661+
is$typeImport
627662
);
628663

629664
const scriptTagInfo = snapshot.scriptInfo || snapshot.moduleScriptInfo;
@@ -701,7 +736,19 @@ export class CompletionsProviderImpl implements CompletionsProvider<CompletionEn
701736
return className.endsWith('__SvelteComponent_');
702737
}
703738

704-
private changeComponentImport(importText: string, actionTriggeredInScript: boolean) {
739+
private changeComponentImport(
740+
importText: string,
741+
actionTriggeredInScript: boolean,
742+
is$typeImport?: boolean
743+
) {
744+
if (is$typeImport && importText.startsWith('import ')) {
745+
// Take into account Node16 moduleResolution
746+
return importText.replace(
747+
/(['"])(.+?)['"]/,
748+
(_match, quote, path) =>
749+
`${quote}./$types${path.endsWith('.js') ? '.js' : ''}${quote}`
750+
);
751+
}
705752
const changedName = changeSvelteComponentName(importText);
706753
if (importText !== changedName || !actionTriggeredInScript) {
707754
// For some reason, TS sometimes adds the `type` modifier. Remove it

packages/typescript-plugin/src/language-service/completions.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { basename, dirname } from 'path';
12
import type ts from 'typescript/lib/tsserverlibrary';
23
import { Logger } from '../logger';
34
import { isSvelteFilePath, replaceDeep } from '../utils';
@@ -38,7 +39,28 @@ export function decorateCompletions(ls: ts.LanguageService, logger: Logger): voi
3839
preferences,
3940
data
4041
) => {
41-
const details = getCompletionEntryDetails(
42+
let is$typeImport = false;
43+
const originalSource = source;
44+
const originalData = data ? { ...data } : undefined;
45+
if (basename(fileName).startsWith('+') && source?.includes('.svelte-kit/types')) {
46+
// resolve path from FileName to svelte-kit/types
47+
// src/routes/foo/+page.svelte -> .svelte-kit/types/foo/$types.d.ts
48+
const routesFolder = 'src/routes'; // TODO somehow get access to kit.files.routes in here
49+
const relativeFileName = fileName.split(routesFolder)[1]?.slice(1);
50+
if (relativeFileName) {
51+
is$typeImport = true;
52+
source =
53+
source.split('.svelte-kit/types')[0] +
54+
// note the missing .d.ts at the end - TS wants it that way for some reason
55+
`.svelte-kit/types/${routesFolder}/${dirname(relativeFileName)}/$types`;
56+
if (data) {
57+
data.fileName = data.fileName?.replace(originalSource!, source);
58+
data.moduleSpecifier = data.moduleSpecifier?.replace(originalSource!, source);
59+
}
60+
}
61+
}
62+
63+
let details = getCompletionEntryDetails(
4264
fileName,
4365
position,
4466
entryName,
@@ -47,8 +69,35 @@ export function decorateCompletions(ls: ts.LanguageService, logger: Logger): voi
4769
preferences,
4870
data
4971
);
72+
if (!details && is$typeImport) {
73+
// Try again
74+
is$typeImport = false;
75+
details = getCompletionEntryDetails(
76+
fileName,
77+
position,
78+
entryName,
79+
formatOptions,
80+
originalSource,
81+
preferences,
82+
originalData
83+
);
84+
}
85+
5086
if (details) {
51-
if (isSvelteFilePath(source || '')) {
87+
if (is$typeImport) {
88+
details.codeActions = details.codeActions?.map((codeAction) => {
89+
codeAction.description = adjustPath(codeAction.description);
90+
codeAction.changes = codeAction.changes.map((change) => {
91+
change.textChanges = change.textChanges.map((textChange) => {
92+
textChange.newText = adjustPath(textChange.newText);
93+
return textChange;
94+
});
95+
return change;
96+
});
97+
return codeAction;
98+
});
99+
return details;
100+
} else if (isSvelteFilePath(source || '')) {
52101
logger.debug('TS found Svelte Component import completion details');
53102
return replaceDeep(details, componentPostfix, '');
54103
} else {
@@ -79,3 +128,11 @@ export function decorateCompletions(ls: ts.LanguageService, logger: Logger): voi
79128
return replaceDeep(svelteDetails, componentPostfix, '');
80129
};
81130
}
131+
132+
function adjustPath(path: string) {
133+
return path.replace(
134+
/(['"])(.+?)['"]/,
135+
// .js logic for node16 module resolution
136+
(_match, quote, path) => `${quote}./$types${path.endsWith('.js') ? '.js' : ''}${quote}`
137+
);
138+
}

0 commit comments

Comments
 (0)