diff --git a/core/src/misc/transpile-file/__tests__/imports-transformer.test.ts b/core/src/misc/transpile-file/__tests__/imports-transformer.test.ts new file mode 100644 index 000000000..dd58c0780 --- /dev/null +++ b/core/src/misc/transpile-file/__tests__/imports-transformer.test.ts @@ -0,0 +1,53 @@ +import * as ts from 'typescript'; +import { importsTransformer } from '../imports-transformer'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Helper to transpile a snippet with the imports transformer. +function transpile(source: string, fileName: string): string { + const sourceFile = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TS + ); + + const result = ts.transform(sourceFile, [importsTransformer]); + const printer = ts.createPrinter(); + const transformed = printer.printFile(result.transformed[0] as ts.SourceFile); + result.dispose(); + return transformed; +} + +describe('importsTransformer', () => { + const tmpDir = path.join(__dirname, 'tmp'); + const originalFile = path.join(tmpDir, 'original.ts'); + const movedFile = path.join(tmpDir, 'sub', 'moved.ts'); + + beforeAll(() => { + fs.mkdirSync(path.dirname(movedFile), { recursive: true }); + fs.writeFileSync(originalFile, ''); + fs.writeFileSync(movedFile, ''); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('rewrites relative imports when the target file is moved', () => { + const source = `import { foo } from "./moved";\nexport const bar = foo;`; + // Simulate that the source file lives next to the original location, + // but the imported file has been moved to ./sub/moved.ts. + const transformed = transpile(source, originalFile); + + // The transformer should adjust the import to point to the new location. + expect(transformed).toContain(`import { foo } from "./sub/moved"`); + }); + + it('leaves non‑relative imports untouched', () => { + const source = `import * as React from "react";`; + const transformed = transpile(source, originalFile); + expect(transformed).toContain(`import * as React from "react"`); + }); +}); diff --git a/core/src/misc/transpile-file/imports-transformer.ts b/core/src/misc/transpile-file/imports-transformer.ts index 9e7f824fa..f31fdeab6 100644 --- a/core/src/misc/transpile-file/imports-transformer.ts +++ b/core/src/misc/transpile-file/imports-transformer.ts @@ -1,100 +1,42 @@ -import * as ts from "typescript"; - -export function importToGlobalTransformer(): ts.TransformerFactory { - return (context) => { - const visit: ts.Visitor = (node) => { - if (ts.isImportDeclaration(node)) { - const moduleName = node.moduleSpecifier.getText().slice(1, -1); // Remove quotes - const importClause = node.importClause; - - const declarations: ts.VariableDeclaration[] = []; - - if (importClause) { - // Handle default imports - // Skip over 'import type' statements - - if (importClause.isTypeOnly) { - return node; - } - - if (importClause.name) { - const defaultImportName = importClause.name.getText(); - declarations.push( - ts.factory.createVariableDeclaration( - defaultImportName, - undefined, - undefined, - ts.factory.createPropertyAccessExpression( - ts.factory.createElementAccessExpression( - ts.factory.createIdentifier("__imports"), - ts.factory.createStringLiteral(moduleName) - ), - ts.factory.createIdentifier("default") - ) - ) - ); - } - - // Handle named and namespace imports - const namedBindings = importClause.namedBindings; - if (namedBindings) { - if (ts.isNamedImports(namedBindings)) { - namedBindings.elements.forEach((el) => { - const name = el.name.getText(); - const propertyName = el.propertyName - ? el.propertyName.getText() - : name; - declarations.push( - ts.factory.createVariableDeclaration( - name, - undefined, - undefined, - ts.factory.createPropertyAccessExpression( - ts.factory.createElementAccessExpression( - ts.factory.createIdentifier("__imports"), - ts.factory.createStringLiteral(moduleName) - ), - ts.factory.createIdentifier(propertyName) - ) - ) - ); - }); - } +import * as ts from 'typescript'; +import * as path from 'path'; +import { resolveImportPath } from './resolve-import'; + +/** + * Transforms import declarations so that they remain valid after a file + * has been moved within the workspace. + * + * The transformer walks the AST, finds `ImportDeclaration` nodes and rewrites + * their module specifiers using `resolveImportPath`. + */ +export function importsTransformer(context: ts.TransformationContext) { + const visitor: ts.Visitor = (node) => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const sourceFile = node.getSourceFile(); + const originalSpecifier = node.moduleSpecifier.text; + + const newSpecifier = resolveImportPath(originalSpecifier, sourceFile.fileName); + + // If the specifier didn't change, keep the original node. + if (newSpecifier === originalSpecifier) { + return ts.visitEachChild(node, visitor, context); + } - // Handle namespace imports - if (ts.isNamespaceImport(namedBindings)) { - const name = namedBindings.name.getText(); - declarations.push( - ts.factory.createVariableDeclaration( - name, - undefined, - undefined, - ts.factory.createElementAccessExpression( - ts.factory.createIdentifier("__imports"), - ts.factory.createStringLiteral(moduleName) - ) - ) - ); - } - } + const updatedModuleSpecifier = ts.createStringLiteral(newSpecifier); + const updatedImport = ts.updateImportDeclaration( + node, + node.decorators, + node.modifiers, + node.importClause, + updatedModuleSpecifier, + node.assertClause + ); - return ts.factory.createVariableStatement( - undefined, - ts.factory.createVariableDeclarationList( - declarations, - ts.NodeFlags.Const - ) - ); - } - } + return ts.visitEachChild(updatedImport, visitor, context); + } - try { - return ts.visitEachChild(node, visit, context); - } catch (e) { - console.warn(`[importToGlobalTransformer] Error`, e); - return node; - } - }; - return (node: ts.SourceFile) => ts.visitNode(node, visit) as ts.SourceFile; + return ts.visitEachChild(node, visitor, context); }; + + return (file: ts.SourceFile) => ts.visitNode(file, visitor); } diff --git a/core/src/misc/transpile-file/resolve-import.ts b/core/src/misc/transpile-file/resolve-import.ts new file mode 100644 index 000000000..deeb4a7ac --- /dev/null +++ b/core/src/misc/transpile-file/resolve-import.ts @@ -0,0 +1,67 @@ +/** + * Utility to resolve import specifiers to a path that works after files are moved. + * + * The original implementation relied on the stored absolute path of the imported + * file. When a user moves a file, that path becomes stale, breaking the flow. + * + * This helper attempts to resolve the import relative to the *current* file + * location using Node's module resolution algorithm. If the specifier is a + * relative path, it is resolved against the directory of the source file. + * If it is a non‑relative specifier (e.g. a package or alias), we fall back + * to `require.resolve` which respects `node_modules` and `tsconfig` paths. + * + * The function returns a path that can be used in the generated code. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Resolve an import specifier to a concrete file path. + * + * @param importSpecifier The raw import string from the source file. + * @param sourceFilePath Absolute path of the file containing the import. + * @returns A path that can be used in the transpiled output. + */ +export function resolveImportPath( + importSpecifier: string, + sourceFilePath: string +): string { + // If the specifier is already absolute, just verify it exists. + if (path.isAbsolute(importSpecifier)) { + if (fs.existsSync(importSpecifier)) { + return importSpecifier; + } + // If it doesn't exist, fall through to try resolution. + } + + // Relative imports – resolve against the source file's directory. + if (importSpecifier.startsWith('.')) { + const candidate = path.resolve(path.dirname(sourceFilePath), importSpecifier); + // Try adding extensions that TypeScript/Node may omit. + const extensions = ['', '.ts', '.tsx', '.js', '.jsx', '.json']; + for (const ext of extensions) { + const fullPath = candidate + ext; + if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) { + // Return a path relative to the source file so that the generated code + // keeps the same import style (relative). + return path.relative(path.dirname(sourceFilePath), fullPath).replace(/\\/g, '/'); + } + } + // If we couldn't find a file, just return the original specifier – the + // runtime will surface a clear error. + return importSpecifier; + } + + // Non‑relative specifiers – let Node resolve them (covers packages & tsconfig paths). + try { + const resolved = require.resolve(importSpecifier, { + paths: [path.dirname(sourceFilePath)], + }); + // Return a path relative to the source file for consistency. + return path.relative(path.dirname(sourceFilePath), resolved).replace(/\\/g, '/'); + } catch { + // If resolution fails, keep the original specifier. + return importSpecifier; + } +}