Skip to content

Commit 6a04679

Browse files
authored
fix: rewrite relative imports reaching outside root (#2942)
When using svelte-check's `--incremental/--tsgo` flags the files are written to a generated folder, making use of the rootDirs tsconfig feature. This has one limitation: when a relative import within a generated file reaches into a directory that is not covered by rootDirs, it cannot be resolved. This therefore adds a new option to svelte2tsx to rewrite imports as part of the transformation. Note that this will not help with transforming files outside of the working directory, but when you e.g. import a regular TS/JS file that way it now works.
1 parent c82f540 commit 6a04679

File tree

17 files changed

+484
-29
lines changed

17 files changed

+484
-29
lines changed

.changeset/icy-clubs-sniff.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'svelte-language-server': patch
3+
'svelte-check': patch
4+
'svelte2tsx': patch
5+
---
6+
7+
fix: handle relative imports reaching outside working directory when using `--incremental/--tsgo` flags

packages/language-server/src/plugins/typescript/DocumentSnapshot.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ export interface SvelteSnapshotOptions {
7272
transformOnTemplateError: boolean;
7373
typingsNamespace: string;
7474
emitJsDoc?: boolean;
75+
rewriteExternalImports?: {
76+
workspacePath: string;
77+
generatedPath: string;
78+
};
7579
}
7680

7781
const ambientPathPattern = /node_modules[\/\\]svelte[\/\\]types[\/\\]ambient\.d\.ts$/;
@@ -213,7 +217,8 @@ function preprocessSvelteFile(document: Document, options: SvelteSnapshotOptions
213217
accessors:
214218
document.config?.compilerOptions?.accessors ??
215219
document.config?.compilerOptions?.customElement,
216-
emitJsDoc: options.emitJsDoc
220+
emitJsDoc: options.emitJsDoc,
221+
rewriteExternalImports: options.rewriteExternalImports
217222
});
218223
text = tsx.code;
219224
tsxMap = tsx.map as EncodedSourceMap;

packages/language-server/src/svelte-check.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,22 @@ import { groupBy } from 'lodash';
2929
export function mapSvelteCheckDiagnostics(
3030
sourcePath: string,
3131
sourceText: string,
32-
tsDiagnostics: ts.Diagnostic[]
32+
tsDiagnostics: ts.Diagnostic[],
33+
options?: {
34+
rewriteExternalImports?: {
35+
workspacePath: string;
36+
generatedPath: string;
37+
};
38+
}
3339
): Diagnostic[] {
3440
const document = new Document(pathToUrl(sourcePath), sourceText);
3541
const snapshot = DocumentSnapshot.fromDocument(document, {
3642
parse: document.compiler?.parse,
3743
version: document.compiler?.VERSION,
3844
transformOnTemplateError: false,
3945
typingsNamespace: 'svelteHTML',
40-
emitJsDoc: true
46+
emitJsDoc: true,
47+
rewriteExternalImports: options?.rewriteExternalImports
4148
} satisfies SvelteSnapshotOptions) as SvelteDocumentSnapshot;
4249

4350
return mapAndFilterDiagnostics(tsDiagnostics, document, snapshot);

packages/svelte-check/src/incremental.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,11 @@ export async function emitSvelteFiles(
248248
isTsFile,
249249
mode: 'ts',
250250
emitOnTemplateError: false,
251-
emitJsDoc: true // without this, tsc/tsgo will choke on the syntactic errors and not emit semantic errors
251+
emitJsDoc: true, // without this, tsc/tsgo will choke on the syntactic errors and not emit semantic errors
252+
rewriteExternalImports: {
253+
workspacePath,
254+
generatedPath: outPath
255+
}
252256
});
253257

254258
fs.writeFileSync(outPath, tsx.code, 'utf-8');
@@ -306,14 +310,23 @@ export async function emitSvelteFiles(
306310
const text = fs.readFileSync(sourcePath, 'utf-8');
307311
const isTsFile = sourcePath.endsWith('.ts');
308312

309-
const result = internalHelpers.upsertKitFile(ts, sourcePath, kitFilesSettings, () =>
310-
ts.createSourceFile(
311-
sourcePath,
312-
text,
313-
ts.ScriptTarget.Latest,
314-
true,
315-
isTsFile ? ts.ScriptKind.TS : ts.ScriptKind.JS
316-
)
313+
const result = internalHelpers.upsertKitFile(
314+
ts,
315+
sourcePath,
316+
kitFilesSettings,
317+
() =>
318+
ts.createSourceFile(
319+
sourcePath,
320+
text,
321+
ts.ScriptTarget.Latest,
322+
true,
323+
isTsFile ? ts.ScriptKind.TS : ts.ScriptKind.JS
324+
),
325+
undefined,
326+
{
327+
workspacePath,
328+
generatedPath: outPath
329+
}
317330
);
318331

319332
if (!result) {
@@ -532,7 +545,7 @@ export function mapCliDiagnosticsToLsp(
532545
);
533546
const excludedSourcePaths = new Set(
534547
emitResult.entries
535-
.map((e) => e.addedCode?.length && path.normalize(e.sourcePath))
548+
.map((e) => e.isKitFile && e.addedCode?.length && path.normalize(e.sourcePath))
536549
.filter((p): p is string => !!p)
537550
);
538551

@@ -613,7 +626,13 @@ export function mapCliDiagnosticsToLsp(
613626
const mappedDiagnostics = mapSvelteCheckDiagnostics(
614627
entry.sourcePath,
615628
sourceText,
616-
tsDiagnostics
629+
tsDiagnostics,
630+
{
631+
rewriteExternalImports: {
632+
workspacePath: emitResult.workspacePath,
633+
generatedPath: entry.outPath
634+
}
635+
}
617636
);
618637

619638
results.set(entry.sourcePath, {

packages/svelte-check/test-error/Index.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script lang="ts">
22
import Jsdoc from './Jsdoc.svelte';
33
import { foo } from './relative';
4+
import nope from '../../outside';
45
56
let count: number = 'oops';
67
let x = 0;

packages/svelte-check/test-sanity.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,25 +139,31 @@ test('clean project (incremental, warm cache)', {
139139
const errors = [
140140
{
141141
file: 'Index.svelte',
142-
line: 4,
142+
line: 3,
143+
column: 21,
144+
code: 2307
145+
},
146+
{
147+
file: 'Index.svelte',
148+
line: 5,
143149
column: 8,
144150
code: 2322
145151
},
146152
{
147153
file: 'Index.svelte',
148-
line: 7,
154+
line: 8,
149155
column: 4,
150156
code: 2367
151157
},
152158
{
153159
file: 'Index.svelte',
154-
line: 10,
160+
line: 11,
155161
column: 4,
156162
code: 2367
157163
},
158164
{
159165
file: 'Index.svelte',
160-
line: 14,
166+
line: 15,
161167
column: 1,
162168
code: 2741
163169
},

packages/svelte2tsx/index.d.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ export function svelte2tsx(
9090
* valid JS that tsc can process without errors.
9191
*/
9292
emitJsDoc?: boolean;
93+
/**
94+
* Rewrites relative imports that resolve outside the workspace so they stay valid
95+
* from the generated file location.
96+
*/
97+
rewriteExternalImports?: InternalHelpers.RewriteExternalImportsConfig;
9398
}
9499
): SvelteCompiledToTsx
95100

@@ -163,7 +168,8 @@ export const internalHelpers: {
163168
fileName: string,
164169
kitFilesSettings: InternalHelpers.KitFilesSettings,
165170
getSource: () => ts.SourceFile | undefined,
166-
surround?: (code: string) => string
171+
surround?: (code: string) => string,
172+
rewriteExternalImports?: InternalHelpers.RewriteExternalImportsConfig
167173
) => { text: string; addedCode: InternalHelpers.AddedCode[] } | undefined,
168174
toVirtualPos: (pos: number, addedCode: InternalHelpers.AddedCode[]) => number,
169175
toOriginalPos: (pos: number, addedCode: InternalHelpers.AddedCode[]) => {pos: number; inGenerated: boolean},
@@ -201,4 +207,9 @@ export namespace InternalHelpers {
201207
universalHooksPath: string;
202208
paramsPath: string;
203209
}
210+
211+
export interface RewriteExternalImportsConfig {
212+
workspacePath: string;
213+
generatedPath: string;
214+
}
204215
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import path from 'path';
2+
import type ts from 'typescript';
3+
4+
export type RewriteExternalImportsOptions = {
5+
sourcePath: string;
6+
generatedPath: string;
7+
workspacePath: string;
8+
};
9+
10+
export type ExternalImportRewrite = {
11+
rewritten: string;
12+
insertedPrefix: string;
13+
};
14+
15+
function toPosixPath(value: string): string {
16+
return value.replace(/\\/g, '/');
17+
}
18+
19+
function isWithinDirectory(filePath: string, directoryPath: string): boolean {
20+
const relative = path.relative(path.resolve(directoryPath), path.resolve(filePath));
21+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
22+
}
23+
24+
function splitImportSpecifier(specifier: string): { pathPart: string; suffix: string } {
25+
const queryIndex = specifier.indexOf('?');
26+
const hashIndex = specifier.indexOf('#');
27+
const cutIndex =
28+
queryIndex === -1
29+
? hashIndex
30+
: hashIndex === -1
31+
? queryIndex
32+
: Math.min(queryIndex, hashIndex);
33+
34+
if (cutIndex === -1) {
35+
return { pathPart: specifier, suffix: '' };
36+
}
37+
38+
return {
39+
pathPart: specifier.slice(0, cutIndex),
40+
suffix: specifier.slice(cutIndex)
41+
};
42+
}
43+
44+
export function getExternalImportRewrite(
45+
specifier: string,
46+
options: RewriteExternalImportsOptions
47+
): ExternalImportRewrite | null {
48+
const sourceDir = path.dirname(options.sourcePath);
49+
const generatedDir = path.dirname(options.generatedPath);
50+
const { pathPart, suffix } = splitImportSpecifier(specifier);
51+
if (!pathPart.startsWith('../')) {
52+
return null;
53+
}
54+
55+
const targetPath = path.resolve(sourceDir, pathPart);
56+
if (isWithinDirectory(targetPath, options.workspacePath)) {
57+
return null;
58+
}
59+
60+
const rewrittenRelative = toPosixPath(path.relative(generatedDir, targetPath));
61+
const rewritten = `${rewrittenRelative}${suffix}`;
62+
if (rewritten === specifier) {
63+
return null;
64+
}
65+
66+
return {
67+
rewritten,
68+
insertedPrefix: rewrittenRelative.slice(0, rewrittenRelative.length - pathPart.length)
69+
};
70+
}
71+
72+
export function getImportTypeSpecifierLiteral(
73+
ts_impl: typeof ts,
74+
node: ts.ImportTypeNode
75+
): ts.StringLiteralLike | undefined {
76+
const argument = node.argument;
77+
if (ts_impl.isLiteralTypeNode(argument) && ts_impl.isStringLiteralLike(argument.literal)) {
78+
return argument.literal;
79+
}
80+
return undefined;
81+
}
82+
83+
function rewriteImportTypesInNode(
84+
ts_impl: typeof ts,
85+
node: ts.Node,
86+
applyImportRewrite: (module_specifier: ts.StringLiteralLike) => void
87+
) {
88+
if (ts_impl.isImportTypeNode(node)) {
89+
const specifier = getImportTypeSpecifierLiteral(ts_impl, node);
90+
if (specifier) {
91+
applyImportRewrite(specifier);
92+
}
93+
}
94+
ts_impl.forEachChild(node, (child) =>
95+
rewriteImportTypesInNode(ts_impl, child, applyImportRewrite)
96+
);
97+
}
98+
99+
export function rewriteExternalImportsInNode(
100+
ts_impl: typeof ts,
101+
node: ts.Node,
102+
options: RewriteExternalImportsOptions,
103+
on_rewrite: (module_specifier: ts.StringLiteralLike, rewrite: ExternalImportRewrite) => void
104+
) {
105+
const applyImportRewrite = (module_specifier: ts.StringLiteralLike) => {
106+
const rewrite = getExternalImportRewrite(module_specifier.text, options);
107+
if (rewrite) {
108+
on_rewrite(module_specifier, rewrite);
109+
}
110+
};
111+
112+
if (ts_impl.isImportDeclaration(node) || ts_impl.isExportDeclaration(node)) {
113+
if (node.moduleSpecifier && ts_impl.isStringLiteralLike(node.moduleSpecifier)) {
114+
applyImportRewrite(node.moduleSpecifier);
115+
}
116+
} else if (ts_impl.isCallExpression(node)) {
117+
const firstArg = node.arguments[0];
118+
if (firstArg && ts_impl.isStringLiteralLike(firstArg)) {
119+
const isDynamicImport = node.expression.kind === ts_impl.SyntaxKind.ImportKeyword;
120+
const isRequireCall =
121+
ts_impl.isIdentifier(node.expression) && node.expression.text === 'require';
122+
if (isDynamicImport || isRequireCall) {
123+
applyImportRewrite(firstArg);
124+
}
125+
}
126+
} else if (ts_impl.isImportTypeNode(node)) {
127+
const specifier = getImportTypeSpecifierLiteral(ts_impl, node);
128+
if (specifier) {
129+
applyImportRewrite(specifier);
130+
}
131+
}
132+
133+
const jsDoc = (node as ts.Node & { jsDoc?: ts.NodeArray<ts.JSDoc> }).jsDoc;
134+
if (jsDoc) {
135+
for (const doc of jsDoc) {
136+
rewriteImportTypesInNode(ts_impl, doc, applyImportRewrite);
137+
}
138+
}
139+
}
140+
141+
export function forEachExternalImportRewrite(
142+
ts_impl: typeof ts,
143+
source: ts.SourceFile,
144+
options: RewriteExternalImportsOptions,
145+
on_rewrite: (module_specifier: ts.StringLiteralLike, rewrite: ExternalImportRewrite) => void
146+
) {
147+
const visit = (node: ts.Node) => {
148+
rewriteExternalImportsInNode(ts_impl, node, options, on_rewrite);
149+
ts_impl.forEachChild(node, visit);
150+
};
151+
152+
ts_impl.forEachChild(source, visit);
153+
}

0 commit comments

Comments
 (0)